bmsdna-devtools 0.40.0__tar.gz → 0.42.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 (99) hide show
  1. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/PKG-INFO +39 -3
  2. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/README.md +38 -2
  3. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/ado_issue.py +191 -20
  4. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/cli.py +86 -7
  5. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/cli_tools.py +43 -0
  6. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gh_issue.py +77 -2
  7. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gh_pr.py +18 -3
  8. bmsdna_devtools-0.42.0/bmsdna/devtools/issue_show.py +65 -0
  9. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_build.py +17 -3
  10. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/pyproject.toml +1 -1
  11. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/skills/bmsdna-devtools/SKILL.md +19 -2
  12. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_ado_issue.py +86 -0
  13. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_ado_issue_flow.py +174 -0
  14. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_issue.py +150 -6
  15. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pr_id.py +2 -2
  16. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_gh_issue.py +116 -0
  17. bmsdna_devtools-0.42.0/tests/test_issue_show.py +59 -0
  18. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_choice.py +2 -2
  19. bmsdna_devtools-0.42.0/tests/test_wait_timeout.py +187 -0
  20. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/uv.lock +1 -1
  21. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.github/workflows/auto-release.yml +0 -0
  22. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.github/workflows/python-publish.yml +0 -0
  23. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.github/workflows/python-test.yml +0 -0
  24. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.gitignore +0 -0
  25. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.python-version +0 -0
  26. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/__init__.py +0 -0
  27. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/_api_dump.py +0 -0
  28. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/ado_auth.py +0 -0
  29. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/agent_mentions.py +0 -0
  30. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/api_usage.py +0 -0
  31. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/app_service_logs.py +0 -0
  32. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/bdt_config.py +0 -0
  33. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/commit.py +0 -0
  34. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/dead_code.py +0 -0
  35. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/env_config.py +0 -0
  36. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/env_files.py +0 -0
  37. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/find_injection.py +0 -0
  38. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/find_repo.py +0 -0
  39. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gitrepo.py +0 -0
  40. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/injection_frontend.py +0 -0
  41. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/injection_python.py +0 -0
  42. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_do.py +0 -0
  43. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_watch.py +0 -0
  44. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint.py +0 -0
  45. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_findings.py +0 -0
  46. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_models.py +0 -0
  47. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_sql.py +0 -0
  48. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  49. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_tooling.py +0 -0
  50. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_typescript.py +0 -0
  51. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/logs.py +0 -0
  52. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_choice.py +0 -0
  53. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_info.py +0 -0
  54. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  55. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_labels.py +0 -0
  56. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_markdown.py +0 -0
  57. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pull.py +0 -0
  58. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/testdb.py +0 -0
  59. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/translate.py +0 -0
  60. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/worktree.py +0 -0
  61. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/justfile +0 -0
  62. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/conftest.py +0 -0
  63. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_ado_auth.py +0 -0
  64. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_agent_mentions.py +0 -0
  65. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_api_usage.py +0 -0
  66. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_app_service_logs.py +0 -0
  67. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_bdt_config.py +0 -0
  68. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_cleanup.py +0 -0
  69. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_commit.py +0 -0
  70. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pr_create.py +0 -0
  71. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pull.py +0 -0
  72. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_tools.py +0 -0
  73. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_commit.py +0 -0
  74. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_env_config.py +0 -0
  75. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_env_files.py +0 -0
  76. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_find_injection.py +0 -0
  77. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_find_repo.py +0 -0
  78. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_gh_pr.py +0 -0
  79. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_gitrepo.py +0 -0
  80. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_issue_do.py +0 -0
  81. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_issue_for_me.py +0 -0
  82. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_issue_watch.py +0 -0
  83. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint.py +0 -0
  84. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_models.py +0 -0
  85. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_sql.py +0 -0
  86. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_sql_files.py +0 -0
  87. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_tooling.py +0 -0
  88. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_typescript.py +0 -0
  89. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_build.py +0 -0
  90. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_build_flow.py +0 -0
  91. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_description_not_truncated.py +0 -0
  92. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_info.py +0 -0
  93. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_issue_link.py +0 -0
  94. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_labels.py +0 -0
  95. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_markdown.py +0 -0
  96. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pull.py +0 -0
  97. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_testdb.py +0 -0
  98. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_translate.py +0 -0
  99. {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.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.40.0
3
+ Version: 0.42.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
@@ -50,7 +50,7 @@ Find the PR opened from the current branch and report build/check status
50
50
  GitHub — whichever `origin` points at.
51
51
 
52
52
  ```bash
53
- bdt pr status [--target-branch BRANCH] [--wait]
53
+ bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
54
54
  ```
55
55
 
56
56
  Every command that acts on an existing PR (`pr status/info/retry/publish/update/comment`, `issue take` without
@@ -75,6 +75,12 @@ stage/check needs a reviewer plus a link to act on it, and exits **3**
75
75
  build/check has already failed, that's reported instead (exit 1) even when
76
76
  one is also waiting on approval.
77
77
 
78
+ `--wait --timeout SECONDS` gives up after that long when the checks are still running: it prints the current
79
+ state of every pipeline/check, ends with `Still running after N s. Re-run ... to keep waiting.` (the command pins `--pr-id` to the PR it watched) and exits **4**
80
+ (like `issue watch`). Use it where the caller's own limit is shorter than CI, e.g. an agent's shell tool, which
81
+ kills the command after a few minutes and discards its output; re-run the same command to keep waiting.
82
+ Without `--timeout` (or with `0`) it waits until everything is done, as before. `--timeout` needs `--wait`.
83
+
78
84
  On Azure DevOps, `--wait` on a PR with no builds yet also checks that the target branch has an enabled
79
85
  Build validation policy; if it doesn't, no build will ever run, so it exits 1 with an error instead of
80
86
  polling forever (a failed policy lookup is ignored and the wait continues).
@@ -247,11 +253,14 @@ create prints a reminder to run `bdt pr status` afterward to check whether
247
253
  the CI build passes. This is a best-effort check — failures reading policy
248
254
  config (auth, permissions) fail open and simply skip the reminder.
249
255
 
250
- ## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
256
+ ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
251
257
 
252
258
  ```bash
253
259
  bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
260
+ bdt issue create --title "Add retries" --type Task --iteration 'MyProject\Sprint 1' --parent 1200 --blocked-by 1210
261
+ bdt issue show 1234 [--json]
254
262
  bdt issue update 1234 --state Resolved --tag fixed
263
+ bdt issue update 1234 --iteration 'MyProject\Sprint 2' --parent 1200 --blocked-by 1210 --blocked-by 1211
255
264
  bdt issue delete 1234 --yes
256
265
 
257
266
  bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
@@ -264,6 +273,17 @@ adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
264
273
  *`. `bdt issue comment add` prints the new comment's ID so you can pass it to
265
274
  `update`/`delete` later.
266
275
 
276
+ `issue show NUMBER` prints one issue / work item: state, title, URL, description, tags, links to other
277
+ issues (parent, children, blocked by, blocks; related on Azure DevOps), the Azure DevOps Type / Area /
278
+ Iteration / Assigned to (GitHub: assignees, milestone) and all comments (Azure DevOps HTML is shown as plain
279
+ text). `--json` prints the same as one JSON document (`number, title, state, url, body, tags, fields,
280
+ parent, children, blocked_by, blocks, related, comments`).
281
+
282
+ `--parent ID` and `--blocked-by ID` (repeatable) on `create` and `update` link the item to its parent and to
283
+ the issues / work items that block it, on both backends. On `update` only the links that are missing are
284
+ added, so repeating a command is harmless (like `--tag`); `--parent` replaces a different current parent.
285
+ They only ever add links: removing one still goes through the web UI.
286
+
267
287
  Destructive commands (`issue delete`, `issue comment delete`) require an
268
288
  explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
269
289
  also invoked by AI-agent callers that can't answer one.
@@ -290,6 +310,17 @@ literal text, but the work item Discussion/Comments control has always
290
310
  rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
291
311
  (restorable, not permanent).
292
312
 
313
+ `--iteration <path>` on `create` and `update` sets `System.IterationPath`, e.g. `'MyProject\Sprint 1'` (in
314
+ Basic-process projects a work item without an iteration doesn't show on the team backlog). It is Azure DevOps
315
+ only: on a GitHub repo it fails with a usage error (exit 2) instead of being ignored. On `create` it defaults
316
+ to `[tool.bdt.ado].iteration` from `pyproject.toml`; on `update` it is only applied when given. `bdt` doesn't
317
+ look up the board team's current iteration, so name the path yourself:
318
+
319
+ ```toml
320
+ [tool.bdt.ado]
321
+ iteration = 'MyProject\Sprint 1' # single quotes: TOML takes the backslash literally
322
+ ```
323
+
293
324
  `--board <team>` sets the work item's Area Path to that Azure Boards team's
294
325
  default, so it shows up on that team's board — a CLI flag beats
295
326
  `[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
@@ -329,6 +360,11 @@ subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
329
360
  to `bdt issue create` pass through to `gh issue create`, e.g.
330
361
  `bdt issue create --title "..." -- --assignee @me`.
331
362
 
363
+ `--parent` / `--blocked-by` use GitHub's sub-issues and issue dependencies (`gh issue create --parent` /
364
+ `--blocked-by`, `gh issue edit --parent` / `--add-blocked-by`), which need a recent `gh`; `issue show` reads
365
+ them with `gh issue view --json parent,subIssues,blockedBy,blocking`. `--iteration` isn't supported: a
366
+ GitHub Projects (v2) iteration is a per-project field, not a property of the issue.
367
+
332
368
  `--board <name>` on `create` adds the issue to that GitHub Projects (v2)
333
369
  board by title (needs a token with the `project` scope — `gh auth refresh -s
334
370
  project`). On `search`, `--board` (title or number) scopes results to issues
@@ -36,7 +36,7 @@ Find the PR opened from the current branch and report build/check status
36
36
  GitHub — whichever `origin` points at.
37
37
 
38
38
  ```bash
39
- bdt pr status [--target-branch BRANCH] [--wait]
39
+ bdt pr status [--target-branch BRANCH] [--pr-id N] [--wait [--timeout SECONDS]]
40
40
  ```
41
41
 
42
42
  Every command that acts on an existing PR (`pr status/info/retry/publish/update/comment`, `issue take` without
@@ -61,6 +61,12 @@ stage/check needs a reviewer plus a link to act on it, and exits **3**
61
61
  build/check has already failed, that's reported instead (exit 1) even when
62
62
  one is also waiting on approval.
63
63
 
64
+ `--wait --timeout SECONDS` gives up after that long when the checks are still running: it prints the current
65
+ state of every pipeline/check, ends with `Still running after N s. Re-run ... to keep waiting.` (the command pins `--pr-id` to the PR it watched) and exits **4**
66
+ (like `issue watch`). Use it where the caller's own limit is shorter than CI, e.g. an agent's shell tool, which
67
+ kills the command after a few minutes and discards its output; re-run the same command to keep waiting.
68
+ Without `--timeout` (or with `0`) it waits until everything is done, as before. `--timeout` needs `--wait`.
69
+
64
70
  On Azure DevOps, `--wait` on a PR with no builds yet also checks that the target branch has an enabled
65
71
  Build validation policy; if it doesn't, no build will ever run, so it exits 1 with an error instead of
66
72
  polling forever (a failed policy lookup is ignored and the wait continues).
@@ -233,11 +239,14 @@ create prints a reminder to run `bdt pr status` afterward to check whether
233
239
  the CI build passes. This is a best-effort check — failures reading policy
234
240
  config (auth, permissions) fail open and simply skip the reminder.
235
241
 
236
- ## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
242
+ ## `bdt issue create` / `show` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
237
243
 
238
244
  ```bash
239
245
  bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
246
+ bdt issue create --title "Add retries" --type Task --iteration 'MyProject\Sprint 1' --parent 1200 --blocked-by 1210
247
+ bdt issue show 1234 [--json]
240
248
  bdt issue update 1234 --state Resolved --tag fixed
249
+ bdt issue update 1234 --iteration 'MyProject\Sprint 2' --parent 1200 --blocked-by 1210 --blocked-by 1211
241
250
  bdt issue delete 1234 --yes
242
251
 
243
252
  bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
@@ -250,6 +259,17 @@ adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
250
259
  *`. `bdt issue comment add` prints the new comment's ID so you can pass it to
251
260
  `update`/`delete` later.
252
261
 
262
+ `issue show NUMBER` prints one issue / work item: state, title, URL, description, tags, links to other
263
+ issues (parent, children, blocked by, blocks; related on Azure DevOps), the Azure DevOps Type / Area /
264
+ Iteration / Assigned to (GitHub: assignees, milestone) and all comments (Azure DevOps HTML is shown as plain
265
+ text). `--json` prints the same as one JSON document (`number, title, state, url, body, tags, fields,
266
+ parent, children, blocked_by, blocks, related, comments`).
267
+
268
+ `--parent ID` and `--blocked-by ID` (repeatable) on `create` and `update` link the item to its parent and to
269
+ the issues / work items that block it, on both backends. On `update` only the links that are missing are
270
+ added, so repeating a command is harmless (like `--tag`); `--parent` replaces a different current parent.
271
+ They only ever add links: removing one still goes through the web UI.
272
+
253
273
  Destructive commands (`issue delete`, `issue comment delete`) require an
254
274
  explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
255
275
  also invoked by AI-agent callers that can't answer one.
@@ -276,6 +296,17 @@ literal text, but the work item Discussion/Comments control has always
276
296
  rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
277
297
  (restorable, not permanent).
278
298
 
299
+ `--iteration <path>` on `create` and `update` sets `System.IterationPath`, e.g. `'MyProject\Sprint 1'` (in
300
+ Basic-process projects a work item without an iteration doesn't show on the team backlog). It is Azure DevOps
301
+ only: on a GitHub repo it fails with a usage error (exit 2) instead of being ignored. On `create` it defaults
302
+ to `[tool.bdt.ado].iteration` from `pyproject.toml`; on `update` it is only applied when given. `bdt` doesn't
303
+ look up the board team's current iteration, so name the path yourself:
304
+
305
+ ```toml
306
+ [tool.bdt.ado]
307
+ iteration = 'MyProject\Sprint 1' # single quotes: TOML takes the backslash literally
308
+ ```
309
+
279
310
  `--board <team>` sets the work item's Area Path to that Azure Boards team's
280
311
  default, so it shows up on that team's board — a CLI flag beats
281
312
  `[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
@@ -315,6 +346,11 @@ subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
315
346
  to `bdt issue create` pass through to `gh issue create`, e.g.
316
347
  `bdt issue create --title "..." -- --assignee @me`.
317
348
 
349
+ `--parent` / `--blocked-by` use GitHub's sub-issues and issue dependencies (`gh issue create --parent` /
350
+ `--blocked-by`, `gh issue edit --parent` / `--add-blocked-by`), which need a recent `gh`; `issue show` reads
351
+ them with `gh issue view --json parent,subIssues,blockedBy,blocking`. `--iteration` isn't supported: a
352
+ GitHub Projects (v2) iteration is a per-project field, not a property of the issue.
353
+
318
354
  `--board <name>` on `create` adds the issue to that GitHub Projects (v2)
319
355
  board by title (needs a token with the `project` scope — `gh auth refresh -s
320
356
  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