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.
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/PKG-INFO +26 -5
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/README.md +25 -4
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/app_service_logs.py +7 -6
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/cli.py +20 -12
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/commit.py +37 -5
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/env_config.py +8 -2
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/gh_pr.py +28 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/pr_build.py +54 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/pyproject.toml +1 -1
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/skills/bmsdna-devtools/SKILL.md +3 -0
- bmsdna_devtools-0.2.2/tests/test_app_service_logs.py +39 -0
- bmsdna_devtools-0.2.2/tests/test_commit.py +124 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/tests/test_env_config.py +12 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/tests/test_gh_pr.py +11 -1
- bmsdna_devtools-0.2.2/tests/test_pr_build.py +97 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/uv.lock +1 -1
- bmsdna_devtools-0.2.0/tests/test_pr_build.py +0 -38
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.github/workflows/python-publish.yml +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.github/workflows/python-test.yml +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.gitignore +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/.python-version +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/__init__.py +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/ado_auth.py +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/cli_tools.py +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/gitrepo.py +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/logs.py +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/bmsdna/devtools/worktree.py +0 -0
- {bmsdna_devtools-0.2.0 → bmsdna_devtools-0.2.2}/justfile +0 -0
- {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.
|
|
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
|
|
138
|
-
|
|
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 --
|
|
142
|
-
bdt logs fetch --
|
|
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
|
|
128
|
-
|
|
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 --
|
|
132
|
-
bdt logs fetch --
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
az
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|