blob: 358c05a95d452e72f331319edef267105b88836c [file]
#!/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 hashlib
import json
import re
import subprocess
import time
from collections import Counter
from pathlib import Path
SUBMISSION_FILE = "review-submission.json"
REJECTION_FILE = "review-submission-rejected.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 api(path, remaining, *, payload=None, paginated=False):
command = ["gh", "api", path]
if paginated:
command += ["--paginate", "--slurp"]
if payload is not None:
command += ["--method", "POST", "--input", "-"]
result = subprocess.run(
command, input=json.dumps(payload) if payload is not None else None,
capture_output=True, text=True, check=True, timeout=min(30, remaining()),
)
value = json.loads(result.stdout)
return [item for page in value for item in page] if paginated else value
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")
level = priority(comment["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
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 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):
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("Final submission was attempted but cannot be verified; refusing to repost")
# 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)
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("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")
# Replies may arrive while the model wraps up. They are not part of the
# declared inline set and must not invalidate an otherwise complete review.
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("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")
# 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()