fix(#2088): merge pr review bodies into hive-forge comments listing
This commit is contained in:
parent
689a217c39
commit
1cff77f5fa
1 changed files with 117 additions and 2 deletions
|
|
@ -14,6 +14,15 @@
|
||||||
//! on this long thread?" without scrolling through the whole
|
//! on this long thread?" without scrolling through the whole
|
||||||
//! history.
|
//! history.
|
||||||
//!
|
//!
|
||||||
|
//! For PRs, review *bodies* (the summary text submitted with an
|
||||||
|
//! approve / request-changes / comment review) are merged in too:
|
||||||
|
//! they live in the `pulls/<n>/reviews` object, NOT the
|
||||||
|
//! issues/comments thread, so plain comment listings used to miss
|
||||||
|
//! them entirely and reviewers/authors silently lost feedback
|
||||||
|
//! (#2088). They're always included regardless of `--limit`/`--tail`
|
||||||
|
//! (reviews are few + high-signal) and tagged `[review: STATE]` so
|
||||||
|
//! they're distinguishable from issue-thread comments.
|
||||||
|
//!
|
||||||
//! Use the global `--json` flag for JSON output.
|
//! Use the global `--json` flag for JSON output.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
@ -48,10 +57,13 @@ pub struct Args {
|
||||||
|
|
||||||
pub fn run(client: &Client, args: Args) -> Result<()> {
|
pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
let repo = client.repo();
|
let repo = client.repo();
|
||||||
let comments = match args.tail {
|
let thread = match args.tail {
|
||||||
Some(n) => fetch_tail(client, repo, args.number, n)?,
|
Some(n) => fetch_tail(client, repo, args.number, n)?,
|
||||||
None => fetch_head(client, repo, args.number, args.limit)?,
|
None => fetch_head(client, repo, args.number, args.limit)?,
|
||||||
};
|
};
|
||||||
|
// Merge in PR review bodies (empty for issues — degrades to a
|
||||||
|
// no-op) so review feedback isn't silently dropped (#2088).
|
||||||
|
let comments = merge_chronological(thread, fetch_review_bodies(client, repo, args.number));
|
||||||
// Reading the thread clears its unread notification so the
|
// Reading the thread clears its unread notification so the
|
||||||
// read-before-comment guard (in `comment`) lets a reply through.
|
// read-before-comment guard (in `comment`) lets a reply through.
|
||||||
notify::mark_read_best_effort(client, repo, args.number);
|
notify::mark_read_best_effort(client, repo, args.number);
|
||||||
|
|
@ -66,6 +78,8 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
"updated_at": c.get("updated_at"),
|
"updated_at": c.get("updated_at"),
|
||||||
"body": c.get("body"),
|
"body": c.get("body"),
|
||||||
"url": c.get("html_url"),
|
"url": c.get("html_url"),
|
||||||
|
"kind": c.get("kind").and_then(Value::as_str).unwrap_or("comment"),
|
||||||
|
"state": c.get("state"),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -79,13 +93,80 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
|
||||||
.unwrap_or("?");
|
.unwrap_or("?");
|
||||||
let ts = c.get("created_at").and_then(Value::as_str).unwrap_or("?");
|
let ts = c.get("created_at").and_then(Value::as_str).unwrap_or("?");
|
||||||
let body = c.get("body").and_then(Value::as_str).unwrap_or("");
|
let body = c.get("body").and_then(Value::as_str).unwrap_or("");
|
||||||
println!("**{user} @ {ts}**: {body}");
|
if c.get("kind").and_then(Value::as_str) == Some("review") {
|
||||||
|
let state = c.get("state").and_then(Value::as_str).unwrap_or("?");
|
||||||
|
println!("**{user} @ {ts}** [review: {state}]: {body}");
|
||||||
|
} else {
|
||||||
|
println!("**{user} @ {ts}**: {body}");
|
||||||
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a PR's review *bodies* and normalise them to the comment
|
||||||
|
/// shape so they merge alongside issue-thread comments.
|
||||||
|
///
|
||||||
|
/// Review summaries live in the `pulls/<n>/reviews` object, not the
|
||||||
|
/// issues/comments thread, so the plain comment listing misses them
|
||||||
|
/// (#2088). Best-effort: returns empty on any error — notably when
|
||||||
|
/// `number` is an issue (no reviews endpoint) — so callers degrade
|
||||||
|
/// gracefully. Skips PENDING reviews (not yet visible to others) and
|
||||||
|
/// empty-body reviews (a bare approval adds nothing to the thread).
|
||||||
|
/// `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, repo: &str, number: u64) -> Vec<Value> {
|
||||||
|
let reviews = client
|
||||||
|
.get_json(&format!("/repos/{repo}/pulls/{number}/reviews"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_array().cloned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
reviews
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|r| {
|
||||||
|
let state = r.get("state").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if state == "PENDING" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if r.get("body")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(json!({
|
||||||
|
"id": r.get("id"),
|
||||||
|
"user": r.get("user"),
|
||||||
|
"created_at": r.get("submitted_at"),
|
||||||
|
"updated_at": r.get("submitted_at"),
|
||||||
|
"body": r.get("body"),
|
||||||
|
"html_url": r.get("html_url"),
|
||||||
|
"kind": "review",
|
||||||
|
"state": state,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge issue-thread comments with review bodies and sort the
|
||||||
|
/// combined list chronologically by `created_at`. ISO-8601 timestamps
|
||||||
|
/// sort lexicographically in time order, so a plain string compare is
|
||||||
|
/// correct; the sort is stable, so same-timestamp entries keep fetch
|
||||||
|
/// order.
|
||||||
|
fn merge_chronological(mut items: Vec<Value>, reviews: Vec<Value>) -> Vec<Value> {
|
||||||
|
items.extend(reviews);
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
let ka = a.get("created_at").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let kb = b.get("created_at").and_then(Value::as_str).unwrap_or("");
|
||||||
|
ka.cmp(kb)
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch the first page's worth of comments (existing behaviour).
|
/// Fetch the first page's worth of comments (existing behaviour).
|
||||||
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
|
fn fetch_head(client: &Client, repo: &str, number: u64, limit: u64) -> Result<Vec<Value>> {
|
||||||
let v = client.get_json(&format!(
|
let v = client.get_json(&format!(
|
||||||
|
|
@ -206,4 +287,38 @@ mod tests {
|
||||||
// small-thread case above.
|
// small-thread case above.
|
||||||
assert_eq!(tail_plan(5, 100), (1, 1));
|
assert_eq!(tail_plan(5, 100), (1, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_interleaves_reviews_by_timestamp() {
|
||||||
|
// A review submitted between two comments must land between
|
||||||
|
// them, not appended at the end — that's the whole #2088 fix.
|
||||||
|
let comments = vec![
|
||||||
|
json!({"created_at": "2026-06-29T01:00:00Z", "body": "c1"}),
|
||||||
|
json!({"created_at": "2026-06-29T01:20:00Z", "body": "c2"}),
|
||||||
|
];
|
||||||
|
let reviews =
|
||||||
|
vec![json!({"created_at": "2026-06-29T01:10:00Z", "body": "r1", "kind": "review"})];
|
||||||
|
let merged = merge_chronological(comments, reviews);
|
||||||
|
let bodies: Vec<&str> = merged
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.get("body").and_then(Value::as_str).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(bodies, vec!["c1", "r1", "c2"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_with_no_reviews_is_identity() {
|
||||||
|
// Issues have no reviews → fetch_review_bodies returns empty →
|
||||||
|
// the merge must leave the comment thread untouched.
|
||||||
|
let comments = vec![
|
||||||
|
json!({"created_at": "2026-06-29T01:00:00Z", "body": "c1"}),
|
||||||
|
json!({"created_at": "2026-06-29T01:20:00Z", "body": "c2"}),
|
||||||
|
];
|
||||||
|
let merged = merge_chronological(comments, vec![]);
|
||||||
|
let bodies: Vec<&str> = merged
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.get("body").and_then(Value::as_str).unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(bodies, vec!["c1", "c2"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue