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.
Files changed (57) hide show
  1. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/PKG-INFO +1 -1
  2. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_issue.py +67 -21
  3. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli.py +12 -2
  4. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_issue.py +51 -3
  5. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue.py +78 -0
  7. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_gh_issue.py +78 -0
  8. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/uv.lock +1 -1
  9. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.github/workflows/auto-release.yml +0 -0
  10. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-publish.yml +0 -0
  11. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-test.yml +0 -0
  12. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.gitignore +0 -0
  13. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/.python-version +0 -0
  14. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/README.md +0 -0
  15. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/__init__.py +0 -0
  16. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_auth.py +0 -0
  17. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/app_service_logs.py +0 -0
  18. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/bdt_config.py +0 -0
  19. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli_tools.py +0 -0
  20. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/commit.py +0 -0
  21. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/env_config.py +0 -0
  22. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/find_repo.py +0 -0
  23. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_pr.py +0 -0
  24. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gitrepo.py +0 -0
  25. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/logs.py +0 -0
  26. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_build.py +0 -0
  27. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  28. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_labels.py +0 -0
  29. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_markdown.py +0 -0
  30. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pull.py +0 -0
  31. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/testdb.py +0 -0
  32. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/worktree.py +0 -0
  33. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/justfile +0 -0
  34. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  35. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/conftest.py +0 -0
  36. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_ado_auth.py +0 -0
  37. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue_flow.py +0 -0
  38. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_app_service_logs.py +0 -0
  39. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_bdt_config.py +0 -0
  40. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_cleanup.py +0 -0
  41. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_commit.py +0 -0
  42. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_pr_create.py +0 -0
  43. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_pull.py +0 -0
  44. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_cli_tools.py +0 -0
  45. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_commit.py +0 -0
  46. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_env_config.py +0 -0
  47. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_find_repo.py +0 -0
  48. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_gh_pr.py +0 -0
  49. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_gitrepo.py +0 -0
  50. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build.py +0 -0
  51. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build_flow.py +0 -0
  52. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_issue_link.py +0 -0
  53. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_labels.py +0 -0
  54. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pr_markdown.py +0 -0
  55. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_pull.py +0 -0
  56. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_testdb.py +0 -0
  57. {bmsdna_devtools-0.23.0 → bmsdna_devtools-0.24.0}/tests/test_worktree_clean.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.23.0
3
+ Version: 0.24.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: pgdevkit[db]>=0.7.1
@@ -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"https://dev.azure.com/{quote(remote.org, safe='')}/{quote(remote.project, safe='')}"
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
- return f"{_base_url(remote)}/_workitems/edit/{work_item_id}"
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) and/or
421
- scoped to a board's Area Path subtree, most recently changed first. `keywords` may be empty,
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
- where = " AND ".join(["[System.TeamProject] = @project", *clauses])
447
- return f"SELECT [System.Id] FROM WorkItems WHERE {where} ORDER BY [System.ChangedDate] DESC"
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"{_base_url(remote)}/_apis/wit/wiql",
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 the given `ids` order — WIQL only
462
- returns ids (not field values), and this batch endpoint doesn't guarantee it echoes them back
463
- in the order they were requested, so the caller's WIQL `ORDER BY` isn't preserved otherwise.
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"{_base_url(remote)}/_apis/wit/workitems",
469
- params={"ids": ",".join(map(str, ids)), "fields": "System.Title,System.State", "api-version": "7.1"},
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
- ids = run_wiql(session, remote, build_search_wiql(keywords, since, area_path, state), top)
492
- items = get_work_items(session, remote, ids)
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
- print(f"#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
496
- print(edit_url(remote, item["id"]))
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(require_gh(), remote.owner, remote.repo, keywords or [], since, limit, state, board=resolved_board)
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, owner: str, repo: str, keywords: list[str], since: str | None, limit: int, state: str = "open", board: str | None = None
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(gh, ["issue", "list", "--search", query, "--state", state, "--limit", str(fetch_limit), "--json", "number,title,url,state"])
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.23.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
 
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.23.0"
25
+ version = "0.24.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },