bmsdna-devtools 0.22.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 (58) hide show
  1. bmsdna_devtools-0.22.0/README.md → bmsdna_devtools-0.24.0/PKG-INFO +28 -0
  2. bmsdna_devtools-0.22.0/PKG-INFO → bmsdna_devtools-0.24.0/README.md +17 -11
  3. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_issue.py +101 -21
  4. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli.py +130 -5
  5. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_issue.py +86 -3
  6. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gh_pr.py +32 -0
  7. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/gitrepo.py +6 -0
  8. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_build.py +15 -0
  9. bmsdna_devtools-0.24.0/bmsdna/devtools/pr_issue_link.py +108 -0
  10. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_labels.py +32 -0
  11. bmsdna_devtools-0.24.0/bmsdna/devtools/pull.py +286 -0
  12. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/worktree.py +21 -0
  13. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/pyproject.toml +1 -1
  14. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue.py +136 -0
  15. bmsdna_devtools-0.24.0/tests/test_cli_pr_create.py +569 -0
  16. bmsdna_devtools-0.24.0/tests/test_cli_pull.py +45 -0
  17. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_gh_issue.py +136 -0
  18. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_gh_pr.py +82 -0
  19. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_gitrepo.py +19 -0
  20. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build.py +27 -0
  21. bmsdna_devtools-0.24.0/tests/test_pr_issue_link.py +158 -0
  22. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_labels.py +52 -1
  23. bmsdna_devtools-0.24.0/tests/test_pull.py +390 -0
  24. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_worktree_clean.py +64 -0
  25. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/uv.lock +1 -1
  26. bmsdna_devtools-0.22.0/tests/test_cli_pr_create.py +0 -236
  27. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.github/workflows/auto-release.yml +0 -0
  28. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-publish.yml +0 -0
  29. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.github/workflows/python-test.yml +0 -0
  30. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.gitignore +0 -0
  31. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/.python-version +0 -0
  32. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/__init__.py +0 -0
  33. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/ado_auth.py +0 -0
  34. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/app_service_logs.py +0 -0
  35. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/bdt_config.py +0 -0
  36. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/cli_tools.py +0 -0
  37. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/commit.py +0 -0
  38. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/env_config.py +0 -0
  39. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/find_repo.py +0 -0
  40. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/logs.py +0 -0
  41. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/pr_markdown.py +0 -0
  42. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/bmsdna/devtools/testdb.py +0 -0
  43. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/justfile +0 -0
  44. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  45. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/conftest.py +0 -0
  46. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_ado_auth.py +0 -0
  47. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_ado_issue_flow.py +0 -0
  48. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_app_service_logs.py +0 -0
  49. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_bdt_config.py +0 -0
  50. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_cli_cleanup.py +0 -0
  51. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_cli_commit.py +0 -0
  52. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_cli_tools.py +0 -0
  53. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_commit.py +0 -0
  54. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_env_config.py +0 -0
  55. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_find_repo.py +0 -0
  56. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_build_flow.py +0 -0
  57. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_pr_markdown.py +0 -0
  58. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.24.0}/tests/test_testdb.py +0 -0
@@ -1,3 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.24.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pgdevkit[db]>=0.7.1
7
+ Requires-Dist: requests>=2.32.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: typer>=0.26.7
10
+ Description-Content-Type: text/markdown
11
+
1
12
  # bmsdna-devtools
2
13
 
3
14
  Shared developer tooling for BMS projects: PR build/check status, PR
@@ -160,6 +171,23 @@ With the above, `bdt pr create --label feature --label breaking` passes,
160
171
  but `bdt pr create --label feature` fails with a message naming the unmet
161
172
  group (`risk`) and its allowed choices.
162
173
 
174
+ `[tool.bdt.pr.scope_labels]` in pyproject.toml can auto-apply a label based on
175
+ the "scope" of the HEAD commit's conventional-commit subject (the
176
+ `type(scope): description` format `bdt commit` itself expects, e.g.
177
+ `feat(customers): ...`):
178
+
179
+ ```toml
180
+ [tool.bdt.pr.scope_labels]
181
+ customers = "e2e-customers"
182
+ billing = "e2e-billing"
183
+ ```
184
+
185
+ With the above, creating a PR whose HEAD commit is `feat(customers): add
186
+ widget` automatically adds the `e2e-customers` label (merged with, not
187
+ replacing, any explicit `--label`). No match, no `[tool.bdt.pr.scope_labels]`
188
+ table, or a commit subject without a `(scope)` all mean no label gets added —
189
+ opt-in, so repos that don't configure it see no change in behavior.
190
+
163
191
  If `--target` has a build policy configured (an Azure DevOps Build policy,
164
192
  or a GitHub branch protection rule requiring status checks), a successful
165
193
  create prints a reminder to run `bdt pr status` afterward to check whether
@@ -1,14 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bmsdna-devtools
3
- Version: 0.22.0
4
- Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
- Requires-Python: >=3.14
6
- Requires-Dist: pgdevkit[db]>=0.7.1
7
- Requires-Dist: requests>=2.32.0
8
- Requires-Dist: rich>=13.0.0
9
- Requires-Dist: typer>=0.26.7
10
- Description-Content-Type: text/markdown
11
-
12
1
  # bmsdna-devtools
13
2
 
14
3
  Shared developer tooling for BMS projects: PR build/check status, PR
@@ -171,6 +160,23 @@ With the above, `bdt pr create --label feature --label breaking` passes,
171
160
  but `bdt pr create --label feature` fails with a message naming the unmet
172
161
  group (`risk`) and its allowed choices.
173
162
 
163
+ `[tool.bdt.pr.scope_labels]` in pyproject.toml can auto-apply a label based on
164
+ the "scope" of the HEAD commit's conventional-commit subject (the
165
+ `type(scope): description` format `bdt commit` itself expects, e.g.
166
+ `feat(customers): ...`):
167
+
168
+ ```toml
169
+ [tool.bdt.pr.scope_labels]
170
+ customers = "e2e-customers"
171
+ billing = "e2e-billing"
172
+ ```
173
+
174
+ With the above, creating a PR whose HEAD commit is `feat(customers): add
175
+ widget` automatically adds the `e2e-customers` label (merged with, not
176
+ replacing, any explicit `--label`). No match, no `[tool.bdt.pr.scope_labels]`
177
+ table, or a commit subject without a `(scope)` all mean no label gets added —
178
+ opt-in, so repos that don't configure it see no change in behavior.
179
+
174
180
  If `--target` has a build policy configured (an Azure DevOps Build policy,
175
181
  or a GitHub branch protection rule requiring status checks), a successful
176
182
  create prints a reminder to run `bdt pr status` afterward to check whether
@@ -49,6 +49,7 @@ import requests
49
49
  from .bdt_config import load_bdt_table
50
50
  from .cli_tools import ensure_agent_session_note
51
51
  from .gitrepo import AdoRemote
52
+ from .pr_issue_link import PR_AVAILABLE_LABEL
52
53
  from .pr_markdown import build_comment_content_html
53
54
 
54
55
  COMMENTS_API_VERSION = "7.1-preview.4"
@@ -59,8 +60,12 @@ def resolve_board(board: str | None, start: Path | None = None) -> str | None:
59
60
  return board or load_bdt_table("ado", start).get("board")
60
61
 
61
62
 
63
+ def _org_url(remote: AdoRemote) -> str:
64
+ return f"https://dev.azure.com/{quote(remote.org, safe='')}"
65
+
66
+
62
67
  def _base_url(remote: AdoRemote) -> str:
63
- return f"https://dev.azure.com/{quote(remote.org, safe='')}/{quote(remote.project, safe='')}"
68
+ return f"{_org_url(remote)}/{quote(remote.project, safe='')}"
64
69
 
65
70
 
66
71
  def get_team_area_path(session: requests.Session, remote: AdoRemote, team: str) -> str:
@@ -204,6 +209,39 @@ def get_valid_states(session: requests.Session, remote: AdoRemote, work_item_typ
204
209
  return [s["name"] for s in r.json()["value"]]
205
210
 
206
211
 
212
+ def get_work_item_tags(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[str]:
213
+ r = session.get(
214
+ f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
215
+ params={"fields": "System.Tags", "api-version": "7.1"},
216
+ )
217
+ if r.status_code == 404:
218
+ sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
219
+ r.raise_for_status()
220
+ raw = r.json().get("fields", {}).get("System.Tags") or ""
221
+ return [t.strip() for t in raw.split(";") if t.strip()]
222
+
223
+
224
+ def add_tag(session: requests.Session, remote: AdoRemote, work_item_id: int, tag: str) -> None:
225
+ """Add `tag` to work item `work_item_id`'s tags, alongside whatever's already there.
226
+
227
+ `update_work_item`'s tags op replaces the whole `System.Tags` field (there's no
228
+ Azure DevOps "add one tag" patch op), so this reads the current tags first and only
229
+ PATCHes if `tag` isn't already among them -- otherwise a second `pr create --issue`
230
+ against the same work item would keep re-sending an unchanged value.
231
+ """
232
+ tags = get_work_item_tags(session, remote, work_item_id)
233
+ if any(t.casefold() == tag.casefold() for t in tags):
234
+ return
235
+ update_work_item(session, remote, work_item_id, tags=[*tags, tag])
236
+
237
+
238
+ def add_pr_available_tag(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
239
+ """Tag work item `work_item_id` `pr-available` -- the Azure DevOps equivalent of the
240
+ GitHub `pr-available` label (Azure DevOps work items have no label concept; tags are the
241
+ closest equivalent, same as `ado_issue.py`'s own `--tag` create/update flag)."""
242
+ add_tag(session, remote, work_item_id, PR_AVAILABLE_LABEL)
243
+
244
+
207
245
  def delete_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
208
246
  """Soft-delete: moves the work item to the project's Recycle Bin, where it can be restored.
209
247
 
@@ -361,8 +399,11 @@ def html_url(work_item: dict) -> str | None:
361
399
  return work_item.get("_links", {}).get("html", {}).get("href")
362
400
 
363
401
 
364
- def edit_url(remote: AdoRemote, work_item_id: int) -> str:
365
- 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}"
366
407
 
367
408
 
368
409
  def _escape_wiql_string(value: str) -> str:
@@ -381,11 +422,13 @@ def build_search_wiql(
381
422
  since: str | None = None,
382
423
  area_path: str | None = None,
383
424
  state: str = "open",
425
+ org_wide: bool = False,
426
+ tags: list[str] | None = None,
384
427
  ) -> str:
385
428
  """WIQL for work items whose Title or Description contains every given keyword (ANDed),
386
- optionally restricted to items changed on/after `since` (an ISO 'YYYY-MM-DD' date) and/or
387
- scoped to a board's Area Path subtree, most recently changed first. `keywords` may be empty,
388
- 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.
389
432
 
390
433
  `since` is rendered as a UTC ISO 8601 literal (`'YYYY-MM-DDT00:00:00Z'`) — the one
391
434
  DateTime format WIQL accepts regardless of the querying account's locale/date-pattern
@@ -396,11 +439,21 @@ def build_search_wiql(
396
439
  CMMI use 'Closed' for their Completed-category state, Scrum and Basic use 'Done' — so
397
440
  `_TERMINAL_STATES` is the union across the built-in templates, not a per-project source of
398
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.
399
450
  """
400
451
  clauses = [
401
452
  f"([System.Title] Contains Words '{_escape_wiql_string(k)}' OR [System.Description] Contains Words '{_escape_wiql_string(k)}')"
402
453
  for k in keywords
403
454
  ]
455
+ for tag in tags or []:
456
+ clauses.append(f"[System.Tags] Contains '{_escape_wiql_string(tag)}'")
404
457
  if since:
405
458
  clauses.append(f"[System.ChangedDate] >= '{since}T00:00:00Z'")
406
459
  if area_path:
@@ -409,13 +462,21 @@ def build_search_wiql(
409
462
  clauses.append(" AND ".join(f"[System.State] <> '{s}'" for s in _TERMINAL_STATES))
410
463
  elif state == "closed":
411
464
  clauses.append("(" + " OR ".join(f"[System.State] = '{s}'" for s in _TERMINAL_STATES) + ")")
412
- where = " AND ".join(["[System.TeamProject] = @project", *clauses])
413
- 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"
414
471
 
415
472
 
416
- 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)
417
478
  r = session.post(
418
- f"{_base_url(remote)}/_apis/wit/wiql",
479
+ f"{base}/_apis/wit/wiql",
419
480
  params={"api-version": "7.1", "$top": top},
420
481
  json={"query": wiql},
421
482
  )
@@ -423,16 +484,23 @@ def run_wiql(session: requests.Session, remote: AdoRemote, wiql: str, top: int)
423
484
  return [wi["id"] for wi in r.json()["workItems"]]
424
485
 
425
486
 
426
- def get_work_items(session: requests.Session, remote: AdoRemote, ids: list[int]) -> list[dict]:
427
- """Batch-fetch Title/State for a set of work item ids, in the given `ids` order — WIQL only
428
- returns ids (not field values), and this batch endpoint doesn't guarantee it echoes them back
429
- 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.
430
496
  """
431
497
  if not ids:
432
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"
433
501
  r = session.get(
434
- f"{_base_url(remote)}/_apis/wit/workitems",
435
- 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"},
436
504
  )
437
505
  r.raise_for_status()
438
506
  by_id = {item["id"]: item for item in r.json()["value"]}
@@ -447,19 +515,31 @@ def search(
447
515
  board: str | None = None,
448
516
  top: int = 10,
449
517
  state: str = "open",
518
+ org_wide: bool = False,
519
+ tags: list[str] | None = None,
450
520
  ) -> list[dict]:
451
521
  """Search (or, with no keywords, just list) work items by keywords (ANDed, matched against
452
522
  Title or Description) and state, most recently changed first. `board` is an Azure Boards team
453
523
  name (like `create`'s `--board`) — resolved to its Area Path so results are scoped to that
454
- 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.
455
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).")
456
533
  area_path = get_team_area_path(session, remote, board) if board else None
457
- ids = run_wiql(session, remote, build_search_wiql(keywords, since, area_path, state), top)
458
- 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)
459
537
  for item in items:
460
538
  fields = item["fields"]
461
- print(f"#{item['id']} [{fields['System.State']}] {fields['System.Title']}")
462
- 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))
463
543
  if not items:
464
544
  print("No matching work items found.")
465
545
  return items
@@ -17,10 +17,10 @@ from . import env_config
17
17
  from . import find_repo as find_repo_mod
18
18
  from . import gh_issue, gh_pr
19
19
  from . import logs as logs_mod
20
- from . import pr_build, pr_labels, worktree as worktree_mod
20
+ from . import pr_build, pr_issue_link, pr_labels, pull as pull_mod, worktree as worktree_mod
21
21
  from .ado_auth import auth_header
22
22
  from .cli_tools import detect_agent_session, require_az, require_gh
23
- from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote
23
+ from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote, head_commit_subject
24
24
 
25
25
  # Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
26
26
  # UTF-8 stream — the default Windows console codepage isn't UTF-8, and would
@@ -120,6 +120,53 @@ def _after_create(step: Callable[[], None], label: str) -> None:
120
120
  print(f"Warning: PR created, but {label} failed: {e}")
121
121
 
122
122
 
123
+ def _link_and_label_github(gh: str, remote: GitHubRemote, issue_numbers: list[int]) -> None:
124
+ """Link every issue in `issue_numbers`, plus every issue `find_issue_refs_in_body` finds in
125
+ the PR's actual body, to the current branch's PR, and label each `pr-available`.
126
+
127
+ Best-effort per issue -- one bad/inaccessible issue number shouldn't stop the others from
128
+ being linked/labeled; failures are collected and surfaced together to the caller (which
129
+ wraps this in `_after_create`, so they're reported as a warning, not a failed `pr create`).
130
+ A no-op (no `gh` calls at all) when there's nothing to link.
131
+ """
132
+ pr_number, body = gh_pr.get_pr_body(gh)
133
+ all_numbers = list(dict.fromkeys([*issue_numbers, *pr_issue_link.find_issue_refs_in_body(body, remote)]))
134
+ if not all_numbers:
135
+ return
136
+ gh_issue.ensure_pr_available_label(gh)
137
+ errors: list[str] = []
138
+ for number in all_numbers:
139
+ try:
140
+ body = gh_pr.link_issue_to_pr(gh, pr_number, body, number)
141
+ gh_issue.add_pr_available_label(gh, number)
142
+ except SystemExit as e:
143
+ errors.append(f"#{number}: {e}")
144
+ if errors:
145
+ raise RuntimeError("; ".join(errors))
146
+
147
+
148
+ def _link_and_label_ado(session: requests.Session, remote: AdoRemote, pr_id: int, description: str, issue_numbers: list[int]) -> None:
149
+ """Azure DevOps equivalent of `_link_and_label_github`: link every issue in `issue_numbers`,
150
+ plus every work item `find_issue_refs_in_body` finds in the PR's description, to PR `pr_id`,
151
+ and tag each `pr-available`.
152
+
153
+ Best-effort per work item -- one bad/inaccessible number shouldn't stop the others from being
154
+ linked/tagged; failures (a REST error, or a 404 `sys.exit` from deeper in `ado_issue.py`) are
155
+ collected and surfaced together to the caller (which wraps this in `_after_create`, so they're
156
+ reported as a warning, not a failed `pr create`).
157
+ """
158
+ all_numbers = list(dict.fromkeys([*issue_numbers, *pr_issue_link.find_issue_refs_in_body(description, remote)]))
159
+ errors: list[str] = []
160
+ for number in all_numbers:
161
+ try:
162
+ pr_build.link_work_item(session, remote, pr_id, number)
163
+ ado_issue.add_pr_available_tag(session, remote, number)
164
+ except (requests.RequestException, SystemExit) as e:
165
+ errors.append(f"#{number}: {e}")
166
+ if errors:
167
+ raise RuntimeError("; ".join(errors))
168
+
169
+
123
170
  @pr_app.command("create")
124
171
  def pr_create(
125
172
  target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
@@ -134,7 +181,8 @@ def pr_create(
134
181
  help="Label to apply to the PR (repeatable). On GitHub the label must already exist on the repo "
135
182
  "(`gh label create`); Azure DevOps PR labels are freeform and created on the fly. "
136
183
  r"[tool.bdt.pr.required_labels] in pyproject.toml can require at least one label from each "
137
- "named group before the PR is created.",
184
+ "named group before the PR is created. [tool.bdt.pr.scope_labels] can auto-add a label based "
185
+ "on the HEAD commit's conventional-commit scope (e.g. `feat(customers): ...` -> a configured label).",
138
186
  ),
139
187
  screenshot: list[str] = typer.Option(
140
188
  [], "--screenshot", help="Path to an image to attach to the PR description (repeatable)"
@@ -142,6 +190,14 @@ def pr_create(
142
190
  file: list[str] = typer.Option(
143
191
  [], "--file", help="Path to an arbitrary file to attach to the PR description as a linked attachment (repeatable)"
144
192
  ),
193
+ issue: list[str] = typer.Option(
194
+ [],
195
+ "--issue",
196
+ help="Issue number or URL (GitHub issue or Azure DevOps work item) to link to this PR (repeatable). "
197
+ "Each linked issue/work item is also labeled/tagged 'pr-available'. Independent of this flag, the PR "
198
+ "body is scanned for 'Fixes/Closes/Resolves #NR' and full issue/work-item URLs; every match found "
199
+ "there gets the same link + label/tag treatment automatically.",
200
+ ),
145
201
  pat: str | None = typer.Option(
146
202
  None,
147
203
  "--pat",
@@ -158,12 +214,30 @@ def pr_create(
158
214
  if not Path(path).is_file():
159
215
  raise typer.BadParameter(f"File not found: {path}", param_hint="--file")
160
216
 
217
+ scope_label_map = pr_labels.scope_labels()
218
+ if scope_label_map:
219
+ scope = commit_mod.conventional_commit_scope(head_commit_subject())
220
+ auto_label = pr_labels.label_for_scope(scope_label_map, scope)
221
+ if auto_label and auto_label.lower() not in {existing.lower() for existing in label}:
222
+ print(f"Auto-applying label '{auto_label}' for scope '{scope}' ([tool.bdt.pr.scope_labels])")
223
+ label = [*label, auto_label]
224
+
161
225
  missing_groups = pr_labels.missing_label_groups(pr_labels.required_label_groups(), label)
162
226
  if missing_groups:
163
227
  raise typer.BadParameter(pr_labels.format_missing_groups_error(missing_groups), param_hint="--label")
164
228
 
165
229
  remote = current_remote()
230
+
231
+ issue_numbers: list[int] = []
232
+ for ref in issue:
233
+ try:
234
+ issue_numbers.append(pr_issue_link.parse_issue_ref(ref, remote))
235
+ except ValueError as e:
236
+ raise typer.BadParameter(str(e), param_hint="--issue") from e
237
+ issue_numbers = list(dict.fromkeys(issue_numbers))
238
+
166
239
  source_branch = current_branch()
240
+
167
241
  pr_url: str | None = None
168
242
  if isinstance(remote, GitHubRemote):
169
243
  gh = require_gh()
@@ -171,6 +245,8 @@ def pr_create(
171
245
  build_policy = gh_pr.has_build_policy(gh, target)
172
246
  if returncode == 0 and (screenshot or file):
173
247
  _after_create(lambda: gh_pr.add_attachments(gh, remote.owner, remote.repo, source_branch, screenshot, file), "attaching screenshots/files")
248
+ if returncode == 0:
249
+ _after_create(lambda: _link_and_label_github(gh, remote, issue_numbers), "linking issue(s) / setting 'pr-available' label")
174
250
  else:
175
251
  az = require_az()
176
252
  cmd = [
@@ -219,6 +295,17 @@ def pr_create(
219
295
  pr_build.ensure_session_note(session, remote, pr)
220
296
 
221
297
  _after_create(_finish, "attaching screenshots/files and/or noting the agent session")
298
+ if returncode == 0:
299
+ # Fetches the PR fresh via `pr_build.get_pr` rather than trusting `pr_json` -- `az`
300
+ # can exit 0 with stdout that isn't clean JSON (e.g. a deprecation banner ahead of
301
+ # it), which already leaves `pr_json` as None above; gating issue-linking on it too
302
+ # would then silently skip it instead of at least warning, unlike every other
303
+ # best-effort step here.
304
+ def _link_issues() -> None:
305
+ pr = pr_build.get_pr(session, remote, source_branch, target)
306
+ _link_and_label_ado(session, remote, pr["pullRequestId"], pr.get("description") or "", issue_numbers)
307
+
308
+ _after_create(_link_issues, "linking work item(s) / setting 'pr-available' tag")
222
309
 
223
310
  if returncode == 0 and pr_url:
224
311
  print(f"\n{pr_url}")
@@ -450,6 +537,14 @@ def issue_search(
450
537
  r"(falls back to \[tool.bdt.github].board), same as `issue create`",
451
538
  ),
452
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)"),
453
548
  pat: str | None = typer.Option(
454
549
  None,
455
550
  "--pat",
@@ -467,12 +562,14 @@ def issue_search(
467
562
  remote = current_remote()
468
563
  if isinstance(remote, GitHubRemote):
469
564
  resolved_board = gh_issue.resolve_board(board)
470
- 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
+ )
471
568
  else:
472
569
  session = requests.Session()
473
570
  session.headers.update(auth_header(pat))
474
571
  resolved_board = ado_issue.resolve_board(board)
475
- 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)
476
573
 
477
574
 
478
575
  @issue_app.command("update")
@@ -639,6 +736,34 @@ def worktree(
639
736
  worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)
640
737
 
641
738
 
739
+ @app.command()
740
+ def pull(
741
+ remote: str = typer.Option(
742
+ "origin",
743
+ "--remote",
744
+ help="Remote to pull main/master and the default branch from (the current branch's own tracking "
745
+ "branch step always follows its real configured upstream, regardless of this)",
746
+ ),
747
+ no_default: bool = typer.Option(
748
+ False,
749
+ "--no-default",
750
+ help="Don't also pull the repo's DEFAULT branch -- skip this when the current branch was already "
751
+ "branched from main/master, since pulling it again would be redundant",
752
+ ),
753
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be pulled, without doing it"),
754
+ pull_args: list[str] = typer.Argument(
755
+ None,
756
+ help="Extra flags passed through to every `git pull` step, e.g. `-- --rebase --ff-only` (put them after `--`)",
757
+ ),
758
+ ) -> None:
759
+ """Bring the current branch up to date from three sources: its own remote tracking branch, the
760
+ remote's main/master branch, and the repo's DEFAULT branch (unless --no-default) -- each a
761
+ separate `git pull`, stopping with a clear error (and the exact command to re-run) if any step
762
+ hits a merge conflict.
763
+ """
764
+ pull_mod.run(remote=remote, no_default=no_default, dry_run=dry_run, pull_args=pull_args or [])
765
+
766
+
642
767
  @app.command("find-repo")
643
768
  def find_repo_cmd(
644
769
  name: str,
@@ -27,6 +27,7 @@ from pathlib import Path
27
27
  from .bdt_config import load_bdt_table
28
28
  from .cli_tools import ensure_agent_session_note
29
29
  from .gh_pr import push_assets
30
+ from .pr_issue_link import PR_AVAILABLE_LABEL
30
31
  from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
31
32
 
32
33
  _COMMENT_ID_RE = re.compile(r"#issuecomment-(\d+)")
@@ -166,6 +167,40 @@ def create(
166
167
  print(f"Attached {len(screenshot_paths)} screenshot(s), {len(file_paths)} file(s) to issue #{number}")
167
168
 
168
169
 
170
+ def _label_exists(gh: str, name: str) -> bool:
171
+ out = _run_gh(gh, ["label", "list", "--search", name, "--json", "name"])
172
+ names = {label["name"].casefold() for label in (json.loads(out) if out else [])}
173
+ return name.casefold() in names
174
+
175
+
176
+ def ensure_pr_available_label(gh: str) -> None:
177
+ """Make sure the repo-wide `pr-available` label exists, creating it if not -- unlike Azure
178
+ DevOps tags (freeform), `gh issue edit --add-label` rejects a label name GitHub doesn't
179
+ already know about (see `gh_pr.create`'s docstring). Only creates the label when it's
180
+ actually missing, so an existing custom color/description isn't clobbered.
181
+
182
+ Call this once per `pr create` invocation, before any `add_pr_available_label` calls --
183
+ it's the same repo-wide label being checked/created each time, not a per-issue thing, so a
184
+ caller linking several issues in one go should only pay for this lookup once.
185
+ """
186
+ if not _label_exists(gh, PR_AVAILABLE_LABEL):
187
+ _run_gh(
188
+ gh,
189
+ [
190
+ "label", "create", PR_AVAILABLE_LABEL,
191
+ "--color", "0E8A16",
192
+ "--description", "A PR exists that addresses this issue",
193
+ ],
194
+ )
195
+
196
+
197
+ def add_pr_available_label(gh: str, number: int) -> None:
198
+ """Label issue `number` `pr-available`. Caller must have already called
199
+ `ensure_pr_available_label` (once, not per-issue) so the label exists on the repo first.
200
+ """
201
+ _run_gh(gh, ["issue", "edit", str(number), "--add-label", PR_AVAILABLE_LABEL])
202
+
203
+
169
204
  def build_search_query(keywords: list[str], since: str | None) -> str:
170
205
  """The `gh issue list --search` query string: keywords ANDed together (GitHub search's
171
206
  implicit default), optionally scoped to issues updated on/after `since` (an ISO
@@ -180,18 +215,66 @@ def build_search_query(keywords: list[str], since: str | None) -> str:
180
215
 
181
216
 
182
217
  def search(
183
- 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,
184
228
  ) -> list[dict]:
185
229
  """Search (or, with no keywords, just list) issues by state, most recently updated first.
186
230
 
187
231
  `state` is `gh issue list`'s own `open|closed|all` flag, not a search qualifier. `board`
188
232
  scopes results to a GitHub Projects (v2) board (by title or number) -- `gh issue list` has no
189
233
  board filter of its own, so this over-fetches a wider raw pool first and filters down to
190
- `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`.
191
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
+
192
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
+
193
273
  fetch_limit = limit * _BOARD_SEARCH_OVERFETCH if board else limit
194
- 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
+ )
195
278
  items = json.loads(out) if out else []
196
279
 
197
280
  if board:
@@ -18,6 +18,7 @@ import tempfile
18
18
  import time
19
19
  from pathlib import Path
20
20
 
21
+ from . import pr_issue_link
21
22
  from .cli_tools import CLI_TIMEOUT_SECS, EXIT_NEEDS_APPROVAL, PollHeartbeat, detect_agent_session, ensure_agent_session_note, is_claude_code
22
23
  from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section
23
24
 
@@ -490,6 +491,37 @@ def ensure_session_note(gh: str) -> None:
490
491
  pass
491
492
 
492
493
 
494
+ def get_pr_body(gh: str) -> tuple[int, str]:
495
+ """(PR number, PR body) for the current branch's PR -- used by issue-linking, which needs
496
+ the body actually stored (e.g. autofilled from the commit message via `--fill`), not
497
+ whatever a caller happened to pass to `pr create`.
498
+ """
499
+ pr = _run_gh_json(gh, ["pr", "view", "--json", "number,body"])
500
+ return pr["number"], pr.get("body") or ""
501
+
502
+
503
+ def link_issue_to_pr(gh: str, pr_number: int, body: str, issue_number: int) -> str:
504
+ """Make sure `body` (the PR's current body) references `issue_number` via a GitHub closing
505
+ keyword, so GitHub shows this as a genuinely linked PR (appearing in the issue's
506
+ "Development" sidebar and auto-closing it on merge) -- a plain `#N` mention elsewhere (only
507
+ in a comment, or just prose referencing it, say) doesn't get that treatment, so it isn't
508
+ mistaken for one already being present (see `pr_issue_link.github_body_already_closes`). A
509
+ no-op if `body` already has a real closing-keyword reference to the issue (e.g. because this
510
+ number came from scanning the body for one in the first place).
511
+
512
+ Returns the (possibly unchanged) new body -- callers linking more than one issue in a row
513
+ pass this back in as `body` for the next call, so each edit builds on the last instead of
514
+ re-fetching or clobbering a previous append.
515
+ """
516
+ if pr_issue_link.github_body_already_closes(body, issue_number):
517
+ return body
518
+ new_body = f"{body.rstrip()}\n\nFixes #{issue_number}\n".lstrip("\n")
519
+ r = _run([gh, "pr", "edit", str(pr_number), "--body", new_body], capture_output=True, encoding="utf-8")
520
+ if r.returncode != 0:
521
+ sys.exit((r.stderr or r.stdout).strip() or "`gh pr edit` failed")
522
+ return new_body
523
+
524
+
493
525
  def publish(gh: str) -> None:
494
526
  """Mark the current branch's draft PR as ready for review."""
495
527
  r = _run([gh, "pr", "ready"], capture_output=True, encoding="utf-8")