finery 0.9.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
use std::{
    cell::RefCell,
    rc::Rc,
    sync::mpsc::{self, Receiver, TryRecvError},
};

use crate::{
    service::{AppService, SubmittedComposerChangeSet, composer_service::SubmitChangeSetOutcome},
    store::composer::{ComposerState, TicketChange},
};

pub(super) struct SubmissionController {
    state: Rc<RefCell<ComposerState>>,
    service: AppService,
    response: Option<Receiver<Result<SubmittedComposerChangeSet, String>>>,
    preflight_error: Option<String>,
}

impl SubmissionController {
    pub(super) fn new(state: Rc<RefCell<ComposerState>>, service: AppService) -> Self {
        Self {
            state,
            service,
            response: None,
            preflight_error: None,
        }
    }

    pub(super) fn is_submitting(&self) -> bool {
        self.response.is_some()
    }

    pub(super) fn take_preflight_error(&mut self) -> Option<String> {
        self.preflight_error.take()
    }

    pub(super) fn start(&mut self, changes: Vec<TicketChange>, ctx: &mut tuicore::EventCtx<()>) {
        if self.is_submitting() || changes.is_empty() {
            return;
        }
        let Some(change_set_id) = self.state.borrow().active_change_set.clone() else {
            self.notify_error("Commit failed", "Open a change set before committing");
            return;
        };
        if !self.state.borrow_mut().begin_submission(&change_set_id) {
            self.notify_error(
                "Commit blocked",
                "Another client owns a durable submission attempt",
            );
            return;
        }
        let set = self
            .state
            .borrow()
            .change_sets
            .iter()
            .find(|set| set.id == change_set_id)
            .cloned()
            .expect("submission target must exist");
        let selected_ticket_ids = changes.into_iter().map(|change| change.id).collect();
        let service = self.service.clone();
        let (sender, receiver) = mpsc::channel();
        match std::thread::Builder::new()
            .name("finery-jira-commit".into())
            .spawn(move || {
                let _ =
                    sender.send(service.submit_change_set_from_snapshot(set, selected_ticket_ids));
            }) {
            Ok(_) => {
                self.response = Some(receiver);
                ctx.request_tick();
            }
            Err(error) => {
                self.state.borrow_mut().end_submission(&change_set_id);
                self.notify_error(
                    "Commit failed",
                    &format!("Could not start Jira commit: {error}"),
                );
            }
        }
    }

    pub(super) fn drain_results(&mut self) -> bool {
        let Some(response) = self.response.as_ref() else {
            return false;
        };
        let result = match response.try_recv() {
            Ok(result) => result,
            Err(TryRecvError::Empty) => return false,
            Err(TryRecvError::Disconnected) => {
                Err("submission worker stopped before Jira returned a result".into())
            }
        };
        self.response = None;
        let active_change_set = self.state.borrow().active_change_set.clone();
        if let Some(change_set_id) = active_change_set.as_deref() {
            self.state.borrow_mut().end_submission(change_set_id);
        }
        match result {
            Ok(submitted) => {
                self.state
                    .borrow_mut()
                    .replace_change_sets(submitted.change_sets);
                self.report_outcome(&submitted.response.outcome);
            }
            Err(error) => {
                self.service.load_composer_catalog();
                self.notify_error("Commit failed", &error);
            }
        }
        true
    }

    fn report_outcome(&mut self, outcome: &SubmitChangeSetOutcome) {
        match outcome {
            SubmitChangeSetOutcome::PreflightError { message } => {
                self.preflight_error = Some(message.clone());
            }
            SubmitChangeSetOutcome::Conflict { ticket_ids } => {
                self.service.report_notification(tuicore::Notification::warning(
                    "Commit cancelled",
                    format!(
                        "Jira changed since this change set was composed: {}. Refresh those tickets before retrying.",
                        ticket_ids.join(", ")
                    ),
                ));
            }
            SubmitChangeSetOutcome::Completed { tickets } => {
                for ticket in tickets.iter().filter(|ticket| !ticket.submitted) {
                    self.notify_error(
                        &format!("{} failed", ticket.ticket_id),
                        ticket
                            .message
                            .as_deref()
                            .unwrap_or("Jira did not return a failure message"),
                    );
                }
                let submitted = tickets.iter().filter(|ticket| ticket.submitted).count();
                if submitted == 1 {
                    self.service
                        .report_notification(tuicore::Notification::success(
                            "Ticket committed",
                            "Jira submission completed",
                        ));
                } else if submitted > 1 {
                    self.service
                        .report_notification(tuicore::Notification::success(
                            "Tickets committed",
                            format!("{submitted} tickets committed"),
                        ));
                }
            }
        }
    }

    fn notify_error(&self, title: impl Into<String>, message: &str) {
        self.service
            .report_notification(tuicore::Notification::error(title, message));
    }
}