#!/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 ` 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()