Skip to content

Commit 0d777a0

Browse files
committed
update docs open to pr
1 parent ea50e56 commit 0d777a0

5 files changed

Lines changed: 507 additions & 0 deletions

File tree

.github/scripts/sync_roadmap.py

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
#!/usr/bin/env python3
2+
"""
3+
sync_roadmap.py — Sync ROADMAP.md checkboxes with GitHub Issues.
4+
5+
This script parses a ROADMAP.md file with a standardized format and
6+
synchronizes the state of each task with its corresponding GitHub issue.
7+
It can also auto-create issues for tasks that don't have one yet.
8+
9+
Expected ROADMAP.md format:
10+
## Phase N · Title <!-- phase:label-name -->
11+
- [ ] Task description (#123)
12+
- [x] Completed task (#124)
13+
- [/] In-progress task (#125)
14+
- [ ] New task without issue ← auto-created
15+
16+
States:
17+
[ ] → open issue, remove 'in-progress' label
18+
[/] → open issue, add 'in-progress' label
19+
[x] → close issue, remove 'in-progress' label
20+
21+
Usage:
22+
python sync_roadmap.py # uses ./ROADMAP.md
23+
python sync_roadmap.py --roadmap path/to.md # custom path
24+
python sync_roadmap.py --dry-run # preview without changes
25+
python sync_roadmap.py --repo owner/repo # explicit repo
26+
27+
Requires: gh CLI authenticated (https://cli.github.com)
28+
"""
29+
30+
import argparse
31+
import json
32+
import re
33+
import subprocess
34+
import sys
35+
from dataclasses import dataclass
36+
from enum import Enum
37+
from pathlib import Path
38+
39+
40+
class TaskState(Enum):
41+
TODO = "todo"
42+
IN_PROGRESS = "in-progress"
43+
DONE = "done"
44+
45+
46+
@dataclass
47+
class Task:
48+
title: str
49+
issue_number: int | None
50+
state: TaskState
51+
phase_label: str
52+
line_number: int
53+
54+
55+
# ── Parsing ──────────────────────────────────────────────────────────────────
56+
57+
PHASE_RE = re.compile(r"^##\s+.+<!--\s*phase:(\S+)\s*-->")
58+
TASK_WITH_ISSUE_RE = re.compile(r"^-\s+\[([ x/])\]\s+(.+?)\s+\(#(\d+)\)\s*$")
59+
TASK_WITHOUT_ISSUE_RE = re.compile(r"^-\s+\[([ x/])\]\s+(.+?)\s*$")
60+
61+
62+
def parse_roadmap(path: Path) -> list[Task]:
63+
"""Parse ROADMAP.md and extract tasks with their states."""
64+
tasks: list[Task] = []
65+
current_phase = ""
66+
67+
with open(path, encoding="utf-8") as f:
68+
for line_num, line in enumerate(f, start=1):
69+
phase_match = PHASE_RE.match(line)
70+
if phase_match:
71+
current_phase = phase_match.group(1)
72+
continue
73+
74+
# Try matching task with issue number first
75+
task_match = TASK_WITH_ISSUE_RE.match(line)
76+
if task_match:
77+
checkbox, title, issue_str = task_match.groups()
78+
issue_num = int(issue_str)
79+
else:
80+
# Try matching task without issue number
81+
task_match = TASK_WITHOUT_ISSUE_RE.match(line)
82+
if task_match and current_phase:
83+
checkbox, title = task_match.groups()
84+
issue_num = None
85+
else:
86+
continue
87+
88+
if checkbox == "x":
89+
state = TaskState.DONE
90+
elif checkbox == "/":
91+
state = TaskState.IN_PROGRESS
92+
else:
93+
state = TaskState.TODO
94+
95+
tasks.append(Task(
96+
title=title,
97+
issue_number=issue_num,
98+
state=state,
99+
phase_label=current_phase,
100+
line_number=line_num,
101+
))
102+
103+
return tasks
104+
105+
106+
# ── GitHub CLI helpers ───────────────────────────────────────────────────────
107+
108+
def gh(args: list[str], repo: str | None = None) -> str:
109+
"""Run a gh CLI command and return stdout."""
110+
cmd = ["gh"] + args
111+
if repo:
112+
cmd += ["--repo", repo]
113+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
114+
return result.stdout.strip()
115+
116+
117+
def get_issue(number: int, repo: str | None) -> dict:
118+
"""Fetch issue state and labels from GitHub."""
119+
raw = gh(["issue", "view", str(number), "--json", "state,labels,title"], repo)
120+
return json.loads(raw)
121+
122+
123+
def ensure_label_exists(label: str, repo: str | None, color: str = "ededed") -> None:
124+
"""Create a label if it doesn't exist."""
125+
try:
126+
gh(["label", "create", label, "--color", color,
127+
"--description", f"Label: {label}"], repo)
128+
except subprocess.CalledProcessError:
129+
pass # label already exists
130+
131+
132+
def find_existing_issue(title: str, repo: str | None) -> int | None:
133+
"""Search for an open or closed issue with the exact same title. Returns its number or None."""
134+
try:
135+
raw = gh(["issue", "list", "--search", f"in:title {title}",
136+
"--state", "all", "--json", "number,title", "--limit", "10"], repo)
137+
issues = json.loads(raw)
138+
for issue in issues:
139+
if issue["title"].strip() == title.strip():
140+
return issue["number"]
141+
except subprocess.CalledProcessError:
142+
pass
143+
return None
144+
145+
146+
def create_issue(title: str, label: str, repo: str | None) -> int:
147+
"""Create a new GitHub issue and return its number."""
148+
url = gh(["issue", "create",
149+
"--title", title,
150+
"--body", f"Auto-created from ROADMAP.md\n\nPhase: `{label}`",
151+
"--label", label], repo)
152+
# gh issue create prints the URL: https://github.com/owner/repo/issues/42
153+
match = re.search(r"/issues/(\d+)", url)
154+
if not match:
155+
raise RuntimeError(f"Could not parse issue number from gh output: {url}")
156+
return int(match.group(1))
157+
158+
159+
def close_issue(number: int, repo: str | None) -> None:
160+
gh(["issue", "close", str(number)], repo)
161+
162+
163+
def reopen_issue(number: int, repo: str | None) -> None:
164+
gh(["issue", "reopen", str(number)], repo)
165+
166+
167+
def add_label(number: int, label: str, repo: str | None) -> None:
168+
gh(["issue", "edit", str(number), "--add-label", label], repo)
169+
170+
171+
def remove_label(number: int, label: str, repo: str | None) -> None:
172+
try:
173+
gh(["issue", "edit", str(number), "--remove-label", label], repo)
174+
except subprocess.CalledProcessError:
175+
pass # label wasn't on the issue
176+
177+
178+
# ── Roadmap file updater ─────────────────────────────────────────────────────
179+
180+
def update_roadmap_line(path: Path, line_number: int, title: str,
181+
checkbox: str, issue_number: int) -> None:
182+
"""Update a specific line in the ROADMAP.md to include the issue number."""
183+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
184+
idx = line_number - 1
185+
lines[idx] = f"- [{checkbox}] {title} (#{issue_number})\n"
186+
path.write_text("".join(lines), encoding="utf-8")
187+
188+
189+
def git_commit_roadmap(path: Path) -> None:
190+
"""Commit the updated ROADMAP.md with auto-generated issue numbers."""
191+
subprocess.run(
192+
["git", "config", "user.name", "roadmap-sync[bot]"],
193+
capture_output=True, check=False,
194+
)
195+
subprocess.run(
196+
["git", "config", "user.email", "roadmap-sync[bot]@users.noreply.github.com"],
197+
capture_output=True, check=False,
198+
)
199+
subprocess.run(["git", "add", str(path)], capture_output=True, check=True)
200+
subprocess.run(
201+
["git", "commit", "-m",
202+
"docs: update ROADMAP.md with issue numbers [roadmap-sync]"],
203+
capture_output=True, check=True,
204+
)
205+
subprocess.run(["git", "push"], capture_output=True, check=True)
206+
207+
208+
# ── Sync logic ───────────────────────────────────────────────────────────────
209+
210+
IN_PROGRESS_LABEL = "in-progress"
211+
212+
213+
def sync_task(task: Task, repo: str | None, dry_run: bool) -> list[str]:
214+
"""Sync a single task that already has an issue. Returns actions taken."""
215+
actions: list[str] = []
216+
issue = get_issue(task.issue_number, repo)
217+
is_open = issue["state"] == "OPEN"
218+
has_in_progress = any(
219+
l["name"] == IN_PROGRESS_LABEL for l in issue.get("labels", [])
220+
)
221+
222+
if task.state == TaskState.DONE:
223+
if is_open:
224+
actions.append(f" close #{task.issue_number}")
225+
if not dry_run:
226+
close_issue(task.issue_number, repo)
227+
if has_in_progress:
228+
actions.append(f" remove '{IN_PROGRESS_LABEL}' from #{task.issue_number}")
229+
if not dry_run:
230+
remove_label(task.issue_number, IN_PROGRESS_LABEL, repo)
231+
232+
elif task.state == TaskState.IN_PROGRESS:
233+
if not is_open:
234+
actions.append(f" reopen #{task.issue_number}")
235+
if not dry_run:
236+
reopen_issue(task.issue_number, repo)
237+
if not has_in_progress:
238+
actions.append(f" add '{IN_PROGRESS_LABEL}' to #{task.issue_number}")
239+
if not dry_run:
240+
add_label(task.issue_number, IN_PROGRESS_LABEL, repo)
241+
242+
elif task.state == TaskState.TODO:
243+
if not is_open:
244+
actions.append(f" reopen #{task.issue_number}")
245+
if not dry_run:
246+
reopen_issue(task.issue_number, repo)
247+
if has_in_progress:
248+
actions.append(f" remove '{IN_PROGRESS_LABEL}' from #{task.issue_number}")
249+
if not dry_run:
250+
remove_label(task.issue_number, IN_PROGRESS_LABEL, repo)
251+
252+
return actions
253+
254+
255+
def main() -> None:
256+
parser = argparse.ArgumentParser(
257+
description="Sync ROADMAP.md checkboxes with GitHub Issues"
258+
)
259+
parser.add_argument(
260+
"--roadmap", default="ROADMAP.md",
261+
help="Path to the ROADMAP.md file (default: ./ROADMAP.md)"
262+
)
263+
parser.add_argument(
264+
"--repo", default=None,
265+
help="GitHub repo in owner/name format (default: auto-detect)"
266+
)
267+
parser.add_argument(
268+
"--dry-run", action="store_true",
269+
help="Preview changes without applying them"
270+
)
271+
args = parser.parse_args()
272+
273+
roadmap_path = Path(args.roadmap)
274+
if not roadmap_path.exists():
275+
print(f"Error: {roadmap_path} not found", file=sys.stderr)
276+
sys.exit(1)
277+
278+
tasks = parse_roadmap(roadmap_path)
279+
if not tasks:
280+
print("No tasks found in roadmap. Check the format.")
281+
sys.exit(1)
282+
283+
prefix = "[DRY RUN] " if args.dry_run else ""
284+
print(f"{prefix}Found {len(tasks)} tasks in roadmap.")
285+
print()
286+
287+
# Ensure in-progress label exists
288+
if not args.dry_run:
289+
ensure_label_exists(IN_PROGRESS_LABEL, args.repo, "fbca04")
290+
291+
# ── Phase 1: Auto-create issues for tasks without (#N) ────────────────
292+
# This includes [x] tasks — they get created and immediately closed
293+
new_tasks = [t for t in tasks if t.issue_number is None]
294+
if new_tasks:
295+
done_count = sum(1 for t in new_tasks if t.state == TaskState.DONE)
296+
open_count = len(new_tasks) - done_count
297+
print(f"{prefix}Creating {len(new_tasks)} new issue(s) "
298+
f"({open_count} open, {done_count} done → create+close)...")
299+
roadmap_modified = False
300+
for task in new_tasks:
301+
checkbox_char = {"todo": " ", "in-progress": "/", "done": "x"}[task.state.value]
302+
if args.dry_run:
303+
suffix = " → would create + close" if task.state == TaskState.DONE else ""
304+
print(f" would create: \"{task.title}\" [{task.phase_label}]{suffix}")
305+
else:
306+
# Check for existing issue with same title to avoid duplicates
307+
existing = find_existing_issue(task.title, args.repo)
308+
if existing:
309+
print(f" found existing #{existing}: \"{task.title}\" (skipping creation)")
310+
issue_num = existing
311+
else:
312+
ensure_label_exists(task.phase_label, args.repo)
313+
issue_num = create_issue(task.title, task.phase_label, args.repo)
314+
print(f" created #{issue_num}: \"{task.title}\"")
315+
316+
# If the task is already done, close the issue immediately
317+
if task.state == TaskState.DONE:
318+
close_issue(issue_num, args.repo)
319+
print(f" closed #{issue_num} (already done)")
320+
321+
task.issue_number = issue_num
322+
update_roadmap_line(
323+
roadmap_path, task.line_number,
324+
task.title, checkbox_char, issue_num
325+
)
326+
roadmap_modified = True
327+
328+
if roadmap_modified:
329+
print()
330+
print("Committing updated ROADMAP.md...")
331+
try:
332+
git_commit_roadmap(roadmap_path)
333+
print("Pushed updated ROADMAP.md with issue numbers.")
334+
except subprocess.CalledProcessError as e:
335+
print(f"Warning: could not auto-commit: {e}", file=sys.stderr)
336+
print("Please commit ROADMAP.md manually.")
337+
print()
338+
339+
# ── Phase 2: Sync state for all tasks with issue numbers ──────────────
340+
tasks_with_issues = [t for t in tasks if t.issue_number is not None]
341+
print(f"{prefix}Syncing {len(tasks_with_issues)} task(s)...")
342+
343+
total_actions = 0
344+
for task in tasks_with_issues:
345+
actions = sync_task(task, args.repo, args.dry_run)
346+
if actions:
347+
print(f"#{task.issue_number}{task.title} [{task.state.value}]")
348+
for action in actions:
349+
print(action)
350+
total_actions += len(actions)
351+
352+
print()
353+
if total_actions == 0 and not new_tasks:
354+
print("Everything is already in sync.")
355+
else:
356+
verb = "Would apply" if args.dry_run else "Applied"
357+
created = f", created {len(new_tasks)} issue(s)" if new_tasks else ""
358+
print(f"{verb} {total_actions} action(s){created}.")
359+
360+
361+
if __name__ == "__main__":
362+
main()

.github/workflows/roadmap-sync.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Sync Roadmap with Issues
2+
3+
on:
4+
push:
5+
paths:
6+
- 'ROADMAP.md'
7+
branches:
8+
- main
9+
10+
# Allow manual trigger
11+
workflow_dispatch:
12+
13+
permissions:
14+
issues: write
15+
contents: write
16+
17+
jobs:
18+
sync:
19+
runs-on: ubuntu-latest
20+
# Prevent infinite loops from auto-commits
21+
if: "!contains(github.event.head_commit.message, '[roadmap-sync]')"
22+
steps:
23+
- name: Checkout repository
24+
uses: actions/checkout@v4
25+
with:
26+
token: ${{ secrets.GITHUB_TOKEN }}
27+
28+
- name: Set up Python
29+
uses: actions/setup-python@v5
30+
with:
31+
python-version: '3.12'
32+
33+
- name: Sync roadmap with GitHub Issues
34+
env:
35+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
36+
run: python .github/scripts/sync_roadmap.py --roadmap ROADMAP.md

0 commit comments

Comments
 (0)