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().filter_map(Result::ok).flatten() {
128 let remote = repo
129 .find_remote(remote_name)
130 .context("error finding remote")
131 .attach_with(|| format!("remote={remote_name:?}"))?;
132 let remote_url = remote
133 .url()
134 .context("error invalid remote URL UTF-8")
135 .attach_with(|| format!("remote={remote_name:?}"))?;
136 let repo_url = parse_github_url_from_git_remote_url(remote_url)
137 .context("error parsing remote URL")
138 .attach_with(|| format!("remote={remote_name:?}"))?;
139 repo_urls.push(repo_url);
140 }
141 Ok(repo_urls)
142}
143
144fn parse_github_url_from_git_remote_url(git_remote_url: &str) -> rootcause::Result<Url> {
153 if let Ok(mut url) = Url::parse(git_remote_url) {
154 url.set_path(url.clone().path().trim_end_matches(".git"));
155 return Ok(url);
156 }
157
158 let path = git_remote_url
159 .split_once(':')
160 .map(|(_, path)| path.trim_end_matches(".git"))
161 .ok_or_else(|| report!("error extracting URL path"))
162 .attach_with(|| format!("git_remote_url={git_remote_url:?}"))?;
163
164 let mut url = Url::parse("https://github.com").context("error parsing base GitHub URL")?;
165 url.set_path(path);
166
167 Ok(url)
168}
169
170fn extract_pr_id_form_url(url: &Url) -> rootcause::Result<String> {
180 let host = url
181 .host_str()
182 .ok_or_else(|| report!("error extracting host from URL"))
183 .attach_with(|| format!("url={url}"))?;
184 if host != GITHUB_HOST {
185 Err(report!("error host mismatch"))
186 .attach_with(|| format!("host={host:?} expected={GITHUB_HOST:?} URL={url}"))?;
187 }
188
189 if let Some(pr_id) = url
193 .query_pairs()
194 .find(|(key, _)| key == GITHUB_PR_ID_QUERY_KEY)
195 .map(|(_, pr_id)| pr_id.to_string())
196 {
197 return Ok(pr_id);
198 }
199
200 let path_segments = url
201 .path_segments()
202 .ok_or_else(|| report!("error URL cannot be base"))
203 .attach_with(|| format!("url={url}"))?
204 .enumerate()
205 .collect::<Vec<_>>();
206
207 match path_segments
208 .iter()
209 .filter(|(_, ps)| ps == &GITHUB_PR_ID_PREFIX)
210 .collect::<Vec<_>>()
211 .as_slice()
212 {
213 [(idx, _)] => Ok(path_segments
214 .get(idx.saturating_add(1))
215 .ok_or_else(|| report!("error missing PR ID"))
216 .attach_with(|| format!("url={url} path_segments={path_segments:#?}"))
217 .and_then(|(_, pr_id)| {
218 if pr_id.is_empty() {
219 return Err(
220 report!("error empty PR ID").attach(format!("url={url} path_segments={path_segments:#?}"))
221 );
222 }
223 Ok((*pr_id).to_string())
224 })?),
225 [] => Err(report!("error missing PR ID prefix").attach(format!(
226 "prefix={GITHUB_PR_ID_PREFIX:?} url={url} path_segments={path_segments:#?}"
227 ))),
228 _ => Err(report!("error multiple PR ID prefixes").attach(format!(
229 "prefix={GITHUB_PR_ID_PREFIX:?} url={url} path_segments={path_segments:#?}"
230 ))),
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use rstest::rstest;
237 use test_that::prelude::*;
238
239 use super::*;
240
241 #[test]
242 fn test_extract_pr_id_form_url_returns_the_expected_error_when_host_cannot_be_extracted() {
243 let url = Url::parse("mailto:foo@bar.com").unwrap();
244 assert_that!(
245 (extract_pr_id_form_url(&url)).map(|_| ()),
246 err(result_of!(
247 |err: &rootcause::Report| err.format_current_context().to_string(),
248 eq("error extracting host from URL")
249 ))
250 );
251 }
252
253 #[test]
254 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_is_not_from_github() {
255 let url = Url::parse("https://foo.bar").unwrap();
256 assert_that!(
257 (extract_pr_id_form_url(&url)).map(|_| ()),
258 err(result_of!(
259 |err: &rootcause::Report| err.format_current_context().to_string(),
260 eq("error host mismatch")
261 ))
262 );
263 }
264
265 #[test]
266 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_path_segments() {
267 let url = Url::parse(&format!("https://{GITHUB_HOST}")).unwrap();
268 assert_that!(
269 (extract_pr_id_form_url(&url)).map(|_| ()),
270 err(result_of!(
271 |err: &rootcause::Report| err.format_current_context().to_string(),
272 eq("error missing PR ID prefix")
273 ))
274 );
275 }
276
277 #[test]
278 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_pr_id() {
279 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull")).unwrap();
280 assert_that!(
281 (extract_pr_id_form_url(&url)).map(|_| ()),
282 err(result_of!(
283 |err: &rootcause::Report| err.format_current_context().to_string(),
284 eq("error missing PR ID")
285 ))
286 );
287 }
288
289 #[test]
290 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_doenst_have_the_expected_pr_id_prefix() {
291 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo")).unwrap();
292 assert_that!(
293 (extract_pr_id_form_url(&url)).map(|_| ()),
294 err(result_of!(
295 |err: &rootcause::Report| err.format_current_context().to_string(),
296 eq("error missing PR ID prefix")
297 ))
298 );
299 }
300
301 #[test]
302 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_has_multiple_pr_id_prefixes() {
303 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/pull/43")).unwrap();
304 assert_that!(
305 (extract_pr_id_form_url(&url)).map(|_| ()),
306 err(result_of!(
307 |err: &rootcause::Report| err.format_current_context().to_string(),
308 eq("error multiple PR ID prefixes")
309 ))
310 );
311 }
312
313 #[test]
314 fn test_extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_ends_with_the_pr_id() {
315 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42")).unwrap();
316 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
317 }
318
319 #[test]
320 fn test_extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_does_not_end_with_the_pr_id() {
321 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/foo")).unwrap();
322 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
323 }
324
325 #[test]
326 fn test_extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_if_pr_id_prefix_is_not_1st_path_segment()
327 {
328 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo/pull/42/foo")).unwrap();
329 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
330 }
331
332 #[test]
333 fn test_extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_if_pr_is_in_query_string() {
334 let url = Url::parse(&format!(
335 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=42"
336 ))
337 .unwrap();
338 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
339
340 let url = Url::parse(&format!(
341 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=42"
342 ))
343 .unwrap();
344 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
345 }
346
347 #[rstest]
348 #[case::ssh_url_with_git_suffix(
349 "git@github.com:fusillicode/dotfiles.git",
350 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
351 )]
352 #[case::https_url_without_git_suffix(
353 "https://github.com/fusillicode/dotfiles",
354 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
355 )]
356 fn parse_github_url_from_git_remote_url_works_as_expected(#[case] input: &str, #[case] expected: Url) {
357 let result = parse_github_url_from_git_remote_url(input).unwrap();
358 assert_eq!(result, expected);
359 }
360}