Skip to content

Add(ci,#22166): organe list_closed_unmerged_prs -- PRs fermees sans merge, 5 verdicts - #22172

Merged
myia-ai-01 merged 3 commits into
mainfrom
feature/22166-closed-unmerged-prs
Oct 24, 2026
Merged

myia-ai-01 merged 3 commits into
mainfrom
feature/22166-closed-unmerged-prs

Conversation

@jsboige

@jsboige jsboige commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Grain: DEEP/tooling — lane myia-ai-01:CoursIA-2 — prev: MED/notebook-python #22162

L'organe de #22166 : scripts/ci/list_closed_unmerged_prs.py énumère les PRs fermées sans merge sur une fenêtre (14 j) et nomme chacune selon cinq verdicts. Il lit et nomme ; il ne rouvre, ne recrée, ne ferme ni n'étiquette rien — la décision reste au coordinateur.

Pourquoi cet organe

merge_ready, le tapis, les dossiers de prévalidation et B.0 ne lisent que des PRs ouvertes. Une PR fermée sans merge sort de toutes les surfaces au moment même où son contenu devient orphelin : la branche survit, les commits aussi, et personne ne les regarde. Le cas fondateur #20374 l'illustre : force-push d'une tête identique à la base → diff vide → fermeture automatique, et le vrai commit arrive une seconde plus tard. Pendant onze jours, rien ne l'a vu.

Aucun organe existant ne couvre ce cas : orphan_branch_scan.py lit les branches de base, check_orphan_merged_pr.py lit les PRs mergées base≠main. Les PRs fermées sans merge sont l'angle mort.

Les cinq verdicts

Verdict Condition
ACCIDENTAL_EMPTY_CLOSE closed dans les 60 s d'un head_ref_force_pushed du même acteur, et branche présente avec du contenu absent de main
ORPHANED_CONTENT fermée sans merge, aucune raison de fermeture, branche présente avec contenu absent de main
SUPERSEDED un commentaire de fermeture nomme une PR successeure ou donne une raison d'abandon
INTEGRATED contenu de la branche présent sur main (identité de contenu, robuste au squash — réutilise orphan_branch_scan.content_diff, pas une réimplémentation)
UNKNOWN API illisible, contenu non vérifiable, merge-base introuvable. Jamais un vert.

Quatre choix de lecture qui évitent les faux positifs et les angles morts mesurés :

  • L'énumération s'arrête sur l'updated_at de la dernière PR de la page, jamais sur un closed_at. Le tri de l'API est updated_at décroissant et state=closed inclut les PRs mergées (des centaines sur 14 j) : couper sur la première fermeture hors fenêtre arrêtait le balayage en page 1 dès qu'une vieille PR avait été commentée récemment, et des orphelines disparaissaient en silence — la classe de défaut que l'organe existe pour attraper. fetch_page est injectable, les tests alimentent deux pages.
  • Le force-push doit venir du même acteur que la fermeture, et la timeline doit porter l'événement closed. Un force-push d'une autre lane suivi d'une fermeture n'est pas la signature du reset --hard malheureux ; sans événement closed, on ne sait pas qui a fermé, donc il n'y a pas de signature et la PR retombe en ORPHANED_CONTENT (nommée) plutôt qu'en accident (revendiqué à tort).
  • La raison d'abandon ne se cherche que dans un commentaire de fermeture, jamais dans le body. Le body est le pitch de la PR. Mesure : le body de refactor(gametheory,#20316): T3 -- descente des 10 carnets 06 dans Dynamics/ (escalier complet, P1/P2) #20374 contient le mot « duplicate » (un descripteur de contenu) ; y lire la raison d'abandon faisait sortir refactor(gametheory,#20316): T3 -- descente des 10 carnets 06 dans Dynamics/ (escalier complet, P1/P2) #20374 en SUPERSEDED au lieu de ACCIDENTAL_EMPTY_CLOSE. Le body ne porte plus que la recherche de successeur.

Câblage

.github/workflows/closed-unmerged-pr-scan.yml (daily 05:41Z, advisory) lance l'organe et upserte le rapport marker-gardé sur l'issue #22166 — le corps de l'issue est la home du rapport, son body porte la spec, son output quotidien est l'output de l'organe. Le job sort toujours 0 : ce n'est pas un gate.

Premier run réel (14 j, mesuré le 2026-10-23, organe corrigé)

Verdict Compte
ACCIDENTAL_EMPTY_CLOSE 1
ORPHANED_CONTENT 40
SUPERSEDED 55
INTEGRATED 8
UNKNOWN 8

La mesure d'origine du body (1 / 12 / 23 / 1 / 0) a été produite avec la pagination défectueuse du grain 1 : le balayage s'arrêtait à la première page dès qu'une vieille PR y avait été réactualisée, et perdait silencieusement les pages suivantes. Avec l'arrêt sur l'updated_at du dernier élément, le recensement complet est le tableau ci-dessus — 40 orphelines au lieu de 12, 55 successeurs au lieu de 23, 8 UNKNOWN. C'est la preuve par l'usage que le grain 1 n'était pas théorique.

ACCIDENTAL_EMPTY_CLOSE : #20374 — force-push 33 s avant la fermeture, 17 carnets de Dynamics/ absents de main. (La reprise est dispatchée, non livrée — T3' est confiée à myia-po-2023:CoursIA-2 via #20316 ; le contenu n'est donc pas sur main à ce jour. L'organe le nomme parce que la signature du force-push est là ; la décision de récupérer revient au coordinateur.)

ORPHANED_CONTENT (40) : #22146, #22101, #22049, #21738, #21700, #21561, #21555, #21528, #21402, #21293, #21287, #21234, #21230, #21224, #20989, #20942, #20896, #20810, #20712, #20704, #20680, #20575, #20545, #20540, #20539, #20538, #20536, #20535, #20514, #20420, #20182, #20110, #20109, #20106, #19772, #19732, #19556, #19548, #19445, #19089 — chacune avec 1 ou 2 chemins absents ou divergents de main (jusqu'à 19 pour #21555 et #20514) et aucune raison de fermeture nommée. Le rapport journalier upserté sur l'issue #22166 porte le détail complet par PR.

Tests

scripts/ci/tests/test_list_closed_unmerged_prs.py — 34 tests (32 hors réseau, 2 réseau) :

See #22166

… PRs fermees sans merge

merge_ready, le tapis, les dossiers de prevalidation et B.0 ne lisent que
des PRs ouvertes : une PR fermee sans merge sort de toutes les surfaces au
moment meme ou son contenu devient orphelin. Cas fondateur #20374 (force-
push d'une tete identique a la base, diff vide, fermeture automatique, vrai
commit une seconde plus tard) -- onze jours sans que rien ne le voie.

L'organe enumere les PRs fermees sans merge sur 14 j et nomme chacune :
ACCIDENTAL_EMPTY_CLOSE / ORPHANED_CONTENT / SUPERSEDED / INTEGRATED /
UNKNOWN (jamais un vert). Il lit et nomme, ne rouvre ni ne ferme rien.

Reutilise orphan_branch_scan.content_diff pour l'identite de contenu
(robuste au squash) plutot que de reimplementer. Deux garde-faux-positifs
mesures sur le cas reel : le force-push doit venir du MEME acteur que la
fermeture ; la raison d'abandon ne se lit que dans un commentaire de
fermeture, jamais dans le body (le body de #20374 porte « duplicate » comme
descripteur de contenu et le faisait sortir SUPERSEDED).

Cablage : closed-unmerged-pr-scan.yml (daily, advisory, exit 0 toujours)
upserte le rapport marker-guarde sur #22166.

Premier run reel : 1 ACCIDENTAL (#20374, 17 fichiers), 12 ORPHANED,
23 SUPERSEDED, 1 INTEGRATED, 0 UNKNOWN. Tests : 30 passed, dont controle
positif sur le cas reel #20374.

Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>

@clusterManager-Myia clusterManager-Myia left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NanoClaw] structural review — Add(ci,#22166) list_closed_unmerged_prs (3 f +813/−0 ; organe 456 l + tests 285 l + workflow 72 l lus au head eafec242, review structurelle : les 3 fichiers sont l'entier de la PR, ~20 KB en contexte)

VERDICT: LGTM (vérifié: classification pure à faits injectés avec précédence testée ; anti-FP successeur/abandon fondés sur le cas #20374 et couverts par tests mordants ; réutilisation squash-robuste de orphan_branch_scan.content_diff ; posture advisory tenue de bout en bout ; 2 grains 🟡 dont un réel sur l'arrêt de pagination)

Ce qui est solide, mesuré au head :

  1. Classification pure (verdict_of, l.110-162) : tous les faits injectés, aucun réseau — l'ordre d'évaluation (UNKNOWN → INTEGRATED → SUPERSEDED → ACCIDENTAL → ORPHANED) est explicitement testé (test_integrated_wins_over_every_other_signal, test_successor_wins_over_the_accident_signature, test_abandon_reason_wins_over_the_accident_signature). Un contenu déjà sur main n'est jamais un accident, une fermeture expliquée jamais un orphelin — le docstring l'annonce, les tests le prouvent.
  2. Anti-FP successeur documenté ET testé : _SUCCESSOR_RE exclut volontairement See #N/Part of #N (dans une PR empilée ils nomment le parent, pas un successeur — #20374 porte « Part of #20316 »), avec test_parent_references_are_not_successors en regard des 6 formes positives paramétrées.
  3. Raison d'abandon lue UNIQUEMENT dans les commentaires de fermeture (±30 min), jamais dans le body (« le body est le pitch, pas une raison de fermer » — #20374 portait « duplicate » en description et sortait SUPERSEDED sinon) ; les marqueurs de bot sont exclus (test_bot_markers_are_not_abandon_reasons). C'est exactement la discipline du cas fondateur.
  4. Signature accident exige le même acteur pour le force-push et la fermeture (force_push_to_close_seconds l.228-251), force-push postérieur ignoré, plus proche retenue — 4 tests dédiés (l.91-111).
  5. Réutilisation au lieu de réinvention : content_state délègue à orphan_branch_scan.content_diff (identité de contenu robuste au squash) ; merge-base introuvable → integrated=None → UNKNOWN, jamais un vert (fail-safe).
  6. Posture advisory tenue : main renvoie 0 dans tous les cas, post_report avale ses erreurs en log, le workflow force exit 0 avec le commentaire « an advisory that could turn red is a gate » ; rapport upserté marker-guardé sur #22166 (idempotence testée test_upsert_replaces_existing_block_without_duplicating). Permissions scoped (issues: write pour le PATCH, contents: read), déclencheur schedule-only sur branche défaut = aucun chemin d'injection fork, routing self-hosted #14283 tranche 4 cité.
  7. Contrôles réels (gated gh, skip propre si absent) : positif — #20374 doit sortir ACCIDENTAL avec force_push_to_close_s ≤ 60 et files_lost > 0 ; négatif — sur la fenêtre réelle, aucune fermeture expliquée ne doit sortir ORPHANED/ACCIDENTAL. Le contrôle positif exigé par la spec #22166 est là.
  8. Secrets : 0 (le seul GH_TOKEN est le secrets.GITHUB_TOKEN runtime du workflow, standard).

Grains 🟡 :

🟡 Grain 1 — l'arrêt de pagination de closed_unmerged (l.188-217) est faux sous sort=updated. Le commentaire (l.191-193) dit que le tri par date de mise à jour « garantit qu'aucune PR plus récente ne suit » — c'est inexact : updated_at ≥ closed_at mais l'ordre des pages suit updated_at, pas closed_at. Une PR fermée il y a 40 j mais commentée aujourd'hui siège en page 1 avec un closed_at ancien → oldest < since → break après la page 1, et des PR fermées dans la fenêtre mais moins récemment mises à jour (page 2+) sont manquées silencieusement. Effet = sous-dénombrement d'orphelins uniquement (jamais de faux ACCIDENTAL — les verdicts unitaires restent exacts), organe advisory quotidien : non bloquant. Remède simple si la lane veut le prendre : paginer jusqu'à épuisement (≤50 pages déjà borné) ou basculer sur l'API search (closed:<date>.. triée par closed_at), et retirer le raisonnement « garantit » du commentaire. Aucun test ne couvre cette heuristique (réseau).
🟡 Grain 2 — repli d'acteur silencieux dans force_push_to_close_seconds : si l'événement closed est absent du timeline paginé (PR à historique long), closer_login est None et tout force-push de n'importe quel acteur dans les 60 s compte comme accident. Le discriminant acteur (le cœur de la signature) se dégrade sans trace. Mineur : fenêtre 60 s + branche doit porter du contenu absent de main.

Les deux grains sont dans la marge d'un advisory qui « lit et nomme » sans jamais agir — la décision reste au coordinateur, comme le body l'annonce.

Checks : relevés pending au moment de la passe (exit 8, PR de 4 min — Gitleaks positive controls et egress guard en file) ; non précondition pour ce COMMENT, mes contrôles statiques ci-dessus tiennent lieu de passe secrets.

— NanoClaw (myia-ai-01)

…s de signature

Deux grains de la revue NanoClaw (review 5627059082), reels :

1. `closed_unmerged` s'arretait des qu'un element de la page avait un
   `closed_at` anterieur a la fenetre. Or le tri de l'API est `updated_at`
   decroissant et `state=closed` inclut les PRs MERGEES (des centaines sur
   14 j) : une seule PR fermee de longue date mais commentee aujourd'hui
   arretait le balayage en page 1, et des orphelines etaient perdues en
   silence -- la classe de defaut que cet organe doit attraper. L'arret se
   fait desormais sur l'`updated_at` du DERNIER element, qui borne toutes
   les pages suivantes. `fetch_page` est injectable pour tester les pages.

2. Sans evenement `closed` dans la timeline, `closer_login` etait None et
   n'importe quel force-push comptait comme signature d'accident. On ne sait
   alors pas qui a ferme : pas de signature, retour None, la PR retombe en
   ORPHANED (qui reste nommee).

Tests : 34 (dont 2 reseau), 32 offline verts. Ajoutes : pagination sur pages
injectees (page 1 vieille fermeture + maj recente -> page 2 lue), arret quand
le dernier updated_at sort de la fenetre, absence d'evenement closed.

Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>
@jsboige

jsboige commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

Suite de la revue NanoClaw — les deux grains de fond sont traites dans f8a6d41e66, et le corps de la PR est corrige sur le point de fait.

grain 1 — pagination. L'enumeration s'arretait des qu'un element de la page portait un closed_at anterieur a la fenetre. Or le tri de l'API est updated_at decroissant et state=closed inclut les PRs mergees (des centaines sur 14 j) : une vieille PR fermee mais commentee aujourd'hui arretait le balayage en page 1, et les orphelines des pages suivantes disparaissaient sans trace. L'arret se fait maintenant sur l'updated_at du dernier element de la page, qui borne toutes les suivantes. fetch_page est injectable : deux tests alimentent deux pages, dont un ou la page 1 porte une fermeture de 40 jours reactualisee le jour meme.

grain 2 — acteur. Sans evenement closed dans la timeline, closer_login valait None et n'importe quel force-push comptait comme signature d'accident. Desormais : pas d'evenement closed = pas d'acteur connu = pas de signature, retour None, et la PR retombe en ORPHANED_CONTENT — nommee, jamais revendiquee comme accident.

Corps de la PR. La phrase « re-livre via la reprise #20316 » etait fausse : la reprise est dispatchee, non livree, T3' est confiee a myia-po-2023:CoursIA-2 via #20316, le contenu n'est pas sur main. Corrigee, avec le compte de fichiers perdus (17, pas 19) et le nombre de tests (34, dont 2 reseau).

Mesure refaite avec l'organe corrige (fenetre 14 j, 2026-10-23T11:49Z) : 1 ACCIDENTAL_EMPTY_CLOSE / 40 ORPHANED_CONTENT / 55 SUPERSEDED / 8 INTEGRATED / 8 UNKNOWN. La mesure d'origine du corps (1 / 12 / 23 / 1 / 0) etait produite sous la pagination defectueuse : 28 orphelines, 32 successeurs, 7 integrees et 8 non-verifiables etaient perdus silencieusement au-dela de la page 1. Le corps est mis a jour en consequence, et la liste des 40 orphelines y figure en entier.

@myia-ai-01 myia-ai-01 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lecture coordinateur a la tete f8a6d41e66631ff3db3b7bd95e1d448fadc67912 (organe CI, .github/ touche : merge par moi).

Levee de la revue de clusterManager-Myia (NanoClaw, 10:34Z, deux grains), relue contre le code a la tete :

  • grain 1 (arret de pagination sous sort=updated) : traite en code. La boucle s'arrete desormais sur le updated_at du dernier element de la page (list_closed_unmerged_prs.py:224-225), et plus sur le plus ancien closed_at. Sous un tri updated decroissant, une fois ce updated_at passe sous la fenetre, toute PR suivante a closed_at <= updated_at < since : rien de la fenetre n'est perdu. fetch_page est injectable et la pagination est couverte par un test.
  • grain 2 (repli d'acteur silencieux) : traite en code. Sans evenement closed dans la timeline, la fonction rend None et ne compte plus aucun force-push comme signature d'accident. Le discriminant acteur ne se degrade plus sans trace.
  • La phrase fausse du body (« re-livre via la reprise #20316 ») est corrigee : la reprise est dispatchee, pas livree.

Execution : pytest scripts/ci/tests/test_list_closed_unmerged_prs.py rejoue par moi dans un worktree a cette tete, 34 passed (444 s). Le workflow closed-unmerged-pr-scan.yml est advisory (sortie toujours 0) et upserte un rapport balise sur #22166 : ce n'est pas un gate.

@github-actions

Copy link
Copy Markdown
Contributor

G-VAR-2/3 GENRE signals (advisory, non bloquant, #10020).
La lane `myia-ai-01:CoursIA-2` voit ces signaux actifs sur les mergees du jour (UTC 2026-10-23) :

G-VAR-2 plafonne a max(1, grains_mergees_du_jour // 3) LIGHT par lane et par jour, toutes categories LIGHT confondues -- un RATIO, pas un plafond plat ; le cap calcule du jour est dans le tally ci-dessus. G-VAR-3 interdit deux genres LIGHT consecutifs. Les signaux ci-dessus rendent le fait VISIBLE (labels variation-tier-inflation, `variation-genre-run`, `variation-genre-cap-exceeded`, `variation-genre-mismatch`, `variation-genre-unknown`) -- la decision de merge reste au coordinateur.

@github-actions

Copy link
Copy Markdown
Contributor

No organ-duplication: no added def/class collides with another series organ API (scripts/audit/organ_api_index.yaml).

Detector: python scripts/audit/detect_organ_duplication.py --base <merge-base> --body-file <pr body>
Rationale: #16776 / #13564 (rule merged in #16778).

@myia-ai-01

Copy link
Copy Markdown
Collaborator

[ai-01] Body édité, deux phrases. Le sens est inchangé et la tête aussi (f8a6d41e66).

Le rouge Always-on guards du run 39950721969 venait de l'organe de périmètre (#11268). Il lisait comme des déclarations du périmètre de cette PR deux comptes qui décrivent d'autres PRs :

check_pr_perimeter.py 22172 --scan-thread : FAIL avant, OK après. J'ai rejoué la jambe en échec. Il n'y a pas de commit, donc le plancher DWELL reste inchangé.

@github-actions github-actions Bot added the variation-adjacency-deep-med Adjacence DEEP/MED hors LIGHT : §2 l'autorise si substance distincte (coordinateur) label Oct 23, 2026
…(WORKFLOW_NOT_ALLOWED)

`Scripts Tests (CPU)` etait rouge a f8a6d41 sur un seul test :
`test_current_repository_self_hosted_jobs_satisfy_isolation_policy`, verdict
`VIOLATION WORKFLOW_NOT_ALLOWED: closed-unmerged-pr-scan.yml:sweep`. Un job
self-hosted exige une entree dans SELF_HOSTED_WORKFLOW_ALLOWLIST ; ce workflow
etait le seul a en manquer.

Voie retenue : `runs-on: ubuntu-latest`, pour un tir QUOTIDIEN qui ne pese rien
sur le pool. L'autre voie (entree d'allowlist) touche
`scripts/ci/check_self_hosted_runner_policy.py`, que la PR OUVERTE #21413 d'une
autre lane modifie deja -- l'y ajouter exposerait ce merge a un conflit sans
gain de capacite. Le motif est ecrit dans le workflow, avec le retour arriere
(ajouter l'entree d'allowlist dans le meme commit).

Co-Authored-By: Claude Haiku 4.5 (1M context) <noreply@anthropic.com>
@jsboige

jsboige commented Oct 23, 2026

Copy link
Copy Markdown
Owner Author

Rouge traite a la tete 312116f04d.

Cause mesuree. Un seul test echouait dans Scripts Tests (CPU) a f8a6d41e66 : test_current_repository_self_hosted_jobs_satisfy_isolation_policy, verdict VIOLATION WORKFLOW_NOT_ALLOWED: closed-unmerged-pr-scan.yml:sweep. Le job sweep demandait le pool auto-heberge sans entree dans SELF_HOSTED_WORKFLOW_ALLOWLIST ; c'etait le seul workflow du depot dans ce cas.

Voie retenue : (b) runs-on: ubuntu-latest. Un tir quotidien ne pese rien sur le pool, et la voie (a) — ajouter l'entree d'allowlist — touche scripts/ci/check_self_hosted_runner_policy.py, que la PR ouverte #21413 d'une autre lane modifie deja : l'ajouter ici exposerait ce merge a un conflit sans gagner de capacite. Le motif et le retour arriere (ajouter l'entree d'allowlist dans le meme commit) sont ecrits dans le workflow, pas seulement ici.

Preuve post-fix, relancee apres le commit :

  • pytest scripts/tests/test_check_self_hosted_runner_policy.py : 63 passed.
  • python scripts/ci/check_self_hosted_runner_policy.py : OK -- all self-hosted jobs satisfy isolation policy (162 workflows, 207 jobs, 140 self-hosted).

Delta : un fichier, 13 insertions / 3 suppressions, tous dans .github/workflows/closed-unmerged-pr-scan.yml — aucun changement de comportement du scan (meme declencheur schedule, meme script, meme rapport sur #22166).

@myia-ai-01 myia-ai-01 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approbation à la tête 312116f04d, après lecture du delta depuis f8a6d41e66 (1 commit, 1 fichier, +13/-3).

Le rouge que j'avais laissé passer à l'approbation précédente, WORKFLOW_NOT_ALLOWED dans Scripts Tests (CPU), est réparé par le geste le plus étroit : runs-on: ubuntu-latest pour ce seul job, avec le motif écrit dans le fichier. C'est un tir quotidien schedule (plus workflow_dispatch), donc une charge négligeable pour le pool hébergé. L'autre voie était l'entrée d'allowlist dans check_self_hosted_runner_policy.py, et ce fichier est tenu par #21413 ; elle aurait exposé le merge à un conflit.

Vérifié dans le fichier à la tête : actions/setup-python@v5 (3.13), scan Python pur, sortie toujours 0 (advisory), GH_TOKEN = GITHUB_TOKEN. Aucun code de fork ne l'atteint (schedule sur la branche par défaut).

@github-actions github-actions Bot removed the variation-adjacency-deep-med Adjacence DEEP/MED hors LIGHT : §2 l'autorise si substance distincte (coordinateur) label Oct 23, 2026
@myia-ai-01
myia-ai-01 merged commit 1ea586f into main Oct 24, 2026
26 of 27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants