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 let existing = Command::new("gh")
263 .current_dir(root)
264 .args(["pr", "view", "--json", "url", "--jq", ".url"])
265 .output();
266 if let Ok(existing) = existing
267 && existing.status.success()
268 {
269 let url = String::from_utf8(existing.stdout)?.trim().to_string();
270 if !url.is_empty() {
271 return Ok(url);
272 }
273 }
274 let mut command = Command::new("gh");
275 command
276 .current_dir(root)
277 .env("GH_PROMPT_DISABLED", "1")
281 .stdin(std::process::Stdio::null())
282 .args(["pr", "create", "--title", title, "--body", body]);
283 if open_browser {
284 command.arg("--web");
285 }
286 let output = command.output()?;
287 if !output.status.success() {
288 let stderr = String::from_utf8_lossy(&output.stderr);
289 return Err(format!("gh pr create failed: {}", stderr.trim()).into());
290 }
291 Ok(String::from_utf8(output.stdout)?.trim().to_string())
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn validates_repo_names_for_native_provider() {
300 assert!(validate_owner_repo("madhavajay", "rho").is_ok());
301 assert!(validate_owner_repo("madhavajay", "rho.desktop").is_ok());
302 assert!(validate_owner_repo("madhavajay", ".hidden").is_err());
303 assert!(validate_owner_repo("madhavajay", "../rho").is_err());
304 assert!(validate_owner_repo("-bad", "rho").is_err());
305 }
306
307 #[test]
308 fn parses_github_api_user_shape() {
309 let user: GitHubUser = serde_json::from_str(r#"{"login":"madhavajay","id":123}"#).unwrap();
310 assert_eq!(
311 user,
312 GitHubUser {
313 login: "madhavajay".to_string(),
314 id: 123,
315 }
316 );
317 }
318}