PX4-Autopilot/Tools/ci/pr-review-poster.py
Ramon Roche c515f81298 fix(ci): stop pr-review-poster from spamming REQUEST_CHANGES on every push
Branch protection rules block the GITHUB_TOKEN from dismissing reviews
(HTTP 403), so every push added another undismissable REQUEST_CHANGES
review. PR #27004 accumulated 12 identical blocking reviews.

Switch to COMMENT-only reviews. Findings still show inline on the diff
but don't create blocking reviews that require manual maintainer
dismissal. The CI check status (pass/fail) gates merging, not the
review state.

Also enable CMAKE_TESTING=ON in the clang-tidy build so test files get
proper include paths in compile_commands.json. Without this,
clang-tidy-diff runs on test files from the PR diff but can't resolve
gtest headers, producing false positives.

Fixes #27004

Signed-off-by: Ramon Roche <mrpollo@gmail.com>
2026-04-10 20:33:34 -06:00

482 lines
17 KiB
Python

#!/usr/bin/env python3
"""
PR review-comment poster for analysis workflows.
Sibling of Tools/ci/pr-comment-poster.py. Where pr-comment-poster.py posts
sticky issue-style PR comments, this script posts line-anchored review
comments on the "Files changed" tab. Use it for tools like clang-tidy that
want to flag specific lines instead of (or in addition to) a rollup
comment.
This script is invoked from the `PR Review Poster` workflow which runs on
`workflow_run` in the base repository context. It consumes a `pr-review`
artifact produced by an upstream analysis job and posts a fresh PR review
via the GitHub REST API, dismissing any stale review the same producer
left on a previous run.
Artifact contract (directory passed on the command line):
manifest.json
{
"pr_number": 12345, (required, int > 0)
"marker": "<!-- pr-review-poster:clang-tidy -->", (required, printable ASCII)
"event": "COMMENT", (required, "COMMENT" only)
"commit_sha": "0123456789abcdef0123456789abcdef01234567",(required, 40 hex chars)
"summary": "Optional review body text" (optional)
}
comments.json
JSON array of line-anchored review comment objects:
[
{"path": "src/foo.cpp", "line": 42, "side": "RIGHT",
"body": "..."},
{"path": "src/bar.hpp", "start_line": 10, "line": 15,
"side": "RIGHT", "start_side": "RIGHT", "body": "..."}
]
Note: `APPROVE` and `REQUEST_CHANGES` events are intentionally NOT
supported. Bots should never approve a pull request, and REQUEST_CHANGES
cannot be dismissed by the GITHUB_TOKEN when branch protection restricts
review dismissals, leading to undismissable spam on every push.
Security: this script is run in a write-token context from a workflow that
MUST NOT check out PR code. Both manifest.json and comments.json are
treated as opaque data. The marker is validated to printable ASCII only
before use, and only reviews authored by github-actions[bot] whose body
contains the marker can be dismissed (a fork cannot spoof either).
Subcommands:
validate <dir> Validate that <dir> contains a conforming manifest +
comments file.
post <dir> Validate, then dismiss any stale matching review and
post a new review on the target PR. Requires env
GITHUB_TOKEN and GITHUB_REPOSITORY.
Python stdlib only. No third-party dependencies.
"""
import argparse
import json
import os
import re
import sys
import time
import _github_helpers
from _github_helpers import fail as _fail
USER_AGENT = 'px4-pr-review-poster'
# Marker length bounds. 1..200 is plenty for an HTML comment tag such as
# "<!-- pr-review-poster:clang-tidy -->".
MARKER_MIN_LEN = 1
MARKER_MAX_LEN = 200
# Cap per-comment body size well under GitHub's hard limit so we leave
# headroom for the wrapping JSON envelope. Empirically GitHub allows ~65535
# bytes per review comment body; 60000 is a safe ceiling.
MAX_COMMENT_BODY_BYTES = 60000
# Cap on number of comments per single review POST. platisd uses 10. The
# value matters because GitHub's review-creation endpoint has a payload
# size limit and review comments occasionally trip an abuse-detection
# threshold when posted in very large batches. Smaller chunks also let us
# spread the work across multiple reviews so a single bad entry only
# fails its own chunk.
COMMENTS_PER_REVIEW = 10
# Sleep between successive review POSTs to stay clear of GitHub's
# secondary rate limits. platisd uses 10s; 5s is enough for our volume
# and cuts user-visible latency.
SLEEP_BETWEEN_CHUNKS_SECONDS = 5
ACCEPTED_EVENTS = ('COMMENT',)
ACCEPTED_SIDES = ('LEFT', 'RIGHT')
COMMIT_SHA_RE = re.compile(r'^[0-9a-f]{40}$')
# The login GitHub assigns to the built-in actions token. Used to filter
# the list of existing reviews so we never touch a human reviewer's review.
BOT_LOGIN = 'github-actions[bot]'
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def _is_printable_ascii(s):
return all(0x20 <= ord(ch) <= 0x7E for ch in s)
def validate_marker(marker):
"""Validate the marker string. See pr-comment-poster.py for rationale."""
if not isinstance(marker, str):
_fail('marker must be a string')
n = len(marker)
if n < MARKER_MIN_LEN or n > MARKER_MAX_LEN:
_fail('marker length out of range ({}..{}): {}'.format(
MARKER_MIN_LEN, MARKER_MAX_LEN, n))
if not _is_printable_ascii(marker):
_fail('marker contains non-printable or non-ASCII character')
def _validate_comment_entry(idx, entry):
"""Validate a single review-comment entry. Raises via _fail on error."""
if not isinstance(entry, dict):
_fail('comments[{}]: must be an object'.format(idx))
path = entry.get('path')
if not isinstance(path, str) or not path:
_fail('comments[{}].path: required non-empty string'.format(idx))
line = entry.get('line')
if not isinstance(line, int) or isinstance(line, bool) or line <= 0:
_fail('comments[{}].line: required positive integer'.format(idx))
side = entry.get('side', 'RIGHT')
if side not in ACCEPTED_SIDES:
_fail('comments[{}].side: must be one of {} (got {!r})'.format(
idx, ', '.join(ACCEPTED_SIDES), side))
if 'start_line' in entry:
start_line = entry['start_line']
if (not isinstance(start_line, int)
or isinstance(start_line, bool)
or start_line <= 0):
_fail('comments[{}].start_line: must be positive integer'.format(idx))
if start_line >= line:
_fail('comments[{}].start_line ({}) must be < line ({})'.format(
idx, start_line, line))
start_side = entry.get('start_side', side)
if start_side not in ACCEPTED_SIDES:
_fail('comments[{}].start_side: must be one of {}'.format(
idx, ', '.join(ACCEPTED_SIDES)))
body = entry.get('body')
if not isinstance(body, str) or not body:
_fail('comments[{}].body: required non-empty string'.format(idx))
body_bytes = len(body.encode('utf-8'))
if body_bytes > MAX_COMMENT_BODY_BYTES:
_fail('comments[{}].body too large: {} bytes (max {})'.format(
idx, body_bytes, MAX_COMMENT_BODY_BYTES))
def validate_manifest(directory):
"""Validate <directory>/manifest.json and <directory>/comments.json.
Returns a dict with keys: pr_number, marker, event, commit_sha,
summary, comments (list of validated comment dicts).
"""
manifest_path = os.path.join(directory, 'manifest.json')
comments_path = os.path.join(directory, 'comments.json')
if not os.path.isfile(manifest_path):
_fail('manifest.json missing at {}'.format(manifest_path))
if not os.path.isfile(comments_path):
_fail('comments.json missing at {}'.format(comments_path))
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
_fail('manifest.json is not valid JSON: {}'.format(e))
if not isinstance(manifest, dict):
_fail('manifest.json must be a JSON object')
pr_number = manifest.get('pr_number')
if not isinstance(pr_number, int) or isinstance(pr_number, bool):
_fail('pr_number must be an integer')
if pr_number <= 0:
_fail('pr_number must be > 0 (got {})'.format(pr_number))
marker = manifest.get('marker')
validate_marker(marker)
event = manifest.get('event')
if event not in ACCEPTED_EVENTS:
_fail('event must be one of {} (got {!r}). APPROVE and '
'REQUEST_CHANGES are intentionally forbidden.'.format(
', '.join(ACCEPTED_EVENTS), event))
commit_sha = manifest.get('commit_sha')
if not isinstance(commit_sha, str) or not COMMIT_SHA_RE.match(commit_sha):
_fail('commit_sha must be a 40-character lowercase hex string')
summary = manifest.get('summary', '')
if summary is None:
summary = ''
if not isinstance(summary, str):
_fail('summary must be a string if present')
try:
with open(comments_path, 'r', encoding='utf-8') as f:
comments = json.load(f)
except (OSError, json.JSONDecodeError) as e:
_fail('comments.json is not valid JSON: {}'.format(e))
if not isinstance(comments, list):
_fail('comments.json must be a JSON array')
for idx, entry in enumerate(comments):
_validate_comment_entry(idx, entry)
return {
'pr_number': pr_number,
'marker': marker,
'event': event,
'commit_sha': commit_sha,
'summary': summary,
'comments': comments,
}
# ---------------------------------------------------------------------------
# Stale-review dismissal
# ---------------------------------------------------------------------------
def find_stale_reviews(client, repo, pr_number, marker):
"""Yield (id, state) for each existing review owned by the bot AND
whose body contains the marker.
Filtering on BOTH author == github-actions[bot] AND marker-in-body is
the security invariant: a fork PR cannot impersonate the bot login,
and a fork PR also cannot inject the marker into a human reviewer's
body without API access.
"""
path = 'repos/{}/pulls/{}/reviews'.format(repo, pr_number)
for review in client.paginated(path):
user = review.get('user') or {}
if user.get('login') != BOT_LOGIN:
continue
body = review.get('body') or ''
if marker not in body:
continue
yield review.get('id'), review.get('state')
def dismiss_stale_reviews(client, repo, pr_number, marker):
"""Dismiss (or, for PENDING reviews, delete) every stale matching review.
Returns the number of reviews that could NOT be dismissed (still active).
"""
dismissal_message = 'Superseded by a newer run'
failed_dismissals = 0
for review_id, state in find_stale_reviews(client, repo, pr_number, marker):
if review_id is None:
continue
if state in ('DISMISSED', 'COMMENTED'):
# Already inert or non-blocking; nothing to do.
continue
if state == 'PENDING':
# PENDING reviews cannot be dismissed; they must be deleted.
print('Deleting pending stale review {}'.format(review_id))
try:
client.request(
'DELETE',
'repos/{}/pulls/{}/reviews/{}'.format(
repo, pr_number, review_id))
except RuntimeError as e:
failed_dismissals += 1
print('warning: failed to delete pending review {}: {}'.format(
review_id, e), file=sys.stderr)
continue
print('Dismissing stale review {} (state={})'.format(review_id, state))
try:
client.request(
'PUT',
'repos/{}/pulls/{}/reviews/{}/dismissals'.format(
repo, pr_number, review_id),
json_body={
'message': dismissal_message,
'event': 'DISMISS',
},
)
except RuntimeError as e:
failed_dismissals += 1
print('warning: failed to dismiss review {}: {}'.format(
review_id, e), file=sys.stderr)
return failed_dismissals
# ---------------------------------------------------------------------------
# Review posting
# ---------------------------------------------------------------------------
def _chunk(lst, n):
"""Yield successive n-sized slices of lst."""
for i in range(0, len(lst), n):
yield lst[i:i + n]
def _build_review_body(marker, summary, chunk_index, chunk_total):
"""Construct the review body text.
Always begins with the marker (so future runs can find and dismiss
this review). Appends a chunk index when the comment set is split
across multiple reviews, and the producer-supplied summary if any.
"""
parts = [marker]
if chunk_total > 1:
parts.append('({}/{})'.format(chunk_index + 1, chunk_total))
if summary:
parts.append('')
parts.append(summary)
return '\n'.join(parts)
def _comment_to_api(entry):
"""Project a validated comment dict to the GitHub API shape."""
api = {
'path': entry['path'],
'line': entry['line'],
'side': entry.get('side', 'RIGHT'),
'body': entry['body'],
}
if 'start_line' in entry:
api['start_line'] = entry['start_line']
api['start_side'] = entry.get('start_side', api['side'])
return api
def post_review(client, repo, pr_number, marker, event, commit_sha, summary,
comments):
"""Post one or more reviews containing the validated comments.
Comments are split into COMMENTS_PER_REVIEW-sized chunks. Each chunk
becomes its own review POST. A failed chunk logs a warning and the
loop continues to the next chunk.
"""
chunks = list(_chunk(comments, COMMENTS_PER_REVIEW))
total = len(chunks)
if total == 0:
print('No comments to post; skipping review creation.')
return
posted_any = False
for idx, chunk in enumerate(chunks):
if idx > 0:
time.sleep(SLEEP_BETWEEN_CHUNKS_SECONDS)
body = _build_review_body(marker, summary, idx, total)
payload = {
'commit_id': commit_sha,
'body': body,
'event': event,
'comments': [_comment_to_api(c) for c in chunk],
}
print('Posting review chunk {}/{} with {} comment(s)'.format(
idx + 1, total, len(chunk)))
try:
client.request(
'POST',
'repos/{}/pulls/{}/reviews'.format(repo, pr_number),
json_body=payload,
)
posted_any = True
except RuntimeError as e:
# Most common cause is HTTP 422: a comment refers to a line
# GitHub does not consider part of the diff. Skip the bad
# chunk and keep going so other findings still get posted.
print('warning: review chunk {}/{} failed: {}'.format(
idx + 1, total, e), file=sys.stderr)
if not posted_any:
# If every single chunk failed, surface that as a hard error so
# the workflow turns red and a human looks at it.
_fail('all review chunks failed to post; see warnings above')
# ---------------------------------------------------------------------------
# Entry points
# ---------------------------------------------------------------------------
def cmd_validate(args):
result = validate_manifest(args.directory)
print(('ok: pr_number={} marker_len={} event={} commit_sha={} '
'comments={} summary_len={}').format(
result['pr_number'],
len(result['marker']),
result['event'],
result['commit_sha'],
len(result['comments']),
len(result['summary']),
))
return 0
def cmd_post(args):
result = validate_manifest(args.directory)
# Empty comment lists short-circuit silently. A producer that ran but
# found nothing to flag should not generate noise on the PR.
if len(result['comments']) == 0:
print('No comments in artifact; nothing to post.')
return 0
token = os.environ.get('GITHUB_TOKEN')
if not token:
_fail('GITHUB_TOKEN is not set')
repo = os.environ.get('GITHUB_REPOSITORY')
if not repo:
_fail('GITHUB_REPOSITORY is not set (expected "owner/name")')
if '/' not in repo:
_fail('GITHUB_REPOSITORY must be "owner/name", got {!r}'.format(repo))
try:
client = _github_helpers.GitHubClient(token, user_agent=USER_AGENT)
undismissed = dismiss_stale_reviews(
client=client,
repo=repo,
pr_number=result['pr_number'],
marker=result['marker'],
)
if undismissed > 0:
print('{} prior review(s) could not be dismissed (likely '
'branch protection).'.format(undismissed))
post_review(
client=client,
repo=repo,
pr_number=result['pr_number'],
marker=result['marker'],
event=result['event'],
commit_sha=result['commit_sha'],
summary=result['summary'],
comments=result['comments'],
)
except RuntimeError as e:
_fail(str(e))
return 0
def main(argv=None):
parser = argparse.ArgumentParser(
description='Validate and post line-anchored PR review comments '
'from CI artifacts.',
)
sub = parser.add_subparsers(dest='command', required=True)
p_validate = sub.add_parser(
'validate',
help='Validate manifest.json and comments.json in the given directory.',
)
p_validate.add_argument('directory')
p_validate.set_defaults(func=cmd_validate)
p_post = sub.add_parser(
'post',
help='Validate, then dismiss any stale review and post a new one. '
'Requires env GITHUB_TOKEN and GITHUB_REPOSITORY.',
)
p_post.add_argument('directory')
p_post.set_defaults(func=cmd_post)
args = parser.parse_args(argv)
return args.func(args)
if __name__ == '__main__':
sys.exit(main())