From 6942e886f00d7b40be0d142fa36fb52363f0fcd0 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 5 Aug 2026 13:45:16 +0300 Subject: [PATCH] web/pipelines: label pull requests from forks the spindle only hands over the fork's repo did, so the page collects those dids and bulk-resolves owner + repo name through bobbin while the list is already on screen. the source label shows a skeleton until that lands, and stays the bare branch if the repo is gone. Signed-off-by: dawn --- web/src/lib/api/repo.test.ts | 52 +++++++++++++ web/src/lib/api/repo.ts | 31 +++++++- web/src/lib/api/spindle.test.ts | 41 ++++++++++ web/src/lib/api/spindle.ts | 14 ++-- .../pipelines/PipelineCard.stories.svelte | 11 +++ .../repo/pipelines/PipelineCard.svelte | 7 +- .../repo/pipelines/PipelineCardContent.svelte | 21 ++++- .../repo/pipelines/PipelineList.svelte | 6 +- web/src/lib/components/repo/pipelines/mock.ts | 3 +- web/src/lib/components/repo/types.ts | 2 + .../[handle]/[repo]/pipelines/+page.svelte | 1 + .../routes/[handle]/[repo]/pipelines/+page.ts | 25 +++++- .../[repo]/pipelines/pipelines.test.ts | 77 +++++++++++++++++++ 13 files changed, 275 insertions(+), 16 deletions(-) diff --git a/web/src/lib/api/repo.test.ts b/web/src/lib/api/repo.test.ts index 45aeb4cd..1f6af5b5 100644 --- a/web/src/lib/api/repo.test.ts +++ b/web/src/lib/api/repo.test.ts @@ -3,6 +3,7 @@ import { coAuthorsFrom, logFor, repoNameOf, + resolveForkRepoLabels, resolveRepoByName, sortTreeEntries, toBranchSummary, @@ -289,3 +290,54 @@ describe("resolveRepoByName", () => { ); }); }); + +describe("resolveForkRepoLabels", () => { + const enrichReply = jsonResponse({ + output: { + items: [ + { + uri: "at://did:plc:forkowner/sh.tangled.repo/core", + value: { + $type: "sh.tangled.repo", + knot: "knot.test", + name: "core", + repoDid: "did:plc:forkrepo" + } + } + ] + }, + data: { + "did:plc:forkowner": { + "sh.tangled.repo:.repo": { + "com.bad-example.identity.miniDoc": { handle: "fork.example" } + } + } + } + }); + + it("labels each fork did as owner/name from the enrich sidecar", async () => { + const fetchMock = vi.fn().mockResolvedValue(enrichReply); + const labels = await resolveForkRepoLabels(makeCtx(fetchMock), ["did:plc:forkrepo"]); + expect(labels).toEqual({ "did:plc:forkrepo": "fork.example/core" }); + + const [url, request] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("/xrpc/sh.tangled.query.enrichResponse"); + expect(JSON.parse(String(request?.body))).toMatchObject({ + xrpc: "sh.tangled.repo.getReposByRepoDids", + params: { dids: ["did:plc:forkrepo"] } + }); + }); + + it("skips dids bobbin has no repo for", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ output: { items: [] }, data: {} })); + expect(await resolveForkRepoLabels(makeCtx(fetchMock), ["did:plc:gone"])).toEqual({}); + }); + + it("does not ask when there is nothing to label", async () => { + const fetchMock = vi.fn(); + expect(await resolveForkRepoLabels(makeCtx(fetchMock), [])).toEqual({}); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/api/repo.ts b/web/src/lib/api/repo.ts index b47d9ef5..ce1e1048 100644 --- a/web/src/lib/api/repo.ts +++ b/web/src/lib/api/repo.ts @@ -1,9 +1,10 @@ import { ClientResponseError, type BobbinContext, type XrpcRequestInit } from "./client"; import type { NiceCommit } from "./diff"; -import { getRepoByName, type RecordView, type RepoRecord } from "./records"; +import { enrich, handleOf, TYPE_MINIDOC } from "./enrich"; +import { getRepoByName, type RecordList, type RecordView, type RepoRecord } from "./records"; import { branches as knotBranches, log as knotLog, tag as knotTag, tags as knotTags } from "./knot"; import { httpStatusFor } from "./load"; -import { rkeyFromUri } from "./uri"; +import { didFromUri, rkeyFromUri } from "./uri"; import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; // log, branches and tags are `*/*` in the lexicons, so these shapes are copied @@ -109,6 +110,32 @@ export const resolveRepoByName = async ( } }; +// bulk did -> `owner/name` for pipeline runs that came from a fork. repos that +// resolve to nothing are left out, the card falls back to the bare branch +export const resolveForkRepoLabels = async ( + ctx: BobbinContext, + repoDids: readonly string[], + init?: XrpcRequestInit +): Promise> => { + if (repoDids.length === 0) return {}; + const page = await enrich>( + ctx, + { + xrpc: "sh.tangled.repo.getReposByRepoDids", + params: { dids: repoDids }, + enrich: [{ source: "sh.tangled.repo:.repo", type: TYPE_MINIDOC, targets: ["items[].uri"] }] + }, + init + ); + const labels: Record = {}; + for (const item of page.output.items ?? []) { + if (!item.value.repoDid) continue; + const handle = handleOf(page.data, didFromUri(item.uri), "sh.tangled.repo:.repo"); + labels[item.value.repoDid] = `${handle}/${repoNameOf(item)}`; + } + return labels; +}; + export interface CommitSummary { hash: string; shortHash: string; diff --git a/web/src/lib/api/spindle.test.ts b/web/src/lib/api/spindle.test.ts index 5ac96a3b..9270e012 100644 --- a/web/src/lib/api/spindle.test.ts +++ b/web/src/lib/api/spindle.test.ts @@ -62,6 +62,47 @@ describe("toPipelineSummary", () => { }); }); + it("keeps a fork's repo did so the page can upgrade the label", () => { + const summary = toPipelineSummary( + pipeline({ + trigger: { + $type: "sh.tangled.ci.trigger#pullRequest", + targetBranch: "master", + sourceBranch: "fix-timeout", + sourceSha: "c".repeat(40), + sourceRepo: "did:plc:forkowner" + } + }), + "did:plc:thisrepo" + ); + expect(summary.trigger).toEqual({ + kind: "pull_request", + targetRef: "master", + sourceLabel: "fix-timeout", + sourceRepo: "did:plc:forkowner" + }); + }); + + it("drops the did when the pull came from the same repo", () => { + const summary = toPipelineSummary( + pipeline({ + trigger: { + $type: "sh.tangled.ci.trigger#pullRequest", + targetBranch: "master", + sourceBranch: "fix-timeout", + sourceSha: "c".repeat(40), + sourceRepo: "did:plc:thisrepo" + } + }), + "did:plc:thisrepo" + ); + expect(summary.trigger).toEqual({ + kind: "pull_request", + targetRef: "master", + sourceLabel: "fix-timeout" + }); + }); + it("times a workflow from its own start and finish stamps", () => { const summary = toPipelineSummary( pipeline({ diff --git a/web/src/lib/api/spindle.ts b/web/src/lib/api/spindle.ts index 10eed0a7..98ce2a37 100644 --- a/web/src/lib/api/spindle.ts +++ b/web/src/lib/api/spindle.ts @@ -30,7 +30,7 @@ export const getPipeline = ( init?: XrpcRequestInit ) => jsonGet(ctx, "sh.tangled.ci.getPipeline", asParams(params), init); -const toTrigger = (trigger: Pipeline.Main["trigger"]): PipelineTrigger => { +const toTrigger = (trigger: Pipeline.Main["trigger"], repoDid?: string): PipelineTrigger => { if ("newSha" in trigger) { return { kind: "push", @@ -41,9 +41,11 @@ const toTrigger = (trigger: Pipeline.Main["trigger"]): PipelineTrigger => { return { kind: "pull_request", targetRef: trigger.targetBranch, - // a fork reads `owner/repo:branch` in the appview, but that needs the - // source repo resolved and the spindle only gives us its did - sourceLabel: trigger.sourceBranch ?? "" + // fork ci labels are handled by the page resolving the name of the fork first + sourceLabel: trigger.sourceBranch ?? "", + ...(trigger.sourceRepo && trigger.sourceRepo !== repoDid + ? { sourceRepo: trigger.sourceRepo } + : {}) }; } return { kind: "manual" }; @@ -57,11 +59,11 @@ const durationOf = (workflow: Pipeline.Workflow): number => { return Math.max(0, finished - started); }; -export const toPipelineSummary = (pipeline: Pipeline.Main): PipelineSummary => ({ +export const toPipelineSummary = (pipeline: Pipeline.Main, repoDid?: string): PipelineSummary => ({ id: pipeline.id, sha: pipeline.commit, createdAt: pipeline.createdAt ?? "", - trigger: toTrigger(pipeline.trigger), + trigger: toTrigger(pipeline.trigger, repoDid), workflows: (pipeline.workflows ?? []).map((workflow) => ({ name: workflow.name, status: workflow.status as PipelineStatus, diff --git a/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte index 1746bb19..ddde759a 100644 --- a/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineCard.stories.svelte @@ -20,6 +20,17 @@ + +>(() => {}) }} +/> diff --git a/web/src/lib/components/repo/pipelines/PipelineCard.svelte b/web/src/lib/components/repo/pipelines/PipelineCard.svelte index ee9e4f88..4f0ec415 100644 --- a/web/src/lib/components/repo/pipelines/PipelineCard.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineCard.svelte @@ -10,6 +10,8 @@ border?: CardVariants["border"]; shadow?: CardVariants["shadow"]; background?: CardVariants["background"]; + /** fork repo did -> `owner/name`, streams in behind the list */ + forkLabels?: Promise>; } let { @@ -18,10 +20,11 @@ pipeline, border = true, shadow = false, - background = true + background = true, + forkLabels }: Props = $props(); - + diff --git a/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte b/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte index f39d58bf..25db6319 100644 --- a/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineCardContent.svelte @@ -62,14 +62,17 @@ import { STATUS_ICONS } from "./PipelineStatusIcon.svelte"; import PipelineWorkflows from "./PipelineWorkflows.svelte"; import Separator from "$lib/components/ui/Separator.svelte"; + import Skeleton from "$lib/components/ui/Skeleton.svelte"; interface Props { ownerHandle: string; repoName: string; pipeline: PipelineSummary; + /** fork repo did -> `owner/name`, streams in behind the list */ + forkLabels?: Promise>; } - let { ownerHandle, repoName, pipeline }: Props = $props(); + let { ownerHandle, repoName, pipeline, forkLabels }: Props = $props(); const status = $derived(aggregateStatus(pipeline)); const duration = $derived(totalDuration(pipeline)); @@ -98,7 +101,21 @@ Pull request {pipeline.trigger.targetRef}