Skip to main content

pipedash_plugin_actions/
lib.rs

1mod log_dialect;
2
3pub use log_dialect::ActionsLogDialect;
4use pipedash_plugin_api::{
5    PluginError,
6    PluginResult,
7    WorkflowParameter,
8    WorkflowParameterType,
9};
10use serde::de::{
11    Deserializer,
12    IgnoredAny,
13    MapAccess,
14    Visitor,
15};
16use serde::Deserialize;
17use serde_json::Value;
18
19#[derive(Deserialize)]
20struct WorkflowFile {
21    #[serde(default)]
22    on: Option<Triggers>,
23}
24
25#[derive(Deserialize)]
26#[serde(untagged)]
27enum Triggers {
28    Map {
29        #[serde(default)]
30        workflow_dispatch: Option<Dispatch>,
31    },
32    Other(IgnoredAny),
33}
34
35#[derive(Deserialize)]
36struct Dispatch {
37    #[serde(default)]
38    inputs: Option<Inputs>,
39}
40
41struct Inputs(Vec<(String, Value)>);
42
43impl<'de> Deserialize<'de> for Inputs {
44    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        struct OrderedMap;
46
47        impl<'de> Visitor<'de> for OrderedMap {
48            type Value = Inputs;
49
50            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
51                formatter.write_str("a map of workflow inputs")
52            }
53
54            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Inputs, A::Error> {
55                let mut entries = Vec::new();
56                while let Some(entry) = map.next_entry()? {
57                    entries.push(entry);
58                }
59                Ok(Inputs(entries))
60            }
61        }
62
63        deserializer.deserialize_map(OrderedMap)
64    }
65}
66
67const INPUT_PREFIX: &str = "inputs.";
68
69pub fn dispatch_inputs(workflow_source: &str) -> PluginResult<Vec<WorkflowParameter>> {
70    let file: WorkflowFile = serde_saphyr::from_str(workflow_source)
71        .map_err(|e| PluginError::ApiError(format!("Failed to parse workflow file: {e}")))?;
72    let inputs = match file.on {
73        Some(Triggers::Map {
74            workflow_dispatch: Some(Dispatch {
75                inputs: Some(inputs),
76            }),
77        }) => inputs.0,
78        _ => Vec::new(),
79    };
80
81    Ok(inputs
82        .into_iter()
83        .map(|(name, input)| WorkflowParameter {
84            name: format!("{INPUT_PREFIX}{name}"),
85            label: Some(name),
86            description: input
87                .get("description")
88                .and_then(Value::as_str)
89                .map(str::to_string),
90            param_type: input_type(&input),
91            required: input
92                .get("required")
93                .and_then(Value::as_bool)
94                .unwrap_or(false),
95        })
96        .collect())
97}
98
99pub fn dispatch_body(inputs: Option<Value>) -> Value {
100    let mut values = match inputs {
101        Some(Value::Object(values)) => values,
102        _ => serde_json::Map::new(),
103    };
104    let ref_value = match values.remove("ref") {
105        Some(Value::String(value)) => value,
106        _ => "main".to_string(),
107    };
108    let inputs: serde_json::Map<String, Value> = values
109        .into_iter()
110        .map(|(mut key, value)| {
111            if key.starts_with(INPUT_PREFIX) {
112                key.replace_range(..INPUT_PREFIX.len(), "");
113            }
114            (key, value)
115        })
116        .collect();
117
118    let mut body = serde_json::json!({ "ref": ref_value });
119    if !inputs.is_empty() {
120        body["inputs"] = Value::Object(inputs);
121    }
122    body
123}
124
125fn input_type(input: &Value) -> WorkflowParameterType {
126    let default = input.get("default");
127    let text = |value: &Value| match value {
128        Value::String(s) => s.clone(),
129        other => other.to_string(),
130    };
131    match input
132        .get("type")
133        .and_then(Value::as_str)
134        .unwrap_or("string")
135    {
136        "boolean" => WorkflowParameterType::Boolean {
137            default: default
138                .is_some_and(|d| d.as_bool() == Some(true) || d.as_str() == Some("true")),
139        },
140        "number" => WorkflowParameterType::Number {
141            default: default.and_then(|d| d.as_f64().or_else(|| d.as_str()?.parse().ok())),
142        },
143        "choice" => WorkflowParameterType::Choice {
144            options: input
145                .get("options")
146                .and_then(Value::as_array)
147                .map(|options| options.iter().map(text).collect())
148                .unwrap_or_default(),
149            default: default.map(text),
150        },
151        _ => WorkflowParameterType::String {
152            default: default.map(text),
153        },
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn dispatch_inputs_are_read_from_the_workflow_file() {
163        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";
164        let params = dispatch_inputs(source).unwrap();
165        let summary: Vec<_> = params
166            .iter()
167            .map(|p| {
168                (
169                    p.name.as_str(),
170                    p.required,
171                    serde_json::to_value(&p.param_type).unwrap(),
172                )
173            })
174            .collect();
175        assert_eq!(
176            summary,
177            [
178                (
179                    "inputs.version",
180                    true,
181                    serde_json::json!({"type": "string", "default": null})
182                ),
183                (
184                    "inputs.dry_run",
185                    false,
186                    serde_json::json!({"type": "boolean", "default": true})
187                ),
188                (
189                    "inputs.level",
190                    false,
191                    serde_json::json!({"type": "choice", "options": ["debug", "info"], "default": "info"})
192                ),
193                (
194                    "inputs.retries",
195                    false,
196                    serde_json::json!({"type": "number", "default": 3.0})
197                ),
198            ]
199        );
200    }
201
202    #[test]
203    fn dispatch_body_keeps_a_workflow_input_named_ref_separate_from_the_branch() {
204        let body = dispatch_body(Some(serde_json::json!({
205            "ref": "release",
206            "inputs.ref": "abc123",
207            "inputs.greeting": "hi"
208        })));
209        assert_eq!(
210            body,
211            serde_json::json!({"ref": "release", "inputs": {"ref": "abc123", "greeting": "hi"}})
212        );
213        assert_eq!(dispatch_body(None), serde_json::json!({"ref": "main"}));
214    }
215
216    #[test]
217    fn workflows_without_dispatch_inputs_have_no_parameters() {
218        assert!(dispatch_inputs("on: push\njobs: {}\n").unwrap().is_empty());
219        assert!(dispatch_inputs("on: [push, workflow_dispatch]\n")
220            .unwrap()
221            .is_empty());
222        assert!(dispatch_inputs("on:\n  workflow_dispatch:\n")
223            .unwrap()
224            .is_empty());
225    }
226}