1use serde::Serialize;
6
7use crate::vendor_error::VendorErrorClass;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ExitClass {
14 Success,
16 Failure(i32),
18 KilledBySignal,
20}
21
22pub fn classify_exit(exit_code: Option<i32>) -> ExitClass {
23 match exit_code {
24 Some(0) => ExitClass::Success,
25 Some(code) => ExitClass::Failure(code),
26 None => ExitClass::KilledBySignal,
27 }
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum MergeOutcome {
33 Merged,
34 Diverged,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct RunEvidence {
43 pub cancelled: bool,
45 pub exit: ExitClass,
46 pub vendor_error: Option<VendorErrorClass>,
48 pub commit_count: Option<usize>,
52 pub aftercare_failed: bool,
54 pub merge: Option<MergeOutcome>,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum Outcome {
63 Merged,
64 Failed,
65 NeedsReview,
66 Cancelled,
67 RateLimited,
69 Orphaned,
72}
73
74impl Outcome {
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Self::Merged => "merged",
78 Self::Failed => "failed",
79 Self::NeedsReview => "needs_review",
80 Self::Cancelled => "cancelled",
81 Self::RateLimited => "rate_limited",
82 Self::Orphaned => "orphaned",
83 }
84 }
85}
86
87pub fn derive_outcome(evidence: &RunEvidence) -> Outcome {
103 if evidence.cancelled {
104 return Outcome::Cancelled;
105 }
106 if let Some(class) = evidence.vendor_error {
107 return if class.requires_cooldown() {
108 Outcome::RateLimited
109 } else {
110 Outcome::Failed
111 };
112 }
113 if evidence.merge == Some(MergeOutcome::Merged) {
114 return Outcome::Merged;
115 }
116 if evidence.exit != ExitClass::Success {
117 return Outcome::Failed;
118 }
119 if evidence.aftercare_failed && evidence.commit_count == Some(0) {
120 return Outcome::Failed;
121 }
122 Outcome::NeedsReview
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn evidence() -> RunEvidence {
130 RunEvidence {
131 cancelled: false,
132 exit: ExitClass::Success,
133 vendor_error: None,
134 commit_count: Some(0),
135 aftercare_failed: false,
136 merge: None,
137 }
138 }
139
140 #[test]
141 fn a_successful_exit_without_a_merge_needs_review() {
142 let outcome = derive_outcome(&evidence());
143 assert_eq!(outcome, Outcome::NeedsReview);
144 }
145
146 #[test]
147 fn a_successful_merge_is_the_only_path_to_merged() {
148 let merged = derive_outcome(&RunEvidence {
149 commit_count: Some(1),
150 merge: Some(MergeOutcome::Merged),
151 ..evidence()
152 });
153 assert_eq!(merged, Outcome::Merged);
154
155 let diverged = derive_outcome(&RunEvidence {
156 commit_count: Some(1),
157 merge: Some(MergeOutcome::Diverged),
158 ..evidence()
159 });
160 assert_ne!(diverged, Outcome::Merged);
161 }
162
163 #[test]
164 fn failed_aftercare_with_commits_needs_review() {
165 let outcome = derive_outcome(&RunEvidence {
166 commit_count: Some(1),
167 aftercare_failed: true,
168 ..evidence()
169 });
170 assert_eq!(outcome, Outcome::NeedsReview);
171 }
172
173 #[test]
174 fn failed_aftercare_without_commits_fails() {
175 let outcome = derive_outcome(&RunEvidence {
176 aftercare_failed: true,
177 ..evidence()
178 });
179 assert_eq!(outcome, Outcome::Failed);
180 }
181
182 #[test]
183 fn failed_aftercare_with_unknown_commits_needs_review() {
184 let outcome = derive_outcome(&RunEvidence {
185 commit_count: None,
186 aftercare_failed: true,
187 ..evidence()
188 });
189 assert_eq!(outcome, Outcome::NeedsReview);
190 }
191
192 #[test]
193 fn a_crashed_agent_fails_regardless_of_git_history() {
194 let outcome = derive_outcome(&RunEvidence {
195 exit: ExitClass::Failure(1),
196 ..evidence()
197 });
198 assert_eq!(outcome, Outcome::Failed);
199 }
200
201 #[test]
202 fn cancellation_wins_over_every_other_reading() {
203 let outcome = derive_outcome(&RunEvidence {
204 cancelled: true,
205 exit: ExitClass::KilledBySignal,
206 vendor_error: None,
207 commit_count: Some(5),
208 aftercare_failed: false,
209 merge: Some(MergeOutcome::Merged),
210 });
211 assert_eq!(outcome, Outcome::Cancelled);
212 }
213
214 #[test]
215 fn exit_classification_reads_the_wait_status() {
216 assert_eq!(classify_exit(Some(0)), ExitClass::Success);
217 assert_eq!(classify_exit(Some(2)), ExitClass::Failure(2));
218 assert_eq!(classify_exit(None), ExitClass::KilledBySignal);
219 }
220 #[test]
221 fn vendor_rejections_follow_code_owned_policy() {
222 for class in [
223 VendorErrorClass::AuthenticationRequired,
224 VendorErrorClass::InvalidConfiguration,
225 ] {
226 assert_eq!(
227 derive_outcome(&RunEvidence {
228 vendor_error: Some(class),
229 ..evidence()
230 }),
231 Outcome::Failed
232 );
233 }
234 for class in [
235 VendorErrorClass::RateLimited,
236 VendorErrorClass::UnknownRejection,
237 ] {
238 assert_eq!(
239 derive_outcome(&RunEvidence {
240 vendor_error: Some(class),
241 ..evidence()
242 }),
243 Outcome::RateLimited
244 );
245 }
246 }
247}