Skip to main content

ytil_gh/
lib.rs

1//! Lightweight GitHub helpers using the `gh` CLI.
2#![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
14/// The GitHub host domain.
15const GITHUB_HOST: &str = "github.com";
16/// The URL path segment prefix for pull requests.
17const GITHUB_PR_ID_PREFIX: &str = "pull";
18/// The query parameter key used for pull request IDs in GitHub Actions URLs.
19const GITHUB_PR_ID_QUERY_KEY: &str = "pr";
20
21/// Repository fields available for querying via `gh repo view`.
22#[derive(strum::AsRefStr, Debug)]
23pub enum RepoViewField {
24    #[strum(serialize = "nameWithOwner")]
25    NameWithOwner,
26    #[strum(serialize = "url")]
27    Url,
28}
29
30impl RepoViewField {
31    /// Returns the jq representation of the field for GitHub CLI queries.
32    pub fn jq_repr(&self) -> String {
33        format!(".{}", self.as_ref())
34    }
35}
36
37/// Return the specified repository field via `gh repo view`.
38///
39/// Invokes: `gh repo view --json <field> --jq .<field>`.
40///
41/// # Errors
42/// - Spawning or executing the `gh repo view` command fails.
43/// - Command exits with non‑zero status.
44/// - Output is not valid UTF‑8.
45pub 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
55/// Ensures the user is authenticated with the GitHub CLI.
56///
57/// Runs `gh auth status`; if not authenticated it invokes an interactive `gh auth login`.
58///
59/// # Errors
60/// - Checking auth status fails.
61/// - The login command fails or exits with a non-zero status.
62pub 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
80/// Retrieves the latest release tag name for the specified GitHub repository.
81///
82/// # Errors
83/// - Executing `gh` fails or returns a non-zero exit status.
84/// - UTF-8 conversion fails.
85/// - Invoking `gh api` fails.
86pub 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
96/// Extracts the branch name from a GitHub pull request [`Url`].
97///
98/// # Errors
99/// - Executing `gh` fails or returns a non-zero exit status.
100/// - Invoking `gh pr view` fails.
101/// - Output cannot be parsed.
102pub 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
114/// Returns all GitHub remote URLs for the repository rooted at `repo_path`.
115///
116/// Filters remotes to those that parse as GitHub URLs.
117///
118/// # Errors
119/// - The repository cannot be opened.
120/// - A remote cannot be resolved.
121/// - A remote URL is invalid UTF-8.
122pub 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
144/// Converts a Git remote URL (SSH or HTTPS) to a canonical GitHub HTTPS URL without the `.git` suffix.
145///
146/// Accepts formats like:
147/// - `git@github.com:owner/repo.git`
148/// - `https://github.com/owner/repo[.git]`
149///
150/// # Errors
151/// - The URL cannot be parsed or lacks a path component.
152fn 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
170/// Extracts the pull request numeric ID from a GitHub URL.
171///
172/// Supported forms:
173/// - Direct PR path: `.../pull/<ID>` (ID may not be last segment).
174/// - Actions run URL with `?pr=<ID>` (also supports `/job/<JOB_ID>` variants).
175///
176/// # Errors
177/// - Host is not `github.com`.
178/// - The PR id segment or query parameter is missing, empty, duplicated, or malformed.
179fn 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    // To handle URLs like:
190    // - https://github.com/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=<PR_ID>
191    // - https://github.com/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=<PR_ID>
192    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}