@julioborges/gantry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/gantry/SKILL.md +166 -0
- package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
- package/.agents/skills/gantry/capabilities/codex.json +14 -0
- package/.agents/skills/gantry/capabilities/opencode.json +15 -0
- package/.agents/skills/gantry/dashboard/static/app.js +100 -0
- package/.agents/skills/gantry/dashboard/static/index.html +16 -0
- package/.agents/skills/gantry/dashboard/static/style.css +74 -0
- package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
- package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
- package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
- package/.agents/skills/gantry/hooks/git/pre-push +123 -0
- package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
- package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
- package/.agents/skills/gantry/reference/round-workflow.md +755 -0
- package/.agents/skills/gantry/schemas/critic.json +93 -0
- package/.agents/skills/gantry/schemas/implementer.json +52 -0
- package/.agents/skills/gantry/schemas/learner.json +35 -0
- package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
- package/.agents/skills/gantry/schemas/planner.json +64 -0
- package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
- package/.agents/skills/gantry/schemas/reviewer.json +52 -0
- package/.agents/skills/gantry/scripts/acceptance.py +66 -0
- package/.agents/skills/gantry/scripts/budget.py +162 -0
- package/.agents/skills/gantry/scripts/cleanup.py +186 -0
- package/.agents/skills/gantry/scripts/common.py +361 -0
- package/.agents/skills/gantry/scripts/dashboard.py +233 -0
- package/.agents/skills/gantry/scripts/frontier.py +192 -0
- package/.agents/skills/gantry/scripts/gates.py +401 -0
- package/.agents/skills/gantry/scripts/guard.py +568 -0
- package/.agents/skills/gantry/scripts/learner.py +99 -0
- package/.agents/skills/gantry/scripts/result.py +104 -0
- package/.agents/skills/gantry/scripts/roadmap.py +212 -0
- package/.agents/skills/gantry/scripts/runlog.py +491 -0
- package/.agents/skills/gantry/scripts/setup.py +139 -0
- package/.agents/skills/gantry/scripts/spec.py +252 -0
- package/.agents/skills/gantry/templates/issue.md +32 -0
- package/.agents/skills/gantry/templates/prd.md +26 -0
- package/.agents/skills/gantry/templates/spec.md +48 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
- package/.agents/skills/gantry-setup/SKILL.md +30 -0
- package/LICENSE +201 -0
- package/README.md +437 -0
- package/bin/gantry.mjs +45 -0
- package/package.json +36 -0
- package/scripts/ensure-npm-author.mjs +29 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Print or execute an explicit cleanup plan for merged done Issue worktrees."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
12
|
+
from common import issue_branch, load_policy_issues, repo_root, resolve_policy # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
|
|
16
|
+
"""Run Git in the repository and retain output for a caller-facing error."""
|
|
17
|
+
return subprocess.run(
|
|
18
|
+
["git", *args],
|
|
19
|
+
cwd=root,
|
|
20
|
+
text=True,
|
|
21
|
+
capture_output=True,
|
|
22
|
+
check=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def git_output(root: Path, *args: str) -> str:
|
|
27
|
+
result = git(root, *args)
|
|
28
|
+
if result.returncode:
|
|
29
|
+
raise ValueError((result.stderr or result.stdout).strip())
|
|
30
|
+
return result.stdout
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_branch(root: Path) -> str:
|
|
34
|
+
branch = git_output(root, "branch", "--show-current").strip()
|
|
35
|
+
if not branch:
|
|
36
|
+
raise ValueError("cleanup requires a checked-out run branch; use --run-branch from a branch checkout")
|
|
37
|
+
return branch
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def worktrees(root: Path) -> dict[str, str]:
|
|
41
|
+
"""Map checked-out local branches to their registered worktree paths."""
|
|
42
|
+
result: dict[str, str] = {}
|
|
43
|
+
path: str | None = None
|
|
44
|
+
for line in git_output(root, "worktree", "list", "--porcelain").splitlines():
|
|
45
|
+
if line.startswith("worktree "):
|
|
46
|
+
path = line.removeprefix("worktree ")
|
|
47
|
+
elif line.startswith("branch refs/heads/") and path:
|
|
48
|
+
result[line.removeprefix("branch refs/heads/")] = path
|
|
49
|
+
elif not line:
|
|
50
|
+
path = None
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def branch_exists(root: Path, branch: str) -> bool:
|
|
55
|
+
return git(root, "show-ref", "--verify", "--quiet", f"refs/heads/{branch}").returncode == 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def merged_into_run(root: Path, branch: str, run_branch: str) -> bool:
|
|
59
|
+
return git(root, "merge-base", "--is-ancestor", branch, run_branch).returncode == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def branch_head(root: Path, branch: str) -> str:
|
|
63
|
+
return git_output(root, "rev-parse", "--verify", branch).strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cleanup_plan(root: Path, run_branch: str) -> dict:
|
|
67
|
+
"""Plan cleanup only for done Issue branches merged into the run branch."""
|
|
68
|
+
policy = resolve_policy(root)
|
|
69
|
+
checked_out = worktrees(root)
|
|
70
|
+
candidates: list[tuple[str, str]] = []
|
|
71
|
+
for issue in load_policy_issues(root).values():
|
|
72
|
+
branch = issue_branch(policy, issue)
|
|
73
|
+
if issue.done and branch != run_branch and branch_exists(root, branch) and merged_into_run(root, branch, run_branch):
|
|
74
|
+
candidates.append((issue.ref, branch))
|
|
75
|
+
|
|
76
|
+
branches = [{"issue": ref, "name": branch} for ref, branch in sorted(candidates)]
|
|
77
|
+
planned_worktrees = [
|
|
78
|
+
{"issue": ref, "path": checked_out[branch], "branch": branch}
|
|
79
|
+
for ref, branch in sorted(candidates)
|
|
80
|
+
if branch in checked_out
|
|
81
|
+
]
|
|
82
|
+
return {
|
|
83
|
+
"run_branch": run_branch,
|
|
84
|
+
"worktrees": planned_worktrees,
|
|
85
|
+
"branches": branches,
|
|
86
|
+
"state": {
|
|
87
|
+
"run_head": branch_head(root, run_branch),
|
|
88
|
+
"branch_heads": [
|
|
89
|
+
{"name": branch, "head": branch_head(root, branch)}
|
|
90
|
+
for _, branch in sorted(candidates)
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def execute_plan(root: Path, plan: dict) -> tuple[dict[str, list[str]], list[str]]:
|
|
97
|
+
"""Remove the exact worktrees then branches in an authorized plan."""
|
|
98
|
+
removed = {"worktrees": [], "branches": []}
|
|
99
|
+
errors: list[str] = []
|
|
100
|
+
for item in plan["worktrees"]:
|
|
101
|
+
result = git(root, "worktree", "remove", "--", item["path"])
|
|
102
|
+
if result.returncode:
|
|
103
|
+
errors.append(f"could not remove worktree {item['path']}: {(result.stderr or result.stdout).strip()}")
|
|
104
|
+
else:
|
|
105
|
+
removed["worktrees"].append(item["path"])
|
|
106
|
+
if errors:
|
|
107
|
+
return removed, errors
|
|
108
|
+
for item in plan["branches"]:
|
|
109
|
+
result = git(root, "branch", "-d", "--", item["name"])
|
|
110
|
+
if result.returncode:
|
|
111
|
+
errors.append(f"could not remove branch {item['name']}: {(result.stderr or result.stdout).strip()}")
|
|
112
|
+
else:
|
|
113
|
+
removed["branches"].append(item["name"])
|
|
114
|
+
return removed, errors
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_authorized_plan(path: str) -> dict:
|
|
118
|
+
"""Read the exact cleanup plan accepted by the operator."""
|
|
119
|
+
try:
|
|
120
|
+
plan = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
121
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
122
|
+
raise ValueError(f"could not read authorized cleanup plan: {exc}") from exc
|
|
123
|
+
if not isinstance(plan, dict):
|
|
124
|
+
raise ValueError("authorized cleanup plan must be a JSON object")
|
|
125
|
+
return plan
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main() -> int:
|
|
129
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
130
|
+
action = parser.add_mutually_exclusive_group()
|
|
131
|
+
action.add_argument("--plan", action="store_true", help="print the read-only cleanup plan (default)")
|
|
132
|
+
action.add_argument("--yes", action="store_true", help="execute an unchanged authorized cleanup plan")
|
|
133
|
+
parser.add_argument("--plan-file", help="JSON output from the authorized --plan invocation; required with --yes")
|
|
134
|
+
parser.add_argument("--run-branch", help="run branch used to determine whether Issue branches are merged")
|
|
135
|
+
parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
|
|
136
|
+
parser.add_argument("--json", action="store_true", help="emit the cleanup plan as JSON")
|
|
137
|
+
args = parser.parse_args()
|
|
138
|
+
|
|
139
|
+
root = repo_root(Path(args.cwd))
|
|
140
|
+
try:
|
|
141
|
+
run_branch = args.run_branch or current_branch(root)
|
|
142
|
+
current_plan = cleanup_plan(root, run_branch)
|
|
143
|
+
if args.plan_file and not args.yes:
|
|
144
|
+
raise ValueError("--plan-file is only valid with --yes")
|
|
145
|
+
if args.yes:
|
|
146
|
+
if not args.plan_file:
|
|
147
|
+
raise ValueError("--yes requires --plan-file from the authorized --plan output")
|
|
148
|
+
plan = load_authorized_plan(args.plan_file)
|
|
149
|
+
if plan != current_plan:
|
|
150
|
+
raise ValueError("authorized cleanup plan no longer matches the repository state")
|
|
151
|
+
else:
|
|
152
|
+
plan = current_plan
|
|
153
|
+
except ValueError as error:
|
|
154
|
+
payload = {"error": str(error)}
|
|
155
|
+
print(json.dumps(payload, indent=2) if args.json else f"ERROR: {payload['error']}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
if args.yes:
|
|
159
|
+
removed, errors = execute_plan(root, plan)
|
|
160
|
+
plan["removed"] = removed
|
|
161
|
+
if errors:
|
|
162
|
+
plan["errors"] = errors
|
|
163
|
+
if args.json:
|
|
164
|
+
print(json.dumps(plan, indent=2))
|
|
165
|
+
else:
|
|
166
|
+
print(f"run branch: {plan['run_branch']}")
|
|
167
|
+
for item in plan["worktrees"]:
|
|
168
|
+
print(f"worktree: {item['path']} ({item['branch']}, {item['issue']})")
|
|
169
|
+
for item in plan["branches"]:
|
|
170
|
+
print(f"branch: {item['name']} ({item['issue']})")
|
|
171
|
+
if not plan["worktrees"] and not plan["branches"]:
|
|
172
|
+
print("nothing eligible for cleanup")
|
|
173
|
+
if args.yes:
|
|
174
|
+
for path in plan["removed"]["worktrees"]:
|
|
175
|
+
print(f"removed worktree: {path}")
|
|
176
|
+
for branch in plan["removed"]["branches"]:
|
|
177
|
+
print(f"removed branch: {branch}")
|
|
178
|
+
for error in plan.get("errors", []):
|
|
179
|
+
print(f"ERROR: {error}", file=sys.stderr)
|
|
180
|
+
if plan.get("errors"):
|
|
181
|
+
return 1
|
|
182
|
+
return 0 if plan["worktrees"] or plan["branches"] else 2
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
sys.exit(main())
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Shared parsing and policy helpers for the Gantry workflow scripts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
DEFAULT_POLICY = {
|
|
13
|
+
"artifacts": {
|
|
14
|
+
"specs": ".scratch/{slug}/spec.md",
|
|
15
|
+
"issues": ".scratch/{slug}/issues",
|
|
16
|
+
"adrs": "docs/adr",
|
|
17
|
+
"decisions": "docs/adr",
|
|
18
|
+
"context": "CONTEXT.md",
|
|
19
|
+
"issueTracker": "docs/agents/issue-tracker.md",
|
|
20
|
+
},
|
|
21
|
+
"templates": {"dir": ".gantry/templates", "headingMap": {}},
|
|
22
|
+
"checks": [],
|
|
23
|
+
"git": {
|
|
24
|
+
"target": "main",
|
|
25
|
+
"prefix": "gantry/",
|
|
26
|
+
"issueBranch": "{prefix}{spec}-{number:02d}",
|
|
27
|
+
},
|
|
28
|
+
"hooks": {"record": [], "deny": []},
|
|
29
|
+
"budget": {"corrections": 2, "contextShare": 0.15},
|
|
30
|
+
"dashboard": {"staleAfterSeconds": 900},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
REF_RE = re.compile(r"`?([a-z0-9][a-z0-9-]*)#(\d{2,})`?")
|
|
34
|
+
STATUS_RE = re.compile(r"^Status:\s*(.+?)\s*$", re.MULTILINE)
|
|
35
|
+
SLICE_RE = re.compile(r"^Slice:\s*`?([a-z0-9-]+#\d+)`?\s*$", re.MULTILINE)
|
|
36
|
+
TITLE_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
37
|
+
CHECKBOX_RE = re.compile(r"^(\s*)- \[( |x|X)\]\s+(.*)$")
|
|
38
|
+
ISSUE_FILE_RE = re.compile(r"^(\d{2,})-[a-z0-9-]+\.md$")
|
|
39
|
+
DONE_STATUSES = {"done"}
|
|
40
|
+
PARKED_STATUSES = {"blocked", "needs-operator", "draft"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def repo_root(start: Path | None = None) -> Path:
|
|
44
|
+
"""Find the enclosing repository, including linked worktrees."""
|
|
45
|
+
path = (start or Path.cwd()).resolve()
|
|
46
|
+
for candidate in [path, *path.parents]:
|
|
47
|
+
if (candidate / ".git").exists():
|
|
48
|
+
return candidate
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _merge_policy(base: dict, overlay: dict) -> dict:
|
|
53
|
+
result = copy.deepcopy(base)
|
|
54
|
+
for key, value in overlay.items():
|
|
55
|
+
result[key] = _merge_policy(result[key], value) if isinstance(value, dict) and isinstance(result.get(key), dict) else copy.deepcopy(value)
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def resolve_policy(root: Path | None = None) -> dict:
|
|
60
|
+
"""Overlay optional repository policy onto portable pack defaults."""
|
|
61
|
+
root = repo_root(root)
|
|
62
|
+
path = root / ".gantry" / "config.json"
|
|
63
|
+
if not path.exists():
|
|
64
|
+
return copy.deepcopy(DEFAULT_POLICY)
|
|
65
|
+
try:
|
|
66
|
+
overlay = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except json.JSONDecodeError as exc:
|
|
68
|
+
raise ValueError(f"{path} is not valid JSON: {exc}") from exc
|
|
69
|
+
if not isinstance(overlay, dict):
|
|
70
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
71
|
+
return _merge_policy(DEFAULT_POLICY, overlay)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_effective_template(root: Path | None, kind: str) -> Path:
|
|
75
|
+
"""Locate a repository template, falling back to the pack default."""
|
|
76
|
+
if kind not in {"spec", "prd", "issue"}:
|
|
77
|
+
raise ValueError(f"unknown template kind: {kind}")
|
|
78
|
+
root = repo_root(root)
|
|
79
|
+
configured = root / resolve_policy(root)["templates"]["dir"] / f"{kind}.md"
|
|
80
|
+
if configured.exists():
|
|
81
|
+
return configured
|
|
82
|
+
return Path(__file__).resolve().parent.parent / "templates" / f"{kind}.md"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def resolve_heading_map(root: Path | None = None) -> dict[str, str]:
|
|
86
|
+
"""Return policy-declared equivalent headings as canonical Markdown headings."""
|
|
87
|
+
raw_map = resolve_policy(root)["templates"].get("headingMap", {})
|
|
88
|
+
if not isinstance(raw_map, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in raw_map.items()):
|
|
89
|
+
raise ValueError("templates.headingMap must map heading strings to heading strings")
|
|
90
|
+
|
|
91
|
+
def heading(value: str) -> str:
|
|
92
|
+
value = value.strip()
|
|
93
|
+
return value if value.startswith("## ") else f"## {value.lstrip('#').strip()}"
|
|
94
|
+
|
|
95
|
+
return {heading(alias): heading(canonical) for alias, canonical in raw_map.items()}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_workflow_paths(root: Path, slug: str) -> dict[str, Path]:
|
|
99
|
+
"""Render the effective policy's repository paths for a workflow run."""
|
|
100
|
+
root = repo_root(root)
|
|
101
|
+
artifacts = resolve_policy(root)["artifacts"]
|
|
102
|
+
|
|
103
|
+
def render(name: str) -> Path:
|
|
104
|
+
return root / artifacts[name].format(slug=slug)
|
|
105
|
+
|
|
106
|
+
issue_dir = render("issues")
|
|
107
|
+
existing = sorted(issue_dir.glob("*.md"))
|
|
108
|
+
return {
|
|
109
|
+
"specPath": render("specs"),
|
|
110
|
+
"issueDir": issue_dir,
|
|
111
|
+
"exemplarIssue": existing[0] if existing else render("issueTracker"),
|
|
112
|
+
"decisions": render("decisions"),
|
|
113
|
+
"issueTracker": render("issueTracker"),
|
|
114
|
+
"context": render("context"),
|
|
115
|
+
"adrs": render("adrs"),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def artifact_path(root: Path, name: str, slug: str) -> Path:
|
|
120
|
+
"""Render one effective artifact path for an Issue's parent Spec."""
|
|
121
|
+
root = repo_root(root)
|
|
122
|
+
return root / resolve_policy(root)["artifacts"][name].format(slug=slug)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_policy_issues(root: Path, policy: dict | None = None) -> dict[str, Issue]:
|
|
126
|
+
"""Load Issue files from the policy's rendered, slug-aware issue directory."""
|
|
127
|
+
root = repo_root(root)
|
|
128
|
+
effective = policy or resolve_policy(root)
|
|
129
|
+
pattern = effective["artifacts"]["issues"].format(slug="*").rstrip("/")
|
|
130
|
+
issues: dict[str, Issue] = {}
|
|
131
|
+
for path in sorted(root.glob(f"{pattern}/*.md")):
|
|
132
|
+
if ISSUE_FILE_RE.match(path.name):
|
|
133
|
+
issue = parse_issue(path)
|
|
134
|
+
issues[issue.ref] = issue
|
|
135
|
+
return issues
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def policy_spec_numbers(root: Path, policy: dict | None = None) -> dict[str, int]:
|
|
139
|
+
"""Read Spec numbers from the policy's rendered, slug-aware spec paths."""
|
|
140
|
+
root = repo_root(root)
|
|
141
|
+
effective = policy or resolve_policy(root)
|
|
142
|
+
template = effective["artifacts"]["specs"]
|
|
143
|
+
pattern = template.format(slug="*")
|
|
144
|
+
slug_matcher = re.compile("^" + re.escape(template).replace(re.escape("{slug}"), r"(?P<slug>[^/]+)") + "$")
|
|
145
|
+
result: dict[str, int] = {}
|
|
146
|
+
for path in sorted(root.glob(pattern)):
|
|
147
|
+
match = re.search(r"\(spec\s+(\d+)", path.read_text(encoding="utf-8"))
|
|
148
|
+
if match:
|
|
149
|
+
relative = path.relative_to(root).as_posix()
|
|
150
|
+
slug = slug_matcher.match(relative)
|
|
151
|
+
if slug:
|
|
152
|
+
result[slug.group("slug")] = int(match.group(1))
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def section(text: str, heading: str) -> str:
|
|
157
|
+
"""Return a level-two Markdown section body."""
|
|
158
|
+
match = re.search(rf"^##\s+{re.escape(heading)}\s*$", text, re.MULTILINE | re.IGNORECASE)
|
|
159
|
+
if not match:
|
|
160
|
+
return ""
|
|
161
|
+
rest = text[match.end():]
|
|
162
|
+
next_heading = re.search(r"^##\s+", rest, re.MULTILINE)
|
|
163
|
+
return rest[:next_heading.start()] if next_heading else rest
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass
|
|
167
|
+
class Criterion:
|
|
168
|
+
index: int
|
|
169
|
+
text: str
|
|
170
|
+
checked: bool
|
|
171
|
+
line_no: int
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class Issue:
|
|
176
|
+
ref: str
|
|
177
|
+
spec: str
|
|
178
|
+
number: int
|
|
179
|
+
path: Path
|
|
180
|
+
title: str
|
|
181
|
+
status: str
|
|
182
|
+
blocked_by: list[str] = field(default_factory=list)
|
|
183
|
+
criteria: list[Criterion] = field(default_factory=list)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def done(self) -> bool:
|
|
187
|
+
return self.status.lower() in DONE_STATUSES
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def parked(self) -> bool:
|
|
191
|
+
return self.status.lower() in PARKED_STATUSES
|
|
192
|
+
|
|
193
|
+
def to_dict(self, root: Path) -> dict:
|
|
194
|
+
return {
|
|
195
|
+
"ref": self.ref, "spec": self.spec, "number": self.number,
|
|
196
|
+
"path": str(self.path.relative_to(root)), "title": self.title,
|
|
197
|
+
"status": self.status, "blocked_by": self.blocked_by,
|
|
198
|
+
"criteria_total": len(self.criteria),
|
|
199
|
+
"criteria_checked": sum(criterion.checked for criterion in self.criteria),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def parse_criteria(text: str) -> list[Criterion]:
|
|
204
|
+
body = section(text, "Acceptance criteria")
|
|
205
|
+
if not body:
|
|
206
|
+
return []
|
|
207
|
+
start_line = text[:text.index(body)].count("\n")
|
|
208
|
+
criteria: list[Criterion] = []
|
|
209
|
+
for offset, line in enumerate(body.splitlines()):
|
|
210
|
+
match = CHECKBOX_RE.match(line)
|
|
211
|
+
if match:
|
|
212
|
+
criteria.append(Criterion(len(criteria) + 1, match.group(3).strip(), match.group(2).lower() == "x", start_line + offset + 1))
|
|
213
|
+
return criteria
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_blocked_by(text: str, own_spec: str) -> list[str]:
|
|
217
|
+
refs: list[str] = []
|
|
218
|
+
for line in section(text, "Blocked by").splitlines():
|
|
219
|
+
if line.strip().startswith("-"):
|
|
220
|
+
for spec, number in REF_RE.findall(line):
|
|
221
|
+
ref = f"{spec}#{int(number):02d}"
|
|
222
|
+
if ref not in refs:
|
|
223
|
+
refs.append(ref)
|
|
224
|
+
return refs
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def parse_issue(path: Path) -> Issue:
|
|
228
|
+
path = path.resolve()
|
|
229
|
+
text = path.read_text(encoding="utf-8")
|
|
230
|
+
spec = path.parent.parent.name
|
|
231
|
+
file_match = ISSUE_FILE_RE.match(path.name)
|
|
232
|
+
number = int(file_match.group(1)) if file_match else 0
|
|
233
|
+
ref_match = SLICE_RE.search(text)
|
|
234
|
+
raw_ref = ref_match.group(1) if ref_match else f"{spec}#{number:02d}"
|
|
235
|
+
ref_spec, raw_number = raw_ref.split("#")
|
|
236
|
+
ref = f"{ref_spec}#{int(raw_number):02d}"
|
|
237
|
+
title = TITLE_RE.search(text)
|
|
238
|
+
status = STATUS_RE.search(text)
|
|
239
|
+
return Issue(ref, ref_spec, int(raw_number), path, title.group(1) if title else path.stem,
|
|
240
|
+
status.group(1) if status else "unknown", parse_blocked_by(text, ref_spec), parse_criteria(text))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def issue_branch(policy: dict, issue: Issue) -> str:
|
|
244
|
+
"""Render the policy's branch name for an Issue."""
|
|
245
|
+
template = policy["git"]["issueBranch"]
|
|
246
|
+
try:
|
|
247
|
+
branch = template.format(
|
|
248
|
+
prefix=policy["git"]["prefix"],
|
|
249
|
+
spec=issue.spec,
|
|
250
|
+
number=issue.number,
|
|
251
|
+
)
|
|
252
|
+
except (AttributeError, KeyError, ValueError) as exc:
|
|
253
|
+
raise ValueError("git.issueBranch must use only {prefix}, {spec}, and {number}") from exc
|
|
254
|
+
if not branch:
|
|
255
|
+
raise ValueError("git.issueBranch must render a non-empty branch name")
|
|
256
|
+
return branch
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def load_issues(scratch: Path) -> dict[str, Issue]:
|
|
260
|
+
issues: dict[str, Issue] = {}
|
|
261
|
+
for directory in sorted(scratch.glob("*/issues")):
|
|
262
|
+
for path in sorted(directory.glob("*.md")):
|
|
263
|
+
if ISSUE_FILE_RE.match(path.name):
|
|
264
|
+
issue = parse_issue(path)
|
|
265
|
+
issues[issue.ref] = issue
|
|
266
|
+
return issues
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def normalise_ref(raw: str) -> str | None:
|
|
270
|
+
match = REF_RE.fullmatch(raw.strip().strip("`"))
|
|
271
|
+
return f"{match.group(1)}#{int(match.group(2)):02d}" if match else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def resolve_issue_arg(arg: str, root: Path, issues: dict[str, Issue]) -> Issue | None:
|
|
275
|
+
ref = normalise_ref(arg)
|
|
276
|
+
if ref and ref in issues:
|
|
277
|
+
return issues[ref]
|
|
278
|
+
path = Path(arg)
|
|
279
|
+
path = path if path.is_absolute() else root / path
|
|
280
|
+
return parse_issue(path) if path.exists() else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def roadmap_waves(roadmap_text: str) -> dict[int, list[str]]:
|
|
284
|
+
waves: dict[int, list[str]] = {}
|
|
285
|
+
current: int | None = None
|
|
286
|
+
for line in roadmap_text.splitlines():
|
|
287
|
+
match = re.match(r"^###\s+Wave\s+(\d+)", line)
|
|
288
|
+
if match:
|
|
289
|
+
current = int(match.group(1))
|
|
290
|
+
waves.setdefault(current, [])
|
|
291
|
+
continue
|
|
292
|
+
item = re.match(r"^- \[( |x|X)\] \*\*`([a-z0-9-]+#\d+)`\*\*", line)
|
|
293
|
+
if item and current is not None:
|
|
294
|
+
spec, number = item.group(2).split("#")
|
|
295
|
+
waves[current].append(f"{spec}#{int(number):02d}")
|
|
296
|
+
return waves
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def spec_numbers(scratch: Path) -> dict[str, int]:
|
|
300
|
+
result: dict[str, int] = {}
|
|
301
|
+
for spec in sorted(scratch.glob("*/spec.md")):
|
|
302
|
+
match = re.search(r"\(spec\s+(\d+)", spec.read_text(encoding="utf-8"))
|
|
303
|
+
if match:
|
|
304
|
+
result[spec.parent.name] = int(match.group(1))
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def issue_levels(issues: dict[str, Issue]) -> dict[str, int]:
|
|
309
|
+
levels: dict[str, int] = {}
|
|
310
|
+
visiting: set[str] = set()
|
|
311
|
+
|
|
312
|
+
def depth(ref: str) -> int:
|
|
313
|
+
if ref in levels:
|
|
314
|
+
return levels[ref]
|
|
315
|
+
if ref in visiting:
|
|
316
|
+
raise ValueError(f"dependency cycle through {ref}")
|
|
317
|
+
visiting.add(ref)
|
|
318
|
+
blockers = [blocker for blocker in issues[ref].blocked_by if blocker in issues]
|
|
319
|
+
levels[ref] = 0 if not blockers else 1 + max(depth(blocker) for blocker in blockers)
|
|
320
|
+
visiting.remove(ref)
|
|
321
|
+
return levels[ref]
|
|
322
|
+
|
|
323
|
+
for reference in sorted(issues):
|
|
324
|
+
depth(reference)
|
|
325
|
+
return levels
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def main() -> int:
|
|
329
|
+
"""Print the portable policy and rendered workflow paths."""
|
|
330
|
+
parser = argparse.ArgumentParser(description=main.__doc__)
|
|
331
|
+
parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
|
|
332
|
+
parser.add_argument("--scope-slug", default="sample", help="slug used to render artifact paths")
|
|
333
|
+
parser.add_argument("--issue", help="Issue reference or path whose configured branch to render")
|
|
334
|
+
parser.add_argument("--json", action="store_true")
|
|
335
|
+
args = parser.parse_args()
|
|
336
|
+
|
|
337
|
+
root = repo_root(Path(args.cwd))
|
|
338
|
+
paths = resolve_workflow_paths(root, args.scope_slug)
|
|
339
|
+
payload = {
|
|
340
|
+
"repo_root": str(root),
|
|
341
|
+
"policy": resolve_policy(root),
|
|
342
|
+
"paths": {name: str(path) for name, path in paths.items()},
|
|
343
|
+
}
|
|
344
|
+
if args.issue:
|
|
345
|
+
issue = resolve_issue_arg(args.issue, root, load_policy_issues(root, payload["policy"]))
|
|
346
|
+
if issue is None:
|
|
347
|
+
raise ValueError(f"could not resolve Issue {args.issue!r}")
|
|
348
|
+
payload["issueBranch"] = issue_branch(payload["policy"], issue)
|
|
349
|
+
if args.json:
|
|
350
|
+
print(json.dumps(payload, indent=2))
|
|
351
|
+
else:
|
|
352
|
+
print(f"repo_root: {payload['repo_root']}")
|
|
353
|
+
if "issueBranch" in payload:
|
|
354
|
+
print(f"issue_branch: {payload['issueBranch']}")
|
|
355
|
+
for name, path in payload["paths"].items():
|
|
356
|
+
print(f"{name}: {path}")
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
sys.exit(main())
|