Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1319,6 +1319,7 @@ Semantic Versioning where the repository publishes a release.
never saw it and later repositories in the same rotation kept spending
the bucket too. It now stops the repository's scan and propagates the
error like the pre-loop path already did.
- Made pull-request scheduler mutation and dispatch failures fail the targeted workflow and organization sweep after the complete structured decision summary is emitted, while ordinary policy waits remain successful.
- Web verification now checks services through local readiness addresses only.
Start the backend and frontend on this computer and use their local health
URLs when running the check.
Expand Down
40 changes: 40 additions & 0 deletions docs/doctoring/pr-review-merge-scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# PR review and merge scheduler

## Terminal result policy

The scheduler isolates a failed mutation or dispatch to its pull request and
continues the bounded scan. It emits the human-readable lines, job summary, and
versioned JSON decision payload for every inspected pull request before choosing
the process result.

An `action_error` is a material execution failure, so one or more such decisions
produce a non-zero terminal result after the summary is written. Policy outcomes
such as `wait`, `block`, `skip`, and deferred capacity do not make an otherwise
healthy scheduler invocation fail.

A targeted single-pull-request run and the organization sweep use the same
terminal policy. The organization sweep preserves each repository's captured
summary, records that repository as failed, finishes its bounded repository
walk, and then fails the job. A repository is classified as unavailable only
when the scheduler fails before emitting its versioned structured payload and
the error proves that the sweep credential cannot read the repository.

This separation keeps ordinary governance waits visible without reporting them
as incidents, while preventing a failed merge, update, auto-merge, or review
dispatch from producing a passing workflow result. GitHub Actions maps a
non-zero exit code to a failed check, and `GITHUB_STEP_SUMMARY` retains the
operator-facing Markdown evidence before that terminal result.

## References (APA 7th)

GitHub. (n.d.). *Setting exit codes for actions*. GitHub Docs. Retrieved August
24, 2026, from
https://docs.github.com/en/actions/how-tos/create-and-publish-actions/set-exit-codes

GitHub. (n.d.). *Workflow commands for GitHub Actions*. GitHub Docs. Retrieved
August 24, 2026, from
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands

GitHub. (n.d.). *Workflow syntax for GitHub Actions*. GitHub Docs. Retrieved
August 24, 2026, from
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax
13 changes: 12 additions & 1 deletion scripts/ci/pr_review_merge_scheduler_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4949,6 +4949,17 @@ def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decisio
return decide("block", "current head has no OpenCode approval")


def scheduler_exit_code(decisions: list[Decision]) -> int:
"""Return failure after a complete scan when a requested action failed.

Ordinary policy outcomes remain successful scheduler executions. A caught
``action_error`` is different: the scheduler attempted a mutation or
dispatch and could not complete it. The caller receives that failure only
after :func:`print_summary` preserves every per-PR decision.
"""
return 1 if any(decision.action == "action_error" for decision in decisions) else 0


def print_summary(
decisions: list[Decision],
*,
Expand Down Expand Up @@ -6211,7 +6222,7 @@ def main(argv: list[str]) -> int:
project_flow=args.project_flow,
)
_ACTIVE_ADMISSION_GATE = None
return 0
return scheduler_exit_code(decisions)


if __name__ == "__main__": # pragma: no cover
Expand Down
4 changes: 2 additions & 2 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9329,7 +9329,7 @@ def fake_inspect(repo, pr, **kwargs):
monkeypatch.setattr(sched, "fetch_open_prs", lambda repo, max_prs: prs)
monkeypatch.setattr(sched, "inspect_pr", fake_inspect)

assert sched.main(["--repo", "owner/repo", "--base-branch", "main", "--project-flow", "github"]) == 0
assert sched.main(["--repo", "owner/repo", "--base-branch", "main", "--project-flow", "github"]) == 1
assert seen == [1, 2]
output = capsys.readouterr().out
assert "PR #1: action_error: Command failed (1): gh pr merge 1; GraphQL: Resource not accessible by integration" in output
Expand Down Expand Up @@ -9453,7 +9453,7 @@ def fake_inspect(repo, pr, **kwargs):
monkeypatch.setattr(sched, "fetch_open_prs", lambda repo, max_prs: prs)
monkeypatch.setattr(sched, "inspect_pr", fake_inspect)

assert sched.main(["--repo", "owner/repo", "--base-branch", "main", "--project-flow", "github"]) == 0
assert sched.main(["--repo", "owner/repo", "--base-branch", "main", "--project-flow", "github"]) == 1
assert seen == [1, 2, 3]
output = capsys.readouterr().out
assert "PR #1: action_error:" in output
Expand Down
55 changes: 55 additions & 0 deletions tests/test_required_workflow_queue_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,37 @@ def test_scan_pr_queue_has_a_bounded_runtime() -> None:
assert scan_timeout < 60


def test_scheduler_action_errors_propagate_after_structured_summary() -> None:
"""Targeted and organization scans must fail after retaining their summary."""
workflow = workflow_text("pr-review-merge-scheduler.yml")

targeted = workflow.split(" - name: Inspect PR review and merge queue", 1)[1].split(
"\n org-queue-sweep:", 1
)[0]
assert 'python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}"' in targeted
assert "continue-on-error: true" not in targeted
assert "|| true" not in targeted

org_sweep = workflow.split(" org-queue-sweep:", 1)[1]
assert 'sweep_output="$(python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}" 2>&1)"' in org_sweep
assert "sweep_rc=$?" in org_sweep
assert 'if [ "$sweep_rc" -ne 0 ]; then' in org_sweep
assert "grep -Eq '\"schema_version\"[[:space:]]*:[[:space:]]*\"pr-review-merge-scheduler/v2\"'" in org_sweep
assert "failures=$((failures + 1))" in org_sweep


def test_scheduler_exit_policy_is_documented() -> None:
"""Keep the doctoring record bound to the terminal action-error contract."""
policy = (REPO_ROOT / "docs/doctoring/pr-review-merge-scheduler.md").read_text(
encoding="utf-8"
)

assert "action_error" in policy
assert "non-zero" in policy
assert "targeted" in policy
assert "organization sweep" in policy


def test_fix_scheduler_cancels_superseded_cron_runs() -> None:
"""Cancel stale scheduled repair runs before they duplicate mutation work."""
workflow = workflow_text("pr-review-fix-scheduler.yml")
Expand Down Expand Up @@ -1622,3 +1653,27 @@ def test_scorecard_medium_plus_governance_has_owner_and_runbook() -> None:
assert "latest head commit" in runbook
assert "cancel superseded runs" in runbook
assert "Every central workflow failure must print the actionable reason" in runbook


def test_scheduler_action_errors_propagate_after_structured_summary() -> None:
"""The targeted scan must fail after retaining its structured summary."""
workflow = workflow_text("pr-review-merge-scheduler.yml")

# main removed the org-queue-sweep job, so the Inspect step now runs to the
# end of the single scan-pr-queue job.
targeted = workflow.split(" - name: Inspect PR review and merge queue", 1)[1]
assert 'python3 scripts/ci/pr_review_merge_scheduler.py "${args[@]}"' in targeted
assert "continue-on-error: true" not in targeted
assert "|| true" not in targeted


def test_scheduler_exit_policy_is_documented() -> None:
"""Keep the doctoring record bound to the terminal action-error contract."""
policy = (REPO_ROOT / "docs/doctoring/pr-review-merge-scheduler.md").read_text(
encoding="utf-8"
)

assert "action_error" in policy
assert "non-zero" in policy
assert "targeted" in policy
assert "organization sweep" in policy
Loading