diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfa6a3..33d96ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,19 @@ worse than no alert, because one day it carries a security fix. count as shipped and a break in it would fail the build as a live outage — on a version nobody is running yet. +- **An unchanged fingerprint froze the bug report's body, not just its comments.** Two + questions were sharing one answer. *Have the findings changed?* gates **comments** — + they notify, and a daily "still broken, same as yesterday" is what teaches everyone + to ignore the one that finally matters. *Is the body still true?* gates the **body** — + and editing an issue body notifies nobody on either forge, so keeping it honest is + free. Conflated, the report could never be corrected while the findings held steady, + and the fingerprint deliberately ignores everything that moves on its own — healthy + rows, the hardware-verified column, point releases, and how a row is labelled. The + `master` → `27-dev` relabel above would have reached the README and never the issue + anybody actually opens. The body is now rewritten whenever it is out of date (after + normalising line endings, so a forge round-tripping `\r\n` does not cause a rewrite + every run) and comments remain strictly a changelog of real changes. + - **The compatibility bot filed a new duplicate bug report on every Gitea run.** `find_issue()` skipped pull requests by testing for the *presence* of the `pull_request` key. GitHub omits that key on a plain issue; Gitea sends it as diff --git a/tests/test_compat.py b/tests/test_compat.py index c4617dc..596caa1 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -15,6 +15,7 @@ dangerous thing this file can say -- it means "TrueNAS does this now, retire the module" -- and it rests on nothing more than a substring match. """ +import json import os import sys @@ -725,3 +726,83 @@ class TestMasterIsNotTheNextRelease: # ...and the ordinary rows are untouched. assert "| 25.10.4 |" in md assert "| 26.0.0-BETA.3 _(unreleased)_ |" in md + + +class TestTheBodyIsTruthAndCommentsAreTheChangelog: + """An unchanged fingerprint used to freeze the BODY, not just silence the comments. + + Two different questions were sharing one answer. "Have the findings changed?" gates + COMMENTS -- they notify, and a daily "still broken, same as yesterday" is what + teaches people to ignore the one that finally matters. But "is the body still + true?" gates the BODY, and editing a body notifies nobody, so keeping it honest + costs nothing. + + Conflated, an unchanged fingerprint meant the report could never be corrected -- + and the fingerprint deliberately ignores everything that moves on its own, which + includes how a row is LABELLED. Relabelling master `27-dev` would have reached the + README and never the issue anybody opens. + """ + + ROWS = [{"ref": "master", "unreleased": True, + "modules": check_files(with_(**{ + "plugins/cloud_backup/restic.py": + "class ResticConfig:\n cmd: list\n\n" + "def get_restic_config(entry, credentials):\n pass\n", + }))}] + + def _run(self, monkeypatch, tmp_path, existing_body): + calls = [] + + def fake(url, token, method="GET", data=None): + calls.append((method, url, data)) + if url.endswith("/issues?state=all&per_page=100&limit=100"): + return [{"number": 1, "title": compat_publish.TITLE, + "state": "open", "body": existing_body, + "pull_request": None}] + return {"number": 1} + + monkeypatch.setattr(compat_publish, "_call", fake) + matrix = tmp_path / "m.json" + matrix.write_text(json.dumps(self.ROWS)) + compat_publish.main([ + "prog", "--api", "https://forge/api", "--token", "t", + "--matrix", str(matrix)]) + return calls + + def _writes(self, calls): + patched = [c for c in calls if c[0] == "PATCH"] + commented = [c for c in calls if c[0] == "POST" and c[1].endswith("/comments")] + return patched, commented + + def test_identical_body_and_findings_touches_nothing(self, monkeypatch, tmp_path): + body = compat.render_issue(self.ROWS) + patched, commented = self._writes(self._run(monkeypatch, tmp_path, body)) + assert not patched and not commented, "a quiet run must be completely silent" + + def test_a_relabel_refreshes_the_body_but_says_NOTHING(self, monkeypatch, tmp_path): + # Same findings (same fingerprint), different rendering -- the exact shape of + # the master -> 27-dev relabel. + stale = compat.render_issue(self.ROWS).replace("27-dev", "unreleased") + assert compat.extract_fingerprint(stale) == compat.fingerprint(self.ROWS) + + patched, commented = self._writes(self._run(monkeypatch, tmp_path, stale)) + assert patched, "the body was left stale, so the issue keeps telling lies" + assert "27-dev" in patched[0][2]["body"] + assert not commented, ( + "a rendering change is not news -- commenting on it is how the bot gets " + "muted before the next real finding" + ) + + def test_a_REAL_findings_change_still_comments(self, monkeypatch, tmp_path): + # ...and the fix must not have made it mute. + stale = compat.render_issue(self.ROWS).replace( + compat.fingerprint(self.ROWS), "0" * 16) + patched, commented = self._writes(self._run(monkeypatch, tmp_path, stale)) + assert patched and commented, "a genuine change must still notify" + + def test_a_body_differing_only_by_CRLF_is_not_rewritten(self, monkeypatch, tmp_path): + # Forges round-trip line endings. Without normalising, every run would rewrite + # the body -- silent, but it churns updated_at and looks freshly touched daily. + body = compat.render_issue(self.ROWS).replace("\n", "\r\n") + patched, _ = self._writes(self._run(monkeypatch, tmp_path, body)) + assert not patched diff --git a/tools/compat_publish.py b/tools/compat_publish.py index adfec1f..4700f58 100644 --- a/tools/compat_publish.py +++ b/tools/compat_publish.py @@ -62,6 +62,19 @@ def _call(url, token, method="GET", data=None): return json.load(r) if r.length != 0 else {} +def _same(a, b): + """Is the issue body already what we would write? + + Compared after normalising line endings and trailing space: forges are free to + round-trip `\r\n`, and a body that only "differs" by that would be rewritten on + every single run -- a silent edit, but a pointless one that churns `updated_at` + and makes the issue look freshly touched every morning. + """ + def norm(s): + return "\n".join(line.rstrip() for line in (s or "").replace("\r\n", "\n").split("\n")).strip() + return norm(a) == norm(b) + + def find_issue(api, token, title): """The LOWEST-numbered issue with this title, open or closed. @@ -124,11 +137,24 @@ def main(argv): have = extract_fingerprint(issue.get("body") or "") n = issue["number"] - # ── the findings are UNCHANGED: say nothing ────────────────────────────── + # ── two different questions, and they were being answered with one answer ──── # - # This is the whole point. A daily "still broken, same as yesterday" comment is - # what taught everyone to ignore the last one. - if have == want and issue["state"] == "open": + # * IS THE BODY STILL TRUE? -> if not, rewrite it. Editing an issue body + # notifies NOBODY on either forge, so keeping it honest is free. + # * HAVE THE FINDINGS CHANGED? -> only then comment. Comments DO notify, and a + # daily "still broken, same as yesterday" is what teaches everyone to ignore + # the one that finally matters. + # + # Conflating them meant an unchanged FINGERPRINT froze the BODY. The fingerprint + # deliberately ignores everything that moves on its own -- healthy rows, the + # hardware-verified column, point releases, how a row is LABELLED -- so none of + # that could ever reach the report. Relabelling master `27-dev` (it is not the + # next release; a red row there was reading as "the version you are about to + # install is broken") would have shipped to the README and never to the issue + # anybody actually opens. + body_is_current = _same(issue.get("body"), body) + + if have == want and issue["state"] == "open" and body_is_current: print(f"#{n} is already current ({want}) — staying quiet") return 0 @@ -146,8 +172,12 @@ def main(argv): ) _call(f"{args.api}/issues/{n}/comments", args.token, "POST", {"body": note}) print(f"updated #{n}: {have} -> {want}") - else: + elif issue["state"] != "open": print(f"reopened #{n}") + else: + # Same findings, new rendering. Silent by design: nothing has changed that + # anybody needs waking up for, but the report should not be telling lies. + print(f"#{n}: findings unchanged ({want}); body refreshed silently") return 0