feat: generate ticket browser contract from ServerApi

This commit is contained in:
2026-09-23 10:51:50 +09:00
parent 6f9dc7ab41
commit 515a19bbc3
27 changed files with 2121 additions and 462 deletions
+4 -2
View File
@@ -6,12 +6,14 @@
"dev": "deno run -A npm:vite@7.2.7 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@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --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 tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.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 test/console/ansi.test.ts src/lib/workspace/console/model.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/console/tasks.test.ts test/ticket-detail-route-reuse.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/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 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/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs --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 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 test/console/ansi.test.ts src/lib/workspace/console/model.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/console/tasks.test.ts test/ticket-detail-route-reuse.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/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 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/ui.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview",
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check"
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
},
"imports": {
"$lib/": "./src/lib/",
+100 -28
View File
@@ -1,54 +1,126 @@
// 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
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:550383b600fe2a2e55630849c7de7a6a24463ad41d0c18dc5d254a3d14328f07)
// 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 InvalidProjectRecord = { label: string, reason: string, };
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
export type TicketSummary = { id: string, resource_key: string, title: string, state: string, priority: string, updated_at: string | null, queued_by: string | null, queued_at: string | null, workspace_action_priority: string, record_source: string, };
export type BrowserCloseTicketRequest = { resolution: string; };
export type TicketListResponse = { workspace_id: string, limit: number, items: Array<TicketSummary>, page: QueryPage, invalid_records: Array<InvalidProjectRecord>, record_authority: 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 QueryPage = { limit: number, returned: number, has_more: boolean, next_cursor: string | null, sort: string, source_limit: number | null, source_truncated: boolean, };
export type BrowserQueueTicketRequest = { [key: string]: never; };
export type TicketEventDetail = { sequence: number, event_ref: string, kind: string, author: string | null, at: string | null, status: string | null, from: string | null, to: string | null, reason: string | null, state_field: string | null, heading: string | null, body: string | null, attributes: { [key in string]: string }, references: Array<string>, };
export type BrowserTicketTargetEdit = { action: "set"; ref_selector?: string | null; repository_key: string; } | { action: "clear"; };
export type ObjectiveLinkSummary = { id: string, resource_key: string, title: string, state: string, };
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
export type TicketEvidenceEvent = { event_ref: string, sequence: number, kind: string, at: string | null, author: string | null, excerpt: string, };
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
export type TicketAssignmentSummary = { assignment_id: string, runtime_id: string, worker_id: string, worker_resource_key?: string | null, };
export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; };
export type TicketRoleAssignmentSummary = { assignment_id: string, role: string, principal: TicketAssignmentPrincipalSummary, assigned_by: string, assigned_at: string, };
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
export type TicketAssignmentPrincipalSummary = { "kind": "user", account_id: string, } | { "kind": "worker", runtime_id: string, worker_id: string, } | { "kind": "workspace_agent", agent_key: string, };
export type ConflictResolution = "none" | "clean" | "conflicts_resolved";
export type TicketActionEligibility = { can_assign_orchestrator: boolean, can_unassign_orchestrator: boolean, can_queue: boolean, can_start_manual_coder: boolean, queue_tickets: Array<string>, blockers: Array<string>, };
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
export type TicketMergeRequestSummary = { merge_request_id: string, repository_key: string, state: string, review_status: string, selector_from: string | null, selector_to: string, updated_at: string, current_subject_ref: string | null, review_subject_ref: string | null, review_requested_at: string | null, review_submitted_at: string | null, review_excerpt: string | null, };
export type DiagnosticSeverity = "info" | "warning" | "error";
export type MergeRequestRefDiagnostic = { code: string, message: string, };
export type FindingSeverity = "blocker" | "major" | "minor" | "note";
export type MergeRequestListItem = { summary: TicketMergeRequestSummary, ticket_ids: Array<string>, thread_event_count: number, ref_diagnostics?: Array<MergeRequestRefDiagnostic>, };
export type InvalidProjectRecord = { label: string; reason: string; };
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>, next_cursor: string | null, };
export type MarkdownText = string;
export type TicketEvidenceSummary = { has_merge_request: boolean, has_current_subject_ref: boolean, has_review_request: boolean, has_commit: boolean, review_status: string | null, approved_current_subject: boolean, review_after_rescope: boolean, unresolved_request_changes: boolean, complete_for_integration: boolean, missing: Array<string>, };
export type MergeRequestDetailResponse = { created_at: string; linked_tickets: Array<MergeRequestLinkedTicketResponse>; merge_request_id: string; repository_key: string; selector_from?: string | null; selector_to: string; source: MergeRequestRefResponse; state: MergeRequestState; target: MergeRequestRefResponse; thread: Array<MergeRequestThreadEvent>; ticket_ids: Array<string>; updated_at: string; workspace_id: string; };
export type TicketQueryRequest = { query: string | null, states: Array<string>, event_kinds: Array<string>, evidence: Array<string>, review_status: string | null, attention: Array<string>, related_ticket_id: string | null, relation_kind: string | null, linked_objective_id: string | null, updated_after: string | null, updated_before: string | null, sort: string | null, limit: number | null, cursor: string | null, };
export type MergeRequestLinkedTicketResponse = { key?: string | null; ticket_id: string; };
export type TicketQueryItem = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, workspace_action_priority: string, matched_fields: Array<string>, snippet: string | null, matching_event: TicketEvidenceEvent | null, linked_objective_ids: Array<string>, relation_count: number, blocker_count: number, unresolved_blocker_count: number, unresolved_review_count: number, evidence: TicketEvidenceSummary, merge_request: TicketMergeRequestSummary | null, };
export type MergeRequestListItem = { ref_diagnostics?: Array<MergeRequestRefDiagnostic>; summary: TicketMergeRequestSummary; thread_event_count: number; ticket_ids: Array<string>; };
export type TicketQueryResponse = { items: Array<TicketQueryItem>, page: QueryPage, record_authority: string, };
export type MergeRequestListResponse = { items: Array<MergeRequestListItem>; next_cursor?: string | null; };
export type TicketShowRequest = { event_limit: number | null, event_cursor: string | null, };
export type MergeRequestRefDiagnostic = { code: string; message: string; };
export type TicketRelation = { ticket_id: string, kind: string, target: string, target_resource_key?: string | null, note: string | null, author: string, at: string, };
export type MergeRequestRefResponse = { diagnostic?: MergeRequestRefDiagnostic | null; observed_at: string; ref?: string | null; status: string; };
export type DerivedTicketRelation = { source_ticket: string, source_resource_key?: string | null, inverse_kind: string, forward_kind: string, note: string | null, author: string, at: string, };
export type MergeRequestState = "open" | "merged" | "closed";
export type TicketRelationBlocker = { blocking_ticket: string, blocking_resource_key?: string | null, reason_kind: string, relation_kind: string, note: string | null, blocking_state: string, };
export type MergeRequestThreadEvent = { created_at: string; event_id: string; kind: "review_requested"; requested_by: MergeRequestWorkerIdentity; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { body: string; created_at: string; decision: ReviewDecision; event_id: string; findings: Array<ReviewFinding>; kind: "review"; request_event_id: string; reviewer: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_revoked"; reason: string; review_event_id: string; revoked_by: MergeRequestWorkerIdentity; sequence: number; subject_ref: string; } | { created_at: string; event_id: string; kind: "review_cancelled"; reason: string; request_event_id: string; sequence: number; subject_ref: string; } | { author: MergeRequestWorkerIdentity; body: string; created_at: string; event_id: string; kind: "comment"; sequence: number; } | { approval_event_id: string; approved_source_ref: string; created_at: string; event_id: string; kind: "merge"; merged_by: MergeRequestWorkerIdentity; operation_id: string; resolution: ConflictResolution; sequence: number; strategy: MergeStrategy; target_ref_after: string; target_ref_before: string; };
export type TicketRelationNotice = { related_ticket: string, kind: string, message: string, };
export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string; };
export type TicketRelationView = { outgoing: Array<TicketRelation>, incoming: Array<DerivedTicketRelation>, blockers: Array<TicketRelationBlocker>, notices: Array<TicketRelationNotice>, };
export type MergeStrategy = "fast_forward" | "merge";
export type TicketDetail = { id: string, resource_key: string, title: string, state: string, readiness: string | null, priority: string, created_at: string | null, updated_at: string | null, item_revision: string, queued_by: string | null, queued_at: string | null, repository_key: string | null, ref_selector: string | null, risk_flags: Array<string>, body: string, body_truncated: boolean, event_count: number, events: Array<TicketEventDetail>, event_page: QueryPage, artifact_count: number, artifacts: Array<string>, relations: TicketRelationView, linked_objectives: Array<ObjectiveLinkSummary>, implementation_reports: Array<TicketEvidenceEvent>, assignments: Array<TicketRoleAssignmentSummary>, current_coder: TicketAssignmentSummary | null, assignment_diagnostics: Array<string>, action_eligibility: TicketActionEligibility, merge_request: TicketMergeRequestSummary | null, evidence: TicketEvidenceSummary, resolution: string | null, record_source: string, };
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>; revision: string; state: string; title: string; updated_at?: string | null; };
export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; };
export type ObjectiveLinkSummary = { id: string; resource_key: string; state: string; title: string; };
export type ObjectiveLinkedTicketSummary = { id: string; resource_key: string; state: string; title: string; };
export type ObjectiveListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<ObjectiveSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; record_authority: string; workspace_id: string; };
export type ObjectiveResourceSummary = { bytes: number; media_type?: string | null; path: string; updated_at: string; };
export type ObjectiveSummary = { created_at?: string | null; id: string; linked_tickets: Array<string>; record_source: string; resource_key: string; state: string; summary: string; title: string; updated_at?: string | null; };
export type QueryPage = { has_more: boolean; limit: number; next_cursor?: string | null; returned: number; sort: string; source_limit?: number | null; source_truncated: boolean; };
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
export type ReviewDecision = "approve" | "request_changes";
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; };
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor";
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; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; ref_selector?: string | null; relations: TicketDetailRelationView; repository_key?: string | null; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; 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; };
export type TicketDetailRelation = { at: string; author: string; kind: string; note?: string | null; target: string; target_resource_key?: string | null; ticket_id: string; };
export type TicketDetailRelationBlocker = { blocking_resource_key?: string | null; blocking_state: string; blocking_ticket: string; note?: string | null; reason_kind: string; relation_kind: string; };
export type TicketDetailRelationNotice = { kind: string; message: string; related_ticket: string; };
export type TicketDetailRelationView = { blockers: Array<TicketDetailRelationBlocker>; incoming: Array<TicketDetailDerivedRelation>; notices: Array<TicketDetailRelationNotice>; outgoing: Array<TicketDetailRelation>; };
export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array<string>; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; };
export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; };
export type TicketEvidenceSummary = { approved_current_subject: boolean; complete_for_integration: boolean; has_commit: boolean; has_current_subject_ref: boolean; has_merge_request: boolean; has_review_request: boolean; missing: Array<string>; review_after_rescope: boolean; review_status?: string | null; unresolved_request_changes: boolean; };
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 TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; };
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; state: string; updated_at: string; };
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
export type TicketReference = { kind: string; target: string; };
export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; };
export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: TicketAssignmentRole; ticket_id: string; workspace_id: string; };
export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; };
+25 -5
View File
@@ -177,23 +177,41 @@ export async function readBoundedJson(
return JSON.parse(text) as unknown;
}
async function requireJson<T>(response: Response, path: string): Promise<T> {
async function requireJson<T>(
response: Response,
path: string,
parse: (value: unknown) => T,
maxResponseBytes?: number,
): Promise<T> {
if (!response.ok) {
if (maxResponseBytes !== undefined) {
await response.body?.cancel();
throw new Error(`${path} request failed (${response.status})`);
}
const text = await response.text();
throw new Error(text || `${path} request failed (${response.status})`);
}
return (await response.json()) as T;
const value: unknown = maxResponseBytes === undefined
? await response.json()
: await readBoundedJson(response, maxResponseBytes);
return parse(value);
}
export async function workspaceApiJson<T>(path: string): Promise<T> {
return requireJson<T>(await fetch(path), path);
export async function workspaceApiJson<T>(
path: string,
parse: (value: unknown) => T = (value) => value as T,
maxResponseBytes?: number,
): Promise<T> {
return requireJson(await fetch(path), path, parse, maxResponseBytes);
}
export async function workspaceApiJsonWithBody<T>(
path: string,
init: RequestInit,
parse: (value: unknown) => T = (value) => value as T,
maxResponseBytes?: number,
): Promise<T> {
return requireJson<T>(
return requireJson(
await fetch(path, {
headers: {
"content-type": "application/json",
@@ -202,6 +220,8 @@ export async function workspaceApiJsonWithBody<T>(
...init,
}),
path,
parse,
maxResponseBytes,
);
}
@@ -1,48 +1,13 @@
import type { MergeRequestListResponse } from "$lib/generated/ticket-api";
import type {
MergeRequestDetailResponse,
MergeRequestListResponse,
MergeRequestThreadEvent,
} from "$lib/generated/ticket-api";
import { workspaceApiPath } from "./http";
export type MergeRequestState = "open" | "merged" | "closed";
export type MergeRequestActor = {
runtime_id: string;
worker_id: string;
assignment_id: string;
};
export type MergeRequestThreadEvent = {
kind: string;
sequence: number;
at: string;
[key: string]: unknown;
};
export type MergeRequestRecord = {
merge_request_id: string;
workspace_id: string;
repository_key: string;
selector_from: string | null;
selector_to: string;
ticket_ids: string[];
state: MergeRequestState;
opened_by: MergeRequestActor;
created_at: string;
updated_at: string;
thread: MergeRequestThreadEvent[];
};
export type MergeRequestRefObservation = {
status: string;
ref: string | null;
observed_at: string;
};
export type MergeRequestDetail = MergeRequestRecord & {
source: MergeRequestRefObservation;
target: MergeRequestRefObservation;
linked_tickets: Array<{ ticket_id: string; key: string | null }>;
};
export type MergeRequestDetail = MergeRequestDetailResponse;
export type MergeRequestListPage = MergeRequestListResponse;
export type { MergeRequestThreadEvent };
export function mergeRequestCollectionPath(workspaceId: string): string {
return workspaceApiPath(workspaceId, "/merge-requests");
@@ -0,0 +1,143 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import {
parseMergeRequestDetailResponse,
parseObjectiveListResponse,
parseTicketListResponse,
} from "./ticket-browser.ts";
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, received ${actualJson}`);
}
}
function assertThrows(operation: () => unknown, message: string): void {
try {
operation();
} catch (error) {
if (error instanceof Error && error.message.includes(message)) return;
throw error;
}
throw new Error(`Expected operation to throw: ${message}`);
}
const page = {
has_more: false,
limit: 30,
next_cursor: null,
returned: 1,
sort: "updated_desc",
source_limit: null,
source_truncated: false,
};
Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => {
const parsed = parseTicketListResponse({
workspace_id: "workspace-a",
limit: 30,
items: [{
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "ready",
priority: "P2",
updated_at: null,
queued_by: null,
queued_at: null,
workspace_action_priority: "ready",
record_source: "sqlite",
}],
page,
invalid_records: [],
record_authority: "sqlite",
});
assertEquals(parsed.items[0]?.resource_key, "T-1");
});
Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits", () => {
const fixture = {
workspace_id: "workspace-a",
limit: 30,
items: [],
page: { ...page, returned: 0 },
invalid_records: [],
record_authority: "sqlite",
};
assertThrows(
() => parseTicketListResponse({ ...fixture, stale: true }),
"unknown field stale",
);
assertThrows(
() => parseTicketListResponse({ ...fixture, limit: 1001 }),
"0 through 1000",
);
});
Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => {
const parsed = parseObjectiveListResponse({
workspace_id: "workspace-a",
limit: 100,
items: [{
id: "objective-id",
resource_key: "O-1",
title: "Objective",
state: "active",
created_at: null,
updated_at: null,
summary: "Summary",
linked_tickets: ["T-1"],
record_source: "sqlite",
}],
invalid_records: [],
record_authority: "sqlite",
});
assertEquals(parsed.items[0]?.resource_key, "O-1");
});
Deno.test("Ticket Browser parser validates the canonical Merge Request thread", () => {
const parsed = parseMergeRequestDetailResponse({
workspace_id: "workspace-a",
merge_request_id: "mr-1",
repository_key: "main",
state: "open",
selector_from: "work/T-1",
selector_to: "develop",
ticket_ids: ["ticket-id"],
created_at: "2026-09-22T00:00:00Z",
updated_at: "2026-09-22T00:00:00Z",
thread: [{
kind: "review_requested",
event_id: "event-1",
sequence: 1,
subject_ref: "abc123",
requested_by: { runtime_id: "runtime-a", worker_id: "worker-a" },
reviewer: { runtime_id: "runtime-a", worker_id: "reviewer-a" },
created_at: "2026-09-22T00:00:00Z",
}],
source: {
status: "resolved",
ref: "abc123",
observed_at: "2026-09-22T00:00:00Z",
},
target: {
status: "resolved",
ref: "def456",
observed_at: "2026-09-22T00:00:00Z",
},
linked_tickets: [{ ticket_id: "ticket-id", key: "T-1" }],
});
assertEquals(parsed.thread[0]?.kind, "review_requested");
assertThrows(
() =>
parseMergeRequestDetailResponse({
...parsed,
thread: [{ ...parsed.thread[0], unexpected: true }],
}),
"unknown field unexpected",
);
});
File diff suppressed because it is too large Load Diff
@@ -385,7 +385,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
);
assert(
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
ticketsLoad.includes("loadLane(fetch, workspaceId") &&
ticketsLoad.includes('limit: "30"') &&
ticketsLoad.includes('states: states.join(",")') &&
ticketsLoad.includes("/tickets?${search}") &&
@@ -405,9 +405,12 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
"Ticket Kanban should combine related states into independent 30-item display windows",
);
assert(
generatedTicketApi.includes("Generated from yoi-workspace-server") &&
generatedTicketApi.includes(
"@generated by yoi-ticket-browser-openapi-typescript@1",
) &&
generatedTicketApi.includes("Input contract: 0.1.0 (sha256:") &&
generatedTicketApi.includes("export type TicketListResponse") &&
generatedTicketApi.includes("items: Array<TicketSummary>"),
generatedTicketApi.includes("items: Array<TicketListItemSummary>"),
"Ticket response types should come from the generated Server contract",
);
assert(
@@ -3,11 +3,17 @@ import type {
MergeRequestThreadEvent,
} from "./api/merge-requests.ts";
type ReviewEvent = Extract<MergeRequestThreadEvent, { kind: "review" }>;
type SourceEvidenceEvent = Extract<
MergeRequestThreadEvent,
{ kind: "review" | "review_requested" }
>;
function isCurrentSourceEvent(
event: MergeRequestThreadEvent,
source: string,
): boolean {
return event.subject_ref === source;
return "subject_ref" in event && event.subject_ref === source;
}
function requestHasTerminalOutcome(
@@ -26,16 +32,18 @@ export function sourceReviewFreshness(
const source = mergeRequest.source.ref;
if (!source) return "Source review unavailable: selector_from is unresolved.";
const effectiveReview = [...mergeRequest.thread].reverse().find((event) => {
if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) {
return false;
}
return !mergeRequest.thread.some(
(candidate) =>
candidate.kind === "review_revoked" &&
candidate.review_event_id === event.event_id,
);
});
const effectiveReview = [...mergeRequest.thread].reverse().find(
(event): event is ReviewEvent => {
if (event.kind !== "review" || !isCurrentSourceEvent(event, source)) {
return false;
}
return !mergeRequest.thread.some(
(candidate) =>
candidate.kind === "review_revoked" &&
candidate.review_event_id === event.event_id,
);
},
);
if (effectiveReview) {
return effectiveReview.decision === "approve"
? `Current source approved at exact ref ${source}.`
@@ -43,9 +51,8 @@ export function sourceReviewFreshness(
}
const latestEvidence = [...mergeRequest.thread].reverse().find(
(event) =>
(event.kind === "review" || event.kind === "review_requested") &&
typeof event.subject_ref === "string",
(event): event is SourceEvidenceEvent =>
event.kind === "review" || event.kind === "review_requested",
);
if (latestEvidence?.subject_ref && latestEvidence.subject_ref !== source) {
return `Fresh source review required: selector_from moved from ${latestEvidence.subject_ref} to ${source}.`;
@@ -82,15 +82,17 @@ export type WorkerCapabilities = SharedWorkerCapabilitySummary;
export type WorkerWorkdirAttachment = SharedWorkerWorkdirAttachmentSummary;
export type Worker = Omit<
SharedWorkerSummary,
"display_name" | "tags" | "worker_state" | "diagnostics"
> & {
display_name: string;
tags: string[];
worker_state?: WorkerStateSnapshot | null;
diagnostics: Diagnostic[];
};
export type Worker =
& Omit<
SharedWorkerSummary,
"display_name" | "tags" | "worker_state" | "diagnostics"
>
& {
display_name: string;
tags: string[];
worker_state?: WorkerStateSnapshot | null;
diagnostics: Diagnostic[];
};
export type WorkerOperationState = "accepted" | "unsupported" | "rejected";
@@ -149,61 +151,22 @@ export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
export type RepositoryLogResponse = SharedRepositoryLogResponse;
export type {
DerivedTicketRelation,
ObjectiveDetail,
ObjectiveLinkedTicketSummary,
ObjectiveListResponse,
ObjectiveSummary,
TicketDetail,
TicketEventDetail,
TicketListResponse,
TicketRelation,
TicketRelationBlocker,
TicketRelationNotice,
TicketRelationView,
TicketSummary,
} from "$lib/generated/ticket-api";
export type ObjectiveSummary = {
id: string;
resource_key: string;
title: string;
state: string;
updated_at?: string | null;
summary: string;
linked_tickets?: string[];
record_source?: string;
};
export type ObjectiveLinkedTicketSummary = {
id: string;
resource_key: string;
title: string;
state: string;
};
export type ObjectiveDetail = {
id: string;
resource_key: string;
title: string;
state: string;
created_at?: string | null;
updated_at?: string | null;
linked_tickets: string[];
linked_ticket_summaries: ObjectiveLinkedTicketSummary[];
body: string;
body_truncated: boolean;
record_source: string;
};
export type InvalidProjectRecord = {
label: string;
reason: string;
};
export type ObjectiveListResponse = {
workspace_id: string;
limit: number;
items: ObjectiveSummary[];
invalid_records: InvalidProjectRecord[];
record_authority: string;
};
export type {
TicketDetailDerivedRelation as DerivedTicketRelation,
TicketDetailRelation as TicketRelation,
TicketDetailRelationBlocker as TicketRelationBlocker,
TicketDetailRelationNotice as TicketRelationNotice,
TicketDetailRelationView as TicketRelationView,
TicketListItemSummary as TicketSummary,
} from "$lib/generated/ticket-api";
export type {
CompanionCancelRequest,
@@ -7,7 +7,7 @@ import {
} from "./ticket-panel.ts";
import type {
TicketDetail,
TicketSummary,
TicketListItemSummary as TicketSummary,
} from "../../generated/ticket-api.ts";
declare const Deno: {
@@ -120,7 +120,10 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
assertEquals(source.includes("ticket.action_eligibility.queue_tickets"), true);
assertEquals(
source.includes("ticket.action_eligibility.queue_tickets"),
true,
);
assertEquals(source.includes("This operation queues:"), true);
assertEquals(source.includes("outcome.queued_tickets.join"), true);
assertEquals(
@@ -1,5 +1,8 @@
import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api";
import type { TicketDetail, TicketSummary } from "$lib/generated/ticket-api";
import type {
TicketDetail,
TicketListItemSummary,
} from "$lib/generated/ticket-api";
export const TICKET_STATES = [
"planning",
@@ -38,7 +41,7 @@ export const TICKET_LANE_PAGE_SIZE = 30;
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
export type TicketLaneId = TicketLaneDefinition["id"];
export type TicketCardSummary = Pick<
TicketSummary,
TicketListItemSummary,
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
>;
@@ -1,33 +1,22 @@
import type { PageLoad } from "./$types";
import { loadJson } from "$lib/workspace/api/http";
import { mergeRequestCollectionPath } from "$lib/workspace/api/merge-requests";
import {
mergeRequestCollectionPath,
type MergeRequestListPage,
} from "$lib/workspace/api/merge-requests";
parseMergeRequestListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
export const load: PageLoad = async ({ params, fetch }) => {
try {
const response = await fetch(
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
workspaceId: params.workspaceId,
mergeRequests: null,
error: body?.error ?? body?.message ??
`Request failed (${response.status})`,
};
}
return {
workspaceId: params.workspaceId,
mergeRequests: body as MergeRequestListPage,
error: null,
};
} catch (error) {
return {
workspaceId: params.workspaceId,
mergeRequests: null,
error: error instanceof Error ? error.message : String(error),
};
}
const result = await loadJson(
fetch,
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
undefined,
parseMergeRequestListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
mergeRequests: result.data,
error: result.error,
};
};
@@ -1,4 +1,5 @@
<script lang="ts">
import type { MergeRequestThreadEvent } from "$lib/generated/ticket-api";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import {
sourceReviewFreshness,
@@ -13,8 +14,15 @@
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function textField(event: Record<string, unknown>, key: string): string | null {
const value = event[key];
function textField(
event: MergeRequestThreadEvent,
key: "subject_ref" | "decision" | "reason" | "body",
): string | null {
let value: unknown;
if (key === "subject_ref" && "subject_ref" in event) value = event.subject_ref;
if (key === "decision" && "decision" in event) value = event.decision;
if (key === "reason" && "reason" in event) value = event.reason;
if (key === "body" && "body" in event) value = event.body;
return typeof value === "string" && value.length > 0 ? value : null;
}
@@ -77,7 +85,7 @@
<div>
<header>
<strong>{event.kind}</strong>
<time>{prettyDate(event.at)}</time>
<time>{prettyDate(event.created_at)}</time>
</header>
{#if textField(event, "subject_ref")}
<p><code>{textField(event, "subject_ref")}</code></p>
@@ -1,38 +1,25 @@
import type { PageLoad } from "./$types";
import { loadJson } from "$lib/workspace/api/http";
import { mergeRequestDetailPath } from "$lib/workspace/api/merge-requests";
import {
type MergeRequestDetail,
mergeRequestDetailPath,
} from "$lib/workspace/api/merge-requests";
parseMergeRequestDetailResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
export const load: PageLoad = async ({ params, fetch }) => {
try {
const response = await fetch(
`${
mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)
}?limit=100`,
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: null,
error: body?.error ?? body?.message ??
`Request failed (${response.status})`,
};
}
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: body as MergeRequestDetail,
error: null,
};
} catch (error) {
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: null,
error: error instanceof Error ? error.message : String(error),
};
}
const result = await loadJson(
fetch,
`${
mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)
}?limit=100`,
undefined,
parseMergeRequestDetailResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: result.data,
error: result.error,
};
};
@@ -1,12 +1,18 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { ObjectiveListResponse } from "$lib/workspace/sidebar/types";
import {
parseObjectiveListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectives = await loadJson<ObjectiveListResponse>(
const objectives = await loadJson(
fetch,
apiPath("/objectives"),
undefined,
parseObjectiveListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
return {
@@ -1,18 +1,24 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseObjectiveDetail,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import {
canonicalResourceReference,
resourceKey,
} from "$lib/workspace/resource-links";
import type { ObjectiveDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const objectiveId = resourceKey(params.objectiveId);
const objective = await loadJson<ObjectiveDetail>(
const objective = await loadJson(
fetch,
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
undefined,
parseObjectiveDetail,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (objective.data) {
@@ -3,10 +3,13 @@
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import {
TICKET_BROWSER_API_LOAD_POLICY,
parseTicketListResponse,
} from "$lib/workspace/api/ticket-browser";
import type {
QueryPage,
TicketListResponse,
TicketSummary,
TicketListItemSummary as TicketSummary,
} from "$lib/generated/ticket-api";
import { ticketHref } from "$lib/workspace/resource-links";
import {
@@ -70,15 +73,18 @@
states: lane.states.join(","),
cursor: lane.page.next_cursor,
});
const response = await fetch(
const result = await loadJson(
fetch,
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
undefined,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!response.ok) {
throw new Error(`追加読み込みに失敗しました (${response.status})`);
if (!result.data) {
throw new Error(result.error ?? "追加読み込みに失敗しました");
}
const page = (await response.json()) as TicketListResponse;
lane.tickets = mergeTickets(lane.tickets, page.items);
lane.page = page.page;
lane.tickets = mergeTickets(lane.tickets, result.data.items);
lane.page = result.data.page;
} catch (error) {
lane.error = error instanceof Error ? error.message : String(error);
} finally {
@@ -1,5 +1,9 @@
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import type { TicketListResponse } from "$lib/generated/ticket-api";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { PageLoad } from "./$types";
@@ -17,43 +21,51 @@ export type TicketLanePage = {
response: TicketListResponse;
};
async function loadLane(
fetchFn: typeof fetch,
workspaceId: string,
laneId: TicketLaneId,
): Promise<TicketLanePage> {
const states = LANE_STATES[laneId];
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const result = await loadJson(
fetchFn,
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
undefined,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
);
if (!result.data) {
throw new Error(result.error ?? `failed to load ${laneId} Ticket lane`);
}
return { states: [...states], response: result.data };
}
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const [entries, orchestrator] = await Promise.all([
Promise.all(
Object.entries(LANE_STATES).map(async ([laneId, states]) => {
const search = new URLSearchParams({
limit: "30",
states: states.join(","),
});
const response = await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
);
if (!response.ok) {
throw new Error(
`failed to load ${laneId} Ticket lane (${response.status})`,
);
}
return [
laneId,
{ states: [...states], response: await response.json() },
] as const;
}),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
const [readyPlanning, inprogressQueued, doneClosed, orchestrator] =
await Promise.all([
loadLane(fetch, workspaceId, "ready-planning"),
loadLane(fetch, workspaceId, "inprogress-queued"),
loadLane(fetch, workspaceId, "done-closed"),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
return {
workspaceId,
ticketLanes: Object.fromEntries(entries) as unknown as Record<
TicketLaneId,
TicketLanePage
>,
ticketLanes: {
"ready-planning": readyPlanning,
"inprogress-queued": inprogressQueued,
"done-closed": doneClosed,
},
orchestrator,
};
};
@@ -6,6 +6,12 @@
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import {
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketDetail,
parseTicketQueueOutcome,
parseTicketRoleAssignmentMutationResponse,
} from "$lib/workspace/api/ticket-browser";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import {
relationLabel,
@@ -38,11 +44,6 @@
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = $derived(data.repositories.data);
type QueueOutcome = {
requested_ticket: string;
queued_tickets: string[];
};
let ticket = $state<TicketDetail>(loadedTicket);
const mergeRequest = $derived(ticket.merge_request);
let editing = $state(false);
@@ -146,10 +147,10 @@
busy = action;
errorMessage = null;
try {
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
const response = await workspaceApiJsonWithBody(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return false;
applyTicket(response);
return true;
@@ -171,12 +172,18 @@
errorMessage = null;
queueMessage = null;
try {
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
const outcome = await workspaceApiJsonWithBody(
`${path}/queue`,
{ method: "POST", body: JSON.stringify({}) },
parseTicketQueueOutcome,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(updatedTicket);
@@ -210,9 +217,15 @@
expected_assignment_id: null,
}),
},
parseTicketRoleAssignmentMutationResponse,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
const updatedTicket = await workspaceApiJson(
path,
parseTicketDetail,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) {
@@ -317,7 +330,7 @@
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/thread", {
await mutate("thread", "/events", {
role: threadRole,
body: threadBody.trim(),
})
@@ -1,12 +1,16 @@
import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import {
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import {
canonicalResourceReference,
resourceKey,
} from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { TicketDetail } from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
export const load = (async ({ fetch, params }) => {
@@ -16,11 +20,19 @@ export const load = (async ({ fetch, params }) => {
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson(
fetch,
ticketPath,
undefined,
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (ticket.data) {
+95 -57
View File
@@ -14,11 +14,88 @@ function assertEquals(actual: unknown, expected: unknown): void {
}
}
function event(
kind: string,
fields: Record<string, unknown>,
): MergeRequestThreadEvent {
return { kind, sequence: 1, at: "2026-09-01T00:00:00Z", ...fields };
const worker = { runtime_id: "runtime", worker_id: "worker" };
const createdAt = "2026-09-01T00:00:00Z";
type ReviewRequested = Extract<
MergeRequestThreadEvent,
{ kind: "review_requested" }
>;
type Review = Extract<MergeRequestThreadEvent, { kind: "review" }>;
type ReviewRevoked = Extract<
MergeRequestThreadEvent,
{ kind: "review_revoked" }
>;
type ReviewCancelled = Extract<
MergeRequestThreadEvent,
{ kind: "review_cancelled" }
>;
function reviewRequested(
eventId: string,
subjectRef: string,
): ReviewRequested {
return {
kind: "review_requested",
event_id: eventId,
sequence: 1,
subject_ref: subjectRef,
requested_by: worker,
reviewer: worker,
created_at: createdAt,
};
}
function review(
eventId: string,
requestEventId: string,
subjectRef: string,
): Review {
return {
kind: "review",
event_id: eventId,
sequence: 1,
request_event_id: requestEventId,
subject_ref: subjectRef,
decision: "approve",
body: "approved",
findings: [],
reviewer: worker,
created_at: createdAt,
};
}
function reviewRevoked(
eventId: string,
reviewEventId: string,
subjectRef: string,
): ReviewRevoked {
return {
kind: "review_revoked",
event_id: eventId,
sequence: 1,
review_event_id: reviewEventId,
subject_ref: subjectRef,
reason: "superseded",
revoked_by: worker,
created_at: createdAt,
};
}
function reviewCancelled(
eventId: string,
requestEventId: string,
subjectRef: string,
): ReviewCancelled {
return {
kind: "review_cancelled",
event_id: eventId,
sequence: 1,
request_event_id: requestEventId,
subject_ref: subjectRef,
reason: "cancelled",
created_at: createdAt,
};
}
function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
@@ -30,22 +107,17 @@ function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
selector_from: "work/ticket",
selector_to: "develop",
state: "open",
opened_by: {
runtime_id: "runtime",
worker_id: "worker",
assignment_id: "assignment",
},
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
created_at: createdAt,
updated_at: createdAt,
source: {
status: "known",
ref: "source-2",
observed_at: "2026-09-01T00:00:00Z",
observed_at: createdAt,
},
target: {
status: "known",
ref: "target-2",
observed_at: "2026-09-01T00:00:00Z",
observed_at: createdAt,
},
linked_tickets: [{ ticket_id: "T-1", key: "T-1" }],
thread,
@@ -54,20 +126,9 @@ function detail(thread: MergeRequestThreadEvent[]): MergeRequestDetail {
Deno.test("revoked review requires a fresh review instead of appearing pending", () => {
const mergeRequest = detail([
event("review_requested", {
event_id: "request-1",
subject_ref: "source-2",
}),
event("review", {
event_id: "review-1",
request_event_id: "request-1",
subject_ref: "source-2",
decision: "approve",
}),
event("review_revoked", {
event_id: "revoke-1",
review_event_id: "review-1",
}),
reviewRequested("request-1", "source-2"),
review("review-1", "request-1", "source-2"),
reviewRevoked("revoke-1", "review-1", "source-2"),
]);
assertEquals(
@@ -78,10 +139,7 @@ Deno.test("revoked review requires a fresh review instead of appearing pending",
Deno.test("unresolved review request for the current source is pending", () => {
const mergeRequest = detail([
event("review_requested", {
event_id: "request-2",
subject_ref: "source-2",
}),
reviewRequested("request-2", "source-2"),
]);
assertEquals(
@@ -92,16 +150,8 @@ Deno.test("unresolved review request for the current source is pending", () => {
Deno.test("completed or cancelled request is not projected as pending", () => {
const approved = detail([
event("review_requested", {
event_id: "request-3",
subject_ref: "source-2",
}),
event("review", {
event_id: "review-3",
request_event_id: "request-3",
subject_ref: "source-2",
decision: "approve",
}),
reviewRequested("request-3", "source-2"),
review("review-3", "request-3", "source-2"),
]);
assertEquals(
sourceReviewFreshness(approved),
@@ -109,15 +159,8 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
);
const cancelled = detail([
event("review_requested", {
event_id: "request-4",
subject_ref: "source-2",
}),
event("review_cancelled", {
event_id: "cancel-4",
request_event_id: "request-4",
subject_ref: "source-2",
}),
reviewRequested("request-4", "source-2"),
reviewCancelled("cancel-4", "request-4", "source-2"),
]);
assertEquals(
sourceReviewFreshness(cancelled),
@@ -127,12 +170,7 @@ Deno.test("completed or cancelled request is not projected as pending", () => {
Deno.test("source movement explains the exact stale and current refs", () => {
const mergeRequest = detail([
event("review", {
event_id: "review-old",
request_event_id: "request-old",
subject_ref: "source-1",
decision: "approve",
}),
review("review-old", "request-old", "source-1"),
]);
assertEquals(