fabro/lib/apps/fabro-cli/src/gh.rs
2026-07-23 17:59:34 -04:00

107 lines
3.4 KiB
Rust

use tokio::process::Command;
use tracing::debug;
/// Best-effort wrapper around the `gh` CLI.
///
/// All methods degrade gracefully: if `gh` is missing or not authenticated
/// the caller receives `None` / empty results rather than errors.
pub(crate) struct GhCli {
_private: (),
}
impl GhCli {
/// Attempt to find an authenticated `gh` CLI on PATH.
///
/// Returns `None` if `gh` is not installed or not authenticated
/// against github.com.
pub(crate) async fn detect() -> Option<Self> {
Self::detect_with_command("gh").await
}
async fn detect_with_command(command: &str) -> Option<Self> {
let version = Command::new(command).arg("--version").output().await;
let Ok(output) = version else {
debug!("gh CLI not found on PATH");
return None;
};
if !output.status.success() {
debug!("gh --version failed");
return None;
}
let auth = Command::new(command)
.args(["auth", "status", "--hostname", "github.com"])
.output()
.await;
match auth {
Ok(o) if o.status.success() => {
debug!("gh CLI available and authenticated for github.com");
Some(Self { _private: () })
}
_ => {
debug!("gh is not authenticated for github.com");
None
}
}
}
/// Return the login of the authenticated GitHub user, or `None` on failure.
pub(crate) async fn authenticated_user(&self) -> Option<String> {
let output = Command::new("gh")
.args(["api", "--hostname", "github.com", "/user", "--jq", ".login"])
.output()
.await
.ok()?;
if !output.status.success() {
debug!("gh api /user failed");
return None;
}
let login = String::from_utf8_lossy(&output.stdout).trim().to_string();
if login.is_empty() { None } else { Some(login) }
}
/// List GitHub organizations the authenticated user is an admin of.
///
/// Returns an empty vec on any failure (network, parse, etc.).
pub(crate) async fn list_admin_orgs(&self) -> Vec<String> {
let output = Command::new("gh")
.args([
"api",
"--hostname",
"github.com",
"--paginate",
"/user/memberships/orgs",
"--jq",
r#".[] | select(.role == "admin" and .state == "active") | .organization.login"#,
])
.output()
.await;
let Ok(output) = output else {
debug!("gh api /user/memberships/orgs failed to execute");
return Vec::new();
};
if !output.status.success() {
debug!(
"gh api /user/memberships/orgs exited with {}",
output.status
);
return Vec::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|line| !line.is_empty())
.map(String::from)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn detect_returns_none_when_gh_is_missing() {
let result = GhCli::detect_with_command("fabro-test-gh-that-should-not-exist").await;
assert!(result.is_none());
}
}