pipedash_plugin_actions/
lib.rs1mod 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}