xbp 10.14.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use super::{SecretVariable, SecretsProvider};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::path::Path;
use tokio::process::Command;

const GITHUB_API_BASE: &str = "https://api.github.com";

#[derive(Debug)]
pub struct GitHubProvider {
    owner: String,
    repo: String,
    token: String,
    client: Client,
}

impl GitHubProvider {
    pub async fn new(project_root: &Path, repo_override: Option<&str>) -> Result<Self, String> {
        let (owner, repo) = if let Some(value) = repo_override {
            parse_repo_override(value)?
        } else {
            detect_repo_info(project_root).await?
        };
        let token = find_github_token().await?;
        let client = Client::builder()
            .user_agent("xbp-cli")
            .build()
            .map_err(|e| format!("Failed to build HTTP client: {}", e))?;

        Ok(Self {
            owner,
            repo,
            token,
            client,
        })
    }

    fn base_url(&self) -> String {
        format!(
            "{}/repos/{}/{}/actions/variables",
            GITHUB_API_BASE, self.owner, self.repo
        )
    }

    fn auth_headers(&self) -> Result<HeaderMap, String> {
        let mut headers = HeaderMap::new();
        let value = HeaderValue::from_str(&format!("Bearer {}", self.token))
            .map_err(|e| format!("Invalid authorization header: {}", e))?;
        headers.insert(AUTHORIZATION, value);
        headers.insert(
            ACCEPT,
            HeaderValue::from_static("application/vnd.github+json"),
        );
        headers.insert(USER_AGENT, HeaderValue::from_static("xbp-cli"));
        Ok(headers)
    }
}

#[async_trait::async_trait]
impl SecretsProvider for GitHubProvider {
    async fn push(&self, secrets: &HashMap<String, String>, _force: bool) -> Result<(), String> {
        let headers = self.auth_headers()?;
        for (name, value) in secrets {
            let url = format!("{}/{}", self.base_url(), name);
            let payload = serde_json::json!({
                "name": name,
                "value": value,
            });

            let request = self
                .client
                .put(&url)
                .headers(headers.clone())
                .json(&payload);

            let response = request
                .send()
                .await
                .map_err(|e| format!("GitHub push failed for {}: {}", name, e))?;

            response
                .error_for_status()
                .map_err(|e| format!("GitHub rejected {}: {}", name, e))?;
        }
        Ok(())
    }

    async fn pull(&self) -> Result<Vec<SecretVariable>, String> {
        self.list().await
    }

    async fn list(&self) -> Result<Vec<SecretVariable>, String> {
        let url = self.base_url();
        let headers = self.auth_headers()?;
        let response = self
            .client
            .get(&url)
            .headers(headers)
            .send()
            .await
            .map_err(|e| format!("GitHub list request failed: {}", e))?;

        let status = response.status();
        let body = response
            .json::<ListVariablesResponse>()
            .await
            .map_err(|e| format!("GitHub response parsing failed: {}", e))?;

        if !status.is_success() {
            return Err(format!(
                "GitHub API returned {} when listing variables",
                status
            ));
        }

        let variables = body
            .variables
            .into_iter()
            .map(|v| SecretVariable {
                name: v.name,
                value: v.value,
            })
            .collect();

        Ok(variables)
    }
}

#[derive(Debug, Deserialize)]
struct ListVariablesResponse {
    #[allow(dead_code)]
    total_count: usize,
    variables: Vec<VariableEntry>,
}

#[derive(Debug, Deserialize)]
struct VariableEntry {
    name: String,
    value: String,
}

fn parse_repo_override(value: &str) -> Result<(String, String), String> {
    let trimmed = value.trim();
    let normalized = trimmed.trim_end_matches(".git");

    let parts: Vec<&str> = normalized.split('/').filter(|p| !p.is_empty()).collect();
    if parts.len() != 2 {
        return Err(format!(
            "Invalid repository override '{}'; expected owner/repo",
            value
        ));
    }

    Ok((parts[0].to_string(), parts[1].to_string()))
}

async fn detect_repo_info(project_root: &Path) -> Result<(String, String), String> {
    let output = Command::new("git")
        .arg("remote")
        .arg("get-url")
        .arg("origin")
        .current_dir(project_root)
        .output()
        .await
        .map_err(|e| format!("Failed to run git: {}", e))?;

    if !output.status.success() {
        return Err(
            "Failed to detect GitHub repository. Is 'origin' remote configured?".to_string(),
        );
    }

    let url = String::from_utf8_lossy(&output.stdout)
        .trim()
        .to_string()
        .trim_end_matches(".git")
        .to_string();

    if url.is_empty() {
        return Err("Git remote URL is empty.".to_string());
    }

    let segment = if let Some(idx) = url.find("github.com/") {
        &url[(idx + "github.com/".len())..]
    } else if let Some(idx) = url.find("github.com:") {
        &url[(idx + "github.com:".len())..]
    } else {
        return Err("Origin remote is not a GitHub repository URL.".to_string());
    };

    let segment = segment.trim_end_matches('/');
    let parts: Vec<&str> = segment.split('/').collect();
    if parts.len() != 2 {
        return Err(format!("Unexpected GitHub remote format: {}", url));
    }

    Ok((parts[0].to_string(), parts[1].to_string()))
}

pub fn needs_repo_setup(error: &str) -> bool {
    error.contains("origin' remote configured")
        || error.contains("Git remote URL is empty")
        || error.contains("Origin remote is not a GitHub repository URL")
        || error.contains("Unexpected GitHub remote format")
}

async fn find_github_token() -> Result<String, String> {
    if let Ok(token) = env::var("GITHUB_TOKEN") {
        return Ok(token);
    }
    if let Ok(token) = env::var("GH_TOKEN") {
        return Ok(token);
    }

    let output = Command::new("gh")
        .arg("auth")
        .arg("token")
        .output()
        .await
        .map_err(|e| format!("Failed to run 'gh auth token': {}", e))?;

    if !output.status.success() {
        return Err(
            "GitHub CLI is not authenticated. Run 'gh auth login' or set GITHUB_TOKEN.".to_string(),
        );
    }

    let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if token.is_empty() {
        return Err("GitHub auth token command returned no value.".to_string());
    }

    Ok(token)
}

#[cfg(test)]
mod tests {
    use super::{needs_repo_setup, parse_repo_override};

    #[test]
    fn detects_repo_setup_errors() {
        assert!(needs_repo_setup(
            "Failed to detect GitHub repository. Is 'origin' remote configured?"
        ));
        assert!(needs_repo_setup("Git remote URL is empty."));
        assert!(needs_repo_setup(
            "Origin remote is not a GitHub repository URL."
        ));
        assert!(needs_repo_setup(
            "Unexpected GitHub remote format: git@example.com:foo"
        ));
    }

    #[test]
    fn ignores_non_repo_setup_errors() {
        assert!(!needs_repo_setup(
            "GitHub CLI is not authenticated. Run 'gh auth login' or set GITHUB_TOKEN."
        ));
    }

    #[test]
    fn parses_owner_repo_override() {
        let parsed =
            parse_repo_override("octocat/hello-world.git").expect("owner/repo.git should parse");

        assert_eq!(parsed.0, "octocat");
        assert_eq!(parsed.1, "hello-world");
    }

    #[test]
    fn rejects_invalid_repo_override() {
        let error = parse_repo_override("not-a-valid-repo").expect_err("invalid repo should fail");

        assert!(error.contains("expected owner/repo"));
    }
}