bmsdna-devtools 0.4.0__tar.gz → 0.5.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.4.0 → bmsdna_devtools-0.5.0}/PKG-INFO +64 -4
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/README.md +63 -3
- bmsdna_devtools-0.5.0/bmsdna/devtools/ado_issue.py +314 -0
- bmsdna_devtools-0.5.0/bmsdna/devtools/bdt_config.py +29 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/cli.py +188 -2
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/env_config.py +3 -16
- bmsdna_devtools-0.5.0/bmsdna/devtools/gh_issue.py +132 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/pyproject.toml +1 -1
- bmsdna_devtools-0.5.0/tests/test_ado_issue.py +93 -0
- bmsdna_devtools-0.5.0/tests/test_ado_issue_flow.py +189 -0
- bmsdna_devtools-0.5.0/tests/test_bdt_config.py +31 -0
- bmsdna_devtools-0.5.0/tests/test_cli_pr_create.py +43 -0
- bmsdna_devtools-0.5.0/tests/test_gh_issue.py +24 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/uv.lock +1 -1
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.gitignore +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.python-version +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/justfile +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_pr_markdown.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bmsdna-devtools
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: requests>=2.32.0
|
|
@@ -11,9 +11,10 @@ Description-Content-Type: text/markdown
|
|
|
11
11
|
# bmsdna-devtools
|
|
12
12
|
|
|
13
13
|
Shared developer tooling for BMS projects: PR build/check status, PR
|
|
14
|
-
creation,
|
|
15
|
-
and
|
|
16
|
-
`
|
|
14
|
+
creation, issue/work item creation and comments, git worktrees, a
|
|
15
|
+
commit-and-push helper with pre-flight checks, and Azure log queries.
|
|
16
|
+
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
17
|
+
remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
|
|
17
18
|
Consolidates near-duplicate scripts that used to be copy-pasted across
|
|
18
19
|
OneSales, ccmt2, and MDMApp into one versioned package with a `bdt` CLI.
|
|
19
20
|
|
|
@@ -83,6 +84,65 @@ create prints a reminder to run `bdt pr status` afterward to check whether
|
|
|
83
84
|
the CI build passes. This is a best-effort check — failures reading policy
|
|
84
85
|
config (auth, permissions) fail open and simply skip the reminder.
|
|
85
86
|
|
|
87
|
+
## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
|
|
91
|
+
bdt issue update 1234 --state Resolved --tag fixed
|
|
92
|
+
bdt issue delete 1234 --yes
|
|
93
|
+
|
|
94
|
+
bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
|
|
95
|
+
bdt issue comment update 1234 5678 --message "Actually, see the second screenshot"
|
|
96
|
+
bdt issue comment delete 1234 5678 --yes
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Creates/updates/deletes an issue (GitHub) or work item (Azure DevOps), and
|
|
100
|
+
adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
|
|
101
|
+
*`. `bdt issue comment add` prints the new comment's ID so you can pass it to
|
|
102
|
+
`update`/`delete` later.
|
|
103
|
+
|
|
104
|
+
Destructive commands (`issue delete`, `issue comment delete`) require an
|
|
105
|
+
explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
|
|
106
|
+
also invoked by AI-agent callers that can't answer one.
|
|
107
|
+
|
|
108
|
+
**Azure DevOps**: `--type` selects the work item type on `create` (`Bug`,
|
|
109
|
+
`Task`, `User Story`, ... — whatever the project's process defines; default
|
|
110
|
+
`Bug`). `--tag` sets/replaces the full tag list (repeatable; omit on
|
|
111
|
+
`update` to leave tags unchanged). `--state` (`update` only) sets
|
|
112
|
+
`System.State`, e.g. `Active`, `Resolved`, `Closed`. `--screenshot` uploads
|
|
113
|
+
each image as a work item attachment (visible in the Attachments tab) and
|
|
114
|
+
posts a comment embedding them inline with Markdown — the Description field
|
|
115
|
+
defaults to HTML via the REST API, where a raw `![]()` would just show as
|
|
116
|
+
literal text, but the work item Discussion/Comments control has always
|
|
117
|
+
rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
|
|
118
|
+
(restorable, not permanent).
|
|
119
|
+
|
|
120
|
+
`--board <team>` sets the work item's Area Path to that Azure Boards team's
|
|
121
|
+
default, so it shows up on that team's board — a CLI flag beats
|
|
122
|
+
`[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
|
|
123
|
+
project's root area:
|
|
124
|
+
|
|
125
|
+
```toml
|
|
126
|
+
[tool.bdt.ado]
|
|
127
|
+
board = "My Team"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
On `update`, `--board` only moves the item when you pass it explicitly — it
|
|
131
|
+
never falls back to `pyproject.toml`, so an unrelated field update (e.g.
|
|
132
|
+
just `--title`) can't silently relocate the item to a different board.
|
|
133
|
+
|
|
134
|
+
**GitHub**: a thin wrapper around `gh issue create` / `edit` / `delete` /
|
|
135
|
+
`comment`. `--label` adds a label on `create`, or adds/removes one on
|
|
136
|
+
`update` (paired with `--remove-label`); labels must already exist in the
|
|
137
|
+
repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
|
|
138
|
+
pr create --screenshot` uses, since GitHub has no API for uploading an image
|
|
139
|
+
into an issue) and appends them to the issue body / comment as Markdown.
|
|
140
|
+
`issue delete` is **permanent** — GitHub has no recycle bin for issues.
|
|
141
|
+
Comment update/delete go through `gh api` directly (`gh issue` has no
|
|
142
|
+
subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
|
|
143
|
+
to `bdt issue create` pass through to `gh issue create`, e.g.
|
|
144
|
+
`bdt issue create --title "..." -- --assignee @me`.
|
|
145
|
+
|
|
86
146
|
## `bdt worktree`
|
|
87
147
|
|
|
88
148
|
```bash
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# bmsdna-devtools
|
|
2
2
|
|
|
3
3
|
Shared developer tooling for BMS projects: PR build/check status, PR
|
|
4
|
-
creation,
|
|
5
|
-
and
|
|
6
|
-
`
|
|
4
|
+
creation, issue/work item creation and comments, git worktrees, a
|
|
5
|
+
commit-and-push helper with pre-flight checks, and Azure log queries.
|
|
6
|
+
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
|
|
7
|
+
remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
|
|
7
8
|
Consolidates near-duplicate scripts that used to be copy-pasted across
|
|
8
9
|
OneSales, ccmt2, and MDMApp into one versioned package with a `bdt` CLI.
|
|
9
10
|
|
|
@@ -73,6 +74,65 @@ create prints a reminder to run `bdt pr status` afterward to check whether
|
|
|
73
74
|
the CI build passes. This is a best-effort check — failures reading policy
|
|
74
75
|
config (auth, permissions) fail open and simply skip the reminder.
|
|
75
76
|
|
|
77
|
+
## `bdt issue create` / `update` / `delete`, `bdt issue comment add` / `update` / `delete`
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
|
|
81
|
+
bdt issue update 1234 --state Resolved --tag fixed
|
|
82
|
+
bdt issue delete 1234 --yes
|
|
83
|
+
|
|
84
|
+
bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
|
|
85
|
+
bdt issue comment update 1234 5678 --message "Actually, see the second screenshot"
|
|
86
|
+
bdt issue comment delete 1234 5678 --yes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Creates/updates/deletes an issue (GitHub) or work item (Azure DevOps), and
|
|
90
|
+
adds/edits/deletes comments on one, auto-detected from `origin` like `bdt pr
|
|
91
|
+
*`. `bdt issue comment add` prints the new comment's ID so you can pass it to
|
|
92
|
+
`update`/`delete` later.
|
|
93
|
+
|
|
94
|
+
Destructive commands (`issue delete`, `issue comment delete`) require an
|
|
95
|
+
explicit `--yes` — there's no interactive confirmation prompt, since `bdt` is
|
|
96
|
+
also invoked by AI-agent callers that can't answer one.
|
|
97
|
+
|
|
98
|
+
**Azure DevOps**: `--type` selects the work item type on `create` (`Bug`,
|
|
99
|
+
`Task`, `User Story`, ... — whatever the project's process defines; default
|
|
100
|
+
`Bug`). `--tag` sets/replaces the full tag list (repeatable; omit on
|
|
101
|
+
`update` to leave tags unchanged). `--state` (`update` only) sets
|
|
102
|
+
`System.State`, e.g. `Active`, `Resolved`, `Closed`. `--screenshot` uploads
|
|
103
|
+
each image as a work item attachment (visible in the Attachments tab) and
|
|
104
|
+
posts a comment embedding them inline with Markdown — the Description field
|
|
105
|
+
defaults to HTML via the REST API, where a raw `![]()` would just show as
|
|
106
|
+
literal text, but the work item Discussion/Comments control has always
|
|
107
|
+
rendered Markdown. `issue delete` soft-deletes to the project's Recycle Bin
|
|
108
|
+
(restorable, not permanent).
|
|
109
|
+
|
|
110
|
+
`--board <team>` sets the work item's Area Path to that Azure Boards team's
|
|
111
|
+
default, so it shows up on that team's board — a CLI flag beats
|
|
112
|
+
`[tool.bdt.ado].board` in `pyproject.toml`, which beats filing under the
|
|
113
|
+
project's root area:
|
|
114
|
+
|
|
115
|
+
```toml
|
|
116
|
+
[tool.bdt.ado]
|
|
117
|
+
board = "My Team"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
On `update`, `--board` only moves the item when you pass it explicitly — it
|
|
121
|
+
never falls back to `pyproject.toml`, so an unrelated field update (e.g.
|
|
122
|
+
just `--title`) can't silently relocate the item to a different board.
|
|
123
|
+
|
|
124
|
+
**GitHub**: a thin wrapper around `gh issue create` / `edit` / `delete` /
|
|
125
|
+
`comment`. `--label` adds a label on `create`, or adds/removes one on
|
|
126
|
+
`update` (paired with `--remove-label`); labels must already exist in the
|
|
127
|
+
repo. `--screenshot` pushes images to a `pr-assets` branch (same trick `bdt
|
|
128
|
+
pr create --screenshot` uses, since GitHub has no API for uploading an image
|
|
129
|
+
into an issue) and appends them to the issue body / comment as Markdown.
|
|
130
|
+
`issue delete` is **permanent** — GitHub has no recycle bin for issues.
|
|
131
|
+
Comment update/delete go through `gh api` directly (`gh issue` has no
|
|
132
|
+
subcommand for editing/deleting an arbitrary comment by ID). Extra arguments
|
|
133
|
+
to `bdt issue create` pass through to `gh issue create`, e.g.
|
|
134
|
+
`bdt issue create --title "..." -- --assignee @me`.
|
|
135
|
+
|
|
76
136
|
## `bdt worktree`
|
|
77
137
|
|
|
78
138
|
```bash
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Azure DevOps work items ("issues"): create/update/delete, comment
|
|
2
|
+
add/update/delete, and screenshot support.
|
|
3
|
+
|
|
4
|
+
Built directly on the Work Item Tracking REST API (api-version 7.1):
|
|
5
|
+
- Create: POST {org}/{project}/_apis/wit/workitems/${type}
|
|
6
|
+
- Update: PATCH {org}/{project}/_apis/wit/workitems/{id}
|
|
7
|
+
- Delete: DELETE {org}/{project}/_apis/wit/workitems/{id} (soft-delete to the Recycle Bin — recoverable;
|
|
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}
|
|
10
|
+
- Attachments: POST {org}/{project}/_apis/wit/attachments?fileName=...
|
|
11
|
+
- Comments: POST/PATCH/DELETE {org}/{project}/_apis/wit/workItems/{id}/comments[/{commentId}]
|
|
12
|
+
(api-version 7.1-preview.4 — the "Comments"/discussion resource is still in preview
|
|
13
|
+
even on the 7.1 line)
|
|
14
|
+
|
|
15
|
+
Screenshots are handled in two steps, since the classic long-text fields
|
|
16
|
+
(Description, Repro Steps, ...) default to HTML formatting via the REST API
|
|
17
|
+
(Markdown is an explicit opt-in per field via `/multilineFieldsFormat/...`)
|
|
18
|
+
while a raw `` would just render as literal text there:
|
|
19
|
+
1. Upload each screenshot as an attachment and link it to the work item via
|
|
20
|
+
an "AttachedFile" relation, so it shows up in the Attachments tab.
|
|
21
|
+
2. Post/append a comment embedding the same images with Markdown `![]()`
|
|
22
|
+
syntax — the work item Discussion/Comments control has rendered
|
|
23
|
+
Markdown (including inline images) since it replaced the old HTML
|
|
24
|
+
System.History field, independent of the Description field's
|
|
25
|
+
HTML/Markdown toggle.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from urllib.parse import quote
|
|
33
|
+
|
|
34
|
+
import requests
|
|
35
|
+
|
|
36
|
+
from .bdt_config import load_bdt_table
|
|
37
|
+
from .gitrepo import AdoRemote
|
|
38
|
+
from .pr_markdown import build_screenshots_section
|
|
39
|
+
|
|
40
|
+
COMMENTS_API_VERSION = "7.1-preview.4"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_board(board: str | None, start: Path | None = None) -> str | None:
|
|
44
|
+
"""--board wins; else `[tool.bdt.ado].board` from pyproject.toml; else None."""
|
|
45
|
+
return board or load_bdt_table("ado", start).get("board")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _base_url(remote: AdoRemote) -> str:
|
|
49
|
+
return f"https://dev.azure.com/{quote(remote.org, safe='')}/{quote(remote.project, safe='')}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_team_area_path(session: requests.Session, remote: AdoRemote, team: str) -> str:
|
|
53
|
+
"""The Area Path new work items need to be filed under to show up on `team`'s board."""
|
|
54
|
+
r = session.get(
|
|
55
|
+
f"{_base_url(remote)}/{quote(team, safe='')}/_apis/work/teamsettings/teamfieldvalues",
|
|
56
|
+
params={"api-version": "7.1"},
|
|
57
|
+
)
|
|
58
|
+
if r.status_code == 404:
|
|
59
|
+
sys.exit(f"Board/team '{team}' not found in project '{remote.project}'. Check --board / [tool.bdt.ado].board in pyproject.toml.")
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
return r.json()["defaultValue"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_create_ops(
|
|
65
|
+
title: str,
|
|
66
|
+
description: str | None = None,
|
|
67
|
+
area_path: str | None = None,
|
|
68
|
+
tags: list[str] | None = None,
|
|
69
|
+
) -> list[dict]:
|
|
70
|
+
"""The JSON Patch document body for creating a work item with these fields."""
|
|
71
|
+
ops = [{"op": "add", "path": "/fields/System.Title", "value": title}]
|
|
72
|
+
if description:
|
|
73
|
+
ops.append({"op": "add", "path": "/fields/System.Description", "value": description})
|
|
74
|
+
if area_path:
|
|
75
|
+
ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": area_path})
|
|
76
|
+
if tags:
|
|
77
|
+
ops.append({"op": "add", "path": "/fields/System.Tags", "value": "; ".join(tags)})
|
|
78
|
+
return ops
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_attach_ops(images: list[tuple[str, str]]) -> list[dict]:
|
|
82
|
+
"""The JSON Patch document body for linking already-uploaded attachments via 'AttachedFile' relations."""
|
|
83
|
+
return [
|
|
84
|
+
{"op": "add", "path": "/relations/-", "value": {"rel": "AttachedFile", "url": url, "attributes": {"comment": name}}}
|
|
85
|
+
for name, url in images
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_update_ops(
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
description: str | None = None,
|
|
92
|
+
area_path: str | None = None,
|
|
93
|
+
tags: list[str] | None = None,
|
|
94
|
+
state: str | None = None,
|
|
95
|
+
) -> list[dict]:
|
|
96
|
+
"""The JSON Patch document body for updating a work item — only fields that aren't None are touched."""
|
|
97
|
+
ops = []
|
|
98
|
+
if title is not None:
|
|
99
|
+
ops.append({"op": "add", "path": "/fields/System.Title", "value": title})
|
|
100
|
+
if description is not None:
|
|
101
|
+
ops.append({"op": "add", "path": "/fields/System.Description", "value": description})
|
|
102
|
+
if area_path is not None:
|
|
103
|
+
ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": area_path})
|
|
104
|
+
if tags is not None:
|
|
105
|
+
ops.append({"op": "add", "path": "/fields/System.Tags", "value": "; ".join(tags)})
|
|
106
|
+
if state is not None:
|
|
107
|
+
ops.append({"op": "add", "path": "/fields/System.State", "value": state})
|
|
108
|
+
return ops
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_work_item(
|
|
112
|
+
session: requests.Session,
|
|
113
|
+
remote: AdoRemote,
|
|
114
|
+
work_item_type: str,
|
|
115
|
+
title: str,
|
|
116
|
+
description: str | None = None,
|
|
117
|
+
area_path: str | None = None,
|
|
118
|
+
tags: list[str] | None = None,
|
|
119
|
+
) -> dict:
|
|
120
|
+
ops = build_create_ops(title, description, area_path, tags)
|
|
121
|
+
|
|
122
|
+
r = session.post(
|
|
123
|
+
f"{_base_url(remote)}/_apis/wit/workitems/${quote(work_item_type, safe='')}",
|
|
124
|
+
params={"api-version": "7.1"},
|
|
125
|
+
json=ops,
|
|
126
|
+
headers={"Content-Type": "application/json-patch+json"},
|
|
127
|
+
)
|
|
128
|
+
if r.status_code == 404:
|
|
129
|
+
sys.exit(f"Work item type '{work_item_type}' not found in project '{remote.project}'. Check --type.")
|
|
130
|
+
r.raise_for_status()
|
|
131
|
+
return r.json()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def update_work_item(
|
|
135
|
+
session: requests.Session,
|
|
136
|
+
remote: AdoRemote,
|
|
137
|
+
work_item_id: int,
|
|
138
|
+
title: str | None = None,
|
|
139
|
+
description: str | None = None,
|
|
140
|
+
area_path: str | None = None,
|
|
141
|
+
tags: list[str] | None = None,
|
|
142
|
+
state: str | None = None,
|
|
143
|
+
) -> dict:
|
|
144
|
+
ops = build_update_ops(title, description, area_path, tags, state)
|
|
145
|
+
if not ops:
|
|
146
|
+
sys.exit("Nothing to update — provide at least one of --title, --description, --board, --tag, --state.")
|
|
147
|
+
|
|
148
|
+
r = session.patch(
|
|
149
|
+
f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
|
|
150
|
+
params={"api-version": "7.1"},
|
|
151
|
+
json=ops,
|
|
152
|
+
headers={"Content-Type": "application/json-patch+json"},
|
|
153
|
+
)
|
|
154
|
+
if r.status_code == 404:
|
|
155
|
+
sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
|
|
156
|
+
r.raise_for_status()
|
|
157
|
+
return r.json()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def delete_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
161
|
+
"""Soft-delete: moves the work item to the project's Recycle Bin, where it can be restored.
|
|
162
|
+
|
|
163
|
+
Deliberately doesn't expose the REST API's `destroy=true` option — that's
|
|
164
|
+
a permanent, unrecoverable delete, and there's no confirmation step that
|
|
165
|
+
makes that safe to offer from a CLI flag.
|
|
166
|
+
"""
|
|
167
|
+
r = session.delete(f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}", params={"api-version": "7.1"})
|
|
168
|
+
if r.status_code == 404:
|
|
169
|
+
sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
|
|
170
|
+
r.raise_for_status()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def upload_attachment(session: requests.Session, remote: AdoRemote, attachment_name: str, file_path: str) -> tuple[str, str]:
|
|
174
|
+
"""Upload `file_path` as a work item attachment named `attachment_name`; returns (id, download url)."""
|
|
175
|
+
r = session.post(
|
|
176
|
+
f"{_base_url(remote)}/_apis/wit/attachments",
|
|
177
|
+
params={"fileName": attachment_name, "api-version": "7.1"},
|
|
178
|
+
data=Path(file_path).read_bytes(),
|
|
179
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
180
|
+
)
|
|
181
|
+
r.raise_for_status()
|
|
182
|
+
body = r.json()
|
|
183
|
+
return body["id"], body["url"]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _upload_screenshots(session: requests.Session, remote: AdoRemote, screenshot_paths: list[str]) -> list[tuple[str, str]]:
|
|
187
|
+
"""Upload each screenshot as an attachment; returns (display name, download url) pairs.
|
|
188
|
+
|
|
189
|
+
Attachment names are index-prefixed so two screenshots sharing a basename
|
|
190
|
+
(e.g. two 'before.png' from different folders) don't overwrite each other.
|
|
191
|
+
"""
|
|
192
|
+
return [
|
|
193
|
+
(Path(path).name, upload_attachment(session, remote, f"{i:02d}-{Path(path).name}", path)[1])
|
|
194
|
+
for i, path in enumerate(screenshot_paths)
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def link_attachments(session: requests.Session, remote: AdoRemote, work_item_id: int, images: list[tuple[str, str]]) -> None:
|
|
199
|
+
"""Link already-uploaded attachments to a work item so they show up in its Attachments tab."""
|
|
200
|
+
ops = build_attach_ops(images)
|
|
201
|
+
r = session.patch(
|
|
202
|
+
f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
|
|
203
|
+
params={"api-version": "7.1"},
|
|
204
|
+
json=ops,
|
|
205
|
+
headers={"Content-Type": "application/json-patch+json"},
|
|
206
|
+
)
|
|
207
|
+
r.raise_for_status()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def add_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, text: str) -> dict:
|
|
211
|
+
r = session.post(
|
|
212
|
+
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments",
|
|
213
|
+
params={"api-version": COMMENTS_API_VERSION},
|
|
214
|
+
json={"text": text},
|
|
215
|
+
)
|
|
216
|
+
r.raise_for_status()
|
|
217
|
+
return r.json()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def update_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int, text: str) -> dict:
|
|
221
|
+
r = session.patch(
|
|
222
|
+
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}",
|
|
223
|
+
params={"api-version": COMMENTS_API_VERSION},
|
|
224
|
+
json={"text": text},
|
|
225
|
+
)
|
|
226
|
+
if r.status_code == 404:
|
|
227
|
+
sys.exit(f"Comment #{comment_id} not found on work item #{work_item_id}.")
|
|
228
|
+
r.raise_for_status()
|
|
229
|
+
print(f"Updated comment #{comment_id} on work item #{work_item_id}")
|
|
230
|
+
return r.json()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def delete_comment(session: requests.Session, remote: AdoRemote, work_item_id: int, comment_id: int) -> None:
|
|
234
|
+
r = session.delete(
|
|
235
|
+
f"{_base_url(remote)}/_apis/wit/workItems/{work_item_id}/comments/{comment_id}",
|
|
236
|
+
params={"api-version": COMMENTS_API_VERSION},
|
|
237
|
+
)
|
|
238
|
+
if r.status_code == 404:
|
|
239
|
+
sys.exit(f"Comment #{comment_id} not found on work item #{work_item_id}.")
|
|
240
|
+
r.raise_for_status()
|
|
241
|
+
print(f"Deleted comment #{comment_id} on work item #{work_item_id}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def add_screenshots(session: requests.Session, remote: AdoRemote, work_item_id: int, screenshot_paths: list[str]) -> None:
|
|
245
|
+
"""Upload+link screenshots as attachments, then post a comment embedding them (Markdown)."""
|
|
246
|
+
images = _upload_screenshots(session, remote, screenshot_paths)
|
|
247
|
+
link_attachments(session, remote, work_item_id, images)
|
|
248
|
+
comment = add_comment(session, remote, work_item_id, build_screenshots_section(None, images).strip())
|
|
249
|
+
print(f"Attached {len(screenshot_paths)} screenshot(s) to work item #{work_item_id} (comment #{comment['id']})")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def comment_with_screenshots(session: requests.Session, remote: AdoRemote, work_item_id: int, message: str | None, screenshot_paths: list[str]) -> dict:
|
|
253
|
+
"""Post a comment, with a message and/or screenshots, on the work item."""
|
|
254
|
+
images = _upload_screenshots(session, remote, screenshot_paths) if screenshot_paths else []
|
|
255
|
+
if images:
|
|
256
|
+
link_attachments(session, remote, work_item_id, images)
|
|
257
|
+
content = build_screenshots_section(message, images).strip() if images else (message or "")
|
|
258
|
+
comment = add_comment(session, remote, work_item_id, content)
|
|
259
|
+
print(f"Added comment #{comment['id']} ({len(screenshot_paths)} screenshot(s)) to work item #{work_item_id}")
|
|
260
|
+
return comment
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def html_url(work_item: dict) -> str | None:
|
|
264
|
+
return work_item.get("_links", {}).get("html", {}).get("href")
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def create(
|
|
268
|
+
session: requests.Session,
|
|
269
|
+
remote: AdoRemote,
|
|
270
|
+
work_item_type: str,
|
|
271
|
+
title: str,
|
|
272
|
+
description: str | None,
|
|
273
|
+
board: str | None,
|
|
274
|
+
tags: list[str],
|
|
275
|
+
screenshot_paths: list[str],
|
|
276
|
+
) -> dict:
|
|
277
|
+
area_path = get_team_area_path(session, remote, board) if board else None
|
|
278
|
+
work_item = create_work_item(session, remote, work_item_type, title, description, area_path, tags)
|
|
279
|
+
work_item_id = work_item["id"]
|
|
280
|
+
|
|
281
|
+
print(f"Created {work_item_type} #{work_item_id}: {title}")
|
|
282
|
+
url = html_url(work_item)
|
|
283
|
+
if url:
|
|
284
|
+
print(url)
|
|
285
|
+
|
|
286
|
+
if screenshot_paths:
|
|
287
|
+
add_screenshots(session, remote, work_item_id, screenshot_paths)
|
|
288
|
+
|
|
289
|
+
return work_item
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def update(
|
|
293
|
+
session: requests.Session,
|
|
294
|
+
remote: AdoRemote,
|
|
295
|
+
work_item_id: int,
|
|
296
|
+
title: str | None = None,
|
|
297
|
+
description: str | None = None,
|
|
298
|
+
board: str | None = None,
|
|
299
|
+
tags: list[str] | None = None,
|
|
300
|
+
state: str | None = None,
|
|
301
|
+
) -> dict:
|
|
302
|
+
"""Update a work item's fields. Unlike `create`, `board` is only resolved (and the Area Path only
|
|
303
|
+
touched) when explicitly given — it never falls back to `[tool.bdt.ado].board`, so an unrelated
|
|
304
|
+
field update (e.g. just `--title`) can't silently move the item to a different team's board.
|
|
305
|
+
"""
|
|
306
|
+
area_path = get_team_area_path(session, remote, board) if board else None
|
|
307
|
+
work_item = update_work_item(session, remote, work_item_id, title, description, area_path, tags, state)
|
|
308
|
+
print(f"Updated work item #{work_item_id}")
|
|
309
|
+
return work_item
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def delete(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
313
|
+
delete_work_item(session, remote, work_item_id)
|
|
314
|
+
print(f"Deleted work item #{work_item_id} (moved to the Recycle Bin — restorable)")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared lookup for `[tool.bdt.*]` configuration tables in the consuming
|
|
2
|
+
repo's pyproject.toml (walks up from a start directory the same way `git`
|
|
3
|
+
looks for `.git`, so it works from any subdirectory of the repo).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_pyproject(start: Path | None = None) -> Path | None:
|
|
13
|
+
start = start or Path.cwd()
|
|
14
|
+
for directory in (start, *start.parents):
|
|
15
|
+
candidate = directory / "pyproject.toml"
|
|
16
|
+
if candidate.is_file():
|
|
17
|
+
return candidate
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_bdt_table(key: str, start: Path | None = None) -> dict:
|
|
22
|
+
"""Load the `[tool.bdt.<key>]` table (e.g. `key="envs"` -> `[tool.bdt.envs]`)."""
|
|
23
|
+
path = find_pyproject(start)
|
|
24
|
+
if path is None:
|
|
25
|
+
return {}
|
|
26
|
+
with path.open("rb") as f:
|
|
27
|
+
data = tomllib.load(f)
|
|
28
|
+
value = data.get("tool", {}).get("bdt", {}).get(key, {})
|
|
29
|
+
return value if isinstance(value, dict) else {}
|