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};
8use std::time::Duration;
9
10const GITHUB_API_BASE: &str = "https://api.github.com";
11const GITHUB_API_VERSION: &str = "2022-11-28";
12const VARIABLE_LIST_PAGE_SIZE: usize = 30;
13const VARIABLE_WRITE_CONCURRENCY: usize = 2;
16const VARIABLE_WRITE_MAX_RATE_LIMIT_RETRIES: u32 = 8;
18const VARIABLE_WRITE_DEFAULT_BACKOFF_MS: u64 = 1_000;
19const VARIABLE_WRITE_MAX_BACKOFF_MS: u64 = 60_000;
20const GITHUB_ENV_PREFIX: &str = "GITHUB_";
21const GITHUB_STORAGE_PREFIX: &str = "_GITHUB_";
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct GitHubSecretVariable {
25 pub name: String,
26 pub value: String,
27}
28
29#[derive(Debug)]
30pub struct GitHubEnvironmentClient {
31 owner: String,
32 repo: String,
33 environment: String,
34 token: String,
35 client: Client,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum VariableWriteMode {
40 Create,
41 Update,
42}
43
44impl GitHubEnvironmentClient {
45 pub fn new(
46 owner: impl Into<String>,
47 repo: impl Into<String>,
48 environment: impl Into<String>,
49 token: impl Into<String>,
50 ) -> Result<Self, String> {
51 let client = Client::builder()
52 .user_agent("xbp")
53 .build()
54 .map_err(|error| format!("Failed to build GitHub client: {}", error))?;
55 Ok(Self {
56 owner: owner.into(),
57 repo: repo.into(),
58 environment: environment.into(),
59 token: token.into(),
60 client,
61 })
62 }
63
64 pub async fn validate_repo_access(&self) -> Result<(), String> {
65 let response = self
66 .client
67 .get(format!(
68 "{}/repos/{}/{}",
69 GITHUB_API_BASE, self.owner, self.repo
70 ))
71 .headers(self.auth_headers()?)
72 .send()
73 .await
74 .map_err(|error| format!("GitHub repository check failed: {}", error))?;
75 if response.status().is_success() {
76 return Ok(());
77 }
78 let status = response.status();
79 let body = response.text().await.unwrap_or_default();
80 let detail =
81 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
82 Err(format!(
83 "GitHub repository `{}/{}` is not accessible: {}",
84 self.owner, self.repo, detail
85 ))
86 }
87
88 pub async fn list(&self) -> Result<Vec<GitHubSecretVariable>, String> {
89 self.list_with_progress(&mut |_| {}).await
90 }
91
92 pub async fn list_with_progress<F>(
93 &self,
94 progress: &mut F,
95 ) -> Result<Vec<GitHubSecretVariable>, String>
96 where
97 F: FnMut(&str),
98 {
99 Ok(self
100 .list_raw_with_progress(progress)
101 .await?
102 .into_iter()
103 .map(normalize_variable_name_from_github)
104 .collect())
105 }
106
107 async fn list_raw_with_progress<F>(
108 &self,
109 progress: &mut F,
110 ) -> Result<Vec<GitHubSecretVariable>, String>
111 where
112 F: FnMut(&str),
113 {
114 let mut page = 1usize;
115 let mut results = Vec::new();
116 loop {
117 progress(&format!("Fetching GitHub variables page {}", page));
118 let url = format!(
119 "{}/repos/{}/{}/environments/{}/variables?per_page={}&page={}",
120 GITHUB_API_BASE,
121 self.owner,
122 self.repo,
123 self.environment,
124 VARIABLE_LIST_PAGE_SIZE,
125 page
126 );
127 let response = self
128 .client
129 .get(&url)
130 .headers(self.auth_headers()?)
131 .send()
132 .await
133 .map_err(|error| format!("GitHub list request failed: {}", error))?;
134
135 let status = response.status();
136 let body = response
137 .text()
138 .await
139 .map_err(|error| format!("GitHub response read failed: {}", error))?;
140
141 if !status.is_success() {
142 let detail = extract_github_error_message(&body)
143 .unwrap_or_else(|| format!("HTTP {}", status));
144 return Err(format!(
145 "GitHub API returned {} when listing variables: {}",
146 status, detail
147 ));
148 }
149
150 let payload: ListVariablesResponse = serde_json::from_str(&body)
151 .map_err(|error| format!("GitHub response parsing failed: {}", error))?;
152 let count = payload.variables.len();
153 results.extend(
154 payload
155 .variables
156 .into_iter()
157 .map(raw_variable_entry_to_secret_variable),
158 );
159 if count < VARIABLE_LIST_PAGE_SIZE {
160 break;
161 }
162 page += 1;
163 }
164 Ok(results)
165 }
166
167 pub async fn upsert(
168 &self,
169 variables: &HashMap<String, String>,
170 progress: &mut dyn FnMut(&str),
171 ) -> Result<(), String> {
172 self.upsert_with_counts(variables, &mut |completed, total, message| {
173 if total == 0 {
174 progress(message);
175 } else {
176 progress(&format!("{message} ({completed}/{total})"));
177 }
178 })
179 .await
180 }
181
182 pub async fn upsert_with_counts(
184 &self,
185 variables: &HashMap<String, String>,
186 progress: &mut dyn FnMut(usize, usize, &str),
187 ) -> Result<(), String> {
188 progress(0, 0, "Ensuring GitHub environment exists");
189 self.ensure_environment_exists().await?;
190 progress(0, 0, "Listing existing GitHub variables");
191 let existing_names = self.list_variable_names().await?;
192 let provider_variables = variables_for_github(variables)?;
193 let plan = plan_variable_writes(&provider_variables, &existing_names);
194 let total = plan.len();
195 if plan.is_empty() {
196 progress(0, 0, "Nothing to write to GitHub.");
197 return Ok(());
198 }
199
200 progress(0, total, "Writing GitHub variables");
201 let headers = self.auth_headers()?;
202 let provider = self;
203 let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
204 let headers = headers.clone();
205 async move {
206 provider
207 .write_variable(&name, &value, mode, headers)
208 .await
209 .map(|_| name)
210 }
211 }))
212 .buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
213
214 let mut completed = 0usize;
215 while let Some(result) = writes.next().await {
216 completed += 1;
217 match result {
218 Ok(name) => {
219 progress(
220 completed,
221 total,
222 &format!("Wrote `{name}`"),
223 );
224 }
225 Err(error) => {
226 progress(completed, total, "Write failed");
227 return Err(error);
228 }
229 }
230 }
231
232 progress(total, total, "All variables written");
233 Ok(())
234 }
235
236 pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
237 self.validate_repo_access().await?;
238 let variables = self.list().await?;
239 Ok(ProviderErrorResponse {
240 provider: "github".to_string(),
241 message: format!(
242 "GitHub access ok. Environment `{}` variables reachable ({} found).",
243 self.environment,
244 variables.len()
245 ),
246 })
247 }
248
249 pub async fn ensure_environment_exists(&self) -> Result<(), String> {
250 let url = format!(
251 "{}/repos/{}/{}/environments/{}",
252 GITHUB_API_BASE, self.owner, self.repo, self.environment
253 );
254 let response = self
255 .client
256 .put(&url)
257 .headers(self.auth_headers()?)
258 .json(&serde_json::json!({}))
259 .send()
260 .await
261 .map_err(|error| format!("GitHub environment create failed: {}", error))?;
262 let status = response.status();
263 if status.is_success() {
264 return Ok(());
265 }
266 let body = response.text().await.unwrap_or_default();
267 let detail =
268 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
269 Err(format!(
270 "GitHub rejected environment `{}`: {}",
271 self.environment, detail
272 ))
273 }
274
275 async fn list_variable_names(&self) -> Result<HashMap<String, String>, String> {
276 Ok(self
277 .list_raw_with_progress(&mut |_| {})
278 .await?
279 .into_iter()
280 .map(|variable| {
281 (
282 canonical_github_variable_name(&variable.name),
283 variable.name,
284 )
285 })
286 .collect())
287 }
288
289 async fn write_variable(
290 &self,
291 name: &str,
292 value: &str,
293 mode: VariableWriteMode,
294 headers: HeaderMap,
295 ) -> Result<(), String> {
296 let mut mode = mode;
297 let mut mode_flipped = false;
298 let mut rate_limit_attempts = 0u32;
299
300 loop {
301 let response = self
302 .send_variable_write(name, value, mode, headers.clone())
303 .await?;
304 let status = response.status();
305 if status.is_success() {
306 return Ok(());
307 }
308
309 if !mode_flipped {
311 if let Some(retry_mode) = retry_variable_write_mode(mode, status) {
312 mode = retry_mode;
313 mode_flipped = true;
314 let _ = response.bytes().await;
316 continue;
317 }
318 }
319
320 let retry_after_ms = parse_retry_after_ms(response.headers());
321 let detail = describe_variable_write_failure(response).await;
322
323 if is_github_rate_limit_detail(status, &detail) {
324 if rate_limit_attempts >= VARIABLE_WRITE_MAX_RATE_LIMIT_RETRIES {
325 return Err(format!(
326 "GitHub rejected {}: {} (gave up after {} rate-limit retries)",
327 name, detail, VARIABLE_WRITE_MAX_RATE_LIMIT_RETRIES
328 ));
329 }
330 let backoff_ms = rate_limit_backoff_ms(retry_after_ms, rate_limit_attempts);
331 rate_limit_attempts += 1;
332 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
333 continue;
334 }
335
336 return Err(format!("GitHub rejected {}: {}", name, detail));
337 }
338 }
339
340 async fn send_variable_write(
341 &self,
342 name: &str,
343 value: &str,
344 mode: VariableWriteMode,
345 headers: HeaderMap,
346 ) -> Result<reqwest::Response, String> {
347 let payload = serde_json::json!({
348 "name": name,
349 "value": value,
350 });
351 Ok(match mode {
352 VariableWriteMode::Create => self
353 .client
354 .post(format!(
355 "{}/repos/{}/{}/environments/{}/variables",
356 GITHUB_API_BASE, self.owner, self.repo, self.environment
357 ))
358 .headers(headers)
359 .json(&payload)
360 .send()
361 .await
362 .map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
363 VariableWriteMode::Update => self
364 .client
365 .patch(format!(
366 "{}/repos/{}/{}/environments/{}/variables/{}",
367 GITHUB_API_BASE, self.owner, self.repo, self.environment, name
368 ))
369 .headers(headers)
370 .json(&payload)
371 .send()
372 .await
373 .map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
374 })
375 }
376
377 fn auth_headers(&self) -> Result<HeaderMap, String> {
378 let mut headers = HeaderMap::new();
379 headers.insert(
380 AUTHORIZATION,
381 HeaderValue::from_str(&format!("Bearer {}", self.token))
382 .map_err(|error| format!("Invalid authorization header: {}", error))?,
383 );
384 headers.insert(
385 ACCEPT,
386 HeaderValue::from_static("application/vnd.github+json"),
387 );
388 headers.insert(
389 "X-GitHub-Api-Version",
390 HeaderValue::from_static(GITHUB_API_VERSION),
391 );
392 headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
393 Ok(headers)
394 }
395}
396
397fn retry_variable_write_mode(
398 mode: VariableWriteMode,
399 status: StatusCode,
400) -> Option<VariableWriteMode> {
401 match (mode, status) {
402 (VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
403 Some(VariableWriteMode::Update)
404 }
405 (VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
406 _ => None,
407 }
408}
409
410async fn describe_variable_write_failure(response: reqwest::Response) -> String {
411 let status = response.status();
412 let body = response.text().await.unwrap_or_default();
413 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
414}
415
416fn is_github_rate_limit_detail(status: StatusCode, detail: &str) -> bool {
418 if status == StatusCode::TOO_MANY_REQUESTS {
419 return true;
420 }
421 if status == StatusCode::FORBIDDEN {
422 let lower = detail.to_ascii_lowercase();
423 return lower.contains("rate limit")
424 || lower.contains("secondary rate")
425 || lower.contains("abuse detection")
426 || lower.contains("too many requests");
427 }
428 false
429}
430
431fn rate_limit_backoff_ms(retry_after_ms: Option<u64>, attempt: u32) -> u64 {
433 if let Some(ms) = retry_after_ms {
434 return ms.clamp(100, VARIABLE_WRITE_MAX_BACKOFF_MS);
435 }
436 let shift = attempt.min(6);
437 VARIABLE_WRITE_DEFAULT_BACKOFF_MS
438 .saturating_mul(1u64 << shift)
439 .min(VARIABLE_WRITE_MAX_BACKOFF_MS)
440}
441
442fn parse_retry_after_ms(headers: &HeaderMap) -> Option<u64> {
443 let value = headers.get("retry-after")?.to_str().ok()?.trim();
444 if value.is_empty() {
445 return None;
446 }
447 if let Ok(secs) = value.parse::<u64>() {
449 return Some(secs.saturating_mul(1000));
450 }
451 None
452}
453
454fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
455 match value {
456 serde_json::Value::String(value) => value,
457 other => other.to_string(),
458 }
459}
460
461fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
462 GitHubSecretVariable {
463 name: variable.name,
464 value: github_variable_value_to_env_string(variable.value),
465 }
466}
467
468fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
469 GitHubSecretVariable {
470 name: github_variable_name_to_env_name(&variable.name),
471 value: variable.value,
472 }
473}
474
475fn variables_for_github(
476 variables: &HashMap<String, String>,
477) -> Result<HashMap<String, String>, String> {
478 let mut canonicalized = HashMap::<String, (String, String)>::new();
479
480 for (name, value) in variables {
481 let provider_name = env_name_to_github_variable_name(name);
482 validate_github_variable_name(&provider_name)?;
483 let canonical_name = canonical_github_variable_name(&provider_name);
484
485 match canonicalized.entry(canonical_name) {
486 Entry::Vacant(entry) => {
487 entry.insert((provider_name, value.clone()));
488 }
489 Entry::Occupied(mut entry) => {
490 let (existing_name, existing_value) = entry.get_mut();
491 if existing_value != value {
492 return Err(format!(
493 "Local env keys collapse to the same GitHub variable `{}` but have different values: `{}` and `{}`",
494 existing_name,
495 existing_name,
496 provider_name
497 ));
498 }
499 if should_prefer_github_variable_name(&provider_name, existing_name) {
500 *existing_name = provider_name;
501 }
502 }
503 }
504 }
505
506 Ok(canonicalized.into_values().collect())
507}
508
509fn validate_github_variable_name(name: &str) -> Result<(), String> {
511 let mut chars = name.chars();
512 let Some(first) = chars.next() else {
513 return Err("GitHub variable name cannot be empty.".to_string());
514 };
515 if !(first.is_ascii_alphabetic() || first == '_') {
516 return Err(format!(
517 "Invalid GitHub variable name `{name}`: must start with a letter or underscore \
518(got leading character U+{:04X}). If this came from a `.env` file, re-save without a UTF-8 BOM.",
519 first as u32
520 ));
521 }
522 if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
523 return Err(format!(
524 "Invalid GitHub variable name `{name}`: only letters, digits, and underscores are allowed."
525 ));
526 }
527 Ok(())
528}
529
530fn env_name_to_github_variable_name(name: &str) -> String {
531 let name = name
533 .trim()
534 .trim_start_matches('\u{feff}')
535 .chars()
536 .filter(|ch| {
537 !matches!(
538 ch,
539 '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
540 )
541 })
542 .collect::<String>();
543
544 name.strip_prefix(GITHUB_ENV_PREFIX)
545 .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
546 .unwrap_or(name)
547}
548
549fn github_variable_name_to_env_name(name: &str) -> String {
550 name.strip_prefix(GITHUB_STORAGE_PREFIX)
551 .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
552 .unwrap_or_else(|| name.to_string())
553}
554
555fn canonical_github_variable_name(name: &str) -> String {
556 name.to_ascii_uppercase()
557}
558
559fn should_prefer_github_variable_name(candidate: &str, current: &str) -> bool {
560 match (has_lowercase_ascii(candidate), has_lowercase_ascii(current)) {
561 (false, true) => true,
562 (true, false) => false,
563 _ => candidate < current,
564 }
565}
566
567fn has_lowercase_ascii(value: &str) -> bool {
568 value.chars().any(|ch| ch.is_ascii_lowercase())
569}
570
571fn plan_variable_writes(
572 secrets: &HashMap<String, String>,
573 existing_names: &HashMap<String, String>,
574) -> Vec<(String, String, VariableWriteMode)> {
575 let mut plan = secrets
576 .iter()
577 .map(|(name, value)| {
578 if let Some(existing_name) = existing_names.get(&canonical_github_variable_name(name)) {
579 (
580 existing_name.clone(),
581 value.clone(),
582 VariableWriteMode::Update,
583 )
584 } else {
585 (name.clone(), value.clone(), VariableWriteMode::Create)
586 }
587 })
588 .collect::<Vec<_>>();
589 plan.sort_by(|left, right| left.0.cmp(&right.0));
590 plan
591}
592
593#[derive(Debug, Deserialize)]
594struct ListVariablesResponse {
595 variables: Vec<VariableEntry>,
596}
597
598#[derive(Debug, Deserialize)]
599struct VariableEntry {
600 name: String,
601 value: serde_json::Value,
602}
603
604#[cfg(test)]
605mod tests {
606 use super::{
607 is_github_rate_limit_detail, parse_retry_after_ms, plan_variable_writes,
608 rate_limit_backoff_ms, variables_for_github, VariableWriteMode,
609 };
610 use reqwest::header::{HeaderMap, HeaderValue};
611 use reqwest::StatusCode;
612 use std::collections::HashMap;
613
614 #[test]
615 fn rate_limit_detects_429() {
616 assert!(is_github_rate_limit_detail(
617 StatusCode::TOO_MANY_REQUESTS,
618 "HTTP 429"
619 ));
620 }
621
622 #[test]
623 fn rate_limit_detects_secondary_403() {
624 assert!(is_github_rate_limit_detail(
625 StatusCode::FORBIDDEN,
626 "You have exceeded a secondary rate limit. Please wait a few minutes."
627 ));
628 assert!(!is_github_rate_limit_detail(
629 StatusCode::FORBIDDEN,
630 "Resource not accessible by integration"
631 ));
632 }
633
634 #[test]
635 fn rate_limit_backoff_prefers_retry_after() {
636 assert_eq!(rate_limit_backoff_ms(Some(5_000), 0), 5_000);
637 assert_eq!(rate_limit_backoff_ms(Some(10), 0), 100); assert_eq!(rate_limit_backoff_ms(None, 0), 1_000);
639 assert_eq!(rate_limit_backoff_ms(None, 1), 2_000);
640 assert_eq!(rate_limit_backoff_ms(None, 2), 4_000);
641 assert_eq!(rate_limit_backoff_ms(None, 10), 60_000); }
643
644 #[test]
645 fn parse_retry_after_seconds_header() {
646 let mut headers = HeaderMap::new();
647 headers.insert("retry-after", HeaderValue::from_static("3"));
648 assert_eq!(parse_retry_after_ms(&headers), Some(3_000));
649 }
650
651 #[test]
652 fn variables_for_github_strips_bom_from_keys() {
653 let mut variables = HashMap::new();
654 variables.insert("\u{feff}MOLLIE_API_KEY".to_string(), "secret".to_string());
655
656 let provider = variables_for_github(&variables).expect("bom key should sanitize");
657 assert_eq!(provider.get("MOLLIE_API_KEY"), Some(&"secret".to_string()));
658 assert!(!provider.keys().any(|k| k.contains('\u{feff}')));
659 }
660
661 #[test]
662 fn variables_for_github_dedupes_case_insensitive_aliases() {
663 let mut variables = HashMap::new();
664 variables.insert(
665 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
666 "xbp".to_string(),
667 );
668 variables.insert(
669 "cloudflare_oauth_client_publisher".to_string(),
670 "xbp".to_string(),
671 );
672
673 let provider = variables_for_github(&variables).expect("dedupe should succeed");
674
675 assert_eq!(provider.len(), 1);
676 assert_eq!(
677 provider.get("CLOUDFLARE_OAUTH_CLIENT_PUBLISHER"),
678 Some(&"xbp".to_string())
679 );
680 }
681
682 #[test]
683 fn variables_for_github_rejects_case_insensitive_aliases_with_different_values() {
684 let mut variables = HashMap::new();
685 variables.insert(
686 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
687 "one".to_string(),
688 );
689 variables.insert(
690 "cloudflare_oauth_client_publisher".to_string(),
691 "two".to_string(),
692 );
693
694 let error = variables_for_github(&variables).expect_err("conflict should be rejected");
695 assert!(error.contains("collapse to the same GitHub variable"));
696 }
697
698 #[test]
699 fn plan_variable_writes_updates_existing_name_even_when_case_differs() {
700 let mut secrets = HashMap::new();
701 secrets.insert(
702 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
703 "xbp".to_string(),
704 );
705
706 let mut existing_names = HashMap::new();
707 existing_names.insert(
708 "CLOUDFLARE_OAUTH_CLIENT_PUBLISHER".to_string(),
709 "cloudflare_oauth_client_publisher".to_string(),
710 );
711
712 let plan = plan_variable_writes(&secrets, &existing_names);
713
714 assert_eq!(
715 plan,
716 vec![(
717 "cloudflare_oauth_client_publisher".to_string(),
718 "xbp".to_string(),
719 VariableWriteMode::Update,
720 )]
721 );
722 }
723}