mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Check GitHub App installation during arc init
Catches missing GitHub App installations early by verifying the app is installed for the repo's GitHub remote after scaffolding project files. Shows install URL and optional interactive prompt if not installed; never fails init on check errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
92f40e59fb
commit
963f0e2c7c
3 changed files with 227 additions and 1 deletions
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,7 +127,9 @@ enum LlmCommand {
|
|||
Chat(arc_llm::cli::ChatArgs),
|
||||
}
|
||||
|
||||
fn build_github_app_credentials(app_id: Option<&str>) -> Option<arc_github::GitHubAppCredentials> {
|
||||
pub(crate) fn build_github_app_credentials(
|
||||
app_id: Option<&str>,
|
||||
) -> Option<arc_github::GitHubAppCredentials> {
|
||||
let app_id = app_id?;
|
||||
let raw = std::env::var("GITHUB_APP_PRIVATE_KEY").ok()?;
|
||||
let private_key_pem = if raw.starts_with("-----") {
|
||||
|
|
|
|||
|
|
@ -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<bool, String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue