diff --git a/README.md b/README.md index 7edc12b..8f86b67 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,17 @@ CLI flag beats `[tool.bdt.github].board` in `pyproject.toml`: board = "Roadmap" ``` +## `bdt issue do` + +```bash +bdt issue do 60 [--agent claude] [--dry-run] [extra agent args...] +``` + +Hands an issue (GitHub) or work item (Azure DevOps) to a coding agent. Fetches the title and +description and runs `claude -p "" --name "60: "`, so the session is easy to find +with `claude --resume`. `--agent` picks another executable (it then gets just the prompt as its +last argument); extra args are passed through; `--dry-run` prints the command. + ## `bdt worktree` ```bash diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index bd4da1e..1f0bacb 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -15,6 +15,7 @@ from . import ado_issue, app_service_logs, commit as commit_mod from . import env_config from . import find_repo as find_repo_mod +from . import issue_do as issue_do_mod from . import gh_issue, gh_pr from . import find_injection as find_injection_mod from . import lint as lint_mod @@ -694,6 +695,31 @@ def issue_search( ) +@issue_app.command("do", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) +def issue_do( + ctx: typer.Context, + number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"), + agent: str = typer.Option("claude", "--agent", help="Agent executable to start (claude runs headless: `claude -p ... --name '<number>: <title>'`)"), + dry_run: bool = typer.Option(False, "--dry-run", help="Print the agent command instead of running it"), + pat: str | None = typer.Option( + None, + "--pat", + envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], + help="Azure DevOps PAT (else falls back to `az` login)", + ), +) -> None: + """Start a coding agent on an issue / work item, with the session named '<number>: <title>'. + Extra args after the number are passed through to the agent.""" + remote = current_remote() + if isinstance(remote, GitHubRemote): + title, body = issue_do_mod.fetch_github(require_gh(), number) + else: + session = requests.Session() + session.headers.update(auth_header(pat)) + title, body = issue_do_mod.fetch_ado(session, remote, number) + issue_do_mod.run(number, title, body, agent=agent, extra=list(ctx.args), dry_run=dry_run) + + @issue_app.command("update") def issue_update( number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"), diff --git a/bmsdna/devtools/issue_do.py b/bmsdna/devtools/issue_do.py new file mode 100644 index 0000000..e7d1c52 --- /dev/null +++ b/bmsdna/devtools/issue_do.py @@ -0,0 +1,71 @@ +"""`bdt issue do <number>`: hand an issue / work item to a coding agent. + +Fetches the issue's title and description (GitHub via `gh`, Azure DevOps via the REST API) +and starts the agent headless (`claude -p`), naming the session `<number>: <title>` so it is +easy to find again with `claude --resume`. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys + +import requests + +from .ado_issue import _base_url +from .gitrepo import AdoRemote + +_HTML_TAG_RE = re.compile(r"<[^>]+>") + + +def fetch_github(gh: str, number: int) -> tuple[str, str]: + result = subprocess.run([gh, "issue", "view", str(number), "--json", "title,body"], capture_output=True, text=True, check=False) + if result.returncode != 0: + sys.exit(f"gh issue view {number} failed:\n{result.stderr.strip()}") + data = json.loads(result.stdout) + return data["title"], data.get("body") or "" + + +def fetch_ado(session: requests.Session, remote: AdoRemote, number: int) -> tuple[str, str]: + r = session.get( + f"{_base_url(remote)}/_apis/wit/workitems/{number}", + params={"fields": "System.Title,System.Description", "api-version": "7.1"}, + ) + r.raise_for_status() + fields = r.json()["fields"] + # ADO descriptions are HTML; the agent only needs the text. + description = _HTML_TAG_RE.sub("", (fields.get("System.Description") or "").replace("<br>", "\n").replace("</div>", "\n")) + return fields["System.Title"], description.strip() + + +def session_name(number: int, title: str) -> str: + return f"{number}: {title}" + + +def build_prompt(number: int, title: str, body: str) -> str: + return ( + f"Work on issue {number}: {title}\n\n{body.strip() or '(no description)'}\n\n" + "Follow the dev-workflow skill: worktree, draft PR early, implement, test, publish the PR " + f"and reference issue {number} in it. Do not merge." + ) + + +def build_command(agent: str, number: int, title: str, body: str, extra: list[str]) -> list[str]: + prompt = build_prompt(number, title, body) + if agent == "claude": + return [agent, "-p", prompt, "--name", session_name(number, title), *extra] + # Other agents: no known naming flag, so just hand over the prompt. + return [agent, *extra, prompt] + + +def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool) -> None: + cmd = build_command(agent, number, title, body, extra) + if dry_run: + print(" ".join(json.dumps(c) if " " in c or "\n" in c else c for c in cmd)) + return + if shutil.which(agent) is None: + sys.exit(f"'{agent}' is required for this command but wasn't found on PATH.") + raise SystemExit(subprocess.run(cmd, check=False).returncode) diff --git a/tests/test_issue_do.py b/tests/test_issue_do.py new file mode 100644 index 0000000..24d99fb --- /dev/null +++ b/tests/test_issue_do.py @@ -0,0 +1,20 @@ +from bmsdna.devtools import issue_do + + +def test_claude_command_names_session_and_is_headless() -> None: + cmd = issue_do.build_command("claude", 60, "add command", "body text", ["--model", "opus"]) + assert cmd[:2] == ["claude", "-p"] + assert "body text" in cmd[2] + assert cmd[3:5] == ["--name", "60: add command"] + assert cmd[-2:] == ["--model", "opus"] + + +def test_other_agent_gets_prompt_last() -> None: + cmd = issue_do.build_command("codex", 7, "t", "", ["exec"]) + assert cmd[:2] == ["codex", "exec"] + assert "(no description)" in cmd[2] + + +def test_dry_run_prints(capsys) -> None: + issue_do.run(1, "x", "y", agent="claude", extra=[], dry_run=True) + assert '"1: x"' in capsys.readouterr().out