From 4adc99142686621f7fdd9e427c51d52551030f59 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 01:19:52 +0800 Subject: [PATCH] emrg: read a blank gh payload as unmeasurable, not as an empty queue --- scripts/check-issue-links.py | 73 ++++++++++++++- tests/test_check_issue_links.py | 158 ++++++++++++++++++++++++++++++-- 2 files changed, 221 insertions(+), 10 deletions(-) diff --git a/scripts/check-issue-links.py b/scripts/check-issue-links.py index a0f2f908..463f4b64 100644 --- a/scripts/check-issue-links.py +++ b/scripts/check-issue-links.py @@ -1023,10 +1023,21 @@ def _paged_json(args: list[str]) -> list[dict]: `.[]` just as often as it is given it, and a list of lists would then reach the caller as rows that are not issue rows. - A payload that parses to neither is not "no rows": it raises, and `main` reports - it unmeasurable rather than as an empty queue. + A payload that parses to neither is not "no rows": a blank answer raises here, and a + line that is not JSON raises out of `json.loads`, so `main` reports both unmeasurable + rather than as an empty queue. Measured 2026-10-23 (issue #2500): the fallback below + used to return `[]` for a blank answer, so a run that read no subject at all printed + the `OK:` sentence — the paragraph above was true of the intent and false of the + code, and the code is what runs. What stays legitimate is a **real** `[]`: an issue + with no comments and a timeline with no events both answer that way, and neither is + unmeasurable. """ out = _gh([*args, "--paginate"]) + if not out.strip(): + raise RuntimeError( + f"gh printed nothing for {' '.join(args)}: an empty answer is not an empty " + f"queue" + ) try: payload: object = json.loads(out) except json.JSONDecodeError: @@ -1073,6 +1084,31 @@ def load_queue(repo: str, state: str = "open", since: str | None = None) -> Queu return queue +def open_queue_total(repo: str) -> int: + """How many open subjects `repo` reports, from an endpoint other than the queue. + + The second reading a zero-row queue needs (issue #2500). The queue query and this + count are two independent answers to "how many open subjects are there", so a queue + that reads nothing while the repository reports a non-zero count is a reading that + contradicts itself — unmeasurable, never an empty queue. + + `open_issues_count` is the number of open **issues and** open PRs, which is exactly + the subject set `load_queue` returns, so the two numbers are comparable rather than + merely both numbers. + + `--jq` rather than parsing the payload, for `issue_state`'s reason: the caller gets + one number and no shape. A count that is not a number — including the blank answer + the queue reading just refused to accept — is unmeasurable, never zero. + """ + out = _gh(["api", f"repos/{repo}", "--jq", ".open_issues_count"]).strip() + try: + return int(out) + except ValueError: + raise RuntimeError( + f"the repository's own open count is not a number: {out!r}" + ) from None + + def issue_state(repo: str, number: int) -> str: """One number's own `state` — `"open"`, `"closed"`, or `""` when it cannot be read. @@ -1625,6 +1661,26 @@ def main(argv: list[str] | None = None) -> int: print(f"cannot determine the issue/PR links: {exc}", file=sys.stderr) return 2 + if not queue.issues and not queue.prs: + # A zero-subject queue is a reading, not a verdict (issue #2500): the queue + # answered nothing, so ask a second endpoint before concluding there is nothing + # open. Reached only when the queue is empty, so a normal run pays no extra call, + # and a genuinely empty repository comes back with the same zero from the other + # endpoint — which is what separates "nothing to link" from "nothing was read". + try: + reported = open_queue_total(args.repo) + except RuntimeError as exc: + print(f"cannot determine the issue/PR links: {exc}", file=sys.stderr) + return 2 + if reported: + print( + f"cannot determine the issue/PR links: the queue read no subject, but " + f"{args.repo} reports {reported} open issue(s)/PR(s) - an unread queue " + f"is not an empty one", + file=sys.stderr, + ) + return 2 + rows = judge_issues(queue.issues, refs_by_issue, stated_by_pr) + judge_prs( queue.prs, queue.issue_numbers, stated_by_pr ) @@ -1679,7 +1735,18 @@ def main(argv: list[str] | None = None) -> int: ordered = sorted(rows, key=lambda r: (r.kind != "issue", r.number)) for row in ordered: print(render(row)) - if not bad: + if not rows: + # Nothing to judge is not "everything is linked". The `OK:` sentence makes + # three claims about open subjects, and with none open it would make them + # about nothing — the defect issue #2500 names. Both endpoints agreeing on + # zero is a real measurement, so this exits 0, but the sentence says what was + # measured: no link was read, so none was found complete. + print( + "\nno subject to judge: the queue is empty and the repository's own " + "count agrees (0 open), so this run read no link at all - it did not " + "find them all linked" + ) + elif not bad: clauses = [ "every open issue has exactly one open PR declaring it", "each declaration is named back in the issue", diff --git a/tests/test_check_issue_links.py b/tests/test_check_issue_links.py index 291bba85..bee610a5 100644 --- a/tests/test_check_issue_links.py +++ b/tests/test_check_issue_links.py @@ -142,12 +142,14 @@ def _merged(number: int, *, body: str = "", merged_at: str = "2026-09-25T00:00:0 class FakeGh: """`_gh` replaced by a routing table; every call recorded. - Three payloads are served, all as the JSON *text* `gh` would print, because the tool - parses its own output: the open queue (one `/issues` call), a per-subject timeline, - and a per-issue comment list. An unexpected query is an assertion failure rather than - an empty answer, so a typo in a path cannot read as "no links" — and a comments call - for an issue that is not in the queue is such a typo, since no claiming issue can be - outside it. + Four payloads are served, all as the JSON *text* `gh` would print, because the tool + parses its own output: the open queue (one `/issues` call), a per-subject timeline, a + per-issue comment list, and — only when a test passes `open_issues_count` — the + repository's own count, which is the second reading a zero-row queue is cross-checked + against (issue #2500). An unexpected query is an assertion failure rather than an + empty answer, so a typo in a path cannot read as "no links", and a `repos/` + query with no `open_issues_count` behind it is exactly such an unexpected query — + which is what pins "the cross-check is spent only on an empty queue". The shape served is the one measured on this machine (2026-09-26): with no `--jq`, `gh api --paginate` prints **one merged array on one line**. The other measured @@ -160,20 +162,29 @@ def __init__( prs: list[dict], timelines: dict[int, list[dict]], comments: dict[int, list[dict]] | None = None, + open_issues_count: str | int | None = None, ): self.issues = issues self.prs = prs self.timelines = timelines self.comments = comments or {} + self.open_issues_count = open_issues_count self.calls: list[list[str]] = [] def __call__(self, args: list[str]) -> str: self.calls.append(list(args)) assert args, "gh was called with no arguments" assert args[0] == "api", f"unexpected gh call: {args}" - assert args[-1] == "--paginate", args target = next((a for a in args if a.startswith("repos/")), None) assert target, args + if target == f"repos/{REPO}": + assert self.open_issues_count is not None, ( + "the repository's own count was read with " + f"{len(self.issues) + len(self.prs)} subject(s) in the queue: {args}" + ) + assert args[-1] == ".open_issues_count", args + return str(self.open_issues_count) + assert args[-1] == "--paginate", args if "/issues?" in target: return json.dumps([*self.issues, *self.prs]) if "/comments?" in target: @@ -1377,6 +1388,139 @@ def routed(args: list[str]) -> str: assert "OK" not in captured.out +def test_a_blank_queue_answer_is_unmeasurable_not_an_empty_queue( + mod, monkeypatch, capsys +) -> None: + """A successful `gh` call that printed nothing is not a repository with nothing open. + + Issue #2500: the payload fallback read a blank answer as `[]`, so a run that had read + no subject went on to print the `OK:` sentence — the strongest claim the tool makes, + made about nothing. The reading must now be exit 2 with no verdict. + + The fake answers the queue call and **refuses every other**, which is the second half + of the defect: a blank queue does not get to ask the repository what it thinks either, + because the cross-check presumes a queue that parsed. Two branches, not one. + """ + + def blank(args: list[str]) -> str: + assert "/issues?" in args[1], ( + f"a queue that answered nothing is not a question for another endpoint: {args}" + ) + return "" + + monkeypatch.setattr(mod, "_gh", blank) + + rc = mod.main([]) + captured = capsys.readouterr() + + assert rc == 2 + assert "printed nothing" in captured.err + assert "OK" not in captured.out + + +def test_a_blank_timeline_answer_is_unmeasurable_too(mod, monkeypatch, capsys) -> None: + """The blank-payload branch lives in `_paged_json`, so the timeline is covered as well. + + A timeline that answered nothing would otherwise read as "no reference" — the same + confident wrong verdict (`unlinked`) the unparseable-timeline test above refuses, one + layer earlier in the read: there the lines were not JSON, here there were no lines. + """ + fake = FakeGh([_issue(10, "the problem")], [_pr(20, "the fix", body="Closes #10.")], {}) + real = fake + + def routed(args: list[str]) -> str: + target = next(a for a in args if a.startswith("repos/")) + if "/issues?" in target: + return real(args) + return "" + + monkeypatch.setattr(mod, "_gh", routed) + + rc = mod.main([]) + captured = capsys.readouterr() + + assert rc == 2 + assert "printed nothing" in captured.err + assert "OK" not in captured.out + + +def test_an_empty_queue_is_cross_checked_against_the_repositorys_own_count( + mod, monkeypatch, capsys +) -> None: + """A queue that read nothing is contradicted by a repository reporting subjects. + + The count comes from a different endpoint (`repos/`'s `open_issues_count`), so + the two readings cannot be one answer looked at twice — which is what makes a zero + queue a measurement instead of a silence. Both numbers appear in the reason, because + the reader has to see which two disagree. + """ + fake = FakeGh([], [], {}, open_issues_count=3) + _install(mod, monkeypatch, fake) + + rc = mod.main([]) + captured = capsys.readouterr() + + assert rc == 2 + assert "read no subject" in captured.err + assert "3 open" in captured.err + assert "OK" not in captured.out + + +def test_a_genuinely_empty_repository_says_it_measured_nothing( + mod, monkeypatch, capsys +) -> None: + """Both readings agreeing on zero is a measurement — and still not an `OK`. + + Exit 0, because nothing is wrong with an empty repository; but the sentence must not + claim that links were checked, because no subject was read. `OK:` is the token a + caller greps for, so its absence is the assertion. + """ + fake = FakeGh([], [], {}, open_issues_count=0) + _install(mod, monkeypatch, fake) + + rc, out = _run(mod, capsys) + + assert rc == 0, out + assert "OK" not in out + assert "no subject to judge" in out + assert "did not find them all linked" in out + + +def test_a_count_that_is_not_a_number_is_unmeasurable(mod, monkeypatch, capsys) -> None: + """The cross-check refuses to read a non-number as zero. + + The count endpoint going quiet is exactly the silence the queue reading just refused, + so it cannot become the reassurance here — issue #2500's own argument, one endpoint + over. The reading is unmeasurable, and no verdict is printed. + """ + fake = FakeGh([], [], {}, open_issues_count="not a number") + _install(mod, monkeypatch, fake) + + rc = mod.main([]) + captured = capsys.readouterr() + + assert rc == 2 + assert "is not a number" in captured.err + assert "OK" not in captured.out + + +def test_a_queue_with_subjects_pays_no_cross_check_call(mod, monkeypatch, capsys) -> None: + """The second reading is spent only when the first one read nothing. + + `FakeGh` refuses the repository query unless a test asked for it, so this is the + control arm for the cross-check: a linked pair runs as before, with no extra call and + no change to the verdict. + """ + fake = _linked_pair() + _install(mod, monkeypatch, fake) + + rc, out = _run(mod, capsys) + + assert rc == 0, out + assert "OK:" in out + assert not [c for c in fake.calls if c[1] == f"repos/{REPO}"], fake.calls + + # --------------------------------------------------------------------------- # # The report's own shape # --------------------------------------------------------------------------- #