xbp 10.15.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
use super::{SecretVariable, SecretsProvider};
#[cfg(target_os = "linux")]
use crate::utils::command_exists;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
use std::env;
use std::fs;
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(project_root).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(project_root: &Path) -> 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 = match Command::new("gh").arg("auth").arg("token").output().await {
        Ok(o) => o,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            try_install_gh_or_generate_script(project_root).await?;
            let out = Command::new("gh")
                .arg("auth")
                .arg("token")
                .output()
                .await
                .map_err(|e2| format!("Failed to run 'gh auth token' after install: {}", e2))?;
            if !out.status.success() {
                return Err(
                    "GitHub CLI installed but not authenticated. Run 'gh auth login' or set GITHUB_TOKEN.".to_string(),
                );
            }
            let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if token.is_empty() {
                return Err("GitHub auth token command returned no value.".to_string());
            }
            return Ok(token);
        }
        Err(e) => return Err(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)
}

/// On Linux: try to install gh via apt or snap. Otherwise generate install-gh.sh.
async fn try_install_gh_or_generate_script(project_root: &Path) -> Result<(), String> {
    #[cfg(target_os = "linux")]
    {
        if command_exists("sudo") {
            if command_exists("apt") || command_exists("apt-get") {
                let apt = if command_exists("apt") { "apt" } else { "apt-get" };
                let status = Command::new("sudo")
                    .args([apt, "install", "-y", "gh"])
                    .status()
                    .await
                    .map_err(|e| format!("Failed to run sudo {} install: {}", apt, e))?;
                if status.success() {
                    return Ok(());
                }
            }
            if command_exists("snap") {
                let status = Command::new("sudo")
                    .args(["snap", "install", "gh", "--classic"])
                    .status()
                    .await
                    .map_err(|e| format!("Failed to run sudo snap install: {}", e))?;
                if status.success() {
                    return Ok(());
                }
            }
        }
    }

    let script_path = project_root.join("install-gh.sh");
    let script = r#"#!/bin/sh
# Install GitHub CLI (gh) - generated by xbp secrets
set -e
if command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update && sudo apt-get install -y gh
elif command -v apt >/dev/null 2>&1; then
  sudo apt update && sudo apt install -y gh
elif command -v snap >/dev/null 2>&1; then
  sudo snap install gh --classic
else
  echo "Unsupported: install gh manually from https://cli.github.com/"
  exit 1
fi
echo "Installed. Run: gh auth login"
"#;
    fs::write(&script_path, script)
        .map_err(|e| format!("Failed to write {}: {}", script_path.display(), e))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = fs::metadata(&script_path)
            .map_err(|e| format!("Failed to stat {}: {}", script_path.display(), e))?
            .permissions();
        perms.set_mode(0o755);
        fs::set_permissions(&script_path, perms)
            .map_err(|e| format!("Failed to chmod {}: {}", script_path.display(), e))?;
    }
    Err(format!(
        "GitHub CLI (gh) is not installed. Run: sh {} (or set GITHUB_TOKEN)",
        script_path.display()
    ))
}

#[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"));
    }
}