Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 42 additions & 10 deletions scripts/pr_gate_missing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
- #10898 : the head commit's SUBJECT contained the literal ``[skip ci]`` --
GitHub skipped every ``pull_request`` workflow (only CodeQL ran). Fixed by a
re-push whose message does not carry the token.
- #10558 : PR opened by the bot (``app/github-actions``). A push made with
``GITHUB_TOKEN`` does not create a new workflow run (GitHub anti-recursion)
-- structural, by design, but nowhere written.
- #10558 : PR opened by the bot (``github-actions[bot]`` in REST,
``app/github-actions`` in GraphQL -- both recognised since #15758). A push
made with ``GITHUB_TOKEN`` does not create a new workflow run (GitHub
anti-recursion) -- structural, by design, but nowhere written.
- #10902 : unknown cause; the PR was ``DIRTY`` and its rebase re-triggered CI.

This tool is an ADVISORY organ, never blocking (it cannot block: the missing
Expand Down Expand Up @@ -79,7 +80,31 @@
# required by main's branch protection. Renaming here silently detaches the
# detector (same invariant as pr-gate.yml: keep the string stable).
GATE_NAME = "PR gate"
BOT_LOGIN = "app/github-actions"

# Le bot du depot n'a pas UNE orthographe : elle depend de l'API qui le rend.
# Mesure #15758 (2026-09-12, sur #15678) : REST -- la source du collecteur,
# `/pulls` puis `.user.login` -- ecrit `github-actions[bot]` ; GraphQL (`gh pr
# view --json author`) ecrit `app/github-actions` ; le champ brut
# `author.login` ecrit `github-actions`. La constante unique d'origine portait
# l'orthographe GraphQL pendant que le collecteur lisait en REST, donc
# l'egalite etait fausse pour TOUTE PR du bot : `bot_missing` et la cause
# `bot` etaient structurellement inatteignables. Les trois orthographes
# mesurees sont ici, comme dans les autres organes qui lisent un auteur
# (`ci/guard_comment_upsert.py` GUARD_BOT_LOGINS, `pick_idle_grain.py`
# AUTOMATION_AUTHORS, `review_coverage.py`).
BOT_LOGINS = frozenset({"app/github-actions", "github-actions[bot]",
"github-actions"})


def is_bot_author(login: object) -> bool:
"""L'auteur est-il le bot du depot, quelle que soit l'orthographe rendue ?

Point de comparaison UNIQUE : les deux sites (`classify`, verdict
`bot_missing`, et `prescribe`, cause `bot`) l'appellent, donc une
quatrieme orthographe ne peut pas n'en corriger qu'un seul.
"""
return (login or "") in BOT_LOGINS


# Marker framing the advisory comment, so re-runs can find and update it.
COMMENT_MARKER_START = "<!-- PR-GATE-MISSING:START -->"
Expand Down Expand Up @@ -146,7 +171,9 @@
)

REMEDIATION_BOT = (
"PR ouverte par le bot (`app/github-actions`) sans `PR gate` dans son "
"PR ouverte par le bot du depot (son login est nomme dans la ligne "
"« Cause mesuree » ci-dessous ; il s'ecrit `github-actions[bot]` en REST et "
"`app/github-actions` en GraphQL) sans `PR gate` dans son "
"rollup : cas **structurel** (issue #10928). Un push fait avec "
"`GITHUB_TOKEN` ne cree pas de nouveau workflow run (regle anti-recursion "
"GitHub), donc le contexte requis ne sera jamais rapporte par un push du "
Expand Down Expand Up @@ -196,8 +223,10 @@ def classify(pr: dict) -> tuple[str, str]:
return ("draft", f"#{number} draft PR, non mergeable")
if GATE_NAME in rollup_names(pr):
return ("has_gate", f"#{number} PR gate present (conclusion: {len(rollup_names(pr))} checks)")
if pr.get("author_login") == BOT_LOGIN:
return ("bot_missing", f"#{number} bot PR, no PR gate (structural)")
if is_bot_author(pr.get("author_login")):
return ("bot_missing",
f"#{number} bot PR, no PR gate (structural; auteur "
f"{pr.get('author_login')})")
return ("missing", f"#{number} PR gate absent du rollup")


Expand All @@ -224,7 +253,8 @@ def prescribe(pr: dict) -> tuple[str, str]:
retarget -> un ``base_ref_changed`` postérieur au dernier run
``pull_request`` : remede = commit vide a arbre identique
skip_ci -> token ``[skip ci]`` dans le sujet de tete (cause #10898)
bot -> PR ``app/github-actions`` (cause #10558)
bot -> auteur = bot du depot, quelle que soit l'orthographe
rendue (cause #10558 ; multi-orthographe #15758)
unknown -> aucune des quatre : nommer les mesures, ne rien prescrire

Returns:
Expand All @@ -240,8 +270,10 @@ def prescribe(pr: dict) -> tuple[str, str]:
f"base_ref_changed={changed}, dernier run PR gate={last or 'aucun'}")
if "[skip ci]" in head_subject(pr):
return ("skip_ci", f"sujet de tete porte le token [skip ci] : {head_subject(pr)[:72]!r}")
if pr.get("author_login") == BOT_LOGIN:
return ("bot", "auteur app/github-actions -- push GITHUB_TOKEN sans run")
if is_bot_author(pr.get("author_login")):
return ("bot",
f"auteur {pr.get('author_login')} (bot du depot) -- "
"push GITHUB_TOKEN sans run")
return ("unknown",
f"mergeable_state={ms}, pas de base_ref_changed, sujet sans [skip ci], "
f"auteur {pr.get('author_login')}")
Expand Down
73 changes: 73 additions & 0 deletions scripts/tests/test_pr_gate_missing.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
rollup_names,
list_open_prs,
has_label,
is_bot_author,
GATE_NAME,
LABEL_BOT_DESC,
LABEL_CONFLICT_DESC,
Expand Down Expand Up @@ -314,6 +315,78 @@ def test_collector_output_carries_author_and_labels():
assert not has_label(rows[0], "pr-gate-conflict")


# --- #15758 : le bot du depot n'a pas UNE orthographe -----------------------
#
# Mesure firsthand sur #15678 (2026-09-12) :
# REST `gh api repos/jsboige/CoursIA/pulls/15678 --jq '.user.login'`
# -> github-actions[bot]
# GraphQL `gh pr view 15678 --json author` -> app/github-actions
# GraphQL `author.login` (brut) -> github-actions
#
# Le collecteur lit en REST, donc la PREMIERE est ce que `classify_input` range
# sous `author_login`. La constante unique d'origine portait la deuxieme : la
# comparaison etait fausse pour toute PR du bot, et `bot_missing` comme la cause
# `bot` etaient inatteignables (#15758).
REST_BOT = "github-actions[bot]"
GQL_BOT = "app/github-actions"
RAW_BOT = "github-actions"


def test_bot_verdict_for_every_measured_spelling():
for spelling in (REST_BOT, GQL_BOT, RAW_BOT):
verdict, why = classify(_pr(10558, author=spelling,
rollup=_codeql_only_rollup()))
assert verdict == "bot_missing", f"{spelling!r} -> {verdict} ({why})"


def test_prescribe_names_the_bot_cause_for_the_rest_spelling():
# L'autre site de comparaison : c'est `prescribe` qui choisit le label et le
# commentaire. Le defaut les rendait muets tous les deux.
cause, detail = prescribe(_candidate(10558, author=REST_BOT))
assert cause == "bot", detail
assert REST_BOT in detail, detail


def test_human_author_is_not_absorbed_by_the_bot_predicate():
for author in ("jsboige", "myia-ai-01", ""):
assert not is_bot_author(author), author
assert prescribe(_candidate(10558, author=author))[0] == "unknown", author
assert classify(_pr(10558, author="jsboige",
rollup=_codeql_only_rollup()))[0] == "missing"


def test_collector_contract_reads_the_login_in_rest():
# Contrat du PRODUCTEUR. Rien n'epinglait l'orthographe produite : les tests
# passaient `app/github-actions` a la main -- la valeur de la constante du
# consommateur, jamais celle que le collecteur rend vraiment. Changer la
# source d'auteur (REST -> GraphQL) change l'orthographe rendue ; ce test
# doit rougir a ce moment-la plutot que suivre en silence.
seen = {}

def fake_rows(args):
seen["args"] = args
return []

with mock.patch("pr_gate_missing._gh_rows", fake_rows):
list_open_prs("jsboige/CoursIA")
args = seen["args"]
jq = args[args.index("--jq") + 1]
assert "author: .user.login" in jq, jq
assert "author: .author.login" not in jq, jq
assert "state=open" in args[1] and "repos/" in args[1], args[1]


def test_collector_emits_the_rest_spelling_end_to_end():
# Chemin reel producteur -> consommateur, avec l'orthographe que le
# collecteur produit. C'est ce couple que le test d'origine ratait : il
# pilotait le collecteur mais lui donnait la chaine du consommateur.
rows = _collector_output([_collector_row(10558, author=REST_BOT)],
check_run_names=("CodeQL",))
assert rows[0]["author_login"] == REST_BOT
verdict, why = classify(rows[0])
assert verdict == "bot_missing", why


def test_classify_input_is_the_only_shape():
# Epingle le contrat : toucher a `classify_input` sans mettre a jour
# classify() (ou l'inverse) fait rougir ici au lieu de desactiver un verdict
Expand Down
Loading