1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{CheckConclusion, EvidenceBundle, EvidenceState};
3
4pub struct RequiredStatusChecksControl;
6
7impl Control for RequiredStatusChecksControl {
8 fn id(&self) -> ControlId {
9 builtin::id(builtin::REQUIRED_STATUS_CHECKS)
10 }
11
12 fn description(&self) -> &'static str {
13 "At least one required status check must be configured"
14 }
15
16 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
17 let id = self.id();
18
19 let runs = match &evidence.check_runs {
20 EvidenceState::NotApplicable => {
21 return vec![ControlFinding::not_applicable(
22 id,
23 "Check runs evidence is not applicable",
24 )];
25 }
26 EvidenceState::Missing { gaps } => {
27 return vec![ControlFinding::indeterminate(
28 id,
29 "Check runs evidence is unavailable",
30 vec!["commit".to_string()],
31 gaps.clone(),
32 )];
33 }
34 EvidenceState::Complete { value } => value,
35 EvidenceState::Partial { value, .. } => value,
36 };
37
38 if runs.is_empty() {
39 return vec![ControlFinding::indeterminate(
40 id,
41 "No check runs found on the HEAD commit",
42 vec!["commit".to_string()],
43 vec![],
44 )];
45 }
46
47 let failed: Vec<&str> = runs
48 .iter()
49 .filter(|r| is_failing_conclusion(&r.conclusion))
50 .map(|r| r.name.as_str())
51 .collect();
52
53 if failed.is_empty() {
54 vec![ControlFinding::satisfied(
55 id,
56 format!("{} check run(s) passed", runs.len()),
57 vec!["commit".to_string()],
58 )]
59 } else {
60 vec![ControlFinding::violated(
61 id,
62 format!(
63 "{} check run(s) failed: {}",
64 failed.len(),
65 failed.join(", ")
66 ),
67 vec!["commit".to_string()],
68 )]
69 }
70 }
71}
72
73fn is_failing_conclusion(conclusion: &CheckConclusion) -> bool {
75 matches!(
76 conclusion,
77 CheckConclusion::Failure
78 | CheckConclusion::Cancelled
79 | CheckConclusion::TimedOut
80 | CheckConclusion::ActionRequired
81 | CheckConclusion::Pending
82 )
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::control::ControlStatus;
89 use crate::evidence::{CheckConclusion, CheckRunEvidence, EvidenceGap};
90
91 fn make_bundle(runs: Vec<CheckRunEvidence>) -> EvidenceBundle {
92 EvidenceBundle {
93 check_runs: EvidenceState::complete(runs),
94 ..Default::default()
95 }
96 }
97
98 fn run(name: &str, conclusion: CheckConclusion) -> CheckRunEvidence {
99 CheckRunEvidence {
100 name: name.to_string(),
101 conclusion,
102 app_slug: None,
103 }
104 }
105
106 #[test]
109 fn all_checks_success_is_satisfied() {
110 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
111 "ci/build",
112 CheckConclusion::Success,
113 )]));
114 assert_eq!(findings.len(), 1);
115 assert_eq!(findings[0].status, ControlStatus::Satisfied);
116 assert_eq!(findings[0].subjects, vec!["commit"]);
117 assert!(findings[0].rationale.contains("1 check run(s) passed"));
118 }
119
120 #[test]
121 fn multiple_checks_all_pass_is_satisfied() {
122 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![
123 run("ci/build", CheckConclusion::Success),
124 run("ci/test", CheckConclusion::Success),
125 run("ci/lint", CheckConclusion::Neutral),
126 ]));
127 assert_eq!(findings.len(), 1);
128 assert_eq!(findings[0].status, ControlStatus::Satisfied);
129 assert!(findings[0].rationale.contains("3 check run(s) passed"));
130 }
131
132 #[test]
133 fn skipped_check_is_satisfied() {
134 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![
135 run("ci/build", CheckConclusion::Success),
136 run("ci/optional", CheckConclusion::Skipped),
137 ]));
138 assert_eq!(findings[0].status, ControlStatus::Satisfied);
139 }
140
141 #[test]
144 fn one_check_failed_is_violated() {
145 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![
146 run("ci/build", CheckConclusion::Success),
147 run("ci/test", CheckConclusion::Failure),
148 ]));
149 assert_eq!(findings.len(), 1);
150 assert_eq!(findings[0].status, ControlStatus::Violated);
151 assert!(findings[0].rationale.contains("ci/test"));
152 }
153
154 #[test]
155 fn cancelled_check_is_violated() {
156 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
157 "ci/build",
158 CheckConclusion::Cancelled,
159 )]));
160 assert_eq!(findings[0].status, ControlStatus::Violated);
161 }
162
163 #[test]
164 fn timed_out_check_is_violated() {
165 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
166 "ci/build",
167 CheckConclusion::TimedOut,
168 )]));
169 assert_eq!(findings[0].status, ControlStatus::Violated);
170 }
171
172 #[test]
173 fn pending_check_is_violated() {
174 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![run(
175 "ci/deploy",
176 CheckConclusion::Pending,
177 )]));
178 assert_eq!(findings[0].status, ControlStatus::Violated);
179 }
180
181 #[test]
184 fn no_check_runs_is_indeterminate() {
185 let findings = RequiredStatusChecksControl.evaluate(&make_bundle(vec![]));
186 assert_eq!(findings.len(), 1);
187 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
188 assert!(findings[0].rationale.contains("No check runs found"));
189 }
190
191 #[test]
192 fn indeterminate_when_evidence_missing() {
193 let bundle = EvidenceBundle {
194 check_runs: EvidenceState::missing(vec![EvidenceGap::CollectionFailed {
195 source: "github".to_string(),
196 subject: "commit".to_string(),
197 detail: "403 Forbidden".to_string(),
198 }]),
199 ..Default::default()
200 };
201 let findings = RequiredStatusChecksControl.evaluate(&bundle);
202 assert_eq!(findings.len(), 1);
203 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
204 assert_eq!(findings[0].evidence_gaps.len(), 1);
205 }
206
207 #[test]
210 fn not_applicable_when_evidence_not_applicable() {
211 let bundle = EvidenceBundle {
212 check_runs: EvidenceState::not_applicable(),
213 ..Default::default()
214 };
215 let findings = RequiredStatusChecksControl.evaluate(&bundle);
216 assert_eq!(findings.len(), 1);
217 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
218 }
219
220 #[test]
223 fn partial_evidence_still_evaluates() {
224 let bundle = EvidenceBundle {
225 check_runs: EvidenceState::partial(
226 vec![run("ci/test", CheckConclusion::Success)],
227 vec![EvidenceGap::Truncated {
228 source: "github".to_string(),
229 subject: "check_runs".to_string(),
230 }],
231 ),
232 ..Default::default()
233 };
234 let findings = RequiredStatusChecksControl.evaluate(&bundle);
235 assert_eq!(findings.len(), 1);
236 assert_eq!(findings[0].status, ControlStatus::Satisfied);
237 }
238
239 #[test]
240 fn control_id_is_required_status_checks() {
241 assert_eq!(
242 RequiredStatusChecksControl.id(),
243 builtin::id(builtin::REQUIRED_STATUS_CHECKS)
244 );
245 }
246}