1212 the full quality gate (L1, L2, consistency) against CURRENT main with the
1313 PR's changes applied; a pass gets an ``auto`` verdict.
1414
15- Anything else goes to people once per head (with what would make it pass).
16- A staged external change set then follows the ordinary merge flow (verdict
17- comment, PR-stage kb-gate, merge queue). A new head, or a verdict whose
18- context moved, is simply judged again at the next poll.
15+ A PR that does not pass is never merged (design v8, D12): the author is told
16+ why in ONE findings comment on the PR, updated in place, and the PR is checked
17+ again once a day (not on every push) until it passes. A passing PR follows the
18+ ordinary merge flow: a signed verdict in a ``merge`` item, the publisher's
19+ local gate, the merge. A passing head is not judged again; a new head, or a
20+ merge refused meanwhile, is judged at the next daily pass.
1921
2022Judging "on current main" requires the PR's pre-images to equal main's:
2123otherwise the merge queue would apply different changes than were signed, and
2426
2527from __future__ import annotations
2628
29+ import json
2730from typing import Any
2831
2932from ..knowledge_service .gate_verifier import MAINTAINER_PATH , WHITELIST_CODES , code_owners , parse_codeowners
3740HUMAN_LABEL = "kb:human-approved"
3841GOVERNED = ("knowledge/repos/" , "knowledge/general/" )
3942SUFFIXES = (".md" , ".yaml" )
40- # statuses after which a head is judged again (the verdict can no longer verify)
41- REJUDGE = ("head_changed" , "stale_context" , "superseded" )
43+ # statuses after which a head is judged again at the next daily pass: it did
44+ # not pass, or its verdict can no longer verify
45+ REJUDGE = ("head_changed" , "stale_context" , "superseded" , "failed" , "human" , "gate_failed" , "rebuild_failed" )
46+ EXTERNAL_EVERY = 24 * 3600 # other people's PRs are checked once a day
47+ CHECKED_CURSOR = "external_checked:{number}" # per PR: a failed pass never re-judges finished ones
48+ FINDINGS_MARKER = "<!-- kb-findings:v1 -->"
49+ # + PR number, kept GLOBALLY (a comment belongs to the PR, whatever
50+ # repository scope it touches now): the latest findings until delivered
51+ FINDINGS_CURSOR = "findings:"
4252
4353
4454def _governed (path : str ) -> bool :
@@ -157,11 +167,117 @@ def _ours(rt, number: int) -> bool:
157167 "paused" , "shadow_recorded" , "calibration_required" , "approval_withdrawn" )))
158168
159169
160- def poll_external (rt ) -> list [str ]:
161- """One pass over open knowledge PRs; needs the scheduler's lease."""
170+ def poll_external (rt , * , force : bool = False ) -> list [str ]:
171+ """A pass over open knowledge PRs; needs the scheduler's lease. Each PR is
172+ checked at most once per ``EXTERNAL_EVERY``, recorded per PR in the ledger
173+ as soon as it was checked: a restart, or a pass that fails half-way, never
174+ checks a PR twice in a day. ``force`` ignores the daily limit."""
162175 owner = getattr (rt , "lease_owner" , None )
163176 if rt .outbox is None or not owner :
164177 return []
178+ return renew_findings (rt ) + _poll (rt , owner , force = force )
179+
180+
181+ def _due (rt , number : int , force : bool ) -> bool :
182+ last = float (rt .ledger .get_cursor ("*" , CHECKED_CURSOR .format (number = number )) or 0 )
183+ return force or rt .clock () - last >= EXTERNAL_EVERY
184+
185+
186+ def _checked (rt , number : int ) -> None :
187+ rt .ledger .set_cursor ("*" , CHECKED_CURSOR .format (number = number ), str (rt .clock ()))
188+
189+
190+ def findings_text (number : int , head : str , status : str , reasons : list [str ], today : str ) -> str :
191+ """The one comment the service keeps on another person's knowledge PR."""
192+ lines = [FINDINGS_MARKER , f"**Knowledge quality gate** · checked { today } at `{ head [:12 ]} ` "
193+ "(checked again once a day)" , "" ]
194+ if status in ("pr_open" , "merged" ):
195+ lines .append ("Result: **passed**. The publisher merges it after its final check on the exact merge result." )
196+ elif status == "shadow_recorded" :
197+ lines .append ("Result: **passed** (this repository is in shadow mode: nothing is merged automatically yet)." )
198+ else :
199+ lines .append ("Result: **not passed**. This PR will not be merged until the problems below are fixed; "
200+ "push a fix and it is checked again at the next daily pass." )
201+ lines += ["" , "Problems:" ] + [f"- { r } " for r in (reasons or ["the change needs a person's decision" ])[:20 ]]
202+ return "\n " .join (lines ) + "\n "
203+
204+
205+ def _findings_lifecycle (rt , paths : list [str ]):
206+ """Which repository may carry the findings of a PR that belongs to none
207+ (it spans several): only when every scope it touches publishes (a private
208+ upstream's name must never appear), through one in auto_merge."""
209+ scopes = set ()
210+ for path in paths :
211+ try :
212+ scopes .add (repo_scope (path [len ("knowledge/" ):]).split ("/" )[- 1 ])
213+ except Exception : # noqa: BLE001 - outside repos/ and general/
214+ continue
215+ lifecycles = [rt .registry .get (name ) for name in sorted (scopes )]
216+ if not lifecycles or any (lc is None or not lc .publishes for lc in lifecycles ):
217+ return None
218+ return next ((lc for lc in lifecycles if lc .auto_merge ), None )
219+
220+
221+ def post_findings (rt , lifecycle , number : int , head : str , status : str , reasons : list [str ]) -> None :
222+ """Hand the findings comment to the publisher (never in shadow mode, never
223+ for a private upstream: those publish nothing)."""
224+ if lifecycle is None or not lifecycle .auto_merge or not lifecycle .publishes :
225+ return
226+ comment = findings_text (number , head , status , reasons , rt .today ())
227+ _issue_findings (rt , lifecycle .repo , number , head , comment )
228+
229+
230+ def _issue_findings (rt , repo : str , number : int , head : str , comment : str , revision : float | None = None ) -> None :
231+ """Issue the findings and remember them (one record per PR) until the
232+ publisher confirms delivery. ``revision`` is when the findings were
233+ written: the publisher drops an item older than what it already posted,
234+ and a renewal keeps the original revision, so it can never overtake newer
235+ findings."""
236+ revision = rt .clock () if revision is None else revision
237+ item = rt .outbox .issue (repo , "post_findings" , {
238+ "pr" : number , "head_sha" : head , "marker" : FINDINGS_MARKER , "comment" : comment , "revision" : revision })
239+ rt .ledger .set_cursor ("*" , f"{ FINDINGS_CURSOR } { number } " , json .dumps ({
240+ "repo" : repo , "item_id" : item .id , "expires_at" : item .expires_at , "head_sha" : head , "comment" : comment ,
241+ "revision" : revision , "delivered" : False }))
242+
243+
244+ def findings_delivered (rt , ack : dict ) -> None :
245+ """The publisher's ack for a findings item: only the latest one counts."""
246+ name = f"{ FINDINGS_CURSOR } { int (ack .get ('pr' ) or 0 )} "
247+ raw = rt .ledger .get_cursor ("*" , name )
248+ if not raw or not ack .get ("ok" ):
249+ return
250+ record = json .loads (raw )
251+ if record .get ("item_id" ) == ack .get ("item_id" ):
252+ rt .ledger .set_cursor ("*" , name , json .dumps ({** record , "delivered" : True }))
253+
254+
255+ def renew_findings (rt ) -> list [str ]:
256+ """Findings that expired undelivered (the publisher was down or GitHub
257+ failed for a day) are issued again with the same text: an author is
258+ never left without them, whatever the PR's status."""
259+ events = []
260+ for name , raw in rt .ledger .cursors_with_prefix ("*" , FINDINGS_CURSOR ).items ():
261+ record = json .loads (raw )
262+ if record .get ("delivered" ) or float (record .get ("expires_at" ) or 0 ) > rt .clock ():
263+ continue
264+ lifecycle = rt .registry .get (record .get ("repo" ))
265+ if lifecycle is None or not lifecycle .auto_merge or not lifecycle .publishes :
266+ continue # its repository publishes nothing any more
267+ number = int (name [len (FINDINGS_CURSOR ):])
268+ _issue_findings (rt , lifecycle .repo , number , record ["head_sha" ], record ["comment" ],
269+ revision = float (record .get ("revision" ) or 0 ))
270+ events .append (f"findings reissued PR #{ number } " )
271+ return events
272+
273+
274+ def _decision_reasons (decision : dict ) -> list [str ]:
275+ from .refine import reasons_from_decision
276+
277+ return reasons_from_decision (decision )
278+
279+
280+ def _poll (rt , owner : str , * , force : bool = False ) -> list [str ]:
165281 events : list [str ] = []
166282 main_sha = rt .knowledge .fetch ()
167283 maintainers : set [str ] | None = None
@@ -172,6 +288,8 @@ def poll_external(rt) -> list[str]:
172288 human = HUMAN_LABEL in labels
173289 if pr .get ("draft" ) or not head or _ours (rt , number ) or _judged (rt , number , head , "human" if human else "auto" ):
174290 continue
291+ if not _due (rt , number , force ):
292+ continue # checked today already: once a day, not on every push
175293 if rt .knowledge .fetch_pull (number ) != head :
176294 continue # pushed while we looked: next poll
177295 merge_base = rt .knowledge .merge_base (main_sha , head )
@@ -182,7 +300,9 @@ def poll_external(rt) -> list[str]:
182300 path = "human" if human else "auto"
183301 lifecycle , why = _scope ([p for p in paths if p .startswith ("knowledge/" )], rt .registry )
184302 if lifecycle is None :
185- events .append (_to_people (rt , None , number , head , f"PR #{ number } { why } " , path ))
303+ events .append (_to_people (rt , None , number , head , f"PR #{ number } { why } " , path ,
304+ findings_via = _findings_lifecycle (rt , paths )))
305+ _checked (rt , number )
186306 continue
187307 if human :
188308 maintainers = maintainers if maintainers is not None else _maintainers (rt , main_sha )
@@ -193,6 +313,7 @@ def poll_external(rt) -> list[str]:
193313 events .append (_to_people (rt , lifecycle , number , head ,
194314 f"PR #{ number } changes paths outside the governed knowledge pages; "
195315 f"a knowledge maintainer can approve it and add { HUMAN_LABEL } " , path ))
316+ _checked (rt , number )
196317 continue
197318 base_main = rt .knowledge .knowledge_files (main_sha )
198319 pre = rt .knowledge .knowledge_files (merge_base )
@@ -203,6 +324,7 @@ def poll_external(rt) -> list[str]:
203324 if moved :
204325 events .append (_to_people (rt , lifecycle , number , head ,
205326 f"PR #{ number } must be rebased: main changed { moved [:3 ]} since it branched" , path ))
327+ _checked (rt , number )
206328 continue
207329 head_files = dict (base_main )
208330 for rel in rels :
@@ -223,6 +345,7 @@ def poll_external(rt) -> list[str]:
223345 if issues :
224346 events .append (_to_people (rt , lifecycle , number , head , f"PR #{ number } fails L1: " + "; " .join (
225347 f"{ i .code } { i .path } { i .detail } " for i in issues [:3 ]), path ))
348+ _checked (rt , number )
226349 continue
227350 detail .update (source = "human-approved" , review_ids = review_ids , reviewers = reviewers ,
228351 generator = "human" , judge = "human" , decision = {"blocks" : [], "consistency" : []},
@@ -251,14 +374,17 @@ def poll_external(rt) -> list[str]:
251374 if r in head_files },
252375 "deleted" : [r for r in rels if r not in head_files ], "evidence" : []})
253376 rt .ledger .stage_intake (owner , lifecycle .repo , changeset_id , kind = KIND , status = status , verdicts = [],
254- human_reason = "" if status in ( "pr_open" , "shadow_recorded" )
255- else f"external PR # { number } : " + "; " . join ( detail [ "decision" ]. get ( "reasons" , [])) ,
377+ # the author fixes it (told in the findings comment): not people's queue
378+ human_reason = "" ,
256379 drafted_events = [], detail = detail )
257380 rt .ledger .update_changeset (changeset_id , pr_number = number , head_sha = head )
258381 rt .trace ("decision" , context = {"repo" : lifecycle .repo , "changeset_id" : changeset_id , "pr" : number ,
259382 "playbook" : "kb-external" , "step" : "gate" },
260383 result = {"status" : status , "source" : detail ["source" ],
261384 "reviewers" : detail .get ("reviewers" , [])})
385+ post_findings (rt , lifecycle , number , head , status ,
386+ [] if status in ("pr_open" , "shadow_recorded" ) else _decision_reasons (detail ["decision" ]))
387+ _checked (rt , number )
262388 events .append (f"external { changeset_id } PR #{ number } { detail ['source' ]} { status } " )
263389 return events
264390
@@ -274,14 +400,15 @@ def _lifecycle_bookkeeping(result, head_files: dict[str, str]) -> dict:
274400 "purges" : list (result .purged )}
275401
276402
277- def _to_people (rt , lifecycle , number : int , head : str , reason : str , path : str ) -> str :
278- """Record a head that people must handle, once ( as a human change set) ."""
403+ def _to_people (rt , lifecycle , number : int , head : str , reason : str , path : str , findings_via = None ) -> str :
404+ """Record a head the gate cannot take as is; the author is told why ."""
279405 repo = lifecycle .repo if lifecycle is not None else next (iter (rt .registry ))
280406 changeset_id = rt .ledger .new_changeset_id (repo , KIND )
281407 rt .save_changeset_files (changeset_id , {"base_sha" : "" , "files" : {}, "deleted" : [], "evidence" : []})
282408 rt .ledger .stage_intake (rt .lease_owner , repo , changeset_id , kind = KIND , status = "human" , verdicts = [],
283- human_reason = reason , drafted_events = [],
409+ human_reason = "" , drafted_events = [],
284410 detail = {"external_pr" : number , "reason" : reason , "path" : path })
285411 rt .ledger .update_changeset (changeset_id , pr_number = number , head_sha = head )
412+ post_findings (rt , lifecycle or findings_via , number , head , "human" , [reason ])
286413 return f"external { changeset_id } PR #{ number } to people"
287414
0 commit comments