Files
doris/.github/scripts/resolve_code_review_status.py
shuke 3c060af5da [fix](ci) Deliver non-converged reviews at the round limit (#68996)
Problem Summary:

The review runner simultaneously required unanimous
`NO_NEW_VALUABLE_FINDINGS` before submission and required submission
after at most three rounds. When round 3 still produced candidates, the
agent could mark its goal blocked and withhold verified findings. This
happened in the reviews of
[#67549](https://github.com/apache/doris/actions/runs/38222336278) and
[#68499](https://github.com/apache/doris/actions/runs/38271916203).

Separate verified delivery from convergence:

- Submit after convergence or the third round. At the cap, publish
verified findings and disclose remaining questions or coverage gaps;
unverified candidates must not become inline findings.
- Require `converged`, `rounds`, and `incomplete_reason` in the
submission. Non-convergence requires round 3 and a nonempty reason. The
trusted helper renders this declaration into the review body and binds
it to the existing digest and exact API readback.
- A verified delivery completes execution, but non-convergence produces
a failing review status even with zero P0/P1. Both the source and
aggregate status explain non-convergence. Reviews without P0/P1 remain
GitHub comments; no artificial blocking finding is created.
- Preserve head/base validation, submission deduplication, capacity
recovery, stale-head cancellation, and existing authorized local/skip
overrides.

The prompt and helpers are loaded from the same workflow revision.
Existing invocations retain their old schema; new invocations reject
missing convergence fields rather than defaulting to a pass. This change
does not update the agent binary or model.
2026-10-13 16:15:46 +08:00

148 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""Resolve the SHA-wide code-review status from PR/base-specific sources."""
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path
SOURCE_CONTEXT_RE = re.compile(
r"code-review/source/(?:automated|local|skip)/pr-(\d+)/base-([0-9a-fA-F]{40})"
)
SHA_RE = re.compile(r"[0-9a-fA-F]{40}")
class ResolutionError(ValueError):
"""The supplied GitHub API data cannot be resolved safely."""
@dataclass(frozen=True)
class Resolution:
state: str
description: str
def resolve_status(
pulls: list[object], statuses: list[object], *, head_sha: str
) -> Resolution:
if SHA_RE.fullmatch(head_sha) is None:
raise ResolutionError("head SHA is not a full SHA")
open_contexts: set[tuple[int, str]] = set()
for item in pulls:
if not isinstance(item, dict) or item.get("state") != "open":
continue
head = item.get("head")
base = item.get("base")
if not isinstance(head, dict) or not isinstance(base, dict):
raise ResolutionError("pull request is missing head or base information")
pull_head = head.get("sha")
base_sha = base.get("sha")
number = item.get("number")
if not isinstance(pull_head, str) or not isinstance(base_sha, str):
raise ResolutionError("pull request head or base SHA is invalid")
if pull_head.casefold() != head_sha.casefold():
continue
if not isinstance(number, int) or SHA_RE.fullmatch(base_sha) is None:
raise ResolutionError("pull request number or base SHA is invalid")
open_contexts.add((number, base_sha.casefold()))
if not open_contexts:
return Resolution(
"pending", f"No open pull request currently uses {head_sha[:12]}."
)
latest_by_context: dict[str, dict[str, object]] = {}
for item in statuses:
if not isinstance(item, dict):
raise ResolutionError("commit status entry is invalid")
context = item.get("context")
status_id = item.get("id")
state = item.get("state")
if not isinstance(context, str) or not isinstance(status_id, int):
raise ResolutionError("commit status context or id is invalid")
if not isinstance(state, str):
raise ResolutionError("commit status state is invalid")
previous = latest_by_context.get(context)
if previous is None or status_id > int(previous["id"]):
latest_by_context[context] = item
approved_contexts: set[tuple[int, str]] = set()
blocked_contexts: set[tuple[int, str]] = set()
incomplete_contexts: set[tuple[int, str]] = set()
for context, item in latest_by_context.items():
match = SOURCE_CONTEXT_RE.fullmatch(context)
if match is not None and item["state"] == "success":
approved_contexts.add((int(match.group(1)), match.group(2).casefold()))
elif (match is not None and item["state"] == "failure"
and context.startswith("code-review/source/automated/")):
key = (int(match.group(1)), match.group(2).casefold())
blocked_contexts.add(key)
# State remains the sole gating signal. Use the runner's description
# only to explain an already-blocking result, never to grant a pass.
description = item.get("description")
if isinstance(description, str) and description.startswith("Review did not converge after "):
incomplete_contexts.add(key)
# Preserve the authorized local PASS / skip override. Otherwise a completed
# automated review with P0/P1 or incomplete coverage blocks instead of pending.
blocked = sorted((open_contexts - approved_contexts) & blocked_contexts)
if blocked:
number, base_sha = blocked[0]
if (number, base_sha) in incomplete_contexts:
return Resolution(
"failure", f"Review did not converge for PR #{number} at {base_sha[:12]}.",
)
return Resolution(
"failure", f"Review completed with P0/P1 for PR #{number} at {base_sha[:12]}.",
)
missing = sorted(open_contexts - approved_contexts)
if missing:
number, base_sha = missing[0]
remaining = len(missing) - 1
suffix = f" (+{remaining} more)" if remaining else ""
return Resolution(
"pending",
f"Awaiting code review for PR #{number} at {base_sha[:12]}{suffix}.",
)
count = len(open_contexts)
noun = "context" if count == 1 else "contexts"
return Resolution(
"success",
f"Code review passed for {count} open PR {noun} on {head_sha[:12]}.",
)
def load_list(path: Path, name: str) -> list[object]:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, list):
raise ResolutionError(f"{name} JSON must be an array")
return value
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--pulls-file", type=Path, required=True)
parser.add_argument("--statuses-file", type=Path, required=True)
parser.add_argument("--head-sha", required=True)
args = parser.parse_args()
resolution = resolve_status(
load_list(args.pulls_file, "pulls"),
load_list(args.statuses_file, "statuses"),
head_sha=args.head_sha,
)
print(f"state={resolution.state}")
print(f"description={resolution.description}")
return 0
if __name__ == "__main__":
raise SystemExit(main())