pipedash-plugin-buildkite 0.6.0

Buildkite plugin for Pipedash
Documentation
use std::collections::HashMap;

use chrono::{
    DateTime,
    Utc,
};
use pipedash_plugin_api::{
    BuildAgent,
    LogAvailability,
    LogNode,
    LogNodeKind,
    PipelineStatus,
};

use crate::types;

pub(crate) fn map_build_state(state: &str) -> PipelineStatus {
    match state {
        "passed" => PipelineStatus::Success,
        "failed" => PipelineStatus::Failed,
        "running" | "started" => PipelineStatus::Running,
        "scheduled" | "creating" | "waiting" => PipelineStatus::Pending,
        "canceled" | "canceling" => PipelineStatus::Cancelled,
        "skipped" | "not_run" => PipelineStatus::Skipped,
        "blocked" => PipelineStatus::Pending,
        _ => PipelineStatus::Pending,
    }
}

fn map_job_state(state: &str) -> PipelineStatus {
    match state {
        "passed" => PipelineStatus::Success,
        "failed" | "timed_out" => PipelineStatus::Failed,
        "running" | "canceling" | "timing_out" => PipelineStatus::Running,
        "canceled" | "expired" => PipelineStatus::Cancelled,
        "skipped" | "broken" | "waiting_failed" | "blocked_failed" | "unblocked_failed" => {
            PipelineStatus::Skipped
        }
        _ => PipelineStatus::Pending,
    }
}

fn parse_time(value: Option<&str>) -> Option<DateTime<Utc>> {
    value
        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
        .map(|at| at.with_timezone(&Utc))
}

fn job_name(job: &types::Job) -> String {
    let base = job
        .name
        .as_deref()
        .filter(|name| !name.trim().is_empty())
        .or_else(|| {
            job.command
                .as_deref()
                .and_then(|command| command.lines().find(|line| !line.trim().is_empty()))
        })
        .or(job.step_key.as_deref())
        .unwrap_or("Job")
        .trim();
    match (job.parallel_group_index, job.parallel_group_total) {
        (Some(index), Some(total)) if total > 1 => format!("{base} ({}/{total})", index + 1),
        _ => base.to_string(),
    }
}

pub(crate) fn jobs_to_log_nodes(jobs: &[types::Job]) -> Vec<LogNode> {
    jobs.iter()
        .filter(|job| job.kind.as_deref() == Some("script") && job.retried != Some(true))
        .map(|job| {
            let status = map_job_state(job.state.as_deref().unwrap_or_default());
            let started_at = parse_time(job.started_at.as_deref());
            let log = match status {
                PipelineStatus::Running => Some(LogAvailability::Partial),
                PipelineStatus::Pending => Some(LogAvailability::Pending),
                _ => started_at.map(|_| LogAvailability::Ready),
            };
            LogNode {
                id: job.id.clone(),
                parent_id: None,
                kind: LogNodeKind::Job,
                name: job_name(job),
                status,
                started_at,
                finished_at: parse_time(job.finished_at.as_deref()),
                log,
                web_url: job.web_url.clone(),
            }
        })
        .collect()
}

pub(crate) fn map_agent(agent: types::Agent) -> BuildAgent {
    let status = if agent.connected {
        if agent.job.is_some() {
            "busy".to_string()
        } else {
            "idle".to_string()
        }
    } else {
        "disconnected".to_string()
    };

    let mut metadata = HashMap::new();
    metadata.insert("ip_address".to_string(), agent.ip_address);
    metadata.insert("version".to_string(), agent.version);

    BuildAgent {
        id: agent.id,
        name: agent.name,
        hostname: agent.hostname,
        status,
        job_id: agent.job.map(|j| j.id),
        last_seen: Utc::now(),
        metadata,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_map_build_state() {
        assert_eq!(map_build_state("passed"), PipelineStatus::Success);
        assert_eq!(map_build_state("failed"), PipelineStatus::Failed);
        assert_eq!(map_build_state("running"), PipelineStatus::Running);
        assert_eq!(map_build_state("canceled"), PipelineStatus::Cancelled);
        assert_eq!(map_build_state("skipped"), PipelineStatus::Skipped);
        assert_eq!(map_build_state("unknown"), PipelineStatus::Pending);
    }

    #[test]
    fn every_job_state_maps_to_a_status() {
        let cases = [
            ("pending", PipelineStatus::Pending),
            ("waiting", PipelineStatus::Pending),
            ("blocked", PipelineStatus::Pending),
            ("unblocked", PipelineStatus::Pending),
            ("limiting", PipelineStatus::Pending),
            ("limited", PipelineStatus::Pending),
            ("scheduled", PipelineStatus::Pending),
            ("assigned", PipelineStatus::Pending),
            ("accepted", PipelineStatus::Pending),
            ("running", PipelineStatus::Running),
            ("canceling", PipelineStatus::Running),
            ("timing_out", PipelineStatus::Running),
            ("passed", PipelineStatus::Success),
            ("failed", PipelineStatus::Failed),
            ("timed_out", PipelineStatus::Failed),
            ("canceled", PipelineStatus::Cancelled),
            ("expired", PipelineStatus::Cancelled),
            ("skipped", PipelineStatus::Skipped),
            ("broken", PipelineStatus::Skipped),
            ("waiting_failed", PipelineStatus::Skipped),
            ("blocked_failed", PipelineStatus::Skipped),
            ("unblocked_failed", PipelineStatus::Skipped),
        ];
        for (state, status) in cases {
            assert_eq!(map_job_state(state), status, "{state}");
        }
    }

    #[test]
    fn script_jobs_of_the_latest_attempt_become_log_nodes() {
        let build: types::BuildJobs = serde_json::from_str(
            r#"{"number":2,"state":"failed","jobs":[
                {"id":"old","type":"script","name":"RSpec","state":"failed","retried":true,
                 "started_at":"2026-10-27T10:00:00.000Z","finished_at":"2026-10-27T10:01:00.000Z"},
                {"id":"new","type":"script","name":"RSpec","state":"passed","retried":false,
                 "web_url":"https://buildkite.com/acme/app/builds/2#new",
                 "started_at":"2026-10-27T10:02:00.000Z","finished_at":"2026-10-27T10:03:30.000Z"},
                {"id":"wait","type":"waiter","state":null},
                {"id":"gate","type":"manual","label":"Deploy?","state":"blocked"},
                {"id":"shard","type":"script","name":":rspec: Tests","state":"running",
                 "started_at":"2026-10-27T10:04:00.000Z","parallel_group_index":1,"parallel_group_total":4},
                {"id":"queued","type":"script","name":null,"command":"\nmake lint\nmake test","state":"scheduled"},
                {"id":"never","type":"script","step_key":"deploy","state":"broken"},
                {"id":"stopped","type":"script","name":"Lint","state":"canceled",
                 "started_at":"2026-10-27T10:05:00.000Z","finished_at":"2026-10-27T10:05:10.000Z"}
            ]}"#,
        )
        .unwrap();

        let nodes = jobs_to_log_nodes(&build.jobs);

        let summary: Vec<_> = nodes
            .iter()
            .map(|node| (node.id.as_str(), node.name.as_str(), node.status, node.log))
            .collect();
        assert_eq!(
            summary,
            vec![
                (
                    "new",
                    "RSpec",
                    PipelineStatus::Success,
                    Some(LogAvailability::Ready)
                ),
                (
                    "shard",
                    ":rspec: Tests (2/4)",
                    PipelineStatus::Running,
                    Some(LogAvailability::Partial)
                ),
                (
                    "queued",
                    "make lint",
                    PipelineStatus::Pending,
                    Some(LogAvailability::Pending)
                ),
                ("never", "deploy", PipelineStatus::Skipped, None),
                (
                    "stopped",
                    "Lint",
                    PipelineStatus::Cancelled,
                    Some(LogAvailability::Ready)
                ),
            ]
        );
        assert!(nodes
            .iter()
            .all(|node| node.kind == LogNodeKind::Job && node.parent_id.is_none()));
        let new = &nodes[0];
        assert_eq!(
            new.web_url.as_deref(),
            Some("https://buildkite.com/acme/app/builds/2#new")
        );
        assert_eq!(
            new.finished_at.unwrap() - new.started_at.unwrap(),
            chrono::Duration::seconds(90)
        );
    }
}