merge: compose classification tags with Ticket case migration

This commit is contained in:
2026-10-11 02:57:45 +09:00
41 changed files with 6752 additions and 229 deletions
@@ -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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// 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
+22 -4
View File
@@ -1,10 +1,12 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// 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 Array_of_WorkspaceTag = Array<WorkspaceTag>;
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
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; };
@@ -89,6 +91,8 @@ 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 TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence";
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>; };
@@ -99,7 +103,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s
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 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; tags: Array<WorkspaceTag>; 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; };
@@ -121,7 +125,7 @@ export type TicketIntakeSummary = { author?: string | null; body: MarkdownText;
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 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; tags?: Array<WorkspaceTag>; 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; };
@@ -129,6 +133,12 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: 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; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
export type TicketQueryItem = { blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
export type TicketQueryRequest = { attention?: Array<TicketAttention>; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
export type TicketQueryResponse = { items: Array<TicketQueryItem>; page: QueryPage; record_authority: string; };
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; };
@@ -147,10 +157,18 @@ 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 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; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; };
export type TicketTagSetRequest = { expected_tag_ids: Array<string>; operation_key: string; tag_ids: Array<string>; };
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 WorkspaceTag = { deleted: boolean; id: string; last_operation_key: string; name: string; workspace_id: string; };
export type WorkspaceTagAction = { kind: "create"; name: string; } | { expected_last_operation_key: string; kind: "rename"; name: string; tag_id: string; } | { expected_last_operation_key: string; kind: "delete"; tag_id: string; };
export type WorkspaceTagMutation = { action: WorkspaceTagAction; operation_key: string; };
@@ -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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// 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:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
// 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
@@ -10,6 +10,7 @@ import {
parseTicketListResponse,
parseTicketRecordRef,
parseTicketRoleAssignmentMutationResponse,
parseTicketSummary,
} from "./ticket-browser.ts";
function assertEquals(actual: unknown, expected: unknown): void {
@@ -48,6 +49,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () =>
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
tags: [],
state: "open",
priority: "P2",
updated_at: null,
@@ -176,6 +178,7 @@ function ticketDetailFixture() {
resource_key: "T-1",
risk_flags: [],
state: "open",
tags: [],
targets: [
{
repository_key: "docs",
@@ -452,6 +455,14 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
);
});
const tags = ["ready", "closed"].map((name) => ({
workspace_id: "workspace-a",
id: `tag-${name}`,
name,
last_operation_key: `create-${name}`,
deleted: false,
}));
const closure = {
operation_key: "close-1",
reason: "not_planned",
@@ -499,10 +510,11 @@ const legacyHandoff = {
resolution: "Historical result, not a current case state",
};
Deno.test("case projections retain reasons, actors, references and migration history after reopening", () => {
Deno.test("tags named ready and closed coexist with case judgment and retained reopen history without inferring state", () => {
const fixture = {
...ticketDetailFixture(),
state: "open",
tags,
closure_operation_key: "correct-1",
current_closure: null,
closure_history: closureHistory,
@@ -513,11 +525,16 @@ Deno.test("case projections retain reasons, actors, references and migration his
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,
);
assertEquals(parsed.tags, tags);
const closed = parseTicketDetail({
...fixture,
state: "closed",
current_closure: closure,
});
assertEquals(closed.state, "closed");
assertEquals(closed.current_closure, closure);
assertEquals(closed.closure_history, closureHistory);
assertEquals(closed.tags, tags);
const list = parseTicketListResponse({
workspace_id: "workspace-a",
limit: 30,
@@ -529,6 +546,7 @@ Deno.test("case projections retain reasons, actors, references and migration his
resource_key: "T-1",
title: "Ticket",
state: "closed",
tags,
priority: "P2",
record_source: "sqlite",
workspace_action_priority: "background",
@@ -537,10 +555,73 @@ Deno.test("case projections retain reasons, actors, references and migration his
legacy_handoff: legacyHandoff,
}],
});
assertEquals(list.items[0].state, "closed");
assertEquals(list.items[0].tags, tags);
assertEquals(list.items[0].current_closure, closure);
assertEquals(list.items[0].legacy_handoff, legacyHandoff);
});
Deno.test("Ticket tag projections reject missing collections and malformed identities, types and unknown fields", () => {
const parsers = [
(tags: unknown) => parseTicketDetail({ ...ticketDetailFixture(), tags }),
(tags: unknown) =>
parseTicketSummary({
id: "ticket-id",
slug: "ticket",
title: "Ticket",
kind: "task",
labels: [],
priority: "P2",
status: "Open",
workflow_state: "open",
workflow_state_explicit: true,
tags,
}),
(tags: unknown) =>
parseTicketListResponse({
workspace_id: "workspace-a",
limit: 30,
page,
invalid_records: [],
record_authority: "sqlite",
items: [{
id: "ticket-id",
resource_key: "T-1",
title: "Ticket",
state: "open",
priority: "P2",
record_source: "sqlite",
workspace_action_priority: "background",
tags,
}],
}),
];
const tag = tags[0];
for (const parse of parsers) {
for (const value of [undefined, null, {}, "ready"]) {
assertThrows(() => parse(value), "tags must be an array");
}
for (
const [value, message] of [
[{ ...tag, id: undefined }, "id must be a string"],
[{ ...tag, id: 1 }, "id must be a string"],
[{ ...tag, id: " " }, "id must be nonempty"],
[{ ...tag, id: "é".repeat(65) }, "at most 128 UTF-8 bytes"],
[{ ...tag, workspace_id: false }, "workspace_id must be a string"],
[{ ...tag, name: null }, "name must be a string"],
[
{ ...tag, last_operation_key: 1 },
"last_operation_key must be a string",
],
[{ ...tag, deleted: "false" }, "deleted must be a boolean"],
[{ ...tag, state: "closed" }, "unknown field state"],
] as const
) {
assertThrows(() => parse([value]), message);
}
}
});
Deno.test("case decoders reject legacy current states and malformed closure judgments", () => {
const fixture = ticketDetailFixture();
for (const state of ["planning", "ready", "queued", "inprogress", "done"]) {
@@ -46,6 +46,7 @@ import type {
TicketRoleAssignmentSummary,
TicketSummary,
TicketTarget,
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
@@ -331,6 +332,31 @@ function caseProjection(item: Record<string, unknown>, label: string) {
};
}
function parseWorkspaceTag(value: unknown, label: string): WorkspaceTag {
const item = object(value, label);
exact(item, [
"workspace_id",
"id",
"name",
"last_operation_key",
"deleted",
], label);
const id = string(item.id, `${label}.id`);
if (id.trim().length === 0 || new TextEncoder().encode(id).length > 128) {
throw new Error(`${label}.id must be nonempty and at most 128 UTF-8 bytes`);
}
return {
workspace_id: string(item.workspace_id, `${label}.workspace_id`),
id,
name: string(item.name, `${label}.name`),
last_operation_key: string(
item.last_operation_key,
`${label}.last_operation_key`,
),
deleted: boolean(item.deleted, `${label}.deleted`),
};
}
export function parseTicketSummary(value: unknown): TicketSummary {
const label = "Ticket summary";
const item = object(value, label);
@@ -345,6 +371,7 @@ export function parseTicketSummary(value: unknown): TicketSummary {
"resource_key",
"slug",
"status",
"tags",
"title",
"updated_at",
"workflow_state",
@@ -371,6 +398,7 @@ export function parseTicketSummary(value: unknown): TicketSummary {
),
slug: string(item.slug, `${label}.slug`),
status,
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
workflow_state: parseTicketState(
@@ -400,6 +428,7 @@ function parseTicketListItem(
"record_source",
"resource_key",
"state",
"tags",
"title",
"updated_at",
"workspace_action_priority",
@@ -413,6 +442,7 @@ function parseTicketListItem(
record_source: string(item.record_source, `${label}.record_source`),
resource_key: string(item.resource_key, `${label}.resource_key`),
state: parseTicketState(item.state, `${label}.state`),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
workspace_action_priority: string(
@@ -960,6 +990,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"resource_key",
"risk_flags",
"state",
"tags",
"targets",
"title",
"updated_at",
@@ -1036,6 +1067,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
resource_key: string(item.resource_key, `${label}.resource_key`),
risk_flags: strings(item.risk_flags, `${label}.risk_flags`),
state: parseTicketState(item.state, `${label}.state`),
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
@@ -34,6 +34,13 @@ const summary = {
title: "Case",
kind: "task",
labels: [],
tags: [{
workspace_id: "workspace one",
id: "tag-closed",
name: "closed",
last_operation_key: "create-tag",
deleted: false,
}],
priority: "P2",
status: "Closed",
workflow_state: "closed",
@@ -73,11 +80,9 @@ Deno.test("case actions carry content/state/last-closure CAS without browser aut
assertEquals(JSON.parse(String(options.body)), request);
return Promise.resolve(Response.json(summary));
};
assertEquals(
(await updateTicketCase("workspace one", "T/1", request))
.workflow_state,
"closed",
);
const result = await updateTicketCase("workspace one", "T/1", request);
assertEquals(result.workflow_state, "closed");
assertEquals(result.tags, summary.tags);
}
} finally {
globalThis.fetch = original;
@@ -25,6 +25,7 @@ export function fixtureTicket(
current_closure: null,
legacy_handoff: null,
state,
tags: [],
priority: "normal",
queued_at: null,
queued_by: null,
@@ -29,15 +29,35 @@ Deno.test("ticketLanes separates open and closed cases and orders updates determ
id: "old",
state: "open",
title: "Open old",
tags: [{
workspace_id: "workspace-a",
id: "tag-closed",
name: "closed",
last_operation_key: "create-closed",
deleted: false,
}],
updated_at: "2026-07-29T12:00:00Z",
},
{
id: "new",
state: "open",
title: "Open new",
tags: [],
updated_at: "2026-07-30T12:00:00Z",
},
{ id: "closed", state: "closed", title: "Closed", updated_at: null },
{
id: "closed",
state: "closed",
title: "Closed",
tags: [{
workspace_id: "workspace-a",
id: "tag-ready",
name: "ready",
last_operation_key: "create-ready",
deleted: false,
}],
updated_at: null,
},
] as TicketSummary[];
const lanes = ticketLanes(tickets);
assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]);