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
73 changes: 70 additions & 3 deletions scripts/check-issue-links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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",
Expand Down
158 changes: 151 additions & 7 deletions tests/test_check_issue_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<repo>`
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
Expand All @@ -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:
Expand Down Expand Up @@ -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/<repo>`'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
# --------------------------------------------------------------------------- #
Expand Down
Loading