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 required_task_fields: Option<Vec<String>>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub auto_advance_on_success: Option<bool>,
78}
79
80impl KanbanColumnAutomation {
81 pub fn primary_step(&self) -> Option<KanbanAutomationStep> {
82 if !self.enabled {
83 return None;
84 }
85
86 if let Some(step) = self.steps.as_ref().and_then(|steps| {
87 steps.iter().find(|step| {
88 matches!(step.transport, Some(KanbanTransport::A2a))
89 || step.provider_id.is_some()
90 || step.role.is_some()
91 || step.specialist_id.is_some()
92 || step.specialist_name.is_some()
93 || step.agent_card_url.is_some()
94 || step.skill_id.is_some()
95 || step.auth_config_id.is_some()
96 })
97 }) {
98 return Some(step.clone());
99 }
100
101 Some(KanbanAutomationStep {
102 id: "step-1".to_string(),
103 transport: None, provider_id: self.provider_id.clone(),
105 role: self.role.clone(),
106 specialist_id: self.specialist_id.clone(),
107 specialist_name: self.specialist_name.clone(),
108 agent_card_url: None,
109 skill_id: None,
110 auth_config_id: None,
111 })
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::{KanbanAutomationStep, KanbanColumnAutomation, KanbanTransport};
118
119 #[test]
120 fn primary_step_keeps_a2a_only_steps() {
121 let automation = KanbanColumnAutomation {
122 enabled: true,
123 steps: Some(vec![KanbanAutomationStep {
124 id: "step-a2a".to_string(),
125 transport: Some(KanbanTransport::A2a),
126 provider_id: None,
127 role: None,
128 specialist_id: None,
129 specialist_name: None,
130 agent_card_url: Some("https://example.com/agent-card.json".to_string()),
131 skill_id: Some("skill-1".to_string()),
132 auth_config_id: Some("auth-1".to_string()),
133 }]),
134 ..Default::default()
135 };
136
137 let step = automation
138 .primary_step()
139 .expect("a2a step should be preserved");
140 assert_eq!(step.id, "step-a2a");
141 assert_eq!(step.transport, Some(KanbanTransport::A2a));
142 assert_eq!(
143 step.agent_card_url.as_deref(),
144 Some("https://example.com/agent-card.json")
145 );
146 assert_eq!(step.skill_id.as_deref(), Some("skill-1"));
147 assert_eq!(step.auth_config_id.as_deref(), Some("auth-1"));
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152#[serde(rename_all = "camelCase")]
153pub struct KanbanColumn {
154 pub id: String,
155 pub name: String,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub color: Option<String>,
158 pub position: i64,
159 pub stage: String,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub visible: Option<bool>,
163 #[serde(skip_serializing_if = "Option::is_none")]
165 pub width: Option<String>,
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub automation: Option<KanbanColumnAutomation>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct KanbanBoard {
174 pub id: String,
175 pub workspace_id: String,
176 pub name: String,
177 pub is_default: bool,
178 pub columns: Vec<KanbanColumn>,
179 pub created_at: DateTime<Utc>,
180 pub updated_at: DateTime<Utc>,
181}
182
183pub fn default_kanban_columns() -> Vec<KanbanColumn> {
184 vec![
185 KanbanColumn {
186 id: "backlog".to_string(),
187 name: "Backlog".to_string(),
188 color: Some("slate".to_string()),
189 position: 0,
190 stage: "backlog".to_string(),
191 automation: None,
192 visible: Some(true),
193 width: None,
194 },
195 KanbanColumn {
196 id: "todo".to_string(),
197 name: "Todo".to_string(),
198 color: Some("sky".to_string()),
199 position: 1,
200 stage: "todo".to_string(),
201 automation: None,
202 visible: Some(true),
203 width: None,
204 },
205 KanbanColumn {
206 id: "dev".to_string(),
207 name: "Dev".to_string(),
208 color: Some("amber".to_string()),
209 position: 2,
210 stage: "dev".to_string(),
211 automation: None,
212 visible: Some(true),
213 width: None,
214 },
215 KanbanColumn {
216 id: "review".to_string(),
217 name: "Review".to_string(),
218 color: Some("slate".to_string()),
219 position: 3,
220 stage: "review".to_string(),
221 automation: None,
222 visible: Some(true),
223 width: None,
224 },
225 KanbanColumn {
226 id: "done".to_string(),
227 name: "Done".to_string(),
228 color: Some("emerald".to_string()),
229 position: 4,
230 stage: "done".to_string(),
231 automation: None,
232 visible: Some(true),
233 width: None,
234 },
235 KanbanColumn {
236 id: "blocked".to_string(),
237 name: "Blocked".to_string(),
238 color: Some("rose".to_string()),
239 position: 5,
240 stage: "blocked".to_string(),
241 automation: None,
242 visible: Some(true),
243 width: None,
244 },
245 ]
246}
247
248fn normalize_kanban_automation_step_ids(
249 mut steps: Vec<KanbanAutomationStep>,
250) -> Vec<KanbanAutomationStep> {
251 for (index, step) in steps.iter_mut().enumerate() {
252 if step.id.trim().is_empty() {
253 step.id = format!("step-{}", index + 1);
254 }
255 }
256
257 steps
258 .into_iter()
259 .filter(|step| {
260 matches!(step.transport, Some(KanbanTransport::A2a))
261 || step.provider_id.is_some()
262 || step.role.is_some()
263 || step.specialist_id.is_some()
264 || step.specialist_name.is_some()
265 || step.agent_card_url.is_some()
266 || step.skill_id.is_some()
267 || step.auth_config_id.is_some()
268 })
269 .collect()
270}
271
272fn normalize_kanban_automation(mut automation: KanbanColumnAutomation) -> KanbanColumnAutomation {
273 let mut steps =
274 normalize_kanban_automation_step_ids(automation.steps.clone().unwrap_or_default());
275 if automation.enabled && steps.is_empty() {
276 steps = vec![KanbanAutomationStep {
277 id: "step-1".to_string(),
278 transport: None,
279 provider_id: automation.provider_id.clone(),
280 role: automation.role.clone(),
281 specialist_id: automation.specialist_id.clone(),
282 specialist_name: automation.specialist_name.clone(),
283 agent_card_url: None,
284 skill_id: None,
285 auth_config_id: None,
286 }];
287 }
288 if steps.is_empty() {
289 return automation;
290 }
291
292 automation.steps = Some(steps.clone());
293 let primary = steps[0].clone();
294 automation.provider_id = primary.provider_id;
295 automation.role = primary.role;
296 automation.specialist_id = primary.specialist_id;
297 automation.specialist_name = primary.specialist_name;
298 automation
299}
300
301fn automation_steps(automation: &KanbanColumnAutomation) -> Vec<KanbanAutomationStep> {
302 normalize_kanban_automation_step_ids(automation.steps.clone().unwrap_or_default())
303}
304
305fn legacy_specialist_ids_for_stage(stage: &str) -> &'static [&'static str] {
306 match stage {
307 "backlog" => &["issue-enricher", "kanban-workflow", "kanban-agent"],
308 "todo" => &["routa", "developer", "kanban-workflow"],
309 "dev" => &["pr-reviewer", "developer", "claude-code", "kanban-workflow"],
310 "review" => &[
311 "desk-check",
312 "gate",
313 "pr-reviewer",
314 "kanban-workflow",
315 "kanban-review-guard",
316 ],
317 "blocked" => &["claude-code", "developer", "routa", "kanban-workflow"],
318 "done" => &["gate", "verifier", "claude-code", "kanban-workflow"],
319 _ => &[],
320 }
321}
322
323fn recommended_step(id: &str, role: &str, specialist_name: &str) -> KanbanAutomationStep {
324 KanbanAutomationStep {
325 id: id.to_string(),
326 transport: None,
327 provider_id: None,
328 role: Some(role.to_string()),
329 specialist_id: Some(format!("kanban-{id}")),
330 specialist_name: Some(specialist_name.to_string()),
331 agent_card_url: None,
332 skill_id: None,
333 auth_config_id: None,
334 }
335}
336
337fn build_recommended_automation(
338 steps: Vec<KanbanAutomationStep>,
339 auto_advance_on_success: bool,
340) -> KanbanColumnAutomation {
341 normalize_kanban_automation(KanbanColumnAutomation {
342 enabled: true,
343 steps: Some(steps),
344 transition_type: Some("entry".to_string()),
345 auto_advance_on_success: Some(auto_advance_on_success),
346 required_artifacts: None,
347 required_task_fields: None,
348 provider_id: None,
349 role: None,
350 specialist_id: None,
351 specialist_name: None,
352 })
353}
354
355fn recommended_automation_for_stage(stage: &str) -> Option<KanbanColumnAutomation> {
356 match stage {
357 "backlog" => Some(build_recommended_automation(
358 vec![recommended_step(
359 "backlog-refiner",
360 "CRAFTER",
361 "Backlog Refiner",
362 )],
363 true,
364 )),
365 "todo" => Some(build_recommended_automation(
366 vec![recommended_step(
367 "todo-orchestrator",
368 "CRAFTER",
369 "Todo Orchestrator",
370 )],
371 false,
372 )),
373 "dev" => Some(build_recommended_automation(
374 vec![recommended_step("dev-executor", "CRAFTER", "Dev Crafter")],
375 false,
376 )),
377 "review" => Some(build_recommended_automation(
378 vec![
379 recommended_step("qa-frontend", "GATE", "QA Frontend"),
380 recommended_step("review-guard", "GATE", "Review Guard"),
381 ],
382 false,
383 ))
384 .map(|mut automation| {
385 automation.required_artifacts =
386 Some(vec!["screenshot".to_string(), "test_results".to_string()]);
387 automation
388 }),
389 "blocked" => Some(build_recommended_automation(
390 vec![recommended_step(
391 "blocked-resolver",
392 "CRAFTER",
393 "Blocked Resolver",
394 )],
395 false,
396 )),
397 "done" => Some(build_recommended_automation(
398 vec![recommended_step("done-reporter", "GATE", "Done Reporter")],
399 false,
400 )),
401 _ => None,
402 }
403}
404
405fn default_column_position_for_stage(stage: &str) -> usize {
406 match stage {
407 "backlog" => 0,
408 "todo" => 1,
409 "dev" => 2,
410 "review" => 3,
411 "done" => 4,
412 "blocked" => 5,
413 _ => 99,
414 }
415}
416
417pub fn normalize_default_kanban_column_positions(columns: Vec<KanbanColumn>) -> Vec<KanbanColumn> {
418 let mut normalized = columns;
419 normalized.sort_by(|left, right| {
420 let left_index = default_column_position_for_stage(&left.id);
421 let right_index = default_column_position_for_stage(&right.id);
422 left_index
423 .cmp(&right_index)
424 .then(left.position.cmp(&right.position))
425 });
426
427 normalized
428 .into_iter()
429 .enumerate()
430 .map(|(index, mut column)| {
431 column.position = index as i64;
432 column
433 })
434 .collect()
435}
436
437pub fn apply_recommended_automation_to_columns(columns: Vec<KanbanColumn>) -> Vec<KanbanColumn> {
438 let columns = columns
439 .into_iter()
440 .map(|mut column| {
441 if let Some(recommended) = recommended_automation_for_stage(&column.stage) {
442 let normalized_recommended = normalize_kanban_automation(recommended);
443 let recommended_primary = get_primary_step(&normalized_recommended);
444 let recommended_steps = automation_steps(&normalized_recommended);
445 let recommended_specialist_ids: Vec<&str> = recommended_steps
446 .iter()
447 .filter_map(|step| step.specialist_id.as_deref())
448 .collect();
449 let recommended_specialist_names: Vec<&str> = recommended_steps
450 .iter()
451 .filter_map(|step| step.specialist_name.as_deref())
452 .collect();
453 let recommended_primary_provider_id =
454 recommended_primary.as_ref().and_then(|step| step.provider_id.clone());
455 let recommended_primary_role = recommended_primary
456 .as_ref()
457 .and_then(|step| step.role.clone());
458 let recommended_primary_specialist_id =
459 recommended_primary.as_ref().and_then(|step| step.specialist_id.clone());
460 let recommended_primary_specialist_name =
461 recommended_primary.as_ref().and_then(|step| step.specialist_name.clone());
462
463 let with_default = if let Some(automation) = column.automation.clone() {
464 let current = normalize_kanban_automation(automation);
465 let current_steps = automation_steps(¤t);
466 let legacy_specialists = legacy_specialist_ids_for_stage(&column.stage);
467 let has_custom_steps = current_steps.iter().any(|step| {
468 if let Some(id) = step.specialist_id.as_deref() {
469 !legacy_specialists.contains(&id)
470 && !recommended_specialist_ids.iter().any(|specialist_id| specialist_id == &id)
471 } else if let Some(name) = step.specialist_name.as_deref() {
472 !recommended_specialist_names
473 .iter()
474 .any(|recommended_name| recommended_name == &name)
475 } else {
476 false
477 }
478 });
479
480 let should_migrate_legacy_specialist = current
481 .specialist_id
482 .as_deref()
483 .is_some_and(|specialist_id| legacy_specialists.contains(&specialist_id));
484
485 let should_migrate_recommended_specialist = current
486 .specialist_id
487 .as_deref()
488 .is_some_and(|specialist_id| {
489 Some(specialist_id) == recommended_primary_specialist_id.as_deref()
490 })
491 || current.specialist_id.is_none()
492 && current
493 .specialist_name
494 .as_deref()
495 .is_some_and(|specialist_name| {
496 Some(specialist_name) == recommended_primary_specialist_name.as_deref()
497 });
498
499 let should_refresh_artifact_policy = (should_migrate_legacy_specialist
500 || should_migrate_recommended_specialist)
501 && matches!(current.required_artifacts.as_deref(), Some([artifact]) if artifact == "screenshot");
502
503 if has_custom_steps
504 || ((current.specialist_id.is_some() || current.specialist_name.is_some())
505 && !should_migrate_legacy_specialist
506 && !should_migrate_recommended_specialist)
507 {
508 current
509 } else {
510 let merged_steps = recommended_steps
511 .into_iter()
512 .enumerate()
513 .map(|(index, recommended_step)| {
514 let current_step = current_steps.get(index);
515
516 KanbanAutomationStep {
517 id: recommended_step.id,
518 transport: current_step.and_then(|step| step.transport.clone()),
519 provider_id: current_step
520 .and_then(|step| step.provider_id.clone())
521 .or_else(|| recommended_step.provider_id.clone()),
522 role: current_step
523 .and_then(|step| step.role.clone())
524 .or_else(|| recommended_step.role.clone()),
525 specialist_id: current_step
526 .and_then(|step| step.specialist_id.clone())
527 .or_else(|| recommended_step.specialist_id.clone()),
528 specialist_name: current_step
529 .and_then(|step| step.specialist_name.clone())
530 .or_else(|| recommended_step.specialist_name.clone()),
531 agent_card_url: current_step
532 .and_then(|step| step.agent_card_url.clone())
533 .or_else(|| recommended_step.agent_card_url.clone()),
534 skill_id: current_step
535 .and_then(|step| step.skill_id.clone())
536 .or_else(|| recommended_step.skill_id.clone()),
537 auth_config_id: current_step
538 .and_then(|step| step.auth_config_id.clone())
539 .or_else(|| recommended_step.auth_config_id.clone()),
540 }
541 })
542 .collect();
543
544 let merged = KanbanColumnAutomation {
545 enabled: current.enabled,
546 steps: Some(merged_steps),
547 provider_id: current.provider_id.or(recommended_primary_provider_id),
548 role: current.role.or(recommended_primary_role),
549 specialist_id: recommended_primary_specialist_id,
550 specialist_name: recommended_primary_specialist_name,
551 transition_type: current
552 .transition_type
553 .or(normalized_recommended.transition_type),
554 required_artifacts: if should_refresh_artifact_policy {
555 normalized_recommended.required_artifacts.clone()
556 } else {
557 current
558 .required_artifacts
559 .or(normalized_recommended.required_artifacts.clone())
560 },
561 required_task_fields: current.required_task_fields,
562 auto_advance_on_success: normalized_recommended.auto_advance_on_success,
563 };
564
565 normalize_kanban_automation(merged)
566 }
567 } else {
568 normalized_recommended
569 };
570
571 column.automation = Some(with_default);
572 }
573
574 column
575 })
576 .collect();
577
578 normalize_default_kanban_column_positions(columns)
579}
580
581pub fn apply_new_board_story_readiness_defaults(columns: Vec<KanbanColumn>) -> Vec<KanbanColumn> {
582 columns
583 .into_iter()
584 .map(|mut column| {
585 if column.stage == "dev" {
586 if let Some(automation) = column.automation.as_mut() {
587 automation.required_task_fields = Some(vec![
588 "scope".to_string(),
589 "acceptance_criteria".to_string(),
590 "verification_plan".to_string(),
591 ]);
592 }
593 }
594 column
595 })
596 .collect()
597}
598
599fn get_primary_step(automation: &KanbanColumnAutomation) -> Option<KanbanAutomationStep> {
600 automation_steps(automation).into_iter().next()
601}
602
603pub fn default_kanban_board(workspace_id: String) -> KanbanBoard {
604 let now = Utc::now();
605
606 KanbanBoard {
607 id: uuid::Uuid::new_v4().to_string(),
608 workspace_id,
609 name: "Board".to_string(),
610 is_default: true,
611 columns: default_kanban_columns(),
612 created_at: now,
613 updated_at: now,
614 }
615}
616
617pub fn column_id_to_task_status(column_id: Option<&str>) -> TaskStatus {
618 match column_id.unwrap_or("backlog").to_ascii_lowercase().as_str() {
619 "dev" => TaskStatus::InProgress,
620 "review" => TaskStatus::ReviewRequired,
621 "blocked" => TaskStatus::Blocked,
622 "done" => TaskStatus::Completed,
623 _ => TaskStatus::Pending,
624 }
625}
626
627pub fn task_status_to_column_id(status: &TaskStatus) -> &'static str {
628 match status {
629 TaskStatus::InProgress => "dev",
630 TaskStatus::ReviewRequired => "review",
631 TaskStatus::Blocked => "blocked",
632 TaskStatus::Completed => "done",
633 _ => "backlog",
634 }
635}