Skip to main content

libverify_github/
range.rs

1use std::collections::HashSet;
2
3use anyhow::{Context, Result, bail};
4
5use crate::client::GitHubClient;
6use crate::{graphql, pr_api, release_api};
7
8/// Specification for a range of PRs to verify.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum RangeSpec {
11    /// `#100..#200` -- PR numbers in a range
12    PrRange { start: u32, end: u32 },
13    /// `SHA..SHA` or `TAG..TAG` -- commits between two refs
14    RefRange { base: String, head: String },
15    /// `2024-01-01..2024-02-01` -- PRs merged within a date window
16    DateRange { since: String, until: String },
17}
18
19/// Try to parse a range specification from a CLI argument.
20/// Returns `None` if the argument is not a range (e.g., a plain PR number).
21pub fn parse_range(arg: &str) -> Option<RangeSpec> {
22    let sep_idx = arg.find("..")?;
23    let left = &arg[..sep_idx];
24    let right = &arg[sep_idx + 2..];
25
26    if left.is_empty() || right.is_empty() {
27        return None;
28    }
29
30    // #N..#M -- PR number range
31    if let (Some(l), Some(r)) = (left.strip_prefix('#'), right.strip_prefix('#'))
32        && let (Ok(start), Ok(end)) = (l.parse::<u32>(), r.parse::<u32>())
33    {
34        return Some(RangeSpec::PrRange { start, end });
35    }
36
37    // YYYY-MM-DD..YYYY-MM-DD -- date range
38    if is_date(left) && is_date(right) {
39        return Some(RangeSpec::DateRange {
40            since: left.to_string(),
41            until: right.to_string(),
42        });
43    }
44
45    // Fallback: ref range (SHA or tag)
46    Some(RangeSpec::RefRange {
47        base: left.to_string(),
48        head: right.to_string(),
49    })
50}
51
52fn is_date(s: &str) -> bool {
53    let bytes = s.as_bytes();
54    bytes.len() == 10
55        && bytes[4] == b'-'
56        && bytes[7] == b'-'
57        && bytes[..4].iter().all(|b| b.is_ascii_digit())
58        && bytes[5..7].iter().all(|b| b.is_ascii_digit())
59        && bytes[8..10].iter().all(|b| b.is_ascii_digit())
60}
61
62/// Resolve a range specification into a list of merged PR numbers.
63pub fn resolve_pr_numbers(
64    spec: &RangeSpec,
65    client: &GitHubClient,
66    owner: &str,
67    repo: &str,
68) -> Result<Vec<u32>> {
69    match spec {
70        RangeSpec::PrRange { start, end } => {
71            pr_api::search_merged_prs_in_range(client, owner, repo, *start, *end)
72        }
73        RangeSpec::RefRange { base, head } => {
74            let commits = release_api::compare_refs(client, owner, repo, base, head)
75                .context("failed to compare refs")?;
76
77            if commits.len() >= 250 {
78                eprintln!(
79                    "Warning: GitHub API returned {} commits (max 250). Some PRs may be missing.",
80                    commits.len()
81                );
82            }
83
84            let shas: Vec<&str> = commits.iter().map(|c| c.sha.as_str()).collect();
85            let commit_pr_map = graphql::resolve_commit_prs(client, owner, repo, &shas)
86                .unwrap_or_else(|err| {
87                    eprintln!("Warning: failed to resolve commit PRs via GraphQL: {err}");
88                    std::collections::HashMap::new()
89                });
90
91            let mut pr_numbers = HashSet::new();
92            for prs in commit_pr_map.values() {
93                for pr in prs {
94                    if pr.merged_at.is_some() {
95                        pr_numbers.insert(pr.number);
96                    }
97                }
98            }
99
100            let mut sorted: Vec<u32> = pr_numbers.into_iter().collect();
101            sorted.sort_unstable();
102            Ok(sorted)
103        }
104        RangeSpec::DateRange { since, until } => {
105            pr_api::search_merged_prs(client, owner, repo, since, until)
106        }
107    }
108}
109
110/// Parse a release argument into (base_tag, head_tag).
111///
112/// If the argument contains `..`, it is treated as an explicit range.
113/// Otherwise, the previous tag is detected automatically.
114pub fn parse_release_arg(
115    arg: &str,
116    client: &GitHubClient,
117    owner: &str,
118    repo: &str,
119) -> Result<(String, String)> {
120    if let Some(sep_idx) = arg.find("..") {
121        let base = arg[..sep_idx].to_string();
122        let head = arg[sep_idx + 2..].to_string();
123        return Ok((base, head));
124    }
125
126    let head_tag = arg.to_string();
127    let tags = release_api::get_tags(client, owner, repo)?;
128
129    for (idx, t) in tags.iter().enumerate() {
130        if t.name == head_tag {
131            if idx + 1 < tags.len() {
132                return Ok((tags[idx + 1].name.clone(), head_tag));
133            } else {
134                bail!("no previous tag found before {head_tag}");
135            }
136        }
137    }
138    bail!("tag not found: {head_tag}");
139}
140
141/// Detect the latest release tag from repository tags.
142pub fn detect_latest_release_tag(client: &GitHubClient, owner: &str, repo: &str) -> Result<String> {
143    let tags = release_api::get_tags(client, owner, repo)?;
144    tags.into_iter()
145        .next()
146        .map(|t| t.name)
147        .context("no tags found in repository")
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parse_pr_range() {
156        assert_eq!(
157            parse_range("#100..#200"),
158            Some(RangeSpec::PrRange {
159                start: 100,
160                end: 200
161            })
162        );
163    }
164
165    #[test]
166    fn parse_date_range() {
167        assert_eq!(
168            parse_range("2024-01-01..2024-06-01"),
169            Some(RangeSpec::DateRange {
170                since: "2024-01-01".to_string(),
171                until: "2024-06-01".to_string()
172            })
173        );
174    }
175
176    #[test]
177    fn parse_ref_range_tags() {
178        assert_eq!(
179            parse_range("v1.0..v2.0"),
180            Some(RangeSpec::RefRange {
181                base: "v1.0".to_string(),
182                head: "v2.0".to_string()
183            })
184        );
185    }
186
187    #[test]
188    fn parse_ref_range_shas() {
189        assert_eq!(
190            parse_range("abc123..def456"),
191            Some(RangeSpec::RefRange {
192                base: "abc123".to_string(),
193                head: "def456".to_string()
194            })
195        );
196    }
197
198    #[test]
199    fn parse_single_number_returns_none() {
200        assert_eq!(parse_range("42"), None);
201    }
202
203    #[test]
204    fn parse_empty_sides_returns_none() {
205        assert_eq!(parse_range(".."), None);
206        assert_eq!(parse_range("abc.."), None);
207        assert_eq!(parse_range("..abc"), None);
208    }
209
210    #[test]
211    fn is_date_valid() {
212        assert!(is_date("2024-01-01"));
213        assert!(is_date("2024-12-31"));
214    }
215
216    #[test]
217    fn is_date_invalid() {
218        assert!(!is_date("2024-1-1"));
219        assert!(!is_date("not-a-date"));
220        assert!(!is_date("20240101"));
221    }
222}