libverify_core/controls/
issue_linkage.rs1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3
4pub struct IssueLinkageControl;
6
7impl Control for IssueLinkageControl {
8 fn id(&self) -> ControlId {
9 builtin::id(builtin::ISSUE_LINKAGE)
10 }
11
12 fn description(&self) -> &'static str {
13 "Change request must reference at least one issue or ticket"
14 }
15
16 fn evaluate(&self, evidence: &EvidenceBundle) -> Vec<ControlFinding> {
17 if evidence.change_requests.is_empty() {
18 return vec![ControlFinding::not_applicable(
19 self.id(),
20 "No change requests found",
21 )];
22 }
23
24 evidence
25 .change_requests
26 .iter()
27 .map(|cr| evaluate_change(self.id(), cr))
28 .collect()
29 }
30}
31
32fn evaluate_change(id: ControlId, cr: &GovernedChange) -> ControlFinding {
33 let cr_subject = cr.id.to_string();
34
35 match &cr.work_item_refs {
36 EvidenceState::NotApplicable => {
37 ControlFinding::not_applicable(id, "Issue linkage not applicable")
38 }
39 EvidenceState::Missing { gaps } => ControlFinding::indeterminate(
40 id,
41 format!("{cr_subject}: issue linkage evidence could not be collected"),
42 vec![cr_subject],
43 gaps.clone(),
44 ),
45 EvidenceState::Complete { value } | EvidenceState::Partial { value, .. } => {
46 if value.is_empty() {
47 ControlFinding::violated(
48 id,
49 format!("{cr_subject}: no issue or ticket references found"),
50 vec![cr_subject],
51 )
52 } else {
53 let subjects: Vec<String> = value
54 .iter()
55 .map(|r| format!("{}:{}", r.system, r.value))
56 .collect();
57 ControlFinding::satisfied(
58 id,
59 format!(
60 "{cr_subject}: references {} issue(s)/ticket(s)",
61 value.len()
62 ),
63 subjects,
64 )
65 }
66 }
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::control::ControlStatus;
74 use crate::evidence::{ChangeRequestId, EvidenceGap, WorkItemRef};
75
76 fn make_change(refs: EvidenceState<Vec<WorkItemRef>>) -> GovernedChange {
77 GovernedChange {
78 id: ChangeRequestId::new("test", "owner/repo#1"),
79 title: "test".to_string(),
80 summary: None,
81 submitted_by: None,
82 changed_assets: EvidenceState::not_applicable(),
83 approval_decisions: EvidenceState::not_applicable(),
84 source_revisions: EvidenceState::not_applicable(),
85 work_item_refs: refs,
86 }
87 }
88
89 fn bundle(changes: Vec<GovernedChange>) -> EvidenceBundle {
90 EvidenceBundle {
91 change_requests: changes,
92 ..Default::default()
93 }
94 }
95
96 #[test]
97 fn not_applicable_when_no_changes() {
98 let findings = IssueLinkageControl.evaluate(&EvidenceBundle::default());
99 assert_eq!(findings[0].status, ControlStatus::NotApplicable);
100 }
101
102 #[test]
103 fn satisfied_when_refs_present() {
104 let cr = make_change(EvidenceState::complete(vec![WorkItemRef {
105 system: "github_issue".to_string(),
106 value: "#42".to_string(),
107 }]));
108 let findings = IssueLinkageControl.evaluate(&bundle(vec![cr]));
109 assert_eq!(findings[0].status, ControlStatus::Satisfied);
110 assert!(findings[0].subjects.iter().any(|s| s.contains("#42")));
111 }
112
113 #[test]
114 fn violated_when_no_refs() {
115 let cr = make_change(EvidenceState::complete(vec![]));
116 let findings = IssueLinkageControl.evaluate(&bundle(vec![cr]));
117 assert_eq!(findings[0].status, ControlStatus::Violated);
118 }
119
120 #[test]
121 fn indeterminate_when_missing() {
122 let cr = make_change(EvidenceState::missing(vec![
123 EvidenceGap::CollectionFailed {
124 source: "github".to_string(),
125 subject: "body".to_string(),
126 detail: "parse error".to_string(),
127 },
128 ]));
129 let findings = IssueLinkageControl.evaluate(&bundle(vec![cr]));
130 assert_eq!(findings[0].status, ControlStatus::Indeterminate);
131 }
132}