Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
82b7cb1
fix(governance): select enabled auto-merge method
seonghobae Jul 13, 2026
ac2bf77
test(governance): cover auto-merge settings race
seonghobae Jul 13, 2026
b7843e0
fix(review): retry exhausted pool in central repository
seonghobae Jul 13, 2026
edcdf19
fix(review): make fast approval reusable
seonghobae Jul 13, 2026
d03f12b
fix(governance): tolerate protected stale review
seonghobae Jul 14, 2026
027a03d
fix(governance): close scheduler security races
seonghobae Jul 20, 2026
5468c49
test(governance): cover scheduler refresh guards
seonghobae Jul 20, 2026
8b122af
fix(governance): deduplicate dynamic review runs
seonghobae Jul 20, 2026
c8f5111
fix(governance): deduplicate pending review runs
seonghobae Jul 20, 2026
776cdf7
Merge branch 'main' into fix/opencode-merge-method-concurrency-20260714
opencode-agent[bot] Jul 23, 2026
43b8428
chore(ci): bootstrap PR 558 status-filter repair
seonghobae Aug 3, 2026
7743915
chore(ci): trigger reviewed PR 558 status repair
seonghobae Aug 3, 2026
c5c2dfa
chore(ci): remove inactive PR 558 bootstrap workflow
seonghobae Aug 3, 2026
ab21b5e
fix(ci): retry PR 558 active-status repair
seonghobae Aug 3, 2026
a496626
chore(ci): trigger bounded PR 558 repair
seonghobae Aug 3, 2026
64a2c1d
fix(ci): allow bounded PR 558 repair trigger
seonghobae Aug 3, 2026
2640fc7
fix(ci): trigger PR 558 active-status repair safely
seonghobae Aug 3, 2026
a821b2a
fix(ci): retrigger bounded PR 558 repair
seonghobae Aug 3, 2026
40a07b0
chore(ci): trigger PR 558 active-status repair
seonghobae Aug 3, 2026
06f5f8e
chore(ci): retrigger PR 558 active-status repair
seonghobae Aug 3, 2026
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
13 changes: 7 additions & 6 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7291,6 +7291,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
actions: write
contents: read
pull-requests: read
env:
Expand All @@ -7299,12 +7300,12 @@ jobs:
- name: Dispatch deferred same-head review retry after model-pool exhaustion
env:
GH_TOKEN: ${{ github.token }}
# Cross-repository dispatch of the central workflow needs a PAT; the
# target-repository runner token cannot dispatch workflows that live
# in the central .github repository, and the OpenCode app token has
# no Actions permission.
RETRY_DISPATCH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN }}
RETRY_DISPATCH_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'none' }}
# Cross-repository dispatch needs a PAT. Inside the central repository
# itself, its runner token can dispatch the same trusted workflow.
# This token is only passed to `gh workflow run`; review writes remain
# exclusively authenticated by the OpenCode App token.
RETRY_DISPATCH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || (github.repository == 'ContextualWisdomLab/.github' && github.token) || '' }}
RETRY_DISPATCH_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || github.repository == 'ContextualWisdomLab/.github' && 'github-token' || 'none' }}
# One fixed backoff window before the single deferred retry. GitHub
# Models per-minute throttles recover well inside this window; daily
# quota exhaustion outlives any in-workflow delay and is the org
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-review-merge-scheduler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main, develop, master]
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, auto_merge_enabled, closed]
types: [opened, synchronize, reopened, ready_for_review, closed]
pull_request_review:
types: [submitted, dismissed]
workflow_run:
Expand Down
40 changes: 38 additions & 2 deletions scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1452,16 +1452,23 @@ def run_head_guarded_merge(
auto: bool,
) -> None:
"""Run a head-guarded merge using an allowed repository merge method."""
merge_flag = repository_auto_merge_flag(repo) if auto else "--squash"
args = ["gh", "pr", "merge", number, "--repo", repo]
if auto:
args.append("--auto")
args.extend(["--squash", "--match-head-commit", head])
print(
f"PR #{number}: enabling auto-merge with repository-enabled method "
f"{merge_flag.removeprefix('--')} at guarded head {head}."
)
args.extend([merge_flag, "--match-head-commit", head])
try:
run(args)
return
except RuntimeError as exc:
detail = str(exc).lower()
if not any(marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS):
if merge_flag != "--squash" or not any(
marker in detail for marker in SQUASH_MERGE_DISABLED_MARKERS
):
raise
reason = str(exc).splitlines()[-1][:400]

Expand All @@ -1477,6 +1484,35 @@ def run_head_guarded_merge(
run(merge_args)


def repository_auto_merge_flag(repo: str) -> str:
"""Return the first merge method enabled in repository settings."""
raw = run_github_read(["gh", "api", f"repos/{repo}"])
try:
settings = json.loads(raw)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"Could not select an auto-merge method for {repo}: "
"repository settings were not valid JSON."
) from exc
if not isinstance(settings, dict):
raise RuntimeError(
f"Could not select an auto-merge method for {repo}: "
"repository settings were not an object."
)

for setting, flag in (
("allow_squash_merge", "--squash"),
("allow_merge_commit", "--merge"),
("allow_rebase_merge", "--rebase"),
):
if settings.get(setting) is True:
return flag
raise RuntimeError(
f"Could not enable auto-merge for {repo}: repository settings expose "
"no enabled merge method."
)


def enable_auto_merge(repo: str, pr: dict[str, Any], *, dry_run: bool) -> None:
"""Enable auto-merge for a PR at its current head using an allowed method."""
number = str(pr["number"])
Expand Down
5 changes: 5 additions & 0 deletions tests/test_opencode_agent_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,11 @@ def test_merge_scheduler_uses_escalating_mutation_credentials():
assert '--branch-update-limit "$ORG_SWEEP_BRANCH_UPDATE_LIMIT"' in workflow
assert "pull_request_review:" in workflow
assert "types: [submitted, dismissed]" in workflow
assert (
"types: [opened, synchronize, reopened, ready_for_review, closed]"
in workflow
)
assert "auto_merge_enabled" not in workflow
assert (
"github.event_name == 'pull_request_review' && "
"format('pr-{0}', github.event.pull_request.number)" in workflow
Expand Down
92 changes: 82 additions & 10 deletions tests/test_pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,10 @@ def test_actions_call_gh_with_expected_arguments(monkeypatch):

def fake_run(args, stdin=None):
calls.append(args)
if args == ["gh", "api", "repos/owner/repo"]:
return json.dumps(
{"allow_squash_merge": False, "allow_merge_commit": True}
)
if args[:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]:
return '{"workflow_runs": []}'
return ""
Expand All @@ -1580,10 +1584,12 @@ def fake_run(args, stdin=None):
sched.update_branch("owner/repo", pr, dry_run=False)
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 calls[0][:4] == ["gh", "pr", "merge", "1"]
assert "--squash" in calls[0]
assert calls[0][-2:] == ["--match-head-commit", head_sha]
assert calls[1] == [
assert calls[0] == ["gh", "api", "repos/owner/repo"]
assert calls[1][:4] == ["gh", "pr", "merge", "1"]
assert "--auto" in calls[1]
assert "--merge" in calls[1]
assert calls[1][-2:] == ["--match-head-commit", head_sha]
assert calls[2] == [
"gh",
"pr",
"merge",
Expand All @@ -1594,13 +1600,13 @@ def fake_run(args, stdin=None):
"--match-head-commit",
head_sha,
]
assert calls[2] == ["gh", "pr", "merge", "1", "--repo", "owner/repo", "--disable-auto"]
assert calls[3][:4] == ["gh", "api", "-X", "PUT"]
assert calls[3][-1] == f"expected_head_sha={head_sha}"
assert calls[4][:5] == ["gh", "workflow", "run", "Strix Security Scan", "--repo"]
assert calls[5][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[3] == ["gh", "pr", "merge", "1", "--repo", "owner/repo", "--disable-auto"]
assert calls[4][:4] == ["gh", "api", "-X", "PUT"]
assert calls[4][-1] == f"expected_head_sha={head_sha}"
assert calls[5][:5] == ["gh", "workflow", "run", "Strix Security Scan", "--repo"]
assert calls[6][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[7][:5] == ["gh", "workflow", "run", "OpenCode Review", "--repo"]
assert calls[7][:5] == ["gh", "api", "--method", "GET", "repos/owner/repo/actions/runs"]
assert calls[8][:5] == ["gh", "workflow", "run", "OpenCode Review", "--repo"]
calls.clear()

required_workflow_pr = make_pr(
Expand Down Expand Up @@ -1702,6 +1708,11 @@ def fake_run(args, stdin=None):
return ""

monkeypatch.setattr(sched, "run", fake_run)
monkeypatch.setattr(
sched,
"run_github_read",
lambda _args: json.dumps({"allow_squash_merge": True}),
)

sched.run_head_guarded_merge(
"owner/repo",
Expand All @@ -1713,12 +1724,73 @@ def fake_run(args, stdin=None):
assert len(calls) == 2
assert "--squash" in calls[0]
assert "--merge" in calls[1]
assert ("--auto" in calls[0]) is auto
assert ("--auto" in calls[1]) is auto
assert calls[0][-2:] == ["--match-head-commit", head_sha]
assert calls[1][-2:] == ["--match-head-commit", head_sha]
assert "Squash merges are not allowed" in capsys.readouterr().out


@pytest.mark.parametrize(
("settings", "expected_flag"),
[
({"allow_squash_merge": True, "allow_merge_commit": True}, "--squash"),
({"allow_squash_merge": False, "allow_merge_commit": True}, "--merge"),
(
{
"allow_squash_merge": False,
"allow_merge_commit": False,
"allow_rebase_merge": True,
},
"--rebase",
),
],
)
def test_head_guarded_auto_merge_uses_repository_enabled_method(
monkeypatch, capsys, settings, expected_flag
):
calls = []
head_sha = "a" * 40

monkeypatch.setattr(sched, "run_github_read", lambda _args: json.dumps(settings))
monkeypatch.setattr(sched, "run", lambda args, stdin=None: calls.append(args) or "")

sched.run_head_guarded_merge("owner/repo", "7", head_sha, auto=True)

assert calls == [
[
"gh",
"pr",
"merge",
"7",
"--repo",
"owner/repo",
"--auto",
expected_flag,
"--match-head-commit",
head_sha,
]
]
assert expected_flag.removeprefix("--") in capsys.readouterr().out


@pytest.mark.parametrize(
("payload", "message"),
[
("not-json", "not valid JSON"),
("[]", "not an object"),
("{}", "no enabled merge method"),
],
)
def test_repository_auto_merge_flag_explains_invalid_settings(
monkeypatch, payload, message
):
monkeypatch.setattr(sched, "run_github_read", lambda _args: payload)

with pytest.raises(RuntimeError, match=message):
sched.repository_auto_merge_flag("owner/repo")


def test_head_guarded_merge_does_not_mask_unrelated_failure(monkeypatch):
calls = []

Expand Down
18 changes: 18 additions & 0 deletions tests/test_required_workflow_queue_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ def test_merge_scheduler_provides_same_repository_dispatch_credential() -> None:
assert workflow.count("SCHEDULER_DISPATCH_TOKEN: ${{ github.token }}") == 2


def test_opencode_exhausted_retry_uses_runner_token_only_for_central_dispatch() -> None:
"""Keep same-repository retry live without weakening App-only review writes."""
workflow = workflow_text("opencode-review.yml")
retry_job = workflow.split(" opencode-exhausted-retry:\n", 1)[1]

assert "actions: write" in retry_job.split(" env:\n", 1)[0]
assert (
"github.repository == 'ContextualWisdomLab/.github' && github.token"
in retry_job
)
assert (
"github.repository == 'ContextualWisdomLab/.github' && 'github-token'"
in retry_job
)
assert "review writes remain" in retry_job
assert "GH_TOKEN=\"$RETRY_DISPATCH_TOKEN\" gh workflow run" in retry_job


def test_required_pull_request_workflows_cancel_superseded_runs() -> None:
for filename in (
"close-empty-pr.yml",
Expand Down
Loading