Resolve labels from the org, and never touch a held issue #4

Merged
claude merged 1 commits from fix/org-labels-and-hands-off into main 2026-09-07 16:10:21 +00:00

View File

@@ -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/<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. 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.