use chrono::{DateTime, Datelike as _, Utc};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub(crate) struct RollupEntry {
#[serde(default, rename = "__typename")]
typename: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
context: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
conclusion: Option<String>,
#[serde(default)]
state: Option<String>,
#[serde(default, rename = "completedAt")]
completed_at: Option<String>,
#[serde(default, rename = "startedAt")]
started_at: Option<String>,
}
fn terminal_state(s: &str) -> bool {
["SUCCESS", "FAILURE", "ERROR"]
.iter()
.any(|t| s.eq_ignore_ascii_case(t))
}
impl RollupEntry {
pub(crate) fn label(&self) -> Option<&str> {
self.name
.as_deref()
.or(self.context.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
}
pub(crate) fn display_label(&self) -> String {
self.name
.clone()
.or_else(|| self.context.clone())
.or_else(|| self.typename.clone())
.unwrap_or_else(|| "<unnamed check>".to_string())
}
pub(crate) fn is_success(&self) -> bool {
let v = self.conclusion.as_deref().or(self.state.as_deref());
v.is_some_and(|s| s.eq_ignore_ascii_case("SUCCESS"))
}
fn kind(&self) -> &str {
match self.typename.as_deref().map(str::trim) {
Some(t) if !t.is_empty() => t,
_ if self.name.is_some() => "CheckRun",
_ => "StatusContext",
}
}
pub(crate) fn is_unfinished(&self) -> bool {
!self.settled()
}
pub(crate) fn run_summary(&self) -> String {
let status = self
.status
.as_deref()
.or(self.state.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("no status");
match parse_stamp(self.started_at.as_deref()) {
Some(t) => format!("({status}, started {})", t.to_rfc3339()),
None => format!("({status}, no startedAt)"),
}
}
pub(crate) fn settled(&self) -> bool {
let completed = self
.status
.as_deref()
.is_some_and(|s| s.eq_ignore_ascii_case("COMPLETED"))
&& self
.conclusion
.as_deref()
.is_some_and(|c| !c.trim().is_empty());
completed || self.state.as_deref().is_some_and(terminal_state)
}
pub(crate) fn failed(&self) -> bool {
let verdict = self
.conclusion
.as_deref()
.map(str::trim)
.filter(|c| !c.is_empty())
.or(self.state.as_deref())
.unwrap_or_default();
self.settled()
&& !["SUCCESS", "NEUTRAL", "SKIPPED"]
.iter()
.any(|p| verdict.eq_ignore_ascii_case(p))
}
}
fn parse_stamp(raw: Option<&str>) -> Option<DateTime<Utc>> {
let parsed = DateTime::parse_from_rfc3339(raw?.trim()).ok()?;
let utc = parsed.with_timezone(&Utc);
(utc.year() > 1).then_some(utc)
}
fn recency(entry: &RollupEntry) -> Option<DateTime<Utc>> {
parse_stamp(entry.completed_at.as_deref()).or_else(|| parse_stamp(entry.started_at.as_deref()))
}
pub(crate) fn deciding_runs<'a>(entries: &'a [RollupEntry], name: &str) -> Vec<&'a RollupEntry> {
let mut kinds: Vec<&str> = Vec::new();
for e in entries.iter().filter(|e| e.label() == Some(name)) {
if !kinds.contains(&e.kind()) {
kinds.push(e.kind());
}
}
kinds
.into_iter()
.filter_map(|kind| {
let runs = || {
entries
.iter()
.filter(move |e| e.label() == Some(name) && e.kind() == kind)
};
runs()
.filter(|e| e.is_unfinished())
.max_by_key(|e| recency(e))
.or_else(|| runs().max_by_key(|e| recency(e)))
})
.collect()
}
pub(crate) fn deciding_per_check(entries: &[RollupEntry]) -> Vec<&RollupEntry> {
entries
.iter()
.filter(|e| {
e.label().is_none_or(|n| {
deciding_runs(entries, n)
.iter()
.any(|chosen| std::ptr::eq(*chosen, *e))
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn runs(json: &str) -> Vec<RollupEntry> {
serde_json::from_str(json).expect("valid rollup JSON")
}
fn pick<'a>(runs: &'a [RollupEntry], name: &str) -> &'a RollupEntry {
let got = deciding_runs(runs, name);
assert_eq!(got.len(), 1, "one type, one deciding run");
got[0]
}
#[test]
fn rollup_latest_prefers_later_completion() {
let runs = runs(
r#"[{"name":"ci","status":"COMPLETED","conclusion":"SUCCESS","completedAt":"2026-09-25T22:53:26Z"},
{"name":"ci","status":"COMPLETED","conclusion":"CANCELLED","completedAt":"2026-09-25T22:51:15Z"}]"#,
);
assert!(pick(&runs, "ci").is_success());
assert!(deciding_runs(&runs, "other").is_empty());
}
#[test]
fn rollup_unfinished_run_wins_whatever_the_timestamps() {
for open in [
r#"{"name":"ci","status":"IN_PROGRESS","conclusion":"","startedAt":"2026-09-25T22:51:00Z","completedAt":"0001-01-01T00:00:00Z"}"#,
r#"{"name":"ci","status":"QUEUED","conclusion":""}"#,
] {
let runs = runs(&format!(
r#"[{{"name":"ci","status":"COMPLETED","conclusion":"SUCCESS",
"startedAt":"2026-09-25T22:50:00Z","completedAt":"2026-09-25T22:51:15Z"}},{open}]"#
));
let got = pick(&runs, "ci");
assert!(got.is_unfinished(), "{open}");
}
}
#[test]
fn rollup_untimed_entry_sorts_oldest() {
let runs = runs(
r#"[{"name":"ci","status":"COMPLETED","conclusion":"SUCCESS","completedAt":"2026-09-25T22:51:15Z"},
{"name":"ci","status":"COMPLETED","conclusion":"CANCELLED"}]"#,
);
assert!(pick(&runs, "ci").is_success());
}
#[test]
fn rollup_latest_per_name_keeps_unnamed() {
let runs = runs(
r#"[{"name":"ci","status":"COMPLETED","conclusion":"CANCELLED","completedAt":"2026-09-25T22:51:15Z"},
{"__typename":"Mystery"},
{"name":"ci","status":"COMPLETED","conclusion":"SUCCESS","completedAt":"2026-09-25T22:53:26Z"},
{"__typename":"Mystery"},
{"context":"lint","state":"SUCCESS"}]"#,
);
let kept = deciding_per_check(&runs);
assert_eq!(kept.len(), 4, "one `ci`, one `lint`, both unnamed");
for (k, i) in kept.iter().zip([1, 2, 3, 4]) {
assert!(std::ptr::eq(*k, &runs[i]));
}
}
}