feat: support permissioned multi-repository ticket targets
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
Reference in New Issue
Block a user