1#![feature(exit_status_error)]
21
22use std::path::Path;
23use std::process::Command;
24
25use color_eyre::eyre::Context;
26use color_eyre::eyre::bail;
27use color_eyre::eyre::eyre;
28use url::Url;
29
30pub mod issue;
31pub mod pr;
32
33const GITHUB_HOST: &str = "github.com";
35const GITHUB_PR_ID_PREFIX: &str = "pull";
37const GITHUB_PR_ID_QUERY_KEY: &str = "pr";
39
40#[derive(strum::AsRefStr, Debug)]
42pub enum RepoViewField {
43 #[strum(serialize = "nameWithOwner")]
45 NameWithOwner,
46 #[strum(serialize = "url")]
48 Url,
49}
50
51impl RepoViewField {
52 pub fn jq_repr(&self) -> String {
54 format!(".{}", self.as_ref())
55 }
56}
57
58pub fn get_repo_view_field(field: &RepoViewField) -> color_eyre::Result<String> {
67 let output = Command::new("gh")
68 .args(["repo", "view", "--json", field.as_ref(), "--jq", &field.jq_repr()])
69 .output()
70 .wrap_err_with(|| eyre!("error getting repo view field | field={field:?}"))?;
71
72 ytil_cmd::extract_success_output(&output)
73}
74
75pub fn log_into_github() -> color_eyre::Result<()> {
83 if ytil_cmd::silent_cmd("gh")
84 .args(["auth", "status"])
85 .status()
86 .wrap_err_with(|| eyre!("error checking gh auth status"))?
87 .success()
88 {
89 return Ok(());
90 }
91
92 ytil_cmd::silent_cmd("sh")
93 .args(["-c", "gh auth login"])
94 .status()
95 .wrap_err_with(|| eyre!("error running gh auth login command"))?
96 .exit_ok()
97 .wrap_err_with(|| eyre!("error running gh auth login"))
98}
99
100pub fn get_latest_release(repo: &str) -> color_eyre::Result<String> {
107 let output = Command::new("gh")
108 .args(["api", &format!("repos/{repo}/releases/latest"), "--jq=.tag_name"])
109 .output()
110 .wrap_err_with(|| eyre!("error getting latest release | repo={repo:?}"))?;
111
112 ytil_cmd::extract_success_output(&output)
113}
114
115pub fn get_branch_name_from_url(url: &Url) -> color_eyre::Result<String> {
122 let pr_id = extract_pr_id_form_url(url)?;
123
124 let output = Command::new("gh")
125 .args(["pr", "view", &pr_id, "--json", "headRefName", "--jq", ".headRefName"])
126 .output()
127 .wrap_err_with(|| eyre!("error getting branch name | pr_id={pr_id:?}"))?;
128
129 ytil_cmd::extract_success_output(&output)
130}
131
132pub fn get_repo_urls(repo_path: &Path) -> color_eyre::Result<Vec<Url>> {
141 let repo = ytil_git::repo::discover(repo_path)
142 .wrap_err_with(|| eyre!("error opening repo | path={}", repo_path.display()))?;
143 let mut repo_urls = vec![];
144 for remote_name in repo.remotes()?.iter().flatten() {
145 repo_urls.push(
146 repo.find_remote(remote_name)
147 .wrap_err_with(|| eyre!("error finding remote | remote={remote_name:?}"))?
148 .url()
149 .map(parse_github_url_from_git_remote_url)
150 .ok_or_else(|| eyre!("error invalid remote URL UTF-8 | remote={remote_name:?}"))
151 .wrap_err_with(|| eyre!("error parsing remote URL | remote={remote_name:?}"))??,
152 );
153 }
154 Ok(repo_urls)
155}
156
157fn parse_github_url_from_git_remote_url(git_remote_url: &str) -> color_eyre::Result<Url> {
166 if let Ok(mut url) = Url::parse(git_remote_url) {
167 url.set_path(url.clone().path().trim_end_matches(".git"));
168 return Ok(url);
169 }
170
171 let path = git_remote_url
172 .split_once(':')
173 .map(|(_, path)| path.trim_end_matches(".git"))
174 .ok_or_else(|| eyre!("error extracting URL path | git_remote_url={git_remote_url:?}"))?;
175
176 let mut url = Url::parse("https://github.com").wrap_err_with(|| eyre!("error parsing base GitHub URL"))?;
177 url.set_path(path);
178
179 Ok(url)
180}
181
182fn extract_pr_id_form_url(url: &Url) -> color_eyre::Result<String> {
192 let host = url
193 .host_str()
194 .ok_or_else(|| eyre!("error extracting host from URL | url={url}"))?;
195 if host != GITHUB_HOST {
196 bail!("error host mismatch | host={host:?} expected={GITHUB_HOST:?} URL={url}")
197 }
198
199 if let Some(pr_id) = url
203 .query_pairs()
204 .find(|(key, _)| key == GITHUB_PR_ID_QUERY_KEY)
205 .map(|(_, pr_id)| pr_id.to_string())
206 {
207 return Ok(pr_id);
208 }
209
210 let path_segments = url
211 .path_segments()
212 .ok_or_else(|| eyre!("error URL cannot be base | url={url}"))?
213 .enumerate()
214 .collect::<Vec<_>>();
215
216 match path_segments
217 .iter()
218 .filter(|(_, ps)| ps == &GITHUB_PR_ID_PREFIX)
219 .collect::<Vec<_>>()
220 .as_slice()
221 {
222 [(idx, _)] => Ok(path_segments
223 .get(idx.saturating_add(1))
224 .ok_or_else(|| eyre!("error missing PR ID | url={url} path_segments={path_segments:#?}"))
225 .and_then(|(_, pr_id)| {
226 if pr_id.is_empty() {
227 return Err(eyre!("error empty PR ID | url={url} path_segments={path_segments:#?}"));
228 }
229 Ok((*pr_id).to_string())
230 })?),
231 [] => Err(eyre!(
232 "error missing PR ID prefix | prefix={GITHUB_PR_ID_PREFIX:?} url={url} path_segments={path_segments:#?}"
233 )),
234 _ => Err(eyre!(
235 "error multiple PR ID prefixes | prefix={GITHUB_PR_ID_PREFIX:?} url={url} path_segments={path_segments:#?}"
236 )),
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use rstest::rstest;
243
244 use super::*;
245
246 #[test]
247 fn extract_pr_id_form_url_returns_the_expected_error_when_host_cannot_be_extracted() {
248 let url = Url::parse("mailto:foo@bar.com").unwrap();
249 assert2::let_assert!(Err(err) = extract_pr_id_form_url(&url));
250 assert_eq!(
251 err.to_string(),
252 "error extracting host from URL | url=mailto:foo@bar.com"
253 );
254 }
255
256 #[test]
257 fn extract_pr_id_form_url_returns_the_expected_error_when_url_is_not_from_github() {
258 let url = Url::parse("https://foo.bar").unwrap();
259 assert2::let_assert!(Err(err) = extract_pr_id_form_url(&url));
260 let msg = err.to_string();
261 assert!(msg.starts_with("error host mismatch |"));
262 assert!(msg.contains(r#"host="foo.bar""#), "actual: {msg}");
263 assert!(msg.contains(r#"expected="github.com""#), "actual: {msg}");
264 assert!(msg.contains("URL=https://foo.bar/"), "actual: {msg}");
265 }
266
267 #[test]
268 fn extract_pr_id_form_url_returns_the_expected_error_when_url_doesnt_have_path_segments() {
269 let url = Url::parse(&format!("https://{GITHUB_HOST}")).unwrap();
270 assert2::let_assert!(Err(err) = extract_pr_id_form_url(&url));
271 let msg = err.to_string();
272 assert!(msg.starts_with("error missing PR ID prefix |"), "actual: {msg}");
273 assert!(msg.contains("prefix=\"pull\""), "actual: {msg}");
274 assert!(msg.contains("url=https://github.com/"), "actual: {msg}");
275 }
276
277 #[test]
278 fn 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 assert2::let_assert!(Err(err) = extract_pr_id_form_url(&url));
281 let msg = err.to_string();
282 assert!(msg.starts_with("error missing PR ID |"), "actual: {msg}");
283 assert!(msg.contains("url=https://github.com/pull"), "actual: {msg}");
284 }
285
286 #[test]
287 fn extract_pr_id_form_url_returns_the_expected_error_when_url_doenst_have_the_expected_pr_id_prefix() {
288 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo")).unwrap();
289 assert2::let_assert!(Err(err) = extract_pr_id_form_url(&url));
290 let msg = err.to_string();
291 assert!(msg.starts_with("error missing PR ID prefix |"), "actual: {msg}");
292 assert!(msg.contains("prefix=\"pull\""), "actual: {msg}");
293 assert!(msg.contains("url=https://github.com/foo"), "actual: {msg}");
294 }
295
296 #[test]
297 fn extract_pr_id_form_url_returns_the_expected_error_when_url_has_multiple_pr_id_prefixes() {
298 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/pull/43")).unwrap();
299 assert2::let_assert!(Err(err) = extract_pr_id_form_url(&url));
300 let msg = err.to_string();
301 assert!(msg.starts_with("error multiple PR ID prefixes |"), "actual: {msg}");
302 assert!(msg.contains("prefix=\"pull\""), "actual: {msg}");
303 assert!(msg.contains("url=https://github.com/pull/42/pull/43"), "actual: {msg}");
304 }
305
306 #[test]
307 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_ends_with_the_pr_id() {
308 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42")).unwrap();
309 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
310 }
311
312 #[test]
313 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_that_does_not_end_with_the_pr_id() {
314 let url = Url::parse(&format!("https://{GITHUB_HOST}/pull/42/foo")).unwrap();
315 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
316 }
317
318 #[test]
319 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()
320 {
321 let url = Url::parse(&format!("https://{GITHUB_HOST}/foo/pull/42/foo")).unwrap();
322 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
323 }
324
325 #[test]
326 fn extract_pr_id_form_url_returns_the_expected_pr_id_from_a_github_pr_url_if_pr_is_in_query_string() {
327 let url = Url::parse(&format!(
328 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=42"
329 ))
330 .unwrap();
331 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
332
333 let url = Url::parse(&format!(
334 "https://{GITHUB_HOST}/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=42"
335 ))
336 .unwrap();
337 assert_eq!(extract_pr_id_form_url(&url).unwrap(), "42");
338 }
339
340 #[rstest]
341 #[case::ssh_url_with_git_suffix(
342 "git@github.com:fusillicode/dotfiles.git",
343 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
344 )]
345 #[case::https_url_without_git_suffix(
346 "https://github.com/fusillicode/dotfiles",
347 Url::parse("https://github.com/fusillicode/dotfiles").unwrap()
348 )]
349 fn parse_github_url_from_git_remote_url_works_as_expected(#[case] input: &str, #[case] expected: Url) {
350 let result = parse_github_url_from_git_remote_url(input).unwrap();
351 assert_eq!(result, expected);
352 }
353}