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::{HashMap, HashSet};
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 plan = plan_variable_writes(&variables_for_github(variables), &existing_names);
168 if plan.is_empty() {
169 progress("Nothing to write to GitHub.");
170 return Ok(());
171 }
172
173 let headers = self.auth_headers()?;
174 let provider = self;
175 let mut writes = futures::stream::iter(plan.into_iter().map(|(name, value, mode)| {
176 let headers = headers.clone();
177 async move {
178 provider
179 .write_variable(&name, &value, mode, headers)
180 .await
181 .map(|_| name)
182 }
183 }))
184 .buffer_unordered(VARIABLE_WRITE_CONCURRENCY);
185
186 let mut completed = 0usize;
187 while let Some(result) = writes.next().await {
188 completed += 1;
189 progress(&format!("Writing GitHub variables ({})", completed));
190 result?;
191 }
192
193 Ok(())
194 }
195
196 pub async fn diag(&self) -> Result<ProviderErrorResponse, String> {
197 self.validate_repo_access().await?;
198 let variables = self.list().await?;
199 Ok(ProviderErrorResponse {
200 provider: "github".to_string(),
201 message: format!(
202 "GitHub access ok. Environment `{}` variables reachable ({} found).",
203 self.environment,
204 variables.len()
205 ),
206 })
207 }
208
209 async fn ensure_environment_exists(&self) -> Result<(), String> {
210 let url = format!(
211 "{}/repos/{}/{}/environments/{}",
212 GITHUB_API_BASE, self.owner, self.repo, self.environment
213 );
214 let response = self
215 .client
216 .put(&url)
217 .headers(self.auth_headers()?)
218 .json(&serde_json::json!({}))
219 .send()
220 .await
221 .map_err(|error| format!("GitHub environment create failed: {}", error))?;
222 let status = response.status();
223 if status.is_success() {
224 return Ok(());
225 }
226 let body = response.text().await.unwrap_or_default();
227 let detail =
228 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status));
229 Err(format!(
230 "GitHub rejected environment `{}`: {}",
231 self.environment, detail
232 ))
233 }
234
235 async fn list_variable_names(&self) -> Result<HashSet<String>, String> {
236 Ok(self
237 .list_raw_with_progress(&mut |_| {})
238 .await?
239 .into_iter()
240 .map(|variable| variable.name)
241 .collect())
242 }
243
244 async fn write_variable(
245 &self,
246 name: &str,
247 value: &str,
248 mode: VariableWriteMode,
249 headers: HeaderMap,
250 ) -> Result<(), String> {
251 let response = self
252 .send_variable_write(name, value, mode, headers.clone())
253 .await?;
254 if response.status().is_success() {
255 return Ok(());
256 }
257
258 if let Some(retry_mode) = retry_variable_write_mode(mode, response.status()) {
259 let retry = self
260 .send_variable_write(name, value, retry_mode, headers)
261 .await?;
262 if retry.status().is_success() {
263 return Ok(());
264 }
265 let detail = describe_variable_write_failure(retry).await;
266 return Err(format!("GitHub rejected {}: {}", name, detail));
267 }
268
269 let detail = describe_variable_write_failure(response).await;
270 Err(format!("GitHub rejected {}: {}", name, detail))
271 }
272
273 async fn send_variable_write(
274 &self,
275 name: &str,
276 value: &str,
277 mode: VariableWriteMode,
278 headers: HeaderMap,
279 ) -> Result<reqwest::Response, String> {
280 let payload = serde_json::json!({
281 "name": name,
282 "value": value,
283 });
284 Ok(match mode {
285 VariableWriteMode::Create => self
286 .client
287 .post(format!(
288 "{}/repos/{}/{}/environments/{}/variables",
289 GITHUB_API_BASE, self.owner, self.repo, self.environment
290 ))
291 .headers(headers)
292 .json(&payload)
293 .send()
294 .await
295 .map_err(|error| format!("GitHub create failed for {}: {}", name, error))?,
296 VariableWriteMode::Update => self
297 .client
298 .patch(format!(
299 "{}/repos/{}/{}/environments/{}/variables/{}",
300 GITHUB_API_BASE, self.owner, self.repo, self.environment, name
301 ))
302 .headers(headers)
303 .json(&payload)
304 .send()
305 .await
306 .map_err(|error| format!("GitHub update failed for {}: {}", name, error))?,
307 })
308 }
309
310 fn auth_headers(&self) -> Result<HeaderMap, String> {
311 let mut headers = HeaderMap::new();
312 headers.insert(
313 AUTHORIZATION,
314 HeaderValue::from_str(&format!("Bearer {}", self.token))
315 .map_err(|error| format!("Invalid authorization header: {}", error))?,
316 );
317 headers.insert(
318 ACCEPT,
319 HeaderValue::from_static("application/vnd.github+json"),
320 );
321 headers.insert(
322 "X-GitHub-Api-Version",
323 HeaderValue::from_static(GITHUB_API_VERSION),
324 );
325 headers.insert(USER_AGENT, HeaderValue::from_static("xbp"));
326 Ok(headers)
327 }
328}
329
330fn retry_variable_write_mode(
331 mode: VariableWriteMode,
332 status: StatusCode,
333) -> Option<VariableWriteMode> {
334 match (mode, status) {
335 (VariableWriteMode::Create, StatusCode::UNPROCESSABLE_ENTITY) => {
336 Some(VariableWriteMode::Update)
337 }
338 (VariableWriteMode::Update, StatusCode::NOT_FOUND) => Some(VariableWriteMode::Create),
339 _ => None,
340 }
341}
342
343async fn describe_variable_write_failure(response: reqwest::Response) -> String {
344 let status = response.status();
345 let body = response.text().await.unwrap_or_default();
346 extract_github_error_message(&body).unwrap_or_else(|| format!("HTTP {}", status))
347}
348
349fn github_variable_value_to_env_string(value: serde_json::Value) -> String {
350 match value {
351 serde_json::Value::String(value) => value,
352 other => other.to_string(),
353 }
354}
355
356fn raw_variable_entry_to_secret_variable(variable: VariableEntry) -> GitHubSecretVariable {
357 GitHubSecretVariable {
358 name: variable.name,
359 value: github_variable_value_to_env_string(variable.value),
360 }
361}
362
363fn normalize_variable_name_from_github(variable: GitHubSecretVariable) -> GitHubSecretVariable {
364 GitHubSecretVariable {
365 name: github_variable_name_to_env_name(&variable.name),
366 value: variable.value,
367 }
368}
369
370fn variables_for_github(variables: &HashMap<String, String>) -> HashMap<String, String> {
371 variables
372 .iter()
373 .map(|(name, value)| (env_name_to_github_variable_name(name), value.clone()))
374 .collect()
375}
376
377fn env_name_to_github_variable_name(name: &str) -> String {
378 name.strip_prefix(GITHUB_ENV_PREFIX)
379 .map(|suffix| format!("{}{}", GITHUB_STORAGE_PREFIX, suffix))
380 .unwrap_or_else(|| name.to_string())
381}
382
383fn github_variable_name_to_env_name(name: &str) -> String {
384 name.strip_prefix(GITHUB_STORAGE_PREFIX)
385 .map(|suffix| format!("{}{}", GITHUB_ENV_PREFIX, suffix))
386 .unwrap_or_else(|| name.to_string())
387}
388
389fn plan_variable_writes(
390 secrets: &HashMap<String, String>,
391 existing_names: &HashSet<String>,
392) -> Vec<(String, String, VariableWriteMode)> {
393 let mut plan = secrets
394 .iter()
395 .map(|(name, value)| {
396 let mode = if existing_names.contains(name) {
397 VariableWriteMode::Update
398 } else {
399 VariableWriteMode::Create
400 };
401 (name.clone(), value.clone(), mode)
402 })
403 .collect::<Vec<_>>();
404 plan.sort_by(|left, right| left.0.cmp(&right.0));
405 plan
406}
407
408#[derive(Debug, Deserialize)]
409struct ListVariablesResponse {
410 variables: Vec<VariableEntry>,
411}
412
413#[derive(Debug, Deserialize)]
414struct VariableEntry {
415 name: String,
416 value: serde_json::Value,
417}