diff --git a/README.md b/README.md index 74189d7..6b25e00 100644 --- a/README.md +++ b/README.md @@ -255,12 +255,47 @@ Get machine-readable output for scripting and automation: # List releases in JSON format ghr -r owner/repo --format json -# Search repositories in JSON format +# Search repositories in JSON format (includes latest tags) ghr -s "rust-lang/" --format json -n 5 +# The -n flag controls both: +# - Number of repositories to return +# - Number of latest tags to fetch for each repository +ghr -s "microsoft/" --format json -n 10 + # Parse with jq ghr -r owner/repo --format json | jq '.[0].tag_name' ghr -r owner/repo --format json | jq -r '.[] | .assets[].name' + +# Extract repository names and their latest tags +ghr -s "rust-lang/" --format json | jq -r '.[] | "\(.full_name): \(.latest_tags | join(", "))"' +``` + +**JSON Output for Search Mode:** +When using `--format json` with search (`-s`), each repository includes: +- All standard repository fields (name, description, stars, etc.) +- `latest_tags`: Array of the latest N tag names (where N is specified by `-n`) + +Example output: +```json +[ + { + "name": "rust", + "full_name": "rust-lang/rust", + "description": "Empowering everyone to build reliable and efficient software.", + "stargazers_count": 95000, + "html_url": "https://github.com/rust-lang/rust", + "owner": { + "login": "rust-lang" + }, + "private": false, + "latest_tags": [ + "1.75.0", + "1.74.1", + "1.74.0" + ] + } +] ``` ### Response Caching diff --git a/src/constants.rs b/src/constants.rs index 15491a8..ec7fa9c 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -104,6 +104,20 @@ pub mod endpoints { num ) } + + /// Get tags for a repository + #[allow(dead_code)] + pub fn tags(owner: &str, repo: &str, per_page: usize) -> String { + tags_with_base(GITHUB_API_BASE, owner, repo, per_page) + } + + /// Get tags for a repository with custom base URL + pub fn tags_with_base(base_url: &str, owner: &str, repo: &str, per_page: usize) -> String { + format!( + "{}/repos/{}/{}/tags?per_page={}", + base_url, owner, repo, per_page + ) + } } /// HTTP headers diff --git a/src/github.rs b/src/github.rs index c3a3a97..dab8a4a 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,7 +1,7 @@ use crate::cache::Cache; use crate::constants; use crate::errors::{GhrError, Result}; -use crate::models::{Release, Repository, RepositoryInfo, SearchResponse}; +use crate::models::{Release, Repository, RepositoryInfo, SearchResponse, Tag}; use jlogger_tracing::{jdebug, jinfo}; use reqwest::Client; use tokio::time::{sleep, Duration}; @@ -370,6 +370,37 @@ pub async fn validate_ref_with_base( }) } +/// Fetch tags for a repository +pub async fn get_repository_tags( + client: &Client, + base_url: &str, + owner: &str, + repo: &str, + per_page: usize, +) -> Result> { + let url = constants::endpoints::tags_with_base(base_url, owner, repo, per_page); + + retry_with_backoff(|| async { + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + // If tags endpoint fails, return empty list instead of error + // This allows the search to continue even if some repos don't have tags + jdebug!( + "Failed to fetch tags for {}/{}: HTTP {}", + owner, + repo, + response.status() + ); + return Ok(Vec::new()); + } + + let tags: Vec = response.json().await?; + Ok(tags.into_iter().map(|t| t.name).collect()) + }) + .await +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/main.rs b/src/main.rs index fa4566c..74ae7d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -158,7 +158,31 @@ async fn main() -> Result<()> { // Display results based on format match cli.format { cli::OutputFormat::Json => { - let json = serde_json::to_string_pretty(&repositories)?; + // Fetch tags for each repository to enrich JSON output + jinfo!("Fetching tags for {} repositories...", repositories.len()); + + let mut repos_with_tags = Vec::new(); + for repo in &repositories { + let parts: Vec<&str> = repo.full_name.split('/').collect(); + if parts.len() == 2 { + let tags = github::get_repository_tags( + &client, + &cli.api_url, + parts[0], + parts[1], + cli.num, + ) + .await + .unwrap_or_default(); // If tags fetch fails, use empty list + + repos_with_tags.push(models::RepositoryWithTags { + repository: repo.clone(), + latest_tags: tags, + }); + } + } + + let json = serde_json::to_string_pretty(&repos_with_tags)?; println!("{}", json); } cli::OutputFormat::Table => { diff --git a/src/models.rs b/src/models.rs index 31adf0d..778aa8c 100644 --- a/src/models.rs +++ b/src/models.rs @@ -53,7 +53,7 @@ pub struct SearchResponse { /// GitHub repository #[allow(dead_code)] -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct Repository { pub name: String, pub full_name: String, @@ -64,8 +64,16 @@ pub struct Repository { pub private: bool, } +/// Repository with additional tag information for enhanced JSON output +#[derive(Debug, Serialize)] +pub struct RepositoryWithTags { + #[serde(flatten)] + pub repository: Repository, + pub latest_tags: Vec, +} + #[allow(dead_code)] -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct Owner { pub login: String, } @@ -125,6 +133,12 @@ pub struct RepositoryInfo { pub private: bool, } +/// GitHub tag +#[derive(Debug, Deserialize)] +pub struct Tag { + pub name: String, +} + // Result type is now defined in errors.rs #[cfg(test)]