1use super::http::extract_github_error_message;
2use super::models::ProviderErrorResponse;
3use futures::StreamExt;
4use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
5use reqwest::{Client, StatusCode};
6use serde::{Deserialize, Serialize};
7use std::collections::{hash_map::Entry, HashMap};
8
9const GITHUB_API_BASE: &str = "https://api.github.com";
10const GITHUB_API_VERSION: &str = "2022-11-28";
11const VARIABLE_LIST_PAGE_SIZE: usize = 30;
12const VARIABLE_WRITE_CONCURRENCY: usize = 8;
13const GITHUB_ENV_PREFIX: &str = "GITHUB_";
14const GITHUB_STORAGE_PREFIX: &str = "_GITHUB_";
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GitHubSecretVariable {
18 pub name: String,
19 pub value: String,
20}
21
22#[derive(Debug)]
23pub struct GitHubEnvironmentClient {
24 owner: String,
25 repo: String,
26 environment: String,
27 token: String,
28 client: Client,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum VariableWriteMode {
33 Create,
34 Update,
35}
36
37impl GitHubEnvironmentClient {
38 pub fn new(
39 owner: impl Into<String>,
40 repo: impl Into<String>,
41 environment: impl Into<String>,
42 token: impl Into<String>,
43 ) -> Result<Self, String> {
44 let client = Client::builder()
45 .user_agent("xbp")
46 .build()
47 .map_err(|error| format!("Failed to build GitHub client: {}", error))?;
48 Ok(Self {
49 owner: owner.into(),
50 repo: repo.into(),
51 environment: environment.into(),
52 token: token.into(),
53 client,
54 })
55 }
56
57 pub async fn validate_repo_access(&self) -> Result<(), String> {
58 let response = self
59 .client
60 .get(format!(
61 "{}/repos/{}/{}",
62 GITHUB_API_BASE, self.owner, self.repo
63 ))
64 .headers(self.auth_headers()?)
65 .send()
66 .await
67 .map_err(|error| format!("GitHub repository check failed: {}", error))?;
68 if response.status().is_success() {
69 return Ok(());
70 }
71 let status = response.status();
72 let body = response.text().await.unwrap_or_default();
73 let detail =
74 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
75 Err(format!(
76 "GitHub repository `{}/{}` is not accessible: {}",
77 self.owner, self.repo, detail
78 ))
79 }
80
81 pub async fn list(&self) -> Result<Vec<GitHubSecretVariable>, String> {
82 self.list_with_progress(&mut |_| {}).await
83 }
84
85 pub async fn list_with_progress<F>(
86 &self,
87 progress: &mut F,
88 ) -> Result<Vec<GitHubSecretVariable>, String>
89 where
90 F: FnMut(&str),
91 {
92 Ok(self
93 .list_raw_with_progress(progress)
94 .await?
95 .into_iter()
96 .map(normalize_variable_name_from_github)
97 .collect())
98 }
99
100 async fn list_raw_with_progress<F>(
101 &self,
102 progress: &mut F,
103 ) -> Result<Vec<GitHubSecretVariable>, String>
104 where
105 F: FnMut(&str),
106 {
107 let mut page = 1usize;
108 let mut results = Vec::new();
109 loop {
110 progress(&format!("Fetching GitHub variables page {}", page));
111 let url = format!(
112 "{}/repos/{}/{}/environments/{}/variables?per_page={}&page={}",
113 GITHUB_API_BASE,
114 self.owner,
115 self.repo,
116 self.environment,
117 VARIABLE_LIST_PAGE_SIZE,
118 page
119 );
120 let response = self
121 .client
122 .get(&url)
123 .headers(self.auth_headers()?)
124 .send()
125 .await
126 .map_err(|error| format!("GitHub list request failed: {}", error))?;
127
128 let status = response.status();
129 let body = response
130 .text()
131 .await
132 .map_err(|error| format!("GitHub response read failed: {}", error))?;
133
134 if !status.is_success() {
135 let detail = extract_github_error_message(&body)
136 .unwrap_or_else(|| format!("HTTP {}", status));
137 return Err(format!(
138 "GitHub API returned {} when listing variables: {}",
139 status, detail
140 ));
141 }
142
143 let payload: ListVariablesResponse = serde_json::from_str(&body)
144 .map_err(|error| format!("GitHub response parsing failed: {}", error))?;
145 let count = payload.variables.len();
146 results.extend(
147 payload
148 .variables
149 .into_iter()
150 .map(raw_variable_entry_to_secret_variable),
151 );
152 if count < VARIABLE_LIST_PAGE_SIZE {
153 break;
154 }
155 page += 1;
156 }
157 Ok(results)
158 }
159
160 pub async fn upsert(
161 &self,
162 variables: &HashMap<String, String>,
163 progress: &mut dyn FnMut(&str),
164 ) -> Result<(), String> {
165 self.ensure_environment_exists().await?;
166 let existing_names = self.list_variable_names().await?;
167 let provider_variables = variables_for_github(variables)?;
168 let plan = plan_variable_writes(&provider_variables, &existing_names);
169 if plan.is_empty() {
170 progress("Nothing to write to GitHub.");
171 return Ok(());
172 }
173
174 let headers = self.auth_headers()?;
175 let provider = self;
176 let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
177 let headers = headers.clone();
178 async move {
179 provider
180 .write_variable(&name, &value, mode, headers)
181 .await
182 .map(|_| name)
183 }
184 }))
185 .buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
186
187 let mut completed = 0usize;
188 while let Some(result) = writes.next().await {
189 completed += 1;
190 progress(&format!("Writing GitHub variables ({})", completed));
191 result?;
192 }
193
194 Ok(())
195 }
196
197 pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
198 self.validate_repo_access().await?;
199 let variables = self.list().await?;
200 Ok(ProviderErrorResponse {
201 provider: "github".to_string(),
202 message: format!(
203 "GitHub access ok. Environment `{}` variables reachable ({} found).",
204 self.environment,
205 variables.len()
206 ),
207 })
208 }
209
210 pub async fn ensure_environment_exists(&self) -> Result<(), String> {
211 let url = format!(
212 "{}/repos/{}/{}/environments/{}",
213 GITHUB_API_BASE, self.owner, self.repo, self.environment
214 );
215 let response = self
216 .client
217 .put(&url)
218 .headers(self.auth_headers()?)
219 .json(&serde_json::json!({}))
220 .send()
221 .await
222 .map_err(|error| format!("GitHub environment create failed: {}", error))?;
223 let status = response.status();
224 if status.is_success() {
225 return Ok(());
226 }
227 let body = response.text().await.unwrap_or_default();
228 let detail =
229 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
230 Err(format!(
231 "GitHub rejected environment `{}`: {}",
232 self.environment, detail
233 ))
234 }
235
236 async fn list_variable_names(&self) -> Result<HashMap<String, String>, String> {
237 Ok(self
238 .list_raw_with_progress(&mut |_| {})
239 .await?
240 .into_iter()
241 .map(|variable| {
242 (
243 canonical_github_variable_name(&variable.name),
244 variable.name,
245 )
246 })
247 .collect())
248 }
249
250 async fn write_variable(
251 &self,
252 name: &str,
253 value: &str,
254 mode: VariableWriteMode,
255 headers: HeaderMap,
256 ) -> Result<(), String> {
257 let response = self
258 .send_variable_write(name, value, mode, headers.clone())
259 .await?;
260 if response.status().is_success() {
261 return Ok(());
262 }
263
264 if let Some(retry_mode) = retry_variable_write_mode(mode, response.status()) {
265 let retry = self
266 .send_variable_write(name, value, retry_mode, headers)
267 .await?;
268 if retry.status().is_success() {
269 return Ok(());
270 }
271 let detail = describe_variable_write_failure(retry).await;
272 return Err(format!("GitHub rejected {}: {}", name, detail));
273 }
274
275 let detail = describe_variable_write_failure(response).await;
276 Err(format!("GitHub rejected {}: {}", name, detail))
277 }
278
279 async fn send_variable_write(
280 &self,
281 name: &str,
282 value: &str,
283 mode: VariableWriteMode,
284 headers: HeaderMap,
285 ) -> Result<reqwest::Response, String> {
286 let payload = serde_json::json!({
287 "name": name,
288 "value": value,
289 });
290 Ok(match mode {
291 VariableWriteMode::Create => self
292 .client
293 .post(format!(
294 "{}/repos/{}/{}/environments/{}/variables",
295 GITHUB_API_BASE, self.owner, self.repo, self.environment
296 ))
297 .headers(headers)
298 .json(&payload)
299 .send()
300 .await
301 .map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
302 VariableWriteMode::Update => self
303 .client
304 .patch(format!(
305 "{}/repos/{}/{}/environments/{}/variables/{}",
306 GITHUB_API_BASE, self.owner, self.repo, self.environment, name
307 ))
308 .headers(headers)
309 .json(&payload)
310 .send()
311 .await
312 .map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
313 })
314 }
315
316 fn auth_headers(&self) -> Result<HeaderMap, String> {
317 let mut headers = HeaderMap::new();
318 headers.insert(
319 AUTHORIZATION,
320 HeaderValue::from_str(&format!("Bearer {}", self.token))
321 .map_err(|error| format!("Invalid authorization header: {}", error))?,
322 );
323 headers.insert(
324 ACCEPT,
325 HeaderValue::from_static("application/vnd.github+json"),
326 );
327 headers.insert(
328 "X-GitHub-Api-Version",
329 HeaderValue::from_static(GITHUB_API_VERSION),
330 );
331 headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
332 Ok(headers)
333 }
334}
335
336fn retry_variable_write_mode(
337 mode: VariableWriteMode,
338 status: StatusCode,
339) -> Option<VariableWriteMode> {
340 match (mode, status) {
341 (VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
342 Some(VariableWriteMode::Update)
343 }
344 (VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
345 _ => None,
346 }
347}
348
349async fn describe_variable_write_failure(response: reqwest::Response) -> String {
350 let status = response.status();
351 let body = response.text().await.unwrap_or_default();
352 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
353}
354
355fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
356 match value {
357 serde_json::Value::String(value) => value,
358 other => other.to_string(),
359 }
360}
361
362fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
363 GitHubSecretVariable {
364 name: variable.name,
365 value: github_variable_value_to_env_string(variable.value),
366 }
367}
368
369fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
370 GitHubSecretVariable {
371 name: github_variable_name_to_env_name(&variable.name),
372 value: variable.value,
373 }
374}
375
376fn variables_for_github(
377 variables: &HashMap<String, String>,
378) -> Result<HashMap<String, String>, String> {
379 let mut canonicalized = HashMap::<String, (String, String)>::new();
380
381 for (name, value) in variables {
382 let provider_name = env_name_to_github_variable_name(name);
383 let canonical_name = canonical_github_variable_name(&provider_name);
384
385 match canonicalized.entry(canonical_name) {
386 Entry::Vacant(entry) => {
387 entry.insert((provider_name, value.clone()));
388 }
389 Entry::Occupied(mut entry) => {
390 let (existing_name, existing_value) = entry.get_mut();
391 if existing_value != value {
392 return Err(format!(
393 "Local env keys collapse to the same GitHub variable `{}` but have different values: `{}` and `{}`",
394 existing_name,
395 existing_name,
396 provider_name
397 ));
398 }
399 if should_prefer_github_variable_name(&provider_name, existing_name) {
400 *existing_name = provider_name;
401 }
402 }
403 }
404 }
405
406 Ok(canonicalized.into_values().collect())
407}
408
409fn env_name_to_github_variable_name(name: &str) -> String {
410 name.strip_prefix(GITHUB_ENV_PREFIX)
411 .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
412 .unwrap_or_else(|| name.to_string())
413}
414
415fn github_variable_name_to_env_name(name: &str) -> String {
416 name.strip_prefix(GITHUB_STORAGE_PREFIX)
417 .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
418 .unwrap_or_else(|| name.to_string())
419}
420
421fn canonical_github_variable_name(name: &str) -> String {
422 name.to_ascii_uppercase()
423}
424
425fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
426 match (has_lowercase_ascii(candidate), has_lowercase_ascii(current)) {
427 (false, true) => true,
428 (true, false) => false,
429 _ => candidate < current,
430 }
431}
432
433fn has_lowercase_ascii(value: &str) -> bool {
434 value.chars().any(|ch| ch.is_ascii_lowercase())
435}
436
437fn plan_variable_writes(
438 secrets: &HashMap<String, String>,
439 existing_names: &HashMap<String, String>,
440) -> Vec<(String, String, VariableWriteMode)> {
441 let mut plan = secrets
442 .iter()
443 .map(|(name, value)| {
444 if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
445 (
446 existing_name.clone(),
447 value.clone(),
448 VariableWriteMode::Update,
449 )
450 } else {
451 (name.clone(), value.clone(), VariableWriteMode::Create)
452 }
453 })
454 .collect::<Vec<_>>();
455 plan.sort_by(|left, right| left.0.cmp(&right.0));
456 plan
457}
458
459#[derive(Debug, Deserialize)]
460struct ListVariablesResponse {
461 variables: Vec<VariableEntry>,
462}
463
464#[derive(Debug, Deserialize)]
465struct VariableEntry {
466 name: String,
467 value: serde_json::Value,
468}
469
470#[cfg(test)]
471mod tests {
472 use super::{plan_variable_writes, variables_for_github, VariableWriteMode};
473 use std::collections::HashMap;
474
475 #[test]
476 fn variables_for_github_dedupes_case_insensitive_aliases() {
477 let mut variables = HashMap::new();
478 variables.insert(
479 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
480 "xbp".to_string(),
481 );
482 variables.insert(
483 "cloudflare_oauth_client_publisher".to_string(),
484 "xbp".to_string(),
485 );
486
487 let provider = variables_for_github(&variables).expect("dedupe should succeed");
488
489 assert_eq!(provider.len(), 1);
490 assert_eq!(
491 provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
492 Some(&"xbp".to_string())
493 );
494 }
495
496 #[test]
497 fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
498 let mut variables = HashMap::new();
499 variables.insert(
500 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
501 "one".to_string(),
502 );
503 variables.insert(
504 "cloudflare_oauth_client_publisher".to_string(),
505 "two".to_string(),
506 );
507
508 let error = variables_for_github(&variables).expect_err("conflict should be rejected");
509 assert!(error.contains("collapse to the same GitHub variable"));
510 }
511
512 #[test]
513 fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
514 let mut secrets = HashMap::new();
515 secrets.insert(
516 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
517 "xbp".to_string(),
518 );
519
520 let mut existing_names = HashMap::new();
521 existing_names.insert(
522 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
523 "cloudflare_oauth_client_publisher".to_string(),
524 );
525
526 let plan = plan_variable_writes(&secrets, &existing_names);
527
528 assert_eq!(
529 plan,
530 vec![(
531 "cloudflare_oauth_client_publisher".to_string(),
532 "xbp".to_string(),
533 VariableWriteMode::Update,
534 )]
535 );
536 }
537}