bmsdna-devtools 0.8.0__tar.gz → 0.9.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.8.0 → bmsdna_devtools-0.9.0}/PKG-INFO +1 -1
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/ado_issue.py +63 -4
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/cli.py +9 -2
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/gh_issue.py +40 -3
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_ado_issue_flow.py +90 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_gh_issue.py +41 -1
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/uv.lock +1 -1
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/.gitignore +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/.python-version +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/README.md +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/justfile +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_ado_issue.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.8.0 → bmsdna_devtools-0.9.0}/tests/test_pr_markdown.py +0 -0
|
@@ -157,6 +157,29 @@ def update_work_item(
|
|
|
157
157
|
return r.json()
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def get_work_item_type(session: requests.Session, remote: AdoRemote, work_item_id: int) -> str:
|
|
161
|
+
r = session.get(
|
|
162
|
+
f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
|
|
163
|
+
params={"fields": "System.WorkItemType", "api-version": "7.1"},
|
|
164
|
+
)
|
|
165
|
+
if r.status_code == 404:
|
|
166
|
+
sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
|
|
167
|
+
r.raise_for_status()
|
|
168
|
+
return r.json()["fields"]["System.WorkItemType"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_valid_states(session: requests.Session, remote: AdoRemote, work_item_type: str) -> list[str]:
|
|
172
|
+
"""The state names defined for `work_item_type` by this project's process template —
|
|
173
|
+
state names (and which ones are terminal) are per-type, per-template, not a fixed set.
|
|
174
|
+
"""
|
|
175
|
+
r = session.get(
|
|
176
|
+
f"{_base_url(remote)}/_apis/wit/workitemtypes/{quote(work_item_type, safe='')}/states",
|
|
177
|
+
params={"api-version": "7.1"},
|
|
178
|
+
)
|
|
179
|
+
r.raise_for_status()
|
|
180
|
+
return [s["name"] for s in r.json()["value"]]
|
|
181
|
+
|
|
182
|
+
|
|
160
183
|
def delete_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
|
|
161
184
|
"""Soft-delete: moves the work item to the project's Recycle Bin, where it can be restored.
|
|
162
185
|
|
|
@@ -327,7 +350,10 @@ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int)
|
|
|
327
350
|
|
|
328
351
|
|
|
329
352
|
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
|
|
330
|
-
"""Batch-fetch Title/State for a set of work item ids
|
|
353
|
+
"""Batch-fetch Title/State for a set of work item ids, in the given `ids` order — WIQL only
|
|
354
|
+
returns ids (not field values), and this batch endpoint doesn't guarantee it echoes them back
|
|
355
|
+
in the order they were requested, so the caller's WIQL `ORDER BY` isn't preserved otherwise.
|
|
356
|
+
"""
|
|
331
357
|
if not ids:
|
|
332
358
|
return []
|
|
333
359
|
r = session.get(
|
|
@@ -335,7 +361,8 @@ def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int])
|
|
|
335
361
|
params={"ids": ",".join(map(str, ids)), "fields": "System.Title,System.State", "api-version": "7.1"},
|
|
336
362
|
)
|
|
337
363
|
r.raise_for_status()
|
|
338
|
-
|
|
364
|
+
by_id = {item["id"]: item for item in r.json()["value"]}
|
|
365
|
+
return [by_id[i] for i in ids if i in by_id]
|
|
339
366
|
|
|
340
367
|
|
|
341
368
|
def search(
|
|
@@ -398,13 +425,45 @@ def update(
|
|
|
398
425
|
board: str | None = None,
|
|
399
426
|
tags: list[str] | None = None,
|
|
400
427
|
state: str | None = None,
|
|
401
|
-
) -> dict:
|
|
428
|
+
) -> dict | None:
|
|
402
429
|
"""Update a work item's fields. Unlike `create`, `board` is only resolved (and the Area Path only
|
|
403
430
|
touched) when explicitly given — it never falls back to `[tool.bdt.ado].board`, so an unrelated
|
|
404
431
|
field update (e.g. just `--title`) can't silently move the item to a different team's board.
|
|
432
|
+
|
|
433
|
+
If `state` isn't one of this work item's type's valid states (state names, and which ones are
|
|
434
|
+
terminal, are defined per work item type per process template — e.g. a Basic-process Issue has
|
|
435
|
+
'Done' but no 'Closed'), the state is left unchanged and a comment records the exact state that
|
|
436
|
+
was requested instead of the update failing or silently doing nothing.
|
|
405
437
|
"""
|
|
406
438
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
407
|
-
|
|
439
|
+
applied_state = state
|
|
440
|
+
if state is not None:
|
|
441
|
+
work_item_type = get_work_item_type(session, remote, work_item_id)
|
|
442
|
+
valid_states = get_valid_states(session, remote, work_item_type)
|
|
443
|
+
# Azure DevOps state values are case-sensitive, so a case-insensitive match ('closed' for
|
|
444
|
+
# a project whose actual state is 'Closed') must still PATCH the canonical casing, not the
|
|
445
|
+
# caller's — otherwise the update fails (or silently sets a technically-invalid value)
|
|
446
|
+
# despite the validation above having found a match.
|
|
447
|
+
canonical_state = next((s for s in valid_states if state.lower() == s.lower()), None)
|
|
448
|
+
if canonical_state is not None:
|
|
449
|
+
applied_state = canonical_state
|
|
450
|
+
else:
|
|
451
|
+
add_comment(
|
|
452
|
+
session,
|
|
453
|
+
remote,
|
|
454
|
+
work_item_id,
|
|
455
|
+
f"Requested state change to '{state}', which isn't a valid state for a "
|
|
456
|
+
f"'{work_item_type}' here (valid states: {', '.join(valid_states)}) — left unchanged.",
|
|
457
|
+
)
|
|
458
|
+
applied_state = None
|
|
459
|
+
|
|
460
|
+
if title is None and description is None and area_path is None and tags is None and applied_state is None:
|
|
461
|
+
if state is not None:
|
|
462
|
+
print(f"Work item #{work_item_id}: '{state}' isn't a valid state here — noted in a comment.")
|
|
463
|
+
return None
|
|
464
|
+
sys.exit("Nothing to update — provide at least one of --title, --description, --board, --tag, --state.")
|
|
465
|
+
|
|
466
|
+
work_item = update_work_item(session, remote, work_item_id, title, description, area_path, tags, applied_state)
|
|
408
467
|
print(f"Updated work item #{work_item_id}")
|
|
409
468
|
return work_item
|
|
410
469
|
|
|
@@ -378,7 +378,14 @@ def issue_update(
|
|
|
378
378
|
r"this is only applied when explicitly given here — it does not fall back to \[tool.bdt.ado].board, "
|
|
379
379
|
"so an unrelated field update can't silently move the item onto a different board.",
|
|
380
380
|
),
|
|
381
|
-
state: str | None = typer.Option(
|
|
381
|
+
state: str | None = typer.Option(
|
|
382
|
+
None,
|
|
383
|
+
"--state",
|
|
384
|
+
help="New state. Both backends understand 'Open', 'Closed'/'Done' (closes as completed), and "
|
|
385
|
+
"'Removed'/'Not Planned' (closes as not planned); other values (e.g. Active, Resolved) only "
|
|
386
|
+
"apply where that exact state name exists for the work item's type — elsewhere the state is "
|
|
387
|
+
"left unchanged and a comment records what was requested.",
|
|
388
|
+
),
|
|
382
389
|
tag: list[str] | None = typer.Option(None, "--tag", help="Replaces all tags (Azure DevOps only, repeatable); omit to leave unchanged"),
|
|
383
390
|
label: list[str] | None = typer.Option(None, "--label", help="Label to add (GitHub only, repeatable)"),
|
|
384
391
|
remove_label: list[str] | None = typer.Option(None, "--remove-label", help="Label to remove (GitHub only, repeatable)"),
|
|
@@ -392,7 +399,7 @@ def issue_update(
|
|
|
392
399
|
"""Update an issue / work item's fields (Azure DevOps or GitHub, auto-detected)."""
|
|
393
400
|
remote = current_remote()
|
|
394
401
|
if isinstance(remote, GitHubRemote):
|
|
395
|
-
gh_issue.update(require_gh(), number, title, description, label, remove_label)
|
|
402
|
+
gh_issue.update(require_gh(), number, title, description, label, remove_label, state)
|
|
396
403
|
else:
|
|
397
404
|
session = requests.Session()
|
|
398
405
|
session.headers.update(auth_header(pat))
|
|
@@ -111,6 +111,39 @@ def search(gh: str, keywords: list[str], since: str | None, limit: int, state: s
|
|
|
111
111
|
return items
|
|
112
112
|
|
|
113
113
|
|
|
114
|
+
_DONE_STATE_NAMES = ("closed", "done", "completed")
|
|
115
|
+
_REMOVED_STATE_NAMES = ("removed", "not planned", "not_planned", "wontfix", "won't fix")
|
|
116
|
+
_OPEN_STATE_NAMES = ("open", "reopened", "reopen")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _set_state(gh: str, number: int, state: str) -> None:
|
|
120
|
+
"""GitHub issues only have two states (open/closed) plus, when closed, a `state_reason` of
|
|
121
|
+
'completed' or 'not planned' — no per-process-template state names like Azure DevOps. Map the
|
|
122
|
+
common terminal-state spellings onto that: 'Closed'/'Done'/'Completed' close as completed
|
|
123
|
+
(GitHub's "done" concept); 'Removed'/'Not Planned'/'Wontfix' close as not planned; 'Open'/
|
|
124
|
+
'Reopened' reopens. Anything else has no GitHub equivalent — leave the issue's state
|
|
125
|
+
unchanged and comment with the exact state that was requested, so it isn't silently dropped.
|
|
126
|
+
"""
|
|
127
|
+
normalized = state.strip().lower()
|
|
128
|
+
if normalized in _DONE_STATE_NAMES:
|
|
129
|
+
_run_gh(gh, ["issue", "close", str(number), "--reason", "completed"])
|
|
130
|
+
elif normalized in _REMOVED_STATE_NAMES:
|
|
131
|
+
_run_gh(gh, ["issue", "close", str(number), "--reason", "not planned"])
|
|
132
|
+
elif normalized in _OPEN_STATE_NAMES:
|
|
133
|
+
_run_gh(gh, ["issue", "reopen", str(number)])
|
|
134
|
+
else:
|
|
135
|
+
_run_gh(
|
|
136
|
+
gh,
|
|
137
|
+
[
|
|
138
|
+
"issue",
|
|
139
|
+
"comment",
|
|
140
|
+
str(number),
|
|
141
|
+
"--body",
|
|
142
|
+
f"Requested state change to '{state}', which isn't a valid GitHub issue state — left unchanged.",
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
114
147
|
def update(
|
|
115
148
|
gh: str,
|
|
116
149
|
number: int,
|
|
@@ -118,6 +151,7 @@ def update(
|
|
|
118
151
|
body: str | None = None,
|
|
119
152
|
add_labels: list[str] | None = None,
|
|
120
153
|
remove_labels: list[str] | None = None,
|
|
154
|
+
state: str | None = None,
|
|
121
155
|
) -> None:
|
|
122
156
|
args = ["issue", "edit", str(number)]
|
|
123
157
|
if title is not None:
|
|
@@ -128,10 +162,13 @@ def update(
|
|
|
128
162
|
args += ["--add-label", label]
|
|
129
163
|
for label in remove_labels or []:
|
|
130
164
|
args += ["--remove-label", label]
|
|
131
|
-
if len(args) == 3:
|
|
132
|
-
sys.exit("Nothing to update — provide at least one of --title, --description, --label, --remove-label.")
|
|
165
|
+
if len(args) == 3 and state is None:
|
|
166
|
+
sys.exit("Nothing to update — provide at least one of --title, --description, --label, --remove-label, --state.")
|
|
133
167
|
|
|
134
|
-
|
|
168
|
+
if len(args) > 3:
|
|
169
|
+
_run_gh(gh, args)
|
|
170
|
+
if state is not None:
|
|
171
|
+
_set_state(gh, number, state)
|
|
135
172
|
print(f"Updated issue #{number}")
|
|
136
173
|
|
|
137
174
|
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.9.0"
|
|
14
14
|
description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
|
|
15
15
|
readme = "README.md"
|
|
16
16
|
requires-python = ">=3.11"
|
|
@@ -8,6 +8,8 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from unittest.mock import MagicMock
|
|
10
10
|
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
11
13
|
from bmsdna.devtools.ado_issue import (
|
|
12
14
|
comment_with_screenshots,
|
|
13
15
|
create,
|
|
@@ -212,6 +214,27 @@ def test_search_runs_wiql_then_batch_fetches_matched_fields() -> None:
|
|
|
212
214
|
assert results == [{"id": 42, "fields": {"System.Title": "Auth timeout bug", "System.State": "Active"}}]
|
|
213
215
|
|
|
214
216
|
|
|
217
|
+
def test_search_preserves_wiql_order_when_batch_fetch_returns_a_different_order() -> None:
|
|
218
|
+
# The batch "list work items by id" endpoint doesn't guarantee it echoes ids back in the
|
|
219
|
+
# order they were requested — WIQL's ORDER BY [System.ChangedDate] DESC must still win.
|
|
220
|
+
session = make_session(
|
|
221
|
+
get_map={
|
|
222
|
+
"/_apis/wit/workitems": {
|
|
223
|
+
"value": [
|
|
224
|
+
{"id": 50, "fields": {"System.Title": "Oldest match", "System.State": "Active"}},
|
|
225
|
+
{"id": 102, "fields": {"System.Title": "Newest match", "System.State": "Active"}},
|
|
226
|
+
{"id": 99, "fields": {"System.Title": "Middle match", "System.State": "Active"}},
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
wiql_ids=[102, 99, 50],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
results = search(session, REMOTE, ["auth"])
|
|
234
|
+
|
|
235
|
+
assert [item["id"] for item in results] == [102, 99, 50]
|
|
236
|
+
|
|
237
|
+
|
|
215
238
|
def test_search_skips_batch_fetch_when_no_matches() -> None:
|
|
216
239
|
session = make_session(wiql_ids=[])
|
|
217
240
|
|
|
@@ -246,3 +269,70 @@ def test_search_without_board_does_not_look_up_area_path() -> None:
|
|
|
246
269
|
search(session, REMOTE, ["auth"])
|
|
247
270
|
|
|
248
271
|
session.get.assert_not_called() # no --board given, so no team field values lookup at all
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_update_with_valid_state_includes_it_in_the_patch() -> None:
|
|
275
|
+
session = make_session(
|
|
276
|
+
get_map={
|
|
277
|
+
"/_apis/wit/workitems/42": {"fields": {"System.WorkItemType": "Bug"}},
|
|
278
|
+
"/_apis/wit/workitemtypes/Bug/states": {"value": [{"name": "New"}, {"name": "Active"}, {"name": "Closed"}]},
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
update(session, REMOTE, 42, state="Closed")
|
|
283
|
+
|
|
284
|
+
ops = session.patch.call_args.kwargs["json"]
|
|
285
|
+
assert {"op": "add", "path": "/fields/System.State", "value": "Closed"} in ops
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_update_state_uses_canonical_casing_from_valid_states() -> None:
|
|
289
|
+
session = make_session(
|
|
290
|
+
get_map={
|
|
291
|
+
"/_apis/wit/workitems/42": {"fields": {"System.WorkItemType": "Bug"}},
|
|
292
|
+
"/_apis/wit/workitemtypes/Bug/states": {"value": [{"name": "New"}, {"name": "Closed"}]},
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
update(session, REMOTE, 42, state="closed") # lowercase — doesn't match ADO's 'Closed' exactly
|
|
297
|
+
|
|
298
|
+
ops = session.patch.call_args.kwargs["json"]
|
|
299
|
+
assert {"op": "add", "path": "/fields/System.State", "value": "Closed"} in ops
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_update_with_invalid_state_comments_instead_of_failing() -> None:
|
|
303
|
+
session = make_session(
|
|
304
|
+
get_map={
|
|
305
|
+
"/_apis/wit/workitems/42": {"fields": {"System.WorkItemType": "Bug"}},
|
|
306
|
+
"/_apis/wit/workitemtypes/Bug/states": {"value": [{"name": "New"}, {"name": "Active"}, {"name": "Closed"}]},
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
result = update(session, REMOTE, 42, state="Done")
|
|
311
|
+
|
|
312
|
+
assert result is None
|
|
313
|
+
session.patch.assert_not_called()
|
|
314
|
+
comment_url, comment_kwargs = session.post.call_args.args[0], session.post.call_args.kwargs
|
|
315
|
+
assert comment_url == "https://dev.azure.com/myorg/MyProj/_apis/wit/workItems/42/comments"
|
|
316
|
+
assert "'Done'" in comment_kwargs["json"]["text"]
|
|
317
|
+
assert "Bug" in comment_kwargs["json"]["text"]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def test_update_with_invalid_state_still_patches_other_given_fields() -> None:
|
|
321
|
+
session = make_session(
|
|
322
|
+
get_map={
|
|
323
|
+
"/_apis/wit/workitems/42": {"fields": {"System.WorkItemType": "Bug"}},
|
|
324
|
+
"/_apis/wit/workitemtypes/Bug/states": {"value": [{"name": "New"}]},
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
update(session, REMOTE, 42, title="New title", state="Done")
|
|
329
|
+
|
|
330
|
+
ops = session.patch.call_args.kwargs["json"]
|
|
331
|
+
assert ops == [{"op": "add", "path": "/fields/System.Title", "value": "New title"}]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_update_with_nothing_given_still_errors() -> None:
|
|
335
|
+
session = make_session()
|
|
336
|
+
|
|
337
|
+
with pytest.raises(SystemExit):
|
|
338
|
+
update(session, REMOTE, 42)
|
|
@@ -2,7 +2,7 @@ from unittest.mock import MagicMock
|
|
|
2
2
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
|
-
from bmsdna.devtools.gh_issue import build_search_query, parse_comment_id, parse_issue_number, search
|
|
5
|
+
from bmsdna.devtools.gh_issue import build_search_query, parse_comment_id, parse_issue_number, search, update
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@pytest.mark.parametrize(
|
|
@@ -70,3 +70,43 @@ def test_search_passes_through_requested_state(monkeypatch) -> None:
|
|
|
70
70
|
search("gh", ["auth"], None, 10, state="all")
|
|
71
71
|
|
|
72
72
|
assert captured_cmd[captured_cmd.index("--state") + 1] == "all"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_update_capturing_commands(monkeypatch, **update_kwargs) -> list[list[str]]:
|
|
76
|
+
captured_cmds: list[list[str]] = []
|
|
77
|
+
|
|
78
|
+
def fake_run(cmd, **kwargs):
|
|
79
|
+
captured_cmds.append(cmd)
|
|
80
|
+
return MagicMock(returncode=0, stdout="", stderr="")
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
83
|
+
update("gh", 42, **update_kwargs)
|
|
84
|
+
return captured_cmds
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize("state", ["Closed", "Done", "completed"])
|
|
88
|
+
def test_update_closes_as_completed_for_done_states(monkeypatch, state: str) -> None:
|
|
89
|
+
cmds = _run_update_capturing_commands(monkeypatch, state=state)
|
|
90
|
+
assert cmds == [["gh", "issue", "close", "42", "--reason", "completed"]]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("state", ["Removed", "Not Planned", "wontfix"])
|
|
94
|
+
def test_update_closes_as_not_planned_for_removed_states(monkeypatch, state: str) -> None:
|
|
95
|
+
cmds = _run_update_capturing_commands(monkeypatch, state=state)
|
|
96
|
+
assert cmds == [["gh", "issue", "close", "42", "--reason", "not planned"]]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_update_reopens_for_open_state(monkeypatch) -> None:
|
|
100
|
+
cmds = _run_update_capturing_commands(monkeypatch, state="Open")
|
|
101
|
+
assert cmds == [["gh", "issue", "reopen", "42"]]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_update_comments_exact_state_when_unsupported(monkeypatch) -> None:
|
|
105
|
+
cmds = _run_update_capturing_commands(monkeypatch, state="Active")
|
|
106
|
+
assert cmds == [["gh", "issue", "comment", "42", "--body", "Requested state change to 'Active', which isn't a valid GitHub issue state — left unchanged."]]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_update_errors_with_nothing_to_do(monkeypatch) -> None:
|
|
110
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", MagicMock())
|
|
111
|
+
with pytest.raises(SystemExit):
|
|
112
|
+
update("gh", 42)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|