pleme-doc-gen 0.1.54

Rust replacement for the M0 Python _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions. Walks every action.yml + emits substrate's patterns-full.nix + per-action README.md + root catalog. Per the NO-SHELL prime directive.
//! await-ci — observe + verify auto-release workflow ran green.
//!
//! The final lifecycle verification verb. After `ship` pushes the
//! eaten dir to a new GH repo, the auto-release.yml shim triggers a
//! workflow run on the remote. `await-ci` polls `gh run list` until
//! the latest run completes, reports success/failure + URL.
//!
//! Closes the operator's "sailed out + verified package destination"
//! loop: substrate proves the eaten caixa actually built + shipped +
//! published successfully on GitHub-Actions free OSS compute.

use anyhow::{anyhow, Result};

#[derive(Debug, Clone)]
pub struct WaitReport {
    pub repo_slug: String,
    pub run_id: Option<String>,
    pub status: String,        // queued | in_progress | completed
    pub conclusion: Option<String>, // success | failure | cancelled | ...
    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 {
    /// Max total seconds to wait. Default: 900 (15 minutes).
    pub timeout_seconds: u64,
    /// Seconds between polls. Default: 15.
    pub poll_seconds: u64,
    /// Specific workflow file to watch. Default: auto-release.yml.
    pub workflow: String,
}

impl Default for AwaitConfig {
    fn default() -> Self {
        Self {
            timeout_seconds: 900,
            poll_seconds: 15,
            workflow: "auto-release.yml".to_string(),
        }
    }
}

/// Poll `gh run list --workflow=<workflow> --repo <slug> --limit 1
/// --json status,conclusion,databaseId,url` until the latest run
/// finishes or the timeout fires.
pub fn await_ci(repo_slug: &str, cfg: &AwaitConfig) -> Result<WaitReport> {
    let start = std::time::Instant::now();
    // Capture start time as a UTC RFC3339 string so we can compare
    // against run createdAt values. This is the load-bearing fix for
    // bump-triggered runs: a single `gh run list --limit 1` on first
    // poll often returns the pre-bump run; once it completes we'd
    // report "success" while the actual release-bump run hasn't even
    // started. Tracking start_time lets us prefer runs newer than
    // await-ci's own start, falling back to latest only after a
    // grace window.
    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);
            // Transient classes that should NOT abort the poll loop:
            //   HTTP 404 — workflow file not yet indexed by GH after
            //              first push (few-second delay).
            //   connection reset / EOF / timeout — IPv4-stack noise
            //              while local network shifts, GH API edge
            //              flaps, or proxy hiccups. Each shows up as
            //              a non-zero exit from `gh` with an empty
            //              json payload.
            //   rate limit — GH applies request quotas; surface as
            //              transient + poll-throttle.
            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() {
            // No runs yet — workflow may not have triggered. Wait + retry.
            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;
        }
        // Prefer the newest run that started after we did (catches the
        // bump-triggered run when auto-release pushes a v0.x.y commit
        // on top of the originally-pushed commit). Falls back to the
        // newest run overall if none is newer than start — that path
        // covers the case where the original push didn't fire a bump
        // and there's only one run to follow.
        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));
    }
}

/// Pick the run to follow from `gh run list`'s JSON array. Prefers
/// the newest run with `createdAt > start_iso` (the bump-triggered
/// run); falls back to the array head when none qualifies.
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])
}

/// Minimal UTC RFC3339 timestamp builder. Avoids pulling chrono just
/// for the start-time string. Format: `YYYY-MM-DDTHH:MM:SSZ`.
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);
    // RFC3339 is what GH returns; lexicographic comparison on the
    // ISO-8601 string suffices because the format is monotonic.
    // Use a typed iso8601 builder via a tiny ymdhms split.
    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)
}

/// Howard Hinnant's date algorithm: days-since-1970 → (Y, M, D).
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"}),
        ];
        // start_iso is between runs 1 (old) and runs 2/3 (newer).
        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"}),
        ];
        // start is way in the future — nothing is newer.
        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() {
        // 2026-01-01T00:00:00Z = 1767225600 (verified against `date -u`).
        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));
        // 2026-05-25T00:00:00Z = 1767225600 + (31+28+31+30+24)*86400.
        let (y, m, d, _, _, _) = epoch_to_ymdhms(1_767_225_600 + 144 * 86_400);
        assert_eq!((y, m, d), (2026, 5, 25));
        // Time-of-day separation: 1767225600 + 3661 = 01:01:01 on 2026-01-01.
        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();
        // 20 chars: YYYY-MM-DDTHH:MM:SSZ
        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\""));
    }
}