Two fixes. The first is a live regression I caused today; the second stops one
before it arms.
1. `label_id()` looked the label up in `repos/{repo}/labels` only. Labels moved
to the organisation today (weblib-archive#63) and that endpoint now returns
`[]` in all five repos, so it returned None everywhere and the whole script
became a silent no-op:
$ sync_blocked_label.py --repo weblib/weblib-archive --dry-run
skipped: no 'Status/Blocked' label in this repo
--> 0 change(s)
It failed *safe* - skipping rather than mislabelling, which is what that
docstring was written for - but a job that runs every 15 minutes reported
success while doing nothing. It now tries the repo, then the org, so it does
not care how an instance is arranged.
2. bit, 2026-09-07: "the reconciler must not touch issues that are already on
hold or abandoned". Implemented literally - neither add nor remove.
This matters because `Status/*` is becoming exclusive again. Under that,
adding `Status/Blocked` does not sit beside an existing status, it
*replaces* it - so the reconciler would silently delete a deliberate
`Status/On Hold` on its next pass. And since On Hold is exactly what makes
the backlog sweep skip an issue, a parked issue would quietly become an
available one, with nothing in the log to say why.
Verified against the live forge rather than by reading:
* add path, org-resolved: stripped Status/Blocked off cfbypass#8, dry-run
said "would add", the real run added it back
* hands-off, as a control on ONE issue with ONE open blocker, changing only
the label:
without Status/On Hold -> "would add Status/Blocked ... (blocked by #54)"
with Status/On Hold -> "hands off", 0 changes, label intact
Closes #3
Co-authored-by: bit <bit@das-labor.org>
216 lines
8.9 KiB
Python
Executable File
216 lines
8.9 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 not positively closed -> ensure the label is present
|
|
* every blocker closed -> ensure the label is absent
|
|
|
|
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.
|
|
|
|
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 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
|
|
# 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"]
|
|
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 not dry_run:
|
|
forge.post(f"repos/{repo}/issues/{number}/labels", {"labels": [lid]})
|
|
elif not blockers 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())
|