From 66837e7c100996a626281fed504b50e37adc25f4 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 4 Nov 2026 20:07:39 +0100 Subject: [PATCH] hive-forge: pr review-reply answers an inline review comment in its conversation --- docs/tools/forge-cli.md | 24 +++- hive-forge/src/main.rs | 2 +- hive-forge/src/verbs/mod.rs | 1 + hive-forge/src/verbs/pr_cmd.rs | 3 + hive-forge/src/verbs/pr_review_reply.rs | 165 ++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 3 deletions(-) create mode 100644 hive-forge/src/verbs/pr_review_reply.rs diff --git a/docs/tools/forge-cli.md b/docs/tools/forge-cli.md index 12b0b192..4102fddc 100644 --- a/docs/tools/forge-cli.md +++ b/docs/tools/forge-cli.md @@ -35,6 +35,7 @@ This document contains the help content for the `hive-forge` command-line progra * [`hive-forge pr status`↴](#hive-forge-pr-status) * [`hive-forge pr merge`↴](#hive-forge-pr-merge) * [`hive-forge pr reviews`↴](#hive-forge-pr-reviews) +* [`hive-forge pr review-reply`↴](#hive-forge-pr-review-reply) * [`hive-forge pr assign-reviewer`↴](#hive-forge-pr-assign-reviewer) * [`hive-forge pr diff`↴](#hive-forge-pr-diff) * [`hive-forge pr view`↴](#hive-forge-pr-view) @@ -97,7 +98,7 @@ Deadline: hive-forge stops after HIVE_FORGE_DEADLINE seconds (default 900), prin ###### **Subcommands:** * `issue` — Issue-scoped commands: `issue …` -* `pr` — PR-scoped commands: `pr …` +* `pr` — PR-scoped commands: `pr …` * `comment-show` — Print the body (or full JSON) of a single comment by id * `comment-edit` — Edit an existing comment by id * `clone` — Clone a forge repo (default `-r`/`HIVE_FORGE_REPO`) with credentials autoinjected. Pairs with `pr create --agit` @@ -469,7 +470,7 @@ List timeline events ## `hive-forge pr` -PR-scoped commands: `pr …` +PR-scoped commands: `pr …` **Usage:** `hive-forge pr ` @@ -481,6 +482,7 @@ PR-scoped commands: `pr ` + +###### **Arguments:** + +* `` — PR number +* `` — Id of the inline comment to answer: the `#id` that `pr reviews` shows + +###### **Options:** + +* `--body ` — Inline body text +* `--body-file ` — Read body from a file. `-` means stdin + + + ## `hive-forge pr assign-reviewer` Request (or withdraw with `--remove`) a review from a user diff --git a/hive-forge/src/main.rs b/hive-forge/src/main.rs index 1d174338..2934561a 100644 --- a/hive-forge/src/main.rs +++ b/hive-forge/src/main.rs @@ -85,7 +85,7 @@ enum Verb { View(verbs::view::Args), /// Issue-scoped commands: `issue …`. Issue(verbs::issue_cmd::Args), - /// PR-scoped commands: `pr …`. + /// PR-scoped commands: `pr …`. Pr(verbs::pr_cmd::Args), /// Post a comment on an issue or PR. #[command(hide = true)] diff --git a/hive-forge/src/verbs/mod.rs b/hive-forge/src/verbs/mod.rs index c44017f6..893cbf35 100644 --- a/hive-forge/src/verbs/mod.rs +++ b/hive-forge/src/verbs/mod.rs @@ -36,6 +36,7 @@ pub mod pr_commits; pub mod pr_create; pub mod pr_edit; pub mod pr_merge; +pub mod pr_review_reply; pub mod pr_reviews; pub mod pr_status; pub mod reaction; diff --git a/hive-forge/src/verbs/pr_cmd.rs b/hive-forge/src/verbs/pr_cmd.rs index 5b2ef040..2b84c782 100644 --- a/hive-forge/src/verbs/pr_cmd.rs +++ b/hive-forge/src/verbs/pr_cmd.rs @@ -33,6 +33,8 @@ enum Cmd { Merge(verbs::pr_merge::Args), /// List a PR's reviews, or submit one. Reviews(verbs::pr_reviews::Args), + /// Answer an inline review comment, in its conversation. + ReviewReply(verbs::pr_review_reply::Args), /// Request (or withdraw with `--remove`) a review from a user. AssignReviewer(verbs::pr_assign_reviewer::Args), /// Print the PR's unified diff. @@ -73,6 +75,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { Cmd::Status(a) => verbs::pr_status::run(client, a), Cmd::Merge(a) => verbs::pr_merge::run(client, a), Cmd::Reviews(a) => verbs::pr_reviews::run(client, a), + Cmd::ReviewReply(a) => verbs::pr_review_reply::run(client, a), Cmd::AssignReviewer(a) => verbs::pr_assign_reviewer::run(client, a), Cmd::Diff(a) => verbs::diff::run(client, a), // Generics shared with `issue` — verify the number is a PR first. diff --git a/hive-forge/src/verbs/pr_review_reply.rs b/hive-forge/src/verbs/pr_review_reply.rs new file mode 100644 index 00000000..96782f45 --- /dev/null +++ b/hive-forge/src/verbs/pr_review_reply.rs @@ -0,0 +1,165 @@ +//! `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()); + } +}