ticket: share workspace actionable projection
This commit is contained in:
parent
556adb429c
commit
a40d9c2027
|
|
@ -739,6 +739,415 @@ pub struct TicketRelationView {
|
||||||
pub notices: Vec<TicketRelationNotice>,
|
pub notices: Vec<TicketRelationNotice>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TicketWorkspaceActionPriority {
|
||||||
|
ReadyForQueue,
|
||||||
|
ActiveWork,
|
||||||
|
Background,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TicketWorkspaceNextAction {
|
||||||
|
Clarify,
|
||||||
|
QueueForOrchestrator,
|
||||||
|
Close,
|
||||||
|
WaitForOrchestrator,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TicketWorkspaceRowKind {
|
||||||
|
Planning,
|
||||||
|
Ticket,
|
||||||
|
Review,
|
||||||
|
ActiveWork,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketWorkspaceStateOverlay {
|
||||||
|
pub source: String,
|
||||||
|
pub workflow_state: TicketWorkflowState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketQueueGuard {
|
||||||
|
pub can_queue_for_orchestrator: bool,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub blocked_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct TicketWorkspaceProjection {
|
||||||
|
pub kind: TicketWorkspaceRowKind,
|
||||||
|
pub priority: TicketWorkspaceActionPriority,
|
||||||
|
pub next_action: Option<TicketWorkspaceNextAction>,
|
||||||
|
pub visible_state: String,
|
||||||
|
pub visible_overlay: Option<TicketWorkspaceStateOverlay>,
|
||||||
|
pub disabled_reason: Option<String>,
|
||||||
|
pub key_hint: Option<String>,
|
||||||
|
pub blocked_reason: Option<String>,
|
||||||
|
pub queue_guard: TicketQueueGuard,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn project_ticket_workspace_item(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||||
|
) -> TicketWorkspaceProjection {
|
||||||
|
let visible_overlay = orchestration_overlay
|
||||||
|
.filter(|overlay| {
|
||||||
|
ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
let mut projection = derive_ticket_workspace_projection(summary, relation_blockers);
|
||||||
|
if let Some(overlay) = visible_overlay.as_ref() {
|
||||||
|
apply_workspace_overlay_to_projection(&mut projection, summary.workflow_state, overlay);
|
||||||
|
}
|
||||||
|
projection.visible_state =
|
||||||
|
ticket_workspace_state_display(summary.workflow_state, visible_overlay.as_ref());
|
||||||
|
projection.visible_overlay = visible_overlay;
|
||||||
|
projection.queue_guard = ticket_queue_guard(
|
||||||
|
summary,
|
||||||
|
relation_blockers,
|
||||||
|
projection.visible_overlay.as_ref(),
|
||||||
|
);
|
||||||
|
projection
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ticket_queue_guard(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
orchestration_overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||||
|
) -> TicketQueueGuard {
|
||||||
|
if orchestration_overlay.is_some() {
|
||||||
|
return TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(
|
||||||
|
"orchestration overlay already shows progress; duplicate queue is suppressed"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
blocked_reason: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if summary.workflow_state != TicketWorkflowState::Ready {
|
||||||
|
return TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(format!(
|
||||||
|
"Ticket state is {}; only ready Tickets can be queued for Orchestrator",
|
||||||
|
summary.workflow_state.as_str()
|
||||||
|
)),
|
||||||
|
blocked_reason: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let active_blockers = relation_blockers
|
||||||
|
.iter()
|
||||||
|
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !active_blockers.is_empty() {
|
||||||
|
let blockers = format_workspace_relation_blockers(&active_blockers);
|
||||||
|
return TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(format!("waiting for {blockers}")),
|
||||||
|
blocked_reason: Some(blockers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: true,
|
||||||
|
reason: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_ticket_workspace_projection(
|
||||||
|
summary: &TicketSummary,
|
||||||
|
relation_blockers: &[TicketRelationBlocker],
|
||||||
|
) -> TicketWorkspaceProjection {
|
||||||
|
if !relation_blockers.is_empty() {
|
||||||
|
let active_blockers = relation_blockers
|
||||||
|
.iter()
|
||||||
|
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
|
||||||
|
let blockers_to_report = if active_blockers.is_empty() {
|
||||||
|
relation_blockers.iter().collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
active_blockers
|
||||||
|
};
|
||||||
|
let blockers = format_workspace_relation_blockers(&blockers_to_report);
|
||||||
|
let waiting_reason = format!("waiting for {blockers}");
|
||||||
|
return TicketWorkspaceProjection {
|
||||||
|
kind: workspace_row_kind_for_state(summary.workflow_state),
|
||||||
|
priority: match summary.workflow_state {
|
||||||
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||||
|
TicketWorkspaceActionPriority::ActiveWork
|
||||||
|
}
|
||||||
|
_ => TicketWorkspaceActionPriority::Background,
|
||||||
|
},
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some(format!(
|
||||||
|
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
|
||||||
|
)),
|
||||||
|
key_hint: Some(format!("Gate: {waiting_reason}")),
|
||||||
|
blocked_reason: Some(blockers),
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(waiting_reason),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockers = format_workspace_relation_blockers(
|
||||||
|
&relation_blockers
|
||||||
|
.iter()
|
||||||
|
.collect::<Vec<&TicketRelationBlocker>>(),
|
||||||
|
);
|
||||||
|
return TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Ticket,
|
||||||
|
priority: TicketWorkspaceActionPriority::ReadyForQueue,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: None,
|
||||||
|
key_hint: Some(format!(
|
||||||
|
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
|
||||||
|
)),
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: true,
|
||||||
|
reason: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match summary.workflow_state {
|
||||||
|
TicketWorkflowState::Ready => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Ticket,
|
||||||
|
priority: TicketWorkspaceActionPriority::ReadyForQueue,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: None,
|
||||||
|
key_hint: Some(
|
||||||
|
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
||||||
|
),
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: true,
|
||||||
|
reason: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Queued => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::ActiveWork,
|
||||||
|
priority: TicketWorkspaceActionPriority::ActiveWork,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is already queued for Orchestrator routing".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::InProgress => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::ActiveWork,
|
||||||
|
priority: TicketWorkspaceActionPriority::ActiveWork,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is already in progress".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Done => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Review,
|
||||||
|
priority: TicketWorkspaceActionPriority::Background,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::Close),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some(
|
||||||
|
"state is done; close if a resolution is still missing.".to_string(),
|
||||||
|
),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is done; close or review instead of queueing".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Planning => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Planning,
|
||||||
|
priority: TicketWorkspaceActionPriority::Background,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::Clarify),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some(
|
||||||
|
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
||||||
|
),
|
||||||
|
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is still in planning".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TicketWorkflowState::Closed => TicketWorkspaceProjection {
|
||||||
|
kind: TicketWorkspaceRowKind::Review,
|
||||||
|
priority: TicketWorkspaceActionPriority::Background,
|
||||||
|
next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
visible_state: summary.workflow_state.as_str().to_string(),
|
||||||
|
visible_overlay: None,
|
||||||
|
disabled_reason: Some("Ticket is closed.".to_string()),
|
||||||
|
key_hint: None,
|
||||||
|
blocked_reason: None,
|
||||||
|
queue_guard: TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Ticket is closed".to_string()),
|
||||||
|
blocked_reason: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning,
|
||||||
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
||||||
|
TicketWorkspaceRowKind::ActiveWork
|
||||||
|
}
|
||||||
|
TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review,
|
||||||
|
TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_workspace_overlay_to_projection(
|
||||||
|
projection: &mut TicketWorkspaceProjection,
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: &TicketWorkspaceStateOverlay,
|
||||||
|
) {
|
||||||
|
projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator);
|
||||||
|
let overlay_state = overlay.workflow_state.as_str();
|
||||||
|
match overlay.workflow_state {
|
||||||
|
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
|
||||||
|
projection.kind = TicketWorkspaceRowKind::Review;
|
||||||
|
projection.priority = TicketWorkspaceActionPriority::Background;
|
||||||
|
projection.disabled_reason = Some(format!(
|
||||||
|
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
|
||||||
|
overlay.source,
|
||||||
|
local.as_str()
|
||||||
|
));
|
||||||
|
projection.key_hint = Some(format!(
|
||||||
|
"Merge pending: local: {} · {}: {overlay_state}",
|
||||||
|
local.as_str(),
|
||||||
|
overlay.source
|
||||||
|
));
|
||||||
|
}
|
||||||
|
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
|
||||||
|
projection.kind = TicketWorkspaceRowKind::ActiveWork;
|
||||||
|
projection.priority = TicketWorkspaceActionPriority::ActiveWork;
|
||||||
|
projection.disabled_reason = Some(format!(
|
||||||
|
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
|
||||||
|
overlay.source,
|
||||||
|
local.as_str()
|
||||||
|
));
|
||||||
|
projection.key_hint = Some(format!(
|
||||||
|
"Progress overlay: local: {} · {}: {overlay_state}",
|
||||||
|
local.as_str(),
|
||||||
|
overlay.source
|
||||||
|
));
|
||||||
|
}
|
||||||
|
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_workspace_state_display(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: Option<&TicketWorkspaceStateOverlay>,
|
||||||
|
) -> String {
|
||||||
|
match overlay {
|
||||||
|
Some(overlay) => format!(
|
||||||
|
"{}→{}",
|
||||||
|
compact_ticket_state_label(local),
|
||||||
|
compact_ticket_state_label(overlay.workflow_state)
|
||||||
|
),
|
||||||
|
None => local.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_overlay_state_has_progressed(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: TicketWorkflowState,
|
||||||
|
) -> bool {
|
||||||
|
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => 0,
|
||||||
|
TicketWorkflowState::Ready => 1,
|
||||||
|
TicketWorkflowState::Queued => 2,
|
||||||
|
TicketWorkflowState::InProgress => 3,
|
||||||
|
TicketWorkflowState::Done => 4,
|
||||||
|
TicketWorkflowState::Closed => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => "plan",
|
||||||
|
TicketWorkflowState::Ready => "ready",
|
||||||
|
TicketWorkflowState::Queued => "q",
|
||||||
|
TicketWorkflowState::InProgress => "prog",
|
||||||
|
TicketWorkflowState::Done => "done",
|
||||||
|
TicketWorkflowState::Closed => "cls",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
|
||||||
|
matches!(
|
||||||
|
blocker.blocking_state,
|
||||||
|
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
|
||||||
|
let shown_blockers = blockers.iter().take(3).count();
|
||||||
|
let mut formatted = blockers
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|blocker| {
|
||||||
|
format!(
|
||||||
|
"{} via {} (state: {})",
|
||||||
|
blocker.blocking_ticket,
|
||||||
|
blocker.reason_kind,
|
||||||
|
blocker.blocking_state.as_str()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
|
||||||
|
if remaining_blockers > 0 {
|
||||||
|
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
|
||||||
|
}
|
||||||
|
formatted
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum OrchestrationPlanKind {
|
pub enum OrchestrationPlanKind {
|
||||||
|
|
@ -3962,6 +4371,108 @@ mod tests {
|
||||||
LocalTicketBackend::new(dir.path().join("tickets"))
|
LocalTicketBackend::new(dir.path().join("tickets"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn summary_with_state(state: TicketWorkflowState) -> TicketSummary {
|
||||||
|
TicketSummary {
|
||||||
|
id: "000TEST".to_string(),
|
||||||
|
slug: "000TEST".to_string(),
|
||||||
|
title: "Test Ticket".to_string(),
|
||||||
|
status: ExtensibleTicketStatus::Open,
|
||||||
|
kind: "ticket".to_string(),
|
||||||
|
priority: "P2".to_string(),
|
||||||
|
labels: Vec::new(),
|
||||||
|
readiness: None,
|
||||||
|
workflow_state: state,
|
||||||
|
workflow_state_explicit: true,
|
||||||
|
queued_by: None,
|
||||||
|
queued_at: None,
|
||||||
|
updated_at: Some("2026-07-20T00:00:00Z".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker {
|
||||||
|
TicketRelationBlocker {
|
||||||
|
blocking_ticket: "000BLOCK".to_string(),
|
||||||
|
reason_kind: "depends_on".to_string(),
|
||||||
|
relation_kind: TicketRelationKind::DependsOn,
|
||||||
|
note: None,
|
||||||
|
blocking_state: state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_queues_ready_ticket_for_orchestrator() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &[], None);
|
||||||
|
|
||||||
|
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
|
||||||
|
assert_eq!(
|
||||||
|
projection.priority,
|
||||||
|
TicketWorkspaceActionPriority::ReadyForQueue
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
|
||||||
|
);
|
||||||
|
assert!(projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.disabled_reason.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_blocks_ready_queue_on_unstarted_dependency() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let blockers = [blocker_with_state(TicketWorkflowState::Planning)];
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||||
|
|
||||||
|
assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket);
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
|
||||||
|
);
|
||||||
|
assert!(!projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.blocked_reason.is_some());
|
||||||
|
assert!(projection.disabled_reason.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_allows_ready_queue_when_dependency_is_already_queued() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let blockers = [blocker_with_state(TicketWorkflowState::Queued)];
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::QueueForOrchestrator)
|
||||||
|
);
|
||||||
|
assert!(projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.blocked_reason.is_none());
|
||||||
|
assert!(
|
||||||
|
projection
|
||||||
|
.key_hint
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("Orchestrator will preserve order")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_projection_overlay_suppresses_duplicate_queue() {
|
||||||
|
let summary = summary_with_state(TicketWorkflowState::Ready);
|
||||||
|
let overlay = TicketWorkspaceStateOverlay {
|
||||||
|
source: "orchestration".to_string(),
|
||||||
|
workflow_state: TicketWorkflowState::InProgress,
|
||||||
|
};
|
||||||
|
let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay));
|
||||||
|
|
||||||
|
assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork);
|
||||||
|
assert_eq!(
|
||||||
|
projection.next_action,
|
||||||
|
Some(TicketWorkspaceNextAction::WaitForOrchestrator)
|
||||||
|
);
|
||||||
|
assert_eq!(projection.visible_state, "ready→prog");
|
||||||
|
assert!(!projection.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(projection.visible_overlay.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workflow_state_rejects_legacy_intake_alias() {
|
fn workflow_state_rejects_legacy_intake_alias() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use ticket::config::{
|
||||||
use ticket::{
|
use ticket::{
|
||||||
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
|
LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketIdOrSlug,
|
||||||
TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
|
TicketInvalidRecord, TicketListQuery, TicketMeta, TicketRelationBlocker, TicketSummary,
|
||||||
TicketWorkflowState,
|
TicketWorkflowState, TicketWorkspaceActionPriority, TicketWorkspaceNextAction,
|
||||||
|
TicketWorkspaceRowKind, TicketWorkspaceStateOverlay, project_ticket_workspace_item,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
use crate::role_session_registry::{PanelRegistrySnapshot, PanelRegistryStore};
|
||||||
|
|
@ -267,11 +268,7 @@ pub(crate) struct TicketPanelEntry {
|
||||||
pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>,
|
pub(crate) intake_workers: Vec<TicketAssociatedIntakeEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
pub(crate) type TicketStateOverlay = TicketWorkspaceStateOverlay;
|
||||||
pub(crate) struct TicketStateOverlay {
|
|
||||||
pub(crate) source: String,
|
|
||||||
pub(crate) workflow_state: TicketWorkflowState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub(crate) struct TicketAssociatedIntakeEntry {
|
pub(crate) struct TicketAssociatedIntakeEntry {
|
||||||
|
|
@ -1233,29 +1230,24 @@ fn ticket_row(
|
||||||
related_workers.push(worker_name);
|
related_workers.push(worker_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let visible_overlay = orchestration_overlay
|
let projection =
|
||||||
.filter(|overlay| {
|
project_ticket_workspace_item(&summary, relation_blockers, orchestration_overlay);
|
||||||
overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state)
|
|
||||||
})
|
|
||||||
.cloned();
|
|
||||||
let mut derived = derive_ticket_state(&summary, relation_blockers);
|
|
||||||
if let Some(overlay) = visible_overlay.as_ref() {
|
|
||||||
apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay);
|
|
||||||
}
|
|
||||||
let latest_event = events.last();
|
let latest_event = events.last();
|
||||||
let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref());
|
let kind = panel_row_kind_from_workspace(projection.kind);
|
||||||
|
let priority = action_priority_from_workspace(projection.priority);
|
||||||
|
let next_action = projection.next_action.map(next_user_action_from_workspace);
|
||||||
let entry = TicketPanelEntry {
|
let entry = TicketPanelEntry {
|
||||||
id: summary.id.clone(),
|
id: summary.id.clone(),
|
||||||
title: summary.title.clone(),
|
title: summary.title.clone(),
|
||||||
priority: summary.priority.clone(),
|
priority: summary.priority.clone(),
|
||||||
workflow_state: summary.workflow_state,
|
workflow_state: summary.workflow_state,
|
||||||
workflow_state_explicit: summary.workflow_state_explicit,
|
workflow_state_explicit: summary.workflow_state_explicit,
|
||||||
orchestration_overlay: visible_overlay,
|
orchestration_overlay: projection.visible_overlay.clone(),
|
||||||
next_action: derived.action,
|
next_action,
|
||||||
updated_at: summary.updated_at.clone(),
|
updated_at: summary.updated_at.clone(),
|
||||||
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
|
latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()),
|
||||||
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
latest_event_excerpt: latest_event.and_then(|event| excerpt(event.body.as_str(), 72)),
|
||||||
blocked_reason: derived.blocked_reason.clone(),
|
blocked_reason: projection.blocked_reason.clone(),
|
||||||
related_workers: related_workers.clone(),
|
related_workers: related_workers.clone(),
|
||||||
local_claim,
|
local_claim,
|
||||||
intake_workers,
|
intake_workers,
|
||||||
|
|
@ -1263,251 +1255,42 @@ fn ticket_row(
|
||||||
let subtitle = ticket_subtitle(&entry);
|
let subtitle = ticket_subtitle(&entry);
|
||||||
PanelRow {
|
PanelRow {
|
||||||
key: PanelRowKey::Ticket(summary.id),
|
key: PanelRowKey::Ticket(summary.id),
|
||||||
kind: derived.kind,
|
kind,
|
||||||
title: summary.title,
|
title: summary.title,
|
||||||
subtitle,
|
subtitle,
|
||||||
status: state_display,
|
status: projection.visible_state,
|
||||||
priority: derived.priority,
|
priority,
|
||||||
next_action: derived.action,
|
next_action,
|
||||||
ticket: Some(entry),
|
ticket: Some(entry),
|
||||||
related_workers,
|
related_workers,
|
||||||
disabled_reason: derived.disabled_reason,
|
disabled_reason: projection.disabled_reason,
|
||||||
key_hint: derived.key_hint,
|
key_hint: projection.key_hint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
fn panel_row_kind_from_workspace(kind: TicketWorkspaceRowKind) -> PanelRowKind {
|
||||||
struct DerivedTicketState {
|
match kind {
|
||||||
kind: PanelRowKind,
|
TicketWorkspaceRowKind::Planning => PanelRowKind::Planning,
|
||||||
priority: ActionPriority,
|
TicketWorkspaceRowKind::Ticket => PanelRowKind::Ticket,
|
||||||
action: Option<NextUserAction>,
|
TicketWorkspaceRowKind::Review => PanelRowKind::Review,
|
||||||
disabled_reason: Option<String>,
|
TicketWorkspaceRowKind::ActiveWork => PanelRowKind::ActiveWork,
|
||||||
key_hint: Option<String>,
|
|
||||||
blocked_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 {
|
|
||||||
match state {
|
|
||||||
TicketWorkflowState::Planning => 0,
|
|
||||||
TicketWorkflowState::Ready => 1,
|
|
||||||
TicketWorkflowState::Queued => 2,
|
|
||||||
TicketWorkflowState::InProgress => 3,
|
|
||||||
TicketWorkflowState::Done => 4,
|
|
||||||
TicketWorkflowState::Closed => 5,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool {
|
fn action_priority_from_workspace(priority: TicketWorkspaceActionPriority) -> ActionPriority {
|
||||||
workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local)
|
match priority {
|
||||||
}
|
TicketWorkspaceActionPriority::ReadyForQueue => ActionPriority::ReadyForQueue,
|
||||||
|
TicketWorkspaceActionPriority::ActiveWork => ActionPriority::ActiveWork,
|
||||||
fn ticket_state_display(
|
TicketWorkspaceActionPriority::Background => ActionPriority::Background,
|
||||||
local: TicketWorkflowState,
|
|
||||||
overlay: Option<&TicketStateOverlay>,
|
|
||||||
) -> String {
|
|
||||||
match overlay {
|
|
||||||
Some(overlay) => format!(
|
|
||||||
"{}→{}",
|
|
||||||
compact_ticket_state_label(local),
|
|
||||||
compact_ticket_state_label(overlay.workflow_state)
|
|
||||||
),
|
|
||||||
None => local.as_str().to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
fn next_user_action_from_workspace(action: TicketWorkspaceNextAction) -> NextUserAction {
|
||||||
match state {
|
match action {
|
||||||
TicketWorkflowState::Planning => "plan",
|
TicketWorkspaceNextAction::Clarify => NextUserAction::Clarify,
|
||||||
TicketWorkflowState::Ready => "ready",
|
TicketWorkspaceNextAction::QueueForOrchestrator => NextUserAction::Queue,
|
||||||
TicketWorkflowState::Queued => "q",
|
TicketWorkspaceNextAction::Close => NextUserAction::Close,
|
||||||
TicketWorkflowState::InProgress => "prog",
|
TicketWorkspaceNextAction::WaitForOrchestrator => NextUserAction::Wait,
|
||||||
TicketWorkflowState::Done => "done",
|
|
||||||
TicketWorkflowState::Closed => "cls",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_orchestration_overlay_to_derived(
|
|
||||||
derived: &mut DerivedTicketState,
|
|
||||||
local: TicketWorkflowState,
|
|
||||||
overlay: &TicketStateOverlay,
|
|
||||||
) {
|
|
||||||
derived.action = Some(NextUserAction::Wait);
|
|
||||||
let overlay_state = overlay.workflow_state.as_str();
|
|
||||||
match overlay.workflow_state {
|
|
||||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => {
|
|
||||||
derived.kind = PanelRowKind::Review;
|
|
||||||
derived.priority = ActionPriority::Background;
|
|
||||||
derived.disabled_reason = Some(format!(
|
|
||||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.",
|
|
||||||
overlay.source,
|
|
||||||
local.as_str()
|
|
||||||
));
|
|
||||||
derived.key_hint = Some(format!(
|
|
||||||
"Merge pending: local: {} · {}: {overlay_state}",
|
|
||||||
local.as_str(),
|
|
||||||
overlay.source
|
|
||||||
));
|
|
||||||
}
|
|
||||||
TicketWorkflowState::InProgress | TicketWorkflowState::Queued => {
|
|
||||||
derived.kind = PanelRowKind::ActiveWork;
|
|
||||||
derived.priority = ActionPriority::ActiveWork;
|
|
||||||
derived.disabled_reason = Some(format!(
|
|
||||||
"{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.",
|
|
||||||
overlay.source,
|
|
||||||
local.as_str()
|
|
||||||
));
|
|
||||||
derived.key_hint = Some(format!(
|
|
||||||
"Progress overlay: local: {} · {}: {overlay_state}",
|
|
||||||
local.as_str(),
|
|
||||||
overlay.source
|
|
||||||
));
|
|
||||||
}
|
|
||||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String {
|
|
||||||
let shown_blockers = blockers.iter().take(3).count();
|
|
||||||
let mut formatted = blockers
|
|
||||||
.iter()
|
|
||||||
.take(3)
|
|
||||||
.map(|blocker| {
|
|
||||||
format!(
|
|
||||||
"{} via {} (state: {})",
|
|
||||||
blocker.blocking_ticket,
|
|
||||||
blocker.reason_kind,
|
|
||||||
blocker.blocking_state.as_str()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ");
|
|
||||||
let remaining_blockers = blockers.len().saturating_sub(shown_blockers);
|
|
||||||
if remaining_blockers > 0 {
|
|
||||||
formatted.push_str(&format!(" (+{remaining_blockers} more)"));
|
|
||||||
}
|
|
||||||
formatted
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool {
|
|
||||||
matches!(
|
|
||||||
blocker.blocking_state,
|
|
||||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derive_ticket_state(
|
|
||||||
summary: &TicketSummary,
|
|
||||||
relation_blockers: &[TicketRelationBlocker],
|
|
||||||
) -> DerivedTicketState {
|
|
||||||
if !relation_blockers.is_empty() {
|
|
||||||
let active_blockers = relation_blockers
|
|
||||||
.iter()
|
|
||||||
.filter(|blocker| !relation_blocker_allows_ready_queue(blocker))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
if !active_blockers.is_empty() || summary.workflow_state != TicketWorkflowState::Ready {
|
|
||||||
let blockers_to_report = if active_blockers.is_empty() {
|
|
||||||
relation_blockers.iter().collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
active_blockers
|
|
||||||
};
|
|
||||||
let blockers = format_relation_blockers(&blockers_to_report);
|
|
||||||
let waiting_reason = format!("waiting for {blockers}");
|
|
||||||
return DerivedTicketState {
|
|
||||||
kind: match summary.workflow_state {
|
|
||||||
TicketWorkflowState::Planning => PanelRowKind::Planning,
|
|
||||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
|
||||||
PanelRowKind::ActiveWork
|
|
||||||
}
|
|
||||||
TicketWorkflowState::Done | TicketWorkflowState::Closed => PanelRowKind::Review,
|
|
||||||
TicketWorkflowState::Ready => PanelRowKind::Ticket,
|
|
||||||
},
|
|
||||||
priority: match summary.workflow_state {
|
|
||||||
TicketWorkflowState::Queued | TicketWorkflowState::InProgress => {
|
|
||||||
ActionPriority::ActiveWork
|
|
||||||
}
|
|
||||||
_ => ActionPriority::Background,
|
|
||||||
},
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some(format!(
|
|
||||||
"Queue disabled: {waiting_reason}. Resolve dependency/blocker before ready -> queued."
|
|
||||||
)),
|
|
||||||
key_hint: Some(format!("Gate: {waiting_reason}")),
|
|
||||||
blocked_reason: Some(blockers),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let blockers = format_relation_blockers(
|
|
||||||
&relation_blockers
|
|
||||||
.iter()
|
|
||||||
.collect::<Vec<&TicketRelationBlocker>>(),
|
|
||||||
);
|
|
||||||
return DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Ticket,
|
|
||||||
priority: ActionPriority::ReadyForQueue,
|
|
||||||
action: Some(NextUserAction::Queue),
|
|
||||||
disabled_reason: None,
|
|
||||||
key_hint: Some(format!(
|
|
||||||
"Queue allowed: prerequisites are already queued/in progress; Orchestrator will preserve order ({blockers})."
|
|
||||||
)),
|
|
||||||
blocked_reason: None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
match summary.workflow_state {
|
|
||||||
TicketWorkflowState::Ready => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Ticket,
|
|
||||||
priority: ActionPriority::ReadyForQueue,
|
|
||||||
action: Some(NextUserAction::Queue),
|
|
||||||
disabled_reason: None,
|
|
||||||
key_hint: Some(
|
|
||||||
"Queue transitions ready -> queued and may notify Orchestrator".to_string(),
|
|
||||||
),
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Queued => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::ActiveWork,
|
|
||||||
priority: ActionPriority::ActiveWork,
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::InProgress => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::ActiveWork,
|
|
||||||
priority: ActionPriority::ActiveWork,
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some("Ticket is already in progress.".to_string()),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Done => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Review,
|
|
||||||
priority: ActionPriority::Background,
|
|
||||||
action: Some(NextUserAction::Close),
|
|
||||||
disabled_reason: Some(
|
|
||||||
"state is done; close if a resolution is still missing.".to_string(),
|
|
||||||
),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Planning => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Planning,
|
|
||||||
priority: ActionPriority::Background,
|
|
||||||
action: Some(NextUserAction::Clarify),
|
|
||||||
disabled_reason: Some(
|
|
||||||
"Ticket is still in planning; mark it ready before queueing.".to_string(),
|
|
||||||
),
|
|
||||||
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
TicketWorkflowState::Closed => DerivedTicketState {
|
|
||||||
kind: PanelRowKind::Review,
|
|
||||||
priority: ActionPriority::Background,
|
|
||||||
action: Some(NextUserAction::Wait),
|
|
||||||
disabled_reason: Some("Ticket is closed.".to_string()),
|
|
||||||
key_hint: None,
|
|
||||||
blocked_reason: None,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1652,6 +1435,31 @@ pub(crate) fn local_claim_status_for_pod(
|
||||||
TicketLocalClaimStatus::Stale
|
TicketLocalClaimStatus::Stale
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ticket_state_display(
|
||||||
|
local: TicketWorkflowState,
|
||||||
|
overlay: Option<&TicketStateOverlay>,
|
||||||
|
) -> String {
|
||||||
|
match overlay {
|
||||||
|
Some(overlay) => format!(
|
||||||
|
"{}→{}",
|
||||||
|
compact_ticket_state_label(local),
|
||||||
|
compact_ticket_state_label(overlay.workflow_state)
|
||||||
|
),
|
||||||
|
None => local.as_str().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
TicketWorkflowState::Planning => "plan",
|
||||||
|
TicketWorkflowState::Ready => "ready",
|
||||||
|
TicketWorkflowState::Queued => "q",
|
||||||
|
TicketWorkflowState::InProgress => "prog",
|
||||||
|
TicketWorkflowState::Done => "done",
|
||||||
|
TicketWorkflowState::Closed => "cls",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
|
||||||
let mut parts = vec![format!(
|
let mut parts = vec![format!(
|
||||||
"{} · {}",
|
"{} · {}",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user