Skip to main content

devops_models/models/
github_actions.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5// ═══════════════════════════════════════════════════════════════════════════
6// GitHub Actions Workflow
7// ═══════════════════════════════════════════════════════════════════════════
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct GHStep {
11    #[serde(default)]
12    pub name: Option<String>,
13    #[serde(default)]
14    pub uses: Option<String>,
15    #[serde(default)]
16    pub run: Option<String>,
17    #[serde(default, rename = "with")]
18    pub with_inputs: Option<serde_json::Value>,
19    #[serde(default)]
20    pub env: Option<serde_json::Value>,
21    #[serde(default, rename = "if")]
22    pub condition: Option<String>,
23    #[serde(default)]
24    pub id: Option<String>,
25    #[serde(default, rename = "working-directory")]
26    pub working_directory: Option<String>,
27    #[serde(default)]
28    pub shell: Option<String>,
29    #[serde(default, rename = "continue-on-error")]
30    pub continue_on_error: Option<serde_json::Value>,
31    #[serde(default, rename = "timeout-minutes")]
32    pub timeout_minutes: Option<u32>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct GHContainer {
37    pub image: String,
38    #[serde(default)]
39    pub credentials: Option<serde_json::Value>,
40    #[serde(default)]
41    pub env: Option<serde_json::Value>,
42    #[serde(default)]
43    pub ports: Vec<serde_json::Value>,
44    #[serde(default)]
45    pub volumes: Vec<String>,
46    #[serde(default)]
47    pub options: Option<String>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct GHStrategy {
52    #[serde(default)]
53    pub matrix: Option<serde_json::Value>,
54    #[serde(default, rename = "fail-fast")]
55    pub fail_fast: Option<bool>,
56    #[serde(default, rename = "max-parallel")]
57    pub max_parallel: Option<u32>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct GHJob {
62    #[serde(default, rename = "runs-on")]
63    pub runs_on: Option<serde_json::Value>,
64    #[serde(default)]
65    pub steps: Vec<GHStep>,
66    #[serde(default)]
67    pub needs: Option<serde_json::Value>,
68    #[serde(default)]
69    pub strategy: Option<GHStrategy>,
70    #[serde(default)]
71    pub env: Option<serde_json::Value>,
72    #[serde(default)]
73    pub name: Option<String>,
74    #[serde(default, rename = "if")]
75    pub condition: Option<String>,
76    #[serde(default, rename = "timeout-minutes")]
77    pub timeout_minutes: Option<u32>,
78    #[serde(default, rename = "continue-on-error")]
79    pub continue_on_error: Option<serde_json::Value>,
80    #[serde(default)]
81    pub container: Option<serde_json::Value>,
82    #[serde(default)]
83    pub services: Option<serde_json::Value>,
84    #[serde(default)]
85    pub permissions: Option<serde_json::Value>,
86    #[serde(default)]
87    pub outputs: Option<serde_json::Value>,
88    #[serde(default)]
89    pub concurrency: Option<serde_json::Value>,
90    #[serde(default)]
91    pub defaults: Option<serde_json::Value>,
92    #[serde(default)]
93    pub uses: Option<String>,
94    #[serde(default, rename = "with")]
95    pub with_inputs: Option<serde_json::Value>,
96    #[serde(default)]
97    pub secrets: Option<serde_json::Value>,
98    #[serde(default, rename = "environment")]
99    pub environment: Option<serde_json::Value>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct GitHubActions {
104    #[serde(default)]
105    pub name: Option<String>,
106    #[serde(default)]
107    pub on: Option<serde_json::Value>,
108    #[serde(default)]
109    pub env: Option<serde_json::Value>,
110    #[serde(default)]
111    pub permissions: Option<serde_json::Value>,
112    #[serde(default)]
113    pub concurrency: Option<serde_json::Value>,
114    #[serde(default)]
115    pub defaults: Option<serde_json::Value>,
116    /// Jobs map — the core of the workflow
117    #[serde(default)]
118    pub jobs: HashMap<String, GHJob>,
119}
120
121impl GitHubActions {
122    pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
123        serde_json::from_value(data.clone())
124            .map_err(|e| format!("Failed to parse GitHub Actions workflow: {e}"))
125    }
126}
127
128impl ConfigValidator for GitHubActions {
129    fn yaml_type(&self) -> YamlType { YamlType::GitHubActions }
130
131    fn validate_structure(&self) -> Vec<Diagnostic> {
132        let mut diags = Vec::new();
133        if self.on.is_none() {
134            diags.push(Diagnostic {
135                severity: Severity::Error,
136                message: "'on' trigger is required".into(),
137                path: Some("on".into()),
138            });
139        }
140        if self.jobs.is_empty() {
141            diags.push(Diagnostic {
142                severity: Severity::Error,
143                message: "No jobs defined".into(),
144                path: Some("jobs".into()),
145            });
146        }
147        for (name, job) in &self.jobs {
148            if job.runs_on.is_none() && job.uses.is_none() {
149                diags.push(Diagnostic {
150                    severity: Severity::Error,
151                    message: format!("Job '{}': must specify 'runs-on' or 'uses' (reusable workflow)", name),
152                    path: Some(format!("jobs > {}", name)),
153                });
154            }
155            if job.steps.is_empty() && job.uses.is_none() {
156                diags.push(Diagnostic {
157                    severity: Severity::Error,
158                    message: format!("Job '{}': no steps defined", name),
159                    path: Some(format!("jobs > {} > steps", name)),
160                });
161            }
162        }
163        diags
164    }
165
166    fn validate_semantics(&self) -> Vec<Diagnostic> {
167        let mut diags = Vec::new();
168        let job_names: Vec<&String> = self.jobs.keys().collect();
169
170        for (name, job) in &self.jobs {
171            // Check needs references
172            if let Some(needs) = &job.needs {
173                let needed: Vec<String> = match needs {
174                    serde_json::Value::String(s) => vec![s.clone()],
175                    serde_json::Value::Array(arr) => {
176                        arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()
177                    }
178                    _ => vec![],
179                };
180                for dep in &needed {
181                    if !job_names.contains(&dep) {
182                        diags.push(Diagnostic {
183                            severity: Severity::Warning,
184                            message: format!("Job '{}': needs '{}' which is not defined in this workflow", name, dep),
185                            path: Some(format!("jobs > {} > needs", name)),
186                        });
187                    }
188                }
189            }
190
191            // No timeout
192            if job.timeout_minutes.is_none() {
193                diags.push(Diagnostic {
194                    severity: Severity::Info,
195                    message: format!("Job '{}': no timeout-minutes — defaults to 360 (6 hours)", name),
196                    path: Some(format!("jobs > {} > timeout-minutes", name)),
197                });
198            }
199
200            // Step analysis
201            let has_checkout = job.steps.iter().any(|s| {
202                s.uses.as_deref().map(|u| u.starts_with("actions/checkout")).unwrap_or(false)
203            });
204
205            let has_run = job.steps.iter().any(|s| s.run.is_some());
206
207            if has_run && !has_checkout && job.uses.is_none() {
208                diags.push(Diagnostic {
209                    severity: Severity::Info,
210                    message: format!("Job '{}': has 'run' steps but no actions/checkout — repo code won't be available", name),
211                    path: Some(format!("jobs > {} > steps", name)),
212                });
213            }
214
215            for (i, step) in job.steps.iter().enumerate() {
216                // Action version pinning
217                if let Some(uses) = &step.uses
218                    && (uses.contains("@master") || uses.contains("@main")) {
219                        diags.push(Diagnostic {
220                            severity: Severity::Warning,
221                            message: format!("Job '{}' step[{}]: '{}' uses branch ref instead of tag/SHA — may break unexpectedly", name, i, uses),
222                            path: Some(format!("jobs > {} > steps > {} > uses", name, i)),
223                        });
224                    }
225
226                // Step must have uses or run
227                if step.uses.is_none() && step.run.is_none() {
228                    diags.push(Diagnostic {
229                        severity: Severity::Warning,
230                        message: format!("Job '{}' step[{}]: has neither 'uses' nor 'run'", name, i),
231                        path: Some(format!("jobs > {} > steps > {}", name, i)),
232                    });
233                }
234            }
235        }
236
237        if self.name.is_none() {
238            diags.push(Diagnostic {
239                severity: Severity::Info,
240                message: "No workflow 'name' — file name will be used as identifier".into(),
241                path: Some("name".into()),
242            });
243        }
244
245        diags
246    }
247}