diff --git a/CHANGELOG.md b/CHANGELOG.md index 407fd7834f..62a3764810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,13 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Re-dispatch an exact-head OpenCode review after its coverage-only blocker is + cleared, selecting the newest coverage rerun by timestamp across workflow + names and ignoring only the superseded `opencode-review` failure and central + required-workflow placeholder. Conflicting heads and failed sibling jobs in an + OpenCode workflow remain fail-closed alongside unresolved threads, Strix, + coverage, and unrelated failed checks. + - Publish only the sanitized cumulative Strix report tree, avoiding a later copy of relative scanner output that could reintroduce known internal warning text into uploaded security evidence. diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 44620fcab5..f7efcf617c 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -15,7 +15,7 @@ import time from collections.abc import Iterator, Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import quote @@ -117,6 +117,7 @@ # remains deliberately larger than the job cap while recovering genuine zombie # checks in the same operating window instead of leaving them for seven hours. DEFAULT_STALE_OPENCODE_MINUTES = 90 +DEFAULT_COVERAGE_RETRY_FLOOR_MINUTES = 60 DEFAULT_UPDATE_BRANCH_HEAD_POLL_ATTEMPTS = 6 DEFAULT_UPDATE_BRANCH_HEAD_POLL_SECONDS = 5.0 OPENCODE_WORKFLOW_NAMES = { @@ -159,6 +160,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" @@ -1021,6 +1027,20 @@ def context_nodes(pr: dict[str, Any]) -> list[dict[str, Any]]: return contexts.get("nodes") or [] +def is_opencode_check_run(node: dict[str, Any]) -> bool: + """Return whether a CheckRun carries the OpenCode workflow identity.""" + if node.get("__typename") != "CheckRun": + return False + workflow = ( + ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") + or {} + ) + return ( + node.get("name") == "opencode-review" + or workflow.get("name") in OPENCODE_WORKFLOW_NAMES + ) + + def is_opencode_context(node: dict[str, Any]) -> bool: """Return whether a check or status context belongs to OpenCode Review.""" if node.get("__typename") == "CheckRun": @@ -1029,11 +1049,7 @@ def is_opencode_context(node: dict[str, Any]) -> bool: # status. Organization required-workflow CheckRuns are deliberately # non-authoritative placeholders and must not suppress that dispatch. return False - workflow = ( - ((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") - or {} - ) - return node.get("name") == "opencode-review" or workflow.get("name") in OPENCODE_WORKFLOW_NAMES + return is_opencode_check_run(node) return node.get("context") == "opencode-review" @@ -1083,6 +1099,39 @@ def parse_github_datetime(value: str | None) -> datetime | None: return parsed.astimezone(timezone.utc) +def latest_check_runs(pr: dict[str, Any]) -> list[dict[str, Any]]: + """Return the newest check run for each workflow and check-name pair.""" + latest: dict[ + tuple[str, str], + tuple[datetime | None, int, dict[str, Any]], + ] = {} + for index, node in enumerate(context_nodes(pr)): + if node.get("__typename") != "CheckRun": + continue + workflow = ( + (((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") + or "" + ) + key = (workflow, node.get("name") or "check-run") + started_at = parse_github_datetime(node.get("startedAt")) + previous = latest.get(key) + if previous is None: + latest[key] = (started_at, index, node) + continue + previous_started_at, previous_index, _ = previous + if started_at is None and previous_started_at is not None: + continue + if previous_started_at is None and started_at is not None: + latest[key] = (started_at, index, node) + continue + if (started_at or datetime.min.replace(tzinfo=timezone.utc), index) >= ( + previous_started_at or datetime.min.replace(tzinfo=timezone.utc), + previous_index, + ): + latest[key] = (started_at, index, node) + return [node for _, _, node in sorted(latest.values(), key=lambda item: item[1])] + + def review_matches_current_head(review: dict[str, Any], pr: dict[str, Any]) -> bool: """Return whether a review is valid evidence for the current head commit.""" head = pr.get("headRefOid") @@ -1277,6 +1326,117 @@ def has_current_head_changes_requested(pr: dict[str, Any]) -> bool: return current_head_review_state(pr, "CHANGES_REQUESTED") +def latest_current_head_coverage_change_request( + pr: dict[str, Any], +) -> dict[str, Any] | None: + """Return the latest exact-head OpenCode request that only cites coverage.""" + 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 None + body = (review.get("body") or "").lower() + return review if all(marker in body for marker in COVERAGE_REVIEW_MARKERS) else None + return None + + +def current_head_coverage_change_request(pr: dict[str, Any]) -> bool: + """Return whether the latest current-head request is only a coverage gate.""" + return latest_current_head_coverage_change_request(pr) is not None + + +def coverage_retry_wait_reason( + pr: dict[str, Any], + *, + repo: str | None = None, + workflow: str | None = None, + now: datetime | None = None, + floor_minutes: int = DEFAULT_COVERAGE_RETRY_FLOOR_MINUTES, +) -> str | None: + """Return a wait reason until one same-head coverage retry interval elapses. + + The latest exact-head review submission or completed dispatch timestamp is the + durable same-head retry marker. Missing or malformed timestamps fail closed so + a repeated coverage-only review cannot create an unbounded dispatch loop. + """ + review = latest_current_head_coverage_change_request(pr) + if review is None: + return None + submitted_at = parse_github_datetime(review.get("submittedAt")) + if submitted_at is None: + return "current-head OpenCode coverage review has no valid submission timestamp; defer same-head re-review" + retry_anchor = submitted_at + if repo and workflow: + try: + dispatch_started_at = latest_opencode_dispatch_started_at(repo, workflow, pr) + except RuntimeError: + return "same-head OpenCode dispatch history is unavailable; defer same-head re-review" + if dispatch_started_at and dispatch_started_at > retry_anchor: + retry_anchor = dispatch_started_at + current_time = now or datetime.now(timezone.utc) + if current_time < retry_anchor + timedelta(minutes=max(0, floor_minutes)): + return "same-head OpenCode coverage retry floor has not elapsed" + return None + + +def coverage_evidence_indices(check_runs: Sequence[dict[str, Any]]) -> list[int]: + """Return indexes of coverage-evidence checks in one check-run snapshot.""" + return [ + index + for index, node in enumerate(check_runs) + if (node.get("name") or "").lower() == "coverage-evidence" + ] + + +def latest_coverage_evidence_index(check_runs: Sequence[dict[str, Any]]) -> int | None: + """Return the newest coverage-evidence index across workflow names.""" + coverage_indices = coverage_evidence_indices(check_runs) + if not coverage_indices: + return None + return max( + coverage_indices, + key=lambda item: ( + parse_github_datetime(check_runs[item].get("startedAt")) + or datetime.min.replace(tzinfo=timezone.utc), + item, + ), + ) + + +def coverage_evidence_state(pr: dict[str, Any]) -> str: + """Return missing, running, complete, or failed for the latest coverage gate.""" + check_runs = latest_check_runs(pr) + latest_index = latest_coverage_evidence_index(check_runs) + if latest_index is not None: + node = check_runs[latest_index] + status = (node.get("status") or "").upper() + if status in RUNNING_CHECK_STATES: + return "running" + return "complete" if (node.get("conclusion") or "").upper() == "SUCCESS" else "failed" + for node in reversed(context_nodes(pr)): + if node.get("__typename") == "CheckRun": + continue + 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" + return "complete" if status == "SUCCESS" else "failed" + return "missing" + + +def superseded_coverage_evidence_indices(check_runs: Sequence[dict[str, Any]]) -> set[int]: + """Return older coverage checks superseded by a newer successful run.""" + authoritative_index = latest_coverage_evidence_index(check_runs) + if authoritative_index is None: + return set() + authoritative = check_runs[authoritative_index] + if (authoritative.get("conclusion") or "").upper() != "SUCCESS": + return set() + return set(coverage_evidence_indices(check_runs)) - {authoritative_index} + + 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,48 +1619,41 @@ 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-review`` job or status is expected to be + failing there because it published the current-head coverage change request + being retried. Sibling jobs in the same workflow remain authoritative. + """ failed: list[str] = [] - latest_check_runs: dict[ - tuple[str, str], - tuple[datetime | None, int, dict[str, Any]], - ] = {} - status_contexts: list[dict[str, Any]] = [] - for index, node in enumerate(context_nodes(pr)): - if node.get("__typename") != "CheckRun": - status_contexts.append(node) - continue - workflow = ( - (((node.get("checkSuite") or {}).get("workflowRun") or {}).get("workflow") or {}).get("name") - or "" - ) - key = (workflow, node.get("name") or "check-run") - started_at = parse_github_datetime(node.get("startedAt")) - previous = latest_check_runs.get(key) - if previous is None: - latest_check_runs[key] = (started_at, index, node) - continue - previous_started_at, previous_index, _ = previous - if started_at is None and previous_started_at is not None: - continue - if previous_started_at is None and started_at is not None: - latest_check_runs[key] = (started_at, index, node) - continue - if (started_at or datetime.min.replace(tzinfo=timezone.utc), index) >= ( - previous_started_at or datetime.min.replace(tzinfo=timezone.utc), - previous_index, - ): - latest_check_runs[key] = (started_at, index, node) + check_runs = latest_check_runs(pr) + superseded_coverage_indices = ( + superseded_coverage_evidence_indices(check_runs) if ignore_opencode else set() + ) + status_contexts = [ + node + for node in context_nodes(pr) + if node.get("__typename") != "CheckRun" + ] successful_status_contexts = { node.get("context") for node in status_contexts if (node.get("state") or "").upper() == "SUCCESS" } - for _, _, node in sorted(latest_check_runs.values(), key=lambda item: item[1]): + for index, node in enumerate(check_runs): conclusion = (node.get("conclusion") or "").upper() if conclusion in FAILED_CHECK_CONCLUSIONS: + if index in superseded_coverage_indices: + continue + if ignore_opencode and node.get("name") == "opencode-review": + 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 +1662,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 @@ -2069,6 +2224,46 @@ def active_opencode_run_refs( ) +def latest_opencode_dispatch_started_at( + repo: str, + workflow: str, + pr: dict[str, Any], +) -> datetime | None: + """Return the latest completed same-head OpenCode dispatch start time.""" + target_repo = validate_github_repository(repo) + dispatch_repo = repository_dispatch_target(target_repo) + head = str(pr.get("headRefOid") or "").lower() + number = int(pr["number"]) + title_prefixes = tuple( + f"{title} {target_repo}#{number}@" + for title in sorted( + {"Required OpenCode Review", *OPENCODE_WORKFLOW_NAMES}, + key=len, + reverse=True, + ) + ) + latest: datetime | None = None + for run_data in active_workflow_runs(dispatch_repo, ("completed",)): + if run_data.get("event") != "repository_dispatch": + continue + display_title = str(run_data.get("display_title") or "") + prefix = next( + (candidate for candidate in title_prefixes if display_title.startswith(candidate)), + None, + ) + if prefix is None: + continue + dispatched_head = display_title.removeprefix(prefix).lower() + if not GIT_SHA_RE.fullmatch(dispatched_head) or dispatched_head != head: + continue + started_at = parse_github_datetime( + run_data.get("run_started_at") or run_data.get("created_at") + ) + if started_at and (latest is None or started_at > latest): + latest = started_at + return latest + + def active_opencode_run_ids( repo: str, workflow: str, @@ -2492,16 +2687,102 @@ 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_retry_progress = opencode_progress_state( + pr, stale_after_minutes=stale_opencode_minutes + ) + coverage_ready = ( + merge_state not in {"DIRTY", "CONFLICTING"} + and 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: + if coverage_retry_progress == "running": + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + "current-head OpenCode coverage evidence is complete; disable " + "auto-merge while same-head re-review is already running" + ), + ) + ) + return decide( + "wait", + "current-head OpenCode coverage evidence is complete; " + "same-head OpenCode re-review is already running", + ) + retry_wait_reason = coverage_retry_wait_reason( + pr, + repo=repo if not dry_run else None, + workflow=workflow if not dry_run else None, + ) + if retry_wait_reason: + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + f"{retry_wait_reason}; disable auto-merge until the same-head " + "coverage retry floor elapses" + ), + ) + ) + return decide("wait", retry_wait_reason) + if pr.get("autoMergeRequest"): + return finish( + disable_auto_merge_decision( + repo, + pr, + dry_run=dry_run, + reason=( + "current-head OpenCode coverage blocker is cleared; disable auto-merge " + "before same-head re-review" + ), + ) + ) + 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", + ) + conflict_suffix = ( + f"; {merge_conflict_guidance(pr, merge_state)}" + if merge_state in {"DIRTY", "CONFLICTING"} + else "" + ) if pr.get("autoMergeRequest"): return finish( disable_auto_merge_decision( repo, pr, dry_run=dry_run, - reason="current-head OpenCode review requested changes; address the review before re-enabling auto-merge", + reason=( + "current-head OpenCode review requested changes; address the review " + f"before re-enabling auto-merge{conflict_suffix}" + ), ) ) - return decide("block", "current-head OpenCode review requested changes") + return decide( + "block", + f"current-head OpenCode review requested changes{conflict_suffix}", + ) current_head_approved = has_current_head_approval(pr) if current_head_approved: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 0e71bdbe24..2fded40c6c 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -922,6 +922,7 @@ def test_context_review_and_check_helpers(monkeypatch): assert sched.context_nodes(make_pr()) == [] assert sched.compare_behind_by({"compareBehindBy": "2"}) == 2 assert sched.compare_behind_by({"compareBehindBy": "unknown"}) == 0 + assert not sched.is_opencode_check_run({"context": "opencode-review"}) assert sched.is_opencode_context({"__typename": "CheckRun", "name": "opencode-review"}) assert sched.is_opencode_context( { @@ -1096,6 +1097,549 @@ def test_central_progress_ignores_required_workflow_checkrun_placeholder( ) +def test_central_coverage_retry_ignores_failed_required_workflow_placeholder( + monkeypatch, +): + """A non-authoritative OpenCode CheckRun cannot self-block its retry.""" + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda *_: None) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda *args, **kwargs: "dispatched", + ) + 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", + }, + { + **opencode_check(status="COMPLETED"), + "conclusion": "FAILURE", + }, + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "review_dispatch" + assert decision.reason == ( + "current-head OpenCode coverage blocker is cleared; " + "same-head OpenCode re-dispatched" + ) + + +def test_coverage_retry_disables_auto_merge_before_dispatch(monkeypatch): + """A coverage retry must not leave an unsafe auto-merge request enabled.""" + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + disabled = [] + dispatched = [] + monkeypatch.setattr( + sched, + "disable_auto_merge", + lambda repo, pr, dry_run: disabled.append((repo, pr["number"], dry_run)), + ) + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda *args, **kwargs: dispatched.append((args, kwargs)) or "dispatched", + ) + coverage_request = make_pr( + autoMergeRequest={"enabledAt": "now"}, + 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", + }, + { + **opencode_check(status="COMPLETED"), + "conclusion": "FAILURE", + }, + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "disable_auto_merge" + assert "before same-head re-review" in decision.reason + assert disabled == [("owner/repo", 1, True)] + assert dispatched == [] + + +def test_coverage_retry_waits_for_visible_opencode_run(monkeypatch): + """A visible same-head OpenCode run prevents duplicate coverage dispatch.""" + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda *_: None) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda *args, **kwargs: dispatched.append((args, kwargs)) or "dispatched", + ) + 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", + }, + opencode_check( + status="IN_PROGRESS", + started_at=datetime.now(timezone.utc).isoformat(), + ), + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "wait" + assert decision.reason == ( + "current-head OpenCode coverage evidence is complete; " + "same-head OpenCode re-review is already running" + ) + assert dispatched == [] + + +def test_coverage_retry_disables_auto_merge_for_visible_opencode_run(monkeypatch): + """An active same-head re-review disables any pre-existing auto-merge request.""" + disabled = [] + monkeypatch.setattr( + sched, + "disable_auto_merge", + lambda repo, pr, dry_run: disabled.append((repo, pr["number"], dry_run)), + ) + coverage_request = make_pr( + autoMergeRequest={"enabledAt": "now"}, + reviews={ + "nodes": [ + { + **opencode_review("CHANGES_REQUESTED", "head"), + "body": ( + "coverage evidence did not pass; coverage-evidence reported that " + "required test/docstring evidence was not proven" + ), + } + ] + }, + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(), + { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": "SUCCESS", + }, + opencode_check( + status="IN_PROGRESS", + started_at=datetime.now(timezone.utc).isoformat(), + ), + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "disable_auto_merge" + assert disabled == [("owner/repo", 1, True)] + + +def test_coverage_retry_waits_for_same_head_retry_floor(monkeypatch): + """A fresh coverage-only review disables auto-merge during its retry floor.""" + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda *_: None) + disabled = [] + monkeypatch.setattr( + sched, + "disable_auto_merge", + lambda repo, pr, dry_run: disabled.append((repo, pr["number"], dry_run)), + ) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda *args, **kwargs: dispatched.append((args, kwargs)) or "dispatched", + ) + coverage_request = make_pr( + autoMergeRequest={"enabledAt": "now"}, + reviews={ + "nodes": [ + { + **opencode_review( + "CHANGES_REQUESTED", + "head", + submitted_at="2999-01-01T00:00:00Z", + ), + "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", + }, + {**opencode_check(status="COMPLETED"), "conclusion": "FAILURE"}, + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "disable_auto_merge" + assert "same-head OpenCode coverage retry floor has not elapsed" in decision.reason + assert "disable auto-merge until the same-head coverage retry floor elapses" in decision.reason + assert disabled == [("owner/repo", 1, True)] + assert dispatched == [] + + +def test_coverage_retry_floor_uses_latest_dispatch_timestamp(monkeypatch): + """A completed dispatch without a review still receives a bounded retry floor.""" + coverage_review = { + **opencode_review( + "CHANGES_REQUESTED", + "head", + submitted_at="2026-08-24T00:00:00Z", + ), + "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." + ), + } + coverage_request = make_pr(reviews={"nodes": [coverage_review]}) + monkeypatch.setattr( + sched, + "latest_opencode_dispatch_started_at", + lambda repo, workflow, pr: datetime(2026, 8, 24, 1, 0, tzinfo=timezone.utc), + ) + + assert sched.coverage_retry_wait_reason( + coverage_request, + repo="owner/repo", + workflow="OpenCode Review", + now=datetime(2026, 8, 24, 1, 59, tzinfo=timezone.utc), + ) == "same-head OpenCode coverage retry floor has not elapsed" + assert sched.coverage_retry_wait_reason( + coverage_request, + repo="owner/repo", + workflow="OpenCode Review", + now=datetime(2026, 8, 24, 2, 0, tzinfo=timezone.utc), + ) is None + + +def test_coverage_retry_wait_reason_fails_closed_without_coverage_review(): + """A non-coverage change request cannot authorize a retry.""" + assert sched.coverage_retry_wait_reason(make_pr()) is None + + +def test_coverage_retry_wait_reason_fails_closed_when_dispatch_history_is_unavailable( + monkeypatch, +): + """Unavailable dispatch history prevents an unbounded same-head retry.""" + coverage_request = make_pr( + reviews={ + "nodes": [ + { + **opencode_review( + "CHANGES_REQUESTED", + "head", + submitted_at="2026-08-24T00:00:00Z", + ), + "body": ( + "coverage evidence did not pass; coverage-evidence reported that " + "required test/docstring evidence was not proven" + ), + } + ] + } + ) + monkeypatch.setattr( + sched, + "latest_opencode_dispatch_started_at", + lambda repo, workflow, pr: (_ for _ in ()).throw(RuntimeError("temporary API failure")), + ) + + assert sched.coverage_retry_wait_reason( + coverage_request, + repo="owner/repo", + workflow="OpenCode Review", + ) == "same-head OpenCode dispatch history is unavailable; defer same-head re-review" + + +def test_coverage_retry_floor_keeps_newer_review_timestamp(monkeypatch): + """An older dispatch cannot extend or replace the newer review timestamp.""" + coverage_request = make_pr( + reviews={ + "nodes": [ + { + **opencode_review( + "CHANGES_REQUESTED", + "head", + submitted_at="2026-08-24T02:00:00Z", + ), + "body": ( + "coverage evidence did not pass; coverage-evidence reported that " + "required test/docstring evidence was not proven" + ), + } + ] + } + ) + monkeypatch.setattr( + sched, + "latest_opencode_dispatch_started_at", + lambda repo, workflow, pr: datetime(2026, 8, 24, 1, 0, tzinfo=timezone.utc), + ) + + assert sched.coverage_retry_wait_reason( + coverage_request, + repo="owner/repo", + workflow="OpenCode Review", + now=datetime(2026, 8, 24, 2, 30, tzinfo=timezone.utc), + ) == "same-head OpenCode coverage retry floor has not elapsed" + + +def test_coverage_retry_without_timestamp_fails_closed(monkeypatch): + """A coverage-only review without a timestamp cannot authorize redispatch.""" + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda *_: None) + coverage_review = opencode_review("CHANGES_REQUESTED", "head") + coverage_review.pop("submittedAt") + coverage_request = make_pr( + reviews={ + "nodes": [ + { + **coverage_review, + "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", + }, + {**opencode_check(status="COMPLETED"), "conclusion": "FAILURE"}, + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "wait" + assert "no valid submission timestamp" in decision.reason + + +def test_coverage_retry_keeps_failed_opencode_workflow_siblings_fail_closed( + monkeypatch, +): + """Only the superseded review job is ignored during coverage retry.""" + monkeypatch.setenv( + "SCHEDULER_REQUIRED_WORKFLOW_REPOSITORY", + "ContextualWisdomLab/.github", + ) + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda *_: None) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda *args, **kwargs: dispatched.append((args, kwargs)) or "dispatched", + ) + 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", + }, + { + **opencode_check(status="COMPLETED"), + "conclusion": "FAILURE", + }, + { + "__typename": "CheckRun", + "name": "coverage-source-tree", + "status": "COMPLETED", + "conclusion": "FAILURE", + "checkSuite": { + "workflowRun": { + "workflow": {"name": "Required OpenCode Review"} + } + }, + }, + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert sched.failed_status_checks(coverage_request, ignore_opencode=True) == [ + "coverage-source-tree" + ] + assert decision.action == "block" + assert decision.reason == "current-head OpenCode review requested changes" + assert dispatched == [] + + +def test_conflicting_coverage_retry_blocks_with_conflict_guidance(monkeypatch): + """Coverage-only retries never dispatch while the exact head conflicts.""" + monkeypatch.setattr(sched, "repository_dispatch_wait_reason", lambda *_: None) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda *args, **kwargs: dispatched.append((args, kwargs)) or "dispatched", + ) + coverage_request = make_pr( + mergeStateStatus="CONFLICTING", + restMergeableState="CONFLICTING", + 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", + }, + ] + } + }, + ) + + decision = inspect(coverage_request) + + assert decision.action == "block" + assert "merge conflict: CONFLICTING" in decision.reason + assert dispatched == [] + + def test_review_state_and_failed_checks(): pr = make_pr(reviews={"nodes": [opencode_review("APPROVED", "old"), opencode_review("APPROVED", "head")]}) assert sched.current_head_review_state(pr, "APPROVED") @@ -1252,6 +1796,102 @@ 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": [ + { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "IN_PROGRESS", + } + ] + } + } + ) + ) == "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"}, + {"name": "lint", "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": [ @@ -1333,6 +1973,22 @@ def test_review_state_and_failed_checks(): } ) assert sched.failed_status_checks(failed) == ["strix", "lint"] + assert sched.failed_status_checks( + make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + { + "__typename": "StatusContext", + "context": "opencode-review", + "state": "FAILURE", + } + ] + } + } + ), + ignore_opencode=True, + ) == [] action_required = make_pr( statusCheckRollup={ "contexts": { @@ -1616,6 +2272,139 @@ def test_failed_status_checks_prefers_timestamped_duplicate_check_runs(): assert sched.failed_status_checks(missing_then_timestamped) == [] +def test_coverage_evidence_state_prefers_newest_rerun(): + """An older failed rerun cannot hide newer successful coverage evidence.""" + + def coverage_check(started_at: str, conclusion: str) -> dict: + return { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": conclusion, + "startedAt": started_at, + "checkSuite": {"workflowRun": {"workflow": {"name": "OpenCode Review"}}}, + } + + pr = make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + coverage_check("2026-08-24T02:00:00Z", "SUCCESS"), + coverage_check("2026-08-24T01:00:00Z", "FAILURE"), + ] + } + } + ) + + assert sched.coverage_evidence_state(pr) == "complete" + + +def test_coverage_evidence_state_prefers_newest_run_across_workflows(): + """Coverage evidence uses time, not rollup order, across workflow names.""" + + def coverage_check(workflow: str, started_at: str, conclusion: str) -> dict: + return { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": conclusion, + "startedAt": started_at, + "checkSuite": {"workflowRun": {"workflow": {"name": workflow}}}, + } + + pr = make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + coverage_check( + "OpenCode Review Dispatch", + "2026-08-24T02:00:00Z", + "SUCCESS", + ), + coverage_check( + "Required OpenCode Review", + "2026-08-24T01:00:00Z", + "FAILURE", + ), + ] + } + } + ) + + assert sched.coverage_evidence_state(pr) == "complete" + + +def test_coverage_retry_ignores_superseded_failure_across_workflows(): + """A newer successful workflow supersedes an older coverage failure.""" + + def coverage_check(workflow: str, started_at: str, conclusion: str) -> dict: + return { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": conclusion, + "startedAt": started_at, + "checkSuite": {"workflowRun": {"workflow": {"name": workflow}}}, + } + + pr = make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + coverage_check( + "Required OpenCode Review", + "2026-08-24T01:00:00Z", + "FAILURE", + ), + coverage_check( + "OpenCode Review Dispatch", + "2026-08-24T02:00:00Z", + "SUCCESS", + ), + ] + } + } + ) + + assert sched.failed_status_checks(pr) == ["coverage-evidence"] + assert sched.failed_status_checks(pr, ignore_opencode=True) == [] + + +def test_coverage_retry_keeps_newest_failed_run_authoritative_across_workflows(): + """An unsuccessful newest run remains a blocking coverage failure.""" + + def coverage_check(workflow: str, started_at: str, conclusion: str) -> dict: + return { + "__typename": "CheckRun", + "name": "coverage-evidence", + "status": "COMPLETED", + "conclusion": conclusion, + "startedAt": started_at, + "checkSuite": {"workflowRun": {"workflow": {"name": workflow}}}, + } + + pr = make_pr( + statusCheckRollup={ + "contexts": { + "nodes": [ + coverage_check( + "Required OpenCode Review", + "2026-08-24T01:00:00Z", + "SUCCESS", + ), + coverage_check( + "OpenCode Review Dispatch", + "2026-08-24T02:00:00Z", + "FAILURE", + ), + ] + } + } + ) + + assert sched.failed_status_checks(pr, ignore_opencode=True) == ["coverage-evidence"] + + def test_run_command_failure_scrubs_secrets(monkeypatch): import subprocess @@ -2294,6 +3083,74 @@ def fake_run(args, stdin=None): assert not any(call[:3] == ["gh", "workflow", "run"] for call in calls) +def test_latest_opencode_dispatch_started_at_matches_exact_completed_run(monkeypatch): + """Completed same-head repository dispatch runs provide a retry timestamp.""" + head_sha = "a" * 40 + monkeypatch.setattr( + sched, + "active_workflow_runs", + lambda repo, statuses: [ + {"event": "push", "display_title": "irrelevant"}, + { + "event": "repository_dispatch", + "display_title": "Different workflow owner/repo#1@" + head_sha, + "created_at": "2026-08-24T00:30:00Z", + }, + { + "event": "repository_dispatch", + "display_title": f"Required OpenCode Review owner/repo#1@{head_sha}", + "run_started_at": "2026-08-24T01:00:00Z", + }, + { + "event": "repository_dispatch", + "display_title": f"Required OpenCode Review owner/repo#1@{head_sha}", + "created_at": "2026-08-24T02:00:00Z", + }, + { + "event": "repository_dispatch", + "display_title": f"Required OpenCode Review owner/repo#1@{head_sha}", + "created_at": "2026-08-24T01:30:00Z", + }, + { + "event": "repository_dispatch", + "display_title": f"Required OpenCode Review owner/repo#1@{head_sha}", + }, + { + "event": "repository_dispatch", + "display_title": "Required OpenCode Review owner/repo#1@not-a-sha", + "created_at": "2026-08-24T03:00:00Z", + }, + ], + ) + + assert sched.latest_opencode_dispatch_started_at( + "owner/repo", + "OpenCode Review", + make_pr(headRefOid=head_sha), + ) == datetime(2026, 8, 24, 2, 0, tzinfo=timezone.utc) + + +def test_latest_opencode_dispatch_started_at_returns_none_without_exact_run(monkeypatch): + """Unrelated completed runs cannot become a same-head retry marker.""" + monkeypatch.setattr( + sched, + "active_workflow_runs", + lambda repo, statuses: [ + { + "event": "repository_dispatch", + "display_title": "Required OpenCode Review owner/repo#1@" + "b" * 40, + "created_at": "2026-08-24T02:00:00Z", + } + ], + ) + + assert sched.latest_opencode_dispatch_started_at( + "owner/repo", + "OpenCode Review", + make_pr(headRefOid="a" * 40), + ) is None + + def test_dispatch_strix_cancels_stale_central_run_and_keeps_current(monkeypatch, capsys): calls = [] head_sha = "a" * 40 @@ -3130,10 +3987,101 @@ def test_inspect_pr_blocks_and_waits_for_policy_states(monkeypatch): ) ) assert conflict_with_stale_review.action == "block" - assert conflict_with_stale_review.reason == ( - "current-head OpenCode review requested changes" + assert conflict_with_stale_review.reason.startswith( + "current-head OpenCode review requested changes; merge conflict:" ) + assert merge_state in conflict_with_stale_review.reason 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": {