Skip to main content

libverify_core/controls/
issue_linkage.rs

1use crate::control::{Control, ControlFinding, ControlId, builtin};
2use crate::evidence::{EvidenceBundle, EvidenceState, GovernedChange};
3
4/// Verifies that change requests reference at least one issue or ticket.
5pub 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}