forge_notify: append a comment racing the merge instead of dropping it (#2507)
This commit is contained in:
parent
fc7720572b
commit
f902592e71
2 changed files with 161 additions and 4 deletions
|
|
@ -256,6 +256,25 @@ 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
|
timestamps default to the state-change path, so a merge is never silently
|
||||||
hidden behind a stale comment.
|
hidden behind a stale comment.
|
||||||
|
|
||||||
|
#### Merge racing a comment
|
||||||
|
|
||||||
|
The one gap the timestamp cut leaves: a genuine comment posted **within
|
||||||
|
`NEW_ITEM_TOLERANCE_SECS` of the merge** bumps `updated_at` close enough
|
||||||
|
to `closed_at` that `state_change_is_current` returns `true` — so it takes
|
||||||
|
the state-change path and its body would be dropped. Best of both worlds:
|
||||||
|
on the merge/close path we fetch the `latest_comment_url` comment and, when
|
||||||
|
its `created_at` is strictly **after** the subject's `closed_at`
|
||||||
|
(`comment_is_after_close`) — i.e. it raced the merge rather than being the
|
||||||
|
pre-merge last comment the subject keeps — append it as a
|
||||||
|
`comment by <author>: <excerpt>` block before the meta suffix
|
||||||
|
(`fresh_post_close_comment_tail`). So the wake carries **both** `[PR merged]`
|
||||||
|
and the racing comment. The kept pre-merge comment (created before
|
||||||
|
`closed_at`) is left off, a self-authored racing comment is dropped (don't
|
||||||
|
echo the agent's own write), and a missing/unparseable `created_at`/
|
||||||
|
`closed_at` appends nothing (conservative — only surface a comment we can
|
||||||
|
positively place after the close). Cost: one extra comment fetch on
|
||||||
|
merge/close notifications, acceptable given how rare they are.
|
||||||
|
|
||||||
### "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`,
|
||||||
|
|
|
||||||
|
|
@ -488,7 +488,33 @@ async fn format_notification(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
format_state_change_notification(notif.thread.updated_at, ¬if.state, &meta, own_login)
|
// State-change path (merge/close/new/activity). A just-merged or
|
||||||
|
// closed subject keeps its pre-merge last comment on
|
||||||
|
// `latest_comment_url`, so `has_comment` is often true here. When
|
||||||
|
// that comment was genuinely posted AFTER the close — a comment
|
||||||
|
// racing the merge inside the state-change tolerance window — append
|
||||||
|
// it so it isn't lost (best of both worlds; see
|
||||||
|
// `docs/forge.md::Merge racing a comment`). The ordinary pre-merge
|
||||||
|
// last comment (created before `closed_at`) is left off.
|
||||||
|
let comment_tail = if has_comment {
|
||||||
|
fresh_post_close_comment_tail(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
comment_api_url,
|
||||||
|
meta.subject.as_ref(),
|
||||||
|
own_login,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
format_state_change_notification(
|
||||||
|
notif.thread.updated_at,
|
||||||
|
¬if.state,
|
||||||
|
&meta,
|
||||||
|
own_login,
|
||||||
|
comment_tail,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -633,6 +659,7 @@ fn format_state_change_notification(
|
||||||
notif_state: &str,
|
notif_state: &str,
|
||||||
meta: &NotifMeta<'_>,
|
meta: &NotifMeta<'_>,
|
||||||
own_login: &str,
|
own_login: &str,
|
||||||
|
comment_tail: Option<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
// Classification uses the raw `subject.state` string extracted in
|
// Classification uses the raw `subject.state` string extracted in
|
||||||
// `parse_notification` — Forgejo returns "open" / "closed" / "merged"
|
// `parse_notification` — Forgejo returns "open" / "closed" / "merged"
|
||||||
|
|
@ -720,6 +747,11 @@ fn format_state_change_notification(
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
|
let mut out = format!("[{kind}] {title}\nurl: {html_url}{body_block}");
|
||||||
|
// Append a comment that raced the merge/close (best of both worlds), when
|
||||||
|
// the caller found one genuinely newer than `closed_at`.
|
||||||
|
if let Some(tail) = comment_tail {
|
||||||
|
out.push_str(&tail);
|
||||||
|
}
|
||||||
out.push_str(meta_suffix);
|
out.push_str(meta_suffix);
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
|
|
@ -776,6 +808,60 @@ fn state_change_is_current(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the (already-fetched) comment payload was created strictly
|
||||||
|
/// after the subject's `closed_at` — i.e. a comment racing the merge/close,
|
||||||
|
/// not the pre-merge last comment a merged/closed subject keeps on
|
||||||
|
/// `latest_comment_url`. Unlike `state_change_is_current`, a missing or
|
||||||
|
/// unparseable timestamp defaults to `false`: we only append a comment we
|
||||||
|
/// can positively place after the close, so an unplaceable one is never
|
||||||
|
/// bolted onto a merge message where it might be stale.
|
||||||
|
fn comment_is_after_close(
|
||||||
|
comment: &serde_json::Value,
|
||||||
|
subject: Option<&serde_json::Value>,
|
||||||
|
) -> bool {
|
||||||
|
let closed = subject
|
||||||
|
.and_then(|s| s["closed_at"].as_str())
|
||||||
|
.and_then(parse_rfc3339);
|
||||||
|
let created = comment["created_at"].as_str().and_then(parse_rfc3339);
|
||||||
|
match (created, closed) {
|
||||||
|
(Some(cr), Some(cl)) => cr > cl,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the trailing comment block to append to a merge/close notification
|
||||||
|
/// when a comment raced the merge (posted after `closed_at`). Fetches the
|
||||||
|
/// comment via `latest_comment_url` and returns `None` when it predates the
|
||||||
|
/// close (the ordinary kept last comment), is self-authored (don't echo the
|
||||||
|
/// agent's own write back at it), is empty/bodiless, or can't be fetched.
|
||||||
|
/// This is the one extra fetch the merge/close path pays for the best of
|
||||||
|
/// both worlds — cheap given how rare merge notifications are.
|
||||||
|
async fn fresh_post_close_comment_tail(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
token: &str,
|
||||||
|
comment_api_url: &str,
|
||||||
|
subject: Option<&serde_json::Value>,
|
||||||
|
own_login: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let payload = fetch_json(client, comment_api_url, token).await?;
|
||||||
|
if !comment_is_after_close(&payload, subject) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let author = payload["user"]["login"].as_str().unwrap_or("");
|
||||||
|
// Don't surface the agent's own racing comment back to it — mirrors the
|
||||||
|
// self-authored filter on the comment/review path.
|
||||||
|
if !own_login.is_empty() && author == own_login {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let body = payload["body"].as_str().unwrap_or("").trim();
|
||||||
|
if body.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let author = if author.is_empty() { "?" } else { author };
|
||||||
|
let (excerpt, mentions) = render_body_excerpt(body);
|
||||||
|
Some(format!("\n\ncomment by {author}: {excerpt}{mentions}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
@ -1378,13 +1464,13 @@ mod tests {
|
||||||
fn state_change_drops_self_authored_creation() {
|
fn state_change_drops_self_authored_creation() {
|
||||||
// No timestamps ⇒ treated as a creation; poster login == own_login.
|
// No timestamps ⇒ treated as a creation; poster login == own_login.
|
||||||
let meta = state_change_meta(serde_json::json!({ "user": { "login": "damocles" } }));
|
let meta = state_change_meta(serde_json::json!({ "user": { "login": "damocles" } }));
|
||||||
assert!(format_state_change_notification(None, "open", &meta, "damocles").is_none());
|
assert!(format_state_change_notification(None, "open", &meta, "damocles", None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_change_keeps_other_authored_creation() {
|
fn state_change_keeps_other_authored_creation() {
|
||||||
let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } }));
|
let meta = state_change_meta(serde_json::json!({ "user": { "login": "someone-else" } }));
|
||||||
assert!(format_state_change_notification(None, "open", &meta, "damocles").is_some());
|
assert!(format_state_change_notification(None, "open", &meta, "damocles", None).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1397,6 +1483,58 @@ mod tests {
|
||||||
"created_at": "2020-01-01T00:00:00Z",
|
"created_at": "2020-01-01T00:00:00Z",
|
||||||
}));
|
}));
|
||||||
let event = parse_rfc3339("2026-06-22T16:00:00Z");
|
let event = parse_rfc3339("2026-06-22T16:00:00Z");
|
||||||
assert!(format_state_change_notification(event, "closed", &meta, "damocles").is_some());
|
assert!(
|
||||||
|
format_state_change_notification(event, "closed", &meta, "damocles", None).is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_is_after_close_distinguishes_racing_from_kept_comment() {
|
||||||
|
let subject = serde_json::json!({ "closed_at": "2026-06-13T11:18:40+02:00" });
|
||||||
|
|
||||||
|
// Comment posted a minute after the merge → a comment racing the
|
||||||
|
// merge → surface it.
|
||||||
|
let racing = serde_json::json!({ "created_at": "2026-06-13T11:19:40+02:00" });
|
||||||
|
assert!(comment_is_after_close(&racing, Some(&subject)));
|
||||||
|
|
||||||
|
// The pre-merge last comment kept on `latest_comment_url` predates
|
||||||
|
// the close → must NOT be appended.
|
||||||
|
let kept = serde_json::json!({ "created_at": "2026-06-13T10:00:00+02:00" });
|
||||||
|
assert!(!comment_is_after_close(&kept, Some(&subject)));
|
||||||
|
|
||||||
|
// Missing/unparseable timestamps → false (don't append a comment we
|
||||||
|
// can't place after the close), opposite of the state-change default.
|
||||||
|
assert!(!comment_is_after_close(&racing, None));
|
||||||
|
assert!(!comment_is_after_close(
|
||||||
|
&serde_json::json!({}),
|
||||||
|
Some(&subject)
|
||||||
|
));
|
||||||
|
assert!(!comment_is_after_close(
|
||||||
|
&racing,
|
||||||
|
Some(&serde_json::json!({}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_state_change_appends_comment_tail() {
|
||||||
|
// The racing-comment tail lands between the body block and the meta
|
||||||
|
// suffix, so the assignee line stays last.
|
||||||
|
let meta = state_change_meta(serde_json::json!({
|
||||||
|
"user": { "login": "someone-else" },
|
||||||
|
"body": "the PR description",
|
||||||
|
}));
|
||||||
|
let out = format_state_change_notification(
|
||||||
|
None,
|
||||||
|
"merged",
|
||||||
|
&meta,
|
||||||
|
"damocles",
|
||||||
|
Some("\n\ncomment by argus: nice, merging".to_owned()),
|
||||||
|
)
|
||||||
|
.expect("merge notification must render");
|
||||||
|
assert!(out.contains("comment by argus: nice, merging"));
|
||||||
|
// Tail precedes the meta suffix.
|
||||||
|
let tail_at = out.find("comment by argus").unwrap();
|
||||||
|
let assignee_at = out.find("assignee:").unwrap();
|
||||||
|
assert!(tail_at < assignee_at);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue