feat: separate Ticket case state and immutable closure judgments
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
|
||||
|
||||
export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
|
||||
|
||||
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
|
||||
|
||||
export type BrowserQueueTicketRequest = { [key: string]: never; };
|
||||
|
||||
export type BrowserTicketState = "open" | "closed";
|
||||
|
||||
export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; };
|
||||
|
||||
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
|
||||
|
||||
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
|
||||
|
||||
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
|
||||
|
||||
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
|
||||
|
||||
export type ConflictResolution = "none" | "clean" | "conflicts_resolved";
|
||||
@@ -29,6 +25,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExtensibleTicketStatus = "Open" | "Closed" | { Other: string; };
|
||||
|
||||
export type FindingSeverity = "blocker" | "major" | "minor" | "note";
|
||||
|
||||
export type InvalidProjectRecord = { label: string; reason: string; };
|
||||
@@ -57,7 +55,7 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string
|
||||
|
||||
export type MergeStrategy = "fast_forward" | "merge";
|
||||
|
||||
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; };
|
||||
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; };
|
||||
|
||||
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; };
|
||||
|
||||
@@ -91,7 +89,17 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri
|
||||
|
||||
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
||||
|
||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
|
||||
|
||||
export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; };
|
||||
|
||||
export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; };
|
||||
|
||||
export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; };
|
||||
|
||||
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
|
||||
|
||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||
|
||||
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
||||
|
||||
@@ -111,7 +119,9 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet
|
||||
|
||||
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
|
||||
|
||||
export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; };
|
||||
export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; };
|
||||
|
||||
export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; title: string; updated_at?: string | null; workspace_action_priority: string; };
|
||||
|
||||
export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
|
||||
|
||||
@@ -133,12 +143,14 @@ export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: st
|
||||
|
||||
export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; };
|
||||
|
||||
export type TicketState = "open" | "closed";
|
||||
|
||||
export type TicketStatus = "Open" | "Closed";
|
||||
|
||||
export type TicketSummary = { id: string; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; };
|
||||
|
||||
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
|
||||
|
||||
export type TicketTargetAccess = "read_only" | "read_write";
|
||||
|
||||
export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; };
|
||||
|
||||
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -48,7 +48,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () =>
|
||||
id: "ticket-id",
|
||||
resource_key: "T-1",
|
||||
title: "Ticket",
|
||||
state: "ready",
|
||||
state: "open",
|
||||
priority: "P2",
|
||||
updated_at: null,
|
||||
queued_by: null,
|
||||
@@ -105,16 +105,23 @@ Deno.test("Ticket Browser accepts generic Worker eligibility and assignments", (
|
||||
assigned_at: "2026-10-06T00:00:00Z",
|
||||
assigned_by: "user-1",
|
||||
assignment_id: "assignment-1",
|
||||
principal: { kind: "worker", runtime_id: "runtime-1", worker_id: "worker-1" },
|
||||
principal: {
|
||||
kind: "worker",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
},
|
||||
role: "worker",
|
||||
ticket_id: "ticket-id",
|
||||
workspace_id: "workspace-a",
|
||||
};
|
||||
assertEquals(parseTicketRoleAssignmentMutationResponse({
|
||||
assertEquals(
|
||||
parseTicketRoleAssignmentMutationResponse({
|
||||
assignment,
|
||||
ticket_id: "ticket-id",
|
||||
workspace_id: "workspace-a",
|
||||
}).assignment,
|
||||
assignment,
|
||||
ticket_id: "ticket-id",
|
||||
workspace_id: "workspace-a",
|
||||
}).assignment, assignment);
|
||||
);
|
||||
});
|
||||
|
||||
function ticketDetailFixture() {
|
||||
@@ -131,6 +138,10 @@ function ticketDetailFixture() {
|
||||
artifacts: [],
|
||||
assignment_diagnostics: [],
|
||||
assignments: [],
|
||||
closure_history: [],
|
||||
closure_operation_key: null,
|
||||
current_closure: null,
|
||||
legacy_handoff: null,
|
||||
body: "Body",
|
||||
body_truncated: false,
|
||||
current_worker: null,
|
||||
@@ -164,7 +175,7 @@ function ticketDetailFixture() {
|
||||
resolution: null,
|
||||
resource_key: "T-1",
|
||||
risk_flags: [],
|
||||
state: "planning",
|
||||
state: "open",
|
||||
targets: [
|
||||
{
|
||||
repository_key: "docs",
|
||||
@@ -342,11 +353,11 @@ Deno.test("Merge Request summaries require a nullable bounded integration eviden
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => {
|
||||
Deno.test("closed Ticket state is preserved when current requirement evidence is incomplete", () => {
|
||||
const fixture = ticketDetailFixture();
|
||||
const parsed = parseTicketDetail({
|
||||
...fixture,
|
||||
state: "done",
|
||||
state: "closed",
|
||||
evidence: { ...fixture.evidence, missing: ["review_after_rescope"] },
|
||||
merge_requests: [{
|
||||
...mergeRequestSummary,
|
||||
@@ -354,7 +365,7 @@ Deno.test("done Ticket state is preserved when current requirement evidence is i
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}],
|
||||
});
|
||||
assertEquals(parsed.state, "done");
|
||||
assertEquals(parsed.state, "closed");
|
||||
assertEquals(parsed.evidence.complete_for_integration, false);
|
||||
assertEquals(parsed.merge_requests[0]?.review_status, "approved");
|
||||
});
|
||||
@@ -440,3 +451,135 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
|
||||
"unknown field unexpected",
|
||||
);
|
||||
});
|
||||
|
||||
const closure = {
|
||||
operation_key: "close-1",
|
||||
reason: "not_planned",
|
||||
explanation: "Not planned for this scope.",
|
||||
references: [{ kind: "document", target: "result.md" }],
|
||||
author: "user:alice",
|
||||
at: "2026-10-10T00:00:00Z",
|
||||
};
|
||||
const closureHistory = [
|
||||
{
|
||||
...closure,
|
||||
reason: undefined,
|
||||
previous_closure_operation_key: null,
|
||||
action: { action: "close", reason: "not_planned" },
|
||||
},
|
||||
{
|
||||
...closure,
|
||||
reason: undefined,
|
||||
operation_key: "correct-1",
|
||||
previous_closure_operation_key: "close-1",
|
||||
action: { action: "correct_closure", reason: { other: "Deferred" } },
|
||||
},
|
||||
{
|
||||
...closure,
|
||||
reason: undefined,
|
||||
operation_key: "reopen-1",
|
||||
previous_closure_operation_key: "correct-1",
|
||||
action: { action: "reopen" },
|
||||
},
|
||||
].map(({ reason: _, ...entry }) => ({
|
||||
operation_key: entry.operation_key,
|
||||
previous_closure_operation_key: entry.previous_closure_operation_key,
|
||||
action: entry.action,
|
||||
explanation: entry.explanation,
|
||||
references: entry.references,
|
||||
author: entry.author,
|
||||
at: entry.at,
|
||||
}));
|
||||
const legacyHandoff = {
|
||||
workflow_state: "done",
|
||||
workflow_state_explicit: true,
|
||||
status: "Closed",
|
||||
queued_by: "workspace-web",
|
||||
queued_at: "2026-10-09T00:00:00Z",
|
||||
resolution: "Historical result, not a current case state",
|
||||
};
|
||||
|
||||
Deno.test("case projections retain reasons, actors, references and migration history after reopening", () => {
|
||||
const fixture = {
|
||||
...ticketDetailFixture(),
|
||||
state: "open",
|
||||
closure_operation_key: "correct-1",
|
||||
current_closure: null,
|
||||
closure_history: closureHistory,
|
||||
legacy_handoff: legacyHandoff,
|
||||
};
|
||||
const parsed = parseTicketDetail(fixture);
|
||||
assertEquals(parsed.state, "open");
|
||||
assertEquals(parsed.closure_operation_key, "correct-1");
|
||||
assertEquals(parsed.closure_history, closureHistory);
|
||||
assertEquals(parsed.legacy_handoff, legacyHandoff);
|
||||
assertEquals(
|
||||
parseTicketDetail({ ...fixture, state: "closed", current_closure: closure })
|
||||
.current_closure,
|
||||
closure,
|
||||
);
|
||||
const list = parseTicketListResponse({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 30,
|
||||
page,
|
||||
invalid_records: [],
|
||||
record_authority: "sqlite",
|
||||
items: [{
|
||||
id: "ticket-id",
|
||||
resource_key: "T-1",
|
||||
title: "Ticket",
|
||||
state: "closed",
|
||||
priority: "P2",
|
||||
record_source: "sqlite",
|
||||
workspace_action_priority: "background",
|
||||
closure_operation_key: "close-1",
|
||||
current_closure: closure,
|
||||
legacy_handoff: legacyHandoff,
|
||||
}],
|
||||
});
|
||||
assertEquals(list.items[0].current_closure, closure);
|
||||
assertEquals(list.items[0].legacy_handoff, legacyHandoff);
|
||||
});
|
||||
|
||||
Deno.test("case decoders reject legacy current states and malformed closure judgments", () => {
|
||||
const fixture = ticketDetailFixture();
|
||||
for (const state of ["planning", "ready", "queued", "inprogress", "done"]) {
|
||||
assertThrows(
|
||||
() => parseTicketDetail({ ...fixture, state }),
|
||||
"state is invalid",
|
||||
);
|
||||
}
|
||||
for (const reason of ["done", { other: 1 }, { other: "ok", extra: true }]) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseTicketDetail({
|
||||
...fixture,
|
||||
current_closure: { ...closure, reason },
|
||||
}),
|
||||
"Ticket detail.current_closure.reason",
|
||||
);
|
||||
}
|
||||
for (
|
||||
const action of ["reopen", { action: "close" }, {
|
||||
action: "reopen",
|
||||
reason: "completed",
|
||||
}, { action: "complete", reason: "completed" }]
|
||||
) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseTicketDetail({
|
||||
...fixture,
|
||||
closure_history: [{ ...closureHistory[0], action }],
|
||||
}),
|
||||
"Ticket detail.closure_history[0].action",
|
||||
);
|
||||
}
|
||||
assertThrows(
|
||||
() => parseTicketDetail({ ...fixture, closure_history: {} }),
|
||||
"closure_history must be an array",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseTicketDetail({ ...fixture, closure_operation_key: 1 }),
|
||||
"closure_operation_key must be a string",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BrowserTicketState,
|
||||
InvalidProjectRecord,
|
||||
MergeRequestDetailResponse,
|
||||
MergeRequestLinkedTicketResponse,
|
||||
@@ -20,6 +21,10 @@ import type {
|
||||
TicketActionEligibility,
|
||||
TicketAssignmentPrincipal,
|
||||
TicketAssignmentSummary,
|
||||
TicketCaseAction,
|
||||
TicketCaseHistoryEntry,
|
||||
TicketClosureJudgment,
|
||||
TicketClosureReason,
|
||||
TicketDetail,
|
||||
TicketDetailDerivedRelation,
|
||||
TicketDetailRelation,
|
||||
@@ -29,14 +34,17 @@ import type {
|
||||
TicketEventDetail,
|
||||
TicketEvidenceEvent,
|
||||
TicketEvidenceSummary,
|
||||
TicketLegacyWorkflowSnapshot,
|
||||
TicketListItemSummary,
|
||||
TicketListResponse,
|
||||
TicketMergeRequestSummary,
|
||||
TicketQueueOutcome,
|
||||
TicketRef,
|
||||
TicketReference,
|
||||
TicketRoleAssignmentMutationResponse,
|
||||
TicketRoleAssignmentRecord,
|
||||
TicketRoleAssignmentSummary,
|
||||
TicketSummary,
|
||||
TicketTarget,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
|
||||
@@ -183,12 +191,208 @@ function parseInvalidProjectRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function parseTicketState(value: unknown, label: string): BrowserTicketState {
|
||||
if (value !== "open" && value !== "closed") {
|
||||
throw new Error(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseClosureReason(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketClosureReason {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
["completed", "not_planned", "duplicate", "unknown"].includes(value)
|
||||
) {
|
||||
return value as TicketClosureReason;
|
||||
}
|
||||
const item = object(value, label);
|
||||
exact(item, ["other"], label);
|
||||
return { other: string(item.other, `${label}.other`) };
|
||||
}
|
||||
|
||||
function parseCaseAction(value: unknown, label: string): TicketCaseAction {
|
||||
const item = object(value, label);
|
||||
if (item.action === "reopen") {
|
||||
exact(item, ["action"], label);
|
||||
return { action: "reopen" };
|
||||
}
|
||||
if (item.action !== "close" && item.action !== "correct_closure") {
|
||||
throw new Error(`${label}.action is invalid`);
|
||||
}
|
||||
exact(item, ["action", "reason"], label);
|
||||
return {
|
||||
action: item.action,
|
||||
reason: parseClosureReason(item.reason, `${label}.reason`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReference(value: unknown, label: string): TicketReference {
|
||||
const item = object(value, label);
|
||||
exact(item, ["kind", "target"], label);
|
||||
return {
|
||||
kind: string(item.kind, `${label}.kind`),
|
||||
target: string(item.target, `${label}.target`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseClosure(value: unknown, label: string): TicketClosureJudgment {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"operation_key",
|
||||
"reason",
|
||||
"explanation",
|
||||
"references",
|
||||
"author",
|
||||
"at",
|
||||
], label);
|
||||
return {
|
||||
operation_key: string(item.operation_key, `${label}.operation_key`),
|
||||
reason: parseClosureReason(item.reason, `${label}.reason`),
|
||||
explanation: string(item.explanation, `${label}.explanation`),
|
||||
references: array(item.references, `${label}.references`, parseReference),
|
||||
author: string(item.author, `${label}.author`),
|
||||
at: string(item.at, `${label}.at`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCaseHistory(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketCaseHistoryEntry {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"operation_key",
|
||||
"previous_closure_operation_key",
|
||||
"action",
|
||||
"explanation",
|
||||
"references",
|
||||
"author",
|
||||
"at",
|
||||
], label);
|
||||
return {
|
||||
operation_key: string(item.operation_key, `${label}.operation_key`),
|
||||
previous_closure_operation_key: optionalNullableString(
|
||||
item.previous_closure_operation_key,
|
||||
`${label}.previous_closure_operation_key`,
|
||||
),
|
||||
action: parseCaseAction(item.action, `${label}.action`),
|
||||
explanation: string(item.explanation, `${label}.explanation`),
|
||||
references: array(item.references, `${label}.references`, parseReference),
|
||||
author: string(item.author, `${label}.author`),
|
||||
at: string(item.at, `${label}.at`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyHandoff(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketLegacyWorkflowSnapshot {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"workflow_state",
|
||||
"workflow_state_explicit",
|
||||
"status",
|
||||
"queued_by",
|
||||
"queued_at",
|
||||
"resolution",
|
||||
], label);
|
||||
return {
|
||||
workflow_state: string(item.workflow_state, `${label}.workflow_state`),
|
||||
workflow_state_explicit: boolean(
|
||||
item.workflow_state_explicit,
|
||||
`${label}.workflow_state_explicit`,
|
||||
),
|
||||
status: string(item.status, `${label}.status`),
|
||||
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
|
||||
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
|
||||
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
|
||||
};
|
||||
}
|
||||
|
||||
function caseProjection(item: Record<string, unknown>, label: string) {
|
||||
return {
|
||||
closure_operation_key: optionalNullableString(
|
||||
item.closure_operation_key,
|
||||
`${label}.closure_operation_key`,
|
||||
),
|
||||
current_closure: item.current_closure === undefined
|
||||
? undefined
|
||||
: item.current_closure === null
|
||||
? null
|
||||
: parseClosure(item.current_closure, `${label}.current_closure`),
|
||||
legacy_handoff: item.legacy_handoff === undefined
|
||||
? undefined
|
||||
: item.legacy_handoff === null
|
||||
? null
|
||||
: parseLegacyHandoff(item.legacy_handoff, `${label}.legacy_handoff`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTicketSummary(value: unknown): TicketSummary {
|
||||
const label = "Ticket summary";
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"id",
|
||||
"kind",
|
||||
"labels",
|
||||
"priority",
|
||||
"queued_at",
|
||||
"queued_by",
|
||||
"readiness",
|
||||
"resource_key",
|
||||
"slug",
|
||||
"status",
|
||||
"title",
|
||||
"updated_at",
|
||||
"workflow_state",
|
||||
"workflow_state_explicit",
|
||||
], label);
|
||||
let status: TicketSummary["status"];
|
||||
if (item.status === "Open" || item.status === "Closed") status = item.status;
|
||||
else {
|
||||
const other = object(item.status, `${label}.status`);
|
||||
exact(other, ["Other"], `${label}.status`);
|
||||
status = { Other: string(other.Other, `${label}.status.Other`) };
|
||||
}
|
||||
return {
|
||||
id: string(item.id, `${label}.id`),
|
||||
kind: string(item.kind, `${label}.kind`),
|
||||
labels: strings(item.labels, `${label}.labels`),
|
||||
priority: string(item.priority, `${label}.priority`),
|
||||
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
|
||||
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
|
||||
readiness: optionalNullableString(item.readiness, `${label}.readiness`),
|
||||
resource_key: optionalNullableString(
|
||||
item.resource_key,
|
||||
`${label}.resource_key`,
|
||||
),
|
||||
slug: string(item.slug, `${label}.slug`),
|
||||
status,
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
workflow_state: parseTicketState(
|
||||
item.workflow_state,
|
||||
`${label}.workflow_state`,
|
||||
),
|
||||
workflow_state_explicit: boolean(
|
||||
item.workflow_state_explicit,
|
||||
`${label}.workflow_state_explicit`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTicketListItem(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketListItemSummary {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"closure_operation_key",
|
||||
"current_closure",
|
||||
"legacy_handoff",
|
||||
"id",
|
||||
"priority",
|
||||
"queued_at",
|
||||
@@ -201,13 +405,14 @@ function parseTicketListItem(
|
||||
"workspace_action_priority",
|
||||
], label);
|
||||
return {
|
||||
...caseProjection(item, label),
|
||||
id: string(item.id, `${label}.id`),
|
||||
priority: string(item.priority, `${label}.priority`),
|
||||
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
|
||||
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
|
||||
record_source: string(item.record_source, `${label}.record_source`),
|
||||
resource_key: string(item.resource_key, `${label}.resource_key`),
|
||||
state: string(item.state, `${label}.state`),
|
||||
state: parseTicketState(item.state, `${label}.state`),
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
workspace_action_priority: string(
|
||||
@@ -727,6 +932,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"artifacts",
|
||||
"assignment_diagnostics",
|
||||
"assignments",
|
||||
"closure_history",
|
||||
"body",
|
||||
"body_truncated",
|
||||
"created_at",
|
||||
@@ -735,6 +941,9 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"event_page",
|
||||
"events",
|
||||
"evidence",
|
||||
"closure_operation_key",
|
||||
"current_closure",
|
||||
"legacy_handoff",
|
||||
"id",
|
||||
"implementation_reports",
|
||||
"content_digest",
|
||||
@@ -756,6 +965,12 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"updated_at",
|
||||
], label);
|
||||
return {
|
||||
...caseProjection(item, label),
|
||||
closure_history: array(
|
||||
item.closure_history,
|
||||
`${label}.closure_history`,
|
||||
parseCaseHistory,
|
||||
),
|
||||
action_eligibility: parseActionEligibility(
|
||||
item.action_eligibility,
|
||||
`${label}.action_eligibility`,
|
||||
@@ -820,7 +1035,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
|
||||
resource_key: string(item.resource_key, `${label}.resource_key`),
|
||||
risk_flags: strings(item.risk_flags, `${label}.risk_flags`),
|
||||
state: string(item.state, `${label}.state`),
|
||||
state: parseTicketState(item.state, `${label}.state`),
|
||||
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
};
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, received ${
|
||||
JSON.stringify(actual)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
async function assertRejects(operation: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("409")) return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected stale CAS rejection");
|
||||
}
|
||||
import type { TicketCaseAction } from "#lib/generated/ticket-api.ts";
|
||||
import { ticketCaseRequest, updateTicketCase } from "./ticket-case.ts";
|
||||
|
||||
const observed = {
|
||||
content_digest: "a".repeat(64),
|
||||
state: "open",
|
||||
closure_operation_key: "last-correction",
|
||||
} as const;
|
||||
const summary = {
|
||||
id: "ticket-1",
|
||||
resource_key: "T-1",
|
||||
slug: "case",
|
||||
title: "Case",
|
||||
kind: "task",
|
||||
labels: [],
|
||||
priority: "P2",
|
||||
status: "Closed",
|
||||
workflow_state: "closed",
|
||||
workflow_state_explicit: true,
|
||||
};
|
||||
|
||||
Deno.test("case actions carry content/state/last-closure CAS without browser author or unrelated lifecycle fields", async () => {
|
||||
const actions: TicketCaseAction[] = [
|
||||
...["completed", "not_planned", "duplicate", "unknown", {
|
||||
other: "Deferred",
|
||||
}].map((reason) => ({ action: "close", reason } as TicketCaseAction)),
|
||||
{ action: "reopen" },
|
||||
{ action: "correct_closure", reason: "unknown" },
|
||||
];
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
for (const action of actions) {
|
||||
const request = ticketCaseRequest(
|
||||
observed,
|
||||
action,
|
||||
" Case judgment ",
|
||||
"operation-1",
|
||||
);
|
||||
assertEquals(request, {
|
||||
expected_content_digest: observed.content_digest,
|
||||
expected_state: "open",
|
||||
expected_closure_operation_key: "last-correction",
|
||||
operation_key: "operation-1",
|
||||
action,
|
||||
explanation: "Case judgment",
|
||||
references: [],
|
||||
});
|
||||
globalThis.fetch = (input, init) => {
|
||||
assertEquals(input, "/workspaces/workspace%20one/tickets/T%2F1/case");
|
||||
const options = init as { method?: string; body?: string };
|
||||
assertEquals(options.method, "POST");
|
||||
assertEquals(JSON.parse(String(options.body)), request);
|
||||
return Promise.resolve(Response.json(summary));
|
||||
};
|
||||
assertEquals(
|
||||
(await updateTicketCase("workspace one", "T/1", request))
|
||||
.workflow_state,
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
assertEquals(
|
||||
ticketCaseRequest(
|
||||
{ ...observed, closure_operation_key: null },
|
||||
{ action: "close", reason: "unknown" },
|
||||
"Close",
|
||||
"first",
|
||||
).expected_closure_operation_key,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("case API propagates stale CAS failure rather than reporting success", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = () =>
|
||||
Promise.resolve(Response.json({ message: "stale case" }, { status: 409 }));
|
||||
try {
|
||||
await assertRejects(() =>
|
||||
updateTicketCase(
|
||||
"w",
|
||||
"T-1",
|
||||
ticketCaseRequest(
|
||||
observed,
|
||||
{ action: "reopen" },
|
||||
"Reopen",
|
||||
"operation-1",
|
||||
),
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
TicketCaseAction,
|
||||
TicketCaseUpdateRequest,
|
||||
TicketDetail,
|
||||
TicketSummary,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { workspaceApiJsonWithBody } from "./http.ts";
|
||||
import {
|
||||
parseTicketSummary,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
} from "./ticket-browser.ts";
|
||||
|
||||
export function ticketCaseRequest(
|
||||
ticket: Pick<
|
||||
TicketDetail,
|
||||
"content_digest" | "state" | "closure_operation_key"
|
||||
>,
|
||||
action: TicketCaseAction,
|
||||
explanation: string,
|
||||
operationKey: string,
|
||||
): TicketCaseUpdateRequest {
|
||||
return {
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
// The last judgment key survives reopening; do not derive it from current_closure.
|
||||
expected_closure_operation_key: ticket.closure_operation_key ?? null,
|
||||
operation_key: operationKey,
|
||||
action,
|
||||
explanation: explanation.trim(),
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTicketCase(
|
||||
workspaceId: string,
|
||||
ticketId: string,
|
||||
request: TicketCaseUpdateRequest,
|
||||
): Promise<TicketSummary> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/workspaces/${encodeURIComponent(workspaceId)}/tickets/${
|
||||
encodeURIComponent(ticketId)
|
||||
}/case`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
parseTicketSummary,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
}
|
||||
@@ -404,12 +404,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
|
||||
);
|
||||
assert(
|
||||
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
||||
ticketPanelModel.includes('label: "Done + Closed"') &&
|
||||
ticketPanelModel.includes('label: "Open"') &&
|
||||
ticketPanelModel.includes('label: "Closed"') &&
|
||||
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
|
||||
ticketPanelModel.includes("nextTicketLaneVisibleCount"),
|
||||
"Ticket Kanban should combine related states into independent 30-item display windows",
|
||||
"Ticket Kanban should separate cases into independent 30-item display windows",
|
||||
);
|
||||
assert(
|
||||
generatedTicketApi.includes(
|
||||
@@ -428,7 +427,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
);
|
||||
assert(
|
||||
ticketDetailLoad.includes("/repositories") &&
|
||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||
ticketDetailPage.includes("ticketCaseRequest(ticket, action") &&
|
||||
ticketDetailPage.includes("async function queueTicket") &&
|
||||
ticketDetailPage.includes("const path = ticketPath") &&
|
||||
ticketDetailPage.includes("`${path}/queue`") &&
|
||||
@@ -436,7 +435,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
|
||||
ticketDetailPage.includes("mergeRequestPagePath") &&
|
||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||
ticketDetailPage.includes("await updateTicketCase(") &&
|
||||
ticketDetailPage.includes("mutateAssignment") &&
|
||||
ticketDetailPage.includes("can_start_manual_worker") &&
|
||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||
|
||||
@@ -10,29 +10,35 @@ export const fixturePage = (returned: number, more = false) => ({
|
||||
});
|
||||
const updated = (hour: number) =>
|
||||
`2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`;
|
||||
export function fixtureTicket(key: string, state: string, hour = 10) {
|
||||
export function fixtureTicket(
|
||||
key: string,
|
||||
state: "open" | "closed",
|
||||
hour = 10,
|
||||
) {
|
||||
return {
|
||||
id: `internal-${key}`,
|
||||
resource_key: key,
|
||||
title: key === "T-101"
|
||||
? "Restore queued Worker submissions"
|
||||
: `${state} ticket ${key}`,
|
||||
closure_operation_key: null,
|
||||
current_closure: null,
|
||||
legacy_handoff: null,
|
||||
state,
|
||||
priority: "normal",
|
||||
queued_at: null,
|
||||
queued_by: null,
|
||||
updated_at: updated(hour),
|
||||
record_source: "fixture",
|
||||
workspace_action_priority: state === "inprogress" || state === "queued"
|
||||
? "active_work"
|
||||
: "background",
|
||||
workspace_action_priority: state === "open" ? "active_work" : "background",
|
||||
};
|
||||
}
|
||||
export function fixtureDetail(key = "T-101") {
|
||||
const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued");
|
||||
const summary = fixtureTicket(key, "open");
|
||||
const { workspace_action_priority: _, ...ticket } = summary;
|
||||
return {
|
||||
...ticket,
|
||||
closure_history: [],
|
||||
content_digest: "a".repeat(64),
|
||||
created_at: updated(1),
|
||||
readiness: null,
|
||||
@@ -87,7 +93,7 @@ export function fixtureDetail(key = "T-101") {
|
||||
blockers: key === "T-101"
|
||||
? [{
|
||||
blocking_resource_key: "T-99",
|
||||
blocking_state: "ready",
|
||||
blocking_state: "open",
|
||||
blocking_ticket: "internal-T-99",
|
||||
note: null,
|
||||
reason_kind: "dependency",
|
||||
@@ -108,11 +114,13 @@ export function dashboardFixture(
|
||||
const state = search.get("states")!;
|
||||
const items = empty
|
||||
? []
|
||||
: state === "inprogress,queued"
|
||||
? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")]
|
||||
: state === "done"
|
||||
? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)]
|
||||
: [fixtureTicket("T-105", "closed", 14)];
|
||||
: state === "open"
|
||||
? [fixtureTicket("T-101", "open"), fixtureTicket("T-102", "open")]
|
||||
: [
|
||||
fixtureTicket("T-105", "closed", 14),
|
||||
fixtureTicket("T-103", "closed", 12),
|
||||
fixtureTicket("T-104", "closed", 7),
|
||||
];
|
||||
if (long && items[0]) {
|
||||
items[0].title = "長いTicket名 — ".repeat(8) +
|
||||
"preserve-the-full-title-without-horizontal-overflow";
|
||||
|
||||
@@ -35,9 +35,7 @@ export type Dashboard = {
|
||||
};
|
||||
|
||||
const states: Record<string, string> = {
|
||||
inprogress: "In progress",
|
||||
queued: "Queued",
|
||||
done: "Done",
|
||||
open: "Open",
|
||||
closed: "Closed",
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
@@ -113,8 +111,7 @@ export function loadDashboard(
|
||||
})}`,
|
||||
parseTicketListResponse,
|
||||
);
|
||||
const activeResult = tickets("inprogress,queued");
|
||||
const doneResult = tickets("done");
|
||||
const activeResult = tickets("open");
|
||||
const closedResult = tickets("closed");
|
||||
const objectivesResult = get(
|
||||
`/objectives?limit=${HOME_LIMIT}`,
|
||||
@@ -179,7 +176,7 @@ export function loadDashboard(
|
||||
}
|
||||
const current = detail.data;
|
||||
// A concurrent transition can remove a Ticket from this snapshot's scope.
|
||||
if (!["inprogress", "queued"].includes(current.state)) {
|
||||
if (current.state !== "open") {
|
||||
return { changed: true };
|
||||
}
|
||||
const blockers = current.relations.blockers;
|
||||
@@ -218,17 +215,14 @@ export function loadDashboard(
|
||||
: result.data.page.has_more || result.data.page.source_truncated
|
||||
? `Showing ${
|
||||
Math.min(result.data.items.length, HOME_LIMIT)
|
||||
} active tickets, with in-progress work first.`
|
||||
} open tickets.`
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
recent: Promise.all([doneResult, closedResult, objectivesResult]).then((
|
||||
[done, closed, objectives],
|
||||
recent: Promise.all([closedResult, objectivesResult]).then((
|
||||
[closed, objectives],
|
||||
): DashboardFeed => ({
|
||||
rows: recentRows([
|
||||
...(done.data?.items ?? []).map((ticket) =>
|
||||
ticketRow(workspaceId, ticket)
|
||||
),
|
||||
...(closed.data?.items ?? []).map((ticket) =>
|
||||
ticketRow(workspaceId, ticket)
|
||||
),
|
||||
@@ -243,21 +237,17 @@ export function loadDashboard(
|
||||
})),
|
||||
]),
|
||||
errors: [
|
||||
...(!done.data ? [failure("Done tickets", done.error)] : []),
|
||||
...(!closed.data ? [failure("Closed tickets", closed.error)] : []),
|
||||
...(!objectives.data ? [failure("Objectives", objectives.error)] : []),
|
||||
...([done.data, closed.data, objectives.data].some((data) =>
|
||||
...([closed.data, objectives.data].some((data) =>
|
||||
data?.invalid_records.length
|
||||
)
|
||||
? ["Some records could not be read."]
|
||||
: []),
|
||||
],
|
||||
notice:
|
||||
[done.data, closed.data, objectives.data].some((data) =>
|
||||
data?.items.length
|
||||
)
|
||||
? "Up to 5 latest updates to done / closed tickets and Objectives."
|
||||
: undefined,
|
||||
notice: [closed.data, objectives.data].some((data) => data?.items.length)
|
||||
? "Up to 5 latest updates to closed tickets and Objectives."
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,10 +86,10 @@ Deno.test("observed live source movement requires fresh review but an unchanged
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
|
||||
Deno.test("closed Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
|
||||
const ticket = parseTicketDetail({
|
||||
...fixtureDetail(),
|
||||
state: "done",
|
||||
state: "closed",
|
||||
evidence: {
|
||||
...fixtureDetail().evidence,
|
||||
has_merge_request: true,
|
||||
@@ -107,7 +107,7 @@ Deno.test("done Ticket can retain immutable approval while current requirement e
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}],
|
||||
});
|
||||
assertEquals(ticket.state, "done");
|
||||
assertEquals(ticket.state, "closed");
|
||||
assertEquals(ticket.evidence.complete_for_integration, false);
|
||||
assertEquals(
|
||||
currentRequirementApprovalStatus(ticket.evidence),
|
||||
@@ -128,14 +128,14 @@ Deno.test("done Ticket can retain immutable approval while current requirement e
|
||||
currentRequirementApprovalStatus(recovered.evidence),
|
||||
"approved",
|
||||
);
|
||||
assertEquals(recovered.state, "done");
|
||||
assertEquals(recovered.state, "closed");
|
||||
assertEquals(
|
||||
summarySourceReviewStatus(ticket.merge_requests[0]!),
|
||||
"Integration approval recorded for immutable merged source source-1.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => {
|
||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
@@ -144,15 +144,15 @@ Deno.test("Ticket route separates current requirement evidence from immutable in
|
||||
);
|
||||
for (
|
||||
const required of [
|
||||
"Current requirement evidence",
|
||||
"MR requirement evidence",
|
||||
"ticket.evidence.complete_for_integration",
|
||||
"Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}",
|
||||
"ticket.evidence.missing",
|
||||
"summarySourceReviewStatus(mergeRequest)",
|
||||
"Immutable integration evidence",
|
||||
"Integration evidence error:",
|
||||
'{#if ticket.state === "done"}',
|
||||
"Incomplete current requirement evidence does not cancel recorded completion.",
|
||||
'{#if ticket.state === "closed"}',
|
||||
"Incomplete MR evidence does not alter the recorded case judgment.",
|
||||
]
|
||||
) {
|
||||
if (!source.includes(required)) {
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
.ticket-kanban {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: repeat(3, minmax(18rem, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(18rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
@@ -168,13 +168,10 @@
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
|
||||
.ticket-lane[data-state="open"] .ticket-state-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
|
||||
.ticket-lane[data-state="closed"] .ticket-state-dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.ticket-lane-count {
|
||||
|
||||
@@ -23,56 +23,26 @@ function assertEquals<T>(actual: T, expected: T): void {
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => {
|
||||
Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => {
|
||||
const tickets = [
|
||||
{
|
||||
id: "planning",
|
||||
state: "planning",
|
||||
title: "Planning",
|
||||
updated_at: "2026-07-30T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ready-old",
|
||||
state: "ready",
|
||||
title: "Ready old",
|
||||
id: "old",
|
||||
state: "open",
|
||||
title: "Open old",
|
||||
updated_at: "2026-07-29T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ready-new",
|
||||
state: "ready",
|
||||
title: "Ready new",
|
||||
id: "new",
|
||||
state: "open",
|
||||
title: "Open 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",
|
||||
]);
|
||||
assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]);
|
||||
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]);
|
||||
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]);
|
||||
});
|
||||
|
||||
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
|
||||
|
||||
@@ -4,35 +4,12 @@ import type {
|
||||
TicketListItemSummary,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
|
||||
export const TICKET_STATES = [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] as const;
|
||||
|
||||
export const TICKET_STATES = ["open", "closed"] as const;
|
||||
export type TicketState = (typeof TICKET_STATES)[number];
|
||||
|
||||
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
|
||||
|
||||
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"],
|
||||
},
|
||||
{ id: "open", label: "Open", states: ["open"] },
|
||||
{ id: "closed", label: "Closed", states: ["closed"] },
|
||||
] as const;
|
||||
|
||||
export const TICKET_LANE_PAGE_SIZE = 30;
|
||||
@@ -44,15 +21,6 @@ export type TicketCardSummary = Pick<
|
||||
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>;
|
||||
|
||||
const STATE_SORT_ORDER = new Map<string, number>([
|
||||
["ready", 0],
|
||||
["planning", 1],
|
||||
["inprogress", 0],
|
||||
["queued", 1],
|
||||
["done", 0],
|
||||
["closed", 1],
|
||||
]);
|
||||
|
||||
export type TicketLane = {
|
||||
id: TicketLaneId;
|
||||
label: string;
|
||||
@@ -77,9 +45,6 @@ export function sortTickets(
|
||||
tickets: TicketCardSummary[],
|
||||
): TicketCardSummary[] {
|
||||
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);
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
|
||||
<div class="home-columns">
|
||||
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
|
||||
<DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
|
||||
<DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
|
||||
<DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} />
|
||||
<DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
|
||||
);
|
||||
await Promise.all(Object.values(result!.dashboard));
|
||||
expect(depends).toHaveBeenCalledWith("workspace:home");
|
||||
expect(fetch).toHaveBeenCalledTimes(7);
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
for (const [path, init] of fetch.mock.calls) {
|
||||
const url = new URL(String(path), "https://example.test");
|
||||
expect(init?.method ?? "GET").toBe("GET");
|
||||
@@ -96,7 +96,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
|
||||
expect(url.searchParams.get("limit")).toBe("5");
|
||||
}
|
||||
}
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done")))
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=open")))
|
||||
.toBe(true);
|
||||
expect(
|
||||
fetch.mock.calls.some(([url]) => String(url).includes("states=closed")),
|
||||
@@ -152,7 +152,7 @@ test("one recent source failure preserves the other records and does not assert
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"Objectives",
|
||||
);
|
||||
expect(screen.queryByText("No done / closed tickets or Objectives yet."))
|
||||
expect(screen.queryByText("No closed tickets or Objectives yet."))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti
|
||||
const fallback = fixtureFetch();
|
||||
const fetchFn: typeof fetch = (path, init) =>
|
||||
String(path).endsWith("/tickets/T-101")
|
||||
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" }))
|
||||
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "closed" }))
|
||||
: fallback(path, init);
|
||||
const feed = await data("home-owner", fetchFn).dashboard.active;
|
||||
expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]);
|
||||
@@ -179,9 +179,9 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti
|
||||
test("empty resources have scoped empty states and no fabricated totals", async () => {
|
||||
mount(data("home-empty"));
|
||||
expect(await screen.findByText("No open Merge Requests.")).toBeTruthy();
|
||||
expect(await screen.findByText("No in-progress or queued tickets."))
|
||||
expect(await screen.findByText("No open tickets."))
|
||||
.toBeTruthy();
|
||||
expect(await screen.findByText("No done / closed tickets or Objectives yet."))
|
||||
expect(await screen.findByText("No closed tickets or Objectives yet."))
|
||||
.toBeTruthy();
|
||||
expect(screen.queryAllByRole("link")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
labels: [],
|
||||
body: createBody,
|
||||
risk_flags: [],
|
||||
workflow_state: "planning",
|
||||
workflow_state: "open",
|
||||
targets: normalizedCreateTargets(),
|
||||
};
|
||||
try {
|
||||
@@ -297,7 +297,7 @@
|
||||
</p>
|
||||
{:else if !orchestrator.data?.online}
|
||||
<p class="workspace-callout">
|
||||
Orchestrator is offline. Requests can be queued; progress and results can still be recorded directly.
|
||||
Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -9,11 +9,7 @@ import type { TicketListResponse } from "#lib/generated/ticket-api.ts";
|
||||
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const LANE_STATES = {
|
||||
"ready-planning": ["ready", "planning"],
|
||||
"inprogress-queued": ["inprogress", "queued"],
|
||||
"done-closed": ["done", "closed"],
|
||||
} as const;
|
||||
const LANE_STATES = { open: ["open"], closed: ["closed"] } as const;
|
||||
|
||||
export type TicketLaneId = keyof typeof LANE_STATES;
|
||||
|
||||
@@ -48,15 +44,13 @@ async function loadLane(
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [
|
||||
readyPlanning,
|
||||
inprogressQueued,
|
||||
doneClosed,
|
||||
open,
|
||||
closed,
|
||||
orchestrator,
|
||||
repositories,
|
||||
] = await Promise.all([
|
||||
loadLane(fetch, workspaceId, "ready-planning"),
|
||||
loadLane(fetch, workspaceId, "inprogress-queued"),
|
||||
loadLane(fetch, workspaceId, "done-closed"),
|
||||
loadLane(fetch, workspaceId, "open"),
|
||||
loadLane(fetch, workspaceId, "closed"),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
@@ -69,9 +63,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
return {
|
||||
workspaceId,
|
||||
ticketLanes: {
|
||||
"ready-planning": readyPlanning,
|
||||
"inprogress-queued": inprogressQueued,
|
||||
"done-closed": doneClosed,
|
||||
open,
|
||||
closed,
|
||||
},
|
||||
orchestrator,
|
||||
repositories,
|
||||
|
||||
@@ -12,16 +12,18 @@
|
||||
parseTicketQueueOutcome,
|
||||
parseTicketRoleAssignmentMutationResponse,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { ticketCaseRequest, updateTicketCase } from "#lib/workspace/api/ticket-case.ts";
|
||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||
import {
|
||||
relationLabel,
|
||||
TICKET_STATES,
|
||||
ticketWorkerLaunchHref,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { ApiResult } from "#lib/workspace/api/http.ts";
|
||||
import type {
|
||||
TicketCaseAction,
|
||||
TicketClosureReason,
|
||||
TicketTarget,
|
||||
TicketTargetAccess,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
@@ -37,7 +39,6 @@
|
||||
access: TicketTargetAccess;
|
||||
};
|
||||
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES;
|
||||
|
||||
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
|
||||
return targets.map((target) => ({
|
||||
@@ -78,12 +79,11 @@
|
||||
let targetDrafts = $state<EditableTicketTarget[]>(
|
||||
editableTargets(loadedTicket.targets),
|
||||
);
|
||||
let nextState = $state(loadedTicket.state);
|
||||
let transitionReason = $state("");
|
||||
let progressBody = $state("");
|
||||
let closureReason = $state("unknown");
|
||||
let otherClosureReason = $state("");
|
||||
let threadRole = $state("comment");
|
||||
let threadBody = $state("");
|
||||
let resolution = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let queueMessage = $state<string | null>(null);
|
||||
@@ -139,17 +139,16 @@
|
||||
editTitle = updatedTicket.title;
|
||||
editBody = updatedTicket.body;
|
||||
targetDrafts = editableTargets(updatedTicket.targets);
|
||||
nextState = updatedTicket.state;
|
||||
}
|
||||
|
||||
function resetTicketView(updatedTicket: TicketDetail): void {
|
||||
applyTicket(updatedTicket);
|
||||
editing = false;
|
||||
transitionReason = "";
|
||||
progressBody = "";
|
||||
closureReason = "unknown";
|
||||
otherClosureReason = "";
|
||||
threadRole = "comment";
|
||||
threadBody = "";
|
||||
resolution = "";
|
||||
busy = null;
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
@@ -363,7 +362,7 @@
|
||||
}
|
||||
|
||||
async function markReady() {
|
||||
if (ticket.state !== "planning" || busy) return;
|
||||
if (ticket.state !== "open" || busy) return;
|
||||
readyOperationKey ??= crypto.randomUUID();
|
||||
if (
|
||||
await mutate("ready", "/ready", {
|
||||
@@ -376,18 +375,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
await mutate("state", "/state", {
|
||||
state: nextState,
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
reason: transitionReason.trim(),
|
||||
body: progressBody.trim() || null,
|
||||
})
|
||||
) { transitionReason = ""; progressBody = ""; }
|
||||
function selectedClosureReason(): TicketClosureReason {
|
||||
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
|
||||
}
|
||||
|
||||
async function caseDecision(action: TicketCaseAction): Promise<void> {
|
||||
if (busy || !transitionReason.trim()) return;
|
||||
const generation = routeGeneration;
|
||||
const path = ticketPath;
|
||||
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
|
||||
busy = action.action;
|
||||
errorMessage = null;
|
||||
try {
|
||||
await updateTicketCase(data.workspaceId, data.ticketId, request);
|
||||
if (generation !== routeGeneration) return;
|
||||
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (generation !== routeGeneration) return;
|
||||
applyTicket(updated);
|
||||
transitionReason = "";
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
|
||||
} finally {
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closureReasonLabel(reason: TicketClosureReason): string {
|
||||
return typeof reason === "string" ? relationLabel(reason) : reason.other;
|
||||
}
|
||||
|
||||
async function appendThread(event: SubmitEvent) {
|
||||
@@ -401,19 +415,6 @@
|
||||
) threadBody = "";
|
||||
}
|
||||
|
||||
async function closeTicket(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!resolution.trim()) return;
|
||||
if (
|
||||
await mutate("close", "/close", {
|
||||
resolution: resolution.trim(),
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
})
|
||||
) resolution = "";
|
||||
}
|
||||
|
||||
function eventTitle(kind: string): string {
|
||||
return relationLabel(kind);
|
||||
}
|
||||
@@ -550,22 +551,29 @@
|
||||
|
||||
<aside class="ticket-control-rail">
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Progress decision</h2></header>
|
||||
<p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p>
|
||||
<form class="ticket-control-form" onsubmit={transition}>
|
||||
<label>State
|
||||
<select bind:value={nextState}>
|
||||
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||
<header><h2>Case decision</h2></header>
|
||||
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
|
||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
|
||||
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
|
||||
<label>Closure reason
|
||||
<select bind:value={closureReason}>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="not_planned">Not planned</option>
|
||||
<option value="duplicate">Duplicate</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label>
|
||||
<label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}>
|
||||
{nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"}
|
||||
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
|
||||
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
|
||||
</button>
|
||||
{#if ticket.state === "closed" && ticket.current_closure}
|
||||
<button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
|
||||
{/if}
|
||||
</form>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</button>
|
||||
{#if ticket.state === "open"}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button>
|
||||
{/if}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
|
||||
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
|
||||
@@ -631,12 +639,12 @@
|
||||
</button>
|
||||
</form></details>
|
||||
{/if}
|
||||
{#if ticket.state === "inprogress" && workerAssignment}
|
||||
{#if workerAssignment}
|
||||
<details class="ticket-cancel-implementation">
|
||||
<summary>Cancel implementation</summary>
|
||||
<form class="ticket-control-form" onsubmit={cancelImplementation}>
|
||||
<p class="workspace-empty-copy">
|
||||
Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir.
|
||||
Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir.
|
||||
</p>
|
||||
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
|
||||
<button
|
||||
@@ -644,7 +652,7 @@
|
||||
type="submit"
|
||||
disabled={busy !== null || !cancellationReason.trim()}
|
||||
>
|
||||
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"}
|
||||
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -724,16 +732,16 @@
|
||||
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p>
|
||||
{#if ticket.state === "done"}
|
||||
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
|
||||
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
|
||||
{#if ticket.state === "closed"}
|
||||
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Merge Requests</h2></header>
|
||||
<p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p>
|
||||
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
|
||||
{#if mergeRequests.length > 0}
|
||||
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
|
||||
<article class="ticket-control-card">
|
||||
@@ -759,20 +767,33 @@
|
||||
</article>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p>
|
||||
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if ticket.state !== "closed"}
|
||||
<details class="ticket-control-card ticket-close-card">
|
||||
<summary>Close ticket</summary>
|
||||
<form class="ticket-control-form" onsubmit={closeTicket}>
|
||||
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
|
||||
<button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button>
|
||||
</form>
|
||||
{#if ticket.current_closure}
|
||||
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
|
||||
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
|
||||
<RichMarkdown text={ticket.current_closure.explanation} />
|
||||
{#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{#if ticket.closure_history.length > 0}
|
||||
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
|
||||
{#each ticket.closure_history as judgment (judgment.operation_key)}
|
||||
<article>
|
||||
<p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p>
|
||||
<RichMarkdown text={judgment.explanation} />
|
||||
{#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
|
||||
</article>
|
||||
{/each}
|
||||
</details>
|
||||
{/if}
|
||||
{#if ticket.legacy_handoff}
|
||||
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
|
||||
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
|
||||
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
|
||||
</details>
|
||||
{:else if ticket.resolution}
|
||||
<section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ Deno.test("ticket detail fences stale mutation responses", () => {
|
||||
const operation of [
|
||||
"async function mutate(",
|
||||
"async function queueTicket(",
|
||||
"async function caseDecision(",
|
||||
"async function mutateAssignment(",
|
||||
]
|
||||
) {
|
||||
|
||||
@@ -40,6 +40,11 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
proxy: {
|
||||
// Case judgments use the canonical authenticated Workspace API route.
|
||||
"/workspaces": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user