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";
7const GITHUB_API_VERSION: &str = "2022-11-28";
8
9#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
10pub struct GitHubUser {
11    pub login: String,
12    pub id: u64,
13}
14
15#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq, Eq)]
16pub struct GitHubRepository {
17    pub full_name: String,
18    pub html_url: String,
19    pub private: bool,
20}
21
22pub trait GitHubAccountProvider {
23    fn current_user(&self) -> RhoResult<GitHubUser>;
24    fn repository(&self, owner: &str, repo: &str) -> RhoResult<Option<GitHubRepository>>;
25
26    fn repository_exists(&self, owner: &str, repo: &str) -> RhoResult<bool> {
27        Ok(self.repository(owner, repo)?.is_some())
28    }
29}
30
31#[derive(Debug, Clone, Copy, Default)]
32pub struct GitHubCliProvider;
33
34#[derive(Debug, Clone)]
35pub struct GitHubApiProvider {
36    token: String,
37}
38
39impl GitHubApiProvider {
40    pub fn new(token: impl Into<String>) -> Self {
41        Self {
42            token: token.into(),
43        }
44    }
45
46    fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> RhoResult<T> {
47        let url = format!("https://api.github.com{path}");
48        let response = ureq::get(&url)
49            .set("Accept", "application/vnd.github+json")
50            .set("Authorization", &format!("Bearer {}", self.token))
51            .set("X-GitHub-Api-Version", GITHUB_API_VERSION)
52            .call()
53            .map_err(|error| format!("GitHub API GET {path} failed: {error}"))?;
54        let body = response
55            .into_string()
56            .map_err(|error| format!("failed to read GitHub API response: {error}"))?;
57        Ok(serde_json::from_str(&body)
58            .map_err(|error| format!("failed to parse GitHub API response: {error}"))?)
59    }
60}
61
62impl GitHubAccountProvider for GitHubApiProvider {
63    fn current_user(&self) -> RhoResult<GitHubUser> {
64        self.get_json("/user")
65    }
66
67    fn repository(&self, owner: &str, repo: &str) -> RhoResult<Option<GitHubRepository>> {
68        validate_owner_repo(owner, repo)?;
69        let path = format!("/repos/{owner}/{repo}");
70        match self.get_json(&path) {
71            Ok(repository) => Ok(Some(repository)),
72            Err(error) if error.to_string().contains("404") => Ok(None),
73            Err(error) => Err(error),
74        }
75    }
76}
77
78impl GitHubAccountProvider for GitHubCliProvider {
79    fn current_user(&self) -> RhoResult<GitHubUser> {
80        let output = Command::new("gh")
81            .args(["api", "user", "--jq", "{login: .login, id: .id}"])
82            .output()?;
83        if !output.status.success() {
84            let stderr = String::from_utf8_lossy(&output.stderr);
85            return Err(format!("gh api user failed: {}", stderr.trim()).into());
86        }
87        Ok(serde_json::from_slice(&output.stdout)?)
88    }
89
90    fn repository(&self, owner: &str, repo: &str) -> RhoResult<Option<GitHubRepository>> {
91        validate_owner_repo(owner, repo)?;
92        let slug = format!("{owner}/{repo}");
93        let output = Command::new("gh")
94            .args([
95                "repo",
96                "view",
97                &slug,
98                "--json",
99                "nameWithOwner,url,isPrivate",
100            ])
101            .output()?;
102        if !output.status.success() {
103            let stderr = String::from_utf8_lossy(&output.stderr);
104            if stderr.contains("Could not resolve to a Repository")
105                || stderr.contains("HTTP 404")
106                || stderr.contains("not found")
107            {
108                return Ok(None);
109            }
110            return Err(format!("gh repo view {slug} failed: {}", stderr.trim()).into());
111        }
112        let value: serde_json::Value = serde_json::from_slice(&output.stdout)?;
113        Ok(Some(GitHubRepository {
114            full_name: value["nameWithOwner"].as_str().unwrap_or(&slug).to_string(),
115            html_url: value["url"].as_str().unwrap_or_default().to_string(),
116            private: value["isPrivate"].as_bool().unwrap_or(false),
117        }))
118    }
119}
120
121#[derive(Debug, Clone, Copy)]
122pub struct GithubIdentityProvider;
123
124impl super::IdentityProvider for GithubIdentityProvider {
125    fn provider(&self) -> &'static str {
126        PROVIDER
127    }
128
129    fn validate_handle(&self, handle: &str) -> RhoResult<()> {
130        validate_handle(handle)
131    }
132}
133
134pub fn identity_id(handle: &str) -> RhoResult<String> {
135    <GithubIdentityProvider as super::IdentityProvider>::identity_id(
136        &GithubIdentityProvider,
137        handle,
138    )
139}
140
141pub fn handle_from_identity_id(identity_id: &str) -> RhoResult<String> {
142    <GithubIdentityProvider as super::IdentityProvider>::handle_from_identity_id(
143        &GithubIdentityProvider,
144        identity_id,
145    )
146}
147
148pub fn provider_url(handle: &str) -> RhoResult<String> {
149    validate_handle(handle)?;
150    Ok(format!("https://github.com/{handle}"))
151}
152
153pub fn handle_from_provider_url(provider_url: &str) -> RhoResult<String> {
154    let Some(handle) = provider_url.strip_prefix("https://github.com/") else {
155        return Err(format!("unsupported github provider_url: {provider_url}").into());
156    };
157    let handle = handle.trim_end_matches('/');
158    if handle.contains('/') || handle.contains('#') || handle.contains('?') {
159        return Err(format!("unsupported github provider_url: {provider_url}").into());
160    }
161    validate_handle(handle)?;
162    Ok(handle.to_string())
163}
164
165pub fn validate_handle(handle: &str) -> RhoResult<()> {
166    if handle.is_empty() || handle.len() > 39 {
167        return Err("github handle must be 1-39 characters".into());
168    }
169    if handle.starts_with('-') || handle.ends_with('-') {
170        return Err(format!("invalid github handle: {handle}").into());
171    }
172    let valid = handle
173        .chars()
174        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-');
175    if !valid || handle.contains("--") {
176        return Err(format!("invalid github handle: {handle}").into());
177    }
178    Ok(())
179}
180
181fn validate_owner_repo(owner: &str, repo: &str) -> RhoResult<()> {
182    validate_handle(owner)?;
183    if repo.is_empty() || repo.len() > 100 {
184        return Err("github repo name must be 1-100 characters".into());
185    }
186    let valid = repo
187        .bytes()
188        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'));
189    if !valid || repo == "." || repo == ".." || repo.starts_with('.') {
190        return Err(format!("invalid github repo name: {repo}").into());
191    }
192    Ok(())
193}
194
195pub fn repo_candidate_from_remote(remote: &str) -> Option<String> {
196    repo_candidate_from_remote_with_host_resolver(remote, ssh_host_is_github)
197}
198
199pub fn repo_candidate_from_remote_with_host_resolver<F>(
200    remote: &str,
201    host_is_github: F,
202) -> Option<String>
203where
204    F: Fn(&str) -> bool,
205{
206    if let Some(path) = remote.strip_prefix("https://github.com/") {
207        return slug_from_remote_path(path);
208    }
209    if let Some(path) = remote.strip_prefix("git@github.com:") {
210        return slug_from_remote_path(path);
211    }
212    if let Some(rest) = remote.strip_prefix("git@")
213        && let Some((host, path)) = rest.split_once(':')
214        && (host == "github.com" || host_is_github(host))
215    {
216        return slug_from_remote_path(path);
217    }
218    if let Some(rest) = remote.strip_prefix("ssh://git@")
219        && let Some((host, path)) = rest.split_once('/')
220        && (host == "github.com" || host_is_github(host))
221    {
222        return slug_from_remote_path(path);
223    }
224    None
225}
226
227fn slug_from_remote_path(path: &str) -> Option<String> {
228    let path = path.trim_end_matches(".git").trim_matches('/');
229    let mut parts = path.split('/');
230    let owner = parts.next()?;
231    let repo = parts.next()?;
232    if parts.next().is_some() || owner.is_empty() || repo.is_empty() {
233        return None;
234    }
235    Some(format!("{owner}/{repo}"))
236}
237
238fn ssh_host_is_github(host: &str) -> bool {
239    let output = Command::new("ssh").args(["-G", host]).output();
240    let Ok(output) = output else {
241        return false;
242    };
243    if !output.status.success() {
244        return false;
245    }
246    let config = String::from_utf8_lossy(&output.stdout);
247    config.lines().any(|line| {
248        let mut fields = line.split_whitespace();
249        matches!(
250            (fields.next(), fields.next(), fields.next()),
251            (Some("hostname"), Some("github.com"), None)
252        )
253    })
254}
255
256pub fn create_pull_request(
257    root: &Path,
258    title: &str,
259    body: &str,
260    open_browser: bool,
261) -> RhoResult<String> {
262    // Reuse the PR only if one is already OPEN for this branch. A previously
263    // CLOSED (rejected) or MERGED PR should not block a fresh request — open a
264    // new PR instead. (Old ones can be auto-closed via a GitHub Action later.)
265    let existing = Command::new("gh")
266        .current_dir(root)
267        .args(["pr", "view", "--json", "url,state"])
268        .output();
269    if let Ok(existing) = existing
270        && existing.status.success()
271        && let Ok(value) = serde_json::from_slice::<serde_json::Value>(&existing.stdout)
272    {
273        let url = value["url"].as_str().unwrap_or_default().to_string();
274        if value["state"].as_str() == Some("OPEN") && !url.is_empty() {
275            return Ok(url);
276        }
277    }
278    let mut command = Command::new("gh");
279    command
280        .current_dir(root)
281        // Never block on an interactive prompt (e.g. "Where should we push
282        // the branch?") — without a TTY that would hang forever and can pop
283        // a browser/auth window. Fail fast with a readable error instead.
284        .env("GH_PROMPT_DISABLED", "1")
285        .stdin(std::process::Stdio::null())
286        .args(["pr", "create", "--title", title, "--body", body]);
287    // With prompts disabled, gh can't ask which branch to push, so tell it the
288    // head explicitly (the current branch, which we've already pushed to origin).
289    if let Some(branch) = current_branch_name(root) {
290        command.args(["--head", &branch]);
291    }
292    if open_browser {
293        command.arg("--web");
294    }
295    let output = command.output()?;
296    if !output.status.success() {
297        let stderr = String::from_utf8_lossy(&output.stderr);
298        return Err(format!("gh pr create failed: {}", stderr.trim()).into());
299    }
300    Ok(String::from_utf8(output.stdout)?.trim().to_string())
301}
302
303fn current_branch_name(root: &Path) -> Option<String> {
304    let output = Command::new("git")
305        .current_dir(root)
306        .args(["rev-parse", "--abbrev-ref", "HEAD"])
307        .output()
308        .ok()?;
309    if !output.status.success() {
310        return None;
311    }
312    let branch = String::from_utf8(output.stdout).ok()?.trim().to_string();
313    if branch.is_empty() || branch == "HEAD" {
314        None
315    } else {
316        Some(branch)
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn validates_repo_names_for_native_provider() {
326        assert!(validate_owner_repo("madhavajay", "rho").is_ok());
327        assert!(validate_owner_repo("madhavajay", "rho.desktop").is_ok());
328        assert!(validate_owner_repo("madhavajay", ".hidden").is_err());
329        assert!(validate_owner_repo("madhavajay", "../rho").is_err());
330        assert!(validate_owner_repo("-bad", "rho").is_err());
331    }
332
333    #[test]
334    fn parses_github_api_user_shape() {
335        let user: GitHubUser = serde_json::from_str(r#"{"login":"madhavajay","id":123}"#).unwrap();
336        assert_eq!(
337            user,
338            GitHubUser {
339                login: "madhavajay".to_string(),
340                id: 123,
341            }
342        );
343    }
344}