hive-forge: point to per-line review comments instead of inlining them, fix pr reviews line numbers

This commit is contained in:
damocles 2026-08-15 12:33:46 +02:00
commit b7a7628845
2 changed files with 35 additions and 15 deletions

View file

@ -21,8 +21,9 @@
//! same way (see `crate::verbs::MAX_LIMIT`). //! same way (see `crate::verbs::MAX_LIMIT`).
//! //!
//! Review *bodies* on PRs are always merged in too (`pulls/<n>/reviews` //! Review *bodies* on PRs are always merged in too (`pulls/<n>/reviews`
//! isn't the issues/comments thread, so a plain listing used to miss //! isn't the issues/comments thread), tagged `[review: STATE]`, with a
//! them), tagged `[review: STATE]`. //! `(N line comment(s) — see 'pr reviews')` pointer when a review has
//! inline comments — this verb never inlines those, `pr reviews` does.
//! //!
//! `--json` output is an object (`{"comments": [...], "more_before": N, //! `--json` output is an object (`{"comments": [...], "more_before": N,
//! "more_after": N, "since_more": bool}`), not a bare array, so a //! "more_after": N, "since_more": bool}`), not a bare array, so a
@ -106,6 +107,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
"url": c.get("html_url"), "url": c.get("html_url"),
"kind": c.get("kind").and_then(Value::as_str).unwrap_or("comment"), "kind": c.get("kind").and_then(Value::as_str).unwrap_or("comment"),
"state": c.get("state"), "state": c.get("state"),
"comments_count": c.get("comments_count"),
"attachments": attachment_json_from_value(c), "attachments": attachment_json_from_value(c),
}) })
}) })
@ -128,7 +130,15 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
let body = c.get("body").and_then(Value::as_str).unwrap_or(""); let body = c.get("body").and_then(Value::as_str).unwrap_or("");
if c.get("kind").and_then(Value::as_str) == Some("review") { if c.get("kind").and_then(Value::as_str) == Some("review") {
let state = c.get("state").and_then(Value::as_str).unwrap_or("?"); let state = c.get("state").and_then(Value::as_str).unwrap_or("?");
println!("**{user} @ {ts}** [review: {state}]: {body}"); let n = c.get("comments_count").and_then(Value::as_i64).unwrap_or(0);
if n > 0 {
println!(
"**{user} @ {ts}** [review: {state}] ({n} line comment(s) — see `hive-forge pr reviews {}`): {body}",
args.number
);
} else {
println!("**{user} @ {ts}** [review: {state}]: {body}");
}
} else { } else {
println!("**{user} @ {ts}**: {body}"); println!("**{user} @ {ts}**: {body}");
} }
@ -240,14 +250,19 @@ fn to_values<T: serde::Serialize>(items: Vec<T>) -> Result<Vec<Value>> {
/// ///
/// Review summaries live in the `pulls/<n>/reviews` object, not the /// Review summaries live in the `pulls/<n>/reviews` object, not the
/// issues/comments thread, so the plain comment listing misses them /// issues/comments thread, so the plain comment listing misses them
/// — the review-body gap this fixes. Best-effort: returns empty on /// — the review-body gap this fixes. Best-effort: returns empty on any
/// any error — notably when /// error — notably when `number` is an issue (no reviews endpoint) —
/// `number` is an issue (no reviews endpoint) — so callers degrade /// so callers degrade gracefully. Skips PENDING reviews (not yet
/// gracefully. Skips PENDING reviews (not yet visible to others) and /// visible to others) and reviews with neither a body nor line
/// empty-body reviews (a bare approval adds nothing to the thread). /// comments (a bare approval adds nothing to the thread). Deliberately
/// `created_at` is synthesised from the review's `submitted_at` so /// does NOT inline the per-line comments themselves: merging them into
/// the chronological merge sorts uniformly; `kind:"review"` + the /// this glance-first thread would spam anyone who just wants status.
/// review `state` tag the entry for display. /// `comments_count` carries a pointer to `pr reviews <n>` instead,
/// which prints the full per-line detail — see that verb
/// (`pr_reviews.rs`) for the exhaustive form. `created_at`
/// is synthesised from the review's `submitted_at` so the chronological
/// merge sorts uniformly; `kind:"review"` + the review `state` tag the
/// entry for display.
fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> { fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> {
let Ok((owner, name)) = client.owner_repo() else { let Ok((owner, name)) = client.owner_repo() else {
return Vec::new(); return Vec::new();
@ -268,7 +283,8 @@ fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> {
if state == "PENDING" { if state == "PENDING" {
return None; return None;
} }
if r.body.as_deref().unwrap_or("").trim().is_empty() { let comments_count = r.comments_count.unwrap_or(0);
if r.body.as_deref().unwrap_or("").trim().is_empty() && comments_count == 0 {
return None; return None;
} }
let submitted = rfc3339(r.submitted_at); let submitted = rfc3339(r.submitted_at);
@ -281,6 +297,7 @@ fn fetch_review_bodies(client: &Client, number: u64) -> Vec<Value> {
"html_url": r.html_url, "html_url": r.html_url,
"kind": "review", "kind": "review",
"state": state, "state": state,
"comments_count": comments_count,
})) }))
}) })
.collect() .collect()

View file

@ -124,7 +124,7 @@ fn list_reviews_json(client: &Client, number: u64, reviews: &[Value]) -> Result<
json!({ json!({
"id": c.get("id"), "id": c.get("id"),
"path": c.get("path"), "path": c.get("path"),
"line": c.get("line"), "line": c.get("position"),
"body": c.get("body"), "body": c.get("body"),
}) })
}) })
@ -187,8 +187,11 @@ fn list_reviews_text(client: &Client, number: u64, reviews: &[Value]) {
for c in &fetch_inline_comments(client, number, id) { for c in &fetch_inline_comments(client, number, id) {
let path = c.get("path").and_then(Value::as_str).unwrap_or("?"); 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(); let cbody = c.get("body").and_then(Value::as_str).unwrap_or("").trim();
// PR-level comments have no line; omit `:line` when absent. // PR-level comments have no position; omit `:line` when
match c.get("line").and_then(Value::as_u64) { // absent (also true for a comment whose anchored line has
// since fallen out of the diff — forgejo drops `position`
// in that case too, same display fallback).
match c.get("position").and_then(Value::as_u64) {
Some(line) => println!(" [{path}:{line}] {cbody}"), Some(line) => println!(" [{path}:{line}] {cbody}"),
None => println!(" [{path}] {cbody}"), None => println!(" [{path}] {cbody}"),
} }