diff --git a/requirements-pip-audit-ci-hashes.txt b/requirements-pip-audit-ci-hashes.txt index ade197a49a..0ae099d8fe 100644 --- a/requirements-pip-audit-ci-hashes.txt +++ b/requirements-pip-audit-ci-hashes.txt @@ -213,9 +213,9 @@ packaging==26.2 \ # via # pip-audit # pip-requirements-parser -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via pip-api pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ diff --git a/scripts/ci/organization_commercial_readiness_loop.py b/scripts/ci/organization_commercial_readiness_loop.py index c00cfa1e0a..1ede18a00b 100644 --- a/scripts/ci/organization_commercial_readiness_loop.py +++ b/scripts/ci/organization_commercial_readiness_loop.py @@ -239,6 +239,7 @@ class GitHubClient: """Use the GitHub CLI as an authenticated, bounded REST transport.""" def __init__(self, token: str, *, timeout_seconds: int = 60) -> None: + """Initialize the client with one non-empty GitHub credential.""" if not token: raise GitHubError("GH_TOKEN is required for organization coordination") self._token = token @@ -853,4 +854,4 @@ def main( if __name__ == "__main__": # pragma: no cover - exercised through main() - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 44620fcab5..6e3795a5bd 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -159,6 +159,11 @@ "deterministic fallback approval", "did not emit a usable current-head control block", ) +COVERAGE_REVIEW_MARKERS = ( + "coverage evidence did not pass", + "coverage-evidence", + "required test/docstring evidence", +) LAST_PUSH_APPROVAL_RESTAMP_MESSAGE = "chore: refresh head for last-push approval" @@ -1277,6 +1282,33 @@ def has_current_head_changes_requested(pr: dict[str, Any]) -> bool: return current_head_review_state(pr, "CHANGES_REQUESTED") +def current_head_coverage_change_request(pr: dict[str, Any]) -> bool: + """Return whether the latest current-head request is only a coverage gate.""" + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if not is_opencode_review(review) or not review_matches_current_head(review, pr): + continue + if (review.get("state") or "").upper() != "CHANGES_REQUESTED": + return False + body = (review.get("body") or "").lower() + return all(marker in body for marker in COVERAGE_REVIEW_MARKERS) + return False + + +def coverage_evidence_state(pr: dict[str, Any]) -> str: + """Return missing, running, complete, or failed for the latest coverage gate.""" + for node in reversed(context_nodes(pr)): + name = (node.get("name") or node.get("context") or "").lower() + if name != "coverage-evidence": + continue + status = (node.get("status") or node.get("state") or "").upper() + if status in RUNNING_CHECK_STATES: + return "running" + if node.get("__typename") == "CheckRun": + return "complete" if (node.get("conclusion") or "").upper() == "SUCCESS" else "failed" + return "complete" if status == "SUCCESS" else "failed" + return "missing" + + def stale_opencode_change_request_ids(pr: dict[str, Any]) -> list[int]: """Return dismissible automated change requests tied to previous heads.""" review_ids: list[int] = [] @@ -1459,8 +1491,17 @@ def dismiss_stale_opencode_change_requests(repo: str, pr: dict[str, Any], *, dry return len(review_ids) -def failed_status_checks(pr: dict[str, Any]) -> list[str]: - """Return failing check or status context names from the PR rollup.""" +def failed_status_checks( + pr: dict[str, Any], + *, + ignore_opencode: bool = False, +) -> list[str]: + """Return failing check or status context names from the PR rollup. + + ``ignore_opencode`` is reserved for the authenticated coverage-only retry + path: the previous OpenCode run is expected to be failing there because it + published the current-head coverage change request being retried. + """ failed: list[str] = [] latest_check_runs: dict[ tuple[str, str], @@ -1501,6 +1542,8 @@ def failed_status_checks(pr: dict[str, Any]) -> list[str]: for _, _, node in sorted(latest_check_runs.values(), key=lambda item: item[1]): conclusion = (node.get("conclusion") or "").upper() if conclusion in FAILED_CHECK_CONCLUSIONS: + if ignore_opencode and is_opencode_context(node): + continue if is_strix_context(node) and "strix" in successful_status_contexts: continue if is_opencode_context(node) and "opencode-review" in successful_status_contexts: @@ -1509,6 +1552,8 @@ def failed_status_checks(pr: dict[str, Any]) -> list[str]: for node in status_contexts: state = (node.get("state") or "").upper() if state in {"FAILURE", "ERROR"}: + if ignore_opencode and is_opencode_context(node): + continue failed.append(node.get("context") or "status-context") return failed @@ -2492,6 +2537,28 @@ def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decisio return request_branch_update( "current-head OpenCode review requested changes; branch is outdated before re-review" ) + coverage_ready = ( + trigger_reviews + and review_dispatch_allowed + and current_head_coverage_change_request(pr) + and coverage_evidence_state(pr) == "complete" + and strix_evidence_state(pr) == "complete" + and not failed_status_checks(pr, ignore_opencode=True) + ) + if coverage_ready: + wait_reason = repository_dispatch_wait_reason(repo, workflow) + if wait_reason: + return decide("wait", wait_reason) + dispatch_result = dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + if dispatch_result == "already_running": + return decide( + "wait", + "current-head coverage evidence is complete, but a same-head OpenCode workflow run is already active", + ) + return decide( + "review_dispatch", + "current-head OpenCode coverage blocker is cleared; same-head OpenCode re-dispatched", + ) if pr.get("autoMergeRequest"): return finish( disable_auto_merge_decision( diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 0e71bdbe24..32bf8e81b2 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1252,6 +1252,82 @@ def test_review_state_and_failed_checks(): assert sched.has_current_head_approval(superseded) assert not sched.has_current_head_changes_requested(superseded) + coverage_request = make_pr( + reviews={ + "nodes": [ + { + **opencode_review("CHANGES_REQUESTED", "head"), + "body": ( + "OpenCode cannot approve yet because required coverage evidence did not pass. " + "The coverage-evidence gate reported that required test/docstring evidence was not proven." + ), + } + ] + }, + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(), + { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": "SUCCESS", + }, + ] + } + }, + ) + assert sched.current_head_coverage_change_request(coverage_request) + assert sched.coverage_evidence_state(coverage_request) == "complete" + assert sched.coverage_evidence_state( + make_pr( + statusCheckRollup={ + "contexts": {"nodes": [{"name": "coverage-evidence", "state": "PENDING"}]} + } + ) + ) == "running" + assert sched.coverage_evidence_state( + make_pr(statusCheckRollup={"contexts": {"nodes": [strix_check()]}}) + ) == "missing" + assert sched.coverage_evidence_state( + make_pr( + statusCheckRollup={ + "contexts": {"nodes": [{"name": "coverage-evidence", "state": "SUCCESS"}]} + } + ) + ) == "complete" + assert sched.coverage_evidence_state( + make_pr( + statusCheckRollup={ + "contexts": {"nodes": [{"name": "coverage-evidence", "state": "FAILURE"}]} + } + ) + ) == "failed" + assert sched.coverage_evidence_state(make_pr()) == "missing" + human_coverage_request = make_pr( + reviews={ + "nodes": [ + { + **opencode_review("CHANGES_REQUESTED", "head", login="human"), + "body": "coverage evidence did not pass; coverage-evidence; required test/docstring evidence", + } + ] + } + ) + assert not sched.current_head_coverage_change_request(human_coverage_request) + assert not sched.current_head_coverage_change_request( + make_pr(reviews={"nodes": [opencode_review("APPROVED", "head")]}) + ) + ordinary_request = make_pr( + reviews={ + "nodes": [ + {**opencode_review("CHANGES_REQUESTED", "head"), "body": "Fix the estimator."} + ] + } + ) + assert not sched.current_head_coverage_change_request(ordinary_request) + stale_gate_reviews = make_pr( reviews={ "nodes": [ @@ -3134,6 +3210,96 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): "current-head OpenCode review requested changes" ) assert update_calls == [] + coverage_request = make_pr( + reviews={ + "nodes": [ + { + **opencode_review("CHANGES_REQUESTED", "head"), + "body": ( + "OpenCode cannot approve yet because required coverage evidence did not pass. " + "The coverage-evidence gate reported that required test/docstring evidence was not proven." + ), + } + ] + }, + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(), + { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": "SUCCESS", + }, + { + "__typename": "CheckRun", + "name": "opencode-review", + "status": "COMPLETED", + "conclusion": "FAILURE", + "checkSuite": { + "workflowRun": {"workflow": {"name": "OpenCode Review"}} + }, + }, + ] + } + }, + ) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: dispatched.append( + (repo, workflow, pr["headRefOid"], dry_run) + ) + or "dispatched", + ) + monkeypatch.setattr( + sched, + "repository_dispatch_wait_reason", + lambda repo, workflow: "review dispatch waits", + ) + coverage_wait = inspect(coverage_request) + assert coverage_wait.action == "wait" + assert coverage_wait.reason == "review dispatch waits" + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda repo, workflow: None) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: "already_running", + ) + coverage_active = inspect(coverage_request) + assert coverage_active.action == "wait" + assert coverage_active.reason == ( + "current-head coverage evidence is complete, but a same-head OpenCode workflow run is already active" + ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: dispatched.append( + (repo, workflow, pr["headRefOid"], dry_run) + ) + or "dispatched", + ) + coverage_decision = inspect(coverage_request) + assert coverage_decision.action == "review_dispatch" + assert coverage_decision.reason == ( + "current-head OpenCode coverage blocker is cleared; same-head OpenCode re-dispatched" + ) + assert dispatched == [("owner/repo", "OpenCode Review", "head", True)] + + coverage_request["statusCheckRollup"]["contexts"]["nodes"].append( + { + "__typename": "CheckRun", + "name": "Security Scan", + "status": "COMPLETED", + "conclusion": "FAILURE", + } + ) + unrelated_failure = inspect(coverage_request) + assert unrelated_failure.action == "block" + assert unrelated_failure.reason == "current-head OpenCode review requested changes" + action_required_pr = make_pr( statusCheckRollup={ "contexts": {