use anyhow::{anyhow, bail, Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::jwt::sign_app_jwt;
pub const GITHUB_API: &str = "https://api.github.com";
const USER_AGENT: &str = "github-app-forge/0.1";
fn api_base() -> String {
std::env::var("GITHUB_API_URL").unwrap_or_else(|_| GITHUB_API.to_string())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AppCredentials {
pub id: u64,
pub slug: String,
pub node_id: String,
pub owner: serde_json::Value,
pub name: String,
pub html_url: String,
pub pem: String,
pub webhook_secret: Option<String>,
pub client_id: String,
pub client_secret: String,
#[serde(default)]
pub installation_id: Option<u64>,
}
pub async fn exchange_manifest_code(code: &str) -> Result<AppCredentials> {
let url = format!("{}/app-manifests/{code}/conversions", api_base());
let resp = Client::new()
.post(&url)
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("manifest exchange request failed")?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
bail!("manifest exchange returned {}: {}", status, body);
}
serde_json::from_str(&body).context("failed to parse manifest-exchange response")
}
#[derive(Debug, Deserialize)]
struct Installation {
id: u64,
account: InstallationAccount,
}
#[derive(Debug, Deserialize)]
struct InstallationAccount {
login: String,
}
#[derive(Debug, Deserialize)]
struct InstallationToken {
token: String,
}
pub async fn lookup_installation_id(
creds: &AppCredentials,
owner: &str,
) -> Result<Option<u64>> {
let jwt = sign_app_jwt(creds.id, &creds.pem)?;
let resp = Client::new()
.get(format!("{}/app/installations", api_base()))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&jwt)
.send()
.await
.context("listing installations failed")?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
bail!("list installations returned {}: {}", status, body);
}
let installations: Vec<Installation> = serde_json::from_str(&body)?;
Ok(installations
.into_iter()
.find(|i| i.account.login.eq_ignore_ascii_case(owner))
.map(|i| i.id))
}
async fn installation_token(creds: &AppCredentials, installation_id: u64) -> Result<String> {
let jwt = sign_app_jwt(creds.id, &creds.pem)?;
let resp = Client::new()
.post(format!(
"{}/app/installations/{installation_id}/access_tokens",
api_base()
))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&jwt)
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
bail!("installation-token request returned {}: {}", status, body);
}
let tok: InstallationToken = serde_json::from_str(&body)?;
Ok(tok.token)
}
#[derive(Debug, Deserialize)]
struct Repo {
id: u64,
name: String,
}
#[derive(Debug, Deserialize)]
struct AppKey {
id: u64,
key: String,
}
pub async fn rotate_private_key(
creds: &AppCredentials,
sink_cfg: &crate::manifest::SinkConfig,
) -> Result<()> {
use colored::Colorize;
println!("{} rotating private key for app: {}", ">>".dimmed(), creds.slug.cyan());
let old_jwt = sign_app_jwt(creds.id, &creds.pem)?;
let resp = Client::new()
.post(format!("{}/apps/{}/keys", api_base(), creds.slug))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&old_jwt)
.send()
.await
.context("failed to POST new private key")?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
bail!("create key returned {status}: {body}");
}
let new_key: AppKey =
serde_json::from_str(&body).context("parsing new-key response")?;
println!(" {} new key issued (id={})", "✓".green(), new_key.id);
let _new_jwt = crate::jwt::sign_app_jwt(creds.id, &new_key.key)
.context("the freshly-issued private key failed to parse — aborting before old-key deletion")?;
println!(" {} new key signs cleanly (verified before old-key delete)", "✓".green());
let mut new_creds = creds.clone();
new_creds.pem = new_key.key.clone();
crate::sink::write(sink_cfg, &new_creds)?;
println!(" {} new credentials written to sink", "✓".green());
let old_key_id = first_key_id(creds, &old_jwt).await?;
if let Some(id) = old_key_id {
if id == new_key.id {
println!(" {} old/new key id collision — skipping delete (idempotent re-run)", "~".dimmed());
} else {
let resp = Client::new()
.delete(format!("{}/apps/{}/keys/{}", api_base(), creds.slug, id))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&old_jwt)
.send()
.await
.context("failed to DELETE old private key")?;
if !resp.status().is_success() {
let s = resp.status();
let b = resp.text().await?;
bail!("delete old key {id} returned {s}: {b}");
}
println!(" {} old key {id} deleted", "✓".green());
}
}
println!("{}", "Rotation complete.".green().bold());
Ok(())
}
async fn first_key_id(creds: &AppCredentials, jwt: &str) -> Result<Option<u64>> {
let resp = Client::new()
.get(format!("{}/apps/{}/keys", api_base(), creds.slug))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(jwt)
.send()
.await?;
if !resp.status().is_success() {
let s = resp.status();
let b = resp.text().await?;
bail!("list keys returned {s}: {b}");
}
let keys: Vec<AppKey> = resp.json().await?;
Ok(keys.into_iter().next().map(|k| k.id))
}
pub async fn install_on_repos(
creds: &AppCredentials,
owner: &str,
_app_slug: &str,
repos: &[String],
) -> Result<()> {
let installation_id = creds
.installation_id
.or(lookup_installation_id(creds, owner).await?)
.ok_or_else(|| {
anyhow!(
"no installation found for app {} on owner {}",
creds.slug,
owner
)
})?;
let token = installation_token(creds, installation_id).await?;
let client = Client::new();
for repo_name in repos {
let resp = client
.get(format!("{}/repos/{owner}/{repo_name}", api_base()))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&token)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await?;
bail!(
"repo lookup {owner}/{repo_name} returned {status}: {body}"
);
}
let repo: Repo = resp.json().await?;
let put = client
.put(format!(
"{}/user/installations/{installation_id}/repositories/{}",
api_base(),
repo.id
))
.header("User-Agent", USER_AGENT)
.header("Accept", "application/vnd.github+json")
.bearer_auth(&token)
.send()
.await?;
if !put.status().is_success() {
let status = put.status();
let body = put.text().await?;
bail!("PUT repo {} returned {status}: {body}", repo.name);
}
println!(" added {}/{}", owner, repo.name);
}
Ok(())
}