libverify_github/
range.rs1use std::collections::HashSet;
2
3use anyhow::{Context, Result, bail};
4
5use crate::client::GitHubClient;
6use crate::{graphql, pr_api, release_api};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum RangeSpec {
11 PrRange { start: u32, end: u32 },
13 RefRange { base: String, head: String },
15 DateRange { since: String, until: String },
17}
18
19pub 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 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 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 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
62pub 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
110pub 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
141pub 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}