Mitigate the stuck/reasserted cross-repo Status/Blocked (does not fully fix #11) #13

Merged
bit merged 2 commits from fix/cross-repo-blocker-state into main 2026-09-09 16:35:13 +00:00
3 changed files with 364 additions and 19 deletions
Showing only changes of commit 05e8103eb0 - Show all commits
+8 -6
View File
@@ -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,
+56 -13
View File
@@ -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
+300
View File
@@ -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)