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 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", "1",
"--json", "status,conclusion,databaseId,url"])
.output()
.map_err(|e| anyhow!("gh run list: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
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 = &arr[0];
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));
}
}
#[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 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\""));
}
}