Skip to content

g1t/crates/rules/src/window.rs

215 lines8,587 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1//! Merge windows: when a branch takes merges, by the week, with freezes and
2//! exceptions on a calendar.
3
4use g1t_contracts::rules::{MergeWindowRule, Period, Weekday};
5use g1t_contracts::time::{parse_rfc3339, rfc3339};
6
7const MINUTE: u64 = 60 * 1000;
8const DAY_MINUTES: i64 = 24 * 60;
9
10/// A time zone's offset from UTC in minutes: `+02:00`, `-0530`, `UTC`.
11pub fn offset_minutes(zone: &str) -> Option<i64> {
12 let zone = zone.trim();
13 if zone.is_empty() || zone.eq_ignore_ascii_case("utc") || zone == "Z" {
14 return Some(0);
15 }
16 let zone = zone.strip_prefix("UTC").or_else(|| zone.strip_prefix("utc")).unwrap_or(zone);
17 let (sign, rest) = match zone.chars().next()? {
18 '+' => (1, &zone[1..]),
19 '-' => (-1, &zone[1..]),
20 _ => return None,
21 };
22 let (hours, minutes) = match rest.split_once(':') {
23 Some((hours, minutes)) => (hours, minutes),
24 None if rest.len() == 4 => rest.split_at(2),
25 None => (rest, "0"),
26 };
27 let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
28 if hours > 14 || minutes > 59 {
29 return None;
30 }
31 Some(sign * (hours * 60 + minutes))
32}
33
34/// Minutes into the day of `HH:MM`.
35pub fn clock(text: &str) -> Option<i64> {
36 let (hours, minutes) = text.trim().split_once(':')?;
37 let (hours, minutes) = (hours.parse::<i64>().ok()?, minutes.parse::<i64>().ok()?);
38 if hours > 24 || minutes > 59 || (hours == 24 && minutes > 0) {
39 return None;
40 }
41 Some(hours * 60 + minutes)
42}
43
44/// The day of the week of a count of days since 1970-01-01, a Thursday.
45fn weekday(days: i64) -> Weekday {
46 Weekday::ALL[(days + 3).rem_euclid(7) as usize]
47}
48
49fn in_period(period: &Period, now: u64) -> bool {
50 let Some(start) = parse_rfc3339(&period.start) else { return false };
51 let end = period.end.as_deref().and_then(parse_rfc3339);
52 start <= now && end.is_none_or(|end| now < end)
53}
54
55/// Why merging is closed now, or `None` when it is open.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub enum Closed {
58 /// A freeze covers now: its reason, and when it ends if it does.
59 Frozen { reason: String, until: Option<String> },
60 /// Outside every weekly window: when the next one opens, RFC 3339.
61 Outside { opens: Option<String> },
62}
63
64/// Whether merging is allowed at `now` (milliseconds since the epoch).
65pub fn closed(rule: &MergeWindowRule, now: u64) -> Option<Closed> {
66 if rule.exceptions.iter().any(|period| in_period(period, now)) {
67 return None;
68 }
69 if let Some(freeze) = rule.freezes.iter().find(|period| in_period(period, now)) {
70 return Some(Closed::Frozen { reason: freeze.reason.trim().to_owned(), until: freeze.end.clone() });
71 }
72 if rule.windows.is_empty() {
73 return None;
74 }
75 let offset = offset_minutes(&rule.time_zone).unwrap_or(0);
76 let local = (now / MINUTE) as i64 + offset;
77 if open_at(rule, local) {
78 return None;
79 }
80 // The next minute a window opens, within a week and a day.
81 let opens = (1..=(8 * DAY_MINUTES))
82 .map(|ahead| local + ahead)
83 .find(|minute| open_at(rule, *minute) && !open_at(rule, minute - 1))
84 .map(|minute| rfc3339(((minute - offset) as u64) * MINUTE));
85 Some(Closed::Outside { opens })
86}
87
88/// Whether a local minute (since the epoch) falls in a weekly window.
89fn open_at(rule: &MergeWindowRule, local: i64) -> bool {
90 let (day, minute) = (local.div_euclid(DAY_MINUTES), local.rem_euclid(DAY_MINUTES));
91 rule.windows.iter().any(|window| {
92 let (Some(start), Some(end)) = (clock(&window.start), clock(&window.end)) else {
93 return false;
94 };
95 if start <= end {
96 window.days.contains(&weekday(day)) && start <= minute && minute < end
97 } else {
98 // Past midnight: the evening of a listed day, or the morning after.
99 (window.days.contains(&weekday(day)) && minute >= start)
100 || (window.days.contains(&weekday(day - 1)) && minute < end)
101 }
102 })
103}
104
105/// What a closed window tells people.
106pub fn explain(closed: &Closed) -> String {
107 match closed {
108 Closed::Frozen { reason, until } => {
109 let why = if reason.is_empty() { String::new() } else { format!(" ({reason})") };
110 match until {
111 Some(until) => format!("Merging is frozen{why} until {until}."),
112 None => format!("Merging is frozen{why} until the freeze is lifted."),
113 }
114 }
115 Closed::Outside { opens: Some(opens) } => format!("Merging is outside the merge window; it next opens at {opens}."),
116 Closed::Outside { opens: None } => "Merging is outside the merge window.".to_owned(),
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use g1t_contracts::rules::WeeklyWindow;
124
125 /// 2026-10-07 is a Wednesday.
126 fn at(text: &str) -> u64 {
127 parse_rfc3339(text).unwrap()
128 }
129
130 fn weekdays(start: &str, end: &str) -> MergeWindowRule {
131 MergeWindowRule {
132 windows: vec![WeeklyWindow {
133 days: vec![Weekday::Mon, Weekday::Tue, Weekday::Wed, Weekday::Thu],
134 start: start.into(),
135 end: end.into(),
136 }],
137 ..MergeWindowRule::default()
138 }
139 }
140
141 #[test]
142 fn zones_and_clocks_parse() {
143 assert_eq!(offset_minutes("UTC"), Some(0));
144 assert_eq!(offset_minutes(""), Some(0));
145 assert_eq!(offset_minutes("+02:00"), Some(120));
146 assert_eq!(offset_minutes("-0530"), Some(-330));
147 assert_eq!(offset_minutes("UTC+1"), Some(60));
148 assert_eq!(offset_minutes("Europe/Paris"), None);
149 assert_eq!(clock("09:30"), Some(570));
150 assert_eq!(clock("24:00"), Some(1440));
151 assert_eq!(clock("25:00"), None);
152 }
153
154 #[test]
155 fn days_of_the_week_are_counted_from_a_thursday() {
156 assert_eq!(weekday(0), Weekday::Thu);
157 assert_eq!(weekday(at("2026-10-07T12:00:00Z") as i64 / 86_400_000), Weekday::Wed);
158 }
159
160 #[test]
161 fn inside_the_window_merging_is_open() {
162 let rule = weekdays("09:00", "17:00");
163 assert_eq!(closed(&rule, at("2026-10-07T10:00:00Z")), None);
164 assert_eq!(closed(&MergeWindowRule::default(), at("2026-10-10T03:00:00Z")), None);
165 }
166
167 #[test]
168 fn outside_it_says_when_it_opens() {
169 let rule = weekdays("09:00", "17:00");
170 // Wednesday evening: Thursday morning.
171 assert_eq!(
172 closed(&rule, at("2026-10-07T18:00:00Z")),
173 Some(Closed::Outside { opens: Some("2026-10-08T09:00:00.000Z".into()) })
174 );
175 // Friday: the next Monday.
176 assert_eq!(
177 closed(&rule, at("2026-10-09T10:00:00Z")),
178 Some(Closed::Outside { opens: Some("2026-10-12T09:00:00.000Z".into()) })
179 );
180 }
181
182 #[test]
183 fn the_window_is_in_its_time_zone() {
184 let rule = MergeWindowRule { time_zone: "-05:00".into(), ..weekdays("09:00", "17:00") };
185 // 13:00 UTC is 08:00 there: not yet.
186 assert!(closed(&rule, at("2026-10-07T13:00:00Z")).is_some());
187 assert_eq!(closed(&rule, at("2026-10-07T15:00:00Z")), None);
188 }
189
190 #[test]
191 fn a_window_can_run_past_midnight() {
192 let rule = MergeWindowRule {
193 windows: vec![WeeklyWindow { days: vec![Weekday::Fri], start: "22:00".into(), end: "02:00".into() }],
194 ..MergeWindowRule::default()
195 };
196 assert_eq!(closed(&rule, at("2026-10-09T23:00:00Z")), None);
197 assert_eq!(closed(&rule, at("2026-10-10T01:00:00Z")), None);
198 assert!(closed(&rule, at("2026-10-10T03:00:00Z")).is_some());
199 }
200
201 #[test]
202 fn freezes_close_it_and_exceptions_open_it() {
203 let mut rule = weekdays("00:00", "24:00");
204 rule.freezes.push(Period { start: "2026-10-07T00:00:00Z".into(), end: Some("2026-10-08T00:00:00Z".into()), reason: "Release".into() });
205 rule.freezes.push(Period { start: "2026-10-12T00:00:00Z".into(), end: None, reason: String::new() });
206 assert_eq!(
207 explain(&closed(&rule, at("2026-10-07T10:00:00Z")).unwrap()),
208 "Merging is frozen (Release) until 2026-10-08T00:00:00Z."
209 );
210 assert_eq!(closed(&rule, at("2026-10-08T10:00:00Z")), None);
211 assert_eq!(explain(&closed(&rule, at("2026-10-13T10:00:00Z")).unwrap()), "Merging is frozen until the freeze is lifted.");
212 rule.exceptions.push(Period { start: "2026-10-07T12:00:00Z".into(), end: Some("2026-10-07T13:00:00Z".into()), reason: "Hotfix".into() });
213 assert_eq!(closed(&rule, at("2026-10-07T12:30:00Z")), None);
214 }
215}

This file's history is long; its oldest lines are credited to the oldest commit read.