finery 0.7.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use std::sync::Arc;

use crate::{
    mcp::{
        JiraPosition, final_order, placement_rank_plan, run_composer,
        workspace_capacity_guidance_view, workspace_view,
    },
    service::composer_service::{
        ChangeSetCatalogView, ChangeSetView, JiraTicketLookup, Versioned, test_service,
    },
    storage::Storage,
    store::{
        composer::Ticket,
        work_items::{
            BacklogRunway, BacklogSnapshot, RunwayCapacitySource, RunwayTicket, Sprint,
            VelocityReport, VelocitySprint, WorkItem,
        },
    },
};

#[test]
fn composer_calls_complete_from_async_runtime() {
    let setup_runtime = Arc::new(tokio::runtime::Runtime::new().unwrap());
    let storage = setup_runtime
        .block_on(Storage::connect_for_tests())
        .unwrap();
    let jira: Arc<dyn JiraTicketLookup> =
        Arc::new(|_: &str| -> Result<Ticket, String> { Err("not used".into()) });
    let service = test_service(storage, setup_runtime, jira);
    let result = tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(run_composer(service, |service| {
            service.change_set_catalog()
        }));

    assert!(result.is_ok(), "{result:?}");
}

fn work_item(index: usize) -> WorkItem {
    WorkItem {
        key: format!("FIN-{index}"),
        title: format!("Title for FIN-{index}"),
        kind: "Story".into(),
        status: "To Do".into(),
        done: false,
        priority: "Medium".into(),
        assignee: "Unassigned".into(),
        parent_key: None,
        parent_title: None,
        has_children: false,
        subtask_progress: None,
        labels: Vec::new(),
        fix_versions: Vec::new(),
        epic_name: None,
        story_points: Some(index as f64),
    }
}

#[test]
fn workspace_compacts_backlog_and_change_set_payloads() {
    let open = ChangeSetView {
        id: "CS-open".into(),
        name: "Open".into(),
        closed: false,
        selected_ticket_ids: Vec::new(),
        tickets: Vec::new(),
    };
    let closed = ChangeSetView {
        id: "CS-closed".into(),
        name: "Closed".into(),
        closed: true,
        selected_ticket_ids: Vec::new(),
        tickets: Vec::new(),
    };
    let mut sprint_tickets = (0..51).map(work_item).collect::<Vec<_>>();
    sprint_tickets[0].done = true;
    sprint_tickets[0].story_points = None;
    let view = workspace_view(
        BacklogSnapshot {
            board_name: "Finery".into(),
            story_points_configured: true,
            sprints: vec![Sprint {
                id: 1,
                name: "Sprint 1".into(),
                state: "active".into(),
                goal: Some("Ship workspace planning".into()),
                start_date: Some("2026-08-01T09:00:00.000Z".into()),
                end_date: Some("2026-08-14T17:00:00.000Z".into()),
                work_items: sprint_tickets,
                capacity: None,
            }],
            work_items: (51..102).map(work_item).collect(),
            warnings: vec!["Story points are unavailable".into()],
            runway: Some(BacklogRunway {
                capacity: 20.5,
                source: RunwayCapacitySource::JiraVelocity,
                estimated_points: 100.0,
                assumed_points: 12.0,
                tickets: (51..102)
                    .map(|index| RunwayTicket {
                        key: format!("FIN-{index}"),
                        virtual_sprint: 1,
                        effective_points: index as f64,
                        assumed: true,
                        assumed_from_average: true,
                    })
                    .collect(),
            }),
            velocity: Some(VelocityReport {
                configured_sprints: 2,
                dynamic_capacity: Some(20.5),
                sprints: vec![
                    VelocitySprint {
                        id: 10,
                        name: "Sprint 0".into(),
                        completed: 22.0,
                        goal: Some("Finish prior work".into()),
                    },
                    VelocitySprint {
                        id: 9,
                        name: "Sprint -1".into(),
                        completed: 20.0,
                        goal: None,
                    },
                    VelocitySprint {
                        id: 8,
                        name: "Sprint -2".into(),
                        completed: 18.0,
                        goal: None,
                    },
                ],
            }),
        },
        Versioned {
            revision: 7,
            value: ChangeSetCatalogView {
                change_sets: vec![
                    Versioned {
                        revision: 2,
                        value: open,
                    },
                    Versioned {
                        revision: 3,
                        value: closed,
                    },
                ],
            },
        },
    )
    .unwrap();

    assert_eq!(view.backlog.sprints[0].tickets.len(), 51);
    assert_eq!(view.backlog.unplanned.tickets.len(), 50);
    assert_eq!(view.backlog.unplanned.total_count, 51);
    assert_eq!(view.backlog.warnings, ["Story points are unavailable"]);
    assert_eq!(
        view.backlog
            .velocity
            .as_ref()
            .unwrap()
            .average_completed_points,
        Some(20.5)
    );
    assert_eq!(
        view.backlog.velocity.as_ref().unwrap().sprints[0].completed_points,
        22.0
    );
    assert_eq!(
        view.backlog.capacity_guidance.as_ref().unwrap().source,
        "jira_velocity"
    );
    let guidance = view.backlog.capacity_guidance.as_ref().unwrap();
    assert_eq!(guidance.first_unplanned_capacity_band.len(), 50);
    assert_eq!(guidance.first_unplanned_capacity_band[0].key, "FIN-51");
    assert_eq!(
        guidance.first_unplanned_capacity_band[0].effective_points,
        51.0
    );
    assert_eq!(view.backlog.velocity.as_ref().unwrap().sprints.len(), 2);
    assert_eq!(
        view.backlog.velocity.as_ref().unwrap().sprints[1].name,
        "Sprint -1"
    );
    assert_eq!(
        view.backlog.sprints[0].end_date.as_deref(),
        Some("2026-08-14T17:00:00.000Z")
    );
    assert_eq!(
        view.backlog.sprints[0].goal.as_deref(),
        Some("Ship workspace planning")
    );
    assert_eq!(
        view.backlog.velocity.as_ref().unwrap().sprints[0]
            .goal
            .as_deref(),
        Some("Finish prior work")
    );
    assert_eq!(view.change_sets.len(), 1);
    assert_eq!(view.change_sets[0].id, "CS-open");
    assert_eq!(view.change_sets[0].revision, 2);

    let json = serde_json::to_value(view).unwrap();
    assert!(json.get("change_set_catalog_revision").is_none());
    assert!(json["change_sets"][0].get("tickets").is_none());
    assert!(json["change_sets"][0].get("closed").is_none());
    assert!(json["backlog"].get("unplanned_tickets").is_none());
    assert!(json["backlog"].get("runway").is_none());
    let sparse_ticket = &json["backlog"]["sprints"][0]["tickets"][0];
    assert_eq!(sparse_ticket["done"], true);
    assert!(sparse_ticket.get("parent").is_none());
    assert!(sparse_ticket.get("has_children").is_none());
    assert!(sparse_ticket.get("story_points").is_none());
}

#[test]
fn workspace_caps_velocity_projection_at_ten_sprints() {
    let view = workspace_view(
        BacklogSnapshot {
            board_name: "Finery".into(),
            story_points_configured: true,
            sprints: Vec::new(),
            work_items: Vec::new(),
            warnings: Vec::new(),
            runway: None,
            velocity: Some(VelocityReport {
                configured_sprints: 11,
                dynamic_capacity: Some(20.5),
                sprints: (0..11)
                    .map(|index| VelocitySprint {
                        id: index,
                        name: format!("Sprint {index}"),
                        completed: index as f64,
                        goal: None,
                    })
                    .collect(),
            }),
        },
        Versioned {
            revision: 1,
            value: ChangeSetCatalogView {
                change_sets: Vec::new(),
            },
        },
    )
    .unwrap();

    let serialized_view = serde_json::to_value(view).unwrap();
    let velocity_sprints = serialized_view["backlog"]["velocity"]["sprints"]
        .as_array()
        .unwrap();
    assert_eq!(velocity_sprints.len(), 10);
    assert_eq!(velocity_sprints.last().unwrap()["name"], "Sprint 9");
}

#[test]
fn capacity_guidance_uses_key_references_and_points_sources() {
    let mut fixed = work_item(2);
    fixed.story_points = None;
    let mut bug = work_item(3);
    bug.kind = "Bug".into();
    bug.story_points = None;
    let mut average = work_item(4);
    average.story_points = None;
    let guidance = workspace_capacity_guidance_view(
        BacklogRunway {
            capacity: 20.0,
            source: RunwayCapacitySource::Fixed,
            estimated_points: 3.0,
            assumed_points: 0.0,
            tickets: vec![
                RunwayTicket {
                    key: "FIN-1".into(),
                    virtual_sprint: 1,
                    effective_points: 1.0,
                    assumed: false,
                    assumed_from_average: false,
                },
                RunwayTicket {
                    key: "FIN-2".into(),
                    virtual_sprint: 1,
                    effective_points: 2.0,
                    assumed: true,
                    assumed_from_average: false,
                },
                RunwayTicket {
                    key: "FIN-3".into(),
                    virtual_sprint: 1,
                    effective_points: 0.0,
                    assumed: false,
                    assumed_from_average: false,
                },
                RunwayTicket {
                    key: "FIN-4".into(),
                    virtual_sprint: 1,
                    effective_points: 2.0,
                    assumed: true,
                    assumed_from_average: true,
                },
            ],
        },
        &[work_item(1), fixed, bug, average],
    )
    .unwrap();

    assert_eq!(
        guidance
            .first_unplanned_capacity_band
            .iter()
            .map(|candidate| (candidate.key.as_str(), candidate.effective_points))
            .collect::<Vec<_>>(),
        [
            ("FIN-1", 1.0),
            ("FIN-2", 2.0),
            ("FIN-3", 0.0),
            ("FIN-4", 2.0)
        ]
    );
    let json = serde_json::to_value(guidance).unwrap();
    let band = json["first_unplanned_capacity_band"].as_array().unwrap();
    assert_eq!(band[0].as_object().unwrap().len(), 3);
    assert!(band[0].get("title").is_none());
    assert_eq!(
        band.iter()
            .map(|candidate| candidate["points_source"].as_str().unwrap())
            .collect::<Vec<_>>(),
        [
            "story_points",
            "fixed_assumption",
            "unestimated_bug",
            "average_assumption",
        ]
    );
}

#[test]
fn placement_order_keeps_multiple_moved_tickets_in_requested_order() {
    let order = final_order(
        &[
            "FIN-1".into(),
            "FIN-2".into(),
            "FIN-3".into(),
            "FIN-4".into(),
        ],
        &["FIN-4".into(), "FIN-2".into()],
        &JiraPosition::After {
            issue_key: "FIN-1".into(),
        },
    )
    .unwrap();

    assert_eq!(order, ["FIN-1", "FIN-4", "FIN-2", "FIN-3"]);
}

#[test]
fn placement_plan_can_order_an_entire_destination() {
    let plan = placement_rank_plan(
        &["FIN-3".into(), "FIN-2".into(), "FIN-1".into()],
        &["FIN-3".into(), "FIN-2".into(), "FIN-1".into()],
    )
    .unwrap()
    .unwrap();

    assert_eq!(plan.issues, ["FIN-2", "FIN-1"]);
    assert_eq!(plan.rank_after_issue.as_deref(), Some("FIN-3"));
    assert_eq!(plan.rank_before_issue, None);
}