Files
improvise/scripts/gen_roadmap.py
2026-04-15 22:44:47 -07:00

180 lines
6.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Generate roadmap.org from bd.
Inverted-tree layout: each tree is rooted at an issue that nothing else
(among the open set) depends on — the "epic" or standalone goal. Its
children are the things blocking it, recursively. Diamonds in the DAG
are broken into a tree by duplicating shared deps under each parent so
emacs `[/]` cookies count whole subtrees.
Usage: python3 scripts/gen_roadmap.py [output-path]
(default output: roadmap.org at the repo root)
"""
import json
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
def bd_json(*args):
"""Run `bd <args>` and parse JSON from stdout."""
cmd = ['bd', *args, '--json']
res = subprocess.run(cmd, check=True, capture_output=True, text=True)
return json.loads(res.stdout)
def main():
repo = Path(__file__).resolve().parent.parent
out_path = Path(sys.argv[1]) if len(sys.argv) > 1 else repo / 'roadmap.org'
issues = bd_json('list')
by_id = {i['id']: i for i in issues}
# `bd list --all` includes closed issues — used only to resolve titles
# of already-done dep targets so we can label them in :DONE_DEPS:.
all_issues = bd_json('list', '--all')
all_by_id = {i['id']: i for i in all_issues}
# --- Build dep graph (open-issue subgraph only) ----------------------
# child depends_on parent; in our inverted tree, parent (the goal/epic)
# sits ABOVE its deps (children).
tree_children = defaultdict(set)
tree_parents = defaultdict(set)
for i in issues:
iid = i['id']
for d in (i.get('dependencies') or []):
dep = d['depends_on_id']
if dep in by_id and iid in by_id:
tree_children[iid].add(dep)
tree_parents[dep].add(iid)
def order_key(iid):
i = by_id[iid]
return (
0 if i['status'] == 'in_progress' else 1,
i['priority'],
iid,
)
roots = sorted(
[iid for iid in by_id if not tree_parents.get(iid)],
key=order_key,
)
# --- Helpers ---------------------------------------------------------
status_kw = {
'open': 'TODO',
'in_progress': 'DOING',
'closed': 'DONE',
'blocked': 'WAIT',
'deferred': 'WAIT',
}
def tag(s):
return re.sub(r'[^A-Za-z0-9_@#%]', '_', s)
def headline_tags(i, kind):
tags = [kind, f"P{i['priority']}", tag(i['issue_type'])]
if i.get('assignee'):
tags.append('@' + tag(i['assignee']))
return ':' + ':'.join(tags) + ':'
def fmt_date(s):
return s.replace('T', ' ').rstrip('Z') if s else ''
def kind_of(iid):
return 'epic' if tree_children.get(iid) else 'standalone'
out = []
out.append('#+TITLE: Improvise Roadmap')
out.append('#+AUTHOR: Edward Langley')
out.append('#+TODO: TODO DOING WAIT | DONE')
out.append('#+STARTUP: overview')
out.append('#+TAGS: epic standalone P0 P1 P2 P3 P4 task feature bug')
out.append('#+PROPERTY: COOKIE_DATA todo recursive')
out.append('')
out.append(
f'Generated from ~bd list --json~. {len(issues)} open issues '
f'organised as {len(roots)} inverted dep-trees: each root is a goal '
'that nothing else depends on; its children are the deps blocking '
'it. Diamonds in the DAG are duplicated so each tree stands alone.'
)
out.append('')
def emit(iid, depth, path):
i = by_id[iid]
kind = kind_of(iid)
kw = status_kw.get(i['status'], 'TODO')
title = i['title'].replace('[', '(').replace(']', ')')
stars = '*' * depth
cookie = ' [/]' if tree_children.get(iid) else ''
head = f'{stars} {kw} {title} ({kind}){cookie}'
pad = max(1, 95 - len(head))
out.append(f'{head}{" " * pad}{headline_tags(i, kind)}')
indent = ' ' * (depth - 1) + ' '
out.append(f'{indent}:PROPERTIES:')
out.append(f'{indent}:ID: {iid}')
out.append(f'{indent}:TYPE: {i["issue_type"]}')
out.append(f'{indent}:PRIORITY: P{i["priority"]}')
out.append(f'{indent}:STATUS: {i["status"]}')
if i.get('assignee'):
out.append(f'{indent}:ASSIGNEE: {i["assignee"]}')
if i.get('owner'):
out.append(f'{indent}:OWNER: {i["owner"]}')
if i.get('created_by'):
out.append(f'{indent}:CREATED_BY: {i["created_by"]}')
out.append(f'{indent}:CREATED: {fmt_date(i["created_at"])}')
out.append(f'{indent}:UPDATED: {fmt_date(i["updated_at"])}')
if i.get('comment_count'):
out.append(f'{indent}:COMMENTS: {i["comment_count"]}')
out.append(f'{indent}:KIND: {kind}')
closed_deps = sorted(
d['depends_on_id']
for d in (i.get('dependencies') or [])
if (ref := all_by_id.get(d['depends_on_id'])) and ref['status'] == 'closed'
)
if closed_deps:
out.append(f'{indent}:DONE_DEPS: {", ".join(closed_deps)}')
if iid in path:
out.append(f'{indent}:CYCLE: yes — descendants pruned at this node')
out.append(f'{indent}:END:')
if iid in path:
return # cycle guard
details_stars = '*' * (depth + 1)
section_stars = '*' * (depth + 2)
section_indent = ' ' * (depth + 1) + ' '
if any(i.get(k) for k in ('description', 'design', 'acceptance_criteria', 'notes')):
out.append(f'{details_stars} Details')
for label, key in [
('Description', 'description'),
('Design', 'design'),
('Acceptance Criteria', 'acceptance_criteria'),
('Notes', 'notes'),
]:
v = i.get(key)
if not v:
continue
out.append(f'{section_stars} {label}')
for line in v.rstrip('\n').splitlines():
out.append(f'{section_indent}{line}' if line else '')
new_path = path | {iid}
for c in sorted(tree_children.get(iid, set()), key=order_key):
out.append('')
emit(c, depth + 1, new_path)
for r in roots:
emit(r, 1, frozenset())
out.append('')
out_path.write_text('\n'.join(out))
print(f'Wrote {out_path} ({len(out)} lines, {len(roots)} roots, {len(by_id)} issues)')
if __name__ == '__main__':
main()