pipedash-plugin-actions 0.7.0

Shared Actions workflow helpers for Pipedash plugins
Documentation
mod log_dialect;

pub use log_dialect::ActionsLogDialect;
use pipedash_plugin_api::{
    PluginError,
    PluginResult,
    WorkflowParameter,
    WorkflowParameterType,
};
use serde::de::{
    Deserializer,
    IgnoredAny,
    MapAccess,
    Visitor,
};
use serde::Deserialize;
use serde_json::Value;

#[derive(Deserialize)]
struct WorkflowFile {
    #[serde(default)]
    on: Option<Triggers>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum Triggers {
    Map {
        #[serde(default)]
        workflow_dispatch: Option<Dispatch>,
    },
    Other(IgnoredAny),
}

#[derive(Deserialize)]
struct Dispatch {
    #[serde(default)]
    inputs: Option<Inputs>,
}

struct Inputs(Vec<(String, Value)>);

impl<'de> Deserialize<'de> for Inputs {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        struct OrderedMap;

        impl<'de> Visitor<'de> for OrderedMap {
            type Value = Inputs;

            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
                formatter.write_str("a map of workflow inputs")
            }

            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Inputs, A::Error> {
                let mut entries = Vec::new();
                while let Some(entry) = map.next_entry()? {
                    entries.push(entry);
                }
                Ok(Inputs(entries))
            }
        }

        deserializer.deserialize_map(OrderedMap)
    }
}

const INPUT_PREFIX: &str = "inputs.";

pub fn dispatch_inputs(workflow_source: &str) -> PluginResult<Vec<WorkflowParameter>> {
    let file: WorkflowFile = serde_saphyr::from_str(workflow_source)
        .map_err(|e| PluginError::ApiError(format!("Failed to parse workflow file: {e}")))?;
    let inputs = match file.on {
        Some(Triggers::Map {
            workflow_dispatch: Some(Dispatch {
                inputs: Some(inputs),
            }),
        }) => inputs.0,
        _ => Vec::new(),
    };

    Ok(inputs
        .into_iter()
        .map(|(name, input)| WorkflowParameter {
            name: format!("{INPUT_PREFIX}{name}"),
            label: Some(name),
            description: input
                .get("description")
                .and_then(Value::as_str)
                .map(str::to_string),
            param_type: input_type(&input),
            required: input
                .get("required")
                .and_then(Value::as_bool)
                .unwrap_or(false),
        })
        .collect())
}

pub fn dispatch_body(inputs: Option<Value>) -> Value {
    let mut values = match inputs {
        Some(Value::Object(values)) => values,
        _ => serde_json::Map::new(),
    };
    let ref_value = match values.remove("ref") {
        Some(Value::String(value)) => value,
        _ => "main".to_string(),
    };
    let inputs: serde_json::Map<String, Value> = values
        .into_iter()
        .map(|(mut key, value)| {
            if key.starts_with(INPUT_PREFIX) {
                key.replace_range(..INPUT_PREFIX.len(), "");
            }
            (key, value)
        })
        .collect();

    let mut body = serde_json::json!({ "ref": ref_value });
    if !inputs.is_empty() {
        body["inputs"] = Value::Object(inputs);
    }
    body
}

fn input_type(input: &Value) -> WorkflowParameterType {
    let default = input.get("default");
    let text = |value: &Value| match value {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    };
    match input
        .get("type")
        .and_then(Value::as_str)
        .unwrap_or("string")
    {
        "boolean" => WorkflowParameterType::Boolean {
            default: default
                .is_some_and(|d| d.as_bool() == Some(true) || d.as_str() == Some("true")),
        },
        "number" => WorkflowParameterType::Number {
            default: default.and_then(|d| d.as_f64().or_else(|| d.as_str()?.parse().ok())),
        },
        "choice" => WorkflowParameterType::Choice {
            options: input
                .get("options")
                .and_then(Value::as_array)
                .map(|options| options.iter().map(text).collect())
                .unwrap_or_default(),
            default: default.map(text),
        },
        _ => WorkflowParameterType::String {
            default: default.map(text),
        },
    }
}

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

    #[test]
    fn dispatch_inputs_are_read_from_the_workflow_file() {
        let source = "name: Release\non:\n  push:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: Release version\n        required: true\n      dry_run:\n        type: boolean\n        default: true\n      level:\n        type: choice\n        options: [debug, info]\n        default: info\n      retries:\n        type: number\n        default: 3\njobs: {}\n";
        let params = dispatch_inputs(source).unwrap();
        let summary: Vec<_> = params
            .iter()
            .map(|p| {
                (
                    p.name.as_str(),
                    p.required,
                    serde_json::to_value(&p.param_type).unwrap(),
                )
            })
            .collect();
        assert_eq!(
            summary,
            [
                (
                    "inputs.version",
                    true,
                    serde_json::json!({"type": "string", "default": null})
                ),
                (
                    "inputs.dry_run",
                    false,
                    serde_json::json!({"type": "boolean", "default": true})
                ),
                (
                    "inputs.level",
                    false,
                    serde_json::json!({"type": "choice", "options": ["debug", "info"], "default": "info"})
                ),
                (
                    "inputs.retries",
                    false,
                    serde_json::json!({"type": "number", "default": 3.0})
                ),
            ]
        );
    }

    #[test]
    fn dispatch_body_keeps_a_workflow_input_named_ref_separate_from_the_branch() {
        let body = dispatch_body(Some(serde_json::json!({
            "ref": "release",
            "inputs.ref": "abc123",
            "inputs.greeting": "hi"
        })));
        assert_eq!(
            body,
            serde_json::json!({"ref": "release", "inputs": {"ref": "abc123", "greeting": "hi"}})
        );
        assert_eq!(dispatch_body(None), serde_json::json!({"ref": "main"}));
    }

    #[test]
    fn workflows_without_dispatch_inputs_have_no_parameters() {
        assert!(dispatch_inputs("on: push\njobs: {}\n").unwrap().is_empty());
        assert!(dispatch_inputs("on: [push, workflow_dispatch]\n")
            .unwrap()
            .is_empty());
        assert!(dispatch_inputs("on:\n  workflow_dispatch:\n")
            .unwrap()
            .is_empty());
    }
}