use super::http::extract_github_error_message;
use super::models::ProviderErrorResponse;
use futures::StreamExt;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
const GITHUB_API_BASE: &str = "https://api.github.com";
const GITHUB_API_VERSION: &str = "2022-11-28";
const VARIABLE_LIST_PAGE_SIZE: usize = 30;
const VARIABLE_WRITE_CONCURRENCY: usize = 8;
const GITHUB_ENV_PREFIX: &str = "GITHUB_";
const GITHUB_STORAGE_PREFIX: &str = "_GITHUB_";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubSecretVariable {
pub name: String,
pub value: String,
}
#[derive(Debug)]
pub struct GitHubEnvironmentClient {
owner: String,
repo: String,
environment: String,
token: String,
client: Client,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VariableWriteMode {
Create,
Update,
}
impl GitHubEnvironmentClient {
pub fn new(
owner: impl Into<String>,
repo: impl Into<String>,
environment: impl Into<String>,
token: impl Into<String>,
) -> Result<Self, String> {
let client = Client::builder()
.user_agent("xbp")
.build()
.map_err(|error| format!("Failed to build GitHub client: {}", error))?;
Ok(Self {
owner: owner.into(),
repo: repo.into(),
environment: environment.into(),
token: token.into(),
client,
})
}
pub async fn validate_repo_access(&self) -> Result<(), String> {
let response = self
.client
.get(format!(
"{}/repos/{}/{}",
GITHUB_API_BASE, self.owner, self.repo
))
.headers(self.auth_headers()?)
.send()
.await
.map_err(|error| format!("GitHub repository check failed: {}", error))?;
if response.status().is_success() {
return Ok(());
}
let status = response.status();
let body = response.text().await.unwrap_or_default();
let detail =
extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
Err(format!(
"GitHub repository `{}/{}` is not accessible: {}",
self.owner, self.repo, detail
))
}
pub async fn list(&self) -> Result<Vec<GitHubSecretVariable>, String> {
self.list_with_progress(&mut |_| {}).await
}
pub async fn list_with_progress<F>(
&self,
progress: &mut F,
) -> Result<Vec<GitHubSecretVariable>, String>
where
F: FnMut(&str),
{
Ok(self
.list_raw_with_progress(progress)
.await?
.into_iter()
.map(normalize_variable_name_from_github)
.collect())
}
async fn list_raw_with_progress<F>(
&self,
progress: &mut F,
) -> Result<Vec<GitHubSecretVariable>, String>
where
F: FnMut(&str),
{
let mut page = 1usize;
let mut results = Vec::new();
loop {
progress(&format!("Fetching GitHub variables page {}", page));
let url = format!(
"{}/repos/{}/{}/environments/{}/variables?per_page={}&page={}",
GITHUB_API_BASE,
self.owner,
self.repo,
self.environment,
VARIABLE_LIST_PAGE_SIZE,
page
);
let response = self
.client
.get(&url)
.headers(self.auth_headers()?)
.send()
.await
.map_err(|error| format!("GitHub list request failed: {}", error))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("GitHub response read failed: {}", error))?;
if !status.is_success() {
let detail = extract_github_error_message(&body)
.unwrap_or_else(|| format!("HTTP {}", status));
return Err(format!(
"GitHub API returned {} when listing variables: {}",
status, detail
));
}
let payload: ListVariablesResponse = serde_json::from_str(&body)
.map_err(|error| format!("GitHub response parsing failed: {}", error))?;
let count = payload.variables.len();
results.extend(
payload
.variables
.into_iter()
.map(raw_variable_entry_to_secret_variable),
);
if count < VARIABLE_LIST_PAGE_SIZE {
break;
}
page += 1;
}
Ok(results)
}
pub async fn upsert(
&self,
variables: &HashMap<String, String>,
progress: &mut dyn FnMut(&str),
) -> Result<(), String> {
self.ensure_environment_exists().await?;
let existing_names = self.list_variable_names().await?;
let plan = plan_variable_writes(&variables_for_github(variables), &existing_names);
if plan.is_empty() {
progress("Nothing to write to GitHub.");
return Ok(());
}
let headers = self.auth_headers()?;
let provider = self;
let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
let headers = headers.clone();
async move {
provider
.write_variable(&name, &value, mode, headers)
.await
.map(|_| name)
}
}))
.buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
let mut completed = 0usize;
while let Some(result) = writes.next().await {
completed += 1;
progress(&format!("Writing GitHub variables ({})", completed));
result?;
}
Ok(())
}
pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
self.validate_repo_access().await?;
let variables = self.list().await?;
Ok(ProviderErrorResponse {
provider: "github".to_string(),
message: format!(
"GitHub access ok. Environment `{}` variables reachable ({} found).",
self.environment,
variables.len()
),
})
}
async fn ensure_environment_exists(&self) -> Result<(), String> {
let url = format!(
"{}/repos/{}/{}/environments/{}",
GITHUB_API_BASE, self.owner, self.repo, self.environment
);
let response = self
.client
.put(&url)
.headers(self.auth_headers()?)
.json(&serde_json::json!({}))
.send()
.await
.map_err(|error| format!("GitHub environment create failed: {}", error))?;
let status = response.status();
if status.is_success() {
return Ok(());
}
let body = response.text().await.unwrap_or_default();
let detail =
extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
Err(format!(
"GitHub rejected environment `{}`: {}",
self.environment, detail
))
}
async fn list_variable_names(&self) -> Result<HashSet<String>, String> {
Ok(self
.list_raw_with_progress(&mut |_| {})
.await?
.into_iter()
.map(|variable| variable.name)
.collect())
}
async fn write_variable(
&self,
name: &str,
value: &str,
mode: VariableWriteMode,
headers: HeaderMap,
) -> Result<(), String> {
let response = self
.send_variable_write(name, value, mode, headers.clone())
.await?;
if response.status().is_success() {
return Ok(());
}
if let Some(retry_mode) = retry_variable_write_mode(mode, response.status()) {
let retry = self
.send_variable_write(name, value, retry_mode, headers)
.await?;
if retry.status().is_success() {
return Ok(());
}
let detail = describe_variable_write_failure(retry).await;
return Err(format!("GitHub rejected {}: {}", name, detail));
}
let detail = describe_variable_write_failure(response).await;
Err(format!("GitHub rejected {}: {}", name, detail))
}
async fn send_variable_write(
&self,
name: &str,
value: &str,
mode: VariableWriteMode,
headers: HeaderMap,
) -> Result<reqwest::Response, String> {
let payload = serde_json::json!({
"name": name,
"value": value,
});
Ok(match mode {
VariableWriteMode::Create => self
.client
.post(format!(
"{}/repos/{}/{}/environments/{}/variables",
GITHUB_API_BASE, self.owner, self.repo, self.environment
))
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
VariableWriteMode::Update => self
.client
.patch(format!(
"{}/repos/{}/{}/environments/{}/variables/{}",
GITHUB_API_BASE, self.owner, self.repo, self.environment, name
))
.headers(headers)
.json(&payload)
.send()
.await
.map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
})
}
fn auth_headers(&self) -> Result<HeaderMap, String> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", self.token))
.map_err(|error| format!("Invalid authorization header: {}", error))?,
);
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"));
Ok(headers)
}
}
fn retry_variable_write_mode(
mode: VariableWriteMode,
status: StatusCode,
) -> Option<VariableWriteMode> {
match (mode, status) {
(VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
Some(VariableWriteMode::Update)
}
(VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
_ => None,
}
}
async fn describe_variable_write_failure(response: reqwest::Response) -> String {
let status = response.status();
let body = response.text().await.unwrap_or_default();
extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
}
fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
match value {
serde_json::Value::String(value) => value,
other => other.to_string(),
}
}
fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
GitHubSecretVariable {
name: variable.name,
value: github_variable_value_to_env_string(variable.value),
}
}
fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
GitHubSecretVariable {
name: github_variable_name_to_env_name(&variable.name),
value: variable.value,
}
}
fn variables_for_github(variables: &HashMap<String, String>) -> HashMap<String, String> {
variables
.iter()
.map(|(name, value)| (env_name_to_github_variable_name(name), value.clone()))
.collect()
}
fn env_name_to_github_variable_name(name: &str) -> String {
name.strip_prefix(GITHUB_ENV_PREFIX)
.map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
.unwrap_or_else(|| name.to_string())
}
fn github_variable_name_to_env_name(name: &str) -> String {
name.strip_prefix(GITHUB_STORAGE_PREFIX)
.map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
.unwrap_or_else(|| name.to_string())
}
fn plan_variable_writes(
secrets: &HashMap<String, String>,
existing_names: &HashSet<String>,
) -> Vec<(String, String, VariableWriteMode)> {
let mut plan = secrets
.iter()
.map(|(name, value)| {
let mode = if existing_names.contains(name) {
VariableWriteMode::Update
} else {
VariableWriteMode::Create
};
(name.clone(), value.clone(), mode)
})
.collect::<Vec<_>>();
plan.sort_by(|left, right| left.0.cmp(&right.0));
plan
}
#[derive(Debug, Deserialize)]
struct ListVariablesResponse {
variables: Vec<VariableEntry>,
}
#[derive(Debug, Deserialize)]
struct VariableEntry {
name: String,
value: serde_json::Value,
}