pinner 0.0.10

Secure CI/CD workflows by pinning mutable tags to immutable SHA-1 hashes. A high-performance Rust CLI that preserves YAML formatting and comments. Supports GitHub, GitLab, Bitbucket, Forgejo, and Docker image pinning.
Documentation
use crate::core::{BranchName, DependencyName, DependencyRef};
use crate::error::PinnerError;
use crate::resolver::provider::{BaseHttpClient, RemoteProvider};
use async_trait::async_trait;
use serde::Deserialize;

pub struct ReqwestGitLabProvider {
    pub base: BaseHttpClient,
}

impl ReqwestGitLabProvider {
    pub fn new(base_url: String, token: Option<String>) -> Result<Self, PinnerError> {
        Ok(Self {
            base: BaseHttpClient::new(base_url, token, "Bearer", "GITLAB_TOKEN")?,
        })
    }
}

#[async_trait]
impl RemoteProvider for ReqwestGitLabProvider {
    async fn get_commit_sha(
        &self,
        action: &DependencyName,
        tag: &str,
        _key: &str,
    ) -> Result<DependencyRef, PinnerError> {
        let project_id = action.0.replace('/', "%2F");
        let url = format!(
            "{}/api/v4/projects/{}/repository/commits/{}",
            self.base.base_url, project_id, tag
        );
        let resp = self
            .base
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| PinnerError::Api(e.to_string()))?;

        if resp.status().is_success() {
            #[derive(Deserialize)]
            struct GitLabCommit {
                id: String,
            }
            let res: GitLabCommit = resp
                .json()
                .await
                .map_err(|e| PinnerError::Api(e.to_string()))?;
            Ok(DependencyRef::from(res.id))
        } else {
            Err(self.base.handle_error(resp, action))
        }
    }

    async fn get_latest_release(
        &self,
        action: &DependencyName,
        _key: &str,
    ) -> Result<String, PinnerError> {
        let project_id = action.0.replace('/', "%2F");
        let url = format!(
            "{}/api/v4/projects/{}/releases",
            self.base.base_url, project_id
        );
        let resp = self
            .base
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| PinnerError::Api(e.to_string()))?;

        if resp.status().is_success() {
            #[derive(Deserialize)]
            struct GitLabRelease {
                tag_name: String,
            }
            let releases: Vec<GitLabRelease> = resp
                .json()
                .await
                .map_err(|e| PinnerError::Api(e.to_string()))?;
            if let Some(rel) = releases.first() {
                return Ok(rel.tag_name.clone());
            }
        }

        let branch = self.get_default_branch(action, "").await?;
        Ok(branch.0)
    }

    async fn list_tags(
        &self,
        action: &DependencyName,
        _key: &str,
    ) -> Result<Vec<String>, PinnerError> {
        let project_id = action.0.replace('/', "%2F");
        let url = format!(
            "{}/api/v4/projects/{}/repository/tags",
            self.base.base_url, project_id
        );
        let resp = self
            .base
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| PinnerError::Api(e.to_string()))?;

        if resp.status().is_success() {
            #[derive(Deserialize)]
            struct GitLabTag {
                name: String,
            }
            let tags: Vec<GitLabTag> = resp
                .json()
                .await
                .map_err(|e| PinnerError::Api(e.to_string()))?;
            Ok(tags.into_iter().map(|t| t.name).collect())
        } else {
            Ok(vec![])
        }
    }

    async fn get_default_branch(
        &self,
        action: &DependencyName,
        _key: &str,
    ) -> Result<BranchName, PinnerError> {
        let project_id = action.0.replace('/', "%2F");
        let url = format!("{}/api/v4/projects/{}", self.base.base_url, project_id);
        let resp = self
            .base
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| PinnerError::Api(e.to_string()))?;

        if resp.status().is_success() {
            #[derive(Deserialize)]
            struct GitLabProject {
                default_branch: Option<String>,
            }
            let project: GitLabProject = resp
                .json()
                .await
                .map_err(|e| PinnerError::Api(e.to_string()))?;
            Ok(BranchName(
                project.default_branch.unwrap_or_else(|| "main".to_string()),
            ))
        } else {
            Ok(BranchName("main".to_string()))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn test_gitlab_get_commit_sha() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", "/api/v4/projects/o%2Fr/repository/commits/v1")
            .with_status(200)
            .with_body(r#"{"id":"gitlabsha"}"#)
            .create_async()
            .await;

        let provider = ReqwestGitLabProvider::new(server.url(), None).unwrap();
        let sha = provider
            .get_commit_sha(&DependencyName::from("o/r"), "v1", "include")
            .await
            .unwrap();
        assert_eq!(sha.to_string(), "gitlabsha");
    }

    #[tokio::test]
    async fn test_gitlab_get_default_branch() {
        let mut server = mockito::Server::new_async().await;
        let _m = server
            .mock("GET", "/api/v4/projects/o%2Fr")
            .with_status(200)
            .with_body(r#"{"default_branch":"develop"}"#)
            .create_async()
            .await;

        let provider = ReqwestGitLabProvider::new(server.url(), None).unwrap();
        let branch = provider
            .get_default_branch(&DependencyName::from("o/r"), "")
            .await
            .unwrap();
        assert_eq!(branch.0, "develop");
    }
}