use std::collections::HashSet;
use anyhow::{Context, Result, bail};
use crate::client::GitHubClient;
use crate::{graphql, pr_api, release_api};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RangeSpec {
PrRange { start: u32, end: u32 },
RefRange { base: String, head: String },
DateRange { since: String, until: String },
}
pub fn parse_range(arg: &str) -> Option<RangeSpec> {
let sep_idx = arg.find("..")?;
let left = &arg[..sep_idx];
let right = &arg[sep_idx + 2..];
if left.is_empty() || right.is_empty() {
return None;
}
if let (Some(l), Some(r)) = (left.strip_prefix('#'), right.strip_prefix('#'))
&& let (Ok(start), Ok(end)) = (l.parse::<u32>(), r.parse::<u32>())
{
return Some(RangeSpec::PrRange { start, end });
}
if is_date(left) && is_date(right) {
return Some(RangeSpec::DateRange {
since: left.to_string(),
until: right.to_string(),
});
}
Some(RangeSpec::RefRange {
base: left.to_string(),
head: right.to_string(),
})
}
fn is_date(s: &str) -> bool {
let bytes = s.as_bytes();
bytes.len() == 10
&& bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes[..4].iter().all(|b| b.is_ascii_digit())
&& bytes[5..7].iter().all(|b| b.is_ascii_digit())
&& bytes[8..10].iter().all(|b| b.is_ascii_digit())
}
pub fn resolve_pr_numbers(
spec: &RangeSpec,
client: &GitHubClient,
owner: &str,
repo: &str,
) -> Result<Vec<u32>> {
match spec {
RangeSpec::PrRange { start, end } => {
pr_api::search_merged_prs_in_range(client, owner, repo, *start, *end)
}
RangeSpec::RefRange { base, head } => {
let commits = release_api::compare_refs(client, owner, repo, base, head)
.context("failed to compare refs")?;
if commits.len() >= 250 {
eprintln!(
"Warning: GitHub API returned {} commits (max 250). Some PRs may be missing.",
commits.len()
);
}
let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
let commit_pr_map = graphql::resolve_commit_prs(client, owner, repo, &shas)
.unwrap_or_else(|err| {
eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
std::collections::HashMap::new()
});
let mut pr_numbers = HashSet::new();
for prs in commit_pr_map.values() {
for pr in prs {
if pr.merged_at.is_some() {
pr_numbers.insert(pr.number);
}
}
}
let mut sorted: Vec<u32> = pr_numbers.into_iter().collect();
sorted.sort_unstable();
Ok(sorted)
}
RangeSpec::DateRange { since, until } => {
pr_api::search_merged_prs(client, owner, repo, since, until)
}
}
}
pub fn parse_release_arg(
arg: &str,
client: &GitHubClient,
owner: &str,
repo: &str,
) -> Result<(String, String)> {
if let Some(sep_idx) = arg.find("..") {
let base = arg[..sep_idx].to_string();
let head = arg[sep_idx + 2..].to_string();
return Ok((base, head));
}
let head_tag = arg.to_string();
let tags = release_api::get_tags(client, owner, repo)?;
for (idx, t) in tags.iter().enumerate() {
if t.name == head_tag {
if idx + 1 < tags.len() {
return Ok((tags[idx + 1].name.clone(), head_tag));
} else {
bail!("no previous tag found before {head_tag}");
}
}
}
bail!("tag not found: {head_tag}");
}
pub fn detect_latest_release_tag(client: &GitHubClient, owner: &str, repo: &str) -> Result<String> {
let tags = release_api::get_tags(client, owner, repo)?;
tags.into_iter()
.next()
.map(|t| t.name)
.context("no tags found in repository")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pr_range() {
assert_eq!(
parse_range("#100..#200"),
Some(RangeSpec::PrRange {
start: 100,
end: 200
})
);
}
#[test]
fn parse_date_range() {
assert_eq!(
parse_range("2024-01-01..2024-06-01"),
Some(RangeSpec::DateRange {
since: "2024-01-01".to_string(),
until: "2024-06-01".to_string()
})
);
}
#[test]
fn parse_ref_range_tags() {
assert_eq!(
parse_range("v1.0..v2.0"),
Some(RangeSpec::RefRange {
base: "v1.0".to_string(),
head: "v2.0".to_string()
})
);
}
#[test]
fn parse_ref_range_shas() {
assert_eq!(
parse_range("abc123..def456"),
Some(RangeSpec::RefRange {
base: "abc123".to_string(),
head: "def456".to_string()
})
);
}
#[test]
fn parse_single_number_returns_none() {
assert_eq!(parse_range("42"), None);
}
#[test]
fn parse_empty_sides_returns_none() {
assert_eq!(parse_range(".."), None);
assert_eq!(parse_range("abc.."), None);
assert_eq!(parse_range("..abc"), None);
}
#[test]
fn is_date_valid() {
assert!(is_date("2024-01-01"));
assert!(is_date("2024-12-31"));
}
#[test]
fn is_date_invalid() {
assert!(!is_date("2024-1-1"));
assert!(!is_date("not-a-date"));
assert!(!is_date("20240101"));
}
}