fix: align queue projections with dependency closure
This commit is contained in:
@@ -723,6 +723,9 @@ impl SqliteWorkspaceAuthority {
|
||||
request: TicketShowRequest,
|
||||
) -> Result<TicketDetail> {
|
||||
let id = ticket.meta.id.as_str();
|
||||
let dependency_check = self
|
||||
.ticket_backend
|
||||
.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
|
||||
let (body, body_truncated) =
|
||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||
let event_limit = request
|
||||
@@ -822,6 +825,27 @@ impl SqliteWorkspaceAuthority {
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
||||
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
||||
let mut queue_assignment_blockers = Vec::new();
|
||||
for ticket_id in &dependency_check.queue_tickets {
|
||||
let assignments = self
|
||||
.store
|
||||
.list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?;
|
||||
if !assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
|
||||
{
|
||||
queue_assignment_blockers.push(format!(
|
||||
"Ticket {ticket_id} requires an active Orchestrator assignment"
|
||||
));
|
||||
}
|
||||
if assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
||||
{
|
||||
queue_assignment_blockers
|
||||
.push(format!("Ticket {ticket_id} has an active Coder assignment"));
|
||||
}
|
||||
}
|
||||
let mut assignment_diagnostics = Vec::new();
|
||||
if let Some(legacy_assignee) = ticket
|
||||
.meta
|
||||
@@ -833,6 +857,19 @@ impl SqliteWorkspaceAuthority {
|
||||
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
||||
));
|
||||
}
|
||||
let mut action_blockers = Vec::new();
|
||||
if !has_target {
|
||||
action_blockers.push("Ticket target is required".to_string());
|
||||
}
|
||||
if !dependency_check.queue_guard.can_queue_for_orchestrator {
|
||||
if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() {
|
||||
action_blockers.push(reason);
|
||||
} else if let Some(reason) = dependency_check.queue_guard.reason.clone() {
|
||||
action_blockers.push(reason);
|
||||
}
|
||||
}
|
||||
let queue_assignments_valid = queue_assignment_blockers.is_empty();
|
||||
action_blockers.extend(queue_assignment_blockers);
|
||||
let action_eligibility = TicketActionEligibility {
|
||||
can_assign_orchestrator: matches!(
|
||||
ticket.meta.workflow_state,
|
||||
@@ -847,16 +884,15 @@ impl SqliteWorkspaceAuthority {
|
||||
can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||
&& has_orchestrator
|
||||
&& !has_coder
|
||||
&& has_target,
|
||||
&& has_target
|
||||
&& dependency_check.queue_guard.can_queue_for_orchestrator
|
||||
&& queue_assignments_valid,
|
||||
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||
&& !has_orchestrator
|
||||
&& !has_coder
|
||||
&& has_target
|
||||
&& !has_blockers,
|
||||
blockers: [(!has_target).then_some("Ticket target is required".to_string())]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
blockers: action_blockers,
|
||||
};
|
||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||
Ok(request) => {
|
||||
@@ -2923,7 +2959,7 @@ mod tests {
|
||||
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning");
|
||||
write_ticket(dir.path(), "00000000001J5", "Second ticket", "queued");
|
||||
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||
let db_path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
@@ -3034,10 +3070,22 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
.ticket_backend
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id("00000000001J2".to_string()),
|
||||
ticket::NewTicketRelation {
|
||||
kind: ticket::TicketRelationKind::DependsOn,
|
||||
target: "00000000001J5".to_string(),
|
||||
note: Some("queued dependency with a transitive blocker".to_string()),
|
||||
author: Some("tester".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
authority
|
||||
.ticket_backend
|
||||
.add_ticket_relation(
|
||||
TicketIdOrSlug::Id("00000000001J5".to_string()),
|
||||
ticket::NewTicketRelation {
|
||||
kind: ticket::TicketRelationKind::DependsOn,
|
||||
target: "00000000001J6".to_string(),
|
||||
note: Some("separate dependency relation".to_string()),
|
||||
note: Some("transitive planning dependency".to_string()),
|
||||
author: Some("tester".to_string()),
|
||||
},
|
||||
)
|
||||
@@ -3052,6 +3100,14 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||
|
||||
let ticket = authority.ticket("00000000001J2").unwrap();
|
||||
assert!(!ticket.action_eligibility.can_queue);
|
||||
assert!(
|
||||
ticket
|
||||
.action_eligibility
|
||||
.blockers
|
||||
.iter()
|
||||
.any(|reason| reason.contains("00000000001J6"))
|
||||
);
|
||||
assert!(ticket.body.contains("Ticket body"));
|
||||
assert!(ticket.body_truncated);
|
||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||
@@ -3135,8 +3191,8 @@ VALUES ('workspace-test', 'ticket', 4);
|
||||
assert!(note_only_kind.items.is_empty());
|
||||
let crossed_relation_filters = authority
|
||||
.query_tickets(TicketQueryRequest {
|
||||
related_ticket_id: Some("00000000001J5".to_string()),
|
||||
relation_kind: Some("depends_on".to_string()),
|
||||
related_ticket_id: Some("00000000001J6".to_string()),
|
||||
relation_kind: Some("related".to_string()),
|
||||
..TicketQueryRequest::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -4025,6 +4025,20 @@ async fn execute_ticket_rest_operation(
|
||||
queue_assignment_candidates(&backend, &ticket.meta.id).map_err(Error::from)?;
|
||||
let mut assignment_ids = BTreeMap::new();
|
||||
for ticket_id in candidates {
|
||||
if api
|
||||
.store
|
||||
.get_current_ticket_role_assignment(
|
||||
workspace_id,
|
||||
&ticket_id,
|
||||
TicketAssignmentRole::Coder,
|
||||
)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"Queue rejects Ticket {ticket_id} while a Coder assignment is active"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
let assignment = active_orchestrator_assignment(api, workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict(format!(
|
||||
@@ -17419,7 +17433,11 @@ mod tests {
|
||||
let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(ready_detail.action_eligibility.can_queue);
|
||||
assert!(
|
||||
ready_detail.action_eligibility.can_queue,
|
||||
"Queue blockers: {:?}",
|
||||
ready_detail.action_eligibility.blockers
|
||||
);
|
||||
assert!(ready_detail.action_eligibility.blockers.is_empty());
|
||||
assert_eq!(ready_detail.relations.blockers.len(), 1);
|
||||
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
|
||||
|
||||
Reference in New Issue
Block a user