diff --git a/Agent.md b/Agent.md index afc358f6..a5a1b44a 100644 --- a/Agent.md +++ b/Agent.md @@ -125,6 +125,7 @@ Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapsh CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Merge freshness: `uv run --no-sync python3 scripts/check-merge-freshness.py ...` — 合并前问一句「这条绿色 CI 说的还是**将要合并的那棵树**吗」。`pull_request` 事件下 GitHub 构建的是 `Merge into `(合到**分叉点**,不是当前 master):分叉点就是 master 时两者同一棵树,master 一移动就不是了,而 master 移动**不触发** `synchronize`(只有 push 分支才触发),于是绿灯永远保持绿灯却已经过期。#1137 实测:`MERGEABLE/CLEAN` + 双 job 全绿,两侧计数行都写成同一个 1397 ⇒ git **无冲突**自动合并、保留 1397,而合并树实收 1401,两个守卫在 master 上才变红——**干净合并才是危险的那一种**(冲突时人被迫看一眼,反而安全)。判定不用时间戳比较(时钟/秒级竞态会骗人)而用**图结构**:master 的 tip 是否是 head 的祖先(`compare/master...` 的 `identical`/`ahead`),是则 merge base 就是 master 本身、判决可平移。**祖先性只是一半**:head 含 master 但**根本没有 CI 运行**(push 事件丢失 ⇒ `no checks reported`)同样不算新鲜,故第二个条件是「该 SHA 上存在一个**通过**的运行」。按 SHA 而非分支取运行(分支推两次会有两个运行)。exit 0 全部新鲜 / 1 至少一条过期 / 2 问不出来(坏 PR、gh 失败、状态不认识)——不认识的状态一律 fail-loud,绝不默认新鲜 +PR base check: `uv run --no-sync python3 scripts/check-pr-base.py [--repo owner/name] [PR...]` — 合并前查每张 open PR 的 **base 分支是否还能到达 master**。`gh pr merge` 合的是 PR 的 **base 分支**,而 stacked PR(base 是另一条 feature 分支)的 base 在本仓库被 squash 合并后**永远不是 master 的祖先**:合进去只会落到那条死分支上,master 一无所获,而 PR 状态显示 MERGED、CI 双绿、票数也照常累计——三个现有闸门全都放行。实测(cyc20260911-210746):#1148 的 base 是 #1147 的分支,而 #1147 已 squash 合入 master,若照原样批准,这个「让 master 的分类器不再重复内容」的修复就会合进死分支,队列里留一张 MERGED 却什么都没改变的 PR。判据不是「base 是不是 master」(stacked PR 在父分支未合并时是正常且有用的工作流,正是 #1149 要服务的情形),而是**「合进这个 base 还能不能到 master」**:base 已在 master 上 ⇒ OK;base 是**某张 open PR 的 head 分支** ⇒ LIVE(父分支仍在飞行中,合进去会落到活着的父分支上、随父 PR 落地;不是死路,但也不是保证——父 PR 仍可能被**未合并关闭**,故不判失败、只提示优先 retarget 到 master);base 既不在 master 上、也没有任何 open PR 以它为 head ⇒ DEAD(须先 retarget)。**LIVE 与 DEAD 无法由 compare status 区分**(cyc20260911-215935 实测修正:飞行中的父分支**必然**有 master 没有的 commit,status 就是 `ahead`;master 因任何无关原因前进后它又变成 `diverged`,与 squash 合并后的死分支**逐字节相同**——五个 open PR 分支当时全部是 `ahead`,只因 master 尚未前进)。可判定的输入是「有没有 open PR 的 head 等于这条分支」,而 `headRefName` 本就在抓取的字段里;旧版直接用 status 判 OK/DEAD,于是**每个飞行中的 stacked 父分支都被报成 DEAD**(5/5 真实父分支),而它自己文档里的「live stacked base 返回 OK」在旧谓词下**不可达**(能到 OK 的非 master base 只有「内容已在 master 上」的已落地分支,那不是 stacked 工作流)。LIVE/DEAD 两侧各有变异验证测试(删掉 LIVE 分支、或把 `open_heads` 挪到 `prs` 过滤之后,测试即变红——后者是过滤顺序陷阱:父 PR 通常不在被问的 PR 列表里)。status 读法用 compare API(`identical`/`behind` = 在 master 上,`ahead`/`diverged` = 不在),四个状态都有测试钉住(把判据放宽成「不是 ahead 就算 OK」会让 `diverged` 蒙混过关)。exit 0 无死路 / 1 有 PR 停在死路 / 2 问不出来(gh 失败、响应不可解析)——问不出来绝不报 OK。retarget 命令:`gh api -X PATCH repos///pulls/ -f base=master`(`gh pr edit --base` 会因 Projects-classic 弃用报错)。 Release bump: `python3 scripts/bump-version.py ` — 一次改齐 8 处版本声明(`emrg/__init__.py`、`pyproject.toml`、`emrg/gui/package.json`、`emrg/gui/package-lock.json` 根 + `packages[""]`、`uv.lock`、`packaging/{build-runtime,make-installer,make-run-installer}.sh`);`--check` 只报告漂移(宿主侧自检,与 CI 的 test_version_sync 对称),`--dry-run` 预览不落盘。锚点缺失/数量不符即 fail-loud,绝不猜测;`uv.lock` 只改 `name = "emrg"` 那一行(v0.2.94 教训:直接 `uv run` 会把 lock 里所有 registry URL 重写成镜像,556 行环境噪声)。bump 后用 `uv run --no-sync pytest` 避免 uv 重生成 lock。**测的是「你站着的那个 checkout」**(与 doc/node count 两工具同一处修复:root 原先取自 `__file__` ⇒ 在 worktree 里跑主 checkout 的那份脚本会去**锁主树**——worktree 处于 9.9.9/7 处漂移时它报 `OK: all 8 version sources agree`,而 `bump` 会改写**另一个** checkout 的 8 处版本声明,含决定构建产物的那个)。现 root 从 cwd 解析,输出首行报 `tree: `。详见 Agent.md「Releasing」 Doc count sync: `uv run --no-sync python3 scripts/check-doc-count.py [--measure|--resolve-conflict]` — **「Python 测试数不写进任何 tracked 文件」的守卫**(宿主侧自检,与 CI 的 `tests/test_doc_counts.py::test_no_tracked_file_states_the_python_test_count` 同一份规则,两处读的是本工具里的同一个正则)。默认扫描全仓 tracked 文件(`tests/` 与 `scripts/` 除外——那两个树是规则与它的探针住的地方,必须能拼出「写死的样子」;该排除有实测背书:466 个 tracked 文件里这两处之外的命中只有下面那两条真实声明)并**指名文件+行号+写法**,exit 1。`--measure` 现场测量当前树收集的测试数(`pytest --collect-only`,缺 pytest/解析不出即 fail-loud,绝不报假数)。`--resolve-conflict` 专治计数行的合并冲突:**解法不是选边而是去掉那个数字**(两侧各自去掉计数后文本必须完全相同才动手;措辞也不同即拒绝——那是内容冲突,得人读)。**存在理由(2026-09-13 实测,宿主的决定)**:写死一行派生数字既是冲突磁铁又是静默错误源——14 张在飞 PR 里 11 张冲突,**11/11 冲突的都是这一行**(每张加测试的 PR 都得改同一个数字),而两个分支写**同一个值**时 git 干净合并、悄悄留下陈旧值(实测 #1179+#1180 都写 1601 而合并树实收 1603,守卫只在合并后的树上才红)。故数字不写、现场测量:`README.md`/`README.cn.md` 早就这么做了(改挂 Tests badge)。**测的是「你站着的那个 checkout」**(2026-09-11 实测的坑:解冲突要在 git worktree 里干活,而 root 原先取自 `__file__` ⇒ 跑主 checkout 的脚本、量的却是**主树**,worktree 自己的 Agent.md 写 1401 而工具报 `OK: ... documents 1420`——**读错了树还说一致**)。现在 root 从 cwd 解析(cwd 同时有 `Agent.md` 与 `scripts/` 才算 checkout),拿不准就退回脚本自身 root,并在输出首行**报出所测的树** Node count sync: `uv run --no-sync python3 scripts/check-node-test-count.py [--write|--dry-run]` — 直接问**真实运行器**(vitest / node --test)并校验 Agent.md 的 Renderer 与 GUI 两个总数:`tests/test_doc_counts.py` 只能**静态**数 `it(`/`test(` 定义(pytest 作业没有 node_modules),而静态计数只是运行器的**模型**——R2254(445→448)、#1120(同 stem 文件整份被吞)、#1125(`it.each`/`test.skip` 正则看不见)三次都是模型与实践脱节。GUI 侧按 CI 环境(EMRG_SKIP_INTEGRATION=1)运行后减去 1 个模块级 `skip()` 原因条目(该条目数会先断言为 1,形状变了就停手而不是报个看着像对的数)。缺 node_modules 即报该原因,绝不报假数。**同样测「你站着的那个 checkout」**(与 doc count 同一处修复:root 原先取自 `__file__`,在 worktree 里跑主 checkout 的脚本会量到主树,`--write` 会改错树;现从 cwd 解析并报出所测的树) diff --git a/scripts/check-pr-base.py b/scripts/check-pr-base.py new file mode 100644 index 00000000..d09495ef --- /dev/null +++ b/scripts/check-pr-base.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +"""Report PRs whose base branch is not on the path to master. + +The class this exists for +------------------------ +A PR whose `base` is another *feature branch* passes every check this repo has: + +* CI is green (a stacked PR runs against its base, and #1149 now gives it runs at + all); +* the vote helper counts its LGTMs normally - nothing in it looks at the base; +* `gh pr merge` succeeds. + +And it still lands **nothing on master**. `gh pr merge` merges into the PR's +*base branch*, which for a stacked PR is the parent *branch* - and this repo +squash-merges its PRs, so the parent branch is never an ancestor of master +afterwards. The merge commit goes to the parent branch, master never sees the +change, and the PR is marked MERGED. + +Measured 2026-09-11 (`cyc20260911-210746`): #1148 was based on +`feature/conflict-classifier-multiline-count`, the branch of #1147. #1147 was +squash-merged as `25904b6`, so that base branch is not an ancestor of master and +its content on master came from the squash - not from the branch. #1148 carried +the *fix* to the classifier master had just received; had it been approved as it +stood, the fix would have been merged into a dead branch, master's classifier +would have kept answering `KEEP BOTH (concatenate)` on the very blocks the PR +fixes, and the queue would have shown a MERGED PR with three green votes that +changed nothing. The condition is invisible in every dashboard: the PR is +MERGEABLE, CI is green, and the votes count. + +Why the base being non-master is not automatically fatal +-------------------------------------------------------- +A stacked PR is a legitimate, useful workflow *while the parent is in flight* - +that is the whole point of #1149, which made CI run for non-master bases. The +question is not "is the base master", it is **"can a merge into this base still +reach master"**: + +* the base branch's head is **on** master (or is master) -> yes; nothing to do. + `OK`. +* the base branch is the head of an **open PR** -> it is still in flight. A merge + into it goes into the live parent branch and rides along when the parent lands, + so this is not a dead end - but it is not a guarantee either, because the parent + can still be *closed* unmerged. Reported as `LIVE`, and **not** a failure. +* the base branch no longer exists, or its head is not on master and no open PR + is named by it -> **no**. This is the squash-merge shape: the parent PR landed + as a squash, so its branch was never an ancestor of master and merging into it + lands nothing. `DEAD`. + +The live case is what keeps the check from being noise, and it cannot be +recognised from the compare status - a parent that is in flight *necessarily* has +commits master does not, so its head scores `ahead`, or `diverged` as soon as +master moves for any unrelated reason. Both are byte-identical to a squash-merged +branch's status (measured 2026-09-11: all five open PR branches here scored +`ahead` only because master had not advanced since they were cut). The decidable +input is whether an open PR's head *is* that branch - which `_open_prs` already +fetches and this check previously ignored. + +Exit codes +---------- +0 no open PR's base is a dead end (bases either reach master, or are an open + PR's head - the latter reported as LIVE) +1 at least one open PR is based on a dead end - retarget it before merging +2 the question could not be answered (gh failed, response unparseable) + +Never reports a count it could not obtain: a check that guesses "OK" when it +could not read the state is worse than no check, because the failure it hides is +exactly the silent one. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys + +MASTER = "master" + + +def _gh(*args: str) -> str: + """Run `gh`, failing loud rather than guessing. + + A **missing** `gh` is a failure of the same kind as a failing one, and must + take the same exit path. It is not a hypothetical: on the Windows CI runner + `gh` is not installed, so the test that drives this tool against a bogus repo + raised `FileNotFoundError` out of `subprocess.run` - an uncaught exception, + which CPython reports as **rc 1**, i.e. "a PR is on a dead end". That is the + exact wrong answer this tool exists to prevent: a caller reading rc 1 would + go looking for a PR to retarget that does not exist, while the real state is + "could not look". Caught by CI (run 34605389233), not by review. + """ + try: + proc = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"gh is not available on this machine ({exc}); cannot determine PR bases" + ) from exc + if proc.returncode != 0: + raise RuntimeError( + f"gh failed (rc={proc.returncode}): gh {' '.join(args)}\n" + f"{proc.stderr.strip()}" + ) + return proc.stdout + + +def _gh_json(*args: str) -> object: + return json.loads(_gh(*args)) + + +def _open_prs(repo: str | None) -> list[dict]: + """Every open PR with the fields this check needs.""" + args = [ + "pr", + "list", + "--state", + "open", + "--limit", + "100", + "--json", + "number,baseRefName,headRefName,state", + ] + if repo: + args += ["-R", repo] + payload = _gh_json(*args) + if not isinstance(payload, list): + raise RuntimeError(f"expected a list of PRs, got {type(payload).__name__}") + for pr in payload: + for field in ("number", "baseRefName"): + if field not in pr: + raise RuntimeError(f"PR entry missing {field!r}: {pr}") + return payload + + +def _repo_path(repo: str | None) -> str: + """`owner/name` - the caller's, or gh's placeholder for the current repo.""" + return repo if repo else "{owner}/{repo}" + + +def _branch_heads(repo: str | None) -> dict[str, str]: + """branch name -> head sha, for every branch on the remote.""" + # `_gh` (not a bare subprocess.run) so a missing `gh` raises the same + # RuntimeError as a failing one and takes the rc-2 path. + out = _gh("api", f"repos/{_repo_path(repo)}/branches", "--paginate") + # --paginate concatenates page arrays, which is not valid JSON as a whole; + # the endpoint returns an array per page, so parse per document if it parses, + # else fall back to line-delimited objects. + try: + payload = json.loads(out) + except json.JSONDecodeError: + payload = [json.loads(line) for line in out.splitlines() if line.strip()] + if not isinstance(payload, list): + raise RuntimeError("branch listing was not a list") + heads = {} + for entry in payload: + name = entry.get("name") + sha = (entry.get("commit") or {}).get("sha") + if name and sha: + heads[name] = sha + return heads + + +def _ref_is_on_master(sha: str, repo: str | None) -> bool: + """True when `sha` is an ancestor of (or equal to) master's head.""" + data = _gh_json("api", f"repos/{_repo_path(repo)}/compare/{MASTER}...{sha}") + status = data.get("status") + if status is None: + raise RuntimeError(f"compare response has no status: {data}") + # `identical` and `behind` mean the commit is on master's history; `ahead` + # and `diverged` mean it is not. + return status in ("identical", "behind") + + +def classify_base( + base: str, branches: dict[str, str], repo: str | None, open_heads: set[str] = frozenset() +) -> tuple[str, str]: + """`(verdict, why)` for one PR's base branch. + + `open_heads` is the set of branches that are the head of an open PR; a base in + it is a parent still in flight (`LIVE`), which is not a dead end. + """ + if base == MASTER: + return ("OK", "base is master") + if base not in branches: + return ( + "DEAD", + f"base branch {base!r} no longer exists on the remote, so a merge " + "would have nowhere to land and could never reach master", + ) + sha = branches[base] + if _ref_is_on_master(sha, repo): + return ("OK", f"base {base!r} ({sha[:8]}) is already on master") + if base in open_heads: + return ( + "LIVE", + f"base {base!r} ({sha[:8]}) is the head of an open PR, so this is a " + "stacked PR on a parent still in flight: a merge into it lands on the " + "live parent branch and arrives when the parent lands. Not a dead end " + "- but not a guarantee either, since the parent can still be closed " + "unmerged. Prefer retargeting to master before merging", + ) + return ( + "DEAD", + f"base {base!r} ({sha[:8]}) is not on master and is not the head of any " + "open PR - it is a dead end. This is the squash-merge shape: if the parent " + "PR was squash-merged, the branch was never an ancestor of master and " + "merging into it lands nothing. " + "Retarget this PR to master before approving it", + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Report PRs whose base branch cannot reach master." + ) + parser.add_argument("--repo", help="owner/name (default: the current repo)") + parser.add_argument( + "prs", nargs="*", type=int, help="only these PR numbers (default: all open)" + ) + args = parser.parse_args(argv) + + try: + prs = _open_prs(args.repo) + branches = _branch_heads(args.repo) + except (RuntimeError, json.JSONDecodeError) as exc: + print(f"cannot determine PR bases: {exc}", file=sys.stderr) + return 2 + + # Branches that are the head of an open PR: a base among these is a parent + # still in flight (`LIVE`), not a dead end. Computed from **every** open PR, + # before any `prs` filter - the parent of a stacked PR is usually not one of + # the PRs being asked about, and is precisely the row we must not misjudge. + open_heads = {p.get("headRefName") for p in prs} - {None, ""} + + if args.prs: + wanted = set(args.prs) + prs = [p for p in prs if p["number"] in wanted] + + dead = 0 + for pr in sorted(prs, key=lambda p: p["number"]): + try: + verdict, why = classify_base( + pr["baseRefName"], branches, args.repo, open_heads + ) + except (RuntimeError, json.JSONDecodeError) as exc: + print(f"#{pr['number']} UNKNOWN base={pr['baseRefName']!r}: {exc}") + return 2 + mark = f"{verdict:<5}" + print(f"#{pr['number']} {mark} base={pr['baseRefName']!r} - {why}") + if verdict == "DEAD": + dead += 1 + + if dead: + print( + f"\n{dead} open PR(s) are based on a branch that cannot reach master. " + f"Retarget with: gh api -X PATCH repos///pulls/ -f base=master" + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_pr_base.py b/tests/test_check_pr_base.py new file mode 100644 index 00000000..d55ac8b4 --- /dev/null +++ b/tests/test_check_pr_base.py @@ -0,0 +1,264 @@ +"""Tests for scripts/check-pr-base.py - PRs based on a branch that cannot reach master. + +The defect (`cyc20260911-210746`): #1148's base was the branch of #1147, which was +*squash*-merged. The base branch therefore is not an ancestor of master, and +`gh pr merge` - which merges into the PR's **base branch** - would have landed the +fix in a dead end. The PR would have read MERGED with three valid ✅ votes while +master's classifier kept answering `KEEP BOTH (concatenate)` on the blocks the PR +fixes. Every existing gate passes this state: CI is green (against the base), the +vote helper counts the votes (it never reads the base), and the PR is MERGEABLE. + +The tests below pin the *classification*, with the two shapes that must come out +differently - which is the whole reason the check cannot just be "base == master": + +* a stacked PR whose parent is still live on master is **fine** (that is the + workflow #1149 exists to serve, and flagging it would make the check noise); +* a base that is gone, or whose head is not on master after a squash merge, is a + dead end and must be flagged. + +The measurement predicate itself is verified: `_ref_is_on_master` reads the +compare API's `status`, and the fixtures cover every status GitHub can return - +including the two that mean "on master" (`identical`, `behind`), because a version +that treated any non-`ahead` status as fine would pass a `diverged` base. +""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "check-pr-base.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("check_pr_base", SCRIPT) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def mod(): + return _load() + + +class TestCompareStatusIsReadCorrectly: + """`_ref_is_on_master` must read the compare status, not infer from a count.""" + + @pytest.mark.parametrize( + "status,expected", + [ + ("identical", True), # the commit *is* master's head + ("behind", True), # master has moved on; the commit is its ancestor + ("ahead", False), # the commit is not in master at all + ("diverged", False), # forked off before master's tip + ], + ) + def test_every_compare_status_maps_to_the_right_answer( + self, mod, monkeypatch, status, expected + ) -> None: + def fake_gh_json(*args): + assert any("compare/master..." in a for a in args), args + return {"status": status} + + monkeypatch.setattr(mod, "_gh_json", fake_gh_json) + assert mod._ref_is_on_master("deadbeef", "owner/repo") is expected + + def test_a_missing_status_is_an_error_not_a_pass(self, mod, monkeypatch) -> None: + """Unreadable state must fail loud - guessing 'OK' hides the silent case.""" + monkeypatch.setattr(mod, "_gh_json", lambda *a: {"commits": []}) + with pytest.raises(RuntimeError, match="no status"): + mod._ref_is_on_master("deadbeef", "owner/repo") + + +class TestClassifyBase: + def test_master_is_always_ok(self, mod) -> None: + verdict, why = mod.classify_base("master", {}, "owner/repo") + assert verdict == "OK" + assert "master" in why + + def test_a_deleted_base_branch_is_dead(self, mod) -> None: + """No branch at all: a merge would have nowhere to land.""" + verdict, why = mod.classify_base("feature/gone", {}, "owner/repo") + assert verdict == "DEAD" + assert "no longer exists" in why + + def test_a_base_not_on_master_is_dead(self, mod, monkeypatch) -> None: + """The measured #1148 shape: the branch exists, its head is not on master.""" + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: False) + verdict, why = mod.classify_base( + "feature/conflict-classifier-multiline-count", + {"feature/conflict-classifier-multiline-count": "fb5a4e99" * 5}, + "owner/repo", + ) + assert verdict == "DEAD" + assert "not on master" in why + + def test_a_base_already_on_master_is_ok(self, mod, monkeypatch) -> None: + """A base whose head is on master has nothing left to reach - `OK`.""" + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: True) + verdict, why = mod.classify_base( + "feature/landed", {"feature/landed": "abc12345" * 5}, "owner/repo" + ) + assert verdict == "OK" + assert "already on master" in why + + def test_a_live_stacked_base_is_live_not_ok(self, mod, monkeypatch) -> None: + """The **in-flight** parent: the state a stacked PR is normally in. + + This is the discrimination the check exists for, and it is the one the + previous version of this test got wrong: it stubbed "live" as + `_ref_is_on_master -> True`, i.e. *a branch already merged into master* - + the one state a live parent can never be in. It therefore asserted the + conclusion (`OK`) instead of the discrimination, and would have kept + passing with the in-flight case dropped from the tool entirely. + + A parent that is in flight necessarily has commits master does not, so the + compare status is `ahead` (or `diverged`, once master moves for any + unrelated reason) - byte-identical to the squash-merged branch. The only + thing that separates them is whether an open PR's head *is* that branch. + """ + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: False) + verdict, why = mod.classify_base( + "feature/live-parent", + {"feature/live-parent": "abc12345" * 5}, + "owner/repo", + {"feature/live-parent"}, + ) + assert verdict == "LIVE", "an in-flight parent is not a dead end" + assert "still in flight" in why + + def test_a_stacked_base_with_no_open_parent_is_dead(self, mod, monkeypatch) -> None: + """Same branch, same compare status, no open PR naming it -> the #1148 shape. + + The two states above are only decidable *together*: the status is identical + (`_ref_is_on_master -> False` in both), so a tool that reads only the status + must return the same verdict for both. It does not - this is the half that + would catch a revert of the fix. + """ + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: False) + verdict, why = mod.classify_base( + "feature/dead-parent", + {"feature/dead-parent": "abc12345" * 5}, + "owner/repo", + {"feature/some-other-open-pr"}, + ) + assert verdict == "DEAD" + assert "not the head of any open PR" in why + + +class TestMainExitCodes: + def _wire(self, mod, monkeypatch, prs, branches, on_master): + monkeypatch.setattr(mod, "_open_prs", lambda repo: prs) + monkeypatch.setattr(mod, "_branch_heads", lambda repo: branches) + monkeypatch.setattr(mod, "_ref_is_on_master", lambda sha, repo: on_master(sha)) + + def test_exit_1_when_a_pr_is_based_on_a_dead_end(self, mod, monkeypatch) -> None: + prs = [ + {"number": 1148, "baseRefName": "feature/dead", "headRefName": "h1"}, + {"number": 1151, "baseRefName": "master", "headRefName": "h2"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/dead": "s1"}, lambda sha: False) + assert mod.main(["--repo", "owner/repo"]) == 1 + + def test_exit_0_when_every_base_can_reach_master(self, mod, monkeypatch) -> None: + prs = [ + {"number": 1151, "baseRefName": "master", "headRefName": "h1"}, + {"number": 1152, "baseRefName": "feature/live", "headRefName": "h2"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/live": "s2"}, lambda sha: True) + assert mod.main(["--repo", "owner/repo"]) == 0 + + def test_exit_0_for_a_stacked_pr_on_an_open_parent(self, mod, monkeypatch) -> None: + """A stacked child of an open parent: reported, but not a failure. + + End-to-end (through `main`, not just `classify_base`) because the parent is + usually *not* among the PRs being asked about - the verdict depends on the + full open-PR list, so a filter applied before `open_heads` is built would + silently turn this back into `DEAD`. + """ + prs = [ + {"number": 1147, "baseRefName": "master", "headRefName": "feature/parent"}, + {"number": 1148, "baseRefName": "feature/parent", "headRefName": "feature/child"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/parent": "s1"}, lambda sha: False) + # Asking only about the child: the parent is in the payload but not selected. + assert mod.main(["--repo", "owner/repo", "1148"]) == 0 + + def test_exit_1_for_a_stacked_child_whose_parent_is_gone(self, mod, monkeypatch) -> None: + """The #1148 shape end-to-end: the parent branch exists, but no open PR names it.""" + prs = [ + {"number": 1148, "baseRefName": "feature/parent", "headRefName": "feature/child"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/parent": "s1"}, lambda sha: False) + assert mod.main(["--repo", "owner/repo", "1148"]) == 1 + + def test_exit_2_when_the_state_cannot_be_read(self, mod, monkeypatch) -> None: + """A check that reports OK when it could not look is worse than none.""" + def boom(repo): + raise RuntimeError("gh failed (rc=1): network down") + + monkeypatch.setattr(mod, "_open_prs", boom) + assert mod.main(["--repo", "owner/repo"]) == 2 + + def test_selecting_one_pr_filters_the_rest(self, mod, monkeypatch, capsys) -> None: + prs = [ + {"number": 1, "baseRefName": "feature/dead", "headRefName": "h1"}, + {"number": 2, "baseRefName": "master", "headRefName": "h2"}, + ] + self._wire(mod, monkeypatch, prs, {"feature/dead": "s1"}, lambda sha: False) + rc = mod.main(["--repo", "owner/repo", "2"]) + out = capsys.readouterr().out + assert rc == 0, "PR 2 is fine; PR 1 was not asked about" + assert "#2" in out and "#1" not in out + + +class TestRealInvocationSurface: + """The script must be runnable as a tool, and must not silently pass on error.""" + + def test_help_exits_zero(self) -> None: + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--help"], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert proc.returncode == 0 + assert "base branch" in proc.stdout + + def test_missing_gh_exits_2_not_1(self, monkeypatch) -> None: + """A missing `gh` must take the "could not look" path (rc 2), not rc 1. + + This is the failure CI caught on Windows (run 34605389233): `gh` is not + installed on that runner, `subprocess.run(["gh", ...])` raised + `FileNotFoundError`, and an uncaught exception exits **1** - which for + this tool means "a PR is based on a dead end". A caller would then hunt + for a PR to retarget that does not exist, while the truth is that the + state could not be read at all. Pinned here by simulating the missing + binary rather than depending on `gh` being absent on the test machine. + """ + mod = _load() + + def no_gh(*args, **kwargs): + raise FileNotFoundError(2, "No such file or directory", "gh") + + monkeypatch.setattr(mod.subprocess, "run", no_gh) + assert mod.main(["--repo", "argszero/emrg"]) == 2 + + def test_unreachable_repo_exits_2_not_0(self) -> None: + """With a bogus repo gh fails; the tool must not report a clean bill.""" + proc = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", "argszero/definitely-not-a-repo-xyz"], + capture_output=True, + text=True, + encoding="utf-8", + ) + # On a machine with no `gh` at all (e.g. the Windows runner) this is 2 as + # well - the code path is now the same, which is the point. + assert proc.returncode == 2, (proc.returncode, proc.stdout, proc.stderr)