From 3d9fee2d57ea99615d2551bf5ab3347f9b7ae672 Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Fri, 18 Sep 2026 16:21:02 +0200 Subject: [PATCH 1/3] fix: bdt pr status --wait no longer hangs on a pending approval Azure Pipelines stages and GitHub Actions environments can pause a run on a manual approval gate. That leaves the run "in progress" indefinitely, so --wait polled forever waiting for something only a human can resolve. Detect it explicitly instead: on Azure DevOps, look for an open Checkpoint.Approval timeline record and resolve its stage name by walking the parent chain; on GitHub, a CheckRun's status "WAITING" already flags a deployment protection rule. Either way, --wait now stops, prints which stage/check needs a reviewer plus a link to act on it, and exits with a dedicated code (2) distinct from success/failure. Fixes #27. Co-Authored-By: Claude Sonnet 5 --- bmsdna/devtools/cli.py | 2 +- bmsdna/devtools/cli_tools.py | 5 ++ bmsdna/devtools/gh_pr.py | 18 ++++++- bmsdna/devtools/pr_build.py | 63 +++++++++++++++++++++++- tests/test_gh_pr.py | 1 + tests/test_pr_build.py | 94 +++++++++++++++++++++++++++++++++++- 6 files changed, 179 insertions(+), 4 deletions(-) diff --git a/bmsdna/devtools/cli.py b/bmsdna/devtools/cli.py index 8e94a0d..ed97dff 100644 --- a/bmsdna/devtools/cli.py +++ b/bmsdna/devtools/cli.py @@ -221,7 +221,7 @@ def pr_publish( @pr_app.command("status") def pr_status( 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)"), - wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed"), + wait: bool = typer.Option(False, "--wait", help="Poll until all pipelines/checks are completed; stops early and reports status if one needs manual approval"), pat: str | None = typer.Option(None, "--pat", envvar=["AZURE_DEVOPS_EXT_PAT", "AZURE_DEVOPS_PAT"], help="Azure DevOps PAT (else falls back to `az` login)"), ) -> None: """Show build/check status for the PR opened from the current branch (Azure DevOps or GitHub, auto-detected).""" diff --git a/bmsdna/devtools/cli_tools.py b/bmsdna/devtools/cli_tools.py index 133aad5..72f047a 100644 --- a/bmsdna/devtools/cli_tools.py +++ b/bmsdna/devtools/cli_tools.py @@ -16,6 +16,11 @@ AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli" GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com" +# `pr status --wait` exits with this code (not 0=success, not 1=CI failure) when it stops +# because a build/check needs a human to approve it — there's nothing more the CLI can do +# but wait indefinitely, which defeats the point of --wait. +EXIT_NEEDS_APPROVAL = 2 + def is_claude_code() -> bool: """True if this process is running as a subprocess of Claude Code. diff --git a/bmsdna/devtools/gh_pr.py b/bmsdna/devtools/gh_pr.py index 3ec7ede..7d44927 100644 --- a/bmsdna/devtools/gh_pr.py +++ b/bmsdna/devtools/gh_pr.py @@ -17,7 +17,7 @@ import time from pathlib import Path -from .cli_tools import is_claude_code +from .cli_tools import EXIT_NEEDS_APPROVAL, is_claude_code from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section PR_VIEW_FIELDS = "number,title,baseRefName,mergeable,statusCheckRollup,isDraft" @@ -71,6 +71,11 @@ def get_pr(gh: str) -> dict: def check_bucket(check: dict) -> str: if check.get("__typename") == "StatusContext": return _STATUS_CONTEXT_BUCKET.get(check.get("state"), "pending") + # CheckRun.status "WAITING" is GitHub's distinct state for a run paused on a deployment + # protection rule (e.g. a required reviewer on the target environment) — unlike ordinary + # "still running" states, nothing here resolves on its own without a human. + if check.get("status") == "WAITING": + return "waiting_approval" if check.get("status") != "COMPLETED": return "pending" return _CHECK_RUN_BUCKET.get(check.get("conclusion"), "fail") @@ -145,6 +150,17 @@ def run(gh: str, wait: bool) -> None: buckets = [check_bucket(c) for c in checks] msg += " | " + ", ".join(f"{check_label(c)}: {check_bucket(c)}" for c in checks) + waiting_approval = [c for c, b in zip(checks, buckets) if b == "waiting_approval"] if wait else [] + if waiting_approval: + print(msg) + print("\nWaiting for approval:") + for c in waiting_approval: + details_url = c.get("detailsUrl") + suffix = f" — {details_url}" if details_url else "" + print(f" {check_label(c)} needs a reviewer to approve the deployment{suffix}") + print("\nApprove at the link(s) above, then re-run `bdt pr status --wait`.") + sys.exit(EXIT_NEEDS_APPROVAL) + if "pending" in buckets and wait: if msg != last_line: print(msg, end="", flush=True) diff --git a/bmsdna/devtools/pr_build.py b/bmsdna/devtools/pr_build.py index b68c24b..eab7241 100644 --- a/bmsdna/devtools/pr_build.py +++ b/bmsdna/devtools/pr_build.py @@ -11,13 +11,18 @@ import requests from .ado_auth import auth_header -from .cli_tools import is_claude_code +from .cli_tools import EXIT_NEEDS_APPROVAL, is_claude_code from .gitrepo import AdoRemote, current_branch from .pr_markdown import build_attachments_section, build_comment_content, build_screenshots_section # Matches an ISO 8601 timestamp at the start of a log line, e.g. 2024-03-21T15:01:23.1234567Z TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*") +# Timeline record name for a YAML pipeline stage's manual-approval check. Its `state` stays +# "inProgress" (like an ordinary running step) until someone approves/rejects it or it times +# out — indistinguishable from "still building" unless you look at the timeline specifically. +CHECKPOINT_APPROVAL_NAME = "Checkpoint.Approval" + # GitPullRequest.mergeStatus values (PullRequestAsyncStatus) that mean the PR # can't be merged as-is — build status is moot until this is resolved. BAD_MERGE_STATUSES = { @@ -296,6 +301,49 @@ def latest_per_pipeline(builds: list) -> list: return sorted(latest.values(), key=lambda b: b["id"], reverse=True) +def build_web_url(remote: AdoRemote, build_id: int) -> str: + """The browsable web page for a build, where a pending approval can actually be acted on.""" + return f"{_base_url(remote)}/_build/results?buildId={build_id}&view=results" + + +def get_timeline_records(session: requests.Session, remote: AdoRemote, build_id: int) -> list: + r = session.get(f"{_base_url(remote)}/_apis/build/builds/{build_id}/timeline", params={"api-version": "7.1"}) + r.raise_for_status() + return r.json().get("records") or [] + + +def pending_approval_records(records: list) -> list: + """Timeline records for still-open `Checkpoint.Approval` gates (manual stage approvals).""" + return [rec for rec in records if rec.get("state") == "inProgress" and rec.get("name") == CHECKPOINT_APPROVAL_NAME] + + +def approval_stage_name(records: list, approval_record: dict) -> str: + """Human-readable stage name for an approval record. + + Resolved by walking the timeline's parent chain: Checkpoint.Approval -> Checkpoint -> Stage. + Falls back to the approval record's own name if that chain is missing (unexpected shape). + """ + by_id = {rec.get("id"): rec for rec in records} + checkpoint = by_id.get(approval_record.get("parentId")) + stage = by_id.get(checkpoint.get("parentId")) if checkpoint else None + return (stage or {}).get("name") or approval_record.get("name") or "?" + + +def find_pending_approvals(session: requests.Session, remote: AdoRemote, builds: list) -> list: + """(build, timeline records, pending approval records) for each not-yet-completed build + that's actually blocked on a stage approval, not just still running. + """ + result = [] + for build in builds: + if build.get("status") == "completed": + continue + records = get_timeline_records(session, remote, build["id"]) + approvals = pending_approval_records(records) + if approvals: + result.append((build, records, approvals)) + return result + + def get_failed_step_logs(session: requests.Session, remote: AdoRemote, build_id: int) -> None: r = session.get(f"{_base_url(remote)}/_apis/build/builds/{build_id}/timeline", params={"api-version": "7.1"}) r.raise_for_status() @@ -393,6 +441,19 @@ def run(remote: AdoRemote, pat: str | None, target_branch: str, wait: bool, sour for b in pipeline_builds ) + if wait: + pending_approvals = find_pending_approvals(session, remote, pipeline_builds) + if pending_approvals: + print(msg) + print("\nWaiting for approval:") + for build, records, approvals in pending_approvals: + pipeline_name = build.get("definition", {}).get("name", "?") + for rec in approvals: + stage = approval_stage_name(records, rec) + print(f" {pipeline_name} #{build['id']}: stage '{stage}' needs approval — {build_web_url(remote, build['id'])}") + print("\nApprove at the link(s) above, then re-run `bdt pr status --wait`.") + sys.exit(EXIT_NEEDS_APPROVAL) + all_done = all(b.get("status") == "completed" for b in pipeline_builds) if all_done or not wait: print(msg) diff --git a/tests/test_gh_pr.py b/tests/test_gh_pr.py index 0e049e0..891ed6f 100644 --- a/tests/test_gh_pr.py +++ b/tests/test_gh_pr.py @@ -39,6 +39,7 @@ (COMPLETED_SKIPPED_CHECK_RUN, "skipping"), ({"__typename": "CheckRun", "status": "IN_PROGRESS"}, "pending"), ({"__typename": "CheckRun", "status": "QUEUED"}, "pending"), + ({"__typename": "CheckRun", "status": "WAITING"}, "waiting_approval"), ({"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "FAILURE"}, "fail"), ({"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "TIMED_OUT"}, "fail"), ({"__typename": "CheckRun", "status": "COMPLETED", "conclusion": "CANCELLED"}, "cancel"), diff --git a/tests/test_pr_build.py b/tests/test_pr_build.py index e9a8b77..ae33914 100644 --- a/tests/test_pr_build.py +++ b/tests/test_pr_build.py @@ -1,7 +1,18 @@ +from unittest.mock import MagicMock + import pytest from bmsdna.devtools.gitrepo import AdoRemote -from bmsdna.devtools.pr_build import draft_notice, merge_conflict_message, policy_configs_include_branch, pr_web_url +from bmsdna.devtools.pr_build import ( + approval_stage_name, + build_web_url, + draft_notice, + find_pending_approvals, + merge_conflict_message, + pending_approval_records, + policy_configs_include_branch, + pr_web_url, +) REPO_ID = "0cd3a822-389e-416e-a4fa-b73f988c2930" @@ -127,3 +138,84 @@ def test_policy_configs_include_branch_matches_default_branch_scope() -> None: def test_pr_web_url_is_the_browsable_page_not_the_rest_api_url() -> None: remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2") assert pr_web_url(remote, 123) == "https://dev.azure.com/bmeurope/BMS%20-%20CCMT2/_git/BMS%20-%20CCMT2/pullrequest/123" + + +class FakeTimelineResponse: + def __init__(self, records: list) -> None: + self._records = records + + def json(self) -> dict: + return {"records": self._records} + + def raise_for_status(self) -> None: + pass + + +def test_build_web_url_is_the_browsable_results_page() -> None: + remote = AdoRemote("bmeurope", "BMS - CCMT2", "BMS - CCMT2") + assert build_web_url(remote, 456) == "https://dev.azure.com/bmeurope/BMS%20-%20CCMT2/_build/results?buildId=456&view=results" + + +# Shape captured from a real `.../_apis/build/builds/{id}/timeline` response for a YAML +# pipeline paused on a stage's manual approval check. +STAGE_RECORD = {"id": "stage-1", "type": "Stage", "name": "Deploy to Production", "state": "inProgress"} +CHECKPOINT_RECORD = {"id": "checkpoint-1", "type": "Checkpoint", "parentId": "stage-1", "state": "inProgress"} +PENDING_APPROVAL_RECORD = { + "id": "approval-1", + "type": "Checkpoint.Approval", + "name": "Checkpoint.Approval", + "parentId": "checkpoint-1", + "state": "inProgress", +} +APPROVED_APPROVAL_RECORD = {**PENDING_APPROVAL_RECORD, "id": "approval-2", "state": "completed"} +TASK_RECORD = {"id": "task-1", "type": "Task", "name": "npm install", "state": "inProgress"} + + +def test_pending_approval_records_finds_open_checkpoint_approval() -> None: + records = [STAGE_RECORD, CHECKPOINT_RECORD, PENDING_APPROVAL_RECORD, TASK_RECORD] + assert pending_approval_records(records) == [PENDING_APPROVAL_RECORD] + + +def test_pending_approval_records_ignores_completed_approval() -> None: + records = [STAGE_RECORD, CHECKPOINT_RECORD, APPROVED_APPROVAL_RECORD] + assert pending_approval_records(records) == [] + + +def test_pending_approval_records_ignores_ordinary_in_progress_steps() -> None: + assert pending_approval_records([TASK_RECORD]) == [] + + +def test_approval_stage_name_walks_parent_chain() -> None: + records = [STAGE_RECORD, CHECKPOINT_RECORD, PENDING_APPROVAL_RECORD] + assert approval_stage_name(records, PENDING_APPROVAL_RECORD) == "Deploy to Production" + + +def test_approval_stage_name_falls_back_when_chain_is_missing() -> None: + orphan = {"id": "approval-1", "name": "Checkpoint.Approval", "parentId": "missing", "state": "inProgress"} + assert approval_stage_name([orphan], orphan) == "Checkpoint.Approval" + + +def test_find_pending_approvals_skips_completed_builds() -> None: + remote = AdoRemote("myorg", "MyProj", "myrepo") + session = MagicMock() + session.get.return_value = FakeTimelineResponse([PENDING_APPROVAL_RECORD]) + + result = find_pending_approvals(session, remote, [{"id": 1, "status": "completed"}]) + + assert result == [] + session.get.assert_not_called() + + +def test_find_pending_approvals_reports_blocked_build() -> None: + remote = AdoRemote("myorg", "MyProj", "myrepo") + session = MagicMock() + session.get.return_value = FakeTimelineResponse([STAGE_RECORD, CHECKPOINT_RECORD, PENDING_APPROVAL_RECORD]) + + build = {"id": 1, "status": "inProgress", "definition": {"name": "deploy"}} + result = find_pending_approvals(session, remote, [build]) + + assert len(result) == 1 + found_build, records, approvals = result[0] + assert found_build is build + assert approvals == [PENDING_APPROVAL_RECORD] + assert approval_stage_name(records, approvals[0]) == "Deploy to Production" From 8d075f7eccbe30259a8525dacf8d2435f29fed2f Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Fri, 18 Sep 2026 16:45:37 +0200 Subject: [PATCH 2/3] fix: code-review fallout from the approval-wait fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gh_pr.py / pr_build.py: don't let a pending-approval report mask an already-failed check/pipeline elsewhere in the same PR — report the failure (exit 1) instead of just prompting for approval (exit 2), and don't silently fall back into the old infinite-poll loop when the failure suppressed the approval check. - pr_build.py: only fetch a build's timeline when its status is actually "inProgress" — a build that's "notStarted"/"postponed" has no timeline yet and the endpoint 404s, which crashed --wait. - Add regression tests for both wait-loop interactions and the not-started-build skip. Co-Authored-By: Claude Sonnet 5 --- bmsdna/devtools/gh_pr.py | 5 +++- bmsdna/devtools/pr_build.py | 46 ++++++++++++++++++---------- tests/test_gh_pr.py | 25 ++++++++++++++++ tests/test_pr_build.py | 60 +++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 16 deletions(-) diff --git a/bmsdna/devtools/gh_pr.py b/bmsdna/devtools/gh_pr.py index 7d44927..674a798 100644 --- a/bmsdna/devtools/gh_pr.py +++ b/bmsdna/devtools/gh_pr.py @@ -150,7 +150,10 @@ def run(gh: str, wait: bool) -> None: buckets = [check_bucket(c) for c in checks] msg += " | " + ", ".join(f"{check_label(c)}: {check_bucket(c)}" for c in checks) - waiting_approval = [c for c, b in zip(checks, buckets) if b == "waiting_approval"] if wait else [] + # A failed check anywhere in the PR is reported as such even when another check is + # separately waiting on approval — a human shouldn't be sent to go approve a + # deployment gate while staying unaware that CI has already failed elsewhere. + waiting_approval = [c for c, b in zip(checks, buckets) if b == "waiting_approval"] if wait and "fail" not in buckets else [] if waiting_approval: print(msg) print("\nWaiting for approval:") diff --git a/bmsdna/devtools/pr_build.py b/bmsdna/devtools/pr_build.py index eab7241..9e0b270 100644 --- a/bmsdna/devtools/pr_build.py +++ b/bmsdna/devtools/pr_build.py @@ -330,12 +330,16 @@ def approval_stage_name(records: list, approval_record: dict) -> str: def find_pending_approvals(session: requests.Session, remote: AdoRemote, builds: list) -> list: - """(build, timeline records, pending approval records) for each not-yet-completed build - that's actually blocked on a stage approval, not just still running. + """(build, timeline records, pending approval records) for each build that's actually + blocked on a stage approval, not just still running. + + Only builds with status "inProgress" have a timeline at all — one that's "notStarted" + (queued, waiting on agent capacity) or "postponed" gets a 404 from the timeline endpoint, + and a Checkpoint.Approval gate can only exist mid-run anyway. """ result = [] for build in builds: - if build.get("status") == "completed": + if build.get("status") != "inProgress": continue records = get_timeline_records(session, remote, build["id"]) approvals = pending_approval_records(records) @@ -441,18 +445,30 @@ def run(remote: AdoRemote, pat: str | None, target_branch: str, wait: bool, sour for b in pipeline_builds ) - if wait: - pending_approvals = find_pending_approvals(session, remote, pipeline_builds) - if pending_approvals: - print(msg) - print("\nWaiting for approval:") - for build, records, approvals in pending_approvals: - pipeline_name = build.get("definition", {}).get("name", "?") - for rec in approvals: - stage = approval_stage_name(records, rec) - print(f" {pipeline_name} #{build['id']}: stage '{stage}' needs approval — {build_web_url(remote, build['id'])}") - print("\nApprove at the link(s) above, then re-run `bdt pr status --wait`.") - sys.exit(EXIT_NEEDS_APPROVAL) + # A pipeline stuck on approval might never resolve on its own, so it has to end + # the --wait loop the same way a completed pipeline would — but if another + # pipeline has already failed, that's the more urgent, more actionable fact: + # report it (and exit 1, not the approval code) instead of just telling the user + # to go approve a stage while staying unaware CI already failed elsewhere. + already_failed = any(b.get("result") == "failed" for b in pipeline_builds) + pending_approvals = find_pending_approvals(session, remote, pipeline_builds) if wait else [] + if pending_approvals: + print(msg) + if already_failed: + print("\nNote: another pipeline in this PR has already failed — see details below.") + print("\nWaiting for approval:") + for build, records, approvals in pending_approvals: + pipeline_name = build.get("definition", {}).get("name", "?") + for rec in approvals: + stage = approval_stage_name(records, rec) + print(f" {pipeline_name} #{build['id']}: stage '{stage}' needs approval — {build_web_url(remote, build['id'])}") + if already_failed: + print("\nDetails:") + for b in pipeline_builds: + print_build(session, remote, b) + sys.exit(1) + print("\nApprove at the link(s) above, then re-run `bdt pr status --wait`.") + sys.exit(EXIT_NEEDS_APPROVAL) all_done = all(b.get("status") == "completed" for b in pipeline_builds) if all_done or not wait: diff --git a/tests/test_gh_pr.py b/tests/test_gh_pr.py index 891ed6f..06e6bd4 100644 --- a/tests/test_gh_pr.py +++ b/tests/test_gh_pr.py @@ -12,6 +12,7 @@ draft_notice, merge_conflict_message, protection_requires_status_checks, + run, update, ) @@ -53,6 +54,30 @@ def test_check_bucket(check: dict, expected_bucket: str) -> None: assert check_bucket(check) == expected_bucket +def test_run_wait_exits_1_not_2_when_a_check_already_failed_and_another_needs_approval(monkeypatch) -> None: + """Regression: an already-failed check elsewhere in the PR must still end --wait even when + another check is separately waiting on a deployment approval — must report the failure + (exit 1), not silently prioritize the approval prompt (exit 2) or hang forever. + """ + pr = { + "number": 42, + "title": "feat: widgets", + "baseRefName": "main", + "mergeable": "MERGEABLE", + "isDraft": False, + "statusCheckRollup": [ + {"__typename": "CheckRun", "name": "deploy", "status": "WAITING", "workflowName": "Deploy"}, + {"__typename": "CheckRun", "name": "build", "status": "COMPLETED", "conclusion": "FAILURE", "workflowName": "CI"}, + ], + } + monkeypatch.setattr("bmsdna.devtools.gh_pr.get_pr", lambda gh: pr) + + with pytest.raises(SystemExit) as exc_info: + run("gh", wait=True) + + assert exc_info.value.code == 1 + + def test_check_label_prefixes_workflow_when_distinct() -> None: assert check_label(COMPLETED_SUCCESS_CHECK_RUN) == "PR Triaging / label-external / label_issues" diff --git a/tests/test_pr_build.py b/tests/test_pr_build.py index ae33914..e094298 100644 --- a/tests/test_pr_build.py +++ b/tests/test_pr_build.py @@ -12,6 +12,7 @@ pending_approval_records, policy_configs_include_branch, pr_web_url, + run, ) REPO_ID = "0cd3a822-389e-416e-a4fa-b73f988c2930" @@ -206,6 +207,19 @@ def test_find_pending_approvals_skips_completed_builds() -> None: session.get.assert_not_called() +@pytest.mark.parametrize("status", ["notStarted", "postponed", "none"]) +def test_find_pending_approvals_skips_builds_with_no_timeline_yet(status: str) -> None: + """A build that hasn't started running yet has no timeline — fetching it would 404.""" + remote = AdoRemote("myorg", "MyProj", "myrepo") + session = MagicMock() + session.get.return_value = FakeTimelineResponse([PENDING_APPROVAL_RECORD]) + + result = find_pending_approvals(session, remote, [{"id": 1, "status": status}]) + + assert result == [] + session.get.assert_not_called() + + def test_find_pending_approvals_reports_blocked_build() -> None: remote = AdoRemote("myorg", "MyProj", "myrepo") session = MagicMock() @@ -219,3 +233,49 @@ def test_find_pending_approvals_reports_blocked_build() -> None: assert found_build is build assert approvals == [PENDING_APPROVAL_RECORD] assert approval_stage_name(records, approvals[0]) == "Deploy to Production" + + +class _BuildsSequence: + """First call (baseline, before the polling loop starts) returns builds one id behind the + ones returned on every later call — so `run()`'s staleness check ("only accept builds newer + than baseline") doesn't itself treat the loop's builds as stale and keep --wait spinning. + """ + + def __init__(self, baseline: list, polled: list) -> None: + self._baseline = baseline + self._polled = polled + self._calls = 0 + + def __call__(self, *args, **kwargs) -> list: + self._calls += 1 + return self._baseline if self._calls == 1 else self._polled + + +def test_run_wait_exits_1_not_2_when_a_pipeline_already_failed_and_another_needs_approval(monkeypatch) -> None: + """Regression: an already-failed pipeline elsewhere in the PR must still end --wait even + when another pipeline is separately blocked on approval — and must report the failure + (exit 1), not silently prioritize the approval prompt (exit 2) or hang waiting for the + blocked pipeline to complete on its own (which it never will without a human). + """ + remote = AdoRemote("myorg", "MyProj", "myrepo") + pr = {"pullRequestId": 42, "title": "feat: widgets", "status": "active", "isDraft": False} + failed_build = {"id": 100, "status": "completed", "result": "failed", "definition": {"id": 1, "name": "build"}} + blocked_build = {"id": 200, "status": "inProgress", "result": None, "definition": {"id": 2, "name": "deploy"}} + + monkeypatch.setattr("bmsdna.devtools.pr_build.current_branch", lambda: "feature-x") + monkeypatch.setattr("bmsdna.devtools.pr_build.auth_header", lambda pat: {}) + monkeypatch.setattr("bmsdna.devtools.pr_build.get_pr", lambda *a, **k: pr) + monkeypatch.setattr( + "bmsdna.devtools.pr_build.get_builds_for_pr", + _BuildsSequence(baseline=[{**failed_build, "id": 99}, {**blocked_build, "id": 199}], polled=[failed_build, blocked_build]), + ) + monkeypatch.setattr( + "bmsdna.devtools.pr_build.find_pending_approvals", + lambda session, remote, builds: [(blocked_build, [PENDING_APPROVAL_RECORD], [PENDING_APPROVAL_RECORD])], + ) + monkeypatch.setattr("bmsdna.devtools.pr_build.print_build", lambda session, remote, build: None) + + with pytest.raises(SystemExit) as exc_info: + run(remote, pat=None, target_branch="main", wait=True) + + assert exc_info.value.code == 1 From 37172cc8a0574ca2768e71b69c20d5340bd028b8 Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Fri, 18 Sep 2026 17:50:01 +0200 Subject: [PATCH 3/3] chore: bump version to 0.18.0 Co-Authored-By: Claude Sonnet 5 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 29ef807..2c6632f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ packages = ["bmsdna"] [project] name = "bmsdna-devtools" -version = "0.17.0" +version = "0.18.0" description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects" readme = "README.md" requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency diff --git a/uv.lock b/uv.lock index c2cd7f4..3fef825 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "bmsdna-devtools" -version = "0.17.0" +version = "0.18.0" source = { editable = "." } dependencies = [ { name = "pgdevkit", extra = ["db"] },