From e6f18b5a3be3a5c1682d2d9710e1209bb480c79f Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 7 Sep 2026 16:07:18 +0000 Subject: [PATCH] Resolve labels from the org, and never touch a held issue 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 --- sync_blocked_label.py | 45 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) 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. -- 2.51.2