Files
weblib-ci/sync_blocked_label.py
T
claudeandbit 66ef3f78d1 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 <bit@das-labor.org>
2026-09-09 16:04:22 +00:00

271 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): 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()`.
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())