fix(#2495): surface [PR merged] on merge notifications instead of [comment on PR]
This commit is contained in:
parent
12ce346d02
commit
6aa7c9613b
2 changed files with 127 additions and 17 deletions
|
|
@ -178,9 +178,12 @@ silently (mark-read without delivery):
|
||||||
`own_login`. Only _creations_ are dropped; a later state change on the
|
`own_login`. Only _creations_ are dropped; a later state change on the
|
||||||
agent's own subject is driven by someone else and still surfaces.
|
agent's own subject is driven by someone else and still surfaces.
|
||||||
|
|
||||||
`own_login` is fetched once at startup via `GET /api/v1/user`. On
|
`own_login` is fetched at startup via `GET /api/v1/user`. On fetch
|
||||||
fetch failure the filter degrades open (no filtering) rather than
|
failure the filter degrades open (no filtering) rather than crashing
|
||||||
crashing the task — a noisy inbox beats a silently-stuck poller.
|
the task — a noisy inbox beats a silently-stuck poller — but the fetch
|
||||||
|
is **re-attempted on each poll tick** until it succeeds, so a boot-time
|
||||||
|
failure (the forge not yet reachable) self-heals instead of leaving
|
||||||
|
self-echo filtering off for the whole process lifetime.
|
||||||
|
|
||||||
### Body excerpt + truncation + heading escape
|
### Body excerpt + truncation + heading escape
|
||||||
|
|
||||||
|
|
@ -236,6 +239,23 @@ A review submitted with **no body** renders `reviewed by: <author>` in
|
||||||
place of the `<author>: <body>` line — deliberately worded to not collide
|
place of the `<author>: <body>` line — deliberately worded to not collide
|
||||||
with the meta-suffix `reviewer:` line (requested reviewers, below).
|
with the meta-suffix `reviewer:` line (requested reviewers, below).
|
||||||
|
|
||||||
|
### Merge/close vs a later comment
|
||||||
|
|
||||||
|
A notification carrying a `latest_comment_url` normally takes the comment
|
||||||
|
path. But a merged/closed subject **keeps** its `latest_comment_url` set,
|
||||||
|
so a just-merged PR that had any prior discussion would route to the
|
||||||
|
comment path and render `[comment on PR]` (with a stale pre-merge comment
|
||||||
|
body) instead of `[PR merged]` — the agent never learns its PR merged
|
||||||
|
(#2495). So when the notification IS the merge/close transition — its
|
||||||
|
event time (`updated_at`) is within `NEW_ITEM_TOLERANCE_SECS` of the
|
||||||
|
subject's `closed_at` (set for both `merged` and `closed`) — the
|
||||||
|
state-change path wins even with a comment url present
|
||||||
|
(`state_change_is_current`). A genuine **later** comment on an
|
||||||
|
already-closed subject bumps `updated_at` well past `closed_at`, so it
|
||||||
|
stays on the comment path and keeps its comment body. Missing/unparseable
|
||||||
|
timestamps default to the state-change path, so a merge is never silently
|
||||||
|
hidden behind a stale comment.
|
||||||
|
|
||||||
### "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`,
|
||||||
|
|
|
||||||
|
|
@ -138,20 +138,17 @@ pub async fn run(socket: PathBuf) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch own login once for self-notification filtering. Falls back
|
// Fetch own login for self-notification filtering. Falls back to
|
||||||
// to empty string on failure — no filtering (safe degradation; see
|
// empty string on failure — no filtering (safe degradation; see
|
||||||
// `docs/forge.md::Self-notification filtering`).
|
// `docs/forge.md::Self-notification filtering`). A boot-time failure
|
||||||
let own_login = tokio::time::timeout(
|
// (e.g. the forge not yet reachable) is re-attempted on each poll tick
|
||||||
Duration::from_secs(HTTP_TIMEOUT_SECS),
|
// below rather than leaving filtering off for the whole process.
|
||||||
forge.user_get_current().send(),
|
let mut own_login = resolve_own_login(&forge).await;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(Result::ok)
|
|
||||||
.and_then(|u| u.login)
|
|
||||||
.unwrap_or_default();
|
|
||||||
if own_login.is_empty() {
|
if own_login.is_empty() {
|
||||||
warn!("forge_notify: could not resolve own login — self-notification filtering disabled");
|
warn!(
|
||||||
|
"forge_notify: could not resolve own login yet — self-notification \
|
||||||
|
filtering disabled until it resolves on a later poll"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
debug!(%own_login, "forge_notify: own login resolved");
|
debug!(%own_login, "forge_notify: own login resolved");
|
||||||
}
|
}
|
||||||
|
|
@ -192,10 +189,35 @@ pub async fn run(socket: PathBuf) {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
|
// If own-login didn't resolve at boot (forge unreachable then),
|
||||||
|
// retry before this poll so self-echo filtering self-heals instead
|
||||||
|
// of staying off for the whole process lifetime.
|
||||||
|
if own_login.is_empty() {
|
||||||
|
own_login = resolve_own_login(&forge).await;
|
||||||
|
if !own_login.is_empty() {
|
||||||
|
debug!(%own_login, "forge_notify: own login resolved on retry");
|
||||||
|
}
|
||||||
|
}
|
||||||
poll_once(&forge, &client, &token, &socket, &mut delivered, &own_login).await;
|
poll_once(&forge, &client, &token, &socket, &mut delivered, &own_login).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the agent's own forge login (`GET /api/v1/user`) for
|
||||||
|
/// self-notification filtering. Returns the empty string on any failure
|
||||||
|
/// (timeout, HTTP error, missing field); the caller treats empty as
|
||||||
|
/// "filtering disabled" and retries on the next poll tick.
|
||||||
|
async fn resolve_own_login(forge: &Forgejo) -> String {
|
||||||
|
tokio::time::timeout(
|
||||||
|
Duration::from_secs(HTTP_TIMEOUT_SECS),
|
||||||
|
forge.user_get_current().send(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok)
|
||||||
|
.and_then(|u| u.login)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetch a JSON value from a URL using the agent's forge token. Returns
|
/// Fetch a JSON value from a URL using the agent's forge token. Returns
|
||||||
/// `None` on any HTTP or parse error (best-effort enrichment).
|
/// `None` on any HTTP or parse error (best-effort enrichment).
|
||||||
async fn fetch_json(client: &reqwest::Client, url: &str, token: &str) -> Option<serde_json::Value> {
|
async fn fetch_json(client: &reqwest::Client, url: &str, token: &str) -> Option<serde_json::Value> {
|
||||||
|
|
@ -447,7 +469,15 @@ async fn format_notification(
|
||||||
subject,
|
subject,
|
||||||
is_pr,
|
is_pr,
|
||||||
};
|
};
|
||||||
if has_comment {
|
// A merged/closed subject keeps its `latest_comment_url` set, so a
|
||||||
|
// just-merged PR that had any discussion would otherwise route to the
|
||||||
|
// comment path and render as `[comment on PR]` instead of `[PR merged]`.
|
||||||
|
// When this notification IS the merge/close transition, prefer
|
||||||
|
// the state-change path even with a comment url present; a genuine later
|
||||||
|
// comment stays on the comment path (see `state_change_is_current`).
|
||||||
|
let is_fresh_state_change =
|
||||||
|
state_change_is_current(¬if.state, notif.thread.updated_at, meta.subject.as_ref());
|
||||||
|
if has_comment && !is_fresh_state_change {
|
||||||
format_comment_notification(
|
format_comment_notification(
|
||||||
client,
|
client,
|
||||||
token,
|
token,
|
||||||
|
|
@ -716,6 +746,36 @@ fn notification_is_creation(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this notification represents the subject's *own* merge/close
|
||||||
|
/// transition, as opposed to a later comment on an already-merged/closed
|
||||||
|
/// subject. A merged/closed PR keeps its `latest_comment_url` set, so
|
||||||
|
/// without this a just-merged PR would route to the comment path and
|
||||||
|
/// render as `[comment on PR]` instead of `[PR merged]`. We treat
|
||||||
|
/// the transition as current when the notification's event time
|
||||||
|
/// (`updated_at`) is within `NEW_ITEM_TOLERANCE_SECS` of the subject's
|
||||||
|
/// `closed_at` (set for both `merged` and `closed`). A genuine later
|
||||||
|
/// comment bumps `updated_at` well past `closed_at`, so it stays on the
|
||||||
|
/// comment path (and keeps its comment body). Missing/unparseable
|
||||||
|
/// timestamps default to `true` so a merge is never silently hidden
|
||||||
|
/// behind a stale comment — mirrors `notification_is_creation`'s
|
||||||
|
/// preserve-the-signal fallback.
|
||||||
|
fn state_change_is_current(
|
||||||
|
state: &str,
|
||||||
|
event: Option<OffsetDateTime>,
|
||||||
|
subject: Option<&serde_json::Value>,
|
||||||
|
) -> bool {
|
||||||
|
if !matches!(state, "merged" | "closed") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let closed = subject
|
||||||
|
.and_then(|s| s["closed_at"].as_str())
|
||||||
|
.and_then(parse_rfc3339);
|
||||||
|
match (closed, event) {
|
||||||
|
(Some(c), Some(e)) => (e - c).whole_seconds().abs() <= NEW_ITEM_TOLERANCE_SECS,
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse an RFC 3339 timestamp as Forgejo emits them
|
/// Parse an RFC 3339 timestamp as Forgejo emits them
|
||||||
/// (`2026-06-13T11:18:42+02:00` or `...Z`, optionally with fractional
|
/// (`2026-06-13T11:18:42+02:00` or `...Z`, optionally with fractional
|
||||||
/// seconds). Returns `None` on any shape `time` doesn't recognise so
|
/// seconds). Returns `None` on any shape `time` doesn't recognise so
|
||||||
|
|
@ -1220,6 +1280,36 @@ mod tests {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_change_is_current_distinguishes_merge_from_later_comment() {
|
||||||
|
// A merged/closed PR whose notification fired ~when it was closed →
|
||||||
|
// the transition itself → prefer the state-change ([PR merged]) path
|
||||||
|
// even though a merged PR keeps its latest_comment_url set.
|
||||||
|
let merged = serde_json::json!({ "closed_at": "2026-06-13T11:18:40+02:00" });
|
||||||
|
let at_merge = parse_rfc3339("2026-06-13T11:18:42+02:00");
|
||||||
|
assert!(state_change_is_current("merged", at_merge, Some(&merged)));
|
||||||
|
assert!(state_change_is_current("closed", at_merge, Some(&merged)));
|
||||||
|
|
||||||
|
// A comment hours after the merge → not the transition → stays on the
|
||||||
|
// comment path so the comment body survives.
|
||||||
|
let later = parse_rfc3339("2026-06-13T14:55:00+02:00");
|
||||||
|
assert!(!state_change_is_current("merged", later, Some(&merged)));
|
||||||
|
|
||||||
|
// Non-terminal states never override the comment path.
|
||||||
|
assert!(!state_change_is_current("open", at_merge, Some(&merged)));
|
||||||
|
assert!(!state_change_is_current("", at_merge, Some(&merged)));
|
||||||
|
|
||||||
|
// Missing/unparseable timestamps → default true so a merge is never
|
||||||
|
// hidden behind a stale comment.
|
||||||
|
assert!(state_change_is_current("merged", None, Some(&merged)));
|
||||||
|
assert!(state_change_is_current(
|
||||||
|
"merged",
|
||||||
|
at_merge,
|
||||||
|
Some(&serde_json::json!({}))
|
||||||
|
));
|
||||||
|
assert!(state_change_is_current("merged", None, None));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_notification_tolerates_merged_state() {
|
fn parse_notification_tolerates_merged_state() {
|
||||||
// forgejo-api's `StateType` has no "merged" variant; the raw
|
// forgejo-api's `StateType` has no "merged" variant; the raw
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue