bmsdna-devtools 0.2.0__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.0 → bmsdna_devtools-0.2.2}/PKG-INFO +26 -5
  2. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/README.md +25 -4
  3. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/app_service_logs.py +7 -6
  4. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/cli.py +20 -12
  5. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/commit.py +37 -5
  6. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/env_config.py +8 -2
  7. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/gh_pr.py +28 -0
  8. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/pr_build.py +54 -0
  9. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/pyproject.toml +1 -1
  10. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/skills/bmsdna-devtools/SKILL.md +3 -0
  11. bmsdna_devtools-0.2.2/tests/test_app_service_logs.py +39 -0
  12. bmsdna_devtools-0.2.2/tests/test_commit.py +124 -0
  13. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/tests/test_env_config.py +12 -0
  14. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/tests/test_gh_pr.py +11 -1
  15. bmsdna_devtools-0.2.2/tests/test_pr_build.py +97 -0
  16. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/uv.lock +1 -1
  17. bmsdna_devtools-0.2.0/tests/test_pr_build.py +0 -38
  18. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.github/workflows/python-publish.yml +0 -0
  19. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.github/workflows/python-test.yml +0 -0
  20. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.gitignore +0 -0
  21. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.python-version +0 -0
  22. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/__init__.py +0 -0
  23. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/ado_auth.py +0 -0
  24. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/cli_tools.py +0 -0
  25. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/gitrepo.py +0 -0
  26. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/logs.py +0 -0
  27. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/worktree.py +0 -0
  28. {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/justfile +0 -0
  29. {bmsdna_devtools-0.2.0 → 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.0
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
@@ -134,10 +140,25 @@ Downloads the App Service log archive for a webapp/slot via
134
140
  common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
135
141
  `4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
136
142
  often preferable to the KQL commands above when you just want "what broke
137
- recently" rather than a queryable trace stream — no defaults are baked in
138
- here either.
143
+ recently" rather than a queryable trace stream.
144
+
145
+ The webapp/resource-group/slot come from a named environment configured in
146
+ the calling repo's `pyproject.toml`. `slot` is optional — omit it for an
147
+ app's default/production slot (no `--slot` is passed to `az`); set it for a
148
+ named deployment slot:
149
+
150
+ ```toml
151
+ [tool.bdt.envs.prod]
152
+ webapp = "my-webapp"
153
+ resource_group = "my-rg"
154
+
155
+ [tool.bdt.envs.test]
156
+ webapp = "my-webapp"
157
+ resource_group = "my-rg"
158
+ slot = "test"
159
+ ```
139
160
 
140
161
  ```bash
141
- bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production
142
- bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production --out logs/ --keep-archive
162
+ bdt logs fetch --env prod
163
+ bdt logs fetch --env prod --out logs/ --keep-archive
143
164
  ```
@@ -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
@@ -124,10 +130,25 @@ Downloads the App Service log archive for a webapp/slot via
124
130
  common error/warning marker (`ERROR`, `CRITICAL`, `WARNING`, tracebacks,
125
131
  `4xx`/`5xx`, `FAILED`, `FATAL`) to `<out>/<slot>_errors.log`. Simpler and
126
132
  often preferable to the KQL commands above when you just want "what broke
127
- recently" rather than a queryable trace stream — no defaults are baked in
128
- here either.
133
+ recently" rather than a queryable trace stream.
134
+
135
+ The webapp/resource-group/slot come from a named environment configured in
136
+ the calling repo's `pyproject.toml`. `slot` is optional — omit it for an
137
+ app's default/production slot (no `--slot` is passed to `az`); set it for a
138
+ named deployment slot:
139
+
140
+ ```toml
141
+ [tool.bdt.envs.prod]
142
+ webapp = "my-webapp"
143
+ resource_group = "my-rg"
144
+
145
+ [tool.bdt.envs.test]
146
+ webapp = "my-webapp"
147
+ resource_group = "my-rg"
148
+ slot = "test"
149
+ ```
129
150
 
130
151
  ```bash
131
- bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production
132
- bdt logs fetch --webapp my-webapp --resource-group my-rg --slot production --out logs/ --keep-archive
152
+ bdt logs fetch --env prod
153
+ bdt logs fetch --env prod --out logs/ --keep-archive
133
154
  ```
@@ -28,13 +28,13 @@ def run_az(args: list[str]) -> str:
28
28
  return proc.stdout
29
29
 
30
30
 
31
- def download_logs(webapp: str, resource_group: str, slot: str, archive: pathlib.Path) -> None:
32
- print(f"Downloading logs for {webapp}/{slot}...", flush=True)
31
+ def download_logs(webapp: str, resource_group: str, slot: str | None, archive: pathlib.Path) -> None:
32
+ print(f"Downloading logs for {webapp}/{slot or '(production)'}...", flush=True)
33
33
  run_az([
34
34
  "webapp", "log", "download",
35
35
  "--name", webapp,
36
36
  "--resource-group", resource_group,
37
- "--slot", slot,
37
+ *(["--slot", slot] if slot else []),
38
38
  "--log-file", str(archive),
39
39
  ])
40
40
 
@@ -58,13 +58,14 @@ def extract_errors(archive: pathlib.Path, error_file: pathlib.Path) -> int:
58
58
  def fetch(
59
59
  webapp: str,
60
60
  resource_group: str,
61
- slot: str,
61
+ slot: str | None,
62
62
  out_dir: pathlib.Path,
63
63
  keep_archive: bool = False,
64
64
  ) -> pathlib.Path:
65
65
  out_dir.mkdir(parents=True, exist_ok=True)
66
- archive = out_dir / f"{slot}_logs.zip"
67
- error_file = out_dir / f"{slot}_errors.log"
66
+ label = slot or "production"
67
+ archive = out_dir / f"{label}_logs.zip"
68
+ error_file = out_dir / f"{label}_errors.log"
68
69
 
69
70
  download_logs(webapp, resource_group, slot, archive)
70
71
  count = extract_errors(archive, error_file)
@@ -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")
@@ -131,7 +139,7 @@ def logs_fetch(
131
139
  ) -> None:
132
140
  """Download an App Service log archive and extract error/warning lines."""
133
141
  cfg = env_config.resolve_env(env)
134
- app_service_logs.fetch(cfg["webapp"], cfg["resource_group"], cfg["slot"], out, keep_archive=keep_archive)
142
+ app_service_logs.fetch(cfg["webapp"], cfg["resource_group"], cfg.get("slot"), out, keep_archive=keep_archive)
135
143
 
136
144
 
137
145
  if __name__ == "__main__":
@@ -30,6 +30,26 @@ def _in_sandbox() -> bool:
30
30
  return os.getenv(IS_SANDBOX_ENV_VAR, "0").lower() in ("1", "true", "yes")
31
31
 
32
32
 
33
+ def _staged_deletion(path: str, cwd: str | None = None) -> bool:
34
+ """True if `path` is already staged as a deletion (e.g. via `git rm`) in
35
+ the repo at `cwd`. This is a deliberate-intent signal, not just "HEAD used
36
+ to have this path" — a file that vanished because a write failed or raced
37
+ would be missing from disk but NOT staged as a deletion, so it still
38
+ fails the check below instead of silently being committed as removed."""
39
+ r = _run(["git", "diff", "--cached", "--name-only", "--diff-filter=D", "--", path], cwd=cwd)
40
+ return r.returncode == 0 and path in r.stdout.splitlines()
41
+
42
+
43
+ def _present_or_staged_deletion(path: str, subrepos: list[str]) -> bool:
44
+ if os.path.exists(path):
45
+ return True
46
+ for subrepo in subrepos:
47
+ prefix = subrepo + "/"
48
+ if path.startswith(prefix):
49
+ return _staged_deletion(path[len(prefix):], cwd=subrepo)
50
+ return _staged_deletion(path)
51
+
52
+
33
53
  @dataclass
34
54
  class CommitResult:
35
55
  success: bool
@@ -64,17 +84,29 @@ def _git_commit(message: str, cwd: str | None = None, no_verify: bool = False) -
64
84
  return _run(cmd, cwd=cwd)
65
85
 
66
86
 
87
+ def _add(files: list[str], cwd: str | None) -> None:
88
+ """`git add`, excluding paths already fully removed (disk + index) by a
89
+ prior `git rm` -- those have nothing left to add, and `git add` fails its
90
+ ENTIRE invocation (staging nothing, for any path in the same call) if even
91
+ one pathspec doesn't match anything. Passing only what's actually present
92
+ on disk avoids silently dropping the other files' staged content."""
93
+ root = cwd or "."
94
+ addable = [f for f in files if os.path.exists(os.path.join(root, f))]
95
+ if addable:
96
+ _run(["git", "add", *addable], cwd=cwd)
97
+
98
+
67
99
  def _commit_with_retry(
68
100
  message: str, files: list[str], cwd: str | None, no_verify: bool
69
101
  ) -> tuple[bool, subprocess.CompletedProcess | None]:
70
102
  """Commit, retrying once (re-`git add`) if a pre-commit hook reformatted files."""
71
- _run(["git", "add", *files], cwd=cwd)
103
+ _add(files, cwd)
72
104
  r = _git_commit(message, cwd=cwd, no_verify=no_verify)
73
105
  if r.returncode == 0:
74
106
  return True, None
75
107
  if "nothing to commit" in r.stdout + r.stderr:
76
108
  return False, None
77
- _run(["git", "add", *files], cwd=cwd)
109
+ _add(files, cwd)
78
110
  r2 = _git_commit(message, cwd=cwd, no_verify=no_verify)
79
111
  if r2.returncode == 0:
80
112
  return True, None
@@ -108,8 +140,8 @@ def commit_and_push(
108
140
  print(f" {'✓' if ok else '✗'} {label}", file=sys.stdout if ok else sys.stderr)
109
141
  return ok
110
142
 
111
- missing = [f for f in files if not os.path.exists(f)]
112
- if not check(not missing, "files exist"):
143
+ missing = [f for f in files if not _present_or_staged_deletion(f, subrepos)]
144
+ if not check(not missing, "files exist (or are a staged deletion)"):
113
145
  return CommitResult(
114
146
  False, False, False, message, files,
115
147
  error=f"File not found: {missing[0]} — did you typo the path? Run `git status` to see changed files",
@@ -177,7 +209,7 @@ def commit_and_push(
177
209
  pr = _run(["git", "push"])
178
210
  if pr.returncode != 0:
179
211
  return CommitResult(
180
- True, committed, False, message, files,
212
+ False, committed, False, message, files,
181
213
  commit_sha=_sha(),
182
214
  error=(pr.stdout + pr.stderr).strip(),
183
215
  hint="Push rejected. Run `git pull --rebase`, resolve conflicts, then retry.",
@@ -11,13 +11,19 @@ import sys
11
11
  import tomllib
12
12
  from pathlib import Path
13
13
 
14
- REQUIRED_KEYS = ("webapp", "resource_group", "slot")
14
+ REQUIRED_KEYS = ("webapp", "resource_group")
15
15
 
16
16
  CONFIG_EXAMPLE = """\
17
17
  [tool.bdt.envs.prod]
18
18
  webapp = "my-app"
19
19
  resource_group = "my-app-rg"
20
- slot = "production"
20
+ # slot is optional — omit it for an app's default/production slot,
21
+ # set it for a named deployment slot (e.g. "test", "stage").
22
+
23
+ [tool.bdt.envs.test]
24
+ webapp = "my-app"
25
+ resource_group = "my-app-rg"
26
+ slot = "test"
21
27
  """
22
28
 
23
29
 
@@ -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.0"
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
@@ -0,0 +1,39 @@
1
+ import zipfile
2
+
3
+ from bmsdna.devtools import app_service_logs
4
+
5
+
6
+ def test_download_logs_omits_slot_flag_when_none(monkeypatch, tmp_path):
7
+ captured = {}
8
+
9
+ def fake_run_az(args):
10
+ captured["args"] = args
11
+ return ""
12
+
13
+ monkeypatch.setattr(app_service_logs, "run_az", fake_run_az)
14
+ app_service_logs.download_logs("my-app", "my-rg", None, tmp_path / "archive.zip")
15
+ assert "--slot" not in captured["args"]
16
+
17
+
18
+ def test_download_logs_includes_slot_flag_when_given(monkeypatch, tmp_path):
19
+ captured = {}
20
+
21
+ def fake_run_az(args):
22
+ captured["args"] = args
23
+ return ""
24
+
25
+ monkeypatch.setattr(app_service_logs, "run_az", fake_run_az)
26
+ app_service_logs.download_logs("my-app", "my-rg", "test", tmp_path / "archive.zip")
27
+ assert "--slot" in captured["args"]
28
+ assert captured["args"][captured["args"].index("--slot") + 1] == "test"
29
+
30
+
31
+ def test_fetch_uses_production_label_when_slot_is_none(monkeypatch, tmp_path):
32
+ def fake_download(webapp, resource_group, slot, archive):
33
+ with zipfile.ZipFile(archive, "w"):
34
+ pass
35
+
36
+ monkeypatch.setattr(app_service_logs, "download_logs", fake_download)
37
+ error_file = app_service_logs.fetch("my-app", "my-rg", None, tmp_path, keep_archive=True)
38
+ assert error_file == tmp_path / "production_errors.log"
39
+ assert (tmp_path / "production_logs.zip").exists()
@@ -0,0 +1,124 @@
1
+ import subprocess
2
+
3
+ from bmsdna.devtools.commit import commit_and_push
4
+
5
+
6
+ def init_repo(path):
7
+ subprocess.run(["git", "init", "-q", "-b", "feature"], cwd=path, check=True)
8
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
9
+ subprocess.run(["git", "config", "user.name", "Test"], cwd=path, check=True)
10
+
11
+
12
+ def test_commit_and_push_fails_when_push_has_no_remote(tmp_path, monkeypatch):
13
+ init_repo(tmp_path)
14
+ (tmp_path / "a.txt").write_text("hello")
15
+ monkeypatch.chdir(tmp_path)
16
+
17
+ result = commit_and_push(
18
+ "feat(x): add a.txt",
19
+ ["a.txt"],
20
+ require_message_quality=False,
21
+ require_feature_branch=False,
22
+ )
23
+
24
+ assert result.committed is True
25
+ assert result.pushed is False
26
+ assert result.success is False
27
+ assert result.error
28
+
29
+
30
+ def test_commit_and_push_allows_staged_deletion(tmp_path, monkeypatch):
31
+ init_repo(tmp_path)
32
+ (tmp_path / "a.txt").write_text("hello")
33
+ monkeypatch.chdir(tmp_path)
34
+ subprocess.run(["git", "add", "a.txt"], cwd=tmp_path, check=True)
35
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
36
+
37
+ subprocess.run(["git", "rm", "-q", "a.txt"], cwd=tmp_path, check=True)
38
+
39
+ result = commit_and_push(
40
+ "feat(x): remove a.txt",
41
+ ["a.txt"],
42
+ require_message_quality=False,
43
+ require_feature_branch=False,
44
+ )
45
+
46
+ assert result.committed is True
47
+ assert result.error != "File not found: a.txt — did you typo the path? Run `git status` to see changed files"
48
+
49
+
50
+ def test_commit_and_push_rejects_unstaged_missing_file(tmp_path, monkeypatch):
51
+ """A file that vanished from disk without git being told (e.g. a failed
52
+ write) must still be rejected -- only an already-*staged* deletion (via
53
+ `git rm`) is treated as an intentional removal."""
54
+ init_repo(tmp_path)
55
+ (tmp_path / "a.txt").write_text("hello")
56
+ monkeypatch.chdir(tmp_path)
57
+ subprocess.run(["git", "add", "a.txt"], cwd=tmp_path, check=True)
58
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
59
+
60
+ (tmp_path / "a.txt").unlink()
61
+
62
+ result = commit_and_push(
63
+ "feat(x): update a.txt",
64
+ ["a.txt"],
65
+ require_message_quality=False,
66
+ require_feature_branch=False,
67
+ )
68
+
69
+ assert result.committed is False
70
+ assert result.error == "File not found: a.txt — did you typo the path? Run `git status` to see changed files"
71
+
72
+
73
+ def test_commit_and_push_stages_modified_file_alongside_staged_deletion(tmp_path, monkeypatch):
74
+ """Regression test: `git add <modified-file> <already-git-rm'd-file>` fails
75
+ its ENTIRE invocation (git errors "pathspec did not match any files" for
76
+ the already-removed path, staging nothing at all in that call) -- which
77
+ previously silently dropped the modified file's new content from the
78
+ commit, since _commit_with_retry didn't check git add's exit code."""
79
+ init_repo(tmp_path)
80
+ (tmp_path / "a.md").write_text("old")
81
+ (tmp_path / "gone.py").write_text("old")
82
+ subprocess.run(["git", "add", "a.md", "gone.py"], cwd=tmp_path, check=True)
83
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=tmp_path, check=True)
84
+ monkeypatch.chdir(tmp_path)
85
+
86
+ subprocess.run(["git", "rm", "-q", "gone.py"], cwd=tmp_path, check=True)
87
+ (tmp_path / "a.md").write_text("new content")
88
+
89
+ result = commit_and_push(
90
+ "feat(x): update a.md and remove gone.py",
91
+ ["a.md", "gone.py"],
92
+ require_message_quality=False,
93
+ require_feature_branch=False,
94
+ )
95
+
96
+ assert result.committed is True
97
+ committed_content = subprocess.run(
98
+ ["git", "show", "HEAD:a.md"], cwd=tmp_path, capture_output=True, text=True, check=True
99
+ ).stdout
100
+ assert committed_content == "new content"
101
+
102
+
103
+ def test_commit_and_push_allows_staged_deletion_in_subrepo(tmp_path, monkeypatch):
104
+ subrepo = tmp_path / "database"
105
+ subrepo.mkdir()
106
+ init_repo(subrepo)
107
+ (subrepo / "schema.sql").write_text("create table t();")
108
+ subprocess.run(["git", "add", "schema.sql"], cwd=subrepo, check=True)
109
+ subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=subrepo, check=True)
110
+
111
+ init_repo(tmp_path)
112
+ monkeypatch.chdir(tmp_path)
113
+ subprocess.run(["git", "commit", "-q", "-m", "init", "--allow-empty"], cwd=tmp_path, check=True)
114
+ subprocess.run(["git", "rm", "-q", "schema.sql"], cwd=subrepo, check=True)
115
+
116
+ result = commit_and_push(
117
+ "feat(x): remove schema.sql",
118
+ ["database/schema.sql"],
119
+ require_message_quality=False,
120
+ require_feature_branch=False,
121
+ subrepos=["database"],
122
+ )
123
+
124
+ assert result.error != "File not found: database/schema.sql — did you typo the path? Run `git status` to see changed files"
@@ -93,3 +93,15 @@ slot = "production"
93
93
  "resource_group": "my-app-rg",
94
94
  "slot": "production",
95
95
  }
96
+
97
+
98
+ def test_resolve_env_allows_missing_slot(tmp_path):
99
+ write_pyproject(
100
+ tmp_path,
101
+ """
102
+ [tool.bdt.envs.prod]
103
+ webapp = "my-app"
104
+ resource_group = "my-app-rg"
105
+ """,
106
+ )
107
+ assert resolve_env("prod", tmp_path) == {"webapp": "my-app", "resource_group": "my-app-rg"}
@@ -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.0"
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