bmsdna-devtools 0.2.1__tar.gz → 0.2.2__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 (29) hide show
  1. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/PKG-INFO +7 -1
  2. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/README.md +6 -0
  3. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/cli.py +19 -11
  4. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/gh_pr.py +28 -0
  5. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/pr_build.py +54 -0
  6. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/pyproject.toml +1 -1
  7. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/skills/bmsdna-devtools/SKILL.md +3 -0
  8. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/tests/test_gh_pr.py +11 -1
  9. bmsdna_devtools-0.2.2/tests/test_pr_build.py +97 -0
  10. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/uv.lock +1 -1
  11. bmsdna_devtools-0.2.1/tests/test_pr_build.py +0 -38
  12. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/.github/workflows/python-publish.yml +0 -0
  13. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/.github/workflows/python-test.yml +0 -0
  14. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/.gitignore +0 -0
  15. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/.python-version +0 -0
  16. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/__init__.py +0 -0
  17. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/ado_auth.py +0 -0
  18. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/app_service_logs.py +0 -0
  19. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/cli_tools.py +0 -0
  20. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/commit.py +0 -0
  21. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/env_config.py +0 -0
  22. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/gitrepo.py +0 -0
  23. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/logs.py +0 -0
  24. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/bmsdna/devtools/worktree.py +0 -0
  25. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/justfile +0 -0
  26. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/tests/test_app_service_logs.py +0 -0
  27. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/tests/test_commit.py +0 -0
  28. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/tests/test_env_config.py +0 -0
  29. {bmsdna_devtools-0.2.1 → bmsdna_devtools-0.2.2}/tests/test_gitrepo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bmsdna-devtools
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -77,6 +77,12 @@ title/body from commit info so it never blocks on an interactive prompt).
77
77
  Extra arguments pass through either way, e.g.
78
78
  `bdt pr create --target main -- --title "..."`.
79
79
 
80
+ If `--target` has a build policy configured (an Azure DevOps Build policy,
81
+ or a GitHub branch protection rule requiring status checks), a successful
82
+ create prints a reminder to run `bdt pr status` afterward to check whether
83
+ the CI build passes. This is a best-effort check — failures reading policy
84
+ config (auth, permissions) fail open and simply skip the reminder.
85
+
80
86
  ## `bdt worktree`
81
87
 
82
88
  ```bash
@@ -67,6 +67,12 @@ title/body from commit info so it never blocks on an interactive prompt).
67
67
  Extra arguments pass through either way, e.g.
68
68
  `bdt pr create --target main -- --title "..."`.
69
69
 
70
+ If `--target` has a build policy configured (an Azure DevOps Build policy,
71
+ or a GitHub branch protection rule requiring status checks), a successful
72
+ create prints a reminder to run `bdt pr status` afterward to check whether
73
+ the CI build passes. This is a best-effort check — failures reading policy
74
+ config (auth, permissions) fail open and simply skip the reminder.
75
+
70
76
  ## `bdt worktree`
71
77
 
72
78
  ```bash
@@ -38,17 +38,25 @@ def pr_create(
38
38
  """Create a PR from the current branch into --target (Azure DevOps or GitHub, auto-detected)."""
39
39
  remote = current_remote()
40
40
  if isinstance(remote, GitHubRemote):
41
- raise typer.Exit(gh_pr.create(require_gh(), target, args or []))
42
-
43
- az = require_az()
44
- cmd = [
45
- az, "repos", "pr", "create",
46
- "--target-branch", target,
47
- "--source-branch", current_branch(),
48
- "--auto-complete", "false",
49
- *(args or []),
50
- ]
51
- raise typer.Exit(subprocess.run(cmd).returncode)
41
+ gh = require_gh()
42
+ returncode = gh_pr.create(gh, target, args or [])
43
+ build_policy = gh_pr.has_build_policy(gh, target)
44
+ else:
45
+ az = require_az()
46
+ cmd = [
47
+ az, "repos", "pr", "create",
48
+ "--target-branch", target,
49
+ "--source-branch", current_branch(),
50
+ "--auto-complete", "false",
51
+ *(args or []),
52
+ ]
53
+ returncode = subprocess.run(cmd).returncode
54
+ build_policy = pr_build.has_build_policy(remote, target)
55
+
56
+ if returncode == 0 and build_policy:
57
+ print("\nRun `bdt pr status` to check whether the CI build passes.")
58
+
59
+ raise typer.Exit(returncode)
52
60
 
53
61
 
54
62
  @pr_app.command("status")
@@ -138,3 +138,31 @@ def create(gh: str, target: str, extra_args: list[str]) -> int:
138
138
  """
139
139
  cmd = [gh, "pr", "create", "--base", target, "--fill", *extra_args]
140
140
  return subprocess.run(cmd).returncode
141
+
142
+
143
+ def protection_requires_status_checks(protection: dict) -> bool:
144
+ """True if a branch's `.../protection` response requires status checks to pass."""
145
+ return bool(protection.get("required_status_checks"))
146
+
147
+
148
+ def has_build_policy(gh: str, branch: str) -> bool:
149
+ """Best-effort check for whether `branch` has branch protection requiring status checks.
150
+
151
+ Only used to decide whether to print a `bdt pr status` reminder after
152
+ `pr create` — `{owner}`/`{repo}` are resolved by `gh` from the current
153
+ repo, and any failure (no permission to read protection settings, branch
154
+ not protected at all, etc.) fails open (returns False) rather than
155
+ blocking PR creation.
156
+ """
157
+ r = subprocess.run(
158
+ [gh, "api", f"repos/{{owner}}/{{repo}}/branches/{branch}/protection"],
159
+ capture_output=True,
160
+ encoding="utf-8",
161
+ )
162
+ if r.returncode != 0:
163
+ return False
164
+ try:
165
+ data = json.loads(r.stdout)
166
+ except json.JSONDecodeError:
167
+ return False
168
+ return protection_requires_status_checks(data)
@@ -28,6 +28,60 @@ def _base_url(remote: AdoRemote) -> str:
28
28
  return f"https://dev.azure.com/{remote.org}/{quote(remote.project, safe='')}"
29
29
 
30
30
 
31
+ # PolicyType.id for the built-in "Build" policy (branch policy requiring a
32
+ # build to pass) — same GUID across every ADO organization.
33
+ BUILD_POLICY_TYPE_ID = "0609b952-1397-4640-95ec-e00a01b2c241"
34
+
35
+
36
+ def _scope_matches(scope: dict, repo_id: str, target_ref: str, default_branch: str | None) -> bool:
37
+ if scope.get("repositoryId") not in (None, repo_id):
38
+ return False
39
+ if scope.get("matchKind") == "DefaultBranch":
40
+ return default_branch == target_ref
41
+ return scope.get("refName") in (None, target_ref)
42
+
43
+
44
+ def policy_configs_include_branch(configs: list, repo_id: str, branch: str, default_branch: str | None) -> bool:
45
+ """True if any enabled, non-deleted Build-type policy configuration's scope covers `branch`."""
46
+ target_ref = f"refs/heads/{branch}"
47
+ for config in configs:
48
+ if not config.get("isEnabled") or config.get("isDeleted"):
49
+ continue
50
+ if config.get("type", {}).get("id") != BUILD_POLICY_TYPE_ID:
51
+ continue
52
+ scopes = config.get("settings", {}).get("scope", [])
53
+ if any(_scope_matches(scope, repo_id, target_ref, default_branch) for scope in scopes):
54
+ return True
55
+ return False
56
+
57
+
58
+ def has_build_policy(remote: AdoRemote, branch: str, pat: str | None = None) -> bool:
59
+ """Best-effort check for whether `branch` has an enabled Build policy configured.
60
+
61
+ Only used to decide whether to print a `bdt pr status` reminder after
62
+ `pr create` — failures here (auth, permissions, network) fail open
63
+ (return False) rather than blocking PR creation.
64
+ """
65
+ try:
66
+ session = requests.Session()
67
+ session.headers.update(auth_header(pat))
68
+
69
+ r = session.get(
70
+ f"{_base_url(remote)}/_apis/git/repositories/{quote(remote.repo, safe='')}",
71
+ params={"api-version": "7.1"},
72
+ )
73
+ r.raise_for_status()
74
+ repo = r.json()
75
+
76
+ r = session.get(f"{_base_url(remote)}/_apis/policy/configurations", params={"api-version": "7.1"})
77
+ r.raise_for_status()
78
+ configs = r.json().get("value", [])
79
+ except (requests.RequestException, SystemExit):
80
+ return False
81
+
82
+ return policy_configs_include_branch(configs, repo.get("id"), branch, repo.get("defaultBranch"))
83
+
84
+
31
85
  def merge_conflict_message(pr: dict) -> str | None:
32
86
  """None if the PR's mergeStatus is fine; else a human-readable description of the problem."""
33
87
  merge_status = pr.get("mergeStatus")
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.2.1"
13
+ version = "0.2.2"
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"
@@ -66,6 +66,9 @@ info, so it never blocks waiting on an interactive prompt). Extra args pass
66
66
  straight through either way, e.g.
67
67
  `bdt pr create --target main -- --title "..." --description "..."`.
68
68
 
69
+ After creating a PR, use `bdt pr status` (see above) to check whether the
70
+ CI build passes.
71
+
69
72
  ## Creating a worktree
70
73
 
71
74
  ```bash
@@ -1,6 +1,6 @@
1
1
  import pytest
2
2
 
3
- from bmsdna.devtools.gh_pr import check_bucket, check_label, merge_conflict_message
3
+ from bmsdna.devtools.gh_pr import check_bucket, check_label, merge_conflict_message, protection_requires_status_checks
4
4
 
5
5
  # Real statusCheckRollup entries captured from `gh pr view 13902 -R cli/cli --json statusCheckRollup`.
6
6
  COMPLETED_SUCCESS_CHECK_RUN = {
@@ -60,3 +60,13 @@ def test_merge_conflict_message_conflicting() -> None:
60
60
  assert msg is not None
61
61
  assert "PR #42" in msg
62
62
  assert "main" in msg
63
+
64
+
65
+ def test_protection_requires_status_checks_true_when_configured() -> None:
66
+ protection = {"required_status_checks": {"strict": True, "contexts": ["build"]}}
67
+ assert protection_requires_status_checks(protection) is True
68
+
69
+
70
+ @pytest.mark.parametrize("protection", [{}, {"required_status_checks": None}])
71
+ def test_protection_requires_status_checks_false_when_absent(protection: dict) -> None:
72
+ assert protection_requires_status_checks(protection) is False
@@ -0,0 +1,97 @@
1
+ import pytest
2
+
3
+ from bmsdna.devtools.pr_build import merge_conflict_message, policy_configs_include_branch
4
+
5
+ REPO_ID = "0cd3a822-389e-416e-a4fa-b73f988c2930"
6
+
7
+ # Shape captured from a real `.../_apis/policy/configurations` response.
8
+ BUILD_POLICY_ON_MAIN = {
9
+ "isEnabled": True,
10
+ "isDeleted": False,
11
+ "type": {"id": "0609b952-1397-4640-95ec-e00a01b2c241", "displayName": "Build"},
12
+ "settings": {
13
+ "scope": [{"refName": "refs/heads/main", "matchKind": "Exact", "repositoryId": REPO_ID}],
14
+ },
15
+ }
16
+ REVIEWER_POLICY_ON_EMERGENCY_RELEASE = {
17
+ "isEnabled": True,
18
+ "isDeleted": False,
19
+ "type": {"id": "fd2167ab-b0be-447a-8ec8-39368250530e", "displayName": "Minimum number of reviewers"},
20
+ "settings": {
21
+ "scope": [{"refName": "refs/heads/emergency-release", "matchKind": "Exact", "repositoryId": REPO_ID}],
22
+ },
23
+ }
24
+
25
+
26
+ @pytest.mark.parametrize("merge_status", ["notSet", "queued", "succeeded"])
27
+ def test_merge_conflict_message_none_when_mergeable(merge_status: str) -> None:
28
+ assert merge_conflict_message({"pullRequestId": 1, "title": "x", "mergeStatus": merge_status}) is None
29
+
30
+
31
+ def test_merge_conflict_message_missing_field_is_fine() -> None:
32
+ assert merge_conflict_message({"pullRequestId": 1, "title": "x"}) is None
33
+
34
+
35
+ def test_merge_conflict_message_conflicts() -> None:
36
+ pr = {"pullRequestId": 42, "title": "feat: widgets", "mergeStatus": "conflicts"}
37
+ msg = merge_conflict_message(pr)
38
+ assert msg is not None
39
+ assert "PR #42" in msg
40
+ assert "conflicts" in msg
41
+
42
+
43
+ def test_merge_conflict_message_uses_failure_message_when_present() -> None:
44
+ pr = {
45
+ "pullRequestId": 7,
46
+ "title": "fix: x",
47
+ "mergeStatus": "failure",
48
+ "mergeFailureMessage": "object too large to merge",
49
+ }
50
+ msg = merge_conflict_message(pr)
51
+ assert msg is not None
52
+ assert "object too large to merge" in msg
53
+
54
+
55
+ @pytest.mark.parametrize("merge_status", ["conflicts", "failure", "rejectedByPolicy"])
56
+ def test_merge_conflict_message_covers_all_bad_statuses(merge_status: str) -> None:
57
+ pr = {"pullRequestId": 1, "title": "x", "mergeStatus": merge_status}
58
+ assert merge_conflict_message(pr) is not None
59
+
60
+
61
+ def test_policy_configs_include_branch_matches_build_policy_on_scoped_branch() -> None:
62
+ configs = [BUILD_POLICY_ON_MAIN, REVIEWER_POLICY_ON_EMERGENCY_RELEASE]
63
+ assert policy_configs_include_branch(configs, REPO_ID, "main", "refs/heads/main") is True
64
+
65
+
66
+ def test_policy_configs_include_branch_false_for_unscoped_branch() -> None:
67
+ configs = [BUILD_POLICY_ON_MAIN, REVIEWER_POLICY_ON_EMERGENCY_RELEASE]
68
+ assert policy_configs_include_branch(configs, REPO_ID, "test", "refs/heads/main") is False
69
+
70
+
71
+ def test_policy_configs_include_branch_ignores_non_build_policy_types() -> None:
72
+ assert policy_configs_include_branch([REVIEWER_POLICY_ON_EMERGENCY_RELEASE], REPO_ID, "emergency-release", "refs/heads/main") is False
73
+
74
+
75
+ def test_policy_configs_include_branch_ignores_disabled_policy() -> None:
76
+ disabled = {**BUILD_POLICY_ON_MAIN, "isEnabled": False}
77
+ assert policy_configs_include_branch([disabled], REPO_ID, "main", "refs/heads/main") is False
78
+
79
+
80
+ def test_policy_configs_include_branch_ignores_deleted_policy() -> None:
81
+ deleted = {**BUILD_POLICY_ON_MAIN, "isDeleted": True}
82
+ assert policy_configs_include_branch([deleted], REPO_ID, "main", "refs/heads/main") is False
83
+
84
+
85
+ def test_policy_configs_include_branch_ignores_other_repo() -> None:
86
+ assert policy_configs_include_branch([BUILD_POLICY_ON_MAIN], "some-other-repo-id", "main", "refs/heads/main") is False
87
+
88
+
89
+ def test_policy_configs_include_branch_matches_default_branch_scope() -> None:
90
+ default_branch_policy = {
91
+ "isEnabled": True,
92
+ "isDeleted": False,
93
+ "type": {"id": "0609b952-1397-4640-95ec-e00a01b2c241"},
94
+ "settings": {"scope": [{"matchKind": "DefaultBranch", "repositoryId": REPO_ID}]},
95
+ }
96
+ assert policy_configs_include_branch([default_branch_policy], REPO_ID, "main", "refs/heads/main") is True
97
+ assert policy_configs_include_branch([default_branch_policy], REPO_ID, "test", "refs/heads/main") is False
@@ -13,7 +13,7 @@ wheels = [
13
13
 
14
14
  [[package]]
15
15
  name = "bmsdna-devtools"
16
- version = "0.2.1"
16
+ version = "0.2.2"
17
17
  source = { editable = "." }
18
18
  dependencies = [
19
19
  { name = "requests" },
@@ -1,38 +0,0 @@
1
- import pytest
2
-
3
- from bmsdna.devtools.pr_build import merge_conflict_message
4
-
5
-
6
- @pytest.mark.parametrize("merge_status", ["notSet", "queued", "succeeded"])
7
- def test_merge_conflict_message_none_when_mergeable(merge_status: str) -> None:
8
- assert merge_conflict_message({"pullRequestId": 1, "title": "x", "mergeStatus": merge_status}) is None
9
-
10
-
11
- def test_merge_conflict_message_missing_field_is_fine() -> None:
12
- assert merge_conflict_message({"pullRequestId": 1, "title": "x"}) is None
13
-
14
-
15
- def test_merge_conflict_message_conflicts() -> None:
16
- pr = {"pullRequestId": 42, "title": "feat: widgets", "mergeStatus": "conflicts"}
17
- msg = merge_conflict_message(pr)
18
- assert msg is not None
19
- assert "PR #42" in msg
20
- assert "conflicts" in msg
21
-
22
-
23
- def test_merge_conflict_message_uses_failure_message_when_present() -> None:
24
- pr = {
25
- "pullRequestId": 7,
26
- "title": "fix: x",
27
- "mergeStatus": "failure",
28
- "mergeFailureMessage": "object too large to merge",
29
- }
30
- msg = merge_conflict_message(pr)
31
- assert msg is not None
32
- assert "object too large to merge" in msg
33
-
34
-
35
- @pytest.mark.parametrize("merge_status", ["conflicts", "failure", "rejectedByPolicy"])
36
- def test_merge_conflict_message_covers_all_bad_statuses(merge_status: str) -> None:
37
- pr = {"pullRequestId": 1, "title": "x", "mergeStatus": merge_status}
38
- assert merge_conflict_message(pr) is not None