cargo_version_info/
github.rs

1//! GitHub API integration for version queries.
2
3use anyhow::{
4    Context,
5    Result,
6};
7
8use crate::version::{
9    format_version,
10    increment_patch,
11    parse_version,
12};
13
14/// Get the latest published release version from GitHub.
15///
16/// Uses the GitHub API via octocrab if GITHUB_TOKEN is set,
17/// otherwise falls back to gh CLI.
18pub async fn get_latest_release_version(
19    owner: &str,
20    repo: &str,
21    github_token: Option<&str>,
22) -> Result<Option<String>> {
23    // Try GitHub API first if token is available
24    if let Some(token) = github_token
25        && let Ok(version) = get_latest_release_via_api(owner, repo, token).await
26    {
27        return Ok(Some(version));
28    }
29
30    // Fallback to gh CLI
31    get_latest_release_via_cli(owner, repo)
32}
33
34/// Get latest release via GitHub API.
35async fn get_latest_release_via_api(owner: &str, repo: &str, token: &str) -> Result<String> {
36    let octocrab = octocrab::OctocrabBuilder::new()
37        .personal_token(token.to_string())
38        .build()
39        .context("Failed to create GitHub API client")?;
40
41    let releases = octocrab
42        .repos(owner, repo)
43        .releases()
44        .list()
45        .per_page(1)
46        .send()
47        .await
48        .context("Failed to query GitHub releases")?;
49
50    let release = releases.items.first().context("No releases found")?;
51
52    let tag_name = release.tag_name.as_str();
53    let version = tag_name.strip_prefix('v').unwrap_or(tag_name);
54    let version = version.strip_prefix('V').unwrap_or(version);
55
56    Ok(version.to_string())
57}
58
59/// Get latest release via gh CLI.
60fn get_latest_release_via_cli(owner: &str, repo: &str) -> Result<Option<String>> {
61    use std::process::Command;
62
63    let output = Command::new("gh")
64        .args([
65            "release",
66            "list",
67            "--repo",
68            &format!("{}/{}", owner, repo),
69            "--exclude-drafts",
70            "--limit",
71            "1",
72            "--json",
73            "tagName",
74            "--jq",
75            ".[0].tagName",
76        ])
77        .output()
78        .context("Failed to execute gh CLI. Is it installed?")?;
79
80    if !output.status.success() {
81        let stderr = String::from_utf8_lossy(&output.stderr);
82        if stderr.contains("no releases found") || stderr.is_empty() {
83            return Ok(None);
84        }
85        anyhow::bail!("gh CLI failed: {}", stderr);
86    }
87
88    let tag_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
89    if tag_name.is_empty() {
90        return Ok(None);
91    }
92
93    let version = tag_name.strip_prefix('v').unwrap_or(&tag_name);
94    let version = version.strip_prefix('V').unwrap_or(version);
95
96    Ok(Some(version.to_string()))
97}
98
99/// Calculate next patch version from latest GitHub release.
100pub async fn calculate_next_version(
101    owner: &str,
102    repo: &str,
103    github_token: Option<&str>,
104) -> Result<(String, String)> {
105    // Get latest release
106    let latest_version_str = match get_latest_release_version(owner, repo, github_token).await? {
107        Some(v) => v,
108        None => {
109            // No releases yet, start at 0.0.1
110            return Ok(("0.0.0".to_string(), "0.0.1".to_string()));
111        }
112    };
113
114    let (major, minor, patch) = parse_version(&latest_version_str)
115        .with_context(|| format!("Failed to parse latest version: {}", latest_version_str))?;
116
117    let (major, minor, patch) = increment_patch(major, minor, patch);
118    let next_version = format_version(major, minor, patch);
119
120    Ok((latest_version_str, next_version))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[tokio::test]
128    #[ignore] // Requires network access
129    async fn test_get_latest_release_via_cli() {
130        // This test requires gh CLI and network access
131        // Only run manually
132        if let Ok(Some(version)) = get_latest_release_via_cli("rust-lang", "rust") {
133            println!("Latest rust release: {}", version);
134        }
135    }
136}