From 9f760850bfa97ecd5ae93a94ff7a857fbd36c142 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 5 Aug 2026 11:57:29 +0300 Subject: [PATCH] web/pipelines: read runs from the repo's spindle drops the mocked loader for a real one that asks the repo's spindle for its pipeline runs. the mock stays for the stories and the commit list. the trigger tabs go to the spindle as a kinds filter, the search still narrows locally since queryPipelines has no query param. Signed-off-by: dawn --- web/src/lib/components/repo/pipelines/mock.ts | 8 +- .../[handle]/[repo]/pipelines/+page.svelte | 19 ++- .../routes/[handle]/[repo]/pipelines/+page.ts | 54 ++++++- .../[repo]/pipelines/pipelines.test.ts | 132 ++++++++++++++++++ 4 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts diff --git a/web/src/lib/components/repo/pipelines/mock.ts b/web/src/lib/components/repo/pipelines/mock.ts index 38b87d0ef..7cab7c448 100644 --- a/web/src/lib/components/repo/pipelines/mock.ts +++ b/web/src/lib/components/repo/pipelines/mock.ts @@ -1,4 +1,5 @@ -// fake data for the pipelines tab until it is wired to bobbin +// fixture pipelines for the stories and the commit list, the pipelines tab +// reads from a spindle now import type { PipelineSummary } from "$lib/components/repo/types"; @@ -88,9 +89,8 @@ export const pipelines: PipelineSummary[] = [ ]; /** - * fake per-commit runs for the repo overview's commit list. the appview asks its - * own /commit-statuses endpoint for these; bobbin's equivalent is - * sh.tangled.pipeline.listPipelines, the same call the pipelines tab still owes. + * fake per-commit runs for the repo overview's commit list. the real call is + * the spindle's ci.queryPipelines with a commits filter, one run per commit. * every third commit is left without a run so the gap renders too. */ export const pipelinesByCommitHash = (hashes: string[]): Record => { diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte index 7693b0f27..4bde942f9 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.svelte +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.svelte @@ -2,6 +2,7 @@ import PipelineList from "$lib/components/repo/pipelines/PipelineList.svelte"; import PipelineToolbar from "$lib/components/repo/pipelines/PipelineToolbar.svelte"; import TabPanel from "$lib/components/ui/TabPanel.svelte"; + import Error from "$lib/components/ui/Error.svelte"; let { data } = $props(); @@ -11,15 +12,19 @@ ownerHandle={data.repo.ownerHandle} repoName={data.repo.name} filter={data.filter} - total={data.pipelines.length} + total={data.total} />
- + {#if data.spindleError} + + {:else} + + {/if}
diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/+page.ts index 9756b4f47..84307d698 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.ts @@ -1,25 +1,65 @@ -// TODO: mocked. swap for sh.tangled.pipeline.listPipelines via bobbin, the way -// issues/+page.ts does. a real load hands `q` to the server instead of matching here. -import { pipelines as mockPipelines } from "$lib/components/repo/pipelines/mock"; +import { createSpindleClient, queryPipelines, toPipelineSummary } from "$lib/api/spindle"; import { matchesQuery, type PipelineFilter } from "$lib/components/repo/pipelines/pipeline"; +import type { PipelineSummary } from "$lib/components/repo/types"; +import type { Did } from "@atcute/lexicons"; import type { PageLoad } from "./$types"; +// the appview only offers these two, a manual run still shows up under "all" const FILTERS: PipelineFilter[] = ["all", "push", "pull_request"]; +// same page size as the appview's pipelines tab +const PIPELINE_LIMIT = 30; + export const load: PageLoad = async (event) => { + const parent = await event.parent(); const requested = event.url.searchParams.get("trigger") as PipelineFilter | null; const filter = requested && FILTERS.includes(requested) ? requested : "all"; const query = event.url.searchParams.get("q") ?? ""; - const pipelines = mockPipelines - .filter((pipeline) => filter === "all" || pipeline.trigger.kind === filter) + const empty = { + filter, + query, + pipelines: [] as PipelineSummary[], + total: 0, + // the setup steps only make sense on the plain unfiltered view, otherwise + // an empty result reads as "nothing matched" + filtered: filter !== "all" || query !== "", + spindleError: null as string | null + }; + + const { spindle, repoDid } = parent.repo; + + if (!spindle || !repoDid) return empty; + + const ctx = createSpindleClient(spindle, event.fetch); + + let page; + try { + page = await queryPipelines(ctx, { + repo: repoDid as Did, + kinds: filter === "all" ? undefined : [filter], + limit: PIPELINE_LIMIT + }); + } catch { + // a spindle that is down or slow shouldn't take out the tab, say so instead + return { ...empty, spindleError: `Could not reach ${spindle}` }; + } + + // the lexicon says pipelines is required, but the spindle sends null for an + // empty result + const pipelines = (page.pipelines ?? []) + // a spindle answering for a different repo is misconfigured or lying + .filter((pipeline) => pipeline.repo === repoDid) + .map(toPipelineSummary) .filter((pipeline) => matchesQuery(pipeline, query)); return { filter, query, pipelines, - // lets the list tell "no runs at all" from "nothing matched" - hasAny: mockPipelines.length > 0 + // the spindle counts every run matching the tab, the query narrows here + total: query === "" ? page.total : pipelines.length, + filtered: page.total > 0 || filter !== "all" || query !== "", + spindleError: null }; }; diff --git a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts new file mode 100644 index 000000000..3a175027e --- /dev/null +++ b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest"; +import { load } from "./+page"; +import type * as Pipeline from "$lib/api/lexicons/types/sh/tangled/ci/pipeline"; +import type { PipelineSummary } from "$lib/components/repo/types"; + +const REPO_DID = "did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; + +const pushPipeline = (overrides: Partial = {}): Pipeline.Main => + ({ + id: "3mrf4wqyxv22", + createdAt: "2026-08-04T09:00:00.000Z", + repo: REPO_DID, + commit: "6d78fffa9e429ff5a693fb0c275cd5cc2f84755d", + trigger: { + $type: "sh.tangled.ci.trigger#push", + newSha: "6d78fffa", + oldSha: "ebbd7e8d", + ref: "refs/heads/master" + }, + workflows: [ + { + id: "build.yml", + name: "build.yml", + status: "success", + startedAt: "2026-08-04T09:00:00.000Z", + finishedAt: "2026-08-04T09:02:00.000Z" + } + ], + ...overrides + }) as Pipeline.Main; + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" } + }); + +// the load event boiled down to what the loader actually reads +const makeEvent = ({ + spindle = "spindle.tangled.sh", + search = "" +}: { + spindle?: string | null; + search?: string; +} = {}) => { + const fetchMock = vi.fn(); + return { + fetchMock, + event: { + url: new URL(`http://web.test/tangled.org/core/pipelines${search}`), + fetch: fetchMock, + parent: async () => ({ + repo: { + spindle: spindle ?? undefined, + repoDid: REPO_DID, + ownerHandle3: "tangled.org", + name: "core" + } + }) + } as never + }; +}; + +// PageLoad's return type allows void, but this loader always answers +const runLoad = async (event: never) => { + const data = await load(event); + expect(data).toBeDefined(); + return data!; +}; + +describe("pipelines load", () => { + it("short-circuits without a spindle", async () => { + const { event, fetchMock } = makeEvent({ spindle: null }); + const data = await runLoad(event); + expect(fetchMock).not.toHaveBeenCalled(); + expect(data).toMatchObject({ pipelines: [], total: 0, filtered: false, spindleError: null }); + }); + + it("maps a real push run and marks the plain view unfiltered", async () => { + const { event, fetchMock } = makeEvent(); + fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 1 })); + + const data = await runLoad(event); + expect(data.pipelines).toHaveLength(1); + expect(data.pipelines[0]).toMatchObject({ + trigger: { kind: "push", targetRef: "master" }, + workflows: [{ name: "build.yml", status: "success", duration: 120_000 }] + }); + expect(data.total).toBe(1); + // setup steps belong to a repo that never ran anything, with runs the + // flag just has to keep them hidden + expect(data.filtered).toBe(true); + }); + + it("tolerates a null pipelines array, which is what the spindle sends when empty", async () => { + const { event, fetchMock } = makeEvent(); + fetchMock.mockResolvedValue(jsonResponse({ pipelines: null, total: 0 })); + const data = await runLoad(event); + expect(data.pipelines).toEqual([]); + }); + + it("drops runs that answer for a different repo", async () => { + const { event, fetchMock } = makeEvent(); + fetchMock.mockResolvedValue( + jsonResponse({ + pipelines: [pushPipeline(), pushPipeline({ id: "other", repo: "did:plc:someoneelse" })], + total: 2 + }) + ); + const data = await runLoad(event); + expect(data.pipelines.map((p: PipelineSummary) => p.id)).toEqual(["3mrf4wqyxv22"]); + }); + + it("narrows by the query and counts what survived, not the spindle total", async () => { + const { event, fetchMock } = makeEvent({ search: "?q=nope" }); + fetchMock.mockResolvedValue(jsonResponse({ pipelines: [pushPipeline()], total: 5 })); + const data = await runLoad(event); + expect(data.pipelines).toEqual([]); + expect(data.total).toBe(0); + expect(data.filtered).toBe(true); + }); + + it("says so instead of throwing when the spindle is down", async () => { + const { event, fetchMock } = makeEvent(); + fetchMock.mockRejectedValue(new Error("connection refused")); + const data = await runLoad(event); + expect(data).toMatchObject({ + pipelines: [], + spindleError: "Could not reach spindle.tangled.sh" + }); + }); +}); -- 2.51.2