1use 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
18fn api_base() -> String {
22 std::env::var("GITHUB_API_URL").unwrap_or_else(|_| GITHUB_API.to_string())
23}
24
25#[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 #[serde(default)]
41 pub installation_id: Option<u64>,
42}
43
44pub 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
79pub 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
107async 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
141pub 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 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 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 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 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
215async 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
235pub 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 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 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}