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.
@@ -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))
@@ -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)