feat: roadmap from beads
This commit is contained in:
9170
roadmap.org
Normal file
9170
roadmap.org
Normal file
File diff suppressed because it is too large
Load Diff
179
scripts/gen_roadmap.py
Executable file
179
scripts/gen_roadmap.py
Executable file
@ -0,0 +1,179 @@
|
|||||||
|
#!/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()
|
||||||
Reference in New Issue
Block a user