feat: expose merge requests as workspace resources
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
};
|
||||
+104
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user