Compare commits
9
Commits
8c85b93e7d
...
24291a4545
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24291a4545 | ||
|
|
92d073ff36 | ||
|
|
bc57ef38c1 | ||
|
|
ae8a0316d8 | ||
|
|
2cfbb1caea | ||
|
|
703398bd2c | ||
|
|
10c80ae514 | ||
|
|
285763f4ae | ||
|
|
8030045602 |
@@ -679,7 +679,9 @@ fn validate_generic_state_change(
|
||||
to: TicketWorkflowState,
|
||||
) -> Result<()> {
|
||||
if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready
|
||||
|| current == TicketWorkflowState::Planning && to == TicketWorkflowState::InProgress
|
||||
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued
|
||||
|| current == TicketWorkflowState::Ready && to == TicketWorkflowState::InProgress
|
||||
{
|
||||
return Err(TicketError::InvalidWorkflowTransition {
|
||||
from: current.as_str().to_owned(),
|
||||
@@ -6998,6 +7000,24 @@ mod tests {
|
||||
assert!(projection.visible_overlay.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_state_change_rejects_manual_start_bypass() {
|
||||
assert!(
|
||||
validate_generic_state_change(
|
||||
TicketWorkflowState::Ready,
|
||||
TicketWorkflowState::InProgress
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_generic_state_change(
|
||||
TicketWorkflowState::Planning,
|
||||
TicketWorkflowState::InProgress
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_state_rejects_legacy_intake_alias() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,16 +17,17 @@ use ticket::{
|
||||
use crate::records::{
|
||||
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkSummary, ObjectiveLinkedTicketSummary,
|
||||
ObjectiveQueryItem, ObjectiveQueryRequest, ObjectiveQueryResponse, ObjectiveResourceSummary,
|
||||
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketAssignmentSummary,
|
||||
TicketDetail, TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary,
|
||||
TicketListPageRequest, TicketMergeRequestSummary, TicketQueryItem, TicketQueryRequest,
|
||||
TicketQueryResponse, TicketRelationView, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||
ObjectiveShowRequest, ObjectiveSummary, ProjectRecordList, QueryPage, TicketActionEligibility,
|
||||
TicketAssignmentPrincipalSummary, TicketAssignmentSummary, TicketDetail, TicketEventDetail,
|
||||
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
|
||||
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
|
||||
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
|
||||
summarize_body, truncate_body, validate_project_id,
|
||||
};
|
||||
use crate::store::{
|
||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
||||
WorkspaceResourceKind,
|
||||
TicketAssignmentPrincipal, TicketAssignmentRole, WorkspaceResourceKind,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -146,7 +147,7 @@ impl merge_request::AssignmentSource for AuthorityMergeRequestSource {
|
||||
ticket_id: &str,
|
||||
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
||||
self.store
|
||||
.get_current_ticket_worker_assignment(workspace_id, ticket_id)
|
||||
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||
.map(|assignment| {
|
||||
assignment.map(|assignment| merge_request::CurrentAssignment {
|
||||
assignment_id: assignment.assignment_id,
|
||||
@@ -762,23 +763,105 @@ impl SqliteWorkspaceAuthority {
|
||||
.filter(|(_, event)| event.kind.as_str() == "implementation_report")
|
||||
.map(|(sequence, event)| ticket_evidence_event(sequence, event))
|
||||
.collect::<Vec<_>>();
|
||||
let current_assignment = self
|
||||
let role_assignments = self
|
||||
.store
|
||||
.get_current_ticket_worker_assignment(&self.workspace_id, id)?
|
||||
.map(|assignment| {
|
||||
.list_current_ticket_role_assignments(&self.workspace_id, id)?;
|
||||
let assignments = role_assignments
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|assignment| TicketRoleAssignmentSummary {
|
||||
assignment_id: assignment.assignment_id,
|
||||
role: assignment.role.as_str().to_string(),
|
||||
principal: match assignment.principal {
|
||||
TicketAssignmentPrincipal::User { account_id } => {
|
||||
TicketAssignmentPrincipalSummary::User { account_id }
|
||||
}
|
||||
TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => TicketAssignmentPrincipalSummary::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
},
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
TicketAssignmentPrincipalSummary::WorkspaceAgent { agent_key }
|
||||
}
|
||||
},
|
||||
assigned_by: assignment.assigned_by,
|
||||
assigned_at: assignment.assigned_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let current_coder = role_assignments
|
||||
.iter()
|
||||
.find(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
||||
.and_then(|assignment| {
|
||||
assignment
|
||||
.principal
|
||||
.worker()
|
||||
.map(|worker| (assignment, worker))
|
||||
})
|
||||
.map(|(assignment, worker)| {
|
||||
let worker_resource_key = self.store.resource_key(
|
||||
&self.workspace_id,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&assignment.worker.worker_id,
|
||||
&worker.worker_id,
|
||||
)?;
|
||||
Ok::<_, Error>(TicketAssignmentSummary {
|
||||
assignment_id: assignment.assignment_id,
|
||||
runtime_id: assignment.worker.runtime_id,
|
||||
worker_id: assignment.worker.worker_id,
|
||||
assignment_id: assignment.assignment_id.clone(),
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
worker_resource_key,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let has_orchestrator = role_assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator);
|
||||
let has_coder = role_assignments
|
||||
.iter()
|
||||
.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 assignment_diagnostics = Vec::new();
|
||||
if let Some(legacy_assignee) = ticket
|
||||
.meta
|
||||
.assignee
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
assignment_diagnostics.push(format!(
|
||||
"legacy Ticket assignee `{legacy_assignee}` is not assignment authority"
|
||||
));
|
||||
}
|
||||
let action_eligibility = TicketActionEligibility {
|
||||
can_assign_orchestrator: matches!(
|
||||
ticket.meta.workflow_state,
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||
) && !has_orchestrator
|
||||
&& !has_coder,
|
||||
can_unassign_orchestrator: has_orchestrator
|
||||
&& matches!(
|
||||
ticket.meta.workflow_state,
|
||||
TicketWorkflowState::Planning | TicketWorkflowState::Ready
|
||||
),
|
||||
can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready
|
||||
&& has_orchestrator
|
||||
&& !has_coder
|
||||
&& has_target
|
||||
&& !has_blockers,
|
||||
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()),
|
||||
has_blockers.then_some("unresolved blocking relations remain".to_string()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect(),
|
||||
};
|
||||
let merge_request = match self.merge_request_store.get(&self.workspace_id, id) {
|
||||
Ok(request) => {
|
||||
let current_subject_ref = request.selector_from.as_deref().and_then(|selector| {
|
||||
@@ -847,7 +930,6 @@ impl SqliteWorkspaceAuthority {
|
||||
item_revision,
|
||||
queued_by: ticket.meta.queued_by,
|
||||
queued_at: ticket.meta.queued_at,
|
||||
assignee: ticket.meta.assignee,
|
||||
repository_id: ticket.meta.repository_id,
|
||||
ref_selector: ticket.meta.ref_selector,
|
||||
risk_flags: ticket.meta.risk_flags,
|
||||
@@ -873,7 +955,10 @@ impl SqliteWorkspaceAuthority {
|
||||
relations,
|
||||
linked_objectives,
|
||||
implementation_reports,
|
||||
current_assignment,
|
||||
assignments,
|
||||
current_coder,
|
||||
assignment_diagnostics,
|
||||
action_eligibility,
|
||||
merge_request,
|
||||
evidence,
|
||||
resolution: ticket
|
||||
|
||||
@@ -81,7 +81,6 @@ pub struct TicketDetail {
|
||||
pub item_revision: String,
|
||||
pub queued_by: Option<String>,
|
||||
pub queued_at: Option<String>,
|
||||
pub assignee: Option<String>,
|
||||
pub repository_id: Option<String>,
|
||||
pub ref_selector: Option<String>,
|
||||
pub risk_flags: Vec<String>,
|
||||
@@ -95,7 +94,10 @@ pub struct TicketDetail {
|
||||
pub relations: TicketRelationView,
|
||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||
pub current_assignment: Option<TicketAssignmentSummary>,
|
||||
pub assignments: Vec<TicketRoleAssignmentSummary>,
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
pub assignment_diagnostics: Vec<String>,
|
||||
pub action_eligibility: TicketActionEligibility,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub resolution: Option<String>,
|
||||
@@ -260,6 +262,43 @@ pub struct TicketAssignmentSummary {
|
||||
pub worker_resource_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketRoleAssignmentSummary {
|
||||
pub assignment_id: String,
|
||||
pub role: String,
|
||||
pub principal: TicketAssignmentPrincipalSummary,
|
||||
pub assigned_by: String,
|
||||
pub assigned_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))]
|
||||
pub enum TicketAssignmentPrincipalSummary {
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
Worker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
WorkspaceAgent {
|
||||
agent_key: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketActionEligibility {
|
||||
pub can_assign_orchestrator: bool,
|
||||
pub can_unassign_orchestrator: bool,
|
||||
pub can_queue: bool,
|
||||
pub can_start_manual_coder: bool,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct TicketMergeRequestSummary {
|
||||
@@ -424,6 +463,9 @@ pub fn ticket_api_typescript() -> String {
|
||||
ObjectiveLinkSummary::decl(&config),
|
||||
TicketEvidenceEvent::decl(&config),
|
||||
TicketAssignmentSummary::decl(&config),
|
||||
TicketRoleAssignmentSummary::decl(&config),
|
||||
TicketAssignmentPrincipalSummary::decl(&config),
|
||||
TicketActionEligibility::decl(&config),
|
||||
TicketMergeRequestSummary::decl(&config),
|
||||
MergeRequestListItem::decl(&config),
|
||||
MergeRequestListResponse::decl(&config),
|
||||
|
||||
@@ -1047,7 +1047,7 @@ fn parse_state(v: &str) -> rusqlite::Result<WorkerRemovalPlanState> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::{ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerRegistryRecord};
|
||||
use crate::store::{ControlPlaneStore, TicketCoderAssignmentRecord, WorkerRegistryRecord};
|
||||
use worker_runtime::identity::WorkerId;
|
||||
fn worker_id() -> WorkerId {
|
||||
WorkerId::from_legacy_u64(1)
|
||||
@@ -1505,7 +1505,7 @@ mod tests {
|
||||
).map_err(StoreError::from)).unwrap();
|
||||
assert_eq!(revision, "rev1");
|
||||
|
||||
let assignment = TicketWorkerAssignmentRecord {
|
||||
let assignment = TicketCoderAssignmentRecord {
|
||||
workspace_id: "w".into(),
|
||||
ticket_id: "new-ticket".into(),
|
||||
assignment_id: "new-assignment".into(),
|
||||
@@ -1518,7 +1518,7 @@ mod tests {
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.set_current_ticket_worker_assignment(
|
||||
.set_current_ticket_coder_assignment(
|
||||
&assignment,
|
||||
None,
|
||||
"event",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,241 +1,52 @@
|
||||
// Generated from yoi-workspace-server. Do not edit by hand.
|
||||
// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts
|
||||
|
||||
export type InvalidProjectRecord = { label: string; reason: string };
|
||||
export type InvalidProjectRecord = { label: string, reason: string, };
|
||||
|
||||
export type TicketSummary = {
|
||||
id: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
priority: string;
|
||||
updated_at: string | null;
|
||||
queued_by: string | null;
|
||||
queued_at: string | null;
|
||||
workspace_action_priority: string;
|
||||
record_source: string;
|
||||
};
|
||||
export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, };
|
||||
|
||||
export type TicketListResponse = {
|
||||
workspace_id: string;
|
||||
limit: number;
|
||||
items: Array<TicketSummary>;
|
||||
page: QueryPage;
|
||||
invalid_records: Array<InvalidProjectRecord>;
|
||||
record_authority: string;
|
||||
};
|
||||
export type TicketListResponse = { workspace_id: string, limit: number, items: Array<TicketSummary>, page: QueryPage, invalid_records: Array<InvalidProjectRecord>, record_authority: string, };
|
||||
|
||||
export type QueryPage = {
|
||||
limit: number;
|
||||
returned: number;
|
||||
has_more: boolean;
|
||||
next_cursor: string | null;
|
||||
sort: string;
|
||||
source_limit: number | null;
|
||||
source_truncated: boolean;
|
||||
};
|
||||
export type QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, };
|
||||
|
||||
export type TicketEventDetail = {
|
||||
sequence: number;
|
||||
event_ref: string;
|
||||
kind: string;
|
||||
author: string | null;
|
||||
at: string | null;
|
||||
status: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
reason: string | null;
|
||||
state_field: string | null;
|
||||
heading: string | null;
|
||||
body: string | null;
|
||||
attributes: { [key in string]: string };
|
||||
references: Array<string>;
|
||||
};
|
||||
export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array<string>, };
|
||||
|
||||
export type ObjectiveLinkSummary = {
|
||||
id: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
};
|
||||
export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, };
|
||||
|
||||
export type TicketEvidenceEvent = {
|
||||
event_ref: string;
|
||||
sequence: number;
|
||||
kind: string;
|
||||
at: string | null;
|
||||
author: string | null;
|
||||
excerpt: string;
|
||||
};
|
||||
export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, };
|
||||
|
||||
export type TicketAssignmentSummary = {
|
||||
assignment_id: string;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
worker_resource_key?: string | null;
|
||||
};
|
||||
export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, };
|
||||
|
||||
export type TicketMergeRequestSummary = {
|
||||
merge_request_id: string;
|
||||
repository_id: string;
|
||||
state: string;
|
||||
review_status: string;
|
||||
selector_from: string | null;
|
||||
selector_to: string;
|
||||
updated_at: string;
|
||||
current_subject_ref: string | null;
|
||||
review_subject_ref: string | null;
|
||||
review_requested_at: string | null;
|
||||
review_submitted_at: string | null;
|
||||
review_excerpt: string | null;
|
||||
};
|
||||
export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, };
|
||||
|
||||
export type MergeRequestListItem = {
|
||||
summary: TicketMergeRequestSummary;
|
||||
ticket_ids: Array<string>;
|
||||
thread_event_count: number;
|
||||
};
|
||||
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
|
||||
|
||||
export type MergeRequestListResponse = {
|
||||
items: Array<MergeRequestListItem>;
|
||||
next_cursor: string | null;
|
||||
};
|
||||
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, blockers: Array<string>, };
|
||||
|
||||
export type TicketEvidenceSummary = {
|
||||
has_merge_request: boolean;
|
||||
has_current_subject_ref: boolean;
|
||||
has_review_request: boolean;
|
||||
has_commit: boolean;
|
||||
review_status: string | null;
|
||||
approved_current_subject: boolean;
|
||||
review_after_rescope: boolean;
|
||||
unresolved_request_changes: boolean;
|
||||
complete_for_integration: boolean;
|
||||
missing: Array<string>;
|
||||
};
|
||||
export type TicketMergeRequestSummary = { merge_request_id: string, repository_id: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
|
||||
|
||||
export type TicketQueryRequest = {
|
||||
query: string | null;
|
||||
states: Array<string>;
|
||||
event_kinds: Array<string>;
|
||||
evidence: Array<string>;
|
||||
review_status: string | null;
|
||||
attention: Array<string>;
|
||||
related_ticket_id: string | null;
|
||||
relation_kind: string | null;
|
||||
linked_objective_id: string | null;
|
||||
updated_after: string | null;
|
||||
updated_before: string | null;
|
||||
sort: string | null;
|
||||
limit: number | null;
|
||||
cursor: string | null;
|
||||
};
|
||||
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, };
|
||||
|
||||
export type TicketQueryItem = {
|
||||
id: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
priority: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
item_revision: string;
|
||||
workspace_action_priority: string;
|
||||
matched_fields: Array<string>;
|
||||
snippet: string | null;
|
||||
matching_event: TicketEvidenceEvent | null;
|
||||
linked_objective_ids: Array<string>;
|
||||
relation_count: number;
|
||||
blocker_count: number;
|
||||
unresolved_blocker_count: number;
|
||||
unresolved_review_count: number;
|
||||
evidence: TicketEvidenceSummary;
|
||||
merge_request: TicketMergeRequestSummary | null;
|
||||
};
|
||||
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
|
||||
|
||||
export type TicketQueryResponse = {
|
||||
items: Array<TicketQueryItem>;
|
||||
page: QueryPage;
|
||||
record_authority: string;
|
||||
};
|
||||
export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array<string>, };
|
||||
|
||||
export type TicketShowRequest = {
|
||||
event_limit: number | null;
|
||||
event_cursor: string | null;
|
||||
};
|
||||
export type TicketQueryRequest = { query: string | null, states: Array<string>, event_kinds: Array<string>, evidence: Array<string>, review_status: string | null, attention: Array<string>, related_ticket_id: string | null, relation_kind: string | null, linked_objective_id: string | null, updated_after: string | null, updated_before: string | null, sort: string | null, limit: number | null, cursor: string | null, };
|
||||
|
||||
export type TicketRelation = {
|
||||
ticket_id: string;
|
||||
kind: string;
|
||||
target: string;
|
||||
target_resource_key?: string | null;
|
||||
note: string | null;
|
||||
author: string;
|
||||
at: string;
|
||||
};
|
||||
export type TicketQueryItem = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, workspace_action_priority: string, matched_fields: Array<string>, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array<string>, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, };
|
||||
|
||||
export type DerivedTicketRelation = {
|
||||
source_ticket: string;
|
||||
source_resource_key?: string | null;
|
||||
inverse_kind: string;
|
||||
forward_kind: string;
|
||||
note: string | null;
|
||||
author: string;
|
||||
at: string;
|
||||
};
|
||||
export type TicketQueryResponse = { items: Array<TicketQueryItem>, page: QueryPage, record_authority: string, };
|
||||
|
||||
export type TicketRelationBlocker = {
|
||||
blocking_ticket: string;
|
||||
blocking_resource_key?: string | null;
|
||||
reason_kind: string;
|
||||
relation_kind: string;
|
||||
note: string | null;
|
||||
blocking_state: string;
|
||||
};
|
||||
export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, };
|
||||
|
||||
export type TicketRelationNotice = {
|
||||
related_ticket: string;
|
||||
kind: string;
|
||||
message: string;
|
||||
};
|
||||
export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, };
|
||||
|
||||
export type TicketRelationView = {
|
||||
outgoing: Array<TicketRelation>;
|
||||
incoming: Array<DerivedTicketRelation>;
|
||||
blockers: Array<TicketRelationBlocker>;
|
||||
notices: Array<TicketRelationNotice>;
|
||||
};
|
||||
export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, };
|
||||
|
||||
export type TicketDetail = {
|
||||
id: string;
|
||||
resource_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
priority: string;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
item_revision: string;
|
||||
queued_by: string | null;
|
||||
queued_at: string | null;
|
||||
assignee: string | null;
|
||||
repository_id: string | null;
|
||||
ref_selector: string | null;
|
||||
risk_flags: Array<string>;
|
||||
body: string;
|
||||
body_truncated: boolean;
|
||||
event_count: number;
|
||||
events: Array<TicketEventDetail>;
|
||||
event_page: QueryPage;
|
||||
artifact_count: number;
|
||||
artifacts: Array<string>;
|
||||
relations: TicketRelationView;
|
||||
linked_objectives: Array<ObjectiveLinkSummary>;
|
||||
implementation_reports: Array<TicketEvidenceEvent>;
|
||||
current_assignment: TicketAssignmentSummary | null;
|
||||
merge_request: TicketMergeRequestSummary | null;
|
||||
evidence: TicketEvidenceSummary;
|
||||
resolution: string | null;
|
||||
record_source: string;
|
||||
};
|
||||
export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, };
|
||||
|
||||
export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, };
|
||||
|
||||
export type TicketRelationView = { outgoing: Array<TicketRelation>, incoming: Array<DerivedTicketRelation>, blockers: Array<TicketRelationBlocker>, notices: Array<TicketRelationNotice>, };
|
||||
|
||||
export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_id: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, };
|
||||
|
||||
@@ -110,6 +110,25 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("ticket detail uses server-derived role assignment actions", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
||||
assertEquals(
|
||||
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
||||
true,
|
||||
);
|
||||
assertEquals(source.includes("/assignments/${role}"), true);
|
||||
assertEquals(source.includes('kind: "workspace_agent"'), true);
|
||||
assertEquals(source.includes('kind: "worker"'), true);
|
||||
assertEquals(source.includes("ticket.assignee"), false);
|
||||
});
|
||||
|
||||
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
|
||||
const css = await Deno.readTextFile(
|
||||
new URL("../styles/tickets.css", import.meta.url),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { untrack } from "svelte";
|
||||
import RichMarkdown from "$lib/workspace/console/RichMarkdown.svelte";
|
||||
import {
|
||||
workspaceApiJson,
|
||||
workspaceApiJsonWithBody,
|
||||
workspaceApiPath,
|
||||
} from "$lib/workspace/api/http";
|
||||
@@ -9,7 +10,6 @@
|
||||
import {
|
||||
relationLabel,
|
||||
TICKET_STATES,
|
||||
ticketWorkerLaunchHref,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "$lib/workspace/tickets/ticket-panel";
|
||||
import type { ApiResult } from "$lib/workspace/api/http";
|
||||
@@ -37,7 +37,6 @@
|
||||
const loadedTicket = initialData.ticket.data;
|
||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||
const loadedRepositories = initialData.repositories.data;
|
||||
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
|
||||
|
||||
let ticket = $state<TicketDetail>(loadedTicket);
|
||||
const mergeRequest = $derived(ticket.merge_request);
|
||||
@@ -54,6 +53,8 @@
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let readyOperationKey = $state<string | null>(null);
|
||||
let manualRuntimeId = $state("");
|
||||
let manualWorkerId = $state("");
|
||||
const selectedRepository = $derived(
|
||||
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.id === repositoryId) ?? null,
|
||||
);
|
||||
@@ -72,7 +73,7 @@
|
||||
),
|
||||
);
|
||||
const implementationStartEligible = $derived(
|
||||
persistedTargetValid && ticket.state !== "planning" && ticket.state !== "closed",
|
||||
ticket.action_eligibility.can_start_manual_coder,
|
||||
);
|
||||
|
||||
const ticketPath = $derived(
|
||||
@@ -116,6 +117,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateAssignment(
|
||||
action: string,
|
||||
role: "orchestrator" | "coder",
|
||||
principal: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (busy) return;
|
||||
busy = action;
|
||||
errorMessage = null;
|
||||
try {
|
||||
await workspaceApiJsonWithBody(
|
||||
`${ticketPath}/assignments/${role}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
operation_id: crypto.randomUUID(),
|
||||
principal,
|
||||
expected_assignment_id: null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function assignOrchestrator(): Promise<void> {
|
||||
await mutateAssignment("assign-orchestrator", "orchestrator", {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
}
|
||||
|
||||
async function startManualCoder(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
|
||||
await mutateAssignment("start-manual", "coder", {
|
||||
kind: "worker",
|
||||
runtime_id: manualRuntimeId.trim(),
|
||||
worker_id: manualWorkerId.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEdit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
@@ -326,24 +372,54 @@
|
||||
|
||||
<aside class="ticket-control-rail">
|
||||
<section class="ticket-control-card ticket-worker-card">
|
||||
<header><h2>Start a Worker</h2><span>Ticket role</span></header>
|
||||
<p class="ticket-assignment-line">
|
||||
Assigned to <strong>{ticket.assignee ?? "Unassigned"}</strong>
|
||||
</p>
|
||||
{#if orchestratorOnline && implementationStartEligible}
|
||||
<p>The Orchestrator is online. Start a role-specific Worker with the validated Ticket target below.</p>
|
||||
<div class="ticket-role-actions">
|
||||
<a class="workspace-primary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket, "coder")}>Coder</a>
|
||||
</div>
|
||||
<header><h2>Role assignments</h2><span>Server-authoritative</span></header>
|
||||
{#if ticket.assignments.length > 0}
|
||||
<ul class="ticket-assignment-list">
|
||||
{#each ticket.assignments as assignment}
|
||||
<li>
|
||||
<strong>{assignment.role}</strong>
|
||||
<span>
|
||||
{#if assignment.principal.kind === "worker"}
|
||||
{assignment.principal.runtime_id}/{assignment.principal.worker_id}
|
||||
{:else if assignment.principal.kind === "user"}
|
||||
{assignment.principal.account_id}
|
||||
{:else}
|
||||
<p class="workspace-callout">
|
||||
{orchestratorOnline
|
||||
? "Validate and persist the repository target before starting a Ticket Worker."
|
||||
: "Start the Workspace Orchestrator from the Ticket panel before launching Ticket Workers."}
|
||||
</p>
|
||||
<div class="ticket-role-actions">
|
||||
<button class="workspace-primary-button" type="button" disabled>Coder</button>
|
||||
</div>
|
||||
{assignment.principal.agent_key}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">No active role assignment.</p>
|
||||
{/if}
|
||||
{#if ticket.action_eligibility.can_assign_orchestrator}
|
||||
<button
|
||||
class="workspace-primary-button"
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onclick={assignOrchestrator}
|
||||
>
|
||||
{busy === "assign-orchestrator" ? "Assigning…" : "Assign Orchestrator"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if implementationStartEligible}
|
||||
<form class="ticket-control-form" onsubmit={startManualCoder}>
|
||||
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
|
||||
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
|
||||
<button
|
||||
class="workspace-secondary-button"
|
||||
type="submit"
|
||||
disabled={busy !== null || !manualRuntimeId.trim() || !manualWorkerId.trim()}
|
||||
>
|
||||
{busy === "start-manual" ? "Starting…" : "Assign Coder and start"}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if ticket.assignment_diagnostics.length > 0}
|
||||
{#each ticket.assignment_diagnostics as diagnostic}
|
||||
<p class="workspace-callout">{diagnostic}</p>
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -386,9 +462,12 @@
|
||||
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
|
||||
{/if}
|
||||
{:else if ticket.state === "ready"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !orchestratorOnline || !persistedTargetValid} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{busy === "queue" ? "Queueing…" : orchestratorOnline ? "Queue ticket" : "Orchestrator offline"}
|
||||
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => mutate("queue", "/queue", {})}>
|
||||
{busy === "queue" ? "Queueing…" : "Queue ticket"}
|
||||
</button>
|
||||
{#if !ticket.action_eligibility.can_queue}
|
||||
<p class="workspace-empty-copy">Assign the Orchestrator role and resolve the listed blockers before Queue.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user