Skip to main content

github_app_forge/
client.rs

1//! GitHub Apps API client.
2//!
3//! Three responsibilities:
4//!   1. Exchange a manifest-flow `code` for permanent app credentials.
5//!   2. List installations of an app + map owner → installation_id.
6//!   3. Add/remove repos within an existing installation (full IaC for ongoing
7//!      install scope changes).
8
9use anyhow::{anyhow, bail, Context, Result};
10use reqwest::Client;
11use serde::{Deserialize, Serialize};
12
13use crate::jwt::sign_app_jwt;
14
15pub const GITHUB_API: &str = "https://api.github.com";
16const USER_AGENT: &str = "github-app-forge/0.1";
17
18/// Resolve the API base URL, allowing tests to override via env var.
19/// Read fresh each call (no caching) so tests with different wiremock servers
20/// can run sequentially without process-restart.
21fn api_base() -> String {
22    std::env::var("GITHUB_API_URL").unwrap_or_else(|_| GITHUB_API.to_string())
23}
24
25/// Permanent credentials returned by the manifest exchange.
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct AppCredentials {
28    pub id: u64,
29    pub slug: String,
30    pub node_id: String,
31    pub owner: serde_json::Value,
32    pub name: String,
33    pub html_url: String,
34    pub pem: String,
35    pub webhook_secret: Option<String>,
36    pub client_id: String,
37    pub client_secret: String,
38    /// Installation ID — populated AFTER the operator installs the app on the
39    /// target org/user. Empty after `create`; filled by the install step.
40    #[serde(default)]
41    pub installation_id: Option<u64>,
42}
43
44/// Exchange a one-time `code` from the manifest redirect for permanent app
45/// credentials. The `code` is single-use and expires within 1 hour.
46pub async fn exchange_manifest_code(code: &str) -> Result<AppCredentials> {
47    let url = format!("{}/app-manifests/{code}/conversions", api_base());
48    let resp = Client::new()
49        .post(&url)
50        .header("User-Agent", USER_AGENT)
51        .header("Accept", "application/vnd.github+json")
52        .send()
53        .await
54        .context("manifest exchange request failed")?;
55    let status = resp.status();
56    let body = resp.text().await?;
57    if !status.is_success() {
58        bail!("manifest exchange returned {}: {}", status, body);
59    }
60    serde_json::from_str(&body).context("failed to parse manifest-exchange response")
61}
62
63#[derive(Debug, Deserialize)]
64struct Installation {
65    id: u64,
66    account: InstallationAccount,
67}
68
69#[derive(Debug, Deserialize)]
70struct InstallationAccount {
71    login: String,
72}
73
74#[derive(Debug, Deserialize)]
75struct InstallationToken {
76    token: String,
77}
78
79/// List all installations of the app and return the installation ID for the
80/// given owner (org or user). Returns `Ok(None)` if the app isn't installed
81/// on that owner yet — caller can prompt for an install click.
82pub async fn lookup_installation_id(
83    creds: &AppCredentials,
84    owner: &str,
85) -> Result<Option<u64>> {
86    let jwt = sign_app_jwt(creds.id, &creds.pem)?;
87    let resp = Client::new()
88        .get(format!("{}/app/installations", api_base()))
89        .header("User-Agent", USER_AGENT)
90        .header("Accept", "application/vnd.github+json")
91        .bearer_auth(&jwt)
92        .send()
93        .await
94        .context("listing installations failed")?;
95    let status = resp.status();
96    let body = resp.text().await?;
97    if !status.is_success() {
98        bail!("list installations returned {}: {}", status, body);
99    }
100    let installations: Vec<Installation> = serde_json::from_str(&body)?;
101    Ok(installations
102        .into_iter()
103        .find(|i| i.account.login.eq_ignore_ascii_case(owner))
104        .map(|i| i.id))
105}
106
107/// Issue an installation access token (15-min lifetime) using the App JWT.
108async fn installation_token(creds: &AppCredentials, installation_id: u64) -> Result<String> {
109    let jwt = sign_app_jwt(creds.id, &creds.pem)?;
110    let resp = Client::new()
111        .post(format!(
112            "{}/app/installations/{installation_id}/access_tokens",
113            api_base()
114        ))
115        .header("User-Agent", USER_AGENT)
116        .header("Accept", "application/vnd.github+json")
117        .bearer_auth(&jwt)
118        .send()
119        .await?;
120    let status = resp.status();
121    let body = resp.text().await?;
122    if !status.is_success() {
123        bail!("installation-token request returned {}: {}", status, body);
124    }
125    let tok: InstallationToken = serde_json::from_str(&body)?;
126    Ok(tok.token)
127}
128
129#[derive(Debug, Deserialize)]
130struct Repo {
131    id: u64,
132    name: String,
133}
134
135#[derive(Debug, Deserialize)]
136struct AppKey {
137    id: u64,
138    key: String,
139}
140
141/// Rotate the App's private key. Creates a new key, writes the new credentials
142/// to the sink, then deletes the old key. Idempotent: if the second or third
143/// step fails, re-running the rotate command picks up where it left off
144/// (the API tracks both keys until one is explicitly deleted).
145///
146/// Order of operations matters: the new key MUST be persisted to the sink
147/// BEFORE the old one is deleted, otherwise a partial failure could leave the
148/// operator with no working credentials.
149pub async fn rotate_private_key(
150    creds: &AppCredentials,
151    sink_cfg: &crate::manifest::SinkConfig,
152) -> Result<()> {
153    use colored::Colorize;
154    println!("{} rotating private key for app: {}", ">>".dimmed(), creds.slug.cyan());
155
156    let old_jwt = sign_app_jwt(creds.id, &creds.pem)?;
157
158    // 1. Create new private key
159    let resp = Client::new()
160        .post(format!("{}/apps/{}/keys", api_base(), creds.slug))
161        .header("User-Agent", USER_AGENT)
162        .header("Accept", "application/vnd.github+json")
163        .bearer_auth(&old_jwt)
164        .send()
165        .await
166        .context("failed to POST new private key")?;
167    let status = resp.status();
168    let body = resp.text().await?;
169    if !status.is_success() {
170        bail!("create key returned {status}: {body}");
171    }
172    let new_key: AppKey =
173        serde_json::from_str(&body).context("parsing new-key response")?;
174    println!("  {} new key issued (id={})", "✓".green(), new_key.id);
175
176    // 2. Verify the new key works by signing a fresh JWT with it
177    let _new_jwt = crate::jwt::sign_app_jwt(creds.id, &new_key.key)
178        .context("the freshly-issued private key failed to parse — aborting before old-key deletion")?;
179    println!("  {} new key signs cleanly (verified before old-key delete)", "✓".green());
180
181    // 3. Persist the new credentials to the sink BEFORE deleting the old key.
182    // If sink write fails, the operator still has the old key working.
183    let mut new_creds = creds.clone();
184    new_creds.pem = new_key.key.clone();
185    crate::sink::write(sink_cfg, &new_creds)?;
186    println!("  {} new credentials written to sink", "✓".green());
187
188    // 4. Now safe to delete the old key.
189    let old_key_id = first_key_id(creds, &old_jwt).await?;
190    if let Some(id) = old_key_id {
191        if id == new_key.id {
192            println!("  {} old/new key id collision — skipping delete (idempotent re-run)", "~".dimmed());
193        } else {
194            let resp = Client::new()
195                .delete(format!("{}/apps/{}/keys/{}", api_base(), creds.slug, id))
196                .header("User-Agent", USER_AGENT)
197                .header("Accept", "application/vnd.github+json")
198                .bearer_auth(&old_jwt)
199                .send()
200                .await
201                .context("failed to DELETE old private key")?;
202            if !resp.status().is_success() {
203                let s = resp.status();
204                let b = resp.text().await?;
205                bail!("delete old key {id} returned {s}: {b}");
206            }
207            println!("  {} old key {id} deleted", "✓".green());
208        }
209    }
210
211    println!("{}", "Rotation complete.".green().bold());
212    Ok(())
213}
214
215/// Look up the first existing key id for the App (the one we want to delete
216/// after issuing a fresh one). Returns None if the App has no keys (which
217/// shouldn't happen for an App created via the manifest flow).
218async fn first_key_id(creds: &AppCredentials, jwt: &str) -> Result<Option<u64>> {
219    let resp = Client::new()
220        .get(format!("{}/apps/{}/keys", api_base(), creds.slug))
221        .header("User-Agent", USER_AGENT)
222        .header("Accept", "application/vnd.github+json")
223        .bearer_auth(jwt)
224        .send()
225        .await?;
226    if !resp.status().is_success() {
227        let s = resp.status();
228        let b = resp.text().await?;
229        bail!("list keys returned {s}: {b}");
230    }
231    let keys: Vec<AppKey> = resp.json().await?;
232    Ok(keys.into_iter().next().map(|k| k.id))
233}
234
235/// Add a list of repos to an existing installation. Idempotent — GitHub
236/// returns 204 even if a repo is already in the installation.
237pub async fn install_on_repos(
238    creds: &AppCredentials,
239    owner: &str,
240    _app_slug: &str,
241    repos: &[String],
242) -> Result<()> {
243    let installation_id = creds
244        .installation_id
245        .or(lookup_installation_id(creds, owner).await?)
246        .ok_or_else(|| {
247            anyhow!(
248                "no installation found for app {} on owner {}",
249                creds.slug,
250                owner
251            )
252        })?;
253
254    // Repo IDs are required; resolve each name → id via the search API
255    // using an installation token (not the app JWT — repos endpoint requires
256    // installation-scoped auth).
257    let token = installation_token(creds, installation_id).await?;
258    let client = Client::new();
259
260    for repo_name in repos {
261        let resp = client
262            .get(format!("{}/repos/{owner}/{repo_name}", api_base()))
263            .header("User-Agent", USER_AGENT)
264            .header("Accept", "application/vnd.github+json")
265            .bearer_auth(&token)
266            .send()
267            .await?;
268        if !resp.status().is_success() {
269            let status = resp.status();
270            let body = resp.text().await?;
271            bail!(
272                "repo lookup {owner}/{repo_name} returned {status}: {body}"
273            );
274        }
275        let repo: Repo = resp.json().await?;
276        // Add repo to installation
277        let put = client
278            .put(format!(
279                "{}/user/installations/{installation_id}/repositories/{}",
280                api_base(),
281                repo.id
282            ))
283            .header("User-Agent", USER_AGENT)
284            .header("Accept", "application/vnd.github+json")
285            .bearer_auth(&token)
286            .send()
287            .await?;
288        if !put.status().is_success() {
289            let status = put.status();
290            let body = put.text().await?;
291            bail!("PUT repo {} returned {status}: {body}", repo.name);
292        }
293        println!("  added {}/{}", owner, repo.name);
294    }
295    Ok(())
296}