Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<prompt>" --name "60: <title>"`, 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
Expand Down
26 changes: 26 additions & 0 deletions bmsdna/devtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"),
Expand Down
71 changes: 71 additions & 0 deletions bmsdna/devtools/issue_do.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions tests/test_issue_do.py
Original file line number Diff line number Diff line change
@@ -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
Loading