//! `pr reviews ` — list reviews, or submit one via `--approve` / //! `--request-changes` / `--comment`. use std::fmt::Write as _; 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::{all_pages, 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, /// When listing: narrow the inline comments shown (and leave out the /// reviews with none left). `unresolved` = still open on the current /// head, that is neither resolved nor outdated: what a re-review has to /// check. #[arg(long, value_enum, conflicts_with_all = ["approve", "request_changes", "comment"])] only: Option, } /// Which inline comments `--only` keeps. #[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)] enum Only { /// Neither resolved nor outdated. Unresolved, } 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, args.only) } } /// 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. pub(crate) 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, only: Option) -> Result<()> { let (owner, name) = client.owner_repo()?; let idx = index(number)?; let reviews = all_pages(|page, size| { let (_, batch) = client .api() .repo_list_pull_reviews(owner, name, idx) .page(page) .page_size(size) .send()?; Ok(batch) })?; 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, only) } else { list_reviews_text(client, number, &reviews, only); Ok(()) } } /// Where an inline comment sits: `path:line`, or the bare `path` when /// Forgejo dropped the position because the line left the diff. Control /// characters in the path become spaces so one cannot shape the output. pub(crate) fn comment_location(c: &Value) -> String { let path: String = c .get("path") .and_then(Value::as_str) .unwrap_or("?") .chars() .map(|ch| if ch.is_control() { ' ' } else { ch }) .collect(); match c.get("position").and_then(Value::as_u64).filter(|&l| l > 0) { Some(line) => format!("{path}:{line}"), None => path, } } /// An inline comment whose anchored line is no longer in the diff (a push /// moved it): Forgejo drops `position` then. fn is_outdated(c: &Value) -> bool { c.get("position") .and_then(Value::as_u64) .is_none_or(|l| l == 0) } /// Login of whoever resolved the conversation, if anyone did. fn resolved_by(c: &Value) -> Option<&str> { c.get("resolver") .and_then(|r| r.get("login")) .and_then(Value::as_str) } /// Still needs an answer on the current head: neither resolved nor outdated. fn is_open(c: &Value) -> bool { resolved_by(c).is_none() && !is_outdated(c) } /// One inline comment as text: `[#id path:line] body`, continuation lines /// indented, `(resolved by X)` / `(outdated)` after the body. Shared by /// `pr reviews` and `pr timeline`. pub(crate) fn render_inline_comment(c: &Value) -> String { let id = c.get("id").and_then(Value::as_i64).unwrap_or(0); let body = c .get("body") .and_then(Value::as_str) .unwrap_or("") .trim() .replace('\n', "\n "); let mut out = format!("[#{id} {}] {body}", comment_location(c)); if let Some(who) = resolved_by(c) { write!(out, " (resolved by {who})").ok(); } if is_outdated(c) { out.push_str(" (outdated)"); } out } /// The comments to show for one review: all of them, or with `--only` just /// the matching ones. fn visible(comments: Vec, only: Option) -> Vec { match only { Some(Only::Unresolved) => comments.into_iter().filter(is_open).collect(), None => comments, } } /// JSON output: one object per review, with an inline `comments` array. /// With `--only`, only reviews that still have an open comment. fn list_reviews_json( client: &Client, number: u64, reviews: &[Value], only: Option, ) -> Result<()> { let trimmed: Vec = reviews .iter() .filter_map(|r| { let id = r.get("id").and_then(Value::as_i64).unwrap_or(0); let inline = if id > 0 { visible(fetch_inline_comments(client, number, id), only) } else { vec![] }; if only.is_some() && inline.is_empty() { return None; } let inline: Vec = inline .iter() .map(|c| { json!({ "id": c.get("id"), "path": c.get("path"), "line": c.get("position"), "body": c.get("body"), "resolved_by": resolved_by(c), "outdated": is_outdated(c), }) }) .collect(); Some(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 via [`render_inline_comment`]. With `--only`, only reviews /// that still have an open comment, and only those comments. fn list_reviews_text(client: &Client, number: u64, reviews: &[Value], only: Option) { let mut shown = false; for r in reviews { let id = r.get("id").and_then(Value::as_i64).unwrap_or(0); let inline = if id > 0 { visible(fetch_inline_comments(client, number, id), only) } else { vec![] }; if only.is_some() && inline.is_empty() { continue; } shown = true; 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() && only.is_none() { println!("{body}"); } for c in &inline { println!(" {}", render_inline_comment(c)); } println!(); } if !shown { println!( "{}", if only.is_some() { "(no matching inline comments)" } else { "(no reviews)" } ); } } #[cfg(test)] mod tests { use super::*; fn comment(id: i64, position: u64, resolver: Option<&str>) -> Value { json!({ "id": id, "path": "src/a.rs", "position": position, "body": "off by one", "resolver": resolver.map(|login| json!({"login": login})), }) } #[test] fn location_is_path_line_or_bare_path() { assert_eq!(comment_location(&comment(1, 42, None)), "src/a.rs:42"); assert_eq!(comment_location(&comment(1, 0, None)), "src/a.rs"); } #[test] fn location_neutralises_control_characters() { let c = json!({"path": "a\nb\tc.rs", "position": 3}); assert_eq!(comment_location(&c), "a b c.rs:3"); } #[test] fn open_means_neither_resolved_nor_outdated() { assert!(is_open(&comment(1, 42, None))); assert!(!is_open(&comment(1, 42, Some("mara")))); assert!(!is_open(&comment(1, 0, None))); } #[test] fn unresolved_filter_keeps_only_open_comments() { let all = vec![ comment(1, 42, None), comment(2, 42, Some("mara")), comment(3, 0, None), ]; let ids = |v: Vec| -> Vec { v.iter().filter_map(|c| c["id"].as_i64()).collect() }; assert_eq!(ids(visible(all.clone(), Some(Only::Unresolved))), vec![1]); assert_eq!(ids(visible(all, None)), vec![1, 2, 3]); } #[test] fn render_shows_id_location_body_and_state_tags() { assert_eq!( render_inline_comment(&comment(7, 42, None)), "[#7 src/a.rs:42] off by one" ); assert_eq!( render_inline_comment(&comment(7, 42, Some("mara"))), "[#7 src/a.rs:42] off by one (resolved by mara)" ); assert_eq!( render_inline_comment(&comment(7, 0, None)), "[#7 src/a.rs] off by one (outdated)" ); } #[test] fn render_indents_continuation_lines() { let c = json!({"id": 1, "path": "f.rs", "position": 2, "body": "one\ntwo"}); assert_eq!(render_inline_comment(&c), "[#1 f.rs:2] one\n two"); } }