github-app-forge 0.1.1

Declarative GitHub App lifecycle management via Manifest flow
Documentation
//! GitHub Apps API client.
//!
//! Three responsibilities:
//!   1. Exchange a manifest-flow `code` for permanent app credentials.
//!   2. List installations of an app + map owner → installation_id.
//!   3. Add/remove repos within an existing installation (full IaC for ongoing
//!      install scope changes).

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";

/// Resolve the API base URL, allowing tests to override via env var.
/// Read fresh each call (no caching) so tests with different wiremock servers
/// can run sequentially without process-restart.
fn api_base() -> String {
    std::env::var("GITHUB_API_URL").unwrap_or_else(|_| GITHUB_API.to_string())
}

/// Permanent credentials returned by the manifest exchange.
#[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,
    /// Installation ID — populated AFTER the operator installs the app on the
    /// target org/user. Empty after `create`; filled by the install step.
    #[serde(default)]
    pub installation_id: Option<u64>,
}

/// Exchange a one-time `code` from the manifest redirect for permanent app
/// credentials. The `code` is single-use and expires within 1 hour.
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,
}

/// List all installations of the app and return the installation ID for the
/// given owner (org or user). Returns `Ok(None)` if the app isn't installed
/// on that owner yet — caller can prompt for an install click.
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))
}

/// Issue an installation access token (15-min lifetime) using the App JWT.
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,
}

/// Rotate the App's private key. Creates a new key, writes the new credentials
/// to the sink, then deletes the old key. Idempotent: if the second or third
/// step fails, re-running the rotate command picks up where it left off
/// (the API tracks both keys until one is explicitly deleted).
///
/// Order of operations matters: the new key MUST be persisted to the sink
/// BEFORE the old one is deleted, otherwise a partial failure could leave the
/// operator with no working credentials.
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)?;

    // 1. Create new private key
    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);

    // 2. Verify the new key works by signing a fresh JWT with it
    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());

    // 3. Persist the new credentials to the sink BEFORE deleting the old key.
    // If sink write fails, the operator still has the old key working.
    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());

    // 4. Now safe to delete the old key.
    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(())
}

/// Look up the first existing key id for the App (the one we want to delete
/// after issuing a fresh one). Returns None if the App has no keys (which
/// shouldn't happen for an App created via the manifest flow).
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))
}

/// Add a list of repos to an existing installation. Idempotent — GitHub
/// returns 204 even if a repo is already in the installation.
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
            )
        })?;

    // Repo IDs are required; resolve each name → id via the search API
    // using an installation token (not the app JWT — repos endpoint requires
    // installation-scoped auth).
    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?;
        // Add repo to installation
        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(())
}