#!/usr/bin/env python3 """Create or update a weekly roadmap health report issue.""" from __future__ import annotations import argparse import datetime as dt import json import subprocess import sys from pathlib import Path def run(cmd: list[str], expect_json: bool = False) -> dict | list | str: proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{proc.stderr.strip()}") data = proc.stdout.strip() if expect_json: return json.loads(data or "{}") return data def parse_date(value: str | None) -> dt.date | None: if not value: return None text = value.strip() if not text: return None try: if "T" in text: return dt.date.fromisoformat(text.split("T", 1)[0]) return dt.date.fromisoformat(text) except Exception: return None def fetch_open_roadmap_issues(repo: str) -> list[dict]: payload = run( [ "gh", "issue", "list", "--repo", repo, "--state", "open", "--limit", "200", "--json", "number,title,milestone,labels,url", ], expect_json=True, ) out = [] for item in payload: labels = [it.get("name", "") for it in item.get("labels", [])] if "roadmap" not in labels: continue out.append(item) return out def fetch_project_metadata(project_number: int | None, project_owner: str | None) -> tuple[dict[int, dict], str | None]: if not project_number or not project_owner: return {}, "Project-Metadaten nicht konfiguriert (owner/number fehlen)." try: payload = run( [ "gh", "project", "item-list", str(project_number), "--owner", project_owner, "--format", "json", ], expect_json=True, ) except Exception as exc: return {}, f"Project-Metadaten nicht abrufbar: {exc}" out: dict[int, dict] = {} for item in payload.get("items", []): content = item.get("content") or {} if content.get("type") != "Issue": continue number = content.get("number") if not isinstance(number, int): continue out[number] = { "target_date": item.get("target date"), "start_date": item.get("start date"), "priority": item.get("priority"), "status": item.get("status"), "window": item.get("roadmap Window"), } return out, None def build_report(issues: list[dict], project_meta: dict[int, dict], warning: str | None, upcoming_days: int) -> str: today = dt.date.today() threshold = today + dt.timedelta(days=upcoming_days) overdue = [] upcoming = [] no_deadline = [] all_rows = [] for issue in issues: number = issue["number"] meta = project_meta.get(number, {}) milestone = issue.get("milestone") or {} milestone_due = parse_date(milestone.get("dueOn")) target_date = parse_date(meta.get("target_date")) deadline = target_date or milestone_due source = "target date" if target_date else ("milestone" if milestone_due else "-") days_left = (deadline - today).days if deadline else None row = { "number": number, "title": issue["title"], "url": issue["url"], "status": meta.get("status", "Todo"), "priority": meta.get("priority", "-"), "window": meta.get("window", "-"), "milestone": milestone.get("title", "-"), "deadline": deadline.isoformat() if deadline else "-", "source": source, "days_left": days_left, } all_rows.append(row) if not deadline: no_deadline.append(row) elif deadline < today: overdue.append(row) elif deadline <= threshold: upcoming.append(row) all_rows.sort(key=lambda r: (r["deadline"] == "-", r["deadline"], r["priority"], r["number"])) overdue.sort(key=lambda r: (r["deadline"], r["number"])) upcoming.sort(key=lambda r: (r["deadline"], r["number"])) no_deadline.sort(key=lambda r: r["number"]) lines = [] lines.append(f"# Roadmap Health Report ({today.isoformat()})") lines.append("") lines.append("Automatisch generierter Reminder fuer Roadmap-Issues.") lines.append("") lines.append("## Summary") lines.append("") lines.append(f"- Open roadmap issues: **{len(issues)}**") lines.append(f"- Overdue: **{len(overdue)}**") lines.append(f"- Upcoming (naechste {upcoming_days} Tage): **{len(upcoming)}**") lines.append(f"- Ohne Deadline: **{len(no_deadline)}**") lines.append("") if warning: lines.append(f"> Hinweis: {warning}") lines.append("") def section(name: str, rows: list[dict]) -> None: lines.append(f"## {name}") lines.append("") if not rows: lines.append("_Keine Eintraege._") lines.append("") return lines.append("| Issue | Titel | Status | Prio | Window | Deadline | Quelle | Tage |") lines.append("|---|---|---|---|---|---|---|---:|") for row in rows: days = "-" if row["days_left"] is None else str(row["days_left"]) title = row["title"].replace("|", "/") lines.append( f"| [#{row['number']}]({row['url']}) | {title} | {row['status']} | {row['priority']} | " f"{row['window']} | {row['deadline']} | {row['source']} | {days} |" ) lines.append("") section("Overdue", overdue) section(f"Upcoming (<= {upcoming_days} Tage)", upcoming) section("Ohne Deadline", no_deadline) section("Alle Open Roadmap Issues", all_rows) return "\n".join(lines).strip() + "\n" def ensure_label(repo: str, label: str) -> None: run( [ "gh", "label", "create", label, "--repo", repo, "--color", "8a2be2", "--description", "Automatischer Roadmap Report", "--force", ] ) def upsert_issue(repo: str, title: str, body_file: Path, labels: list[str]) -> str: payload = run( [ "gh", "issue", "list", "--repo", repo, "--state", "open", "--search", f'in:title "{title}"', "--json", "number,title,url", "--limit", "20", ], expect_json=True, ) existing = next((item for item in payload if item.get("title") == title), None) if existing: run( [ "gh", "issue", "edit", str(existing["number"]), "--repo", repo, "--body-file", str(body_file), ] ) return existing["url"] cmd = [ "gh", "issue", "create", "--repo", repo, "--title", title, "--body-file", str(body_file), ] for label in labels: cmd.extend(["--label", label]) url = run(cmd) return str(url) def main() -> int: parser = argparse.ArgumentParser(description="Generate roadmap reminder report and upsert issue") parser.add_argument("--repo", required=True, help="OWNER/REPO") parser.add_argument("--project-owner", default="", help="Project owner login, e.g. @me or OliverGiertz") parser.add_argument("--project-number", type=int, default=0, help="Project number") parser.add_argument("--days-upcoming", type=int, default=14) parser.add_argument("--upsert-issue-title", default="[Roadmap] Weekly Health Report") parser.add_argument("--output-file", type=Path, default=Path("roadmap-health-report.md")) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() issues = fetch_open_roadmap_issues(args.repo) project_meta, warning = fetch_project_metadata( project_number=args.project_number if args.project_number > 0 else None, project_owner=args.project_owner or None, ) report = build_report(issues, project_meta, warning, args.days_upcoming) args.output_file.write_text(report, encoding="utf-8") print(f"Report written to {args.output_file}") if args.dry_run: print("[dry-run] skip issue upsert") print(report) return 0 labels = ["roadmap-report", "roadmap"] for label in labels: ensure_label(args.repo, label) issue_url = upsert_issue(args.repo, args.upsert_issue_title, args.output_file, labels) print(f"Report issue upserted: {issue_url}") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(f"[error] {exc}", file=sys.stderr) raise SystemExit(1)