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";
const GITHUB_API_VERSION: &str = "2022-11-28";
#[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>,
token_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, token_override).await?;
let client = Client::builder()
.user_agent("xbp-cli")
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))?;
validate_repo_access(&client, &owner, &repo, &token).await?;
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(
"X-GitHub-Api-Version",
HeaderValue::from_static(GITHUB_API_VERSION),
);
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 {
self.put_variable(name, value, headers.clone()).await?;
}
Ok(())
}
async fn pull(&self) -> Result<Vec<SecretVariable>, String> {
self.list().await
}
async fn list(&self) -> Result<Vec<SecretVariable>, String> {
self.list_paginated().await
}
}
impl GitHubProvider {
async fn list_paginated(&self) -> Result<Vec<SecretVariable>, String> {
let mut page = 1;
let mut results: Vec<SecretVariable> = Vec::new();
loop {
let url = format!("{}?per_page=100&page={}", self.base_url(), page);
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 chunk: Vec<SecretVariable> = body
.variables
.into_iter()
.map(|v| SecretVariable {
name: v.name,
value: v.value,
})
.collect();
let items = chunk.len();
results.extend(chunk);
if items < 100 {
break;
}
page += 1;
}
Ok(results)
}
async fn put_variable(
&self,
name: &str,
value: &str,
headers: HeaderMap,
) -> Result<(), String> {
let url = format!("{}/{}", self.base_url(), name);
let payload = serde_json::json!({
"name": name,
"value": value,
});
let response = self
.client
.put(&url)
.headers(headers)
.json(&payload)
.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(())
}
}
#[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")
}
pub async fn assert_repo_scope(client: &Client, token: &str) -> Result<(), String> {
let url = format!("{}/user", GITHUB_API_BASE);
let response = client
.get(&url)
.header(USER_AGENT, "xbp-cli")
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("GitHub token scope check failed: {}", e))?;
let scopes = response
.headers()
.get("X-OAuth-Scopes")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !scopes
.split(',')
.any(|s| s.trim().eq_ignore_ascii_case("repo"))
{
return Err("GitHub token is missing 'repo' scope; required for private repo secrets. Set a PAT with repo scope or run `gh auth login --scopes repo`.".to_string());
}
Ok(())
}
async fn validate_repo_access(
client: &Client,
owner: &str,
repo: &str,
token: &str,
) -> Result<(), String> {
assert_repo_scope(client, token).await?;
let url = format!("{}/repos/{}/{}", GITHUB_API_BASE, owner, repo);
let resp = client
.get(&url)
.header(USER_AGENT, "xbp-cli")
.header(AUTHORIZATION, format!("Bearer {}", token))
.send()
.await
.map_err(|e| format!("GitHub repo check failed: {}", e))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(format!(
"Repository {}/{} not found or token lacks access. Ensure the token has repo scope and the repo exists.",
owner, repo
));
}
resp.error_for_status()
.map_err(|e| format!("GitHub repo access check failed: {}", e))?;
Ok(())
}
async fn find_github_token(
project_root: &Path,
override_token: Option<&str>,
) -> Result<String, String> {
if let Some(tok) = pick_token(override_token) {
return Ok(tok);
}
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)
}
fn pick_token(override_token: Option<&str>) -> Option<String> {
override_token
.map(|t| t.to_string())
.or_else(|| env::var("GITHUB_TOKEN").ok())
.or_else(|| env::var("GH_TOKEN").ok())
}
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, pick_token};
#[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"));
}
#[test]
fn pick_token_prefers_override_then_envs() {
std::env::remove_var("GITHUB_TOKEN");
std::env::remove_var("GH_TOKEN");
assert_eq!(pick_token(Some("override")), Some("override".to_string()));
std::env::set_var("GITHUB_TOKEN", "env-token");
assert_eq!(pick_token(None), Some("env-token".to_string()));
std::env::remove_var("GITHUB_TOKEN");
std::env::set_var("GH_TOKEN", "gh-token");
assert_eq!(pick_token(None), Some("gh-token".to_string()));
std::env::remove_var("GH_TOKEN");
}
}