bmsdna-devtools 0.23.0__tar.gz → 0.24.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.23.0 → bmsdna_devtools-0.24.0}/PKG-INFO +1 -1
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_issue.py +67 -21
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli.py +12 -2
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_issue.py +51 -3
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/pyproject.toml +1 -1
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue.py +78 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_gh_issue.py +78 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/uv.lock +1 -1
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.github/workflows/auto-release.yml +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.gitignore +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.python-version +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/README.md +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/app_service_logs.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/bdt_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/commit.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/env_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/find_repo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_pr.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_build.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_issue_link.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_labels.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_markdown.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pull.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/testdb.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/justfile +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/skills/bmsdna-devtools/SKILL.md +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/conftest.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_ado_auth.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue_flow.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_app_service_logs.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_bdt_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_cleanup.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_commit.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_pr_create.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_pull.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_tools.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_commit.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_env_config.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_find_repo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_gh_pr.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_gitrepo.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build_flow.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_issue_link.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_labels.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_markdown.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pull.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_testdb.py +0 -0
- {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_worktree_clean.py +0 -0
|
@@ -60,8 +60,12 @@ def resolve_board(board: str | None, start: Path | None = None) -> str | None:
|
|
|
60
60
|
return board or load_bdt_table("ado", start).get("board")
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def _org_url(remote: AdoRemote) -> str:
|
|
64
|
+
return f"https://dev.azure.com/{quote(remote.org, safe='')}"
|
|
65
|
+
|
|
66
|
+
|
|
63
67
|
def _base_url(remote: AdoRemote) -> str:
|
|
64
|
-
return f"
|
|
68
|
+
return f"{_org_url(remote)}/{quote(remote.project, safe='')}"
|
|
65
69
|
|
|
66
70
|
|
|
67
71
|
def get_team_area_path(session: requests.Session, remote: AdoRemote, team: str) -> str:
|
|
@@ -395,8 +399,11 @@ def html_url(work_item: dict) -> str | None:
|
|
|
395
399
|
return work_item.get("_links", {}).get("html", {}).get("href")
|
|
396
400
|
|
|
397
401
|
|
|
398
|
-
def edit_url(remote: AdoRemote, work_item_id: int) -> str:
|
|
399
|
-
|
|
402
|
+
def edit_url(remote: AdoRemote, work_item_id: int, project: str | None = None) -> str:
|
|
403
|
+
"""`project` overrides `remote.project` -- needed by an org-wide `search()`, whose results
|
|
404
|
+
can belong to a different project than the one the command was run in.
|
|
405
|
+
"""
|
|
406
|
+
return f"{_org_url(remote)}/{quote(project or remote.project, safe='')}/_workitems/edit/{work_item_id}"
|
|
400
407
|
|
|
401
408
|
|
|
402
409
|
def _escape_wiql_string(value: str) -> str:
|
|
@@ -415,11 +422,13 @@ def build_search_wiql(
|
|
|
415
422
|
since: str | None = None,
|
|
416
423
|
area_path: str | None = None,
|
|
417
424
|
state: str = "open",
|
|
425
|
+
org_wide: bool = False,
|
|
426
|
+
tags: list[str] | None = None,
|
|
418
427
|
) -> str:
|
|
419
428
|
"""WIQL for work items whose Title or Description contains every given keyword (ANDed),
|
|
420
|
-
optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date)
|
|
421
|
-
scoped to a board's Area Path subtree, most recently changed
|
|
422
|
-
to list work items without a text filter.
|
|
429
|
+
optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date), carrying
|
|
430
|
+
every given tag (ANDed), and/or scoped to a board's Area Path subtree, most recently changed
|
|
431
|
+
first. `keywords` may be empty, to list work items without a text filter.
|
|
423
432
|
|
|
424
433
|
`since` is rendered as a UTC ISO 8601 literal (`'YYYY-MM-DDT00:00:00Z'`) — the one
|
|
425
434
|
DateTime format WIQL accepts regardless of the querying account's locale/date-pattern
|
|
@@ -430,11 +439,21 @@ def build_search_wiql(
|
|
|
430
439
|
CMMI use 'Closed' for their Completed-category state, Scrum and Basic use 'Done' — so
|
|
431
440
|
`_TERMINAL_STATES` is the union across the built-in templates, not a per-project source of
|
|
432
441
|
truth (a custom process with its own state names won't be filtered correctly).
|
|
442
|
+
|
|
443
|
+
`org_wide` drops the `[System.TeamProject] = @project` clause -- `@project` is only valid
|
|
444
|
+
when the query runs against a project-scoped WIQL endpoint (see `run_wiql`'s `org_wide`),
|
|
445
|
+
and dropping it is what actually lets the query match work items outside the current project.
|
|
446
|
+
|
|
447
|
+
`tags` is a separate filter from `keywords` -- `Contains Words` against Title/Description
|
|
448
|
+
doesn't match `System.Tags` at all, so a tag search (e.g. every work item tagged
|
|
449
|
+
'ready4implementation' across the whole org) needs its own `[System.Tags] Contains` clause.
|
|
433
450
|
"""
|
|
434
451
|
clauses = [
|
|
435
452
|
f"([System.Title] Contains Words '{_escape_wiql_string(k)}' OR [System.Description] Contains Words '{_escape_wiql_string(k)}')"
|
|
436
453
|
for k in keywords
|
|
437
454
|
]
|
|
455
|
+
for tag in tags or []:
|
|
456
|
+
clauses.append(f"[System.Tags] Contains '{_escape_wiql_string(tag)}'")
|
|
438
457
|
if since:
|
|
439
458
|
clauses.append(f"[System.ChangedDate] >= '{since}T00:00:00Z'")
|
|
440
459
|
if area_path:
|
|
@@ -443,13 +462,21 @@ def build_search_wiql(
|
|
|
443
462
|
clauses.append(" AND ".join(f"[System.State] <> '{s}'" for s in _TERMINAL_STATES))
|
|
444
463
|
elif state == "closed":
|
|
445
464
|
clauses.append("(" + " OR ".join(f"[System.State] = '{s}'" for s in _TERMINAL_STATES) + ")")
|
|
446
|
-
|
|
447
|
-
|
|
465
|
+
if not org_wide:
|
|
466
|
+
clauses.insert(0, "[System.TeamProject] = @project")
|
|
467
|
+
query = "SELECT [System.Id] FROM WorkItems"
|
|
468
|
+
if clauses:
|
|
469
|
+
query += " WHERE " + " AND ".join(clauses)
|
|
470
|
+
return f"{query} ORDER BY [System.ChangedDate] DESC"
|
|
448
471
|
|
|
449
472
|
|
|
450
|
-
def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int) -> list[int]:
|
|
473
|
+
def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int, org_wide: bool = False) -> list[int]:
|
|
474
|
+
"""`org_wide` runs the query against the org-level WIQL endpoint (no `/{project}` segment),
|
|
475
|
+
which Azure DevOps resolves across every project in the org instead of just `remote.project`.
|
|
476
|
+
"""
|
|
477
|
+
base = _org_url(remote) if org_wide else _base_url(remote)
|
|
451
478
|
r = session.post(
|
|
452
|
-
f"{
|
|
479
|
+
f"{base}/_apis/wit/wiql",
|
|
453
480
|
params={"api-version": "7.1", "$top": top},
|
|
454
481
|
json={"query": wiql},
|
|
455
482
|
)
|
|
@@ -457,16 +484,23 @@ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int)
|
|
|
457
484
|
return [wi["id"] for wi in r.json()["workItems"]]
|
|
458
485
|
|
|
459
486
|
|
|
460
|
-
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
|
|
461
|
-
"""Batch-fetch Title/State for a set of work item ids, in
|
|
462
|
-
returns ids (not field values), and this batch endpoint
|
|
463
|
-
in the order they were requested, so the caller's WIQL
|
|
487
|
+
def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int], org_wide: bool = False) -> list[dict]:
|
|
488
|
+
"""Batch-fetch Title/State (plus TeamProject when `org_wide`) for a set of work item ids, in
|
|
489
|
+
the given `ids` order — WIQL only returns ids (not field values), and this batch endpoint
|
|
490
|
+
doesn't guarantee it echoes them back in the order they were requested, so the caller's WIQL
|
|
491
|
+
`ORDER BY` isn't preserved otherwise.
|
|
492
|
+
|
|
493
|
+
`org_wide` fetches via the org-level endpoint (ids from an org-wide `run_wiql` may belong to
|
|
494
|
+
a project other than `remote.project`) and also asks for `System.TeamProject`, so `search()`
|
|
495
|
+
can show which project each result came from.
|
|
464
496
|
"""
|
|
465
497
|
if not ids:
|
|
466
498
|
return []
|
|
499
|
+
base = _org_url(remote) if org_wide else _base_url(remote)
|
|
500
|
+
fields = "System.Title,System.State,System.TeamProject" if org_wide else "System.Title,System.State"
|
|
467
501
|
r = session.get(
|
|
468
|
-
f"{
|
|
469
|
-
params={"ids": ",".join(map(str, ids)), "fields":
|
|
502
|
+
f"{base}/_apis/wit/workitems",
|
|
503
|
+
params={"ids": ",".join(map(str, ids)), "fields": fields, "api-version": "7.1"},
|
|
470
504
|
)
|
|
471
505
|
r.raise_for_status()
|
|
472
506
|
by_id = {item["id"]: item for item in r.json()["value"]}
|
|
@@ -481,19 +515,31 @@ def search(
|
|
|
481
515
|
board: str | None = None,
|
|
482
516
|
top: int = 10,
|
|
483
517
|
state: str = "open",
|
|
518
|
+
org_wide: bool = False,
|
|
519
|
+
tags: list[str] | None = None,
|
|
484
520
|
) -> list[dict]:
|
|
485
521
|
"""Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
|
|
486
522
|
Title or Description) and state, most recently changed first. `board` is an Azure Boards team
|
|
487
523
|
name (like `create`'s `--board`) — resolved to its Area Path so results are scoped to that
|
|
488
|
-
team's subtree instead of the whole project.
|
|
524
|
+
team's subtree instead of the whole project. `tags` is a separate ANDed filter against
|
|
525
|
+
`System.Tags` (see `build_search_wiql`) -- a plain keyword doesn't match tags.
|
|
526
|
+
|
|
527
|
+
`org_wide` searches every project in `remote.org` instead of just `remote.project` --
|
|
528
|
+
mutually exclusive with `board`, since a team's Area Path only means something within a
|
|
529
|
+
single project.
|
|
489
530
|
"""
|
|
531
|
+
if org_wide and board:
|
|
532
|
+
sys.exit("--board can't be combined with --org-wide (a board's Area Path is scoped to a single project).")
|
|
490
533
|
area_path = get_team_area_path(session, remote, board) if board else None
|
|
491
|
-
|
|
492
|
-
|
|
534
|
+
wiql = build_search_wiql(keywords, since, area_path, state, org_wide=org_wide, tags=tags)
|
|
535
|
+
ids = run_wiql(session, remote, wiql, top, org_wide=org_wide)
|
|
536
|
+
items = get_work_items(session, remote, ids, org_wide=org_wide)
|
|
493
537
|
for item in items:
|
|
494
538
|
fields = item["fields"]
|
|
495
|
-
|
|
496
|
-
|
|
539
|
+
project = fields.get("System.TeamProject")
|
|
540
|
+
prefix = f"[{project}] " if org_wide else ""
|
|
541
|
+
print(f"{prefix}#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
|
|
542
|
+
print(edit_url(remote, item["id"], project if org_wide else None))
|
|
497
543
|
if not items:
|
|
498
544
|
print("No matching work items found.")
|
|
499
545
|
return items
|
|
@@ -537,6 +537,14 @@ def issue_search(
|
|
|
537
537
|
r"(falls back to \[tool.bdt.github].board), same as `issue create`",
|
|
538
538
|
),
|
|
539
539
|
limit: int = typer.Option(10, "--limit", help="Max results to return"),
|
|
540
|
+
org_wide: bool = typer.Option(
|
|
541
|
+
False,
|
|
542
|
+
"--org-wide",
|
|
543
|
+
help="Search every project in the Azure DevOps org, or every repo owned by the GitHub owner, "
|
|
544
|
+
"instead of just the current one. Can't be combined with --board.",
|
|
545
|
+
),
|
|
546
|
+
label: list[str] = typer.Option([], "--label", help="Only issues carrying this label, ANDed (GitHub only, repeatable)"),
|
|
547
|
+
tag: list[str] = typer.Option([], "--tag", help="Only work items carrying this tag, ANDed (Azure DevOps only, repeatable)"),
|
|
540
548
|
pat: str | None = typer.Option(
|
|
541
549
|
None,
|
|
542
550
|
"--pat",
|
|
@@ -554,12 +562,14 @@ def issue_search(
|
|
|
554
562
|
remote = current_remote()
|
|
555
563
|
if isinstance(remote, GitHubRemote):
|
|
556
564
|
resolved_board = gh_issue.resolve_board(board)
|
|
557
|
-
gh_issue.search(
|
|
565
|
+
gh_issue.search(
|
|
566
|
+
require_gh(), remote.owner, remote.repo, keywords or [], since, limit, state, board=resolved_board, org_wide=org_wide, labels=label
|
|
567
|
+
)
|
|
558
568
|
else:
|
|
559
569
|
session = requests.Session()
|
|
560
570
|
session.headers.update(auth_header(pat))
|
|
561
571
|
resolved_board = ado_issue.resolve_board(board)
|
|
562
|
-
ado_issue.search(session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state)
|
|
572
|
+
ado_issue.search(session, remote, keywords or [], since=since, board=resolved_board, top=limit, state=state, org_wide=org_wide, tags=tag)
|
|
563
573
|
|
|
564
574
|
|
|
565
575
|
@issue_app.command("update")
|
|
@@ -215,18 +215,66 @@ def build_search_query(keywords: list[str], since: str | None) -> str:
|
|
|
215
215
|
|
|
216
216
|
|
|
217
217
|
def search(
|
|
218
|
-
gh: str,
|
|
218
|
+
gh: str,
|
|
219
|
+
owner: str,
|
|
220
|
+
repo: str,
|
|
221
|
+
keywords: list[str],
|
|
222
|
+
since: str | None,
|
|
223
|
+
limit: int,
|
|
224
|
+
state: str = "open",
|
|
225
|
+
board: str | None = None,
|
|
226
|
+
org_wide: bool = False,
|
|
227
|
+
labels: list[str] | None = None,
|
|
219
228
|
) -> list[dict]:
|
|
220
229
|
"""Search (or, with no keywords, just list) issues by state, most recently updated first.
|
|
221
230
|
|
|
222
231
|
`state` is `gh issue list`'s own `open|closed|all` flag, not a search qualifier. `board`
|
|
223
232
|
scopes results to a GitHub Projects (v2) board (by title or number) -- `gh issue list` has no
|
|
224
233
|
board filter of its own, so this over-fetches a wider raw pool first and filters down to
|
|
225
|
-
`limit` afterward, client-side.
|
|
234
|
+
`limit` afterward, client-side. `labels` is ANDed (an issue must carry every given label) --
|
|
235
|
+
both `gh issue list` and `gh search issues` support a native, repeatable `--label` flag, so
|
|
236
|
+
it doesn't need folding into `query` as a `label:` qualifier.
|
|
237
|
+
|
|
238
|
+
`org_wide` searches every repo `owner` has, via `gh search issues --owner` (GitHub's
|
|
239
|
+
cross-repo search endpoint) instead of `gh issue list` (always single-repo, scoped by cwd's
|
|
240
|
+
git remote) -- mutually exclusive with `board`, whose membership is only resolved against
|
|
241
|
+
`repo`, not every repo under `owner`.
|
|
226
242
|
"""
|
|
243
|
+
if org_wide and board:
|
|
244
|
+
sys.exit("--board can't be combined with --org-wide (board membership is only resolved for the current repo).")
|
|
245
|
+
|
|
227
246
|
query = build_search_query(keywords, since)
|
|
247
|
+
label_args = [arg for label in labels or [] for arg in ("--label", label)]
|
|
248
|
+
|
|
249
|
+
if org_wide:
|
|
250
|
+
# `gh search issues --state` only accepts open/closed (unlike `gh issue list --state`
|
|
251
|
+
# below, which also takes 'all') -- so 'all' is passed by omitting --state entirely
|
|
252
|
+
# (`gh search issues`'s own default), rather than forwarding a value it would reject.
|
|
253
|
+
#
|
|
254
|
+
# `gh search issues`' `--json state` also returns lowercase 'open'/'closed' (the GitHub
|
|
255
|
+
# search API's own casing), unlike `gh issue list`'s 'OPEN'/'CLOSED' below (GraphQL enum
|
|
256
|
+
# casing) -- both are printed as-is, so the two search modes' output differs in casing.
|
|
257
|
+
state_args = [] if state == "all" else ["--state", state]
|
|
258
|
+
out = _run_gh(
|
|
259
|
+
gh,
|
|
260
|
+
[
|
|
261
|
+
"search", "issues", query, "--owner", owner, *state_args, *label_args,
|
|
262
|
+
"--limit", str(limit), "--json", "number,title,url,state,repository",
|
|
263
|
+
],
|
|
264
|
+
)
|
|
265
|
+
items = json.loads(out) if out else []
|
|
266
|
+
for item in items:
|
|
267
|
+
print(f"{item['repository']['nameWithOwner']}#{item['number']} [{item['state']}] {item['title']}")
|
|
268
|
+
print(item["url"])
|
|
269
|
+
if not items:
|
|
270
|
+
print("No matching issues found.")
|
|
271
|
+
return items
|
|
272
|
+
|
|
228
273
|
fetch_limit = limit * _BOARD_SEARCH_OVERFETCH if board else limit
|
|
229
|
-
out = _run_gh(
|
|
274
|
+
out = _run_gh(
|
|
275
|
+
gh,
|
|
276
|
+
["issue", "list", "--search", query, "--state", state, *label_args, "--limit", str(fetch_limit), "--json", "number,title,url,state"],
|
|
277
|
+
)
|
|
230
278
|
items = json.loads(out) if out else []
|
|
231
279
|
|
|
232
280
|
if board:
|
|
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
|
|
|
10
10
|
|
|
11
11
|
[project]
|
|
12
12
|
name = "bmsdna-devtools"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.24.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.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from unittest.mock import MagicMock
|
|
2
2
|
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
3
5
|
from bmsdna.devtools.ado_issue import (
|
|
4
6
|
add_pr_available_tag,
|
|
5
7
|
add_tag,
|
|
@@ -9,8 +11,11 @@ from bmsdna.devtools.ado_issue import (
|
|
|
9
11
|
build_update_ops,
|
|
10
12
|
edit_url,
|
|
11
13
|
get_work_item_tags,
|
|
14
|
+
get_work_items,
|
|
12
15
|
html_url,
|
|
13
16
|
resolve_board,
|
|
17
|
+
run_wiql,
|
|
18
|
+
search,
|
|
14
19
|
upload_attachment,
|
|
15
20
|
)
|
|
16
21
|
from bmsdna.devtools.gitrepo import AdoRemote
|
|
@@ -248,6 +253,79 @@ def test_build_search_wiql_no_area_path_by_default() -> None:
|
|
|
248
253
|
assert "AreaPath" not in build_search_wiql(["auth"])
|
|
249
254
|
|
|
250
255
|
|
|
256
|
+
def test_build_search_wiql_org_wide_drops_team_project_clause() -> None:
|
|
257
|
+
wiql = build_search_wiql(["auth"], org_wide=True)
|
|
258
|
+
assert "TeamProject" not in wiql
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_build_search_wiql_org_wide_with_no_other_clauses_has_no_where() -> None:
|
|
262
|
+
assert build_search_wiql([], state="all", org_wide=True) == "SELECT [System.Id] FROM WorkItems ORDER BY [System.ChangedDate] DESC"
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_build_search_wiql_not_org_wide_still_scopes_to_project() -> None:
|
|
266
|
+
assert "[System.TeamProject] = @project" in build_search_wiql(["auth"], org_wide=False)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_edit_url_org_wide_uses_given_project_not_remote_project() -> None:
|
|
270
|
+
assert (
|
|
271
|
+
edit_url(REMOTE, 42, project="OtherProj")
|
|
272
|
+
== "https://dev.azure.com/myorg/OtherProj/_workitems/edit/42"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_run_wiql_org_wide_hits_org_level_endpoint_without_project() -> None:
|
|
277
|
+
session = MagicMock()
|
|
278
|
+
session.post.return_value.json.return_value = {"workItems": [{"id": 1}]}
|
|
279
|
+
|
|
280
|
+
run_wiql(session, REMOTE, "SELECT ...", 10, org_wide=True)
|
|
281
|
+
|
|
282
|
+
url = session.post.call_args.args[0]
|
|
283
|
+
assert url == "https://dev.azure.com/myorg/_apis/wit/wiql"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_run_wiql_project_scoped_by_default() -> None:
|
|
287
|
+
session = MagicMock()
|
|
288
|
+
session.post.return_value.json.return_value = {"workItems": []}
|
|
289
|
+
|
|
290
|
+
run_wiql(session, REMOTE, "SELECT ...", 10)
|
|
291
|
+
|
|
292
|
+
url = session.post.call_args.args[0]
|
|
293
|
+
assert url == "https://dev.azure.com/myorg/MyProj/_apis/wit/wiql"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_get_work_items_org_wide_requests_team_project_field() -> None:
|
|
297
|
+
session = MagicMock()
|
|
298
|
+
session.get.return_value.json.return_value = {"value": [{"id": 1, "fields": {"System.TeamProject": "OtherProj"}}]}
|
|
299
|
+
|
|
300
|
+
get_work_items(session, REMOTE, [1], org_wide=True)
|
|
301
|
+
|
|
302
|
+
fields_param = session.get.call_args.kwargs["params"]["fields"]
|
|
303
|
+
assert "System.TeamProject" in fields_param
|
|
304
|
+
url = session.get.call_args.args[0]
|
|
305
|
+
assert url == "https://dev.azure.com/myorg/_apis/wit/workitems"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_search_org_wide_and_board_together_exits() -> None:
|
|
309
|
+
session = MagicMock()
|
|
310
|
+
with pytest.raises(SystemExit):
|
|
311
|
+
search(session, REMOTE, [], board="SomeTeam", org_wide=True)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_search_org_wide_prints_project_prefix(capsys) -> None:
|
|
315
|
+
session = MagicMock()
|
|
316
|
+
session.post.return_value.json.return_value = {"workItems": [{"id": 1}]}
|
|
317
|
+
session.get.return_value.json.return_value = {
|
|
318
|
+
"value": [{"id": 1, "fields": {"System.Title": "Some bug", "System.State": "Active", "System.TeamProject": "OtherProj"}}]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
items = search(session, REMOTE, [], org_wide=True)
|
|
322
|
+
|
|
323
|
+
assert len(items) == 1
|
|
324
|
+
out = capsys.readouterr().out
|
|
325
|
+
assert "[OtherProj] #1 [Active] Some bug" in out
|
|
326
|
+
assert "https://dev.azure.com/myorg/OtherProj/_workitems/edit/1" in out
|
|
327
|
+
|
|
328
|
+
|
|
251
329
|
# -- get_work_item_tags / add_tag / add_pr_available_tag --------------------
|
|
252
330
|
|
|
253
331
|
|
|
@@ -114,6 +114,84 @@ def test_search_board_overfetches_then_filters_to_limit(monkeypatch) -> None:
|
|
|
114
114
|
assert all(item["number"] % 2 == 0 for item in results)
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
def test_search_org_wide_uses_gh_search_issues_with_owner(monkeypatch) -> None:
|
|
118
|
+
import json
|
|
119
|
+
|
|
120
|
+
captured_cmd: list[str] = []
|
|
121
|
+
items = [
|
|
122
|
+
{"number": 5, "title": "org-wide bug", "url": "https://github.com/owner/other-repo/issues/5", "state": "open", "repository": {"nameWithOwner": "owner/other-repo"}}
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
def fake_run(cmd, **kwargs):
|
|
126
|
+
captured_cmd[:] = cmd
|
|
127
|
+
return MagicMock(returncode=0, stdout=json.dumps(items), stderr="")
|
|
128
|
+
|
|
129
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
130
|
+
|
|
131
|
+
results = search("gh", "owner", "repo", ["auth"], None, 10, org_wide=True)
|
|
132
|
+
|
|
133
|
+
assert captured_cmd[1:3] == ["search", "issues"]
|
|
134
|
+
assert captured_cmd[captured_cmd.index("--owner") + 1] == "owner"
|
|
135
|
+
assert "--repo" not in captured_cmd
|
|
136
|
+
assert results == items
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_search_org_wide_all_state_omits_state_flag(monkeypatch) -> None:
|
|
140
|
+
import json
|
|
141
|
+
|
|
142
|
+
captured_cmd: list[str] = []
|
|
143
|
+
|
|
144
|
+
def fake_run(cmd, **kwargs):
|
|
145
|
+
captured_cmd[:] = cmd
|
|
146
|
+
return MagicMock(returncode=0, stdout=json.dumps([]), stderr="")
|
|
147
|
+
|
|
148
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
149
|
+
|
|
150
|
+
search("gh", "owner", "repo", [], None, 10, state="all", org_wide=True)
|
|
151
|
+
|
|
152
|
+
# `gh search issues --state` (unlike `gh issue list --state`) has no 'all' value -- it must
|
|
153
|
+
# be omitted, not forwarded, or `gh` rejects it outright.
|
|
154
|
+
assert "--state" not in captured_cmd
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_search_org_wide_passes_labels_as_repeatable_flag(monkeypatch) -> None:
|
|
158
|
+
import json
|
|
159
|
+
|
|
160
|
+
captured_cmd: list[str] = []
|
|
161
|
+
|
|
162
|
+
def fake_run(cmd, **kwargs):
|
|
163
|
+
captured_cmd[:] = cmd
|
|
164
|
+
return MagicMock(returncode=0, stdout=json.dumps([]), stderr="")
|
|
165
|
+
|
|
166
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
167
|
+
|
|
168
|
+
search("gh", "owner", "repo", [], None, 10, org_wide=True, labels=["bug", "urgent"])
|
|
169
|
+
|
|
170
|
+
label_indices = [i for i, arg in enumerate(captured_cmd) if arg == "--label"]
|
|
171
|
+
assert [captured_cmd[i + 1] for i in label_indices] == ["bug", "urgent"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_search_non_org_wide_passes_labels_as_repeatable_flag(monkeypatch) -> None:
|
|
175
|
+
import json
|
|
176
|
+
|
|
177
|
+
captured_cmd: list[str] = []
|
|
178
|
+
|
|
179
|
+
def fake_run(cmd, **kwargs):
|
|
180
|
+
captured_cmd[:] = cmd
|
|
181
|
+
return MagicMock(returncode=0, stdout=json.dumps([]), stderr="")
|
|
182
|
+
|
|
183
|
+
monkeypatch.setattr("bmsdna.devtools.gh_issue.subprocess.run", fake_run)
|
|
184
|
+
|
|
185
|
+
search("gh", "owner", "repo", [], None, 10, labels=["bug"])
|
|
186
|
+
|
|
187
|
+
assert captured_cmd[captured_cmd.index("--label") + 1] == "bug"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_search_org_wide_and_board_together_exits(monkeypatch) -> None:
|
|
191
|
+
with pytest.raises(SystemExit):
|
|
192
|
+
search("gh", "owner", "repo", [], None, 10, board="Roadmap", org_wide=True)
|
|
193
|
+
|
|
194
|
+
|
|
117
195
|
def test_search_board_ignores_same_number_issue_from_a_different_repo(monkeypatch) -> None:
|
|
118
196
|
import json
|
|
119
197
|
|
|
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
|
|
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
|