Watch
0
0
Fork
You've already forked hyperhive
0

forge cli: unresolved filter drops only resolved comments, position-less ones stay labelled

This commit is contained in:
damocles 2026-11-01 02:08:37 +01:00 • committed by mara
commit 1dc18d41d4
3 changed files with 34 additions and 26 deletions

View file

@ -607,11 +607,11 @@ List a PR's reviews, or submit one
* `--request-changes` — Request changes on the PR (submit a `REQUEST_CHANGES` review) * `--request-changes` — Request changes on the PR (submit a `REQUEST_CHANGES` review)
* `--comment` — Leave a comment review (submit a COMMENT review) * `--comment` — Leave a comment review (submit a COMMENT review)
* `-m`, `--body <BODY>` — Optional body / message for the review (used with --approve, --request-changes, or --comment) * `-m`, `--body <BODY>` — Optional body / message for the review (used with --approve, --request-changes, or --comment)
* `--only <ONLY>` — When listing: narrow the inline comments shown (and leave out the reviews with none left). `unresolved` = still open on the current head, that is neither resolved nor outdated: what a re-review has to check * `--only <ONLY>` — When listing: narrow the inline comments shown (and leave out the reviews with none left). `unresolved` keeps every comment nobody has resolved yet: what a re-review has to check
Possible values: Possible values:
- `unresolved`: - `unresolved`:
Neither resolved nor outdated Not resolved yet. Comments without a position stay in: one might sit on a line a push moved or on the whole file, and hiding it could hide a live finding

View file

@ -77,8 +77,8 @@ hive-forge pr edit 42 --base main # retarget a stacked PR onto main
hive-forge pr 42 # PR metadata as JSON hive-forge pr 42 # PR metadata as JSON
hive-forge pr create --title "..." --head my-branch --push # also `git push forge my-branch` hive-forge pr create --title "..." --head my-branch --push # also `git push forge my-branch`
hive-forge pr create --title "..." --head my-branch --label area/ops # repeatable, same as issue create hive-forge pr create --title "..." --head my-branch --label area/ops # repeatable, same as issue create
hive-forge pr reviews 42 # list reviews; each inline comment as `[#id path:line] text`, tagged (resolved by X) / (outdated) hive-forge pr reviews 42 # list reviews; each inline comment as `[#id path:line] text`, tagged (resolved by X) / (no position)
hive-forge pr reviews 42 --only unresolved # only inline comments still open on the current head hive-forge pr reviews 42 --only unresolved # every inline comment nobody has resolved yet
hive-forge pr timeline 42 # review rows list their line comments (id, path:line, state) too hive-forge pr timeline 42 # review rows list their line comments (id, path:line, state) too
hive-forge pr reviews 42 --approve # submit APPROVED review hive-forge pr reviews 42 --approve # submit APPROVED review
hive-forge pr reviews 42 --request-changes -m "msg" # submit REQUEST_CHANGES review hive-forge pr reviews 42 --request-changes -m "msg" # submit REQUEST_CHANGES review

View file

@ -34,9 +34,8 @@ pub struct Args {
body: Option<String>, body: Option<String>,
/// When listing: narrow the inline comments shown (and leave out the /// When listing: narrow the inline comments shown (and leave out the
/// reviews with none left). `unresolved` = still open on the current /// reviews with none left). `unresolved` keeps every comment nobody has
/// head, that is neither resolved nor outdated: what a re-review has to /// resolved yet: what a re-review has to check.
/// check.
#[arg(long, value_enum, conflicts_with_all = ["approve", "request_changes", "comment"])] #[arg(long, value_enum, conflicts_with_all = ["approve", "request_changes", "comment"])]
only: Option<Only>, only: Option<Only>,
} }
@ -44,7 +43,9 @@ pub struct Args {
/// Which inline comments `--only` keeps. /// Which inline comments `--only` keeps.
#[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)] #[derive(Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
enum Only { enum Only {
/// Neither resolved nor outdated. /// Not resolved yet. Comments without a position stay in: one might sit on
/// a line a push moved or on the whole file, and hiding it could hide a
/// live finding.
Unresolved, Unresolved,
} }
@ -150,9 +151,10 @@ pub(crate) fn comment_location(c: &Value) -> String {
} }
} }
/// An inline comment whose anchored line is no longer in the diff (a push /// An inline comment with no line position. Forgejo drops `position` when a
/// moved it): Forgejo drops `position` then. /// push moved the anchored line out of the diff, but a file-level comment or
fn is_outdated(c: &Value) -> bool { /// one on a removed line may lack it too, so this is a hint, not a verdict.
fn has_no_position(c: &Value) -> bool {
c.get("position") c.get("position")
.and_then(Value::as_u64) .and_then(Value::as_u64)
.is_none_or(|l| l == 0) .is_none_or(|l| l == 0)
@ -165,13 +167,15 @@ fn resolved_by(c: &Value) -> Option<&str> {
.and_then(Value::as_str) .and_then(Value::as_str)
} }
/// Still needs an answer on the current head: neither resolved nor outdated. /// Nobody has resolved the conversation yet. Position plays no part: a
fn is_open(c: &Value) -> bool { /// comment without one may be a live finding, and a filter built for a
resolved_by(c).is_none() && !is_outdated(c) /// re-review gate must not hide it.
fn is_unresolved(c: &Value) -> bool {
resolved_by(c).is_none()
} }
/// One inline comment as text: `[#id path:line] body`, continuation lines /// One inline comment as text: `[#id path:line] body`, continuation lines
/// indented, `(resolved by X)` / `(outdated)` after the body. Shared by /// indented, `(resolved by X)` / `(no position: ...)` after the body. Shared by
/// `pr reviews` and `pr timeline`. /// `pr reviews` and `pr timeline`.
pub(crate) fn render_inline_comment(c: &Value) -> String { pub(crate) fn render_inline_comment(c: &Value) -> String {
let id = c.get("id").and_then(Value::as_i64).unwrap_or(0); let id = c.get("id").and_then(Value::as_i64).unwrap_or(0);
@ -185,8 +189,8 @@ pub(crate) fn render_inline_comment(c: &Value) -> String {
if let Some(who) = resolved_by(c) { if let Some(who) = resolved_by(c) {
write!(out, " (resolved by {who})").ok(); write!(out, " (resolved by {who})").ok();
} }
if is_outdated(c) { if has_no_position(c) {
out.push_str(" (outdated)"); out.push_str(" (no position: outdated or file-level)");
} }
out out
} }
@ -195,7 +199,7 @@ pub(crate) fn render_inline_comment(c: &Value) -> String {
/// the matching ones. /// the matching ones.
fn visible(comments: Vec<Value>, only: Option<Only>) -> Vec<Value> { fn visible(comments: Vec<Value>, only: Option<Only>) -> Vec<Value> {
match only { match only {
Some(Only::Unresolved) => comments.into_iter().filter(is_open).collect(), Some(Only::Unresolved) => comments.into_iter().filter(is_unresolved).collect(),
None => comments, None => comments,
} }
} }
@ -229,7 +233,7 @@ fn list_reviews_json(
"line": c.get("position"), "line": c.get("position"),
"body": c.get("body"), "body": c.get("body"),
"resolved_by": resolved_by(c), "resolved_by": resolved_by(c),
"outdated": is_outdated(c), "no_position": has_no_position(c),
}) })
}) })
.collect(); .collect();
@ -335,14 +339,15 @@ mod tests {
} }
#[test] #[test]
fn open_means_neither_resolved_nor_outdated() { fn unresolved_means_only_that_nobody_resolved_it() {
assert!(is_open(&comment(1, 42, None))); assert!(is_unresolved(&comment(1, 42, None)));
assert!(!is_open(&comment(1, 42, Some("mara")))); assert!(!is_unresolved(&comment(1, 42, Some("mara"))));
assert!(!is_open(&comment(1, 0, None))); // No position does not hide a comment: it may be a live finding.
assert!(is_unresolved(&comment(1, 0, None)));
} }
#[test] #[test]
fn unresolved_filter_keeps_only_open_comments() { fn unresolved_filter_drops_only_resolved_comments() {
let all = vec![ let all = vec![
comment(1, 42, None), comment(1, 42, None),
comment(2, 42, Some("mara")), comment(2, 42, Some("mara")),
@ -350,7 +355,10 @@ mod tests {
]; ];
let ids = let ids =
|v: Vec<Value>| -> Vec<i64> { v.iter().filter_map(|c| c["id"].as_i64()).collect() }; |v: Vec<Value>| -> Vec<i64> { v.iter().filter_map(|c| c["id"].as_i64()).collect() };
assert_eq!(ids(visible(all.clone(), Some(Only::Unresolved))), vec![1]); assert_eq!(
ids(visible(all.clone(), Some(Only::Unresolved))),
vec![1, 3]
);
assert_eq!(ids(visible(all, None)), vec![1, 2, 3]); assert_eq!(ids(visible(all, None)), vec![1, 2, 3]);
} }
@ -366,7 +374,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
render_inline_comment(&comment(7, 0, None)), render_inline_comment(&comment(7, 0, None)),
"[#7 src/a.rs] off by one (outdated)" "[#7 src/a.rs] off by one (no position: outdated or file-level)"
); );
} }