#!/usr/bin/env python3 """Post, list and close Forgejo issues in the convention these projects use. Exists because every one of the rules below was learned by getting it wrong once. The script is the enforcement; the skill is the explanation. create file an issue, refusing one that has no `Verify:` line or that duplicates an existing title batch file several from a JSON file, skipping ones already there list open issues, grouped by milestone close close with an evidence comment — "Done" is not a close labels / milestones — what exists, with the ids the API wants Run any subcommand with --dry-run to see the payload and change nothing. """ from __future__ import annotations import argparse import json import os import re import subprocess import sys import urllib.error import urllib.parse import urllib.request # Where the credentials live, when they are not already in the environment. # # Keep this file OUTSIDE the repository. A token in a file the repo can see is # a token one `git add -A` away from being published — the same argument # `release.sh` makes about its registry env. ENV_FILE = os.environ.get("FORGEJO_ENV_FILE", os.path.expanduser("~/.forgejo.env")) # Cloudflare fronts the Forgejo instance and 1010-blocks Python's default # urllib User-Agent (browser_signature_banned). Every call fails with a # Cloudflare HTML body that looks nothing like a Forgejo error. Do not remove. USER_AGENT = "curl/8.5.0" SEVERITY = ("P0", "P1", "P2", "release-blocker") # ── plumbing ───────────────────────────────────────────────────────────────── def die(msg: str, code: int = 1): print(f"error: {msg}", file=sys.stderr) sys.exit(code) def load_env() -> tuple[str, str]: """Read host + token. Never print the token; it is not registry-scoped — it carries admin/push/pull over the whole API.""" host = os.environ.get("FORGEJO_REGISTRY") token = os.environ.get("FORGEJO_REGISTRY_TOKEN") if not (host and token): try: with open(ENV_FILE, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) v = v.strip().strip("'\"") if k.strip() == "FORGEJO_REGISTRY" and not host: host = v elif k.strip() == "FORGEJO_REGISTRY_TOKEN" and not token: token = v except FileNotFoundError: pass if not (host and token): die( f"FORGEJO_REGISTRY / FORGEJO_REGISTRY_TOKEN not in the environment " f"or {ENV_FILE}.\n" f"Set FORGEJO_ENV_FILE to point somewhere else, or export both." ) return host, token def detect_repo() -> str | None: """owner/name from the git remote of the current directory.""" try: url = subprocess.run( ["git", "remote", "get-url", "origin"], capture_output=True, text=True, check=True, ).stdout.strip() except (subprocess.CalledProcessError, FileNotFoundError): return None m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?$", url) return f"{m.group(1)}/{m.group(2)}" if m else None class Api: def __init__(self, host: str, token: str, repo: str, dry_run: bool = False): self.base = f"https://{host}/api/v1" self.token = token self.repo = repo self.dry_run = dry_run def _call(self, method: str, path: str, body=None, params=None): url = f"{self.base}{path}" if params: url += "?" + urllib.parse.urlencode(params) data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"token {self.token}") req.add_header("User-Agent", USER_AGENT) if data: req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read().decode() return json.loads(raw) if raw.strip() else None except urllib.error.HTTPError as e: detail = e.read().decode()[:400] if " dict[str, int]: return {l["name"]: l["id"] for l in (self.get(f"/repos/{self.repo}/labels", {"limit": 100}) or [])} def milestones(self) -> dict[str, int]: out = {} for state in ("open", "closed"): for m in (self.get(f"/repos/{self.repo}/milestones", {"state": state, "limit": 100}) or []): out[m["title"]] = m["id"] return out def milestones_full(self, state="all") -> list[dict]: states = ("open", "closed") if state == "all" else (state,) out = [] for s in states: out.extend(self.get(f"/repos/{self.repo}/milestones", {"state": s, "limit": 100}) or []) return out def current_milestone(self) -> dict | None: """The first OPEN milestone that still has open issues. 'First' is the order Forgejo returns, which is creation order — NOT a numeric sort by title. A milestone created last therefore cannot become current while an earlier one still has open issues, which is the lever for filing work that must not disturb the card. """ openms = self.milestones_full("open") for m in openms: if m["open_issues"] > 0: return m return openms[0] if openms else None # ── convention checks ──────────────────────────────────────────────────────── def check_verify_line(body: str, title: str) -> str: """Every issue ends with a Verify: line stating the acceptance check. A deliverable nobody can re-test cannot be closed, so it must not be filed.""" lines = [l for l in body.strip().splitlines() if l.strip()] if not any(l.strip().startswith("Verify:") for l in lines): die(f'"{title}" has no `Verify:` line. State the acceptance check — a\n' " finding that cannot be re-tested cannot be closed.") if not lines[-1].strip().startswith("Verify:"): print(f'warning: "{title}" has a Verify: line but it is not last', file=sys.stderr) return body def check_surface_named(body: str, title: str) -> str: """An issue that produces a data shape names its surface. Both tests read the `Verify:` line, not the body — bodies say "page" and "renders" incidentally all the time, and the acceptance check is the thing that decides whether the issue can close with nothing visible. Deliberately narrow. It fires only when the acceptance check's subject is the data itself — retrievable, returned, reported, recorded — and nothing in it requires a person to see the result. A warning that fires on most issues is one nobody reads, and it would take the `Verify:`-not-last warning down with it. A warning rather than a refusal: API-only is a legitimate decision, it just has to be a decision rather than an omission.""" lines = [l for l in body.strip().splitlines() if l.strip()] verify = next((l for l in reversed(lines) if l.strip().startswith("Verify:")), "") data_is_the_subject = re.search( r"(retrievable|returns the|--json\b|a caller|is stored|recorded with|" r"\breports\b|available (?:from|via) the API)", verify, re.I) a_person_sees_it = re.search( r"\b(admin|screen|page|shown|shows|displayed|visible|sees|operator|" r"renders|dialog|button|UI)\b", verify, re.I) if data_is_the_subject and not a_person_sees_it: print(f'warning: "{title}" accepts on the data being retrievable and never on\n' " anyone seeing it. Name the screen, link the issue that provides\n" " one, or state that it is deliberately API-only.", file=sys.stderr) return body def resolve_labels(names, available: dict[str, int]) -> list[int]: """Names → ids, failing loudly. A typo'd severity label is reported by the Command Center as *not adopted*, not as zero defects — silently dropping it would hide the whole repo's defect count.""" ids = [] for n in names: # A severity label that differs only in case is the dangerous one: it # looks right in the UI and is invisible to a query by exact name. if n not in available: near = [a for a in available if a.lower() == n.lower()] if near: die(f"label {n!r} does not exist, but {near[0]!r} does. " "Names are matched exactly — use that one.") die(f"label {n!r} does not exist. " f"Available: {', '.join(sorted(available)) or '(none)'}") if n.upper() in {s.upper() for s in SEVERITY} and n not in SEVERITY: die(f"severity label must be spelled exactly one of {SEVERITY}, " f"got {n!r}") ids.append(available[n]) return ids def find_duplicate(title: str, existing: list) -> dict | None: t = title.strip().lower() for i in existing: if i["title"].strip().lower() == t: return i return None def validate_milestone_title(title: str) -> None: """Refuse a comma. `milestones=` takes a comma-separated list of names, so a title containing one splits into names that do not exist, the filter DROPS, and the query returns the newest open issue in the WHOLE repository — which the card then presents as that milestone's next action. Percent-encoding does not save it; Forgejo decodes before splitting. Measured on null/fruit-fall, where `0.3.7 Logo, Icons & Branding` was renamed for exactly this reason.""" if "," in title: die(f"milestone title contains a comma: {title!r}\n" " That silently breaks the `milestones=` filter and makes the project\n" " card show the wrong next action. Rename it without the comma.") first = title.strip().split()[0] if title.strip() else "" if first and first.replace("v", "", 1).replace(".", "").isdigit(): rest = title.strip()[len(first):].strip() if rest: print(f" note: the dashboard phase will show just {first!r} — a title " f"starting with a\n version token has the rest dropped. Put a " f"word first to keep it whole.") _warned_current: set[str] = set() def _warn_if_current(api: Api, milestone: str) -> None: """The dashboard's next action is the NEWEST open issue in the current milestone — not the highest priority; priority labels have no influence at all. Filing a routine item into the milestone the team is working on therefore replaces what the card shows.""" if milestone in _warned_current: return _warned_current.add(milestone) cur = api.current_milestone() if cur and cur["title"] == milestone: print(f" warning: {milestone!r} is the CURRENT milestone. The next action on " "the project\n card is the NEWEST open issue in it, ignoring " "priority — so this will\n replace whatever is shown there now. " "To avoid that, file into a\n milestone created later; order is " "creation order, not title order.") # ── commands ───────────────────────────────────────────────────────────────── def cmd_check(api: Api, args): """The probes that prove the card is not lying. Run before and after filing. Everything here exists because Forgejo's filters FAIL OPEN: given a value they cannot match they ignore the filter and return the unfiltered list, with no error. A query returning plausible results is not evidence it filtered. """ print(f"tracker health — {api.repo}\n") problems = 0 labels = api.labels() missing = [n for n in SEVERITY if n not in labels] if missing: problems += 1 print(f" FAIL severity labels missing: {', '.join(missing)}") print(" a query for a label that does not exist matches EVERYTHING") else: print(" ok all four severity labels defined") openi = api.issues(state="open") blockers = [i for i in openi if any(l["name"] == "release-blocker" for l in i["labels"])] if blockers: print(f" WARN {len(blockers)} open release-blocker — takes over the whole card:") for i in blockers[:5]: print(f" #{i['number']} {i['title'][:58]}") else: print(" ok no release-blocker hijacking the card") orphans = [i for i in openi if not i.get("milestone")] if orphans: problems += 1 print(f" FAIL {len(orphans)} open issue(s) with no milestone — invisible on the card:") for i in orphans[:5]: print(f" #{i['number']} {i['title'][:58]}") else: print(f" ok no orphan issues ({len(openi)} open)") openms = api.milestones_full("open") commas = [m["title"] for m in openms if "," in m["title"]] if commas: problems += 1 print(f" FAIL comma in milestone title — breaks the filter: {commas}") else: print(" ok no comma in any open milestone title") empty = [m["title"] for m in openms if m["open_issues"] == 0] if empty: print(f" WARN {len(empty)} open milestone(s) with nothing in them — the card") print(f" will read 'Close milestone …': {empty[:3]}") else: print(" ok no empty open milestones") cur = api.current_milestone() if cur: first = cur["title"].strip().split()[0] tok = first.replace("v", "", 1).replace(".", "") print(f"\n current milestone : {cur['title']!r}") print(f" phase shown : {(first if tok.isdigit() else cur['title'].strip())!r}") nxt = api.get(f"/repos/{api.repo}/issues", {"type": "issues", "state": "open", "limit": 1, "milestones": cur["title"]}) or [] if nxt: i = nxt[0] in_ms = (i.get("milestone") or {}).get("title") if in_ms != cur["title"]: problems += 1 print(f" next action : {i['title']!r}") print(" ^ FILTER DROPPED — that issue is in " f"{in_ms!r}.\n The card is showing a " "wrong next action.") else: print(f" next action : {i['title']!r}") else: print("\n no open milestones — the phase would be 'release'") print(f"\n{'PROBLEMS: ' + str(problems) if problems else 'All checks passed.'}") return 1 if problems else 0 def cmd_milestone(api: Api, args): """Create a milestone. No due date is ever set — a due date means a commitment, and an invented one is worse than none.""" title = args.title.strip() validate_milestone_title(title) if title in api.milestones(): print(f"exists {title!r} — not creating a second one") return desc = args.description or "" if args.description_file: desc = open(args.description_file, encoding="utf-8").read() if not desc.strip(): print(" note: no description. It should say what the batch is for and how " "anybody\n will know it landed.") m = api.post(f"/repos/{api.repo}/milestones", {"title": title, "description": desc}) print(f"created milestone {m.get('title', title)!r} (id {m.get('id', '?')})") def create_one(api: Api, spec: dict, labels_map, ms_map, existing, allow_dup=False) -> dict | None: title = spec["title"].strip() body = check_verify_line(spec.get("body", ""), title) body = check_surface_named(body, title) dup = find_duplicate(title, existing) if dup and not allow_dup: print(f"skip #{dup['number']} already titled {title!r} " f"({dup['state']}) — creates are not idempotent, so this is a skip " f"not an error") return None payload = {"title": title, "body": body} if spec.get("labels"): payload["labels"] = resolve_labels(spec["labels"], labels_map) if "release-blocker" in spec["labels"]: print(" warning: release-blocker does NOT filter by milestone, so one " "stray label\n takes over the phase and next action for " "the entire project. It means\n *nothing else can " "proceed* — it is not a synonym for important; that is P1.") if spec.get("milestone"): m = spec["milestone"] if m not in ms_map: die(f"milestone {m!r} does not exist. Available: " f"{', '.join(sorted(ms_map)) or '(none)'}") payload["milestone"] = ms_map[m] _warn_if_current(api, m) else: print(f" warning: {title[:48]!r} has no milestone — it can never become the " "next action\n and never appears anywhere on the project card.") d = api.post(f"/repos/{api.repo}/issues", payload) names = ",".join(l["name"] for l in d.get("labels", [])) mile = (d.get("milestone") or {}).get("title", "—") print(f"filed #{d['number']} [{names}] {d['title']}" + (f" → {mile}" if mile != "—" else "")) return d def cmd_create(api: Api, args): labels_map, ms_map = api.labels(), api.milestones() existing = api.issues(state="all") body = args.body if args.body_file: body = open(args.body_file, encoding="utf-8").read() create_one(api, {"title": args.title, "body": body or "", "labels": args.label, "milestone": args.milestone}, labels_map, ms_map, existing, args.allow_duplicate) def cmd_batch(api: Api, args): specs = json.load(open(args.file, encoding="utf-8")) if isinstance(specs, dict): specs = specs.get("issues", []) if not isinstance(specs, list): die("batch file must be a JSON list, or an object with an 'issues' list") labels_map, ms_map = api.labels(), api.milestones() existing = api.issues(state="all") filed = 0 for spec in specs: d = create_one(api, spec, labels_map, ms_map, existing, args.allow_duplicate) if d: filed += 1 existing.append({"number": d["number"], "title": d["title"], "state": "open"}) print(f"\n{filed} filed, {len(specs) - filed} skipped") def cmd_list(api: Api, args): issues = api.issues(state=args.state) groups: dict[str, list] = {} for i in issues: groups.setdefault((i.get("milestone") or {}).get("title", "(no milestone)"), []).append(i) for m in sorted(groups): print(f"\n### {m}") for i in sorted(groups[m], key=lambda x: x["number"]): names = ",".join(l["name"] for l in i["labels"]) print(f" #{i['number']:<4} [{names}] {i['title']}") print(f"\n{len(issues)} {args.state} issue(s) — pull requests excluded") def cmd_close(api: Api, args): """Close with the evidence that proves it: a path, a symbol, a test name, or the command that shows it. 'Done' is not a close.""" ev = args.evidence.strip() if len(ev) < 15: die("evidence too thin. Give a path, a symbol, a test name, or the " "command that proves it — 'Done' is not a close.") api.post(f"/repos/{api.repo}/issues/{args.number}/comments", {"body": ev}) api.patch(f"/repos/{api.repo}/issues/{args.number}", {"state": "closed"}) print(f"closed #{args.number} with evidence") print("note: prefer `closes #N` in the commit that does the work — the " "tracker then records who and when from the thing that happened.") def cmd_labels(api: Api, args): labels = api.labels() print("severity (exact names — queried by name by the Command Center):") for s in SEVERITY: print(f" {'✓' if s in labels else '✗ MISSING'} {s}" + (f" id={labels[s]}" if s in labels else "")) print("\nother:") for n, i in sorted(labels.items()): if n not in SEVERITY: print(f" {n} id={i}") def cmd_milestones(api: Api, args): for state in ("open", "closed"): ms = api.get(f"/repos/{api.repo}/milestones", {"state": state, "limit": 100}) or [] if ms: print(f"\n{state}:") for m in ms: print(f" [{m['id']}] {m['title']} — open {m['open_issues']} / " f"closed {m['closed_issues']}") def main(): # --repo and --dry-run are accepted on BOTH sides of the subcommand. Putting # them only on the top-level parser means `… create "T" --dry-run` — the # natural way to type it, and the position that matters most — dies with an # argparse usage error instead of previewing. A safety flag that is easy to # put in the wrong place is a safety flag that gets left off. # default=SUPPRESS is load-bearing, not tidiness. With a normal default the # subparser re-defines the same dest and argparse writes its default over # whatever the top-level parser already parsed — so `--repo X create …` # silently became repo=None and `--dry-run create …` silently became False. # A --dry-run that quietly turns itself off is the worst possible bug in a # tool whose job is writing to a live tracker. SUPPRESS leaves the attribute # unset unless it was actually given, so neither position clobbers the other. common = argparse.ArgumentParser(add_help=False) common.add_argument("--repo", default=argparse.SUPPRESS, help="owner/name (default: from git remote)") common.add_argument("--dry-run", action="store_true", default=argparse.SUPPRESS, help="print payloads, change nothing") p = argparse.ArgumentParser( parents=[common], description="File Forgejo issues in the tracker convention.", epilog="Every open issue is a denominator. Do not pad the tracker.") sub = p.add_subparsers(dest="cmd", required=True) c = sub.add_parser("create", parents=[common], help="file one issue") c.add_argument("title") c.add_argument("--body", help="issue body; must end with a Verify: line") c.add_argument("--body-file", help="read the body from a file") c.add_argument("--label", action="append", default=[], help="label name, repeatable") c.add_argument("--milestone", help="milestone title") c.add_argument("--allow-duplicate", action="store_true") c.set_defaults(fn=cmd_create) b = sub.add_parser("batch", parents=[common], help="file several from a JSON file") b.add_argument("file") b.add_argument("--allow-duplicate", action="store_true") b.set_defaults(fn=cmd_batch) l = sub.add_parser("list", parents=[common], help="open issues by milestone") l.add_argument("--state", default="open", choices=["open", "closed", "all"]) l.set_defaults(fn=cmd_list) x = sub.add_parser("close", parents=[common], help="close with an evidence comment") x.add_argument("number", type=int) x.add_argument("evidence", help="what was checked and where") x.set_defaults(fn=cmd_close) m = sub.add_parser("milestone", parents=[common], help="create a milestone (batch)") m.add_argument("title") m.add_argument("--description") m.add_argument("--description-file") m.set_defaults(fn=cmd_milestone) sub.add_parser("check", parents=[common], help="health probes — run before AND after filing" ).set_defaults(fn=cmd_check) sub.add_parser("labels", parents=[common], help="labels and their ids").set_defaults( fn=cmd_labels) sub.add_parser("milestones", parents=[common], help="milestones and their ids").set_defaults( fn=cmd_milestones) args = p.parse_args() # getattr, because SUPPRESS means the attribute may legitimately be absent. repo = getattr(args, "repo", None) or detect_repo() if not repo: die("could not detect owner/name from the git remote — pass --repo") host, token = load_env() rc = args.fn(Api(host, token, repo, getattr(args, "dry_run", False)), args) # `check` returns a count so it can gate a script; the rest return None. sys.exit(rc or 0) if __name__ == "__main__": main()