bmsdna-devtools 0.36.0__tar.gz → 0.37.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/PKG-INFO +63 -3
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/README.md +62 -2
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_issue.py +57 -1
- bmsdna_devtools-0.37.0/bmsdna/devtools/agent_mentions.py +82 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli.py +122 -7
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_issue.py +56 -2
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_pr.py +17 -2
- bmsdna_devtools-0.37.0/bmsdna/devtools/issue_watch.py +125 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_build.py +66 -1
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/skills/bmsdna-devtools/SKILL.md +25 -4
- bmsdna_devtools-0.37.0/tests/test_agent_mentions.py +102 -0
- bmsdna_devtools-0.37.0/tests/test_issue_for_me.py +422 -0
- bmsdna_devtools-0.37.0/tests/test_issue_watch.py +183 -0
- bmsdna_devtools-0.37.0/tests/test_pr_description_not_truncated.py +138 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/uv.lock +1 -1
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.gitignore +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.python-version +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/justfile +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_issue.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_id.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_gh_issue.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_translate.py +0 -0
- {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_worktree_clean.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.37.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: pgdevkit[db]>=0.7.1
|
|
@@ -15,7 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
# bmsdna-devtools
|
|
16
16
|
|
|
17
17
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
18
|
-
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
|
|
18
|
+
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, finding issues addressed to an agent (`bdt issue search --for-me`) and waiting for new comments (`bdt issue watch`), git worktrees (creation and
|
|
19
19
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
20
20
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
21
21
|
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
@@ -179,7 +179,15 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
|
|
|
179
179
|
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
|
|
180
180
|
title/body from commit info so it never blocks on an interactive prompt).
|
|
181
181
|
Extra arguments pass through either way, e.g.
|
|
182
|
-
`bdt pr create --target dev -- --
|
|
182
|
+
`bdt pr create --target dev -- --reviewers "..."`.
|
|
183
|
+
|
|
184
|
+
Pass the text with `--title "..."` and `--description "$(cat pr.md)"` rather than as passthrough
|
|
185
|
+
args: it is kept in full. On GitHub the description goes to `gh` on stdin (`--body-file -`); on
|
|
186
|
+
Azure DevOps it is set over the REST API right after `az` creates the PR, because
|
|
187
|
+
`az repos pr create --description` has been seen cutting long text at ~450 characters (a `--title`/
|
|
188
|
+
`--description` given after `--` is intercepted the same way). Azure DevOps itself allows at most
|
|
189
|
+
4000 characters in a PR description: a longer one is rejected up front, and a description the
|
|
190
|
+
server stored shorter than sent is reported as a warning (`bdt pr update` does the same check).
|
|
183
191
|
|
|
184
192
|
PRs are created as **drafts by default**; a successful create always prints
|
|
185
193
|
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
|
|
@@ -343,6 +351,58 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
|
|
|
343
351
|
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
|
|
344
352
|
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
|
|
345
353
|
|
|
354
|
+
## `bdt issue search --for-me` / `bdt issue watch`
|
|
355
|
+
|
|
356
|
+
Issues are how people and coding agents talk to each other: someone comments `@Claude, can you look
|
|
357
|
+
at this?` on an issue, and the agent finds it; the agent asks a question in an issue and waits for the answer.
|
|
358
|
+
|
|
359
|
+
```bash
|
|
360
|
+
bdt issue search --for-me [--agent claude] [--state all] [--limit 10]
|
|
361
|
+
bdt issue watch 84 [--timeout 900] [--interval 20] [--since-comment ID] [--for-me] [--skip-own] [--json]
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
**`issue search --for-me`** only keeps issues (GitHub) / work items (Azure DevOps) whose **last comment**
|
|
365
|
+
addresses the calling agent. It combines with every other `search` filter (keywords, `--label`, `--board`,
|
|
366
|
+
`--org-wide`, ...). A comment addresses the agent when it contains `@Claude`, `@Codex`, `@Goose`, ... for
|
|
367
|
+
the agent that is running `bdt`, or `@Agent`, which addresses every agent. Matching is case-insensitive and
|
|
368
|
+
on whole words: `@claude`, `@CLAUDE,` and `@Claude.` match, `@Claudette`, `@claude-bot` and `me@claude.ai` do not.
|
|
369
|
+
Only the newest comment counts, so once someone (including the agent, e.g. with a reply) comments after the
|
|
370
|
+
mention, the issue drops out of the list. The newest comment isn't part of the list output of `gh`/Azure DevOps,
|
|
371
|
+
so this costs one extra request per candidate; candidates are scanned in recently-updated order (up to 10x
|
|
372
|
+
`--limit` on GitHub, 200 on Azure DevOps) until `--limit` have matched.
|
|
373
|
+
|
|
374
|
+
Which agent is calling is detected from the environment, first match wins: `--agent NAME` (or the `BDT_AGENT`
|
|
375
|
+
environment variable, to pin it), `CLAUDECODE=1` (Claude), the agent-agnostic `AI_AGENT` convention (first
|
|
376
|
+
token of its value, `claude-code_2-1_agent` is Claude; `gh` reads it too), then marker variables of
|
|
377
|
+
`CLAUDE_CODE_*` (Claude), `CODEX_SANDBOX*` / `CODEX_THREAD_*` / `CODEX_CI` (Codex), `GOOSE_TERMINAL` /
|
|
378
|
+
`GOOSE_SESSION` (Goose), `OPENCODE` (OpenCode) and `GEMINI_CLI=1` (Gemini). These variables are not a
|
|
379
|
+
documented contract of those tools, so use `--agent`/`BDT_AGENT` for any other agent (any name works:
|
|
380
|
+
`--agent myagent` looks for `@myagent`). With no agent detected (a human at a terminal), `--for-me` notes
|
|
381
|
+
that on stderr and matches `@Agent` only.
|
|
382
|
+
|
|
383
|
+
**`issue watch NUMBER`** (also available as `bdt watch-issue NUMBER`) blocks until a new comment arrives on the
|
|
384
|
+
issue / work item, polling every `--interval` seconds (default 20), then prints the new comment(s) and exits 0. It
|
|
385
|
+
exits **4** if nothing arrived within `--timeout` seconds (default 900; `0` waits forever), so an agent can
|
|
386
|
+
tell "no answer yet" from a failure (exit 1) and run it again. Comments that were already there when the
|
|
387
|
+
watch started are ignored -- use `--since-comment ID` (the id `issue comment add` printed) to also receive anything
|
|
388
|
+
posted after that comment, which avoids missing a reply that came in before the watch started. So an agent asks
|
|
389
|
+
and waits like this:
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
bdt issue comment add 84 --message "@aersam Should this also cover Azure DevOps?" # prints (comment #123)
|
|
393
|
+
bdt issue watch 84 --since-comment 123 --skip-own --json --timeout 1800
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
- `--for-me` only reports new comments that address the calling agent (same matching and `--agent` as `search`).
|
|
397
|
+
- `--skip-own` ignores comments this agent wrote itself: those carrying its session note (bdt appends
|
|
398
|
+
`<Agent> Session: <id>` to every comment it posts under an agent) or, outside an agent, those authored by the
|
|
399
|
+
authenticated user. Comments of a human who shares the agent's account are still reported under an agent.
|
|
400
|
+
- `--json` prints each new comment as one JSON object per line (`id`, `author`, `created_at`, `url`, `body`; Azure
|
|
401
|
+
DevOps's HTML is converted to plain text); without it a short readable block per comment. Status messages go to
|
|
402
|
+
stderr, so stdout holds only the comments.
|
|
403
|
+
- A single failed poll (network blip, API error) is retried; three in a row abort with the error.
|
|
404
|
+
- On Azure DevOps only the newest 100 comments of a work item are looked at per poll.
|
|
405
|
+
|
|
346
406
|
## `bdt env get-keys`
|
|
347
407
|
|
|
348
408
|
Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# bmsdna-devtools
|
|
2
2
|
|
|
3
3
|
Shared developer tooling for BMS projects: PR build/check status and a
|
|
4
|
-
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
|
|
4
|
+
one-shot PR summary (`bdt pr info`), PR creation, claiming an issue (`bdt issue take`), issue/work item creation and comments, finding issues addressed to an agent (`bdt issue search --for-me`) and waiting for new comments (`bdt issue watch`), git worktrees (creation and
|
|
5
5
|
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
|
|
6
6
|
pre-flight checks, Azure log queries, and static checks (`bdt lint`) for
|
|
7
7
|
postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
|
|
@@ -165,7 +165,15 @@ a thin wrapper around `az repos pr create` (org/project/repo inferred by
|
|
|
165
165
|
`az` itself from the git remote). On GitHub, `gh pr create --fill` (autofills
|
|
166
166
|
title/body from commit info so it never blocks on an interactive prompt).
|
|
167
167
|
Extra arguments pass through either way, e.g.
|
|
168
|
-
`bdt pr create --target dev -- --
|
|
168
|
+
`bdt pr create --target dev -- --reviewers "..."`.
|
|
169
|
+
|
|
170
|
+
Pass the text with `--title "..."` and `--description "$(cat pr.md)"` rather than as passthrough
|
|
171
|
+
args: it is kept in full. On GitHub the description goes to `gh` on stdin (`--body-file -`); on
|
|
172
|
+
Azure DevOps it is set over the REST API right after `az` creates the PR, because
|
|
173
|
+
`az repos pr create --description` has been seen cutting long text at ~450 characters (a `--title`/
|
|
174
|
+
`--description` given after `--` is intercepted the same way). Azure DevOps itself allows at most
|
|
175
|
+
4000 characters in a PR description: a longer one is rejected up front, and a description the
|
|
176
|
+
server stored shorter than sent is reported as a warning (`bdt pr update` does the same check).
|
|
169
177
|
|
|
170
178
|
PRs are created as **drafts by default**; a successful create always prints
|
|
171
179
|
the PR's link plus `bdt pr publish` (which abstracts over the host) to mark
|
|
@@ -329,6 +337,58 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
|
|
|
329
337
|
that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
|
|
330
338
|
says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
|
|
331
339
|
|
|
340
|
+
## `bdt issue search --for-me` / `bdt issue watch`
|
|
341
|
+
|
|
342
|
+
Issues are how people and coding agents talk to each other: someone comments `@Claude, can you look
|
|
343
|
+
at this?` on an issue, and the agent finds it; the agent asks a question in an issue and waits for the answer.
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
bdt issue search --for-me [--agent claude] [--state all] [--limit 10]
|
|
347
|
+
bdt issue watch 84 [--timeout 900] [--interval 20] [--since-comment ID] [--for-me] [--skip-own] [--json]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**`issue search --for-me`** only keeps issues (GitHub) / work items (Azure DevOps) whose **last comment**
|
|
351
|
+
addresses the calling agent. It combines with every other `search` filter (keywords, `--label`, `--board`,
|
|
352
|
+
`--org-wide`, ...). A comment addresses the agent when it contains `@Claude`, `@Codex`, `@Goose`, ... for
|
|
353
|
+
the agent that is running `bdt`, or `@Agent`, which addresses every agent. Matching is case-insensitive and
|
|
354
|
+
on whole words: `@claude`, `@CLAUDE,` and `@Claude.` match, `@Claudette`, `@claude-bot` and `me@claude.ai` do not.
|
|
355
|
+
Only the newest comment counts, so once someone (including the agent, e.g. with a reply) comments after the
|
|
356
|
+
mention, the issue drops out of the list. The newest comment isn't part of the list output of `gh`/Azure DevOps,
|
|
357
|
+
so this costs one extra request per candidate; candidates are scanned in recently-updated order (up to 10x
|
|
358
|
+
`--limit` on GitHub, 200 on Azure DevOps) until `--limit` have matched.
|
|
359
|
+
|
|
360
|
+
Which agent is calling is detected from the environment, first match wins: `--agent NAME` (or the `BDT_AGENT`
|
|
361
|
+
environment variable, to pin it), `CLAUDECODE=1` (Claude), the agent-agnostic `AI_AGENT` convention (first
|
|
362
|
+
token of its value, `claude-code_2-1_agent` is Claude; `gh` reads it too), then marker variables of
|
|
363
|
+
`CLAUDE_CODE_*` (Claude), `CODEX_SANDBOX*` / `CODEX_THREAD_*` / `CODEX_CI` (Codex), `GOOSE_TERMINAL` /
|
|
364
|
+
`GOOSE_SESSION` (Goose), `OPENCODE` (OpenCode) and `GEMINI_CLI=1` (Gemini). These variables are not a
|
|
365
|
+
documented contract of those tools, so use `--agent`/`BDT_AGENT` for any other agent (any name works:
|
|
366
|
+
`--agent myagent` looks for `@myagent`). With no agent detected (a human at a terminal), `--for-me` notes
|
|
367
|
+
that on stderr and matches `@Agent` only.
|
|
368
|
+
|
|
369
|
+
**`issue watch NUMBER`** (also available as `bdt watch-issue NUMBER`) blocks until a new comment arrives on the
|
|
370
|
+
issue / work item, polling every `--interval` seconds (default 20), then prints the new comment(s) and exits 0. It
|
|
371
|
+
exits **4** if nothing arrived within `--timeout` seconds (default 900; `0` waits forever), so an agent can
|
|
372
|
+
tell "no answer yet" from a failure (exit 1) and run it again. Comments that were already there when the
|
|
373
|
+
watch started are ignored -- use `--since-comment ID` (the id `issue comment add` printed) to also receive anything
|
|
374
|
+
posted after that comment, which avoids missing a reply that came in before the watch started. So an agent asks
|
|
375
|
+
and waits like this:
|
|
376
|
+
|
|
377
|
+
```bash
|
|
378
|
+
bdt issue comment add 84 --message "@aersam Should this also cover Azure DevOps?" # prints (comment #123)
|
|
379
|
+
bdt issue watch 84 --since-comment 123 --skip-own --json --timeout 1800
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- `--for-me` only reports new comments that address the calling agent (same matching and `--agent` as `search`).
|
|
383
|
+
- `--skip-own` ignores comments this agent wrote itself: those carrying its session note (bdt appends
|
|
384
|
+
`<Agent> Session: <id>` to every comment it posts under an agent) or, outside an agent, those authored by the
|
|
385
|
+
authenticated user. Comments of a human who shares the agent's account are still reported under an agent.
|
|
386
|
+
- `--json` prints each new comment as one JSON object per line (`id`, `author`, `created_at`, `url`, `body`; Azure
|
|
387
|
+
DevOps's HTML is converted to plain text); without it a short readable block per comment. Status messages go to
|
|
388
|
+
stderr, so stdout holds only the comments.
|
|
389
|
+
- A single failed poll (network blip, API error) is retried; three in a row abort with the error.
|
|
390
|
+
- On Azure DevOps only the newest 100 comments of a work item are looked at per poll.
|
|
391
|
+
|
|
332
392
|
## `bdt env get-keys`
|
|
333
393
|
|
|
334
394
|
Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
|
|
@@ -41,6 +41,8 @@ a field is saved as Markdown it can't be switched back to HTML via the API.
|
|
|
41
41
|
from __future__ import annotations
|
|
42
42
|
|
|
43
43
|
import sys
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from dataclasses import replace
|
|
44
46
|
from pathlib import Path
|
|
45
47
|
from urllib.parse import quote
|
|
46
48
|
|
|
@@ -325,6 +327,32 @@ def last_comment(session: requests.Session, remote: AdoRemote, work_item_id: int
|
|
|
325
327
|
return comments[0].get("text") if comments else None
|
|
326
328
|
|
|
327
329
|
|
|
330
|
+
# Newest comments to fetch per poll of a work item (see `list_comments`) -- a watcher only ever needs
|
|
331
|
+
# what arrived since its last poll, so this is far more than enough while keeping one request per poll.
|
|
332
|
+
_LIST_COMMENTS_TOP = 100
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def list_comments(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[dict]:
|
|
336
|
+
"""The work item's newest comments (at most `_LIST_COMMENTS_TOP`), oldest first, as
|
|
337
|
+
`{id, author, created_at, url, body}` -- `body` is the comment's HTML, as stored."""
|
|
338
|
+
r = session.get(
|
|
339
|
+
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments",
|
|
340
|
+
params={"api-version": COMMENTS_API_VERSION, "$top": _LIST_COMMENTS_TOP, "order": "desc"},
|
|
341
|
+
)
|
|
342
|
+
r.raise_for_status()
|
|
343
|
+
url = edit_url(remote, work_item_id)
|
|
344
|
+
return [
|
|
345
|
+
{
|
|
346
|
+
"id": c["id"],
|
|
347
|
+
"author": (c.get("createdBy") or {}).get("displayName") or "unknown",
|
|
348
|
+
"created_at": c.get("createdDate") or "",
|
|
349
|
+
"url": url,
|
|
350
|
+
"body": c.get("text") or "",
|
|
351
|
+
}
|
|
352
|
+
for c in reversed(r.json().get("comments") or [])
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
328
356
|
def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict:
|
|
329
357
|
r = session.patch(
|
|
330
358
|
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}",
|
|
@@ -524,6 +552,27 @@ def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int],
|
|
|
524
552
|
return [by_id[i] for i in ids if i in by_id]
|
|
525
553
|
|
|
526
554
|
|
|
555
|
+
# Max candidates a comment-filtered `search()` looks at -- also the most work item ids the batch
|
|
556
|
+
# `get_work_items` endpoint accepts per request.
|
|
557
|
+
_COMMENT_FILTER_POOL = 200
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _filter_by_last_comment(
|
|
561
|
+
session: requests.Session, remote: AdoRemote, items: list[dict], top: int, comment_filter: Callable[[str | None], bool]
|
|
562
|
+
) -> list[dict]:
|
|
563
|
+
"""The first `top` of `items` (in order) whose newest comment `comment_filter` accepts. An
|
|
564
|
+
item from another project (org-wide search) is looked up in its own project."""
|
|
565
|
+
matched: list[dict] = []
|
|
566
|
+
for item in items:
|
|
567
|
+
if len(matched) >= top:
|
|
568
|
+
break
|
|
569
|
+
project = item["fields"].get("System.TeamProject")
|
|
570
|
+
item_remote = replace(remote, project=project) if project else remote
|
|
571
|
+
if comment_filter(last_comment(session, item_remote, item["id"])):
|
|
572
|
+
matched.append(item)
|
|
573
|
+
return matched
|
|
574
|
+
|
|
575
|
+
|
|
527
576
|
def search(
|
|
528
577
|
session: requests.Session,
|
|
529
578
|
remote: AdoRemote,
|
|
@@ -534,6 +583,7 @@ def search(
|
|
|
534
583
|
state: str = "open",
|
|
535
584
|
org_wide: bool = False,
|
|
536
585
|
tags: list[str] | None = None,
|
|
586
|
+
comment_filter: Callable[[str | None], bool] | None = None,
|
|
537
587
|
) -> list[dict]:
|
|
538
588
|
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
539
589
|
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
@@ -544,13 +594,19 @@ def search(
|
|
|
544
594
|
`org_wide` searches every project in `remote.org` instead of just `remote.project` --
|
|
545
595
|
mutually exclusive with `board`, since a team's Area Path only means something within a
|
|
546
596
|
single project.
|
|
597
|
+
|
|
598
|
+
`comment_filter` keeps only work items whose newest comment (None if it has none) it accepts --
|
|
599
|
+
e.g. `bdt issue search --for-me`. WIQL can't query comments, so up to `_COMMENT_FILTER_POOL`
|
|
600
|
+
candidates are fetched and checked one by one, stopping once `top` have matched.
|
|
547
601
|
"""
|
|
548
602
|
if org_wide and board:
|
|
549
603
|
sys.exit("--board can't be combined with --org-wide (a board's Area Path is scoped to a single project).")
|
|
550
604
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
551
605
|
wiql = build_search_wiql(keywords, since, area_path, state, org_wide=org_wide, tags=tags)
|
|
552
|
-
ids = run_wiql(session, remote, wiql, top, org_wide=org_wide)
|
|
606
|
+
ids = run_wiql(session, remote, wiql, min(top * 10, _COMMENT_FILTER_POOL) if comment_filter else top, org_wide=org_wide)
|
|
553
607
|
items = get_work_items(session, remote, ids, org_wide=org_wide)
|
|
608
|
+
if comment_filter:
|
|
609
|
+
items = _filter_by_last_comment(session, remote, items, top, comment_filter)
|
|
554
610
|
for item in items:
|
|
555
611
|
fields = item["fields"]
|
|
556
612
|
project = fields.get("System.TeamProject")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Which coding agent is calling `bdt`, and does a comment address it?
|
|
2
|
+
|
|
3
|
+
Backs `bdt issue search --for-me` and `bdt issue watch`: people (or other agents) address an agent
|
|
4
|
+
in an issue comment with `@Claude`, `@Codex`, `@Goose`, ... and `@Agent` addresses all of them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
|
|
13
|
+
# A mention addressed to every agent, whatever the caller is.
|
|
14
|
+
ANY_AGENT = "agent"
|
|
15
|
+
|
|
16
|
+
# Env var that overrides detection (the `--agent` option of `issue search`/`issue watch` reads it too).
|
|
17
|
+
AGENT_ENV_VAR = "BDT_AGENT"
|
|
18
|
+
|
|
19
|
+
# Mention handles of the agents we know how to detect, with the environment each one leaves in the
|
|
20
|
+
# processes it spawns. Best-effort, like `cli_tools.detect_agent_session`: none of these is a
|
|
21
|
+
# documented, stable contract, which is why an explicit override always wins.
|
|
22
|
+
_ENV_EXACT: tuple[tuple[str, str, str], ...] = (("CLAUDECODE", "1", "claude"), ("GEMINI_CLI", "1", "gemini"))
|
|
23
|
+
_ENV_PREFIXES: tuple[tuple[str, str], ...] = (
|
|
24
|
+
("CLAUDE_CODE_", "claude"),
|
|
25
|
+
("CODEX_SANDBOX", "codex"),
|
|
26
|
+
("CODEX_THREAD_", "codex"),
|
|
27
|
+
("CODEX_CI", "codex"),
|
|
28
|
+
("GOOSE_TERMINAL", "goose"),
|
|
29
|
+
("GOOSE_SESSION", "goose"),
|
|
30
|
+
("OPENCODE", "opencode"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_agent(name: str) -> str:
|
|
35
|
+
"""The mention handle for an agent name or tag: 'Claude' / 'claude-code_2-1_agent' -> 'claude',
|
|
36
|
+
'Codex CLI' -> 'codex', '@Goose' -> 'goose'."""
|
|
37
|
+
tag = re.split(r"[\s_-]+", name.strip().lstrip("@").lower(), maxsplit=1)[0]
|
|
38
|
+
return tag
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def detect_agent(env: Mapping[str, str] | None = None, override: str | None = None) -> str | None:
|
|
42
|
+
"""The calling agent's mention handle (lowercase, e.g. 'claude'), or None when not run by one.
|
|
43
|
+
|
|
44
|
+
Order: an explicit `override` (the `--agent` option), then `BDT_AGENT`, then `CLAUDECODE=1`,
|
|
45
|
+
then the agent-agnostic `AI_AGENT` convention (the first token of its value; `gh` reads the same
|
|
46
|
+
variable), then each agent's own marker variables (`CLAUDE_CODE_*`, `CODEX_*`, `GOOSE_*`, ...).
|
|
47
|
+
"""
|
|
48
|
+
env = os.environ if env is None else env
|
|
49
|
+
for explicit in (override, env.get(AGENT_ENV_VAR)):
|
|
50
|
+
if explicit and explicit.strip():
|
|
51
|
+
return normalize_agent(explicit)
|
|
52
|
+
for var, value, handle in _ENV_EXACT:
|
|
53
|
+
if env.get(var) == value:
|
|
54
|
+
return handle
|
|
55
|
+
ai_agent = env.get("AI_AGENT", "").strip()
|
|
56
|
+
if ai_agent:
|
|
57
|
+
return normalize_agent(ai_agent)
|
|
58
|
+
for prefix, handle in _ENV_PREFIXES:
|
|
59
|
+
if any(key.startswith(prefix) and env[key] for key in env):
|
|
60
|
+
return handle
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def mentions_agent(text: str | None, agent: str | None) -> bool:
|
|
65
|
+
"""Does `text` contain an `@<agent>` or `@Agent` mention?
|
|
66
|
+
|
|
67
|
+
Case-insensitive, and the handle must be a whole word: `@Claude` and `@claude,` match but
|
|
68
|
+
`@Claudette`, `@claude-bot` and `name@claude.com` do not. With `agent=None` (no agent detected)
|
|
69
|
+
only `@Agent` matches.
|
|
70
|
+
"""
|
|
71
|
+
if not text:
|
|
72
|
+
return False
|
|
73
|
+
handles = {ANY_AGENT}
|
|
74
|
+
if agent:
|
|
75
|
+
handles.add(normalize_agent(agent))
|
|
76
|
+
pattern = r"(?<![\w@])@(?:" + "|".join(re.escape(h) for h in sorted(handles)) + r")(?![\w@]|-\w)"
|
|
77
|
+
return re.search(pattern, text, re.IGNORECASE) is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def last_comment_filter(agent: str | None) -> Callable[[str | None], bool]:
|
|
81
|
+
"""A predicate over an issue's newest comment (None = no comments): does it address `agent`?"""
|
|
82
|
+
return lambda text: mentions_agent(text, agent)
|
|
@@ -15,11 +15,12 @@ import requests
|
|
|
15
15
|
import typer
|
|
16
16
|
from pgdevkit.testdb import constants as pgdevkit_constants
|
|
17
17
|
|
|
18
|
-
from . import ado_issue, api_usage as api_usage_mod, app_service_logs, commit as commit_mod, dead_code as dead_code_mod
|
|
18
|
+
from . import ado_issue, agent_mentions, api_usage as api_usage_mod, app_service_logs, commit as commit_mod, dead_code as dead_code_mod
|
|
19
19
|
from . import env_config
|
|
20
20
|
from . import env_files as env_files_mod
|
|
21
21
|
from . import find_repo as find_repo_mod
|
|
22
22
|
from . import issue_do as issue_do_mod
|
|
23
|
+
from . import issue_watch as issue_watch_mod
|
|
23
24
|
from . import gh_issue, gh_pr
|
|
24
25
|
from . import find_injection as find_injection_mod
|
|
25
26
|
from . import lint as lint_mod
|
|
@@ -258,6 +259,13 @@ def pr_create(
|
|
|
258
259
|
"named group before the PR is created. [tool.bdt.pr.scope_labels] can auto-add a label based "
|
|
259
260
|
"on the HEAD commit's conventional-commit scope (e.g. `feat(customers): ...` -> a configured label).",
|
|
260
261
|
),
|
|
262
|
+
title: str | None = typer.Option(None, "--title", help="PR title (default: from the commits)"),
|
|
263
|
+
description: str | None = typer.Option(
|
|
264
|
+
None,
|
|
265
|
+
"--description",
|
|
266
|
+
help="PR description, kept in full (default: from the commits). On Azure DevOps it is set over the "
|
|
267
|
+
"REST API after the PR is created, since `az repos pr create --description` can cut long text.",
|
|
268
|
+
),
|
|
261
269
|
screenshot: list[str] = typer.Option(
|
|
262
270
|
[], "--screenshot", help="Path to an image to attach to the PR description (repeatable)"
|
|
263
271
|
),
|
|
@@ -315,7 +323,7 @@ def pr_create(
|
|
|
315
323
|
pr_url: str | None = None
|
|
316
324
|
if isinstance(remote, GitHubRemote):
|
|
317
325
|
gh = require_gh()
|
|
318
|
-
returncode, pr_url = gh_pr.create(gh, target, args or [], draft=draft, labels=label)
|
|
326
|
+
returncode, pr_url = gh_pr.create(gh, target, args or [], draft=draft, labels=label, title=title, description=description)
|
|
319
327
|
build_policy = gh_pr.has_build_policy(gh, target)
|
|
320
328
|
if returncode == 0 and (screenshot or file):
|
|
321
329
|
_after_create(lambda: gh_pr.add_attachments(gh, remote.owner, remote.repo, source_branch, screenshot, file), "attaching screenshots/files")
|
|
@@ -323,6 +331,17 @@ def pr_create(
|
|
|
323
331
|
_after_create(lambda: _link_and_label_github(gh, remote, issue_numbers), "linking issue(s) / setting 'pr-available' label")
|
|
324
332
|
else:
|
|
325
333
|
az = require_az()
|
|
334
|
+
# `--title`/`--description` (also when passed through to `az`) are applied over REST
|
|
335
|
+
# below rather than trusting `az` with long text.
|
|
336
|
+
az_args, pass_title, pass_description = pr_build.split_az_text_args(args or [])
|
|
337
|
+
title = title if title is not None else pass_title
|
|
338
|
+
description = description if description is not None else pass_description
|
|
339
|
+
if description is not None and len(description) > pr_build.ADO_PR_DESCRIPTION_MAX:
|
|
340
|
+
raise typer.BadParameter(
|
|
341
|
+
f"{len(description)} characters; Azure DevOps allows at most {pr_build.ADO_PR_DESCRIPTION_MAX}. "
|
|
342
|
+
"Shorten it, or attach the detail with --file.",
|
|
343
|
+
param_hint="--description",
|
|
344
|
+
)
|
|
326
345
|
cmd = [
|
|
327
346
|
az, "repos", "pr", "create",
|
|
328
347
|
"--organization", f"https://dev.azure.com/{remote.org}",
|
|
@@ -334,7 +353,8 @@ def pr_create(
|
|
|
334
353
|
"--output", "json",
|
|
335
354
|
*(["--draft", "true"] if draft else []),
|
|
336
355
|
*(["--labels", *label] if label else []),
|
|
337
|
-
*(
|
|
356
|
+
*(["--title", title] if title is not None else []),
|
|
357
|
+
*az_args,
|
|
338
358
|
]
|
|
339
359
|
r = subprocess.run(cmd, capture_output=True, encoding="utf-8")
|
|
340
360
|
returncode = r.returncode
|
|
@@ -361,14 +381,16 @@ def pr_create(
|
|
|
361
381
|
session = requests.Session()
|
|
362
382
|
session.headers.update(auth_header(pat))
|
|
363
383
|
build_policy = pr_build.has_build_policy(session, remote, target)
|
|
364
|
-
if returncode == 0 and (detect_agent_session() is not None or screenshot or file):
|
|
384
|
+
if returncode == 0 and (detect_agent_session() is not None or screenshot or file or description is not None):
|
|
365
385
|
def _finish() -> None:
|
|
366
386
|
pr = pr_build.get_pr(session, remote, source_branch, target)
|
|
387
|
+
if description is not None:
|
|
388
|
+
pr = pr_build.set_description(session, remote, pr, description)
|
|
367
389
|
if screenshot or file:
|
|
368
390
|
pr = pr_build.add_attachments(session, remote, pr, screenshot, file)
|
|
369
391
|
pr_build.ensure_session_note(session, remote, pr)
|
|
370
392
|
|
|
371
|
-
_after_create(_finish, "attaching screenshots/files and/or noting the agent session")
|
|
393
|
+
_after_create(_finish, "setting the description, attaching screenshots/files and/or noting the agent session (retry with `bdt pr update`)")
|
|
372
394
|
if returncode == 0:
|
|
373
395
|
# Fetches the PR fresh via `pr_build.get_pr` rather than trusting `pr_json` -- `az`
|
|
374
396
|
# can exit 0 with stdout that isn't clean JSON (e.g. a deprecation banner ahead of
|
|
@@ -694,6 +716,20 @@ def issue_search(
|
|
|
694
716
|
envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
|
|
695
717
|
help="Azure DevOps PAT (else falls back to `az` login)",
|
|
696
718
|
),
|
|
719
|
+
for_me: bool = typer.Option(
|
|
720
|
+
False,
|
|
721
|
+
"--for-me",
|
|
722
|
+
help="Only issues/work items whose LAST comment addresses the calling coding agent: contains "
|
|
723
|
+
"@Claude / @Codex / @Goose / ... (case-insensitive, whole word; which one is detected from the "
|
|
724
|
+
"environment, see --agent) or @Agent (addresses every agent).",
|
|
725
|
+
),
|
|
726
|
+
agent: str | None = typer.Option(
|
|
727
|
+
None,
|
|
728
|
+
"--agent",
|
|
729
|
+
envvar=agent_mentions.AGENT_ENV_VAR,
|
|
730
|
+
help="With --for-me: the agent to look for mentions of, e.g. claude, codex, goose. Default: detected "
|
|
731
|
+
"from the environment (CLAUDECODE, AI_AGENT, CODEX_*, GOOSE_*, ...); without any, only @Agent matches.",
|
|
732
|
+
),
|
|
697
733
|
) -> None:
|
|
698
734
|
"""Search (or, with no keywords, just list) issues / work items, defaulting to open issues
|
|
699
735
|
from the last 30 days (Azure DevOps or GitHub, auto-detected).
|
|
@@ -702,6 +738,8 @@ def issue_search(
|
|
|
702
738
|
raise typer.BadParameter("Must be one of: open, closed, all", param_hint="--state")
|
|
703
739
|
since = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d") if since_days > 0 else None
|
|
704
740
|
tags_or_labels = _merge_tags_and_labels(tag, label) or []
|
|
741
|
+
# only passed on with --for-me, so a plain search calls the backends exactly as before
|
|
742
|
+
search_extra = {"comment_filter": agent_mentions.last_comment_filter(_resolve_agent(agent))} if for_me else {}
|
|
705
743
|
|
|
706
744
|
# --org/--github-org are only ever consulted as a *fallback*, when `current_remote()` itself
|
|
707
745
|
# fails (no git repo, no origin remote, or a remote that's neither GitHub nor Azure DevOps) --
|
|
@@ -729,17 +767,26 @@ def issue_search(
|
|
|
729
767
|
if isinstance(remote, GitHubRemote):
|
|
730
768
|
resolved_board = gh_issue.resolve_board(board)
|
|
731
769
|
gh_issue.search(
|
|
732
|
-
require_gh(), remote.owner, remote.repo, keywords or [], since, limit, state, board=resolved_board, org_wide=org_wide, labels=tags_or_labels
|
|
770
|
+
require_gh(), remote.owner, remote.repo, keywords or [], since, limit, state, board=resolved_board, org_wide=org_wide, labels=tags_or_labels, **search_extra
|
|
733
771
|
)
|
|
734
772
|
else:
|
|
735
773
|
session = requests.Session()
|
|
736
774
|
session.headers.update(auth_header(pat))
|
|
737
775
|
resolved_board = ado_issue.resolve_board(board)
|
|
738
776
|
ado_issue.search(
|
|
739
|
-
session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state, org_wide=org_wide, tags=tags_or_labels
|
|
777
|
+
session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state, org_wide=org_wide, tags=tags_or_labels, **search_extra
|
|
740
778
|
)
|
|
741
779
|
|
|
742
780
|
|
|
781
|
+
def _resolve_agent(agent: str | None) -> str | None:
|
|
782
|
+
"""The agent to match `@mentions` of: `--agent`/`BDT_AGENT`, else detected from the environment.
|
|
783
|
+
None (with a note on stderr) when neither -- then only `@Agent` mentions can match."""
|
|
784
|
+
resolved = agent_mentions.detect_agent(override=agent)
|
|
785
|
+
if resolved is None:
|
|
786
|
+
typer.echo("No calling agent detected (pass --agent or set BDT_AGENT) -- only @Agent mentions match", err=True)
|
|
787
|
+
return resolved
|
|
788
|
+
|
|
789
|
+
|
|
743
790
|
@issue_app.command("do", context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
|
|
744
791
|
def issue_do(
|
|
745
792
|
ctx: typer.Context,
|
|
@@ -868,6 +915,74 @@ def _capture(cmd: list[str]) -> subprocess.CompletedProcess | None:
|
|
|
868
915
|
return None
|
|
869
916
|
|
|
870
917
|
|
|
918
|
+
def issue_watch(
|
|
919
|
+
number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
|
|
920
|
+
interval: float = typer.Option(20.0, "--interval", help="Seconds between polls"),
|
|
921
|
+
timeout: float = typer.Option(900.0, "--timeout", help="Give up after this many seconds (0 = wait forever); exits 4 on timeout"),
|
|
922
|
+
since_comment: int | None = typer.Option(
|
|
923
|
+
None,
|
|
924
|
+
"--since-comment",
|
|
925
|
+
help="Report comments newer than this comment ID (as printed by `issue comment add`), even ones already "
|
|
926
|
+
"posted before the watch started. Default: only comments arriving after the watch started.",
|
|
927
|
+
),
|
|
928
|
+
for_me: bool = typer.Option(
|
|
929
|
+
False, "--for-me", help="Only report comments that mention the calling agent (@Claude / @Codex / ...) or @Agent -- see `issue search --for-me`"
|
|
930
|
+
),
|
|
931
|
+
agent: str | None = typer.Option(
|
|
932
|
+
None, "--agent", envvar=agent_mentions.AGENT_ENV_VAR, help="With --for-me: the agent to look for mentions of (default: detected from the environment)"
|
|
933
|
+
),
|
|
934
|
+
skip_own: bool = typer.Option(
|
|
935
|
+
False,
|
|
936
|
+
"--skip-own",
|
|
937
|
+
help="Ignore comments this agent wrote itself: those carrying its session note (appended by bdt to every comment "
|
|
938
|
+
"it posts), or, outside an agent, those authored by the current user",
|
|
939
|
+
),
|
|
940
|
+
as_json: bool = typer.Option(False, "--json", help="Print each new comment as one JSON line (id, author, created_at, url, body)"),
|
|
941
|
+
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)"),
|
|
942
|
+
) -> None:
|
|
943
|
+
"""Wait for new comments on an issue / work item -- so an agent can ask a question in the issue and
|
|
944
|
+
block until someone answers. Prints the new comment(s) and exits 0; exits 4 if none arrived within
|
|
945
|
+
--timeout. Progress notes go to stderr, so stdout holds only the comments (JSON lines with --json).
|
|
946
|
+
"""
|
|
947
|
+
if interval <= 0:
|
|
948
|
+
raise typer.BadParameter("Must be greater than 0", param_hint="--interval")
|
|
949
|
+
if timeout < 0:
|
|
950
|
+
raise typer.BadParameter("Must be 0 (wait forever) or greater", param_hint="--timeout")
|
|
951
|
+
remote = current_remote()
|
|
952
|
+
if isinstance(remote, GitHubRemote):
|
|
953
|
+
gh = require_gh()
|
|
954
|
+
|
|
955
|
+
def fetch() -> list[issue_watch_mod.Comment]:
|
|
956
|
+
return issue_watch_mod.to_comments(gh_issue.list_comments(gh, remote.owner, remote.repo, number))
|
|
957
|
+
else:
|
|
958
|
+
ado_session = requests.Session()
|
|
959
|
+
ado_session.headers.update(auth_header(pat))
|
|
960
|
+
|
|
961
|
+
def fetch() -> list[issue_watch_mod.Comment]:
|
|
962
|
+
return issue_watch_mod.to_comments(ado_issue.list_comments(ado_session, remote, number), is_html=True)
|
|
963
|
+
|
|
964
|
+
own_session = detect_agent_session() if skip_own else None
|
|
965
|
+
accept = issue_watch_mod.build_accept(
|
|
966
|
+
agent=_resolve_agent(agent) if for_me else None,
|
|
967
|
+
for_me=for_me,
|
|
968
|
+
own_session_id=own_session.session_id if own_session else None,
|
|
969
|
+
own_author=_current_user(remote) if skip_own and own_session is None else None,
|
|
970
|
+
)
|
|
971
|
+
typer.echo(f"Watching #{number} for new comments (every {interval:g}s, {f'up to {timeout:g}s' if timeout else 'no timeout'})", err=True)
|
|
972
|
+
new = issue_watch_mod.watch(fetch, since_id=since_comment, accept=accept, interval=interval, timeout=timeout or None)
|
|
973
|
+
if new is None:
|
|
974
|
+
typer.echo(f"No new comment on #{number} within {timeout:g}s", err=True)
|
|
975
|
+
raise typer.Exit(issue_watch_mod.EXIT_TIMEOUT)
|
|
976
|
+
for comment in new:
|
|
977
|
+
typer.echo(issue_watch_mod.format_comment(comment, as_json=as_json))
|
|
978
|
+
if not as_json:
|
|
979
|
+
typer.echo("")
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
issue_app.command("watch")(issue_watch)
|
|
983
|
+
app.command("watch-issue", help="Alias for `bdt issue watch`")(issue_watch)
|
|
984
|
+
|
|
985
|
+
|
|
871
986
|
@issue_app.command("update")
|
|
872
987
|
def issue_update(
|
|
873
988
|
number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
|