Renders srosslink's JSON files into a static website
at main 1186 lines 33 kB view raw
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 &mdash; {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 &mdash; {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">&middot;</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">&middot;</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">&middot;</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">&larr; #{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 &mdash; {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">&middot;</span> 955 <span>{author}</span> 956 <span class="meta-sep">&middot;</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">&larr; 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 &middot; {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()