rho_core/providers/
github.rs1use 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 .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}