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.
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/PKG-INFO +39 -3
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/README.md +38 -2
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/ado_issue.py +191 -20
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/cli.py +86 -7
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/cli_tools.py +43 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gh_issue.py +77 -2
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gh_pr.py +18 -3
- bmsdna_devtools-0.42.0/bmsdna/devtools/issue_show.py +65 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_build.py +17 -3
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/skills/bmsdna-devtools/SKILL.md +19 -2
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_ado_issue.py +86 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_ado_issue_flow.py +174 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_issue.py +150 -6
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pr_id.py +2 -2
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_gh_issue.py +116 -0
- bmsdna_devtools-0.42.0/tests/test_issue_show.py +59 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_choice.py +2 -2
- bmsdna_devtools-0.42.0/tests/test_wait_timeout.py +187 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/uv.lock +1 -1
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.gitignore +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/.python-version +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/_api_dump.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/agent_mentions.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/api_usage.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/dead_code.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/env_files.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/find_injection.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/injection_frontend.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/injection_python.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_do.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/issue_watch.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_findings.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_models.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_sql.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_sql_files.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_tooling.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/lint_typescript.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_choice.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_info.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/translate.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/justfile +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_agent_mentions.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_api_usage.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_env_files.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_find_injection.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_issue_do.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_issue_for_me.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_issue_watch.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_models.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_sql.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_sql_files.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_tooling.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_lint_typescript.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_description_not_truncated.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_info.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.40.0 → bmsdna_devtools-0.42.0}/tests/test_translate.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|