1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{ApprovalDisposition, EvidenceBundle, EvidenceState, GovernedChange};
3
4fn rfc3339_to_epoch_secs(ts: &str) -> Option<i64> {
8 if ts.len() < 20 {
10 return None;
11 }
12 let year: i64 = ts[0..4].parse().ok()?;
13 let month: i64 = ts[5..7].parse().ok()?;
14 let day: i64 = ts[8..10].parse().ok()?;
15 let hour: i64 = ts[11..13].parse().ok()?;
16 let min: i64 = ts[14..16].parse().ok()?;
17 let sec: i64 = ts[17..19].parse().ok()?;
18
19 let days = days_from_epoch(year, month, day);
21 let base_secs = days * 86400 + hour * 3600 + min * 60 + sec;
22
23 let tz_part = &ts[19..];
25 let offset_secs = if tz_part.starts_with('Z') || tz_part.starts_with('z') {
26 0
27 } else if tz_part.len() >= 6 && (tz_part.starts_with('+') || tz_part.starts_with('-')) {
28 let sign = if tz_part.starts_with('+') { 1 } else { -1 };
29 let oh: i64 = tz_part[1..3].parse().ok()?;
30 let om: i64 = tz_part[4..6].parse().ok()?;
31 sign * (oh * 3600 + om * 60)
32 } else {
33 0 };
35
36 Some(base_secs - offset_secs)
37}
38
39fn days_from_epoch(year: i64, month: i64, day: i64) -> i64 {
41 let (y, m) = if month <= 2 {
43 (year - 1, month + 9)
44 } else {
45 (year, month - 3)
46 };
47 let era = y / 400;
48 let yoe = y - era * 400;
49 let doy = (153 * m + 2) / 5 + day - 1;
50 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
51 era * 146097 + doe - 719468
52}
53
54fn ts_is_before(a: &str, b: &str) -> bool {
57 match (rfc3339_to_epoch_secs(a), rfc3339_to_epoch_secs(b)) {
58 (Some(ea), Some(eb)) => ea < eb,
59 _ => a < b,
61 }
62}
63
64pub struct StaleReviewControl;
70
71impl Control for StaleReviewControl {
72 fn id(&self) -> ControlId {
73 builtin::id(builtin::STALE_REVIEW)
74 }
75
76 fn description(&self) -> &'static str {
77 "Approvals must postdate the latest source revision"
78 }
79
80 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
81 if evidence.change_requests.is_empty() {
82 return vec![ControlFinding::not_applicable(
83 self.id(),
84 "No change requests found",
85 )];
86 }
87
88 evidence
89 .change_requests
90 .iter()
91 .map(|cr| evaluate_change(self.id(), cr))
92 .collect()
93 }
94}
95
96fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
97 let cr_subject = cr.id.to_string();
98
99 let approvals = match &cr.approval_decisions {
100 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
101 EvidenceState::Missing { gaps } => {
102 return ControlFinding::indeterminate(
103 id,
104 format!("{cr_subject}: approval evidence could not be collected"),
105 vec![cr_subject],
106 gaps.clone(),
107 );
108 }
109 EvidenceState::NotApplicable => {
110 return ControlFinding::not_applicable(id, "Approval decisions not applicable");
111 }
112 };
113
114 let revisions = match &cr.source_revisions {
115 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => value,
116 EvidenceState::Missing { gaps } => {
117 return ControlFinding::indeterminate(
118 id,
119 format!("{cr_subject}: source revision evidence could not be collected"),
120 vec![cr_subject],
121 gaps.clone(),
122 );
123 }
124 EvidenceState::NotApplicable => {
125 return ControlFinding::not_applicable(id, "Source revisions not applicable");
126 }
127 };
128
129 let latest_commit_ts = revisions
133 .iter()
134 .filter(|r| !r.merge && !is_bot_author(r.authored_by.as_deref()))
135 .filter_map(|r| r.committed_at.as_deref())
136 .max_by(|a, b| {
137 let ea = rfc3339_to_epoch_secs(a).unwrap_or(0);
138 let eb = rfc3339_to_epoch_secs(b).unwrap_or(0);
139 ea.cmp(&eb)
140 });
141
142 let latest_commit_ts = match latest_commit_ts {
143 Some(ts) => ts,
144 None => {
145 return ControlFinding::not_applicable(
146 id,
147 format!("{cr_subject}: no non-merge commits with timestamps"),
148 );
149 }
150 };
151
152 let stale_approvals: Vec<String> = approvals
154 .iter()
155 .filter(|a| a.disposition == ApprovalDisposition::Approved)
156 .filter(|a| {
157 a.submitted_at
158 .as_deref()
159 .is_some_and(|ts| ts_is_before(ts, latest_commit_ts))
160 })
161 .map(|a| a.actor.clone())
162 .collect();
163
164 if stale_approvals.is_empty() {
165 let has_approvals = approvals
167 .iter()
168 .any(|a| a.disposition == ApprovalDisposition::Approved);
169 if !has_approvals {
170 return ControlFinding::not_applicable(
171 id,
172 format!("{cr_subject}: no approval decisions to evaluate for staleness"),
173 );
174 }
175 ControlFinding::satisfied(
176 id,
177 format!("{cr_subject}: all approvals postdate the latest source revision"),
178 vec![cr_subject],
179 )
180 } else {
181 ControlFinding::violated(
182 id,
183 format!(
184 "{cr_subject}: {} approval(s) predate the latest commit ({}): {}",
185 stale_approvals.len(),
186 latest_commit_ts,
187 stale_approvals.join(", ")
188 ),
189 stale_approvals,
190 )
191 }
192}
193
194fn is_bot_author(author: Option<&str>) -> bool {
197 let Some(author) = author else {
198 return false;
199 };
200 let lower = author.to_ascii_lowercase();
201 const BOT_NAMES: &[&str] = &[
203 "bors",
204 "bors[bot]",
205 "mergify[bot]",
206 "mergify",
207 "dependabot[bot]",
208 "dependabot",
209 "renovate[bot]",
210 "renovate",
211 "k8s-ci-robot",
212 "greenkeeper[bot]",
213 "github-actions[bot]",
214 "copybara-service[bot]",
215 ];
216 if BOT_NAMES.contains(&lower.as_str()) {
217 return true;
218 }
219 lower.ends_with("[bot]")
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use crate::control::ControlStatus;
227 use crate::evidence::{ApprovalDecision, ChangeRequestId, EvidenceGap, SourceRevision};
228
229 fn make_change(
230 approvals: EvidenceState<Vec<ApprovalDecision>>,
231 revisions: EvidenceState<Vec<SourceRevision>>,
232 ) -> GovernedChange {
233 GovernedChange {
234 id: ChangeRequestId::new("test", "owner/repo#1"),
235 title: "test".to_string(),
236 summary: None,
237 submitted_by: None,
238 changed_assets: EvidenceState::not_applicable(),
239 approval_decisions: approvals,
240 source_revisions: revisions,
241 work_item_refs: EvidenceState::not_applicable(),
242 }
243 }
244
245 fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
246 EvidenceBundle {
247 change_requests: changes,
248 ..Default::default()
249 }
250 }
251
252 fn approval(actor: &str, ts: &str) -> ApprovalDecision {
253 ApprovalDecision {
254 actor: actor.to_string(),
255 disposition: ApprovalDisposition::Approved,
256 submitted_at: Some(ts.to_string()),
257 }
258 }
259
260 fn revision(id: &str, ts: &str, merge: bool) -> SourceRevision {
261 SourceRevision {
262 id: id.to_string(),
263 authored_by: Some("dev".to_string()),
264 committed_at: Some(ts.to_string()),
265 merge,
266 authenticity: EvidenceState::not_applicable(),
267 }
268 }
269
270 #[test]
271 fn not_applicable_when_no_changes() {
272 let findings = StaleReviewControl.evaluate(&EvidenceBundle::default());
273 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
274 }
275
276 #[test]
277 fn satisfied_when_approval_postdates_latest_commit() {
278 let cr = make_change(
279 EvidenceState::complete(vec![approval("reviewer", "2026-03-15T12:00:00Z")]),
280 EvidenceState::complete(vec![revision("abc", "2026-03-15T10:00:00Z", false)]),
281 );
282 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
283 assert_eq!(findings[0].status, ControlStatus::Satisfied);
284 }
285
286 #[test]
287 fn violated_when_approval_predates_latest_commit() {
288 let cr = make_change(
289 EvidenceState::complete(vec![approval("reviewer", "2026-03-15T10:00:00Z")]),
290 EvidenceState::complete(vec![revision("abc", "2026-03-15T12:00:00Z", false)]),
291 );
292 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
293 assert_eq!(findings[0].status, ControlStatus::Violated);
294 assert!(findings[0].rationale.contains("reviewer"));
295 }
296
297 #[test]
298 fn ignores_merge_commits_for_latest_timestamp() {
299 let cr = make_change(
300 EvidenceState::complete(vec![approval("reviewer", "2026-03-15T11:00:00Z")]),
301 EvidenceState::complete(vec![
302 revision("abc", "2026-03-15T10:00:00Z", false),
303 revision("merge", "2026-03-15T14:00:00Z", true),
304 ]),
305 );
306 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
307 assert_eq!(findings[0].status, ControlStatus::Satisfied);
308 }
309
310 #[test]
311 fn indeterminate_when_approvals_missing() {
312 let cr = make_change(
313 EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
314 source: "github".to_string(),
315 subject: "reviews".to_string(),
316 detail: "API error".to_string(),
317 }]),
318 EvidenceState::complete(vec![revision("abc", "2026-03-15T10:00:00Z", false)]),
319 );
320 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
321 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
322 }
323
324 #[test]
325 fn not_applicable_when_no_approvals() {
326 let cr = make_change(
327 EvidenceState::complete(vec![]),
328 EvidenceState::complete(vec![revision("abc", "2026-03-15T10:00:00Z", false)]),
329 );
330 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
331 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
332 }
333
334 #[test]
335 fn ignores_bot_commits_for_latest_timestamp() {
336 let mut bot_rev = revision("bot-abc", "2026-03-15T14:00:00Z", false);
338 bot_rev.authored_by = Some("bors".to_string());
339 let cr = make_change(
340 EvidenceState::complete(vec![approval("reviewer", "2026-03-15T11:00:00Z")]),
341 EvidenceState::complete(vec![
342 revision("abc", "2026-03-15T10:00:00Z", false),
343 bot_rev,
344 ]),
345 );
346 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
347 assert_eq!(findings[0].status, ControlStatus::Satisfied);
348 }
349
350 #[test]
351 fn ignores_github_app_bot_commits() {
352 let mut bot_rev = revision("bot-abc", "2026-03-15T14:00:00Z", false);
353 bot_rev.authored_by = Some("dependabot[bot]".to_string());
354 let cr = make_change(
355 EvidenceState::complete(vec![approval("reviewer", "2026-03-15T11:00:00Z")]),
356 EvidenceState::complete(vec![
357 revision("abc", "2026-03-15T10:00:00Z", false),
358 bot_rev,
359 ]),
360 );
361 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
362 assert_eq!(findings[0].status, ControlStatus::Satisfied);
363 }
364
365 #[test]
366 fn bot_author_detection() {
367 assert!(is_bot_author(Some("bors")));
368 assert!(is_bot_author(Some("Bors")));
369 assert!(is_bot_author(Some("k8s-ci-robot")));
370 assert!(is_bot_author(Some("dependabot[bot]")));
371 assert!(is_bot_author(Some("custom-app[bot]")));
372 assert!(!is_bot_author(Some("developer")));
373 assert!(!is_bot_author(None));
374 }
375
376 #[test]
377 fn timezone_aware_comparison_utc_vs_offset() {
378 assert!(!ts_is_before(
381 "2026-03-24T02:54:37Z",
382 "2026-03-24T10:34:00+08:00"
383 ));
384 assert!(ts_is_before(
386 "2026-03-24T10:34:00+08:00",
387 "2026-03-24T02:54:37Z"
388 ));
389 }
390
391 #[test]
392 fn timezone_aware_same_tz() {
393 assert!(ts_is_before("2026-03-15T10:00:00Z", "2026-03-15T12:00:00Z"));
394 assert!(!ts_is_before(
395 "2026-03-15T12:00:00Z",
396 "2026-03-15T10:00:00Z"
397 ));
398 }
399
400 #[test]
401 fn timezone_aware_negative_offset() {
402 assert!(!ts_is_before(
404 "2026-03-15T10:00:00-05:00",
405 "2026-03-15T14:00:00Z"
406 ));
407 assert!(ts_is_before(
408 "2026-03-15T14:00:00Z",
409 "2026-03-15T10:00:00-05:00"
410 ));
411 }
412
413 #[test]
414 fn satisfied_when_approval_after_offset_commit() {
415 let cr = make_change(
417 EvidenceState::complete(vec![approval("reviewer", "2026-03-24T02:54:37Z")]),
418 EvidenceState::complete(vec![revision("abc", "2026-03-24T10:34:00+08:00", false)]),
419 );
420 let findings = StaleReviewControl.evaluate(&bundle(vec![cr]));
421 assert_eq!(findings[0].status, ControlStatus::Satisfied);
422 }
423}