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().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
143/// Converts a Git remote URL (SSH or HTTPS) to a canonical GitHub HTTPS URL without the `.git` suffix.
144///
145/// Accepts formats like:
146/// - `git@github.com:owner/repo.git`
147/// - `https://github.com/owner/repo[.git]`
148///
149/// # Errors
150/// - The URL cannot be parsed or lacks a path component.
151fn 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
169/// Extracts the pull request numeric ID from a GitHub URL.
170///
171/// Supported forms:
172/// - Direct PR path: `.../pull/<ID>` (ID may not be last segment).
173/// - Actions run URL with `?pr=<ID>` (also supports `/job/<JOB_ID>` variants).
174///
175/// # Errors
176/// - Host is not `github.com`.
177/// - The PR id segment or query parameter is missing, empty, duplicated, or malformed.
178fn 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    // To handle URLs like:
189    // - https://github.com/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=<PR_ID>
190    // - https://github.com/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=<PR_ID>
191    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}