feat: expose merge requests as workspace resources

This commit is contained in:
2026-08-21 11:31:45 +09:00
parent ccb9d03865
commit 0cd5ca11cc
17 changed files with 880 additions and 148 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -90,6 +90,17 @@ export type TicketMergeRequestSummary = {
review_excerpt: string | null;
};
export type MergeRequestListItem = {
summary: TicketMergeRequestSummary;
ticket_ids: Array<string>;
thread_event_count: number;
};
export type MergeRequestListResponse = {
items: Array<MergeRequestListItem>;
next_cursor: string | null;
};
export type TicketEvidenceSummary = {
has_merge_request: boolean;
has_current_subject_ref: boolean;
@@ -0,0 +1,69 @@
import type { MergeRequestListResponse } from "$lib/generated/ticket-api";
import { workspaceApiPath } from "./http";
export type MergeRequestState = "open" | "merged" | "closed";
export type MergeRequestActor = {
runtime_id: string;
worker_id: string;
assignment_id: string;
};
export type MergeRequestThreadEvent = {
kind: string;
sequence: number;
at: string;
[key: string]: unknown;
};
export type MergeRequestRecord = {
merge_request_id: string;
workspace_id: string;
repository_id: string;
selector_from: string | null;
selector_to: string;
ticket_ids: string[];
state: MergeRequestState;
opened_by: MergeRequestActor;
created_at: string;
updated_at: string;
thread: MergeRequestThreadEvent[];
};
export type MergeRequestRefObservation = {
status: string;
ref: string | null;
observed_at: string;
};
export type MergeRequestDetail = MergeRequestRecord & {
source: MergeRequestRefObservation;
target: MergeRequestRefObservation;
linked_tickets: Array<{ ticket_id: string; key: string | null }>;
};
export type MergeRequestListPage = MergeRequestListResponse;
export function mergeRequestCollectionPath(workspaceId: string): string {
return workspaceApiPath(workspaceId, "/merge-requests");
}
export function mergeRequestDetailPath(
workspaceId: string,
mergeRequestId: string,
): string {
return workspaceApiPath(
workspaceId,
`/merge-requests/${encodeURIComponent(mergeRequestId)}`,
);
}
export function mergeRequestPagePath(
workspaceId: string,
mergeRequestId?: string,
): string {
const root = `/w/${encodeURIComponent(workspaceId)}/merge-requests`;
return mergeRequestId
? `${root}/${encodeURIComponent(mergeRequestId)}`
: root;
}
@@ -0,0 +1,19 @@
<script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
type Props = {
currentPath?: string;
workspaceId: string;
};
let { currentPath = "/", workspaceId }: Props = $props();
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
</script>
<section class="nav-section">
<header class="section-header"><span>Merge Requests</span></header>
<a class="objective-link" class:active={currentPath.startsWith(href)} {href}>
<span class="item-title">All Merge Requests</span>
<span class="item-meta">review and integration resources</span>
</a>
</section>
@@ -2,6 +2,7 @@
import './sidebar.css';
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import MemoryNavSection from './MemoryNavSection.svelte';
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
import TicketsNavSection from './TicketsNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
@@ -41,6 +42,7 @@
<TicketsNavSection {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<MemoryNavSection {currentPath} {workspaceId} />
<MergeRequestsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
</div>
@@ -0,0 +1,66 @@
declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: string | URL): Promise<string>;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
const ticketLoader = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.ts",
import.meta.url,
),
);
const ticketPage = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
const listLoader = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/merge-requests/+page.ts",
import.meta.url,
),
);
const detailLoader = await Deno.readTextFile(
new URL(
"../../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.ts",
import.meta.url,
),
);
const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
);
Deno.test("Ticket detail links to a first-class Merge Request resource", () => {
assert(
!ticketLoader.includes("`${ticketPath}/merge-request`"),
"Ticket loader still locates MR detail through a Ticket route",
);
assert(
ticketPage.includes(
"mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)",
),
"Ticket panel does not link to the MR resource identity",
);
});
Deno.test("Workspace exposes Merge Request collection and detail pages", () => {
assert(
listLoader.includes("mergeRequestCollectionPath(params.workspaceId)"),
"missing MR list API",
);
assert(
detailLoader.includes(
"mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)",
),
"missing MR detail API",
);
assert(
sidebar.includes("MergeRequestsNavSection"),
"MR resources are absent from navigation",
);
});
@@ -0,0 +1,56 @@
<script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
</script>
<svelte:head><title>Merge Requests · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Workspace resources</p>
<h1>Merge Requests</h1>
</div>
<span class="workspace-count">{data.mergeRequests?.items.length ?? 0}</span>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else}
<div class="ticket-list" aria-label="Merge Requests">
{#each data.mergeRequests?.items ?? [] as item (item.summary.merge_request_id)}
{@const mergeRequest = item.summary}
<a
class="ticket-row"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>
<div class="ticket-main">
<div class="ticket-title-row">
<span class="ticket-key">{mergeRequest.merge_request_id}</span>
<strong class="ticket-title">
{mergeRequest.selector_from ?? "Source selector requires repair"}
{mergeRequest.selector_to}
</strong>
</div>
<p class="ticket-summary">
Repository {mergeRequest.repository_id} · {item.ticket_ids.length} linked Ticket{item.ticket_ids.length === 1 ? "" : "s"} · review {mergeRequest.review_status}
</p>
</div>
<div class="ticket-meta">
<span class={`ticket-state state-${mergeRequest.state}`}>{mergeRequest.state}</span>
<time>{prettyDate(mergeRequest.updated_at)}</time>
</div>
</a>
{:else}
<p class="workspace-empty-copy">No Merge Requests.</p>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,33 @@
import type { PageLoad } from "./$types";
import {
mergeRequestCollectionPath,
type MergeRequestListPage,
} from "$lib/workspace/api/merge-requests";
export const load: PageLoad = async ({ params, fetch }) => {
try {
const response = await fetch(
`${mergeRequestCollectionPath(params.workspaceId)}?limit=100`,
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
workspaceId: params.workspaceId,
mergeRequests: null,
error: body?.error ?? body?.message ??
`Request failed (${response.status})`,
};
}
return {
workspaceId: params.workspaceId,
mergeRequests: body as MergeRequestListPage,
error: null,
};
} catch (error) {
return {
workspaceId: params.workspaceId,
mergeRequests: null,
error: error instanceof Error ? error.message : String(error),
};
}
};
@@ -0,0 +1,104 @@
<script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
function prettyDate(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function textField(event: Record<string, unknown>, key: string): string | null {
const value = event[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
</script>
<svelte:head><title>Merge Request · Yoi</title></svelte:head>
<div class="workspace-page">
<header class="workspace-page-header">
<div>
<p class="workspace-eyebrow">Merge Request</p>
<h1>{data.mergeRequest?.merge_request_id ?? data.mergeRequestId}</h1>
{#if data.mergeRequest}
<p class="workspace-page-lede">
{data.mergeRequest.selector_from ?? "Source selector requires repair"}
{data.mergeRequest.selector_to}
</p>
{/if}
</div>
<a class="workspace-secondary-button" href={mergeRequestPagePath(data.workspaceId)}>
All Merge Requests
</a>
</header>
{#if data.error}
<p class="workspace-callout is-error">{data.error}</p>
{:else if data.mergeRequest}
{@const mergeRequest = data.mergeRequest}
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Selectors</h2></div>
<dl class="ticket-facts">
<div><dt>Repository</dt><dd>{mergeRequest.repository_id}</dd></div>
<div><dt>State</dt><dd>{mergeRequest.state}</dd></div>
<div><dt>Source selector</dt><dd><code>{mergeRequest.selector_from ?? "requires repair"}</code></dd></div>
<div><dt>Source revision</dt><dd>{mergeRequest.source.status}{mergeRequest.source.ref ? ` · ${mergeRequest.source.ref}` : ""}</dd></div>
<div><dt>Target selector</dt><dd><code>{mergeRequest.selector_to}</code></dd></div>
<div><dt>Target revision</dt><dd>{mergeRequest.target.status}{mergeRequest.target.ref ? ` · ${mergeRequest.target.ref}` : ""}</dd></div>
<div><dt>Updated</dt><dd>{prettyDate(mergeRequest.updated_at)}</dd></div>
</dl>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Thread</h2><span>{mergeRequest.thread.length}</span>
</div>
<div class="ticket-timeline">
{#each mergeRequest.thread as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.kind}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if textField(event, "subject_ref")}
<p><code>{textField(event, "subject_ref")}</code></p>
{/if}
{#if textField(event, "decision")}<p>{textField(event, "decision")}</p>{/if}
{#if textField(event, "reason")}<p>{textField(event, "reason")}</p>{/if}
{#if textField(event, "body")}<p>{textField(event, "body")}</p>{/if}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No Merge Request events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card">
<header><h2>Linked Tickets</h2></header>
{#each mergeRequest.linked_tickets as linkedTicket}
{#if linkedTicket.key}
<p>
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(linkedTicket.key)}`}>
{linkedTicket.key}
</a>
</p>
{:else}
<p class="workspace-empty-copy">Linked Ticket key unavailable.</p>
{/if}
{:else}
<p class="workspace-empty-copy">No linked Tickets.</p>
{/each}
</section>
</aside>
</div>
{/if}
</div>
@@ -0,0 +1,38 @@
import type { PageLoad } from "./$types";
import {
type MergeRequestDetail,
mergeRequestDetailPath,
} from "$lib/workspace/api/merge-requests";
export const load: PageLoad = async ({ params, fetch }) => {
try {
const response = await fetch(
`${
mergeRequestDetailPath(params.workspaceId, params.mergeRequestId)
}?limit=100`,
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: null,
error: body?.error ?? body?.message ??
`Request failed (${response.status})`,
};
}
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: body as MergeRequestDetail,
error: null,
};
} catch (error) {
return {
workspaceId: params.workspaceId,
mergeRequestId: params.mergeRequestId,
mergeRequest: null,
error: error instanceof Error ? error.message : String(error),
};
}
};
@@ -5,6 +5,7 @@
workspaceApiJsonWithBody,
workspaceApiPath,
} from "$lib/workspace/api/http";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import {
relationLabel,
TICKET_STATES,
@@ -18,54 +19,6 @@
TicketDetail,
} from "$lib/workspace/sidebar/types";
type MergeRequestThreadEvent =
| {
kind: "review_requested";
event_id: string;
sequence: number;
subject_ref: string;
requested_by: { runtime_id: string; worker_id: string };
reviewer: { runtime_id: string; worker_id: string };
}
| {
kind: "review";
event_id: string;
sequence: number;
request_event_id: string;
subject_ref: string;
decision: "approve" | "request_changes";
body: string;
reviewer: { runtime_id: string; worker_id: string };
}
| { kind: "review_revoked"; sequence: number; review_event_id: string; reason: string }
| { kind: "review_cancelled"; sequence: number; request_event_id: string; reason: string }
| {
kind: "comment";
sequence: number;
body: string;
author: { runtime_id: string; worker_id: string };
}
| {
kind: "merge";
sequence: number;
approval_event_id: string;
approved_source_ref: string;
target_ref_after: string;
strategy: "fast_forward" | "merge";
resolution: "none" | "clean" | "conflicts_resolved";
merged_by: { runtime_id: string; worker_id: string };
};
type RefProjection = { status: "known" | "unknown" | "requires_repair"; ref?: string };
type MergeRequestDetail = {
state: "open" | "closed" | "merged";
selector_from: string | null;
selector_to: string;
source: RefProjection;
target: RefProjection;
thread: MergeRequestThreadEvent[];
};
const MUTABLE_TICKET_STATES = TICKET_STATES.filter((state) =>
state !== "done" && state !== "ready" && state !== "queued"
);
@@ -77,7 +30,6 @@
ticket: ApiResult<TicketDetail>;
repositories: ApiResult<RepositoryListResponse>;
orchestrator: ApiResult<WorkspaceOrchestratorStatus>;
mergeRequest: ApiResult<MergeRequestDetail | null>;
};
}>();
@@ -88,24 +40,7 @@
const orchestratorOnline = initialData.orchestrator.data?.online ?? false;
let ticket = $state<TicketDetail>(loadedTicket);
let mergeRequest = $state<MergeRequestDetail | null>(initialData.mergeRequest.data ?? null);
const currentReviewRequest = $derived(
mergeRequest?.thread.findLast((event) => event.kind === "review_requested") ?? null,
);
const currentReview = $derived.by(() => {
if (mergeRequest?.source.status !== "known") return null;
const review = mergeRequest.thread.findLast(
(event) => event.kind === "review" && event.subject_ref === mergeRequest.source.ref,
);
if (!review || review.kind !== "review") return null;
const revoked = mergeRequest.thread.some(
(event) => event.kind === "review_revoked" && event.review_event_id === review.event_id,
);
return revoked ? null : review;
});
const mergeEvent = $derived(
mergeRequest?.thread.findLast((event) => event.kind === "merge") ?? null,
);
const mergeRequest = $derived(ticket.merge_request);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
@@ -458,41 +393,19 @@
<section class="ticket-control-card">
<header><h2>Merge Request</h2></header>
{#if data.mergeRequest.error}
<p class="workspace-callout is-error">{data.mergeRequest.error}</p>
{:else if mergeRequest}
<p><strong>{mergeRequest.state}</strong></p>
<p>From <code>{mergeRequest.selector_from ?? "requires repair"}</code> · {mergeRequest.source.status}{mergeRequest.source.ref ? ` @ ${mergeRequest.source.ref}` : ""}</p>
<p>To <code>{mergeRequest.selector_to}</code> · {mergeRequest.target.status}{mergeRequest.target.ref ? ` @ ${mergeRequest.target.ref}` : ""}</p>
{#if currentReviewRequest?.kind === "review_requested"}
<p>Review requested for <code>{currentReviewRequest.subject_ref}</code></p>
{#if mergeRequest}
<p><strong>{mergeRequest.state}</strong> · review {mergeRequest.review_status}</p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
{#if mergeRequest.current_subject_ref}
<p>Current source <code>{mergeRequest.current_subject_ref}</code></p>
{/if}
{#if currentReview?.kind === "review"}
<p><strong>{currentReview.decision}</strong> by <code>{currentReview.reviewer.runtime_id}/{currentReview.reviewer.worker_id}</code></p>
{#if currentReview.body}<RichMarkdown text={currentReview.body} />{/if}
{/if}
{#if mergeEvent?.kind === "merge"}
<p>Final merge · {mergeEvent.strategy} / {mergeEvent.resolution}</p>
<p>Target ref <code>{mergeEvent.target_ref_after}</code></p>
<p>Completed by <code>{mergeEvent.merged_by.runtime_id}/{mergeEvent.merged_by.worker_id}</code></p>
{/if}
<h4>Thread</h4>
{#each mergeRequest.thread as event (event.sequence)}
<p>
<code>#{event.sequence}</code> · {event.kind}
{#if event.kind === "review_requested"}
· <code>{event.subject_ref}</code> · {event.requested_by.runtime_id}/{event.requested_by.worker_id}
{:else if event.kind === "review"}
· <code>{event.subject_ref}</code> · {event.reviewer.runtime_id}/{event.reviewer.worker_id}
{:else if event.kind === "comment"}
· {event.author.runtime_id}/{event.author.worker_id} · {event.body}
{:else if event.kind === "review_cancelled" || event.kind === "review_revoked"}
· {event.reason}
{:else if event.kind === "merge"}
· approval <code>{event.approval_event_id}</code>
{/if}
</p>
{/each}
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
{:else}
<p class="workspace-empty-copy">The assigned Coder has not opened a Merge Request.</p>
{/if}
@@ -5,34 +5,48 @@ import {
resourceHumanKey,
} from "$lib/workspace/resource-links";
import type { WorkspaceOrchestratorStatus } from "$lib/workspace/tickets/ticket-panel";
import type { RepositoryListResponse, TicketDetail } from "$lib/workspace/sidebar/types";
import type {
RepositoryListResponse,
TicketDetail,
} from "$lib/workspace/sidebar/types";
import type { PageLoad } from "./$types";
async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise<{ data: T | null; error: string | null }> {
try {
const response = await fetcher(path);
if (response.status === 404) return { data: null, error: null };
if (!response.ok) return { data: null, error: await response.text() || `HTTP ${response.status}` };
return { data: await response.json() as T, error: null };
} catch (error) {
return { data: null, error: error instanceof Error ? error.message : String(error) };
}
}
export const load = (async ({ fetch, params }) => {
const reference = resourceHumanKey(params.ticketId);
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`);
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
const ticketPath = workspaceApiPath(
params.workspaceId,
`/tickets/${encodeURIComponent(reference)}`,
);
const [ticket, repositories, orchestrator] = await Promise.all([
loadJson<TicketDetail>(fetch, ticketPath),
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
loadJson<RepositoryListResponse>(
fetch,
workspaceApiPath(params.workspaceId, "/repositories"),
),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
),
]);
if (ticket.data) {
const canonical = canonicalResourceReference(ticket.data.human_key, ticket.data.title);
const canonical = canonicalResourceReference(
ticket.data.human_key,
ticket.data.title,
);
if (params.ticketId !== canonical) {
redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`);
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
encodeURIComponent(canonical)
}`,
);
}
}
return { workspaceId: params.workspaceId, ticketId: ticket.data?.id ?? reference, ticket, repositories, orchestrator, mergeRequest };
return {
workspaceId: params.workspaceId,
ticketId: ticket.data?.id ?? reference,
ticket,
repositories,
orchestrator,
};
}) satisfies PageLoad;