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}