bmsdna-devtools 0.22.0__tar.gz → 0.23.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.23.0/PKG-INFO +28 -0
  2. bmsdna_devtools-0.22.0/PKG-INFO → bmsdna_devtools-0.23.0/README.md +17 -11
  3. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/ado_issue.py +34 -0
  4. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/cli.py +118 -3
  5. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/gh_issue.py +35 -0
  6. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/gh_pr.py +32 -0
  7. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/gitrepo.py +6 -0
  8. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/pr_build.py +15 -0
  9. bmsdna_devtools-0.23.0/bmsdna/devtools/pr_issue_link.py +108 -0
  10. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/pr_labels.py +32 -0
  11. bmsdna_devtools-0.23.0/bmsdna/devtools/pull.py +286 -0
  12. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/worktree.py +21 -0
  13. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/pyproject.toml +1 -1
  14. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_ado_issue.py +58 -0
  15. bmsdna_devtools-0.23.0/tests/test_cli_pr_create.py +569 -0
  16. bmsdna_devtools-0.23.0/tests/test_cli_pull.py +45 -0
  17. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_gh_issue.py +58 -0
  18. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_gh_pr.py +82 -0
  19. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_gitrepo.py +19 -0
  20. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_build.py +27 -0
  21. bmsdna_devtools-0.23.0/tests/test_pr_issue_link.py +158 -0
  22. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_labels.py +52 -1
  23. bmsdna_devtools-0.23.0/tests/test_pull.py +390 -0
  24. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_worktree_clean.py +64 -0
  25. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.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.23.0}/.github/workflows/auto-release.yml +0 -0
  28. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.github/workflows/python-publish.yml +0 -0
  29. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.github/workflows/python-test.yml +0 -0
  30. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.gitignore +0 -0
  31. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/.python-version +0 -0
  32. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/__init__.py +0 -0
  33. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/ado_auth.py +0 -0
  34. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/app_service_logs.py +0 -0
  35. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/bdt_config.py +0 -0
  36. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/cli_tools.py +0 -0
  37. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/commit.py +0 -0
  38. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/env_config.py +0 -0
  39. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/find_repo.py +0 -0
  40. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/logs.py +0 -0
  41. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/pr_markdown.py +0 -0
  42. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/bmsdna/devtools/testdb.py +0 -0
  43. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/justfile +0 -0
  44. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  45. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/conftest.py +0 -0
  46. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_ado_auth.py +0 -0
  47. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_ado_issue_flow.py +0 -0
  48. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_app_service_logs.py +0 -0
  49. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_bdt_config.py +0 -0
  50. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_cli_cleanup.py +0 -0
  51. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_cli_commit.py +0 -0
  52. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_cli_tools.py +0 -0
  53. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_commit.py +0 -0
  54. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_env_config.py +0 -0
  55. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_find_repo.py +0 -0
  56. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_build_flow.py +0 -0
  57. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_pr_markdown.py +0 -0
  58. {bmsdna_devtools-0.22.0 → bmsdna_devtools-0.23.0}/tests/test_testdb.py +0 -0
@@ -1,3 +1,14 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.23.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"
@@ -204,6 +205,39 @@ def get_valid_states(session: requests.Session, remote: AdoRemote, work_item_typ
204
205
  return [s["name"] for s in r.json()["value"]]
205
206
 
206
207
 
208
+ def get_work_item_tags(session: requests.Session, remote: AdoRemote, work_item_id: int) -> list[str]:
209
+ r = session.get(
210
+ f"{_base_url(remote)}/_apis/wit/workitems/{work_item_id}",
211
+ params={"fields": "System.Tags", "api-version": "7.1"},
212
+ )
213
+ if r.status_code == 404:
214
+ sys.exit(f"Work item #{work_item_id} not found in project '{remote.project}'.")
215
+ r.raise_for_status()
216
+ raw = r.json().get("fields", {}).get("System.Tags") or ""
217
+ return [t.strip() for t in raw.split(";") if t.strip()]
218
+
219
+
220
+ def add_tag(session: requests.Session, remote: AdoRemote, work_item_id: int, tag: str) -> None:
221
+ """Add `tag` to work item `work_item_id`'s tags, alongside whatever's already there.
222
+
223
+ `update_work_item`'s tags op replaces the whole `System.Tags` field (there's no
224
+ Azure DevOps "add one tag" patch op), so this reads the current tags first and only
225
+ PATCHes if `tag` isn't already among them -- otherwise a second `pr create --issue`
226
+ against the same work item would keep re-sending an unchanged value.
227
+ """
228
+ tags = get_work_item_tags(session, remote, work_item_id)
229
+ if any(t.casefold() == tag.casefold() for t in tags):
230
+ return
231
+ update_work_item(session, remote, work_item_id, tags=[*tags, tag])
232
+
233
+
234
+ def add_pr_available_tag(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
235
+ """Tag work item `work_item_id` `pr-available` -- the Azure DevOps equivalent of the
236
+ GitHub `pr-available` label (Azure DevOps work items have no label concept; tags are the
237
+ closest equivalent, same as `ado_issue.py`'s own `--tag` create/update flag)."""
238
+ add_tag(session, remote, work_item_id, PR_AVAILABLE_LABEL)
239
+
240
+
207
241
  def delete_work_item(session: requests.Session, remote: AdoRemote, work_item_id: int) -> None:
208
242
  """Soft-delete: moves the work item to the project's Recycle Bin, where it can be restored.
209
243
 
@@ -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}")
@@ -639,6 +726,34 @@ def worktree(
639
726
  worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)
640
727
 
641
728
 
729
+ @app.command()
730
+ def pull(
731
+ remote: str = typer.Option(
732
+ "origin",
733
+ "--remote",
734
+ help="Remote to pull main/master and the default branch from (the current branch's own tracking "
735
+ "branch step always follows its real configured upstream, regardless of this)",
736
+ ),
737
+ no_default: bool = typer.Option(
738
+ False,
739
+ "--no-default",
740
+ help="Don't also pull the repo's DEFAULT branch -- skip this when the current branch was already "
741
+ "branched from main/master, since pulling it again would be redundant",
742
+ ),
743
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be pulled, without doing it"),
744
+ pull_args: list[str] = typer.Argument(
745
+ None,
746
+ help="Extra flags passed through to every `git pull` step, e.g. `-- --rebase --ff-only` (put them after `--`)",
747
+ ),
748
+ ) -> None:
749
+ """Bring the current branch up to date from three sources: its own remote tracking branch, the
750
+ remote's main/master branch, and the repo's DEFAULT branch (unless --no-default) -- each a
751
+ separate `git pull`, stopping with a clear error (and the exact command to re-run) if any step
752
+ hits a merge conflict.
753
+ """
754
+ pull_mod.run(remote=remote, no_default=no_default, dry_run=dry_run, pull_args=pull_args or [])
755
+
756
+
642
757
  @app.command("find-repo")
643
758
  def find_repo_cmd(
644
759
  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
@@ -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")
@@ -50,6 +50,12 @@ def current_branch(cwd: str | None = None) -> str:
50
50
  return _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
51
51
 
52
52
 
53
+ def head_commit_subject(cwd: str | None = None) -> str:
54
+ """Subject line of the current branch's HEAD commit -- e.g. used to derive a PR's
55
+ conventional-commit scope (`type(scope): ...`) locally, before the PR exists."""
56
+ return _run_git(["log", "-1", "--format=%s"], cwd=cwd)
57
+
58
+
53
59
  def origin_url(cwd: str | None = None) -> str:
54
60
  return _run_git(["remote", "get-url", "origin"], cwd=cwd)
55
61
 
@@ -305,6 +305,21 @@ def update(
305
305
  print(f"Updated PR #{pr_id}")
306
306
 
307
307
 
308
+ def link_work_item(session: requests.Session, remote: AdoRemote, pr_id: int, work_item_id: int) -> None:
309
+ """Link work item `work_item_id` to PR `pr_id`, so it shows up in the PR's "Work Items" tab
310
+ (and the work item's own "Development" links) -- the Azure DevOps equivalent of GitHub's
311
+ closing-keyword PR<->issue link, via the dedicated Pull Request Work Items REST resource
312
+ (POST .../pullRequests/{pr_id}/workitems/{work_item_id}, the same one `az repos pr work-item
313
+ add` itself calls). Idempotent -- linking an already-linked work item just re-confirms it.
314
+ """
315
+ r = session.post(
316
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}/pullRequests/{pr_id}/workitems/{work_item_id}",
317
+ params={"api-version": "7.1"},
318
+ timeout=HTTP_TIMEOUT_SECS,
319
+ )
320
+ r.raise_for_status()
321
+
322
+
308
323
  def publish(session: requests.Session, remote: AdoRemote, pr: dict) -> None:
309
324
  """Mark a draft PR as ready for review (clears isDraft)."""
310
325
  pr_id = pr["pullRequestId"]
@@ -0,0 +1,108 @@
1
+ """Parse `bdt pr create --issue` references and scan a PR body for issue/work-item mentions.
2
+
3
+ Both feed the same downstream treatment (see `cli.py::pr_create`): link the issue/work item to
4
+ the PR and label it `pr-available` (a GitHub label, or an Azure DevOps tag -- Azure DevOps has no
5
+ label concept of its own, tags are the closest equivalent, same as `ado_issue.py`'s `--tag`).
6
+
7
+ An issue number alone is only unique within a single tracker -- same reasoning `gh_issue.py`'s
8
+ `_extract_issue_numbers` uses for board membership -- so a URL is only accepted here when it
9
+ names *this* repo's own tracker (same GitHub owner/repo, or same Azure DevOps org). A URL for
10
+ some other repo/org is rejected/ignored rather than silently acted on.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from .gitrepo import AdoRemote, GitHubRemote
18
+
19
+ PR_AVAILABLE_LABEL = "pr-available"
20
+
21
+ # GitHub's own PR-body/commit-message closing keywords -- see
22
+ # https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
23
+ _GITHUB_KEYWORD_RE = re.compile(r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)", re.IGNORECASE)
24
+
25
+ # Matches a GitHub issue URL, e.g. https://github.com/owner/repo/issues/42. The `(?<![\w-])`
26
+ # lookbehind anchors "github.com" to an actual host boundary (start of string, `//`, or a `.`
27
+ # subdomain separator) -- without it, a URL on an unrelated domain that merely *contains* the
28
+ # substring "github.com" (e.g. https://notgithub.com/owner/repo/issues/42, or any host ending in
29
+ # "-github.com") would be misidentified as pointing at github.com itself.
30
+ _GITHUB_ISSUE_URL_RE = re.compile(r"(?<![\w-])github\.com/([^/\s]+)/([^/\s]+)/issues/(\d+)")
31
+
32
+ # Matches an Azure DevOps work item URL, e.g. https://dev.azure.com/org/project/_workitems/edit/42
33
+ # (also the older org.visualstudio.com host form). Same host-boundary reasoning as
34
+ # `_GITHUB_ISSUE_URL_RE` for the "dev.azure.com" branch; the "*.visualstudio.com" branch's
35
+ # `[^./\s]+` group already only captures a single dot-delimited label, so it can't be fooled the
36
+ # same way.
37
+ _ADO_WORK_ITEM_URL_RE = re.compile(r"(?:(?<![\w-])dev\.azure\.com/([^/\s]+)|([^./\s]+)\.visualstudio\.com)/\S*_workitems/edit/(\d+)")
38
+
39
+
40
+ def parse_issue_ref(ref: str, remote: AdoRemote | GitHubRemote) -> int:
41
+ """A bare issue/work-item number, or a URL naming *this* repo's own tracker, -> its number.
42
+
43
+ Raises ValueError (with a message suitable to surface via `typer.BadParameter`) for anything
44
+ else: an unparseable value, or a URL for a different repo/org than `remote`.
45
+ """
46
+ ref = ref.strip()
47
+ if ref.isdigit():
48
+ return int(ref)
49
+
50
+ if isinstance(remote, GitHubRemote):
51
+ match = _GITHUB_ISSUE_URL_RE.search(ref)
52
+ if match and match.group(1).casefold() == remote.owner.casefold() and match.group(2).casefold() == remote.repo.casefold():
53
+ return int(match.group(3))
54
+ raise ValueError(f"'{ref}' isn't a bare issue number or a github.com/{remote.owner}/{remote.repo}/issues/<N> URL")
55
+
56
+ match = _ADO_WORK_ITEM_URL_RE.search(ref)
57
+ if match:
58
+ org = match.group(1) or match.group(2)
59
+ if org and org.casefold() == remote.org.casefold():
60
+ return int(match.group(3))
61
+ raise ValueError(f"'{ref}' isn't a bare work item number or a dev.azure.com/{remote.org}/.../_workitems/edit/<N> URL")
62
+
63
+
64
+ def github_body_already_closes(body: str, issue_number: int) -> bool:
65
+ """True if `body` already contains a GitHub closing-keyword reference (`Fixes`/`Closes`/
66
+ `Resolves #N`, any tense/plural form) to `issue_number`.
67
+
68
+ Deliberately narrower than "the body mentions #N anywhere" -- a plain, non-keyword mention
69
+ (e.g. "see #42 for background") doesn't make GitHub treat the PR as genuinely linked to that
70
+ issue (no Development-sidebar entry, no auto-close on merge), so it must not be mistaken for
71
+ one already being present.
72
+ """
73
+ return any(int(m) == issue_number for m in _GITHUB_KEYWORD_RE.findall(body))
74
+
75
+
76
+ def find_issue_refs_in_body(body: str | None, remote: AdoRemote | GitHubRemote) -> list[int]:
77
+ """Issue/work-item numbers mentioned in `body` that belong to *this* repo's own tracker, in
78
+ first-seen order.
79
+
80
+ GitHub: `Fixes #N`/`Closes #N`/`Resolves #N` (and their tense/plural variants) plus full
81
+ issue URLs for this owner/repo. Azure DevOps: full work-item URLs for this org only -- Azure
82
+ DevOps has no bare-`#N` keyword syntax of its own for this (unlike GitHub's, `AB#N` is a
83
+ commit-message-only linking convention, not something `pr create`'s body scan is asked to
84
+ understand here).
85
+ """
86
+ if not body:
87
+ return []
88
+ numbers: list[int] = []
89
+ seen: set[int] = set()
90
+
91
+ def _add(n: int) -> None:
92
+ if n not in seen:
93
+ seen.add(n)
94
+ numbers.append(n)
95
+
96
+ if isinstance(remote, GitHubRemote):
97
+ for match in _GITHUB_KEYWORD_RE.finditer(body):
98
+ _add(int(match.group(1)))
99
+ for match in _GITHUB_ISSUE_URL_RE.finditer(body):
100
+ if match.group(1).casefold() == remote.owner.casefold() and match.group(2).casefold() == remote.repo.casefold():
101
+ _add(int(match.group(3)))
102
+ else:
103
+ for match in _ADO_WORK_ITEM_URL_RE.finditer(body):
104
+ org = match.group(1) or match.group(2)
105
+ if org and org.casefold() == remote.org.casefold():
106
+ _add(int(match.group(3)))
107
+
108
+ return numbers
@@ -10,6 +10,20 @@ create` must be given at least one label from every configured group
10
10
  (checked locally, before talking to GitHub/Azure DevOps, so a PR missing a
11
11
  required label is never created in the first place). Applies identically on
12
12
  both hosts.
13
+
14
+ Also: scope -> label auto-labeling, configured under `[tool.bdt.pr.scope_labels]`:
15
+
16
+ [tool.bdt.pr.scope_labels]
17
+ customers = "e2e-customers"
18
+ billing = "e2e-billing"
19
+
20
+ `bdt pr create` derives the "scope" from the HEAD commit's conventional-commit
21
+ subject using the same parser `bdt commit` itself enforces
22
+ (`commit.conventional_commit_scope`, e.g. `feat(customers): ...` -> scope
23
+ "customers"). If the derived scope has a configured label, that label is
24
+ added to the PR's `--label` list automatically (deduplicated against any
25
+ explicitly-passed `--label`). Opt-in: with no `[tool.bdt.pr.scope_labels]`
26
+ table, this is a no-op and behavior is unchanged.
13
27
  """
14
28
 
15
29
  from __future__ import annotations
@@ -27,6 +41,24 @@ def required_label_groups(start: Path | None = None) -> dict[str, list[str]]:
27
41
  return {name: choices for name, choices in raw.items() if isinstance(choices, list)}
28
42
 
29
43
 
44
+ def scope_labels(start: Path | None = None) -> dict[str, str]:
45
+ """`[tool.bdt.pr.scope_labels]` -> {scope: label to auto-apply}."""
46
+ raw = load_bdt_table("pr", start).get("scope_labels", {})
47
+ if not isinstance(raw, dict):
48
+ return {}
49
+ return {name: label for name, label in raw.items() if isinstance(label, str)}
50
+
51
+
52
+ def label_for_scope(scope_label_map: dict[str, str], scope: str | None) -> str | None:
53
+ """The configured label for `scope`, matched case-insensitively (same
54
+ convention as `missing_label_groups`), or None if `scope` is unset or
55
+ unconfigured."""
56
+ if not scope:
57
+ return None
58
+ lowered = {name.lower(): label for name, label in scope_label_map.items()}
59
+ return lowered.get(scope.lower())
60
+
61
+
30
62
  def missing_label_groups(groups: dict[str, list[str]], labels: list[str]) -> dict[str, list[str]]:
31
63
  """The subset of `groups` for which none of `labels` is a member.
32
64