ytil_gh/
lib.rs

1//! Provide lightweight GitHub helpers using the `gh` CLI.
2//!
3//! Provide focused wrappers around `gh` subcommands plus URL parsing helpers for PR IDs and remote
4//! canonicalization.
5//!
6//! # Rationale
7//! This module shells out to the GitHub CLI ('gh') instead of using a direct HTTP client (e.g. `octocrab`) because:
8//! - Reuses the user's existing authenticated `gh` session (no PAT / device-flow code, fewer secrets to manage).
9//! - Keeps this utility crate synchronous and lightweight (avoids adding `tokio` + `reqwest` dependency graph).
10//! - Minimizes compile time and binary size in the broader workspace.
11//! - Leverages 'gh' stable porcelain for JSON output (`--json` / `--jq`) and future compatibility with GitHub auth
12//!   flows / SSO.
13//! - Current feature surface (latest release tag, PR head branch lookup) is small; process spawn overhead is negligible
14//!   versus HTTP setup cost.
15//!
16//! Trade-offs accepted:
17//! - Less fine-grained control over rate limiting and retries.
18//! - Tight coupling to `gh` output flags (low churn historically, but still external).
19//! - Requires `gh` binary presence in runtime environments.
20#![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
33/// The GitHub host domain.
34const GITHUB_HOST: &str = "github.com";
35/// The URL path segment prefix for pull requests.
36const GITHUB_PR_ID_PREFIX: &str = "pull";
37/// The query parameter key used for pull request IDs in GitHub Actions URLs.
38const GITHUB_PR_ID_QUERY_KEY: &str = "pr";
39
40/// Repository fields available for querying via `gh repo view`.
41#[derive(strum::AsRefStr, Debug)]
42pub enum RepoViewField {
43    /// The repository name with owner in `owner/name` format.
44    #[strum(serialize = "nameWithOwner")]
45    NameWithOwner,
46    /// The repository URL.
47    #[strum(serialize = "url")]
48    Url,
49}
50
51impl RepoViewField {
52    /// Returns the jq representation of the field for GitHub CLI queries.
53    pub fn jq_repr(&self) -> String {
54        format!(".{}", self.as_ref())
55    }
56}
57
58/// Return the specified repository field via `gh repo view`.
59///
60/// Invokes: `gh repo view --json <field> --jq .<field>`.
61///
62/// # Errors
63/// - Spawning or executing the `gh repo view` command fails.
64/// - Command exits with non‑zero status.
65/// - Output is not valid UTF‑8.
66pub 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
75/// Ensures the user is authenticated with the GitHub CLI.
76///
77/// Runs `gh auth status`; if not authenticated it invokes an interactive `gh auth login`.
78///
79/// # Errors
80/// - Checking auth status fails.
81/// - The login command fails or exits with a non-zero status.
82pub 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
100/// Retrieves the latest release tag name for the specified GitHub repository.
101///
102/// # Errors
103/// - Executing `gh` fails or returns a non-zero exit status.
104/// - UTF-8 conversion fails.
105/// - Invoking `gh api` fails.
106pub 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
115/// Extracts the branch name from a GitHub pull request [`Url`].
116///
117/// # Errors
118/// - Executing `gh` fails or returns a non-zero exit status.
119/// - Invoking `gh pr view` fails.
120/// - Output cannot be parsed.
121pub 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
132/// Returns all GitHub remote URLs for the repository rooted at `repo_path`.
133///
134/// Filters remotes to those that parse as GitHub URLs.
135///
136/// # Errors
137/// - The repository cannot be opened.
138/// - A remote cannot be resolved.
139/// - A remote URL is invalid UTF-8.
140pub 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
157/// Converts a Git remote URL (SSH or HTTPS) to a canonical GitHub HTTPS URL without the `.git` suffix.
158///
159/// Accepts formats like:
160/// - `git@github.com:owner/repo.git`
161/// - `https://github.com/owner/repo[.git]`
162///
163/// # Errors
164/// - The URL cannot be parsed or lacks a path component.
165fn 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
182/// Extracts the pull request numeric ID from a GitHub URL.
183///
184/// Supported forms:
185/// - Direct PR path: `.../pull/<ID>` (ID may not be last segment).
186/// - Actions run URL with `?pr=<ID>` (also supports `/job/<JOB_ID>` variants).
187///
188/// # Errors
189/// - Host is not `github.com`.
190/// - The PR id segment or query parameter is missing, empty, duplicated, or malformed.
191fn 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    // To handle URLs like:
200    // - https://github.com/<OWNER>/<REPO>/actions/runs/<RUN_ID>?pr=<PR_ID>
201    // - https://github.com/<OWNER>/<REPO>/actions/runs/<RUN_ID>/job/<JOB_ID>?pr=<PR_ID>
202    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}