Renders srosslink's JSON files into a static website
1#!/usr/bin/env python3
2"""Render crosslink issue JSON files to a static HTML site."""
3
4import json
5import re
6import sys
7from collections import defaultdict
8from datetime import datetime, timezone
9from html import escape
10from pathlib import Path
11
12# -- Styles ------------------------------------------------------------------
13
14CSS = """
15@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');
16
17:root {
18 --bg: #faf8f4;
19 --surface: #f0ece4;
20 --surface-warm: #ebe5da;
21 --border: #d4cdc0;
22 --border-light: #e4ded4;
23 --text: #1c1916;
24 --text-muted: #7a7264;
25 --text-light: #9c9486;
26 --accent: #2c5ea0;
27 --accent-hover: #1e4578;
28 --green: #2d7a3a;
29 --red: #b83228;
30 --orange: #8a6518;
31 --purple: #6b3fa0;
32 --cyan: #1a7a8a;
33 --kind-plan: #2c5ea0;
34 --kind-decision: #6b3fa0;
35 --kind-observation: #1a7a8a;
36 --kind-blocker: #b83228;
37 --kind-resolution: #2d7a3a;
38 --kind-result: #2d7a3a;
39 --kind-note: #9c9486;
40}
41
42* { margin: 0; padding: 0; box-sizing: border-box; }
43
44body {
45 font-family: 'Karla', sans-serif;
46 background: var(--bg);
47 color: var(--text);
48 line-height: 1.7;
49 max-width: 1120px;
50 margin: 0 auto;
51 padding: 3rem 1.5rem;
52 -webkit-font-smoothing: antialiased;
53}
54
55a { color: var(--accent); text-decoration: none; }
56a:hover { color: var(--accent-hover); text-decoration: underline; }
57
58h1 {
59 font-family: 'Newsreader', Georgia, serif;
60 font-size: 1.75rem;
61 font-weight: 500;
62 margin-bottom: 1.75rem;
63 letter-spacing: -0.01em;
64 line-height: 1.3;
65}
66
67h2 {
68 font-family: 'Karla', sans-serif;
69 font-size: 0.75rem;
70 font-weight: 600;
71 text-transform: uppercase;
72 letter-spacing: 0.08em;
73 color: var(--text-muted);
74 margin: 2.5rem 0 1rem;
75 padding-bottom: 0.5rem;
76 border-bottom: 1px solid var(--border-light);
77}
78
79#issue-search {
80 width: 100%;
81 padding: 0.6rem 0.75rem;
82 font-family: 'Karla', sans-serif;
83 font-size: 0.85rem;
84 background: var(--bg);
85 color: var(--text);
86 border: 1px solid var(--border);
87 border-radius: 4px;
88 outline: none;
89 margin-bottom: 1.5rem;
90 transition: border-color 0.15s;
91}
92
93#issue-search::placeholder { color: var(--text-light); }
94#issue-search:focus { border-color: var(--accent); }
95
96.closed-section {
97 margin-top: 2.5rem;
98}
99
100.closed-section > summary {
101 cursor: pointer;
102 list-style: none;
103 padding-bottom: 0.5rem;
104 border-bottom: 1px solid var(--border-light);
105}
106
107.closed-section > summary::-webkit-details-marker { display: none; }
108.closed-section > summary::marker { display: none; content: ""; }
109
110.closed-section[open] > summary { margin-bottom: 1rem; }
111
112/* -- Navigation ---------------------------------------------------------- */
113
114nav {
115 display: flex;
116 gap: 0;
117 margin-bottom: 3rem;
118 border-bottom: 1px solid var(--border);
119}
120
121nav a {
122 padding: 0.6rem 1.25rem;
123 color: var(--text-muted);
124 font-size: 0.85rem;
125 font-weight: 500;
126 letter-spacing: 0.02em;
127 border-bottom: 2px solid transparent;
128 margin-bottom: -1px;
129 transition: color 0.15s, border-color 0.15s;
130}
131
132nav a:hover {
133 color: var(--text);
134 text-decoration: none;
135}
136
137nav a.active {
138 color: var(--text);
139 font-weight: 600;
140 border-bottom-color: var(--text);
141}
142
143/* -- Tables -------------------------------------------------------------- */
144
145table {
146 width: 100%;
147 border-collapse: collapse;
148 margin-bottom: 1.5rem;
149 font-size: 0.85rem;
150}
151
152thead { border-bottom: 2px solid var(--border); }
153
154th {
155 text-align: left;
156 padding: 0.6rem 0.75rem;
157 color: var(--text-muted);
158 font-weight: 600;
159 font-size: 0.7rem;
160 text-transform: uppercase;
161 letter-spacing: 0.06em;
162}
163
164td {
165 padding: 0.6rem 0.75rem;
166 border-bottom: 1px solid var(--border-light);
167 vertical-align: baseline;
168 white-space: nowrap;
169}
170
171td:first-child {
172 white-space: normal;
173 word-break: break-word;
174}
175
176tr:last-child td { border-bottom: none; }
177tr:hover { background: var(--surface); }
178
179
180/* -- Badges -------------------------------------------------------------- */
181
182.badge {
183 display: inline-block;
184 min-width: 4.5rem;
185 padding: 0.1rem 0.55rem;
186 border-radius: 3px;
187 font-size: 0.7rem;
188 font-weight: 600;
189 letter-spacing: 0.03em;
190 text-transform: uppercase;
191 text-align: center;
192}
193
194.badge-open { background: #d4edda; color: var(--green); }
195.badge-closed { background: var(--surface); color: var(--text-light); }
196.badge-high { background: #f8d7da; color: var(--red); }
197.badge-medium { background: #fff3cd; color: var(--orange); }
198.badge-low { background: var(--surface); color: var(--text-light); }
199
200.label {
201 display: inline-block;
202 max-width: 8rem;
203 padding: 0.05rem 0.5rem;
204 border-radius: 3px;
205 font-size: 0.65rem;
206 font-weight: 500;
207 letter-spacing: 0.03em;
208 background: var(--surface);
209 color: var(--text-muted);
210 border: 1px solid var(--border-light);
211 overflow: hidden;
212 text-overflow: ellipsis;
213 white-space: nowrap;
214 vertical-align: middle;
215}
216
217.subtask-progress {
218 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
219 font-size: 0.7rem;
220 color: var(--text-light);
221 margin-left: 0.3rem;
222}
223
224/* -- Stats --------------------------------------------------------------- */
225
226.stat-grid {
227 display: flex;
228 gap: 2.5rem;
229 margin-bottom: 2.5rem;
230 padding: 1.5rem 0;
231 border-bottom: 1px solid var(--border-light);
232}
233
234.stat-card { text-align: left; }
235
236.stat-card .number {
237 font-family: 'Newsreader', Georgia, serif;
238 font-size: 2rem;
239 font-weight: 500;
240 line-height: 1;
241}
242
243.stat-card .label-text {
244 font-size: 0.7rem;
245 font-weight: 600;
246 text-transform: uppercase;
247 letter-spacing: 0.06em;
248 color: var(--text-light);
249 margin-top: 0.35rem;
250}
251
252/* -- Issue detail -------------------------------------------------------- */
253
254.issue-header {
255 margin-bottom: 2.5rem;
256 padding-bottom: 1.5rem;
257 border-bottom: 1px solid var(--border);
258}
259
260.issue-header h1 {
261 margin-bottom: 0.75rem;
262 font-size: 1.6rem;
263}
264
265.issue-meta {
266 display: flex;
267 flex-wrap: wrap;
268 gap: 0.75rem;
269 align-items: center;
270 font-size: 0.8rem;
271 color: var(--text-muted);
272}
273
274.meta-sep { color: var(--border); }
275
276.issue-meta .timestamp {
277 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
278 font-size: 0.75rem;
279 color: var(--text-light);
280}
281
282/* -- Comment timeline ---------------------------------------------------- */
283
284.timeline {
285 position: relative;
286 padding-left: 0;
287}
288
289.comment {
290 position: relative;
291 padding: 1rem 1.25rem;
292 margin-bottom: 0.5rem;
293 border-left: 3px solid var(--border-light);
294 background: transparent;
295 transition: background 0.15s;
296}
297
298
299.comment-plan { border-left-color: var(--kind-plan); }
300.comment-decision { border-left-color: var(--kind-decision); }
301.comment-observation { border-left-color: var(--kind-observation); }
302.comment-blocker { border-left-color: var(--kind-blocker); }
303.comment-resolution { border-left-color: var(--kind-resolution); }
304.comment-result { border-left-color: var(--kind-result); }
305.comment-note { border-left-color: var(--border); }
306
307.comment-header {
308 display: flex;
309 justify-content: space-between;
310 align-items: baseline;
311 margin-bottom: 0.5rem;
312 font-size: 0.8rem;
313 color: var(--text-muted);
314}
315
316.comment-author { font-weight: 600; color: var(--text); }
317
318.comment-kind {
319 font-size: 0.65rem;
320 font-weight: 600;
321 text-transform: uppercase;
322 letter-spacing: 0.06em;
323 margin-left: 0.5rem;
324}
325
326.comment-kind-plan { color: var(--kind-plan); }
327.comment-kind-decision { color: var(--kind-decision); }
328.comment-kind-observation { color: var(--kind-observation); }
329.comment-kind-blocker { color: var(--kind-blocker); }
330.comment-kind-resolution { color: var(--kind-resolution); }
331.comment-kind-result { color: var(--kind-result); }
332.comment-kind-note { color: var(--text-light); }
333
334.comment-time {
335 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
336 font-size: 0.7rem;
337 color: var(--text-light);
338}
339
340.comment-body {
341 font-size: 0.85rem;
342 line-height: 1.65;
343 word-break: break-word;
344 color: var(--text);
345}
346
347.comment-body p { margin-bottom: 0.6rem; }
348.comment-body p:last-child { margin-bottom: 0; }
349
350.comment-body h3, .comment-body h4, .comment-body h5, .comment-body h6 {
351 font-family: 'Karla', sans-serif;
352 font-weight: 600;
353 margin: 1rem 0 0.4rem;
354 color: var(--text);
355}
356
357.comment-body h3 { font-size: 0.95rem; }
358.comment-body h4 { font-size: 0.85rem; }
359.comment-body h5 { font-size: 0.8rem; color: var(--text-muted); }
360
361.comment-body ul {
362 margin: 0.4rem 0 0.6rem 1.25rem;
363 list-style: disc;
364}
365
366.comment-body li {
367 padding: 0.15rem 0;
368 border-bottom: none;
369}
370
371.comment-body code {
372 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
373 font-size: 0.8rem;
374 background: var(--surface-warm);
375 padding: 0.1rem 0.35rem;
376 border-radius: 3px;
377}
378
379.comment-body pre {
380 background: var(--surface);
381 border: 1px solid var(--border-light);
382 border-radius: 4px;
383 padding: 0.75rem 1rem;
384 margin: 0.5rem 0 0.75rem;
385 overflow-x: auto;
386}
387
388.comment-body pre code {
389 background: none;
390 padding: 0;
391 font-size: 0.78rem;
392 line-height: 1.5;
393}
394
395.milestone-desc p { margin-bottom: 0.4rem; }
396.milestone-desc p:last-child { margin-bottom: 0; }
397
398/* -- Subtasks ------------------------------------------------------------ */
399
400.children-list { margin-top: 0.75rem; }
401
402.children-list li {
403 list-style: none;
404 padding: 0.4rem 0;
405 font-size: 0.85rem;
406 border-bottom: 1px solid var(--border-light);
407}
408
409.children-list li:last-child { border-bottom: none; }
410
411.children-list a {
412 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
413 font-size: 0.8rem;
414 margin-right: 0.4rem;
415}
416
417/* -- Links & misc -------------------------------------------------------- */
418
419.back-link {
420 display: inline-block;
421 font-size: 0.8rem;
422 color: var(--text-light);
423 letter-spacing: 0.01em;
424}
425
426.back-link:hover { color: var(--accent); }
427
428.parent-link {
429 display: inline-block;
430 margin-bottom: 1.5rem;
431 font-size: 0.8rem;
432 color: var(--text-muted);
433 padding: 0.35rem 0.75rem;
434 background: var(--surface);
435 border-radius: 3px;
436 border: 1px solid var(--border-light);
437}
438
439.parent-link:hover {
440 background: var(--surface-warm);
441 text-decoration: none;
442}
443
444.empty {
445 color: var(--text-light);
446 font-style: italic;
447 padding: 3rem 0;
448 text-align: center;
449 font-size: 0.9rem;
450}
451
452/* -- Milestones ---------------------------------------------------------- */
453
454.milestone {
455 padding: 1.25rem 1.5rem;
456 margin-bottom: 1rem;
457 border: 1px solid var(--border-light);
458 border-radius: 4px;
459 background: var(--bg);
460 transition: border-color 0.15s;
461}
462
463.milestone:hover { border-color: var(--border); }
464
465.milestone-header {
466 display: flex;
467 justify-content: space-between;
468 align-items: baseline;
469 cursor: pointer;
470 list-style: none;
471}
472
473.milestone-header::-webkit-details-marker { display: none; }
474.milestone-header::marker { display: none; content: ""; }
475
476.milestone[open] > .milestone-header { margin-bottom: 0.5rem; }
477
478.milestone-name {
479 font-family: 'Newsreader', Georgia, serif;
480 font-size: 1.1rem;
481 font-weight: 500;
482}
483
484.milestone-id {
485 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
486 font-size: 0.75rem;
487 color: var(--text-light);
488}
489
490.milestone-desc {
491 font-size: 0.85rem;
492 color: var(--text-muted);
493 line-height: 1.6;
494}
495
496.milestone-footer {
497 margin-top: 0.75rem;
498 font-size: 0.7rem;
499 color: var(--text-light);
500 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
501}
502
503.milestone-issues {
504 margin-top: 1rem;
505 padding-top: 0.75rem;
506 border-top: 1px solid var(--border-light);
507}
508
509.milestone-progress {
510 font-size: 0.7rem;
511 font-weight: 600;
512 text-transform: uppercase;
513 letter-spacing: 0.06em;
514 color: var(--text-muted);
515 margin-bottom: 0.5rem;
516}
517
518/* -- Agent cards --------------------------------------------------------- */
519
520.agent-grid {
521 display: grid;
522 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
523 gap: 1rem;
524 margin-top: 0.75rem;
525}
526
527.agent-card {
528 padding: 1.25rem;
529 border: 1px solid var(--border-light);
530 border-radius: 4px;
531 transition: border-color 0.15s;
532}
533
534.agent-card:hover { border-color: var(--border); }
535
536.agent-name {
537 font-weight: 600;
538 font-size: 0.95rem;
539 margin-bottom: 0.5rem;
540}
541
542.agent-stat {
543 font-size: 0.8rem;
544 color: var(--text-muted);
545 line-height: 1.8;
546}
547
548.agent-stat strong {
549 font-family: 'Newsreader', Georgia, serif;
550 font-size: 1.1rem;
551 color: var(--text);
552 font-weight: 500;
553}
554
555/* -- Footer -------------------------------------------------------------- */
556
557.page-footer {
558 margin-top: 4rem;
559 padding-top: 1.5rem;
560 border-top: 1px solid var(--border-light);
561 display: flex;
562 justify-content: space-between;
563 align-items: baseline;
564}
565
566.generated-at {
567 margin-top: 3rem;
568 padding-top: 1rem;
569 border-top: 1px solid var(--border-light);
570 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
571 font-size: 0.65rem;
572 color: var(--text-light);
573 text-align: right;
574}
575"""
576
577# -- Helpers ------------------------------------------------------------------
578
579
580COMMENT_KIND_LABELS = {
581 "plan": "plan",
582 "decision": "decision",
583 "observation": "observation",
584 "blocker": "blocker",
585 "resolution": "resolved",
586 "result": "result",
587 "note": "note",
588}
589
590
591# -- Markdown -----------------------------------------------------------------
592
593
594_issue_link_ids = set()
595
596
597def _inline_md(text):
598 """Convert inline markdown (bold, italic, code, links) within escaped HTML."""
599 # Inline code — must come first to protect contents from other transforms
600 parts = re.split(r"(`[^`]+`)", text)
601 result = []
602 for i, part in enumerate(parts):
603 if i % 2 == 1:
604 result.append(f"<code>{escape(part[1:-1])}</code>")
605 else:
606 s = part
607 # Links: [text](url)
608 s = re.sub(
609 r"\[([^\]]+)\]\(([^)]+)\)",
610 lambda m: f'<a href="{escape(m.group(2))}">{m.group(1)}</a>',
611 s,
612 )
613 # Bold
614 s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
615 # Italic (single * not preceded/followed by space for disambiguation)
616 s = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", s)
617 # Issue cross-references: #N → link if issue exists
618 def _issue_ref(m):
619 num = int(m.group(1))
620 if num in _issue_link_ids:
621 return f'<a href="{num}.html">#{num}</a>'
622 return m.group(0)
623 s = re.sub(r"(?<![&\w])#(\d+)\b", _issue_ref, s)
624 result.append(s)
625 return "".join(result)
626
627
628def md(text):
629 """Convert markdown text to HTML. Handles blocks and inline formatting."""
630 lines = text.split("\n")
631 out = []
632 i = 0
633
634 while i < len(lines):
635 line = lines[i]
636 stripped = line.strip()
637
638 # Fenced code block
639 if stripped.startswith("```"):
640 lang = stripped[3:].strip()
641 code_lines = []
642 i += 1
643 while i < len(lines) and not lines[i].strip().startswith("```"):
644 code_lines.append(lines[i])
645 i += 1
646 i += 1 # skip closing ```
647 code_content = escape("\n".join(code_lines))
648 cls = f' class="lang-{escape(lang)}"' if lang else ""
649 out.append(f"<pre><code{cls}>{code_content}</code></pre>")
650 continue
651
652 # Heading
653 if stripped.startswith("#"):
654 hashes = len(stripped) - len(stripped.lstrip("#"))
655 hashes = min(hashes, 6)
656 content = stripped[hashes:].strip()
657 # Use h3-h6 inside comments (h1-h2 are page-level)
658 level = min(hashes + 2, 6)
659 out.append(f"<h{level}>{_inline_md(escape(content))}</h{level}>")
660 i += 1
661 continue
662
663 # Unordered list
664 if stripped.startswith("- ") or stripped.startswith("* "):
665 items = []
666 while i < len(lines):
667 s = lines[i].strip()
668 if s.startswith("- ") or s.startswith("* "):
669 items.append(s[2:])
670 elif s and items:
671 # Continuation line
672 items[-1] += " " + s
673 else:
674 break
675 i += 1
676 list_items = "".join(
677 f"<li>{_inline_md(escape(item))}</li>" for item in items
678 )
679 out.append(f"<ul>{list_items}</ul>")
680 continue
681
682 # Blank line
683 if not stripped:
684 i += 1
685 continue
686
687 # Paragraph — collect consecutive non-blank, non-special lines
688 para_lines = []
689 while i < len(lines):
690 s = lines[i].strip()
691 if not s or s.startswith("#") or s.startswith("```") or s.startswith("- ") or s.startswith("* "):
692 break
693 para_lines.append(s)
694 i += 1
695 content = " ".join(para_lines)
696 out.append(f"<p>{_inline_md(escape(content))}</p>")
697
698 return "\n".join(out)
699
700
701def fmt_time(iso_str):
702 """Format an ISO timestamp to a readable date string."""
703 if not iso_str:
704 return ""
705 dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
706 return dt.strftime("%Y-%m-%d %H:%M")
707
708
709def fmt_date(iso_str):
710 """Format an ISO timestamp to just a date."""
711 if not iso_str:
712 return ""
713 dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
714 return dt.strftime("%Y-%m-%d")
715
716
717def priority_sort_key(issue):
718 """Sort priority: high=0, medium=1, low=2."""
719 return {"high": 0, "medium": 1, "low": 2}.get(issue.get("priority") or "low", 3)
720
721
722_generated_at = ""
723
724
725def page(title, body, active="", prefix=""):
726 """Wrap body HTML in the page shell."""
727 nav_items = [
728 ("index.html", "Issues", "index"),
729 ("agents.html", "Agents", "agents"),
730 ("milestones.html", "Milestones", "milestones"),
731 ]
732 nav_html = "\n".join(
733 f' <a href="{prefix}{href}" class="{"active" if key == active else ""}">{label}</a>'
734 for href, label, key in nav_items
735 )
736 return f"""<!DOCTYPE html>
737<html lang="en">
738<head>
739 <meta charset="utf-8">
740 <meta name="viewport" content="width=device-width, initial-scale=1">
741 <title>{escape(title)}</title>
742 <style>{CSS}</style>
743</head>
744<body>
745 <nav>
746{nav_html}
747 </nav>
748{body}
749 <div class="generated-at">Generated {_generated_at}</div>
750</body>
751</html>"""
752
753
754def badge(cls, text):
755 return f'<span class="badge badge-{cls}">{escape(text)}</span>'
756
757
758def issue_row(issue, milestones_by_uuid, children):
759 """Render a table row for an issue."""
760 did = issue["display_id"]
761 labels = issue.get("labels", [])
762 label_html = " ".join(f'<span class="label">{escape(l)}</span>' for l in labels)
763 date = fmt_date(issue.get("closed_at") or issue.get("created_at"))
764
765 ms_html = ""
766 ms_uuid = issue.get("milestone_uuid")
767 if ms_uuid:
768 ms = milestones_by_uuid.get(ms_uuid)
769 if ms:
770 ms_html = f'<a class="label" href="milestones.html#milestone-{ms["display_id"]}">{escape(ms["name"])}</a>'
771
772 progress_html = ""
773 child_issues = children.get(issue.get("uuid"), [])
774 if child_issues:
775 n_done = sum(1 for c in child_issues if c["status"] == "closed")
776 n_total = len(child_issues)
777 progress_html = f' <span class="subtask-progress">({n_done}/{n_total})</span>'
778
779 return (
780 f'<tr>'
781 f'<td><a href="issues/{did}.html">#{did} {escape(issue["title"])}</a>{progress_html}</td>'
782 f'<td>{badge(issue.get("priority", "low"), issue.get("priority", "low"))}</td>'
783 f'<td>{label_html}</td>'
784 f'<td>{ms_html}</td>'
785 f'<td><span class="timestamp">{date}</span></td>'
786 f'</tr>'
787 )
788
789
790# -- Page renderers -----------------------------------------------------------
791
792
793def render_index(issues, counters, milestones_by_uuid, children):
794 open_issues = sorted(
795 [i for i in issues if i["status"] == "open"],
796 key=lambda i: (priority_sort_key(i), i.get("display_id") or 0),
797 )
798 closed_issues = sorted(
799 [i for i in issues if i["status"] == "closed"],
800 key=lambda i: (i.get("closed_at") or "", i.get("display_id") or 0),
801 reverse=True,
802 )
803
804 total = len(issues)
805 n_open = len(open_issues)
806 n_closed = len(closed_issues)
807
808 stats = f"""
809 <div class="stat-grid">
810 <div class="stat-card">
811 <div class="number">{total}</div>
812 <div class="label-text">Total</div>
813 </div>
814 <div class="stat-card">
815 <div class="number" style="color: var(--green)">{n_open}</div>
816 <div class="label-text">Open</div>
817 </div>
818 <div class="stat-card">
819 <div class="number">{n_closed}</div>
820 <div class="label-text">Closed</div>
821 </div>
822 </div>"""
823
824 def issue_table(rows, headers):
825 if not rows:
826 return '<p class="empty">No issues.</p>'
827 head = "<tr>" + "".join(f"<th>{h}</th>" for h in headers) + "</tr>"
828 return f"<table><thead>{head}</thead><tbody>{''.join(rows)}</tbody></table>"
829
830 open_rows = [issue_row(i, milestones_by_uuid, children) for i in open_issues]
831 closed_rows = [issue_row(i, milestones_by_uuid, children) for i in closed_issues]
832
833 headers = ["Title", "Priority", "Labels", "Milestone", "Date"]
834
835 body = f"""
836 <h1>Issues</h1>
837 <input type="text" id="issue-search" placeholder="Filter issues..." autocomplete="off">
838{stats}
839 <h2>Open — {n_open}</h2>
840 {issue_table(open_rows, headers)}
841 <details class="closed-section">
842 <summary><h2 style="display: inline; border: none; margin: 0; padding: 0;">Closed — {n_closed}</h2></summary>
843 {issue_table(closed_rows, headers)}
844 </details>
845 <script>
846 const input = document.getElementById('issue-search');
847 input.addEventListener('input', () => {{
848 const q = input.value.toLowerCase();
849 document.querySelectorAll('tbody tr').forEach(row => {{
850 row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
851 }});
852 if (q && !document.querySelector('.closed-section').open) {{
853 document.querySelector('.closed-section').open = true;
854 }}
855 }});
856 </script>"""
857
858 return page("Issues", body, active="index")
859
860
861def render_issue(issue, issues_by_uuid, children, milestones_by_uuid, valid_display_ids):
862 """Render a single issue detail page."""
863 did = issue["display_id"]
864 title = escape(issue["title"])
865 status = issue["status"]
866 priority = issue.get("priority", "low")
867
868 meta_parts = [
869 badge(status, status),
870 badge(priority, priority),
871 ]
872 meta_html = " ".join(meta_parts)
873
874 author = escape(issue.get("created_by", "unknown"))
875 created = fmt_time(issue["created_at"])
876 closed_html = ""
877 if issue.get("closed_at"):
878 closed_html = f'<span class="meta-sep">·</span> closed <span class="timestamp">{fmt_time(issue["closed_at"])}</span>'
879
880 labels = issue.get("labels", [])
881 labels_html = ""
882 if labels:
883 labels_html = '<span class="meta-sep">·</span> ' + " ".join(
884 f'<span class="label">{escape(l)}</span>' for l in labels
885 )
886
887 milestone_html = ""
888 ms_uuid = issue.get("milestone_uuid")
889 if ms_uuid:
890 ms = milestones_by_uuid.get(ms_uuid)
891 if ms:
892 milestone_html = (
893 f'<span class="meta-sep">·</span> '
894 f'<a class="label" href="../milestones.html#milestone-{ms["display_id"]}">{escape(ms["name"])}</a>'
895 )
896
897 parent_html = ""
898 if issue.get("parent_uuid"):
899 parent = issues_by_uuid.get(issue["parent_uuid"])
900 if parent:
901 pid = parent["display_id"]
902 parent_html = f'<a class="parent-link" href="{pid}.html">← #{pid} {escape(parent["title"])}</a>'
903
904 child_issues = children.get(issue["uuid"], [])
905 children_html = ""
906 if child_issues:
907 items = []
908 for child in sorted(child_issues, key=lambda c: c["display_id"]):
909 s = badge(child["status"], child["status"])
910 items.append(
911 f'<li>{s} <a href="{child["display_id"]}.html">#{child["display_id"]}</a> {escape(child["title"])}</li>'
912 )
913 children_html = f"""
914 <h2>Subtasks</h2>
915 <ul class="children-list">
916 {"".join(items)}
917 </ul>"""
918
919 comments = issue.get("comments", [])
920 comments_html = ""
921 if comments:
922 entries = []
923 for c in sorted(comments, key=lambda x: x.get("created_at", "")):
924 kind = c.get("kind", "note")
925 kind_label = COMMENT_KIND_LABELS.get(kind, kind)
926 entries.append(f"""
927 <div class="comment comment-{escape(kind)}">
928 <div class="comment-header">
929 <span>
930 <span class="comment-author">{escape(c.get("author", ""))}</span>
931 <span class="comment-kind comment-kind-{escape(kind)}">{escape(kind_label)}</span>
932 </span>
933 <span class="comment-time">{fmt_time(c.get("created_at", ""))}</span>
934 </div>
935 <div class="comment-body">{md(c.get("content", ""))}</div>
936 </div>""")
937 comments_html = f"""
938 <h2>Comments — {len(comments)}</h2>
939 <div class="timeline">
940 {"".join(entries)}
941 </div>"""
942
943 desc_html = ""
944 desc = issue.get("description", "").strip()
945 if desc:
946 desc_html = f'\n <div class="comment-body" style="margin-bottom: 2rem;">{md(desc)}</div>'
947
948 body = f"""
949 {parent_html}
950 <div class="issue-header">
951 <h1>#{did} {title}</h1>
952 <div class="issue-meta">
953 {meta_html}
954 <span class="meta-sep">·</span>
955 <span>{author}</span>
956 <span class="meta-sep">·</span>
957 <span class="timestamp">{created}</span>
958 {closed_html}
959 {labels_html}
960 {milestone_html}
961 </div>
962 </div>{desc_html}
963{children_html}
964{comments_html}
965 <div class="page-footer">
966 <a class="back-link" href="../index.html">← All issues</a>
967 </div>"""
968
969 return page(f"#{did} {title}", body, prefix="../")
970
971
972def render_agents(issues):
973 """Render the agents overview page."""
974 agents = defaultdict(lambda: {"count": 0, "open": 0, "closed": 0, "last_active": ""})
975 for issue in issues:
976 author = issue.get("created_by", "unknown")
977 agents[author]["count"] += 1
978 if issue["status"] == "open":
979 agents[author]["open"] += 1
980 else:
981 agents[author]["closed"] += 1
982 ts = issue.get("updated_at") or issue.get("created_at", "")
983 if ts > agents[author]["last_active"]:
984 agents[author]["last_active"] = ts
985
986 cards = []
987 for name in sorted(agents, key=lambda n: agents[n]["count"], reverse=True):
988 info = agents[name]
989 cards.append(f"""
990 <div class="agent-card">
991 <div class="agent-name">{escape(name)}</div>
992 <div class="agent-stat"><strong>{info['count']}</strong> issues</div>
993 <div class="agent-stat">{info['open']} open · {info['closed']} closed</div>
994 <div class="agent-stat" style="margin-top: 0.25rem;">
995 <span class="timestamp">active {fmt_date(info['last_active'])}</span>
996 </div>
997 </div>""")
998
999 body = f"""
1000 <h1>Agents</h1>
1001 <div class="agent-grid">
1002 {"".join(cards)}
1003 </div>"""
1004
1005 return page("Agents", body, active="agents")
1006
1007
1008def render_milestones(milestones, issues):
1009 """Render the milestones overview page."""
1010 if not milestones:
1011 body = """
1012 <h1>Milestones</h1>
1013 <p class="empty">No milestones defined.</p>"""
1014 return page("Milestones", body, active="milestones")
1015
1016 # Group issues by milestone_uuid
1017 by_milestone = defaultdict(list)
1018 for issue in issues:
1019 ms_uuid = issue.get("milestone_uuid")
1020 if ms_uuid:
1021 by_milestone[ms_uuid].append(issue)
1022
1023 cards = []
1024 for ms in sorted(milestones, key=lambda m: m.get("display_id", 0)):
1025 status = ms.get("status", "open")
1026 ms_issues = sorted(by_milestone.get(ms["uuid"], []), key=lambda i: i["display_id"])
1027
1028 issue_list = ""
1029 if ms_issues:
1030 n_done = sum(1 for i in ms_issues if i["status"] == "closed")
1031 n_total = len(ms_issues)
1032 items = []
1033 for iss in ms_issues:
1034 s = badge(iss["status"], iss["status"])
1035 items.append(
1036 f'<li>{s} <a href="issues/{iss["display_id"]}.html">#{iss["display_id"]}</a> {escape(iss["title"])}</li>'
1037 )
1038 issue_list = f"""
1039 <div class="milestone-issues">
1040 <div class="milestone-progress">{n_done}/{n_total} completed</div>
1041 <ul class="children-list">{"".join(items)}</ul>
1042 </div>"""
1043
1044 cards.append(f"""
1045 <details class="milestone" id="milestone-{ms.get("display_id", "")}">
1046 <summary class="milestone-header">
1047 <span class="milestone-name">{escape(ms.get("name", ""))}</span>
1048 <span>{badge(status, status)} <span class="milestone-id">#{ms.get("display_id", "")}</span></span>
1049 </summary>
1050 <div class="milestone-desc">{md(ms.get("description", ""))}</div>
1051{issue_list}
1052 <div class="milestone-footer">{fmt_date(ms.get("created_at", ""))}</div>
1053 </details>""")
1054
1055 body = f"""
1056 <h1>Milestones</h1>
1057 {"".join(cards)}"""
1058
1059 return page("Milestones", body, active="milestones")
1060
1061
1062# -- Main --------------------------------------------------------------------
1063
1064
1065def load_issues(issues_dir):
1066 issues = []
1067 for path in issues_dir.glob("*.json"):
1068 with open(path) as f:
1069 issues.append(json.load(f))
1070 return issues
1071
1072
1073def load_milestones(meta_dir):
1074 ms_dir = meta_dir / "milestones"
1075 if not ms_dir.exists():
1076 return []
1077 milestones = []
1078 for path in ms_dir.glob("*.json"):
1079 with open(path) as f:
1080 milestones.append(json.load(f))
1081 return milestones
1082
1083
1084def load_counters(meta_dir):
1085 counters_path = meta_dir / "counters.json"
1086 if counters_path.exists():
1087 with open(counters_path) as f:
1088 return json.load(f)
1089 return {}
1090
1091
1092def rewrite_changelog(changelog_path, base_url):
1093 """Rewrite (#N) references in CHANGELOG.md to linked versions.
1094
1095 Turns `(#12)` into `[#12](base_url/issues/12.html)`.
1096 Only rewrites bare `(#N)` — already-linked references are left alone.
1097 """
1098 text = changelog_path.read_text()
1099
1100 def replace_ref(match):
1101 num = match.group(1)
1102 return f"[#{num}]({base_url}/issues/{num}.html)"
1103
1104 # Match (#N) but not when preceded by ] (already a markdown link)
1105 rewritten = re.sub(r"(?<!\])\(#(\d+)\)", replace_ref, text)
1106
1107 if rewritten != text:
1108 changelog_path.write_text(rewritten)
1109 count = len(re.findall(r"(?<!\])\(#(\d+)\)", text))
1110 print(f"Rewrote {count} issue references in {changelog_path}")
1111 else:
1112 print(f"No bare issue references found in {changelog_path}")
1113
1114
1115def main():
1116 if len(sys.argv) < 4:
1117 print("Usage: python3 render.py <issues-dir> <meta-dir> <output-dir> [--rewrite-changelog <path> --base-url <url>]")
1118 sys.exit(1)
1119
1120 issues_dir = Path(sys.argv[1])
1121 meta_dir = Path(sys.argv[2])
1122 output_dir = Path(sys.argv[3])
1123
1124 if not issues_dir.exists():
1125 print(f"Error: issues directory not found: {issues_dir}")
1126 sys.exit(1)
1127
1128 global _generated_at
1129 _generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
1130
1131 all_issues = load_issues(issues_dir)
1132 issues = [i for i in all_issues if i.get("display_id") is not None]
1133 skipped = len(all_issues) - len(issues)
1134 milestones = load_milestones(meta_dir)
1135 counters = load_counters(meta_dir)
1136
1137 issues_by_uuid = {i["uuid"]: i for i in issues}
1138 milestones_by_uuid = {m["uuid"]: m for m in milestones}
1139 _issue_link_ids.update(i["display_id"] for i in issues)
1140 children = defaultdict(list)
1141 for issue in issues:
1142 if issue.get("parent_uuid"):
1143 children[issue["parent_uuid"]].append(issue)
1144
1145 output_dir.mkdir(parents=True, exist_ok=True)
1146 issues_out = output_dir / "issues"
1147 issues_out.mkdir(exist_ok=True)
1148
1149 # Index
1150 with open(output_dir / "index.html", "w") as f:
1151 f.write(render_index(issues, counters, milestones_by_uuid, children))
1152
1153 # Individual issues
1154 for issue in issues:
1155 with open(issues_out / f"{issue['display_id']}.html", "w") as f:
1156 f.write(render_issue(issue, issues_by_uuid, children, milestones_by_uuid, _issue_link_ids))
1157
1158 # Agents
1159 with open(output_dir / "agents.html", "w") as f:
1160 f.write(render_agents(issues))
1161
1162 # Milestones
1163 with open(output_dir / "milestones.html", "w") as f:
1164 f.write(render_milestones(milestones, issues))
1165
1166 print(f"Rendered {len(issues)} issues to {output_dir}/")
1167 if skipped:
1168 print(f" skipped {skipped} issues with missing display_id")
1169 print(f" {sum(1 for i in issues if i['status'] == 'open')} open, "
1170 f"{sum(1 for i in issues if i['status'] == 'closed')} closed")
1171 if milestones:
1172 print(f" {len(milestones)} milestones")
1173
1174 # Optional: rewrite CHANGELOG.md issue references
1175 args = sys.argv[4:]
1176 if "--rewrite-changelog" in args:
1177 idx = args.index("--rewrite-changelog")
1178 changelog_path = Path(args[idx + 1])
1179 base_url = "."
1180 if "--base-url" in args:
1181 base_url = args[args.index("--base-url") + 1].rstrip("/")
1182 rewrite_changelog(changelog_path, base_url)
1183
1184
1185if __name__ == "__main__":
1186 main()