Skip to main content

github_actions_maintainer/
remote.rs

1use std::{
2    collections::BTreeMap,
3    fmt::Write as _,
4    path::{Path, PathBuf},
5    time::{SystemTime, UNIX_EPOCH},
6};
7
8use anyhow::{Result, anyhow, bail};
9
10use crate::{
11    github::{GitHubClient, TreeEntry},
12    model::{FileUpdate, UpdateChange},
13};
14
15#[derive(Debug, Clone, Eq, PartialEq)]
16pub struct PullRequestOptions {
17    pub repo_root: PathBuf,
18    pub owner: String,
19    pub repo: String,
20    pub base_branch: Option<String>,
21    pub branch_name: Option<String>,
22    pub labels: Vec<String>,
23    pub title: String,
24    pub commit_message: String,
25}
26
27#[derive(Debug, Clone, Eq, PartialEq)]
28pub struct PullRequestResult {
29    pub branch_name: String,
30    pub number: u64,
31    pub url: String,
32}
33
34#[derive(Debug, Clone)]
35pub struct RemoteUpdatePublisher {
36    github: GitHubClient,
37}
38
39impl RemoteUpdatePublisher {
40    #[must_use]
41    pub const fn new(github: GitHubClient) -> Self {
42        Self { github }
43    }
44
45    pub fn publish(
46        &self,
47        file_updates: &[FileUpdate],
48        changes: &[UpdateChange],
49        options: &PullRequestOptions,
50    ) -> Result<Option<PullRequestResult>> {
51        if file_updates.is_empty() {
52            return Ok(None);
53        }
54
55        self.github.validate_token_scopes()?;
56
57        let base_branch = match options.base_branch.clone() {
58            Some(branch) if !branch.trim().is_empty() => branch,
59            _ => self.github.default_branch(&options.owner, &options.repo)?,
60        };
61
62        let base_commit_sha =
63            self.github.branch_head_sha(&options.owner, &options.repo, &base_branch)?;
64        let base_tree_sha =
65            self.github.commit_tree_sha(&options.owner, &options.repo, &base_commit_sha)?;
66        let branch_name = options
67            .branch_name
68            .clone()
69            .filter(|name| !name.trim().is_empty())
70            .unwrap_or_else(default_branch_name);
71
72        self.github.create_branch(&options.owner, &options.repo, &branch_name, &base_commit_sha)?;
73
74        let mut tree_entries = Vec::new();
75        let grouped_updates = file_updates.iter().fold(
76            BTreeMap::<&Path, &FileUpdate>::new(),
77            |mut grouped, update| {
78                grouped.insert(update.file.as_path(), update);
79                grouped
80            },
81        );
82
83        for (file, file_update) in grouped_updates {
84            let relative_path = relative_repository_path(&options.repo_root, file)?;
85            let blob_sha = self.github.create_blob(
86                &options.owner,
87                &options.repo,
88                &file_update.updated_content,
89            )?;
90
91            tree_entries.push(TreeEntry { path: relative_path, sha: blob_sha });
92        }
93
94        let tree_sha = self.github.create_tree(
95            &options.owner,
96            &options.repo,
97            &base_tree_sha,
98            &tree_entries,
99        )?;
100        let commit_sha = self.github.create_commit(
101            &options.owner,
102            &options.repo,
103            &options.commit_message,
104            &tree_sha,
105            &base_commit_sha,
106        )?;
107        self.github.update_branch(&options.owner, &options.repo, &branch_name, &commit_sha)?;
108
109        let pull_request = self.github.create_pull_request(
110            &options.owner,
111            &options.repo,
112            &options.title,
113            &generate_pr_body(changes),
114            &branch_name,
115            &base_branch,
116        )?;
117
118        if !options.labels.is_empty() {
119            self.github.add_labels(
120                &options.owner,
121                &options.repo,
122                pull_request.number,
123                &options.labels,
124            )?;
125        }
126
127        Ok(Some(PullRequestResult {
128            branch_name,
129            number: pull_request.number,
130            url: pull_request.url,
131        }))
132    }
133}
134
135fn default_branch_name() -> String {
136    let timestamp = SystemTime::now()
137        .duration_since(UNIX_EPOCH)
138        .map(|duration| duration.as_secs())
139        .unwrap_or_default();
140    format!("dependency-updates-{timestamp}")
141}
142
143pub(crate) fn relative_repository_path(repo_root: &Path, file: &Path) -> Result<String> {
144    let relative = file
145        .strip_prefix(repo_root)
146        .map_err(|error| anyhow!("failed to derive repository-relative path: {error}"))?;
147    let path = relative.to_string_lossy().replace('\\', "/");
148    if path.is_empty() {
149        bail!("repository-relative path is empty");
150    }
151    Ok(path)
152}
153
154fn generate_pr_body(changes: &[UpdateChange]) -> String {
155    let mut body = String::from("This PR updates repository dependencies:\n\n");
156
157    for change in changes {
158        writeln!(
159            body,
160            "* {} `{}` in `{}`: {} -> {}",
161            change.kind.label(),
162            change.subject,
163            change.file.display(),
164            change.from_version,
165            change.to_version
166        )
167        .expect("writing to a String cannot fail");
168    }
169
170    body.push_str("\n---\n");
171    body.push_str("Generated automatically by github-actions-maintainer.\n");
172    body
173}
174
175#[cfg(test)]
176#[allow(clippy::significant_drop_tightening)]
177mod tests {
178    use std::fs;
179
180    use mockito::{Matcher, Server};
181    use tempfile::tempdir;
182
183    use super::{PullRequestOptions, RemoteUpdatePublisher};
184    use crate::{
185        github::GitHubClient,
186        model::{FileUpdate, UpdateChange, UpdateChangeKind},
187    };
188
189    #[test]
190    fn publish_creates_branch_commit_and_pull_request() {
191        let temp_dir = tempdir().expect("tempdir");
192        let repo_root = temp_dir.path().to_path_buf();
193        let workflow_dir = repo_root.join(".github").join("workflows");
194        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
195        let workflow = workflow_dir.join("ci.yml");
196        fs::write(&workflow, "steps:\n  - uses: actions/checkout@v4\n").expect("write workflow");
197
198        let mut server = Server::new();
199        let _user = server
200            .mock("GET", "/user")
201            .match_header("authorization", Matcher::Regex("^Bearer\\s+ghp_testtoken$".into()))
202            .with_status(200)
203            .with_header("x-oauth-scopes", "repo, workflow")
204            .with_body(r#"{"login":"octocat"}"#)
205            .create();
206        let _repo = server
207            .mock("GET", "/repos/acme/demo")
208            .with_status(200)
209            .with_body(r#"{"default_branch":"main"}"#)
210            .create();
211        let _ref = server
212            .mock("GET", "/repos/acme/demo/git/ref/heads/main")
213            .with_status(200)
214            .with_body(r#"{"object":{"sha":"basecommitsha"}} "#)
215            .create();
216        let _commit = server
217            .mock("GET", "/repos/acme/demo/git/commits/basecommitsha")
218            .with_status(200)
219            .with_body(r#"{"tree":{"sha":"basetreesha"}}"#)
220            .create();
221        let _create_branch = server
222            .mock("POST", "/repos/acme/demo/git/refs")
223            .with_status(201)
224            .with_body(r#"{"ref":"refs/heads/github-actions-updates-test"}"#)
225            .create();
226        let _blob = server
227            .mock("POST", "/repos/acme/demo/git/blobs")
228            .match_body(Matcher::Regex("de0fac2e4500dabe0009e67214ff5f5447ce83dd".into()))
229            .with_status(201)
230            .with_body(r#"{"sha":"blobsha"}"#)
231            .create();
232        let _tree = server
233            .mock("POST", "/repos/acme/demo/git/trees")
234            .with_status(201)
235            .with_body(r#"{"sha":"treesha"}"#)
236            .create();
237        let _create_commit = server
238            .mock("POST", "/repos/acme/demo/git/commits")
239            .with_status(201)
240            .with_body(r#"{"sha":"commitsha"}"#)
241            .create();
242        let _update_ref = server
243            .mock("PATCH", "/repos/acme/demo/git/refs/heads/github-actions-updates-test")
244            .with_status(200)
245            .with_body(r#"{"object":{"sha":"commitsha"}}"#)
246            .create();
247        let _pull = server
248            .mock("POST", "/repos/acme/demo/pulls")
249            .with_status(201)
250            .with_body(r#"{"number":42,"html_url":"https://example.test/pr/42"}"#)
251            .create();
252        let _labels = server
253            .mock("POST", "/repos/acme/demo/issues/42/labels")
254            .with_status(200)
255            .with_body(r"{}")
256            .create();
257
258        let github = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
259            .expect("github client");
260        let publisher = RemoteUpdatePublisher::new(github);
261        let result = publisher
262            .publish(
263                &[FileUpdate {
264                    file: workflow_dir.join("ci.yml"),
265                    updated_content: String::from(
266                        "steps:\n  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v4\n",
267                    ),
268                }],
269                &[UpdateChange {
270                    kind: UpdateChangeKind::GitHubAction,
271                    file: workflow,
272                    line_number: Some(2),
273                    subject: String::from("actions/checkout"),
274                    from_version: String::from("v4"),
275                    to_version: String::from("de0fac2e4500dabe0009e67214ff5f5447ce83dd"),
276                }],
277                &PullRequestOptions {
278                    repo_root,
279                    owner: String::from("acme"),
280                    repo: String::from("demo"),
281                    base_branch: None,
282                    branch_name: Some(String::from("github-actions-updates-test")),
283                    labels: vec![String::from("dependencies"), String::from("security")],
284                    title: String::from("Update GitHub Actions dependencies"),
285                    commit_message: String::from("Update GitHub Actions dependencies"),
286                },
287            )
288            .expect("publish remote update")
289            .expect("pull request result");
290
291        assert_eq!(result.number, 42);
292        assert_eq!(result.url, "https://example.test/pr/42");
293    }
294
295    #[test]
296    fn publish_supports_cargo_manifest_updates() {
297        let temp_dir = tempdir().expect("tempdir");
298        let repo_root = temp_dir.path().to_path_buf();
299        let manifest = repo_root.join("Cargo.toml");
300        fs::write(
301            &manifest,
302            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\nreqwest = \"0.12.13\"\n",
303        )
304        .expect("write manifest");
305
306        let mut server = Server::new();
307        let _user = server
308            .mock("GET", "/user")
309            .match_header("authorization", Matcher::Regex("^Bearer\\s+ghp_testtoken$".into()))
310            .with_status(200)
311            .with_header("x-oauth-scopes", "repo, workflow")
312            .with_body(r#"{"login":"octocat"}"#)
313            .create();
314        let _repo = server
315            .mock("GET", "/repos/acme/demo")
316            .with_status(200)
317            .with_body(r#"{"default_branch":"main"}"#)
318            .create();
319        let _ref = server
320            .mock("GET", "/repos/acme/demo/git/ref/heads/main")
321            .with_status(200)
322            .with_body(r#"{"object":{"sha":"basecommitsha"}} "#)
323            .create();
324        let _commit = server
325            .mock("GET", "/repos/acme/demo/git/commits/basecommitsha")
326            .with_status(200)
327            .with_body(r#"{"tree":{"sha":"basetreesha"}}"#)
328            .create();
329        let _create_branch = server
330            .mock("POST", "/repos/acme/demo/git/refs")
331            .with_status(201)
332            .with_body(r#"{"ref":"refs/heads/github-actions-updates-test"}"#)
333            .create();
334        let _blob = server
335            .mock("POST", "/repos/acme/demo/git/blobs")
336            .match_body(Matcher::Regex("0\\.12\\.15".into()))
337            .with_status(201)
338            .with_body(r#"{"sha":"blobsha"}"#)
339            .create();
340        let _tree = server
341            .mock("POST", "/repos/acme/demo/git/trees")
342            .with_status(201)
343            .with_body(r#"{"sha":"treesha"}"#)
344            .create();
345        let _create_commit = server
346            .mock("POST", "/repos/acme/demo/git/commits")
347            .with_status(201)
348            .with_body(r#"{"sha":"commitsha"}"#)
349            .create();
350        let _update_ref = server
351            .mock("PATCH", "/repos/acme/demo/git/refs/heads/github-actions-updates-test")
352            .with_status(200)
353            .with_body(r#"{"object":{"sha":"commitsha"}}"#)
354            .create();
355        let _pull = server
356            .mock("POST", "/repos/acme/demo/pulls")
357            .with_status(201)
358            .with_body(r#"{"number":43,"html_url":"https://example.test/pr/43"}"#)
359            .create();
360
361        let github = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
362            .expect("github client");
363        let publisher = RemoteUpdatePublisher::new(github);
364        let result = publisher
365            .publish(
366                &[FileUpdate {
367                    file: manifest.clone(),
368                    updated_content: String::from(
369                        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\nreqwest = \"0.12.15\"\n",
370                    ),
371                }],
372                &[UpdateChange {
373                    kind: UpdateChangeKind::CargoDependency,
374                    file: manifest,
375                    line_number: None,
376                    subject: String::from("reqwest"),
377                    from_version: String::from("0.12.13"),
378                    to_version: String::from("0.12.15"),
379                }],
380                &PullRequestOptions {
381                    repo_root,
382                    owner: String::from("acme"),
383                    repo: String::from("demo"),
384                    base_branch: None,
385                    branch_name: Some(String::from("github-actions-updates-test")),
386                    labels: Vec::new(),
387                    title: String::from("Update dependencies"),
388                    commit_message: String::from("Update dependencies"),
389                },
390            )
391            .expect("publish remote update")
392            .expect("pull request result");
393
394        assert_eq!(result.number, 43);
395        assert_eq!(result.url, "https://example.test/pr/43");
396    }
397}