diff --git a/lib/crates/arc-cli/src/init.rs b/lib/crates/arc-cli/src/init.rs index f727dd3c2..31935ccdc 100644 --- a/lib/crates/arc-cli/src/init.rs +++ b/lib/crates/arc-cli/src/init.rs @@ -104,5 +104,138 @@ root = \"arc/\" .apply_to("arc run hello") ); + check_github_app_installation().await; + Ok(()) } + +async fn check_github_app_installation() { + // Get the git remote origin URL + let output = match std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .output() + { + Ok(o) if o.status.success() => o, + _ => return, // No origin remote — skip silently + }; + + let remote_url = match String::from_utf8(output.stdout) { + Ok(s) => s.trim().to_string(), + Err(_) => return, + }; + + // Convert SSH URL to HTTPS and parse owner/repo + let https_url = arc_github::ssh_url_to_https(&remote_url); + let (owner, repo) = match arc_github::parse_github_owner_repo(&https_url) { + Ok(pair) => pair, + Err(_) => return, // Not a GitHub repo — skip silently + }; + + // Load CLI config to get app_id and slug + let cli_config = match crate::cli_config::load_cli_config(None) { + Ok(c) => c, + Err(_) => return, + }; + + let app_id = match cli_config.app_id() { + Some(id) => id.to_string(), + None => { + eprintln!( + "\n Run {} to set up the GitHub App", + console::Style::new().cyan().bold().apply_to("arc install") + ); + return; + } + }; + + let slug = cli_config.slug().map(String::from); + + // Build GitHub App credentials + let creds = match crate::build_github_app_credentials(Some(&app_id)) { + Some(c) => c, + None => { + eprintln!( + "\n Set {} to enable GitHub App integration", + console::Style::new() + .cyan() + .bold() + .apply_to("GITHUB_APP_PRIVATE_KEY") + ); + return; + } + }; + + let jwt = match arc_github::sign_app_jwt(&creds.app_id, &creds.private_key_pem) { + Ok(j) => j, + Err(e) => { + eprintln!("\n Warning: failed to sign GitHub App JWT: {e}"); + return; + } + }; + + let client = reqwest::Client::new(); + + match arc_github::check_app_installed(&client, &jwt, &owner, &repo, "https://api.github.com") + .await + { + Ok(true) => { + let green = console::Style::new().green(); + eprintln!( + "\n {} GitHub App is installed for {owner}/{repo}", + green.apply_to("✔") + ); + } + Ok(false) => { + let install_url = match &slug { + Some(s) => format!("https://github.com/apps/{s}/installations/new"), + None => format!("https://github.com/organizations/{owner}/settings/installations"), + }; + + let yellow = console::Style::new().yellow(); + eprintln!( + "\n {} GitHub App is not installed for {owner}/{repo}", + yellow.apply_to("!") + ); + eprintln!(" Install at: {install_url}"); + + // Only prompt if stdin is a terminal + if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + eprintln!(" Press Enter to continue after installing..."); + let _ = tokio::task::spawn_blocking(|| { + let mut buf = String::new(); + let _ = std::io::stdin().read_line(&mut buf); + }) + .await; + + // Re-check after user presses Enter + match arc_github::check_app_installed( + &client, + &jwt, + &owner, + &repo, + "https://api.github.com", + ) + .await + { + Ok(true) => { + let green = console::Style::new().green(); + eprintln!( + " {} GitHub App is installed for {owner}/{repo}", + green.apply_to("✔") + ); + } + Ok(false) => { + eprintln!(" GitHub App is still not installed."); + eprintln!(" Install at: {install_url}"); + } + Err(e) => { + eprintln!(" Warning: could not re-check GitHub App installation: {e}"); + } + } + } + } + Err(e) => { + eprintln!("\n Warning: could not check GitHub App installation: {e}"); + } + } +} diff --git a/lib/crates/arc-cli/src/main.rs b/lib/crates/arc-cli/src/main.rs index a69dd9890..ad10c67af 100644 --- a/lib/crates/arc-cli/src/main.rs +++ b/lib/crates/arc-cli/src/main.rs @@ -127,7 +127,9 @@ enum LlmCommand { Chat(arc_llm::cli::ChatArgs), } -fn build_github_app_credentials(app_id: Option<&str>) -> Option { +pub(crate) fn build_github_app_credentials( + app_id: Option<&str>, +) -> Option { let app_id = app_id?; let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?; let private_key_pem = if raw.starts_with("-----") { diff --git a/lib/crates/arc-github/src/lib.rs b/lib/crates/arc-github/src/lib.rs index 174166807..66dfeaea6 100644 --- a/lib/crates/arc-github/src/lib.rs +++ b/lib/crates/arc-github/src/lib.rs @@ -351,6 +351,42 @@ pub async fn branch_exists( } } +/// Check whether a GitHub App is installed for a specific repository. +/// +/// Uses the App JWT to query `GET /repos/{owner}/{repo}/installation`. +/// Returns `Ok(true)` on 200, `Ok(false)` on 404. +pub async fn check_app_installed( + client: &reqwest::Client, + jwt: &str, + owner: &str, + repo: &str, + base_url: &str, +) -> Result { + let url = format!("{base_url}/repos/{owner}/{repo}/installation"); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {jwt}")) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "arc") + .send() + .await + .map_err(|e| format!("Failed to check GitHub App installation: {e}"))?; + + match resp.status().as_u16() { + 200 => Ok(true), + 404 => Ok(false), + 401 => Err("GitHub App authentication failed. \ + Check that app_id and GITHUB_APP_PRIVATE_KEY are correct." + .to_string()), + 403 => Err("GitHub App installation is suspended. \ + Re-enable it in your organization's GitHub App settings." + .to_string()), + status => Err(format!( + "Unexpected status {status} checking GitHub App installation" + )), + } +} + /// Resolve git clone credentials for a GitHub repository. /// /// Returns `(username, password)` for authenticated cloning. @@ -794,4 +830,59 @@ mod tests { let result = branch_exists(&creds, "owner", "repo", "broken", &server.url()).await; assert!(result.is_err()); } + + // ----------------------------------------------------------------------- + // check_app_installed + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn check_app_installed_returns_true_on_200() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .match_header("Authorization", "Bearer test-jwt") + .with_status(200) + .with_body(r#"{"id": 1}"#) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = check_app_installed(&client, "test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), true); + } + + #[tokio::test] + async fn check_app_installed_returns_false_on_404() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(404) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = check_app_installed(&client, "test-jwt", "owner", "repo", &server.url()).await; + assert_eq!(result.unwrap(), false); + } + + #[tokio::test] + async fn check_app_installed_returns_error_on_401() { + let mut server = mockito::Server::new_async().await; + + server + .mock("GET", "/repos/owner/repo/installation") + .with_status(401) + .create_async() + .await; + + let client = reqwest::Client::new(); + let result = check_app_installed(&client, "test-jwt", "owner", "repo", &server.url()).await; + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("authentication failed"), + "expected auth error" + ); + } }