use anyhow::{anyhow, Result};
#[derive(Debug, Clone)]
pub struct WaitReport {
pub repo_slug: String,
pub run_id: Option<String>,
pub status: String, pub conclusion: Option<String>, pub run_url: Option<String>,
pub polls: usize,
pub waited_seconds: u64,
}
impl WaitReport {
pub fn is_success(&self) -> bool {
self.conclusion.as_deref() == Some("success")
}
pub fn to_json(&self) -> String {
use crate::json_ast::Value;
let mut o = Value::obj();
o.insert("repo", Value::s(&self.repo_slug));
if let Some(id) = &self.run_id { o.insert("run-id", Value::s(id)); }
o.insert("status", Value::s(&self.status));
if let Some(c) = &self.conclusion { o.insert("conclusion", Value::s(c)); }
if let Some(u) = &self.run_url { o.insert("run-url", Value::s(u)); }
o.insert("polls", Value::i(self.polls as i64));
o.insert("waited-seconds", Value::i(self.waited_seconds as i64));
o.insert("success", Value::b(self.is_success()));
crate::json_ast::render(&o)
}
}
#[derive(Debug, Clone)]
pub struct AwaitConfig {
pub timeout_seconds: u64,
pub poll_seconds: u64,
pub workflow: String,
}
impl Default for AwaitConfig {
fn default() -> Self {
Self {
timeout_seconds: 900,
poll_seconds: 15,
workflow: "auto-release.yml".to_string(),
}
}
}
pub fn await_ci(repo_slug: &str, cfg: &AwaitConfig) -> Result<WaitReport> {
let start = std::time::Instant::now();
let start_iso = chrono_like_now_rfc3339();
let mut report = WaitReport {
repo_slug: repo_slug.to_string(),
run_id: None,
status: "pending-first-poll".to_string(),
conclusion: None,
run_url: None,
polls: 0,
waited_seconds: 0,
};
loop {
report.polls += 1;
let out = std::process::Command::new("gh")
.args(["run", "list",
"--repo", repo_slug,
"--workflow", &cfg.workflow,
"--limit", "5",
"--json", "status,conclusion,databaseId,url,createdAt,headSha"])
.output()
.map_err(|e| anyhow!("gh run list: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let is_transient = stderr.contains("HTTP 404")
|| stderr.contains("not found on the default branch")
|| stderr.contains("Not Found")
|| stderr.contains("connection reset by peer")
|| stderr.contains("EOF")
|| stderr.contains("i/o timeout")
|| stderr.contains("read: connection reset")
|| stderr.contains("rate limit");
if is_transient {
if start.elapsed().as_secs() >= cfg.timeout_seconds {
report.status = "timeout-transient-error".to_string();
report.waited_seconds = start.elapsed().as_secs();
return Ok(report);
}
std::thread::sleep(std::time::Duration::from_secs(cfg.poll_seconds));
continue;
}
return Err(anyhow!("gh run list failed: {stderr}"));
}
let text = String::from_utf8_lossy(&out.stdout);
let parsed: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| anyhow!("parse gh output: {e}"))?;
let arr = parsed.as_array().ok_or_else(|| anyhow!("expected JSON array"))?;
if arr.is_empty() {
if start.elapsed().as_secs() >= cfg.timeout_seconds {
report.status = "timeout-no-runs".to_string();
report.waited_seconds = start.elapsed().as_secs();
return Ok(report);
}
std::thread::sleep(std::time::Duration::from_secs(cfg.poll_seconds));
continue;
}
let run = pick_run_to_follow(arr, &start_iso);
report.run_id = run.get("databaseId").and_then(|v| v.as_i64())
.map(|n| n.to_string());
report.status = run.get("status").and_then(|v| v.as_str())
.unwrap_or("unknown").to_string();
report.conclusion = run.get("conclusion").and_then(|v| v.as_str())
.map(String::from)
.filter(|s| !s.is_empty());
report.run_url = run.get("url").and_then(|v| v.as_str()).map(String::from);
if report.status == "completed" {
report.waited_seconds = start.elapsed().as_secs();
return Ok(report);
}
if start.elapsed().as_secs() >= cfg.timeout_seconds {
report.status = format!("timeout-{}", report.status);
report.waited_seconds = start.elapsed().as_secs();
return Ok(report);
}
std::thread::sleep(std::time::Duration::from_secs(cfg.poll_seconds));
}
}
fn pick_run_to_follow<'a>(
runs: &'a [serde_json::Value],
start_iso: &str,
) -> &'a serde_json::Value {
let newest_after_start = runs
.iter()
.filter(|r| {
r.get("createdAt")
.and_then(|v| v.as_str())
.is_some_and(|t| t.as_bytes() > start_iso.as_bytes())
})
.max_by(|a, b| {
let at = a.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
let bt = b.get("createdAt").and_then(|v| v.as_str()).unwrap_or("");
at.cmp(bt)
});
newest_after_start.unwrap_or(&runs[0])
}
fn chrono_like_now_rfc3339() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (year, month, day, hour, minute, second) = epoch_to_ymdhms(secs);
let mut s = String::with_capacity(20);
use std::fmt::Write;
let _ = write!(s, "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z");
s
}
fn epoch_to_ymdhms(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
let days = (secs / 86_400) as i64;
let time_of_day = secs % 86_400;
let hour = (time_of_day / 3600) as u32;
let minute = ((time_of_day % 3600) / 60) as u32;
let second = (time_of_day % 60) as u32;
let (year, month, day) = civil_from_days(days);
(year, month, day, hour, minute, second)
}
fn civil_from_days(days: i64) -> (u32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = (yoe as i64) + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y as u32, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wait_report_is_success_only_when_conclusion_is_success() {
let mut r = WaitReport {
repo_slug: "x/y".into(), run_id: None,
status: "completed".into(), conclusion: Some("success".into()),
run_url: None, polls: 1, waited_seconds: 30,
};
assert!(r.is_success());
r.conclusion = Some("failure".into());
assert!(!r.is_success());
r.conclusion = None;
assert!(!r.is_success());
}
#[test]
fn pick_run_to_follow_prefers_newest_created_after_start() {
let runs = vec![
serde_json::json!({"databaseId": 1, "createdAt": "2026-01-01T00:00:00Z"}),
serde_json::json!({"databaseId": 2, "createdAt": "2026-05-25T10:00:00Z"}),
serde_json::json!({"databaseId": 3, "createdAt": "2026-05-25T10:05:00Z"}),
];
let picked = pick_run_to_follow(&runs, "2026-05-25T09:00:00Z");
assert_eq!(picked["databaseId"], 3, "should pick newest run after start");
}
#[test]
fn pick_run_to_follow_falls_back_to_head_when_none_newer_than_start() {
let runs = vec![
serde_json::json!({"databaseId": 1, "createdAt": "2026-01-01T00:00:00Z"}),
serde_json::json!({"databaseId": 2, "createdAt": "2026-02-01T00:00:00Z"}),
];
let picked = pick_run_to_follow(&runs, "2026-12-31T23:59:59Z");
assert_eq!(picked["databaseId"], 1, "fall back to head of list");
}
#[test]
fn epoch_to_ymdhms_round_trips_known_dates() {
let (y, m, d, h, mi, s) = epoch_to_ymdhms(1_767_225_600);
assert_eq!((y, m, d, h, mi, s), (2026, 1, 1, 0, 0, 0));
let (y, m, d, _, _, _) = epoch_to_ymdhms(1_767_225_600 + 144 * 86_400);
assert_eq!((y, m, d), (2026, 5, 25));
let (_, _, _, h, mi, s) = epoch_to_ymdhms(1_767_225_600 + 3_661);
assert_eq!((h, mi, s), (1, 1, 1));
}
#[test]
fn rfc3339_now_is_monotonic_and_well_formed() {
let a = chrono_like_now_rfc3339();
assert_eq!(a.len(), 20);
assert!(a.ends_with('Z'));
assert_eq!(a.as_bytes()[4], b'-');
assert_eq!(a.as_bytes()[10], b'T');
assert_eq!(a.as_bytes()[13], b':');
}
#[test]
fn wait_report_json_emits_typed_success_flag() {
let r = WaitReport {
repo_slug: "test/repo".into(), run_id: Some("12345".into()),
status: "completed".into(), conclusion: Some("success".into()),
run_url: Some("https://github.com/test/repo/actions/runs/12345".into()),
polls: 3, waited_seconds: 45,
};
let j = r.to_json();
assert!(j.contains("\"repo\": \"test/repo\""));
assert!(j.contains("\"success\": true"));
assert!(j.contains("\"conclusion\": \"success\""));
}
}