bmsdna-devtools 0.2.0__py3-none-any.whl
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/__init__.py +0 -0
- bmsdna/devtools/ado_auth.py +37 -0
- bmsdna/devtools/app_service_logs.py +76 -0
- bmsdna/devtools/cli.py +138 -0
- bmsdna/devtools/cli_tools.py +31 -0
- bmsdna/devtools/commit.py +204 -0
- bmsdna/devtools/env_config.py +61 -0
- bmsdna/devtools/gh_pr.py +140 -0
- bmsdna/devtools/gitrepo.py +123 -0
- bmsdna/devtools/logs.py +170 -0
- bmsdna/devtools/pr_build.py +206 -0
- bmsdna/devtools/skills/bmsdna-devtools/SKILL.md +111 -0
- bmsdna/devtools/worktree.py +49 -0
- bmsdna_devtools-0.2.0.dist-info/METADATA +143 -0
- bmsdna_devtools-0.2.0.dist-info/RECORD +17 -0
- bmsdna_devtools-0.2.0.dist-info/WHEEL +4 -0
- bmsdna_devtools-0.2.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Azure DevOps auth: explicit PAT, or fall back to the caller's `az` login."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .cli_tools import require_az
|
|
10
|
+
|
|
11
|
+
# Well-known Azure DevOps resource ID for `az account get-access-token`.
|
|
12
|
+
ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_az_devops_token() -> str:
|
|
16
|
+
az = require_az()
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
[az, "account", "get-access-token", "--resource", ADO_RESOURCE_ID, "--query", "accessToken", "-o", "tsv"],
|
|
19
|
+
capture_output=True,
|
|
20
|
+
encoding="utf-8",
|
|
21
|
+
)
|
|
22
|
+
if result.returncode != 0:
|
|
23
|
+
sys.exit(f"az login required and no PAT provided.\n{result.stderr.strip()}")
|
|
24
|
+
return result.stdout.strip()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def auth_header(pat: str | None) -> dict[str, str]:
|
|
28
|
+
"""Basic-auth header for an explicit PAT, else Bearer via `az` token.
|
|
29
|
+
|
|
30
|
+
Never hardcode a PAT literal in a caller — pass it in from an env var
|
|
31
|
+
(e.g. AZURE_DEVOPS_PAT) or a CLI flag, or omit it and let `az` supply a
|
|
32
|
+
short-lived token from the operator's own login.
|
|
33
|
+
"""
|
|
34
|
+
if pat:
|
|
35
|
+
token = base64.b64encode(f":{pat}".encode()).decode()
|
|
36
|
+
return {"Authorization": f"Basic {token}"}
|
|
37
|
+
return {"Authorization": f"Bearer {get_az_devops_token()}"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Download an Azure App Service log archive and filter it for errors/warnings.
|
|
2
|
+
|
|
3
|
+
Pulls the log zip via `az webapp log download`, unpacks it in memory, and
|
|
4
|
+
writes every line matching common error/warning markers to a filtered file.
|
|
5
|
+
The webapp/resource-group/slot for a given `--env` come from the consuming
|
|
6
|
+
repo's pyproject.toml (see env_config.py) rather than being baked in here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import pathlib
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import zipfile
|
|
16
|
+
|
|
17
|
+
from .cli_tools import require_az
|
|
18
|
+
|
|
19
|
+
# Matches common error/warning markers across granian, uvicorn, and python tracebacks.
|
|
20
|
+
ERROR_RE = re.compile(r"\b(ERROR|CRITICAL|WARNING|Traceback|Exception|\b[45]\d\d\b|FAILED|FATAL)\b")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_az(args: list[str]) -> str:
|
|
24
|
+
az = require_az()
|
|
25
|
+
proc = subprocess.run([az, *args], capture_output=True, encoding="utf-8")
|
|
26
|
+
if proc.returncode != 0:
|
|
27
|
+
sys.exit(proc.stderr.strip() or proc.stdout.strip())
|
|
28
|
+
return proc.stdout
|
|
29
|
+
|
|
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)
|
|
33
|
+
run_az([
|
|
34
|
+
"webapp", "log", "download",
|
|
35
|
+
"--name", webapp,
|
|
36
|
+
"--resource-group", resource_group,
|
|
37
|
+
"--slot", slot,
|
|
38
|
+
"--log-file", str(archive),
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def extract_errors(archive: pathlib.Path, error_file: pathlib.Path) -> int:
|
|
43
|
+
"""Unzip the archive and write all matching lines to error_file. Returns count."""
|
|
44
|
+
count = 0
|
|
45
|
+
with zipfile.ZipFile(archive) as zf, error_file.open("w") as out:
|
|
46
|
+
for member in zf.namelist():
|
|
47
|
+
if member.endswith("/"):
|
|
48
|
+
continue
|
|
49
|
+
with zf.open(member) as fh:
|
|
50
|
+
for raw in fh:
|
|
51
|
+
line = raw.decode("utf-8", "replace")
|
|
52
|
+
if ERROR_RE.search(line):
|
|
53
|
+
out.write(f"{member}: {line}")
|
|
54
|
+
count += 1
|
|
55
|
+
return count
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fetch(
|
|
59
|
+
webapp: str,
|
|
60
|
+
resource_group: str,
|
|
61
|
+
slot: str,
|
|
62
|
+
out_dir: pathlib.Path,
|
|
63
|
+
keep_archive: bool = False,
|
|
64
|
+
) -> pathlib.Path:
|
|
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"
|
|
68
|
+
|
|
69
|
+
download_logs(webapp, resource_group, slot, archive)
|
|
70
|
+
count = extract_errors(archive, error_file)
|
|
71
|
+
|
|
72
|
+
if not keep_archive:
|
|
73
|
+
archive.unlink()
|
|
74
|
+
|
|
75
|
+
print(f"✓ {count} error/warning lines → {error_file}")
|
|
76
|
+
return error_file
|
bmsdna/devtools/cli.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from . import app_service_logs, commit as commit_mod
|
|
10
|
+
from . import env_config
|
|
11
|
+
from . import gh_pr
|
|
12
|
+
from . import logs as logs_mod
|
|
13
|
+
from . import pr_build, worktree as worktree_mod
|
|
14
|
+
from .cli_tools import require_az, require_gh
|
|
15
|
+
from .gitrepo import GitHubRemote, current_branch, current_remote
|
|
16
|
+
|
|
17
|
+
# Non-ASCII output (checkmarks, en-dashes in ADO project names, etc.) needs a
|
|
18
|
+
# UTF-8 stream — the default Windows console codepage isn't UTF-8, and would
|
|
19
|
+
# otherwise raise UnicodeEncodeError on the first ✓/✗ printed.
|
|
20
|
+
if sys.platform == "win32":
|
|
21
|
+
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
22
|
+
sys.stderr.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(name="bdt", help="Shared BMS developer tooling: PRs/builds (Azure DevOps or GitHub), worktrees, commits, logs")
|
|
25
|
+
|
|
26
|
+
pr_app = typer.Typer(name="pr", help="Pull request commands (Azure DevOps or GitHub, auto-detected from the git remote)")
|
|
27
|
+
app.add_typer(pr_app, name="pr")
|
|
28
|
+
|
|
29
|
+
logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries")
|
|
30
|
+
app.add_typer(logs_app, name="logs")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pr_app.command("create")
|
|
34
|
+
def pr_create(
|
|
35
|
+
target: str = typer.Option("main", "--target", help="Target branch (e.g. main, test)"),
|
|
36
|
+
args: list[str] = typer.Argument(None, help="Extra args passed through to `az repos pr create` / `gh pr create`"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create a PR from the current branch into --target (Azure DevOps or GitHub, auto-detected)."""
|
|
39
|
+
remote = current_remote()
|
|
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)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pr_app.command("status")
|
|
55
|
+
def pr_status(
|
|
56
|
+
target_branch: str = typer.Option("main", "--target-branch", help="Target branch of the PR (Azure DevOps only — gh has no equivalent filter, it always resolves the PR for the current branch)"),
|
|
57
|
+
wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed"),
|
|
58
|
+
pat: str | None = typer.Option(None, "--pat", envvar="AZURE_DEVOPS_PAT", help="Azure DevOps PAT (else falls back to `az` login)"),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Show build/check status for the PR opened from the current branch (Azure DevOps or GitHub, auto-detected)."""
|
|
61
|
+
remote = current_remote()
|
|
62
|
+
if isinstance(remote, GitHubRemote):
|
|
63
|
+
gh_pr.run(require_gh(), wait)
|
|
64
|
+
return
|
|
65
|
+
pr_build.run(remote, pat, target_branch, wait)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def worktree(
|
|
70
|
+
name: str,
|
|
71
|
+
base: str = typer.Option("dev", "--base", help="Branch to base the new worktree on"),
|
|
72
|
+
env_file: str | None = typer.Option(None, "--env-file", help="File to copy into the worktree as .env (default: auto-detect .local_env then .env)"),
|
|
73
|
+
submodules: bool = typer.Option(True, "--submodules/--no-submodules", help="Run `git submodule update --init` in the new worktree"),
|
|
74
|
+
install: str | None = typer.Option(None, "--install", help="Shell command to run inside the new worktree after creation, e.g. 'just install'"),
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Create a git worktree under .worktrees/<name>, mirroring the `just worktree` recipe."""
|
|
77
|
+
install_cmd = install.split() if install else None
|
|
78
|
+
worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command()
|
|
82
|
+
def commit(
|
|
83
|
+
message: str,
|
|
84
|
+
files: list[str],
|
|
85
|
+
json_output: bool = typer.Option(False, "--json", help="Structured JSON output for AI-agent callers"),
|
|
86
|
+
no_verify: bool = typer.Option(False, "--no-verify", help="Skip pre-commit hooks"),
|
|
87
|
+
subrepo: list[str] = typer.Option([], "--subrepo", help="Submodule directory name to split matching files into (repeatable)"),
|
|
88
|
+
skip_message_check: bool = typer.Option(False, "--skip-message-check", help="Don't require a conventional-commit-style message"),
|
|
89
|
+
allow_main: bool = typer.Option(False, "--allow-main", help="Allow committing directly on main/master"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Stage, commit, and push files, with pre-flight checks and a pre-commit-hook retry."""
|
|
92
|
+
result = commit_mod.commit_and_push(
|
|
93
|
+
message,
|
|
94
|
+
files,
|
|
95
|
+
no_verify=no_verify,
|
|
96
|
+
require_message_quality=not skip_message_check,
|
|
97
|
+
require_feature_branch=not allow_main,
|
|
98
|
+
subrepos=subrepo,
|
|
99
|
+
)
|
|
100
|
+
commit_mod.emit(result, use_json=json_output)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@logs_app.command("roles")
|
|
104
|
+
def logs_roles(
|
|
105
|
+
resource_group: str = typer.Option(..., "--resource-group", envvar="AZURE_RESOURCE_GROUP"),
|
|
106
|
+
app_insights: str = typer.Option(..., "--app-insights", envvar="AZURE_APP_INSIGHTS"),
|
|
107
|
+
minutes: int = typer.Option(30, "--minutes"),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""List cloud_RoleName values seen in the last N minutes (to pick a --role for `logs tail`)."""
|
|
110
|
+
logs_mod.print_roles(app_insights, resource_group, minutes)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@logs_app.command("tail")
|
|
114
|
+
def logs_tail(
|
|
115
|
+
role: str = typer.Option(..., "--role", help="cloud_RoleName to filter; 'all' for no filter"),
|
|
116
|
+
resource_group: str = typer.Option(..., "--resource-group", envvar="AZURE_RESOURCE_GROUP"),
|
|
117
|
+
app_insights: str = typer.Option(..., "--app-insights", envvar="AZURE_APP_INSIGHTS"),
|
|
118
|
+
minutes: int = typer.Option(30, "--minutes"),
|
|
119
|
+
level: str = typer.Option("verbose", "--level", help=f"Minimum severity: {', '.join(logs_mod.SEVERITY_MAP)}"),
|
|
120
|
+
no_color: bool = typer.Option(False, "--no-color"),
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Fetch recent traces/exceptions for a role from Application Insights."""
|
|
123
|
+
logs_mod.print_logs(app_insights, resource_group, minutes, level, role, no_color)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@logs_app.command("fetch")
|
|
127
|
+
def logs_fetch(
|
|
128
|
+
env: str = typer.Option(..., "--env", help="Named environment configured under tool.bdt.envs in pyproject.toml"),
|
|
129
|
+
out: Path = typer.Option(Path("logs"), "--out", help="Output directory for the extracted error log"),
|
|
130
|
+
keep_archive: bool = typer.Option(False, "--keep-archive", help="Keep the downloaded .zip instead of deleting it"),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Download an App Service log archive and extract error/warning lines."""
|
|
133
|
+
cfg = env_config.resolve_env(env)
|
|
134
|
+
app_service_logs.fetch(cfg["webapp"], cfg["resource_group"], cfg["slot"], out, keep_archive=keep_archive)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
app()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Locating required external CLIs (az, gh) with clear errors when missing.
|
|
2
|
+
|
|
3
|
+
Uses `shutil.which` rather than a bare command name so Windows .cmd/.bat/.exe
|
|
4
|
+
shims (e.g. az.cmd from the MSI installer) resolve correctly via PATHEXT —
|
|
5
|
+
the same lookup `where`/`Get-Command` would do — instead of guessing an
|
|
6
|
+
extension or relying on shell=True (which also avoids any shell-quoting
|
|
7
|
+
concerns for arguments that come from user input, e.g. PR titles).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli"
|
|
16
|
+
GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def require_tool(name: str, install_hint: str) -> str:
|
|
20
|
+
path = shutil.which(name)
|
|
21
|
+
if not path:
|
|
22
|
+
sys.exit(f"'{name}' is required for this command but wasn't found on PATH.\n{install_hint}")
|
|
23
|
+
return path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_az() -> str:
|
|
27
|
+
return require_tool("az", AZ_INSTALL_HINT)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def require_gh() -> str:
|
|
31
|
+
return require_tool("gh", GH_INSTALL_HINT)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Commit-and-push helper: pre-flight checks, one retry after a pre-commit
|
|
2
|
+
reformat, optional JSON output for AI-agent callers, optional subrepo split
|
|
3
|
+
for repos that vendor a submodule (e.g. a `database/` git submodule).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
IS_SANDBOX_ENV_VAR = "IS_BMS_AI_SANDBOX"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess:
|
|
18
|
+
try:
|
|
19
|
+
return subprocess.run(cmd, capture_output=True, encoding="utf-8", cwd=cwd)
|
|
20
|
+
except FileNotFoundError:
|
|
21
|
+
sys.exit("'git' is required for this command but wasn't found on PATH.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha(cwd: str | None = None) -> str | None:
|
|
25
|
+
r = _run(["git", "rev-parse", "--short", "HEAD"], cwd=cwd)
|
|
26
|
+
return r.stdout.strip() if r.returncode == 0 else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _in_sandbox() -> bool:
|
|
30
|
+
return os.getenv(IS_SANDBOX_ENV_VAR, "0").lower() in ("1", "true", "yes")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CommitResult:
|
|
35
|
+
success: bool
|
|
36
|
+
committed: bool
|
|
37
|
+
pushed: bool
|
|
38
|
+
message: str
|
|
39
|
+
files: list[str]
|
|
40
|
+
commit_sha: str | None = None
|
|
41
|
+
error: str | None = None
|
|
42
|
+
hint: str | None = None
|
|
43
|
+
extra: dict = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def as_dict(self) -> dict:
|
|
46
|
+
d = {
|
|
47
|
+
"success": self.success,
|
|
48
|
+
"committed": self.committed,
|
|
49
|
+
"pushed": self.pushed,
|
|
50
|
+
"message": self.message,
|
|
51
|
+
"files": self.files,
|
|
52
|
+
"commit_sha": self.commit_sha,
|
|
53
|
+
"error": self.error,
|
|
54
|
+
"hint": self.hint,
|
|
55
|
+
}
|
|
56
|
+
d.update(self.extra)
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _git_commit(message: str, cwd: str | None = None, no_verify: bool = False) -> subprocess.CompletedProcess:
|
|
61
|
+
cmd = ["git", "commit", "-m", message]
|
|
62
|
+
if no_verify:
|
|
63
|
+
cmd.append("--no-verify")
|
|
64
|
+
return _run(cmd, cwd=cwd)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _commit_with_retry(
|
|
68
|
+
message: str, files: list[str], cwd: str | None, no_verify: bool
|
|
69
|
+
) -> tuple[bool, subprocess.CompletedProcess | None]:
|
|
70
|
+
"""Commit, retrying once (re-`git add`) if a pre-commit hook reformatted files."""
|
|
71
|
+
_run(["git", "add", *files], cwd=cwd)
|
|
72
|
+
r = _git_commit(message, cwd=cwd, no_verify=no_verify)
|
|
73
|
+
if r.returncode == 0:
|
|
74
|
+
return True, None
|
|
75
|
+
if "nothing to commit" in r.stdout + r.stderr:
|
|
76
|
+
return False, None
|
|
77
|
+
_run(["git", "add", *files], cwd=cwd)
|
|
78
|
+
r2 = _git_commit(message, cwd=cwd, no_verify=no_verify)
|
|
79
|
+
if r2.returncode == 0:
|
|
80
|
+
return True, None
|
|
81
|
+
return False, r2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def commit_and_push(
|
|
85
|
+
message: str,
|
|
86
|
+
files: list[str],
|
|
87
|
+
*,
|
|
88
|
+
no_verify: bool = False,
|
|
89
|
+
require_message_quality: bool = True,
|
|
90
|
+
require_feature_branch: bool = True,
|
|
91
|
+
subrepos: list[str] | None = None,
|
|
92
|
+
) -> CommitResult:
|
|
93
|
+
"""Stage, commit, and push `files`, applying the same pre-flight checks
|
|
94
|
+
and pre-commit-hook retry as the per-repo `commit.py` scripts.
|
|
95
|
+
|
|
96
|
+
`subrepos` is a list of submodule directory names (e.g. ["database"]);
|
|
97
|
+
files under one of those prefixes are committed/pushed inside the
|
|
98
|
+
submodule first, then the submodule bump is staged in the parent repo.
|
|
99
|
+
"""
|
|
100
|
+
# Normalize to forward slashes so subrepo-prefix matching below works the
|
|
101
|
+
# same whether a caller passes "database/x.sql" or "database\x.sql" (both
|
|
102
|
+
# os.path.exists and git accept either separator fine on Windows).
|
|
103
|
+
files = [f.replace("\\", "/") for f in files]
|
|
104
|
+
subrepos = subrepos or []
|
|
105
|
+
print("pre-flight checks:", flush=True)
|
|
106
|
+
|
|
107
|
+
def check(ok: bool, label: str) -> bool:
|
|
108
|
+
print(f" {'✓' if ok else '✗'} {label}", file=sys.stdout if ok else sys.stderr)
|
|
109
|
+
return ok
|
|
110
|
+
|
|
111
|
+
missing = [f for f in files if not os.path.exists(f)]
|
|
112
|
+
if not check(not missing, "files exist"):
|
|
113
|
+
return CommitResult(
|
|
114
|
+
False, False, False, message, files,
|
|
115
|
+
error=f"File not found: {missing[0]} — did you typo the path? Run `git status` to see changed files",
|
|
116
|
+
hint=f"Run `git status` to see what files are actually changed. Missing: {missing}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
msg_ok = not require_message_quality or (len(message) >= 20 and ":" in message)
|
|
120
|
+
if not check(msg_ok, "commit message quality (len>=20, has colon)"):
|
|
121
|
+
return CommitResult(
|
|
122
|
+
False, False, False, message, files,
|
|
123
|
+
error=f"Commit message too short or missing type prefix (e.g. 'feat(x): ...') — got: {message!r}",
|
|
124
|
+
hint="Use conventional commits format: 'feat(scope): description' or 'fix: description'",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
current_branch = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
|
128
|
+
branch_ok = not require_feature_branch or current_branch not in ("main", "master")
|
|
129
|
+
if not check(branch_ok, f"not on main/master (branch: {current_branch})"):
|
|
130
|
+
return CommitResult(
|
|
131
|
+
False, False, False, message, files,
|
|
132
|
+
error=f"Direct push to {current_branch} blocked — create a feature branch first",
|
|
133
|
+
hint="Run `git checkout -b feat/my-branch` to create a feature branch first.",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
main_files = list(files)
|
|
137
|
+
for subrepo in subrepos:
|
|
138
|
+
prefix = subrepo + "/"
|
|
139
|
+
subrepo_files = [f[len(prefix):] for f in files if f.startswith(prefix)]
|
|
140
|
+
main_files = [f for f in main_files if not f.startswith(prefix) and f != subrepo]
|
|
141
|
+
if not subrepo_files:
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
ok, failure = _commit_with_retry(message, subrepo_files, cwd=subrepo, no_verify=no_verify)
|
|
145
|
+
if failure is not None:
|
|
146
|
+
return CommitResult(
|
|
147
|
+
False, False, False, message, subrepo_files,
|
|
148
|
+
error=(failure.stdout + failure.stderr).strip(),
|
|
149
|
+
hint="Pre-commit hook may have failed in the subrepo. Check the error output above.",
|
|
150
|
+
)
|
|
151
|
+
if ok and not _in_sandbox():
|
|
152
|
+
pr = _run(["git", "push"], cwd=subrepo)
|
|
153
|
+
if pr.returncode != 0:
|
|
154
|
+
return CommitResult(
|
|
155
|
+
False, True, False, message, subrepo_files,
|
|
156
|
+
commit_sha=_sha(cwd=subrepo),
|
|
157
|
+
error=(pr.stdout + pr.stderr).strip(),
|
|
158
|
+
hint=f"Push failed. Try `git pull --rebase` in the {subrepo} submodule.",
|
|
159
|
+
)
|
|
160
|
+
print(f" ✓ {subrepo} subrepo pushed", flush=True)
|
|
161
|
+
if ok:
|
|
162
|
+
main_files.append(subrepo)
|
|
163
|
+
|
|
164
|
+
ok, failure = _commit_with_retry(message, main_files, cwd=None, no_verify=no_verify)
|
|
165
|
+
if failure is not None:
|
|
166
|
+
return CommitResult(
|
|
167
|
+
False, False, False, message, files,
|
|
168
|
+
error=(failure.stdout + failure.stderr).strip(),
|
|
169
|
+
hint="Pre-commit hook may have reformatted files and failed. Check output.",
|
|
170
|
+
)
|
|
171
|
+
committed = ok
|
|
172
|
+
print(" ✓ committed", flush=True)
|
|
173
|
+
|
|
174
|
+
if _in_sandbox():
|
|
175
|
+
return CommitResult(True, committed, False, message, files, commit_sha=_sha())
|
|
176
|
+
|
|
177
|
+
pr = _run(["git", "push"])
|
|
178
|
+
if pr.returncode != 0:
|
|
179
|
+
return CommitResult(
|
|
180
|
+
True, committed, False, message, files,
|
|
181
|
+
commit_sha=_sha(),
|
|
182
|
+
error=(pr.stdout + pr.stderr).strip(),
|
|
183
|
+
hint="Push rejected. Run `git pull --rebase`, resolve conflicts, then retry.",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
print(" ✓ pushed", flush=True)
|
|
187
|
+
return CommitResult(True, committed, True, message, files, commit_sha=_sha())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def emit(result: CommitResult, *, use_json: bool) -> None:
|
|
191
|
+
if use_json:
|
|
192
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
193
|
+
elif result.success:
|
|
194
|
+
if not result.committed:
|
|
195
|
+
print("nothing to commit")
|
|
196
|
+
elif not result.pushed:
|
|
197
|
+
print("committed (sandbox mode, push handled separately)")
|
|
198
|
+
else:
|
|
199
|
+
print("committed & pushed")
|
|
200
|
+
else:
|
|
201
|
+
print(f"ERROR: {result.error}", file=sys.stderr)
|
|
202
|
+
if result.hint:
|
|
203
|
+
print(f"HINT: {result.hint}", file=sys.stderr)
|
|
204
|
+
sys.exit(0 if result.success else 1)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Named environments for `bdt logs fetch`, configured in the consuming repo's
|
|
2
|
+
pyproject.toml under [tool.bdt.envs.<name>].
|
|
3
|
+
|
|
4
|
+
This lets a repo define its webapp/resource-group/slot combinations once
|
|
5
|
+
(e.g. "prod", "staging") instead of passing all three flags on every call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
REQUIRED_KEYS = ("webapp", "resource_group", "slot")
|
|
15
|
+
|
|
16
|
+
CONFIG_EXAMPLE = """\
|
|
17
|
+
[tool.bdt.envs.prod]
|
|
18
|
+
webapp = "my-app"
|
|
19
|
+
resource_group = "my-app-rg"
|
|
20
|
+
slot = "production"
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _find_pyproject(start: Path) -> Path | None:
|
|
25
|
+
for directory in (start, *start.parents):
|
|
26
|
+
candidate = directory / "pyproject.toml"
|
|
27
|
+
if candidate.is_file():
|
|
28
|
+
return candidate
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_envs(start: Path | None = None) -> dict[str, dict[str, str]]:
|
|
33
|
+
path = _find_pyproject(start or Path.cwd())
|
|
34
|
+
if path is None:
|
|
35
|
+
return {}
|
|
36
|
+
with path.open("rb") as f:
|
|
37
|
+
data = tomllib.load(f)
|
|
38
|
+
envs = data.get("tool", {}).get("bdt", {}).get("envs", {})
|
|
39
|
+
return envs if isinstance(envs, dict) else {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_env(name: str, start: Path | None = None) -> dict[str, str]:
|
|
43
|
+
"""Look up a named environment, exiting with an actionable message if it's missing."""
|
|
44
|
+
envs = load_envs(start)
|
|
45
|
+
if not envs:
|
|
46
|
+
sys.exit(
|
|
47
|
+
"ERROR: no environments configured. Add a [tool.bdt.envs.<name>] table "
|
|
48
|
+
f"to pyproject.toml, e.g.:\n\n{CONFIG_EXAMPLE}"
|
|
49
|
+
)
|
|
50
|
+
if name not in envs:
|
|
51
|
+
available = ", ".join(sorted(envs))
|
|
52
|
+
sys.exit(f"ERROR: unknown --env '{name}'. Configured environments: {available}")
|
|
53
|
+
|
|
54
|
+
env = envs[name]
|
|
55
|
+
missing = [k for k in REQUIRED_KEYS if k not in env]
|
|
56
|
+
if missing:
|
|
57
|
+
sys.exit(
|
|
58
|
+
f"ERROR: [tool.bdt.envs.{name}] in pyproject.toml is missing required "
|
|
59
|
+
f"key(s): {', '.join(missing)}"
|
|
60
|
+
)
|
|
61
|
+
return env
|