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 = {
|
export type TicketSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
human_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
@@ -51,7 +52,12 @@ export type TicketEventDetail = {
|
|||||||
references: Array<string>;
|
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 = {
|
export type TicketEvidenceEvent = {
|
||||||
event_ref: string;
|
event_ref: string;
|
||||||
@@ -66,6 +72,7 @@ export type TicketAssignmentSummary = {
|
|||||||
assignment_id: string;
|
assignment_id: string;
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
|
worker_human_key?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TicketMergeRequestSummary = {
|
export type TicketMergeRequestSummary = {
|
||||||
@@ -115,6 +122,7 @@ export type TicketQueryRequest = {
|
|||||||
|
|
||||||
export type TicketQueryItem = {
|
export type TicketQueryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
human_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
readiness: string | null;
|
readiness: string | null;
|
||||||
@@ -150,6 +158,7 @@ export type TicketRelation = {
|
|||||||
ticket_id: string;
|
ticket_id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
target_human_key?: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
author: string;
|
author: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -157,6 +166,7 @@ export type TicketRelation = {
|
|||||||
|
|
||||||
export type DerivedTicketRelation = {
|
export type DerivedTicketRelation = {
|
||||||
source_ticket: string;
|
source_ticket: string;
|
||||||
|
source_human_key?: string | null;
|
||||||
inverse_kind: string;
|
inverse_kind: string;
|
||||||
forward_kind: string;
|
forward_kind: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
@@ -166,6 +176,7 @@ export type DerivedTicketRelation = {
|
|||||||
|
|
||||||
export type TicketRelationBlocker = {
|
export type TicketRelationBlocker = {
|
||||||
blocking_ticket: string;
|
blocking_ticket: string;
|
||||||
|
blocking_human_key?: string | null;
|
||||||
reason_kind: string;
|
reason_kind: string;
|
||||||
relation_kind: string;
|
relation_kind: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
@@ -187,6 +198,7 @@ export type TicketRelationView = {
|
|||||||
|
|
||||||
export type TicketDetail = {
|
export type TicketDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
|
human_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
readiness: string | null;
|
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 = {
|
export type Worker = {
|
||||||
runtime_id: string;
|
runtime_id: string;
|
||||||
worker_id: string;
|
worker_id: string;
|
||||||
|
human_key?: string;
|
||||||
host_id: string;
|
host_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -403,6 +404,7 @@ export type {
|
|||||||
|
|
||||||
export type ObjectiveSummary = {
|
export type ObjectiveSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
|
human_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
@@ -411,13 +413,22 @@ export type ObjectiveSummary = {
|
|||||||
record_source?: string;
|
record_source?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ObjectiveLinkedTicketSummary = {
|
||||||
|
id: string;
|
||||||
|
human_key: string;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ObjectiveDetail = {
|
export type ObjectiveDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
|
human_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
linked_tickets: string[];
|
linked_tickets: string[];
|
||||||
|
linked_ticket_summaries: ObjectiveLinkedTicketSummary[];
|
||||||
body: string;
|
body: string;
|
||||||
body_truncated: boolean;
|
body_truncated: boolean;
|
||||||
record_source: string;
|
record_source: string;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
|
|||||||
export type TicketLaneId = TicketLaneDefinition["id"];
|
export type TicketLaneId = TicketLaneDefinition["id"];
|
||||||
export type TicketCardSummary = Pick<
|
export type TicketCardSummary = Pick<
|
||||||
TicketSummary,
|
TicketSummary,
|
||||||
"id" | "title" | "state" | "priority" | "updated_at"
|
"id" | "human_key" | "title" | "state" | "priority" | "updated_at"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const STATE_SORT_ORDER = new Map<string, number>([
|
const STATE_SORT_ORDER = new Map<string, number>([
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="objective-list">
|
<div class="objective-list">
|
||||||
{#each data.objectives.items as objective (objective.id)}
|
{#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-main">
|
||||||
<div class="objective-title-row">
|
<div class="objective-title-row">
|
||||||
<strong class="objective-title">{objective.title}</strong>
|
<strong class="objective-title">{objective.title}</strong>
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
<div class="objective-meta" aria-label="Objective metadata">
|
<div class="objective-meta" aria-label="Objective metadata">
|
||||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
<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>
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<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';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
{#if data.objectives}
|
{#if data.objectives}
|
||||||
<div class="objective-list compact">
|
<div class="objective-list compact">
|
||||||
{#each data.objectives.items as objective (objective.id)}
|
{#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-main">
|
||||||
<div class="objective-title-row">
|
<div class="objective-title-row">
|
||||||
<strong class="objective-title">{objective.title}</strong>
|
<strong class="objective-title">{objective.title}</strong>
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="objective-meta" aria-label="Objective metadata">
|
<div class="objective-meta" aria-label="Objective metadata">
|
||||||
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
<span>Updated {objective.updated_at ? formatDate(objective.updated_at) : 'unknown'}</span>
|
||||||
<code>{objective.id}</code>
|
<code>{objective.human_key}</code>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -60,7 +61,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Linked tickets</dt>
|
<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>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<pre class="objective-body">{data.objective.body}</pre>
|
<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 { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import {
|
||||||
|
canonicalResourceReference,
|
||||||
|
resourceHumanKey,
|
||||||
|
} from "$lib/workspace/resource-links";
|
||||||
import type {
|
import type {
|
||||||
ObjectiveDetail,
|
ObjectiveDetail,
|
||||||
ObjectiveListResponse,
|
ObjectiveListResponse,
|
||||||
@@ -7,7 +12,7 @@ import type { PageLoad } from "./$types";
|
|||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
|
||||||
const objectiveId = params.objectiveId;
|
const objectiveId = resourceHumanKey(params.objectiveId);
|
||||||
const [objectives, objective] = await Promise.all([
|
const [objectives, objective] = await Promise.all([
|
||||||
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
|
loadJson<ObjectiveListResponse>(fetch, apiPath("/objectives")),
|
||||||
loadJson<ObjectiveDetail>(
|
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 {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
objectiveId,
|
objectiveId,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
TicketListResponse,
|
TicketListResponse,
|
||||||
TicketSummary,
|
TicketSummary,
|
||||||
} from "$lib/generated/ticket-api";
|
} from "$lib/generated/ticket-api";
|
||||||
|
import { ticketHref } from "$lib/workspace/resource-links";
|
||||||
import {
|
import {
|
||||||
ticketLanes,
|
ticketLanes,
|
||||||
type WorkspaceOrchestratorStatus,
|
type WorkspaceOrchestratorStatus,
|
||||||
@@ -176,9 +177,9 @@
|
|||||||
{#each lane.tickets as ticket (ticket.id)}
|
{#each lane.tickets as ticket (ticket.id)}
|
||||||
<a
|
<a
|
||||||
class="ticket-card"
|
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>
|
<strong>{ticket.title}</strong>
|
||||||
<div class="ticket-card-meta">
|
<div class="ticket-card-meta">
|
||||||
<span>{ticket.state} · {ticket.priority}</span>
|
<span>{ticket.state} · {ticket.priority}</span>
|
||||||
|
|||||||
@@ -320,8 +320,8 @@
|
|||||||
{#if ticket.relations.blockers.length > 0}
|
{#if ticket.relations.blockers.length > 0}
|
||||||
<div class="ticket-blocker-list">
|
<div class="ticket-blocker-list">
|
||||||
{#each ticket.relations.blockers as blocker}
|
{#each ticket.relations.blockers as blocker}
|
||||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_ticket)}`}>
|
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_human_key ?? blocker.blocking_ticket)}`}>
|
||||||
<strong>Blocked by {blocker.blocking_ticket}</strong>
|
<strong>Blocked by {blocker.blocking_human_key ?? blocker.blocking_ticket}</strong>
|
||||||
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -329,16 +329,16 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="ticket-relations-list">
|
<div class="ticket-relations-list">
|
||||||
{#each ticket.relations.outgoing as relation}
|
{#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>
|
<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}
|
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{#each ticket.relations.incoming as relation}
|
{#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>
|
<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}
|
{#if relation.note}<small>{relation.note}</small>{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
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 { 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";
|
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 }) => {
|
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([
|
const [ticket, repositories, orchestrator, mergeRequest] = await Promise.all([
|
||||||
loadJson<TicketDetail>(fetch, ticketPath),
|
loadJson<TicketDetail>(fetch, ticketPath),
|
||||||
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
|
loadJson<RepositoryListResponse>(fetch, workspaceApiPath(params.workspaceId, "/repositories")),
|
||||||
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
|
loadJson<WorkspaceOrchestratorStatus>(fetch, workspaceApiPath(params.workspaceId, "/orchestrator")),
|
||||||
loadOptionalJson<Record<string, unknown>>(fetch, `${ticketPath}/merge-request`),
|
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;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
|
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
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 { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||||
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
|
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
|
||||||
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
|
import type { CleanupWorkerCandidate, RuntimeCleanupExecutionResponse, RuntimeCleanupPlanResponse, Worker } from '$lib/workspace/sidebar/types';
|
||||||
@@ -195,11 +195,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{#if canOpenWorkerConsole(worker)}
|
{#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}
|
{:else}
|
||||||
<strong>{workerDisplayName}</strong>
|
<strong>{workerDisplayName}</strong>
|
||||||
{/if}
|
{/if}
|
||||||
<small>worker <code>{worker.worker_id}</code></small>
|
<small>worker <code>{worker.human_key ?? worker.worker_id}</code></small>
|
||||||
</td>
|
</td>
|
||||||
<td><code>{worker.runtime_id}</code></td>
|
<td><code>{worker.runtime_id}</code></td>
|
||||||
<td>{workerProfile(worker)}</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;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import {
|
||||||
|
canonicalResourceReference,
|
||||||
|
resourceHumanKey,
|
||||||
|
slugifyResourceTitle,
|
||||||
|
} from "../src/lib/workspace/resource-links.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`expected ${String(expected)}, received ${String(actual)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("resource links normalize titles and preserve the human key", () => {
|
||||||
|
assertEquals(slugifyResourceTitle(" Fix stale URL / 日本語 "), "fix-stale-url-日本語");
|
||||||
|
assertEquals(
|
||||||
|
canonicalResourceReference("T-1842", "Fix stale URL / 日本語"),
|
||||||
|
"T-1842-fix-stale-url-日本語",
|
||||||
|
);
|
||||||
|
assertEquals(resourceHumanKey("T-1842-fix-stale-url-日本語"), "T-1842");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("resource links use a deterministic fallback for punctuation-only titles", () => {
|
||||||
|
assertEquals(canonicalResourceReference("O-7", "---"), "O-7-resource");
|
||||||
|
assertEquals(resourceHumanKey("01a017internal"), "01a017internal");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user