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
238 use super::*;
239
240 #[test]
241 fn test_extract_pr_id_form_url_returns_the_expected_error_when_host_cannot_be_extracted() {
242 let url = Url::parse("mailto:foo@bar.com").unwrap();
243 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
244 assert_eq!(
245 err.format_current_context().to_string(),
246 "error extracting host from URL"
247 );
248 }
249
250 #[test]
251 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_is_not_from_github() {
252 let url = Url::parse("https://foo.bar").unwrap();
253 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
254 assert_eq!(err.format_current_context().to_string(), "error host mismatch");
255 }
256
257 #[test]
258 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_path_segments() {
259 let url = Url::parse(&format!("https://{GITHUB_HOST}")).unwrap();
260 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
261 assert_eq!(err.format_current_context().to_string(), "error missing PR ID prefix");
262 }
263
264 #[test]
265 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_pr_id() {
266 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull")).unwrap();
267 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
268 assert_eq!(err.format_current_context().to_string(), "error missing PR ID");
269 }
270
271 #[test]
272 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_doenst_have_the_expected_pr_id_prefix() {
273 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo")).unwrap();
274 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
275 assert_eq!(err.format_current_context().to_string(), "error missing PR ID prefix");
276 }
277
278 #[test]
279 fn test_extract_pr_id_form_url_returns_the_expected_error_when_url_has_multiple_pr_id_prefixes() {
280 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/pull/43")).unwrap();
281 assert2::assert!(let Err(err) = extract_pr_id_form_url(&url));
282 assert_eq!(
283 err.format_current_context().to_string(),
284 "error multiple PR ID prefixes"
285 );
286 }
287
288 #[test]
289 fn test_extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_ends_with_the_pr_id() {
290 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42")).unwrap();
291 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
292 }
293
294 #[test]
295 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() {
296 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/foo")).unwrap();
297 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
298 }
299
300 #[test]
301 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()
302 {
303 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo/pull/42/foo")).unwrap();
304 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
305 }
306
307 #[test]
308 fn test_extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_if_pr_is_in_query_string() {
309 let url = Url::parse(&format!(
310 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=42"
311 ))
312 .unwrap();
313 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
314
315 let url = Url::parse(&format!(
316 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=42"
317 ))
318 .unwrap();
319 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
320 }
321
322 #[rstest]
323 #[case::ssh_url_with_git_suffix(
324 "git@github.com:fusillicode/dotfiles.git",
325 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
326 )]
327 #[case::https_url_without_git_suffix(
328 "https://github.com/fusillicode/dotfiles",
329 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
330 )]
331 fn parse_github_url_from_git_remote_url_works_as_expected(#[case] input: &str, #[case] expected: Url) {
332 let result = parse_github_url_from_git_remote_url(input).unwrap();
333 assert_eq!(result, expected);
334 }
335}