Skip to main content

fallow_output/
pr_status.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{PrDecisionConclusion, PrDecisionSurface};
4
5/// One commit-status context derived from a [`PrDecisionSurface`].
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub struct PrStatusContext {
8    /// Status context name as shown in the provider UI: `Fallow` for the
9    /// aggregate context, `Fallow / <gate id>` for split contexts.
10    pub name: String,
11    /// Conclusion reported for this context.
12    pub conclusion: PrDecisionConclusion,
13    /// One-line status description.
14    pub summary: String,
15}
16
17/// How a decision surface fans out into commit-status contexts.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum PrStatusMode {
20    /// One combined context for the overall conclusion.
21    Aggregate,
22    /// One context per gate.
23    Split,
24    /// The aggregate context followed by one context per gate.
25    AggregateAndSplit,
26}
27
28/// Per-gate status contexts for `surface`; shorthand for
29/// [`pr_status_contexts_with_mode`] with [`PrStatusMode::Split`].
30pub fn pr_status_contexts(surface: &PrDecisionSurface) -> Vec<PrStatusContext> {
31    pr_status_contexts_with_mode(surface, PrStatusMode::Split)
32}
33
34/// Commit-status contexts for `surface`, fanned out according to `mode`.
35pub fn pr_status_contexts_with_mode(
36    surface: &PrDecisionSurface,
37    mode: PrStatusMode,
38) -> Vec<PrStatusContext> {
39    match mode {
40        PrStatusMode::Aggregate => vec![aggregate_status_context(surface)],
41        PrStatusMode::Split => split_status_contexts(surface),
42        PrStatusMode::AggregateAndSplit => {
43            let mut contexts = vec![aggregate_status_context(surface)];
44            contexts.extend(split_status_contexts(surface));
45            contexts
46        }
47    }
48}
49
50fn split_status_contexts(surface: &PrDecisionSurface) -> Vec<PrStatusContext> {
51    surface
52        .gates
53        .iter()
54        .map(|gate| PrStatusContext {
55            name: format!("Fallow / {}", gate.id),
56            conclusion: gate.status,
57            summary: match &gate.threshold {
58                Some(threshold) => {
59                    format!("{}: {} (threshold {threshold})", gate.label, gate.observed)
60                }
61                None => format!("{}: {}", gate.label, gate.observed),
62            },
63        })
64        .collect()
65}
66
67fn aggregate_status_context(surface: &PrDecisionSurface) -> PrStatusContext {
68    let total = surface.gates.len();
69    let failing = surface
70        .gates
71        .iter()
72        .filter(|gate| gate.status == PrDecisionConclusion::Failure)
73        .count();
74    let neutral = surface
75        .gates
76        .iter()
77        .filter(|gate| gate.status == PrDecisionConclusion::Neutral)
78        .count();
79    let summary = if total == 0 {
80        "No PR gates reported.".to_owned()
81    } else if failing > 0 {
82        format!("{failing} of {total} PR gates failed.")
83    } else if neutral > 0 {
84        format!("{neutral} of {total} PR gates need review.")
85    } else {
86        format!("All {total} PR gates passed.")
87    };
88    PrStatusContext {
89        name: "Fallow".to_owned(),
90        conclusion: surface.conclusion,
91        summary,
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::{PR_DECISION_SCHEMA, PrDecisionDetails, PrDecisionGate, PrDecisionSurface};
99
100    #[test]
101    fn builds_status_contexts_from_decision_gates() {
102        let surface = PrDecisionSurface {
103            schema: PR_DECISION_SCHEMA.to_owned(),
104            title: "Fallow".to_owned(),
105            conclusion: PrDecisionConclusion::Neutral,
106            gates: vec![PrDecisionGate {
107                id: "duplication".to_owned(),
108                label: "Duplication".to_owned(),
109                status: PrDecisionConclusion::Neutral,
110                observed: "2 clone groups".to_owned(),
111                threshold: Some("configured rules".to_owned()),
112                scope: "new code".to_owned(),
113            }],
114            annotations: vec![],
115            details: PrDecisionDetails {
116                summary_markdown: "Review recommended.".to_owned(),
117                full_report_path: None,
118                details_url: None,
119            },
120        };
121
122        let contexts = pr_status_contexts(&surface);
123
124        assert_eq!(contexts.len(), 1);
125        assert_eq!(contexts[0].name, "Fallow / duplication");
126        assert_eq!(contexts[0].conclusion, PrDecisionConclusion::Neutral);
127        assert_eq!(
128            contexts[0].summary,
129            "Duplication: 2 clone groups (threshold configured rules)"
130        );
131    }
132
133    #[test]
134    fn aggregate_and_split_status_contexts_keep_one_consolidated_context() {
135        let surface = PrDecisionSurface {
136            schema: PR_DECISION_SCHEMA.to_owned(),
137            title: "Fallow".to_owned(),
138            conclusion: PrDecisionConclusion::Neutral,
139            gates: vec![PrDecisionGate {
140                id: "health".to_owned(),
141                label: "Health".to_owned(),
142                status: PrDecisionConclusion::Neutral,
143                observed: "1 finding".to_owned(),
144                threshold: None,
145                scope: "new code".to_owned(),
146            }],
147            annotations: vec![],
148            details: PrDecisionDetails {
149                summary_markdown: "Review recommended.".to_owned(),
150                full_report_path: None,
151                details_url: None,
152            },
153        };
154
155        let contexts = pr_status_contexts_with_mode(&surface, PrStatusMode::AggregateAndSplit);
156
157        assert_eq!(contexts.len(), 2);
158        assert_eq!(contexts[0].name, "Fallow");
159        assert_eq!(contexts[0].summary, "1 of 1 PR gates need review.");
160        assert_eq!(contexts[1].name, "Fallow / health");
161    }
162}