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.
Files changed (38) hide show
  1. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/PKG-INFO +64 -4
  2. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/README.md +63 -3
  3. bmsdna_devtools-0.5.0/bmsdna/devtools/ado_issue.py +314 -0
  4. bmsdna_devtools-0.5.0/bmsdna/devtools/bdt_config.py +29 -0
  5. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/cli.py +188 -2
  6. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/env_config.py +3 -16
  7. bmsdna_devtools-0.5.0/bmsdna/devtools/gh_issue.py +132 -0
  8. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/pyproject.toml +1 -1
  9. bmsdna_devtools-0.5.0/tests/test_ado_issue.py +93 -0
  10. bmsdna_devtools-0.5.0/tests/test_ado_issue_flow.py +189 -0
  11. bmsdna_devtools-0.5.0/tests/test_bdt_config.py +31 -0
  12. bmsdna_devtools-0.5.0/tests/test_cli_pr_create.py +43 -0
  13. bmsdna_devtools-0.5.0/tests/test_gh_issue.py +24 -0
  14. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/uv.lock +1 -1
  15. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.github/workflows/python-publish.yml +0 -0
  16. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.github/workflows/python-test.yml +0 -0
  17. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.gitignore +0 -0
  18. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/.python-version +0 -0
  19. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/__init__.py +0 -0
  20. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/ado_auth.py +0 -0
  21. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/app_service_logs.py +0 -0
  22. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/cli_tools.py +0 -0
  23. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/commit.py +0 -0
  24. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/gh_pr.py +0 -0
  25. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/gitrepo.py +0 -0
  26. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/logs.py +0 -0
  27. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/pr_build.py +0 -0
  28. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/pr_markdown.py +0 -0
  29. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/bmsdna/devtools/worktree.py +0 -0
  30. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/justfile +0 -0
  31. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  32. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_app_service_logs.py +0 -0
  33. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_commit.py +0 -0
  34. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_env_config.py +0 -0
  35. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_gh_pr.py +0 -0
  36. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_gitrepo.py +0 -0
  37. {bmsdna_devtools-0.4.0 → bmsdna_devtools-0.5.0}/tests/test_pr_build.py +0 -0
  38. {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.4.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, git worktrees, a commit-and-push helper with pre-flight checks,
15
- and Azure log queries. `bdt pr *` auto-detects whether the current repo's
16
- `origin` remote is Azure DevOps or GitHub and uses `az`/`gh` accordingly.
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, git worktrees, a commit-and-push helper with pre-flight checks,
5
- and Azure log queries. `bdt pr *` auto-detects whether the current repo's
6
- `origin` remote is Azure DevOps or GitHub and uses `az`/`gh` accordingly.
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 `![name](url)` 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 {}