github-actions-maintainer 0.7.0

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
use std::{
    collections::BTreeMap,
    fmt::Write as _,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Result, anyhow, bail};

use crate::{
    github::{GitHubClient, TreeEntry},
    model::{FileUpdate, UpdateChange},
};

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PullRequestOptions {
    pub repo_root: PathBuf,
    pub owner: String,
    pub repo: String,
    pub base_branch: Option<String>,
    pub branch_name: Option<String>,
    pub labels: Vec<String>,
    pub title: String,
    pub commit_message: String,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PullRequestResult {
    pub branch_name: String,
    pub number: u64,
    pub url: String,
}

#[derive(Debug, Clone)]
pub struct RemoteUpdatePublisher {
    github: GitHubClient,
}

impl RemoteUpdatePublisher {
    #[must_use]
    pub const fn new(github: GitHubClient) -> Self {
        Self { github }
    }

    pub fn publish(
        &self,
        file_updates: &[FileUpdate],
        changes: &[UpdateChange],
        options: &PullRequestOptions,
    ) -> Result<Option<PullRequestResult>> {
        if file_updates.is_empty() {
            return Ok(None);
        }

        self.github.validate_token_scopes()?;

        let base_branch = match options.base_branch.clone() {
            Some(branch) if !branch.trim().is_empty() => branch,
            _ => self.github.default_branch(&options.owner, &options.repo)?,
        };

        let base_commit_sha =
            self.github.branch_head_sha(&options.owner, &options.repo, &base_branch)?;
        let base_tree_sha =
            self.github.commit_tree_sha(&options.owner, &options.repo, &base_commit_sha)?;
        let branch_name = options
            .branch_name
            .clone()
            .filter(|name| !name.trim().is_empty())
            .unwrap_or_else(default_branch_name);

        self.github.create_branch(&options.owner, &options.repo, &branch_name, &base_commit_sha)?;

        let mut tree_entries = Vec::new();
        let grouped_updates = file_updates.iter().fold(
            BTreeMap::<&Path, &FileUpdate>::new(),
            |mut grouped, update| {
                grouped.insert(update.file.as_path(), update);
                grouped
            },
        );

        for (file, file_update) in grouped_updates {
            let relative_path = relative_repository_path(&options.repo_root, file)?;
            let blob_sha = self.github.create_blob(
                &options.owner,
                &options.repo,
                &file_update.updated_content,
            )?;

            tree_entries.push(TreeEntry { path: relative_path, sha: blob_sha });
        }

        let tree_sha = self.github.create_tree(
            &options.owner,
            &options.repo,
            &base_tree_sha,
            &tree_entries,
        )?;
        let commit_sha = self.github.create_commit(
            &options.owner,
            &options.repo,
            &options.commit_message,
            &tree_sha,
            &base_commit_sha,
        )?;
        self.github.update_branch(&options.owner, &options.repo, &branch_name, &commit_sha)?;

        let pull_request = self.github.create_pull_request(
            &options.owner,
            &options.repo,
            &options.title,
            &generate_pr_body(changes),
            &branch_name,
            &base_branch,
        )?;

        if !options.labels.is_empty() {
            self.github.add_labels(
                &options.owner,
                &options.repo,
                pull_request.number,
                &options.labels,
            )?;
        }

        Ok(Some(PullRequestResult {
            branch_name,
            number: pull_request.number,
            url: pull_request.url,
        }))
    }
}

fn default_branch_name() -> String {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_secs())
        .unwrap_or_default();
    format!("dependency-updates-{timestamp}")
}

pub(crate) fn relative_repository_path(repo_root: &Path, file: &Path) -> Result<String> {
    let relative = file
        .strip_prefix(repo_root)
        .map_err(|error| anyhow!("failed to derive repository-relative path: {error}"))?;
    let path = relative.to_string_lossy().replace('\\', "/");
    if path.is_empty() {
        bail!("repository-relative path is empty");
    }
    Ok(path)
}

fn generate_pr_body(changes: &[UpdateChange]) -> String {
    let mut body = String::from("This PR updates repository dependencies:\n\n");

    for change in changes {
        writeln!(
            body,
            "* {} `{}` in `{}`: {} -> {}",
            change.kind.label(),
            change.subject,
            change.file.display(),
            change.from_version,
            change.to_version
        )
        .expect("writing to a String cannot fail");
    }

    body.push_str("\n---\n");
    body.push_str("Generated automatically by github-actions-maintainer.\n");
    body
}

#[cfg(test)]
#[allow(clippy::significant_drop_tightening)]
mod tests {
    use std::fs;

    use mockito::{Matcher, Server};
    use tempfile::tempdir;

    use super::{PullRequestOptions, RemoteUpdatePublisher};
    use crate::{
        github::GitHubClient,
        model::{FileUpdate, UpdateChange, UpdateChangeKind},
    };

    #[test]
    fn publish_creates_branch_commit_and_pull_request() {
        let temp_dir = tempdir().expect("tempdir");
        let repo_root = temp_dir.path().to_path_buf();
        let workflow_dir = repo_root.join(".github").join("workflows");
        fs::create_dir_all(&workflow_dir).expect("create workflow directory");
        let workflow = workflow_dir.join("ci.yml");
        fs::write(&workflow, "steps:\n  - uses: actions/checkout@v4\n").expect("write workflow");

        let mut server = Server::new();
        let _user = server
            .mock("GET", "/user")
            .match_header("authorization", Matcher::Regex("^Bearer\\s+ghp_testtoken$".into()))
            .with_status(200)
            .with_header("x-oauth-scopes", "repo, workflow")
            .with_body(r#"{"login":"octocat"}"#)
            .create();
        let _repo = server
            .mock("GET", "/repos/acme/demo")
            .with_status(200)
            .with_body(r#"{"default_branch":"main"}"#)
            .create();
        let _ref = server
            .mock("GET", "/repos/acme/demo/git/ref/heads/main")
            .with_status(200)
            .with_body(r#"{"object":{"sha":"basecommitsha"}} "#)
            .create();
        let _commit = server
            .mock("GET", "/repos/acme/demo/git/commits/basecommitsha")
            .with_status(200)
            .with_body(r#"{"tree":{"sha":"basetreesha"}}"#)
            .create();
        let _create_branch = server
            .mock("POST", "/repos/acme/demo/git/refs")
            .with_status(201)
            .with_body(r#"{"ref":"refs/heads/github-actions-updates-test"}"#)
            .create();
        let _blob = server
            .mock("POST", "/repos/acme/demo/git/blobs")
            .match_body(Matcher::Regex("de0fac2e4500dabe0009e67214ff5f5447ce83dd".into()))
            .with_status(201)
            .with_body(r#"{"sha":"blobsha"}"#)
            .create();
        let _tree = server
            .mock("POST", "/repos/acme/demo/git/trees")
            .with_status(201)
            .with_body(r#"{"sha":"treesha"}"#)
            .create();
        let _create_commit = server
            .mock("POST", "/repos/acme/demo/git/commits")
            .with_status(201)
            .with_body(r#"{"sha":"commitsha"}"#)
            .create();
        let _update_ref = server
            .mock("PATCH", "/repos/acme/demo/git/refs/heads/github-actions-updates-test")
            .with_status(200)
            .with_body(r#"{"object":{"sha":"commitsha"}}"#)
            .create();
        let _pull = server
            .mock("POST", "/repos/acme/demo/pulls")
            .with_status(201)
            .with_body(r#"{"number":42,"html_url":"https://example.test/pr/42"}"#)
            .create();
        let _labels = server
            .mock("POST", "/repos/acme/demo/issues/42/labels")
            .with_status(200)
            .with_body(r"{}")
            .create();

        let github = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
            .expect("github client");
        let publisher = RemoteUpdatePublisher::new(github);
        let result = publisher
            .publish(
                &[FileUpdate {
                    file: workflow_dir.join("ci.yml"),
                    updated_content: String::from(
                        "steps:\n  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # v4\n",
                    ),
                }],
                &[UpdateChange {
                    kind: UpdateChangeKind::GitHubAction,
                    file: workflow,
                    line_number: Some(2),
                    subject: String::from("actions/checkout"),
                    from_version: String::from("v4"),
                    to_version: String::from("de0fac2e4500dabe0009e67214ff5f5447ce83dd"),
                }],
                &PullRequestOptions {
                    repo_root,
                    owner: String::from("acme"),
                    repo: String::from("demo"),
                    base_branch: None,
                    branch_name: Some(String::from("github-actions-updates-test")),
                    labels: vec![String::from("dependencies"), String::from("security")],
                    title: String::from("Update GitHub Actions dependencies"),
                    commit_message: String::from("Update GitHub Actions dependencies"),
                },
            )
            .expect("publish remote update")
            .expect("pull request result");

        assert_eq!(result.number, 42);
        assert_eq!(result.url, "https://example.test/pr/42");
    }

    #[test]
    fn publish_supports_cargo_manifest_updates() {
        let temp_dir = tempdir().expect("tempdir");
        let repo_root = temp_dir.path().to_path_buf();
        let manifest = repo_root.join("Cargo.toml");
        fs::write(
            &manifest,
            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\nreqwest = \"0.12.13\"\n",
        )
        .expect("write manifest");

        let mut server = Server::new();
        let _user = server
            .mock("GET", "/user")
            .match_header("authorization", Matcher::Regex("^Bearer\\s+ghp_testtoken$".into()))
            .with_status(200)
            .with_header("x-oauth-scopes", "repo, workflow")
            .with_body(r#"{"login":"octocat"}"#)
            .create();
        let _repo = server
            .mock("GET", "/repos/acme/demo")
            .with_status(200)
            .with_body(r#"{"default_branch":"main"}"#)
            .create();
        let _ref = server
            .mock("GET", "/repos/acme/demo/git/ref/heads/main")
            .with_status(200)
            .with_body(r#"{"object":{"sha":"basecommitsha"}} "#)
            .create();
        let _commit = server
            .mock("GET", "/repos/acme/demo/git/commits/basecommitsha")
            .with_status(200)
            .with_body(r#"{"tree":{"sha":"basetreesha"}}"#)
            .create();
        let _create_branch = server
            .mock("POST", "/repos/acme/demo/git/refs")
            .with_status(201)
            .with_body(r#"{"ref":"refs/heads/github-actions-updates-test"}"#)
            .create();
        let _blob = server
            .mock("POST", "/repos/acme/demo/git/blobs")
            .match_body(Matcher::Regex("0\\.12\\.15".into()))
            .with_status(201)
            .with_body(r#"{"sha":"blobsha"}"#)
            .create();
        let _tree = server
            .mock("POST", "/repos/acme/demo/git/trees")
            .with_status(201)
            .with_body(r#"{"sha":"treesha"}"#)
            .create();
        let _create_commit = server
            .mock("POST", "/repos/acme/demo/git/commits")
            .with_status(201)
            .with_body(r#"{"sha":"commitsha"}"#)
            .create();
        let _update_ref = server
            .mock("PATCH", "/repos/acme/demo/git/refs/heads/github-actions-updates-test")
            .with_status(200)
            .with_body(r#"{"object":{"sha":"commitsha"}}"#)
            .create();
        let _pull = server
            .mock("POST", "/repos/acme/demo/pulls")
            .with_status(201)
            .with_body(r#"{"number":43,"html_url":"https://example.test/pr/43"}"#)
            .create();

        let github = GitHubClient::new(server.url(), Some(String::from("ghp_testtoken")))
            .expect("github client");
        let publisher = RemoteUpdatePublisher::new(github);
        let result = publisher
            .publish(
                &[FileUpdate {
                    file: manifest.clone(),
                    updated_content: String::from(
                        "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies]\nreqwest = \"0.12.15\"\n",
                    ),
                }],
                &[UpdateChange {
                    kind: UpdateChangeKind::CargoDependency,
                    file: manifest,
                    line_number: None,
                    subject: String::from("reqwest"),
                    from_version: String::from("0.12.13"),
                    to_version: String::from("0.12.15"),
                }],
                &PullRequestOptions {
                    repo_root,
                    owner: String::from("acme"),
                    repo: String::from("demo"),
                    base_branch: None,
                    branch_name: Some(String::from("github-actions-updates-test")),
                    labels: Vec::new(),
                    title: String::from("Update dependencies"),
                    commit_message: String::from("Update dependencies"),
                },
            )
            .expect("publish remote update")
            .expect("pull request result");

        assert_eq!(result.number, 43);
        assert_eq!(result.url, "https://example.test/pr/43");
    }
}