//! `pr-reviews ` — list reviews, or submit one via `--approve` / //! `--request-changes` / `--comment`. use anyhow::{Result, bail}; use clap::Args as ClapArgs; use forgejo_api::structs::CreatePullReviewOptions; use serde_json::{Value, json}; use crate::client::{Client, index}; use crate::verbs::print_json; #[derive(ClapArgs)] pub struct Args { /// PR number. number: u64, /// Approve the PR (submit an APPROVED review). #[arg(long, conflicts_with_all = ["request_changes", "comment"])] approve: bool, /// Request changes on the PR (submit a `REQUEST_CHANGES` review). #[arg(long, conflicts_with_all = ["approve", "comment"])] request_changes: bool, /// Leave a comment review (submit a COMMENT review). #[arg(long, conflicts_with_all = ["approve", "request_changes"])] comment: bool, /// Optional body / message for the review (used with --approve, /// --request-changes, or --comment). #[arg(long, short = 'm')] body: Option, } pub fn run(client: &Client, args: Args) -> Result<()> { let event = if args.approve { Some("APPROVED") } else if args.request_changes { Some("REQUEST_CHANGES") } else if args.comment { Some("COMMENT") } else { None }; if let Some(ev) = event { submit_review(client, args.number, ev, args.body) } else { if args.body.is_some() { bail!("--body requires one of --approve / --request-changes / --comment"); } list_reviews(client, args.number) } } /// Submit a review event (`APPROVED` / `REQUEST_CHANGES` / `COMMENT`) and print /// a compact summary of the created review. fn submit_review(client: &Client, number: u64, event: &str, body: Option) -> Result<()> { let (owner, name) = client.owner_repo()?; let payload = CreatePullReviewOptions { body: Some(body.unwrap_or_default()), comments: None, commit_id: None, event: Some(event.to_owned()), }; let review = client .api() .repo_create_pull_review(owner, name, index(number)?, payload) .send()?; print_json(&json!({ "id": review.id, "state": review.state, "user": review.user.as_ref().and_then(|u| u.login.as_deref()), })) } /// Fetch inline diff comments for a single review, serialized back to /// the API's JSON shape. Returns an empty vec on any error (missing /// review, network failure) so callers can degrade gracefully. fn fetch_inline_comments(client: &Client, pr: u64, review_id: i64) -> Vec { let Ok((owner, name)) = client.owner_repo() else { return Vec::new(); }; let Ok(idx) = index(pr) else { return Vec::new(); }; client .api() .repo_get_pull_review_comments(owner, name, idx, review_id) .send() .ok() .and_then(|comments| serde_json::to_value(comments).ok()) .and_then(|v| v.as_array().cloned()) .unwrap_or_default() } /// List all reviews for a PR, dispatching to the appropriate output mode. fn list_reviews(client: &Client, number: u64) -> Result<()> { let (owner, name) = client.owner_repo()?; let (_, reviews) = client .api() .repo_list_pull_reviews(owner, name, index(number)?) .send()?; let reviews = serde_json::to_value(reviews)?; let reviews = reviews.as_array().cloned().unwrap_or_default(); if client.json_mode() { list_reviews_json(client, number, &reviews) } else { list_reviews_text(client, number, &reviews); Ok(()) } } /// JSON output: one object per review, with an inline `comments` array. fn list_reviews_json(client: &Client, number: u64, reviews: &[Value]) -> Result<()> { let trimmed: Vec = reviews .iter() .map(|r| { let id = r.get("id").and_then(Value::as_i64).unwrap_or(0); let inline: Vec = if id > 0 { fetch_inline_comments(client, number, id) .iter() .map(|c| { json!({ "id": c.get("id"), "path": c.get("path"), "line": c.get("position"), "body": c.get("body"), }) }) .collect() } else { vec![] }; json!({ "id": r.get("id"), "state": r.get("state"), // forgejo's staleness bits: `stale` = head moved since the // review (branch protection wants a fresh one); `dismissed` // = explicitly dismissed. A stale/dismissed APPROVED no // longer satisfies the merge gate despite `state` reading // APPROVED — surface them so callers don't over-trust it. "stale": r.get("stale"), "dismissed": r.get("dismissed"), "user": r.get("user").and_then(|u| u.get("login")), "body": r.get("body"), "comments_count": r.get("comments_count"), "comments": inline, }) }) .collect(); print_json(&Value::Array(trimmed)) } /// Human-readable output: Markdown-style heading per review, inline /// comments as `[path:line] body` (line omitted for PR-level comments). fn list_reviews_text(client: &Client, number: u64, reviews: &[Value]) { if reviews.is_empty() { println!("(no reviews)"); return; } for r in reviews { let id = r.get("id").and_then(Value::as_i64).unwrap_or(0); let user = r .get("user") .and_then(|u| u.get("login")) .and_then(Value::as_str) .unwrap_or("?"); let state = r.get("state").and_then(Value::as_str).unwrap_or("?"); let body = r.get("body").and_then(Value::as_str).unwrap_or("").trim(); // Flag reviews forgejo considers no-longer-current so a stale // APPROVED doesn't read as still-satisfying branch protection. let is_stale = r.get("stale").and_then(Value::as_bool).unwrap_or(false); let is_dismissed = r.get("dismissed").and_then(Value::as_bool).unwrap_or(false); let flag = if is_stale { " [stale — needs re-review]" } else if is_dismissed { " [dismissed]" } else { "" }; println!("### review by {user} ({state}){flag}"); if !body.is_empty() { println!("{body}"); } if id > 0 { for c in &fetch_inline_comments(client, number, id) { let path = c.get("path").and_then(Value::as_str).unwrap_or("?"); let cbody = c.get("body").and_then(Value::as_str).unwrap_or("").trim(); // PR-level comments have no position; omit `:line` when // absent (also true for a comment whose anchored line has // since fallen out of the diff — forgejo drops `position` // in that case too, same display fallback). match c.get("position").and_then(Value::as_u64) { Some(line) => println!(" [{path}:{line}] {cbody}"), None => println!(" [{path}] {cbody}"), } } } println!(); } }