bmsdna-devtools 0.5.0__tar.gz → 0.7.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 (42) hide show
  1. bmsdna_devtools-0.7.0/.github/workflows/auto-release.yml +83 -0
  2. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/PKG-INFO +48 -1
  3. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/README.md +47 -0
  4. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/cli.py +70 -4
  5. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/gh_pr.py +20 -3
  6. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/pr_build.py +7 -0
  7. bmsdna_devtools-0.7.0/bmsdna/devtools/pr_labels.py +43 -0
  8. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/pyproject.toml +1 -1
  9. bmsdna_devtools-0.7.0/tests/test_cli_pr_create.py +132 -0
  10. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_gh_pr.py +33 -1
  11. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_pr_build.py +7 -1
  12. bmsdna_devtools-0.7.0/tests/test_pr_labels.py +56 -0
  13. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/uv.lock +1 -1
  14. bmsdna_devtools-0.5.0/tests/test_cli_pr_create.py +0 -43
  15. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/.github/workflows/python-publish.yml +0 -0
  16. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/.github/workflows/python-test.yml +0 -0
  17. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/.gitignore +0 -0
  18. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/.python-version +0 -0
  19. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/__init__.py +0 -0
  20. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/ado_auth.py +0 -0
  21. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/ado_issue.py +0 -0
  22. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/app_service_logs.py +0 -0
  23. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/bdt_config.py +0 -0
  24. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/cli_tools.py +0 -0
  25. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/commit.py +0 -0
  26. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/env_config.py +0 -0
  27. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/gh_issue.py +0 -0
  28. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/gitrepo.py +0 -0
  29. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/logs.py +0 -0
  30. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/pr_markdown.py +0 -0
  31. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/bmsdna/devtools/worktree.py +0 -0
  32. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/justfile +0 -0
  33. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/skills/bmsdna-devtools/SKILL.md +0 -0
  34. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_ado_issue.py +0 -0
  35. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_ado_issue_flow.py +0 -0
  36. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_app_service_logs.py +0 -0
  37. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_bdt_config.py +0 -0
  38. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_commit.py +0 -0
  39. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_env_config.py +0 -0
  40. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_gh_issue.py +0 -0
  41. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_gitrepo.py +0 -0
  42. {bmsdna_devtools-0.5.0 → bmsdna_devtools-0.7.0}/tests/test_pr_markdown.py +0 -0
@@ -0,0 +1,83 @@
1
+ # Auto-tags, cuts a GitHub Release, and dispatches a PyPI publish whenever
2
+ # pyproject.toml's version changes on main and the Python Test workflow has
3
+ # passed for that commit. No manual "cut a release" step needed.
4
+ #
5
+ # Two GitHub Actions quirks shaped this design -- both hit and confirmed the
6
+ # hard way, so read before "simplifying" this:
7
+ #
8
+ # 1. GitHub Actions doesn't fire other workflows' triggers (release:
9
+ # published included) for events performed with the automatic
10
+ # GITHUB_TOKEN, to prevent recursive runs. So creating the release here
11
+ # would NOT, on its own, trigger python-publish.yml's `release: published`
12
+ # listener. workflow_dispatch is the documented exception to that rule --
13
+ # an API-triggered workflow_dispatch DOES start a new run even when
14
+ # triggered by GITHUB_TOKEN -- so this dispatches python-publish.yml
15
+ # directly instead of relying on the release event to cascade.
16
+ #
17
+ # 2. The obvious alternative -- calling python-publish.yml's job directly via
18
+ # workflow_call instead of dispatching it as a separate run -- avoids (1)
19
+ # entirely, but breaks PyPI's OIDC trusted publishing: PyPI explicitly
20
+ # does not support trusted publishing from reusable/called workflows (the
21
+ # token's claims show a "reusable workflow" parent chain that PyPI's
22
+ # trusted-publisher matching rejects). So python-publish.yml must stay a
23
+ # plain, directly-triggered top-level workflow -- no workflow_call trigger
24
+ # on it, and nothing here should invoke it as `uses:`.
25
+
26
+ name: Auto Release
27
+
28
+ on:
29
+ workflow_run:
30
+ workflows: ["Python Test"]
31
+ types: [completed]
32
+
33
+ permissions:
34
+ contents: read
35
+
36
+ jobs:
37
+ release:
38
+ if: >
39
+ github.event.workflow_run.conclusion == 'success' &&
40
+ github.event.workflow_run.event == 'push' &&
41
+ github.event.workflow_run.head_branch == 'main'
42
+ runs-on: ubuntu-latest
43
+ permissions:
44
+ contents: write
45
+ actions: write
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ with:
49
+ ref: ${{ github.event.workflow_run.head_sha }}
50
+
51
+ - name: Read version from pyproject.toml
52
+ id: version
53
+ run: |
54
+ version=$(grep -m1 '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/')
55
+ echo "version=$version" >> "$GITHUB_OUTPUT"
56
+
57
+ - name: Skip if this version is already on PyPI
58
+ id: check
59
+ run: |
60
+ version="${{ steps.version.outputs.version }}"
61
+ if curl -fsS https://pypi.org/pypi/bmsdna-devtools/json | jq -e --arg v "$version" '.releases[$v] != null' >/dev/null; then
62
+ echo "published=true" >> "$GITHUB_OUTPUT"
63
+ else
64
+ echo "published=false" >> "$GITHUB_OUTPUT"
65
+ fi
66
+
67
+ - name: Create tag and GitHub release, if missing
68
+ if: steps.check.outputs.published == 'false'
69
+ env:
70
+ GH_TOKEN: ${{ github.token }}
71
+ run: |
72
+ if ! gh release view "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then
73
+ gh release create "v${{ steps.version.outputs.version }}" \
74
+ --title "v${{ steps.version.outputs.version }}" \
75
+ --target "${{ github.event.workflow_run.head_sha }}" \
76
+ --generate-notes
77
+ fi
78
+
79
+ - name: Dispatch PyPI publish
80
+ if: steps.check.outputs.published == 'false'
81
+ env:
82
+ GH_TOKEN: ${{ github.token }}
83
+ run: gh workflow run python-publish.yml --ref main
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bmsdna-devtools
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: requests>=2.32.0
@@ -78,6 +78,27 @@ title/body from commit info so it never blocks on an interactive prompt).
78
78
  Extra arguments pass through either way, e.g.
79
79
  `bdt pr create --target main -- --title "..."`.
80
80
 
81
+ `--label` (repeatable) applies labels to the PR on either host: on GitHub
82
+ these map to `gh pr create --label`, so the label must already exist on the
83
+ repo (`gh label create`); on Azure DevOps they map to `az repos pr create
84
+ --labels`, which are freeform and get created on the fly. A successful
85
+ create prints the PR's web/GUI link (not just the REST API URL Azure DevOps'
86
+ `az` output otherwise gives you).
87
+
88
+ `[tool.bdt.pr.required_labels]` in pyproject.toml can require at least one
89
+ label from each named group before the PR is created — checked locally
90
+ (no `gh`/`az` call happens if a group isn't satisfied):
91
+
92
+ ```toml
93
+ [tool.bdt.pr.required_labels]
94
+ type = ["bug", "feature", "chore"]
95
+ risk = ["breaking", "non-breaking"]
96
+ ```
97
+
98
+ With the above, `bdt pr create --label feature --label breaking` passes,
99
+ but `bdt pr create --label feature` fails with a message naming the unmet
100
+ group (`risk`) and its allowed choices.
101
+
81
102
  If `--target` has a build policy configured (an Azure DevOps Build policy,
82
103
  or a GitHub branch protection rule requiring status checks), a successful
83
104
  create prints a reminder to run `bdt pr status` afterward to check whether
@@ -222,3 +243,29 @@ slot = "test"
222
243
  bdt logs fetch --env prod
223
244
  bdt logs fetch --env prod --out logs/ --keep-archive
224
245
  ```
246
+
247
+ ## Releasing
248
+
249
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
250
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
251
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
252
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
253
+ released, e.g. a merge that didn't touch the version), and dispatches
254
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
255
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
256
+ this (see the comments at the top of `auto-release.yml` for the full
257
+ reasoning, since both were hit and confirmed the hard way):
258
+
259
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
260
+ other workflows' `release: published` listeners (an anti-recursion
261
+ safeguard) — `workflow_dispatch` is the documented exception, so
262
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
263
+ run`) instead of relying on the release to cascade into it.
264
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
265
+ top-level workflow rather than something `auto-release.yml` calls via
266
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
267
+ reusable/called workflows and silently rejects the token in that shape.
268
+
269
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
270
+ still works as a manual fallback if you ever need to re-publish a version
271
+ without going through `auto-release.yml`.
@@ -68,6 +68,27 @@ title/body from commit info so it never blocks on an interactive prompt).
68
68
  Extra arguments pass through either way, e.g.
69
69
  `bdt pr create --target main -- --title "..."`.
70
70
 
71
+ `--label` (repeatable) applies labels to the PR on either host: on GitHub
72
+ these map to `gh pr create --label`, so the label must already exist on the
73
+ repo (`gh label create`); on Azure DevOps they map to `az repos pr create
74
+ --labels`, which are freeform and get created on the fly. A successful
75
+ create prints the PR's web/GUI link (not just the REST API URL Azure DevOps'
76
+ `az` output otherwise gives you).
77
+
78
+ `[tool.bdt.pr.required_labels]` in pyproject.toml can require at least one
79
+ label from each named group before the PR is created — checked locally
80
+ (no `gh`/`az` call happens if a group isn't satisfied):
81
+
82
+ ```toml
83
+ [tool.bdt.pr.required_labels]
84
+ type = ["bug", "feature", "chore"]
85
+ risk = ["breaking", "non-breaking"]
86
+ ```
87
+
88
+ With the above, `bdt pr create --label feature --label breaking` passes,
89
+ but `bdt pr create --label feature` fails with a message naming the unmet
90
+ group (`risk`) and its allowed choices.
91
+
71
92
  If `--target` has a build policy configured (an Azure DevOps Build policy,
72
93
  or a GitHub branch protection rule requiring status checks), a successful
73
94
  create prints a reminder to run `bdt pr status` afterward to check whether
@@ -212,3 +233,29 @@ slot = "test"
212
233
  bdt logs fetch --env prod
213
234
  bdt logs fetch --env prod --out logs/ --keep-archive
214
235
  ```
236
+
237
+ ## Releasing
238
+
239
+ Bump `version` in `pyproject.toml` as part of your PR, same as any other
240
+ change. Once that PR merges to `main` and the `Python Test` workflow passes
241
+ for that commit, `.github/workflows/auto-release.yml` automatically tags it
242
+ `vX.Y.Z`, cuts a GitHub Release (skipping if that version was already
243
+ released, e.g. a merge that didn't touch the version), and dispatches
244
+ `python-publish.yml` to publish it to PyPI — no manual release step, and no
245
+ extra secret to configure. Two non-obvious GitHub Actions quirks shaped
246
+ this (see the comments at the top of `auto-release.yml` for the full
247
+ reasoning, since both were hit and confirmed the hard way):
248
+
249
+ - A release created with the default `GITHUB_TOKEN` does **not** trigger
250
+ other workflows' `release: published` listeners (an anti-recursion
251
+ safeguard) — `workflow_dispatch` is the documented exception, so
252
+ `auto-release.yml` dispatches `python-publish.yml` directly (`gh workflow
253
+ run`) instead of relying on the release to cascade into it.
254
+ - `python-publish.yml` deliberately stays a plain, directly-triggered
255
+ top-level workflow rather than something `auto-release.yml` calls via
256
+ `workflow_call`: PyPI's OIDC trusted publishing does not support
257
+ reusable/called workflows and silently rejects the token in that shape.
258
+
259
+ `workflow_dispatch` (or an actual GitHub UI release) on `python-publish.yml`
260
+ still works as a manual fallback if you ever need to re-publish a version
261
+ without going through `auto-release.yml`.
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import subprocess
4
5
  import sys
5
6
  from collections.abc import Callable
7
+ from importlib.metadata import version as _pkg_version
6
8
  from pathlib import Path
7
9
 
8
10
  import requests
@@ -12,7 +14,7 @@ from . import ado_issue, app_service_logs, commit as commit_mod
12
14
  from . import env_config
13
15
  from . import gh_issue, gh_pr
14
16
  from . import logs as logs_mod
15
- from . import pr_build, worktree as worktree_mod
17
+ from . import pr_build, pr_labels, worktree as worktree_mod
16
18
  from .ado_auth import auth_header
17
19
  from .cli_tools import require_az, require_gh
18
20
  from .gitrepo import AdoRemote, GitHubRemote, current_branch, current_remote
@@ -24,7 +26,32 @@ if sys.platform == "win32":
24
26
  sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
25
27
  sys.stderr.reconfigure(encoding="utf-8") # type: ignore[union-attr]
26
28
 
27
- app = typer.Typer(name="bdt", help="Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs")
29
+ __version__ = _pkg_version("bmsdna-devtools")
30
+
31
+ app = typer.Typer(
32
+ name="bdt",
33
+ help=f"Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs (v{__version__})",
34
+ )
35
+
36
+
37
+ def _version_callback(value: bool) -> None:
38
+ if value:
39
+ print(f"bdt {__version__}")
40
+ raise typer.Exit()
41
+
42
+
43
+ @app.callback()
44
+ def main(
45
+ version: bool = typer.Option(
46
+ None,
47
+ "--version",
48
+ callback=_version_callback,
49
+ is_eager=True,
50
+ help="Show the bdt version and exit.",
51
+ ),
52
+ ) -> None:
53
+ pass
54
+
28
55
 
29
56
  pr_app = typer.Typer(name="pr", help="Pull request commands (Azure DevOps or GitHub, auto-detected from the git remote)")
30
57
  app.add_typer(pr_app, name="pr")
@@ -64,6 +91,14 @@ def _attach_screenshots(attach: Callable[[], None]) -> None:
64
91
  def pr_create(
65
92
  target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
66
93
  draft: bool = typer.Option(False, "--draft", help="Create the PR as a draft (not ready for review)"),
94
+ label: list[str] = typer.Option(
95
+ [],
96
+ "--label",
97
+ help="Label to apply to the PR (repeatable). On GitHub the label must already exist on the repo "
98
+ "(`gh label create`); Azure DevOps PR labels are freeform and created on the fly. "
99
+ r"[tool.bdt.pr.required_labels] in pyproject.toml can require at least one label from each "
100
+ "named group before the PR is created.",
101
+ ),
67
102
  screenshot: list[str] = typer.Option(
68
103
  [], "--screenshot", help="Path to an image to attach to the PR description (repeatable)"
69
104
  ),
@@ -80,11 +115,16 @@ def pr_create(
80
115
  if not Path(path).is_file():
81
116
  raise typer.BadParameter(f"Screenshot not found: {path}", param_hint="--screenshot")
82
117
 
118
+ missing_groups = pr_labels.missing_label_groups(pr_labels.required_label_groups(), label)
119
+ if missing_groups:
120
+ raise typer.BadParameter(pr_labels.format_missing_groups_error(missing_groups), param_hint="--label")
121
+
83
122
  remote = current_remote()
84
123
  source_branch = current_branch()
124
+ pr_url: str | None = None
85
125
  if isinstance(remote, GitHubRemote):
86
126
  gh = require_gh()
87
- returncode = gh_pr.create(gh, target, args or [], draft=draft)
127
+ returncode, pr_url = gh_pr.create(gh, target, args or [], draft=draft, labels=label)
88
128
  build_policy = gh_pr.has_build_policy(gh, target)
89
129
  if returncode == 0 and screenshot:
90
130
  _attach_screenshots(lambda: gh_pr.add_screenshots(gh, remote.owner, remote.repo, source_branch, screenshot))
@@ -99,10 +139,33 @@ def pr_create(
99
139
  "--target-branch", target,
100
140
  "--source-branch", source_branch,
101
141
  "--auto-complete", "false",
142
+ "--output", "json",
102
143
  *(["--draft", "true"] if draft else []),
144
+ *(["--labels", *label] if label else []),
103
145
  *(args or []),
104
146
  ]
105
- returncode = subprocess.run(cmd).returncode
147
+ r = subprocess.run(cmd, capture_output=True, encoding="utf-8")
148
+ returncode = r.returncode
149
+ if isinstance(r.stderr, str) and r.stderr.strip():
150
+ print(r.stderr.strip(), file=sys.stderr)
151
+
152
+ pr_json = None
153
+ if returncode == 0 and isinstance(r.stdout, str):
154
+ try:
155
+ pr_json = json.loads(r.stdout)
156
+ except json.JSONDecodeError:
157
+ pr_json = None
158
+
159
+ if pr_json is not None:
160
+ # Concise summary instead of the raw `az` JSON blob — the web
161
+ # link printed below is the part a human actually needs.
162
+ print(f"PR #{pr_json.get('pullRequestId', '?')}: {pr_json.get('title', '?')}")
163
+ try:
164
+ pr_url = pr_build.pr_web_url(remote, pr_json["pullRequestId"])
165
+ except (KeyError, TypeError):
166
+ pr_url = None
167
+ elif isinstance(r.stdout, str) and r.stdout.strip():
168
+ print(r.stdout.rstrip())
106
169
  session = requests.Session()
107
170
  session.headers.update(auth_header(pat))
108
171
  build_policy = pr_build.has_build_policy(session, remote, target)
@@ -114,6 +177,9 @@ def pr_create(
114
177
  _attach_screenshots(_add)
115
178
  publish_hint = "`az repos pr update --id <PR-ID> --draft false`"
116
179
 
180
+ if returncode == 0 and pr_url:
181
+ print(f"\n{pr_url}")
182
+
117
183
  if returncode == 0 and draft:
118
184
  print(f"\nCreated as a draft PR. Run `bdt pr publish` (or {publish_hint}) to mark it ready for review.")
119
185
 
@@ -140,15 +140,32 @@ def run(gh: str, wait: bool) -> None:
140
140
  return
141
141
 
142
142
 
143
- def create(gh: str, target: str, extra_args: list[str], draft: bool = False) -> int:
143
+ def create(gh: str, target: str, extra_args: list[str], draft: bool = False, labels: list[str] | None = None) -> tuple[int, str | None]:
144
144
  """Create a GitHub PR from the current branch into `target`.
145
145
 
146
146
  --fill autofills title/body from commit info so this never blocks on an
147
147
  interactive prompt; pass --title/--body in extra_args to override (gh
148
148
  lets explicit values take precedence over --fill).
149
+
150
+ `-l/--label` labels must already exist on the repo (`gh label create`) —
151
+ unlike Azure DevOps PR labels, GitHub rejects a label name it doesn't
152
+ already know about.
153
+
154
+ Returns (returncode, web_url) — on success `gh pr create` prints the
155
+ PR's web URL as its only stdout line, which is what a human needs to
156
+ open it; on failure the url is `None` and the CLI's stderr is
157
+ surfaced to ours.
149
158
  """
150
- cmd = [gh, "pr", "create", "--base", target, "--fill", *(["--draft"] if draft else []), *extra_args]
151
- return subprocess.run(cmd).returncode
159
+ cmd = [gh, "pr", "create", "--base", target, "--fill", *(["--draft"] if draft else [])]
160
+ for label in labels or []:
161
+ cmd += ["--label", label]
162
+ cmd += extra_args
163
+ r = subprocess.run(cmd, capture_output=True, encoding="utf-8")
164
+ if r.stderr:
165
+ print(r.stderr.strip(), file=sys.stderr)
166
+ if r.returncode != 0:
167
+ return r.returncode, None
168
+ return r.returncode, r.stdout.strip() or None
152
169
 
153
170
 
154
171
  def publish(gh: str) -> None:
@@ -30,6 +30,13 @@ def _base_url(remote: AdoRemote) -> str:
30
30
  return f"https://dev.azure.com/{remote.org}/{quote(remote.project, safe='')}"
31
31
 
32
32
 
33
+ def pr_web_url(remote: AdoRemote, pr_id: int) -> str:
34
+ """The browsable web page for a PR, as opposed to its REST API URL (which is all the
35
+ `az repos pr create` JSON response otherwise gives you).
36
+ """
37
+ return f"{_base_url(remote)}/_git/{quote(remote.repo, safe='')}/pullrequest/{pr_id}"
38
+
39
+
33
40
  # PolicyType.id for the built-in "Build" policy (branch policy requiring a
34
41
  # build to pass) — same GUID across every ADO organization.
35
42
  BUILD_POLICY_TYPE_ID = "0609b952-1397-4640-95ec-e00a01b2c241"
@@ -0,0 +1,43 @@
1
+ """Required PR label groups, configured under `[tool.bdt.pr.required_labels]`
2
+ in pyproject.toml:
3
+
4
+ [tool.bdt.pr.required_labels]
5
+ type = ["bug", "feature", "chore"]
6
+ risk = ["breaking", "non-breaking"]
7
+
8
+ Each key names a group; its value is the labels that satisfy it. `bdt pr
9
+ create` must be given at least one label from every configured group
10
+ (checked locally, before talking to GitHub/Azure DevOps, so a PR missing a
11
+ required label is never created in the first place). Applies identically on
12
+ both hosts.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from .bdt_config import load_bdt_table
20
+
21
+
22
+ def required_label_groups(start: Path | None = None) -> dict[str, list[str]]:
23
+ """`[tool.bdt.pr.required_labels]` -> {group name: [allowed label, ...]}."""
24
+ raw = load_bdt_table("pr", start).get("required_labels", {})
25
+ if not isinstance(raw, dict):
26
+ return {}
27
+ return {name: choices for name, choices in raw.items() if isinstance(choices, list)}
28
+
29
+
30
+ def missing_label_groups(groups: dict[str, list[str]], labels: list[str]) -> dict[str, list[str]]:
31
+ """The subset of `groups` for which none of `labels` is a member.
32
+
33
+ Matched case-insensitively — this is a local config check independent of
34
+ whatever casing rules GitHub/Azure DevOps apply to the label text that
35
+ actually gets sent through unchanged.
36
+ """
37
+ given = {label.lower() for label in labels}
38
+ return {name: choices for name, choices in groups.items() if given.isdisjoint(choice.lower() for choice in choices)}
39
+
40
+
41
+ def format_missing_groups_error(missing: dict[str, list[str]]) -> str:
42
+ parts = [f"'{name}' (choose one of: {', '.join(choices)})" for name, choices in missing.items()]
43
+ return "Missing required PR label(s) for group " + "; group ".join(parts)
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.5.0"
13
+ version = "0.7.0"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.11"
@@ -0,0 +1,132 @@
1
+ """`bdt pr create` must pass the parsed org/project/repo through to
2
+ `az repos pr create` explicitly, rather than relying on `az`'s own remote
3
+ auto-detection -- that auto-detect doesn't understand the SSH `v3` remote
4
+ form at all, and separately, an `az devops configure -d project=...` default
5
+ that happens to point at a *different* ADO project than the current repo's
6
+ own silently makes `az` look for the PR's repository in the wrong project.
7
+ """
8
+
9
+ import json
10
+ from unittest.mock import MagicMock
11
+
12
+ from typer.testing import CliRunner
13
+
14
+ from bmsdna.devtools.cli import app
15
+ from bmsdna.devtools.gitrepo import AdoRemote, GitHubRemote
16
+
17
+ runner = CliRunner()
18
+
19
+
20
+ def test_pr_create_passes_organization_project_repository_to_az(monkeypatch) -> None:
21
+ remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
22
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
23
+ monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
24
+ monkeypatch.setattr("bmsdna.devtools.cli.require_az", lambda: "az")
25
+ monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {})
26
+
27
+ captured_cmd: list[str] = []
28
+
29
+ def fake_run(cmd, **kwargs):
30
+ captured_cmd[:] = cmd
31
+ return MagicMock(returncode=0)
32
+
33
+ monkeypatch.setattr("bmsdna.devtools.cli.subprocess.run", fake_run)
34
+ monkeypatch.setattr("bmsdna.devtools.pr_build.has_build_policy", lambda session, remote, target: False)
35
+
36
+ result = runner.invoke(app, ["pr", "create", "--target", "test"])
37
+
38
+ assert result.exit_code == 0, result.output
39
+ assert "--organization" in captured_cmd
40
+ assert captured_cmd[captured_cmd.index("--organization") + 1] == "https://dev.azure.com/bmeurope"
41
+ assert "--project" in captured_cmd
42
+ assert captured_cmd[captured_cmd.index("--project") + 1] == "BMS - CCMT2"
43
+ assert "--repository" in captured_cmd
44
+ assert captured_cmd[captured_cmd.index("--repository") + 1] == "BMS - CCMT2"
45
+
46
+
47
+ def test_pr_create_passes_labels_and_prints_web_link_for_ado(monkeypatch) -> None:
48
+ remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
49
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
50
+ monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
51
+ monkeypatch.setattr("bmsdna.devtools.cli.require_az", lambda: "az")
52
+ monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {})
53
+ monkeypatch.setattr("bmsdna.devtools.pr_build.has_build_policy", lambda session, remote, target: False)
54
+
55
+ captured_cmd: list[str] = []
56
+
57
+ def fake_run(cmd, **kwargs):
58
+ captured_cmd[:] = cmd
59
+ return MagicMock(returncode=0, stdout=json.dumps({"pullRequestId": 456, "title": "feat: widgets"}), stderr="")
60
+
61
+ monkeypatch.setattr("bmsdna.devtools.cli.subprocess.run", fake_run)
62
+
63
+ result = runner.invoke(app, ["pr", "create", "--target", "test", "--label", "bug", "--label", "urgent"])
64
+
65
+ assert result.exit_code == 0, result.output
66
+ assert "--labels" in captured_cmd
67
+ labels_idx = captured_cmd.index("--labels")
68
+ assert captured_cmd[labels_idx + 1 : labels_idx + 3] == ["bug", "urgent"]
69
+ assert "https://dev.azure.com/bmeurope/BMS%20-%20CCMT2/_git/BMS%20-%20CCMT2/pullrequest/456" in result.output
70
+ assert "PR #456: feat: widgets" in result.output
71
+ assert '"pullRequestId"' not in result.output # the raw `az` JSON blob must not be dumped on success
72
+
73
+
74
+ def test_pr_create_passes_labels_and_prints_web_link_for_github(monkeypatch) -> None:
75
+ remote = GitHubRemote("owner", "repo")
76
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
77
+ monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
78
+ monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
79
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.has_build_policy", lambda gh, target: False)
80
+
81
+ captured_cmd: list[str] = []
82
+
83
+ def fake_run(cmd, **kwargs):
84
+ captured_cmd[:] = cmd
85
+ return MagicMock(returncode=0, stdout="https://github.com/owner/repo/pull/7\n", stderr="")
86
+
87
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.subprocess.run", fake_run)
88
+
89
+ result = runner.invoke(app, ["pr", "create", "--target", "main", "--label", "bug"])
90
+
91
+ assert result.exit_code == 0, result.output
92
+ assert captured_cmd.count("--label") == 1
93
+ assert captured_cmd[captured_cmd.index("--label") + 1] == "bug"
94
+ assert "https://github.com/owner/repo/pull/7" in result.output
95
+
96
+
97
+ def test_pr_create_fails_before_touching_gh_az_when_required_label_group_missing(monkeypatch) -> None:
98
+ monkeypatch.setattr(
99
+ "bmsdna.devtools.cli.pr_labels.required_label_groups",
100
+ lambda: {"risk": ["breaking", "non-breaking"]},
101
+ )
102
+
103
+ def fail_if_called(*args, **kwargs):
104
+ raise AssertionError("should not be reached — the missing-label check must run first")
105
+
106
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", fail_if_called)
107
+
108
+ result = runner.invoke(app, ["pr", "create", "--target", "main", "--label", "bug"])
109
+
110
+ assert result.exit_code != 0
111
+ assert "risk" in result.output
112
+ assert "breaking" in result.output
113
+
114
+
115
+ def test_pr_create_proceeds_when_required_label_group_satisfied(monkeypatch) -> None:
116
+ remote = GitHubRemote("owner", "repo")
117
+ monkeypatch.setattr(
118
+ "bmsdna.devtools.cli.pr_labels.required_label_groups",
119
+ lambda: {"risk": ["breaking", "non-breaking"]},
120
+ )
121
+ monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
122
+ monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
123
+ monkeypatch.setattr("bmsdna.devtools.cli.require_gh", lambda: "gh")
124
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.has_build_policy", lambda gh, target: False)
125
+ monkeypatch.setattr(
126
+ "bmsdna.devtools.gh_pr.subprocess.run",
127
+ lambda cmd, **kwargs: MagicMock(returncode=0, stdout="https://github.com/owner/repo/pull/7\n", stderr=""),
128
+ )
129
+
130
+ result = runner.invoke(app, ["pr", "create", "--target", "main", "--label", "non-breaking"])
131
+
132
+ assert result.exit_code == 0, result.output
@@ -1,6 +1,8 @@
1
+ from unittest.mock import MagicMock
2
+
1
3
  import pytest
2
4
 
3
- from bmsdna.devtools.gh_pr import check_bucket, check_label, merge_conflict_message, protection_requires_status_checks
5
+ from bmsdna.devtools.gh_pr import check_bucket, check_label, create, merge_conflict_message, protection_requires_status_checks
4
6
 
5
7
  # Real statusCheckRollup entries captured from `gh pr view 13902 -R cli/cli --json statusCheckRollup`.
6
8
  COMPLETED_SUCCESS_CHECK_RUN = {
@@ -70,3 +72,33 @@ def test_protection_requires_status_checks_true_when_configured() -> None:
70
72
  @pytest.mark.parametrize("protection", [{}, {"required_status_checks": None}])
71
73
  def test_protection_requires_status_checks_false_when_absent(protection: dict) -> None:
72
74
  assert protection_requires_status_checks(protection) is False
75
+
76
+
77
+ def test_create_passes_repeatable_label_flags(monkeypatch) -> None:
78
+ captured_cmd: list[str] = []
79
+
80
+ def fake_run(cmd, **kwargs):
81
+ captured_cmd[:] = cmd
82
+ return MagicMock(returncode=0, stdout="https://github.com/owner/repo/pull/7\n", stderr="")
83
+
84
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.subprocess.run", fake_run)
85
+
86
+ returncode, url = create("gh", "main", [], labels=["bug", "urgent"])
87
+
88
+ assert returncode == 0
89
+ assert url == "https://github.com/owner/repo/pull/7"
90
+ assert captured_cmd.count("--label") == 2
91
+ assert captured_cmd[captured_cmd.index("--label") + 1] == "bug"
92
+
93
+
94
+ def test_create_returns_none_url_on_failure(monkeypatch, capsys) -> None:
95
+ def fake_run(cmd, **kwargs):
96
+ return MagicMock(returncode=1, stdout="", stderr="label 'nope' not found")
97
+
98
+ monkeypatch.setattr("bmsdna.devtools.gh_pr.subprocess.run", fake_run)
99
+
100
+ returncode, url = create("gh", "main", [], labels=["nope"])
101
+
102
+ assert returncode == 1
103
+ assert url is None
104
+ assert "not found" in capsys.readouterr().err
@@ -1,6 +1,7 @@
1
1
  import pytest
2
2
 
3
- from bmsdna.devtools.pr_build import merge_conflict_message, policy_configs_include_branch
3
+ from bmsdna.devtools.gitrepo import AdoRemote
4
+ from bmsdna.devtools.pr_build import merge_conflict_message, policy_configs_include_branch, pr_web_url
4
5
 
5
6
  REPO_ID = "0cd3a822-389e-416e-a4fa-b73f988c2930"
6
7
 
@@ -95,3 +96,8 @@ def test_policy_configs_include_branch_matches_default_branch_scope() -> None:
95
96
  }
96
97
  assert policy_configs_include_branch([default_branch_policy], REPO_ID, "main", "refs/heads/main") is True
97
98
  assert policy_configs_include_branch([default_branch_policy], REPO_ID, "test", "refs/heads/main") is False
99
+
100
+
101
+ def test_pr_web_url_is_the_browsable_page_not_the_rest_api_url() -> None:
102
+ remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
103
+ assert pr_web_url(remote, 123) == "https://dev.azure.com/bmeurope/BMS%20-%20CCMT2/_git/BMS%20-%20CCMT2/pullrequest/123"
@@ -0,0 +1,56 @@
1
+ from bmsdna.devtools.pr_labels import format_missing_groups_error, missing_label_groups, required_label_groups
2
+
3
+
4
+ def write_pyproject(tmp_path, body: str):
5
+ (tmp_path / "pyproject.toml").write_text(body)
6
+ return tmp_path
7
+
8
+
9
+ def test_required_label_groups_empty_when_no_pyproject(tmp_path) -> None:
10
+ assert required_label_groups(tmp_path) == {}
11
+
12
+
13
+ def test_required_label_groups_empty_when_table_absent(tmp_path) -> None:
14
+ write_pyproject(tmp_path, "[tool.bdt.ado]\nboard = 'x'\n")
15
+ assert required_label_groups(tmp_path) == {}
16
+
17
+
18
+ def test_required_label_groups_reads_named_groups(tmp_path) -> None:
19
+ write_pyproject(
20
+ tmp_path,
21
+ "[tool.bdt.pr.required_labels]\n"
22
+ 'type = ["bug", "feature", "chore"]\n'
23
+ 'risk = ["breaking", "non-breaking"]\n',
24
+ )
25
+ assert required_label_groups(tmp_path) == {
26
+ "type": ["bug", "feature", "chore"],
27
+ "risk": ["breaking", "non-breaking"],
28
+ }
29
+
30
+
31
+ def test_missing_label_groups_none_missing_when_all_satisfied() -> None:
32
+ groups = {"type": ["bug", "feature"], "risk": ["breaking", "non-breaking"]}
33
+ assert missing_label_groups(groups, ["feature", "breaking"]) == {}
34
+
35
+
36
+ def test_missing_label_groups_reports_unsatisfied_group() -> None:
37
+ groups = {"type": ["bug", "feature"], "risk": ["breaking", "non-breaking"]}
38
+ assert missing_label_groups(groups, ["feature"]) == {"risk": ["breaking", "non-breaking"]}
39
+
40
+
41
+ def test_missing_label_groups_matches_case_insensitively() -> None:
42
+ groups = {"risk": ["breaking", "non-breaking"]}
43
+ assert missing_label_groups(groups, ["Breaking"]) == {}
44
+ assert missing_label_groups(groups, ["BREAKING"]) == {}
45
+
46
+
47
+ def test_missing_label_groups_reports_all_when_no_labels_given() -> None:
48
+ groups = {"type": ["bug", "feature"], "risk": ["breaking", "non-breaking"]}
49
+ assert missing_label_groups(groups, []) == groups
50
+
51
+
52
+ def test_format_missing_groups_error_lists_group_name_and_choices() -> None:
53
+ msg = format_missing_groups_error({"risk": ["breaking", "non-breaking"]})
54
+ assert "risk" in msg
55
+ assert "breaking" in msg
56
+ assert "non-breaking" in msg
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "bmsdna-devtools"
16
- version = "0.5.0"
16
+ version = "0.7.0"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "requests" },
@@ -1,43 +0,0 @@
1
- """`bdt pr create` must pass the parsed org/project/repo through to
2
- `az repos pr create` explicitly, rather than relying on `az`'s own remote
3
- auto-detection -- that auto-detect doesn't understand the SSH `v3` remote
4
- form at all, and separately, an `az devops configure -d project=...` default
5
- that happens to point at a *different* ADO project than the current repo's
6
- own silently makes `az` look for the PR's repository in the wrong project.
7
- """
8
-
9
- from unittest.mock import MagicMock
10
-
11
- from typer.testing import CliRunner
12
-
13
- from bmsdna.devtools.cli import app
14
- from bmsdna.devtools.gitrepo import AdoRemote
15
-
16
- runner = CliRunner()
17
-
18
-
19
- def test_pr_create_passes_organization_project_repository_to_az(monkeypatch) -> None:
20
- remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2")
21
- monkeypatch.setattr("bmsdna.devtools.cli.current_remote", lambda: remote)
22
- monkeypatch.setattr("bmsdna.devtools.cli.current_branch", lambda: "feature-x")
23
- monkeypatch.setattr("bmsdna.devtools.cli.require_az", lambda: "az")
24
- monkeypatch.setattr("bmsdna.devtools.cli.auth_header", lambda pat: {})
25
-
26
- captured_cmd: list[str] = []
27
-
28
- def fake_run(cmd, **kwargs):
29
- captured_cmd[:] = cmd
30
- return MagicMock(returncode=0)
31
-
32
- monkeypatch.setattr("bmsdna.devtools.cli.subprocess.run", fake_run)
33
- monkeypatch.setattr("bmsdna.devtools.pr_build.has_build_policy", lambda session, remote, target: False)
34
-
35
- result = runner.invoke(app, ["pr", "create", "--target", "test"])
36
-
37
- assert result.exit_code == 0, result.output
38
- assert "--organization" in captured_cmd
39
- assert captured_cmd[captured_cmd.index("--organization") + 1] == "https://dev.azure.com/bmeurope"
40
- assert "--project" in captured_cmd
41
- assert captured_cmd[captured_cmd.index("--project") + 1] == "BMS - CCMT2"
42
- assert "--repository" in captured_cmd
43
- assert captured_cmd[captured_cmd.index("--repository") + 1] == "BMS - CCMT2"