Files
shuke 112ca45819 [fix](ci) Add review findings to one draft before publishing (#69447)
### What problem does this PR solve?

Related PR: #68499

The review runner's bulk REST create-review request rejects five valid
inline findings on #68499 with HTTP 422 (`Position could not be
resolved`; line/side also fails). In a controlled experiment at the same
head/base, all five original findings were accepted when added
sequentially to **one PENDING review** through GraphQL. The experimental
draft was deleted afterward. The exact GitHub internal diff limit is not
established.

For reviews with inline findings, this change creates one draft, adds
one thread per request, verifies the complete comment set, then
publishes once. It converts the existing path/position/body input to
line/side using the authoritative local PR diff. Summary-only reviews
keep their current submission path.

The invocation marker, exclusive submission intent, frozen head/base
checks, priority/convergence gate, and exact final readback remain
required. The helper rechecks competing bot reviews before publication.
Add failures trigger best-effort cleanup only of the exactly owned
pending draft; uncertain writes are never retried. `review-draft.json`
is included in failure artifacts, and GraphQL errors in HTTP 200
responses retain HTTP diagnostics. Ambiguous publication, unavailable
cleanup, and hard termination boundaries are documented.

Live formal publication and the GitHub Actions identity still need
rollout validation. The existing experiment validates draft
construction, not end-to-end CI delivery.
2026-11-02 09:58:25 +08:00

483 lines
23 KiB
Python

#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Submit and verify a final review bound to this runner invocation."""
import argparse
import codecs
import hashlib
import json
import os
import re
import subprocess
import sys
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from review_github_api import decode_json, record_failure, redact, run_gh
SUBMISSION_FILE = "review-submission.json"
REJECTION_FILE = "review-submission-rejected.json"
API_ERRORS_FILE = "review-api-errors.jsonl"
DRAFT_FILE = "review-draft.json"
RUN_FILE = "review-run.json"
RESULT_FILE = "review-result.json"
PRIORITY = re.compile(r"^\[P([0-3])\]\s+\S")
def write_json(path, value):
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(value, indent=2) + "\n")
temporary.replace(path)
def priority(body):
match = PRIORITY.match(body)
if match is None:
raise ValueError("Every finding must start with [P0], [P1], [P2] or [P3]")
return int(match.group(1))
def validate_submission(value):
if not isinstance(value, dict) or set(value) != {
"body", "comments", "existing_blocking_comment_ids",
"converged", "rounds", "incomplete_reason"
}:
raise ValueError(
"Final review requires body, comments, existing_blocking_comment_ids, "
"converged, rounds and incomplete_reason"
)
if type(value["converged"]) is not bool:
raise ValueError("converged must be a boolean")
if type(value["rounds"]) is not int or not 1 <= value["rounds"] <= 3:
raise ValueError("rounds must be an integer from 1 to 3")
reason = value["incomplete_reason"]
if not isinstance(reason, str):
raise ValueError("incomplete_reason must be text")
if value["converged"]:
if reason.strip():
raise ValueError("A converged review cannot have an incomplete_reason")
elif value["rounds"] != 3 or not reason.strip():
raise ValueError("A non-converged review requires three rounds and an incomplete_reason")
if not isinstance(value["body"], str) or not value["body"].strip():
raise ValueError("Final review summary is empty")
if not isinstance(value["comments"], list):
raise ValueError("comments must be an array")
for comment in value["comments"]:
if not isinstance(comment, dict) or set(comment) != {"path", "position", "body"}:
raise ValueError("Each finding requires path, position and body")
if not isinstance(comment["path"], str) or not comment["path"]:
raise ValueError("Finding path is empty")
if type(comment["position"]) is not int or comment["position"] <= 0:
raise ValueError("Finding position must be a positive diff position")
if not isinstance(comment["body"], str):
raise ValueError("Finding body must be text")
priority(comment["body"])
ids = value["existing_blocking_comment_ids"]
if not isinstance(ids, list) or any(type(i) is not int or i <= 0 for i in ids):
raise ValueError("Existing blocking comment IDs must be positive integers")
if len(set(ids)) != len(ids):
raise ValueError("Existing blocking comment IDs must be unique")
return value
def diagnostic_text(value):
value = redact(value)
return value[:65536] + ("\n[truncated after 65536 characters]" if len(value) > 65536 else "")
class GitHubAPIError(ValueError):
def __init__(self, path, method, error, stdout=None, stderr=None):
self.details = {
"method": method, "path": path, "error_type": type(error).__name__,
"returncode": getattr(error, "returncode", None),
"timeout_seconds": getattr(error, "timeout", None),
"stdout": diagnostic_text(stdout), "stderr": diagnostic_text(stderr),
}
message = (
f"GitHub API {method} {path} failed ({type(error).__name__}, "
f"exit={self.details['returncode']}, timeout={self.details['timeout_seconds']}); "
f"stderr={json.dumps(self.details['stderr'][:1000])}; "
f"stdout={json.dumps(self.details['stdout'][:1000])}"
)
self.details["message"] = message
super().__init__(message)
def api(path, remaining, *, payload=None, paginated=False, method=None):
command = ["gh", "api", path]
if paginated:
command += ["--paginate", "--slurp"]
method = method or ("POST" if payload is not None else "GET")
if method != "GET":
command += ["--method", method]
if payload is not None:
command += ["--input", "-"]
try:
result = run_gh(
command, input=json.dumps(payload) if payload is not None else None,
capture_output=True, text=True, check=True, timeout=min(30, remaining()),
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
raise GitHubAPIError(path, method, error, error.stdout, error.stderr) from None
try:
value = decode_json(result, command)
except json.JSONDecodeError as error:
raise GitHubAPIError(path, method, error, result.stdout, result.stderr) from None
# GraphQL can return HTTP 200 with an errors array. Never count that as a
# successful mutation, even if gh changes its exit-code handling.
if path == "graphql" and value.get("errors"):
error = ValueError("GraphQL errors")
record_failure(command, error, result.stdout, result.stderr, diagnostic=result.github_response)
raise GitHubAPIError(path, method, error, result.stdout, result.stderr)
return [item for page in value for item in page] if paginated else value
def with_api_failure(context, reason):
diagnostics = context / API_ERRORS_FILE
if diagnostics.exists():
with diagnostics.open() as handle:
first = json.loads(next(handle))
reason += f"; first recorded API failure: {first['message']} (see {API_ERRORS_FILE})"
return reason
def check_target(run, remaining):
root = f"repos/{run['repository']}/pulls/{run['pr_number']}"
pr = api(root, remaining)
if (pr["state"] != "open" or pr["head"]["sha"] != run["head_sha"]
or pr["base"]["sha"] != run["base_sha"]):
raise ValueError("PR base/head or open state changed; final review is stale")
return root
def review_payload(submission, run, root, remaining):
counts = [0, 0, 0, 0]
for comment in submission["comments"]:
counts[priority(comment["body"])] += 1
# Previously reported findings are not posted again. The reviewer must
# explicitly reconfirm which existing P0/P1 findings still apply to this head.
ids = submission["existing_blocking_comment_ids"]
if ids:
comments = {c["id"]: c for c in api(f"{root}/comments", remaining, paginated=True)}
for comment_id in ids:
comment = comments.get(comment_id)
if comment is None or comment.get("in_reply_to_id") is not None:
raise ValueError(f"Existing blocking finding {comment_id} is missing or a reply")
# Older reviews may bold the whole heading or only the priority.
# Normalize existing comments only; new findings keep their strict format.
body = re.sub(r"^\*\*(\[P[0-3]\])(?:\*\*)?", r"\1", comment["body"], count=1)
level = priority(body)
if level > 1:
raise ValueError(f"Existing finding {comment_id} is not P0/P1")
counts[level] += 1
# The marker binds the entire declared final submission to this invocation,
# including the deduplicated findings. A parallel run cannot satisfy it.
digest = hashlib.sha256(json.dumps(submission, sort_keys=True).encode()).hexdigest()
marker = f"<!-- code-review-completion:{run['token']}:{digest} -->"
body = submission["body"]
# Render the declared convergence state ourselves and bind it to the same
# digest/readback as the findings. A delivered review can still be incomplete.
if submission["converged"]:
body += f"\n\nReview converged after {submission['rounds']} round(s)."
else:
body += (
"\n\n**Review did not converge after 3 rounds. This automated review cannot pass the review check.**"
f"\n\n{submission['incomplete_reason']}"
)
if ids:
body += "\n\nExisting P0/P1 findings confirmed for this head: " + ", ".join(
f"https://github.com/{run['repository']}/pull/{run['pr_number']}#discussion_r{i}"
for i in ids
)
body += f"\n\n{marker}"
return {
"commit_id": run["head_sha"],
"event": "REQUEST_CHANGES" if counts[0] + counts[1] else "COMMENT",
"body": body,
"comments": submission["comments"],
}, counts, marker
THREAD_MUTATION = """mutation($input: AddPullRequestReviewThreadInput!) {
addPullRequestReviewThread(input: $input) {
thread { id path line diffSide }
}
}"""
def diff_path(header):
value = header[4:]
if value.startswith('"'):
# Git quotes UTF-8 bytes with C escapes (including octal), not JSON.
value = codecs.escape_decode(value[1:-1].encode())[0].decode()
else:
value = value.split("\t", 1)[0]
return None if value == "/dev/null" else value[2:]
def inline_locations(context, comments):
"""Map the existing position schema using the authoritative local PR diff."""
wanted = {(c["path"], c["position"]) for c in comments}
locations = {}
old_path = new_path = position = None
old_line = new_line = 0
with (context / "pr.diff").open() as handle:
for raw in handle:
line = raw.rstrip("\n")
if line.startswith("diff --git "):
old_path = new_path = position = None
elif position is None and line.startswith("--- "):
old_path = diff_path(line)
elif position is None and line.startswith("+++ "):
new_path = diff_path(line)
elif line.startswith("@@ "):
match = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
if match is None:
raise ValueError("Invalid hunk in authoritative PR diff")
old_line, new_line = map(int, match.groups())
# The first hunk header is position zero; subsequent headers
# consume a position, as do no-newline markers (not anchors).
position = 0 if position is None else position + 1
elif position is not None:
position += 1
key = (new_path or old_path, position)
if line.startswith((" ", "+", "-")):
if key in wanted:
locations[key] = {"line": old_line if line[0] == "-" else new_line,
"side": "LEFT" if line[0] == "-" else "RIGHT"}
old_line += line[0] != "+"
new_line += line[0] != "-"
if set(locations) != wanted:
missing = sorted(wanted - set(locations))
raise ValueError(f"Finding positions are not commentable PR diff lines: {missing}")
return [locations[(c["path"], c["position"])] for c in comments]
def another_bot_review(reviews, run):
return any(r.get("user", {}).get("login") == "github-actions[bot]"
and r.get("commit_id") == run["head_sha"]
and (r.get("submitted_at") or "") >= run["started_at"] for r in reviews)
def owned_draft(review, payload):
return (review.get("state") == "PENDING"
and review.get("user", {}).get("login") == "github-actions[bot]"
and review.get("commit_id") == payload["commit_id"]
and review.get("body") == payload["body"])
def verify_inline_comments(root, review, payload, run, remaining, *, pending=False):
# Replies may arrive during wrap-up. They are not declared findings.
comments = [c for c in api(f"{root}/reviews/{review['id']}/comments", remaining, paginated=True)
if c.get("in_reply_to_id") is None]
expected = Counter((c["path"], c["position"], c["body"]) for c in payload["comments"])
actual = Counter((c["path"], c.get("original_position") if pending else c.get("position"), c["body"])
for c in comments)
if actual != expected or any(
c.get("user", {}).get("login") != "github-actions[bot]"
or c.get("commit_id") != run["head_sha"] for c in comments
):
raise ValueError("Final review inline comments were not completely verified")
def submit_draft(context, run, root, payload, locations, remaining):
"""Create one draft, add one thread per request, verify, then publish once.
The exclusive SUBMISSION_FILE remains the no-repost guard. A partial draft
is not completion and is never resumed by another helper invocation.
"""
progress = {"stage": "creating", "review_id": None, "thread_ids": []}
def record(stage, **fields):
progress.update(stage=stage, **fields)
write_json(context / DRAFT_FILE, progress)
record("creating")
publish_attempted = False
try:
draft = api(f"{root}/reviews", remaining, payload={
"commit_id": payload["commit_id"], "body": payload["body"], "comments": [],
})
if not owned_draft(draft, payload):
raise ValueError("Created review is not this invocation's pending draft")
record("created", review_id=draft["id"])
for index, (comment, location) in enumerate(zip(payload["comments"], locations)):
check_target(run, remaining)
record("adding", comment_index=index, path=comment["path"], **location)
result = api("graphql", remaining, payload={
"query": THREAD_MUTATION, "variables": {"input": {
"pullRequestReviewId": draft["node_id"], "path": comment["path"],
"body": comment["body"], **location,
}},
})
thread = result["data"]["addPullRequestReviewThread"]["thread"]
if (thread["path"] != comment["path"] or thread["line"] != location["line"]
or thread["diffSide"] != location["side"]):
raise ValueError("Added review thread location did not match")
progress["thread_ids"].append(thread["id"])
record("added")
draft = api(f"{root}/reviews/{draft['id']}", remaining)
if not owned_draft(draft, payload):
raise ValueError("Pending review author, head, state or summary did not match")
verify_inline_comments(root, draft, payload, run, remaining, pending=True)
if another_bot_review(api(f"{root}/reviews", remaining, paginated=True), run):
raise ValueError("Another bot review was submitted during this run; refusing duplicate submission")
check_target(run, remaining)
record("submitting")
publish_attempted = True
api(f"{root}/reviews/{draft['id']}/events", remaining,
payload={"event": payload["event"], "body": payload["body"]})
record("submitted")
except Exception:
# Before publishing, deleting only our exactly identified pending draft
# prevents an add failure from leaving a draft that blocks future runs.
# After a publish attempt, its outcome can be ambiguous: leave it alone
# and let the existing exact final-review readback recover delivery.
if not publish_attempted:
try:
drafts = [r for r in api(f"{root}/reviews", remaining, paginated=True)
if owned_draft(r, payload)]
if len(drafts) > 1:
raise ValueError("Cannot uniquely identify this invocation's pending draft")
if drafts:
current = api(f"{root}/reviews/{drafts[0]['id']}", remaining)
if not owned_draft(current, payload):
raise ValueError("Pending draft changed before cleanup")
api(f"{root}/reviews/{current['id']}", remaining, method="DELETE")
record("deleted", review_id=current["id"])
except Exception as cleanup_error:
# Keep the original create/add failure as the primary error.
progress.update(stage="cleanup_failed", cleanup_error=diagnostic_text(str(cleanup_error)))
try:
write_json(context / DRAFT_FILE, progress)
except OSError:
pass # Report below; do not replace the original API failure.
print(f"Pending review cleanup needs follow-up: {progress}", file=sys.stderr)
raise
def verify_completion(context, run, remaining, *, submit=False, submission=None):
try:
return _verify_completion(context, run, remaining, submit=submit, submission=submission)
except GitHubAPIError as error:
diagnostic = dict(error.details, recorded_at=datetime.now(timezone.utc).isoformat(),
repository=run["repository"], pr_number=run["pr_number"],
head_sha=run["head_sha"], base_sha=run["base_sha"])
# Append rather than replace: later readback failures must not erase the
# original POST response. Neither this log nor the intent proves delivery.
with (context / API_ERRORS_FILE).open("a") as handle:
handle.write(json.dumps(diagnostic) + "\n")
print(f"Review API diagnostic: {json.dumps(diagnostic)}", file=sys.stderr, flush=True)
raise
def _verify_completion(context, run, remaining, *, submit=False, submission=None):
"""Never infer completion from an unowned review or a local result file."""
path = context / SUBMISSION_FILE
if submission is None:
if not path.exists():
rejection = context / REJECTION_FILE
if rejection.exists():
raise ValueError(json.loads(rejection.read_text())["reason"])
raise ValueError("No final review submission was declared")
submission = json.loads(path.read_text())
submission = validate_submission(submission)
if path.exists() and json.loads(path.read_text()) != submission:
raise ValueError("Final submission already declared; refusing a different review")
root = check_target(run, remaining)
payload, counts, marker = review_payload(submission, run, root, remaining)
reviews = api(f"{root}/reviews", remaining, paginated=True)
matching = [r for r in reviews if marker in (r.get("body") or "")]
if not matching and submit:
if another_bot_review(reviews, run):
reason = "Another bot review was submitted during this run; refusing duplicate submission"
# This is a rejected candidate, not a POST intent or proof of delivery.
# Keep it separate so completion still requires exact GitHub readback.
write_json(context / REJECTION_FILE, {"reason": reason, "submission": submission})
raise ValueError(reason)
if path.exists():
raise ValueError(with_api_failure(
context, "Final submission was attempted but cannot be verified; refusing to repost"
))
# Resolve every anchor before creating any GitHub state. The existing
# submission schema stays compatible with review agents and old runs.
locations = inline_locations(context, payload["comments"]) if payload["comments"] else []
# Persist the complete intent before POST: if the response is lost, the
# runner can read back this exact review without repeating the write.
# Exclusive creation also prevents two accidental simultaneous helper
# invocations from both posting after their initial read found no review.
with path.open("x") as handle:
json.dump(submission, handle)
if payload["comments"]:
submit_draft(context, run, root, payload, locations, remaining)
else:
# A summary-only review does not ask GitHub to parse any diff.
api(f"{root}/reviews", remaining, payload=payload)
matching = [r for r in api(f"{root}/reviews", remaining, paginated=True)
if marker in (r.get("body") or "")]
if len(matching) != 1:
raise ValueError(with_api_failure(
context, "Could not verify exactly one final review from this invocation"
))
review = matching[0]
expected_state = "CHANGES_REQUESTED" if payload["event"] == "REQUEST_CHANGES" else "COMMENTED"
if (review.get("user", {}).get("login") != "github-actions[bot]"
or review.get("commit_id") != run["head_sha"]
or (review.get("submitted_at") or "") < run["started_at"]
or review.get("state") != expected_state
or review.get("body") != payload["body"]):
raise ValueError("Final review author, head, timestamp, state or summary did not match")
verify_inline_comments(root, review, payload, run, remaining)
# Recheck after the API reads so a moving PR cannot pass an obsolete review.
check_target(run, remaining)
return {
"state": "failure" if counts[0] + counts[1] or not submission["converged"] else "success",
"converged": submission["converged"], "rounds": submission["rounds"],
"incomplete_reason": submission["incomplete_reason"],
"p0": counts[0], "p1": counts[1], "review_id": review["id"],
"review_url": review["html_url"],
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--context-dir", type=Path, required=True)
parser.add_argument("--input-file", type=Path, required=True)
args = parser.parse_args()
run = json.loads((args.context_dir / RUN_FILE).read_text())
def remaining():
seconds = run["deadline"] - time.monotonic()
if seconds <= 0:
raise TimeoutError("Review's shared time budget was exhausted")
return seconds
result = verify_completion(
args.context_dir, run, remaining, submit=True,
submission=json.loads(args.input_file.read_text()),
)
print(json.dumps(result))
if __name__ == "__main__":
main()