1#![feature(exit_status_error)]
3
4use std::path::Path;
5use std::process::Command;
6
7use rootcause::prelude::ResultExt;
8use rootcause::report;
9use url::Url;
10
11pub mod issue;
12pub mod pr;
13
14const GITHUB_HOST: &str = "github.com";
16const GITHUB_PR_ID_PREFIX: &str = "pull";
18const GITHUB_PR_ID_QUERY_KEY: &str = "pr";
20
21#[derive(strum::AsRefStr, Debug)]
23pub enum RepoViewField {
24 #[strum(serialize = "nameWithOwner")]
25 NameWithOwner,
26 #[strum(serialize = "url")]
27 Url,
28}
29
30impl RepoViewField {
31 pub fn jq_repr(&self) -> String {
33 format!(".{}", self.as_ref())
34 }
35}
36
37pub fn get_repo_view_field(field: &RepoViewField) -> rootcause::Result<String> {
46 let output = Command::new("gh")
47 .args(["repo", "view", "--json", field.as_ref(), "--jq", &field.jq_repr()])
48 .output()
49 .context("error getting repo view field")
50 .attach_with(|| format!("field={field:?}"))?;
51
52 ytil_cmd::extract_success_output(&output)
53}
54
55pub fn log_into_github() -> rootcause::Result<()> {
63 if ytil_cmd::silent_cmd("gh")
64 .args(["auth", "status"])
65 .status()
66 .context("error checking gh auth status")?
67 .success()
68 {
69 return Ok(());
70 }
71
72 Ok(ytil_cmd::silent_cmd("sh")
73 .args(["-c", "gh auth login"])
74 .status()
75 .context("error running gh auth login command")?
76 .exit_ok()
77 .context("error running gh auth login")?)
78}
79
80pub fn get_latest_release(repo: &str) -> rootcause::Result<String> {
87 let output = Command::new("gh")
88 .args(["api", &format!("repos/{repo}/releases/latest"), "--jq=.tag_name"])
89 .output()
90 .context("error getting latest release")
91 .attach_with(|| format!("repo={repo:?}"))?;
92
93 ytil_cmd::extract_success_output(&output)
94}
95
96pub fn get_branch_name_from_url(url: &Url) -> rootcause::Result<String> {
103 let pr_id = extract_pr_id_form_url(url)?;
104
105 let output = Command::new("gh")
106 .args(["pr", "view", &pr_id, "--json", "headRefName", "--jq", ".headRefName"])
107 .output()
108 .context("error getting branch name")
109 .attach_with(|| format!("pr_id={pr_id:?}"))?;
110
111 ytil_cmd::extract_success_output(&output)
112}
113
114pub fn get_repo_urls(repo_path: &Path) -> rootcause::Result<Vec<Url>> {
123 let repo = ytil_git::repo::discover(repo_path)
124 .context("error opening repo")
125 .attach_with(|| format!("path={}", repo_path.display()))?;
126 let mut repo_urls = vec![];
127 for remote_name in repo.remotes()?.iter().flatten() {
128 repo_urls.push(
129 repo.find_remote(remote_name)
130 .context("error finding remote")
131 .attach_with(|| format!("remote={remote_name:?}"))?
132 .url()
133 .map(parse_github_url_from_git_remote_url)
134 .ok_or_else(|| report!("error invalid remote URL UTF-8"))
135 .attach_with(|| format!("remote={remote_name:?}"))
136 .context("error parsing remote URL")
137 .attach_with(|| format!("remote={remote_name:?}"))??,
138 );
139 }
140 Ok(repo_urls)
141}
142
143fn parse_github_url_from_git_remote_url(git_remote_url: &str) -> rootcause::Result<Url> {
152 if let Ok(mut url) = Url::parse(git_remote_url) {
153 url.set_path(url.clone().path().trim_end_matches(".git"));
154 return Ok(url);
155 }
156
157 let path = git_remote_url
158 .split_once(':')
159 .map(|(_, path)| path.trim_end_matches(".git"))
160 .ok_or_else(|| report!("error extracting URL path"))
161 .attach_with(|| format!("git_remote_url={git_remote_url:?}"))?;
162
163 let mut url = Url::parse("https://github.com").context("error parsing base GitHub URL")?;
164 url.set_path(path);
165
166 Ok(url)
167}
168
169fn extract_pr_id_form_url(url: &Url) -> rootcause::Result<String> {
179 let host = url
180 .host_str()
181 .ok_or_else(|| report!("error extracting host from URL"))
182 .attach_with(|| format!("url={url}"))?;
183 if host != GITHUB_HOST {
184 Err(report!("error host mismatch"))
185 .attach_with(|| format!("host={host:?} expected={GITHUB_HOST:?} URL={url}"))?;
186 }
187
188 if let Some(pr_id) = url
192 .query_pairs()
193 .find(|(key, _)| key == GITHUB_PR_ID_QUERY_KEY)
194 .map(|(_, pr_id)| pr_id.to_string())
195 {
196 return Ok(pr_id);
197 }
198
199 let path_segments = url
200 .path_segments()
201 .ok_or_else(|| report!("error URL cannot be base"))
202 .attach_with(|| format!("url={url}"))?
203 .enumerate()
204 .collect::<Vec<_>>();
205
206 match path_segments
207 .iter()
208 .filter(|(_, ps)| ps == &GITHUB_PR_ID_PREFIX)
209 .collect::<Vec<_>>()
210 .as_slice()
211 {
212 [(idx, _)] => Ok(path_segments
213 .get(idx.saturating_add(1))
214 .ok_or_else(|| report!("error missing PR ID"))
215 .attach_with(|| format!("url={url} path_segments={path_segments:#?}"))
216 .and_then(|(_, pr_id)| {
217 if pr_id.is_empty() {
218 return Err(
219 report!("error empty PR ID").attach(format!("url={url} path_segments={path_segments:#?}"))
220 );
221 }
222 Ok((*pr_id).to_string())
223 })?),
224 [] => Err(report!("error missing PR ID prefix").attach(format!(
225 "prefix={GITHUB_PR_ID_PREFIX:?} url={url} path_segments={path_segments:#?}"
226 ))),
227 _ => Err(report!("error multiple PR ID prefixes").attach(format!(
228 "prefix={GITHUB_PR_ID_PREFIX:?} url={url} path_segments={path_segments:#?}"
229 ))),
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use rstest::rstest;
236
237 use super::*;
238
239 #[test]
240 fn extract_pr_id_form_url_returns_the_expected_error_when_host_cannot_be_extracted() {
241 let url = Url::parse("mailto:foo@bar.com").unwrap();
242 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
243 assert_eq!(
244 err.format_current_context().to_string(),
245 "error extracting host from URL"
246 );
247 }
248
249 #[test]
250 fn extract_pr_id_form_url_returns_the_expected_error_when_url_is_not_from_github() {
251 let url = Url::parse("https://foo.bar").unwrap();
252 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
253 assert_eq!(err.format_current_context().to_string(), "error host mismatch");
254 }
255
256 #[test]
257 fn extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_path_segments() {
258 let url = Url::parse(&format!("https://{GITHUB_HOST}")).unwrap();
259 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
260 assert_eq!(err.format_current_context().to_string(), "error missing PR ID prefix");
261 }
262
263 #[test]
264 fn extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_pr_id() {
265 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull")).unwrap();
266 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
267 assert_eq!(err.format_current_context().to_string(), "error missing PR ID");
268 }
269
270 #[test]
271 fn extract_pr_id_form_url_returns_the_expected_error_when_url_doenst_have_the_expected_pr_id_prefix() {
272 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo")).unwrap();
273 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
274 assert_eq!(err.format_current_context().to_string(), "error missing PR ID prefix");
275 }
276
277 #[test]
278 fn extract_pr_id_form_url_returns_the_expected_error_when_url_has_multiple_pr_id_prefixes() {
279 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/pull/43")).unwrap();
280 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
281 assert_eq!(
282 err.format_current_context().to_string(),
283 "error multiple PR ID prefixes"
284 );
285 }
286
287 #[test]
288 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_ends_with_the_pr_id() {
289 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42")).unwrap();
290 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
291 }
292
293 #[test]
294 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_does_not_end_with_the_pr_id() {
295 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/foo")).unwrap();
296 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
297 }
298
299 #[test]
300 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_if_pr_id_prefix_is_not_1st_path_segment()
301 {
302 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo/pull/42/foo")).unwrap();
303 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
304 }
305
306 #[test]
307 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_if_pr_is_in_query_string() {
308 let url = Url::parse(&format!(
309 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=42"
310 ))
311 .unwrap();
312 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
313
314 let url = Url::parse(&format!(
315 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=42"
316 ))
317 .unwrap();
318 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
319 }
320
321 #[rstest]
322 #[case::ssh_url_with_git_suffix(
323 "git@github.com:fusillicode/dotfiles.git",
324 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
325 )]
326 #[case::https_url_without_git_suffix(
327 "https://github.com/fusillicode/dotfiles",
328 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
329 )]
330 fn parse_github_url_from_git_remote_url_works_as_expected(#[case] input: &str, #[case] expected: Url) {
331 let result = parse_github_url_from_git_remote_url(input).unwrap();
332 assert_eq!(result, expected);
333 }
334}