Watch
0
0
Fork
You've already forked hyperhive
0

forge-notify: list inline comment locations in the wake, not their text

This commit is contained in:
damocles 2026-11-01 01:29:38 +01:00 • committed by mara
commit c6958c7baa
2 changed files with 101 additions and 78 deletions

View file

@ -303,19 +303,20 @@ with the meta-suffix `reviewer:` line (requested reviewers, below).
A review's line comments belong to neither the review body nor the A review's line comments belong to neither the review body nor the
`latest_comment_url` (a review with only inline comments has no such url `latest_comment_url` (a review with only inline comments has no such url
and otherwise lands as a bare `activity on PR`). So every PR wake fetches and otherwise lands as a bare `activity on PR`). So every PR wake fetches
the PR's reviews (`/pulls/N/reviews`, up to four pages of 50) and appends the PR's reviews (`/pulls/N/reviews`, up to four pages of 50) and, for the
the inline comments of those that are new to the agent as an reviews new to the agent, appends an
`inline review comments:` block, one `author [path:line]: text` entry `inline review comments (read them with `hive-forge pr reviews N`):` block
each (`[path]` when Forgejo dropped the position because the line left the saying where they are: one `author: path:line, path, ...` line per review
diff). A review counts as new when it was submitted strictly after the author (bare `path` when Forgejo dropped the position because the line left
thread's previously delivered `updated_at` (all of them without a the diff; control characters in a path become spaces). The comment text stays
baseline, and any with no submit time, so nothing is hidden), is not the out of the wake - the agent reads the full review with the CLI. A review
agent's own, is not a `PENDING` draft, and has `comments_count > 0`. Only counts as new when it was submitted strictly after the thread's previously
the newest 5 such reviews are fetched, at most 10 comments are shown delivered `updated_at` (all of them without a baseline, and any with no
(`(+N more ...)` for the rest), and each excerpt is truncated to 300 bytes submit time, so nothing is hidden), is not the agent's own, is not a
and heading-escaped like a body. Best effort: a failed fetch yields less `PENDING` draft, and has `comments_count > 0`. Only the newest 5 such reviews
text, never a dropped wake. Cost: one reviews fetch per PR wake, plus one are fetched and at most 10 locations are listed (`(+N more)` for the rest).
per new commented review. Best effort: a failed fetch yields less text, never a dropped wake. Cost: one
reviews fetch per PR wake, plus one per new commented review.
### Merge/close vs a later comment ### Merge/close vs a later comment

View file

@ -900,10 +900,8 @@ async fn fresh_post_close_comment_tail<S: Source>(
Some(format!("\n\ncomment by {author}: {excerpt}{mentions}")) Some(format!("\n\ncomment by {author}: {excerpt}{mentions}"))
} }
/// Inline review comments rendered in one wake; the rest become a count. /// Inline review comment locations listed in one wake; the rest become a count.
const INLINE_COMMENTS_MAX: usize = 10; const INLINE_COMMENTS_MAX: usize = 10;
/// Per-comment excerpt cap. Under [`BODY_TRUNCATE`]: one review carries many.
const INLINE_COMMENT_TRUNCATE: usize = 300;
/// Reviews whose inline comments are fetched for one wake (the newest). /// Reviews whose inline comments are fetched for one wake (the newest).
const INLINE_REVIEWS_MAX: usize = 5; const INLINE_REVIEWS_MAX: usize = 5;
/// Page size and page cap for the reviews list, so a long-lived PR's /// Page size and page cap for the reviews list, so a long-lived PR's
@ -911,12 +909,12 @@ const INLINE_REVIEWS_MAX: usize = 5;
const REVIEWS_PAGE_SIZE: usize = 50; const REVIEWS_PAGE_SIZE: usize = 50;
const REVIEWS_PAGES_MAX: usize = 4; const REVIEWS_PAGES_MAX: usize = 4;
/// The PR's reviews endpoint from the notification subject URL. Forgejo /// The PR's reviews endpoint and number from the notification subject URL.
/// hands out the *issues* URL for a PR (`.../issues/N`); its reviews live /// Forgejo hands out the *issues* URL for a PR (`.../issues/N`); its reviews
/// under `.../pulls/N/reviews`. `None` for any other shape. /// live under `.../pulls/N/reviews`. `None` for any other shape.
fn reviews_api_url(subject_api_url: &str) -> Option<String> { fn reviews_api_url(subject_api_url: &str) -> Option<(String, &str)> {
let (head, number) = subject_api_url.rsplit_once("/issues/")?; let (head, number) = subject_api_url.rsplit_once("/issues/")?;
Some(format!("{head}/pulls/{number}/reviews")) Some((format!("{head}/pulls/{number}/reviews"), number))
} }
/// `(review id, author)` of the reviews in `reviews` (the raw reviews /// `(review id, author)` of the reviews in `reviews` (the raw reviews
@ -954,40 +952,46 @@ fn unseen_inline_reviews(
unseen unseen
} }
/// The `inline review comments:` block for the wake: one /// One comment's location for the wake: `path:line`, or just `path` when
/// `author [path:line]: text` entry per comment (`[path]` when Forgejo /// Forgejo dropped the position because the line left the diff. Control
/// dropped the position because the line left the diff), each excerpt /// characters in the path (git allows a newline in one) become spaces so a
/// truncated + heading-escaped like a body, at most /// PR author cannot shape the lines the agent reads.
/// [`INLINE_COMMENTS_MAX`] entries and a count for the rest. Empty when fn comment_location(c: &serde_json::Value) -> String {
/// there is nothing to show. let path: String = c["path"]
fn render_inline_comments(groups: &[(String, Vec<serde_json::Value>)]) -> String { .as_str()
.unwrap_or("?")
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect();
match c["position"].as_u64().filter(|&line| line > 0) {
Some(line) => format!("{path}:{line}"),
None => path,
}
}
/// The `inline review comments` block for the wake: only where the unseen
/// comments are, one line per review author (`author: path:line, ...`),
/// capped at [`INLINE_COMMENTS_MAX`] locations in total with a count for the
/// rest. The text stays out of the wake: the pointer names the CLI call that
/// prints the full review. Empty when there is nothing to show.
fn render_inline_comments(pr_number: &str, groups: &[(String, Vec<serde_json::Value>)]) -> String {
let total: usize = groups.iter().map(|(_, c)| c.len()).sum(); let total: usize = groups.iter().map(|(_, c)| c.len()).sum();
if total == 0 { if total == 0 {
return String::new(); return String::new();
} }
let mut out = String::from("\n\ninline review comments:"); let mut out =
let entries = groups format!("\n\ninline review comments (read them with `hive-forge pr reviews {pr_number}`):");
.iter() let mut budget = INLINE_COMMENTS_MAX;
.flat_map(|(author, comments)| comments.iter().map(move |c| (author, c))); for (author, comments) in groups {
for (author, c) in entries.take(INLINE_COMMENTS_MAX) { let shown: Vec<String> = comments.iter().take(budget).map(comment_location).collect();
let path = c["path"].as_str().unwrap_or("?"); if shown.is_empty() {
let body = escape_md_headings(&truncate( continue;
c["body"].as_str().unwrap_or("").trim(), }
INLINE_COMMENT_TRUNCATE, budget -= shown.len();
)) write!(out, "\n {author}: {}", shown.join(", ")).ok();
.replace('\n', "\n ");
match c["position"].as_u64().filter(|&line| line > 0) {
Some(line) => write!(out, "\n {author} [{path}:{line}]: {body}").ok(),
None => write!(out, "\n {author} [{path}]: {body}").ok(),
};
} }
if total > INLINE_COMMENTS_MAX { if total > INLINE_COMMENTS_MAX {
write!( write!(out, "\n (+{} more)", total - INLINE_COMMENTS_MAX).ok();
out,
"\n (+{} more - `hive-forge pr reviews` lists them all)",
total - INLINE_COMMENTS_MAX
)
.ok();
} }
out out
} }
@ -1002,7 +1006,7 @@ async fn fetch_inline_review_tail<S: Source>(
prev_updated_at: Option<&str>, prev_updated_at: Option<&str>,
own_login: &str, own_login: &str,
) -> String { ) -> String {
let Some(reviews_url) = reviews_api_url(subject_api_url) else { let Some((reviews_url, pr_number)) = reviews_api_url(subject_api_url) else {
return String::new(); return String::new();
}; };
let mut reviews: Vec<serde_json::Value> = Vec::new(); let mut reviews: Vec<serde_json::Value> = Vec::new();
@ -1027,7 +1031,7 @@ async fn fetch_inline_review_tail<S: Source>(
groups.push((author, comments.clone())); groups.push((author, comments.clone()));
} }
} }
render_inline_comments(&groups) render_inline_comments(pr_number, &groups)
} }
/// Parse an RFC 3339 timestamp as Forgejo emits them /// Parse an RFC 3339 timestamp as Forgejo emits them
@ -1997,8 +2001,8 @@ mod tests {
fn reviews_api_url_swaps_issues_for_pulls() { fn reviews_api_url_swaps_issues_for_pulls() {
let subject = "/api/v1/repos/o/r/issues/42"; // lint:allow test input, not a tracker reference let subject = "/api/v1/repos/o/r/issues/42"; // lint:allow test input, not a tracker reference
assert_eq!( assert_eq!(
reviews_api_url(subject).as_deref(), reviews_api_url(subject),
Some("/api/v1/repos/o/r/pulls/42/reviews"), Some(("/api/v1/repos/o/r/pulls/42/reviews".to_owned(), "42")),
); );
assert_eq!(reviews_api_url("/api/v1/repos/o/r"), None); assert_eq!(reviews_api_url("/api/v1/repos/o/r"), None);
} }
@ -2052,39 +2056,57 @@ mod tests {
} }
#[test] #[test]
fn render_inline_comments_formats_path_line_and_fallback() { fn render_inline_comments_lists_locations_not_text() {
let groups = vec![( let groups = vec![
(
"argus".to_owned(), "argus".to_owned(),
vec![ vec![
serde_json::json!({"path": "src/a.rs", "position": 42, "body": "off by one"}), serde_json::json!({"path": "src/a.rs", "position": 42, "body": "off by one"}),
serde_json::json!({"path": "src/b.rs", "position": 0, "body": "line left the diff"}), serde_json::json!({"path": "src/b.rs", "position": 0, "body": "left the diff"}),
serde_json::json!({"path": "src/c.rs", "body": "# heading\nsecond line"}),
], ],
)]; ),
(
"mara".to_owned(),
vec![serde_json::json!({"path": "src/c.rs", "position": 7, "body": "why"})],
),
];
assert_eq!( assert_eq!(
render_inline_comments(&groups), render_inline_comments("12", &groups),
"\n\ninline review comments:\ "\n\ninline review comments (read them with `hive-forge pr reviews 12`):\
\n argus [src/a.rs:42]: off by one\ \n argus: src/a.rs:42, src/b.rs\
\n argus [src/b.rs]: line left the diff\ \n mara: src/c.rs:7"
\n argus [src/c.rs]: \\# heading\n second line"
); );
} }
#[test] #[test]
fn render_inline_comments_empty_is_empty() { fn render_inline_comments_empty_is_empty() {
assert_eq!(render_inline_comments(&[]), ""); assert_eq!(render_inline_comments("12", &[]), "");
assert_eq!(render_inline_comments(&[("argus".to_owned(), vec![])]), ""); assert_eq!(
render_inline_comments("12", &[("argus".to_owned(), vec![])]),
""
);
} }
#[test] #[test]
fn render_inline_comments_caps_entries_and_excerpts() { fn render_inline_comments_caps_locations_across_authors() {
let comments: Vec<_> = (0..13) let many = |n: u64| -> Vec<serde_json::Value> {
.map(|i| serde_json::json!({"path": "f.rs", "position": i + 1, "body": "x".repeat(1000)})) (1..=n)
.collect(); .map(|i| serde_json::json!({"path": "f.rs", "position": i}))
let out = render_inline_comments(&[("argus".to_owned(), comments)]); .collect()
assert_eq!(out.matches("argus [f.rs:").count(), INLINE_COMMENTS_MAX); };
assert!(out.ends_with("(+3 more - `hive-forge pr reviews` lists them all)")); let out = render_inline_comments(
// Each excerpt is cut, not the full 1000 chars. "12",
assert!(out.len() < INLINE_COMMENTS_MAX * (INLINE_COMMENT_TRUNCATE + 60) + 200); &[("argus".to_owned(), many(8)), ("mara".to_owned(), many(5))],
);
// 8 from argus, the 2 left of the budget from mara, 3 counted.
assert_eq!(out.matches("f.rs:").count(), INLINE_COMMENTS_MAX);
assert!(out.contains("\n mara: f.rs:1, f.rs:2\n"));
assert!(out.ends_with("\n (+3 more)"));
}
#[test]
fn comment_location_neutralises_control_characters_in_the_path() {
let c = serde_json::json!({"path": "a\nb\tc.rs", "position": 3});
assert_eq!(comment_location(&c), "a b c.rs:3");
} }
} }