fix: align queue projections with dependency closure
This commit is contained in:
+242
-99
@@ -804,6 +804,7 @@ pub struct TicketDependencyCheck {
|
|||||||
pub ticket: TicketSummary,
|
pub ticket: TicketSummary,
|
||||||
pub blockers: Vec<TicketRelationBlocker>,
|
pub blockers: Vec<TicketRelationBlocker>,
|
||||||
pub queue_guard: TicketQueueGuard,
|
pub queue_guard: TicketQueueGuard,
|
||||||
|
pub queue_tickets: Vec<String>,
|
||||||
pub recommended_action: TicketWorkspaceNextAction,
|
pub recommended_action: TicketWorkspaceNextAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2810,97 +2811,17 @@ impl SqliteTicketBackend {
|
|||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
summaries: &[TicketSummary],
|
summaries: &[TicketSummary],
|
||||||
) -> Result<HashMap<String, Vec<TicketRelationBlocker>>> {
|
) -> Result<HashMap<String, Vec<TicketRelationBlocker>>> {
|
||||||
let listed_ids = summaries
|
let states = self.state_index(conn)?;
|
||||||
|
let relations = self.all_relations(conn)?;
|
||||||
|
summaries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|summary| summary.id.as_str())
|
.map(|summary| {
|
||||||
.collect::<BTreeSet<_>>();
|
|
||||||
let mut statement = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT relation.ticket_id, relation.kind, relation.target, relation.note,
|
|
||||||
source.workflow_state, target.workflow_state
|
|
||||||
FROM typed_ticket_relations AS relation
|
|
||||||
LEFT JOIN typed_tickets AS source
|
|
||||||
ON source.workspace_id = relation.workspace_id
|
|
||||||
AND source.ticket_id = relation.ticket_id
|
|
||||||
LEFT JOIN typed_tickets AS target
|
|
||||||
ON target.workspace_id = relation.workspace_id
|
|
||||||
AND target.ticket_id = relation.target
|
|
||||||
WHERE relation.workspace_id = ?1
|
|
||||||
AND relation.kind IN ('depends_on', 'blocks')
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM json_each(?2) AS listed
|
|
||||||
WHERE listed.value = relation.ticket_id
|
|
||||||
OR listed.value = relation.target
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.map_err(sqlite_err)?;
|
|
||||||
let listed_ids_json = serde_json::to_string(
|
|
||||||
&summaries
|
|
||||||
.iter()
|
|
||||||
.map(|summary| summary.id.as_str())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.map_err(|error| TicketError::Sqlite(error.to_string()))?;
|
|
||||||
let rows = statement
|
|
||||||
.query_map(params![self.workspace_id, listed_ids_json], |row| {
|
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, String>(0)?,
|
summary.id.clone(),
|
||||||
row.get::<_, String>(1)?,
|
transitive_dependency_blockers(&summary.id, &states, &relations)?,
|
||||||
row.get::<_, String>(2)?,
|
|
||||||
row.get::<_, Option<String>>(3)?,
|
|
||||||
row.get::<_, Option<String>>(4)?,
|
|
||||||
row.get::<_, Option<String>>(5)?,
|
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.map_err(sqlite_err)?;
|
.collect()
|
||||||
let mut blockers = HashMap::<String, Vec<TicketRelationBlocker>>::new();
|
|
||||||
for row in rows {
|
|
||||||
let (source, kind, target, note, source_state, target_state) =
|
|
||||||
row.map_err(sqlite_err)?;
|
|
||||||
let (listed_ticket, blocking_ticket, reason_kind, relation_kind, blocking_state) =
|
|
||||||
match kind.as_str() {
|
|
||||||
"depends_on" if listed_ids.contains(source.as_str()) => (
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
"depends_on",
|
|
||||||
TicketRelationKind::DependsOn,
|
|
||||||
target_state,
|
|
||||||
),
|
|
||||||
"blocks" if listed_ids.contains(target.as_str()) => (
|
|
||||||
target,
|
|
||||||
source,
|
|
||||||
"blocked_by",
|
|
||||||
TicketRelationKind::Blocks,
|
|
||||||
source_state,
|
|
||||||
),
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
let blocking_state = blocking_state
|
|
||||||
.as_deref()
|
|
||||||
.and_then(TicketWorkflowState::parse)
|
|
||||||
.unwrap_or(TicketWorkflowState::Planning);
|
|
||||||
if ticket_state_resolved(blocking_state) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
blockers
|
|
||||||
.entry(listed_ticket)
|
|
||||||
.or_default()
|
|
||||||
.push(TicketRelationBlocker {
|
|
||||||
blocking_ticket,
|
|
||||||
reason_kind: reason_kind.to_string(),
|
|
||||||
relation_kind,
|
|
||||||
note,
|
|
||||||
blocking_state,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for ticket_blockers in blockers.values_mut() {
|
|
||||||
ticket_blockers.sort_by(|a, b| {
|
|
||||||
a.reason_kind
|
|
||||||
.cmp(&b.reason_kind)
|
|
||||||
.then_with(|| a.blocking_ticket.cmp(&b.blocking_ticket))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(blockers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> {
|
pub fn import_from_local_backend(&self, local: &LocalTicketBackend) -> Result<()> {
|
||||||
@@ -3716,17 +3637,71 @@ impl TicketBackend for SqliteTicketBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck> {
|
fn dependency_check(&self, id: TicketIdOrSlug) -> Result<TicketDependencyCheck> {
|
||||||
let ticket = self.show(id)?;
|
self.with_read(|conn| {
|
||||||
let blockers = ticket.relations.blockers.clone();
|
let ticket_id = self.resolve_ticket_id(conn, id)?;
|
||||||
let summary = ticket_summary_from_meta(ticket.meta.clone());
|
let ticket = self.load_ticket(conn, &ticket_id)?;
|
||||||
let projection = project_ticket_workspace_item(&summary, &blockers, None);
|
let states = self.state_index(conn)?;
|
||||||
Ok(TicketDependencyCheck {
|
let relations = self.all_relations(conn)?;
|
||||||
ticket: summary,
|
let blockers = transitive_dependency_blockers(&ticket_id, &states, &relations)?;
|
||||||
blockers,
|
let summary = ticket_summary_from_meta(ticket.meta);
|
||||||
queue_guard: projection.queue_guard,
|
let mut projection = project_ticket_workspace_item(&summary, &blockers, None);
|
||||||
recommended_action: projection
|
let queue_tickets = if summary.workflow_state == TicketWorkflowState::Ready {
|
||||||
.next_action
|
match dependency_queue_plan(&ticket_id, &states, &relations) {
|
||||||
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
|
Ok(queue_tickets) => {
|
||||||
|
let target_error = queue_tickets.iter().find_map(|candidate| {
|
||||||
|
self.load_ticket(conn, candidate)
|
||||||
|
.and_then(|ticket| {
|
||||||
|
match resolve_ready_target(
|
||||||
|
self.target_authority.as_ref(),
|
||||||
|
&self.workspace_id,
|
||||||
|
&ticket,
|
||||||
|
) {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(TicketError::TargetAuthorityUnavailable)
|
||||||
|
if ticket.meta.repository_id.is_some()
|
||||||
|
&& ticket.meta.ref_selector.is_some() =>
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.err()
|
||||||
|
});
|
||||||
|
if let Some(error) = target_error {
|
||||||
|
projection.queue_guard = TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some(
|
||||||
|
"Queue dependency target validation failed".to_string(),
|
||||||
|
),
|
||||||
|
blocked_reason: Some(error.to_string()),
|
||||||
|
};
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
queue_tickets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
projection.queue_guard = TicketQueueGuard {
|
||||||
|
can_queue_for_orchestrator: false,
|
||||||
|
reason: Some("Queue dependency validation failed".to_string()),
|
||||||
|
blocked_reason: Some(error.to_string()),
|
||||||
|
};
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
Ok(TicketDependencyCheck {
|
||||||
|
ticket: summary,
|
||||||
|
blockers,
|
||||||
|
queue_guard: projection.queue_guard,
|
||||||
|
queue_tickets,
|
||||||
|
recommended_action: projection
|
||||||
|
.next_action
|
||||||
|
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4473,6 +4448,7 @@ impl TicketBackend for LocalTicketBackend {
|
|||||||
ticket: summary,
|
ticket: summary,
|
||||||
blockers: ticket.relations.blockers,
|
blockers: ticket.relations.blockers,
|
||||||
queue_guard: projection.queue_guard,
|
queue_guard: projection.queue_guard,
|
||||||
|
queue_tickets: Vec::new(),
|
||||||
recommended_action: projection
|
recommended_action: projection
|
||||||
.next_action
|
.next_action
|
||||||
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
|
.unwrap_or(TicketWorkspaceNextAction::WaitForOrchestrator),
|
||||||
@@ -5507,6 +5483,96 @@ fn ticket_state_resolved(state: TicketWorkflowState) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn transitive_dependency_blockers(
|
||||||
|
requested_ticket: &str,
|
||||||
|
states: &HashMap<String, TicketWorkflowState>,
|
||||||
|
relations: &[TicketRelation],
|
||||||
|
) -> Result<Vec<TicketRelationBlocker>> {
|
||||||
|
type DependencyEdge = (String, String, TicketRelationKind, Option<String>);
|
||||||
|
let mut prerequisites = BTreeMap::<String, Vec<DependencyEdge>>::new();
|
||||||
|
for relation in relations {
|
||||||
|
let edge = match relation.kind {
|
||||||
|
TicketRelationKind::DependsOn => Some((
|
||||||
|
relation.ticket_id.clone(),
|
||||||
|
(
|
||||||
|
relation.target.clone(),
|
||||||
|
"depends_on".to_string(),
|
||||||
|
relation.kind,
|
||||||
|
relation.note.clone(),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
TicketRelationKind::Blocks => Some((
|
||||||
|
relation.target.clone(),
|
||||||
|
(
|
||||||
|
relation.ticket_id.clone(),
|
||||||
|
"blocked_by".to_string(),
|
||||||
|
relation.kind,
|
||||||
|
relation.note.clone(),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
TicketRelationKind::Related
|
||||||
|
| TicketRelationKind::Supersedes
|
||||||
|
| TicketRelationKind::DuplicateOf => None,
|
||||||
|
};
|
||||||
|
if let Some((ticket, edge)) = edge {
|
||||||
|
prerequisites.entry(ticket).or_default().push(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dependencies in prerequisites.values_mut() {
|
||||||
|
dependencies.sort_by(|left, right| left.0.cmp(&right.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect(
|
||||||
|
ticket: &str,
|
||||||
|
states: &HashMap<String, TicketWorkflowState>,
|
||||||
|
prerequisites: &BTreeMap<String, Vec<DependencyEdge>>,
|
||||||
|
visited: &mut BTreeSet<String>,
|
||||||
|
blockers: &mut BTreeMap<String, TicketRelationBlocker>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if !visited.insert(ticket.to_owned()) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let state = states
|
||||||
|
.get(ticket)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| TicketError::NotFound(ticket.to_owned()))?;
|
||||||
|
if ticket_state_resolved(state) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(dependencies) = prerequisites.get(ticket) {
|
||||||
|
for (dependency, reason_kind, relation_kind, note) in dependencies {
|
||||||
|
let dependency_state = states
|
||||||
|
.get(dependency)
|
||||||
|
.copied()
|
||||||
|
.ok_or_else(|| TicketError::NotFound(dependency.clone()))?;
|
||||||
|
if !ticket_state_resolved(dependency_state) {
|
||||||
|
blockers
|
||||||
|
.entry(dependency.clone())
|
||||||
|
.or_insert_with(|| TicketRelationBlocker {
|
||||||
|
blocking_ticket: dependency.clone(),
|
||||||
|
reason_kind: reason_kind.clone(),
|
||||||
|
relation_kind: *relation_kind,
|
||||||
|
note: note.clone(),
|
||||||
|
blocking_state: dependency_state,
|
||||||
|
});
|
||||||
|
collect(dependency, states, prerequisites, visited, blockers)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut blockers = BTreeMap::new();
|
||||||
|
collect(
|
||||||
|
requested_ticket,
|
||||||
|
states,
|
||||||
|
&prerequisites,
|
||||||
|
&mut BTreeSet::new(),
|
||||||
|
&mut blockers,
|
||||||
|
)?;
|
||||||
|
Ok(blockers.into_values().collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn dependency_queue_plan(
|
fn dependency_queue_plan(
|
||||||
requested_ticket: &str,
|
requested_ticket: &str,
|
||||||
states: &HashMap<String, TicketWorkflowState>,
|
states: &HashMap<String, TicketWorkflowState>,
|
||||||
@@ -7531,6 +7597,71 @@ state: planning
|
|||||||
assert_ticket_target_edit_semantics(&backend);
|
assert_ticket_target_edit_semantics(&backend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sqlite_dependency_check_blocks_transitive_planning_dependency() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let backend = SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test")
|
||||||
|
.unwrap()
|
||||||
|
.with_target_authority(Arc::new(TestTargetAuthority));
|
||||||
|
let mut root_input = NewTicket::new("Ready root");
|
||||||
|
root_input.workflow_state = Some(TicketWorkflowState::Ready);
|
||||||
|
root_input.repository_id = Some("main".to_string());
|
||||||
|
let root = backend.create(root_input).unwrap();
|
||||||
|
let mut middle_input = NewTicket::new("Queued middle");
|
||||||
|
middle_input.workflow_state = Some(TicketWorkflowState::Queued);
|
||||||
|
middle_input.repository_id = Some("main".to_string());
|
||||||
|
let middle = backend.create(middle_input).unwrap();
|
||||||
|
let leaf = backend.create(NewTicket::new("Planning leaf")).unwrap();
|
||||||
|
for (ticket, target) in [
|
||||||
|
(root.id.clone(), middle.id.clone()),
|
||||||
|
(middle.id.clone(), leaf.id.clone()),
|
||||||
|
] {
|
||||||
|
backend
|
||||||
|
.add_ticket_relation(
|
||||||
|
TicketIdOrSlug::Id(ticket),
|
||||||
|
NewTicketRelation {
|
||||||
|
kind: TicketRelationKind::DependsOn,
|
||||||
|
target,
|
||||||
|
note: None,
|
||||||
|
author: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let check = backend
|
||||||
|
.dependency_check(TicketIdOrSlug::Id(root.id))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!check.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(check.queue_tickets.is_empty());
|
||||||
|
assert!(check.blockers.iter().any(|blocker| {
|
||||||
|
blocker.blocking_ticket == leaf.id
|
||||||
|
&& blocker.blocking_state == TicketWorkflowState::Planning
|
||||||
|
}));
|
||||||
|
|
||||||
|
let page = backend
|
||||||
|
.list_workspace_projection_page(SqliteTicketListPageQuery {
|
||||||
|
states: vec![TicketWorkflowState::Ready],
|
||||||
|
limit: 10,
|
||||||
|
after: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let item = page
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.summary.id == check.ticket.id)
|
||||||
|
.unwrap();
|
||||||
|
assert!(item.relation_blockers.iter().any(|blocker| {
|
||||||
|
blocker.blocking_ticket == leaf.id
|
||||||
|
&& blocker.blocking_state == TicketWorkflowState::Planning
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
!project_ticket_workspace_item(&item.summary, &item.relation_blockers, None)
|
||||||
|
.queue_guard
|
||||||
|
.can_queue_for_orchestrator
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sqlite_queue_cycle_diagnostic_leaves_all_tickets_ready() {
|
fn sqlite_queue_cycle_diagnostic_leaves_all_tickets_ready() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -7605,6 +7736,18 @@ state: planning
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
let check = backend
|
||||||
|
.dependency_check(TicketIdOrSlug::Id(root.id.clone()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(!check.queue_guard.can_queue_for_orchestrator);
|
||||||
|
assert!(
|
||||||
|
check
|
||||||
|
.queue_guard
|
||||||
|
.blocked_reason
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("unknown")
|
||||||
|
);
|
||||||
let error = backend
|
let error = backend
|
||||||
.queue_ready(TicketIdOrSlug::Id(root.id.clone()), "orchestrator")
|
.queue_ready(TicketIdOrSlug::Id(root.id.clone()), "orchestrator")
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
@@ -8015,7 +8158,7 @@ state: planning
|
|||||||
.expect("following method");
|
.expect("following method");
|
||||||
let projection_source = &source[start..end];
|
let projection_source = &source[start..end];
|
||||||
assert_eq!(projection_source.matches("self.with_read(").count(), 2);
|
assert_eq!(projection_source.matches("self.with_read(").count(), 2);
|
||||||
assert_eq!(projection_source.matches(".prepare(").count(), 3);
|
assert_eq!(projection_source.matches(".prepare(").count(), 2);
|
||||||
let item_loop = projection_source
|
let item_loop = projection_source
|
||||||
.split("items: summaries")
|
.split("items: summaries")
|
||||||
.nth(1)
|
.nth(1)
|
||||||
|
|||||||
@@ -723,6 +723,9 @@ impl SqliteWorkspaceAuthority {
|
|||||||
request: TicketShowRequest,
|
request: TicketShowRequest,
|
||||||
) -> Result<TicketDetail> {
|
) -> Result<TicketDetail> {
|
||||||
let id = ticket.meta.id.as_str();
|
let id = ticket.meta.id.as_str();
|
||||||
|
let dependency_check = self
|
||||||
|
.ticket_backend
|
||||||
|
.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
|
||||||
let (body, body_truncated) =
|
let (body, body_truncated) =
|
||||||
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
|
||||||
let event_limit = request
|
let event_limit = request
|
||||||
@@ -822,6 +825,27 @@ impl SqliteWorkspaceAuthority {
|
|||||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
||||||
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
let has_target = ticket.meta.repository_id.is_some() && ticket.meta.ref_selector.is_some();
|
||||||
let has_blockers = !ticket.relations.blockers.is_empty();
|
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();
|
let mut assignment_diagnostics = Vec::new();
|
||||||
if let Some(legacy_assignee) = ticket
|
if let Some(legacy_assignee) = ticket
|
||||||
.meta
|
.meta
|
||||||
@@ -833,6 +857,19 @@ impl SqliteWorkspaceAuthority {
|
|||||||
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
"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 {
|
let action_eligibility = TicketActionEligibility {
|
||||||
can_assign_orchestrator: matches!(
|
can_assign_orchestrator: matches!(
|
||||||
ticket.meta.workflow_state,
|
ticket.meta.workflow_state,
|
||||||
@@ -847,16 +884,15 @@ impl SqliteWorkspaceAuthority {
|
|||||||
can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||||
&& has_orchestrator
|
&& has_orchestrator
|
||||||
&& !has_coder
|
&& !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
|
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||||
&& !has_orchestrator
|
&& !has_orchestrator
|
||||||
&& !has_coder
|
&& !has_coder
|
||||||
&& has_target
|
&& has_target
|
||||||
&& !has_blockers,
|
&& !has_blockers,
|
||||||
blockers: [(!has_target).then_some("Ticket target is required".to_string())]
|
blockers: action_blockers,
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.collect(),
|
|
||||||
};
|
};
|
||||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
@@ -2923,7 +2959,7 @@ mod tests {
|
|||||||
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
write_ticket(dir.path(), "00000000001J2", "Read bridge", "ready");
|
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");
|
write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning");
|
||||||
let db_path = dir.path().join("workspace.db");
|
let db_path = dir.path().join("workspace.db");
|
||||||
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||||
@@ -3034,10 +3070,22 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
.ticket_backend
|
.ticket_backend
|
||||||
.add_ticket_relation(
|
.add_ticket_relation(
|
||||||
TicketIdOrSlug::Id("00000000001J2".to_string()),
|
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 {
|
ticket::NewTicketRelation {
|
||||||
kind: ticket::TicketRelationKind::DependsOn,
|
kind: ticket::TicketRelationKind::DependsOn,
|
||||||
target: "00000000001J6".to_string(),
|
target: "00000000001J6".to_string(),
|
||||||
note: Some("separate dependency relation".to_string()),
|
note: Some("transitive planning dependency".to_string()),
|
||||||
author: Some("tester".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);
|
assert_eq!(ticket_by_key.id, tickets.items[0].id);
|
||||||
|
|
||||||
let ticket = authority.ticket("00000000001J2").unwrap();
|
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.contains("Ticket body"));
|
||||||
assert!(ticket.body_truncated);
|
assert!(ticket.body_truncated);
|
||||||
assert!(!ticket.body.contains("Deep Ticket marker"));
|
assert!(!ticket.body.contains("Deep Ticket marker"));
|
||||||
@@ -3135,8 +3191,8 @@ VALUES ('workspace-test', 'ticket', 4);
|
|||||||
assert!(note_only_kind.items.is_empty());
|
assert!(note_only_kind.items.is_empty());
|
||||||
let crossed_relation_filters = authority
|
let crossed_relation_filters = authority
|
||||||
.query_tickets(TicketQueryRequest {
|
.query_tickets(TicketQueryRequest {
|
||||||
related_ticket_id: Some("00000000001J5".to_string()),
|
related_ticket_id: Some("00000000001J6".to_string()),
|
||||||
relation_kind: Some("depends_on".to_string()),
|
relation_kind: Some("related".to_string()),
|
||||||
..TicketQueryRequest::default()
|
..TicketQueryRequest::default()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -4025,6 +4025,20 @@ async fn execute_ticket_rest_operation(
|
|||||||
queue_assignment_candidates(&backend, &ticket.meta.id).map_err(Error::from)?;
|
queue_assignment_candidates(&backend, &ticket.meta.id).map_err(Error::from)?;
|
||||||
let mut assignment_ids = BTreeMap::new();
|
let mut assignment_ids = BTreeMap::new();
|
||||||
for ticket_id in candidates {
|
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)?
|
let assignment = active_orchestrator_assignment(api, workspace_id, &ticket_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::TicketAssignmentConflict(format!(
|
Error::TicketAssignmentConflict(format!(
|
||||||
@@ -17419,7 +17433,11 @@ mod tests {
|
|||||||
let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path()))
|
let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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!(ready_detail.action_eligibility.blockers.is_empty());
|
||||||
assert_eq!(ready_detail.relations.blockers.len(), 1);
|
assert_eq!(ready_detail.relations.blockers.len(), 1);
|
||||||
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
|
assert_eq!(ready_detail.relations.blockers[0].reason_kind, "depends_on");
|
||||||
|
|||||||
Reference in New Issue
Block a user