feat: add canonical human-key resource routes
This commit is contained in:
@@ -5,6 +5,7 @@ export type InvalidProjectRecord = { label: string; reason: string };
|
||||
|
||||
export type TicketSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
priority: string;
|
||||
@@ -51,7 +52,12 @@ export type TicketEventDetail = {
|
||||
references: Array<string>;
|
||||
};
|
||||
|
||||
export type ObjectiveLinkSummary = { id: string; title: string; state: string };
|
||||
export type ObjectiveLinkSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type TicketEvidenceEvent = {
|
||||
event_ref: string;
|
||||
@@ -66,6 +72,7 @@ export type TicketAssignmentSummary = {
|
||||
assignment_id: string;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
worker_human_key?: string | null;
|
||||
};
|
||||
|
||||
export type TicketMergeRequestSummary = {
|
||||
@@ -115,6 +122,7 @@ export type TicketQueryRequest = {
|
||||
|
||||
export type TicketQueryItem = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
@@ -150,6 +158,7 @@ export type TicketRelation = {
|
||||
ticket_id: string;
|
||||
kind: string;
|
||||
target: string;
|
||||
target_human_key?: string | null;
|
||||
note: string | null;
|
||||
author: string;
|
||||
at: string;
|
||||
@@ -157,6 +166,7 @@ export type TicketRelation = {
|
||||
|
||||
export type DerivedTicketRelation = {
|
||||
source_ticket: string;
|
||||
source_human_key?: string | null;
|
||||
inverse_kind: string;
|
||||
forward_kind: string;
|
||||
note: string | null;
|
||||
@@ -166,6 +176,7 @@ export type DerivedTicketRelation = {
|
||||
|
||||
export type TicketRelationBlocker = {
|
||||
blocking_ticket: string;
|
||||
blocking_human_key?: string | null;
|
||||
reason_kind: string;
|
||||
relation_kind: string;
|
||||
note: string | null;
|
||||
@@ -187,6 +198,7 @@ export type TicketRelationView = {
|
||||
|
||||
export type TicketDetail = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
readiness: string | null;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const HUMAN_KEY_PATTERN = /^(T|O|W)-(\d+)/;
|
||||
|
||||
export function resourceHumanKey(reference: string): string {
|
||||
const match = HUMAN_KEY_PATTERN.exec(reference);
|
||||
return match ? `${match[1]}-${match[2]}` : reference;
|
||||
}
|
||||
|
||||
export function slugifyResourceTitle(title: string): string {
|
||||
const slug = title
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{Mark}+/gu, "")
|
||||
.toLocaleLowerCase("en-US")
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80)
|
||||
.replace(/-+$/g, "");
|
||||
return slug || "resource";
|
||||
}
|
||||
|
||||
export function canonicalResourceReference(
|
||||
humanKey: string,
|
||||
title: string,
|
||||
): string {
|
||||
return `${humanKey}-${slugifyResourceTitle(title)}`;
|
||||
}
|
||||
|
||||
export function ticketHref(
|
||||
workspaceId: string,
|
||||
ticket: { human_key: string; title: string },
|
||||
): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}/tickets/${encodeURIComponent(canonicalResourceReference(ticket.human_key, ticket.title))}`;
|
||||
}
|
||||
|
||||
export function objectiveHref(
|
||||
workspaceId: string,
|
||||
objective: { human_key: string; title: string },
|
||||
): string {
|
||||
return `/w/${encodeURIComponent(workspaceId)}/objectives/${encodeURIComponent(canonicalResourceReference(objective.human_key, objective.title))}`;
|
||||
}
|
||||
|
||||
export function workerHref(
|
||||
workspaceId: string,
|
||||
worker: { human_key?: string; display_name: string; worker_id: string },
|
||||
): string {
|
||||
const reference = worker.human_key
|
||||
? canonicalResourceReference(worker.human_key, worker.display_name)
|
||||
: worker.worker_id;
|
||||
return `/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(reference)}`;
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export type WorkerCapabilities = {
|
||||
export type Worker = {
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
human_key?: string;
|
||||
host_id: string;
|
||||
display_name: string;
|
||||
label: string;
|
||||
@@ -403,6 +404,7 @@ export type {
|
||||
|
||||
export type ObjectiveSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
updated_at?: string | null;
|
||||
@@ -411,13 +413,22 @@ export type ObjectiveSummary = {
|
||||
record_source?: string;
|
||||
};
|
||||
|
||||
export type ObjectiveLinkedTicketSummary = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export type ObjectiveDetail = {
|
||||
id: string;
|
||||
human_key: string;
|
||||
title: string;
|
||||
state: string;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
linked_tickets: string[];
|
||||
linked_ticket_summaries: ObjectiveLinkedTicketSummary[];
|
||||
body: string;
|
||||
body_truncated: boolean;
|
||||
record_source: string;
|
||||
|
||||
@@ -53,7 +53,7 @@ export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
|
||||
export type TicketLaneId = TicketLaneDefinition["id"];
|
||||
export type TicketCardSummary = Pick<
|
||||
TicketSummary,
|
||||
"id" | "title" | "state" | "priority" | "updated_at"
|
||||
"id" | "human_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>;
|
||||
|
||||
const STATE_SORT_ORDER = new Map<string, number>([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import { objectiveHref } from '$lib/workspace/resource-links';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -18,7 +19,7 @@
|
||||
{:else}
|
||||
<div class="objective-list">
|
||||
{#each data.objectives.items as objective (objective.id)}
|
||||
<a class="objective-row" href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
|
||||
<a class="objective-row" href={objectiveHref(data.workspaceId, objective)}>
|
||||
<div class="objective-main">
|
||||
<div class="objective-title-row">
|
||||
<strong class="objective-title">{objective.title}</strong>
|
||||
@@ -29,7 +30,7 @@
|
||||
<div class="objective-meta" aria-label="Objective metadata">
|
||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||
<span>{objective.linked_tickets?.length ? `${objective.linked_tickets.length} linked ticket(s)` : 'No linked tickets'}</span>
|
||||
<code>{objective.id}</code>
|
||||
<code>{objective.human_key}</code>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { formatDate, workspaceRoute } from '$lib/workspace/api/http';
|
||||
import { formatDate } from '$lib/workspace/api/http';
|
||||
import { objectiveHref, ticketHref } from '$lib/workspace/resource-links';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -14,7 +15,7 @@
|
||||
{#if data.objectives}
|
||||
<div class="objective-list compact">
|
||||
{#each data.objectives.items as objective (objective.id)}
|
||||
<a class="objective-row" class:active={objective.id === data.objectiveId} href={workspaceRoute(data.workspaceId, `/objectives/${objective.id}`)}>
|
||||
<a class="objective-row" class:active={objective.id === data.objectiveId} href={objectiveHref(data.workspaceId, objective)}>
|
||||
<div class="objective-main">
|
||||
<div class="objective-title-row">
|
||||
<strong class="objective-title">{objective.title}</strong>
|
||||
@@ -24,7 +25,7 @@
|
||||
</div>
|
||||
<div class="objective-meta" aria-label="Objective metadata">
|
||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||
<code>{objective.id}</code>
|
||||
<code>{objective.human_key}</code>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -60,7 +61,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>Linked tickets</dt>
|
||||
<dd>{data.objective.linked_tickets.length ? data.objective.linked_tickets.join(', ') : 'none'}</dd>
|
||||
<dd>
|
||||
{#if data.objective.linked_ticket_summaries.length}
|
||||
{#each data.objective.linked_ticket_summaries as ticket, index}
|
||||
{#if index}, {/if}<a href={ticketHref(data.workspaceId, ticket)}>{ticket.human_key}</a>
|
||||
{/each}
|
||||
{:else}
|
||||
none
|
||||
{/if}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<pre class="objective-body">{data.objective.body}</pre>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceHumanKey,
|
||||
} from "$lib/workspace/resource-links";
|
||||
import type {
|
||||
ObjectiveDetail,
|
||||
ObjectiveListResponse,
|
||||
@@ -7,7 +12,7 @@ import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||
const objectiveId = params.objectiveId;
|
||||
const objectiveId = resourceHumanKey(params.objectiveId);
|
||||
const [objectives, objective] = await Promise.all([
|
||||
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
|
||||
loadJson<ObjectiveDetail>(
|
||||
@@ -16,6 +21,19 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
),
|
||||
]);
|
||||
|
||||
if (objective.data) {
|
||||
const canonical = canonicalResourceReference(
|
||||
objective.data.human_key,
|
||||
objective.data.title,
|
||||
);
|
||||
if (params.objectiveId !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${encodeURIComponent(canonical)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
objectiveId,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
TicketListResponse,
|
||||
TicketSummary,
|
||||
} from "$lib/generated/ticket-api";
|
||||
import { ticketHref } from "$lib/workspace/resource-links";
|
||||
import {
|
||||
ticketLanes,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
@@ -176,9 +177,9 @@
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(ticket.id)}`}
|
||||
href={ticketHref(data.workspaceId, ticket)}
|
||||
>
|
||||
<span class="ticket-card-id">{ticket.id}</span>
|
||||
<span class="ticket-card-id">{ticket.human_key}</span>
|
||||
<strong>{ticket.title}</strong>
|
||||
<div class="ticket-card-meta">
|
||||
<span>{ticket.state} · {ticket.priority}</span>
|
||||
|
||||
@@ -320,8 +320,8 @@
|
||||
{#if ticket.relations.blockers.length > 0}
|
||||
<div class="ticket-blocker-list">
|
||||
{#each ticket.relations.blockers as blocker}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}>
|
||||
<strong>Blocked by {blocker.blocking_ticket}</strong>
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_human_key ?? blocker.blocking_ticket)}`}>
|
||||
<strong>Blocked by {blocker.blocking_human_key ?? blocker.blocking_ticket}</strong>
|
||||
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
||||
</a>
|
||||
{/each}
|
||||
@@ -329,16 +329,16 @@
|
||||
{/if}
|
||||
<div class="ticket-relations-list">
|
||||
{#each ticket.relations.outgoing as relation}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target)}`}>
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.target_human_key ?? relation.target)}`}>
|
||||
<span>{relationLabel(relation.kind)}</span>
|
||||
<strong>{relation.target}</strong>
|
||||
<strong>{relation.target_human_key ?? relation.target}</strong>
|
||||
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
{#each ticket.relations.incoming as relation}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_ticket)}`}>
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(relation.source_human_key ?? relation.source_ticket)}`}>
|
||||
<span>{relationLabel(relation.inverse_kind)}</span>
|
||||
<strong>{relation.source_ticket}</strong>
|
||||
<strong>{relation.source_human_key ?? relation.source_ticket}</strong>
|
||||
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
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 { PageLoad } from "./$types";
|
||||
@@ -15,12 +20,19 @@ async function loadOptionalJson<T>(fetcher: typeof fetch, path: string): Promise
|
||||
}
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(params.ticketId)}`);
|
||||
const reference = resourceHumanKey(params.ticketId);
|
||||
const ticketPath = workspaceApiPath(params.workspaceId, `/tickets/${encodeURIComponent(reference)}`);
|
||||
const [ticket, repositories, orchestrator, mergeRequest] = 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`),
|
||||
]);
|
||||
return { workspaceId: params.workspaceId, ticketId: params.ticketId, ticket, repositories, orchestrator, mergeRequest };
|
||||
if (ticket.data) {
|
||||
const canonical = canonicalResourceReference(ticket.data.human_key, ticket.data.title);
|
||||
if (params.ticketId !== canonical) {
|
||||
redirect(308, `/w/${encodeURIComponent(params.workspaceId)}/tickets/${encodeURIComponent(canonical)}`);
|
||||
}
|
||||
}
|
||||
return { workspaceId: params.workspaceId, ticketId: ticket.data?.id ?? reference, ticket, repositories, orchestrator, mergeRequest };
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import { workerConsoleHref } from '$lib/workspace/console/model';
|
||||
import { workerHref } from '$lib/workspace/resource-links';
|
||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
|
||||
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
|
||||
@@ -195,11 +195,11 @@
|
||||
<tr>
|
||||
<td>
|
||||
{#if canOpenWorkerConsole(worker)}
|
||||
<a class="worker-title-link" href={workerConsoleHref(worker, data.workspaceId)}><strong>{workerDisplayName}</strong></a>
|
||||
<a class="worker-title-link" href={workerHref(data.workspaceId, worker)}><strong>{workerDisplayName}</strong></a>
|
||||
{:else}
|
||||
<strong>{workerDisplayName}</strong>
|
||||
{/if}
|
||||
<small>worker <code>{worker.worker_id}</code></small>
|
||||
<small>worker <code>{worker.human_key ?? worker.worker_id}</code></small>
|
||||
</td>
|
||||
<td><code>{worker.runtime_id}</code></td>
|
||||
<td>{workerProfile(worker)}</td>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.worker?.human_key ?? 'Worker'} · Yoi</title></svelte:head>
|
||||
|
||||
<section class="workspace-page-shell">
|
||||
{#if data.workerError}
|
||||
<p class="error-message">{data.workerError}</p>
|
||||
{:else if data.worker}
|
||||
<header class="workspace-page-header">
|
||||
<div>
|
||||
<p class="eyebrow">{data.worker.human_key}</p>
|
||||
<h1>{data.worker.display_name}</h1>
|
||||
</div>
|
||||
<a
|
||||
class="button-primary"
|
||||
href={workspaceRoute(
|
||||
data.workspaceId,
|
||||
`/runtimes/${data.worker.runtime_id}/workers/${data.worker.worker_id}/console`,
|
||||
)}
|
||||
>Open console</a>
|
||||
</header>
|
||||
<dl class="resource-meta">
|
||||
<dt>Status</dt><dd>{data.worker.state}</dd>
|
||||
<dt>Profile</dt><dd>{data.worker.profile}</dd>
|
||||
<dt>Internal ID</dt><dd><code>{data.worker.worker_id}</code></dd>
|
||||
</dl>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import {
|
||||
canonicalResourceReference,
|
||||
resourceHumanKey,
|
||||
} from "$lib/workspace/resource-links";
|
||||
import type { Worker } from "$lib/workspace/sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const reference = resourceHumanKey(params.workerRef);
|
||||
const result = await loadJson<Worker>(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/workers/${encodeURIComponent(reference)}`,
|
||||
),
|
||||
);
|
||||
if (result.data?.human_key) {
|
||||
const canonical = canonicalResourceReference(
|
||||
result.data.human_key,
|
||||
result.data.display_name,
|
||||
);
|
||||
if (params.workerRef !== canonical) {
|
||||
redirect(
|
||||
308,
|
||||
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
worker: result.data,
|
||||
workerError: result.error,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
Reference in New Issue
Block a user