From 67f2d281785b6bfdecdb1e67565dab23516ee77f Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" Date: Sat, 3 Oct 2026 09:47:33 +0000 Subject: [PATCH 1/8] wip: claude mods integration (#68) Co-Authored-By: Claude Sonnet 5.5 From 13d321c02de1aeb2b7b55aa1d8a4ffec27b82bc8 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" Date: Sat, 3 Oct 2026 09:51:32 +0000 Subject: [PATCH 2/8] feat: bdt-status Claude mod, bdt pr info, bdt issue take (#68) - bdt pr info [--json]: PR link/state/aggregate build state/closed issues - mods/bdt-status: shows that above the prompt in Claude Code (mods, v2.1.287+) - bdt issue take [NUMBER]: 'Taken by ' comment with the agent session link - marketplace manifest so the mod installs via 'claude plugin install' Co-Authored-By: Claude Sonnet 5.5 --- .claude-plugin/marketplace.json | 12 ++ README.md | 44 +++++ bmsdna/devtools/cli.py | 69 +++++++- bmsdna/devtools/gh_pr.py | 7 + bmsdna/devtools/pr_info.py | 96 +++++++++++ mods/bdt-status/.claude-plugin/plugin.json | 6 + mods/bdt-status/hooks/hooks.json | 3 + mods/bdt-status/hooks/register.js | 82 +++++++++ mods/bdt-status/tests/bdt-status.test.ts | 68 ++++++++ pyproject.toml | 2 +- skills/bmsdna-devtools/SKILL.md | 22 +++ tests/test_pr_info.py | 187 +++++++++++++++++++++ uv.lock | 2 +- 13 files changed, 597 insertions(+), 3 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 bmsdna/devtools/pr_info.py create mode 100644 mods/bdt-status/.claude-plugin/plugin.json create mode 100644 mods/bdt-status/hooks/hooks.json create mode 100644 mods/bdt-status/hooks/register.js create mode 100644 mods/bdt-status/tests/bdt-status.test.ts create mode 100644 tests/test_pr_info.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..5fde055 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,12 @@ +{ + "name": "bmsuisse-devtools", + "owner": { "name": "BMS" }, + "metadata": { "description": "Claude Code mods that ship with bmsdna-devtools" }, + "plugins": [ + { + "name": "bdt-status", + "source": "./mods/bdt-status", + "description": "Shows the current branch's PR and the issue it closes above the prompt, with the build state" + } + ] +} diff --git a/README.md b/README.md index 9929e43..9df16c9 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,50 @@ description and runs `claude -p "" --name "60: "`, so the session 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 issue take` + +```bash +bdt issue take [NUMBER] [--target-branch main] +``` + +Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the +running coding agent's session link (the same `<Agent> Session: <url>` note every bdt comment gets +under Claude Code), so others can see who is on it and open the session. `<you>` is the GitHub login +`gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current +branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if +that is none or several. Run it once, right after you start on the issue -- it doesn't check for an +earlier "Taken by" comment. + +## `bdt pr info` + +```bash +bdt pr info [--json] [--pr-id N] [--target-branch main] +``` + +One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate +build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the +issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what +the Claude Code mod below reads. Build state is only evaluated on GitHub; on Azure DevOps it is +`unknown` (use `bdt pr status`). + +## Claude Code mod: `bdt-status` + +[Mods](https://code.claude.com/docs/en/plugins/mods/overview) are plugins that change how Claude +Code looks and behaves (Claude Code v2.1.287+). `mods/bdt-status` draws one line above the prompt +-- `PR #69 ✗ failing · closes #68`, both numbers clickable -- refreshed every 30 seconds and after +each turn, from `bdt pr info --json`. Nothing is drawn when the branch has no PR or `bdt` isn't on +PATH. It shows in the terminal and the Desktop app (not `claude -p`, the VS Code panel or cloud +sessions, where mods can't draw). + +```bash +claude plugin marketplace add bmsuisse/devtools +claude plugin install bdt-status@bmsuisse-devtools +``` + +Mods run with your permissions, unsandboxed; this one only runs `bdt pr info --json` +(`claude plugin validate mods/bdt-status` lists its calls). Test it with +`claude plugin test mods/bdt-status`. + ## `bdt worktree` ```bash diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index 1f0bacb..ca3b10f 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -20,7 +20,7 @@ from . import find_injection as find_injection_mod from . import lint as lint_mod from . import logs as logs_mod -from . import pr_build, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod +from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod from .ado_auth import auth_header from .cli_tools import detect_agent_session, require_az, require_gh from .gitrepo import AdoRemote, GitHubRemote, UnknownRemoteError, current_branch, current_remote, head_commit_subject @@ -115,6 +115,14 @@ def _resolve_ado_pr(pat: str | None, remote: AdoRemote, target: str, pr_id: int return session, pr +def _current_pr_info(pr_id: int | None, target_branch: str, pat: str | None) -> pr_info_mod.PrInfo: + remote = current_remote() + if isinstance(remote, GitHubRemote): + return pr_info_mod.github_info(gh_pr.get_pr_info(require_gh(), pr_id), remote) + _, pr = _resolve_ado_pr(pat, remote, target_branch, pr_id=pr_id) + return pr_info_mod.ado_info(pr, remote) + + def _gh_branch_for(gh: str, pr_id: int | None, *, needed: bool) -> str: """Branch to namespace uploaded screenshots/files under (see `gh_pr.push_assets`) -- the checked-out branch normally, or (when acting on a PR by `--pr-id`, which may not @@ -408,6 +416,27 @@ def pr_status( pr_build.run(remote, pat, target_branch, wait, pr_id=pr_id) +@pr_app.command("info") +def pr_info( + as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (what the bdt Claude Code mod reads)"), + target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only)"), + pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), + 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: + """One-shot summary of the PR opened from the current branch, or --pr-id directly: link, state, + aggregate build state, and the issue(s)/work item(s) it closes. Exits 1 if there is no such PR. + Build state is only evaluated on GitHub; on Azure DevOps it's reported as "unknown" (use `pr status`). + """ + info = _current_pr_info(pr_id, target_branch, pat) + if as_json: + typer.echo(json.dumps(info.to_json_dict())) + return + flags = ", ".join([info.state, *(["draft"] if info.draft else []), f"build: {info.build}"]) + typer.echo(f"PR #{info.number}: {info.title} ({flags})\n{info.url}") + for issue in info.issues: + typer.echo(f"Issue #{issue.number}: {issue.url}") + + @pr_app.command("retry") def pr_retry( target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only)"), @@ -720,6 +749,44 @@ def issue_do( issue_do_mod.run(number, title, body, agent=agent, extra=list(ctx.args), dry_run=dry_run) +@issue_app.command("take") +def issue_take( + number: int | None = typer.Argument(None, help="Issue number (GitHub) or work item ID (Azure DevOps). Default: the issue the current branch's PR closes"), + target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only; only used to find the issue when no number is given)"), + 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: + """Claim an issue / work item: comment "Taken by <you>" on it, with this coding agent's + session link (detected automatically) so others can see who is working on it and find the session. + """ + remote = current_remote() + if number is None: + issues = _current_pr_info(None, target_branch, pat).issues + if len(issues) != 1: + raise typer.BadParameter( + f"The current branch's PR closes {len(issues)} issues, so can't tell which to take -- pass the number" + if issues + else "No issue number given, and the current branch's PR doesn't reference one (e.g. 'Fixes #N')" + ) + number = issues[0].number + message = f"Taken by {_current_user(remote)}" + if isinstance(remote, GitHubRemote): + gh_issue.comment(require_gh(), remote.owner, remote.repo, number, message, []) + else: + session = requests.Session() + session.headers.update(auth_header(pat)) + ado_issue.comment_with_screenshots(session, remote, number, message, [], []) + + +def _current_user(remote: AdoRemote | GitHubRemote) -> str: + """Who to say took an issue: the GitHub login `gh` is authenticated as, else the git `user.name`.""" + if isinstance(remote, GitHubRemote): + r = subprocess.run([require_gh(), "api", "user", "--jq", ".login"], capture_output=True, text=True, check=False) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + r = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, check=False) + return r.stdout.strip() or "unknown" + + @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/gh_pr.py b/bmsdna/devtools/gh_pr.py index c6c945a..ff7adaf 100644 --- a/bmsdna/devtools/gh_pr.py +++ b/bmsdna/devtools/gh_pr.py @@ -23,6 +23,8 @@ from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section PR_VIEW_FIELDS = "number,title,baseRefName,headRefName,mergeable,statusCheckRollup,isDraft" +# What `bdt pr info` needs on top of the check rollup: where to link to, and the body to scan for closed issues. +PR_INFO_FIELDS = "number,title,url,state,isDraft,body,statusCheckRollup" # GitHub has no API for uploading images to a PR description (only the web # UI's drag-and-drop, which needs a browser session). The standard @@ -110,6 +112,11 @@ def get_pr(gh: str, pr_id: int | None = None) -> dict: return _run_gh_json(gh, args) +def get_pr_info(gh: str, pr_id: int | None = None) -> dict: + """Like `get_pr`, but with `PR_INFO_FIELDS` (url, state, body) instead of the merge-status ones.""" + return _run_gh_json(gh, ["pr", "view", *_pr_id_args(pr_id), "--json", PR_INFO_FIELDS]) + + def check_bucket(check: dict) -> str: if check.get("__typename") == "StatusContext": return _STATUS_CONTEXT_BUCKET.get(check.get("state"), "pending") diff --git a/bmsdna/devtools/pr_info.py b/bmsdna/devtools/pr_info.py new file mode 100644 index 0000000..4be1100 --- /dev/null +++ b/bmsdna/devtools/pr_info.py @@ -0,0 +1,96 @@ +"""`bdt pr info`: a one-shot, machine-readable summary of the current branch's PR. + +The bdt Claude Code mod (`mods/bdt-status`) polls this to show a link to the PR and the +issue/work item it closes, plus the build state, above the prompt. Kept as plain data (no +human-oriented formatting) so any other tool -- a shell prompt, a dashboard -- can consume it too. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field + +from . import ado_issue, gh_pr, pr_build, pr_issue_link +from .gitrepo import AdoRemote, GitHubRemote + +# `build` values: aggregate of every check on the PR. "unknown" is for trackers where bdt +# doesn't (yet) evaluate checks -- Azure DevOps -- as opposed to "none", a PR with no checks at all. +BUILD_PASSING = "passing" +BUILD_FAILING = "failing" +BUILD_PENDING = "pending" +BUILD_WAITING = "waiting" # paused on a manual approval; nothing resolves it without a human +BUILD_NONE = "none" +BUILD_UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class IssueRef: + number: int + url: str + + +@dataclass(frozen=True) +class PrInfo: + number: int + title: str + url: str + state: str # "open" | "merged" | "closed" + draft: bool + build: str + issues: list[IssueRef] = field(default_factory=list) + + def to_json_dict(self) -> dict: + return asdict(self) + + +def build_state(checks: list[dict]) -> str: + """Aggregate a GitHub `statusCheckRollup` into one `BUILD_*` value; worst state wins + (failing > waiting > pending > passing). Skipped checks don't count against a PR. + """ + buckets = {gh_pr.check_bucket(c) for c in checks} + if not buckets: + return BUILD_NONE + if buckets & {"fail", "cancel"}: + return BUILD_FAILING + if "waiting_approval" in buckets: + return BUILD_WAITING + if "pending" in buckets: + return BUILD_PENDING + return BUILD_PASSING + + +def github_info(pr: dict, remote: GitHubRemote) -> PrInfo: + """`pr` is `gh pr view --json` output with `gh_pr.PR_INFO_FIELDS`.""" + issues = [ + IssueRef(n, f"https://github.com/{remote.owner}/{remote.repo}/issues/{n}") + for n in pr_issue_link.find_issue_refs_in_body(pr.get("body"), remote) + ] + return PrInfo( + number=pr["number"], + title=pr.get("title", ""), + url=pr["url"], + state=str(pr.get("state", "OPEN")).lower(), + draft=bool(pr.get("isDraft")), + build=build_state(pr.get("statusCheckRollup") or []), + issues=issues, + ) + + +_ADO_STATE = {"active": "open", "completed": "merged", "abandoned": "closed"} + + +def ado_info(pr: dict, remote: AdoRemote) -> PrInfo: + """`pr` is an Azure DevOps pull request REST object (see `pr_build.get_pr`).""" + issues = [ + IssueRef(n, ado_issue.edit_url(remote, n)) + for n in pr_issue_link.find_issue_refs_in_body(pr.get("description"), remote) + ] + pr_id = pr["pullRequestId"] + return PrInfo( + number=pr_id, + title=pr.get("title", ""), + url=pr_build.pr_web_url(remote, pr_id), + state=_ADO_STATE.get(str(pr.get("status", "active")).lower(), "open"), + draft=bool(pr.get("isDraft")), + build=BUILD_UNKNOWN, + issues=issues, + ) diff --git a/mods/bdt-status/.claude-plugin/plugin.json b/mods/bdt-status/.claude-plugin/plugin.json new file mode 100644 index 0000000..d19ddc5 --- /dev/null +++ b/mods/bdt-status/.claude-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "bdt-status", + "version": "0.1.0", + "description": "Shows the current branch's PR and the issue it closes above the prompt, with the build state (uses `bdt pr info`)", + "author": { "name": "BMS" } +} diff --git a/mods/bdt-status/hooks/hooks.json b/mods/bdt-status/hooks/hooks.json new file mode 100644 index 0000000..0d5260d --- /dev/null +++ b/mods/bdt-status/hooks/hooks.json @@ -0,0 +1,3 @@ +{ + "modules": ["./register.js"] +} diff --git a/mods/bdt-status/hooks/register.js b/mods/bdt-status/hooks/register.js new file mode 100644 index 0000000..dfe2f4a --- /dev/null +++ b/mods/bdt-status/hooks/register.js @@ -0,0 +1,82 @@ +// Shows "PR #69 ✓ · closes #68" (clickable links) in the band above the prompt, from `bdt pr info --json`. +// Needs `bdt` on PATH; with no PR for the current branch (or no bdt) the band is left alone. + +const REFRESH_MS = 30_000 + +// Single-width symbols, not emoji, so the band lines up in every terminal +const BUILD = { + passing: { symbol: '✓', color: 'green' }, + failing: { symbol: '✗', color: 'red' }, + pending: { symbol: '…', color: 'yellow' }, + waiting: { symbol: '⏸', color: 'yellow' }, +} + +// The last `bdt pr info --json` result, or null when the branch has no PR +let info = null +let isRefreshing = false + +async function readInfo($) { + try { + const r = await $.process.run(['bdt', 'pr', 'info', '--json']) + return r.exitCode === 0 ? JSON.parse(r.stdout) : null + } catch { + // bdt isn't installed, timed out, or printed something that isn't JSON + return null + } +} + +async function refresh($) { + // A slow `gh` call must not stack up behind the timer + if (isRefreshing) return + isRefreshing = true + try { + const next = await readInfo($) + if (JSON.stringify(next) !== JSON.stringify(info)) { + info = next + $.ui.invalidate('ui.render') + } + } finally { + isRefreshing = false + } +} + +export function register(on) { + on('session.start', async ($, e, next) => { + $.clock.every(REFRESH_MS, () => refresh($)) + // Not awaited: the session starts right away, and the band fills in when bdt answers + refresh($) + return next(e) + }) + + // A turn is when the branch is most likely to have been pushed or a PR opened + on('turn.complete', async ($, e, next) => { + refresh($) + return next(e) + }) + + on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { + // Keep whatever Claude Code or other mods draw in the band + const theirs = await next(e) + if (!info) return theirs + + const { Box, Text, Link } = $.ui.resolve(e) + const build = BUILD[info.build] + const state = info.draft ? 'draft' : info.state === 'open' ? '' : info.state + + const line = Box({ + flexDirection: 'row', + columnGap: 1, + children: [ + Link({ href: info.url, label: 'PR #' + info.number }), + ...(build ? [Text({ color: build.color, children: [build.symbol + ' ' + info.build] })] : []), + ...(state ? [Text({ dimColor: true, children: [state] })] : []), + ...info.issues.flatMap((issue) => [ + Text({ dimColor: true, children: ['· closes'] }), + Link({ href: issue.url, label: '#' + issue.number }), + ]), + ], + }) + + return Box({ flexDirection: 'column', children: theirs ? [line, theirs] : [line] }) + }) +} diff --git a/mods/bdt-status/tests/bdt-status.test.ts b/mods/bdt-status/tests/bdt-status.test.ts new file mode 100644 index 0000000..780bd41 --- /dev/null +++ b/mods/bdt-status/tests/bdt-status.test.ts @@ -0,0 +1,68 @@ +import { expect, mock, test } from 'claude-code/testing' + +const BAND = { + plugin: 'bdt-status', + component: 'AbovePrompt', + surface: 'terminal', + viewport: { columns: 100, rows: 30 }, + props: { hasSurvey: false, isWorking: false, maxRows: 5, bodyColumns: 80, scroll: { offset: 0, bodyRows: 5 }, view: {} }, +} as const + +const INFO = { + number: 69, + title: 'feat: mod', + url: 'https://github.com/bmsuisse/devtools/pull/69', + state: 'open', + draft: false, + build: 'failing', + issues: [{ number: 68, url: 'https://github.com/bmsuisse/devtools/issues/68' }], +} + +// Answers what Claude Code would: the session starts, `bdt pr info --json` prints `stdout` (exit `exitCode`), +// and the band has nothing of its own to draw +function stubSession(on, exitCode: number, stdout: string) { + const clock = mock.clock(on) + on('session.start', () => ({ cwd: '/work' })) + on('process.run', () => ({ value: { exitCode, stdout, stderr: '' } })) + on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) + return clock +} + +test('links the PR and the issue it closes, with the build state', async ($, on) => { + const clock = stubSession(on, 0, JSON.stringify(INFO)) + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + await clock.settle() + + const ui = await $.ui.mount(BAND) + expect((await ui.find({ type: 'Link', text: 'PR #69' }))?.props.href).toBe(INFO.url) + expect((await ui.find({ type: 'Link', text: '#68' }))?.props.href).toBe(INFO.issues[0].url) + expect(await ui.find({ type: 'Text', text: /failing/ })).toBeDefined() + // What Claude Code draws in the band is kept + expect(await ui.find({ type: 'Text', text: 'drawn by Claude Code' })).toBeDefined() +}) + +test('draws nothing of its own when the branch has no PR', async ($, on) => { + const clock = stubSession(on, 1, '') + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + await clock.settle() + + const ui = await $.ui.mount(BAND) + expect(await ui.find({ type: 'Link' })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: 'drawn by Claude Code' })).toBeDefined() +}) + +test('picks up a PR that appears later, on the next refresh', async ($, on) => { + const clock = mock.clock(on) + let stdout = '' + on('session.start', () => ({ cwd: '/work' })) + on('process.run', () => ({ value: { exitCode: stdout ? 0 : 1, stdout, stderr: '' } })) + on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + await clock.settle() + + stdout = JSON.stringify(INFO) + await clock.advance(30_000) + + const ui = await $.ui.mount(BAND) + expect((await ui.find({ type: 'Link', text: 'PR #69' }))?.props.href).toBe(INFO.url) +}) diff --git a/pyproject.toml b/pyproject.toml index a54bacc..7e4c373 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ packages = ["bmsdna"] [project] name = "bmsdna-devtools" -version = "0.29.0" +version = "0.30.0" description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects" readme = "README.md" requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency diff --git a/skills/bmsdna-devtools/SKILL.md b/skills/bmsdna-devtools/SKILL.md index eacf889..28150ea 100644 --- a/skills/bmsdna-devtools/SKILL.md +++ b/skills/bmsdna-devtools/SKILL.md @@ -69,6 +69,28 @@ straight through either way, e.g. After creating a PR, use `bdt pr status` (see above) to check whether the CI build passes. +## Taking an issue + +```bash +bdt issue take [NUMBER] +``` + +When you start working on an issue / work item, run this first: it comments `Taken by <user>` plus +your session link so others can see it's being worked on and open the session. No parameters needed +-- the session is detected, and without `NUMBER` the issue is the one the current branch's PR closes +(so open the PR with `Fixes #N` first, or pass the number). Run it once. Don't hand-write a +session-link comment instead. + +## PR summary + +```bash +bdt pr info [--json] +``` + +PR link, state, aggregate build state and the issue(s) it closes, in one call -- cheaper than +`bdt pr status` when you only need to know where things stand. Exits 1 if the branch has no PR. +(`bdt pr status --wait` is still what to use to wait for CI.) + ## Creating a worktree ```bash diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py new file mode 100644 index 0000000..65305de --- /dev/null +++ b/tests/test_pr_info.py @@ -0,0 +1,187 @@ +"""`bdt pr info` (what the Claude Code mod polls) and `bdt issue take` (GitHub issue #68).""" + +import json + +import pytest +from typer.testing import CliRunner + +from bmsdna.devtools import pr_info +from bmsdna.devtools.cli import app +from bmsdna.devtools.gitrepo import AdoRemote, GitHubRemote + +runner = CliRunner() + +GITHUB_REMOTE = GitHubRemote("owner", "repo") +ADO_REMOTE = AdoRemote("myorg", "MyProj", "myrepo") + +PASS = {"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "SUCCESS", "name": "a"} +SKIPPED = {"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "SKIPPED", "name": "b"} +FAIL = {"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "FAILURE", "name": "c"} +CANCELLED = {"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "CANCELLED", "name": "d"} +RUNNING = {"__typename": "CheckRun", "status": "IN_PROGRESS", "name": "e"} +WAITING = {"__typename": "CheckRun", "status": "WAITING", "name": "f"} + + +@pytest.mark.parametrize( + "checks,expected", + [ + ([], pr_info.BUILD_NONE), + ([PASS, SKIPPED], pr_info.BUILD_PASSING), + ([PASS, RUNNING], pr_info.BUILD_PENDING), + ([PASS, WAITING], pr_info.BUILD_WAITING), + ([RUNNING, WAITING], pr_info.BUILD_WAITING), + ([PASS, FAIL, RUNNING, WAITING], pr_info.BUILD_FAILING), + ([PASS, CANCELLED], pr_info.BUILD_FAILING), + ], +) +def test_build_state_worst_state_wins(checks, expected) -> None: + assert pr_info.build_state(checks) == expected + + +GH_PR = { + "number": 69, + "title": "feat: mod", + "url": "https://github.com/owner/repo/pull/69", + "state": "OPEN", + "isDraft": True, + "body": "Does the thing.\n\nFixes #68\n", + "statusCheckRollup": [PASS, RUNNING], +} + + +def test_github_info_links_pr_and_closed_issue() -> None: + info = pr_info.github_info(GH_PR, GITHUB_REMOTE) + + assert info.to_json_dict() == { + "number": 69, + "title": "feat: mod", + "url": "https://github.com/owner/repo/pull/69", + "state": "open", + "draft": True, + "build": "pending", + "issues": [{"number": 68, "url": "https://github.com/owner/repo/issues/68"}], + } + + +def test_github_info_without_issue_ref_has_no_issues() -> None: + assert pr_info.github_info({**GH_PR, "body": None}, GITHUB_REMOTE).issues == [] + + +def test_ado_info_reports_unknown_build_and_linked_work_item() -> None: + pr = { + "pullRequestId": 42, + "title": "feat: widgets", + "status": "active", + "isDraft": False, + "description": "see https://dev.azure.com/myorg/MyProj/_workitems/edit/7", + } + + info = pr_info.ado_info(pr, ADO_REMOTE) + + assert info.build == pr_info.BUILD_UNKNOWN + assert info.state == "open" + assert info.url == "https://dev.azure.com/myorg/MyProj/_git/myrepo/pullrequest/42" + assert [i.number for i in info.issues] == [7] + assert info.issues[0].url == "https://dev.azure.com/myorg/MyProj/_workitems/edit/7" + + +def _github_cli(monkeypatch) -> None: + monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE) + monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: GH_PR) + + +def test_pr_info_json(monkeypatch) -> None: + _github_cli(monkeypatch) + + result = runner.invoke(app, ["pr", "info", "--json"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["number"] == 69 and data["build"] == "pending" and data["issues"][0]["number"] == 68 + + +def test_pr_info_text_mentions_pr_and_issue_links(monkeypatch) -> None: + _github_cli(monkeypatch) + + result = runner.invoke(app, ["pr", "info"]) + + assert result.exit_code == 0, result.output + assert "https://github.com/owner/repo/pull/69" in result.output + assert "https://github.com/owner/repo/issues/68" in result.output + + +def test_pr_info_passes_pr_id(monkeypatch) -> None: + monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE) + monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") + seen = {} + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: seen.update(pr_id=pr_id) or GH_PR) + + assert runner.invoke(app, ["pr", "info", "--json", "--pr-id", "5"]).exit_code == 0 + assert seen == {"pr_id": 5} + + +# -- bdt issue take --------------------------------------------------------- + + +def _take_cli(monkeypatch, *, user: str = "octocat") -> list[tuple]: + """GitHub remote with `gh api user` answering `user`; returns the recorded `gh_issue.comment` calls.""" + _github_cli(monkeypatch) + monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: user) + calls: list[tuple] = [] + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.comment", lambda *args: calls.append(args)) + return calls + + +def test_issue_take_with_number_comments_taken_by(monkeypatch) -> None: + calls = _take_cli(monkeypatch) + + result = runner.invoke(app, ["issue", "take", "12"]) + + assert result.exit_code == 0, result.output + assert calls == [("gh", "owner", "repo", 12, "Taken by octocat", [])] + + +def test_issue_take_without_number_uses_issue_the_pr_closes(monkeypatch) -> None: + calls = _take_cli(monkeypatch) + + result = runner.invoke(app, ["issue", "take"]) + + assert result.exit_code == 0, result.output + assert calls[0][3] == 68 + + +def test_issue_take_without_number_and_without_pr_issue_fails(monkeypatch) -> None: + calls = _take_cli(monkeypatch) + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: {**GH_PR, "body": "no refs"}) + + result = runner.invoke(app, ["issue", "take"]) + + assert result.exit_code != 0 + assert calls == [] + + +def test_issue_take_with_several_closed_issues_is_ambiguous(monkeypatch) -> None: + calls = _take_cli(monkeypatch) + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: {**GH_PR, "body": "Fixes #1\nFixes #2"}) + + result = runner.invoke(app, ["issue", "take"]) + + assert result.exit_code != 0 + assert calls == [] + + +def test_issue_take_note_includes_session_link_under_claude(monkeypatch) -> None: + """The session URL comes from `gh_issue.comment`'s own agent-session note (same as every other + bdt comment), so `take` needs no session handling of its own -- verify that end to end.""" + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.setenv("CLAUDE_CODE_BRIDGE_SESSION_ID", "session_abc") + _github_cli(monkeypatch) + monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: "octocat") + bodies: list[str] = [] + monkeypatch.setattr("bmsdna.devtools.gh_issue._run_gh", lambda gh, args: bodies.append(args[args.index("--body") + 1]) or "https://x/issues/12#issuecomment-1") + + result = runner.invoke(app, ["issue", "take", "12"]) + + assert result.exit_code == 0, result.output + assert bodies == ["Taken by octocat\n\nClaude Session: https://claude.ai/code/session_abc"] diff --git a/uv.lock b/uv.lock index 6c8ae7c..8616f35 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "bmsdna-devtools" -version = "0.28.1" +version = "0.30.0" source = { editable = "." } dependencies = [ { name = "pgdevkit", extra = ["db"] }, From 7a95451a2895b97e669ef66a5e36ef6efb990ab9 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" <adrian.ehrsam@bmsuisse.ch> Date: Sat, 3 Oct 2026 09:54:22 +0000 Subject: [PATCH 3/8] fix: address review findings for bdt pr info / issue take / bdt-status - pr info: closed issues from GitHub's closingIssuesReferences; all-skipped checks are 'none'; cancelled checks don't fail the build (same as pr status); PR title stripped of control chars - issue take: sanitised, timeout-bounded user name; says which issue it took - get_pr takes the field list (no near-duplicate get_pr_info) - mod: keeps the last PR on a failed refresh, one timer, validates bdt's output - docs: SKILL triggers, accurate session-link wording, ADO limits Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- README.md | 39 ++++++++++++++---------- bmsdna/devtools/cli.py | 26 ++++++++++++---- bmsdna/devtools/gh_pr.py | 15 ++++----- bmsdna/devtools/pr_info.py | 29 +++++++++++++----- mods/bdt-status/hooks/register.js | 24 ++++++++++++--- mods/bdt-status/tests/bdt-status.test.ts | 21 +++++++++++++ skills/bmsdna-devtools/SKILL.md | 9 ++++-- tests/test_pr_info.py | 28 ++++++++++++----- 8 files changed, 136 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 9df16c9..d1e98e0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # bmsdna-devtools -Shared developer tooling for BMS projects: PR build/check status, PR -creation, issue/work item creation and comments, git worktrees (creation and +Shared developer tooling for BMS projects: PR build/check status and a +one-shot PR summary (`bdt pr info`, also shown in Claude Code by the bundled +`bdt-status` mod), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with pre-flight checks, Azure log queries, and static checks (`bdt lint`) for postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in @@ -72,6 +73,23 @@ locally at all. `bdt pr retry`, `pr publish`, `pr update`, and `pr comment` all accept the same option, for the same reason. (`pr watch-deploy` doesn't: it watches a branch-triggered build/workflow run, not any particular PR.) +## `bdt pr info` + +```bash +bdt pr info [--json] [--pr-id N] [--target-branch main] +``` + +One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate +build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the +issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what +the Claude Code mod below reads. Build state is only evaluated on GitHub; on Azure DevOps it is +`unknown` (use `bdt pr status`). + +Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `main`), +a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed +work items are only those whose URL is in the PR description -- not ones linked via +`bdt pr create --issue`. + ## `bdt pr retry` Retry only the **failed** job(s)/stage(s) of the most recent build/run for the @@ -292,25 +310,14 @@ bdt issue take [NUMBER] [--target-branch main] ``` Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the -running coding agent's session link (the same `<Agent> Session: <url>` note every bdt comment gets -under Claude Code), so others can see who is on it and open the session. `<you>` is the GitHub login +running coding agent's session (the same `<Agent> Session: <id>` note every bdt comment gets: a +claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an +agent), so others can see who is on it. `<you>` is the GitHub login `gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if that is none or several. Run it once, right after you start on the issue -- it doesn't check for an earlier "Taken by" comment. -## `bdt pr info` - -```bash -bdt pr info [--json] [--pr-id N] [--target-branch main] -``` - -One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate -build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the -issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what -the Claude Code mod below reads. Build state is only evaluated on GitHub; on Azure DevOps it is -`unknown` (use `bdt pr status`). - ## Claude Code mod: `bdt-status` [Mods](https://code.claude.com/docs/en/plugins/mods/overview) are plugins that change how Claude diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index ca3b10f..a829a75 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import re import subprocess import sys from collections.abc import Callable @@ -22,7 +23,7 @@ from . import logs as logs_mod from . import pr_build, pr_info as pr_info_mod, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod from .ado_auth import auth_header -from .cli_tools import detect_agent_session, require_az, require_gh +from .cli_tools import CLI_TIMEOUT_SECS, detect_agent_session, require_az, require_gh from .gitrepo import AdoRemote, GitHubRemote, UnknownRemoteError, current_branch, current_remote, head_commit_subject # Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a @@ -118,7 +119,7 @@ def _resolve_ado_pr(pat: str | None, remote: AdoRemote, target: str, pr_id: int def _current_pr_info(pr_id: int | None, target_branch: str, pat: str | None) -> pr_info_mod.PrInfo: remote = current_remote() if isinstance(remote, GitHubRemote): - return pr_info_mod.github_info(gh_pr.get_pr_info(require_gh(), pr_id), remote) + return pr_info_mod.github_info(gh_pr.get_pr(require_gh(), pr_id, fields=gh_pr.PR_INFO_FIELDS), remote) _, pr = _resolve_ado_pr(pat, remote, target_branch, pr_id=pr_id) return pr_info_mod.ado_info(pr, remote) @@ -769,6 +770,7 @@ def issue_take( ) number = issues[0].number message = f"Taken by {_current_user(remote)}" + typer.echo(f"Taking {'issue' if isinstance(remote, GitHubRemote) else 'work item'} #{number}") if isinstance(remote, GitHubRemote): gh_issue.comment(require_gh(), remote.owner, remote.repo, number, message, []) else: @@ -780,11 +782,23 @@ def issue_take( def _current_user(remote: AdoRemote | GitHubRemote) -> str: """Who to say took an issue: the GitHub login `gh` is authenticated as, else the git `user.name`.""" if isinstance(remote, GitHubRemote): - r = subprocess.run([require_gh(), "api", "user", "--jq", ".login"], capture_output=True, text=True, check=False) - if r.returncode == 0 and r.stdout.strip(): + r = _capture([require_gh(), "api", "user", "--jq", ".login"]) + if r and r.returncode == 0 and r.stdout.strip(): return r.stdout.strip() - r = subprocess.run(["git", "config", "user.name"], capture_output=True, text=True, check=False) - return r.stdout.strip() or "unknown" + r = _capture(["git", "config", "user.name"]) + # `git config user.name` comes from the (possibly cloned-from-elsewhere) repo's own config and ends up in a + # markdown/HTML comment, so keep it to plain name characters: no links, tags or @mentions + name = re.sub(r"[^\w .'-]", "", r.stdout if r else "").strip()[:64] + return name or "unknown" + + +def _capture(cmd: list[str]) -> subprocess.CompletedProcess | None: + """Run `cmd` for its UTF-8 output, bounded so a stalled `gh` (expired login prompting) can't hang the + caller; None on timeout.""" + try: + return subprocess.run(cmd, capture_output=True, encoding="utf-8", timeout=CLI_TIMEOUT_SECS, check=False) + except subprocess.TimeoutExpired: + return None @issue_app.command("update") diff --git a/bmsdna/devtools/gh_pr.py b/bmsdna/devtools/gh_pr.py index ff7adaf..b1c0af4 100644 --- a/bmsdna/devtools/gh_pr.py +++ b/bmsdna/devtools/gh_pr.py @@ -23,8 +23,8 @@ from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section PR_VIEW_FIELDS = "number,title,baseRefName,headRefName,mergeable,statusCheckRollup,isDraft" -# What `bdt pr info` needs on top of the check rollup: where to link to, and the body to scan for closed issues. -PR_INFO_FIELDS = "number,title,url,state,isDraft,body,statusCheckRollup" +# What `bdt pr info` needs on top of the check rollup: where to link to, and the issues GitHub says it closes. +PR_INFO_FIELDS = "number,title,url,state,isDraft,closingIssuesReferences,statusCheckRollup" # GitHub has no API for uploading images to a PR description (only the web # UI's drag-and-drop, which needs a browser session). The standard @@ -101,22 +101,19 @@ def _pr_id_args(pr_id: int | None) -> list[str]: return [str(pr_id)] if pr_id is not None else [] -def get_pr(gh: str, pr_id: int | None = None) -> dict: +def get_pr(gh: str, pr_id: int | None = None, fields: str = PR_VIEW_FIELDS) -> dict: """The PR for the current branch, however `gh` resolves it — there's no target-branch filter on `gh pr view` the way ADO's search API has one. If `pr_id` is given, resolves that PR directly instead -- lets a caller act on a specific PR without needing its branch checked out locally at all. + + `fields` is the `--json` field list; defaults to what `bdt pr status` needs. """ - args = ["pr", "view", *_pr_id_args(pr_id), "--json", PR_VIEW_FIELDS] + args = ["pr", "view", *_pr_id_args(pr_id), "--json", fields] return _run_gh_json(gh, args) -def get_pr_info(gh: str, pr_id: int | None = None) -> dict: - """Like `get_pr`, but with `PR_INFO_FIELDS` (url, state, body) instead of the merge-status ones.""" - return _run_gh_json(gh, ["pr", "view", *_pr_id_args(pr_id), "--json", PR_INFO_FIELDS]) - - def check_bucket(check: dict) -> str: if check.get("__typename") == "StatusContext": return _STATUS_CONTEXT_BUCKET.get(check.get("state"), "pending") diff --git a/bmsdna/devtools/pr_info.py b/bmsdna/devtools/pr_info.py index 4be1100..341c39b 100644 --- a/bmsdna/devtools/pr_info.py +++ b/bmsdna/devtools/pr_info.py @@ -7,6 +7,7 @@ from __future__ import annotations +import unicodedata from dataclasses import asdict, dataclass, field from . import ado_issue, gh_pr, pr_build, pr_issue_link @@ -42,14 +43,23 @@ def to_json_dict(self) -> dict: return asdict(self) +def clean_text(text: str) -> str: + """`text` without control and format characters (terminal escapes, bidi overrides): a PR title is + attacker-controlled when the checked-out branch is someone else's PR, and is printed to a terminal.""" + return "".join(c for c in text if unicodedata.category(c)[0] != "C") + + def build_state(checks: list[dict]) -> str: """Aggregate a GitHub `statusCheckRollup` into one `BUILD_*` value; worst state wins - (failing > waiting > pending > passing). Skipped checks don't count against a PR. + (failing > waiting > pending > passing; none if nothing ran). Mirrors `gh_pr.run` (`bdt pr status`): only a "fail" + bucket is failing, so skipped and cancelled checks (e.g. a run superseded by a newer push) + don't count against the PR -- the two commands must never disagree about the same PR. """ buckets = {gh_pr.check_bucket(c) for c in checks} - if not buckets: + if buckets <= {"skipping"}: + # nothing ran (no checks, or every workflow was path-filtered out): not a green build return BUILD_NONE - if buckets & {"fail", "cancel"}: + if "fail" in buckets: return BUILD_FAILING if "waiting_approval" in buckets: return BUILD_WAITING @@ -59,14 +69,17 @@ def build_state(checks: list[dict]) -> str: def github_info(pr: dict, remote: GitHubRemote) -> PrInfo: - """`pr` is `gh pr view --json` output with `gh_pr.PR_INFO_FIELDS`.""" + """`pr` is `gh pr view --json` output with `gh_pr.PR_INFO_FIELDS`. Only issues of this repo are listed.""" + # GitHub's own answer to "which issues does merging this close" (closing keywords in the body, + # or linked in the Development sidebar) -- a mere "related: <issue url>" mention isn't one issues = [ - IssueRef(n, f"https://github.com/{remote.owner}/{remote.repo}/issues/{n}") - for n in pr_issue_link.find_issue_refs_in_body(pr.get("body"), remote) + IssueRef(i["number"], i["url"]) + for i in pr.get("closingIssuesReferences") or [] + if i.get("repository", {}).get("name", remote.repo).casefold() == remote.repo.casefold() ] return PrInfo( number=pr["number"], - title=pr.get("title", ""), + title=clean_text(pr.get("title", "")), url=pr["url"], state=str(pr.get("state", "OPEN")).lower(), draft=bool(pr.get("isDraft")), @@ -87,7 +100,7 @@ def ado_info(pr: dict, remote: AdoRemote) -> PrInfo: pr_id = pr["pullRequestId"] return PrInfo( number=pr_id, - title=pr.get("title", ""), + title=clean_text(pr.get("title", "")), url=pr_build.pr_web_url(remote, pr_id), state=_ADO_STATE.get(str(pr.get("status", "active")).lower(), "open"), draft=bool(pr.get("isDraft")), diff --git a/mods/bdt-status/hooks/register.js b/mods/bdt-status/hooks/register.js index dfe2f4a..bc58712 100644 --- a/mods/bdt-status/hooks/register.js +++ b/mods/bdt-status/hooks/register.js @@ -14,14 +14,25 @@ const BUILD = { // The last `bdt pr info --json` result, or null when the branch has no PR let info = null let isRefreshing = false +let timer = null +const LINK = /^https:\/\/(github\.com|dev\.azure\.com)\/[^\s\x00-\x1f]*$/ + +// `bdt` output only if it has the shape `bdt pr info --json` produces, so a shadowing or outdated `bdt` on PATH +// can't make the band render an arbitrary link +function validated(i) { + const isLink = (x) => typeof x.number === 'number' && typeof x.url === 'string' && LINK.test(x.url) + return isLink(i) && Array.isArray(i.issues) && i.issues.every(isLink) ? i : undefined +} + +// `undefined`: couldn't ask (bdt missing or timed out, unusable output) -- keep showing the last answer. +// `null`: bdt answered that there is no PR for this branch. async function readInfo($) { try { const r = await $.process.run(['bdt', 'pr', 'info', '--json']) - return r.exitCode === 0 ? JSON.parse(r.stdout) : null + return r.exitCode === 0 ? validated(JSON.parse(r.stdout)) : null } catch { - // bdt isn't installed, timed out, or printed something that isn't JSON - return null + return undefined } } @@ -31,10 +42,12 @@ async function refresh($) { isRefreshing = true try { const next = await readInfo($) - if (JSON.stringify(next) !== JSON.stringify(info)) { + if (next !== undefined && JSON.stringify(next) !== JSON.stringify(info)) { info = next $.ui.invalidate('ui.render') } + } catch { + // a failed redraw must not become an unhandled rejection in a timer callback } finally { isRefreshing = false } @@ -42,7 +55,8 @@ async function refresh($) { export function register(on) { on('session.start', async ($, e, next) => { - $.clock.every(REFRESH_MS, () => refresh($)) + // once per module load, not per session.start + timer ??= $.clock.every(REFRESH_MS, () => refresh($)) // Not awaited: the session starts right away, and the band fills in when bdt answers refresh($) return next(e) diff --git a/mods/bdt-status/tests/bdt-status.test.ts b/mods/bdt-status/tests/bdt-status.test.ts index 780bd41..f698594 100644 --- a/mods/bdt-status/tests/bdt-status.test.ts +++ b/mods/bdt-status/tests/bdt-status.test.ts @@ -66,3 +66,24 @@ test('picks up a PR that appears later, on the next refresh', async ($, on) => { const ui = await $.ui.mount(BAND) expect((await ui.find({ type: 'Link', text: 'PR #69' }))?.props.href).toBe(INFO.url) }) + +test('keeps showing the last PR when a refresh fails, and drops it when bdt says there is none', async ($, on) => { + const clock = mock.clock(on) + let answer: 'pr' | 'broken' | 'no-pr' = 'pr' + on('session.start', () => ({ cwd: '/work' })) + on('process.run', () => { + if (answer === 'broken') return { deny: 'bdt not found' } + return { value: { exitCode: answer === 'pr' ? 0 : 1, stdout: answer === 'pr' ? JSON.stringify(INFO) : '', stderr: '' } } + }) + on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) + await clock.settle() + + answer = 'broken' + await clock.advance(30_000) + expect(await (await $.ui.mount(BAND)).find({ type: 'Link', text: 'PR #69' })).toBeDefined() + + answer = 'no-pr' + await clock.advance(30_000) + expect(await (await $.ui.mount(BAND)).find({ type: 'Link' })).toBeUndefined() +}) diff --git a/skills/bmsdna-devtools/SKILL.md b/skills/bmsdna-devtools/SKILL.md index 28150ea..79f5bcb 100644 --- a/skills/bmsdna-devtools/SKILL.md +++ b/skills/bmsdna-devtools/SKILL.md @@ -4,10 +4,12 @@ description: > Use the `bdt` CLI (from the bmsdna-devtools package) instead of ad hoc git/az/gh commands or repo-local scripts for: checking PR build/check status, creating a PR, creating a git worktree, committing and pushing files (with pre-flight - checks), and querying Azure logs. `bdt pr *` works against both Azure DevOps + checks), claiming an issue / work item (`bdt issue take`), a one-shot PR summary + (`bdt pr info`: link, build state, closed issues), and querying Azure logs. `bdt pr *` works against both Azure DevOps and GitHub — it auto-detects which one from the `origin` remote. Trigger whenever the user asks to check a build/PR status, create a PR, make a - worktree, commit changes, or fetch/tail application logs in a repo that has + worktree, commit changes, fetch/tail application logs, or start working on / + take an issue ("work on issue #N"), in a repo that has bmsdna-devtools installed (check for `bdt` on PATH, or `bmsdna-devtools` in pyproject.toml, before assuming it applies). --- @@ -76,7 +78,8 @@ bdt issue take [NUMBER] ``` When you start working on an issue / work item, run this first: it comments `Taken by <user>` plus -your session link so others can see it's being worked on and open the session. No parameters needed +your session (the claude.ai link under a bridged Claude session, otherwise the bare session id; nothing +when no agent is detected) so others can see it's being worked on. No parameters needed -- the session is detected, and without `NUMBER` the issue is the one the current branch's PR closes (so open the PR with `Fixes #N` first, or pass the number). Run it once. Don't hand-write a session-link comment instead. diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py index 65305de..71c2a75 100644 --- a/tests/test_pr_info.py +++ b/tests/test_pr_info.py @@ -26,12 +26,14 @@ "checks,expected", [ ([], pr_info.BUILD_NONE), + ([SKIPPED], pr_info.BUILD_NONE), ([PASS, SKIPPED], pr_info.BUILD_PASSING), ([PASS, RUNNING], pr_info.BUILD_PENDING), ([PASS, WAITING], pr_info.BUILD_WAITING), ([RUNNING, WAITING], pr_info.BUILD_WAITING), ([PASS, FAIL, RUNNING, WAITING], pr_info.BUILD_FAILING), - ([PASS, CANCELLED], pr_info.BUILD_FAILING), + # same as `bdt pr status`, which exits 0 for a PR whose only odd check was cancelled + ([PASS, CANCELLED], pr_info.BUILD_PASSING), ], ) def test_build_state_worst_state_wins(checks, expected) -> None: @@ -44,7 +46,7 @@ def test_build_state_worst_state_wins(checks, expected) -> None: "url": "https://github.com/owner/repo/pull/69", "state": "OPEN", "isDraft": True, - "body": "Does the thing.\n\nFixes #68\n", + "closingIssuesReferences": [{"number": 68, "url": "https://github.com/owner/repo/issues/68", "repository": {"name": "repo"}}], "statusCheckRollup": [PASS, RUNNING], } @@ -63,8 +65,18 @@ def test_github_info_links_pr_and_closed_issue() -> None: } -def test_github_info_without_issue_ref_has_no_issues() -> None: - assert pr_info.github_info({**GH_PR, "body": None}, GITHUB_REMOTE).issues == [] +def test_github_info_without_closing_issue_has_no_issues() -> None: + assert pr_info.github_info({**GH_PR, "closingIssuesReferences": []}, GITHUB_REMOTE).issues == [] + + +def test_github_info_ignores_closed_issues_of_other_repos() -> None: + other = {"number": 9, "url": "https://github.com/owner/other/issues/9", "repository": {"name": "other"}} + assert pr_info.github_info({**GH_PR, "closingIssuesReferences": [other]}, GITHUB_REMOTE).issues == [] + + +def test_github_info_strips_terminal_escapes_from_title() -> None: + info = pr_info.github_info({**GH_PR, "title": "evil\x1b]8;;http://x\x07 \u202etitle"}, GITHUB_REMOTE) + assert info.title == "evil]8;;http://x title" def test_ado_info_reports_unknown_build_and_linked_work_item() -> None: @@ -88,7 +100,7 @@ def test_ado_info_reports_unknown_build_and_linked_work_item() -> None: def _github_cli(monkeypatch) -> None: monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE) monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") - monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: GH_PR) + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr", lambda gh, pr_id=None, fields="": GH_PR) def test_pr_info_json(monkeypatch) -> None: @@ -115,7 +127,7 @@ def test_pr_info_passes_pr_id(monkeypatch) -> None: monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE) monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") seen = {} - monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: seen.update(pr_id=pr_id) or GH_PR) + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr", lambda gh, pr_id=None, fields="": seen.update(pr_id=pr_id) or GH_PR) assert runner.invoke(app, ["pr", "info", "--json", "--pr-id", "5"]).exit_code == 0 assert seen == {"pr_id": 5} @@ -153,7 +165,7 @@ def test_issue_take_without_number_uses_issue_the_pr_closes(monkeypatch) -> None def test_issue_take_without_number_and_without_pr_issue_fails(monkeypatch) -> None: calls = _take_cli(monkeypatch) - monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: {**GH_PR, "body": "no refs"}) + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr", lambda gh, pr_id=None, fields="": {**GH_PR, "closingIssuesReferences": []}) result = runner.invoke(app, ["issue", "take"]) @@ -163,7 +175,7 @@ def test_issue_take_without_number_and_without_pr_issue_fails(monkeypatch) -> No def test_issue_take_with_several_closed_issues_is_ambiguous(monkeypatch) -> None: calls = _take_cli(monkeypatch) - monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr_info", lambda gh, pr_id=None: {**GH_PR, "body": "Fixes #1\nFixes #2"}) + monkeypatch.setattr("bmsdna.devtools.cli.gh_pr.get_pr", lambda gh, pr_id=None, fields="": {**GH_PR, "closingIssuesReferences": [{"number": 1, "url": "u1", "repository": {"name": "repo"}}, {"number": 2, "url": "u2", "repository": {"name": "repo"}}]}) result = runner.invoke(app, ["issue", "take"]) From 08a316013c9aa5174a2f8a600779a10609301cc6 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" <adrian.ehrsam@bmsuisse.ch> Date: Sat, 3 Oct 2026 10:00:48 +0000 Subject: [PATCH 4/8] feat(pr info): judge Azure DevOps build state like pr status Latest build per pipeline: failed result -> failing, unfinished -> pending (waiting when a stage needs approval), none/passing otherwise; unknown if ADO can't be asked. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- README.md | 6 ++- bmsdna/devtools/cli.py | 6 +-- bmsdna/devtools/pr_info.py | 37 ++++++++++++++++-- tests/test_pr_info.py | 80 +++++++++++++++++++++++++++++++++----- 4 files changed, 110 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index d1e98e0..5952058 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,10 @@ bdt pr info [--json] [--pr-id N] [--target-branch main] One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what -the Claude Code mod below reads. Build state is only evaluated on GitHub; on Azure DevOps it is -`unknown` (use `bdt pr status`). +the Claude Code mod below reads. Build state is judged the way `bdt pr status` does: on GitHub from +the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps +from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or +`waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked. Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `main`), a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index a829a75..830b746 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -120,8 +120,8 @@ def _current_pr_info(pr_id: int | None, target_branch: str, pat: str | None) -> remote = current_remote() if isinstance(remote, GitHubRemote): return pr_info_mod.github_info(gh_pr.get_pr(require_gh(), pr_id, fields=gh_pr.PR_INFO_FIELDS), remote) - _, pr = _resolve_ado_pr(pat, remote, target_branch, pr_id=pr_id) - return pr_info_mod.ado_info(pr, remote) + session, pr = _resolve_ado_pr(pat, remote, target_branch, pr_id=pr_id) + return pr_info_mod.ado_info(session, pr, remote) def _gh_branch_for(gh: str, pr_id: int | None, *, needed: bool) -> str: @@ -426,7 +426,7 @@ def pr_info( ) -> None: """One-shot summary of the PR opened from the current branch, or --pr-id directly: link, state, aggregate build state, and the issue(s)/work item(s) it closes. Exits 1 if there is no such PR. - Build state is only evaluated on GitHub; on Azure DevOps it's reported as "unknown" (use `pr status`). + Build state is judged the way `pr status` does (latest build per pipeline on Azure DevOps). """ info = _current_pr_info(pr_id, target_branch, pat) if as_json: diff --git a/bmsdna/devtools/pr_info.py b/bmsdna/devtools/pr_info.py index 341c39b..65cc8f7 100644 --- a/bmsdna/devtools/pr_info.py +++ b/bmsdna/devtools/pr_info.py @@ -10,11 +10,13 @@ import unicodedata from dataclasses import asdict, dataclass, field +import requests + from . import ado_issue, gh_pr, pr_build, pr_issue_link from .gitrepo import AdoRemote, GitHubRemote -# `build` values: aggregate of every check on the PR. "unknown" is for trackers where bdt -# doesn't (yet) evaluate checks -- Azure DevOps -- as opposed to "none", a PR with no checks at all. +# `build` values: aggregate of every check/pipeline on the PR. "unknown" means the state couldn't +# be determined (Azure DevOps request failed), as opposed to "none": nothing ran at all. BUILD_PASSING = "passing" BUILD_FAILING = "failing" BUILD_PENDING = "pending" @@ -91,7 +93,34 @@ def github_info(pr: dict, remote: GitHubRemote) -> PrInfo: _ADO_STATE = {"active": "open", "completed": "merged", "abandoned": "closed"} -def ado_info(pr: dict, remote: AdoRemote) -> PrInfo: +def ado_build_state(builds: list[dict], has_pending_approval: bool) -> str: + """Aggregate the latest build of each pipeline (`pr_build.latest_per_pipeline`) into one `BUILD_*` + value, the way `pr_build.run` (`bdt pr status`) judges them: any failed result is failing, else any + unfinished build is pending -- or waiting, if a stage is blocked on a manual approval. + """ + if not builds: + return BUILD_NONE + if any(b.get("result") == "failed" for b in builds): + return BUILD_FAILING + if any(b.get("status") != "completed" for b in builds): + return BUILD_WAITING if has_pending_approval else BUILD_PENDING + return BUILD_PASSING + + +def _ado_build(session: requests.Session, remote: AdoRemote, pr: dict) -> str: + pr_id = pr["pullRequestId"] + source_branch = pr["sourceRefName"].removeprefix("refs/heads/") + try: + builds = pr_build.latest_per_pipeline(pr_build.get_builds_for_pr(session, remote, source_branch, pr_id)) + # a timeline lookup per running build -- only worth it when nothing has failed yet + failed = any(b.get("result") == "failed" for b in builds) + approval = not failed and any(b.get("status") != "completed" for b in builds) and bool(pr_build.find_pending_approvals(session, remote, builds)) + except requests.RequestException: + return BUILD_UNKNOWN + return ado_build_state(builds, approval) + + +def ado_info(session: requests.Session, pr: dict, remote: AdoRemote) -> PrInfo: """`pr` is an Azure DevOps pull request REST object (see `pr_build.get_pr`).""" issues = [ IssueRef(n, ado_issue.edit_url(remote, n)) @@ -104,6 +133,6 @@ def ado_info(pr: dict, remote: AdoRemote) -> PrInfo: url=pr_build.pr_web_url(remote, pr_id), state=_ADO_STATE.get(str(pr.get("status", "active")).lower(), "open"), draft=bool(pr.get("isDraft")), - build=BUILD_UNKNOWN, + build=_ado_build(session, remote, pr), issues=issues, ) diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py index 71c2a75..b02eb5d 100644 --- a/tests/test_pr_info.py +++ b/tests/test_pr_info.py @@ -3,6 +3,7 @@ import json import pytest +import requests from typer.testing import CliRunner from bmsdna.devtools import pr_info @@ -79,24 +80,71 @@ def test_github_info_strips_terminal_escapes_from_title() -> None: assert info.title == "evil]8;;http://x title" -def test_ado_info_reports_unknown_build_and_linked_work_item() -> None: - pr = { - "pullRequestId": 42, - "title": "feat: widgets", - "status": "active", - "isDraft": False, - "description": "see https://dev.azure.com/myorg/MyProj/_workitems/edit/7", - } +ADO_PR = { + "pullRequestId": 42, + "title": "feat: widgets", + "status": "active", + "isDraft": False, + "sourceRefName": "refs/heads/feat/widgets", + "description": "see https://dev.azure.com/myorg/MyProj/_workitems/edit/7", +} + + +def _build(status: str, result: str | None = None, pipeline: int = 1) -> dict: + return {"id": 100 + pipeline, "definition": {"id": pipeline, "name": f"p{pipeline}"}, "status": status, "result": result} + + +@pytest.mark.parametrize( + "builds,approval,expected", + [ + ([], False, pr_info.BUILD_NONE), + ([_build("completed", "succeeded"), _build("completed", "canceled", 2)], False, pr_info.BUILD_PASSING), + ([_build("completed", "succeeded"), _build("inProgress", None, 2)], False, pr_info.BUILD_PENDING), + ([_build("notStarted")], False, pr_info.BUILD_PENDING), + ([_build("inProgress")], True, pr_info.BUILD_WAITING), + ([_build("completed", "failed"), _build("inProgress", None, 2)], True, pr_info.BUILD_FAILING), + ], +) +def test_ado_build_state_matches_pr_status(builds, approval, expected) -> None: + assert pr_info.ado_build_state(builds, approval) == expected + + +def _ado_builds(monkeypatch, builds: list[dict], approvals: list | None = None) -> list[str]: + """Stub the ADO build lookups; returns the source branches asked about.""" + asked: list[str] = [] + monkeypatch.setattr("bmsdna.devtools.pr_info.pr_build.get_builds_for_pr", lambda s, r, branch, pr_id: asked.append(branch) or builds) + monkeypatch.setattr("bmsdna.devtools.pr_info.pr_build.find_pending_approvals", lambda s, r, b: approvals or []) + return asked + + +def test_ado_info_links_pr_and_work_item_and_judges_builds(monkeypatch) -> None: + asked = _ado_builds(monkeypatch, [_build("completed", "succeeded"), _build("completed", "failed", 2)]) - info = pr_info.ado_info(pr, ADO_REMOTE) + info = pr_info.ado_info(None, ADO_PR, ADO_REMOTE) # ty: ignore[invalid-argument-type] - assert info.build == pr_info.BUILD_UNKNOWN + assert asked == ["feat/widgets"] + assert info.build == pr_info.BUILD_FAILING assert info.state == "open" assert info.url == "https://dev.azure.com/myorg/MyProj/_git/myrepo/pullrequest/42" assert [i.number for i in info.issues] == [7] assert info.issues[0].url == "https://dev.azure.com/myorg/MyProj/_workitems/edit/7" +def test_ado_info_running_build_blocked_on_approval_is_waiting(monkeypatch) -> None: + _ado_builds(monkeypatch, [_build("inProgress")], approvals=[("build", [], [])]) + + assert pr_info.ado_info(None, ADO_PR, ADO_REMOTE).build == pr_info.BUILD_WAITING # ty: ignore[invalid-argument-type] + + +def test_ado_info_unknown_build_when_ado_cannot_be_asked(monkeypatch) -> None: + def boom(*a): + raise requests.ConnectionError("down") + + monkeypatch.setattr("bmsdna.devtools.pr_info.pr_build.get_builds_for_pr", boom) + + assert pr_info.ado_info(None, ADO_PR, ADO_REMOTE).build == pr_info.BUILD_UNKNOWN # ty: ignore[invalid-argument-type] + + def _github_cli(monkeypatch) -> None: monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE) monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") @@ -197,3 +245,15 @@ def test_issue_take_note_includes_session_link_under_claude(monkeypatch) -> None assert result.exit_code == 0, result.output assert bodies == ["Taken by octocat\n\nClaude Session: https://claude.ai/code/session_abc"] + + +def test_pr_info_on_azure_devops_passes_the_session_through(monkeypatch) -> None: + monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: ADO_REMOTE) + monkeypatch.setattr("bmsdna.devtools.cli._resolve_ado_pr", lambda pat, remote, target, pr_id=None: ("session", ADO_PR)) + seen = {} + monkeypatch.setattr("bmsdna.devtools.pr_info.pr_build.get_builds_for_pr", lambda s, r, branch, pr_id: seen.update(session=s) or [_build("completed", "succeeded")]) + + result = runner.invoke(app, ["pr", "info", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output)["build"] == "passing" and seen == {"session": "session"} From c8e03d389caa52bb3200543d716de811069d47e6 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" <adrian.ehrsam@bmsuisse.ch> Date: Sat, 3 Oct 2026 10:28:35 +0000 Subject: [PATCH 5/8] feat!: default --target/--target-branch is now dev One DEFAULT_TARGET_BRANCH for pr create/status/info/retry/publish/update/comment/watch-deploy, issue take and commit; pass --target main to keep the old behaviour. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- README.md | 19 +++++++++++-------- bmsdna/devtools/cli.py | 23 +++++++++++++---------- skills/bmsdna-devtools/SKILL.md | 6 +++--- tests/test_pr_info.py | 20 ++++++++++++++++++++ 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5952058..7074496 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,12 @@ Find the PR opened from the current branch and report build/check status GitHub — whichever `origin` points at. ```bash -bdt pr status [--target-branch main] [--wait] +bdt pr status [--target-branch dev] [--wait] ``` +Every `--target` / `--target-branch` option in bdt (`pr create/status/info/retry/publish/update/comment/watch-deploy`, +`issue take`, `commit`) defaults to `dev`; pass `--target main` (or whatever your PRs go into) to override. + If the PR can't be merged, that's reported immediately instead of polling for builds/checks that will never run — e.g. on Azure DevOps: `PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts)`; @@ -76,7 +79,7 @@ it watches a branch-triggered build/workflow run, not any particular PR.) ## `bdt pr info` ```bash -bdt pr info [--json] [--pr-id N] [--target-branch main] +bdt pr info [--json] [--pr-id N] [--target-branch dev] ``` One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate @@ -87,7 +90,7 @@ the check rollup (only a failed check fails it; skipped/cancelled ones don't cou from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked. -Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `main`), +Azure DevOps limits: the PR is found by the current branch into `--target-branch` (default `dev`), a PR with merge conflicts is reported as an error (exit 1) like `pr status` does, and the closed work items are only those whose URL is in the PR description -- not ones linked via `bdt pr create --issue`. @@ -99,7 +102,7 @@ PR opened from the current branch — not a whole new build/run. Whenever `bdt pr status` reports a failure, it prints a hint to run this. ```bash -bdt pr retry [--target-branch main] +bdt pr retry [--target-branch dev] ``` No build/run ID needed — like `bdt pr status`, it resolves the PR (and its @@ -132,7 +135,7 @@ workflow run triggered *directly* on `--target-branch` and reports its status the same way `pr status` does (failed steps print their logs inline). ```bash -bdt pr watch-deploy [--target-branch main] [--wait] +bdt pr watch-deploy [--target-branch dev] [--wait] ``` After `bdt pr status` reports the PR's build/checks succeeded, if a build/ @@ -153,7 +156,7 @@ branch. Failed steps are printed via `gh run view <id> --log-failed`. ## `bdt pr create` ```bash -bdt pr create --target main # or --target test +bdt pr create --target dev # or --target main / test ``` Creates a PR from the current branch into `--target`. On Azure DevOps, @@ -161,7 +164,7 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by `az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills title/body from commit info so it never blocks on an interactive prompt). Extra arguments pass through either way, e.g. -`bdt pr create --target main -- --title "..."`. +`bdt pr create --target dev -- --title "..."`. PRs are created as **drafts by default**; a successful create always prints the PR's link plus `bdt pr publish` (which abstracts over the host) to mark @@ -308,7 +311,7 @@ last argument); extra args are passed through; `--dry-run` prints the command. ## `bdt issue take` ```bash -bdt issue take [NUMBER] [--target-branch main] +bdt issue take [NUMBER] [--target-branch dev] ``` Claims an issue (GitHub) or work item (Azure DevOps): comments `Taken by <you>` on it, followed by the diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index 830b746..e2f7913 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -35,6 +35,9 @@ __version__ = _pkg_version("bmsdna-devtools") +# What `--target` / `--target-branch` mean when not given: the branch PRs normally go into. +DEFAULT_TARGET_BRANCH = "dev" + app = typer.Typer( name="bdt", help=f"Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs (v{__version__})", @@ -228,7 +231,7 @@ def _link_and_label_ado(session: requests.Session, remote: AdoRemote, pr_id: int @pr_app.command("create") def pr_create( - target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"), + target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch (e.g. dev, main, test)"), draft: bool = typer.Option( True, "--draft/--no-draft", @@ -380,7 +383,7 @@ def _link_issues() -> None: @pr_app.command("publish") def pr_publish( - target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"), + target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), pat: str | None = typer.Option( None, @@ -402,7 +405,7 @@ def pr_publish( @pr_app.command("status") def pr_status( - target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"), + target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"), wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed; stops early and reports status if one needs manual approval"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), 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)"), @@ -420,7 +423,7 @@ def pr_status( @pr_app.command("info") def pr_info( as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (what the bdt Claude Code mod reads)"), - target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only)"), + target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), 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: @@ -440,7 +443,7 @@ def pr_info( @pr_app.command("retry") def pr_retry( - target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only)"), + target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), pat: str | None = typer.Option( None, @@ -462,7 +465,7 @@ def pr_retry( @pr_app.command("watch-deploy") def pr_watch_deploy( - target_branch: str = typer.Option("main", "--target-branch", help="Branch to watch for a directly-triggered build/workflow run (e.g. a post-merge deployment pipeline)"), + target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Branch to watch for a directly-triggered build/workflow run (e.g. a post-merge deployment pipeline)"), wait: bool = typer.Option(False, "--wait", help="Poll until the build/workflow run(s) are completed; stops early and reports status if one needs manual approval"), pat: str | None = typer.Option( None, @@ -497,7 +500,7 @@ def pr_update( file: list[str] = typer.Option( [], "--file", help="Path to an arbitrary file to append to the PR description as a linked attachment (repeatable)" ), - target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"), + target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), pat: str | None = typer.Option( None, @@ -540,7 +543,7 @@ def pr_comment( file: list[str] = typer.Option( [], "--file", help="Path to an arbitrary file to link in the comment as an attachment (repeatable)" ), - target: str = typer.Option("main", "--target", help="Target branch of the PR (Azure DevOps only)"), + target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR (Azure DevOps only)"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), pat: str | None = typer.Option( None, @@ -753,7 +756,7 @@ def issue_do( @issue_app.command("take") def issue_take( number: int | None = typer.Argument(None, help="Issue number (GitHub) or work item ID (Azure DevOps). Default: the issue the current branch's PR closes"), - target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only; only used to find the issue when no number is given)"), + target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only; only used to find the issue when no number is given)"), 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: """Claim an issue / work item: comment "Taken by <you>" on it, with this coding agent's @@ -1197,7 +1200,7 @@ def commit( subrepo: list[str] = typer.Option([], "--subrepo", help="Submodule directory name to split matching files into (repeatable)"), skip_message_check: bool = typer.Option(False, "--skip-message-check", help="Don't require a conventional-commit-style message"), allow_main: bool = typer.Option(False, "--allow-main", help="Allow committing directly on main/master"), - target: str = typer.Option("main", "--target", help="Target branch of the PR to draft on a 'feat' commit (Azure DevOps only)"), + target: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target", help="Target branch of the PR to draft on a 'feat' commit (Azure DevOps only)"), pat: str | None = typer.Option( None, "--pat", diff --git a/skills/bmsdna-devtools/SKILL.md b/skills/bmsdna-devtools/SKILL.md index 79f5bcb..bd64dab 100644 --- a/skills/bmsdna-devtools/SKILL.md +++ b/skills/bmsdna-devtools/SKILL.md @@ -34,7 +34,7 @@ CLI to install rather than trying to work around it. ## Checking PR build/check status ```bash -bdt pr status [--target-branch main] [--wait] +bdt pr status [--target-branch dev] [--wait] ``` Finds the PR opened from the current branch and prints build/check status, @@ -59,14 +59,14 @@ available); GitHub uses whatever `gh auth login` session is active. ## Creating a PR ```bash -bdt pr create --target main # or --target test +bdt pr create --target dev # or --target main / test ``` Uses the current branch as source. On Azure DevOps this wraps `az repos pr create`; on GitHub, `gh pr create --fill` (autofills title/body from commit info, so it never blocks waiting on an interactive prompt). Extra args pass straight through either way, e.g. -`bdt pr create --target main -- --title "..." --description "..."`. +`bdt pr create --target dev -- --title "..." --description "..."`. After creating a PR, use `bdt pr status` (see above) to check whether the CI build passes. diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py index b02eb5d..b128322 100644 --- a/tests/test_pr_info.py +++ b/tests/test_pr_info.py @@ -257,3 +257,23 @@ def test_pr_info_on_azure_devops_passes_the_session_through(monkeypatch) -> None assert result.exit_code == 0, result.output assert json.loads(result.output)["build"] == "passing" and seen == {"session": "session"} + + +def _target_options(command, path=()): + # duck-typed: typer vendors its own click, so there's no `click` to isinstance against + if hasattr(command, "commands"): + for name, sub in command.commands.items(): + yield from _target_options(sub, (*path, name)) + return + for param in command.params: + if param.name in ("target", "target_branch"): + yield " ".join(path), param.default + + +def test_every_target_option_defaults_to_dev() -> None: + import typer.main + + found = dict(_target_options(typer.main.get_command(app))) + + assert {"pr create", "pr status", "pr info", "pr retry", "pr publish", "pr update", "pr comment", "pr watch-deploy", "issue take"} <= set(found) + assert {k: v for k, v in found.items() if v != "dev"} == {} From fda902f8fda5db6003bff89ddc075f21c5dd0e02 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" <adrian.ehrsam@bmsuisse.ch> Date: Sat, 3 Oct 2026 10:35:35 +0000 Subject: [PATCH 6/8] feat(issue do): take the issue before starting the agent Posts 'Taken by <user>' naming the session it generates (--session-id) so the comment links the right session even when bdt issue do itself runs inside an agent; prompt tells the agent not to take it again. comment helpers get agent_note=False for this. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- README.md | 5 +++ bmsdna/devtools/ado_issue.py | 10 ++++- bmsdna/devtools/cli.py | 20 ++++++++-- bmsdna/devtools/gh_issue.py | 11 +++++- bmsdna/devtools/issue_do.py | 21 +++++++--- skills/bmsdna-devtools/SKILL.md | 2 +- tests/test_issue_do.py | 11 ++++++ tests/test_pr_info.py | 69 ++++++++++++++++++++++++++++++++- 8 files changed, 135 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7074496..96e4991 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,11 @@ description and runs `claude -p "<prompt>" --name "60: <title>"`, so the session 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. +It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>` +on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and +passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For +another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. + ## `bdt issue take` ```bash diff --git a/bmsdna/devtools/ado_issue.py b/bmsdna/devtools/ado_issue.py index 53a0332..86d7236 100644 --- a/bmsdna/devtools/ado_issue.py +++ b/bmsdna/devtools/ado_issue.py @@ -379,14 +379,20 @@ def comment_with_screenshots( message: str | None, screenshot_paths: list[str], file_paths: list[str] | None = None, + agent_note: bool = True, ) -> dict: - """Post a comment, with a message and/or screenshots/files, on the work item.""" + """Post a comment, with a message and/or screenshots/files, on the work item. + + `agent_note=False` leaves out the detected agent-session note (see `gh_issue.comment`). + """ file_paths = file_paths or [] images = _upload_attachments(session, remote, screenshot_paths) if screenshot_paths else [] files = _upload_attachments(session, remote, file_paths) if file_paths else [] if images or files: link_attachments(session, remote, work_item_id, images + files) - content = ensure_agent_session_note(build_comment_content_html(message, images, files)) or "" + content = build_comment_content_html(message, images, files) or "" + if agent_note: + content = ensure_agent_session_note(content) or "" comment = add_comment(session, remote, work_item_id, content) print( f"Added comment #{comment['id']} ({len(screenshot_paths)} screenshot(s), {len(file_paths)} file(s)) " diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index e2f7913..65d4e11 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -4,6 +4,7 @@ import re import subprocess import sys +import uuid from collections.abc import Callable from datetime import datetime, timedelta, timezone from importlib.metadata import version as _pkg_version @@ -750,7 +751,16 @@ def issue_do( 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) + extra = list(ctx.args) + # claude can be given the session id up front, so the "Taken by" comment can link the session that will exist + session_id = str(uuid.uuid4()) if agent == "claude" and "--session-id" not in extra else None + if dry_run: + typer.echo(f"(dry run: would comment 'Taken by ...' on #{number} first)", err=True) + else: + message = issue_do_mod.take_message(_current_user(remote), agent, session_id) + typer.echo(f"Taking {'issue' if isinstance(remote, GitHubRemote) else 'work item'} #{number}") + _comment_on_issue(remote, number, message, pat, agent_note=False) + issue_do_mod.run(number, title, body, agent=agent, extra=extra, dry_run=dry_run, session_id=session_id) @issue_app.command("take") @@ -774,12 +784,16 @@ def issue_take( number = issues[0].number message = f"Taken by {_current_user(remote)}" typer.echo(f"Taking {'issue' if isinstance(remote, GitHubRemote) else 'work item'} #{number}") + _comment_on_issue(remote, number, message, pat) + + +def _comment_on_issue(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None: if isinstance(remote, GitHubRemote): - gh_issue.comment(require_gh(), remote.owner, remote.repo, number, message, []) + gh_issue.comment(require_gh(), remote.owner, remote.repo, number, message, [], agent_note=agent_note) else: session = requests.Session() session.headers.update(auth_header(pat)) - ado_issue.comment_with_screenshots(session, remote, number, message, [], []) + ado_issue.comment_with_screenshots(session, remote, number, message, [], [], agent_note=agent_note) def _current_user(remote: AdoRemote | GitHubRemote) -> str: diff --git a/bmsdna/devtools/gh_issue.py b/bmsdna/devtools/gh_issue.py index 635f700..d2ab940 100644 --- a/bmsdna/devtools/gh_issue.py +++ b/bmsdna/devtools/gh_issue.py @@ -366,12 +366,19 @@ def comment( message: str | None, screenshot_paths: list[str], file_paths: list[str] | None = None, + agent_note: bool = True, ) -> None: - """Post a comment, with a message and/or screenshots/files, on a GitHub issue.""" + """Post a comment, with a message and/or screenshots/files, on a GitHub issue. + + `agent_note=False` leaves out the detected agent-session note, for a caller (`bdt issue do`) that + is not itself the agent and writes the right session into `message` instead. + """ file_paths = file_paths or [] images = _screenshot_images(owner, repo, f"issue-{number}", screenshot_paths) if screenshot_paths else [] files = _file_links(owner, repo, f"issue-{number}", file_paths) if file_paths else [] - content = ensure_agent_session_note(build_comment_content(message, images, files)) or "" + content = build_comment_content(message, images, files) or "" + if agent_note: + content = ensure_agent_session_note(content) or "" url = _run_gh(gh, ["issue", "comment", str(number), "--body", content]) comment_id = parse_comment_id(url) suffix = f" (comment #{comment_id})" if comment_id else "" diff --git a/bmsdna/devtools/issue_do.py b/bmsdna/devtools/issue_do.py index e7d1c52..1cdb14e 100644 --- a/bmsdna/devtools/issue_do.py +++ b/bmsdna/devtools/issue_do.py @@ -49,20 +49,31 @@ 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." + f"and reference issue {number} in it. Do not merge. `bdt issue do` has already marked " + f"issue {number} as taken (with this session), so don't run `bdt issue take`." ) -def build_command(agent: str, number: int, title: str, body: str, extra: list[str]) -> list[str]: +def take_message(user: str, agent: str, session_id: str | None) -> str: + """The "Taken by" comment `bdt issue do` posts before the agent starts. It names the session itself, + because the agent's own session doesn't exist yet and `bdt`'s auto-detected one (if `bdt issue do` is + run from inside an agent) would be the wrong one.""" + if session_id is None: + return f"Taken by {user} (via {agent})" + return f"Taken by {user}\n\nClaude Session: {session_id} (resume with `claude --resume {session_id}`)" + + +def build_command(agent: str, number: int, title: str, body: str, extra: list[str], session_id: str | None = None) -> list[str]: prompt = build_prompt(number, title, body) if agent == "claude": - return [agent, "-p", prompt, "--name", session_name(number, title), *extra] + session = ["--session-id", session_id] if session_id else [] + return [agent, "-p", prompt, "--name", session_name(number, title), *session, *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) +def run(number: int, title: str, body: str, *, agent: str, extra: list[str], dry_run: bool, session_id: str | None = None) -> None: + cmd = build_command(agent, number, title, body, extra, session_id) if dry_run: print(" ".join(json.dumps(c) if " " in c or "\n" in c else c for c in cmd)) return diff --git a/skills/bmsdna-devtools/SKILL.md b/skills/bmsdna-devtools/SKILL.md index bd64dab..4ea5f12 100644 --- a/skills/bmsdna-devtools/SKILL.md +++ b/skills/bmsdna-devtools/SKILL.md @@ -81,7 +81,7 @@ When you start working on an issue / work item, run this first: it comments `Tak your session (the claude.ai link under a bridged Claude session, otherwise the bare session id; nothing when no agent is detected) so others can see it's being worked on. No parameters needed -- the session is detected, and without `NUMBER` the issue is the one the current branch's PR closes -(so open the PR with `Fixes #N` first, or pass the number). Run it once. Don't hand-write a +(so open the PR with `Fixes #N` first, or pass the number). Run it once. (`bdt issue do N` already does this for you before it starts the agent -- don't repeat it there.) Don't hand-write a session-link comment instead. ## PR summary diff --git a/tests/test_issue_do.py b/tests/test_issue_do.py index 24d99fb..3a3d3eb 100644 --- a/tests/test_issue_do.py +++ b/tests/test_issue_do.py @@ -18,3 +18,14 @@ def test_other_agent_gets_prompt_last() -> None: 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 + + +def test_claude_gets_the_given_session_id() -> None: + cmd = issue_do.build_command("claude", 60, "t", "b", ["--model", "opus"], session_id="abc") + assert cmd[cmd.index("--session-id") + 1] == "abc" + assert cmd[-2:] == ["--model", "opus"] + + +def test_take_message_names_the_session() -> None: + assert issue_do.take_message("me", "claude", "abc") == "Taken by me\n\nClaude Session: abc (resume with `claude --resume abc`)" + assert issue_do.take_message("me", "codex", None) == "Taken by me (via codex)" diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py index b128322..10ec27e 100644 --- a/tests/test_pr_info.py +++ b/tests/test_pr_info.py @@ -189,7 +189,7 @@ def _take_cli(monkeypatch, *, user: str = "octocat") -> list[tuple]: _github_cli(monkeypatch) monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: user) calls: list[tuple] = [] - monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.comment", lambda *args: calls.append(args)) + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.comment", lambda *args, **kw: calls.append(args)) return calls @@ -277,3 +277,70 @@ def test_every_target_option_defaults_to_dev() -> None: assert {"pr create", "pr status", "pr info", "pr retry", "pr publish", "pr update", "pr comment", "pr watch-deploy", "issue take"} <= set(found) assert {k: v for k, v in found.items() if v != "dev"} == {} + + +# -- bdt issue do takes the issue first --------------------------------------- + + +def _do_cli(monkeypatch, *, agent_env: bool = False) -> dict: + """GitHub issue 12 with `gh` stubbed; records the posted comments and the started agent command.""" + monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: GITHUB_REMOTE) + monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") + monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: "octocat") + monkeypatch.setattr("bmsdna.devtools.cli.issue_do_mod.fetch_github", lambda gh, n: ("add thing", "details")) + seen: dict = {"events": [], "bodies": []} + + def run_gh(gh, args): + seen["events"].append("comment") + seen["bodies"].append(args[args.index("--body") + 1]) + return "https://x/issues/12#issuecomment-1" + + def start(cmd, check=False): + seen["events"].append("agent") + seen["cmd"] = cmd + return type("R", (), {"returncode": 0})() + + monkeypatch.setattr("bmsdna.devtools.gh_issue._run_gh", run_gh) + monkeypatch.setattr("bmsdna.devtools.issue_do.shutil.which", lambda a: "/bin/" + a) + monkeypatch.setattr("bmsdna.devtools.issue_do.subprocess.run", start) + if agent_env: + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.setenv("CLAUDE_CODE_BRIDGE_SESSION_ID", "session_OUTER") + return seen + + +def test_issue_do_takes_the_issue_with_the_new_session_before_starting_the_agent(monkeypatch) -> None: + seen = _do_cli(monkeypatch) + + result = runner.invoke(app, ["issue", "do", "12"]) + + assert seen["events"] == ["comment", "agent"], result.output + session_id = seen["cmd"][seen["cmd"].index("--session-id") + 1] + assert seen["bodies"] == [f"Taken by octocat\n\nClaude Session: {session_id} (resume with `claude --resume {session_id}`)"] + assert "bdt issue take" in seen["cmd"][2] # the prompt says not to run it again + + +def test_issue_do_does_not_credit_the_outer_agent_session(monkeypatch) -> None: + """`bdt issue do` can itself run inside an agent; that session must not end up on the comment.""" + seen = _do_cli(monkeypatch, agent_env=True) + + runner.invoke(app, ["issue", "do", "12"]) + + assert "session_OUTER" not in seen["bodies"][0] + + +def test_issue_do_dry_run_posts_nothing(monkeypatch) -> None: + seen = _do_cli(monkeypatch) + + result = runner.invoke(app, ["issue", "do", "12", "--dry-run"]) + + assert result.exit_code == 0 and seen["events"] == [] + assert "--session-id" in result.output + + +def test_issue_do_other_agent_is_taken_without_a_session(monkeypatch) -> None: + seen = _do_cli(monkeypatch) + + runner.invoke(app, ["issue", "do", "12", "--agent", "codex"]) + + assert seen["bodies"] == ["Taken by octocat (via codex)"] and "--session-id" not in seen["cmd"] From 894e594e8a2e07598fc9f9df1b578eaaf34fad88 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" <adrian.ehrsam@bmsuisse.ch> Date: Sat, 3 Oct 2026 10:38:22 +0000 Subject: [PATCH 7/8] feat(issue take): don't comment again when the newest comment already is a claim Reads the issue's last comment (GitHub GraphQL last:1, ADO comments order=desc) and skips when it starts with 'Taken by'; applies to issue do too, which still starts the agent. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- README.md | 6 +-- bmsdna/devtools/ado_issue.py | 11 +++++ bmsdna/devtools/cli.py | 27 +++++++++--- bmsdna/devtools/gh_issue.py | 13 ++++++ bmsdna/devtools/issue_do.py | 11 ++++- skills/bmsdna-devtools/SKILL.md | 2 +- tests/test_pr_info.py | 76 +++++++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 96e4991..3f5b740 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ last argument); extra args are passed through; `--dry-run` prints the command. It takes the issue first (like `bdt issue take`): before the agent starts it comments `Taken by <you>` on the issue, naming the session it is about to start -- for `claude` a `--session-id` is generated and passed, so the comment says `claude --resume <id>`; the prompt tells the agent not to take it again. For -another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. +another `--agent` the comment just says `(via <agent>)`. `--dry-run` posts nothing. Like `issue take`, it doesn't comment again when the newest comment already is a claim. ## `bdt issue take` @@ -325,8 +325,8 @@ claude.ai link under a bridged Claude session, otherwise the bare session id; no agent), so others can see who is on it. `<you>` is the GitHub login `gh` is authenticated as, else the git `user.name`. Without `NUMBER` it takes the issue the current branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it refuses to guess if -that is none or several. Run it once, right after you start on the issue -- it doesn't check for an -earlier "Taken by" comment. +that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it +says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`. ## Claude Code mod: `bdt-status` diff --git a/bmsdna/devtools/ado_issue.py b/bmsdna/devtools/ado_issue.py index 86d7236..7091ff5 100644 --- a/bmsdna/devtools/ado_issue.py +++ b/bmsdna/devtools/ado_issue.py @@ -314,6 +314,17 @@ def add_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, return r.json() +def last_comment(session: requests.Session, remote: AdoRemote, work_item_id: int) -> str | None: + """Text (HTML, as stored) of the work item's newest comment, or None if it has none.""" + r = session.get( + f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments", + params={"api-version": COMMENTS_API_VERSION, "$top": 1, "order": "desc"}, + ) + r.raise_for_status() + comments = r.json().get("comments") or [] + return comments[0].get("text") if comments else None + + def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict: r = session.patch( f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}", diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index 65d4e11..9abb825 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -757,9 +757,7 @@ def issue_do( if dry_run: typer.echo(f"(dry run: would comment 'Taken by ...' on #{number} first)", err=True) else: - message = issue_do_mod.take_message(_current_user(remote), agent, session_id) - typer.echo(f"Taking {'issue' if isinstance(remote, GitHubRemote) else 'work item'} #{number}") - _comment_on_issue(remote, number, message, pat, agent_note=False) + _take(remote, number, issue_do_mod.take_message(_current_user(remote), agent, session_id), pat, agent_note=False) issue_do_mod.run(number, title, body, agent=agent, extra=extra, dry_run=dry_run, session_id=session_id) @@ -782,9 +780,26 @@ def issue_take( else "No issue number given, and the current branch's PR doesn't reference one (e.g. 'Fixes #N')" ) number = issues[0].number - message = f"Taken by {_current_user(remote)}" - typer.echo(f"Taking {'issue' if isinstance(remote, GitHubRemote) else 'work item'} #{number}") - _comment_on_issue(remote, number, message, pat) + _take(remote, number, f"{issue_do_mod.TAKEN_PREFIX} {_current_user(remote)}", pat) + + +def _take(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None: + """Comment `message` ("Taken by ...") on the issue -- unless its newest comment already is such a + claim, so running this twice (or `issue do` after `issue take`) doesn't post it again.""" + kind = "issue" if isinstance(remote, GitHubRemote) else "work item" + if issue_do_mod.is_take_comment(_last_comment(remote, number, pat)): + typer.echo(f"{kind.capitalize()} #{number} is already taken (its last comment says so) -- not commenting again") + return + typer.echo(f"Taking {kind} #{number}") + _comment_on_issue(remote, number, message, pat, agent_note=agent_note) + + +def _last_comment(remote: AdoRemote | GitHubRemote, number: int, pat: str | None) -> str | None: + if isinstance(remote, GitHubRemote): + return gh_issue.last_comment(require_gh(), remote.owner, remote.repo, number) + session = requests.Session() + session.headers.update(auth_header(pat)) + return ado_issue.last_comment(session, remote, number) def _comment_on_issue(remote: AdoRemote | GitHubRemote, number: int, message: str, pat: str | None, *, agent_note: bool = True) -> None: diff --git a/bmsdna/devtools/gh_issue.py b/bmsdna/devtools/gh_issue.py index d2ab940..22b85bf 100644 --- a/bmsdna/devtools/gh_issue.py +++ b/bmsdna/devtools/gh_issue.py @@ -386,6 +386,19 @@ def comment( print(url) +_LAST_COMMENT_QUERY = ( + "query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) " + "{ issue(number: $number) { comments(last: 1) { nodes { body } } } } }" +) + + +def last_comment(gh: str, owner: str, repo: str, number: int) -> str | None: + """Text of the issue's newest comment, or None if it has none.""" + out = _run_gh(gh, ["api", "graphql", "-f", f"query={_LAST_COMMENT_QUERY}", "-f", f"owner={owner}", "-f", f"repo={repo}", "-F", f"number={number}"]) + nodes = json.loads(out)["data"]["repository"]["issue"]["comments"]["nodes"] + return nodes[-1]["body"] if nodes else None + + def update_comment(gh: str, owner: str, repo: str, comment_id: str, text: str) -> None: _run_gh(gh, ["api", "--method", "PATCH", f"repos/{owner}/{repo}/issues/comments/{comment_id}", "-f", f"body={text}"]) print(f"Updated comment #{comment_id}") diff --git a/bmsdna/devtools/issue_do.py b/bmsdna/devtools/issue_do.py index 1cdb14e..efccfe7 100644 --- a/bmsdna/devtools/issue_do.py +++ b/bmsdna/devtools/issue_do.py @@ -19,6 +19,7 @@ from .gitrepo import AdoRemote _HTML_TAG_RE = re.compile(r"<[^>]+>") +TAKEN_PREFIX = "Taken by" def fetch_github(gh: str, number: int) -> tuple[str, str]: @@ -54,13 +55,19 @@ def build_prompt(number: int, title: str, body: str) -> str: ) +def is_take_comment(text: str | None) -> bool: + """Is `text` (a comment, markdown or ADO HTML) a "Taken by ..." claim -- what `take_message` and + `bdt issue take` post?""" + return _HTML_TAG_RE.sub("", text or "").strip().startswith(TAKEN_PREFIX) + + def take_message(user: str, agent: str, session_id: str | None) -> str: """The "Taken by" comment `bdt issue do` posts before the agent starts. It names the session itself, because the agent's own session doesn't exist yet and `bdt`'s auto-detected one (if `bdt issue do` is run from inside an agent) would be the wrong one.""" if session_id is None: - return f"Taken by {user} (via {agent})" - return f"Taken by {user}\n\nClaude Session: {session_id} (resume with `claude --resume {session_id}`)" + return f"{TAKEN_PREFIX} {user} (via {agent})" + return f"{TAKEN_PREFIX} {user}\n\nClaude Session: {session_id} (resume with `claude --resume {session_id}`)" def build_command(agent: str, number: int, title: str, body: str, extra: list[str], session_id: str | None = None) -> list[str]: diff --git a/skills/bmsdna-devtools/SKILL.md b/skills/bmsdna-devtools/SKILL.md index 4ea5f12..b0da89b 100644 --- a/skills/bmsdna-devtools/SKILL.md +++ b/skills/bmsdna-devtools/SKILL.md @@ -81,7 +81,7 @@ When you start working on an issue / work item, run this first: it comments `Tak your session (the claude.ai link under a bridged Claude session, otherwise the bare session id; nothing when no agent is detected) so others can see it's being worked on. No parameters needed -- the session is detected, and without `NUMBER` the issue is the one the current branch's PR closes -(so open the PR with `Fixes #N` first, or pass the number). Run it once. (`bdt issue do N` already does this for you before it starts the agent -- don't repeat it there.) Don't hand-write a +(so open the PR with `Fixes #N` first, or pass the number). It does nothing if the issue's newest comment already is a "Taken by" claim, so repeating it is harmless. (`bdt issue do N` already does this for you before it starts the agent -- don't repeat it there.) Don't hand-write a session-link comment instead. ## PR summary diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py index 10ec27e..1a42a51 100644 --- a/tests/test_pr_info.py +++ b/tests/test_pr_info.py @@ -190,6 +190,7 @@ def _take_cli(monkeypatch, *, user: str = "octocat") -> list[tuple]: monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: user) calls: list[tuple] = [] monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.comment", lambda *args, **kw: calls.append(args)) + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.last_comment", lambda *args: None) return calls @@ -238,6 +239,7 @@ def test_issue_take_note_includes_session_link_under_claude(monkeypatch) -> None monkeypatch.setenv("CLAUDE_CODE_BRIDGE_SESSION_ID", "session_abc") _github_cli(monkeypatch) monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: "octocat") + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.last_comment", lambda *args: None) bodies: list[str] = [] monkeypatch.setattr("bmsdna.devtools.gh_issue._run_gh", lambda gh, args: bodies.append(args[args.index("--body") + 1]) or "https://x/issues/12#issuecomment-1") @@ -288,6 +290,7 @@ def _do_cli(monkeypatch, *, agent_env: bool = False) -> dict: monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh") monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: "octocat") monkeypatch.setattr("bmsdna.devtools.cli.issue_do_mod.fetch_github", lambda gh, n: ("add thing", "details")) + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.last_comment", lambda *args: None) seen: dict = {"events": [], "bodies": []} def run_gh(gh, args): @@ -344,3 +347,76 @@ def test_issue_do_other_agent_is_taken_without_a_session(monkeypatch) -> None: runner.invoke(app, ["issue", "do", "12", "--agent", "codex"]) assert seen["bodies"] == ["Taken by octocat (via codex)"] and "--session-id" not in seen["cmd"] + + +# -- never take twice --------------------------------------------------------- + + +@pytest.mark.parametrize("last", ["Taken by octocat", "Taken by someone-else\n\nClaude Session: https://claude.ai/code/session_x", " Taken by x"]) +def test_issue_take_skips_when_the_last_comment_is_already_a_take(monkeypatch, last) -> None: + calls = _take_cli(monkeypatch) + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.last_comment", lambda *args: last) + + result = runner.invoke(app, ["issue", "take", "12"]) + + assert result.exit_code == 0 and calls == [] + assert "already taken" in result.output + + +def test_issue_take_comments_when_the_last_comment_is_something_else(monkeypatch) -> None: + calls = _take_cli(monkeypatch) + # an older take followed by a later discussion comment: the claim is no longer the latest word + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.last_comment", lambda *args: "Looks good, who is on this?") + + assert runner.invoke(app, ["issue", "take", "12"]).exit_code == 0 + assert len(calls) == 1 + + +def test_issue_take_on_azure_devops_reads_the_html_comment(monkeypatch) -> None: + monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: ADO_REMOTE) + monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {}) + monkeypatch.setattr("bmsdna.devtools.cli._current_user", lambda remote: "me") + monkeypatch.setattr("bmsdna.devtools.cli.ado_issue.last_comment", lambda session, remote, n: "<div>Taken by <b>someone</b></div>") + posted = [] + monkeypatch.setattr("bmsdna.devtools.cli.ado_issue.comment_with_screenshots", lambda *a, **k: posted.append(a)) + + result = runner.invoke(app, ["issue", "take", "7"]) + + assert result.exit_code == 0 and posted == [] and "already taken" in result.output + + +def test_issue_do_still_starts_the_agent_when_already_taken(monkeypatch) -> None: + seen = _do_cli(monkeypatch) + monkeypatch.setattr("bmsdna.devtools.cli.gh_issue.last_comment", lambda *args: "Taken by octocat") + + runner.invoke(app, ["issue", "do", "12"]) + + assert seen["events"] == ["agent"] + + +def test_gh_last_comment_reads_graphql(monkeypatch) -> None: + from bmsdna.devtools import gh_issue + + out = json.dumps({"data": {"repository": {"issue": {"comments": {"nodes": [{"body": "Taken by x"}]}}}}}) + seen = {} + monkeypatch.setattr(gh_issue, "_run_gh", lambda gh, args: seen.update(args=args) or out) + + assert gh_issue.last_comment("gh", "o", "r", 5) == "Taken by x" + assert "number=5" in seen["args"] and "owner=o" in seen["args"] + + empty = json.dumps({"data": {"repository": {"issue": {"comments": {"nodes": []}}}}}) + monkeypatch.setattr(gh_issue, "_run_gh", lambda gh, args: empty) + assert gh_issue.last_comment("gh", "o", "r", 5) is None + + +def test_ado_last_comment_asks_for_newest_first() -> None: + from unittest.mock import MagicMock + + from bmsdna.devtools import ado_issue + + session = MagicMock() + session.get.return_value.json.return_value = {"comments": [{"text": "<p>Taken by x</p>"}]} + + assert ado_issue.last_comment(session, ADO_REMOTE, 7) == "<p>Taken by x</p>" + params = session.get.call_args.kwargs["params"] + assert params["order"] == "desc" and params["$top"] == 1 From 3574fd06a6aaa7aaa79a0e22d3389f31f785ae02 Mon Sep 17 00:00:00 2001 From: "Claude Code (for Adrian Ehrsam)" <adrian.ehrsam@bmsuisse.ch> Date: Sat, 3 Oct 2026 11:33:26 +0000 Subject: [PATCH 8/8] chore: move the bdt-status Claude mod out to bmsuisse/skills#61 pr info --json stays here as its data source. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- .claude-plugin/marketplace.json | 12 --- README.md | 23 +----- bmsdna/devtools/cli.py | 2 +- bmsdna/devtools/pr_info.py | 4 +- mods/bdt-status/.claude-plugin/plugin.json | 6 -- mods/bdt-status/hooks/hooks.json | 3 - mods/bdt-status/hooks/register.js | 96 ---------------------- mods/bdt-status/tests/bdt-status.test.ts | 89 -------------------- tests/test_pr_info.py | 2 +- 9 files changed, 6 insertions(+), 231 deletions(-) delete mode 100644 .claude-plugin/marketplace.json delete mode 100644 mods/bdt-status/.claude-plugin/plugin.json delete mode 100644 mods/bdt-status/hooks/hooks.json delete mode 100644 mods/bdt-status/hooks/register.js delete mode 100644 mods/bdt-status/tests/bdt-status.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index 5fde055..0000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "bmsuisse-devtools", - "owner": { "name": "BMS" }, - "metadata": { "description": "Claude Code mods that ship with bmsdna-devtools" }, - "plugins": [ - { - "name": "bdt-status", - "source": "./mods/bdt-status", - "description": "Shows the current branch's PR and the issue it closes above the prompt, with the build state" - } - ] -} diff --git a/README.md b/README.md index 3f5b740..e4ff93f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # bmsdna-devtools Shared developer tooling for BMS projects: PR build/check status and a -one-shot PR summary (`bdt pr info`, also shown in Claude Code by the bundled -`bdt-status` mod), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and +one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, git worktrees (creation and merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with pre-flight checks, Azure log queries, and static checks (`bdt lint`) for postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in @@ -85,7 +84,7 @@ bdt pr info [--json] [--pr-id N] [--target-branch dev] One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate build state (`passing`, `failing`, `pending`, `waiting` for a manual approval, `none`) and the issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. `--json` is what -the Claude Code mod below reads. Build state is judged the way `bdt pr status` does: on GitHub from +a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way `bdt pr status` does: on GitHub from the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or `waiting` when a stage needs approval). `unknown` if Azure DevOps can't be asked. @@ -328,24 +327,6 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`. -## Claude Code mod: `bdt-status` - -[Mods](https://code.claude.com/docs/en/plugins/mods/overview) are plugins that change how Claude -Code looks and behaves (Claude Code v2.1.287+). `mods/bdt-status` draws one line above the prompt --- `PR #69 ✗ failing · closes #68`, both numbers clickable -- refreshed every 30 seconds and after -each turn, from `bdt pr info --json`. Nothing is drawn when the branch has no PR or `bdt` isn't on -PATH. It shows in the terminal and the Desktop app (not `claude -p`, the VS Code panel or cloud -sessions, where mods can't draw). - -```bash -claude plugin marketplace add bmsuisse/devtools -claude plugin install bdt-status@bmsuisse-devtools -``` - -Mods run with your permissions, unsandboxed; this one only runs `bdt pr info --json` -(`claude plugin validate mods/bdt-status` lists its calls). Test it with -`claude plugin test mods/bdt-status`. - ## `bdt worktree` ```bash diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index 9abb825..8c4ea0e 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -423,7 +423,7 @@ def pr_status( @pr_app.command("info") def pr_info( - as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (what the bdt Claude Code mod reads)"), + as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON (for status integrations that poll it)"), target_branch: str = typer.Option(DEFAULT_TARGET_BRANCH, "--target-branch", help="Target branch of the PR (Azure DevOps only)"), pr_id: int | None = typer.Option(None, "--pr-id", help=_PR_ID_HELP), 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)"), diff --git a/bmsdna/devtools/pr_info.py b/bmsdna/devtools/pr_info.py index 65cc8f7..a9a45d5 100644 --- a/bmsdna/devtools/pr_info.py +++ b/bmsdna/devtools/pr_info.py @@ -1,7 +1,7 @@ """`bdt pr info`: a one-shot, machine-readable summary of the current branch's PR. -The bdt Claude Code mod (`mods/bdt-status`) polls this to show a link to the PR and the -issue/work item it closes, plus the build state, above the prompt. Kept as plain data (no +A status integration (e.g. the Claude Code mod planned in bmsuisse/skills#61) polls this to show a +link to the PR and the issue/work item it closes, plus the build state, above the prompt. Kept as plain data (no human-oriented formatting) so any other tool -- a shell prompt, a dashboard -- can consume it too. """ diff --git a/mods/bdt-status/.claude-plugin/plugin.json b/mods/bdt-status/.claude-plugin/plugin.json deleted file mode 100644 index d19ddc5..0000000 --- a/mods/bdt-status/.claude-plugin/plugin.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "bdt-status", - "version": "0.1.0", - "description": "Shows the current branch's PR and the issue it closes above the prompt, with the build state (uses `bdt pr info`)", - "author": { "name": "BMS" } -} diff --git a/mods/bdt-status/hooks/hooks.json b/mods/bdt-status/hooks/hooks.json deleted file mode 100644 index 0d5260d..0000000 --- a/mods/bdt-status/hooks/hooks.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "modules": ["./register.js"] -} diff --git a/mods/bdt-status/hooks/register.js b/mods/bdt-status/hooks/register.js deleted file mode 100644 index bc58712..0000000 --- a/mods/bdt-status/hooks/register.js +++ /dev/null @@ -1,96 +0,0 @@ -// Shows "PR #69 ✓ · closes #68" (clickable links) in the band above the prompt, from `bdt pr info --json`. -// Needs `bdt` on PATH; with no PR for the current branch (or no bdt) the band is left alone. - -const REFRESH_MS = 30_000 - -// Single-width symbols, not emoji, so the band lines up in every terminal -const BUILD = { - passing: { symbol: '✓', color: 'green' }, - failing: { symbol: '✗', color: 'red' }, - pending: { symbol: '…', color: 'yellow' }, - waiting: { symbol: '⏸', color: 'yellow' }, -} - -// The last `bdt pr info --json` result, or null when the branch has no PR -let info = null -let isRefreshing = false -let timer = null - -const LINK = /^https:\/\/(github\.com|dev\.azure\.com)\/[^\s\x00-\x1f]*$/ - -// `bdt` output only if it has the shape `bdt pr info --json` produces, so a shadowing or outdated `bdt` on PATH -// can't make the band render an arbitrary link -function validated(i) { - const isLink = (x) => typeof x.number === 'number' && typeof x.url === 'string' && LINK.test(x.url) - return isLink(i) && Array.isArray(i.issues) && i.issues.every(isLink) ? i : undefined -} - -// `undefined`: couldn't ask (bdt missing or timed out, unusable output) -- keep showing the last answer. -// `null`: bdt answered that there is no PR for this branch. -async function readInfo($) { - try { - const r = await $.process.run(['bdt', 'pr', 'info', '--json']) - return r.exitCode === 0 ? validated(JSON.parse(r.stdout)) : null - } catch { - return undefined - } -} - -async function refresh($) { - // A slow `gh` call must not stack up behind the timer - if (isRefreshing) return - isRefreshing = true - try { - const next = await readInfo($) - if (next !== undefined && JSON.stringify(next) !== JSON.stringify(info)) { - info = next - $.ui.invalidate('ui.render') - } - } catch { - // a failed redraw must not become an unhandled rejection in a timer callback - } finally { - isRefreshing = false - } -} - -export function register(on) { - on('session.start', async ($, e, next) => { - // once per module load, not per session.start - timer ??= $.clock.every(REFRESH_MS, () => refresh($)) - // Not awaited: the session starts right away, and the band fills in when bdt answers - refresh($) - return next(e) - }) - - // A turn is when the branch is most likely to have been pushed or a PR opened - on('turn.complete', async ($, e, next) => { - refresh($) - return next(e) - }) - - on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => { - // Keep whatever Claude Code or other mods draw in the band - const theirs = await next(e) - if (!info) return theirs - - const { Box, Text, Link } = $.ui.resolve(e) - const build = BUILD[info.build] - const state = info.draft ? 'draft' : info.state === 'open' ? '' : info.state - - const line = Box({ - flexDirection: 'row', - columnGap: 1, - children: [ - Link({ href: info.url, label: 'PR #' + info.number }), - ...(build ? [Text({ color: build.color, children: [build.symbol + ' ' + info.build] })] : []), - ...(state ? [Text({ dimColor: true, children: [state] })] : []), - ...info.issues.flatMap((issue) => [ - Text({ dimColor: true, children: ['· closes'] }), - Link({ href: issue.url, label: '#' + issue.number }), - ]), - ], - }) - - return Box({ flexDirection: 'column', children: theirs ? [line, theirs] : [line] }) - }) -} diff --git a/mods/bdt-status/tests/bdt-status.test.ts b/mods/bdt-status/tests/bdt-status.test.ts deleted file mode 100644 index f698594..0000000 --- a/mods/bdt-status/tests/bdt-status.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect, mock, test } from 'claude-code/testing' - -const BAND = { - plugin: 'bdt-status', - component: 'AbovePrompt', - surface: 'terminal', - viewport: { columns: 100, rows: 30 }, - props: { hasSurvey: false, isWorking: false, maxRows: 5, bodyColumns: 80, scroll: { offset: 0, bodyRows: 5 }, view: {} }, -} as const - -const INFO = { - number: 69, - title: 'feat: mod', - url: 'https://github.com/bmsuisse/devtools/pull/69', - state: 'open', - draft: false, - build: 'failing', - issues: [{ number: 68, url: 'https://github.com/bmsuisse/devtools/issues/68' }], -} - -// Answers what Claude Code would: the session starts, `bdt pr info --json` prints `stdout` (exit `exitCode`), -// and the band has nothing of its own to draw -function stubSession(on, exitCode: number, stdout: string) { - const clock = mock.clock(on) - on('session.start', () => ({ cwd: '/work' })) - on('process.run', () => ({ value: { exitCode, stdout, stderr: '' } })) - on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) - return clock -} - -test('links the PR and the issue it closes, with the build state', async ($, on) => { - const clock = stubSession(on, 0, JSON.stringify(INFO)) - await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) - await clock.settle() - - const ui = await $.ui.mount(BAND) - expect((await ui.find({ type: 'Link', text: 'PR #69' }))?.props.href).toBe(INFO.url) - expect((await ui.find({ type: 'Link', text: '#68' }))?.props.href).toBe(INFO.issues[0].url) - expect(await ui.find({ type: 'Text', text: /failing/ })).toBeDefined() - // What Claude Code draws in the band is kept - expect(await ui.find({ type: 'Text', text: 'drawn by Claude Code' })).toBeDefined() -}) - -test('draws nothing of its own when the branch has no PR', async ($, on) => { - const clock = stubSession(on, 1, '') - await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) - await clock.settle() - - const ui = await $.ui.mount(BAND) - expect(await ui.find({ type: 'Link' })).toBeUndefined() - expect(await ui.find({ type: 'Text', text: 'drawn by Claude Code' })).toBeDefined() -}) - -test('picks up a PR that appears later, on the next refresh', async ($, on) => { - const clock = mock.clock(on) - let stdout = '' - on('session.start', () => ({ cwd: '/work' })) - on('process.run', () => ({ value: { exitCode: stdout ? 0 : 1, stdout, stderr: '' } })) - on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) - await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) - await clock.settle() - - stdout = JSON.stringify(INFO) - await clock.advance(30_000) - - const ui = await $.ui.mount(BAND) - expect((await ui.find({ type: 'Link', text: 'PR #69' }))?.props.href).toBe(INFO.url) -}) - -test('keeps showing the last PR when a refresh fails, and drops it when bdt says there is none', async ($, on) => { - const clock = mock.clock(on) - let answer: 'pr' | 'broken' | 'no-pr' = 'pr' - on('session.start', () => ({ cwd: '/work' })) - on('process.run', () => { - if (answer === 'broken') return { deny: 'bdt not found' } - return { value: { exitCode: answer === 'pr' ? 0 : 1, stdout: answer === 'pr' ? JSON.stringify(INFO) : '', stderr: '' } } - }) - on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] })) - await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) - await clock.settle() - - answer = 'broken' - await clock.advance(30_000) - expect(await (await $.ui.mount(BAND)).find({ type: 'Link', text: 'PR #69' })).toBeDefined() - - answer = 'no-pr' - await clock.advance(30_000) - expect(await (await $.ui.mount(BAND)).find({ type: 'Link' })).toBeUndefined() -}) diff --git a/tests/test_pr_info.py b/tests/test_pr_info.py index 1a42a51..c30c86d 100644 --- a/tests/test_pr_info.py +++ b/tests/test_pr_info.py @@ -1,4 +1,4 @@ -"""`bdt pr info` (what the Claude Code mod polls) and `bdt issue take` (GitHub issue #68).""" +"""`bdt pr info` (polled by status integrations) and `bdt issue take` (GitHub issue #68).""" import json