Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 32 additions & 1 deletion src/github.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<Vec<String>> {
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<Tag> = response.json().await?;
Ok(tags.into_iter().map(|t| t.name).collect())
})
.await
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
26 changes: 25 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
18 changes: 16 additions & 2 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String>,
}

#[allow(dead_code)]
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Owner {
pub login: String,
}
Expand Down Expand Up @@ -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)]
Expand Down
Loading