Skip to content
Merged
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 .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ jobs:
- name: Inspect PR review and merge queue
env:
GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.scheduler_app_token.outputs.token || github.token }}
SCHEDULER_ACTIONS_TOKEN: ${{ github.token }}
SCHEDULER_READ_TOKEN: ${{ github.token }}
SCHEDULER_MUTATION_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.scheduler_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }}
run: |
Expand Down
46 changes: 39 additions & 7 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,24 @@ def run_github_read(args: Sequence[str], *, stdin: str | None = None) -> str:
return run_with_env(args, stdin=stdin, env=env)


def scheduler_actions_env() -> dict[str, str] | None:
"""Return an env override for GitHub Actions control calls when configured."""
actions_token = os.environ.get("SCHEDULER_ACTIONS_TOKEN")
if not actions_token or actions_token == os.environ.get("GH_TOKEN"):
return None
env = os.environ.copy()
env["GH_TOKEN"] = actions_token
return env


def run_github_actions(args: Sequence[str], *, stdin: str | None = None) -> str:
"""Run a GitHub Actions control command with the workflow token when configured."""
env = scheduler_actions_env()
if env is None:
return run(args, stdin=stdin)
return run_with_env(args, stdin=stdin, env=env)


def split_repo(repo: str) -> tuple[str, str]:
"""Split an owner/name repository string into owner and repository name."""
try:
Expand Down Expand Up @@ -1180,20 +1198,34 @@ def require_github_actions_mutation_actor(action: str) -> None:
)


def require_github_actions_control_actor(action: str) -> None:
"""Refuse Actions rerun or dispatch calls without a workflow control token."""
if os.environ.get("GITHUB_ACTIONS") != "true":
raise RuntimeError(
f"{action} refused outside GitHub Actions; dispatch PR Review Merge Scheduler "
"so the workflow actions credential performs the guarded GitHub Actions control call"
)
if not os.environ.get("SCHEDULER_ACTIONS_TOKEN") and not os.environ.get("GH_TOKEN"):
raise RuntimeError(
f"{action} refused without SCHEDULER_ACTIONS_TOKEN or GH_TOKEN; configure the scheduler "
"job to pass github.token through SCHEDULER_ACTIONS_TOKEN for workflow rerun and dispatch calls"
)


def rerun_actions_job(repo: str, job_id: str, *, dry_run: bool, action: str) -> None:
"""Ask GitHub Actions to rerun an existing required-workflow job."""
if dry_run:
return
require_github_actions_mutation_actor(action)
run(["gh", "api", "-X", "POST", f"repos/{repo}/actions/jobs/{job_id}/rerun"])
require_github_actions_control_actor(action)
run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/jobs/{job_id}/rerun"])


def active_workflow_runs(repo: str) -> list[dict[str, Any]]:
"""Return queued and in-progress workflow runs for a repository."""
runs: list[dict[str, Any]] = []
for status in ("queued", "in_progress"):
payload = json.loads(
run(
run_github_actions(
[
"gh",
"api",
Expand Down Expand Up @@ -1238,12 +1270,12 @@ def cancel_stale_opencode_runs(repo: str, workflow: str, pr: dict[str, Any], *,
"""Force-cancel older OpenCode runs for the same PR before retrying current head."""
if dry_run:
return []
require_github_actions_mutation_actor("force-cancel-stale-opencode-review")
require_github_actions_control_actor("force-cancel-stale-opencode-review")
run_ids = stale_opencode_run_ids(repo, workflow, pr)
if not run_ids:
return []
for run_id in run_ids:
run(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel"])
run_github_actions(["gh", "api", "-X", "POST", f"repos/{repo}/actions/runs/{run_id}/force-cancel"])
return run_ids


Expand All @@ -1256,7 +1288,7 @@ def dispatch_opencode_review(repo: str, workflow: str, pr: dict[str, Any], *, dr
return
if dry_run:
return
run(
run_github_actions(
[
"gh",
"workflow",
Expand Down Expand Up @@ -1286,7 +1318,7 @@ def dispatch_strix_evidence(repo: str, workflow: str, pr: dict[str, Any], *, dry
return
if dry_run:
return
run(
run_github_actions(
[
"gh",
"workflow",
Expand Down
30 changes: 29 additions & 1 deletion tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,33 @@ def fake_run(args, stdin=None):
]


def test_actions_control_uses_workflow_token_when_mutation_token_is_app(monkeypatch):
calls = []

def fake_run_with_env(args, *, stdin=None, env=None):
calls.append((args, stdin, None if env is None else env.get("GH_TOKEN")))
if args[:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]:
return '{"workflow_runs": []}'
return ""

monkeypatch.setattr(sched, "run_with_env", fake_run_with_env)
monkeypatch.setenv("GITHUB_ACTIONS", "true")
monkeypatch.setenv("GH_TOKEN", "opencode-app-token")
monkeypatch.setenv("SCHEDULER_ACTIONS_TOKEN", "workflow-actions-token")

pr = make_pr()
sched.rerun_actions_job("owner/repo", "101", dry_run=False, action="rerun-opencode-review")
sched.dispatch_strix_evidence("owner/repo", "Strix Security Scan", pr, dry_run=False)
sched.dispatch_opencode_review("owner/repo", "OpenCode Review", pr, dry_run=False)

assert [call[2] for call in calls] == ["workflow-actions-token"] * len(calls)
assert calls[0][0] == ["gh", "api", "-X", "POST", "repos/owner/repo/actions/jobs/101/rerun"]
assert calls[1][0][:5] == ["gh", "workflow", "run", "Strix Security Scan", "--repo"]
assert calls[2][0][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[3][0][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[4][0][:5] == ["gh", "workflow", "run", "OpenCode Review", "--repo"]


def test_dispatch_opencode_review_force_cancels_same_pr_old_head_runs(monkeypatch):
calls = []
stale_same_pr = {
Expand Down Expand Up @@ -980,10 +1007,11 @@ def test_mutations_refuse_local_credentials(monkeypatch):

monkeypatch.setenv("GITHUB_ACTIONS", "true")
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("SCHEDULER_ACTIONS_TOKEN", raising=False)
for mutation in (sched.update_branch, sched.enable_auto_merge, sched.merge_pr, sched.disable_auto_merge):
with pytest.raises(RuntimeError, match="refused without GH_TOKEN"):
mutation("owner/repo", make_pr(), dry_run=False)
with pytest.raises(RuntimeError, match="refused without GH_TOKEN"):
with pytest.raises(RuntimeError, match="refused without SCHEDULER_ACTIONS_TOKEN or GH_TOKEN"):
sched.dispatch_opencode_review("owner/repo", "OpenCode Review", rerun_pr, dry_run=False)
assert calls == []

Expand Down