bmsdna-devtools 0.7.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.7.0 → bmsdna_devtools-0.9.0}/PKG-INFO +1 -1
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/ado_issue.py +161 -2
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/cli.py +50 -2
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/gh_issue.py +70 -3
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_ado_issue.py +73 -1
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_ado_issue_flow.py +150 -1
- bmsdna_devtools-0.9.0/tests/test_gh_issue.py +112 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/uv.lock +1 -1
- bmsdna_devtools-0.7.0/tests/test_gh_issue.py +0 -24
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/.gitignore +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/.python-version +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/README.md +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/justfile +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.7.0 → bmsdna_devtools-0.9.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.7.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
|
|
|
@@ -264,6 +287,110 @@ def html_url(work_item: dict) -> str | None:
|
|
|
264
287
|
return work_item.get("_links", {}).get("html", {}).get("href")
|
|
265
288
|
|
|
266
289
|
|
|
290
|
+
def edit_url(remote: AdoRemote, work_item_id: int) -> str:
|
|
291
|
+
return f"{_base_url(remote)}/_workitems/edit/{work_item_id}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _escape_wiql_string(value: str) -> str:
|
|
295
|
+
return value.replace("'", "''")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# The Completed/Removed-category state names across Azure DevOps's built-in process templates:
|
|
299
|
+
# Agile and CMMI use 'Closed' for Completed, Scrum and Basic use 'Done'; all four use 'Removed'.
|
|
300
|
+
# There's no single field/value that means "terminal" independent of process template, so this
|
|
301
|
+
# union is the closest thing to a default that works out of the box on any of them.
|
|
302
|
+
_TERMINAL_STATES = ("Closed", "Done", "Removed")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def build_search_wiql(
|
|
306
|
+
keywords: list[str],
|
|
307
|
+
since: str | None = None,
|
|
308
|
+
area_path: str | None = None,
|
|
309
|
+
state: str = "open",
|
|
310
|
+
) -> str:
|
|
311
|
+
"""WIQL for work items whose Title or Description contains every given keyword (ANDed),
|
|
312
|
+
optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date) and/or
|
|
313
|
+
scoped to a board's Area Path subtree, most recently changed first. `keywords` may be empty,
|
|
314
|
+
to list work items without a text filter.
|
|
315
|
+
|
|
316
|
+
`since` is rendered as a UTC ISO 8601 literal (`'YYYY-MM-DDT00:00:00Z'`) — the one
|
|
317
|
+
DateTime format WIQL accepts regardless of the querying account's locale/date-pattern
|
|
318
|
+
preference (a bare `'YYYY-MM-DD'` is parsed using that locale's date pattern instead).
|
|
319
|
+
|
|
320
|
+
`state` is `"open"` (default, excludes `_TERMINAL_STATES`), `"closed"` (only those), or
|
|
321
|
+
`"all"` (no state filter). Azure DevOps state names are process-template-specific — Agile and
|
|
322
|
+
CMMI use 'Closed' for their Completed-category state, Scrum and Basic use 'Done' — so
|
|
323
|
+
`_TERMINAL_STATES` is the union across the built-in templates, not a per-project source of
|
|
324
|
+
truth (a custom process with its own state names won't be filtered correctly).
|
|
325
|
+
"""
|
|
326
|
+
clauses = [
|
|
327
|
+
f"([System.Title] Contains Words '{_escape_wiql_string(k)}' OR [System.Description] Contains Words '{_escape_wiql_string(k)}')"
|
|
328
|
+
for k in keywords
|
|
329
|
+
]
|
|
330
|
+
if since:
|
|
331
|
+
clauses.append(f"[System.ChangedDate] >= '{since}T00:00:00Z'")
|
|
332
|
+
if area_path:
|
|
333
|
+
clauses.append(f"[System.AreaPath] UNDER '{_escape_wiql_string(area_path)}'")
|
|
334
|
+
if state == "open":
|
|
335
|
+
clauses.append(" AND ".join(f"[System.State] <> '{s}'" for s in _TERMINAL_STATES))
|
|
336
|
+
elif state == "closed":
|
|
337
|
+
clauses.append("(" + " OR ".join(f"[System.State] = '{s}'" for s in _TERMINAL_STATES) + ")")
|
|
338
|
+
where = " AND ".join(["[System.TeamProject] = @project", *clauses])
|
|
339
|
+
return f"SELECT [System.Id] FROM WorkItems WHERE {where} ORDER BY [System.ChangedDate] DESC"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int) -> list[int]:
|
|
343
|
+
r = session.post(
|
|
344
|
+
f"{_base_url(remote)}/_apis/wit/wiql",
|
|
345
|
+
params={"api-version": "7.1", "$top": top},
|
|
346
|
+
json={"query": wiql},
|
|
347
|
+
)
|
|
348
|
+
r.raise_for_status()
|
|
349
|
+
return [wi["id"] for wi in r.json()["workItems"]]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
|
|
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
|
+
"""
|
|
357
|
+
if not ids:
|
|
358
|
+
return []
|
|
359
|
+
r = session.get(
|
|
360
|
+
f"{_base_url(remote)}/_apis/wit/workitems",
|
|
361
|
+
params={"ids": ",".join(map(str, ids)), "fields": "System.Title,System.State", "api-version": "7.1"},
|
|
362
|
+
)
|
|
363
|
+
r.raise_for_status()
|
|
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]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def search(
|
|
369
|
+
session: requests.Session,
|
|
370
|
+
remote: AdoRemote,
|
|
371
|
+
keywords: list[str],
|
|
372
|
+
since: str | None = None,
|
|
373
|
+
board: str | None = None,
|
|
374
|
+
top: int = 10,
|
|
375
|
+
state: str = "open",
|
|
376
|
+
) -> list[dict]:
|
|
377
|
+
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
378
|
+
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
379
|
+
name (like `create`'s `--board`) — resolved to its Area Path so results are scoped to that
|
|
380
|
+
team's subtree instead of the whole project.
|
|
381
|
+
"""
|
|
382
|
+
area_path = get_team_area_path(session, remote, board) if board else None
|
|
383
|
+
ids = run_wiql(session, remote, build_search_wiql(keywords, since, area_path, state), top)
|
|
384
|
+
items = get_work_items(session, remote, ids)
|
|
385
|
+
for item in items:
|
|
386
|
+
fields = item["fields"]
|
|
387
|
+
print(f"#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
|
|
388
|
+
print(edit_url(remote, item["id"]))
|
|
389
|
+
if not items:
|
|
390
|
+
print("No matching work items found.")
|
|
391
|
+
return items
|
|
392
|
+
|
|
393
|
+
|
|
267
394
|
def create(
|
|
268
395
|
session: requests.Session,
|
|
269
396
|
remote: AdoRemote,
|
|
@@ -298,13 +425,45 @@ def update(
|
|
|
298
425
|
board: str | None = None,
|
|
299
426
|
tags: list[str] | None = None,
|
|
300
427
|
state: str | None = None,
|
|
301
|
-
) -> dict:
|
|
428
|
+
) -> dict | None:
|
|
302
429
|
"""Update a work item's fields. Unlike `create`, `board` is only resolved (and the Area Path only
|
|
303
430
|
touched) when explicitly given — it never falls back to `[tool.bdt.ado].board`, so an unrelated
|
|
304
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.
|
|
305
437
|
"""
|
|
306
438
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
307
|
-
|
|
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)
|
|
308
467
|
print(f"Updated work item #{work_item_id}")
|
|
309
468
|
return work_item
|
|
310
469
|
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import sys
|
|
6
6
|
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
7
8
|
from importlib.metadata import version as _pkg_version
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -325,6 +326,46 @@ def issue_create(
|
|
|
325
326
|
ado_issue.create(session, remote, type_, title, description, resolved_board, tag, screenshot)
|
|
326
327
|
|
|
327
328
|
|
|
329
|
+
@issue_app.command("search")
|
|
330
|
+
def issue_search(
|
|
331
|
+
keywords: list[str] = typer.Argument(
|
|
332
|
+
None, help="Keywords to search for (ANDed together); omit to just list issues/work items"
|
|
333
|
+
),
|
|
334
|
+
since_days: int = typer.Option(
|
|
335
|
+
30, "--since-days", help="Only include issues/work items updated within this many days (0 = no date filter)"
|
|
336
|
+
),
|
|
337
|
+
state: str = typer.Option("open", "--state", help="Filter by state: 'open', 'closed', or 'all'"),
|
|
338
|
+
board: str | None = typer.Option(
|
|
339
|
+
None,
|
|
340
|
+
"--board",
|
|
341
|
+
help="Scope the search to this Azure Boards team's Area Path subtree (Azure DevOps only; "
|
|
342
|
+
r"falls back to \[tool.bdt.ado].board in pyproject.toml, same as `issue create`)",
|
|
343
|
+
),
|
|
344
|
+
limit: int = typer.Option(10, "--limit", help="Max results to return"),
|
|
345
|
+
pat: str | None = typer.Option(
|
|
346
|
+
None,
|
|
347
|
+
"--pat",
|
|
348
|
+
envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"],
|
|
349
|
+
help="Azure DevOps PAT (else falls back to `az` login)",
|
|
350
|
+
),
|
|
351
|
+
) -> None:
|
|
352
|
+
"""Search (or, with no keywords, just list) issues / work items, defaulting to open issues
|
|
353
|
+
from the last 30 days (Azure DevOps or GitHub, auto-detected).
|
|
354
|
+
"""
|
|
355
|
+
if state not in ("open", "closed", "all"):
|
|
356
|
+
raise typer.BadParameter("Must be one of: open, closed, all", param_hint="--state")
|
|
357
|
+
since = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d") if since_days > 0 else None
|
|
358
|
+
|
|
359
|
+
remote = current_remote()
|
|
360
|
+
if isinstance(remote, GitHubRemote):
|
|
361
|
+
gh_issue.search(require_gh(), keywords or [], since, limit, state)
|
|
362
|
+
else:
|
|
363
|
+
session = requests.Session()
|
|
364
|
+
session.headers.update(auth_header(pat))
|
|
365
|
+
resolved_board = ado_issue.resolve_board(board)
|
|
366
|
+
ado_issue.search(session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state)
|
|
367
|
+
|
|
368
|
+
|
|
328
369
|
@issue_app.command("update")
|
|
329
370
|
def issue_update(
|
|
330
371
|
number: int = typer.Argument(..., help="Issue number (GitHub) or work item ID (Azure DevOps)"),
|
|
@@ -337,7 +378,14 @@ def issue_update(
|
|
|
337
378
|
r"this is only applied when explicitly given here — it does not fall back to \[tool.bdt.ado].board, "
|
|
338
379
|
"so an unrelated field update can't silently move the item onto a different board.",
|
|
339
380
|
),
|
|
340
|
-
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
|
+
),
|
|
341
389
|
tag: list[str] | None = typer.Option(None, "--tag", help="Replaces all tags (Azure DevOps only, repeatable); omit to leave unchanged"),
|
|
342
390
|
label: list[str] | None = typer.Option(None, "--label", help="Label to add (GitHub only, repeatable)"),
|
|
343
391
|
remove_label: list[str] | None = typer.Option(None, "--remove-label", help="Label to remove (GitHub only, repeatable)"),
|
|
@@ -351,7 +399,7 @@ def issue_update(
|
|
|
351
399
|
"""Update an issue / work item's fields (Azure DevOps or GitHub, auto-detected)."""
|
|
352
400
|
remote = current_remote()
|
|
353
401
|
if isinstance(remote, GitHubRemote):
|
|
354
|
-
gh_issue.update(require_gh(), number, title, description, label, remove_label)
|
|
402
|
+
gh_issue.update(require_gh(), number, title, description, label, remove_label, state)
|
|
355
403
|
else:
|
|
356
404
|
session = requests.Session()
|
|
357
405
|
session.headers.update(auth_header(pat))
|
|
@@ -18,6 +18,7 @@ hosts).
|
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
|
+
import json
|
|
21
22
|
import re
|
|
22
23
|
import subprocess
|
|
23
24
|
import sys
|
|
@@ -81,6 +82,68 @@ def create(
|
|
|
81
82
|
print(f"Attached {len(screenshot_paths)} screenshot(s) to issue #{number}")
|
|
82
83
|
|
|
83
84
|
|
|
85
|
+
def build_search_query(keywords: list[str], since: str | None) -> str:
|
|
86
|
+
"""The `gh issue list --search` query string: keywords ANDed together (GitHub search's
|
|
87
|
+
implicit default), optionally scoped to issues updated on/after `since` (an ISO
|
|
88
|
+
'YYYY-MM-DD' date) via the `updated:` qualifier. Always sorted `updated:desc` — GitHub's
|
|
89
|
+
default search order is text-relevance, not recency, which `search()`'s ordering relies on.
|
|
90
|
+
`keywords` may be empty, to list issues without a text filter.
|
|
91
|
+
"""
|
|
92
|
+
parts = [*keywords, "sort:updated-desc"]
|
|
93
|
+
if since:
|
|
94
|
+
parts.append(f"updated:>={since}")
|
|
95
|
+
return " ".join(parts)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def search(gh: str, keywords: list[str], since: str | None, limit: int, state: str = "open") -> list[dict]:
|
|
99
|
+
"""Search (or, with no keywords, just list) issues by state, most recently updated first.
|
|
100
|
+
|
|
101
|
+
`state` is `gh issue list`'s own `open|closed|all` flag, not a search qualifier.
|
|
102
|
+
"""
|
|
103
|
+
query = build_search_query(keywords, since)
|
|
104
|
+
out = _run_gh(gh, ["issue", "list", "--search", query, "--state", state, "--limit", str(limit), "--json", "number,title,url,state"])
|
|
105
|
+
items = json.loads(out) if out else []
|
|
106
|
+
for item in items:
|
|
107
|
+
print(f"#{item['number']} [{item['state']}] {item['title']}")
|
|
108
|
+
print(item["url"])
|
|
109
|
+
if not items:
|
|
110
|
+
print("No matching issues found.")
|
|
111
|
+
return items
|
|
112
|
+
|
|
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
|
+
|
|
84
147
|
def update(
|
|
85
148
|
gh: str,
|
|
86
149
|
number: int,
|
|
@@ -88,6 +151,7 @@ def update(
|
|
|
88
151
|
body: str | None = None,
|
|
89
152
|
add_labels: list[str] | None = None,
|
|
90
153
|
remove_labels: list[str] | None = None,
|
|
154
|
+
state: str | None = None,
|
|
91
155
|
) -> None:
|
|
92
156
|
args = ["issue", "edit", str(number)]
|
|
93
157
|
if title is not None:
|
|
@@ -98,10 +162,13 @@ def update(
|
|
|
98
162
|
args += ["--add-label", label]
|
|
99
163
|
for label in remove_labels or []:
|
|
100
164
|
args += ["--remove-label", label]
|
|
101
|
-
if len(args) == 3:
|
|
102
|
-
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.")
|
|
103
167
|
|
|
104
|
-
|
|
168
|
+
if len(args) > 3:
|
|
169
|
+
_run_gh(gh, args)
|
|
170
|
+
if state is not None:
|
|
171
|
+
_set_state(gh, number, state)
|
|
105
172
|
print(f"Updated issue #{number}")
|
|
106
173
|
|
|
107
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"
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
from bmsdna.devtools.ado_issue import
|
|
1
|
+
from bmsdna.devtools.ado_issue import (
|
|
2
|
+
build_attach_ops,
|
|
3
|
+
build_create_ops,
|
|
4
|
+
build_search_wiql,
|
|
5
|
+
build_update_ops,
|
|
6
|
+
edit_url,
|
|
7
|
+
html_url,
|
|
8
|
+
resolve_board,
|
|
9
|
+
)
|
|
10
|
+
from bmsdna.devtools.gitrepo import AdoRemote
|
|
2
11
|
|
|
3
12
|
|
|
4
13
|
def write_pyproject(tmp_path, body: str):
|
|
@@ -91,3 +100,66 @@ def test_html_url_present() -> None:
|
|
|
91
100
|
|
|
92
101
|
def test_html_url_missing() -> None:
|
|
93
102
|
assert html_url({}) is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_edit_url() -> None:
|
|
106
|
+
assert edit_url(AdoRemote(org="myorg", project="MyProj", repo="myrepo"), 42) == "https://dev.azure.com/myorg/MyProj/_workitems/edit/42"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_build_search_wiql_keywords_only() -> None:
|
|
110
|
+
wiql = build_search_wiql(["auth"], state="all")
|
|
111
|
+
assert "[System.TeamProject] = @project" in wiql
|
|
112
|
+
assert "[System.Title] Contains Words 'auth'" in wiql
|
|
113
|
+
assert "[System.Description] Contains Words 'auth'" in wiql
|
|
114
|
+
assert "[System.ChangedDate] >=" not in wiql
|
|
115
|
+
assert wiql.endswith("ORDER BY [System.ChangedDate] DESC")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_build_search_wiql_no_keywords_lists_without_text_filter() -> None:
|
|
119
|
+
wiql = build_search_wiql([], state="all")
|
|
120
|
+
assert wiql == "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project ORDER BY [System.ChangedDate] DESC"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_build_search_wiql_defaults_to_open_state() -> None:
|
|
124
|
+
# Covers all four built-in process templates' terminal state names (Agile/CMMI: 'Closed',
|
|
125
|
+
# Scrum/Basic: 'Done'; all: 'Removed') since search() doesn't know which one a project uses.
|
|
126
|
+
wiql = build_search_wiql(["auth"])
|
|
127
|
+
assert "[System.State] <> 'Closed'" in wiql
|
|
128
|
+
assert "[System.State] <> 'Done'" in wiql
|
|
129
|
+
assert "[System.State] <> 'Removed'" in wiql
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_build_search_wiql_closed_state() -> None:
|
|
133
|
+
wiql = build_search_wiql(["auth"], state="closed")
|
|
134
|
+
assert "[System.State] = 'Closed'" in wiql
|
|
135
|
+
assert "[System.State] = 'Done'" in wiql
|
|
136
|
+
assert "[System.State] = 'Removed'" in wiql
|
|
137
|
+
assert "<>" not in wiql
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_build_search_wiql_all_state_has_no_state_clause() -> None:
|
|
141
|
+
assert "System.State" not in build_search_wiql(["auth"], state="all")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_build_search_wiql_multiple_keywords_are_anded() -> None:
|
|
145
|
+
wiql = build_search_wiql(["auth", "timeout"], state="all")
|
|
146
|
+
assert wiql.count(" AND ") == 2 # TeamProject AND keyword1 AND keyword2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_build_search_wiql_since_adds_changed_date_clause() -> None:
|
|
150
|
+
wiql = build_search_wiql(["auth"], since="2026-08-10")
|
|
151
|
+
assert "[System.ChangedDate] >= '2026-08-10T00:00:00Z'" in wiql
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_build_search_wiql_escapes_single_quotes() -> None:
|
|
155
|
+
wiql = build_search_wiql(["it's broken"])
|
|
156
|
+
assert "it''s broken" in wiql
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_build_search_wiql_area_path_scopes_to_board() -> None:
|
|
160
|
+
wiql = build_search_wiql(["auth"], area_path="MyProj\\Data Team")
|
|
161
|
+
assert "[System.AreaPath] UNDER 'MyProj\\Data Team'" in wiql
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_build_search_wiql_no_area_path_by_default() -> None:
|
|
165
|
+
assert "AreaPath" not in build_search_wiql(["auth"])
|
|
@@ -8,11 +8,14 @@ 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,
|
|
14
16
|
delete,
|
|
15
17
|
delete_comment,
|
|
18
|
+
search,
|
|
16
19
|
update,
|
|
17
20
|
update_comment,
|
|
18
21
|
)
|
|
@@ -34,7 +37,7 @@ class FakeResponse:
|
|
|
34
37
|
raise RuntimeError(f"HTTP {self.status_code}")
|
|
35
38
|
|
|
36
39
|
|
|
37
|
-
def make_session(get_map: dict[str, dict] | None = None) -> MagicMock:
|
|
40
|
+
def make_session(get_map: dict[str, dict] | None = None, wiql_ids: list[int] | None = None) -> MagicMock:
|
|
38
41
|
session = MagicMock()
|
|
39
42
|
get_map = get_map or {}
|
|
40
43
|
|
|
@@ -45,6 +48,8 @@ def make_session(get_map: dict[str, dict] | None = None) -> MagicMock:
|
|
|
45
48
|
raise AssertionError(f"unexpected GET {url}")
|
|
46
49
|
|
|
47
50
|
def fake_post(url, params: dict | None = None, **kwargs):
|
|
51
|
+
if "/_apis/wit/wiql" in url:
|
|
52
|
+
return FakeResponse({"workItems": [{"id": i} for i in (wiql_ids or [])]})
|
|
48
53
|
if "/_apis/wit/attachments" in url:
|
|
49
54
|
assert params is not None
|
|
50
55
|
name = params["fileName"]
|
|
@@ -187,3 +192,147 @@ def test_delete_comment_hits_comment_id_endpoint() -> None:
|
|
|
187
192
|
|
|
188
193
|
url = session.delete.call_args.args[0]
|
|
189
194
|
assert url == "https://dev.azure.com/myorg/MyProj/_apis/wit/workItems/42/comments/7"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_search_runs_wiql_then_batch_fetches_matched_fields() -> None:
|
|
198
|
+
session = make_session(
|
|
199
|
+
get_map={"/_apis/wit/workitems": {"value": [{"id": 42, "fields": {"System.Title": "Auth timeout bug", "System.State": "Active"}}]}},
|
|
200
|
+
wiql_ids=[42],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
results = search(session, REMOTE, ["auth", "timeout"])
|
|
204
|
+
|
|
205
|
+
wiql_url, wiql_kwargs = session.post.call_args_list[0].args[0], session.post.call_args_list[0].kwargs
|
|
206
|
+
assert wiql_url == "https://dev.azure.com/myorg/MyProj/_apis/wit/wiql"
|
|
207
|
+
assert "auth" in wiql_kwargs["json"]["query"]
|
|
208
|
+
assert "timeout" in wiql_kwargs["json"]["query"]
|
|
209
|
+
|
|
210
|
+
get_url, get_kwargs = session.get.call_args.args[0], session.get.call_args.kwargs
|
|
211
|
+
assert get_url == "https://dev.azure.com/myorg/MyProj/_apis/wit/workitems"
|
|
212
|
+
assert get_kwargs["params"]["ids"] == "42"
|
|
213
|
+
|
|
214
|
+
assert results == [{"id": 42, "fields": {"System.Title": "Auth timeout bug", "System.State": "Active"}}]
|
|
215
|
+
|
|
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
|
+
|
|
238
|
+
def test_search_skips_batch_fetch_when_no_matches() -> None:
|
|
239
|
+
session = make_session(wiql_ids=[])
|
|
240
|
+
|
|
241
|
+
results = search(session, REMOTE, ["nonexistent-keyword"])
|
|
242
|
+
|
|
243
|
+
session.get.assert_not_called()
|
|
244
|
+
assert results == []
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_search_with_board_resolves_area_path_and_scopes_wiql() -> None:
|
|
248
|
+
session = make_session(
|
|
249
|
+
get_map={
|
|
250
|
+
"teamsettings/teamfieldvalues": {"defaultValue": "MyProj\\Data Team"},
|
|
251
|
+
"/_apis/wit/workitems": {"value": []},
|
|
252
|
+
},
|
|
253
|
+
wiql_ids=[],
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
search(session, REMOTE, ["auth"], board="Data Team")
|
|
257
|
+
|
|
258
|
+
# Team field values lookup must happen before the WIQL query is built/run.
|
|
259
|
+
get_url = session.get.call_args_list[0].args[0]
|
|
260
|
+
assert "myorg/MyProj/Data%20Team/_apis/work/teamsettings/teamfieldvalues" in get_url
|
|
261
|
+
|
|
262
|
+
wiql_kwargs = session.post.call_args.kwargs
|
|
263
|
+
assert "[System.AreaPath] UNDER 'MyProj\\Data Team'" in wiql_kwargs["json"]["query"]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_search_without_board_does_not_look_up_area_path() -> None:
|
|
267
|
+
session = make_session(wiql_ids=[])
|
|
268
|
+
|
|
269
|
+
search(session, REMOTE, ["auth"])
|
|
270
|
+
|
|
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)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from unittest.mock import MagicMock
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from bmsdna.devtools.gh_issue import build_search_query, parse_comment_id, parse_issue_number, search, update
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parametrize(
|
|
9
|
+
"url,expected",
|
|
10
|
+
[
|
|
11
|
+
("https://github.com/owner/repo/issues/42", "42"),
|
|
12
|
+
("https://github.com/owner/repo/issues/42/", "42"),
|
|
13
|
+
("http://github.com/owner/repo/issues/1", "1"),
|
|
14
|
+
],
|
|
15
|
+
)
|
|
16
|
+
def test_parse_issue_number(url: str, expected: str) -> None:
|
|
17
|
+
assert parse_issue_number(url) == expected
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_parse_comment_id_present() -> None:
|
|
21
|
+
url = "https://github.com/owner/repo/issues/42#issuecomment-123456789"
|
|
22
|
+
assert parse_comment_id(url) == "123456789"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_parse_comment_id_absent() -> None:
|
|
26
|
+
assert parse_comment_id("https://github.com/owner/repo/issues/42") is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_build_search_query_keywords_only() -> None:
|
|
30
|
+
assert build_search_query(["auth", "timeout"], None) == "auth timeout sort:updated-desc"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_build_search_query_adds_updated_qualifier() -> None:
|
|
34
|
+
assert build_search_query(["auth"], "2026-08-10") == "auth sort:updated-desc updated:>=2026-08-10"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_build_search_query_no_keywords_still_scopes_by_date() -> None:
|
|
38
|
+
assert build_search_query([], "2026-08-10") == "sort:updated-desc updated:>=2026-08-10"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_build_search_query_always_sorts_by_updated_desc() -> None:
|
|
42
|
+
# search()'s "most recently updated first" ordering depends on this — GitHub's
|
|
43
|
+
# default --search order is text relevance, not recency.
|
|
44
|
+
assert "sort:updated-desc" in build_search_query(["auth"], None)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_search_defaults_to_open_state(monkeypatch) -> None:
|
|
48
|
+
captured_cmd: list[str] = []
|
|
49
|
+
|
|
50
|
+
def fake_run(cmd, **kwargs):
|
|
51
|
+
captured_cmd[:] = cmd
|
|
52
|
+
return MagicMock(returncode=0, stdout="[]", stderr="")
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
55
|
+
|
|
56
|
+
search("gh", [], None, 10)
|
|
57
|
+
|
|
58
|
+
assert captured_cmd[captured_cmd.index("--state") + 1] == "open"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_search_passes_through_requested_state(monkeypatch) -> None:
|
|
62
|
+
captured_cmd: list[str] = []
|
|
63
|
+
|
|
64
|
+
def fake_run(cmd, **kwargs):
|
|
65
|
+
captured_cmd[:] = cmd
|
|
66
|
+
return MagicMock(returncode=0, stdout="[]", stderr="")
|
|
67
|
+
|
|
68
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
69
|
+
|
|
70
|
+
search("gh", ["auth"], None, 10, state="all")
|
|
71
|
+
|
|
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)
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from bmsdna.devtools.gh_issue import parse_comment_id, parse_issue_number
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
@pytest.mark.parametrize(
|
|
7
|
-
"url,expected",
|
|
8
|
-
[
|
|
9
|
-
("https://github.com/owner/repo/issues/42", "42"),
|
|
10
|
-
("https://github.com/owner/repo/issues/42/", "42"),
|
|
11
|
-
("http://github.com/owner/repo/issues/1", "1"),
|
|
12
|
-
],
|
|
13
|
-
)
|
|
14
|
-
def test_parse_issue_number(url: str, expected: str) -> None:
|
|
15
|
-
assert parse_issue_number(url) == expected
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_parse_comment_id_present() -> None:
|
|
19
|
-
url = "https://github.com/owner/repo/issues/42#issuecomment-123456789"
|
|
20
|
-
assert parse_comment_id(url) == "123456789"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def test_parse_comment_id_absent() -> None:
|
|
24
|
-
assert parse_comment_id("https://github.com/owner/repo/issues/42") is None
|
|
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
|