diff --git a/sync_blocked_label.py b/sync_blocked_label.py index 891cbd2..dfa1e47 100755 --- a/sync_blocked_label.py +++ b/sync_blocked_label.py @@ -45,6 +45,18 @@ 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): @@ -75,15 +87,29 @@ class Forge: def label_id(forge, repo, name): - """The label's id, or None if this repo has no such label. + """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//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. weblib-viewer has no labels at all. + a worse state than doing nothing. """ - for label in forge.get(f"repos/{repo}/labels?limit=100"): - if label["name"] == name: - return label["id"] + 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 @@ -110,6 +136,15 @@ def reconcile(forge, repo, label_name, dry_run): 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.