//! `pr review-reply ` - answer an inline review comment. //! //! Forgejo's API has no reply endpoint, so the reply is a one-comment //! `COMMENT` review anchored at the same path and line as the comment it //! answers; the forge shows comments with one anchor as one conversation. use anyhow::{Result, bail}; use clap::Args as ClapArgs; use forgejo_api::structs::{CreatePullReviewComment, CreatePullReviewOptions}; use serde_json::{Value, json}; use crate::body; use crate::client::{Client, index}; use crate::verbs::pr_reviews::{comment_location, fetch_inline_comments}; use crate::verbs::{all_pages, print_json}; #[derive(ClapArgs)] pub struct Args { /// PR number. number: u64, /// Id of the inline comment to answer: the `#id` that `pr reviews` shows. comment_id: i64, /// Inline body text. #[arg(long, conflicts_with = "body_file")] body: Option, /// Read body from a file. `-` means stdin. #[arg(long = "body-file")] body_file: Option, } /// Where a reply has to be anchored to land in the same conversation. #[derive(Debug, PartialEq, Eq)] struct Anchor { path: String, line: i64, extra_lines: i64, } /// The anchor of `comment`, or an error when it has none to reuse. /// /// Forgejo drops `position` when a push moved the line out of the diff, and /// then there is nothing to anchor a reply to. fn anchor_of(comment: &Value) -> Result { let line = comment .get("position") .and_then(Value::as_i64) .filter(|&l| l > 0); let path = comment .get("path") .and_then(Value::as_str) .filter(|p| !p.is_empty()); let (Some(line), Some(path)) = (line, path) else { bail!( "comment at {} has no line position (a push moved it out of the diff), so a reply \ cannot be anchored to it; post a normal PR comment instead", comment_location(comment) ); }; Ok(Anchor { path: path.to_owned(), line, extra_lines: comment .get("extra_lines_count") .and_then(Value::as_i64) .unwrap_or(0), }) } /// Find the inline comment with `id` among the PR's reviews. fn find_comment(client: &Client, number: u64, id: i64) -> Result { let (owner, name) = client.owner_repo()?; let reviews = all_pages(|page, size| { let (_, batch) = client .api() .repo_list_pull_reviews(owner, name, index(number)?) .page(page) .page_size(size) .send()?; Ok(batch) })?; for review in reviews { let Some(review_id) = review.id.filter(|&r| r > 0) else { continue; }; let found = fetch_inline_comments(client, number, review_id) .into_iter() .find(|c| c.get("id").and_then(Value::as_i64) == Some(id)); if let Some(c) = found { return Ok(c); } } bail!("no inline review comment #{id} on PR #{number}; `pr reviews {number}` lists their ids") } /// # Errors /// /// Fails when the comment is not on this PR, has no line position to anchor /// to, or the body is empty; propagates transport errors from the forge. pub fn run(client: &Client, args: Args) -> Result<()> { let text = body::resolve_required( args.body.as_deref(), args.body_file.as_deref(), "pr review-reply", )?; let target = find_comment(client, args.number, args.comment_id)?; let anchor = anchor_of(&target)?; let (owner, name) = client.owner_repo()?; let review = client .api() .repo_create_pull_review( owner, name, index(args.number)?, CreatePullReviewOptions { body: Some(String::new()), comments: Some(vec![CreatePullReviewComment { body: Some(text), extra_lines_count: Some(anchor.extra_lines), new_position: Some(anchor.line), old_position: Some(0), path: Some(anchor.path), }]), commit_id: target .get("commit_id") .and_then(Value::as_str) .map(str::to_owned), event: Some("COMMENT".to_owned()), }, ) .send()?; print_json(&json!({ "review": review.id, "in_reply_to": args.comment_id, "at": comment_location(&target), })) } #[cfg(test)] mod tests { use super::*; #[test] fn anchor_reuses_path_line_and_span() { let c = json!({"path": "src/a.rs", "position": 42, "extra_lines_count": 2}); assert_eq!( anchor_of(&c).unwrap(), Anchor { path: "src/a.rs".into(), line: 42, extra_lines: 2 } ); } #[test] fn anchor_refuses_a_comment_without_position() { let c = json!({"path": "src/a.rs", "position": 0}); let err = anchor_of(&c).unwrap_err().to_string(); assert!( err.contains("src/a.rs") && err.contains("no line position"), "{err}" ); assert!(anchor_of(&json!({"position": 3})).is_err()); } }