#!/usr/bin/env python3 """Render crosslink issue JSON files to a static HTML site.""" import json import re import sys from collections import defaultdict from datetime import datetime, timezone from html import escape from pathlib import Path # -- Styles ------------------------------------------------------------------ CSS = """ @import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&family=Karla:wght@400;500;600&display=swap'); :root { --bg: #faf8f4; --surface: #f0ece4; --surface-warm: #ebe5da; --border: #d4cdc0; --border-light: #e4ded4; --text: #1c1916; --text-muted: #7a7264; --text-light: #9c9486; --accent: #2c5ea0; --accent-hover: #1e4578; --green: #2d7a3a; --red: #b83228; --orange: #8a6518; --purple: #6b3fa0; --cyan: #1a7a8a; --kind-plan: #2c5ea0; --kind-decision: #6b3fa0; --kind-observation: #1a7a8a; --kind-blocker: #b83228; --kind-resolution: #2d7a3a; --kind-result: #2d7a3a; --kind-note: #9c9486; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Karla', sans-serif; background: var(--bg); color: var(--text); line-height: 1.7; max-width: 1120px; margin: 0 auto; padding: 3rem 1.5rem; -webkit-font-smoothing: antialiased; } a { color: var(--accent); text-decoration: none; } a:hover { color: var(--accent-hover); text-decoration: underline; } h1 { font-family: 'Newsreader', Georgia, serif; font-size: 1.75rem; font-weight: 500; margin-bottom: 1.75rem; letter-spacing: -0.01em; line-height: 1.3; } h2 { font-family: 'Karla', sans-serif; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin: 2.5rem 0 1rem; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-light); } #issue-search { width: 100%; padding: 0.6rem 0.75rem; font-family: 'Karla', sans-serif; font-size: 0.85rem; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; outline: none; margin-bottom: 1.5rem; transition: border-color 0.15s; } #issue-search::placeholder { color: var(--text-light); } #issue-search:focus { border-color: var(--accent); } .closed-section { margin-top: 2.5rem; } .closed-section > summary { cursor: pointer; list-style: none; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border-light); } .closed-section > summary::-webkit-details-marker { display: none; } .closed-section > summary::marker { display: none; content: ""; } .closed-section[open] > summary { margin-bottom: 1rem; } /* -- Navigation ---------------------------------------------------------- */ nav { display: flex; gap: 0; margin-bottom: 3rem; border-bottom: 1px solid var(--border); } nav a { padding: 0.6rem 1.25rem; color: var(--text-muted); font-size: 0.85rem; font-weight: 500; letter-spacing: 0.02em; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: color 0.15s, border-color 0.15s; } nav a:hover { color: var(--text); text-decoration: none; } nav a.active { color: var(--text); font-weight: 600; border-bottom-color: var(--text); } /* -- Tables -------------------------------------------------------------- */ table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; font-size: 0.85rem; } thead { border-bottom: 2px solid var(--border); } th { text-align: left; padding: 0.6rem 0.75rem; color: var(--text-muted); font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; } td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border-light); vertical-align: baseline; white-space: nowrap; } td:first-child { white-space: normal; word-break: break-word; } tr:last-child td { border-bottom: none; } tr:hover { background: var(--surface); } /* -- Badges -------------------------------------------------------------- */ .badge { display: inline-block; min-width: 4.5rem; padding: 0.1rem 0.55rem; border-radius: 3px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.03em; text-transform: uppercase; text-align: center; } .badge-open { background: #d4edda; color: var(--green); } .badge-closed { background: var(--surface); color: var(--text-light); } .badge-high { background: #f8d7da; color: var(--red); } .badge-medium { background: #fff3cd; color: var(--orange); } .badge-low { background: var(--surface); color: var(--text-light); } .label { display: inline-block; max-width: 8rem; padding: 0.05rem 0.5rem; border-radius: 3px; font-size: 0.65rem; font-weight: 500; letter-spacing: 0.03em; background: var(--surface); color: var(--text-muted); border: 1px solid var(--border-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; } .subtask-progress { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.7rem; color: var(--text-light); margin-left: 0.3rem; } /* -- Stats --------------------------------------------------------------- */ .stat-grid { display: flex; gap: 2.5rem; margin-bottom: 2.5rem; padding: 1.5rem 0; border-bottom: 1px solid var(--border-light); } .stat-card { text-align: left; } .stat-card .number { font-family: 'Newsreader', Georgia, serif; font-size: 2rem; font-weight: 500; line-height: 1; } .stat-card .label-text { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-light); margin-top: 0.35rem; } /* -- Issue detail -------------------------------------------------------- */ .issue-header { margin-bottom: 2.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border); } .issue-header h1 { margin-bottom: 0.75rem; font-size: 1.6rem; } .issue-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; font-size: 0.8rem; color: var(--text-muted); } .meta-sep { color: var(--border); } .issue-meta .timestamp { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.75rem; color: var(--text-light); } /* -- Comment timeline ---------------------------------------------------- */ .timeline { position: relative; padding-left: 0; } .comment { position: relative; padding: 1rem 1.25rem; margin-bottom: 0.5rem; border-left: 3px solid var(--border-light); background: transparent; transition: background 0.15s; } .comment-plan { border-left-color: var(--kind-plan); } .comment-decision { border-left-color: var(--kind-decision); } .comment-observation { border-left-color: var(--kind-observation); } .comment-blocker { border-left-color: var(--kind-blocker); } .comment-resolution { border-left-color: var(--kind-resolution); } .comment-result { border-left-color: var(--kind-result); } .comment-note { border-left-color: var(--border); } .comment-header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.5rem; font-size: 0.8rem; color: var(--text-muted); } .comment-author { font-weight: 600; color: var(--text); } .comment-kind { font-size: 0.65rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; margin-left: 0.5rem; } .comment-kind-plan { color: var(--kind-plan); } .comment-kind-decision { color: var(--kind-decision); } .comment-kind-observation { color: var(--kind-observation); } .comment-kind-blocker { color: var(--kind-blocker); } .comment-kind-resolution { color: var(--kind-resolution); } .comment-kind-result { color: var(--kind-result); } .comment-kind-note { color: var(--text-light); } .comment-time { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.7rem; color: var(--text-light); } .comment-body { font-size: 0.85rem; line-height: 1.65; word-break: break-word; color: var(--text); } .comment-body p { margin-bottom: 0.6rem; } .comment-body p:last-child { margin-bottom: 0; } .comment-body h3, .comment-body h4, .comment-body h5, .comment-body h6 { font-family: 'Karla', sans-serif; font-weight: 600; margin: 1rem 0 0.4rem; color: var(--text); } .comment-body h3 { font-size: 0.95rem; } .comment-body h4 { font-size: 0.85rem; } .comment-body h5 { font-size: 0.8rem; color: var(--text-muted); } .comment-body ul { margin: 0.4rem 0 0.6rem 1.25rem; list-style: disc; } .comment-body li { padding: 0.15rem 0; border-bottom: none; } .comment-body code { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.8rem; background: var(--surface-warm); padding: 0.1rem 0.35rem; border-radius: 3px; } .comment-body pre { background: var(--surface); border: 1px solid var(--border-light); border-radius: 4px; padding: 0.75rem 1rem; margin: 0.5rem 0 0.75rem; overflow-x: auto; } .comment-body pre code { background: none; padding: 0; font-size: 0.78rem; line-height: 1.5; } .milestone-desc p { margin-bottom: 0.4rem; } .milestone-desc p:last-child { margin-bottom: 0; } /* -- Subtasks ------------------------------------------------------------ */ .children-list { margin-top: 0.75rem; } .children-list li { list-style: none; padding: 0.4rem 0; font-size: 0.85rem; border-bottom: 1px solid var(--border-light); } .children-list li:last-child { border-bottom: none; } .children-list a { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.8rem; margin-right: 0.4rem; } /* -- Links & misc -------------------------------------------------------- */ .back-link { display: inline-block; font-size: 0.8rem; color: var(--text-light); letter-spacing: 0.01em; } .back-link:hover { color: var(--accent); } .parent-link { display: inline-block; margin-bottom: 1.5rem; font-size: 0.8rem; color: var(--text-muted); padding: 0.35rem 0.75rem; background: var(--surface); border-radius: 3px; border: 1px solid var(--border-light); } .parent-link:hover { background: var(--surface-warm); text-decoration: none; } .empty { color: var(--text-light); font-style: italic; padding: 3rem 0; text-align: center; font-size: 0.9rem; } /* -- Milestones ---------------------------------------------------------- */ .milestone { padding: 1.25rem 1.5rem; margin-bottom: 1rem; border: 1px solid var(--border-light); border-radius: 4px; background: var(--bg); transition: border-color 0.15s; } .milestone:hover { border-color: var(--border); } .milestone-header { display: flex; justify-content: space-between; align-items: baseline; cursor: pointer; list-style: none; } .milestone-header::-webkit-details-marker { display: none; } .milestone-header::marker { display: none; content: ""; } .milestone[open] > .milestone-header { margin-bottom: 0.5rem; } .milestone-name { font-family: 'Newsreader', Georgia, serif; font-size: 1.1rem; font-weight: 500; } .milestone-id { font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.75rem; color: var(--text-light); } .milestone-desc { font-size: 0.85rem; color: var(--text-muted); line-height: 1.6; } .milestone-footer { margin-top: 0.75rem; font-size: 0.7rem; color: var(--text-light); font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; } .milestone-issues { margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border-light); } .milestone-progress { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.5rem; } /* -- Agent cards --------------------------------------------------------- */ .agent-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-top: 0.75rem; } .agent-card { padding: 1.25rem; border: 1px solid var(--border-light); border-radius: 4px; transition: border-color 0.15s; } .agent-card:hover { border-color: var(--border); } .agent-name { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.5rem; } .agent-stat { font-size: 0.8rem; color: var(--text-muted); line-height: 1.8; } .agent-stat strong { font-family: 'Newsreader', Georgia, serif; font-size: 1.1rem; color: var(--text); font-weight: 500; } /* -- Footer -------------------------------------------------------------- */ .page-footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border-light); display: flex; justify-content: space-between; align-items: baseline; } .generated-at { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border-light); font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; font-size: 0.65rem; color: var(--text-light); text-align: right; } """ # -- Helpers ------------------------------------------------------------------ COMMENT_KIND_LABELS = { "plan": "plan", "decision": "decision", "observation": "observation", "blocker": "blocker", "resolution": "resolved", "result": "result", "note": "note", } # -- Markdown ----------------------------------------------------------------- _issue_link_ids = set() def _inline_md(text): """Convert inline markdown (bold, italic, code, links) within escaped HTML.""" # Inline code — must come first to protect contents from other transforms parts = re.split(r"(`[^`]+`)", text) result = [] for i, part in enumerate(parts): if i % 2 == 1: result.append(f"{escape(part[1:-1])}") else: s = part # Links: [text](url) s = re.sub( r"\[([^\]]+)\]\(([^)]+)\)", lambda m: f'{m.group(1)}', s, ) # Bold s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # Italic (single * not preceded/followed by space for disambiguation) s = re.sub(r"(?\1", s) # Issue cross-references: #N → link if issue exists def _issue_ref(m): num = int(m.group(1)) if num in _issue_link_ids: return f'#{num}' return m.group(0) s = re.sub(r"(?{code_content}") continue # Heading if stripped.startswith("#"): hashes = len(stripped) - len(stripped.lstrip("#")) hashes = min(hashes, 6) content = stripped[hashes:].strip() # Use h3-h6 inside comments (h1-h2 are page-level) level = min(hashes + 2, 6) out.append(f"{_inline_md(escape(content))}") i += 1 continue # Unordered list if stripped.startswith("- ") or stripped.startswith("* "): items = [] while i < len(lines): s = lines[i].strip() if s.startswith("- ") or s.startswith("* "): items.append(s[2:]) elif s and items: # Continuation line items[-1] += " " + s else: break i += 1 list_items = "".join( f"
  • {_inline_md(escape(item))}
  • " for item in items ) out.append(f"") continue # Blank line if not stripped: i += 1 continue # Paragraph — collect consecutive non-blank, non-special lines para_lines = [] while i < len(lines): s = lines[i].strip() if not s or s.startswith("#") or s.startswith("```") or s.startswith("- ") or s.startswith("* "): break para_lines.append(s) i += 1 content = " ".join(para_lines) out.append(f"

    {_inline_md(escape(content))}

    ") return "\n".join(out) def fmt_time(iso_str): """Format an ISO timestamp to a readable date string.""" if not iso_str: return "" dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) return dt.strftime("%Y-%m-%d %H:%M") def fmt_date(iso_str): """Format an ISO timestamp to just a date.""" if not iso_str: return "" dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) return dt.strftime("%Y-%m-%d") def priority_sort_key(issue): """Sort priority: high=0, medium=1, low=2.""" return {"high": 0, "medium": 1, "low": 2}.get(issue.get("priority") or "low", 3) _generated_at = "" def page(title, body, active="", prefix=""): """Wrap body HTML in the page shell.""" nav_items = [ ("index.html", "Issues", "index"), ("agents.html", "Agents", "agents"), ("milestones.html", "Milestones", "milestones"), ] nav_html = "\n".join( f' {label}' for href, label, key in nav_items ) return f""" {escape(title)} {body}
    Generated {_generated_at}
    """ def badge(cls, text): return f'{escape(text)}' def issue_row(issue, milestones_by_uuid, children): """Render a table row for an issue.""" did = issue["display_id"] labels = issue.get("labels", []) label_html = " ".join(f'{escape(l)}' for l in labels) date = fmt_date(issue.get("closed_at") or issue.get("created_at")) ms_html = "" ms_uuid = issue.get("milestone_uuid") if ms_uuid: ms = milestones_by_uuid.get(ms_uuid) if ms: ms_html = f'{escape(ms["name"])}' progress_html = "" child_issues = children.get(issue.get("uuid"), []) if child_issues: n_done = sum(1 for c in child_issues if c["status"] == "closed") n_total = len(child_issues) progress_html = f' ({n_done}/{n_total})' return ( f'' f'#{did} {escape(issue["title"])}{progress_html}' f'{badge(issue.get("priority", "low"), issue.get("priority", "low"))}' f'{label_html}' f'{ms_html}' f'{date}' f'' ) # -- Page renderers ----------------------------------------------------------- def render_index(issues, counters, milestones_by_uuid, children): open_issues = sorted( [i for i in issues if i["status"] == "open"], key=lambda i: (priority_sort_key(i), i.get("display_id") or 0), ) closed_issues = sorted( [i for i in issues if i["status"] == "closed"], key=lambda i: (i.get("closed_at") or "", i.get("display_id") or 0), reverse=True, ) total = len(issues) n_open = len(open_issues) n_closed = len(closed_issues) stats = f"""
    {total}
    Total
    {n_open}
    Open
    {n_closed}
    Closed
    """ def issue_table(rows, headers): if not rows: return '

    No issues.

    ' head = "" + "".join(f"{h}" for h in headers) + "" return f"{head}{''.join(rows)}
    " open_rows = [issue_row(i, milestones_by_uuid, children) for i in open_issues] closed_rows = [issue_row(i, milestones_by_uuid, children) for i in closed_issues] headers = ["Title", "Priority", "Labels", "Milestone", "Date"] body = f"""

    Issues

    {stats}

    Open — {n_open}

    {issue_table(open_rows, headers)}

    Closed — {n_closed}

    {issue_table(closed_rows, headers)}
    """ return page("Issues", body, active="index") def render_issue(issue, issues_by_uuid, children, milestones_by_uuid, valid_display_ids): """Render a single issue detail page.""" did = issue["display_id"] title = escape(issue["title"]) status = issue["status"] priority = issue.get("priority", "low") meta_parts = [ badge(status, status), badge(priority, priority), ] meta_html = " ".join(meta_parts) author = escape(issue.get("created_by", "unknown")) created = fmt_time(issue["created_at"]) closed_html = "" if issue.get("closed_at"): closed_html = f'· closed {fmt_time(issue["closed_at"])}' labels = issue.get("labels", []) labels_html = "" if labels: labels_html = '· ' + " ".join( f'{escape(l)}' for l in labels ) milestone_html = "" ms_uuid = issue.get("milestone_uuid") if ms_uuid: ms = milestones_by_uuid.get(ms_uuid) if ms: milestone_html = ( f'· ' f'{escape(ms["name"])}' ) parent_html = "" if issue.get("parent_uuid"): parent = issues_by_uuid.get(issue["parent_uuid"]) if parent: pid = parent["display_id"] parent_html = f'← #{pid} {escape(parent["title"])}' child_issues = children.get(issue["uuid"], []) children_html = "" if child_issues: items = [] for child in sorted(child_issues, key=lambda c: c["display_id"]): s = badge(child["status"], child["status"]) items.append( f'
  • {s} #{child["display_id"]} {escape(child["title"])}
  • ' ) children_html = f"""

    Subtasks

    """ comments = issue.get("comments", []) comments_html = "" if comments: entries = [] for c in sorted(comments, key=lambda x: x.get("created_at", "")): kind = c.get("kind", "note") kind_label = COMMENT_KIND_LABELS.get(kind, kind) entries.append(f"""
    {escape(c.get("author", ""))} {escape(kind_label)} {fmt_time(c.get("created_at", ""))}
    {md(c.get("content", ""))}
    """) comments_html = f"""

    Comments — {len(comments)}

    {"".join(entries)}
    """ desc_html = "" desc = issue.get("description", "").strip() if desc: desc_html = f'\n
    {md(desc)}
    ' body = f""" {parent_html}

    #{did} {title}

    {meta_html} · {author} · {created} {closed_html} {labels_html} {milestone_html}
    {desc_html} {children_html} {comments_html} """ return page(f"#{did} {title}", body, prefix="../") def render_agents(issues): """Render the agents overview page.""" agents = defaultdict(lambda: {"count": 0, "open": 0, "closed": 0, "last_active": ""}) for issue in issues: author = issue.get("created_by", "unknown") agents[author]["count"] += 1 if issue["status"] == "open": agents[author]["open"] += 1 else: agents[author]["closed"] += 1 ts = issue.get("updated_at") or issue.get("created_at", "") if ts > agents[author]["last_active"]: agents[author]["last_active"] = ts cards = [] for name in sorted(agents, key=lambda n: agents[n]["count"], reverse=True): info = agents[name] cards.append(f"""
    {escape(name)}
    {info['count']} issues
    {info['open']} open · {info['closed']} closed
    active {fmt_date(info['last_active'])}
    """) body = f"""

    Agents

    {"".join(cards)}
    """ return page("Agents", body, active="agents") def render_milestones(milestones, issues): """Render the milestones overview page.""" if not milestones: body = """

    Milestones

    No milestones defined.

    """ return page("Milestones", body, active="milestones") # Group issues by milestone_uuid by_milestone = defaultdict(list) for issue in issues: ms_uuid = issue.get("milestone_uuid") if ms_uuid: by_milestone[ms_uuid].append(issue) cards = [] for ms in sorted(milestones, key=lambda m: m.get("display_id", 0)): status = ms.get("status", "open") ms_issues = sorted(by_milestone.get(ms["uuid"], []), key=lambda i: i["display_id"]) issue_list = "" if ms_issues: n_done = sum(1 for i in ms_issues if i["status"] == "closed") n_total = len(ms_issues) items = [] for iss in ms_issues: s = badge(iss["status"], iss["status"]) items.append( f'
  • {s} #{iss["display_id"]} {escape(iss["title"])}
  • ' ) issue_list = f"""
    {n_done}/{n_total} completed
    """ cards.append(f"""
    {escape(ms.get("name", ""))} {badge(status, status)} #{ms.get("display_id", "")}
    {md(ms.get("description", ""))}
    {issue_list}
    """) body = f"""

    Milestones

    {"".join(cards)}""" return page("Milestones", body, active="milestones") # -- Main -------------------------------------------------------------------- def load_issues(issues_dir): issues = [] for path in issues_dir.glob("*.json"): with open(path) as f: issues.append(json.load(f)) return issues def load_milestones(meta_dir): ms_dir = meta_dir / "milestones" if not ms_dir.exists(): return [] milestones = [] for path in ms_dir.glob("*.json"): with open(path) as f: milestones.append(json.load(f)) return milestones def load_counters(meta_dir): counters_path = meta_dir / "counters.json" if counters_path.exists(): with open(counters_path) as f: return json.load(f) return {} def rewrite_changelog(changelog_path, base_url): """Rewrite (#N) references in CHANGELOG.md to linked versions. Turns `(#12)` into `[#12](base_url/issues/12.html)`. Only rewrites bare `(#N)` — already-linked references are left alone. """ text = changelog_path.read_text() def replace_ref(match): num = match.group(1) return f"[#{num}]({base_url}/issues/{num}.html)" # Match (#N) but not when preceded by ] (already a markdown link) rewritten = re.sub(r"(? [--rewrite-changelog --base-url ]") sys.exit(1) issues_dir = Path(sys.argv[1]) meta_dir = Path(sys.argv[2]) output_dir = Path(sys.argv[3]) if not issues_dir.exists(): print(f"Error: issues directory not found: {issues_dir}") sys.exit(1) global _generated_at _generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") all_issues = load_issues(issues_dir) issues = [i for i in all_issues if i.get("display_id") is not None] skipped = len(all_issues) - len(issues) milestones = load_milestones(meta_dir) counters = load_counters(meta_dir) issues_by_uuid = {i["uuid"]: i for i in issues} milestones_by_uuid = {m["uuid"]: m for m in milestones} _issue_link_ids.update(i["display_id"] for i in issues) children = defaultdict(list) for issue in issues: if issue.get("parent_uuid"): children[issue["parent_uuid"]].append(issue) output_dir.mkdir(parents=True, exist_ok=True) issues_out = output_dir / "issues" issues_out.mkdir(exist_ok=True) # Index with open(output_dir / "index.html", "w") as f: f.write(render_index(issues, counters, milestones_by_uuid, children)) # Individual issues for issue in issues: with open(issues_out / f"{issue['display_id']}.html", "w") as f: f.write(render_issue(issue, issues_by_uuid, children, milestones_by_uuid, _issue_link_ids)) # Agents with open(output_dir / "agents.html", "w") as f: f.write(render_agents(issues)) # Milestones with open(output_dir / "milestones.html", "w") as f: f.write(render_milestones(milestones, issues)) print(f"Rendered {len(issues)} issues to {output_dir}/") if skipped: print(f" skipped {skipped} issues with missing display_id") print(f" {sum(1 for i in issues if i['status'] == 'open')} open, " f"{sum(1 for i in issues if i['status'] == 'closed')} closed") if milestones: print(f" {len(milestones)} milestones") # Optional: rewrite CHANGELOG.md issue references args = sys.argv[4:] if "--rewrite-changelog" in args: idx = args.index("--rewrite-changelog") changelog_path = Path(args[idx + 1]) base_url = "." if "--base-url" in args: base_url = args[args.index("--base-url") + 1].rstrip("/") rewrite_changelog(changelog_path, base_url) if __name__ == "__main__": main()