From 0d2ade0e211792c0f29f55e607c4c68b9f425afe Mon Sep 17 00:00:00 2001 From: eti Date: Thu, 24 Sep 2026 17:43:28 +0200 Subject: [PATCH] web/repo: apply labels to issues and pulls via label ops Signed-off-by: eti --- web/src/lib/api/labels.test.ts | 107 +++++++++ web/src/lib/api/labels.ts | 207 +++++++++++++++++- .../components/repo/LabelPanel.stories.svelte | 34 ++- web/src/lib/components/repo/LabelPanel.svelte | 88 ++++++-- .../components/repo/SubjectLabelPanel.svelte | 110 ++++++++++ .../repo/issues/IssueThreadView.svelte | 42 +--- web/src/lib/components/repo/types.ts | 1 + .../settings/SettingsSection.svelte | 5 +- web/src/routes/[handle]/[repo]/+layout.ts | 1 + .../[repo]/issues/[aturi]/+page.svelte | 4 + .../[handle]/[repo]/issues/[aturi]/+page.ts | 7 +- .../[handle]/[repo]/pulls/[aturi]/+layout.ts | 11 +- .../[[range]]/PullViewPage.stories.svelte | 2 + .../[version]/[[range]]/PullViewPage.svelte | 39 +--- 14 files changed, 575 insertions(+), 83 deletions(-) create mode 100644 web/src/lib/api/labels.test.ts create mode 100644 web/src/lib/components/repo/SubjectLabelPanel.svelte diff --git a/web/src/lib/api/labels.test.ts b/web/src/lib/api/labels.test.ts new file mode 100644 index 000000000..28bfab9ed --- /dev/null +++ b/web/src/lib/api/labels.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { Datetime, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; +import { foldLabelOps, type LabelDefinition, type LabelView, type OpView } from "$lib/api/labels"; + +const owner = "did:plc:owner"; +const collaborator = "did:plc:collab"; +const stranger = "did:plc:stranger"; +const subject = `at://${owner}/sh.tangled.repo.issue/3aaaaaaaaaa22`; + +const definition = (rkey: string, overrides: Partial = {}): LabelView => ({ + uri: `at://${owner}/sh.tangled.label.definition/${rkey}`, + value: { + $type: "sh.tangled.label.definition", + name: rkey, + color: "#000000", + scope: ["sh.tangled.repo.issue" as Nsid], + valueType: { type: "null", format: "any" }, + createdAt: "2026-01-01T00:00:00Z" as Datetime, + ...overrides + } +}); + +const bug = definition("bug"); +const docs = definition("docs"); +const reviewer = definition("reviewer", { + multiple: true, + valueType: { type: "string", format: "did" } +}); +const pullOnly = definition("pull-only", { scope: ["sh.tangled.repo.pull" as Nsid] }); +const definitions = [bug, docs, reviewer, pullOnly]; + +let rkey = 0; +const op = ( + did: string, + performedAt: string, + change: { add?: [LabelView, string?][]; delete?: [LabelView, string?][] } +): OpView => { + const operands = (entries: [LabelView, string?][] = []) => + entries.map(([label, value = "null"]) => ({ key: label.uri as ResourceUri, value })); + return { + uri: `at://${did}/sh.tangled.label.op/3op${String(rkey++).padStart(10, "a")}`, + value: { + $type: "sh.tangled.label.op", + subject: subject as ResourceUri, + performedAt: performedAt as Datetime, + add: operands(change.add), + delete: operands(change.delete) + } + }; +}; + +const authors = new Set([owner, collaborator]); + +describe("foldLabelOps", () => { + it("applies adds and deletes in performedAt order, not list order", () => { + const ops = [ + op(owner, "2026-01-03T00:00:00Z", { delete: [[bug]] }), + op(owner, "2026-01-01T00:00:00Z", { add: [[bug], [docs]] }) + ]; + + expect(foldLabelOps(subject, definitions, ops, authors)).toEqual({ + [docs.uri]: ["null"] + }); + }); + + it("ignores ops from authors without push access", () => { + const ops = [op(stranger, "2026-01-01T00:00:00Z", { add: [[bug]] })]; + + expect(foldLabelOps(subject, definitions, ops, authors)).toEqual({}); + }); + + it("keeps every value of a multiple label and replaces a single one", () => { + const single = definition("priority", { valueType: { type: "string", format: "any" } }); + const ops = [ + op(owner, "2026-01-01T00:00:00Z", { + add: [ + [reviewer, owner], + [single, "low"] + ] + }), + op(collaborator, "2026-01-02T00:00:00Z", { + add: [ + [reviewer, collaborator], + [single, "high"] + ] + }) + ]; + + expect(foldLabelOps(subject, [...definitions, single], ops, authors)).toEqual({ + [reviewer.uri]: [owner, collaborator], + [single.uri]: ["high"] + }); + }); + + it("skips labels out of scope or without a definition", () => { + const gone = definition("gone"); + const ops = [op(owner, "2026-01-01T00:00:00Z", { add: [[pullOnly], [gone]] })]; + + expect(foldLabelOps(subject, definitions, ops, authors)).toEqual({}); + }); + + it("drops a whole record when one operand has an invalid value", () => { + const ops = [op(owner, "2026-01-01T00:00:00Z", { add: [[bug], [reviewer, "alice"]] })]; + + expect(foldLabelOps(subject, definitions, ops, authors)).toEqual({}); + }); +}); diff --git a/web/src/lib/api/labels.ts b/web/src/lib/api/labels.ts index 1028fce83..88cc69cc0 100644 --- a/web/src/lib/api/labels.ts +++ b/web/src/lib/api/labels.ts @@ -1,20 +1,27 @@ -import type { Datetime, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; +import type { Datetime, Did, Nsid, ResourceUri } from "@atcute/lexicons/syntax"; import { now as tidNow } from "@atcute/tid"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitDeletedRecord, awaitIndexedOrSaved } from "$lib/api/awaitRecord"; import { createBobbinClient, type BobbinContext, type XrpcRequestInit } from "$lib/api/client"; import type * as ShTangledLabelDefinition from "$lib/api/lexicons/types/sh/tangled/label/definition"; +import type * as ShTangledLabelOp from "$lib/api/lexicons/types/sh/tangled/label/op"; import { collect } from "$lib/api/pagination"; import type { RecordView, RepoRecord } from "$lib/api/records"; import { saveRepoRecord } from "$lib/api/repoSettings"; import { createRecord, deleteRecord, type WrittenRecord } from "$lib/api/write"; -import { rkeyFromUri } from "$lib/api/uri"; +import { didFromUri, rkeyFromUri } from "$lib/api/uri"; export type LabelDefinition = ShTangledLabelDefinition.Main; +export type LabelOp = ShTangledLabelOp.Main; +export type LabelOperand = ShTangledLabelOp.Operand; const LABEL_DEFINITION_COLLECTION = "sh.tangled.label.definition" as Nsid; +const LABEL_OP_COLLECTION = "sh.tangled.label.op" as Nsid; const ISSUE_SCOPE = "sh.tangled.repo.issue" as Nsid; const PULL_SCOPE = "sh.tangled.repo.pull" as Nsid; +const MAX_COLLABORATORS = 200; + +export const BASIC_LABEL_VALUE = "null"; export const DEFAULT_LABEL_OWNER = "did:plc:wshs7t2adsemcrrd4snkeqli"; export const DEFAULT_LABELS = [ @@ -107,3 +114,199 @@ export const deleteRepoLabel = async ( await deleteLabelDefinition(agent, uri); await awaitDeletedRecord(createBobbinClient({ serviceUrl: bobbinUrl }), uri as ResourceUri); }; + +export interface OpView { + uri: string; + value: LabelOp; +} + +export type AppliedLabels = Record; + +export interface SubjectLabels { + definitions: LabelView[]; + applied: AppliedLabels; +} + +export const NO_SUBJECT_LABELS: SubjectLabels = { definitions: [], applied: {} }; + +interface FoldOp { + did: string; + rkey: string; + at: number; + del: boolean; + key: string; + value: string; +} + +const collectionOf = (uri: string): string => uri.replace(/^at:\/\//, "").split("/")[1] ?? ""; + +const inScope = (label: LabelDefinition, subject: string): boolean => + label.scope.length === 0 || label.scope.includes(collectionOf(subject) as Nsid); + +const validValue = (label: LabelDefinition, value: string): boolean => { + if (value === "") return true; + const { type, format, enum: allowed } = label.valueType; + if (allowed?.length && type !== "null" && !allowed.includes(value)) return false; + switch (type) { + case "null": + return value === BASIC_LABEL_VALUE; + case "boolean": + return value === "true" || value === "false"; + case "integer": + return /^[+-]?\d/.test(value); + case "string": + return format !== "did" || value.startsWith("did:"); + default: + return false; + } +}; + +const performedAt = (value: string): number => { + const at = Date.parse(value); + return Number.isFinite(at) && at >= 0 ? at : Number.NEGATIVE_INFINITY; +}; + +const compare = (a: string | number, b: string | number): number => (a < b ? -1 : a > b ? 1 : 0); + +const opsOf = (view: OpView, definitions: Map): FoldOp[] => { + const did = didFromUri(view.uri); + const rkey = rkeyFromUri(view.uri); + const at = performedAt(view.value.performedAt); + const toOp = (operand: LabelOperand, del: boolean): FoldOp => ({ + did, + rkey, + at, + del: del || operand.value === "", + key: operand.key, + value: operand.value + }); + const ops = [ + ...(view.value.delete ?? []).map((operand) => toOp(operand, true)), + ...(view.value.add ?? []).map((operand) => toOp(operand, false)) + ]; + const valid = ops.every((op) => { + const label = definitions.get(op.key); + return !label || validValue(label, op.value); + }); + return valid ? ops : []; +}; + +export const foldLabelOps = ( + subject: string, + definitions: LabelView[], + ops: OpView[], + authors: ReadonlySet +): AppliedLabels => { + const byUri = new Map(definitions.map((label) => [label.uri, label.value])); + const sorted = ops + .filter((view) => view.value.subject === subject && authors.has(didFromUri(view.uri))) + .flatMap((view) => opsOf(view, byUri)) + .sort( + (a, b) => + compare(a.at, b.at) || + compare(a.did, b.did) || + compare(a.rkey, b.rkey) || + compare(a.del ? 0 : 1, b.del ? 0 : 1) || + compare(a.key, b.key) || + compare(a.value, b.value) + ); + + const state = new Map>(); + for (const op of sorted) { + const label = byUri.get(op.key); + if (!label || !inScope(label, subject)) continue; + const values = state.get(op.key); + if (op.del) { + if (!values?.has(op.value)) continue; + if (label.multiple) values.delete(op.value); + else state.delete(op.key); + } else if (label.multiple) { + state.set(op.key, (values ?? new Set()).add(op.value)); + } else { + state.set(op.key, new Set([op.value])); + } + } + + return Object.fromEntries( + [...state] + .filter(([, values]) => values.size > 0) + .map(([key, values]) => [key, [...values]]) + ); +}; + +const repoDefinitions = async ( + ctx: BobbinContext, + subscribed: readonly string[], + subject: string, + init?: XrpcRequestInit +): Promise => { + const wanted = new Set(subscribed); + const owners = [...new Set(subscribed.map(didFromUri))]; + const lists = await Promise.all( + owners.map((owner) => listLabelDefinitions(ctx, owner, init).catch(() => [])) + ); + return lists.flat().filter((label) => wanted.has(label.uri) && inScope(label.value, subject)); +}; + +const labelAuthors = async ( + ctx: BobbinContext, + repo: { ownerDid: string; repoDid?: string }, + init?: XrpcRequestInit +): Promise> => { + const authors = new Set([repo.ownerDid]); + if (!repo.repoDid) return authors; + const collaborators = await collect( + ctx, + "sh.tangled.repo.listCollaborators", + { subject: repo.repoDid as Did, limit: 100 }, + { ...init, max: MAX_COLLABORATORS } + ); + for (const collaborator of collaborators) authors.add(collaborator.subject); + return authors; +}; + +export const listLabelOps = async ( + ctx: BobbinContext, + subject: string, + init?: XrpcRequestInit +): Promise => { + const items = await collect( + ctx, + "sh.tangled.label.listOps", + { subject, limit: 1000, order: "asc" }, + { ...init, maxPages: 5 } + ); + return items.map((item) => ({ uri: item.uri, value: item.value as LabelOp })); +}; + +export const loadSubjectLabels = async ( + ctx: BobbinContext, + repo: { ownerDid: string; repoDid?: string; labels?: readonly string[] }, + subject: string, + init?: XrpcRequestInit +): Promise => { + const [definitions, ops, authors] = await Promise.all([ + repoDefinitions(ctx, repo.labels ?? [], subject, init), + listLabelOps(ctx, subject, init), + labelAuthors(ctx, repo, init) + ]); + return { definitions, applied: foldLabelOps(subject, definitions, ops, authors) }; +}; + +export const createLabelOp = ( + agent: OAuthUserAgent, + subject: string, + change: { add: LabelOperand[]; delete: LabelOperand[] } +): Promise => + createRecord( + agent, + LABEL_OP_COLLECTION, + { + $type: "sh.tangled.label.op", + subject: subject as ResourceUri, + add: change.add, + delete: change.delete, + performedAt: new Date().toISOString() as Datetime + }, + tidNow() + ); diff --git a/web/src/lib/components/repo/LabelPanel.stories.svelte b/web/src/lib/components/repo/LabelPanel.stories.svelte index a320cd66f..8a1756d70 100644 --- a/web/src/lib/components/repo/LabelPanel.stories.svelte +++ b/web/src/lib/components/repo/LabelPanel.stories.svelte @@ -4,6 +4,8 @@ import type { ResolvedPathname } from "$app/types"; import LabelPanel, { type Label } from "$lib/components/repo/LabelPanel.svelte"; + const keyed = (label: Omit): Label => ({ ...label, key: label.name }); + const labels: Label[] = [ { name: "enhancement", color: "#a5d8ff" }, { name: "bugfix", color: "#ffc9c9" }, @@ -13,7 +15,7 @@ { name: "api", color: "#99e9f2" }, { name: "pipelines", color: "#d0bfff" }, { name: "workflows", color: "#eebefa" } - ]; + ].map(keyed); const withLink = (label: Label): Label => ({ ...label, @@ -32,7 +34,9 @@ { name: "knots", color: "#a5d8ff" }, { name: "spindle", color: "#fcc2d7" }, { name: "appview", color: "#bac8ff" } - ].map(withLink); + ] + .map(keyed) + .map(withLink); const { Story } = defineMeta({ title: "Repo/LabelPanel", @@ -44,6 +48,8 @@ @@ -106,6 +112,30 @@ {/snippet} + + {#snippet template(args)} +
+ + creatablePickedNames.includes(label.name) + )} + allLabels={creatableLabels} + onLabelsChange={(names) => (creatablePickedNames = names)} + onCreateLabel={(name) => { + creatableLabels = [ + ...creatableLabels, + withLink(keyed({ name, color: "#dee2e6" })) + ]; + creatablePickedNames = [...creatablePickedNames, name]; + }} + settingsHref="/alice/repo/settings#labels" + /> +
+ {/snippet} +
+ {#snippet template(args)}
diff --git a/web/src/lib/components/repo/LabelPanel.svelte b/web/src/lib/components/repo/LabelPanel.svelte index 8226fc851..06a5bd96b 100644 --- a/web/src/lib/components/repo/LabelPanel.svelte +++ b/web/src/lib/components/repo/LabelPanel.svelte @@ -2,15 +2,26 @@ import type { ResolvedPathname } from "$app/types"; export interface Label { + key: string; name: string; color: string; href?: ResolvedPathname; } const COLLAPSED_LABEL_COUNT = 10; + const CREATE_PREFIX = "\0create:"; + const EDIT_VALUE = "\0edit"; + + const labelColorFor = (name: string): string => { + let hash = 0x811c9dc5; + for (const char of name) hash = Math.imul(hash ^ char.codePointAt(0)!, 0x01000193); + return `#${((hash >>> 0) & 0xffffff).toString(16).padStart(6, "0")}`; + }; + + diff --git a/web/src/lib/components/repo/issues/IssueThreadView.svelte b/web/src/lib/components/repo/issues/IssueThreadView.svelte index 76457342d..97215f4db 100644 --- a/web/src/lib/components/repo/issues/IssueThreadView.svelte +++ b/web/src/lib/components/repo/issues/IssueThreadView.svelte @@ -20,7 +20,6 @@ import { untrack } from "svelte"; import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; - import type { ResolvedPathname } from "$app/types"; import BellMinus from "$icon/bell-minus"; import BellPlus from "$icon/bell-plus"; import { createBobbinClient } from "$lib/api/client"; @@ -35,7 +34,8 @@ import { getNotifications } from "$lib/notifications.svelte"; import CommentSection from "$lib/components/comment/CommentSection.svelte"; import type { CommentThread } from "$lib/components/comment/comments"; - import LabelPanel from "$lib/components/repo/LabelPanel.svelte"; + import { NO_SUBJECT_LABELS, type SubjectLabels } from "$lib/api/labels"; + import SubjectLabelPanel from "$lib/components/repo/SubjectLabelPanel.svelte"; import Ticket from "$lib/components/repo/tickets/Ticket.svelte"; import type { RepoInfo } from "$lib/components/repo/types"; import Button from "$lib/components/ui/Button.svelte"; @@ -52,6 +52,8 @@ markup: MarkupContext; repo: RepoInfo; bobbinUrl?: string; + labels?: SubjectLabels; + canPush?: boolean; } let props: Props = $props(); @@ -147,30 +149,6 @@ const issuesBase = $derived(`/${props.repo.ownerHandle}/${props.repo.name}/issues`); - const mockLabelColors = [ - { name: "enhancement", color: "#06b6d4" }, - { name: "bugfix", color: "#ef4444" }, - { name: "svelte", color: "#f97316" }, - { name: "js", color: "#eab308" }, - { name: "fe", color: "#2563eb" }, - { name: "api", color: "#a21caf" }, - { name: "pipelines", color: "#22d3ee" }, - { name: "workflows", color: "#6b7280" } - ]; - - const allMockLabels = $derived( - mockLabelColors.map((label) => ({ - ...label, - href: `${issuesBase}?label=${encodeURIComponent(label.name)}` as ResolvedPathname - })) - ); - - let mockLabelNames = $state(mockLabelColors.map((label) => label.name)); - - const mockLabels = $derived( - allMockLabels.filter((label) => mockLabelNames.includes(label.name)) - ); - const updateIssue = async ({ title, body }: { title: string; body: string }) => { const agent = await auth.agentFor(issue.authorDid as Did); const record: IssueRecord = { @@ -249,11 +227,13 @@ {/if} {/snippet} {#snippet labels()} - (mockLabelNames = names)} + {/snippet} diff --git a/web/src/lib/components/repo/types.ts b/web/src/lib/components/repo/types.ts index 3025de910..717b6b94f 100644 --- a/web/src/lib/components/repo/types.ts +++ b/web/src/lib/components/repo/types.ts @@ -14,6 +14,7 @@ export interface RepoInfo { description?: string; website?: string; topics?: string[]; + labels?: string[]; createdAt?: string; source?: RepoSource; // plain clone url; repo.source is reserved for at-uri fork attribution diff --git a/web/src/lib/components/settings/SettingsSection.svelte b/web/src/lib/components/settings/SettingsSection.svelte index 421a1bfda..a21cf1f54 100644 --- a/web/src/lib/components/settings/SettingsSection.svelte +++ b/web/src/lib/components/settings/SettingsSection.svelte @@ -4,17 +4,18 @@ interface Props { title?: string; + id?: string; headingGap?: "3" | "4"; framed?: boolean; children: Snippet; } - let { title, headingGap = "3", framed = true, children }: Props = $props(); + let { title, id, headingGap = "3", framed = true, children }: Props = $props(); const gapClass = $derived(headingGap === "4" ? "gap-md" : "gap-sm"); -
+
{#if title}

{title}

{/if} diff --git a/web/src/routes/[handle]/[repo]/+layout.ts b/web/src/routes/[handle]/[repo]/+layout.ts index 715c9fc53..05192b042 100644 --- a/web/src/routes/[handle]/[repo]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/+layout.ts @@ -98,6 +98,7 @@ export const load: LayoutLoad = async (event) => { topics: record.topics, importSource: record.source && !isResourceUri(record.source) ? record.source : undefined, + labels: record.labels, defaultBranch: stream( resolveDefaultBranch(git) .then((branch) => branch ?? FALLBACK_BRANCH) diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte index c08aec805..57433b82b 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.svelte @@ -66,6 +66,8 @@ {/snippet} {@const repo = await data.repo} {@const result = await data.page} + {@const labels = await data.labels} + {@const access = await data.viewerAccess} {#if result.kind === "ok"} {:else if result.kind === "not-found"} diff --git a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts index 74799f6fa..8247e5c0b 100644 --- a/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts +++ b/web/src/routes/[handle]/[repo]/issues/[aturi]/+page.ts @@ -6,6 +6,7 @@ import { ISSUE_AUTHOR_DOCS, REACTION_AUTHOR_DOCS } from "$lib/api/descriptors"; import { authorOf, enrich, miniDocOf, target } from "$lib/api/enrich"; import { INVALID_HANDLE, type MiniDoc } from "$lib/api/identity"; import { listIssueStates, type IssueRecord } from "$lib/api/issue"; +import { loadSubjectLabels, NO_SUBJECT_LABELS } from "$lib/api/labels"; import type { ReactionListPage, ReactionRecord } from "$lib/api/reaction"; import type { RecordView } from "$lib/api/records"; import { didFromUri, rkeyFromUri } from "$lib/api/uri"; @@ -186,5 +187,9 @@ export const load: PageLoad = async (event) => { initialResult = resolved; } - return settle({ page: stream(page), initialResult }); + const labels = Promise.resolve(parent.repo).then((repo) => + loadSubjectLabels(ctx, repo, uri).catch(() => NO_SUBJECT_LABELS) + ); + + return settle({ page: stream(page), initialResult, labels: stream(labels) }); }; diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts index 925b53383..1658488bd 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/+layout.ts @@ -4,6 +4,7 @@ import { error } from "@sveltejs/kit"; import { createBobbinClient } from "$lib/api/client"; import { COMMENT_AUTHOR_DOCS } from "$lib/api/descriptors"; import { authorOf, enrich, target } from "$lib/api/enrich"; +import { loadSubjectLabels, NO_SUBJECT_LABELS } from "$lib/api/labels"; import { getPullView, type PullState } from "$lib/api/records"; import { rkeyFromUri } from "$lib/api/uri"; import { settle, stream } from "$lib/api/load"; @@ -88,5 +89,13 @@ export const load: LayoutLoad = async (event) => { // preserve SSR status handling while client navigation paints its skeleton if (!browser) await pullPage; - return settle({ pullPage: stream(pullPage) }); + const labels = Promise.resolve(parent.repo).then((repo) => + loadSubjectLabels( + createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }), + repo, + uri + ).catch(() => NO_SUBJECT_LABELS) + ); + + return settle({ pullPage: stream(pullPage), labels: stream(labels) }); }; diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte index 8188c5cbb..ecad1ae71 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.stories.svelte @@ -22,6 +22,7 @@ } from "$fixtures/pull"; import PullViewRoute from "./+page.svelte"; import { accessFor } from "$lib/api/access"; + import { NO_SUBJECT_LABELS } from "$lib/api/labels"; import type { PullState } from "$lib/api/records"; import type { PullView } from "$lib/api/pullRoute"; import type { CommentView } from "$lib/components/comment/comments"; @@ -111,6 +112,7 @@ Depends on the hydrant filter change, so this is stacked on \`sv-fe\`.`; publicConfig: { bobbinUrl }, counts: { stars: 128, issues: 12, pulls: 7, forks: 19 }, viewerAccess: Promise.resolve(accessFor(viewerCanPush ? "collaborator" : "none")), + labels: Promise.resolve(NO_SUBJECT_LABELS), repo, uri: pullUri, sourceRepoDid, diff --git a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte index 01d3c0a1f..0c3ddb4a4 100644 --- a/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte +++ b/web/src/routes/[handle]/[repo]/pulls/[aturi]/[version]/[[range]]/PullViewPage.svelte @@ -67,7 +67,7 @@ } from "$lib/components/repo/pulls/PullApproveCi.svelte"; import PullCommitListHeader from "$lib/components/repo/pulls/PullCommitListHeader.svelte"; import PullCommitList from "$lib/components/repo/pulls/PullCommitList.svelte"; - import LabelPanel from "$lib/components/repo/LabelPanel.svelte"; + import SubjectLabelPanel from "$lib/components/repo/SubjectLabelPanel.svelte"; import TicketInfoBar from "$lib/components/repo/tickets/TicketInfoBar.svelte"; import type { PageData } from "./$types"; import { CROSSFADE_DURATION, CROSSFADE_EASING } from "$lib/motion/spring"; @@ -99,6 +99,7 @@ const isForkBased = $derived(data.sourceRepoDid !== data.repo.repoDid); const canPush = $derived((await data.viewerAccess).canPush); + const labels = $derived(await data.labels); const pullState = $derived(pull.state); const head = $derived(pull.versions.at(-1)?.head); @@ -222,30 +223,6 @@ let comments = $derived(data.comments); let commits = $derived(data.commits); - const mockLabelColors = [ - { name: "enhancement", color: "#06b6d4" }, - { name: "bugfix", color: "#ef4444" }, - { name: "svelte", color: "#f97316" }, - { name: "js", color: "#eab308" }, - { name: "fe", color: "#2563eb" }, - { name: "api", color: "#a21caf" }, - { name: "pipelines", color: "#22d3ee" }, - { name: "workflows", color: "#6b7280" } - ]; - - const allMockLabels = $derived( - mockLabelColors.map((label) => ({ - ...label, - href: `${pullsBase}?label=${encodeURIComponent(label.name)}` as ResolvedPathname - })) - ); - - let mockLabelNames = $state(mockLabelColors.map((label) => label.name)); - - const mockLabels = $derived( - allMockLabels.filter((label) => mockLabelNames.includes(label.name)) - ); - const participants = $derived.by(() => { const people: { did: string; handle?: string }[] = [pull.author]; for (const comment of comments) { @@ -740,11 +717,13 @@ {/if} {/snippet} - (mockLabelNames = names)} + {#if pull.versions.length} -- 2.51.2