rho-cli 0.1.28

Rho CLI tools for encrypted agent collaboration, dataset publishing, controlled runs, and result release workflows
Documentation
use std::path::Path;
use std::process::Command;

use crate::RhoResult;

pub const PROVIDER: &str = "github";
const GITHUB_API_VERSION: &str = "2022-11-28";

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct GitHubUser {
    pub login: String,
    pub id: u64,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
pub struct GitHubRepository {
    pub full_name: String,
    pub html_url: String,
    pub private: bool,
}

pub trait GitHubAccountProvider {
    fn current_user(&self) -> RhoResult<GitHubUser>;
    fn repository(&self, owner: &str, repo: &str) -> RhoResult<Option<GitHubRepository>>;

    fn repository_exists(&self, owner: &str, repo: &str) -> RhoResult<bool> {
        Ok(self.repository(owner, repo)?.is_some())
    }
}

#[derive(Debug, Clone, Copy, Default)]
pub struct GitHubCliProvider;

#[derive(Debug, Clone)]
pub struct GitHubApiProvider {
    token: String,
}

impl GitHubApiProvider {
    pub fn new(token: impl Into<String>) -> Self {
        Self {
            token: token.into(),
        }
    }

    fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> RhoResult<T> {
        let url = format!("https://api.github.com{path}");
        let response = ureq::get(&url)
            .set("Accept", "application/vnd.github+json")
            .set("Authorization", &format!("Bearer {}", self.token))
            .set("X-GitHub-Api-Version", GITHUB_API_VERSION)
            .call()
            .map_err(|error| format!("GitHub API GET {path} failed: {error}"))?;
        let body = response
            .into_string()
            .map_err(|error| format!("failed to read GitHub API response: {error}"))?;
        Ok(serde_json::from_str(&body)
            .map_err(|error| format!("failed to parse GitHub API response: {error}"))?)
    }
}

impl GitHubAccountProvider for GitHubApiProvider {
    fn current_user(&self) -> RhoResult<GitHubUser> {
        self.get_json("/user")
    }

    fn repository(&self, owner: &str, repo: &str) -> RhoResult<Option<GitHubRepository>> {
        validate_owner_repo(owner, repo)?;
        let path = format!("/repos/{owner}/{repo}");
        match self.get_json(&path) {
            Ok(repository) => Ok(Some(repository)),
            Err(error) if error.to_string().contains("404") => Ok(None),
            Err(error) => Err(error),
        }
    }
}

impl GitHubAccountProvider for GitHubCliProvider {
    fn current_user(&self) -> RhoResult<GitHubUser> {
        let output = Command::new("gh")
            .args(["api", "user", "--jq", "{login: .login, id: .id}"])
            .output()?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(format!("gh api user failed: {}", stderr.trim()).into());
        }
        Ok(serde_json::from_slice(&output.stdout)?)
    }

    fn repository(&self, owner: &str, repo: &str) -> RhoResult<Option<GitHubRepository>> {
        validate_owner_repo(owner, repo)?;
        let slug = format!("{owner}/{repo}");
        let output = Command::new("gh")
            .args([
                "repo",
                "view",
                &slug,
                "--json",
                "nameWithOwner,url,isPrivate",
            ])
            .output()?;
        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            if stderr.contains("Could not resolve to a Repository")
                || stderr.contains("HTTP 404")
                || stderr.contains("not found")
            {
                return Ok(None);
            }
            return Err(format!("gh repo view {slug} failed: {}", stderr.trim()).into());
        }
        let value: serde_json::Value = serde_json::from_slice(&output.stdout)?;
        Ok(Some(GitHubRepository {
            full_name: value["nameWithOwner"].as_str().unwrap_or(&slug).to_string(),
            html_url: value["url"].as_str().unwrap_or_default().to_string(),
            private: value["isPrivate"].as_bool().unwrap_or(false),
        }))
    }
}

#[derive(Debug, Clone, Copy)]
pub struct GithubIdentityProvider;

impl super::IdentityProvider for GithubIdentityProvider {
    fn provider(&self) -> &'static str {
        PROVIDER
    }

    fn validate_handle(&self, handle: &str) -> RhoResult<()> {
        validate_handle(handle)
    }
}

pub fn identity_id(handle: &str) -> RhoResult<String> {
    <GithubIdentityProvider as super::IdentityProvider>::identity_id(
        &GithubIdentityProvider,
        handle,
    )
}

pub fn handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
    <GithubIdentityProvider as super::IdentityProvider>::handle_from_identity_id(
        &GithubIdentityProvider,
        identity_id,
    )
}

pub fn provider_url(handle: &str) -> RhoResult<String> {
    validate_handle(handle)?;
    Ok(format!("https://github.com/{handle}"))
}

pub fn handle_from_provider_url(provider_url: &str) -> RhoResult<String> {
    let Some(handle) = provider_url.strip_prefix("https://github.com/") else {
        return Err(format!("unsupported github provider_url: {provider_url}").into());
    };
    let handle = handle.trim_end_matches('/');
    if handle.contains('/') || handle.contains('#') || handle.contains('?') {
        return Err(format!("unsupported github provider_url: {provider_url}").into());
    }
    validate_handle(handle)?;
    Ok(handle.to_string())
}

pub fn validate_handle(handle: &str) -> RhoResult<()> {
    if handle.is_empty() || handle.len() > 39 {
        return Err("github handle must be 1-39 characters".into());
    }
    if handle.starts_with('-') || handle.ends_with('-') {
        return Err(format!("invalid github handle: {handle}").into());
    }
    let valid = handle
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-');
    if !valid || handle.contains("--") {
        return Err(format!("invalid github handle: {handle}").into());
    }
    Ok(())
}

fn validate_owner_repo(owner: &str, repo: &str) -> RhoResult<()> {
    validate_handle(owner)?;
    if repo.is_empty() || repo.len() > 100 {
        return Err("github repo name must be 1-100 characters".into());
    }
    let valid = repo
        .bytes()
        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
    if !valid || repo == "." || repo == ".." || repo.starts_with('.') {
        return Err(format!("invalid github repo name: {repo}").into());
    }
    Ok(())
}

pub fn repo_candidate_from_remote(remote: &str) -> Option<String> {
    repo_candidate_from_remote_with_host_resolver(remote, ssh_host_is_github)
}

pub fn repo_candidate_from_remote_with_host_resolver<F>(
    remote: &str,
    host_is_github: F,
) -> Option<String>
where
    F: Fn(&str) -> bool,
{
    if let Some(path) = remote.strip_prefix("https://github.com/") {
        return slug_from_remote_path(path);
    }
    if let Some(path) = remote.strip_prefix("git@github.com:") {
        return slug_from_remote_path(path);
    }
    if let Some(rest) = remote.strip_prefix("git@")
        && let Some((host, path)) = rest.split_once(':')
        && (host == "github.com" || host_is_github(host))
    {
        return slug_from_remote_path(path);
    }
    if let Some(rest) = remote.strip_prefix("ssh://git@")
        && let Some((host, path)) = rest.split_once('/')
        && (host == "github.com" || host_is_github(host))
    {
        return slug_from_remote_path(path);
    }
    None
}

fn slug_from_remote_path(path: &str) -> Option<String> {
    let path = path.trim_end_matches(".git").trim_matches('/');
    let mut parts = path.split('/');
    let owner = parts.next()?;
    let repo = parts.next()?;
    if parts.next().is_some() || owner.is_empty() || repo.is_empty() {
        return None;
    }
    Some(format!("{owner}/{repo}"))
}

fn ssh_host_is_github(host: &str) -> bool {
    let output = Command::new("ssh").args(["-G", host]).output();
    let Ok(output) = output else {
        return false;
    };
    if !output.status.success() {
        return false;
    }
    let config = String::from_utf8_lossy(&output.stdout);
    config.lines().any(|line| {
        let mut fields = line.split_whitespace();
        matches!(
            (fields.next(), fields.next(), fields.next()),
            (Some("hostname"), Some("github.com"), None)
        )
    })
}

pub fn create_pull_request(
    root: &Path,
    title: &str,
    body: &str,
    open_browser: bool,
) -> RhoResult<String> {
    let existing = Command::new("gh")
        .current_dir(root)
        .args(["pr", "view", "--json", "url", "--jq", ".url"])
        .output();
    if let Ok(existing) = existing
        && existing.status.success()
    {
        let url = String::from_utf8(existing.stdout)?.trim().to_string();
        if !url.is_empty() {
            return Ok(url);
        }
    }
    let mut command = Command::new("gh");
    command
        .current_dir(root)
        // Never block on an interactive prompt (e.g. "Where should we push
        // the branch?") — without a TTY that would hang forever and can pop
        // a browser/auth window. Fail fast with a readable error instead.
        .env("GH_PROMPT_DISABLED", "1")
        .stdin(std::process::Stdio::null())
        .args(["pr", "create", "--title", title, "--body", body]);
    if open_browser {
        command.arg("--web");
    }
    let output = command.output()?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(format!("gh pr create failed: {}", stderr.trim()).into());
    }
    Ok(String::from_utf8(output.stdout)?.trim().to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_repo_names_for_native_provider() {
        assert!(validate_owner_repo("madhavajay", "rho").is_ok());
        assert!(validate_owner_repo("madhavajay", "rho.desktop").is_ok());
        assert!(validate_owner_repo("madhavajay", ".hidden").is_err());
        assert!(validate_owner_repo("madhavajay", "../rho").is_err());
        assert!(validate_owner_repo("-bad", "rho").is_err());
    }

    #[test]
    fn parses_github_api_user_shape() {
        let user: GitHubUser = serde_json::from_str(r#"{"login":"madhavajay","id":123}"#).unwrap();
        assert_eq!(
            user,
            GitHubUser {
                login: "madhavajay".to_string(),
                id: 123,
            }
        );
    }
}