feat: support permissioned multi-repository ticket targets

This commit is contained in:
2026-09-23 23:11:37 +09:00
parent 9c107eaf74
commit 9ccf2fbc3b
36 changed files with 4093 additions and 1360 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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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
+15 -3
View File
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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
@@ -13,7 +13,7 @@ export type BrowserEditTicketRequest = { author?: string | null; body?: string |
export type BrowserQueueTicketRequest = { [key: string]: never; };
export type BrowserTicketTargetEdit = { action: "set"; ref_selector?: string | null; repository_key: string; } | { action: "clear"; };
export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; };
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
@@ -55,6 +55,8 @@ 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 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; };
@@ -87,7 +89,7 @@ export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contrib
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 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; 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 TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
@@ -117,6 +119,8 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; m
export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; };
export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; };
export type TicketReference = { kind: string; target: string; };
export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssignmentRecord | null; ticket_id: string; workspace_id: string; };
@@ -124,3 +128,11 @@ export type TicketRoleAssignmentMutationResponse = { assignment?: TicketRoleAssi
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; };
export type TicketStatus = "Open" | "Closed";
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
export type TicketTargetAccess = "read_only" | "read_write";
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
@@ -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:7a9d11f50cc41eca55a6dded4b8f55aa6d23b1beb2dc69b0a9b6c8349efc894f)
// Input contract: 0.1.0 (sha256:80be292e32441730f7de890e64836f4ca9655576d655e60e6d44daf5227a1b1c)
// 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
@@ -5,7 +5,9 @@ declare const Deno: {
import {
parseMergeRequestDetailResponse,
parseObjectiveListResponse,
parseTicketDetail,
parseTicketListResponse,
parseTicketRecordRef,
} from "./ticket-browser.ts";
function assertEquals(actual: unknown, expected: unknown): void {
@@ -78,6 +80,107 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits"
);
});
Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => {
const fixture = {
action_eligibility: {
blockers: [],
can_assign_orchestrator: false,
can_queue: false,
can_start_manual_coder: false,
can_unassign_orchestrator: false,
queue_tickets: [],
},
artifact_count: 0,
artifacts: [],
assignment_diagnostics: [],
assignments: [],
body: "Body",
body_truncated: false,
current_coder: null,
event_count: 0,
event_page: { ...page, returned: 0 },
events: [],
evidence: {
approved_current_subject: false,
complete_for_integration: false,
has_commit: false,
has_current_subject_ref: false,
has_merge_request: false,
has_review_request: false,
missing: [],
review_after_rescope: false,
review_status: null,
unresolved_request_changes: false,
},
id: "ticket-id",
implementation_reports: [],
item_revision: "revision-1",
linked_objectives: [],
merge_request: null,
priority: "P2",
queued_at: null,
queued_by: null,
readiness: null,
record_source: "sqlite",
relations: { blockers: [], incoming: [], notices: [], outgoing: [] },
resolution: null,
resource_key: "T-1",
risk_flags: [],
state: "planning",
targets: [
{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
},
{
repository_key: "main",
ref_selector: "develop",
access: "read_write",
},
],
title: "Ticket",
};
const parsed = parseTicketDetail(fixture);
assertEquals(parsed.targets.length, 2);
assertEquals(parsed.targets[0]?.repository_key, "docs");
assertEquals(parsed.targets[0]?.access, "read_only");
assertEquals(parsed.targets[1]?.repository_key, "main");
assertEquals(parsed.targets[1]?.access, "read_write");
assertThrows(
() =>
parseTicketDetail({
...fixture,
targets: [{ ...fixture.targets[0], access: "write" }],
}),
"targets[0].access is invalid",
);
assertThrows(
() =>
parseTicketDetail({
...fixture,
repository_key: "main",
ref_selector: "develop",
}),
"unknown field repository_key",
);
});
Deno.test("Ticket Browser parser validates Ticket create responses", () => {
const parsed = parseTicketRecordRef({
id: "ticket-id",
resource_key: "T-1",
slug: "ticket",
status: "Open",
});
assertEquals(parsed.resource_key, "T-1");
assertThrows(
() => parseTicketRecordRef({ ...parsed, status: "open" }),
"status is invalid",
);
});
Deno.test("Ticket Browser parser accepts the generated Objective list shape", () => {
const parsed = parseObjectiveListResponse({
workspace_id: "workspace-a",
@@ -32,9 +32,11 @@ import type {
TicketListResponse,
TicketMergeRequestSummary,
TicketQueueOutcome,
TicketRef,
TicketRoleAssignmentMutationResponse,
TicketRoleAssignmentRecord,
TicketRoleAssignmentSummary,
TicketTarget,
} from "$lib/generated/ticket-api.ts";
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
@@ -671,6 +673,23 @@ function parseObjectiveLink(value: unknown, label: string) {
};
}
function parseTicketTarget(value: unknown, label: string): TicketTarget {
const item = object(value, label);
exact(item, ["access", "ref_selector", "repository_key"], label);
const access = string(item.access, `${label}.access`);
if (access !== "read_only" && access !== "read_write") {
throw new Error(`${label}.access is invalid`);
}
return {
access,
ref_selector: optionalNullableString(
item.ref_selector,
`${label}.ref_selector`,
),
repository_key: string(item.repository_key, `${label}.repository_key`),
};
}
export function parseTicketDetail(value: unknown): TicketDetail {
const label = "Ticket detail";
const item = object(value, label);
@@ -698,13 +717,12 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"queued_by",
"readiness",
"record_source",
"ref_selector",
"relations",
"repository_key",
"resolution",
"resource_key",
"risk_flags",
"state",
"targets",
"title",
"updated_at",
], label);
@@ -764,24 +782,36 @@ export function parseTicketDetail(value: unknown): TicketDetail {
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
readiness: optionalNullableString(item.readiness, `${label}.readiness`),
record_source: string(item.record_source, `${label}.record_source`),
ref_selector: optionalNullableString(
item.ref_selector,
`${label}.ref_selector`,
),
relations: parseRelationView(item.relations, `${label}.relations`),
repository_key: optionalNullableString(
item.repository_key,
`${label}.repository_key`,
),
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`),
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
title: string(item.title, `${label}.title`),
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
};
}
export function parseTicketRecordRef(value: unknown): TicketRef {
const label = "Ticket create response";
const item = object(value, label);
exact(item, ["id", "resource_key", "slug", "status"], label);
const status = string(item.status, `${label}.status`);
if (status !== "Open" && status !== "Closed") {
throw new Error(`${label}.status is invalid`);
}
return {
id: string(item.id, `${label}.id`),
resource_key: optionalNullableString(
item.resource_key,
`${label}.resource_key`,
),
slug: string(item.slug, `${label}.slug`),
status,
};
}
export function parseTicketQueueOutcome(value: unknown): TicketQueueOutcome {
const item = object(value, "Ticket queue outcome");
exact(item, ["queued_tickets", "requested_ticket"], "Ticket queue outcome");
@@ -342,6 +342,27 @@
display: grid;
gap: 0.75rem;
}
.ticket-target-list, .ticket-target-row {
display: grid;
gap: 0.75rem;
}
.ticket-target-row {
min-width: 0;
margin: 0;
border: 0;
border-top: 1px solid var(--line);
padding: var(--space-3) 0 0;
}
.ticket-target-row:first-child {
border-top: 0;
padding-top: 0;
}
.ticket-target-row legend {
color: var(--text-strong);
font-size: var(--font-size-compact);
font-weight: 700;
padding: 0;
}
.ticket-editor label, .ticket-control-form label {
display: grid;
gap: 0.3rem;
@@ -349,6 +370,7 @@
font-size: var(--font-size-body);
}
.ticket-editor input,
.ticket-editor select,
.ticket-editor textarea,
.ticket-control-form input,
.ticket-control-form select,
@@ -88,8 +88,18 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
const ticket = {
id: "00001KYRRDVH9",
title: "Ticket panel API",
repository_key: "main repo",
ref_selector: "work/ticket",
targets: [
{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
},
{
repository_key: "main repo",
ref_selector: "work/ticket",
access: "read_write",
},
],
} as TicketDetail;
assertEquals(
@@ -110,6 +120,38 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
);
});
Deno.test("ticket worker launch never selects a read_only or ambiguous write target", () => {
const readOnly = {
id: "ticket-read-only",
title: "Read only",
targets: [{
repository_key: "docs",
ref_selector: "main",
access: "read_only",
}],
} as TicketDetail;
const readOnlyUrl = new URL(
ticketWorkerLaunchHref("workspace", readOnly, "coder"),
"https://example.test",
);
assertEquals(readOnlyUrl.searchParams.has("repositoryKey"), false);
assertEquals(readOnlyUrl.searchParams.has("refSelector"), false);
const ambiguous = {
...readOnly,
targets: [
{ repository_key: "main", ref_selector: "develop", access: "read_write" },
{ repository_key: "docs", ref_selector: "main", access: "read_write" },
],
} as TicketDetail;
const ambiguousUrl = new URL(
ticketWorkerLaunchHref("workspace", ambiguous, "coder"),
"https://example.test",
);
assertEquals(ambiguousUrl.searchParams.has("repositoryKey"), false);
assertEquals(ambiguousUrl.searchParams.has("refSelector"), false);
});
Deno.test("ticket detail uses server-derived role assignment actions", async () => {
const source = await Deno.readTextFile(
new URL(
@@ -138,6 +180,26 @@ Deno.test("ticket detail uses server-derived role assignment actions", async ()
assertEquals(source.includes('kind: "workspace_agent"'), true);
assertEquals(source.includes('kind: "worker"'), true);
assertEquals(source.includes("ticket.assignee"), false);
assertEquals(source.includes('action: "set", targets'), true);
assertEquals(source.includes("updatedTicket.targets"), true);
assertEquals(source.includes("ticket.repository_key"), false);
assertEquals(source.includes("ticket.ref_selector"), false);
});
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
const source = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/+page.svelte",
import.meta.url,
),
);
assertEquals(source.includes("const request: NewTicket"), true);
assertEquals(source.includes("targets: normalizedCreateTargets()"), true);
assertEquals(source.includes('value="read_write"'), true);
assertEquals(source.includes('value="read_only"'), true);
assertEquals(source.includes("repository_key: create"), false);
assertEquals(source.includes("ref_selector: create"), false);
});
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
@@ -107,23 +107,26 @@ export function ticketWorkerMessage(
export function ticketWorkerLaunchHref(
workspaceId: string,
ticket: Pick<
TicketDetail,
"id" | "title" | "repository_key" | "ref_selector"
>,
ticket: Pick<TicketDetail, "id" | "title" | "targets">,
role: TicketWorkerRole,
): string {
const implementationTargets = ticket.targets.filter((target) =>
target.access === "read_write"
);
const implementationTarget = implementationTargets.length === 1
? implementationTargets[0]
: null;
const params = new URLSearchParams({
ticketId: ticket.id,
ticketTitle: ticket.title,
ticketRole: role,
initialInput: ticketWorkerMessage(ticket.id, role),
});
if (ticket.repository_key) {
params.set("repositoryKey", ticket.repository_key);
}
if (ticket.ref_selector) {
params.set("refSelector", ticket.ref_selector);
if (implementationTarget) {
params.set("repositoryKey", implementationTarget.repository_key);
if (implementationTarget.ref_selector) {
params.set("refSelector", implementationTarget.ref_selector);
}
}
return `/w/${
encodeURIComponent(workspaceId)
@@ -1,15 +1,25 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import type { ApiResult } from "$lib/workspace/api/http";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
loadJson,
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import {
TICKET_BROWSER_API_LOAD_POLICY,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketListResponse,
parseTicketRecordRef,
} from "$lib/workspace/api/ticket-browser";
import type {
NewTicket,
QueryPage,
TicketListItemSummary as TicketSummary,
TicketTarget,
TicketTargetAccess,
} from "$lib/generated/ticket-api";
import { ticketHref } from "$lib/workspace/resource-links";
import {
@@ -26,6 +36,12 @@
error: string | null;
};
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
let laneState = $state<Record<string, LaneState>>(
@@ -46,6 +62,25 @@
untrack(() => data.orchestrator),
);
let orchestratorStarting = $state(false);
let creatingTicket = $state(false);
let createBusy = $state(false);
let createError = $state<string | null>(null);
let createTitle = $state("");
let createBody = $state("");
let createTargets = $state<EditableTicketTarget[]>([]);
const repositories = $derived(data.repositories.data?.items ?? []);
const createTargetsSavable = $derived.by(() => {
const keys = new Set<string>();
for (const target of createTargets) {
if (
!repositories.some((repository) =>
repository.repository_key === target.repository_key
) || keys.has(target.repository_key)
) return false;
keys.add(target.repository_key);
}
return true;
});
const tickets = $derived(
Object.values(laneState).flatMap((lane) => lane.tickets),
);
@@ -99,6 +134,61 @@
if (remaining <= 96) void loadMore(laneId);
}
function addCreateTarget(): void {
createTargets.push({
repository_key: "",
ref_selector: "",
access: createTargets.some((target) => target.access === "read_write")
? "read_only"
: "read_write",
});
}
function removeCreateTarget(index: number): void {
createTargets.splice(index, 1);
}
function normalizedCreateTargets(): TicketTarget[] {
return createTargets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
async function createTicket(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (createBusy || !createTitle.trim() || !createTargetsSavable) return;
createBusy = true;
createError = null;
const request: NewTicket = {
title: createTitle.trim(),
kind: "task",
priority: "P2",
labels: [],
body: createBody,
risk_flags: [],
workflow_state: "planning",
targets: normalizedCreateTargets(),
};
try {
const created = await workspaceApiJsonWithBody(
workspaceApiPath(data.workspaceId, "/tickets"),
{ method: "POST", body: JSON.stringify(request) },
parseTicketRecordRef,
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
);
const reference = created.resource_key ?? created.id;
await goto(
`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(reference)}`,
);
} catch (error) {
createError = error instanceof Error ? error.message : String(error);
} finally {
createBusy = false;
}
}
async function startOrchestrator() {
if (orchestratorStarting || orchestrator.data?.online) return;
orchestratorStarting = true;
@@ -132,6 +222,11 @@
</p>
</div>
<div class="ticket-panel-controls">
<button
class="workspace-primary-button"
type="button"
onclick={() => creatingTicket = !creatingTicket}
>{creatingTicket ? "Cancel" : "Add Ticket"}</button>
<div class="orchestrator-status" data-online={orchestrator.data?.online ?? false}>
<span class="orchestrator-status-dot"></span>
<div>
@@ -156,6 +251,50 @@
</div>
</header>
{#if creatingTicket}
<form class="ticket-editor ticket-create-form" onsubmit={createTicket}>
<h2>New Ticket</h2>
{#if createError}<p class="workspace-callout is-error" role="alert">{createError}</p>{/if}
{#if data.repositories.error}
<p class="workspace-callout is-error" role="alert">Repositories: {data.repositories.error}</p>
{/if}
<label>Title<input bind:value={createTitle} required /></label>
<label>Body<textarea bind:value={createBody} rows="8"></textarea></label>
<div class="ticket-target-list">
<strong>Repository targets</strong>
{#each createTargets as target, index}
{@const selectedRepository = repositories.find((repository) => repository.repository_key === target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} required>
<option value="">Choose repository</option>
{#each repositories as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} /></label>
<label>Access
<select bind:value={target.access}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeCreateTarget(index)}>Remove target</button>
</fieldset>
{/each}
<button class="workspace-secondary-button" type="button" onclick={addCreateTarget}>Add target</button>
</div>
{#if !createTargetsSavable}
<p class="workspace-empty-copy">Every target row must select a different repository. The exact one read-write target and selector requirements are enforced when the Ticket is marked ready.</p>
{/if}
<button class="workspace-primary-button" type="submit" disabled={createBusy || !createTitle.trim() || !createTargetsSavable}>
{createBusy ? "Creating…" : "Create Ticket"}
</button>
</form>
{/if}
{#if orchestrator.error}
<p class="workspace-callout is-error">
Orchestrator status: {orchestrator.error}
@@ -1,4 +1,5 @@
import { parseBrowserWorkspaceOrchestratorResponse } from "$lib/workspace/api/workers";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseTicketListResponse,
@@ -46,18 +47,24 @@ async function loadLane(
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
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,
),
]);
const [
readyPlanning,
inprogressQueued,
doneClosed,
orchestrator,
repositories,
] = 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,
),
loadWorkspaceRepositoryList(fetch, workspaceId),
]);
return {
workspaceId,
@@ -67,5 +74,6 @@ export const load: PageLoad = async ({ fetch, params }) => {
"done-closed": doneClosed,
},
orchestrator,
repositories,
};
};
@@ -19,16 +19,42 @@
type WorkspaceOrchestratorStatus,
} from "$lib/workspace/tickets/ticket-panel";
import type { ApiResult } from "$lib/workspace/api/http";
import type {
TicketTarget,
TicketTargetAccess,
} from "$lib/generated/ticket-api";
import type {
RepositoryListResponse,
RepositorySummary,
TicketDetail,
} from "$lib/workspace/sidebar/types";
type EditableTicketTarget = {
repository_key: string;
ref_selector: string;
access: TicketTargetAccess;
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key,
ref_selector: target.ref_selector ?? "",
access: target.access,
}));
}
function normalizedTargets(targets: EditableTicketTarget[]): TicketTarget[] {
return targets.map((target) => ({
repository_key: target.repository_key.trim(),
ref_selector: target.ref_selector.trim() || null,
access: target.access,
}));
}
const { data } = $props<{
data: {
workspaceId: string;
@@ -49,8 +75,9 @@
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let repositoryKey = $state(loadedTicket.repository_key ?? "");
let refSelector = $state(loadedTicket.ref_selector ?? "");
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let nextState = $state(loadedTicket.state);
let transitionReason = $state("");
let threadRole = $state("comment");
@@ -68,23 +95,34 @@
const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
);
const selectedRepository = $derived(
(loadedRepositories?.items ?? []).find((repository: RepositorySummary) => repository.repository_key === repositoryKey) ?? null,
);
const effectiveRefSelector = $derived(refSelector.trim() || selectedRepository?.default_ref || "");
const targetCandidateValid = $derived(
ticket.state === "planning" &&
selectedRepository !== null &&
(selectedRepository.diagnostics ?? []).length === 0 &&
effectiveRefSelector.length > 0,
);
const persistedTargetValid = $derived(
ticket.repository_key !== null &&
ticket.ref_selector !== null &&
(loadedRepositories?.items ?? []).some((repository: RepositorySummary) =>
repository.repository_key === ticket.repository_key && (repository.diagnostics ?? []).length === 0
),
);
function repositoryFor(repositoryKey: string): RepositorySummary | null {
return (loadedRepositories?.items ?? []).find((repository: RepositorySummary) =>
repository.repository_key === repositoryKey
) ?? null;
}
function effectiveRefSelector(target: EditableTicketTarget): string {
return target.ref_selector.trim() ||
repositoryFor(target.repository_key)?.default_selector || "";
}
const targetCandidateValid = $derived.by(() => {
if (ticket.state !== "planning" || targetDrafts.length === 0) return false;
const repositoryKeys = new Set<string>();
let readWriteCount = 0;
for (const target of targetDrafts) {
const repository = repositoryFor(target.repository_key);
if (
!target.repository_key || repository === null ||
(repository.diagnostics ?? []).length > 0 ||
!effectiveRefSelector(target) ||
repositoryKeys.has(target.repository_key)
) return false;
repositoryKeys.add(target.repository_key);
if (target.access === "read_write") readWriteCount += 1;
}
return readWriteCount === 1;
});
const implementationStartEligible = $derived(
ticket.action_eligibility.can_start_manual_coder,
);
@@ -100,8 +138,7 @@
ticket = updatedTicket;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
repositoryKey = updatedTicket.repository_key ?? "";
refSelector = updatedTicket.ref_selector ?? "";
targetDrafts = editableTargets(updatedTicket.targets);
nextState = updatedTicket.state;
}
@@ -276,32 +313,35 @@
) editing = false;
}
function addTarget(access: TicketTargetAccess = "read_only"): void {
if (ticket.state !== "planning") return;
targetDrafts.push({ repository_key: "", ref_selector: "", access });
}
function removeTarget(index: number): void {
if (ticket.state !== "planning") return;
targetDrafts.splice(index, 1);
}
function targetEditBody(): Record<string, unknown> {
const targets = normalizedTargets(targetDrafts);
return {
target: targets.length > 0
? { action: "set", targets }
: { action: "clear" },
};
}
async function saveTarget(event: SubmitEvent) {
event.preventDefault();
await mutate("target", "", {
target: repositoryKey
? {
action: "set",
repository_key: repositoryKey,
ref_selector: refSelector.trim() || null,
}
: { action: "clear" },
}, "PATCH");
await mutate("target", "", targetEditBody(), "PATCH");
}
async function markReady() {
if (!targetCandidateValid || busy) return;
if (
ticket.repository_key !== repositoryKey ||
(ticket.ref_selector ?? "") !== refSelector.trim()
) {
const saved = await mutate("target", "", {
target: {
action: "set",
repository_key: repositoryKey,
ref_selector: refSelector.trim() || null,
},
}, "PATCH");
const targets = normalizedTargets(targetDrafts);
if (JSON.stringify(ticket.targets) !== JSON.stringify(targets)) {
const saved = await mutate("target", "", targetEditBody(), "PATCH");
if (!saved) return;
}
readyOperationKey ??= crypto.randomUUID();
@@ -550,19 +590,45 @@
</section>
<section class="ticket-control-card">
<header><h2>Repository target</h2></header>
<header><h2>Repository targets</h2><span>{targetDrafts.length}</span></header>
<form class="ticket-control-form" onsubmit={saveTarget}>
<label>Repository
<select bind:value={repositoryKey} disabled={ticket.state !== "planning"}>
<option value="">Not assigned</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={refSelector} placeholder={selectedRepository?.default_ref ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={ticket.state !== "planning"} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or revision"} disabled={ticket.state !== "planning"} /></label>
<label>Access
<select bind:value={target.access} disabled={ticket.state !== "planning"}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
{#if ticket.state === "planning"}
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
{/if}
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
{#if ticket.state === "planning"}
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget(targetDrafts.some((target) => target.access === "read_write") ? "read_only" : "read_write")}
>Add target</button>
{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy === "target" || ticket.state !== "planning"}>
{busy === "target" ? "Saving…" : "Save target"}
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
@@ -585,7 +651,7 @@
{busy === "ready" ? "Marking ready…" : "Mark ready"}
</button>
{#if !targetCandidateValid}
<p class="workspace-empty-copy">Choose a healthy repository and an effective ref selector before marking ready.</p>
<p class="workspace-empty-copy">Add unique healthy repository targets with effective ref selectors and exactly one read-write target before marking ready.</p>
{/if}
{:else if ticket.state === "ready"}
<button class="workspace-primary-button ticket-queue-button" type="button" disabled={busy === "queue" || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>