587 lines
25 KiB
Python
587 lines
25 KiB
Python
|
|
#!/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 "<html" in detail.lower():
|
||
|
|
detail = ("Cloudflare returned HTML, not a Forgejo error — the "
|
||
|
|
"User-Agent was probably rejected.")
|
||
|
|
die(f"{method} {path} → HTTP {e.code}: {detail}")
|
||
|
|
except urllib.error.URLError as e:
|
||
|
|
die(f"{method} {path} → {e.reason}")
|
||
|
|
|
||
|
|
def get(self, path, params=None):
|
||
|
|
return self._call("GET", path, params=params)
|
||
|
|
|
||
|
|
def post(self, path, body):
|
||
|
|
if self.dry_run:
|
||
|
|
print(f"[dry-run] POST {path}\n{json.dumps(body, indent=2)}")
|
||
|
|
return {"number": "?", "title": body.get("title", ""), "labels": [],
|
||
|
|
"milestone": None, "_dry": True}
|
||
|
|
return self._call("POST", path, body=body)
|
||
|
|
|
||
|
|
def patch(self, path, body):
|
||
|
|
if self.dry_run:
|
||
|
|
print(f"[dry-run] PATCH {path}\n{json.dumps(body, indent=2)}")
|
||
|
|
return {"_dry": True}
|
||
|
|
return self._call("PATCH", path, body=body)
|
||
|
|
|
||
|
|
# ── repo helpers ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def issues(self, state="open", limit=50):
|
||
|
|
"""type=issues matters: without it Forgejo returns pull requests too,
|
||
|
|
and every count drawn from the result is wrong.
|
||
|
|
|
||
|
|
Stops on an EMPTY page, not a short one. The server caps a page at 50
|
||
|
|
however large a `limit` you ask for, so the natural `len(batch) < limit`
|
||
|
|
test with limit=100 is true on the very first page and the loop exits
|
||
|
|
having read 50 of 72 open issues — silently, with a plausible-looking
|
||
|
|
result. That truncation feeds duplicate detection, which is the one thing
|
||
|
|
this list is for, so a short read re-files work that already exists.
|
||
|
|
Costs one extra request; cannot truncate.
|
||
|
|
"""
|
||
|
|
out, page = [], 1
|
||
|
|
while True:
|
||
|
|
batch = self.get(
|
||
|
|
f"/repos/{self.repo}/issues",
|
||
|
|
{"type": "issues", "state": state, "limit": limit, "page": page},
|
||
|
|
) or []
|
||
|
|
if not batch:
|
||
|
|
return out
|
||
|
|
out.extend(batch)
|
||
|
|
page += 1
|
||
|
|
|
||
|
|
def labels(self) -> 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 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)
|
||
|
|
|
||
|
|
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()
|