#!/usr/bin/env python3 """Post a review on a Gitea pull request. Used by the GRM workflow to post structured PR reviews. The review body is provided via --body and inline comments via a JSON file (--comments-json) or stdin (--comments-stdin). This script is a thin CLI wrapper around ``GiteaClient.create_review`` — the actual review analysis is performed by the agent before invoking this tool. Usage: REPO_TOKEN= python3 scripts/review_pr.py \ --event COMMENT \ --body "Review body text" \ --comments-json comments.json The comments JSON file is a list of objects with keys: - path: file path in the repo - body: comment text - new_position: line number in the new file (1-based) - old_position: (optional) line number in the old file For APPROVE events, --checklist-confirmed is required. This attests that the reviewer has gone through every category in REVIEW_CHECKLIST.md. The review body must also be substantive (> 20 characters) — trivial "LGTM" approvals are rejected by the auto-merge gate. """ from __future__ import annotations import json import os import sys from typing import Any import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.api_clients import GiteaClient from gitea_runner_manager.config import GITEA_API_URL from gitea_runner_manager.exceptions import APIError from gitea_runner_manager.i18n import _ load_dotenv(override=True) VALID_EVENTS = ("APPROVE", "REQUEST_CHANGES", "COMMENT") def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict[str, Any]]: """Parse inline comments from a JSON file or stdin.""" if comments_json: try: with open(comments_json) as f: data = json.load(f) except json.JSONDecodeError as e: raise click.ClickException(_("Invalid JSON in comments file: {error}", error=str(e))) from None if not isinstance(data, list): raise click.ClickException(_("Comments JSON must be a list of objects.")) return data if comments_stdin: raw = sys.stdin.read().strip() if not raw: return [] try: data = json.loads(raw) except json.JSONDecodeError as e: raise click.ClickException(_("Invalid JSON on stdin: {error}", error=str(e))) from None if not isinstance(data, list): raise click.ClickException(_("Stdin comments JSON must be a list of objects.")) return data return [] @click.command() @click.argument("pr_number") @click.argument("repo") @click.option( "--event", default="COMMENT", type=click.Choice(VALID_EVENTS), help="Review event type: APPROVE, REQUEST_CHANGES, or COMMENT.", ) @click.option("--body", default="", help="Top-level review body text.") @click.option("--comments-json", default=None, help="Path to JSON file with inline comments.") @click.option( "--comments-stdin", is_flag=True, default=False, help="Read inline comments JSON from stdin.", ) @click.option( "--checklist-confirmed", is_flag=True, default=False, help="Required for APPROVE: confirms all REVIEW_CHECKLIST.md categories reviewed.", ) def main( pr_number: str, repo: str, event: str, body: str, comments_json: str | None, comments_stdin: bool, checklist_confirmed: bool, ) -> None: token = os.environ.get("REPO_TOKEN", "") if not token: raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) owner, repo_name = repo.split("/") client = GiteaClient(GITEA_API_URL, token, owner, repo_name) comments = parse_comments(comments_json, comments_stdin) if event != "APPROVE" and not body and not comments: raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event)) if event == "APPROVE": if not checklist_confirmed: raise click.ClickException( _( "APPROVE requires --checklist-confirmed. " "Review every category in REVIEW_CHECKLIST.md before approving." ) ) if len(body.strip()) <= 20 and not comments: raise click.ClickException( _( "APPROVE review body must be substantive (> 20 characters) " "or include inline comments. Trivial approvals are rejected." ) ) try: review = client.create_review(pr_number, event=event, body=body, comments=comments) except APIError as e: raise click.ClickException( _( "Failed to post review: HTTP {status} — {message}", status=e.status, message=e.message, ) ) from None review_id = review.get("id", "?") click.echo( _( "Review #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).", review_id=review_id, pr_number=pr_number, event=event, num_comments=len(comments), ) ) if __name__ == "__main__": # pragma: no cover main()