pleme-doc-gen 0.1.41

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();
    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() {
            // 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;
        }
        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\""));
    }
}