bmsdna-devtools 0.35.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.
Files changed (94) hide show
  1. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/PKG-INFO +81 -6
  2. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/README.md +80 -5
  3. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_issue.py +57 -1
  4. bmsdna_devtools-0.37.0/bmsdna/devtools/agent_mentions.py +82 -0
  5. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli.py +124 -8
  6. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_issue.py +56 -2
  7. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_pr.py +17 -2
  8. bmsdna_devtools-0.37.0/bmsdna/devtools/issue_watch.py +125 -0
  9. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql.py +147 -24
  10. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_build.py +66 -1
  11. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/pyproject.toml +1 -1
  12. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/skills/bmsdna-devtools/SKILL.md +27 -6
  13. bmsdna_devtools-0.37.0/tests/test_agent_mentions.py +102 -0
  14. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_find_injection.py +14 -0
  15. bmsdna_devtools-0.37.0/tests/test_issue_for_me.py +422 -0
  16. bmsdna_devtools-0.37.0/tests/test_issue_watch.py +183 -0
  17. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql.py +221 -0
  18. bmsdna_devtools-0.37.0/tests/test_pr_description_not_truncated.py +138 -0
  19. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/uv.lock +1 -1
  20. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.github/workflows/auto-release.yml +0 -0
  21. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-publish.yml +0 -0
  22. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-test.yml +0 -0
  23. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.gitignore +0 -0
  24. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/.python-version +0 -0
  25. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/__init__.py +0 -0
  26. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/_api_dump.py +0 -0
  27. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_auth.py +0 -0
  28. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/api_usage.py +0 -0
  29. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/app_service_logs.py +0 -0
  30. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/bdt_config.py +0 -0
  31. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli_tools.py +0 -0
  32. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/commit.py +0 -0
  33. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/dead_code.py +0 -0
  34. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_config.py +0 -0
  35. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_files.py +0 -0
  36. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_injection.py +0 -0
  37. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_repo.py +0 -0
  38. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gitrepo.py +0 -0
  39. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_frontend.py +0 -0
  40. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_python.py +0 -0
  41. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/issue_do.py +0 -0
  42. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint.py +0 -0
  43. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_findings.py +0 -0
  44. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_models.py +0 -0
  45. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  46. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_tooling.py +0 -0
  47. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_typescript.py +0 -0
  48. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/logs.py +0 -0
  49. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_info.py +0 -0
  50. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  51. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_labels.py +0 -0
  52. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_markdown.py +0 -0
  53. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pull.py +0 -0
  54. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/testdb.py +0 -0
  55. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/translate.py +0 -0
  56. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/worktree.py +0 -0
  57. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/justfile +0 -0
  58. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/conftest.py +0 -0
  59. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_ado_auth.py +0 -0
  60. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue.py +0 -0
  61. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue_flow.py +0 -0
  62. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_api_usage.py +0 -0
  63. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_app_service_logs.py +0 -0
  64. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_bdt_config.py +0 -0
  65. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_cleanup.py +0 -0
  66. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_commit.py +0 -0
  67. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_issue.py +0 -0
  68. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_create.py +0 -0
  69. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_id.py +0 -0
  70. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pull.py +0 -0
  71. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_cli_tools.py +0 -0
  72. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_commit.py +0 -0
  73. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_env_config.py +0 -0
  74. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_env_files.py +0 -0
  75. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_find_repo.py +0 -0
  76. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_gh_issue.py +0 -0
  77. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_gh_pr.py +0 -0
  78. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_gitrepo.py +0 -0
  79. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_issue_do.py +0 -0
  80. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint.py +0 -0
  81. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_models.py +0 -0
  82. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql_files.py +0 -0
  83. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_tooling.py +0 -0
  84. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_lint_typescript.py +0 -0
  85. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build.py +0 -0
  86. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build_flow.py +0 -0
  87. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_info.py +0 -0
  88. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_issue_link.py +0 -0
  89. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_labels.py +0 -0
  90. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pr_markdown.py +0 -0
  91. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_pull.py +0 -0
  92. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_testdb.py +0 -0
  93. {bmsdna_devtools-0.35.0 → bmsdna_devtools-0.37.0}/tests/test_translate.py +0 -0
  94. {bmsdna_devtools-0.35.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.35.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,10 +15,10 @@ 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
- postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
21
+ postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
22
22
  TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
23
23
  backend routes nothing calls.
24
24
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
@@ -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 -- --title "..."`.
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
@@ -637,6 +697,21 @@ unresolvable argument, e.g. a plain function parameter, is silently skipped --
637
697
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
638
698
  it can't see through).
639
699
 
700
+ **arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
701
+ and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
702
+ are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
703
+ Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
704
+ arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
705
+ `parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
706
+ them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
707
+ statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
708
+ imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
709
+ under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
710
+ Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
711
+ common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
712
+ arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
713
+ only as safe as `x`.
714
+
640
715
  pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
641
716
  exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
642
717
  `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
@@ -846,8 +921,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
846
921
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
847
922
  code 0 unless `--strict`).
848
923
 
849
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
850
- SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
924
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
925
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
851
926
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
852
927
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
853
928
  reports unverified calls.)
@@ -1,10 +1,10 @@
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
- postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in
7
+ postgres/psycopg (and arrowbricks) SQL rules, pydantic-model placement, hand-wired HTTP access in
8
8
  TypeScript and baseline tooling, plus `bdt dead-code` for `.sql` files nothing loads and
9
9
  backend routes nothing calls.
10
10
  `bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
@@ -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 -- --title "..."`.
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
@@ -623,6 +683,21 @@ unresolvable argument, e.g. a plain function parameter, is silently skipped --
623
683
  this can't false-positive on non-psycopg `.execute()` calls, or on dynamic SQL
624
684
  it can't see through).
625
685
 
686
+ **arrowbricks** (the Databricks SQL client): `Cursor.execute`/`execute_streamed`, `DatabricksClient.query_agent`/`stream_query_json`
687
+ and the free `arrowbricks.stream_query_json(client, sql)` are checked as well. `execute_streamed`/`query_agent`/`stream_query_json`
688
+ are matched by name (the free function's SQL is its *second* argument; the method's is the first; `sql=` as keyword works too).
689
+ Only the injection rules apply there -- an f-string/concatenation/`%`/`.format()` is flagged and the fix suggests an
690
+ arrowbricks t-string (`{value}` binds a named parameter, `{name:i}` quotes an identifier) or literal SQL with
691
+ `parameters=[...]` (`params=[...]` on `query_agent`/`stream_query_json`) for `:name` markers. The postgres house style (`sql-inline-too-complex`, `sql-positional-param`, `sql-forbidden-join`) is not applied to
692
+ them, since Databricks SQL isn't postgres. The SQL is also parsed as Databricks SQL (backticks, `QUALIFY`, ...), so such
693
+ statements are no longer missed; a plain `.execute()` (incl. `execute(sql=...)`) gets that extra dialect only in a file that
694
+ imports `arrowbricks`, and keeps the postgres rules there (it may be a psycopg cursor too). The free function is recognised
695
+ under `from arrowbricks import stream_query_json as x` and `arrowbricks[._streaming].stream_query_json(...)`.
696
+ Like pgdevkit's helpers the three names match by name wherever they are called (a client injected from another module is the
697
+ common case), so an unrelated `agent.query_agent(f"select ...")` whose text parses as SQL is flagged too.
698
+ arrowbricks' `unsafe_sql(x)` -- its typing-only escape hatch -- is treated like `cast(LiteralString, x)` (in any file):
699
+ only as safe as `x`.
700
+
626
701
  pgdevkit's helpers are matched by name (`fetch_all(...)`, `db.fetch_all(...)`, `pgdevkit.db.execute(...)`). The one
627
702
  exception is a *bare* `execute(...)`, a far too generic name: it is only checked in a file that imports it from
628
703
  `pgdevkit`/`pgdevkit.db` (`from pgdevkit.db import execute`, also aliased: `... import execute as run`; the import is
@@ -832,8 +907,8 @@ Findings have two severities: **error** (a definite unsafe pattern, exit code 1)
832
907
  (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit
833
908
  code 0 unless `--strict`).
834
909
 
835
- - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()`): f-string / `%` / concatenation / `.format()` SQL is an error.
836
- SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)` (the cast is only as safe as its argument) or
910
+ - **SQL** (Python `.execute()` / `fetch_all()` / `fetch_one()` / `fetch_scalar()` / `execute()` from pgdevkit / `PostgresJsonResponse()` / arrowbricks `execute_streamed()` / `query_agent()` / `stream_query_json()`): f-string / `%` / concatenation / `.format()` SQL is an error.
911
+ SQL from `load_sql()`, `sql.SQL`, sqlglot (`expr.sql()`, `sqlglot.*`), `cast(LiteralString, <sqlglot expr>)`/`unsafe_sql(...)` (the cast is only as safe as its argument) or
837
912
  a function in the same file annotated `-> LiteralString` is trusted; SQL from any other function
838
913
  call is a `sql-unverified-call` review item. (`bdt lint` accepts the same trusted forms but never
839
914
  reports unverified calls.)
@@ -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)