mirror of
https://github.com/apache/doris.git
synced 2026-11-04 18:29:59 +00:00
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.
148 lines
5.5 KiB
Python
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())
|