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 <bit@das-labor.org>
259 lines
12 KiB
Python
Executable File
259 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Keep `Status/Blocked` in step with Gitea's dependency graph.
|
|
|
|
Gitea knows which issues block which, and will grey out a pull request's merge
|
|
button accordingly, but it does not surface that as a label -- so a list view
|
|
gives no hint that half of it cannot be started. This reconciles the two.
|
|
|
|
Standard library only, so it runs in any runner image and can be driven by
|
|
hand.
|
|
|
|
## The one rule worth arguing about
|
|
|
|
**An issue with no dependencies at all is never touched.** Only issues that
|
|
have at least one dependency are managed:
|
|
|
|
* 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. 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
|
|
decision, not on an issue. A rule of "no open blockers means not blocked"
|
|
would strip those the first time it ran. Anything with a dependency edge is
|
|
fair game because the edge is the statement of intent; anything without one is
|
|
somebody's judgement and stays.
|
|
|
|
Usage:
|
|
sync_blocked_label.py --repo weblib/cfbypass [--repo weblib/weblib-archive]
|
|
[--dry-run] [--label Status/Blocked]
|
|
|
|
Reads the token from --token, else $GITEA_TOKEN, else $GITHUB_TOKEN.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
DEFAULT_LABEL = "Status/Blocked"
|
|
DEFAULT_HOST = "https://git.chaosbit.de"
|
|
|
|
#: Statuses that mean a human has decided something about this issue which
|
|
#: outranks the dependency graph. bit, 2026-09-07: *"the reconciler must not
|
|
#: touch issues that are already on hold or abandoned"*.
|
|
#:
|
|
#: "Not touch" is literal - neither add nor remove. Adding would be actively
|
|
#: destructive once `Status/*` is exclusive again, because the add would
|
|
#: *replace* the human's label rather than sit beside it, and `Status/On Hold`
|
|
#: is precisely what makes the backlog sweep skip an issue. A parked issue
|
|
#: would silently become an available one, every fifteen minutes, with nothing
|
|
#: in the log to say so.
|
|
HANDS_OFF = ("Status/On Hold", "Status/Abandoned")
|
|
|
|
|
|
class Forge:
|
|
def __init__(self, base, token):
|
|
self.base = base.rstrip("/") + "/api/v1"
|
|
self.token = token
|
|
|
|
def _call(self, method, path, body=None):
|
|
url = f"{self.base}/{path.lstrip('/')}"
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
req = urllib.request.Request(url, data=data, method=method)
|
|
req.add_header("Content-Type", "application/json")
|
|
req.add_header("Authorization", f"token {self.token}")
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=30) as res:
|
|
raw = res.read()
|
|
return json.loads(raw) if raw else None
|
|
except urllib.error.HTTPError as e:
|
|
raise SystemExit(f"{method} {path} failed: {e.code} {e.read()[:200].decode('utf-8', 'replace')}")
|
|
|
|
def get(self, path):
|
|
return self._call("GET", path)
|
|
|
|
def post(self, path, body):
|
|
return self._call("POST", path, body)
|
|
|
|
def put(self, path, body):
|
|
return self._call("PUT", path, body)
|
|
|
|
|
|
def label_id(forge, repo, name):
|
|
"""The label's id, or None if neither the repo nor its org has one.
|
|
|
|
Repo first, then the organisation. Labels moved to the org on 2026-09-07
|
|
(weblib-archive#63) and `repos/<r>/labels` now returns `[]` in all five
|
|
repos, which made this return None everywhere and turned the whole script
|
|
into a silent no-op - every run printed "skipped" and reported success.
|
|
Checking both means it does not care how a given instance is arranged.
|
|
|
|
Returning None rather than exiting matters when several repos are passed:
|
|
aborting on the third would leave the first two already modified, which is
|
|
a worse state than doing nothing.
|
|
"""
|
|
owner = repo.split("/")[0]
|
|
for path in (f"repos/{repo}/labels?limit=100",
|
|
f"orgs/{owner}/labels?limit=100"):
|
|
try:
|
|
labels = forge.get(path) or []
|
|
except urllib.error.HTTPError:
|
|
# A user-owned repo has no org endpoint; not an error worth dying on.
|
|
continue
|
|
for label in labels:
|
|
if label["name"] == name:
|
|
return label["id"]
|
|
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 = []
|
|
page = 1
|
|
while True:
|
|
batch = forge.get(f"repos/{repo}/issues?state=open&limit=50&page={page}")
|
|
if not batch:
|
|
break
|
|
out.extend(batch)
|
|
if len(batch) < 50:
|
|
break
|
|
page += 1
|
|
return out
|
|
|
|
|
|
def reconcile(forge, repo, label_name, dry_run):
|
|
lid = label_id(forge, repo, label_name)
|
|
if lid is None:
|
|
print(f" skipped: no {label_name!r} label in this repo", flush=True)
|
|
return []
|
|
changed = []
|
|
for item in open_items(forge, repo):
|
|
number = item["number"]
|
|
names = {l["name"] for l in item.get("labels") or []}
|
|
# A human has already ruled on this one. Leave it entirely alone -
|
|
# neither add nor remove - rather than letting the dependency graph
|
|
# overwrite a deliberate decision. See HANDS_OFF.
|
|
held = names.intersection(HANDS_OFF)
|
|
if held:
|
|
print(f" hands off {repo}#{number} ({', '.join(sorted(held))})",
|
|
flush=True)
|
|
continue
|
|
deps = forge.get(f"repos/{repo}/issues/{number}/dependencies") or []
|
|
if not deps:
|
|
# No edges, no opinion. See the docstring.
|
|
continue
|
|
# 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 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 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
|
|
# DELETE .../labels/{id}. Under Gitea Actions the automatic
|
|
# token can *add* an issue label but not delete one -- the
|
|
# DELETE fails whatever `permissions:` the workflow declares,
|
|
# while the replace succeeds. Measured on 1.25.2; a run by
|
|
# hand with a personal token works either way, so this only
|
|
# ever showed up in CI.
|
|
keep = [l["id"] for l in item.get("labels") or []
|
|
if l["name"] != label_name]
|
|
forge.put(f"repos/{repo}/issues/{number}/labels", {"labels": keep})
|
|
else:
|
|
continue
|
|
changed.append((number, action, detail))
|
|
print(f" {'would ' if dry_run else ''}{action:<6} {label_name} on {repo}#{number}"
|
|
f" ({detail})", flush=True)
|
|
return changed
|
|
|
|
|
|
def main(argv=None):
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--repo", action="append", default=[],
|
|
help="owner/name; repeatable. Defaults to $GITHUB_REPOSITORY.")
|
|
ap.add_argument("--host", default=os.environ.get("GITEA_HOST") or DEFAULT_HOST)
|
|
ap.add_argument("--token", default=os.environ.get("GITEA_TOKEN")
|
|
or os.environ.get("GITHUB_TOKEN"))
|
|
ap.add_argument("--label", default=DEFAULT_LABEL)
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args(argv)
|
|
|
|
repos = args.repo or ([os.environ["GITHUB_REPOSITORY"]]
|
|
if os.environ.get("GITHUB_REPOSITORY") else [])
|
|
if not repos:
|
|
ap.error("no --repo given and GITHUB_REPOSITORY is not set")
|
|
if not args.token:
|
|
ap.error("no token: pass --token or set GITEA_TOKEN/GITHUB_TOKEN")
|
|
|
|
forge = Forge(args.host, args.token)
|
|
total = 0
|
|
for repo in repos:
|
|
print(f"{repo}:", flush=True)
|
|
total += len(reconcile(forge, repo, args.label, args.dry_run))
|
|
print(f"--> {total} change(s){' (dry run, nothing applied)' if args.dry_run else ''}",
|
|
flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|