diff --git a/Cargo.lock b/Cargo.lock index 18e91ad8..13cfdfc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6109,6 +6109,7 @@ dependencies = [ "toml", "tower", "tracing", + "ts-rs", "url", "uuid", "webauthn-rs", diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 8aa6ef83..6617487b 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -10,6 +10,10 @@ autobins = false name = "yoi-server" path = "src/main.rs" +[features] +default = ["typescript"] +typescript = ["dep:ts-rs"] + [dependencies] async-trait.workspace = true axum = { workspace = true, features = ["ws"] } @@ -33,6 +37,7 @@ worker.workspace = true worker-runtime.workspace = true toml.workspace = true tracing.workspace = true +ts-rs = { version = "12.0.1", optional = true } url.workspace = true uuid = { workspace = true, features = ["v7"] } webauthn-rs = { workspace = true } diff --git a/crates/workspace-server/examples/generate_ticket_api_types.rs b/crates/workspace-server/examples/generate_ticket_api_types.rs new file mode 100644 index 00000000..f30c422b --- /dev/null +++ b/crates/workspace-server/examples/generate_ticket_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", yoi_workspace_server::ticket_api_typescript()); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 2105cfa0..528b955e 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -274,6 +274,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { updated_at: ticket.meta.updated_at, 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, @@ -287,6 +288,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .into_iter() .map(|artifact| artifact.relative_path.display().to_string()) .collect(), + relations: ticket.relations.into(), resolution: ticket .resolution .map(|resolution| resolution.as_str().to_string()), diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 008ba54a..0747f467 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -15,6 +15,8 @@ pub mod memory_staging; pub mod observation; pub mod profile_settings; pub mod records; +#[cfg(feature = "typescript")] +pub use records::ticket_api_typescript; pub mod repositories; pub mod resource_broker; pub mod server; diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 830a8a66..c95aa816 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -13,12 +13,14 @@ pub struct ProjectRecordList { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct InvalidProjectRecord { pub label: String, pub reason: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketSummary { pub id: String, pub title: String, @@ -32,6 +34,17 @@ pub struct TicketSummary { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketListResponse { + pub workspace_id: String, + pub limit: usize, + pub items: Vec, + pub invalid_records: Vec, + pub record_authority: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketDetail { pub id: String, pub title: String, @@ -41,6 +54,7 @@ pub struct TicketDetail { pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, + pub assignee: Option, pub repository_id: Option, pub ref_selector: Option, pub risk_flags: Vec, @@ -50,11 +64,13 @@ pub struct TicketDetail { pub events: Vec, pub artifact_count: usize, pub artifacts: Vec, + pub relations: TicketRelationView, pub resolution: Option, pub record_source: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct TicketEventDetail { pub sequence: usize, pub kind: String, @@ -69,6 +85,106 @@ pub struct TicketEventDetail { pub body: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelation { + pub ticket_id: String, + pub kind: String, + pub target: String, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct DerivedTicketRelation { + pub source_ticket: String, + pub inverse_kind: String, + pub forward_kind: String, + pub note: Option, + pub author: String, + pub at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelationBlocker { + pub blocking_ticket: String, + pub reason_kind: String, + pub relation_kind: String, + pub note: Option, + pub blocking_state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelationNotice { + pub related_ticket: String, + pub kind: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct TicketRelationView { + pub outgoing: Vec, + pub incoming: Vec, + pub blockers: Vec, + pub notices: Vec, +} + +impl From for TicketRelationView { + fn from(value: ticket::TicketRelationView) -> Self { + Self { + outgoing: value + .outgoing + .into_iter() + .map(|relation| TicketRelation { + ticket_id: relation.ticket_id, + kind: relation.kind.as_str().to_string(), + target: relation.target, + note: relation.note, + author: relation.author, + at: relation.at, + }) + .collect(), + incoming: value + .incoming + .into_iter() + .map(|relation| DerivedTicketRelation { + source_ticket: relation.source_ticket, + inverse_kind: relation.inverse_kind, + forward_kind: relation.forward_kind.as_str().to_string(), + note: relation.note, + author: relation.author, + at: relation.at, + }) + .collect(), + blockers: value + .blockers + .into_iter() + .map(|blocker| TicketRelationBlocker { + blocking_ticket: blocker.blocking_ticket, + reason_kind: blocker.reason_kind, + relation_kind: blocker.relation_kind.as_str().to_string(), + note: blocker.note, + blocking_state: blocker.blocking_state.as_str().to_string(), + }) + .collect(), + notices: value + .notices + .into_iter() + .map(|notice| TicketRelationNotice { + related_ticket: notice.related_ticket, + kind: notice.kind.as_str().to_string(), + message: notice.message, + }) + .collect(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, @@ -102,6 +218,62 @@ pub struct ObjectiveResourceSummary { pub updated_at: String, } +#[cfg(feature = "typescript")] +pub fn ticket_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + InvalidProjectRecord::decl(&config), + TicketSummary::decl(&config), + TicketListResponse::decl(&config), + TicketEventDetail::decl(&config), + TicketRelation::decl(&config), + DerivedTicketRelation::decl(&config), + TicketRelationBlocker::decl(&config), + TicketRelationNotice::decl(&config), + TicketRelationView::decl(&config), + TicketDetail::decl(&config), + ]; + format!( + "// Generated from yoi-workspace-server. Do not edit by hand.\n// Regenerate: cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + +#[cfg(all(test, feature = "typescript"))] +mod typescript_tests { + #[test] + fn generated_ticket_api_contract_is_current() { + let expected = super::ticket_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/ticket-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate Ticket API TypeScript types with `cargo run -q -p yoi-workspace-server --features typescript --example generate_ticket_api_types > web/workspace/src/lib/generated/ticket-api.ts` and format the generated file", + ); + } + + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect::() + .replace(";}", "}") + } +} + pub(crate) fn validate_project_id(id: &str) -> Result<()> { validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string())) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 736591c0..df4e1f66 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -82,7 +82,7 @@ use crate::profile_settings::{ UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest, }; -use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary}; +use crate::records::{ObjectiveDetail, ProjectRecordList, TicketDetail}; use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, @@ -577,14 +577,6 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/repositories/{repository_id}/log", get(scoped_repository_log), ) - .route( - "/api/repositories/{repository_id}/tickets", - get(repository_tickets), - ) - .route( - "/api/w/{workspace_id}/repositories/{repository_id}/tickets", - get(scoped_repository_tickets), - ) .route("/api/hosts", get(list_hosts)) .route("/api/w/{workspace_id}/hosts", get(scoped_list_hosts)) .route( @@ -1198,34 +1190,11 @@ pub struct RepositoryLogResponse { pub diagnostics: Vec, } -#[derive(Debug, Serialize, Deserialize)] -pub struct RepositoryTicketsResponse { - pub workspace_id: String, - pub repository_id: String, - pub limit: usize, - pub columns: Vec, - pub invalid_records: Vec, - pub record_authority: String, - pub source: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct TicketKanbanColumn { - pub state: String, - pub items: Vec, -} - #[derive(Debug, Deserialize)] struct LogQuery { limit: Option, } -#[derive(Debug, Deserialize)] -struct TicketKanbanQuery { - limit: Option, -} - #[derive(Debug, Deserialize)] struct MemoryStagingQuery { limit: Option, @@ -1256,6 +1225,11 @@ struct ObjectiveEditRequest { replace_all: bool, } +#[derive(Debug, Deserialize)] +struct TicketListQuery { + limit: Option, +} + #[derive(Debug, Deserialize)] struct ObjectiveStateRequest { state: String, @@ -1540,8 +1514,8 @@ async fn scoped_delete_profile_source( async fn scoped_list_tickets( State(api): State, AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; list_tickets(State(api), Query(query)).await } @@ -2232,15 +2206,6 @@ async fn scoped_repository_log( repository_log(State(api), AxumPath(path.repository_id), Query(query)).await } -async fn scoped_repository_tickets( - State(api): State, - AxumPath(path): AxumPath, - Query(query): Query, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - repository_tickets(State(api), AxumPath(path.repository_id), Query(query)).await -} - async fn scoped_list_hosts( State(api): State, AxumPath(path): AxumPath, @@ -4111,8 +4076,8 @@ fn companion_console_extension_point(status: &CompanionStatusResponse) -> Extens async fn list_tickets( State(api): State, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { let requested_limit = query.limit.unwrap_or(api.config.max_records); let limit = requested_limit.min(1000); let ProjectRecordList { @@ -4120,7 +4085,7 @@ async fn list_tickets( invalid_records, record_authority, } = api.authority.list_tickets(limit)?; - Ok(Json(ListResponse { + Ok(Json(crate::records::TicketListResponse { workspace_id: api.config.workspace_id, limit, items, @@ -4211,35 +4176,6 @@ async fn repository_log( })) } -async fn repository_tickets( - State(api): State, - AxumPath(repository_id): AxumPath, - Query(query): Query, -) -> ApiResult> { - repository_lookup(api.repository_reader().summary(&repository_id))?; - let canonical_repository_id = repository_id; - let limit = query.limit.unwrap_or(api.config.max_records).min(200); - let ProjectRecordList { - items, - invalid_records, - record_authority, - } = api.authority.list_tickets(limit)?; - Ok(Json(RepositoryTicketsResponse { - workspace_id: api.config.workspace_id, - repository_id: canonical_repository_id, - limit, - columns: ticket_kanban_columns(items), - invalid_records, - record_authority, - source: "workspace_local_ticket_fallback".to_string(), - diagnostics: vec![RuntimeDiagnostic { - code: "repository_ticket_target_metadata_absent".to_string(), - severity: DiagnosticSeverity::Info, - message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(), - }], - })) -} - async fn list_hosts( State(api): State, ) -> ApiResult>> { @@ -6811,52 +6747,6 @@ fn repository_lookup(result: std::result::Result) - }) } -fn ticket_kanban_columns(items: Vec) -> Vec { - let mut columns = vec![ - TicketKanbanColumn { - state: "planning".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "ready".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "queued".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "inprogress".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "done".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "closed".to_string(), - items: Vec::new(), - }, - TicketKanbanColumn { - state: "other".to_string(), - items: Vec::new(), - }, - ]; - for item in items { - let index = match item.state.as_str() { - "planning" => 0, - "ready" => 1, - "queued" => 2, - "inprogress" => 3, - "done" => 4, - "closed" => 5, - _ => 6, - }; - columns[index].items.push(item); - } - columns -} - async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Response { if uri.path().starts_with("/api/") || uri.path() == "/api" { return ( @@ -7990,6 +7880,40 @@ mod tests { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket_id.clone(), }; + let Json(related) = scoped_ticket_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::Create { + input: ticket::NewTicket::new("Related Browser Ticket"), + }), + ) + .await + .unwrap(); + let related_ticket_id = match related { + TicketBackendHttpResponse::Ok { + result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref), + } => ticket_ref.id, + other => panic!("unexpected related create response: {other:?}"), + }; + let _ = scoped_ticket_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::AddTicketRelation { + id: ticket_id.clone().into(), + relation: ticket::NewTicketRelation { + kind: ticket::TicketRelationKind::Related, + target: related_ticket_id.clone(), + note: Some("Browser relation".to_string()), + author: Some("browser-user".to_string()), + }, + }), + ) + .await + .unwrap(); let Json(edited) = scoped_edit_ticket_item( State(api.clone()), @@ -8013,6 +7937,10 @@ mod tests { assert_eq!(edited.body, "Updated from the Browser API."); assert_eq!(edited.repository_id.as_deref(), Some("main")); assert_eq!(edited.ref_selector.as_deref(), Some("feature/api")); + assert_eq!(edited.assignee, None); + assert_eq!(edited.relations.outgoing.len(), 1); + assert_eq!(edited.relations.outgoing[0].target, related_ticket_id); + assert_eq!(edited.relations.outgoing[0].kind, "related"); let Json(commented) = scoped_append_ticket_event( State(api.clone()), @@ -9408,19 +9336,17 @@ mod tests { assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["limit"], 3); - let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await; - assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID); - let ready_column = repository_tickets["columns"] - .as_array() - .unwrap() - .iter() - .find(|column| column["state"] == "ready") + let removed_repository_tickets = app + .clone() + .oneshot( + Request::builder() + .uri("/api/repositories/main/tickets") + .body(Body::empty()) + .unwrap(), + ) + .await .unwrap(); - assert_eq!(ready_column["items"][0]["title"], "API Ticket"); - assert_eq!( - repository_tickets["diagnostics"][0]["code"], - "repository_ticket_target_metadata_absent" - ); + assert_eq!(removed_repository_tickets.status(), StatusCode::NOT_FOUND); let unknown_repository_response = app .clone() diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts new file mode 100644 index 00000000..df60854d --- /dev/null +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -0,0 +1,101 @@ +// 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 TicketSummary = { + id: 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; + invalid_records: Array; + record_authority: string; +}; + +export type TicketEventDetail = { + sequence: number; + 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; +}; + +export type TicketRelation = { + ticket_id: string; + kind: string; + target: string; + note: string | null; + author: string; + at: string; +}; + +export type DerivedTicketRelation = { + source_ticket: string; + inverse_kind: string; + forward_kind: string; + note: string | null; + author: string; + at: string; +}; + +export type TicketRelationBlocker = { + blocking_ticket: string; + 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; + incoming: Array; + blockers: Array; + notices: Array; +}; + +export type TicketDetail = { + id: string; + title: string; + state: string; + priority: string; + created_at: string | null; + updated_at: string | null; + queued_by: string | null; + queued_at: string | null; + assignee: string | null; + repository_id: string | null; + ref_selector: string | null; + risk_flags: Array; + body: string; + body_truncated: boolean; + event_count: number; + events: Array; + artifact_count: number; + artifacts: Array; + relations: TicketRelationView; + resolution: string | null; + record_source: string; +}; diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index c377bc91..34fe0f72 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -114,7 +114,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () assert( workersPage.includes("workerConsoleHref(worker, data.workspaceId)") && workersPage.includes('') && - workersPage.includes("workerDisplayName = worker.display_name || worker.label") && + workersPage.includes( + "workerDisplayName = worker.display_name || worker.label", + ) && workersPage.includes("worker {worker.worker_id}") && workersPage.includes("Delete ${workerDisplayName}"), "dedicated Workers page should expose a table, console link target, and icon actions per Worker", @@ -136,7 +138,7 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () ); }); -Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async () => { +Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", async () => { const ticketsNav = await Deno.readTextFile( new URL("../sidebar/TicketsNavSection.svelte", import.meta.url), ); @@ -164,6 +166,24 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async import.meta.url, ), ); + const ticketPanelModel = await Deno.readTextFile( + new URL("../tickets/ticket-panel.ts", import.meta.url), + ); + const generatedTicketApi = await Deno.readTextFile( + new URL("../../generated/ticket-api.ts", import.meta.url), + ); + const repositoryLoad = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.ts", + import.meta.url, + ), + ); + const repositoryPage = await Deno.readTextFile( + new URL( + "./../../../routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte", + import.meta.url, + ), + ); assert( ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") && @@ -171,26 +191,41 @@ Deno.test("workspace Tickets surface uses read-only Backend Ticket APIs", async "Tickets sidebar section should link to the workspace Tickets surface", ); assert( - ticketsLoad.includes( - '`${workspaceApiPath(params.workspaceId, "/tickets")}?limit=1000`', - ) && - ticketsPage.includes("Notion-style filtering and sorting") && - ticketsPage.includes("toggleSort('updated_at')") && - ticketsPage.includes("bind:value={visibilityFilter}") && - ticketsPage.includes("sortKey = $state('panel')") && - ticketsPage.includes("workspace_action_priority") && - ticketsPage.includes("bind:value={stateFilter}") && - ticketsPage.includes( - "workspaceRoute(data.workspaceId, `/tickets/${ticket.id}`)", - ), - "Tickets list should read the workspace-scoped Ticket API and expose sortable/filterable table links", + ticketsLoad.includes("?limit=1000") && + ticketsPage.includes("data.tickets.data?.items") && + ticketsPage.includes("ticketLanes(tickets)") && + ticketsPage.includes('class="ticket-kanban"') && + ticketsPage.includes("lane.tickets"), + "Tickets list should load every workflow state and render a workspace Kanban board", ); assert( - ticketDetailLoad.includes("`/tickets/${encodeURIComponent(ticketId)}`") && - ticketDetailPage.includes("event_count") && - ticketDetailPage.includes("artifact_count") && - ticketDetailPage.includes("
{data.ticket.data.body"),
-    "Ticket detail should read one Ticket record and expose body plus metadata without mutation controls",
+    ticketPanelModel.includes('label: "Ready + Planning"') &&
+      ticketPanelModel.includes('label: "In progress + Queued"') &&
+      ticketPanelModel.includes('label: "Done + Closed"') &&
+      ticketPanelModel.includes("updatedAt(right) - updatedAt(left)"),
+    "Ticket Kanban should combine related states and sort state priority before recency",
+  );
+  assert(
+    generatedTicketApi.includes("Generated from yoi-workspace-server") &&
+      generatedTicketApi.includes("export type TicketListResponse") &&
+      generatedTicketApi.includes("items: Array"),
+    "Ticket response types should come from the generated Server contract",
+  );
+  assert(
+    !repositoryLoad.includes("/tickets") &&
+      !repositoryPage.includes("Repository Tickets") &&
+      !repositoryPage.includes("RepositoryTicketKanban"),
+    "Repository detail should not keep a second Repository Tickets surface",
+  );
+  assert(
+    ticketDetailLoad.includes("/repositories") &&
+      ticketDetailPage.includes('mutate("state", "/state"') &&
+      ticketDetailPage.includes('mutate("queue", "/queue"') &&
+      ticketDetailPage.includes('mutate("review", "/review"') &&
+      ticketDetailPage.includes('mutate("close", "/close"') &&
+      ticketDetailPage.includes("ticketWorkerLaunchHref") &&
+      ticketDetailPage.includes("ticket.relations.outgoing"),
+    "Ticket detail should expose typed lifecycle actions, relations, target selection, and role Worker launch",
   );
 });
 
@@ -199,7 +234,10 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
     new URL("../sidebar/MemoryNavSection.svelte", import.meta.url),
   );
   const memoryDocumentLoad = await Deno.readTextFile(
-    new URL("./../../../routes/w/[workspaceId]/memory/+page.ts", import.meta.url),
+    new URL(
+      "./../../../routes/w/[workspaceId]/memory/+page.ts",
+      import.meta.url,
+    ),
   );
   const memoryDocumentPage = await Deno.readTextFile(
     new URL(
diff --git a/web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte b/web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte
deleted file mode 100644
index 1a8b66e5..00000000
--- a/web/workspace/src/lib/workspace/pages/RepositoryTicketKanban.svelte
+++ /dev/null
@@ -1,283 +0,0 @@
-
-
-
- {#each groups as group (group.key)} -
-
-
-

{group.label}

-

{group.states.join(' + ')}

-
- {group.items.length} -
- - {#if group.items.length === 0} -

No tickets.

- {:else} -
onGroupScroll(group, event)} - > -
    - {#each visibleTickets(group) as ticket (ticket.id)} -
  • -
    - {ticket.title} - {ticket.state} -
    - {ticket.id} · updated {formatDate(ticket.updated_at)} -
  • - {/each} -
- {#if hasMore(group)} -

Showing {visibleCount(group.key)} of {group.items.length}; scroll for more.

- {/if} -
- {/if} -
- {/each} -
- - diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index f25c2c1b..5a2834c5 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -390,62 +390,17 @@ export type MemoryStagingListResponse = { items: MemoryStagingEntry[]; }; -export type TicketSummary = { - id: string; - title: string; - state: string; - priority?: string | null; - updated_at?: string | null; - queued_by?: string | null; - queued_at?: string | null; - workspace_action_priority?: - | "ready_for_queue" - | "active_work" - | "background" - | null; - record_source?: string; -}; - -export type TicketListResponse = { - workspace_id: string; - limit: number; - items: TicketSummary[]; - invalid_records: InvalidProjectRecord[]; - record_authority: string; -}; - -export type TicketDetail = { - id: string; - title: string; - state: string; - priority?: string | null; - created_at?: string | null; - updated_at?: string | null; - queued_by?: string | null; - queued_at?: string | null; - risk_flags: string[]; - body: string; - body_truncated: boolean; - event_count: number; - artifact_count: number; - record_source: string; -}; - -export type TicketKanbanColumn = { - state: string; - items: TicketSummary[]; -}; - -export type RepositoryTicketsResponse = { - workspace_id: string; - repository_id: string; - limit: number; - columns: TicketKanbanColumn[]; - invalid_records: InvalidProjectRecord[]; - record_authority: string; - source: string; - diagnostics: Diagnostic[]; -}; +export type { + DerivedTicketRelation, + TicketDetail, + TicketEventDetail, + TicketListResponse, + TicketRelation, + TicketRelationBlocker, + TicketRelationNotice, + TicketRelationView, + TicketSummary, +} from "$lib/generated/ticket-api"; export type ObjectiveSummary = { id: string; diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts index 93750e96..1882934a 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test.ts @@ -86,6 +86,28 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi assertEquals(form.working_directory_selector, "HEAD"); }); +Deno.test("defaultWorkerLaunchForm preserves an available Ticket role profile", () => { + const reviewerOptions = { + ...options, + profiles: [ + ...options.profiles, + { id: "builtin:reviewer", label: "Reviewer", description: "review" }, + ], + }; + const form = defaultWorkerLaunchForm(reviewerOptions, { + runtime_id: "", + display_name: "Review worker", + profile: "builtin:reviewer", + initial_text: "Review the ticket.", + working_directory_id: "", + working_directory_repository_id: "repo", + working_directory_selector: "HEAD", + relative_cwd: "", + }); + + assertEquals(form.profile, "builtin:reviewer"); +}); + Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => { const form = defaultWorkerLaunchForm( { @@ -116,8 +138,45 @@ Deno.test("defaultWorkerLaunchForm skips occupied working directories", () => { ); assertEquals(form.working_directory_id, ""); - assertEquals(form.working_directory_repository_id, "repo"); - assertEquals(form.working_directory_selector, "HEAD"); +}); + +Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () => { + const form = defaultWorkerLaunchForm( + { + ...options, + repositories: [ + ...options.repositories, + { + id: "ticket-repo", + display_name: "Ticket repo", + default_selector: "main", + }, + ], + working_directories: [ + options.working_directories[0], + { + ...options.working_directories[0], + working_directory_id: "ticket-workdir", + repository_id: "ticket-repo", + requested_selector: "work/ticket", + }, + ], + }, + { + runtime_id: "", + display_name: "Ticket worker", + profile: "builtin:coder", + initial_text: "Work on a ticket.", + working_directory_id: "", + working_directory_repository_id: "ticket-repo", + working_directory_selector: "work/ticket", + relative_cwd: "", + }, + ); + + assertEquals(form.working_directory_id, "ticket-workdir"); + assertEquals(form.working_directory_repository_id, "ticket-repo"); + assertEquals(form.working_directory_selector, "work/ticket"); }); Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relative cwd only", () => { diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts index 3049d9d8..974a64ac 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-launch.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.ts @@ -32,9 +32,12 @@ export function defaultWorkerLaunchForm( ) ?? options?.runtimes.find((runtime) => runtime.can_spawn_worker) ?? options?.runtimes[0]; - const preferredProfile = + const coderProfile = options?.profiles.find((candidate) => candidate.id === "builtin:coder") ?? options?.profiles[0]; + const preferredProfile = + options?.profiles.find((candidate) => candidate.id === current.profile) ?? + coderProfile; const availableWorkingDirectories = options?.working_directories.filter((directory) => directory.status === "active" && @@ -51,7 +54,17 @@ export function defaultWorkerLaunchForm( selectedRuntime?.working_directory_required === false; const preferredWorkingDirectory = workdirlessRuntime ? undefined - : availableWorkingDirectories[0]; + : availableWorkingDirectories.find((directory) => + Boolean(current.working_directory_repository_id) && + directory.repository_id === current.working_directory_repository_id && + (!current.working_directory_selector || + directory.requested_selector === current.working_directory_selector) + ) ?? availableWorkingDirectories.find((directory) => + Boolean(current.working_directory_repository_id) && + directory.repository_id === current.working_directory_repository_id + ) ?? (current.working_directory_repository_id + ? undefined + : availableWorkingDirectories[0]); const preferredRepository = options?.repositories.find((repository) => repository.id === current.working_directory_repository_id diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css new file mode 100644 index 00000000..10797bfa --- /dev/null +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -0,0 +1,368 @@ +@layer reset, tokens, base, layout, components; +@layer components { + .workspace-primary-button, + .workspace-secondary-button, + .workspace-danger-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.35rem; + border-radius: 0.55rem; + padding: 0.5rem 0.8rem; + font: inherit; + font-size: 0.82rem; + font-weight: 700; + text-decoration: none; + cursor: pointer; + } + .workspace-primary-button { + border: 1px solid var(--accent); + background: var(--accent); + color: var(--bg); + } + .workspace-secondary-button { + border: 1px solid var(--line); + background: var(--bg-raised); + color: var(--text-strong); + } + .workspace-danger-button { + border: 1px solid #a63f3f; + background: #a63f3f; + color: #fff; + } + .workspace-primary-button:disabled, + .workspace-secondary-button:disabled, + .workspace-danger-button:disabled { + cursor: not-allowed; + opacity: 0.5; + } + .workspace-back-link { + color: var(--text-muted); + font-size: 0.82rem; + text-decoration: none; + } + .workspace-callout { + border: 1px solid var(--line); + border-radius: 0.6rem; + padding: 0.7rem 0.85rem; + } + .workspace-callout.is-error { + border-color: #a63f3f; + color: #d66; + } + .workspace-empty-copy { + margin: 0; + color: var(--text-muted); + } + .ticket-panel-page { + max-width: none; + } + .ticket-panel-header, + .ticket-detail-header, + .ticket-detail-kicker, + .ticket-section-heading, + .ticket-control-card > header, + .ticket-lane-header, + .ticket-lane-header > div, + .ticket-card-meta, + .ticket-role-actions { + display: flex; + align-items: center; + } + .ticket-panel-header, .ticket-detail-header { + justify-content: space-between; + gap: var(--space-4); + } + .ticket-panel-summary { + display: grid; + justify-items: end; + color: var(--text-muted); + } + .ticket-panel-summary strong { + color: var(--text-strong); + font-size: 1.8rem; + line-height: 1; + } + .ticket-kanban { + display: grid; + grid-template-columns: repeat(3, minmax(18rem, 1fr)); + gap: var(--space-3); + overflow-x: auto; + padding-bottom: var(--space-3); + } + .ticket-lane { + min-height: 24rem; + border: 1px solid var(--line); + border-radius: 0.8rem; + background: color-mix(in srgb, var(--bg-raised) 55%, transparent); + } + .ticket-lane-header { + justify-content: space-between; + padding: 0.75rem; + border-bottom: 1px solid var(--line); + } + .ticket-lane-header > div { + gap: 0.45rem; + } + .ticket-lane-header h2 { + margin: 0; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + } + .ticket-state-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--text-muted); + } + .ticket-lane[data-state="ready-planning"] .ticket-state-dot { + background: #43a66d; + } + .ticket-lane[data-state="inprogress-queued"] .ticket-state-dot { + background: #d09b37; + } + .ticket-lane[data-state="done-closed"] .ticket-state-dot { + background: var(--accent); + } + .ticket-lane-count { + color: var(--text-muted); + font-size: 0.75rem; + } + .ticket-lane-cards { + display: grid; + align-content: start; + gap: 0.55rem; + padding: 0.6rem; + } + .ticket-card { + display: grid; + gap: 0.55rem; + border: 1px solid var(--line); + border-radius: 0.65rem; + background: var(--bg-raised); + color: var(--text-strong); + padding: 0.75rem; + text-decoration: none; + } + .ticket-card:hover { + border-color: var(--accent); + } + .ticket-card-id { + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.68rem; + text-overflow: ellipsis; + } + .ticket-card strong { + font-size: 0.85rem; + line-height: 1.35; + } + .ticket-card-meta { + justify-content: space-between; + color: var(--text-muted); + font-size: 0.68rem; + } + .ticket-risk-list { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } + .ticket-risk-list span { + border: 1px solid #a97531; + border-radius: 999px; + color: #d3a55d; + padding: 0.1rem 0.35rem; + font-size: 0.62rem; + } + .ticket-lane-empty { + padding: 1.2rem 0.4rem; + color: var(--text-muted); + font-size: 0.75rem; + text-align: center; + } + .ticket-detail-page { + gap: var(--space-4); + max-width: 88rem; + } + .ticket-detail-header h1 { + margin: 0.45rem 0 0.25rem; + color: var(--text-strong); + font-size: clamp(1.5rem, 3vw, 2.4rem); + } + .ticket-detail-header p { + margin: 0; + color: var(--text-muted); + font-size: 0.78rem; + } + .ticket-detail-kicker { + gap: 0.6rem; + } + .ticket-detail-kicker code { + color: var(--text-muted); + font-size: 0.72rem; + } + .workspace-status-pill { + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.15rem 0.45rem; + color: var(--text-muted); + font-size: 0.68rem; + text-transform: uppercase; + } + .ticket-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(18rem, 24rem); + gap: var(--space-5); + align-items: start; + } + .ticket-detail-main, .ticket-control-rail { + display: grid; + gap: var(--space-4); + } + .ticket-detail-section, .ticket-control-card, .ticket-editor { + border: 1px solid var(--line); + border-radius: 0.8rem; + background: var(--bg-raised); + padding: var(--space-4); + } + .ticket-section-heading, .ticket-control-card > header { + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); + } + .ticket-section-heading h2, .ticket-control-card h2 { + margin: 0; + color: var(--text-strong); + font-size: 0.9rem; + } + .ticket-section-heading span, .ticket-control-card header span { + color: var(--text-muted); + font-size: 0.7rem; + } + .ticket-editor, .ticket-control-form { + display: grid; + gap: 0.75rem; + } + .ticket-editor label, .ticket-control-form label { + display: grid; + gap: 0.3rem; + color: var(--text-muted); + font-size: 0.72rem; + } + .ticket-editor input, + .ticket-editor textarea, + .ticket-control-form input, + .ticket-control-form select, + .ticket-control-form textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 0.5rem; + background: var(--bg); + color: var(--text-strong); + padding: 0.55rem; + font: inherit; + } + .ticket-worker-card p { + color: var(--text-muted); + font-size: 0.78rem; + line-height: 1.5; + } + .ticket-role-actions { + gap: 0.55rem; + } + .ticket-role-actions > * { + flex: 1; + } + .ticket-queue-button { + width: 100%; + margin-top: 0.75rem; + } + details.ticket-control-card summary { + cursor: pointer; + color: var(--text-strong); + font-size: 0.85rem; + font-weight: 700; + } + details.ticket-control-card[open] summary { + margin-bottom: var(--space-3); + } + .ticket-close-card { + border-color: color-mix(in srgb, #a63f3f 55%, var(--line)); + } + .ticket-relations-list, .ticket-blocker-list { + display: grid; + gap: 0.5rem; + } + .ticket-blocker-list { + margin-bottom: 0.75rem; + } + .ticket-relations-list a, .ticket-blocker-list a { + display: grid; + gap: 0.18rem; + border: 1px solid var(--line); + border-radius: 0.55rem; + color: var(--text-strong); + padding: 0.6rem; + text-decoration: none; + } + .ticket-blocker-list a { + border-color: #a97531; + } + .ticket-relations-list span, + .ticket-relations-list small, + .ticket-blocker-list span { + color: var(--text-muted); + font-size: 0.68rem; + } + .ticket-timeline { + display: grid; + } + .ticket-timeline article { + display: grid; + grid-template-columns: 0.65rem minmax(0, 1fr); + gap: 0.75rem; + min-height: 4rem; + } + .ticket-timeline-marker { + position: relative; + margin-top: 0.35rem; + width: 0.55rem; + height: 0.55rem; + border: 2px solid var(--accent); + border-radius: 50%; + } + .ticket-timeline-marker::after { + position: absolute; + top: 0.55rem; + bottom: -3.3rem; + left: 0.15rem; + width: 1px; + background: var(--line); + content: ""; + } + .ticket-timeline article:last-child .ticket-timeline-marker::after { + display: none; + } + .ticket-timeline header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + } + .ticket-timeline time, .ticket-event-author { + color: var(--text-muted); + font-size: 0.68rem; + } + .ticket-event-author { + margin: 0.2rem 0; + } + @media (max-width: 64rem) { + .ticket-detail-grid { + grid-template-columns: 1fr; + } + .ticket-control-rail { + grid-row: 1; + } + } +} diff --git a/web/workspace/src/lib/workspace/styles/workers.css b/web/workspace/src/lib/workspace/styles/workers.css index 6b2e08ba..081330d2 100644 --- a/web/workspace/src/lib/workspace/styles/workers.css +++ b/web/workspace/src/lib/workspace/styles/workers.css @@ -92,6 +92,30 @@ margin-top: var(--space-2); color: var(--text-muted); } + .worker-ticket-context { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + max-width: 48rem; + border: 1px solid var(--accent); + border-radius: 0.7rem; + background: var(--bg-raised); + padding: var(--space-3); + } + .worker-ticket-context > div { + display: grid; + gap: var(--space-1); + } + .worker-ticket-context span, + .worker-ticket-context code { + color: var(--text-muted); + font-size: 0.72rem; + } + .worker-ticket-context a { + color: var(--accent); + font-size: 0.78rem; + } .worker-launch-form { display: grid; gap: var(--space-5); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts new file mode 100644 index 00000000..873f6f79 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -0,0 +1,99 @@ +import { + ticketLanes, + ticketWorkerLaunchHref, + ticketWorkerMessage, +} from "./ticket-panel.ts"; +import type { + TicketDetail, + TicketSummary, +} from "../../generated/ticket-api.ts"; + +declare const Deno: { + test(name: string, fn: () => Promise | void): void; +}; + +function assertEquals(actual: T, expected: T): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => { + const tickets = [ + { + id: "planning", + state: "planning", + title: "Planning", + updated_at: "2026-07-30T12:00:00Z", + }, + { + id: "ready-old", + state: "ready", + title: "Ready old", + updated_at: "2026-07-29T12:00:00Z", + }, + { + id: "ready-new", + state: "ready", + title: "Ready new", + updated_at: "2026-07-30T12:00:00Z", + }, + { id: "queued", state: "queued", title: "Queued", updated_at: null }, + { + id: "inprogress", + state: "inprogress", + title: "In progress", + updated_at: null, + }, + { id: "closed", state: "closed", title: "Closed", updated_at: null }, + { id: "done", state: "done", title: "Done", updated_at: null }, + ] as TicketSummary[]; + + const lanes = ticketLanes(tickets); + assertEquals(lanes.map((lane) => lane.id), [ + "ready-planning", + "inprogress-queued", + "done-closed", + ]); + assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [ + "ready-new", + "ready-old", + "planning", + ]); + assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [ + "inprogress", + "queued", + ]); + assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [ + "done", + "closed", + ]); +}); + +Deno.test("ticket worker launch uses the common Worker route and bounded Ticket context", () => { + const ticket = { + id: "00001KYRRDVH9", + title: "Ticket panel API", + repository_id: "main repo", + ref_selector: "work/ticket", + } as TicketDetail; + + assertEquals( + ticketWorkerMessage(ticket.id, "coder"), + "Work on Ticket 00001KYRRDVH9 as its coder.", + ); + + const href = ticketWorkerLaunchHref("workspace one", ticket, "reviewer"); + const url = new URL(href, "https://example.test"); + assertEquals(url.pathname, "/w/workspace%20one/workers/new"); + assertEquals(url.searchParams.get("ticketId"), ticket.id); + assertEquals(url.searchParams.get("ticketRole"), "reviewer"); + assertEquals(url.searchParams.get("repositoryId"), "main repo"); + assertEquals(url.searchParams.get("refSelector"), "work/ticket"); + assertEquals( + url.searchParams.get("initialInput"), + "Work on Ticket 00001KYRRDVH9 as its reviewer.", + ); +}); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts new file mode 100644 index 00000000..bcfac7b8 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -0,0 +1,111 @@ +import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api"; + +export const TICKET_STATES = [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", +] as const; + +export type TicketState = (typeof TICKET_STATES)[number]; +export type TicketWorkerRole = "coder" | "reviewer"; + +const LANE_DEFINITIONS = [ + { + id: "ready-planning", + label: "Ready + Planning", + states: ["ready", "planning"], + }, + { + id: "inprogress-queued", + label: "In progress + Queued", + states: ["inprogress", "queued"], + }, + { + id: "done-closed", + label: "Done + Closed", + states: ["done", "closed"], + }, +] as const; + +const STATE_SORT_ORDER = new Map([ + ["ready", 0], + ["planning", 1], + ["inprogress", 0], + ["queued", 1], + ["done", 0], + ["closed", 1], +]); + +export type TicketLane = { + id: string; + label: string; + states: readonly TicketState[]; + tickets: TicketSummary[]; +}; + +function updatedAt(ticket: TicketSummary): number { + if (!ticket.updated_at) return 0; + const parsed = Date.parse(ticket.updated_at); + return Number.isNaN(parsed) ? 0 : parsed; +} + +export function sortTickets(tickets: TicketSummary[]): TicketSummary[] { + return [...tickets].sort((left, right) => { + const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) - + (STATE_SORT_ORDER.get(right.state) ?? 99); + if (stateDelta !== 0) return stateDelta; + const updatedDelta = updatedAt(right) - updatedAt(left); + if (updatedDelta !== 0) return updatedDelta; + return left.id.localeCompare(right.id); + }); +} + +export function ticketLanes(tickets: TicketSummary[]): TicketLane[] { + return LANE_DEFINITIONS.map((definition) => ({ + ...definition, + tickets: sortTickets( + tickets.filter((ticket) => + (definition.states as readonly string[]).includes(ticket.state) + ), + ), + })); +} + +export function ticketWorkerMessage( + ticketId: string, + role: TicketWorkerRole, +): string { + return `Work on Ticket ${ticketId} as its ${role}.`; +} + +export function ticketWorkerLaunchHref( + workspaceId: string, + ticket: Pick< + TicketDetail, + "id" | "title" | "repository_id" | "ref_selector" + >, + role: TicketWorkerRole, +): string { + const params = new URLSearchParams({ + ticketId: ticket.id, + ticketTitle: ticket.title, + ticketRole: role, + initialInput: ticketWorkerMessage(ticket.id, role), + }); + if (ticket.repository_id) { + params.set("repositoryId", ticket.repository_id); + } + if (ticket.ref_selector) { + params.set("refSelector", ticket.ref_selector); + } + return `/w/${ + encodeURIComponent(workspaceId) + }/workers/new?${params.toString()}`; +} + +export function relationLabel(kind: string): string { + return kind.replaceAll("_", " "); +} diff --git a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte index 05fb45a5..18590574 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+layout.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+layout.svelte @@ -3,6 +3,7 @@ import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte'; import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte'; import '$lib/workspace/styles/workspace-pages.css'; + import '$lib/workspace/styles/tickets.css'; import '$lib/workspace/styles/workers.css'; import type { LayoutProps } from './$types'; diff --git a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte index a71194cd..ed8a2205 100644 --- a/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/repositories/[repositoryId]/+page.svelte @@ -1,6 +1,5 @@ - - Tickets · Yoi Workspace - - +Tickets · Yoi -
-
+
+
-

Workspace records

-

Tickets

+

Delivery

+

Tickets

+

+ Plan, route, review, and close work without leaving the workspace. +

- {#if data.tickets.data} - {visibleTickets.length} / {data.tickets.data.items.length} ticket{data.tickets.data.items.length === 1 ? '' : 's'} - {/if} -
+
+ {tickets.length} + tickets +
+ -

- Tickets are read from the typed Ticket backend. This read-only table supports Notion-style filtering and sorting for browsing imported workspace Tickets. -

+
+ {#each lanes as lane (lane.id)} +
+
+
+ +

{lane.label}

+
+ {lane.tickets.length} +
- {#if data.tickets.data} - {#if data.tickets.data.items.length === 0} -

No Ticket records are present.

- {:else} -
- - - - - - - -
- -
-
- - - - - - - - - - - - {#each visibleTickets as ticket (ticket.id)} - - - - - - - - - {/each} - -
- {ticket.title} - {ticket.record_source ?? 'ticket backend'} - {ticket.state}{ticket.priority || '—'}{ticket.updated_at ? formatDate(ticket.updated_at) : 'unknown'} - {#if ticket.queued_at} - {formatDate(ticket.queued_at)} - {#if ticket.queued_by} - {ticket.queued_by} - {/if} - {:else} - - {/if} - {ticket.id}
- - - {#if visibleTickets.length === 0} -

No tickets match the current filters.

- {/if} - {/if} - - {#if data.tickets.data.invalid_records.length > 0} -

{data.tickets.data.invalid_records.length} invalid Ticket record(s) hidden.

- {/if} - {:else if data.tickets.error} -

{data.tickets.error}

- {:else} -

Waiting for /api/w/{data.workspaceId}/tickets

- {/if} - - - +
+ {#each lane.tickets as ticket (ticket.id)} + + {ticket.id} + {ticket.title} +
+ {ticket.state} · {ticket.priority} + +
+
+ {:else} +
No tickets
+ {/each} +
+ + {/each} + + diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index bbe3c125..bcaef620 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -1,82 +1,362 @@ - - {data.ticket.data?.title ?? data.ticketId} · Tickets · Yoi Workspace - - +{ticket.title} · Yoi -
- +
+ + ← Ticket board + - {#if data.ticket.data} -
-
-

{data.ticket.data.id}

-

{data.ticket.data.title}

+
+
+
+ {ticket.state} + {ticket.id}
- {data.ticket.data.state} +

{ticket.title}

+

Updated {prettyDate(ticket.updated_at)}

+ +
-
-
-
Priority
-
{data.ticket.data.priority ?? 'unspecified'}
-
-
-
Updated
-
{data.ticket.data.updated_at ? formatDate(data.ticket.data.updated_at) : 'unknown'}
-
-
-
Created
-
{data.ticket.data.created_at ? formatDate(data.ticket.data.created_at) : 'unknown'}
-
-
-
Events
-
{data.ticket.data.event_count}
-
-
-
Artifacts
-
{data.ticket.data.artifact_count}
-
-
-
Source
-
{data.ticket.data.record_source}
-
- {#if data.ticket.data.queued_at || data.ticket.data.queued_by} -
-
Queued
-
- {data.ticket.data.queued_at ? formatDate(data.ticket.data.queued_at) : 'queued'}{data.ticket.data.queued_by ? ` by ${data.ticket.data.queued_by}` : ''} -
-
- {/if} -
- - {#if data.ticket.data.risk_flags.length > 0} -
- {#each data.ticket.data.risk_flags as flag} - {flag} - {/each} -
- {/if} - -
-
-

Body

- {#if data.ticket.data.body_truncated} - truncated - {/if} -
-
{data.ticket.data.body || 'No body text is available.'}
-
- {:else if data.ticket.error} -

{data.ticket.error}

- {:else} -

Waiting for /api/w/{data.workspaceId}/tickets/{data.ticketId}

+ {#if errorMessage} + {/if} -
+ + {#if editing} +
+ + + +
+ {/if} + +
+
+
+

Intent

+ {#if ticket.body} + + {:else} +

No body has been recorded.

+ {/if} +
+ +
+
+

Relations

+ {ticket.relations.outgoing.length + ticket.relations.incoming.length} +
+ {#if ticket.relations.blockers.length > 0} + + {/if} +
+ {#each ticket.relations.outgoing as relation} + + {relationLabel(relation.kind)} + {relation.target} + {#if relation.note}{relation.note}{/if} + + {/each} + {#each ticket.relations.incoming as relation} + + {relationLabel(relation.inverse_kind)} + {relation.source_ticket} + {#if relation.note}{relation.note}{/if} + + {/each} + {#if ticket.relations.outgoing.length === 0 && ticket.relations.incoming.length === 0} +

No Ticket relations.

+ {/if} +
+
+ +
+
+

Timeline

{ticket.event_count} +
+
+ {#each ticket.events as event (event.sequence)} +
+
+
+
+ {event.heading ?? eventTitle(event.kind)} + +
+ {#if event.author}

{event.author}

{/if} + {#if event.from || event.to}

{event.from ?? "—"} → {event.to ?? "—"}

{/if} + {#if event.reason}

{event.reason}

{/if} + {#if event.body}{/if} +
+
+ {:else} +

No timeline events.

+ {/each} +
+
+
+ + +
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts index 6277d9a7..b2075538 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.ts @@ -1,20 +1,29 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import type { TicketDetail } from "$lib/workspace/sidebar/types"; +import type { + RepositoryListResponse, + TicketDetail, +} from "$lib/workspace/sidebar/types"; import type { PageLoad } from "./$types"; export const load = (async ({ fetch, params }) => { - const ticketId = params.ticketId; - const ticket = await loadJson( - fetch, - workspaceApiPath( - params.workspaceId, - `/tickets/${encodeURIComponent(ticketId)}`, + const [ticket, repositories] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath( + params.workspaceId, + `/tickets/${encodeURIComponent(params.ticketId)}`, + ), ), - ); + loadJson( + fetch, + workspaceApiPath(params.workspaceId, "/repositories"), + ), + ]); return { workspaceId: params.workspaceId, - ticketId, + ticketId: params.ticketId, ticket, + repositories, }; }) satisfies PageLoad; diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 2c2a4b87..3bf4e270 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -1,5 +1,6 @@