1use serde::{Deserialize, Serialize};
2
3use crate::domain::ticket::TicketState;
4use crate::outcome::Outcome;
5
6const NEEDS_REVIEW_REASON: &str = "needs-review";
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct OwnerId(pub String);
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SourceVersion(pub String);
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct TicketRef {
16 pub id: String,
17 pub source: String,
18 pub source_ref: Option<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct ExecutionHints {
23 pub worktree: Option<String>,
24 pub trigger_id: Option<String>,
25 pub target: Option<String>,
26 pub model: Option<String>,
27 pub effort: Option<String>,
28 pub flow: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct WorkTicket {
33 pub id: String,
34 pub project_id: String,
35 pub name: String,
36 pub body: String,
37 pub state: WorkTicketState,
38 pub blocked_by: Vec<String>,
39 pub attempts: u32,
40 pub hints: ExecutionHints,
41 pub version: SourceVersion,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum WorkTicketState {
47 Ready,
48 Blocked,
49 Held { reason: String },
50 Claimed { by: OwnerId },
51 Done,
52 Failed,
53}
54
55impl WorkTicketState {
56 pub fn from_ticket_state(
57 state: TicketState,
58 blocked: bool,
59 held_reason: String,
60 owner: OwnerId,
61 ) -> Self {
62 match state {
63 TicketState::Ready if blocked => Self::Blocked,
64 TicketState::Ready => Self::Ready,
65 TicketState::Held => Self::Held {
66 reason: held_reason,
67 },
68 TicketState::Claimed => Self::Claimed { by: owner },
69 TicketState::Merged => Self::Done,
70 TicketState::Failed => Self::Failed,
71 TicketState::NeedsReview => Self::Held {
72 reason: NEEDS_REVIEW_REASON.into(),
73 },
74 }
75 }
76
77 pub fn to_ticket_state(&self) -> TicketState {
78 match self {
79 Self::Ready | Self::Blocked => TicketState::Ready,
80 Self::Held { reason } if reason == NEEDS_REVIEW_REASON => TicketState::NeedsReview,
81 Self::Held { .. } => TicketState::Held,
82 Self::Claimed { .. } => TicketState::Claimed,
83 Self::Done => TicketState::Merged,
84 Self::Failed => TicketState::Failed,
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum Disposition {
92 Complete,
93 Retry {
96 not_before_ms: Option<i64>,
97 },
98 Park {
99 reason: String,
100 },
101 Abandon,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct WorkOutcome {
106 pub ticket_id: String,
107 pub owner: OwnerId,
108 #[serde(with = "outcome_serde")]
109 pub verdict: Outcome,
110 pub branch: Option<String>,
111 pub commit_count: u32,
112 pub attempt: u32,
113 pub finished_at_ms: i64,
114}
115
116mod outcome_serde {
117 use serde::{Deserialize, Deserializer, Serializer, de::Error};
118
119 use crate::outcome::Outcome;
120
121 pub fn serialize<S>(outcome: &Outcome, serializer: S) -> Result<S::Ok, S::Error>
122 where
123 S: Serializer,
124 {
125 serializer.serialize_str(outcome.as_str())
126 }
127
128 pub fn deserialize<'de, D>(deserializer: D) -> Result<Outcome, D::Error>
129 where
130 D: Deserializer<'de>,
131 {
132 let value = String::deserialize(deserializer)?;
133 match value.as_str() {
134 "merged" => Ok(Outcome::Merged),
135 "failed" => Ok(Outcome::Failed),
136 "needs_review" => Ok(Outcome::NeedsReview),
137 "cancelled" => Ok(Outcome::Cancelled),
138 "rate_limited" => Ok(Outcome::RateLimited),
139 "orphaned" => Ok(Outcome::Orphaned),
140 _ => Err(D::Error::unknown_variant(
141 &value,
142 &[
143 "merged",
144 "failed",
145 "needs_review",
146 "cancelled",
147 "rate_limited",
148 "orphaned",
149 ],
150 )),
151 }
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn ticket_states_round_trip_through_work_ticket_states() {
161 let owner = OwnerId("daemon-1".into());
162 for (state, expected) in [
163 (TicketState::Ready, WorkTicketState::Ready),
164 (
165 TicketState::Held,
166 WorkTicketState::Held {
167 reason: "operator".into(),
168 },
169 ),
170 (
171 TicketState::Claimed,
172 WorkTicketState::Claimed { by: owner.clone() },
173 ),
174 (TicketState::Merged, WorkTicketState::Done),
175 (TicketState::Failed, WorkTicketState::Failed),
176 (
177 TicketState::NeedsReview,
178 WorkTicketState::Held {
179 reason: NEEDS_REVIEW_REASON.into(),
180 },
181 ),
182 ] {
183 let work_state =
184 WorkTicketState::from_ticket_state(state, false, "operator".into(), owner.clone());
185 assert_eq!(work_state, expected);
186 assert_eq!(work_state.to_ticket_state(), state);
187 }
188 }
189
190 #[test]
191 fn blocked_is_the_portable_view_of_a_blocked_ready_ticket() {
192 let state = WorkTicketState::from_ticket_state(
193 TicketState::Ready,
194 true,
195 String::new(),
196 OwnerId(String::new()),
197 );
198
199 assert_eq!(state, WorkTicketState::Blocked);
200 assert_eq!(state.to_ticket_state(), TicketState::Ready);
201 }
202
203 #[test]
204 fn work_outcomes_round_trip_all_verdicts_through_json() {
205 for verdict in [
206 Outcome::Merged,
207 Outcome::Failed,
208 Outcome::NeedsReview,
209 Outcome::Cancelled,
210 Outcome::RateLimited,
211 Outcome::Orphaned,
212 ] {
213 let outcome = WorkOutcome {
214 ticket_id: "T1".into(),
215 owner: OwnerId("daemon-1".into()),
216 verdict,
217 branch: Some("sloop/t1".into()),
218 commit_count: 2,
219 attempt: 1,
220 finished_at_ms: 42,
221 };
222 let json = serde_json::to_string(&outcome).unwrap();
223 let restored: WorkOutcome = serde_json::from_str(&json).unwrap();
224
225 assert_eq!(restored, outcome);
226 }
227 }
228}