Skip to main content

github_actions_maintainer/
update.rs

1use std::{collections::BTreeMap, fs, path::PathBuf};
2
3use anyhow::{Context, Result};
4
5use crate::{
6    github::GitHubClient,
7    model::{FileUpdate, PinChange, UpdateChange, UpdateChangeKind},
8    workflow::{apply_changes_to_content, discover_workflow_files, scan_workflow},
9};
10
11#[derive(Debug, Clone, Copy, Eq, PartialEq)]
12pub enum UpdateMode {
13    Apply,
14    DryRun,
15    Status,
16}
17
18#[derive(Debug, Clone, Eq, PartialEq)]
19pub struct UpdateOptions {
20    pub repo_root: PathBuf,
21    pub workflows_path: PathBuf,
22    pub mode: UpdateMode,
23}
24
25#[derive(Debug, Clone, Eq, PartialEq)]
26pub struct VersionEntry {
27    pub file: PathBuf,
28    pub line_number: usize,
29    pub action_slug: String,
30    pub pinned: bool,
31    pub current_version: String,
32    pub current_sha: Option<String>,
33    pub latest_version: String,
34    pub latest_sha: String,
35    pub update_needed: bool,
36}
37
38#[derive(Debug, Clone, Eq, PartialEq)]
39pub struct UpdateReport {
40    pub workflow_files: usize,
41    pub references_scanned: usize,
42    pub already_pinned: usize,
43    pub entries: Vec<VersionEntry>,
44    pub changes: Vec<UpdateChange>,
45    pub file_updates: Vec<FileUpdate>,
46}
47
48impl UpdateReport {
49    #[must_use]
50    pub fn changed_files(&self) -> usize {
51        let mut files =
52            self.file_updates.iter().map(|update| update.file.as_path()).collect::<Vec<_>>();
53        files.sort();
54        files.dedup();
55        files.len()
56    }
57}
58
59#[derive(Debug, Clone)]
60pub struct WorkflowUpdater {
61    github: GitHubClient,
62}
63
64impl WorkflowUpdater {
65    #[must_use]
66    pub const fn new(github: GitHubClient) -> Self {
67        Self { github }
68    }
69
70    pub fn update(&self, options: &UpdateOptions) -> Result<UpdateReport> {
71        let repo_root = options.repo_root.canonicalize().with_context(|| {
72            format!("failed to resolve repository root '{}'", options.repo_root.display())
73        })?;
74        let workflow_files = discover_workflow_files(&repo_root, &options.workflows_path)?;
75
76        let mut references_scanned = 0usize;
77        let mut already_pinned = 0usize;
78        let mut entries = Vec::new();
79        let mut pin_changes = Vec::new();
80
81        for workflow_file in &workflow_files {
82            for action in scan_workflow(workflow_file)? {
83                references_scanned += 1;
84                if action.is_pinned() {
85                    already_pinned += 1;
86                }
87
88                let latest = self.github.latest_reference(&action.owner, &action.repository)?;
89                let current_version = action.logical_version();
90                let current_sha = action.is_pinned().then(|| action.version.clone());
91                let update_needed = !action.is_pinned()
92                    || current_sha.as_deref() != Some(latest.sha.as_str())
93                    || current_version != latest.version;
94
95                entries.push(VersionEntry {
96                    file: action.file.clone(),
97                    line_number: action.line_number,
98                    action_slug: action.action_slug.clone(),
99                    pinned: action.is_pinned(),
100                    current_version: current_version.clone(),
101                    current_sha,
102                    latest_version: latest.version.clone(),
103                    latest_sha: latest.sha.clone(),
104                    update_needed,
105                });
106
107                if update_needed && options.mode != UpdateMode::Status {
108                    pin_changes.push(PinChange {
109                        file: action.file.clone(),
110                        line_number: action.line_number,
111                        action_slug: action.action_slug.clone(),
112                        from_version: current_version,
113                        to_sha: latest.sha.clone(),
114                        original_line: action.original_line.clone(),
115                        rewritten_line: action.rendered_line(&latest.sha, &latest.version),
116                    });
117                }
118            }
119        }
120
121        let file_updates = build_file_updates(&pin_changes)?;
122        if options.mode == UpdateMode::Apply && !file_updates.is_empty() {
123            write_file_updates(&file_updates)?;
124        }
125
126        let changes = pin_changes
127            .into_iter()
128            .map(|change| UpdateChange {
129                kind: UpdateChangeKind::GitHubAction,
130                file: change.file,
131                line_number: Some(change.line_number),
132                subject: change.action_slug,
133                from_version: change.from_version,
134                to_version: change.to_sha,
135            })
136            .collect();
137
138        Ok(UpdateReport {
139            workflow_files: workflow_files.len(),
140            references_scanned,
141            already_pinned,
142            entries,
143            changes,
144            file_updates,
145        })
146    }
147}
148
149fn build_file_updates(changes: &[PinChange]) -> Result<Vec<FileUpdate>> {
150    let grouped_changes = changes.iter().fold(
151        BTreeMap::<&std::path::Path, Vec<&PinChange>>::new(),
152        |mut grouped, change| {
153            grouped.entry(change.file.as_path()).or_default().push(change);
154            grouped
155        },
156    );
157
158    let mut file_updates = Vec::new();
159    for (file, file_changes) in grouped_changes {
160        let content = fs::read_to_string(file)
161            .with_context(|| format!("failed to read workflow '{}'", file.display()))?;
162        let updated_content = apply_changes_to_content(&content, &file_changes)?;
163        file_updates.push(FileUpdate { file: file.to_path_buf(), updated_content });
164    }
165
166    Ok(file_updates)
167}
168
169fn write_file_updates(file_updates: &[FileUpdate]) -> Result<()> {
170    for update in file_updates {
171        fs::write(&update.file, &update.updated_content)
172            .with_context(|| format!("failed to write workflow '{}'", update.file.display()))?;
173    }
174
175    Ok(())
176}
177
178#[cfg(test)]
179#[allow(clippy::significant_drop_tightening)]
180mod tests {
181    use std::fs;
182
183    use mockito::{Matcher, Server};
184    use tempfile::tempdir;
185
186    use crate::github::GitHubClient;
187
188    #[test]
189    fn discovers_latest_release_and_falls_back_to_tags() {
190        let mut server = Server::new();
191        let _latest_release = server
192            .mock("GET", "/repos/actions/checkout/releases/latest")
193            .with_status(200)
194            .with_body(r#"{"tag_name":"v5"}"#)
195            .create();
196        let _latest_release_commit = server
197            .mock("GET", "/repos/actions/checkout/commits/v5")
198            .with_status(200)
199            .with_body(r#"{"sha":"1111111111111111111111111111111111111111"}"#)
200            .create();
201        let _missing_release = server
202            .mock("GET", "/repos/custom/action/releases/latest")
203            .with_status(404)
204            .with_body(r#"{"message":"Not Found"}"#)
205            .create();
206        let _tag_fallback = server
207            .mock("GET", "/repos/custom/action/tags")
208            .match_query(Matcher::UrlEncoded("per_page".into(), "1".into()))
209            .with_status(200)
210            .with_body(r#"[{"name":"v1.2.3","commit":{"sha":"2222222222222222222222222222222222222222"}}]"#)
211            .create();
212
213        let client = GitHubClient::new(server.url(), None).expect("github client");
214
215        let release =
216            client.latest_reference("actions", "checkout").expect("latest release reference");
217        assert_eq!(release.version, "v5");
218        assert_eq!(release.sha, "1111111111111111111111111111111111111111");
219
220        let fallback = client.latest_reference("custom", "action").expect("latest tag fallback");
221        assert_eq!(fallback.version, "v1.2.3");
222        assert_eq!(fallback.sha, "2222222222222222222222222222222222222222");
223    }
224
225    #[test]
226    fn update_dry_run_reports_latest_versions_without_rewriting() {
227        let temp_dir = tempdir().expect("tempdir");
228        let workflow_dir = temp_dir.path().join(".github").join("workflows");
229        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
230        let workflow = workflow_dir.join("ci.yml");
231        fs::write(
232            &workflow,
233            "steps:\n  - uses: actions/checkout@v4\n  - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7  # v5.0.4\n",
234        )
235        .expect("write workflow");
236
237        let mut server = Server::new();
238        let _checkout_release = server
239            .mock("GET", "/repos/actions/checkout/releases/latest")
240            .with_status(200)
241            .with_body(r#"{"tag_name":"v5"}"#)
242            .create();
243        let _checkout_commit = server
244            .mock("GET", "/repos/actions/checkout/commits/v5")
245            .with_status(200)
246            .with_body(r#"{"sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd"}"#)
247            .create();
248        let _cache_release = server
249            .mock("GET", "/repos/actions/cache/releases/latest")
250            .with_status(200)
251            .with_body(r#"{"tag_name":"v5.0.5"}"#)
252            .create();
253        let _cache_commit = server
254            .mock("GET", "/repos/actions/cache/commits/v5.0.5")
255            .with_status(200)
256            .with_body(r#"{"sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#)
257            .create();
258
259        let github = GitHubClient::new(server.url(), None).expect("github client");
260        let planner = crate::update::WorkflowUpdater::new(github);
261        let report = planner
262            .update(&crate::update::UpdateOptions {
263                repo_root: temp_dir.path().to_path_buf(),
264                workflows_path: ".github/workflows".into(),
265                mode: crate::update::UpdateMode::DryRun,
266            })
267            .expect("update dry run");
268
269        assert_eq!(report.entries.len(), 2);
270        assert_eq!(report.changes.len(), 2);
271        assert_eq!(report.entries[0].latest_version, "v5");
272        assert_eq!(report.entries[0].current_version, "v4");
273        assert!(report.entries[0].update_needed);
274
275        let content = fs::read_to_string(&workflow).expect("read workflow");
276        assert!(content.contains("actions/checkout@v4"));
277    }
278
279    #[test]
280    fn status_tracks_current_and_latest_versions() {
281        let temp_dir = tempdir().expect("tempdir");
282        let workflow_dir = temp_dir.path().join(".github").join("workflows");
283        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
284        let workflow = workflow_dir.join("release.yml");
285        fs::write(
286            &workflow,
287            "steps:\n  - uses: github/codeql-action/init@3d8036cf7fe7433e4a725cf513a6ea56c7fd0f14  # v2.25.0 | code scanning\n",
288        )
289        .expect("write workflow");
290
291        let mut server = Server::new();
292        let _release = server
293            .mock("GET", "/repos/github/codeql-action/releases/latest")
294            .with_status(200)
295            .with_body(r#"{"tag_name":"v2.26.0"}"#)
296            .create();
297        let _commit = server
298            .mock("GET", "/repos/github/codeql-action/commits/v2.26.0")
299            .with_status(200)
300            .with_body(r#"{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}"#)
301            .create();
302
303        let github = GitHubClient::new(server.url(), None).expect("github client");
304        let planner = crate::update::WorkflowUpdater::new(github);
305        let report = planner
306            .update(&crate::update::UpdateOptions {
307                repo_root: temp_dir.path().to_path_buf(),
308                workflows_path: ".github/workflows".into(),
309                mode: crate::update::UpdateMode::Status,
310            })
311            .expect("status report");
312
313        assert_eq!(report.entries.len(), 1);
314        assert_eq!(report.entries[0].current_version, "v2.25.0");
315        assert_eq!(report.entries[0].latest_version, "v2.26.0");
316        assert!(report.entries[0].pinned);
317        assert!(report.entries[0].update_needed);
318        assert!(report.changes.is_empty());
319    }
320}