Skip to main content

rho_core/providers/
github.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::RhoResult;
5
6pub const PROVIDER: &str = "github";
7
8#[derive(Debug, Clone, Copy)]
9pub struct GithubIdentityProvider;
10
11impl super::IdentityProvider for GithubIdentityProvider {
12    fn provider(&self) -> &'static str {
13        PROVIDER
14    }
15
16    fn validate_handle(&self, handle: &str) -> RhoResult<()> {
17        validate_handle(handle)
18    }
19}
20
21pub fn identity_id(handle: &str) -> RhoResult<String> {
22    <GithubIdentityProvider as super::IdentityProvider>::identity_id(
23        &GithubIdentityProvider,
24        handle,
25    )
26}
27
28pub fn handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
29    <GithubIdentityProvider as super::IdentityProvider>::handle_from_identity_id(
30        &GithubIdentityProvider,
31        identity_id,
32    )
33}
34
35pub fn provider_url(handle: &str) -> RhoResult<String> {
36    validate_handle(handle)?;
37    Ok(format!("https://github.com/{handle}"))
38}
39
40pub fn handle_from_provider_url(provider_url: &str) -> RhoResult<String> {
41    let Some(handle) = provider_url.strip_prefix("https://github.com/") else {
42        return Err(format!("unsupported github provider_url: {provider_url}").into());
43    };
44    let handle = handle.trim_end_matches('/');
45    if handle.contains('/') || handle.contains('#') || handle.contains('?') {
46        return Err(format!("unsupported github provider_url: {provider_url}").into());
47    }
48    validate_handle(handle)?;
49    Ok(handle.to_string())
50}
51
52pub fn validate_handle(handle: &str) -> RhoResult<()> {
53    if handle.is_empty() || handle.len() > 39 {
54        return Err("github handle must be 1-39 characters".into());
55    }
56    if handle.starts_with('-') || handle.ends_with('-') {
57        return Err(format!("invalid github handle: {handle}").into());
58    }
59    let valid = handle
60        .chars()
61        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-');
62    if !valid || handle.contains("--") {
63        return Err(format!("invalid github handle: {handle}").into());
64    }
65    Ok(())
66}
67
68pub fn repo_candidate_from_remote(remote: &str) -> Option<String> {
69    repo_candidate_from_remote_with_host_resolver(remote, ssh_host_is_github)
70}
71
72pub fn repo_candidate_from_remote_with_host_resolver<F>(
73    remote: &str,
74    host_is_github: F,
75) -> Option<String>
76where
77    F: Fn(&str) -> bool,
78{
79    if let Some(path) = remote.strip_prefix("https://github.com/") {
80        return slug_from_remote_path(path);
81    }
82    if let Some(path) = remote.strip_prefix("git@github.com:") {
83        return slug_from_remote_path(path);
84    }
85    if let Some(rest) = remote.strip_prefix("git@")
86        && let Some((host, path)) = rest.split_once(':')
87        && (host == "github.com" || host_is_github(host))
88    {
89        return slug_from_remote_path(path);
90    }
91    if let Some(rest) = remote.strip_prefix("ssh://git@")
92        && let Some((host, path)) = rest.split_once('/')
93        && (host == "github.com" || host_is_github(host))
94    {
95        return slug_from_remote_path(path);
96    }
97    None
98}
99
100fn slug_from_remote_path(path: &str) -> Option<String> {
101    let path = path.trim_end_matches(".git").trim_matches('/');
102    let mut parts = path.split('/');
103    let owner = parts.next()?;
104    let repo = parts.next()?;
105    if parts.next().is_some() || owner.is_empty() || repo.is_empty() {
106        return None;
107    }
108    Some(format!("{owner}/{repo}"))
109}
110
111fn ssh_host_is_github(host: &str) -> bool {
112    let output = Command::new("ssh").args(["-G", host]).output();
113    let Ok(output) = output else {
114        return false;
115    };
116    if !output.status.success() {
117        return false;
118    }
119    let config = String::from_utf8_lossy(&output.stdout);
120    config.lines().any(|line| {
121        let mut fields = line.split_whitespace();
122        matches!(
123            (fields.next(), fields.next(), fields.next()),
124            (Some("hostname"), Some("github.com"), None)
125        )
126    })
127}
128
129pub fn create_pull_request(
130    root: &Path,
131    title: &str,
132    body: &str,
133    open_browser: bool,
134) -> RhoResult<String> {
135    let existing = Command::new("gh")
136        .current_dir(root)
137        .args(["pr", "view", "--json", "url", "--jq", ".url"])
138        .output();
139    if let Ok(existing) = existing
140        && existing.status.success()
141    {
142        let url = String::from_utf8(existing.stdout)?.trim().to_string();
143        if !url.is_empty() {
144            return Ok(url);
145        }
146    }
147    let mut command = Command::new("gh");
148    command
149        .current_dir(root)
150        // Never block on an interactive prompt (e.g. "Where should we push
151        // the branch?") — without a TTY that would hang forever and can pop
152        // a browser/auth window. Fail fast with a readable error instead.
153        .env("GH_PROMPT_DISABLED", "1")
154        .stdin(std::process::Stdio::null())
155        .args(["pr", "create", "--title", title, "--body", body]);
156    if open_browser {
157        command.arg("--web");
158    }
159    let output = command.output()?;
160    if !output.status.success() {
161        let stderr = String::from_utf8_lossy(&output.stderr);
162        return Err(format!("gh pr create failed: {}", stderr.trim()).into());
163    }
164    Ok(String::from_utf8(output.stdout)?.trim().to_string())
165}