refactor(forge_notify): share render_body_excerpt across formatters; fix nits + docs
This commit is contained in:
parent
dd7f8c5ebb
commit
20ff891962
2 changed files with 48 additions and 35 deletions
|
|
@ -213,7 +213,7 @@ Five shapes, distinguished by the notification's classification:
|
||||||
| Trigger | Wrapper |
|
| Trigger | Wrapper |
|
||||||
| ----------------------------------- | --------------------------------------------------------------------------------- |
|
| ----------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
|
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
|
||||||
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...` |
|
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
|
||||||
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
|
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
|
||||||
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
|
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n<body excerpt>\nassignee: ...` |
|
||||||
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
|
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||||
|
|
@ -224,6 +224,10 @@ Review labels come from the Forgejo `state` field: `APPROVED` →
|
||||||
submitted yet — no peer-visible event). Unknown states fall back to
|
submitted yet — no peer-visible event). Unknown states fall back to
|
||||||
the generic comment wrapper.
|
the generic comment wrapper.
|
||||||
|
|
||||||
|
A review submitted with **no body** renders `reviewed by: <author>` in
|
||||||
|
place of the `<author>: <body>` line — deliberately worded to not collide
|
||||||
|
with the meta-suffix `reviewer:` line (requested reviewers, below).
|
||||||
|
|
||||||
### "new" vs "activity on"
|
### "new" vs "activity on"
|
||||||
|
|
||||||
A review submitted with **no body** carries no `latest_comment_url`,
|
A review submitted with **no body** carries no `latest_comment_url`,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
//! off forge's own unread-state, and the agent reading the thread via the
|
//! off forge's own unread-state, and the agent reading the thread via the
|
||||||
//! CLI is what marks it read. A delivery-dedupe cursor (thread id →
|
//! CLI is what marks it read. A delivery-dedupe cursor (thread id →
|
||||||
//! last-delivered `updated_at`) stops the still-unread notification from
|
//! last-delivered `updated_at`) stops the still-unread notification from
|
||||||
//! re-firing a wake every poll; self-echo and drop-listed notifications
|
//! re-firing a wake every poll; self-echo notifications (the agent's own
|
||||||
//! are still marked read directly. The cursor is persisted as the
|
//! writes) are still marked read directly. The cursor is persisted as the
|
||||||
//! `forge_cursor` field of the harness's consolidated `hyperhive-harness.json`
|
//! `forge_cursor` field of the harness's consolidated `hyperhive-harness.json`
|
||||||
//! (via [`crate::events`]) and reloaded on boot so a container
|
//! (via [`crate::events`]) and reloaded on boot so a container
|
||||||
//! rebuild/restart doesn't re-deliver the whole currently-unread backlog —
|
//! rebuild/restart doesn't re-deliver the whole currently-unread backlog —
|
||||||
|
|
@ -247,7 +247,7 @@ fn truncate(s: &str, max: usize) -> String {
|
||||||
let end = s
|
let end = s
|
||||||
.char_indices()
|
.char_indices()
|
||||||
.map(|(i, _)| i)
|
.map(|(i, _)| i)
|
||||||
.take_while(|&i| i <= max - 3)
|
.take_while(|&i| i <= max.saturating_sub(3))
|
||||||
.last()
|
.last()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
format!("{}…", &s[..end])
|
format!("{}…", &s[..end])
|
||||||
|
|
@ -314,6 +314,20 @@ fn render_truncated_mentions(lines: &[&str]) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The escaped body excerpt + overflowed-mention suffix for one issue / PR /
|
||||||
|
/// comment body, computed in the one required order: truncate → diff the
|
||||||
|
/// overflowed `@mention` lines against the *unescaped* excerpt → heading-escape
|
||||||
|
/// the excerpt. Both notification formatters build their body block from this
|
||||||
|
/// pair so the ordering contract lives in exactly one place. Returns
|
||||||
|
/// `(escaped_excerpt, mentions_suffix)`; `mentions_suffix` is empty when the
|
||||||
|
/// body fit (nothing was truncated away).
|
||||||
|
fn render_body_excerpt(raw: &str) -> (String, String) {
|
||||||
|
let raw_excerpt = truncate(raw, BODY_TRUNCATE);
|
||||||
|
let mentions = render_truncated_mentions(&extract_truncated_mention_lines(raw, &raw_excerpt));
|
||||||
|
let excerpt = escape_md_headings(&raw_excerpt);
|
||||||
|
(excerpt, mentions)
|
||||||
|
}
|
||||||
|
|
||||||
/// Map a Forgejo review state to a readable action label.
|
/// Map a Forgejo review state to a readable action label.
|
||||||
/// Returns `None` for non-review states (regular comments have no `state` field;
|
/// Returns `None` for non-review states (regular comments have no `state` field;
|
||||||
/// `PENDING` means the review was saved but not submitted yet).
|
/// `PENDING` means the review was saved but not submitted yet).
|
||||||
|
|
@ -378,7 +392,9 @@ async fn format_notification(
|
||||||
fetch_json(client, subject_api_url, token).await
|
fetch_json(client, subject_api_url, token).await
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_pr = matches!(notif_type, "Pull Request" | "Pull");
|
// Forgejo's notification `subject.type` is "Pull" / "Issue" (never
|
||||||
|
// "Pull Request") — see the comment in `build_meta_suffix` below.
|
||||||
|
let is_pr = notif_type == "Pull";
|
||||||
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
|
let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr);
|
||||||
|
|
||||||
// Determine whether this notification was triggered by a comment/review or
|
// Determine whether this notification was triggered by a comment/review or
|
||||||
|
|
@ -513,23 +529,17 @@ async fn format_comment_notification(
|
||||||
..
|
..
|
||||||
} = meta;
|
} = meta;
|
||||||
|
|
||||||
// Truncate → mention-overflow → escape, in that order. See
|
// Truncate → mention-overflow → escape (see `render_body_excerpt`).
|
||||||
// `docs/forge.md::Body excerpt + truncation + heading escape` for
|
let (body_for_embed, truncated_mentions) = render_body_excerpt(body_text);
|
||||||
// why truncate comes before escape (mention diff compares against
|
|
||||||
// unescaped raw body).
|
|
||||||
let raw_excerpt = truncate(body_text, BODY_TRUNCATE);
|
|
||||||
let truncated_mentions = if body_text.len() > BODY_TRUNCATE {
|
|
||||||
render_truncated_mentions(&extract_truncated_mention_lines(body_text, &raw_excerpt))
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
let body_for_embed = escape_md_headings(&raw_excerpt);
|
|
||||||
if let Some(review_label) = review_state {
|
if let Some(review_label) = review_state {
|
||||||
// Review submission on a PR.
|
// Review submission on a PR.
|
||||||
let kind = format!("PR {review_label}{num}{repo}");
|
let kind = format!("PR {review_label}{num}{repo}");
|
||||||
let mut out = format!("[{kind}] {title}\nurl: {url}");
|
let mut out = format!("[{kind}] {title}\nurl: {url}");
|
||||||
if body_text.is_empty() {
|
if body_text.is_empty() {
|
||||||
write!(out, "\n\nreviewer: {author}").ok();
|
// Bodiless review: name who reviewed. `reviewed by:` (not
|
||||||
|
// `reviewer:`) to avoid colliding with the `reviewer:` line the
|
||||||
|
// meta suffix carries for a PR's *requested* reviewers.
|
||||||
|
write!(out, "\n\nreviewed by: {author}").ok();
|
||||||
} else {
|
} else {
|
||||||
write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok();
|
write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok();
|
||||||
}
|
}
|
||||||
|
|
@ -541,9 +551,6 @@ async fn format_comment_notification(
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}"
|
"[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}"
|
||||||
);
|
);
|
||||||
if out.ends_with('\n') {
|
|
||||||
out.pop();
|
|
||||||
}
|
|
||||||
out.push_str(meta_suffix);
|
out.push_str(meta_suffix);
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
@ -633,20 +640,15 @@ fn format_state_change_notification(
|
||||||
kind
|
kind
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include the start of the issue/PR description so the agent
|
// Include the start of the issue/PR description so the agent gets context
|
||||||
// gets context without a follow-up fetch. Same truncate →
|
// without a follow-up fetch. Same body pipeline as comment bodies.
|
||||||
// mention-overflow → escape pipeline as comment bodies (see
|
|
||||||
// `docs/forge.md::Body excerpt + truncation + heading escape`).
|
|
||||||
let body_block = subject
|
let body_block = subject
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|s| s["body"].as_str())
|
.and_then(|s| s["body"].as_str())
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|raw| {
|
.map(|raw| {
|
||||||
let raw_excerpt = truncate(raw, BODY_TRUNCATE);
|
let (excerpt, mentions) = render_body_excerpt(raw);
|
||||||
let truncated = extract_truncated_mention_lines(raw, &raw_excerpt);
|
|
||||||
let mentions = render_truncated_mentions(&truncated);
|
|
||||||
let excerpt = escape_md_headings(&raw_excerpt);
|
|
||||||
format!("\n\n{excerpt}{mentions}")
|
format!("\n\n{excerpt}{mentions}")
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
@ -712,8 +714,15 @@ fn parse_rfc3339_secs(s: &str) -> Option<i64> {
|
||||||
if !(rest.is_empty() || rest.starts_with('Z')) {
|
if !(rest.is_empty() || rest.starts_with('Z')) {
|
||||||
let sign = rest.as_bytes()[0];
|
let sign = rest.as_bytes()[0];
|
||||||
let off = &rest[1..];
|
let off = &rest[1..];
|
||||||
let oh: i64 = off.get(0..2)?.parse().ok()?;
|
// Accept `HH:MM` (Forgejo's form) and bare `HHMM`; minutes optional.
|
||||||
let om: i64 = off.get(3..5).unwrap_or("00").parse().ok()?;
|
// Both fields fail the same way — a present-but-unparseable component
|
||||||
|
// returns `None` rather than one silently defaulting.
|
||||||
|
let (hh, mm) = match off.split_once(':') {
|
||||||
|
Some((h, m)) => (h, m),
|
||||||
|
None => (off.get(0..2)?, off.get(2..4).unwrap_or("00")),
|
||||||
|
};
|
||||||
|
let oh: i64 = hh.parse().ok()?;
|
||||||
|
let om: i64 = if mm.is_empty() { 0 } else { mm.parse().ok()? };
|
||||||
let offset = oh * 3_600 + om * 60;
|
let offset = oh * 3_600 + om * 60;
|
||||||
match sign {
|
match sign {
|
||||||
b'+' => epoch -= offset,
|
b'+' => epoch -= offset,
|
||||||
|
|
@ -885,11 +894,11 @@ fn should_deliver(delivered: &HashMap<u64, String>, id: u64, updated_at: &str) -
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a notification thread as read. Best-effort — logs on failure but
|
/// Mark a notification thread as read. Best-effort — logs on failure but
|
||||||
/// does not abort the poll loop. Called only on the self-echo and
|
/// does not abort the poll loop. Called only on the self-echo path (the
|
||||||
/// drop-listed paths (the agent's own writes / explicitly-suppressed
|
/// agent's own comment/review/creation writes) — delivered threads are
|
||||||
/// reasons) — delivered threads are deliberately left unread for the
|
/// deliberately left unread for the read-before-comment guard, and a failed
|
||||||
/// read-before-comment guard, and a failed delivery is left unread + out
|
/// delivery is left unread + out of the dedupe cursor so it resurfaces on the
|
||||||
/// of the dedupe cursor so it resurfaces on the next poll tick.
|
/// next poll tick.
|
||||||
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
|
async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) {
|
||||||
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
|
let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}");
|
||||||
match client
|
match client
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue