diff --git a/hive-forge/src/verbs/pr_reviews.rs b/hive-forge/src/verbs/pr_reviews.rs index 8755f218..4533f468 100644 --- a/hive-forge/src/verbs/pr_reviews.rs +++ b/hive-forge/src/verbs/pr_reviews.rs @@ -32,8 +32,6 @@ pub struct Args { } pub fn run(client: &Client, args: Args) -> Result<()> { - let repo = client.repo(); - let event = if args.approve { Some("APPROVED") } else if args.request_changes { @@ -45,44 +43,138 @@ pub fn run(client: &Client, args: Args) -> Result<()> { }; if let Some(ev) = event { - // Submit a review. - let payload = json!({ - "event": ev, - "body": args.body.unwrap_or_default(), - }); - let v = client.post_json( - &format!("/repos/{repo}/pulls/{}/reviews", args.number), - &payload, - )?; - // Print a compact summary rather than the full review blob. - let summary = json!({ - "id": v.get("id"), - "state": v.get("state"), - "user": v.get("user").and_then(|u| u.get("login")), - }); - print_json(&summary) + submit_review(client, args.number, ev, args.body) } else { if args.body.is_some() { bail!("--body requires one of --approve / --request-changes / --comment"); } - // List mode (original behaviour). - let v = client.get_json(&format!("/repos/{repo}/pulls/{}/reviews", args.number))?; - let trimmed: Vec = v - .as_array() - .map(|a| { - a.iter() - .map(|r| { + 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 repo = client.repo(); + let payload = json!({ + "event": event, + "body": body.unwrap_or_default(), + }); + let v = client.post_json( + &format!("/repos/{repo}/pulls/{number}/reviews"), + &payload, + )?; + print_json(&json!({ + "id": v.get("id"), + "state": v.get("state"), + "user": v.get("user").and_then(|u| u.get("login")), + })) +} + +/// Fetch inline diff comments for a single review. Returns an empty vec on +/// any error (missing review, network failure) so callers can degrade +/// gracefully. +fn fetch_inline_comments(client: &Client, repo: &str, pr: u64, review_id: u64) -> Vec { + client + .get_json(&format!("/repos/{repo}/pulls/{pr}/reviews/{review_id}/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 repo = client.repo(); + let v = client.get_json(&format!("/repos/{repo}/pulls/{number}/reviews"))?; + let reviews = v.as_array().cloned().unwrap_or_default(); + if client.json_mode() { + list_reviews_json(client, repo, number, &reviews) + } else { + list_reviews_text(client, repo, number, &reviews) + } +} + +/// JSON output: one object per review, with an inline `comments` array. +fn list_reviews_json( + client: &Client, + repo: &str, + number: u64, + reviews: &[Value], +) -> Result<()> { + let trimmed: Vec = reviews + .iter() + .map(|r| { + let id = r.get("id").and_then(Value::as_u64).unwrap_or(0); + let inline: Vec = if id > 0 { + fetch_inline_comments(client, repo, number, id) + .iter() + .map(|c| { json!({ - "id": r.get("id"), - "state": r.get("state"), - "user": r.get("user").and_then(|u| u.get("login")), - "body": r.get("body"), - "comments_count": r.get("comments_count"), + "id": c.get("id"), + "path": c.get("path"), + "line": c.get("line"), + "body": c.get("body"), }) }) .collect() + } else { + vec![] + }; + json!({ + "id": r.get("id"), + "state": r.get("state"), + "user": r.get("user").and_then(|u| u.get("login")), + "body": r.get("body"), + "comments_count": r.get("comments_count"), + "comments": inline, }) - .unwrap_or_default(); - print_json(&Value::Array(trimmed)) + }) + .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, + repo: &str, + number: u64, + reviews: &[Value], +) -> Result<()> { + if reviews.is_empty() { + println!("(no reviews)"); + return Ok(()); } + for r in reviews { + let id = r.get("id").and_then(Value::as_u64).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(); + println!("### review by {user} ({state})"); + if !body.is_empty() { + println!("{body}"); + } + if id > 0 { + for c in &fetch_inline_comments(client, repo, 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 line; omit `:line` when absent. + match c.get("line").and_then(Value::as_u64) { + Some(line) => println!(" [{path}:{line}] {cbody}"), + None => println!(" [{path}] {cbody}"), + } + } + } + println!(); + } + Ok(()) }