Watch
0
0
Fork
You've already forked hyperhive
0

hive-forge: pr review-reply answers an inline review comment in its conversation

This commit is contained in:
damocles 2026-11-04 20:07:39 +01:00 • committed by mara
commit 66837e7c10
5 changed files with 192 additions and 3 deletions

View file

@ -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 <show|create|edit|view|comment|comments|close|reopen|labels|assign|dependency|reaction|timeline> …`
* `pr` — PR-scoped commands: `pr <show|status|create|merge|reviews|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`
* `pr` — PR-scoped commands: `pr <show|status|create|merge|reviews|review-reply|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`
* `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 <show|status|create|merge|reviews|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`
PR-scoped commands: `pr <show|status|create|merge|reviews|review-reply|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`
**Usage:** `hive-forge pr <COMMAND>`
@ -481,6 +482,7 @@ PR-scoped commands: `pr <show|status|create|merge|reviews|assign-reviewer|commit
* `status` — PR health view: mergeable / CI / reviews
* `merge` — Merge the PR
* `reviews` — List a PR's reviews, or submit one
* `review-reply` — Answer an inline review comment, in its conversation
* `assign-reviewer` — Request (or withdraw with `--remove`) a review from a user
* `diff` — Print the PR's unified diff
* `view` — Show title + body + comments
@ -616,6 +618,24 @@ List a PR's reviews, or submit one
## `hive-forge pr review-reply`
Answer an inline review comment, in its conversation
**Usage:** `hive-forge pr review-reply [OPTIONS] <NUMBER> <COMMENT_ID>`
###### **Arguments:**
* `<NUMBER>` — PR number
* `<COMMENT_ID>` — Id of the inline comment to answer: the `#id` that `pr reviews` shows
###### **Options:**
* `--body <BODY>` — Inline body text
* `--body-file <BODY_FILE>` — Read body from a file. `-` means stdin
## `hive-forge pr assign-reviewer`
Request (or withdraw with `--remove`) a review from a user

View file

@ -85,7 +85,7 @@ enum Verb {
View(verbs::view::Args),
/// Issue-scoped commands: `issue <show|create|edit|view|comment|comments|close|reopen|labels|assign|dependency|reaction|timeline> …`.
Issue(verbs::issue_cmd::Args),
/// PR-scoped commands: `pr <show|status|create|merge|reviews|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`.
/// PR-scoped commands: `pr <show|status|create|merge|reviews|review-reply|assign-reviewer|commits|diff|view|edit|comment|comments|close|reopen|labels|assign-committer|dependency|reaction|timeline> …`.
Pr(verbs::pr_cmd::Args),
/// Post a comment on an issue or PR.
#[command(hide = true)]

View file

@ -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;

View file

@ -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.

View file

@ -0,0 +1,165 @@
//! `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());
}
}