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
83 changes: 81 additions & 2 deletions scripts/ci/pr_review_fix_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
fetch_open_prs,
fetch_pr,
has_current_head_changes_requested,
is_opencode_review,
review_matches_current_head,
run,
unresolved_thread_count,
)
Expand All @@ -25,6 +27,8 @@
fetch_open_prs,
fetch_pr,
has_current_head_changes_requested,
is_opencode_review,
review_matches_current_head,
run,
unresolved_thread_count,
)
Expand All @@ -37,6 +41,18 @@
r"head_sha=([0-9a-fA-F]{40}) epoch=([0-9]+) -->"
)
REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
NON_AUTOFIX_CHANGE_REQUEST_MARKERS = (
"merge conflict",
"mergestatestatus `dirty`",
"mergestatestatus dirty",
"model pool exhausted",
"could not establish approval sufficiency",
"unresolved human review thread",
"failed check",
"failed-check",
"coverage-evidence",
"strix failed",
)


def run_json(args: list[str]) -> Any:
Expand Down Expand Up @@ -70,10 +86,33 @@ def same_repository_head(repo: str, pr: dict[str, Any]) -> bool:
return ((pr.get("headRepository") or {}).get("nameWithOwner") or "") == repo


def latest_current_head_opencode_review(pr: dict[str, Any]) -> dict[str, Any] | None:
"""Return the newest OpenCode review for the current head, if present."""
for review in reversed((pr.get("reviews") or {}).get("nodes") or []):
if is_opencode_review(review) and review_matches_current_head(review, pr):
return review
return None


def change_request_is_autofixable(pr: dict[str, Any]) -> bool:
"""Return whether the latest OpenCode request is safe for bot autofix."""
merge_state = str(pr.get("mergeStateStatus") or "").upper()
if merge_state and merge_state not in {"CLEAN", "HAS_HOOKS"}:
return False

review = latest_current_head_opencode_review(pr)
if review is None:
return False
body = str((review or {}).get("body") or "").lower()
if any(marker in body for marker in NON_AUTOFIX_CHANGE_REQUEST_MARKERS):
return False
return True


def needs_autofix(pr: dict[str, Any]) -> tuple[bool, tuple[str, ...]]:
"""Return whether current-head evidence justifies an autofix attempt."""
reasons: list[str] = []
if has_current_head_changes_requested(pr):
if has_current_head_changes_requested(pr) and change_request_is_autofixable(pr):
reasons.append("current-head OpenCode requested changes")
unresolved = unresolved_thread_count(pr)
Comment thread
seonghobae marked this conversation as resolved.
if unresolved:
Expand Down Expand Up @@ -260,11 +299,51 @@ def self_test() -> int:
assert recent_fix_marker_exists(comments, head, 24 * 3600)
assert not recent_fix_marker_exists(comments, "b" * 40, 24 * 3600)
pr = {
"reviews": {"nodes": [{"state": "CHANGES_REQUESTED", "author": {"login": "opencode-agent"}, "commit": {"oid": head}}]},
"reviews": {
"nodes": [
{
"state": "CHANGES_REQUESTED",
"author": {"login": "opencode-agent"},
"commit": {"oid": head},
"body": "Actionable source-backed finding with a suggested diff.",
}
]
},
"reviewThreads": {"nodes": []},
"headRefOid": head,
"mergeStateStatus": "CLEAN",
}
assert needs_autofix(pr) == (True, ("current-head OpenCode requested changes",))
dirty_pr = {**pr, "mergeStateStatus": "DIRTY"}
assert needs_autofix(dirty_pr) == (False, ())
model_exhausted_pr = {
**pr,
"reviews": {
"nodes": [
{
"state": "CHANGES_REQUESTED",
"author": {"login": "opencode-agent"},
"commit": {"oid": head},
"body": "OpenCode could not establish approval sufficiency because the model pool exhausted.",
}
]
},
}
assert needs_autofix(model_exhausted_pr) == (False, ())
unresolved_thread_pr = {
**pr,
"reviews": {
"nodes": [
{
"state": "CHANGES_REQUESTED",
"author": {"login": "opencode-agent"},
"commit": {"oid": head},
"body": "OpenCode found unresolved human review thread evidence before approval.",
}
]
},
}
assert needs_autofix(unresolved_thread_pr) == (False, ())
print("self-test passed")
return 0

Expand Down
67 changes: 66 additions & 1 deletion tests/test_pr_review_fix_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ def make_pr(**overrides):
"headRefName": "feature",
"headRefOid": "a" * 40,
"headRepository": {"nameWithOwner": "owner/repo"},
"mergeStateStatus": "CLEAN",
"reviews": {"nodes": []},
"reviewThreads": {"nodes": []},
}
Expand All @@ -44,7 +45,12 @@ def test_needs_autofix_uses_current_head_evidence():
reviews={
"nodes": [
{"state": "APPROVED", "author": {"login": "opencode-agent"}, "commit": {"oid": head}},
{"state": "CHANGES_REQUESTED", "author": {"login": "opencode-agent"}, "commit": {"oid": head}},
{
"state": "CHANGES_REQUESTED",
"author": {"login": "opencode-agent"},
"commit": {"oid": head},
"body": "Actionable source-backed finding with suggested diff.",
},
]
},
reviewThreads={"nodes": [{"id": "thread", "isResolved": False, "isOutdated": False}]},
Expand All @@ -56,6 +62,65 @@ def test_needs_autofix_uses_current_head_evidence():
)


@pytest.mark.parametrize(
("merge_state", "body"),
[
("DIRTY", "Actionable source-backed finding with suggested diff."),
("CONFLICTING", "Actionable source-backed finding with suggested diff."),
("CLEAN", "OpenCode could not establish approval sufficiency because the model pool exhausted."),
("CLEAN", "OpenCode found unresolved human review thread evidence before approval."),
("CLEAN", "Failed-check evidence reports coverage-evidence failure."),
("CLEAN", "Failed check evidence shows coverage-evidence failed on the current head."),
],
)
def test_needs_autofix_suppresses_process_only_reviews(merge_state, body):
"""Process-only or non-clean OpenCode requests do not dispatch autofix."""
head = "a" * 40
pr = make_pr(
headRefOid=head,
mergeStateStatus=merge_state,
reviews={
"nodes": [
{
"state": "CHANGES_REQUESTED",
"author": {"login": "opencode-agent"},
"commit": {"oid": head},
"body": body,
},
]
},
)

assert fix.needs_autofix(pr) == (False, ())


def test_change_request_requires_current_head_opencode_review():
"""Autofixable change requests require an OpenCode review on the current head."""
head = "a" * 40
stale_head = "b" * 40

no_review_pr = make_pr(headRefOid=head, mergeStateStatus="CLEAN")
assert fix.latest_current_head_opencode_review(no_review_pr) is None
assert not fix.change_request_is_autofixable(no_review_pr)

stale_review_pr = make_pr(
headRefOid=head,
mergeStateStatus="CLEAN",
reviews={
"nodes": [
{
"state": "CHANGES_REQUESTED",
"author": {"login": "opencode-agent"},
"commit": {"oid": stale_head},
"body": "Actionable source-backed finding with a suggested diff.",
}
]
},
)
assert fix.latest_current_head_opencode_review(stale_review_pr) is None
assert not fix.change_request_is_autofixable(stale_review_pr)


def test_process_queue_dispatches_same_repo_current_head(monkeypatch, capsys):
"""The queue path dispatches one same-repository autofix."""
pr = make_pr()
Expand Down
Loading