Skip to main content

xbp_cli/provider_support/
github.rs

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}