312 lines
10 KiB
Python
312 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
create_feature.py — create a new feature request (+ discussion & summary)
|
|
|
|
Usage:
|
|
python create_feature.py --title "My Idea"
|
|
python create_feature.py --no-ramble
|
|
python create_feature.py --dir /path/to/repo
|
|
|
|
Behavior:
|
|
- Prefer Ramble (ramble.py in repo root) unless --no-ramble is passed.
|
|
- If Ramble not present or fails, prompt for fields in terminal.
|
|
- Fields come from the feature_request.md template when possible.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import argparse, datetime, json, os, re, subprocess, sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
try:
|
|
from automation.ai_config import load_ai_settings
|
|
except ImportError:
|
|
load_ai_settings = None # type: ignore
|
|
|
|
# --------- helpers ---------
|
|
def say(msg: str) -> None:
|
|
print(msg, flush=True)
|
|
|
|
def git_root_or_cwd(start: Path) -> Path:
|
|
try:
|
|
cp = subprocess.run(["git", "rev-parse", "--show-toplevel"],
|
|
text=True, capture_output=True, check=True, cwd=start)
|
|
return Path(cp.stdout.strip())
|
|
except Exception:
|
|
return start
|
|
|
|
def read_text(p: Path) -> str:
|
|
return p.read_text(encoding="utf-8") if p.exists() else ""
|
|
|
|
def write_text(p: Path, s: str) -> None:
|
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
p.write_text(s, encoding="utf-8")
|
|
|
|
def slugify(s: str) -> str:
|
|
s = s.strip().lower()
|
|
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
s = re.sub(r"-{2,}", "-", s).strip("-")
|
|
return s or "feature"
|
|
|
|
def today() -> str:
|
|
return datetime.date.today().isoformat()
|
|
|
|
def find_template_fields(tmpl: str) -> List[Tuple[str, str]]:
|
|
"""
|
|
Scan template for lines like:
|
|
**Intent**: <...>
|
|
Return list of (FieldName, placeholderText).
|
|
"""
|
|
fields = []
|
|
for m in re.finditer(r"^\s*\*\*(.+?)\*\*:\s*(<[^>]+>|.*)$", tmpl, flags=re.M):
|
|
label = m.group(1).strip()
|
|
placeholder = m.group(2).strip()
|
|
# skip meta/system fields the script will generate
|
|
if label.lower().startswith("feature id") or label.lower().startswith("meta"):
|
|
continue
|
|
fields.append((label, placeholder))
|
|
return fields
|
|
|
|
def default_fields() -> List[str]:
|
|
return ["Title", "Intent", "Motivation / Problem", "Constraints / Non-Goals",
|
|
"Rough Proposal", "Open Questions", "Author"]
|
|
|
|
def collect_via_prompts(field_labels: List[str]) -> Dict[str, str]:
|
|
say("[•] Ramble disabled or not found; collecting fields in terminal…")
|
|
out = {}
|
|
for label in field_labels:
|
|
try:
|
|
val = input(f"{label}: ").strip()
|
|
except EOFError:
|
|
val = ""
|
|
out[label] = val
|
|
if "Title" not in out or not out["Title"].strip():
|
|
out["Title"] = "initialProjectDesign"
|
|
return out
|
|
|
|
def try_ramble(repo_root: Path, field_labels: List[str], provider: str, claude_cmd: str) -> Dict[str, str] | None:
|
|
ramble = repo_root / "ramble.py"
|
|
if not ramble.exists():
|
|
return None
|
|
args = [sys.executable, str(ramble),
|
|
"--provider", provider,
|
|
"--claude-cmd", claude_cmd,
|
|
"--prompt", "Describe your feature idea in your own words",
|
|
"--fields"] + field_labels + [
|
|
"--criteria", json.dumps({
|
|
"Title": "camelCase or kebab-case, <= 32 chars",
|
|
"Intent": "<= 2 sentences"
|
|
})
|
|
]
|
|
say("[•] Launching Ramble… (submit to return)")
|
|
cp = subprocess.run(args, text=True, capture_output=True, cwd=repo_root)
|
|
if cp.stderr and cp.stderr.strip():
|
|
say("[ramble stderr]\n" + cp.stderr.strip())
|
|
try:
|
|
data = json.loads((cp.stdout or "").strip())
|
|
except Exception:
|
|
return None
|
|
|
|
# Normalize: accept either {"fields":{...}} or flat {"Title":...}
|
|
fields = data.get("fields") if isinstance(data, dict) else None
|
|
if not isinstance(fields, dict):
|
|
fields = {k: data.get(k, "") for k in field_labels}
|
|
return {k: (fields.get(k) or "").strip() for k in field_labels}
|
|
|
|
def render_request_from_template(tmpl: str, fields: Dict[str, str], fid: str, created: str) -> str:
|
|
# if template has <title>, replace; also replace known placeholders if present
|
|
body = tmpl
|
|
replacements = {
|
|
"<title>": fields.get("Title", ""),
|
|
"<one paragraph describing purpose>": fields.get("Intent", ""),
|
|
"<why this is needed now>": fields.get("Motivation / Problem", ""),
|
|
"<bulleted list of limitations>": fields.get("Constraints / Non-Goals", ""),
|
|
"<short implementation outline>": fields.get("Rough Proposal", ""),
|
|
"<bulleted list of uncertainties>": fields.get("Open Questions", ""),
|
|
"<name>": fields.get("Author", ""),
|
|
}
|
|
for needle, val in replacements.items():
|
|
body = body.replace(needle, val)
|
|
|
|
# Append meta block if not already present
|
|
if "Feature ID" not in body or "Meta" not in body:
|
|
meta = f"""
|
|
**Feature ID**: {fid}
|
|
**Meta**: Created: {created} • Author: {fields.get('Author','').strip() or '—'}
|
|
""".lstrip()
|
|
body = body.strip() + "\n\n" + meta
|
|
return body.strip() + "\n"
|
|
|
|
def seed_discussion_files(dir_disc: Path, fid: str, created: str) -> None:
|
|
req = f"""---
|
|
type: feature-discussion
|
|
stage: feature
|
|
status: OPEN
|
|
feature_id: {fid}
|
|
created: {created}
|
|
promotion_rule:
|
|
allow_agent_votes: false
|
|
ready_min_eligible_votes: 2
|
|
reject_min_eligible_votes: 1
|
|
participation:
|
|
instructions: |
|
|
- Append your input at the end as: "YourName: your comment…"
|
|
- Every comment must end with a vote line: "VOTE: READY|CHANGES|REJECT"
|
|
- Agents/bots must prefix names with "AI_". Example: "AI_Claude: … VOTE: CHANGES"
|
|
voting:
|
|
values: [READY, CHANGES, REJECT]
|
|
---
|
|
## Summary
|
|
Initial discussion for feature `{fid}`. Append your comments below.
|
|
|
|
## Participation
|
|
- Maintainer: Kickoff. VOTE: READY
|
|
"""
|
|
write_text(dir_disc / "feature.discussion.md", req)
|
|
|
|
sum_md = f"""# Summary — Feature
|
|
|
|
<!-- SUMMARY:DECISIONS START -->
|
|
## Decisions (ADR-style)
|
|
- (none yet)
|
|
<!-- SUMMARY:DECISIONS END -->
|
|
|
|
<!-- SUMMARY:OPEN_QUESTIONS START -->
|
|
## Open Questions
|
|
- (none yet)
|
|
<!-- SUMMARY:OPEN_QUESTIONS END -->
|
|
|
|
<!-- SUMMARY:AWAITING START -->
|
|
## Awaiting Replies
|
|
- (none yet)
|
|
<!-- SUMMARY:AWAITING END -->
|
|
|
|
<!-- SUMMARY:ACTION_ITEMS START -->
|
|
## Action Items
|
|
- (none yet)
|
|
<!-- SUMMARY:ACTION_ITEMS END -->
|
|
|
|
<!-- SUMMARY:VOTES START -->
|
|
## Votes (latest per participant)
|
|
READY: 1 • CHANGES: 0 • REJECT: 0
|
|
- Maintainer: READY
|
|
<!-- SUMMARY:VOTES END -->
|
|
|
|
<!-- SUMMARY:TIMELINE START -->
|
|
## Timeline (most recent first)
|
|
- {created} Maintainer: Kickoff (READY)
|
|
<!-- SUMMARY:TIMELINE END -->
|
|
|
|
<!-- SUMMARY:LINKS START -->
|
|
## Links
|
|
- Design/Plan: ../design/design.md
|
|
<!-- SUMMARY:LINKS END -->
|
|
"""
|
|
write_text(dir_disc / "feature.discussion.sum.md", sum_md)
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dir", help="Repo root (defaults to git root or CWD)")
|
|
ap.add_argument("--title", help="Feature title (useful without Ramble)")
|
|
ap.add_argument("--no-ramble", action="store_true", help="Disable Ramble UI")
|
|
ap.add_argument("--provider", help="Override Ramble provider (defaults to config)")
|
|
ap.add_argument("--claude-cmd", help="Override claude CLI path")
|
|
args = ap.parse_args()
|
|
|
|
start = Path(args.dir).expanduser().resolve() if args.dir else Path.cwd()
|
|
repo = git_root_or_cwd(start)
|
|
say(f"[=] Using repository: {repo}")
|
|
|
|
provider = args.provider
|
|
claude_cmd = args.claude_cmd
|
|
provider_map: Dict[str, Dict[str, object]] = {}
|
|
default_provider = "mock"
|
|
|
|
if load_ai_settings is not None:
|
|
try:
|
|
settings = load_ai_settings(repo)
|
|
provider_map = dict(settings.ramble.providers)
|
|
candidate_default = settings.ramble.default_provider
|
|
if isinstance(candidate_default, str) and candidate_default.strip():
|
|
default_provider = candidate_default.strip()
|
|
except Exception:
|
|
provider_map = {}
|
|
|
|
if "mock" not in provider_map:
|
|
provider_map["mock"] = {"kind": "mock"}
|
|
|
|
if not provider:
|
|
provider = default_provider if default_provider in provider_map else "mock"
|
|
elif provider not in provider_map:
|
|
say(f"[WARN] Unknown Ramble provider '{provider}', defaulting to {default_provider}")
|
|
provider = default_provider if default_provider in provider_map else "mock"
|
|
|
|
if not claude_cmd:
|
|
selected = provider_map.get(provider, {})
|
|
maybe_cmd = selected.get("command") if isinstance(selected, dict) else None
|
|
if isinstance(maybe_cmd, str) and maybe_cmd.strip():
|
|
claude_cmd = maybe_cmd.strip()
|
|
|
|
if not claude_cmd:
|
|
claude_cmd = "claude"
|
|
|
|
tmpl_path = repo / "process" / "templates" / "feature_request.md"
|
|
tmpl = read_text(tmpl_path)
|
|
parsed_fields = find_template_fields(tmpl) or [(f, "") for f in default_fields()]
|
|
field_labels = [name for (name, _) in parsed_fields]
|
|
if "Title" not in field_labels:
|
|
field_labels = ["Title"] + field_labels
|
|
|
|
# Try Ramble unless disabled
|
|
fields: Dict[str, str] | None = None
|
|
if not args.no_ramble:
|
|
fields = try_ramble(repo, field_labels, provider=provider, claude_cmd=claude_cmd)
|
|
|
|
# Terminal prompts fallback
|
|
if not fields:
|
|
fields = collect_via_prompts(field_labels)
|
|
if args.title:
|
|
fields["Title"] = args.title
|
|
|
|
# Derive slug & feature id
|
|
slug = slugify(fields.get("Title", "") or args.title or "feature")
|
|
fid = f"FR_{today()}_{slug}"
|
|
|
|
# Build target paths
|
|
fr_dir = repo / "Docs" / "features" / fid
|
|
disc_dir = fr_dir / "discussions"
|
|
fr_dir.mkdir(parents=True, exist_ok=True)
|
|
disc_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Render request.md
|
|
if tmpl:
|
|
body = render_request_from_template(tmpl, fields, fid=fid, created=today())
|
|
else:
|
|
# fallback body
|
|
body = f"""# Feature Request: {fields.get('Title','')}
|
|
|
|
**Intent**: {fields.get('Intent','')}
|
|
**Motivation / Problem**: {fields.get('Motivation / Problem','')}
|
|
**Constraints / Non-Goals**:
|
|
{fields.get('Constraints / Non-Goals','')}
|
|
**Rough Proposal**:
|
|
{fields.get('Rough Proposal','')}
|
|
**Open Questions**:
|
|
{fields.get('Open Questions','')}
|
|
|
|
**Feature ID**: {fid}
|
|
**Meta**: Created: {today()} • Author: {fields.get('Author','')}
|
|
"""
|
|
write_text(fr_dir / "request.md", body)
|
|
|
|
# Seed discussion & summary
|
|
seed_discussion_files(disc_dir, fid=fid, created=today())
|
|
|
|
say(f"[✓] Created feature at: {fr_dir}")
|
|
say("Next:")
|
|
say(f" git add {fr_dir}")
|
|
say(f" git commit -m \"feat: start {fid}\"")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|