From 9479e0b302843f0207f438971c56f63f4afc42c4 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 09:00:51 +0800 Subject: [PATCH 1/3] emrg: a green CI verdict can be about a tree that can no longer be merged (#1137) --- Agent.md | 3 +- scripts/check-merge-freshness.py | 319 ++++++++++++++++++++++++++++ tests/test_check_merge_freshness.py | 271 +++++++++++++++++++++++ 3 files changed, 592 insertions(+), 1 deletion(-) create mode 100644 scripts/check-merge-freshness.py create mode 100644 tests/test_check_merge_freshness.py diff --git a/Agent.md b/Agent.md index 548093d1..b63e83a6 100644 --- a/Agent.md +++ b/Agent.md @@ -119,11 +119,12 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1397) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1409) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` 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,绝不默认新鲜 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。详见 Agent.md「Releasing」 Doc count sync: `uv run --no-sync python3 scripts/check-doc-count.py [--write|--dry-run|--resolve-conflict]` — 测量当前树的 pytest 收集数并校验 Agent.md 的 Python 测试数(宿主侧自检,与 CI 的 test_python_count_matches_docs 对称);`--write` 仅单 token 替换那个数字(前后遮罩对比,其余字节不变),锚点缺失/出现多次/收集失败均 fail-loud(exit 2)。`--resolve-conflict` 专治合并冲突态:冲突块的**两侧按构造都是陈旧的**,`--write` 会(正确地)拒绝猜哪一侧为真;该模式去掉冲突标记后**在合并后的树上重新测量**写入,绝不选边——实测两侧写 1335/1337 而合并树实收 1339。冲突出现在计数行以外的位置即拒绝(不是什么通用「删标记」按钮)。用途:合并冲突解决后一条命令同步(#1119/#1120/#1121/#1122 四次冲突均在这一行;2026-09-11 #1130 落地后三个 PR 转 DIRTY,逐个解冲突都靠这条) 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 即报该原因,绝不报假数 diff --git a/scripts/check-merge-freshness.py b/scripts/check-merge-freshness.py new file mode 100644 index 00000000..ca0b4be9 --- /dev/null +++ b/scripts/check-merge-freshness.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Check that a pull request's green CI still describes the tree that would merge. + +The class this exists for +------------------------ +On 2026-09-11 PR #1137 was `MERGEABLE/CLEAN` with both CI jobs green. The merge +was nevertheless unsafe, and the way it was unsafe is reproducible: + + merge base cb651a4 : Agent.md count line 1393 own collection 1393 + ours ea0a06a : Agent.md count line 1397 own collection 1397 (+4 tests) + master 64bab52 : Agent.md count line 1397 own collection 1397 (+4 other tests) + +Both sides set the count line to the same number, so git merged it without a +conflict, kept 1397, and the merged tree collected 1401. Two guards +(`test_doc_counts.py::test_python_count_matches_docs` and +`test_check_doc_count.py::test_real_tree_is_consistent`) went red - *after* the +merge, on master, where nobody was looking. + +The CI verdict was not wrong. It was about a different tree. On `pull_request` +GitHub builds `Merge into ` - the head merged onto the branch +point, **not** onto current master. While the branch point is master's tip those +are the same tree; once master moves they are not, and nothing re-runs the check: +the `synchronize` event fires on a branch push, and master moving is not a branch +push. + +The question, made structural +----------------------------- +"Is this verdict current?" reduces exactly to: **is master's tip an ancestor of +the head?** If it is, the merge base *is* master's tip, so the tree CI built and +the tree that would merge are the same commit and the verdict transfers. If it is +not, CI judged a merge onto an older master and the verdict is about a tree that +can no longer be merged. + +That is a graph property, so this tool asks the graph instead of comparing +timestamps. Deliberately so: two timestamps are a proxy that can be wrong (clock +skew, a run created a second before the merge commit), whereas ancestry is the +thing itself. It also gets the "master has not moved" case right for free - the +verdict is simply fresh, and no re-run is needed. + +Ancestry alone is only half the question, so a second condition is checked +---------------------------------------------------------------------------- +Ancestry answers "would a verdict transfer". It does not answer "is there a +verdict". A head can contain master's tip and still have **no CI run at all** - a +dropped push event leaves the branch with zero checks, which reads as `no checks +reported` and is not evidence of anything. So a head only counts as FRESH if it +is an ancestor-descendant of master **and** a run exists for that exact SHA. + +Keyed on the SHA, not the branch: a branch pushed twice has two runs, and reading +the older one as the current verdict is the same class of mistake in miniature. +The run is also required to have *passed* - a failing or cancelled run is not a +stale verdict, it is a verdict the committer has to deal with on its own terms, +and this tool says so rather than calling it fresh. + +Why the obvious shortcut is wrong +--------------------------------- +`gh pr view --json mergeable` returns `CLEAN` here and is actively misleading: +GitHub computes mergeability as "does this textually merge", which is exactly the +property that failed. A cleanly auto-merged line is the *dangerous* case - when +the count line conflicts, a human is forced to look at it. + +Usage +----- + uv run --no-sync python3 scripts/check-merge-freshness.py [ ...] + uv run --no-sync python3 scripts/check-merge-freshness.py --json + +Exit codes +---------- + 0 every head contains master's tip - CI's merge base is master itself + 1 at least one head does NOT contain master's tip - the verdict is stale + 2 the check could not be made (bad PR, gh failed, unreadable response) - + fail loud; never report "fresh" for a question that was not answered + +`gh` is required, and so is network access to GitHub. There is no offline mode: +the whole question is about a remote verdict, and a local guess would be the +failure mode this tool exists to prevent. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from dataclasses import dataclass + +REPO = "argszero/emrg" + +# GitHub `compare` statuses, split by the one property that decides freshness: +# is master's tip an ancestor of the head? +# +# identical / ahead -> master is an ancestor -> merge base IS master -> fresh +# behind / diverged -> master is not an ancestor -> stale +# +# Both sets are named rather than expressed as `status == "ahead"`, so an +# unrecognised status (a new GitHub value) falls through to the fail-loud branch +# instead of being silently treated as fresh. +_FRESH_STATUSES = frozenset({"identical", "ahead"}) +_STALE_STATUSES = frozenset({"behind", "diverged"}) + +# A run that has not concluded yet has judged nothing, so it is not a verdict to +# expire - it is a verdict still being formed. Reported as such, never as fresh. +_UNFINISHED = frozenset({"", "pending", "queued", "in_progress", "requested", "waiting"}) + + +def _gh_json(args: list[str]) -> object: + """Run `gh` and parse JSON, failing loud rather than guessing. + + `args` are gh's arguments *without* the program name; it is prepended here so + every call site cannot forget it. Measured 2026-09-11: a call site that passed + `["pr", "view", ...]` to a helper that also omitted the program name ran the + POSIX `pr` utility instead - which took `view` and the PR number as filenames + and failed with `pr: cannot open view`, a message that names neither gh nor + the real mistake. + """ + proc = subprocess.run( + ["gh", *args], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if proc.returncode != 0: + raise RuntimeError( + f"gh failed (rc={proc.returncode}): gh {' '.join(args)}\n{proc.stderr.strip()}" + ) + return json.loads(proc.stdout) + + +@dataclass +class Verdict: + pr: int + title: str + head_sha: str + merge_base: str + ahead_by: int + behind_by: int + run_created_at: str | None + run_conclusion: str | None + stale: bool + reason: str + + +def _latest_run_for_head(head: str, branch: str, number: int) -> dict | None: + """The newest run whose head is exactly this commit, or None if there is none. + + Keyed on the SHA: a branch pushed twice has two runs, and reading the older + one as the current verdict is the same mistake as reading a stale verdict, + one step smaller. + """ + runs_raw = _gh_json( + [ + "run", + "list", + "-R", + REPO, + "--branch", + branch, + "--limit", + "30", + "--json", + "headSha,createdAt,conclusion", + ] + ) + assert isinstance(runs_raw, list) + matching = [r for r in runs_raw if isinstance(r, dict) and r.get("headSha") == head] + if not matching: + return None + return max(matching, key=lambda r: str(r.get("createdAt") or "")) + + +def check_pr(number: int) -> Verdict: + view = _gh_json( + [ + "pr", + "view", + str(number), + "-R", + REPO, + "--json", + "number,title,headRefOid,headRefName", + ] + ) + assert isinstance(view, dict) + head_sha = str(view["headRefOid"]) + branch = str(view["headRefName"]) + + cmp_raw = _gh_json( + [ + "api", + f"repos/{REPO}/compare/master...{head_sha}", + "--jq", + "{status, ahead_by, behind_by, merge_base: .merge_base_commit.sha}", + ] + ) + assert isinstance(cmp_raw, dict) + status = str(cmp_raw["status"]) + ahead_by = int(cmp_raw["ahead_by"]) + behind_by = int(cmp_raw["behind_by"]) + merge_base = str(cmp_raw["merge_base"]) + + run = _latest_run_for_head(head_sha, branch, number) + created = str(run.get("createdAt") or "") if run else None + conclusion = str(run.get("conclusion") or "") if run else None + + common = dict( + pr=number, + title=str(view["title"]), + head_sha=head_sha, + merge_base=merge_base, + ahead_by=ahead_by, + behind_by=behind_by, + run_created_at=created, + run_conclusion=conclusion, + ) + + if status not in _FRESH_STATUSES and status not in _STALE_STATUSES: + raise RuntimeError( + f"unrecognised compare status {status!r} for #{number}; refusing to call it fresh" + ) + + if status in _STALE_STATUSES: + return Verdict( + **common, + stale=True, + reason=( + f"head does not contain master (status={status}, behind_by={behind_by}) " + f"- CI's merge base was {merge_base[:8]}, so the verdict is about a tree " + "that can no longer be merged" + ), + ) + + # Master is an ancestor. That answers "would a verdict transfer"; now answer + # "is there one". + if run is None: + return Verdict( + **common, + stale=True, + reason=( + f"master is an ancestor (status={status}) but there is NO CI run for head " + f"{head_sha[:8]} - an unjudged head, which `gh pr checks` reports as " + "'no checks reported'" + ), + ) + if conclusion in _UNFINISHED: + return Verdict( + **common, + stale=True, + reason=f"CI is still {conclusion or 'pending'} on head {head_sha[:8]} - no verdict yet", + ) + if conclusion != "success": + return Verdict( + **common, + stale=True, + reason=( + f"CI concluded {conclusion!r} on head {head_sha[:8]} - a failing verdict, " + "not a stale one; re-running will not make it fresh" + ), + ) + return Verdict( + **common, + stale=False, + reason=( + f"master is an ancestor (status={status}, behind_by={behind_by}) and head " + f"{head_sha[:8]} has a passing run - merge base {merge_base[:8]} IS master's tip" + ), + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="check-merge-freshness.py", + description="Is each PR's green CI still about the tree that would merge?", + ) + parser.add_argument("prs", nargs="+", type=int, help="pull request number(s)") + parser.add_argument("--json", action="store_true", help="emit JSON instead of prose") + args = parser.parse_args(argv) + + try: + verdicts = [check_pr(n) for n in args.prs] + except (RuntimeError, KeyError, ValueError, AssertionError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + if args.json: + print( + json.dumps( + [ + { + "pr": v.pr, + "head": v.head_sha, + "merge_base": v.merge_base, + "ahead_by": v.ahead_by, + "behind_by": v.behind_by, + "stale": v.stale, + "reason": v.reason, + } + for v in verdicts + ], + indent=2, + ) + ) + else: + for v in verdicts: + mark = "STALE" if v.stale else "FRESH" + print(f"#{v.pr} {mark} (head {v.head_sha[:8]}, base {v.merge_base[:8]}) - {v.reason}") + + if any(v.stale for v in verdicts): + print( + "\nRe-merge master into each stale branch, then let CI run. On `pull_request` " + "GitHub builds Merge into ; merging current master in is what " + "moves the merge base to master, so CI then judges the real merged tree.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_merge_freshness.py b/tests/test_check_merge_freshness.py new file mode 100644 index 00000000..f4994c3c --- /dev/null +++ b/tests/test_check_merge_freshness.py @@ -0,0 +1,271 @@ +"""Tests for scripts/check-merge-freshness.py - is a PR's CI verdict still current? + +Background (cycle cyc20260911-083721) +------------------------------------- +On 2026-09-11 PR #1137 was `MERGEABLE/CLEAN` with both CI jobs green, and the +merge still produced a tree that failed two guards. The cause is worth pinning in +tests because the failure is silent and the obvious signals are all green: + +* GitHub builds `Merge into ` for `pull_request`, so the verdict + is about the head merged onto the *branch point*. When master moves, the branch + point is no longer master, and the green run describes a tree that cannot be + merged - yet it stays green, because master moving is not a branch push and + fires no `synchronize` event. +* `gh pr view --json mergeable` says `CLEAN` throughout, because it answers "does + this textually merge", which is the property that failed. + +Both states are pinned here, never inferred from the failure case alone (#455): + +* **fresh** - the head contains master's tip and has a passing run for that exact + SHA; rc 0, and the tool is safe to gate a merge on. +* **stale** - four distinguishable ways (diverged, no run at all, still running, + concluded non-success), each with its own reason, so a caller can tell "rebase + it" apart from "fix it". + +The fourth case is the one a naive implementation gets wrong: a head that contains +master but has *zero* CI runs is `no checks reported`, not a pass. Ancestry alone +is not enough to call a verdict current, so it is tested as its own state. + +Nothing here touches the network: `_gh_json` is replaced, and the replacement is +asserted to receive the arguments the real helper would (so a test cannot pass by +never calling it). +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "scripts" / "check-merge-freshness.py" + +HEAD = "a" * 40 +OTHER = "b" * 40 +BASE = "c" * 40 + + +def _load_module(): + spec = importlib.util.spec_from_file_location("check_merge_freshness", SCRIPT) + mod = importlib.util.module_from_spec(spec) + # Register before exec: the module declares a dataclass, and dataclasses + # resolves annotations through sys.modules[cls.__module__] at class-creation + # time. A module that is not registered there raises AttributeError inside + # dataclasses itself - an error that names neither this test nor the cause. + import sys + + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def mod(): + return _load_module() + + +class FakeGh: + """A stand-in for `_gh_json` that answers from a routing table. + + Records every call so a test can assert the query was actually made - a test + whose fake is never called would pass while the code under test queried + nothing at all. + """ + + def __init__(self, pr_view: dict, compare: dict, runs: list[dict] | None): + self.pr_view = pr_view + self.compare = compare + self.runs = runs + self.calls: list[list[str]] = [] + + def __call__(self, args: list[str]) -> object: + self.calls.append(list(args)) + # Every gh invocation must name its program at the call site (the helper + # prepends it); assert the shape rather than trusting it. + assert args, "gh was called with no arguments" + if args[:2] == ["pr", "view"]: + return self.pr_view + if args[0] == "api": + assert any(a.startswith("repos/") and "/compare/" in a for a in args), args + return self.compare + if args[:2] == ["run", "list"]: + return self.runs if self.runs is not None else [] + raise AssertionError(f"unexpected gh call: {args}") + + +def _compare(status: str, ahead: int, behind: int, base: str = BASE) -> dict: + return {"status": status, "ahead_by": ahead, "behind_by": behind, "merge_base": base} + + +def _view(sha: str = HEAD, branch: str = "feature/x") -> dict: + return {"number": 1, "title": "t", "headRefOid": sha, "headRefName": branch} + + +def _install(mod, monkeypatch, fake: FakeGh) -> None: + monkeypatch.setattr(mod, "_gh_json", fake) + + +def _run(mod, monkeypatch, fake: FakeGh, argv: list[str] | None = None) -> int: + _install(mod, monkeypatch, fake) + return mod.main(argv if argv is not None else ["1"]) + + +# --- fresh ----------------------------------------------------------------- + + +def test_fresh_when_head_contains_master_and_has_a_passing_run(mod, monkeypatch, capsys): + fake = FakeGh(_view(), _compare("ahead", 4, 0), [{"headSha": HEAD, "conclusion": "success"}]) + rc = _run(mod, monkeypatch, fake) + out = capsys.readouterr().out + assert rc == 0 + assert "FRESH" in out + assert "STALE" not in out + assert len(fake.calls) == 3, "expected exactly the three queries, not a short-circuit" + + +def test_fresh_when_master_has_not_moved_at_all(mod, monkeypatch, capsys): + """`identical` means master's tip *is* the head - trivially current.""" + fake = FakeGh(_view(), _compare("identical", 0, 0), [{"headSha": HEAD, "conclusion": "success"}]) + assert _run(mod, monkeypatch, fake) == 0 + assert "FRESH" in capsys.readouterr().out + + +# --- stale: the four distinguishable ways ---------------------------------- + + +def test_stale_when_the_head_does_not_contain_master(mod, monkeypatch, capsys): + """The #1137 case: the verdict was green but about an older master.""" + fake = FakeGh( + _view(), _compare("diverged", 2, 1, base="cb651a4"), [{"headSha": HEAD, "conclusion": "success"}] + ) + rc = _run(mod, monkeypatch, fake) + out = capsys.readouterr().out + assert rc == 1 + assert "STALE" in out + assert "no longer be merged" in out + # The reason must name the branch point, or a reader cannot tell which master + # the verdict was actually about. + assert "cb651a4" in out + + +def test_stale_when_there_is_no_ci_run_for_this_head(mod, monkeypatch, capsys): + """Ancestry is not enough: `no checks reported` is not a pass. + + This is the state a dropped push event leaves behind, and it is the one an + ancestry-only implementation would wrongly call fresh. + """ + fake = FakeGh(_view(), _compare("ahead", 1, 0), []) + rc = _run(mod, monkeypatch, fake) + out = capsys.readouterr() + assert rc == 1 + assert "NO CI run" in out.out + assert "no checks reported" in out.out + + +def test_stale_when_a_run_exists_only_for_a_different_sha(mod, monkeypatch, capsys): + """Keyed on the SHA, not the branch. + + A branch pushed twice has runs for both heads; reading the older one as the + current verdict is the same mistake one step smaller. + """ + fake = FakeGh(_view(), _compare("ahead", 1, 0), [{"headSha": OTHER, "conclusion": "success"}]) + rc = _run(mod, monkeypatch, fake) + assert rc == 1 + assert "NO CI run" in capsys.readouterr().out + + +def test_stale_and_distinguished_when_ci_is_still_running(mod, monkeypatch, capsys): + fake = FakeGh(_view(), _compare("ahead", 1, 0), [{"headSha": HEAD, "conclusion": "pending"}]) + rc = _run(mod, monkeypatch, fake) + out = capsys.readouterr().out + assert rc == 1 + assert "still pending" in out + + +def test_a_failing_verdict_is_reported_as_failing_not_as_stale(mod, monkeypatch, capsys): + """Re-running CI will not help, so the wording must not suggest a rebase does.""" + fake = FakeGh(_view(), _compare("ahead", 1, 0), [{"headSha": HEAD, "conclusion": "failure"}]) + rc = _run(mod, monkeypatch, fake) + out = capsys.readouterr().out + assert rc == 1 + assert "failing verdict, not a stale one" in out + + +def test_the_newest_run_for_the_head_wins(mod, monkeypatch, capsys): + """Two runs on one SHA: the freshest conclusion decides, not the first seen.""" + fake = FakeGh( + _view(), + _compare("ahead", 1, 0), + [ + {"headSha": HEAD, "conclusion": "failure", "createdAt": "2026-09-11T00:00:00Z"}, + {"headSha": HEAD, "conclusion": "success", "createdAt": "2026-09-11T01:00:00Z"}, + ], + ) + rc = _run(mod, monkeypatch, fake) + assert rc == 0 + assert "FRESH" in capsys.readouterr().out + + +# --- fail loud, never guess ------------------------------------------------ + + +def test_an_unrecognised_compare_status_is_refused_not_called_fresh(mod, monkeypatch, capsys): + """A new GitHub status must not silently read as fresh. + + The freshness sets are named rather than written as `status == "ahead"` for + exactly this case: anything unrecognised has to fail loud. + """ + fake = FakeGh(_view(), _compare("some_new_status", 1, 0), [{"headSha": HEAD, "conclusion": "success"}]) + rc = _run(mod, monkeypatch, fake) + err = capsys.readouterr().err + assert rc == 2 + assert "unrecognised compare status" in err + + +def test_a_gh_failure_exits_2_with_the_reason(mod, monkeypatch, capsys): + def boom(args): + raise RuntimeError("gh failed (rc=1): gh api repos/x/compare/...\nsome stderr") + + monkeypatch.setattr(mod, "_gh_json", boom) + rc = mod.main(["1"]) + err = capsys.readouterr().err + assert rc == 2 + assert "gh failed" in err + + +def test_the_helper_invokes_the_gh_program_by_name(mod, monkeypatch): + """`_gh_json` must prepend the program name itself. + + Measured 2026-09-11: a call site that passed `["pr", "view", ...]` to a helper + which also omitted the program name ran the POSIX `pr` utility, whose failure + message (`pr: cannot open view`) names neither gh nor the real mistake. + """ + import subprocess as sp + + seen: list[list[str]] = [] + + class _Done: + returncode = 0 + stdout = "{}" + stderr = "" + + def fake_run(cmd, **kwargs): + seen.append(list(cmd)) + return _Done() + + monkeypatch.setattr(sp, "run", fake_run) + mod._gh_json(["pr", "view", "1"]) + assert seen and seen[0][0] == "gh", seen + assert seen[0][1:] == ["pr", "view", "1"] + + +def test_json_mode_is_machine_readable(mod, monkeypatch, capsys): + fake = FakeGh(_view(), _compare("ahead", 4, 0), [{"headSha": HEAD, "conclusion": "success"}]) + rc = _run(mod, monkeypatch, fake, ["1", "--json"]) + payload = json.loads(capsys.readouterr().out) + assert rc == 0 + assert payload[0]["stale"] is False + assert payload[0]["head"] == HEAD + assert payload[0]["merge_base"] == BASE From 1ee5feefcaabf56b94fe6cd7f7751ed3bc0abd45 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 09:20:30 +0800 Subject: [PATCH 2/3] emrg: drop a dead parameter from the merge-freshness helper, and guard the class --- Agent.md | 2 +- scripts/check-merge-freshness.py | 4 +-- tests/test_check_merge_freshness.py | 38 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Agent.md b/Agent.md index b63e83a6..51355967 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1409) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1410) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` 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 上下文) diff --git a/scripts/check-merge-freshness.py b/scripts/check-merge-freshness.py index ca0b4be9..0ce48361 100644 --- a/scripts/check-merge-freshness.py +++ b/scripts/check-merge-freshness.py @@ -140,7 +140,7 @@ class Verdict: reason: str -def _latest_run_for_head(head: str, branch: str, number: int) -> dict | None: +def _latest_run_for_head(head: str, branch: str) -> dict | None: """The newest run whose head is exactly this commit, or None if there is none. Keyed on the SHA: a branch pushed twice has two runs, and reading the older @@ -198,7 +198,7 @@ def check_pr(number: int) -> Verdict: behind_by = int(cmp_raw["behind_by"]) merge_base = str(cmp_raw["merge_base"]) - run = _latest_run_for_head(head_sha, branch, number) + run = _latest_run_for_head(head_sha, branch) created = str(run.get("createdAt") or "") if run else None conclusion = str(run.get("conclusion") or "") if run else None diff --git a/tests/test_check_merge_freshness.py b/tests/test_check_merge_freshness.py index f4994c3c..6a2f7909 100644 --- a/tests/test_check_merge_freshness.py +++ b/tests/test_check_merge_freshness.py @@ -269,3 +269,41 @@ def test_json_mode_is_machine_readable(mod, monkeypatch, capsys): assert payload[0]["stale"] is False assert payload[0]["head"] == HEAD assert payload[0]["merge_base"] == BASE + + +def test_no_function_has_an_unused_parameter() -> None: + """Every declared parameter must be read somewhere in its own body. + + Added because this script shipped one: `_latest_run_for_head(head, branch, + number)` never read `number`, left over from a draft that used it to build the + error message. A dead parameter is not cosmetic here - it tells the next reader + the function needs the PR number to do its job, which is exactly the kind of + false signal about a *merge-gate* helper that this repo treats as a defect. + + Parsed with `ast` rather than a regex, and checked against the whole function + body including nested scopes, so a parameter read only inside a closure still + counts as used. `self` is excluded (methods), as are names prefixed with `_`. + """ + import ast + + tree = ast.parse(SCRIPT.read_text(encoding="utf-8")) + offenders: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + args = node.args + declared = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)] + if args.vararg: + declared.append(args.vararg.arg) + if args.kwarg: + declared.append(args.kwarg.arg) + named = set(declared) - {"self"} + used = {n.id for n in ast.walk(node) if isinstance(n, ast.Name)} + used |= {n.attr for n in ast.walk(node) if isinstance(n, ast.Attribute)} + for param in sorted(named): + if param not in used: + offenders.append(f"{node.name}({param})") + assert not offenders, ( + "unused parameter(s) - each declares a dependency the body does not have: " + f"{offenders}" + ) From 774d5b66be012bc9612566822c6ad0e743a4cf1a Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 09:35:34 +0800 Subject: [PATCH 3/3] emrg: ask GitHub for the head's runs by SHA, and pin which workflow is the verdict pm25coder reviewed #1138 on a Windows/cp936 host and raised two points; both are correct and are fixed here. 1. `_latest_run_for_head` listed a branch's newest 30 runs and filtered by SHA. `actions/runs?head_sha=` addresses the wanted set directly (measured: same answer, total_count 1 for a feature head). The window had two failure modes with one cause - a branch pushed more than 30 times reports "no CI run" for a head that has one (fail-loud, wrong reason), and the run was assumed to be reachable under the head *branch* name, which a fork PR or a rename breaks. 2. A passing run from *any* workflow was accepted. Today `test.yml` is the only workflow `pull_request` triggers, so this gave the right answer by coincidence, not by the query. The tool's claim is "the tests passed", so the query now pins `_VERDICT_WORKFLOW`; a head whose only runs are from another workflow reports "NO Test run" - distinct from "no CI at all". The signature drops `branch`, which the new query no longer needs. The new test is the fresh fixture with the workflow name changed and nothing else, so it fails if the filter is removed (mutation-checked). Agent.md's Python count moves 1410 -> 1411 for the added test. Verified: 1410 passed, 1 skipped; import + CLI checks; actionlint clean. --- Agent.md | 2 +- scripts/check-merge-freshness.py | 70 +++++++++++++++++++---------- tests/test_check_merge_freshness.py | 55 +++++++++++++++++------ 3 files changed, 89 insertions(+), 38 deletions(-) diff --git a/Agent.md b/Agent.md index 51355967..14b50c70 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1410) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1411) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` 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 上下文) diff --git a/scripts/check-merge-freshness.py b/scripts/check-merge-freshness.py index 0ce48361..dc79d01f 100644 --- a/scripts/check-merge-freshness.py +++ b/scripts/check-merge-freshness.py @@ -47,6 +47,11 @@ Keyed on the SHA, not the branch: a branch pushed twice has two runs, and reading the older one as the current verdict is the same class of mistake in miniature. +The query asks GitHub for that SHA's runs directly, so there is no window to fall +out of either. It is also pinned to the workflow whose verdict is being claimed - +`test.yml` - because "some passing run" is only the test verdict while nothing +else happens to run on a PR head. + The run is also required to have *passed* - a failing or cancelled run is not a stale verdict, it is a verdict the committer has to deal with on its own terms, and this tool says so rather than calling it fresh. @@ -101,6 +106,16 @@ # expire - it is a verdict still being formed. Reported as such, never as fresh. _UNFINISHED = frozenset({"", "pending", "queued", "in_progress", "requested", "waiting"}) +# The workflow whose verdict this tool speaks about, by the name GitHub reports. +# `test.yml` ("Test") is the only workflow triggered by `pull_request` in this +# repo, so it is the one whose greenness a merge rests on. Pinned rather than +# "any passing run": the claim "the verdict is about the tests" was otherwise +# carried by coincidence (today nothing else runs on a PR head), and the first +# workflow added on a branch would silently become the verdict instead. A PR head +# that has runs *but none from this workflow* is reported distinctly, so a rename +# here reads as "the verdict workflow did not run", not as "the branch has no CI". +_VERDICT_WORKFLOW = "Test" + def _gh_json(args: list[str]) -> object: """Run `gh` and parse JSON, failing loud rather than guessing. @@ -140,29 +155,39 @@ class Verdict: reason: str -def _latest_run_for_head(head: str, branch: str) -> dict | None: - """The newest run whose head is exactly this commit, or None if there is none. +def _latest_run_for_head(head: str) -> dict | None: + """The newest `_VERDICT_WORKFLOW` run for this exact commit, or None. - Keyed on the SHA: a branch pushed twice has two runs, and reading the older - one as the current verdict is the same mistake as reading a stale verdict, - one step smaller. + Asked by `head_sha`, not by branch + a window: the run set wanted is directly + addressable, and the branch form has two failure modes with one cause. It + carried `--limit 30`, so a branch pushed more than 30 times would report + "no CI run" for a head that has one - fail-loud, but with the wrong reason + (measured 2026-09-11 by pm25coder on #1138, who also confirmed the SHA form + returns the identical answer). It also assumed the run is reachable under the + head *branch* name, which a fork PR or a renamed branch breaks. + + Filtering to `_VERDICT_WORKFLOW` is the second half of the same point: a + passing run from *any* workflow is not a test verdict. """ - runs_raw = _gh_json( + payload = _gh_json( [ - "run", - "list", - "-R", - REPO, - "--branch", - branch, - "--limit", - "30", - "--json", - "headSha,createdAt,conclusion", + "api", + f"repos/{REPO}/actions/runs?head_sha={head}&per_page=100", + "--jq", + "{runs: [.workflow_runs[] | {headSha: .head_sha, name, " + "createdAt: .created_at, conclusion}]}", ] ) + assert isinstance(payload, dict) + runs_raw = payload.get("runs") assert isinstance(runs_raw, list) - matching = [r for r in runs_raw if isinstance(r, dict) and r.get("headSha") == head] + matching = [ + r + for r in runs_raw + if isinstance(r, dict) + and r.get("headSha") == head + and r.get("name") == _VERDICT_WORKFLOW + ] if not matching: return None return max(matching, key=lambda r: str(r.get("createdAt") or "")) @@ -177,12 +202,11 @@ def check_pr(number: int) -> Verdict: "-R", REPO, "--json", - "number,title,headRefOid,headRefName", + "number,title,headRefOid", ] ) assert isinstance(view, dict) head_sha = str(view["headRefOid"]) - branch = str(view["headRefName"]) cmp_raw = _gh_json( [ @@ -198,7 +222,7 @@ def check_pr(number: int) -> Verdict: behind_by = int(cmp_raw["behind_by"]) merge_base = str(cmp_raw["merge_base"]) - run = _latest_run_for_head(head_sha, branch) + run = _latest_run_for_head(head_sha) created = str(run.get("createdAt") or "") if run else None conclusion = str(run.get("conclusion") or "") if run else None @@ -236,9 +260,9 @@ def check_pr(number: int) -> Verdict: **common, stale=True, reason=( - f"master is an ancestor (status={status}) but there is NO CI run for head " - f"{head_sha[:8]} - an unjudged head, which `gh pr checks` reports as " - "'no checks reported'" + f"master is an ancestor (status={status}) but there is NO {_VERDICT_WORKFLOW} " + f"run for head {head_sha[:8]} - an unjudged head, which `gh pr checks` reports " + "as 'no checks reported'" ), ) if conclusion in _UNFINISHED: diff --git a/tests/test_check_merge_freshness.py b/tests/test_check_merge_freshness.py index 6a2f7909..099f2132 100644 --- a/tests/test_check_merge_freshness.py +++ b/tests/test_check_merge_freshness.py @@ -88,10 +88,10 @@ def __call__(self, args: list[str]) -> object: if args[:2] == ["pr", "view"]: return self.pr_view if args[0] == "api": + if any("actions/runs" in a for a in args): + return {"runs": self.runs if self.runs is not None else []} assert any(a.startswith("repos/") and "/compare/" in a for a in args), args return self.compare - if args[:2] == ["run", "list"]: - return self.runs if self.runs is not None else [] raise AssertionError(f"unexpected gh call: {args}") @@ -103,6 +103,15 @@ def _view(sha: str = HEAD, branch: str = "feature/x") -> dict: return {"number": 1, "title": "t", "headRefOid": sha, "headRefName": branch} +def _run_(sha: str = HEAD, conclusion: str = "success", at: str = "2026-09-11T00:00:00Z") -> dict: + """A workflow run, as the actions/runs payload reports it. + + `name` is not decoration: the tool pins the verdict to the `Test` workflow, so + a fixture without it is a run the tool is right to ignore. + """ + return {"headSha": sha, "name": "Test", "conclusion": conclusion, "createdAt": at} + + def _install(mod, monkeypatch, fake: FakeGh) -> None: monkeypatch.setattr(mod, "_gh_json", fake) @@ -116,7 +125,7 @@ def _run(mod, monkeypatch, fake: FakeGh, argv: list[str] | None = None) -> int: def test_fresh_when_head_contains_master_and_has_a_passing_run(mod, monkeypatch, capsys): - fake = FakeGh(_view(), _compare("ahead", 4, 0), [{"headSha": HEAD, "conclusion": "success"}]) + fake = FakeGh(_view(), _compare("ahead", 4, 0), [_run_()]) rc = _run(mod, monkeypatch, fake) out = capsys.readouterr().out assert rc == 0 @@ -127,7 +136,7 @@ def test_fresh_when_head_contains_master_and_has_a_passing_run(mod, monkeypatch, def test_fresh_when_master_has_not_moved_at_all(mod, monkeypatch, capsys): """`identical` means master's tip *is* the head - trivially current.""" - fake = FakeGh(_view(), _compare("identical", 0, 0), [{"headSha": HEAD, "conclusion": "success"}]) + fake = FakeGh(_view(), _compare("identical", 0, 0), [_run_()]) assert _run(mod, monkeypatch, fake) == 0 assert "FRESH" in capsys.readouterr().out @@ -138,7 +147,7 @@ def test_fresh_when_master_has_not_moved_at_all(mod, monkeypatch, capsys): def test_stale_when_the_head_does_not_contain_master(mod, monkeypatch, capsys): """The #1137 case: the verdict was green but about an older master.""" fake = FakeGh( - _view(), _compare("diverged", 2, 1, base="cb651a4"), [{"headSha": HEAD, "conclusion": "success"}] + _view(), _compare("diverged", 2, 1, base="cb651a4"), [_run_()] ) rc = _run(mod, monkeypatch, fake) out = capsys.readouterr().out @@ -160,7 +169,7 @@ def test_stale_when_there_is_no_ci_run_for_this_head(mod, monkeypatch, capsys): rc = _run(mod, monkeypatch, fake) out = capsys.readouterr() assert rc == 1 - assert "NO CI run" in out.out + assert "NO Test run" in out.out assert "no checks reported" in out.out @@ -170,14 +179,32 @@ def test_stale_when_a_run_exists_only_for_a_different_sha(mod, monkeypatch, caps A branch pushed twice has runs for both heads; reading the older one as the current verdict is the same mistake one step smaller. """ - fake = FakeGh(_view(), _compare("ahead", 1, 0), [{"headSha": OTHER, "conclusion": "success"}]) + fake = FakeGh(_view(), _compare("ahead", 1, 0), [_run_(OTHER)]) rc = _run(mod, monkeypatch, fake) assert rc == 1 - assert "NO CI run" in capsys.readouterr().out + assert "NO Test run" in capsys.readouterr().out + + +def test_a_passing_run_from_another_workflow_is_not_the_verdict(mod, monkeypatch, capsys): + """The claim is "the *tests* passed", so the workflow is part of the query. + + Today `test.yml` is the only workflow `pull_request` triggers, so accepting + any passing run happens to give the right answer - which is exactly the kind + of coincidence that stops being true silently, the first time a second + workflow is added to a branch. Pinned on a fixture whose only difference from + the fresh case is the workflow name. + """ + other_workflow = _run_() + other_workflow["name"] = "Build Release" + fake = FakeGh(_view(), _compare("ahead", 4, 0), [other_workflow]) + rc = _run(mod, monkeypatch, fake) + out = capsys.readouterr().out + assert rc == 1 + assert "NO Test run" in out, out def test_stale_and_distinguished_when_ci_is_still_running(mod, monkeypatch, capsys): - fake = FakeGh(_view(), _compare("ahead", 1, 0), [{"headSha": HEAD, "conclusion": "pending"}]) + fake = FakeGh(_view(), _compare("ahead", 1, 0), [_run_(conclusion="pending")]) rc = _run(mod, monkeypatch, fake) out = capsys.readouterr().out assert rc == 1 @@ -186,7 +213,7 @@ def test_stale_and_distinguished_when_ci_is_still_running(mod, monkeypatch, caps def test_a_failing_verdict_is_reported_as_failing_not_as_stale(mod, monkeypatch, capsys): """Re-running CI will not help, so the wording must not suggest a rebase does.""" - fake = FakeGh(_view(), _compare("ahead", 1, 0), [{"headSha": HEAD, "conclusion": "failure"}]) + fake = FakeGh(_view(), _compare("ahead", 1, 0), [_run_(conclusion="failure")]) rc = _run(mod, monkeypatch, fake) out = capsys.readouterr().out assert rc == 1 @@ -199,8 +226,8 @@ def test_the_newest_run_for_the_head_wins(mod, monkeypatch, capsys): _view(), _compare("ahead", 1, 0), [ - {"headSha": HEAD, "conclusion": "failure", "createdAt": "2026-09-11T00:00:00Z"}, - {"headSha": HEAD, "conclusion": "success", "createdAt": "2026-09-11T01:00:00Z"}, + _run_(conclusion="failure", at="2026-09-11T00:00:00Z"), + _run_(at="2026-09-11T01:00:00Z"), ], ) rc = _run(mod, monkeypatch, fake) @@ -217,7 +244,7 @@ def test_an_unrecognised_compare_status_is_refused_not_called_fresh(mod, monkeyp The freshness sets are named rather than written as `status == "ahead"` for exactly this case: anything unrecognised has to fail loud. """ - fake = FakeGh(_view(), _compare("some_new_status", 1, 0), [{"headSha": HEAD, "conclusion": "success"}]) + fake = FakeGh(_view(), _compare("some_new_status", 1, 0), [_run_()]) rc = _run(mod, monkeypatch, fake) err = capsys.readouterr().err assert rc == 2 @@ -262,7 +289,7 @@ def fake_run(cmd, **kwargs): def test_json_mode_is_machine_readable(mod, monkeypatch, capsys): - fake = FakeGh(_view(), _compare("ahead", 4, 0), [{"headSha": HEAD, "conclusion": "success"}]) + fake = FakeGh(_view(), _compare("ahead", 4, 0), [_run_()]) rc = _run(mod, monkeypatch, fake, ["1", "--json"]) payload = json.loads(capsys.readouterr().out) assert rc == 0