bmsdna-devtools 0.40.0__tar.gz → 0.41.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 (98) hide show
  1. bmsdna_devtools-0.40.0/README.md → bmsdna_devtools-0.41.0/PKG-INFO +45 -1
  2. bmsdna_devtools-0.40.0/PKG-INFO → bmsdna_devtools-0.41.0/README.md +31 -15
  3. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/ado_issue.py +191 -20
  4. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/cli.py +73 -4
  5. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/gh_issue.py +77 -2
  6. bmsdna_devtools-0.41.0/bmsdna/devtools/issue_show.py +65 -0
  7. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/pyproject.toml +1 -1
  8. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/skills/bmsdna-devtools/SKILL.md +14 -1
  9. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_ado_issue.py +86 -0
  10. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_ado_issue_flow.py +174 -0
  11. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_issue.py +150 -6
  12. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_gh_issue.py +116 -0
  13. bmsdna_devtools-0.41.0/tests/test_issue_show.py +59 -0
  14. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/uv.lock +1 -1
  15. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/.github/workflows/auto-release.yml +0 -0
  16. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/.github/workflows/python-publish.yml +0 -0
  17. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/.github/workflows/python-test.yml +0 -0
  18. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/.gitignore +0 -0
  19. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/.python-version +0 -0
  20. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/__init__.py +0 -0
  21. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/_api_dump.py +0 -0
  22. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/ado_auth.py +0 -0
  23. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/agent_mentions.py +0 -0
  24. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/api_usage.py +0 -0
  25. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/app_service_logs.py +0 -0
  26. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/bdt_config.py +0 -0
  27. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/cli_tools.py +0 -0
  28. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/commit.py +0 -0
  29. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/dead_code.py +0 -0
  30. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/env_config.py +0 -0
  31. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/env_files.py +0 -0
  32. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/find_injection.py +0 -0
  33. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/find_repo.py +0 -0
  34. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/gh_pr.py +0 -0
  35. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/gitrepo.py +0 -0
  36. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/injection_frontend.py +0 -0
  37. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/injection_python.py +0 -0
  38. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/issue_do.py +0 -0
  39. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/issue_watch.py +0 -0
  40. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint.py +0 -0
  41. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint_findings.py +0 -0
  42. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint_models.py +0 -0
  43. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint_sql.py +0 -0
  44. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  45. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint_tooling.py +0 -0
  46. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/lint_typescript.py +0 -0
  47. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/logs.py +0 -0
  48. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pr_build.py +0 -0
  49. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pr_choice.py +0 -0
  50. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pr_info.py +0 -0
  51. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  52. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pr_labels.py +0 -0
  53. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pr_markdown.py +0 -0
  54. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/pull.py +0 -0
  55. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/testdb.py +0 -0
  56. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/translate.py +0 -0
  57. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/bmsdna/devtools/worktree.py +0 -0
  58. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/justfile +0 -0
  59. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/conftest.py +0 -0
  60. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_ado_auth.py +0 -0
  61. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_agent_mentions.py +0 -0
  62. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_api_usage.py +0 -0
  63. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_app_service_logs.py +0 -0
  64. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_bdt_config.py +0 -0
  65. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_cleanup.py +0 -0
  66. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_commit.py +0 -0
  67. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_pr_create.py +0 -0
  68. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_pr_id.py +0 -0
  69. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_pull.py +0 -0
  70. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_cli_tools.py +0 -0
  71. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_commit.py +0 -0
  72. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_env_config.py +0 -0
  73. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_env_files.py +0 -0
  74. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_find_injection.py +0 -0
  75. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_find_repo.py +0 -0
  76. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_gh_pr.py +0 -0
  77. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_gitrepo.py +0 -0
  78. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_issue_do.py +0 -0
  79. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_issue_for_me.py +0 -0
  80. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_issue_watch.py +0 -0
  81. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_lint.py +0 -0
  82. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_lint_models.py +0 -0
  83. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_lint_sql.py +0 -0
  84. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_lint_sql_files.py +0 -0
  85. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_lint_tooling.py +0 -0
  86. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_lint_typescript.py +0 -0
  87. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_build.py +0 -0
  88. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_build_flow.py +0 -0
  89. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_choice.py +0 -0
  90. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_description_not_truncated.py +0 -0
  91. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_info.py +0 -0
  92. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_issue_link.py +0 -0
  93. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_labels.py +0 -0
  94. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pr_markdown.py +0 -0
  95. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_pull.py +0 -0
  96. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_testdb.py +0 -0
  97. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_translate.py +0 -0
  98. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.41.0}/tests/test_worktree_clean.py +0 -0
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.41.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pgdevkit[db]>=0.7.1
7
+ Requires-Dist: requests>=2.32.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
10
+ Requires-Dist: typer>=0.26.7
11
+ Provides-Extra: lint
12
+ Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # bmsdna-devtools
2
16
 
3
17
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -233,11 +247,14 @@ create prints a reminder to run `bdt pr status` afterward to check whether
233
247
  the CI build passes. This is a best-effort check — failures reading policy
234
248
  config (auth, permissions) fail open and simply skip the reminder.
235
249
 
236
- ## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
250
+ ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
237
251
 
238
252
  ```bash
239
253
  bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
254
+ bdt issue create --title "Add retries" --type Task --iteration 'MyProject\Sprint 1' --parent 1200 --blocked-by 1210
255
+ bdt issue show 1234 [--json]
240
256
  bdt issue update 1234 --state Resolved --tag fixed
257
+ bdt issue update 1234 --iteration 'MyProject\Sprint 2' --parent 1200 --blocked-by 1210 --blocked-by 1211
241
258
  bdt issue delete 1234 --yes
242
259
 
243
260
  bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
@@ -250,6 +267,17 @@ adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
250
267
  *`. `bdt issue comment add` prints the new comment's ID so you can pass it to
251
268
  `update`/`delete` later.
252
269
 
270
+ `issue show NUMBER` prints one issue / work item: state, title, URL, description, tags, links to other
271
+ issues (parent, children, blocked by, blocks; related on Azure DevOps), the Azure DevOps Type / Area /
272
+ Iteration / Assigned to (GitHub: assignees, milestone) and all comments (Azure DevOps HTML is shown as plain
273
+ text). `--json` prints the same as one JSON document (`number, title, state, url, body, tags, fields,
274
+ parent, children, blocked_by, blocks, related, comments`).
275
+
276
+ `--parent ID` and `--blocked-by ID` (repeatable) on `create` and `update` link the item to its parent and to
277
+ the issues / work items that block it, on both backends. On `update` only the links that are missing are
278
+ added, so repeating a command is harmless (like `--tag`); `--parent` replaces a different current parent.
279
+ They only ever add links: removing one still goes through the web UI.
280
+
253
281
  Destructive commands (`issue delete`, `issue comment delete`) require an
254
282
  explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
255
283
  also invoked by AI-agent callers that can't answer one.
@@ -276,6 +304,17 @@ literal text, but the work item Discussion/Comments control has always
276
304
  rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
277
305
  (restorable, not permanent).
278
306
 
307
+ `--iteration <path>` on `create` and `update` sets `System.IterationPath`, e.g. `'MyProject\Sprint 1'` (in
308
+ Basic-process projects a work item without an iteration doesn't show on the team backlog). It is Azure DevOps
309
+ only: on a GitHub repo it fails with a usage error (exit 2) instead of being ignored. On `create` it defaults
310
+ to `[tool.bdt.ado].iteration` from `pyproject.toml`; on `update` it is only applied when given. `bdt` doesn't
311
+ look up the board team's current iteration, so name the path yourself:
312
+
313
+ ```toml
314
+ [tool.bdt.ado]
315
+ iteration = 'MyProject\Sprint 1' # single quotes: TOML takes the backslash literally
316
+ ```
317
+
279
318
  `--board <team>` sets the work item's Area Path to that Azure Boards team's
280
319
  default, so it shows up on that team's board — a CLI flag beats
281
320
  `[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
@@ -315,6 +354,11 @@ subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
315
354
  to `bdt issue create` pass through to `gh issue create`, e.g.
316
355
  `bdt issue create --title "..." -- --assignee @me`.
317
356
 
357
+ `--parent` / `--blocked-by` use GitHub's sub-issues and issue dependencies (`gh issue create --parent` /
358
+ `--blocked-by`, `gh issue edit --parent` / `--add-blocked-by`), which need a recent `gh`; `issue show` reads
359
+ them with `gh issue view --json parent,subIssues,blockedBy,blocking`. `--iteration` isn't supported: a
360
+ GitHub Projects (v2) iteration is a per-project field, not a property of the issue.
361
+
318
362
  `--board <name>` on `create` adds the issue to that GitHub Projects (v2)
319
363
  board by title (needs a token with the `project` scope — `gh auth refresh -s
320
364
  project`). On `search`, `--board` (title or number) scopes results to issues
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bmsdna-devtools
3
- Version: 0.40.0
4
- Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
- Requires-Python: >=3.14
6
- Requires-Dist: pgdevkit[db]>=0.7.1
7
- Requires-Dist: requests>=2.32.0
8
- Requires-Dist: rich>=13.0.0
9
- Requires-Dist: tomli-w>=1.0.0
10
- Requires-Dist: typer>=0.26.7
11
- Provides-Extra: lint
12
- Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
- Description-Content-Type: text/markdown
14
-
15
1
  # bmsdna-devtools
16
2
 
17
3
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -247,11 +233,14 @@ create prints a reminder to run `bdt pr status` afterward to check whether
247
233
  the CI build passes. This is a best-effort check — failures reading policy
248
234
  config (auth, permissions) fail open and simply skip the reminder.
249
235
 
250
- ## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
236
+ ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
251
237
 
252
238
  ```bash
253
239
  bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
240
+ bdt issue create --title "Add retries" --type Task --iteration 'MyProject\Sprint 1' --parent 1200 --blocked-by 1210
241
+ bdt issue show 1234 [--json]
254
242
  bdt issue update 1234 --state Resolved --tag fixed
243
+ bdt issue update 1234 --iteration 'MyProject\Sprint 2' --parent 1200 --blocked-by 1210 --blocked-by 1211
255
244
  bdt issue delete 1234 --yes
256
245
 
257
246
  bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
@@ -264,6 +253,17 @@ adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
264
253
  *`. `bdt issue comment add` prints the new comment's ID so you can pass it to
265
254
  `update`/`delete` later.
266
255
 
256
+ `issue show NUMBER` prints one issue / work item: state, title, URL, description, tags, links to other
257
+ issues (parent, children, blocked by, blocks; related on Azure DevOps), the Azure DevOps Type / Area /
258
+ Iteration / Assigned to (GitHub: assignees, milestone) and all comments (Azure DevOps HTML is shown as plain
259
+ text). `--json` prints the same as one JSON document (`number, title, state, url, body, tags, fields,
260
+ parent, children, blocked_by, blocks, related, comments`).
261
+
262
+ `--parent ID` and `--blocked-by ID` (repeatable) on `create` and `update` link the item to its parent and to
263
+ the issues / work items that block it, on both backends. On `update` only the links that are missing are
264
+ added, so repeating a command is harmless (like `--tag`); `--parent` replaces a different current parent.
265
+ They only ever add links: removing one still goes through the web UI.
266
+
267
267
  Destructive commands (`issue delete`, `issue comment delete`) require an
268
268
  explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
269
269
  also invoked by AI-agent callers that can't answer one.
@@ -290,6 +290,17 @@ literal text, but the work item Discussion/Comments control has always
290
290
  rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
291
291
  (restorable, not permanent).
292
292
 
293
+ `--iteration <path>` on `create` and `update` sets `System.IterationPath`, e.g. `'MyProject\Sprint 1'` (in
294
+ Basic-process projects a work item without an iteration doesn't show on the team backlog). It is Azure DevOps
295
+ only: on a GitHub repo it fails with a usage error (exit 2) instead of being ignored. On `create` it defaults
296
+ to `[tool.bdt.ado].iteration` from `pyproject.toml`; on `update` it is only applied when given. `bdt` doesn't
297
+ look up the board team's current iteration, so name the path yourself:
298
+
299
+ ```toml
300
+ [tool.bdt.ado]
301
+ iteration = 'MyProject\Sprint 1' # single quotes: TOML takes the backslash literally
302
+ ```
303
+
293
304
  `--board <team>` sets the work item's Area Path to that Azure Boards team's
294
305
  default, so it shows up on that team's board — a CLI flag beats
295
306
  `[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
@@ -329,6 +340,11 @@ subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
329
340
  to `bdt issue create` pass through to `gh issue create`, e.g.
330
341
  `bdt issue create --title "..." -- --assignee @me`.
331
342
 
343
+ `--parent` / `--blocked-by` use GitHub's sub-issues and issue dependencies (`gh issue create --parent` /
344
+ `--blocked-by`, `gh issue edit --parent` / `--add-blocked-by`), which need a recent `gh`; `issue show` reads
345
+ them with `gh issue view --json parent,subIssues,blockedBy,blocking`. `--iteration` isn't supported: a
346
+ GitHub Projects (v2) iteration is a per-project field, not a property of the issue.
347
+
332
348
  `--board <name>` on `create` adds the issue to that GitHub Projects (v2)
333
349
  board by title (needs a token with the `project` scope — `gh auth refresh -s
334
350
  project`). On `search`, `--board` (title or number) scopes results to issues
@@ -6,7 +6,7 @@ Built directly on the Work Item Tracking REST API (api-version 7.1):
6
6
  - Update: PATCH {org}/{project}/_apis/wit/workitems/{id}
7
7
  - Delete: DELETE {org}/{project}/_apis/wit/workitems/{id} (soft-delete to the Recycle Bin — recoverable;
8
8
  there is no `destroy=true` support here on purpose, since that's a permanent, unrecoverable delete)
9
- - Get: GET {org}/{project}/_apis/wit/workitems/{id}
9
+ - Get: GET {org}/{project}/_apis/wit/workitems/{id}[?$expand=relations]
10
10
  - Attachments: POST {org}/{project}/_apis/wit/attachments?fileName=...
11
11
  - Comments: POST/PATCH/DELETE {org}/{project}/_apis/wit/workItems/{id}/comments[/{commentId}]
12
12
  (api-version 7.1-preview.4 — the "Comments"/discussion resource is still in preview
@@ -40,6 +40,7 @@ a field is saved as Markdown it can't be switched back to HTML via the API.
40
40
 
41
41
  from __future__ import annotations
42
42
 
43
+ import re
43
44
  import sys
44
45
  from collections.abc import Callable
45
46
  from dataclasses import replace
@@ -51,17 +52,35 @@ import requests
51
52
  from .bdt_config import load_bdt_table
52
53
  from .cli_tools import ensure_agent_session_note
53
54
  from .gitrepo import AdoRemote
55
+ from .issue_show import IssueDetails
56
+ from .issue_watch import html_to_text, to_comments
54
57
  from .pr_issue_link import PR_AVAILABLE_LABEL
55
58
  from .pr_markdown import build_comment_content_html
56
59
 
57
60
  COMMENTS_API_VERSION = "7.1-preview.4"
58
61
 
62
+ # Work item link types. The "Reverse" end sits on the item that points at the other one: a child
63
+ # carries a Hierarchy-Reverse link to its parent, and a blocked item a Dependency-Reverse link to its
64
+ # predecessor (the blocker).
65
+ PARENT_REL = "System.LinkTypes.Hierarchy-Reverse"
66
+ CHILD_REL = "System.LinkTypes.Hierarchy-Forward"
67
+ BLOCKED_BY_REL = "System.LinkTypes.Dependency-Reverse"
68
+ BLOCKS_REL = "System.LinkTypes.Dependency-Forward"
69
+ RELATED_REL = "System.LinkTypes.Related"
70
+
71
+ _WORK_ITEM_URL_ID_RE = re.compile(r"/workitems/(\d+)$", re.IGNORECASE)
72
+
59
73
 
60
74
  def resolve_board(board: str | None, start: Path | None = None) -> str | None:
61
75
  """--board wins; else `[tool.bdt.ado].board` from pyproject.toml; else None."""
62
76
  return board or load_bdt_table("ado", start).get("board")
63
77
 
64
78
 
79
+ def resolve_iteration(iteration: str | None, start: Path | None = None) -> str | None:
80
+ """--iteration wins; else `[tool.bdt.ado].iteration` from pyproject.toml; else None."""
81
+ return iteration or load_bdt_table("ado", start).get("iteration")
82
+
83
+
65
84
  def check_board(board: str | None, start: Path | None = None, *, creating: bool = False) -> str | None:
66
85
  """Enforce `[tool.bdt.ado]`'s `allowed_boards` / `require_board` on an already-resolved `board`
67
86
  (see `resolve_board`) and return the board to use, in the casing the config spells it.
@@ -127,6 +146,8 @@ def build_create_ops(
127
146
  description: str | None = None,
128
147
  area_path: str | None = None,
129
148
  tags: list[str] | None = None,
149
+ iteration_path: str | None = None,
150
+ relation_ops: list[dict] | None = None,
130
151
  ) -> list[dict]:
131
152
  """The JSON Patch document body for creating a work item with these fields."""
132
153
  ops = [{"op": "add", "path": "/fields/System.Title", "value": title}]
@@ -137,6 +158,46 @@ def build_create_ops(
137
158
  ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": area_path})
138
159
  if tags:
139
160
  ops.append({"op": "add", "path": "/fields/System.Tags", "value": "; ".join(tags)})
161
+ if iteration_path:
162
+ ops.append({"op": "add", "path": "/fields/System.IterationPath", "value": iteration_path})
163
+ return [*ops, *(relation_ops or [])]
164
+
165
+
166
+ def _work_item_api_url(remote: AdoRemote, work_item_id: int) -> str:
167
+ """What a relation's `url` must hold to point at a work item (not its web page)."""
168
+ return f"{_org_url(remote)}/_apis/wit/workItems/{work_item_id}"
169
+
170
+
171
+ def _relation_target_id(relation: dict) -> int | None:
172
+ match = _WORK_ITEM_URL_ID_RE.search(relation.get("url") or "")
173
+ return int(match.group(1)) if match else None
174
+
175
+
176
+ def build_relation_ops(
177
+ remote: AdoRemote,
178
+ parent: int | None = None,
179
+ blocked_by: list[int] | None = None,
180
+ existing: list[dict] | None = None,
181
+ ) -> list[dict]:
182
+ """JSON Patch ops linking a work item to its `parent` and to the work items blocking it
183
+ (`blocked_by`, its predecessors), skipping every link `existing` (the work item's current
184
+ `relations`, as `$expand=relations` returns them; none for a new work item) already has.
185
+
186
+ A work item has at most one parent, so setting a different one first removes the current link
187
+ (Azure DevOps rejects a second parent link) -- by its index in `existing`, which the `remove`
188
+ op addresses `/relations/` by.
189
+ """
190
+ existing = existing or []
191
+ ops: list[dict] = []
192
+ if parent is not None:
193
+ current = [(i, rel) for i, rel in enumerate(existing) if rel.get("rel") == PARENT_REL]
194
+ if not any(_relation_target_id(rel) == parent for _, rel in current):
195
+ ops += [{"op": "remove", "path": f"/relations/{i}"} for i, _ in reversed(current)]
196
+ ops.append({"op": "add", "path": "/relations/-", "value": {"rel": PARENT_REL, "url": _work_item_api_url(remote, parent)}})
197
+ already_blocked_by = {_relation_target_id(rel) for rel in existing if rel.get("rel") == BLOCKED_BY_REL}
198
+ for blocker in dict.fromkeys(blocked_by or []):
199
+ if blocker not in already_blocked_by:
200
+ ops.append({"op": "add", "path": "/relations/-", "value": {"rel": BLOCKED_BY_REL, "url": _work_item_api_url(remote, blocker)}})
140
201
  return ops
141
202
 
142
203
 
@@ -154,6 +215,8 @@ def build_update_ops(
154
215
  area_path: str | None = None,
155
216
  tags: list[str] | None = None,
156
217
  state: str | None = None,
218
+ iteration_path: str | None = None,
219
+ relation_ops: list[dict] | None = None,
157
220
  ) -> list[dict]:
158
221
  """The JSON Patch document body for updating a work item — only fields that aren't None are touched."""
159
222
  ops = []
@@ -168,7 +231,9 @@ def build_update_ops(
168
231
  ops.append({"op": "add", "path": "/fields/System.Tags", "value": "; ".join(tags)})
169
232
  if state is not None:
170
233
  ops.append({"op": "add", "path": "/fields/System.State", "value": state})
171
- return ops
234
+ if iteration_path is not None:
235
+ ops.append({"op": "add", "path": "/fields/System.IterationPath", "value": iteration_path})
236
+ return [*ops, *(relation_ops or [])]
172
237
 
173
238
 
174
239
  def create_work_item(
@@ -179,8 +244,10 @@ def create_work_item(
179
244
  description: str | None = None,
180
245
  area_path: str | None = None,
181
246
  tags: list[str] | None = None,
247
+ iteration_path: str | None = None,
248
+ relation_ops: list[dict] | None = None,
182
249
  ) -> dict:
183
- ops = build_create_ops(title, description, area_path, tags)
250
+ ops = build_create_ops(title, description, area_path, tags, iteration_path, relation_ops)
184
251
 
185
252
  r = session.post(
186
253
  f"{_base_url(remote)}/_apis/wit/workitems/${quote(work_item_type, safe='')}",
@@ -203,10 +270,12 @@ def update_work_item(
203
270
  area_path: str | None = None,
204
271
  tags: list[str] | None = None,
205
272
  state: str | None = None,
273
+ iteration_path: str | None = None,
274
+ relation_ops: list[dict] | None = None,
206
275
  ) -> dict:
207
- ops = build_update_ops(title, description, area_path, tags, state)
276
+ ops = build_update_ops(title, description, area_path, tags, state, iteration_path, relation_ops)
208
277
  if not ops:
209
- sys.exit("Nothing to update — provide at least one of --title, --description, --board, --tag, --state.")
278
+ sys.exit("Nothing to update — provide at least one of --title, --description, --board, --iteration, --tag, --state, --parent, --blocked-by.")
210
279
 
211
280
  r = session.patch(
212
281
  f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
@@ -243,6 +312,54 @@ def get_valid_states(session: requests.Session, remote: AdoRemote, work_item_typ
243
312
  return [s["name"] for s in r.json()["value"]]
244
313
 
245
314
 
315
+ def get_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> dict:
316
+ """The whole work item, with its `relations` (links to other work items, attachments, PRs, ...)."""
317
+ r = session.get(
318
+ f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
319
+ params={"$expand": "relations", "api-version": "7.1"},
320
+ )
321
+ if r.status_code == 404:
322
+ sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
323
+ r.raise_for_status()
324
+ return r.json()
325
+
326
+
327
+ def get_details(session: requests.Session, remote: AdoRemote, work_item_id: int) -> IssueDetails:
328
+ """What `bdt issue show` prints for a work item: its fields, links and comments. Long text
329
+ (Description, comments) is HTML as stored; it is shown as plain text."""
330
+ work_item = get_work_item(session, remote, work_item_id)
331
+ fields = work_item.get("fields", {})
332
+ relations = work_item.get("relations") or []
333
+
334
+ def linked(rel: str) -> list[int]:
335
+ return [i for r in relations if r.get("rel") == rel and (i := _relation_target_id(r)) is not None]
336
+
337
+ assigned_to = fields.get("System.AssignedTo")
338
+ if isinstance(assigned_to, dict):
339
+ assigned_to = assigned_to.get("displayName")
340
+ parents = linked(PARENT_REL)
341
+ return IssueDetails(
342
+ number=work_item_id,
343
+ title=fields.get("System.Title", ""),
344
+ state=fields.get("System.State", ""),
345
+ url=edit_url(remote, work_item_id),
346
+ body=html_to_text(fields.get("System.Description") or ""),
347
+ tags=[t.strip() for t in (fields.get("System.Tags") or "").split(";") if t.strip()],
348
+ fields={
349
+ "Type": fields.get("System.WorkItemType"),
350
+ "Area": fields.get("System.AreaPath"),
351
+ "Iteration": fields.get("System.IterationPath"),
352
+ "Assigned to": assigned_to,
353
+ },
354
+ parent=parents[0] if parents else None,
355
+ children=linked(CHILD_REL),
356
+ blocked_by=linked(BLOCKED_BY_REL),
357
+ blocks=linked(BLOCKS_REL),
358
+ related=linked(RELATED_REL),
359
+ comments=to_comments(list_all_comments(session, remote, work_item_id), is_html=True),
360
+ )
361
+
362
+
246
363
  def get_work_item_tags(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[str]:
247
364
  r = session.get(
248
365
  f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
@@ -373,16 +490,38 @@ def list_comments(session: requests.Session, remote: AdoRemote, work_item_id: in
373
490
  )
374
491
  r.raise_for_status()
375
492
  url = edit_url(remote, work_item_id)
376
- return [
377
- {
378
- "id": c["id"],
379
- "author": (c.get("createdBy") or {}).get("displayName") or "unknown",
380
- "created_at": c.get("createdDate") or "",
381
- "url": url,
382
- "body": c.get("text") or "",
383
- }
384
- for c in reversed(r.json().get("comments") or [])
385
- ]
493
+ return [_comment_dict(c, url) for c in reversed(r.json().get("comments") or [])]
494
+
495
+
496
+ def _comment_dict(comment: dict, url: str) -> dict:
497
+ return {
498
+ "id": comment["id"],
499
+ "author": (comment.get("createdBy") or {}).get("displayName") or "unknown",
500
+ "created_at": comment.get("createdDate") or "",
501
+ "url": url,
502
+ "body": comment.get("text") or "",
503
+ }
504
+
505
+
506
+ def list_all_comments(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[dict]:
507
+ """Every comment on the work item, oldest first, as `list_comments` returns them -- which is
508
+ capped to the newest `_LIST_COMMENTS_TOP`, enough for a watcher but not for showing the item.
509
+ """
510
+ url = edit_url(remote, work_item_id)
511
+ comments: list[dict] = []
512
+ token: str | None = None
513
+ while True:
514
+ params = {"api-version": COMMENTS_API_VERSION, "$top": 200, "order": "asc"}
515
+ if token:
516
+ params["continuationToken"] = token
517
+ r = session.get(f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments", params=params)
518
+ r.raise_for_status()
519
+ body = r.json()
520
+ comments += [_comment_dict(c, url) for c in body.get("comments") or []]
521
+ next_token = body.get("continuationToken")
522
+ if not next_token or next_token == token:
523
+ return comments
524
+ token = next_token
386
525
 
387
526
 
388
527
  def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict:
@@ -662,10 +801,14 @@ def create(
662
801
  tags: list[str],
663
802
  screenshot_paths: list[str],
664
803
  file_paths: list[str] | None = None,
804
+ iteration: str | None = None,
805
+ parent: int | None = None,
806
+ blocked_by: list[int] | None = None,
665
807
  ) -> dict:
666
808
  area_path = get_team_area_path(session, remote, board) if board else None
667
809
  description = ensure_agent_session_note(description, also_check=title)
668
- work_item = create_work_item(session, remote, work_item_type, title, description, area_path, tags)
810
+ relation_ops = build_relation_ops(remote, parent, blocked_by)
811
+ work_item = create_work_item(session, remote, work_item_type, title, description, area_path, tags, iteration, relation_ops)
669
812
  work_item_id = work_item["id"]
670
813
 
671
814
  print(f"Created {work_item_type} #{work_item_id}: {title}")
@@ -689,10 +832,18 @@ def update(
689
832
  tags: list[str] | None = None,
690
833
  remove_tags: list[str] | None = None,
691
834
  state: str | None = None,
835
+ iteration: str | None = None,
836
+ parent: int | None = None,
837
+ blocked_by: list[int] | None = None,
692
838
  ) -> dict | None:
693
839
  """Update a work item's fields. Unlike `create`, `board` is only resolved (and the Area Path only
694
840
  touched) when explicitly given — it never falls back to `[tool.bdt.ado].board`, so an unrelated
695
841
  field update (e.g. just `--title`) can't silently move the item to a different team's board.
842
+ The same goes for `iteration` and `[tool.bdt.ado].iteration`.
843
+
844
+ `parent` / `blocked_by` link the work item to its parent / the work items blocking it, adding
845
+ only the links it doesn't have yet (see `build_relation_ops`) -- like `add_tag`, so running
846
+ the same update twice doesn't PATCH, or fail on, a link that is already there.
696
847
 
697
848
  `remove_tags` removes just those tags, leaving the rest untouched -- unlike `tags` (which
698
849
  replaces the whole `System.Tags` field, mirroring GitHub's `--remove-label` alongside the
@@ -729,11 +880,24 @@ def update(
729
880
  applied_state = None
730
881
 
731
882
  has_tag_change = tags is not None or bool(remove_tags)
732
- if title is None and description is None and area_path is None and not has_tag_change and applied_state is None:
883
+ has_link_change = parent is not None or bool(blocked_by)
884
+ if (
885
+ title is None and description is None and area_path is None and iteration is None
886
+ and not has_tag_change and not has_link_change and applied_state is None
887
+ ):
733
888
  if state is not None:
734
889
  print(f"Work item #{work_item_id}: '{state}' isn't a valid state here — noted in a comment.")
735
890
  return None
736
- sys.exit("Nothing to update — provide at least one of --title, --description, --board, --tag, --remove-tag, --state.")
891
+ sys.exit(
892
+ "Nothing to update — provide at least one of --title, --description, --board, --iteration, --tag, --remove-tag, "
893
+ "--state, --parent, --blocked-by."
894
+ )
895
+
896
+ relation_ops: list[dict] = []
897
+ if has_link_change:
898
+ if work_item_id == parent or work_item_id in (blocked_by or []):
899
+ sys.exit(f"Work item #{work_item_id} can't be linked to itself.")
900
+ relation_ops = build_relation_ops(remote, parent, blocked_by, get_work_item(session, remote, work_item_id).get("relations"))
737
901
 
738
902
  if remove_tags:
739
903
  removal_set = {t.casefold() for t in remove_tags}
@@ -746,7 +910,7 @@ def update(
746
910
  filtered_tags = [t for t in current_tags if t.casefold() not in removal_set]
747
911
  if filtered_tags != current_tags:
748
912
  tags = filtered_tags
749
- elif title is None and description is None and area_path is None and applied_state is None:
913
+ elif title is None and description is None and area_path is None and iteration is None and applied_state is None and not relation_ops:
750
914
  # None of `remove_tags` were actually present, and nothing else was given either --
751
915
  # mirrors `add_tag`'s own idempotency check (same file) so this doesn't needlessly
752
916
  # bump the work item's revision/history with a same-value System.Tags PATCH.
@@ -755,10 +919,17 @@ def update(
755
919
  # else: leave `tags` as None -- other given fields still get their own ops below,
756
920
  # without forcing a redundant, unchanged System.Tags op alongside them.
757
921
 
922
+ if title is None and description is None and area_path is None and iteration is None and tags is None and applied_state is None and not relation_ops:
923
+ # only --parent / --blocked-by were given, and the work item already has those links
924
+ if state is not None:
925
+ print(f"Work item #{work_item_id}: '{state}' isn't a valid state here — noted in a comment.")
926
+ print(f"Work item #{work_item_id}: already linked as requested — nothing to do.")
927
+ return None
928
+
758
929
  if description is not None:
759
930
  description = ensure_agent_session_note(description, also_check=title)
760
931
 
761
- work_item = update_work_item(session, remote, work_item_id, title, description, area_path, tags, applied_state)
932
+ work_item = update_work_item(session, remote, work_item_id, title, description, area_path, tags, applied_state, iteration, relation_ops)
762
933
  print(f"Updated work item #{work_item_id}")
763
934
  return work_item
764
935
 
@@ -26,6 +26,7 @@ from . import find_repo as find_repo_mod
26
26
  from . import issue_do as issue_do_mod
27
27
  from . import issue_watch as issue_watch_mod
28
28
  from . import gh_issue, gh_pr
29
+ from . import issue_show as issue_show_mod
29
30
  from . import find_injection as find_injection_mod
30
31
  from . import lint as lint_mod
31
32
  from . import logs as logs_mod
@@ -211,6 +212,15 @@ def _merge_tags_and_labels(tag: list[str] | None, label: list[str] | None) -> li
211
212
  return merged
212
213
 
213
214
 
215
+ def _reject_ado_only_options(**options: object) -> None:
216
+ """GitHub has no counterpart of these `issue create/update` options (Azure DevOps only): fail up front
217
+ instead of silently dropping them."""
218
+ for name, value in options.items():
219
+ if value:
220
+ flag = "--" + name.replace("_", "-")
221
+ raise typer.BadParameter(f"{flag} is only supported for Azure DevOps work items, not GitHub issues.", param_hint=flag)
222
+
223
+
214
224
  def _link_and_label_github(gh: str, remote: GitHubRemote, issue_numbers: list[int]) -> None:
215
225
  """Link every issue in `issue_numbers`, plus every issue `find_issue_refs_in_body` finds in
216
226
  the PR's actual body, to the current branch's PR, and label each `pr-available`.
@@ -653,6 +663,18 @@ def issue_create(
653
663
  file: list[str] = typer.Option(
654
664
  [], "--file", help="Path to an arbitrary file to attach to the issue / work item as a linked attachment (repeatable)"
655
665
  ),
666
+ iteration: str | None = typer.Option(
667
+ None,
668
+ "--iteration",
669
+ help=r"Iteration (sprint) Path to set, e.g. 'MyProject\Sprint 1' (Azure DevOps only; overrides \[tool.bdt.ado].iteration "
670
+ "in pyproject.toml). Work items without one don't show on the team backlog in Basic-process projects.",
671
+ ),
672
+ parent: int | None = typer.Option(
673
+ None, "--parent", help="Make the new issue / work item a child (sub-issue) of this issue / work item ID"
674
+ ),
675
+ blocked_by: list[int] = typer.Option(
676
+ [], "--blocked-by", help="Issue / work item ID blocking the new one -- its predecessor on Azure DevOps (repeatable)"
677
+ ),
656
678
  pat: str | None = typer.Option(
657
679
  None,
658
680
  "--pat",
@@ -672,15 +694,20 @@ def issue_create(
672
694
  tags_or_labels = _merge_tags_and_labels(tag, label) or []
673
695
  remote = current_remote()
674
696
  if isinstance(remote, GitHubRemote):
697
+ _reject_ado_only_options(iteration=iteration)
675
698
  resolved_board = gh_issue.resolve_board(board)
676
699
  gh_issue.create(
677
- require_gh(), remote.owner, remote.repo, title, description, tags_or_labels, screenshot, args or [], file_paths=file, board=resolved_board
700
+ require_gh(), remote.owner, remote.repo, title, description, tags_or_labels, screenshot, args or [], file_paths=file, board=resolved_board,
701
+ parent=parent, blocked_by=blocked_by,
678
702
  )
679
703
  else:
680
704
  session = requests.Session()
681
705
  session.headers.update(auth_header(pat))
682
706
  resolved_board = ado_issue.check_board(ado_issue.resolve_board(board), creating=True)
683
- ado_issue.create(session, remote, type_, title, description, resolved_board, tags_or_labels, screenshot, file)
707
+ ado_issue.create(
708
+ session, remote, type_, title, description, resolved_board, tags_or_labels, screenshot, file,
709
+ iteration=ado_issue.resolve_iteration(iteration), parent=parent, blocked_by=blocked_by,
710
+ )
684
711
 
685
712
 
686
713
  @issue_app.command("search")
@@ -1131,6 +1158,30 @@ issue_app.command("watch")(issue_watch)
1131
1158
  app.command("watch-issue", help="Alias for `bdt issue watch`")(issue_watch)
1132
1159
 
1133
1160
 
1161
+ @issue_app.command("show")
1162
+ def issue_show(
1163
+ number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
1164
+ as_json: bool = typer.Option(False, "--json", help="Print one JSON document instead of text"),
1165
+ pat: str | None = typer.Option(
1166
+ None,
1167
+ "--pat",
1168
+ envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
1169
+ help="Azure DevOps PAT (else falls back to `az` login)",
1170
+ ),
1171
+ ) -> None:
1172
+ """Show an issue / work item (Azure DevOps or GitHub, auto-detected): state, title, description, tags,
1173
+ area / iteration (Azure DevOps), links to other issues / work items (parent, children, blocked by, blocks) and comments.
1174
+ """
1175
+ remote = current_remote()
1176
+ if isinstance(remote, GitHubRemote):
1177
+ details = gh_issue.get_details(require_gh(), remote.owner, remote.repo, number)
1178
+ else:
1179
+ session = requests.Session()
1180
+ session.headers.update(auth_header(pat))
1181
+ details = ado_issue.get_details(session, remote, number)
1182
+ typer.echo(issue_show_mod.format_details(details, as_json=as_json))
1183
+
1184
+
1134
1185
  @issue_app.command("update")
1135
1186
  def issue_update(
1136
1187
  number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
@@ -1170,6 +1221,20 @@ def issue_update(
1170
1221
  remove_label: list[str] | None = typer.Option(
1171
1222
  None, "--remove-label", help="Tag/label to remove (repeatable). Alias for --remove-tag."
1172
1223
  ),
1224
+ iteration: str | None = typer.Option(
1225
+ None,
1226
+ "--iteration",
1227
+ help=r"Move the work item to this Iteration (sprint) Path, e.g. 'MyProject\Sprint 1' (Azure DevOps only). Like --board, "
1228
+ r"only applied when given here -- it does not fall back to \[tool.bdt.ado].iteration.",
1229
+ ),
1230
+ parent: int | None = typer.Option(
1231
+ None,
1232
+ "--parent",
1233
+ help="Make this issue / work item a child (sub-issue) of this issue / work item ID; on Azure DevOps, replaces its current parent",
1234
+ ),
1235
+ blocked_by: list[int] = typer.Option(
1236
+ [], "--blocked-by", help="Issue / work item ID blocking this one -- its predecessor on Azure DevOps (repeatable). Already-set links are left alone."
1237
+ ),
1173
1238
  pat: str | None = typer.Option(
1174
1239
  None,
1175
1240
  "--pat",
@@ -1182,11 +1247,15 @@ def issue_update(
1182
1247
  removals = _merge_tags_and_labels(remove_tag, remove_label)
1183
1248
  remote = current_remote()
1184
1249
  if isinstance(remote, GitHubRemote):
1185
- gh_issue.update(require_gh(), number, title, description, tags_or_labels, removals, state)
1250
+ _reject_ado_only_options(iteration=iteration)
1251
+ gh_issue.update(require_gh(), number, title, description, tags_or_labels, removals, state, parent, blocked_by)
1186
1252
  else:
1187
1253
  session = requests.Session()
1188
1254
  session.headers.update(auth_header(pat))
1189
- ado_issue.update(session, remote, number, title, description, ado_issue.check_board(board), tags_or_labels, removals, state)
1255
+ ado_issue.update(
1256
+ session, remote, number, title, description, ado_issue.check_board(board), tags_or_labels, removals, state,
1257
+ iteration=iteration, parent=parent, blocked_by=blocked_by,
1258
+ )
1190
1259
 
1191
1260
 
1192
1261
  @issue_app.command("delete")