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