From 06dd646aa6b65a67706bc551041e29d211b002cc Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:21:02 -0500 Subject: [PATCH] fix: the watchers cannot open an issue in this repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both watchers report by opening an issue. This repository has issues disabled, so both crashed with a traceback on their first real run: the 'Glyndor/.github' repository has disabled issues subprocess.CalledProcessError: ... 'gh', 'issue', 'list' ... exit status 1 The same code in podup worked and opened podup#1563, so the logic was right and only the channel was unavailable here. The findings were real and were lost. This was avoidable. `Glyndor/apt#121` opens with "Filed here because Glyndor/.github has issues disabled" — the fact was in the issue the work came from. It survived local runs because the stub `gh` modelled the world I assumed rather than the one that exists, which is the same failure as testing a heredoc outside the YAML that processes it. When `gh` reports specifically that issues are disabled, the report now goes to `$GITHUB_STEP_SUMMARY` and the run exits non-zero, so the failure is the notification — GitHub notifies on a failed scheduled run on the default branch. Any other `gh` failure still fails loudly and does NOT take that path: an expired token and a closed issue tracker are different problems, and collapsing them would hide a real break behind a benign-looking message. Exiting non-zero here does not contradict tooling-audit's never-fail rule, and the docstring now says why rather than leaving a reader to reconcile an exit 1 with a comment promising exit 0. That rule exists so a third party's disclosure timing cannot block pull requests; these workflows are scheduled only and run on no pull request, so failing one blocks nothing. A silent success would read as "nothing found", which is the worst outcome when the report cannot be delivered. Three tests per script, all offline: the fallback writes the summary and fails, an auth error fails WITHOUT writing the summary, and no findings with issues disabled still exits 0. Verified by running both scripts against a stub that reproduces each case. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- scripts/pin-watch.py | 119 +++++++++++++++++++++-- scripts/tooling-audit.py | 157 +++++++++++++++++++++++++----- tests/test_pin_watch.py | 156 ++++++++++++++++++++++++++++++ tests/test_tooling_audit.py | 187 ++++++++++++++++++++++++++++++++++++ 4 files changed, 585 insertions(+), 34 deletions(-) diff --git a/scripts/pin-watch.py b/scripts/pin-watch.py index e5c1781..6cf3c56 100644 --- a/scripts/pin-watch.py +++ b/scripts/pin-watch.py @@ -36,6 +36,7 @@ from __future__ import annotations import json +import os import re import subprocess import sys @@ -449,9 +450,78 @@ def render_body( return "\n".join(lines) + "\n" +def _run_gh(cmd: list[str]) -> subprocess.CompletedProcess: + """Run a gh command with stdout+stderr captured. Never raises. + + Callers inspect the return code and the combined output themselves, + so the "has disabled issues" fallback can match on the stderr + before propagating any other error. + """ + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +def _gh_says_issues_disabled(proc: subprocess.CompletedProcess) -> bool: + """True iff gh's output indicates the repository has disabled issues. + + Matches on the literal phrase `has disabled issues` rather than on + exit code, because `gh` reuses a small set of non-zero exit codes + (4 for auth, 1 for "not found") across many distinct failure modes — + collapsing them all into "issues disabled" would hide a real break. + The phrase is what `gh issue list` and `gh issue create` print when + the repository has deliberately closed the issue channel. + """ + if proc.returncode == 0: + return False + combined = (proc.stdout or "") + (proc.stderr or "") + return "has disabled issues" in combined + + +def _write_to_step_summary(body: str) -> None: + """Append body to $GITHUB_STEP_SUMMARY when that env var is set. + + The step summary file is the run's summary page on GitHub Actions; + when the repository has no issue tracker, the summary is the only + place the report can land. Always prints the body to stdout so it + is in the local log when the script is run outside CI. + """ + print(body) + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + try: + with open(summary_path, "a", encoding="utf-8") as f: + f.write(body) + if not body.endswith("\n"): + f.write("\n") + except OSError as e: + print( + f"::warning::could not write to GITHUB_STEP_SUMMARY ({summary_path}): {e}", + file=sys.stderr, + ) + + +class _IssuesDisabledError(Exception): + """Internal sentinel raised by `open_issue` when gh reports issues + are disabled on this repository. + + The body has already been written to $GITHUB_STEP_SUMMARY by the + time this is raised. `main()` catches it to exit 1 with a clear + message — the run failure is the notification when the issue + channel is closed. + """ + + def issue_already_open(title: str) -> bool: - """Return True if an open issue with this exact title exists.""" - out = subprocess.check_output( + """Return True if an open issue with this exact title exists. + + On a `gh` failure that says the repository has disabled issues, + returns False so the caller proceeds to `open_issue` (which fails + the same way and triggers the step-summary fallback). Any OTHER + `gh` failure is propagated — a token without permission or a + network error is not the same as a repository that deliberately + has no issue tracker, and collapsing them would hide a real break. + """ + proc = _run_gh( [ "gh", "issue", @@ -464,19 +534,38 @@ def issue_already_open(title: str) -> bool: "title", "--jq", ".[] | .title", - ], - text=True, + ] ) - titles = [line.strip() for line in out.splitlines() if line.strip()] + if proc.returncode != 0: + if _gh_says_issues_disabled(proc): + return False + raise subprocess.CalledProcessError( + proc.returncode, proc.args, output=proc.stdout, stderr=proc.stderr + ) + titles = [line.strip() for line in proc.stdout.splitlines() if line.strip()] return title in titles def open_issue(title: str, body: str) -> str: + """Open the issue. If gh reports issues are disabled, write the + body to $GITHUB_STEP_SUMMARY and raise `_IssuesDisabledError` so + `main()` exits non-zero with a clear message — the run failure is + the notification when the issue channel is closed. Any OTHER gh + failure propagates as `subprocess.CalledProcessError`; the + step-summary fallback is for the disabled-issues case only. + """ cmd = ["gh", "issue", "create", "--title", title, "--body", body] for label in ISSUE_LABELS: cmd.extend(["--label", label]) - out = subprocess.check_output(cmd, text=True).strip() - return out + proc = _run_gh(cmd) + if proc.returncode != 0: + if _gh_says_issues_disabled(proc): + _write_to_step_summary(body) + raise _IssuesDisabledError(title) + raise subprocess.CalledProcessError( + proc.returncode, proc.args, output=proc.stdout, stderr=proc.stderr + ) + return proc.stdout.strip() # --- Main flow ----------------------------------------------------------- @@ -578,7 +667,21 @@ def main() -> int: print(f"Issue {ISSUE_TITLE!r} is already open — not creating a duplicate.") return 0 - url = open_issue(ISSUE_TITLE, body) + try: + url = open_issue(ISSUE_TITLE, body) + except _IssuesDisabledError: + # Findings exist, the issue channel is closed, and the body has + # already been written to $GITHUB_STEP_SUMMARY. Exiting non-zero + # is the notification — a silent success would read as "nothing + # found", which is the worst outcome when the report cannot be + # delivered through its normal channel. + print( + f"::error::{ISSUE_TITLE}: this repository has issues disabled; " + f"the report has been written to GITHUB_STEP_SUMMARY instead — " + f"the run failure is the notification.", + file=sys.stderr, + ) + return 1 print(f"Opened: {url}") return 0 diff --git a/scripts/tooling-audit.py b/scripts/tooling-audit.py index c983446..6facf80 100644 --- a/scripts/tooling-audit.py +++ b/scripts/tooling-audit.py @@ -61,12 +61,23 @@ script itself failed — a download that did not come back, an unparseable `cargo audit` output, a pin it could not read, a tool it could not audit for a reason that was not "no bundled Cargo.lock". + +The advisory-not-blocking-PR rule does NOT extend to a repository +that has deliberately closed its issue tracker. When findings exist +AND `gh` reports "the '... repository' has disabled issues", the +report is written to `$GITHUB_STEP_SUMMARY` instead and the run +exits non-zero. These workflows do not run on pull requests (they +are scheduled only), so failing one blocks nothing, and GitHub +notifies on a failed scheduled run on the default branch. A silent +success would read as "nothing found", which is the worst outcome +when the report cannot be delivered through its normal channel. """ from __future__ import annotations import importlib.util import json +import os import subprocess import sys import tarfile @@ -433,41 +444,120 @@ def render_body(results: list[AuditResult]) -> str: def issue_already_open(title: str) -> bool: """Return True if an open issue with this exact title exists. - If `gh issue list` fails (network blip, repo has issues disabled, - the token is missing the right scope), we cannot prove the issue - is open — assume it is NOT, so the script tries to open it. A - subsequent `gh issue create` will fail loudly in that case and - main() will exit non-zero, surfacing the real problem. + On a `gh` failure that says the repository has disabled issues, + returns False so the caller proceeds to `open_issue` (which fails + the same way and triggers the step-summary fallback). For any + OTHER `gh` failure, the function re-raises — a token without + permission or a network error is not the same as a repository + that deliberately has no issue tracker, and collapsing them + would hide a real break. """ - try: - out = subprocess.check_output( - [ - "gh", - "issue", - "list", - "--state", - "open", - "--search", - f"in:title {title}", - "--json", - "title", - "--jq", - ".[] | .title", - ], - text=True, + proc = _run_gh( + [ + "gh", + "issue", + "list", + "--state", + "open", + "--search", + f"in:title {title}", + "--json", + "title", + "--jq", + ".[] | .title", + ] + ) + if proc.returncode != 0: + if _gh_says_issues_disabled(proc): + return False + raise subprocess.CalledProcessError( + proc.returncode, proc.args, output=proc.stdout, stderr=proc.stderr ) - except subprocess.CalledProcessError: - return False - titles = [line.strip() for line in out.splitlines() if line.strip()] + titles = [line.strip() for line in proc.stdout.splitlines() if line.strip()] return title in titles def open_issue(title: str, body: str) -> str: + """Open the issue. If gh reports issues are disabled, write the + body to $GITHUB_STEP_SUMMARY and raise `_IssuesDisabledError` so + `main()` exits non-zero with a clear message — the run failure + is the notification when the issue channel is closed. Any OTHER + gh failure propagates as `subprocess.CalledProcessError`; the + step-summary fallback is for the disabled-issues case only. + """ cmd = ["gh", "issue", "create", "--title", title, "--body", body] for label in ISSUE_LABELS: cmd.extend(["--label", label]) - out = subprocess.check_output(cmd, text=True).strip() - return out + proc = _run_gh(cmd) + if proc.returncode != 0: + if _gh_says_issues_disabled(proc): + _write_to_step_summary(body) + raise _IssuesDisabledError(title) + raise subprocess.CalledProcessError( + proc.returncode, proc.args, output=proc.stdout, stderr=proc.stderr + ) + return proc.stdout.strip() + + +def _run_gh(cmd: list[str]) -> subprocess.CompletedProcess: + """Run a gh command with stdout+stderr captured. Never raises. + + Callers inspect the return code and the combined output themselves, + so the "has disabled issues" fallback can match on the stderr + before propagating any other error. + """ + return subprocess.run(cmd, capture_output=True, text=True, check=False) + + +def _gh_says_issues_disabled(proc: subprocess.CompletedProcess) -> bool: + """True iff gh's output indicates the repository has disabled issues. + + Matches on the literal phrase `has disabled issues` rather than on + exit code, because `gh` reuses a small set of non-zero exit codes + (4 for auth, 1 for "not found") across many distinct failure modes — + collapsing them all into "issues disabled" would hide a real break. + The phrase is what `gh issue list` and `gh issue create` print when + the repository has deliberately closed the issue channel. + """ + if proc.returncode == 0: + return False + combined = (proc.stdout or "") + (proc.stderr or "") + return "has disabled issues" in combined + + +def _write_to_step_summary(body: str) -> None: + """Append body to $GITHUB_STEP_SUMMARY when that env var is set. + + The step summary file is the run's summary page on GitHub Actions; + when the repository has no issue tracker, the summary is the only + place the report can land. Always prints the body to stdout so it + is in the local log when the script is run outside CI. + """ + print(body) + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + try: + with open(summary_path, "a", encoding="utf-8") as f: + f.write(body) + if not body.endswith("\n"): + f.write("\n") + except OSError as e: + print( + f"::warning::could not write to GITHUB_STEP_SUMMARY ({summary_path}): {e}", + file=sys.stderr, + ) + + +class _IssuesDisabledError(Exception): + """Internal sentinel raised by `open_issue` when gh reports issues + are disabled on this repository. + + The body has already been written to $GITHUB_STEP_SUMMARY by the + time this is raised. `main()` catches it to exit 1 with a clear + message — the run failure is the notification when the issue + channel is closed. + """ # --- Main flow ----------------------------------------------------------- @@ -537,6 +627,21 @@ def main() -> int: else: try: url = open_issue(ISSUE_TITLE, body) + except _IssuesDisabledError: + # Findings exist, the issue channel is closed, and the + # body has already been written to $GITHUB_STEP_SUMMARY. + # Exiting non-zero is the notification — a silent + # success would read as "nothing found", which is the + # worst outcome when the report cannot be delivered + # through its normal channel. + print( + f"::error::{ISSUE_TITLE}: this repository has issues " + f"disabled; the audit has been written to " + f"GITHUB_STEP_SUMMARY instead — the run failure is " + f"the notification.", + file=sys.stderr, + ) + return 1 except subprocess.CalledProcessError as e: # The audit data is already on stdout; the operator # can see what was found. But the issue was not diff --git a/tests/test_pin_watch.py b/tests/test_pin_watch.py index 4c197ae..64a0313 100644 --- a/tests/test_pin_watch.py +++ b/tests/test_pin_watch.py @@ -33,6 +33,7 @@ from __future__ import annotations import importlib.util +import subprocess from pathlib import Path import pytest @@ -370,3 +371,158 @@ def test_read_pins_fails_when_pin_cannot_be_read(monkeypatch, tmp_path, capsys) assert exc_info.value.code == 1 assert "::error" in captured.err assert "missing-input" in captured.err + + +# --- TASK 2: issues-disabled fallback ---------------------------------- +# +# The watcher reports by opening an issue; this repository has issues +# disabled. A crash with a traceback loses the findings. The fallback +# writes the rendered body to $GITHUB_STEP_SUMMARY and exits non-zero, +# so the run failure IS the notification. The three tests below pin the +# three branches of that decision: +# +# - findings + gh says disabled -> summary written, exit non-zero +# - findings + gh fails for OTHER reason -> script fails loudly, NO summary +# - no findings at all -> exit 0, no summary written +# +# The IO seam stubbed is `_run_gh`, the helper that wraps +# `subprocess.run` for every gh call — that is the seam the new +# detection logic reads from, so a test that replaced `issue_already_open` +# and `open_issue` directly would not exercise the detection path at all. + + +_DISABLED_ISSUES_STDERR = "the 'Glyndor/.github' repository has disabled issues" + + +def _disabled_issues_gh(cmd: list[str]) -> subprocess.CompletedProcess: + """Pretend every gh call is rejected because the repo has no issue tracker.""" + return subprocess.CompletedProcess( + args=cmd, + returncode=1, + stdout="", + stderr=_DISABLED_ISSUES_STDERR, + ) + + +def _auth_error_gh(cmd: list[str]) -> subprocess.CompletedProcess: + """Pretend gh fails with an auth error — different reason, same exit code.""" + return subprocess.CompletedProcess( + args=cmd, + returncode=4, + stdout="", + stderr=( + "gh: authentication required (HTTP 401)\n" + "To use GitHub CLI in a GitHub Actions workflow, set " + "the GH_TOKEN environment variable." + ), + ) + + +def test_findings_and_issues_disabled_writes_step_summary_and_exits_nonzero( + monkeypatch, tmp_path, capsys +) -> None: + """With drift and a gh that says issues are disabled, the report lands on + $GITHUB_STEP_SUMMARY and the script exits non-zero. + + Catches: a regression that crashes the run with a traceback when + this repository's issue tracker is closed — the failure mode that + made the report invisible in the first place. The exit code + matters: a silent success would read as "nothing found", which is + the worst outcome when the report cannot be delivered through its + normal channel. + """ + pin_watch = _load_pin_watch() + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + + monkeypatch.setattr( + pin_watch, "read_pins", lambda: {"pytest": ("python-ci.yml", "8.0.0")} + ) + monkeypatch.setattr(pin_watch, "fetch_upstream", lambda name: "8.1.0") + monkeypatch.setattr(pin_watch, "_run_gh", _disabled_issues_gh) + + exit_code = pin_watch.main() + captured = capsys.readouterr() + + assert exit_code != 0, ( + f"Disabled-issues fallback must exit non-zero; got {exit_code}" + ) + # The body is the same markdown that would have been the issue body: + # pin name, pinned value, and upstream value all land in the summary. + assert summary.exists(), ( + "GITHUB_STEP_SUMMARY file must exist after a disabled-issues fallback" + ) + body = summary.read_text() + assert "pytest" in body + assert "8.0.0" in body + assert "8.1.0" in body + # And the operator gets a clear message on stderr that names the cause. + assert "::error" in captured.err + assert "issues disabled" in captured.err + + +def test_findings_and_other_gh_failure_does_not_trigger_fallback( + monkeypatch, tmp_path, capsys +) -> None: + """With drift and a gh that fails for a NON-disabled reason, the script + fails loudly and the disabled-issues fallback does NOT run. + + Catches: a regression that collapses every gh failure into the + disabled-issues fallback — an auth error, a network blip and an + unknown error are not the same thing as a repository that has + deliberately closed its issue tracker, and collapsing them would + hide a real break. The summary file must stay empty. + """ + pin_watch = _load_pin_watch() + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + + monkeypatch.setattr( + pin_watch, "read_pins", lambda: {"pytest": ("python-ci.yml", "8.0.0")} + ) + monkeypatch.setattr(pin_watch, "fetch_upstream", lambda name: "8.1.0") + monkeypatch.setattr(pin_watch, "_run_gh", _auth_error_gh) + + with pytest.raises(subprocess.CalledProcessError): + pin_watch.main() + capsys.readouterr() + + # The disabled-issues fallback did NOT run: the summary file was + # never written (and even if it had been touched, the body would + # not be in it — the assertion is on absence, not on content). + assert not summary.exists() or summary.read_text() == "", ( + "Disabled-issues fallback must NOT run for a non-disabled gh failure; " + f"summary file contains: {summary.read_text()!r}" + ) + + +def test_no_findings_and_issues_disabled_exits_zero_no_summary( + monkeypatch, tmp_path, capsys +) -> None: + """With no drift and a gh that says issues are disabled, the script + exits 0 and writes nothing to the step summary. + + Catches: a regression that triggers the disabled-issues fallback + even when there are no findings to report — "nothing to report" is + not a failure, and writing an empty body to the summary is noise. + The gh stub is installed in case the script ever short-circuits + differently in the future; today main() returns 0 before reaching gh. + """ + pin_watch = _load_pin_watch() + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + + monkeypatch.setattr( + pin_watch, "read_pins", lambda: {"pytest": ("python-ci.yml", "8.0.0")} + ) + monkeypatch.setattr(pin_watch, "fetch_upstream", lambda name: "8.0.0") + monkeypatch.setattr(pin_watch, "_run_gh", _disabled_issues_gh) + + exit_code = pin_watch.main() + capsys.readouterr() + + assert exit_code == 0, f"With no findings the script must exit 0; got {exit_code}" + assert not summary.exists() or summary.read_text() == "", ( + "With no findings the summary file must stay empty; " + f"got: {summary.read_text()!r}" + ) diff --git a/tests/test_tooling_audit.py b/tests/test_tooling_audit.py index 6d247fa..50def55 100644 --- a/tests/test_tooling_audit.py +++ b/tests/test_tooling_audit.py @@ -32,8 +32,11 @@ from __future__ import annotations import importlib.util +import subprocess from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT = REPO_ROOT / "scripts" / "tooling-audit.py" @@ -345,3 +348,187 @@ def test_no_duplicate_issue_when_title_already_open(monkeypatch, capsys) -> None assert opened == [], ( f"With issue already open, open_issue must NOT be called; got: {opened!r}" ) + + +# --- TASK 3: issues-disabled fallback --------------------------------- +# +# The auditor reports by opening an issue; this repository has issues +# disabled. A crash with a traceback loses the findings. The fallback +# writes the rendered body to $GITHUB_STEP_SUMMARY and exits non-zero, +# so the run failure IS the notification. The three tests below pin the +# three branches of that decision: +# +# - findings + gh says disabled -> summary written, exit non-zero +# - findings + gh fails for OTHER reason -> script fails loudly, NO summary +# - no findings at all -> exit 0, no summary written +# +# The IO seam stubbed is `_run_gh`, the helper that wraps +# `subprocess.run` for every gh call — the same seam the new detection +# logic reads from. Replacing `issue_already_open` and `open_issue` +# directly would skip the detection path entirely. + + +_DISABLED_ISSUES_STDERR = "the 'Glyndor/.github' repository has disabled issues" + + +def _disabled_issues_gh(cmd: list[str]) -> subprocess.CompletedProcess: + """Pretend every gh call is rejected because the repo has no issue tracker.""" + return subprocess.CompletedProcess( + args=cmd, + returncode=1, + stdout="", + stderr=_DISABLED_ISSUES_STDERR, + ) + + +def _auth_error_gh(cmd: list[str]) -> subprocess.CompletedProcess: + """Pretend gh fails with an auth error — different reason, same exit code.""" + return subprocess.CompletedProcess( + args=cmd, + returncode=4, + stdout="", + stderr=( + "gh: authentication required (HTTP 401)\n" + "To use GitHub CLI in a GitHub Actions workflow, set " + "the GH_TOKEN environment variable." + ), + ) + + +def _stub_clean_audit(ta, monkeypatch) -> None: + """Wire up stubs for an audit that finds one advisory. + + The stub returns a single tool with a real vulnerability, so the + script's main() path renders a body and reaches `open_issue`. + """ + monkeypatch.setattr( + ta, + "read_pins", + lambda: {"cargo-audit": ("rust-audit.yml", "0.21.0")}, + ) + monkeypatch.setattr( + ta, + "audit_one", + lambda tool, version: _audit_result( + vulnerabilities=[ + { + "advisory": "RUSTSEC-2025-0001", + "package": "openssl", + "version": "0.1.0", + "cvss": None, + "patched": "0.2.0", + } + ] + ), + ) + + +def test_findings_and_issues_disabled_writes_step_summary_and_exits_nonzero( + monkeypatch, tmp_path, capsys +) -> None: + """With findings and a gh that says issues are disabled, the report lands + on $GITHUB_STEP_SUMMARY and the script exits non-zero. + + Catches: a regression that crashes the run with a traceback when + this repository's issue tracker is closed — the failure mode that + made the report invisible in the first place. The exit code + matters: a silent success would read as "nothing found", which is + the worst outcome when the report cannot be delivered through its + normal channel. + """ + ta = _load_tooling_audit() + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + + _stub_clean_audit(ta, monkeypatch) + monkeypatch.setattr(ta, "_run_gh", _disabled_issues_gh) + + exit_code = ta.main() + captured = capsys.readouterr() + + assert exit_code != 0, ( + f"Disabled-issues fallback must exit non-zero; got {exit_code}" + ) + # The body is the same markdown that would have been the issue body: + # the tool, advisory, package and pinned version all land in the + # summary so the maintainer can act on it. + assert summary.exists(), ( + "GITHUB_STEP_SUMMARY file must exist after a disabled-issues fallback" + ) + body = summary.read_text() + assert "cargo-audit" in body + assert "RUSTSEC-2025-0001" in body + assert "openssl" in body + # And the operator gets a clear message on stderr that names the cause. + assert "::error" in captured.err + assert "issues disabled" in captured.err + + +def test_findings_and_other_gh_failure_does_not_trigger_fallback( + monkeypatch, tmp_path, capsys +) -> None: + """With findings and a gh that fails for a NON-disabled reason, the script + fails loudly and the disabled-issues fallback does NOT run. + + Catches: a regression that collapses every gh failure into the + disabled-issues fallback — an auth error, a network blip and an + unknown error are not the same thing as a repository that has + deliberately closed its issue tracker, and collapsing them would + hide a real break. The summary file must stay empty. + """ + ta = _load_tooling_audit() + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + + _stub_clean_audit(ta, monkeypatch) + monkeypatch.setattr(ta, "_run_gh", _auth_error_gh) + + with pytest.raises(subprocess.CalledProcessError): + ta.main() + capsys.readouterr() + + # The disabled-issues fallback did NOT run: the summary file was + # never written (and even if it had been touched, the body would + # not be in it — the assertion is on absence, not on content). + assert not summary.exists() or summary.read_text() == "", ( + "Disabled-issues fallback must NOT run for a non-disabled gh failure; " + f"summary file contains: {summary.read_text()!r}" + ) + + +def test_no_findings_and_issues_disabled_exits_zero_no_summary( + monkeypatch, tmp_path, capsys +) -> None: + """With no advisories and a gh that says issues are disabled, the script + exits 0 and writes nothing to the step summary. + + Catches: a regression that triggers the disabled-issues fallback + even when there are no findings to report — "nothing to report" is + not a failure, and writing an empty body to the summary is noise. + The gh stub is installed in case the script ever short-circuits + differently in the future; today main() returns 0 before reaching gh. + """ + ta = _load_tooling_audit() + summary = tmp_path / "summary.md" + monkeypatch.setenv("GITHUB_STEP_SUMMARY", str(summary)) + + monkeypatch.setattr( + ta, + "read_pins", + lambda: {"cargo-audit": ("rust-audit.yml", "0.21.0")}, + ) + monkeypatch.setattr( + ta, + "audit_one", + lambda tool, version: _audit_result(), # clean result + ) + monkeypatch.setattr(ta, "_run_gh", _disabled_issues_gh) + + exit_code = ta.main() + capsys.readouterr() + + assert exit_code == 0, f"With no findings the script must exit 0; got {exit_code}" + assert not summary.exists() or summary.read_text() == "", ( + "With no findings the summary file must stay empty; " + f"got: {summary.read_text()!r}" + )