165 lines
5.2 KiB
Rust
165 lines
5.2 KiB
Rust
//! `pr review-reply <number> <comment-id>` - 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<String>,
|
|
/// Read body from a file. `-` means stdin.
|
|
#[arg(long = "body-file")]
|
|
body_file: Option<String>,
|
|
}
|
|
|
|
/// 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<Anchor> {
|
|
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<Value> {
|
|
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());
|
|
}
|
|
}
|