From 05e8103eb01c8fa98400ba776258ee41265965bf Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 9 Sep 2026 15:48:45 +0000 Subject: [PATCH 1/2] Treat an unresolvable cross-repo blocker as unknown, not as open forever Closes weblib-ci#11 in part: the reconciler could add Status/Blocked but never remove it when every blocker lived in another repo, and, found live on weblib-archive#30 (2026-09-09), it went further and *reasserted* the label over a human's hand-removal every 15 minutes. Established which of the two candidate mechanisms in #11 was real by instrumenting the script and running it under a real Gitea Actions token scoped to weblib-archive, against weblib-archive#68 (a throwaway issue depending on the already-closed weblib/cfbypass#1) and the live weblib-archive#30: `deps` is never empty for a cross-repo edge (rules out "Gitea omits the entry"), but every cross-repo entry comes back as Go's zero-value Issue struct -- id 0, state "", repository null -- rather than being omitted or erroring. The old `d.get("state") != "closed"` read that blank state as "still open", which is why the label could never come off, and why it kept getting silently reapplied. `is_resolved()` keys on `id == 0` rather than on the empty `state`, since `id` cannot legitimately be zero for a real issue and a future Gitea could start populating `state` while still stubbing the rest of the entry. `reconcile()` now keeps the existing add/remove behaviour whenever every blocker resolves (same-repo dependencies always do; pinned by TestSameRepoUnchanged so this cannot regress), but when a blocker cannot be resolved and none of the resolvable ones are positively open, it changes nothing and logs the issue number instead of guessing -- keeping the asymmetry from cfbypass#19 (a false "unblocked" sends someone at work that cannot be done; a false "blocked" only hides work) rather than inverting it. Also probed whether the repo-scoped token could resolve a cross-repo blocker by querying the other repo directly instead of via `/dependencies`: `GET repos/weblib/cfbypass/issues/1` and `GET repos/weblib/cfbypass` both 404 under weblib-archive's own Actions token, while the same call against its own repo succeeds. So this token cannot see cfbypass at all; making cross-repo state resolvable would need a broader-scoped credential, which is a real blast-radius decision left for bit rather than assumed here. The "unknown -> leave alone, log it" behaviour in this PR fixes the stuck/reasserted label either way, with or without that credential ever existing. 12 new tests in test_sync_blocked_label.py, offline against a local http.server fake. Proved by fault injection: reverting sync_blocked_label.py to its pre-fix form (keeping the new tests) fails exactly the 5 tests in TestUnresolvableCrossRepoBlocker -- one of them reproducing the live reassert bug verbatim (`[(30, 'add', 'blocked by #0, #0')]`) -- while every other test still passes; restoring the fix (diffed byte-identical against the pre-revert copy) turns the suite green again. python3 test_sync_blocked_label.py # 12 tests, OK python3 test_report_job_log.py # 22 tests, OK (unaffected) Co-authored-by: bit --- README.md | 14 +- sync_blocked_label.py | 69 +++++++-- test_sync_blocked_label.py | 300 +++++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 19 deletions(-) create mode 100644 test_sync_blocked_label.py diff --git a/README.md b/README.md index 07c9cf5..557d801 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,20 @@ fetch it — which was measured to be the difference between one step and three. |---|---| | `with-nixpkgs.sh` | Runs a command with one nixpkgs package on PATH, pinned to the *consuming* repo's `flake.lock`. Avoids `nix shell nixpkgs#x`, which re-resolves the registry and refetches a channel tarball whenever the branch moves. | | `report_job_log.py` | Posts the tail of a build log as a PR comment. Exists because `actions/jobs/{id}/logs` returns 500 for every id on Gitea 1.25.2, so a red job otherwise says only that it failed. Takes `--status`; see below. | -| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. Resolves the label from the repo *or the organisation*, and never touches an issue marked `Status/On Hold` or `Status/Abandoned`. | +| `sync_blocked_label.py` | Keeps `Status/Blocked` in step with Gitea's dependency graph. Resolves the label from the repo *or the organisation*, never touches an issue marked `Status/On Hold` or `Status/Abandoned`, and leaves the label alone (rather than guessing) on a blocker whose state a repo-scoped token cannot resolve — see weblib-ci#11. | All three are standard library / plain bash only. They are *run*, not built, so this repo has no flake. -`test_report_job_log.py` covers the reporter. It is standard library and -offline — the forge it posts to is an `http.server` on localhost that keeps -what it is sent, so a test reads the comment back rather than trusting an exit -status of 0, which this script returns even when the POST failed. +`test_report_job_log.py` covers the reporter and `test_sync_blocked_label.py` +covers the reconciler. Both are standard library and offline — the forge each +talks to is an `http.server` on localhost that keeps what it is sent, so a +test reads state back rather than trusting an exit status of 0 (which the +reporter in particular returns even when its POST failed). ```bash -python3 test_report_job_log.py # 21 tests, ~1.2s, no network +python3 test_report_job_log.py # 22 tests, ~1.2s, no network +python3 test_sync_blocked_label.py # 12 tests, no network ``` There is no workflow running it: this repo has no `.gitea/workflows` at all, diff --git a/sync_blocked_label.py b/sync_blocked_label.py index dfa1e47..aadd4ab 100755 --- a/sync_blocked_label.py +++ b/sync_blocked_label.py @@ -13,12 +13,23 @@ hand. **An issue with no dependencies at all is never touched.** Only issues that have at least one dependency are managed: -* any blocker not positively closed -> ensure the label is present -* every blocker closed -> ensure the label is absent +* any blocker positively open -> ensure the label is present +* every blocker positively closed -> ensure the label is absent +* any blocker's state cannot be confirmed either way -> leave the label + exactly as it is, and say so in the log The asymmetry is deliberate. A dependency in *another* repo is not fully visible to the Actions token, which is scoped to one repo, so "not open" and -"closed" are not the same statement. +"closed" are not the same statement. Gitea does not error or omit such an +entry -- it substitutes a zero-value stub (`id: 0`, every other field blank or +null, including `state: ""`), so `d.get("state") != "closed"` used to treat it +as "open forever". That kept a cross-repo `Status/Blocked` from ever being +removed (weblib-ci#11) and, worse, made the reconciler *reassert* the label +after a human removed it by hand (weblib-archive#30, 2026-09-09): every 15 +minutes it re-ran the same "not closed" test against the same unreadable stub +and put the label back. Treating "cannot tell" as its own outcome -- change +nothing, log the issue number -- fixes both without guessing an answer the +token cannot support. See `is_resolved()`. That matters because `Status/Blocked` is also applied by hand for reasons the graph knows nothing about -- weblib-archive#29 and #30 are blocked on a @@ -113,6 +124,21 @@ def label_id(forge, repo, name): return None +def is_resolved(dep): + """False for a dependency entry the token could not actually read. + + Gitea's `/dependencies` response omits nothing -- an entry the token + cannot see still gets an element, but one where every field is Go's + zero value: `id: 0`, `state: ""`, `repository: null`, etc. `id` is the + field that can never legitimately be 0 for a real issue, so it is the + one thing safe to key on -- `state` happens to be empty today, but a + future Gitea populating it while still stubbing the rest would silently + revive the bug this guards against if the check were on `state` instead. + Measured against a live cross-repo case, 2026-09-09: see weblib-ci#11. + """ + return dep.get("id", 0) != 0 + + def open_items(forge, repo): """Open issues *and* pull requests. They share the /issues endpoint.""" out = [] @@ -149,19 +175,36 @@ def reconcile(forge, repo, label_name, dry_run): if not deps: # No edges, no opinion. See the docstring. continue - # Fail safe: only an *explicitly closed* dependency counts as done. - # A cross-repo blocker is not fully visible to the Actions token, which - # is scoped to one repo -- weblib-archive#30 is blocked by - # weblib/cfbypass#1, and the runner read that entry as not-open and - # took the label off while cfbypass#1 was still open. Anything whose - # state we cannot positively confirm keeps the issue blocked. - blockers = [d for d in deps if d.get("state") != "closed"] + # Three-way split, not two. A dependency the token could not read + # comes back as a zero-value stub (see is_resolved) rather than being + # omitted, and its blank `state` must not be read as "open" or + # "closed" -- either guess has burned us. An *open* blocker anywhere + # in the list is decisive on its own (positive evidence, add/keep the + # label) regardless of what else is unresolvable; only "should this + # come off" needs every entry to be positively closed. + open_blockers = [d for d in deps if is_resolved(d) and d.get("state") != "closed"] + unresolved = [d for d in deps if not is_resolved(d)] labelled = any(l["name"] == label_name for l in item.get("labels") or []) - if blockers and not labelled: - action, detail = "add", f"blocked by {', '.join('#%d' % d['number'] for d in blockers)}" + if unresolved and not open_blockers: + # Cannot confirm every blocker is closed, and no *known* open one + # to justify keeping/adding the label on its own merits either. + # The reconciler's job is to keep the label in step with what it + # can see; when it cannot see part of the graph it has no + # business asserting either state. This is what replaces the old + # "not closed -> still blocked" fail-safe, which kept a + # cross-repo label stuck forever and then started *reasserting* + # it over a human's hand-removal (weblib-archive#30, weblib-ci#11) + # -- silence was the actual defect, not the direction of the + # guess. + print(f" unknown {repo}#{number}: {len(unresolved)} blocker(s) unresolvable " + f"with this token -- leaving {label_name!r} as-is ({'present' if labelled else 'absent'})", + flush=True) + continue + elif open_blockers and not labelled: + action, detail = "add", f"blocked by {', '.join('#%d' % d['number'] for d in open_blockers)}" if not dry_run: forge.post(f"repos/{repo}/issues/{number}/labels", {"labels": [lid]}) - elif not blockers and labelled: + elif not open_blockers and not unresolved and labelled: action, detail = "remove", "every blocker is closed" if not dry_run: # PUT the whole set minus this label, rather than diff --git a/test_sync_blocked_label.py b/test_sync_blocked_label.py new file mode 100644 index 0000000..028812b --- /dev/null +++ b/test_sync_blocked_label.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Tests for `sync_blocked_label.py`. + +Standard library only, like everything else here, and offline: the Gitea it +talks to is a `http.server` on localhost that keeps whatever state a test +hands it, so assertions read that state back rather than trusting an exit +code. + +Run them with any python3: + + python3 test_sync_blocked_label.py + +The point of most of them is the three-way split in `reconcile()` -- +positively open, positively closed, or unresolvable -- because collapsing it +back to two is exactly how weblib-ci#11 happened. A cross-repo dependency the +Actions token cannot read comes back from Gitea as a zero-value stub +(`id: 0`, `state: ""`, `repository: null`, ...) rather than being omitted or +erroring, and the old code's `d.get("state") != "closed"` read that as "still +open" forever -- which first meant the label could never come off, and then +(weblib-archive#30, 2026-09-09) meant the reconciler *reasserted* the label +every 15 minutes over a human's hand-removal. +""" + +import contextlib +import io +import json +import os +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer + +import sync_blocked_label as sbl + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def resolved(number, state, repo="weblib/cfbypass"): + """A dependency entry the token *could* read -- the shape a same-repo + blocker, or a cross-repo one under a token that can see it, comes back + as.""" + owner, name = repo.split("/") + return { + "id": 1000 + number, "number": number, "state": state, + "repository": {"owner": owner, "name": name, "full_name": repo}, + } + + +def unresolvable(): + """The zero-value stub Gitea substitutes for an entry the token cannot + read. Measured live against weblib-archive#68 and #30, 2026-09-09 -- + every field blank or zero, `state` included.""" + return { + "id": 0, "url": "", "html_url": "", "number": 0, "user": None, + "state": "", "repository": None, "labels": None, "milestone": None, + } + + +class FakeGitea(BaseHTTPRequestHandler): + """Just enough of the API to drive `reconcile()`. + + Class-level state, reset per test in `setUp`: + - `labels`: {"repo"/"org": [label dicts]} + - `issues`: [issue dicts] -- the open items in the one repo under test + - `deps`: {issue number: [dependency entries]} + - `label_calls`: every POST/PUT to a labels endpoint, in order + """ + + labels_repo = [] + labels_org = [] + issues = [] + deps = {} + label_calls = [] + + def _send(self, code, payload): + raw = json.dumps(payload).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + cls = type(self) + if self.path.startswith("/api/v1/repos/") and self.path.endswith( + "/labels?limit=100"): + return self._send(200, cls.labels_repo) + if self.path.startswith("/api/v1/orgs/") and "/labels" in self.path: + return self._send(200, cls.labels_org) + if "/issues?state=open" in self.path: + page = int(self.path.rsplit("page=", 1)[1]) + return self._send(200, cls.issues if page == 1 else []) + if "/dependencies" in self.path: + number = int(self.path.rsplit("/issues/", 1)[1].split("/")[0]) + return self._send(200, cls.deps.get(number, [])) + self._send(404, {"message": "no route: " + self.path}) + + def _record_and_ack(self, method): + length = int(self.headers.get("Content-Length") or 0) + payload = json.loads(self.rfile.read(length) or b"{}") + type(self).label_calls.append({ + "method": method, "path": self.path, "labels": payload["labels"], + }) + self._send(200, []) + + def do_POST(self): + self._record_and_ack("POST") + + def do_PUT(self): + self._record_and_ack("PUT") + + def log_message(self, *a): # keep the test output readable + pass + + +BLOCKED_LABEL = {"id": 145, "name": "Status/Blocked"} +ON_HOLD_LABEL = {"id": 152, "name": "Status/On Hold"} + + +def issue(number, *label_dicts): + return {"number": number, "labels": list(label_dicts)} + + +class ReconcileTestCase(unittest.TestCase): + + def setUp(self): + FakeGitea.labels_repo = [] + FakeGitea.labels_org = [BLOCKED_LABEL] + FakeGitea.issues = [] + FakeGitea.deps = {} + FakeGitea.label_calls = [] + self.server = HTTPServer(("127.0.0.1", 0), FakeGitea) + self.host = "http://127.0.0.1:%d" % self.server.server_address[1] + self.thread = threading.Thread( + target=self.server.serve_forever, kwargs={"poll_interval": 0.01}, + daemon=True) + self.thread.start() + self.addCleanup(self.server.server_close) + self.addCleanup(self.server.shutdown) + self.forge = sbl.Forge(self.host, "t0ken") + + def reconcile(self, dry_run=False): + out = io.StringIO() + with contextlib.redirect_stdout(out): + changed = sbl.reconcile(self.forge, "weblib/weblib-archive", + "Status/Blocked", dry_run) + return changed, out.getvalue() + + def calls_for(self, number): + needle = f"/issues/{number}/labels" + return [c for c in FakeGitea.label_calls if c["path"].endswith(needle)] + + +class TestNoDependencyEdges(ReconcileTestCase): + + def test_untouched(self): + """An issue with no dependency edges is never touched -- it may be + blocked by hand for a reason the graph does not know about + (weblib-archive#29/#30's original 'blocked on a decision' case).""" + FakeGitea.issues = [issue(1, BLOCKED_LABEL)] + FakeGitea.deps = {1: []} + changed, _ = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(self.calls_for(1), []) + + +class TestSameRepoUnchanged(ReconcileTestCase): + """Pins the pre-existing, already-correct behaviour so a fix aimed at the + cross-repo case cannot quietly change the path that already worked -- + same-repo dependencies always resolve fully (no id:0 stub ever shows up + for them), so `unresolved` is always empty here regardless of the fix.""" + + def test_open_blocker_adds_the_label(self): + FakeGitea.issues = [issue(5)] + FakeGitea.deps = {5: [resolved(1, "open", "weblib/weblib-archive")]} + changed, log = self.reconcile() + self.assertEqual(changed, [(5, "add", "blocked by #1")]) + self.assertEqual(self.calls_for(5), + [{"method": "POST", "path": "/api/v1/repos/weblib/weblib-archive/issues/5/labels", + "labels": [145]}]) + self.assertIn("add Status/Blocked on weblib/weblib-archive#5", log) + + def test_closed_blocker_removes_the_label(self): + FakeGitea.issues = [issue(6, BLOCKED_LABEL)] + FakeGitea.deps = {6: [resolved(2, "closed", "weblib/weblib-archive")]} + changed, _ = self.reconcile() + self.assertEqual(changed, [(6, "remove", "every blocker is closed")]) + self.assertEqual(self.calls_for(6), + [{"method": "PUT", "path": "/api/v1/repos/weblib/weblib-archive/issues/6/labels", + "labels": []}]) + + def test_already_correct_is_a_no_op_either_direction(self): + FakeGitea.issues = [ + issue(7, BLOCKED_LABEL), # open blocker, already labelled + issue(8), # closed blocker, already unlabelled + ] + FakeGitea.deps = { + 7: [resolved(1, "open", "weblib/weblib-archive")], + 8: [resolved(1, "closed", "weblib/weblib-archive")], + } + changed, _ = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(FakeGitea.label_calls, []) + + +class TestUnresolvableCrossRepoBlocker(ReconcileTestCase): + """The fix. Reproduces weblib-archive#68/#30 exactly: a dependency entry + that comes back as Gitea's zero-value stub because the Actions token + cannot read the other repo.""" + + def test_never_removes_a_label_it_cannot_confirm_is_clear(self): + """The original bug: every blocker closed, cross-repo, label stuck + forever.""" + FakeGitea.issues = [issue(30, BLOCKED_LABEL)] + FakeGitea.deps = {30: [unresolvable(), unresolvable()]} + changed, log = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(self.calls_for(30), []) + self.assertIn("unknown weblib/weblib-archive#30", log) + self.assertIn("2 blocker(s) unresolvable", log) + self.assertIn("present", log) + + def test_never_reasserts_a_label_a_human_removed(self): + """The sharper bug found live on 2026-09-09: same dependency graph + as above, but a human had already taken the label off. The old code + put it straight back on the next run; this must leave it off.""" + FakeGitea.issues = [issue(30)] # label removed by hand + FakeGitea.deps = {30: [unresolvable(), unresolvable()]} + changed, log = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(self.calls_for(30), []) + self.assertIn("absent", log) + + def test_a_known_open_blocker_still_wins_over_an_unresolvable_one(self): + """Positive evidence of an open blocker is decisive on its own -- + this is not a reason to *loosen* the guard, only to stop guessing + where there is no evidence either way.""" + FakeGitea.issues = [issue(9)] + FakeGitea.deps = {9: [resolved(1, "open", "weblib/weblib-archive"), + unresolvable()]} + changed, _ = self.reconcile() + self.assertEqual(changed, [(9, "add", "blocked by #1")]) + + def test_an_unresolvable_blocker_blocks_removal_even_with_other_closed(self): + """One confirmed-closed same-repo blocker plus one unresolvable + cross-repo one must not remove the label -- the unresolvable one + might still be open.""" + FakeGitea.issues = [issue(10, BLOCKED_LABEL)] + FakeGitea.deps = {10: [resolved(1, "closed", "weblib/weblib-archive"), + unresolvable()]} + changed, log = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(self.calls_for(10), []) + self.assertIn("unknown weblib/weblib-archive#10", log) + + def test_is_resolved_keys_on_id_not_on_state(self): + """bit, 2026-09-09: key on `id == 0`, not on the empty `state` -- + `state` is a symptom that happens to be visible today; a future + Gitea could populate it while still stubbing the rest of the + entry.""" + self.assertFalse(sbl.is_resolved(unresolvable())) + self.assertFalse(sbl.is_resolved({"id": 0, "state": "closed"})) + self.assertTrue(sbl.is_resolved({"id": 7, "state": ""})) + + +class TestHandsOff(ReconcileTestCase): + + def test_on_hold_is_never_touched_even_with_open_deps(self): + FakeGitea.issues = [issue(11, ON_HOLD_LABEL)] + FakeGitea.deps = {11: [resolved(1, "open", "weblib/weblib-archive")]} + changed, log = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(self.calls_for(11), []) + self.assertIn("hands off weblib/weblib-archive#11", log) + + +class TestNoLabelInRepo(ReconcileTestCase): + + def test_skips_everything_and_calls_nothing(self): + FakeGitea.labels_repo = [] + FakeGitea.labels_org = [] + FakeGitea.issues = [issue(1)] + changed, log = self.reconcile() + self.assertEqual(changed, []) + self.assertEqual(FakeGitea.label_calls, []) + self.assertIn("skipped: no 'Status/Blocked' label in this repo", log) + + +class TestDryRun(ReconcileTestCase): + + def test_dry_run_reports_but_does_not_call(self): + FakeGitea.issues = [issue(5)] + FakeGitea.deps = {5: [resolved(1, "open", "weblib/weblib-archive")]} + changed, log = self.reconcile(dry_run=True) + self.assertEqual(changed, [(5, "add", "blocked by #1")]) + self.assertEqual(FakeGitea.label_calls, []) + self.assertIn("would add", log) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 66ef3f78d108b9d64f1dce640153155fcf88e950 Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 9 Sep 2026 16:04:22 +0000 Subject: [PATCH 2/2] Say which runs reasserted the label, because it was not the schedule The docstring credited the every-15-minutes cron with putting Status/Blocked back after a hand-removal. It cannot have: schedule and `issues` events run the workflow from the default branch, weblib-archive's default branch is `main`, and `main` still carries a stale pre-org-label-migration copy at tools/sync_blocked_label.py. That copy looks the label up with `repos/{repo}/labels`; both repos now have zero repo-level labels (all 25 are org-level), so it returns None and exits "skipped: no 'Status/Blocked' label in this repo" without writing anything. Measured today: 377 scheduled and 54 `issues` runs, every one a no-op. The reassertions came from pull_request and workflow_dispatch runs on branches cut from `development`, which consume the shared script. The symptom tracks PR activity, not a timer -- which also explains why it looked intermittent. Mechanism and fix are unchanged; only the attribution was wrong, and a wrong cause in a permanent comment is what sends the next reader to the wrong file. Co-authored-by: bit --- sync_blocked_label.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/sync_blocked_label.py b/sync_blocked_label.py index aadd4ab..a64e5f7 100755 --- a/sync_blocked_label.py +++ b/sync_blocked_label.py @@ -25,9 +25,21 @@ entry -- it substitutes a zero-value stub (`id: 0`, every other field blank or null, including `state: ""`), so `d.get("state") != "closed"` used to treat it as "open forever". That kept a cross-repo `Status/Blocked` from ever being removed (weblib-ci#11) and, worse, made the reconciler *reassert* the label -after a human removed it by hand (weblib-archive#30, 2026-09-09): every 15 -minutes it re-ran the same "not closed" test against the same unreadable stub -and put the label back. Treating "cannot tell" as its own outcome -- change +after a human removed it by hand (weblib-archive#30, 2026-09-09): it re-ran the +same "not closed" test against the same unreadable stub and put the label back. + +Which runs did that is worth stating precisely, because the obvious answer is +wrong. It was **not** the every-15-minutes schedule. Scheduled and `issues` +events run the workflow from the *default* branch, and weblib-archive's default +branch is `main`, which still carries a stale pre-org-label-migration local copy +at `tools/sync_blocked_label.py`. That copy resolves the label with +`repos/{repo}/labels`, both repos now have **zero** repo-level labels (all 25 +are org-level), so it returns None and exits with "skipped: no 'Status/Blocked' +label in this repo" — writing nothing, ever. Measured 2026-09-09: 377 scheduled +plus 54 `issues` runs, all no-ops. The reassertions came from `pull_request` and +`workflow_dispatch` runs on branches cut from `development`, which do consume +this shared script. So the symptom appears on PR activity, not on a timer. +See weblib-archive#70 and cfbypass#35 for the stale copies themselves. Treating "cannot tell" as its own outcome -- change nothing, log the issue number -- fixes both without guessing an answer the token cannot support. See `is_resolved()`.