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.
Files changed (94) hide show
  1. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/PKG-INFO +63 -3
  2. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/README.md +62 -2
  3. {bmsdna_devtools-0.36.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.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli.py +122 -7
  6. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gh_issue.py +56 -2
  7. {bmsdna_devtools-0.36.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.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_build.py +66 -1
  10. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/pyproject.toml +1 -1
  11. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/skills/bmsdna-devtools/SKILL.md +25 -4
  12. bmsdna_devtools-0.37.0/tests/test_agent_mentions.py +102 -0
  13. bmsdna_devtools-0.37.0/tests/test_issue_for_me.py +422 -0
  14. bmsdna_devtools-0.37.0/tests/test_issue_watch.py +183 -0
  15. bmsdna_devtools-0.37.0/tests/test_pr_description_not_truncated.py +138 -0
  16. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/uv.lock +1 -1
  17. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.github/workflows/auto-release.yml +0 -0
  18. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-publish.yml +0 -0
  19. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.github/workflows/python-test.yml +0 -0
  20. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.gitignore +0 -0
  21. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/.python-version +0 -0
  22. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/__init__.py +0 -0
  23. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/_api_dump.py +0 -0
  24. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/ado_auth.py +0 -0
  25. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/api_usage.py +0 -0
  26. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/app_service_logs.py +0 -0
  27. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/bdt_config.py +0 -0
  28. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/cli_tools.py +0 -0
  29. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/commit.py +0 -0
  30. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/dead_code.py +0 -0
  31. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_config.py +0 -0
  32. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/env_files.py +0 -0
  33. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_injection.py +0 -0
  34. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/find_repo.py +0 -0
  35. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/gitrepo.py +0 -0
  36. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_frontend.py +0 -0
  37. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/injection_python.py +0 -0
  38. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/issue_do.py +0 -0
  39. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint.py +0 -0
  40. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_findings.py +0 -0
  41. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_models.py +0 -0
  42. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql.py +0 -0
  43. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  44. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_tooling.py +0 -0
  45. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/lint_typescript.py +0 -0
  46. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/logs.py +0 -0
  47. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_info.py +0 -0
  48. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  49. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_labels.py +0 -0
  50. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pr_markdown.py +0 -0
  51. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/pull.py +0 -0
  52. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/testdb.py +0 -0
  53. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/translate.py +0 -0
  54. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/bmsdna/devtools/worktree.py +0 -0
  55. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/justfile +0 -0
  56. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/conftest.py +0 -0
  57. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_ado_auth.py +0 -0
  58. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue.py +0 -0
  59. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_ado_issue_flow.py +0 -0
  60. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_api_usage.py +0 -0
  61. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_app_service_logs.py +0 -0
  62. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_bdt_config.py +0 -0
  63. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_cleanup.py +0 -0
  64. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_commit.py +0 -0
  65. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_issue.py +0 -0
  66. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_create.py +0 -0
  67. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pr_id.py +0 -0
  68. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_pull.py +0 -0
  69. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_cli_tools.py +0 -0
  70. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_commit.py +0 -0
  71. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_env_config.py +0 -0
  72. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_env_files.py +0 -0
  73. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_find_injection.py +0 -0
  74. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_find_repo.py +0 -0
  75. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_gh_issue.py +0 -0
  76. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_gh_pr.py +0 -0
  77. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_gitrepo.py +0 -0
  78. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_issue_do.py +0 -0
  79. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint.py +0 -0
  80. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_models.py +0 -0
  81. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql.py +0 -0
  82. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_sql_files.py +0 -0
  83. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_tooling.py +0 -0
  84. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_lint_typescript.py +0 -0
  85. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build.py +0 -0
  86. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_build_flow.py +0 -0
  87. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_info.py +0 -0
  88. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_issue_link.py +0 -0
  89. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_labels.py +0 -0
  90. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pr_markdown.py +0 -0
  91. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_pull.py +0 -0
  92. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_testdb.py +0 -0
  93. {bmsdna_devtools-0.36.0 → bmsdna_devtools-0.37.0}/tests/test_translate.py +0 -0
  94. {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.36.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 -- --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
@@ -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 -- --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
@@ -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
- *(args or []),
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)"),