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
bmsdna/devtools/gh_pr.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""GitHub PR merge/check status and creation via the `gh` CLI.
|
|
2
|
+
|
|
3
|
+
Deliberately avoids `gh pr checks --json` — that flag was only added in a
|
|
4
|
+
later `gh` release than some machines still run (confirmed missing on gh
|
|
5
|
+
2.45.0). Everything here is built on `gh pr view --json ...`, whose --json
|
|
6
|
+
support has been stable for a long time, plus statusCheckRollup entries
|
|
7
|
+
categorized ourselves using GitHub's documented GraphQL enums.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
PR_VIEW_FIELDS = "number,title,baseRefName,mergeable,statusCheckRollup"
|
|
18
|
+
|
|
19
|
+
# PullRequest.mergeable (GraphQL MergeableState).
|
|
20
|
+
CONFLICTING = "CONFLICTING"
|
|
21
|
+
UNKNOWN_MERGEABLE = "UNKNOWN"
|
|
22
|
+
|
|
23
|
+
# statusCheckRollup entries are a union of CheckRun | StatusContext.
|
|
24
|
+
# CheckRun.conclusion (GraphQL CheckConclusionState) -> bucket.
|
|
25
|
+
_CHECK_RUN_BUCKET = {
|
|
26
|
+
"SUCCESS": "pass",
|
|
27
|
+
"NEUTRAL": "pass",
|
|
28
|
+
"SKIPPED": "skipping",
|
|
29
|
+
"CANCELLED": "cancel",
|
|
30
|
+
"FAILURE": "fail",
|
|
31
|
+
"TIMED_OUT": "fail",
|
|
32
|
+
"ACTION_REQUIRED": "fail",
|
|
33
|
+
"STALE": "fail",
|
|
34
|
+
}
|
|
35
|
+
# Legacy commit Status.state (GraphQL StatusState) -> bucket.
|
|
36
|
+
_STATUS_CONTEXT_BUCKET = {
|
|
37
|
+
"SUCCESS": "pass",
|
|
38
|
+
"PENDING": "pending",
|
|
39
|
+
"EXPECTED": "pending",
|
|
40
|
+
"ERROR": "fail",
|
|
41
|
+
"FAILURE": "fail",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_gh_json(gh: str, args: list[str]) -> dict:
|
|
46
|
+
r = subprocess.run([gh, *args], capture_output=True, encoding="utf-8")
|
|
47
|
+
if r.returncode != 0:
|
|
48
|
+
sys.exit((r.stderr or r.stdout).strip() or f"`gh {' '.join(args)}` failed")
|
|
49
|
+
return json.loads(r.stdout)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_pr(gh: str) -> dict:
|
|
53
|
+
"""The PR for the current branch, however `gh` resolves it — there's no
|
|
54
|
+
target-branch filter on `gh pr view` the way ADO's search API has one.
|
|
55
|
+
"""
|
|
56
|
+
return _run_gh_json(gh, ["pr", "view", "--json", PR_VIEW_FIELDS])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def check_bucket(check: dict) -> str:
|
|
60
|
+
if check.get("__typename") == "StatusContext":
|
|
61
|
+
return _STATUS_CONTEXT_BUCKET.get(check.get("state"), "pending")
|
|
62
|
+
if check.get("status") != "COMPLETED":
|
|
63
|
+
return "pending"
|
|
64
|
+
return _CHECK_RUN_BUCKET.get(check.get("conclusion"), "fail")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_label(check: dict) -> str:
|
|
68
|
+
name = check.get("name", "?")
|
|
69
|
+
workflow = check.get("workflowName")
|
|
70
|
+
return f"{workflow} / {name}" if workflow and workflow not in name else name
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def merge_conflict_message(pr: dict) -> str | None:
|
|
74
|
+
if pr.get("mergeable") != CONFLICTING:
|
|
75
|
+
return None
|
|
76
|
+
return f"PR #{pr.get('number')} ({pr.get('title', '?')!r}) has merge conflicts with '{pr.get('baseRefName', '?')}' (mergeable=CONFLICTING)"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_check(check: dict) -> None:
|
|
80
|
+
bucket = check_bucket(check)
|
|
81
|
+
icon = {"pass": "✓", "fail": "✗", "cancel": "⊘"}.get(bucket, "…")
|
|
82
|
+
print(f" [{icon} {bucket.upper()}] {check_label(check)}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run(gh: str, wait: bool) -> None:
|
|
86
|
+
last_line = ""
|
|
87
|
+
while True:
|
|
88
|
+
pr = get_pr(gh)
|
|
89
|
+
|
|
90
|
+
# GitHub hasn't finished computing mergeability yet (usually resolves
|
|
91
|
+
# within a couple seconds); worth a short wait even outside --wait mode
|
|
92
|
+
# isn't safe (could spin forever if it never resolves) — only retry
|
|
93
|
+
# when the caller already opted into waiting.
|
|
94
|
+
if pr.get("mergeable") == UNKNOWN_MERGEABLE and wait:
|
|
95
|
+
time.sleep(3)
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
conflict = merge_conflict_message(pr)
|
|
99
|
+
if conflict:
|
|
100
|
+
sys.exit(conflict)
|
|
101
|
+
|
|
102
|
+
pr_number = pr.get("number")
|
|
103
|
+
title = pr.get("title", "?")
|
|
104
|
+
base = pr.get("baseRefName", "?")
|
|
105
|
+
msg = f"\rPR #{pr_number}: {title} (base={base})"
|
|
106
|
+
|
|
107
|
+
checks = pr.get("statusCheckRollup") or []
|
|
108
|
+
if not checks:
|
|
109
|
+
print(msg + " | no checks found.")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
buckets = [check_bucket(c) for c in checks]
|
|
113
|
+
msg += " | " + ", ".join(f"{check_label(c)}: {check_bucket(c)}" for c in checks)
|
|
114
|
+
|
|
115
|
+
if "pending" in buckets and wait:
|
|
116
|
+
if msg != last_line:
|
|
117
|
+
print(msg, end="", flush=True)
|
|
118
|
+
last_line = msg
|
|
119
|
+
time.sleep(30)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
print(msg)
|
|
123
|
+
print("\nDetails:")
|
|
124
|
+
for c in checks:
|
|
125
|
+
print_check(c)
|
|
126
|
+
|
|
127
|
+
if "fail" in buckets:
|
|
128
|
+
sys.exit(1)
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def create(gh: str, target: str, extra_args: list[str]) -> int:
|
|
133
|
+
"""Create a GitHub PR from the current branch into `target`.
|
|
134
|
+
|
|
135
|
+
--fill autofills title/body from commit info so this never blocks on an
|
|
136
|
+
interactive prompt; pass --title/--body in extra_args to override (gh
|
|
137
|
+
lets explicit values take precedence over --fill).
|
|
138
|
+
"""
|
|
139
|
+
cmd = [gh, "pr", "create", "--base", target, "--fill", *extra_args]
|
|
140
|
+
return subprocess.run(cmd).returncode
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Git-remote inspection: figure out which host (Azure DevOps or GitHub) the
|
|
2
|
+
current repo's `origin` points at, and parse out its org/project/repo (ADO)
|
|
3
|
+
or owner/repo (GitHub) so callers don't have to.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from urllib.parse import unquote
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotAzureDevOpsRemoteError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NotGitHubRemoteError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnknownRemoteError(Exception):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AdoRemote:
|
|
29
|
+
org: str
|
|
30
|
+
project: str
|
|
31
|
+
repo: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class GitHubRemote:
|
|
36
|
+
owner: str
|
|
37
|
+
repo: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_git(args: list[str], cwd: str | None = None) -> str:
|
|
41
|
+
try:
|
|
42
|
+
return subprocess.check_output(["git", *args], encoding="utf-8", cwd=cwd).strip()
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
sys.exit("'git' is required for this command but wasn't found on PATH.")
|
|
45
|
+
except subprocess.CalledProcessError as e:
|
|
46
|
+
sys.exit((e.stderr or e.stdout or str(e)).strip() if isinstance(e.stderr, str) else str(e))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def current_branch(cwd: str | None = None) -> str:
|
|
50
|
+
return _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def origin_url(cwd: str | None = None) -> str:
|
|
54
|
+
return _run_git(["remote", "get-url", "origin"], cwd=cwd)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_ado_remote(url: str) -> AdoRemote:
|
|
58
|
+
"""Parse an Azure DevOps org/project/repo out of a git remote URL.
|
|
59
|
+
|
|
60
|
+
Handles SSH (git@ssh.dev.azure.com:v3/org/project/repo), dev.azure.com
|
|
61
|
+
HTTPS, and the older *.visualstudio.com HTTPS form. Project/repo names
|
|
62
|
+
are unquoted since ADO allows spaces and punctuation (e.g. "BMS - Data").
|
|
63
|
+
"""
|
|
64
|
+
if url.startswith("git@ssh.dev.azure.com"):
|
|
65
|
+
path = url.split(":", 1)[1]
|
|
66
|
+
parts = path.strip("/").split("/")
|
|
67
|
+
if parts[0] == "v3":
|
|
68
|
+
parts = parts[1:]
|
|
69
|
+
return AdoRemote(parts[0], unquote(parts[1]), unquote(parts[2]))
|
|
70
|
+
|
|
71
|
+
parts = url.rstrip("/").split("/")
|
|
72
|
+
if "dev.azure.com" in url:
|
|
73
|
+
for i, part in enumerate(parts):
|
|
74
|
+
if "dev.azure.com" in part:
|
|
75
|
+
org = parts[i + 1]
|
|
76
|
+
project = unquote(parts[i + 2])
|
|
77
|
+
if i + 4 < len(parts) and parts[i + 3] == "_git":
|
|
78
|
+
repo = unquote(parts[i + 4])
|
|
79
|
+
else:
|
|
80
|
+
repo = unquote(parts[i + 3])
|
|
81
|
+
return AdoRemote(org, project, repo)
|
|
82
|
+
elif "visualstudio.com" in url:
|
|
83
|
+
org = parts[2].split(".")[0]
|
|
84
|
+
project = unquote(parts[3])
|
|
85
|
+
if len(parts) > 5 and parts[4] == "_git":
|
|
86
|
+
repo = unquote(parts[5])
|
|
87
|
+
else:
|
|
88
|
+
repo = unquote(parts[4])
|
|
89
|
+
return AdoRemote(org, project, repo)
|
|
90
|
+
|
|
91
|
+
raise NotAzureDevOpsRemoteError(f"Could not parse Azure DevOps info from remote URL: {url}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Matches git@github.com:owner/repo(.git), ssh://git@github.com/owner/repo(.git),
|
|
95
|
+
# and https://[user@]github.com/owner/repo(.git).
|
|
96
|
+
_GITHUB_RE = re.compile(r"github\.com[:/]([^/]+)/(.+?)(?:\.git)?/?$")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_github_remote(url: str) -> GitHubRemote:
|
|
100
|
+
match = _GITHUB_RE.search(url)
|
|
101
|
+
if not match:
|
|
102
|
+
raise NotGitHubRemoteError(f"Could not parse GitHub owner/repo from remote URL: {url}")
|
|
103
|
+
return GitHubRemote(match.group(1), match.group(2))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_remote(url: str) -> AdoRemote | GitHubRemote:
|
|
107
|
+
"""Parse whichever of GitHub or Azure DevOps `url` points at."""
|
|
108
|
+
if "github.com" in url:
|
|
109
|
+
return parse_github_remote(url)
|
|
110
|
+
try:
|
|
111
|
+
return parse_ado_remote(url)
|
|
112
|
+
except NotAzureDevOpsRemoteError:
|
|
113
|
+
raise UnknownRemoteError(
|
|
114
|
+
f"'{url}' doesn't look like a GitHub or Azure DevOps remote — only those two are supported."
|
|
115
|
+
) from None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def current_remote(cwd: str | None = None) -> AdoRemote | GitHubRemote:
|
|
119
|
+
return parse_remote(origin_url(cwd))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def current_ado_remote(cwd: str | None = None) -> AdoRemote:
|
|
123
|
+
return parse_ado_remote(origin_url(cwd))
|
bmsdna/devtools/logs.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Fetch recent Application Insights / Log Analytics logs via `az monitor app-insights query`.
|
|
2
|
+
|
|
3
|
+
Unlike the other commands, this one takes no repo-specific defaults: pass
|
|
4
|
+
--resource-group/--app-insights explicitly (or set AZURE_RESOURCE_GROUP /
|
|
5
|
+
AZURE_APP_INSIGHTS), since which Azure resource "this repo" maps to isn't
|
|
6
|
+
derivable from the git remote the way Azure DevOps org/project/repo is.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
|
|
16
|
+
from .cli_tools import require_az
|
|
17
|
+
|
|
18
|
+
SEVERITY_MAP = {"verbose": 0, "information": 1, "warning": 2, "error": 3, "critical": 4}
|
|
19
|
+
|
|
20
|
+
COLORS = {
|
|
21
|
+
"CRITICAL": "\033[95m",
|
|
22
|
+
"ERROR ": "\033[91m",
|
|
23
|
+
"WARNING ": "\033[93m",
|
|
24
|
+
"INFO ": "\033[97m",
|
|
25
|
+
"VERBOSE ": "\033[90m",
|
|
26
|
+
"RESET": "\033[0m",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_az(*args: str) -> tuple[int, str]:
|
|
31
|
+
az = require_az()
|
|
32
|
+
# Never shell=True here: KQL queries contain `|`, which a Windows cmd.exe
|
|
33
|
+
# shell would reinterpret as a pipe instead of passing through literally.
|
|
34
|
+
result = subprocess.run([az, *args], capture_output=True, encoding="utf-8")
|
|
35
|
+
return result.returncode, result.stdout + result.stderr
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def find_app_insights(resource_group: str) -> str | None:
|
|
39
|
+
code, out = run_az(
|
|
40
|
+
"resource", "list",
|
|
41
|
+
"--resource-group", resource_group,
|
|
42
|
+
"--resource-type", "Microsoft.Insights/components",
|
|
43
|
+
"--query", "[0].name",
|
|
44
|
+
"--output", "tsv",
|
|
45
|
+
)
|
|
46
|
+
name = out.strip()
|
|
47
|
+
return name if code == 0 and name else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def query_roles(app_insights: str, resource_group: str, minutes: int) -> tuple[dict[str, int] | None, str | None]:
|
|
51
|
+
kql = f"""
|
|
52
|
+
union traces, exceptions
|
|
53
|
+
| where timestamp > ago({minutes}m)
|
|
54
|
+
| summarize count() by cloud_RoleName
|
|
55
|
+
| order by count_ desc
|
|
56
|
+
""".strip()
|
|
57
|
+
code, out = run_az(
|
|
58
|
+
"monitor", "app-insights", "query",
|
|
59
|
+
"--app", app_insights,
|
|
60
|
+
"--resource-group", resource_group,
|
|
61
|
+
"--analytics-query", kql,
|
|
62
|
+
"--output", "json",
|
|
63
|
+
)
|
|
64
|
+
if code != 0:
|
|
65
|
+
return None, out
|
|
66
|
+
try:
|
|
67
|
+
table = json.loads(out)["tables"][0]
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
table = json.loads(out.split("\n")[0])["tables"][0]
|
|
70
|
+
col_names = [c["name"] for c in table["columns"]]
|
|
71
|
+
role_col = next((i for i, n in enumerate(col_names) if n == "cloud_RoleName"), None)
|
|
72
|
+
count_col = next((i for i, n in enumerate(col_names) if n == "count_"), None)
|
|
73
|
+
if role_col is None:
|
|
74
|
+
return None, f"cloud_RoleName column not found; got: {col_names}"
|
|
75
|
+
counts: dict[str, int] = {}
|
|
76
|
+
for row in table["rows"]:
|
|
77
|
+
role = row[role_col] or "(unknown)"
|
|
78
|
+
counts[role] = int(row[count_col]) if count_col is not None and row[count_col] else 1
|
|
79
|
+
return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True)), None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def query_logs(
|
|
83
|
+
app_insights: str, resource_group: str, minutes: int, min_severity: int, role: str
|
|
84
|
+
) -> tuple[list | None, str | None]:
|
|
85
|
+
role_filter = "" if role == "all" else f'| where cloud_RoleName == "{role}"'
|
|
86
|
+
kql = f"""
|
|
87
|
+
union traces, exceptions
|
|
88
|
+
| where timestamp > ago({minutes}m)
|
|
89
|
+
| where severityLevel >= {min_severity}
|
|
90
|
+
{role_filter}
|
|
91
|
+
| order by timestamp asc
|
|
92
|
+
| take 500
|
|
93
|
+
""".strip()
|
|
94
|
+
code, out = run_az(
|
|
95
|
+
"monitor", "app-insights", "query",
|
|
96
|
+
"--app", app_insights,
|
|
97
|
+
"--resource-group", resource_group,
|
|
98
|
+
"--analytics-query", kql,
|
|
99
|
+
"--output", "json",
|
|
100
|
+
)
|
|
101
|
+
if code != 0:
|
|
102
|
+
return None, out
|
|
103
|
+
try:
|
|
104
|
+
table = json.loads(out)["tables"][0]
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
table = json.JSONDecoder().raw_decode(out)[0]["tables"][0]
|
|
107
|
+
col_index = {col["name"]: i for i, col in enumerate(table["columns"])}
|
|
108
|
+
|
|
109
|
+
def col(row: list, *names: str) -> str:
|
|
110
|
+
for name in names:
|
|
111
|
+
if name in col_index:
|
|
112
|
+
return row[col_index[name]] or ""
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
results = []
|
|
116
|
+
for row in table["rows"]:
|
|
117
|
+
sev: int | None = row[col_index["severityLevel"]] if "severityLevel" in col_index else None
|
|
118
|
+
lvl = (
|
|
119
|
+
{0: "VERBOSE ", 1: "INFO ", 2: "WARNING ", 3: "ERROR ", 4: "CRITICAL"}.get(sev, "UNKNOWN ")
|
|
120
|
+
if sev is not None else "UNKNOWN "
|
|
121
|
+
)
|
|
122
|
+
msg = col(row, "message", "outerMessage", "innermostMessage")
|
|
123
|
+
item_type = col(row, "itemType")
|
|
124
|
+
exc = f" | {col(row, 'type')}: {col(row, 'outerMessage')}" if item_type == "exception" else ""
|
|
125
|
+
results.append({"timestamp": row[col_index["timestamp"]], "lvl": lvl, "msg": msg, "exc": exc})
|
|
126
|
+
return results, None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def print_roles(app_insights: str, resource_group: str, minutes: int) -> None:
|
|
130
|
+
print(f"Querying roles for last {minutes} min from {app_insights}...", flush=True)
|
|
131
|
+
roles, err = query_roles(app_insights, resource_group, minutes)
|
|
132
|
+
if roles is None:
|
|
133
|
+
found = find_app_insights(resource_group)
|
|
134
|
+
if not found:
|
|
135
|
+
sys.exit(f"ERROR: No App Insights in {resource_group}.")
|
|
136
|
+
roles, err = query_roles(found, resource_group, minutes)
|
|
137
|
+
if roles is None:
|
|
138
|
+
sys.exit(f"ERROR: {err}")
|
|
139
|
+
print(f"\n{'Role':<50} {'Messages':>10}\n" + "-" * 62)
|
|
140
|
+
for role, count in roles.items():
|
|
141
|
+
print(f"{role:<50} {count:>10}")
|
|
142
|
+
print("\nPass --role <name> to fetch logs for a specific role.")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def print_logs(
|
|
146
|
+
app_insights: str, resource_group: str, minutes: int, level: str, role: str, no_color: bool
|
|
147
|
+
) -> None:
|
|
148
|
+
min_severity = SEVERITY_MAP[level]
|
|
149
|
+
print(f"Querying last {minutes} min | role={role} | severity>={level}...", flush=True)
|
|
150
|
+
rows, err = query_logs(app_insights, resource_group, minutes, min_severity, role)
|
|
151
|
+
if rows is None:
|
|
152
|
+
found = find_app_insights(resource_group)
|
|
153
|
+
if not found:
|
|
154
|
+
sys.exit(f"ERROR: No App Insights in {resource_group}.")
|
|
155
|
+
rows, err = query_logs(found, resource_group, minutes, min_severity, role)
|
|
156
|
+
if rows is None:
|
|
157
|
+
sys.exit(f"ERROR:\n{err}")
|
|
158
|
+
if not rows:
|
|
159
|
+
print(f"No entries in the last {minutes} minutes.")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
print(f"--- {len(rows)} entries ---")
|
|
163
|
+
for row in rows:
|
|
164
|
+
ts = datetime.fromisoformat(row["timestamp"].rstrip("Z")).replace(tzinfo=timezone.utc).strftime("%H:%M:%S")
|
|
165
|
+
line = f"{ts} [{row['lvl']}] {row['msg']}{row['exc']}"
|
|
166
|
+
if no_color:
|
|
167
|
+
print(line)
|
|
168
|
+
else:
|
|
169
|
+
color = COLORS.get(row["lvl"], "")
|
|
170
|
+
print(f"{color}{line}{COLORS['RESET']}")
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Azure DevOps PR build status: find the PR for the current branch and report its builds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .ado_auth import auth_header
|
|
13
|
+
from .gitrepo import AdoRemote, current_branch
|
|
14
|
+
|
|
15
|
+
# Matches an ISO 8601 timestamp at the start of a log line, e.g. 2024-03-21T15:01:23.1234567Z
|
|
16
|
+
TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*")
|
|
17
|
+
|
|
18
|
+
# GitPullRequest.mergeStatus values (PullRequestAsyncStatus) that mean the PR
|
|
19
|
+
# can't be merged as-is — build status is moot until this is resolved.
|
|
20
|
+
BAD_MERGE_STATUSES = {
|
|
21
|
+
"conflicts": "has merge conflicts with the target branch",
|
|
22
|
+
"failure": "merge failed",
|
|
23
|
+
"rejectedByPolicy": "merge was rejected by branch policy",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _base_url(remote: AdoRemote) -> str:
|
|
28
|
+
return f"https://dev.azure.com/{remote.org}/{quote(remote.project, safe='')}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def merge_conflict_message(pr: dict) -> str | None:
|
|
32
|
+
"""None if the PR's mergeStatus is fine; else a human-readable description of the problem."""
|
|
33
|
+
merge_status = pr.get("mergeStatus")
|
|
34
|
+
if merge_status not in BAD_MERGE_STATUSES:
|
|
35
|
+
return None
|
|
36
|
+
pr_id = pr.get("pullRequestId")
|
|
37
|
+
title = pr.get("title", "?")
|
|
38
|
+
detail = pr.get("mergeFailureMessage") or BAD_MERGE_STATUSES[merge_status]
|
|
39
|
+
return f"PR #{pr_id} ({title!r}) {detail} (mergeStatus={merge_status})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_pr(session: requests.Session, remote: AdoRemote, source_branch: str, target_branch: str) -> dict:
|
|
43
|
+
url = f"{_base_url(remote)}/_apis/git/repositories/{remote.repo}/pullrequests"
|
|
44
|
+
for status in ["active", "completed"]:
|
|
45
|
+
r = session.get(
|
|
46
|
+
url,
|
|
47
|
+
params={
|
|
48
|
+
"searchCriteria.sourceRefName": f"refs/heads/{source_branch}",
|
|
49
|
+
"searchCriteria.targetRefName": f"refs/heads/{target_branch}",
|
|
50
|
+
"searchCriteria.status": status,
|
|
51
|
+
"$top": 1,
|
|
52
|
+
"api-version": "7.1",
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
r.raise_for_status()
|
|
56
|
+
items = r.json().get("value", [])
|
|
57
|
+
if items:
|
|
58
|
+
pr = items[0]
|
|
59
|
+
conflict = merge_conflict_message(pr)
|
|
60
|
+
if conflict:
|
|
61
|
+
sys.exit(conflict)
|
|
62
|
+
return pr
|
|
63
|
+
|
|
64
|
+
print(f"No PR found from '{source_branch}' → '{target_branch}'")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_builds_for_pr(session: requests.Session, remote: AdoRemote, source_branch: str, pr_id: int) -> list:
|
|
69
|
+
builds = []
|
|
70
|
+
for ref in [f"refs/pull/{pr_id}/merge", f"refs/heads/{source_branch}"]:
|
|
71
|
+
url = f"{_base_url(remote)}/_apis/build/builds"
|
|
72
|
+
r = session.get(url, params={"branchName": ref, "$top": 5, "api-version": "7.1"})
|
|
73
|
+
r.raise_for_status()
|
|
74
|
+
builds.extend(r.json().get("value", []))
|
|
75
|
+
|
|
76
|
+
if builds:
|
|
77
|
+
builds.sort(key=lambda b: b["id"], reverse=True)
|
|
78
|
+
return builds
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def latest_per_pipeline(builds: list) -> list:
|
|
82
|
+
"""Reduce a build list to the single latest build per pipeline (definition)."""
|
|
83
|
+
latest: dict = {}
|
|
84
|
+
for b in builds:
|
|
85
|
+
def_id = b.get("definition", {}).get("id")
|
|
86
|
+
if def_id not in latest or b["id"] > latest[def_id]["id"]:
|
|
87
|
+
latest[def_id] = b
|
|
88
|
+
return sorted(latest.values(), key=lambda b: b["id"], reverse=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_failed_step_logs(session: requests.Session, remote: AdoRemote, build_id: int) -> None:
|
|
92
|
+
r = session.get(f"{_base_url(remote)}/_apis/build/builds/{build_id}/timeline", params={"api-version": "7.1"})
|
|
93
|
+
r.raise_for_status()
|
|
94
|
+
records = r.json().get("records", [])
|
|
95
|
+
|
|
96
|
+
failed = [rec for rec in records if rec.get("result") == "failed" and rec.get("type") == "Task" and rec.get("log")]
|
|
97
|
+
|
|
98
|
+
if not failed:
|
|
99
|
+
print(" (no failed steps with logs)")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
print(f"\n--- Failed steps (build {build_id}) ---")
|
|
103
|
+
for rec in failed:
|
|
104
|
+
name = rec.get("name", "?")
|
|
105
|
+
log_url = rec["log"]["url"]
|
|
106
|
+
print(f"\n [FAILED] {name}")
|
|
107
|
+
r2 = session.get(log_url, params={"api-version": "7.1"})
|
|
108
|
+
r2.raise_for_status()
|
|
109
|
+
for line in r2.text.splitlines():
|
|
110
|
+
print(f" {TIMESTAMP_RE.sub('', line)}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def print_build(session: requests.Session, remote: AdoRemote, build: dict) -> None:
|
|
114
|
+
build_id = build["id"]
|
|
115
|
+
status = build.get("status", "unknown")
|
|
116
|
+
result = build.get("result", "—")
|
|
117
|
+
name = build.get("definition", {}).get("name", "?")
|
|
118
|
+
number = build.get("buildNumber", "?")
|
|
119
|
+
start = build.get("startTime", "?")
|
|
120
|
+
finish = build.get("finishTime", "?")
|
|
121
|
+
source_version = build.get("sourceVersion", "?")[:8]
|
|
122
|
+
|
|
123
|
+
icon = {"succeeded": "✓", "failed": "✗", "canceled": "⊘"}.get(result, "…")
|
|
124
|
+
|
|
125
|
+
print(f"\n{'-' * 60}")
|
|
126
|
+
print(f"Build #{build_id} [{icon} {result.upper()}]")
|
|
127
|
+
print(f" Commit : {source_version}")
|
|
128
|
+
print(f" Pipeline : {name}")
|
|
129
|
+
print(f" Number : {number}")
|
|
130
|
+
print(f" Status : {status}")
|
|
131
|
+
print(f" Started : {start}")
|
|
132
|
+
print(f" Finished : {finish}")
|
|
133
|
+
|
|
134
|
+
if status == "completed" and result == "failed":
|
|
135
|
+
get_failed_step_logs(session, remote, build_id)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def run(remote: AdoRemote, pat: str | None, target_branch: str, wait: bool, source_branch: str | None = None) -> None:
|
|
139
|
+
source_branch = source_branch or current_branch()
|
|
140
|
+
session = requests.Session()
|
|
141
|
+
session.headers.update(auth_header(pat))
|
|
142
|
+
|
|
143
|
+
# When waiting, a pipeline's "latest" build may already be a completed run
|
|
144
|
+
# from before this invocation. Only accept builds newer than whatever was
|
|
145
|
+
# already there when we started, so --wait actually waits for the build(s)
|
|
146
|
+
# triggered by the current HEAD instead of immediately reporting a stale result.
|
|
147
|
+
baseline_ids: dict[int, int] = {}
|
|
148
|
+
if wait:
|
|
149
|
+
pr = get_pr(session, remote, source_branch, target_branch)
|
|
150
|
+
for b in get_builds_for_pr(session, remote, source_branch, pr["pullRequestId"]):
|
|
151
|
+
def_id = b.get("definition", {}).get("id")
|
|
152
|
+
baseline_ids[def_id] = max(baseline_ids.get(def_id, 0), b["id"])
|
|
153
|
+
|
|
154
|
+
last_line = ""
|
|
155
|
+
while True:
|
|
156
|
+
pr = get_pr(session, remote, source_branch, target_branch)
|
|
157
|
+
pr_id = pr["pullRequestId"]
|
|
158
|
+
pr_title = pr.get("title", "?")
|
|
159
|
+
pr_status = pr.get("status", "?")
|
|
160
|
+
|
|
161
|
+
msg = f"\rPR #{pr_id}: {pr_title} ({pr_status})"
|
|
162
|
+
|
|
163
|
+
builds = get_builds_for_pr(session, remote, source_branch, pr_id)
|
|
164
|
+
if builds:
|
|
165
|
+
pipeline_builds = latest_per_pipeline(builds)
|
|
166
|
+
if wait:
|
|
167
|
+
stale = [b for b in pipeline_builds if b["id"] <= baseline_ids.get(b.get("definition", {}).get("id"), 0)]
|
|
168
|
+
if stale:
|
|
169
|
+
msg += " | waiting for new build(s) to start: " + ", ".join(
|
|
170
|
+
b.get("definition", {}).get("name", "?") for b in stale
|
|
171
|
+
)
|
|
172
|
+
if msg != last_line:
|
|
173
|
+
print(msg, end="", flush=True)
|
|
174
|
+
last_line = msg
|
|
175
|
+
time.sleep(30)
|
|
176
|
+
continue
|
|
177
|
+
msg += " | " + ", ".join(
|
|
178
|
+
f"{b.get('definition', {}).get('name', '?')} #{b['id']} {b.get('status')} ({b.get('result', '—')})"
|
|
179
|
+
for b in pipeline_builds
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
all_done = all(b.get("status") == "completed" for b in pipeline_builds)
|
|
183
|
+
if all_done or not wait:
|
|
184
|
+
print(msg)
|
|
185
|
+
print("\nDetails:")
|
|
186
|
+
for b in pipeline_builds:
|
|
187
|
+
print_build(session, remote, b)
|
|
188
|
+
|
|
189
|
+
if not all_done and not wait:
|
|
190
|
+
print("\nTip: Use --wait to poll until all pipelines are completed.")
|
|
191
|
+
|
|
192
|
+
if any(b.get("result") == "failed" for b in pipeline_builds):
|
|
193
|
+
sys.exit(1)
|
|
194
|
+
return
|
|
195
|
+
else:
|
|
196
|
+
msg += " | No builds found."
|
|
197
|
+
if not wait:
|
|
198
|
+
print(msg)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if msg != last_line:
|
|
202
|
+
print(msg, end="", flush=True)
|
|
203
|
+
last_line = msg
|
|
204
|
+
|
|
205
|
+
if wait:
|
|
206
|
+
time.sleep(30)
|