routa-core 0.15.2

Routa.js core domain — models, stores, protocols, and JSON-RPC (transport-agnostic)
Documentation
use chrono::{DateTime, Utc};
use serde::Serialize;

use crate::error::ServerError;
use crate::models::kanban::{
    column_id_to_task_status, task_status_to_column_id, KanbanAutomationStep, KanbanBoard,
};
use crate::models::task::{Task, TaskLaneSessionStatus, VerificationVerdict};
use crate::state::AppState;

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct KanbanCard {
    pub id: String,
    pub title: String,
    pub description: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub comment: Option<String>,
    pub status: String,
    pub column_id: String,
    pub position: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub priority: Option<String>,
    pub labels: Vec<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub assignee: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

pub async fn ensure_task_board_context(
    state: &AppState,
    task: &mut Task,
) -> Result<(), ServerError> {
    if task.board_id.is_none() {
        let default_board = state
            .kanban_store
            .ensure_default_board(&task.workspace_id)
            .await?;
        task.board_id = Some(default_board.id);
    }

    if task.column_id.is_none() {
        task.column_id = Some(task_status_to_column_id(&task.status).to_string());
    }

    Ok(())
}

pub fn sync_task_status_from_column(task: &mut Task) {
    task.status = column_id_to_task_status(task.column_id.as_deref());
}

pub fn sync_task_column_from_status(task: &mut Task) {
    task.column_id = Some(task_status_to_column_id(&task.status).to_string());
}

pub fn set_task_column(task: &mut Task, column_id: impl Into<String>) {
    task.column_id = Some(column_id.into());
    sync_task_status_from_column(task);
}

fn resolve_board_column_id_for_stage(board: &KanbanBoard, stage: &str) -> Option<String> {
    board
        .columns
        .iter()
        .find(|column| column.stage == stage)
        .map(|column| column.id.clone())
}

fn find_review_step_index(task: &Task, steps: &[KanbanAutomationStep]) -> Option<usize> {
    if let Some(current_column_id) = task.column_id.as_deref() {
        if let Some(step_index) = task
            .lane_sessions
            .iter()
            .rev()
            .find(|session| {
                session.column_id.as_deref() == Some(current_column_id)
                    && session.status == TaskLaneSessionStatus::Running
            })
            .and_then(|session| session.step_index)
            .and_then(|index| usize::try_from(index).ok())
            .filter(|index| *index < steps.len())
        {
            return Some(step_index);
        }
    }

    let mut best_match: Option<(usize, i32)> = None;
    for (index, step) in steps.iter().enumerate() {
        let mut score = 0;

        if let (Some(step_id), Some(task_id)) = (
            step.specialist_id.as_deref(),
            task.assigned_specialist_id.as_deref(),
        ) {
            if step_id != task_id {
                continue;
            }
            score += 8;
        }
        if let (Some(step_name), Some(task_name)) = (
            step.specialist_name.as_deref(),
            task.assigned_specialist_name.as_deref(),
        ) {
            if step_name != task_name {
                continue;
            }
            score += 4;
        }
        if let (Some(step_role), Some(task_role)) =
            (step.role.as_deref(), task.assigned_role.as_deref())
        {
            if step_role != task_role {
                continue;
            }
            score += 2;
        }
        if let (Some(step_provider), Some(task_provider)) = (
            step.provider_id.as_deref(),
            task.assigned_provider.as_deref(),
        ) {
            if step_provider != task_provider {
                continue;
            }
            score += 1;
        }

        if score > 0
            && best_match
                .map(|(_, best_score)| score > best_score)
                .unwrap_or(true)
        {
            best_match = Some((index, score));
        }
    }

    best_match
        .map(|(index, _)| index)
        .or_else(|| (steps.len() == 1).then_some(0))
}

pub fn resolve_review_lane_convergence_column(
    task: &Task,
    board: Option<&KanbanBoard>,
) -> Option<String> {
    let verdict = task.verification_verdict.as_ref()?;
    let current_column_id = task.column_id.as_deref()?;
    let is_review_stage = board
        .and_then(|value| {
            value
                .columns
                .iter()
                .find(|column| column.id == current_column_id)
        })
        .map(|column| column.stage == "review")
        .unwrap_or(current_column_id == "review");
    if !is_review_stage {
        return None;
    }

    let has_remaining_steps = board
        .and_then(|value| {
            value
                .columns
                .iter()
                .find(|column| column.id == current_column_id)
        })
        .and_then(|column| column.automation.as_ref())
        .and_then(|automation| automation.steps.as_ref())
        .map(|steps| {
            if steps.is_empty() {
                return false;
            }
            match find_review_step_index(task, steps) {
                Some(step_index) => step_index + 1 < steps.len(),
                None => steps.len() > 1,
            }
        })
        .unwrap_or(false);
    if has_remaining_steps {
        return None;
    }

    match verdict {
        VerificationVerdict::Approved => board
            .and_then(|value| resolve_board_column_id_for_stage(value, "done"))
            .or_else(|| Some("done".to_string())),
        VerificationVerdict::NotApproved => board
            .and_then(|value| resolve_board_column_id_for_stage(value, "dev"))
            .or_else(|| Some("dev".to_string())),
        VerificationVerdict::Blocked => board
            .and_then(|value| resolve_board_column_id_for_stage(value, "blocked"))
            .or_else(|| Some("blocked".to_string())),
    }
}

pub fn task_to_card(task: &Task) -> KanbanCard {
    KanbanCard {
        id: task.id.clone(),
        title: task.title.clone(),
        description: task.objective.clone(),
        comment: task.comment.clone(),
        status: task.status.as_str().to_string(),
        column_id: task
            .column_id
            .clone()
            .unwrap_or_else(|| "backlog".to_string()),
        position: task.position,
        priority: task
            .priority
            .as_ref()
            .map(|priority| priority.as_str().to_string()),
        labels: task.labels.clone(),
        assignee: task.assignee.clone(),
        created_at: task.created_at,
        updated_at: task.updated_at,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::Database;
    use crate::models::kanban::{
        default_kanban_board, KanbanAutomationStep, KanbanColumnAutomation,
    };
    use crate::models::task::{Task, TaskStatus, VerificationVerdict};
    use crate::state::{AppState, AppStateInner};
    use std::sync::Arc;

    async fn setup_state() -> AppState {
        let db = Database::open_in_memory().expect("in-memory db should open");
        let state: AppState = Arc::new(AppStateInner::new(db));
        state
            .workspace_store
            .ensure_default()
            .await
            .expect("default workspace should exist");
        state
    }

    #[tokio::test]
    async fn ensure_task_board_context_backfills_board_and_column() {
        let state = setup_state().await;
        let mut task = Task::new(
            "task-1".to_string(),
            "Legacy card".to_string(),
            "Repair missing board context".to_string(),
            "default".to_string(),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        );
        task.status = TaskStatus::Pending;
        task.board_id = None;
        task.column_id = None;

        ensure_task_board_context(&state, &mut task)
            .await
            .expect("board context should be filled");

        assert!(task.board_id.is_some());
        assert_eq!(task.column_id.as_deref(), Some("backlog"));
    }

    #[test]
    fn review_lane_convergence_waits_for_final_review_step() {
        let mut board = default_kanban_board("default".to_string());
        let review = board
            .columns
            .iter_mut()
            .find(|column| column.id == "review")
            .expect("review column should exist");
        review.automation = Some(KanbanColumnAutomation {
            enabled: true,
            steps: Some(vec![
                KanbanAutomationStep {
                    id: "qa-frontend".to_string(),
                    role: Some("GATE".to_string()),
                    specialist_id: Some("kanban-qa-frontend".to_string()),
                    specialist_name: Some("QA Frontend".to_string()),
                    ..Default::default()
                },
                KanbanAutomationStep {
                    id: "review-guard".to_string(),
                    role: Some("GATE".to_string()),
                    specialist_id: Some("kanban-review-guard".to_string()),
                    specialist_name: Some("Review Guard".to_string()),
                    ..Default::default()
                },
            ]),
            ..Default::default()
        });

        let mut task = Task::new(
            "task-1".to_string(),
            "Review".to_string(),
            "Review".to_string(),
            "default".to_string(),
            None,
            None,
            None,
            None,
            None,
            None,
            None,
        );
        task.column_id = Some("review".to_string());
        task.assigned_specialist_id = Some("kanban-qa-frontend".to_string());
        task.verification_verdict = Some(VerificationVerdict::NotApproved);

        assert_eq!(
            resolve_review_lane_convergence_column(&task, Some(&board)),
            None
        );

        task.assigned_specialist_id = Some("kanban-review-guard".to_string());
        task.assigned_specialist_name = Some("Review Guard".to_string());
        task.verification_verdict = Some(VerificationVerdict::Approved);

        assert_eq!(
            resolve_review_lane_convergence_column(&task, Some(&board)).as_deref(),
            Some("done")
        );
    }
}