1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::models::task::TaskStatus;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
8#[serde(rename_all = "lowercase")]
9pub enum KanbanTransport {
10 #[default]
12 Acp,
13 A2a,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
20#[serde(rename_all = "camelCase")]
21pub struct KanbanAutomationStep {
22 pub id: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub transport: Option<KanbanTransport>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub provider_id: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub role: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub specialist_id: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub specialist_name: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub agent_card_url: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub skill_id: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub auth_config_id: Option<String>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
46#[serde(rename_all = "camelCase")]
47pub struct KanbanColumnAutomation {
48 #[serde(default)]
50 pub enabled: bool,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub steps: Option<Vec<KanbanAutomationStep>>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub provider_id: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub role: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub specialist_id: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub specialist_name: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub transition_type: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub required_artifacts: Option<Vec<String>>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub auto_advance_on_success: Option<bool>,
75}
76
77impl KanbanColumnAutomation {
78 pub fn primary_step(&self) -> Option<KanbanAutomationStep> {
79 if !self.enabled {
80 return None;
81 }
82
83 if let Some(step) = self.steps.as_ref().and_then(|steps| {
84 steps.iter().find(|step| {
85 matches!(step.transport, Some(KanbanTransport::A2a))
86 || step.provider_id.is_some()
87 || step.role.is_some()
88 || step.specialist_id.is_some()
89 || step.specialist_name.is_some()
90 || step.agent_card_url.is_some()
91 || step.skill_id.is_some()
92 || step.auth_config_id.is_some()
93 })
94 }) {
95 return Some(step.clone());
96 }
97
98 Some(KanbanAutomationStep {
99 id: "step-1".to_string(),
100 transport: None, provider_id: self.provider_id.clone(),
102 role: self.role.clone(),
103 specialist_id: self.specialist_id.clone(),
104 specialist_name: self.specialist_name.clone(),
105 agent_card_url: None,
106 skill_id: None,
107 auth_config_id: None,
108 })
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::{KanbanAutomationStep, KanbanColumnAutomation, KanbanTransport};
115
116 #[test]
117 fn primary_step_keeps_a2a_only_steps() {
118 let automation = KanbanColumnAutomation {
119 enabled: true,
120 steps: Some(vec![KanbanAutomationStep {
121 id: "step-a2a".to_string(),
122 transport: Some(KanbanTransport::A2a),
123 provider_id: None,
124 role: None,
125 specialist_id: None,
126 specialist_name: None,
127 agent_card_url: Some("https://example.com/agent-card.json".to_string()),
128 skill_id: Some("skill-1".to_string()),
129 auth_config_id: Some("auth-1".to_string()),
130 }]),
131 ..Default::default()
132 };
133
134 let step = automation
135 .primary_step()
136 .expect("a2a step should be preserved");
137 assert_eq!(step.id, "step-a2a");
138 assert_eq!(step.transport, Some(KanbanTransport::A2a));
139 assert_eq!(
140 step.agent_card_url.as_deref(),
141 Some("https://example.com/agent-card.json")
142 );
143 assert_eq!(step.skill_id.as_deref(), Some("skill-1"));
144 assert_eq!(step.auth_config_id.as_deref(), Some("auth-1"));
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct KanbanColumn {
151 pub id: String,
152 pub name: String,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 pub color: Option<String>,
155 pub position: i64,
156 pub stage: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub visible: Option<bool>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub width: Option<String>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub automation: Option<KanbanColumnAutomation>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(rename_all = "camelCase")]
170pub struct KanbanBoard {
171 pub id: String,
172 pub workspace_id: String,
173 pub name: String,
174 pub is_default: bool,
175 pub columns: Vec<KanbanColumn>,
176 pub created_at: DateTime<Utc>,
177 pub updated_at: DateTime<Utc>,
178}
179
180pub fn default_kanban_columns() -> Vec<KanbanColumn> {
181 vec![
182 KanbanColumn {
183 id: "backlog".to_string(),
184 name: "Backlog".to_string(),
185 color: Some("slate".to_string()),
186 position: 0,
187 stage: "backlog".to_string(),
188 automation: None,
189 visible: Some(true),
190 width: None,
191 },
192 KanbanColumn {
193 id: "todo".to_string(),
194 name: "Todo".to_string(),
195 color: Some("sky".to_string()),
196 position: 1,
197 stage: "todo".to_string(),
198 automation: None,
199 visible: Some(true),
200 width: None,
201 },
202 KanbanColumn {
203 id: "dev".to_string(),
204 name: "Dev".to_string(),
205 color: Some("amber".to_string()),
206 position: 2,
207 stage: "dev".to_string(),
208 automation: None,
209 visible: Some(true),
210 width: None,
211 },
212 KanbanColumn {
213 id: "review".to_string(),
214 name: "Review".to_string(),
215 color: Some("slate".to_string()),
216 position: 3,
217 stage: "review".to_string(),
218 automation: None,
219 visible: Some(true),
220 width: None,
221 },
222 KanbanColumn {
223 id: "done".to_string(),
224 name: "Done".to_string(),
225 color: Some("emerald".to_string()),
226 position: 4,
227 stage: "done".to_string(),
228 automation: None,
229 visible: Some(true),
230 width: None,
231 },
232 KanbanColumn {
233 id: "blocked".to_string(),
234 name: "Blocked".to_string(),
235 color: Some("rose".to_string()),
236 position: 5,
237 stage: "blocked".to_string(),
238 automation: None,
239 visible: Some(true),
240 width: None,
241 },
242 ]
243}
244
245pub fn default_kanban_board(workspace_id: String) -> KanbanBoard {
246 let now = Utc::now();
247
248 KanbanBoard {
249 id: uuid::Uuid::new_v4().to_string(),
250 workspace_id,
251 name: "Board".to_string(),
252 is_default: true,
253 columns: default_kanban_columns(),
254 created_at: now,
255 updated_at: now,
256 }
257}
258
259pub fn column_id_to_task_status(column_id: Option<&str>) -> TaskStatus {
260 match column_id.unwrap_or("backlog").to_ascii_lowercase().as_str() {
261 "dev" => TaskStatus::InProgress,
262 "review" => TaskStatus::ReviewRequired,
263 "blocked" => TaskStatus::Blocked,
264 "done" => TaskStatus::Completed,
265 _ => TaskStatus::Pending,
266 }
267}
268
269pub fn task_status_to_column_id(status: &TaskStatus) -> &'static str {
270 match status {
271 TaskStatus::InProgress => "dev",
272 TaskStatus::ReviewRequired => "review",
273 TaskStatus::Blocked => "blocked",
274 TaskStatus::Completed => "done",
275 _ => "backlog",
276 }
277}