From e7b106548ed2019d69ff3f2f8cd433f057227ec7 Mon Sep 17 00:00:00 2001 From: Matt Parrett Date: Sat, 10 Oct 2026 19:33:00 -0700 Subject: [PATCH] contrib: git-issue-board, a self-contained HTML page over a repository's issues A read-only view for a laptop or a phone: status groups in working order, filters and search kept in the URL hash, the dependency graph as inline SVG, each card expanding to its body and comments. Python standard library only; reads the notes through --porcelain list, export and show. Co-Authored-By: Claude Fable 5.1 --- contrib/board/README.md | 35 +++ contrib/board/git-issue-board.py | 365 +++++++++++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 contrib/board/README.md create mode 100755 contrib/board/git-issue-board.py diff --git a/contrib/board/README.md b/contrib/board/README.md new file mode 100644 index 0000000..948755c --- /dev/null +++ b/contrib/board/README.md @@ -0,0 +1,35 @@ +# git-issue-board + +One self-contained HTML page over a repository's git-issue notes. git-issue +has no web view; this is a read-only one for a laptop or a phone. + +```sh +git fetch origin 'refs/notes/issue-*:refs/notes/issue-*' +contrib/board/git-issue-board.py # writes git-issue-board.html in the cwd +contrib/board/git-issue-board.py --repo ~/src/other -o /tmp/board/index.html +``` + +Python 3.8 or later, standard library only; it shells out to `git issue +--porcelain list`, `export` and `show`, so `git-issue` must be on `PATH`. + +What the page shows: + +- Issues grouped by status in working order: in progress, review, blocked, + open, deferred, closed. Priority first within a group. +- A segmented filter (Active hides closed), an assignee dropdown and a search + over id, title, body and comments. The state lives in the URL hash, so a + filtered view is a link. +- A dependency graph, as inline SVG, for the issues that take part in an edge: + solid for `blocks` and `parent_of`, dashed for `relates_to`. Boxes link to + their cards. +- Each card expands to the description, comments, author, dates and the + `git issue show` line for it. +- Light and dark follow the OS; the layout holds at 390 px. + +The page is a snapshot. Its footer says when it was generated and from which +commit; fetch and re-run to refresh. To read it on a phone, serve the output +directory with any static server (`python3 -m http.server 8080` in that +directory) and open it over the LAN or a tailnet. + +Known edge: the renderer runs one `export` per issue because `export` with no +ids prints nothing in 1.0.0-rc6 (#3); about 0.1 s per issue. diff --git a/contrib/board/git-issue-board.py b/contrib/board/git-issue-board.py new file mode 100755 index 0000000..25a492a --- /dev/null +++ b/contrib/board/git-issue-board.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Render a repository's git-issue board as one self-contained HTML page. + +git-issue keeps issues as git notes and has no web view. This reads them +through the tool's porcelain (`list`: twelve tab-separated columns per +docs/rfc/draft-ndn-porcelain-output-00.md) plus one `export` and one `show` +per issue for the description and comments, and writes a page that works +offline, on a phone, and in light or dark mode: issues grouped by status in +working order, a status/assignee/search filter kept in the URL hash so a view +is a link, a dependency graph as inline SVG for the issues that have edges, +and each card expanding to its body and comments. + + contrib/board/git-issue-board.py # the repository you are in + contrib/board/git-issue-board.py --repo PATH -o board.html + +Python 3.8+, standard library only. The page is a snapshot: its footer says +when it was generated and from which commit; fetch the notes and re-run to +refresh. Serve the output directory with any static server for a phone. +""" + +import argparse +import datetime as dt +import html +import json +import os +import re +import subprocess +import sys + +ANSI = re.compile(r"\x1b\[[0-9;]*m") +COLS = ["repo", "id", "status", "priority", "assignee", "role", "updated", + "blocks", "depends_on", "parent_of", "relates_to", "title"] +# Display order: what is moving first, what is finished last. +STATUS_ORDER = ["in_progress", "review", "blocked", "open", "deferred", "closed"] +PRIO_RANK = {"critical": 0, "high": 1, "medium": 2, "low": 3} + + +def gi(clone, *args): + out = subprocess.run(["git", "-C", clone, "issue", *args], capture_output=True, + text=True, env={**os.environ, "GIT_ISSUE_NO_HEADER": "1"}) + return ANSI.sub("", out.stdout) + + +def read_issues(clone): + issues = {} + for line in gi(clone, "--porcelain", "list").splitlines(): + parts = line.split("\t") + if len(parts) != len(COLS): + continue + rec = dict(zip(COLS, parts)) + for k in ("blocks", "depends_on", "parent_of", "relates_to"): + rec[k] = [x for x in rec[k].split(",") if x] + issues[rec["id"]] = rec + # One export per id: in 1.0.0-rc6 `export` with no ids prints nothing (#3). + # show carries the comments, export the clean fields. + for iid, rec in issues.items(): + fields = {} + for line in gi(clone, "export", iid).splitlines(): + if line.startswith("---"): + break + m = re.match(r"^([a-z_]+): ?(.*)$", line) + if m: + fields[m.group(1)] = m.group(2) + elif line.startswith(" ") and fields: + last = list(fields)[-1] + fields[last] += "\n" + line.strip() + rec["description"] = fields.get("description", "") + rec["author"] = fields.get("author", "") + rec["created"] = fields.get("created", "") + show = gi(clone, "show", iid) + rec["comments"] = show.split("Comments:\n---------\n", 1)[1].strip() if "Comments:\n" in show else "" + return issues + + +def head_info(clone): + sha = subprocess.run(["git", "-C", clone, "rev-parse", "--short", "HEAD"], + capture_output=True, text=True).stdout.strip() + remote = subprocess.run(["git", "-C", clone, "remote", "get-url", "origin"], + capture_output=True, text=True).stdout.strip() + remote = re.sub(r"^(https://github\.com/|git@github\.com:)|\.git$", "", remote) or os.path.basename(clone) + return sha, remote + + +# --- dependency graph as inline SVG -------------------------------------------- +# Only issues that take part in an edge are drawn; a 40-node graph with five +# edges is a list with a picture attached, not a picture. Layers come from the +# longest path over blocks/parent_of edges; relates_to is drawn dashed and does +# not affect layering. + +def layout(issues): + hard, soft = [], [] + for rec in issues.values(): + for t in rec["blocks"]: + if t in issues: + hard.append((rec["id"], t)) + for t in rec["parent_of"]: + if t in issues: + hard.append((rec["id"], t)) + for t in rec["relates_to"]: + if t in issues and (t, rec["id"]) not in soft: + soft.append((rec["id"], t)) + nodes = sorted({n for e in hard + soft for n in e}) + if not nodes: + return None + layer = {n: 0 for n in nodes} + for _ in range(len(nodes)): + changed = False + for a, b in hard: + if layer[b] < layer[a] + 1: + layer[b] = layer[a] + 1 + changed = True + if not changed: + break + # Components side by side: relates_to pairs with no hard edges would + # otherwise all sit in layer 0 as one tall column. + cols = {} + for n in nodes: + cols.setdefault(layer[n], []).append(n) + W, H, GX, GY, PAD = 230, 44, 60, 16, 12 + pos = {} + for l, ns in cols.items(): + for i, n in enumerate(sorted(ns, key=lambda x: issues[x]["title"])): + pos[n] = (PAD + l * (W + GX), PAD + i * (H + GY)) + width = PAD * 2 + (max(cols) + 1) * W + max(cols) * GX + height = PAD * 2 + max(len(v) for v in cols.values()) * (H + GY) - GY + out = [f'', + '' + ''] + def edge(a, b, dashed): + ax, ay = pos[a]; bx, by = pos[b] + x1, y1 = ax + W, ay + H / 2 + x2, y2 = bx, by + H / 2 + if bx == ax: # same column: join the facing edges, straight down or up + x1 = x2 = ax + W / 2 + if by > ay: + y1, y2 = ay + H, by + else: + y1, y2 = ay, by + H + d = f"M{x1},{y1} L{x2},{y2}" + elif bx < ax: # backwards: loop under both boxes + x1, x2 = ax + W / 2, bx + W / 2 + y1 = y2 = max(ay, by) + H + d = f"M{x1},{y1} C{x1},{y1 + 40} {x2},{y2 + 40} {x2},{y2}" + else: + mx = (x1 + x2) / 2 + d = f"M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}" + cls = "edge soft" if dashed else "edge" + out.append(f'') + for a, b in hard: + edge(a, b, False) + for a, b in soft: + edge(a, b, True) + for n in nodes: + x, y = pos[n] + rec = issues[n] + t = html.escape(rec["title"]) + short = t if len(t) <= 34 else html.escape(rec["title"][:33]) + "…" + out.append(f'' + f'#{n}' + f'{short}{t}') + out.append("") + return "\n".join(out) + + +# --- page ---------------------------------------------------------------------- + +CSS = """ +:root{color-scheme:light; + --ink:#141a24;--muted:#5b6675;--faint:#8a94a3;--line:#d3dae3; + --bg:#f5f7fa;--card:#ffffff;--card2:#fcfdff; + --ok:#15803d;--ok-bg:#e9f6ec;--ok-line:#8fd0a3; + --active:#1d4ed8;--active-bg:#eaf0fe;--active-line:#9db8f6; + --defer:#475569;--defer-bg:#eef1f5; + --block:#b91c1c;--block-bg:#fdecec; + --warn:#b45309;--warn-bg:#fdf3e1;} +@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark; + --ink:#e6e9ee;--muted:#9aa4b2;--faint:#6b7686;--line:#2b333f; + --bg:#0e1218;--card:#161b22;--card2:#1a2029; + --ok:#56d98a;--ok-bg:#11291b;--ok-line:#255f3c; + --active:#7ea2ff;--active-bg:#16233f;--active-line:#2f4b8f; + --defer:#9aa4b2;--defer-bg:#1c222b; + --block:#f87171;--block-bg:#3a1515; + --warn:#fbbf24;--warn-bg:#3a2a0a;}} +:root[data-theme="dark"]{color-scheme:dark; + --ink:#e6e9ee;--muted:#9aa4b2;--faint:#6b7686;--line:#2b333f; + --bg:#0e1218;--card:#161b22;--card2:#1a2029; + --ok:#56d98a;--ok-bg:#11291b;--ok-line:#255f3c; + --active:#7ea2ff;--active-bg:#16233f;--active-line:#2f4b8f; + --defer:#9aa4b2;--defer-bg:#1c222b; + --block:#f87171;--block-bg:#3a1515; + --warn:#fbbf24;--warn-bg:#3a2a0a;} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--ink); + font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif} +.mono,code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums} +header{background:var(--card);border-bottom:1px solid var(--line);padding:10px 16px; + display:flex;gap:14px;align-items:center;flex-wrap:wrap;position:sticky;top:0;z-index:2} +h1{font-size:15px;margin:0;font-weight:600;white-space:nowrap} +.scope{font-size:11px;color:var(--faint);border:1px solid var(--line);border-radius:99px;padding:2px 9px} +.counts{color:var(--muted);font-size:12px} +.controls{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-left:auto} +input[type=search]{padding:5px 9px;border:1px solid var(--line);border-radius:7px;background:var(--card2); + color:var(--ink);min-width:170px;font-size:13px} +.seg{display:inline-flex;gap:1px;padding:2px;background:var(--card2);border:1px solid var(--line);border-radius:8px;flex-wrap:wrap} +.seg button{appearance:none;border:0;background:transparent;color:var(--muted);font:600 11px/1 inherit; + font-family:inherit;padding:5px 8px;border-radius:6px;cursor:pointer} +.seg button[aria-pressed="true"]{background:var(--active-bg);color:var(--active)} +main{padding:16px;max-width:1100px;margin:0 auto} +section.group,section.graphbox{margin-bottom:22px} +section.group h2,section.graphbox h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin:0 0 8px; + display:flex;gap:8px;align-items:center} +section.group h2 .n{color:var(--muted);font-weight:500} +.issue{background:var(--card);border:1px solid var(--line);border-radius:9px;margin-bottom:8px;padding:0} +.issue summary{list-style:none;cursor:pointer;padding:10px 12px;display:grid; + grid-template-columns:auto 1fr auto;gap:6px 10px;align-items:baseline} +.issue summary::-webkit-details-marker{display:none} +.issue .id{color:var(--faint);font-size:12px} +.issue .title{font-weight:500} +.issue .meta{grid-column:2/4;display:flex;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--muted)} +.badge{font-size:11px;padding:1px 7px;border-radius:99px;border:1px solid var(--line);white-space:nowrap} +.p-high,.p-critical{color:var(--block);border-color:var(--block);background:var(--block-bg)} +.p-medium{color:var(--warn);border-color:var(--warn);background:var(--warn-bg)} +.p-low{color:var(--defer);background:var(--defer-bg)} +.st{font-size:11px;padding:1px 7px;border-radius:99px;white-space:nowrap} +.st-in_progress{background:var(--active-bg);color:var(--active)} +.st-review{background:var(--active-bg);color:var(--active)} +.st-blocked{background:var(--block-bg);color:var(--block)} +.st-open{background:var(--card2);color:var(--muted);border:1px solid var(--line)} +.st-deferred{background:var(--defer-bg);color:var(--defer)} +.st-closed{background:var(--ok-bg);color:var(--ok)} +.body{padding:0 12px 12px;border-top:1px solid var(--line);color:var(--ink)} +.body p{margin:10px 0;white-space:pre-wrap;overflow-wrap:anywhere} +.body .kv{font-size:12px;color:var(--muted);display:flex;gap:14px;flex-wrap:wrap;margin-top:8px} +.body a{color:var(--active);text-decoration:none} +.comments{margin-top:8px;padding:8px 10px;background:var(--card2);border-radius:7px;font-size:13px;white-space:pre-wrap} +.issue:target{outline:2px solid var(--active);outline-offset:1px} +.issue.hide{display:none} +#graph{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:12px;overflow:auto;margin-bottom:22px} +#graph svg{display:block;max-width:none} +#graph .edge{fill:none;stroke:var(--block);stroke-width:1.6;color:var(--block)} +#graph .edge.soft{stroke:var(--muted);stroke-dasharray:5 4;color:var(--muted)} +#graph .node rect{fill:var(--card2);stroke:var(--line);stroke-width:1.2} +#graph .node.st-in_progress rect{stroke:var(--active-line);fill:var(--active-bg)} +#graph .node.st-blocked rect{stroke:var(--block);fill:var(--block-bg)} +#graph .node.st-closed rect{stroke:var(--ok-line);fill:var(--ok-bg)} +#graph text{fill:var(--ink);font-size:12px} +#graph .nid{fill:var(--faint);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px} +footer{color:var(--faint);font-size:11px;padding:12px 16px 28px;text-align:center} +footer code{font-size:11px} +@media (max-width:600px){main{padding:10px}.controls{margin-left:0}} +""" + +JS = """ +const Q=document.getElementById('q'),SEG=document.getElementById('seg'),A=document.getElementById('asg'); +let status='active',asg=''; +function apply(){ + const q=Q.value.trim().toLowerCase(); + let shown=0; + document.querySelectorAll('.issue').forEach(el=>{ + const st=el.dataset.status; + const okS=status==='all'||(status==='active'?st!=='closed':st===status); + const okA=!asg||el.dataset.assignee===asg; + const okQ=!q||el.dataset.text.includes(q); + const ok=okS&&okA&&okQ; el.classList.toggle('hide',!ok); if(ok)shown++; + }); + document.querySelectorAll('section.group').forEach(g=>{ + const n=g.querySelectorAll('.issue:not(.hide)').length; + g.style.display=n?'':'none'; g.querySelector('.n').textContent=n; + }); + document.getElementById('shown').textContent=shown; + SEG.querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.v===status)); + const h=new URLSearchParams(); if(status!=='active')h.set('status',status); if(q)h.set('q',q); if(asg)h.set('asg',asg); + history.replaceState(null,'',h.toString()?'#'+h:location.pathname); +} +SEG.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return;status=b.dataset.v;apply();}); +Q.addEventListener('input',apply); A.addEventListener('change',()=>{asg=A.value;apply();}); +(function(){const h=new URLSearchParams(location.hash.slice(1)); + if(h.get('status'))status=h.get('status'); if(h.get('q'))Q.value=h.get('q'); if(h.get('asg')){asg=h.get('asg');A.value=asg;} + if(location.hash.startsWith('#i-')){status='all';} + apply();})(); +""" + + +def short_name(s): + # "Norman Nunley, Jr" -> "Norman"; keeps the full name in the title attribute. + return s.split(",")[0].split()[0] if s else "—" + + +def render(issues, graph, sha, remote, clone): + now = dt.datetime.now(dt.timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M %Z") + counts = {s: sum(1 for r in issues.values() if r["status"] == s) for s in STATUS_ORDER} + assignees = sorted({r["assignee"] for r in issues.values() if r["assignee"]}) + parts = [f"" + f"" + f"{html.escape(remote.split('/')[-1])} issues", + f"

{html.escape(remote.split('/')[-1])} issues

git-issue · {html.escape(remote)}", + f"0 shown · {len(issues)} total · " + + " · ".join(f"{counts[s]} {s.replace('_', ' ')}" for s in STATUS_ORDER if counts[s]) + "", + "
" + + "".join(f"" for v, l in + [("active", "Active"), ("in_progress", "In progress"), ("blocked", "Blocked"), + ("open", "Open"), ("deferred", "Deferred"), ("closed", "Closed"), ("all", "All")]) + + "
"] + if graph: + parts.append("

Dependencies solid = blocks · dashed = relates to · click a box

" + f"
{graph}
") + def link_ids(ids): + return ", ".join(f"#{i}" for i in ids) + for st in STATUS_ORDER: + rows = [r for r in issues.values() if r["status"] == st] + if not rows: + continue + rows.sort(key=lambda r: (PRIO_RANK.get(r["priority"], 9), r["title"].lower())) + parts.append(f"

{st.replace('_', ' ')} {len(rows)}

") + for r in rows: + text = html.escape(" ".join([r["id"], r["title"], r["description"], r["comments"], r["assignee"]]).lower(), quote=True) + upd = r["updated"][:10] + deps = [] + if r["blocks"]: deps.append("blocks " + link_ids(r["blocks"])) + if r["depends_on"]: deps.append("depends on " + link_ids(r["depends_on"])) + if r["parent_of"]: deps.append("parent of " + link_ids(r["parent_of"])) + if r["relates_to"]: deps.append("relates to " + link_ids(r["relates_to"])) + parts.append( + f"
" + f"#{r['id']}{html.escape(r['title'])}" + f"{r['priority']}" + f"{st.replace('_', ' ')}" + f"{html.escape(short_name(r['assignee']))}" + f"updated {upd}" + (f"{' · '.join(deps)}" if deps else "") + "" + f"

{html.escape(r['description']) or 'no description'}

" + + (f"
{html.escape(r['comments'])}
" if r["comments"] else "") + + f"
author {html.escape(r['author'])}created {r['created'][:10]}" + f"git issue show {r['id']}
") + parts.append("
") + parts.append(f"
generated {now} from {len(issues)} issue notes in {html.escape(clone)} at {sha} · " + f"refresh: git fetch origin 'refs/notes/issue-*:refs/notes/issue-*' && git-issue-board.py
" + f"") + return "".join(parts) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--repo", default=".", help="path inside the repository (default: cwd)") + ap.add_argument("-o", "--out", default="git-issue-board.html") + a = ap.parse_args() + top = subprocess.run(["git", "-C", a.repo, "rev-parse", "--show-toplevel"], capture_output=True, text=True) + if top.returncode != 0: + sys.exit(f"git-issue-board: {a.repo} is not inside a git repository") + clone = top.stdout.strip() + issues = read_issues(clone) + if not issues: + sys.exit(f"git-issue-board: no issues in {clone} (git fetch origin 'refs/notes/issue-*:refs/notes/issue-*' first?)") + sha, remote = head_info(clone) + page = render(issues, layout(issues), sha, remote, clone) + if os.path.dirname(a.out): + os.makedirs(os.path.dirname(a.out), exist_ok=True) + with open(a.out, "w") as f: + f.write(page) + print(f"git-issue-board: {len(issues)} issues -> {a.out} ({len(page) // 1024} KB)") + + +if __name__ == "__main__": + main()