From de2807990fa26208864e8dd3e0509143069aed9e Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sun, 18 Oct 2026 02:30:20 +0900 Subject: [PATCH] web: use `org.tangled.*` endpoints for spindle calls Signed-off-by: Seongmin Lee --- web/src/lib/api/spindle.test.ts | 38 +++++++----- web/src/lib/api/spindle.ts | 61 +++++++++++-------- web/src/lib/api/spindleLogs.test.ts | 10 +-- web/src/lib/api/spindleLogs.ts | 15 +++-- .../pipelines/PipelineList.stories.svelte | 2 +- .../repo/pipelines/PipelineList.svelte | 9 ++- .../repo/pipelines/WorkflowRunView.svelte | 8 ++- .../repo/pipelines/logStream.svelte.ts | 5 +- .../[repo]/commit-statuses/+server.ts | 4 +- .../routes/[handle]/[repo]/pipelines/+page.ts | 9 ++- .../[pipeline]/workflow/[workflow]/+page.ts | 6 +- .../[repo]/pipelines/pipelines.test.ts | 19 +++--- 12 files changed, 119 insertions(+), 67 deletions(-) diff --git a/web/src/lib/api/spindle.test.ts b/web/src/lib/api/spindle.test.ts index 3fe10d375..82aab1835 100644 --- a/web/src/lib/api/spindle.test.ts +++ b/web/src/lib/api/spindle.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { spindleUrl, toPipelineSummary } from "$lib/api/spindle"; -import type * as Pipeline from "$lib/api/lexicons/types/sh/tangled/ci/pipeline"; +import type * as Pipeline from "$lib/api/lexicons/types/org/tangled/ci/pipeline"; describe("spindleUrl", () => { it("gives bare hosts https and localhost plain http", () => { @@ -13,31 +13,37 @@ describe("spindleUrl", () => { }); }); -const workflow = (over: Partial = {}): Pipeline.Workflow => ({ - id: "build", - name: "build", - status: "success", - ...over -}); +const workflow = (over: Partial = {}): Pipeline.Workflow => + ({ + id: "build", + name: "build", + status: "success", + ...over + }) as Pipeline.Workflow; -const pipeline = (over: Partial): Pipeline.Main => +// event.push and pullRequest are still empty in the lexicons, so triggers are loose +const pipeline = (over: Record): Pipeline.Main => ({ id: "3lz", commit: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b", - trigger: { $type: "sh.tangled.ci.trigger#manual", sha: "9f2c1ab" }, + trigger: { + $type: "org.tangled.ci.trigger.manual", + repo: "did:plc:thisrepo", + commit: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b" + }, workflows: [workflow()], ...over - }) as Pipeline.Main; + }) as unknown as Pipeline.Main; describe("toPipelineSummary", () => { it("strips the ref prefix off a push trigger", () => { const summary = toPipelineSummary( pipeline({ trigger: { - $type: "sh.tangled.ci.trigger#push", + $type: "org.tangled.event.push", ref: "refs/heads/master", - newSha: "a".repeat(40), - oldSha: "b".repeat(40) + new: "a".repeat(40), + old: "b".repeat(40) } }) ); @@ -48,7 +54,7 @@ describe("toPipelineSummary", () => { const summary = toPipelineSummary( pipeline({ trigger: { - $type: "sh.tangled.ci.trigger#pullRequest", + $type: "org.tangled.event.pullRequest", targetBranch: "master", sourceBranch: "fix-timeout", sourceSha: "c".repeat(40) @@ -66,7 +72,7 @@ describe("toPipelineSummary", () => { const summary = toPipelineSummary( pipeline({ trigger: { - $type: "sh.tangled.ci.trigger#pullRequest", + $type: "org.tangled.event.pullRequest", targetBranch: "master", sourceBranch: "fix-timeout", sourceSha: "c".repeat(40), @@ -87,7 +93,7 @@ describe("toPipelineSummary", () => { const summary = toPipelineSummary( pipeline({ trigger: { - $type: "sh.tangled.ci.trigger#pullRequest", + $type: "org.tangled.event.pullRequest", targetBranch: "master", sourceBranch: "fix-timeout", sourceSha: "c".repeat(40), diff --git a/web/src/lib/api/spindle.ts b/web/src/lib/api/spindle.ts index 89152c824..0be04f544 100644 --- a/web/src/lib/api/spindle.ts +++ b/web/src/lib/api/spindle.ts @@ -2,9 +2,9 @@ import { createBobbinClient, type BobbinContext, type QueryValue } from "$lib/ap import type { XrpcRequestInit } from "$lib/api/client"; import { jsonGet } from "$lib/api/_request"; import { hostForServiceDid } from "$lib/auth/agent"; -import type * as GetPipeline from "$lib/api/lexicons/types/sh/tangled/ci/getPipeline"; -import type * as Pipeline from "$lib/api/lexicons/types/sh/tangled/ci/pipeline"; -import type * as QueryPipelines from "$lib/api/lexicons/types/sh/tangled/ci/queryPipelines"; +import type * as GetPipeline from "$lib/api/lexicons/types/org/tangled/ci/getPipeline"; +import type * as Pipeline from "$lib/api/lexicons/types/org/tangled/ci/pipeline"; +import type * as QueryPipelines from "$lib/api/lexicons/types/org/tangled/ci/queryPipelines"; import type { PipelineStatus, PipelineSummary, PipelineTrigger } from "$lib/components/repo/types"; const asParams = (params: object): Record => @@ -19,37 +19,49 @@ export const spindleUrl = (host: string): string => { export const createSpindleClient = (host: string, fetch: typeof globalThis.fetch): BobbinContext => createBobbinClient({ serviceUrl: spindleUrl(host), fetch }); +// queryPipelines filters by trigger nsid +const TRIGGER_NSIDS = { + push: "org.tangled.event.push", + pull_request: "org.tangled.event.pullRequest", + manual: "org.tangled.ci.trigger.manual" +} as const; + +export const triggersOf = (filter: PipelineTrigger["kind"] | "all") => + filter === "all" ? undefined : [TRIGGER_NSIDS[filter]]; + export const queryPipelines = ( ctx: BobbinContext, params: QueryPipelines.$params, init?: XrpcRequestInit -) => jsonGet(ctx, "sh.tangled.ci.queryPipelines", asParams(params), init); +) => jsonGet(ctx, "org.tangled.ci.queryPipelines", asParams(params), init); export const getPipeline = ( ctx: BobbinContext, params: GetPipeline.$params, init?: XrpcRequestInit -) => jsonGet(ctx, "sh.tangled.ci.getPipeline", asParams(params), init); +) => jsonGet(ctx, "org.tangled.ci.getPipeline", asParams(params), init); const toTrigger = (trigger: Pipeline.Main["trigger"], repoDid?: string): PipelineTrigger => { - if ("newSha" in trigger) { - return { - kind: "push", - targetRef: trigger.ref.replace(/^refs\/(?:heads|tags)\//, "") - }; - } - if ("targetBranch" in trigger) { - return { - kind: "pull_request", - targetRef: trigger.targetBranch, - // 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 } - : {}) - }; + switch (trigger.$type) { + case "org.tangled.event.push": + return { kind: "push", targetRef: trigger.ref.replace(/^refs\/(?:heads|tags)\//, "") }; + case "org.tangled.event.pullRequest": { + // org.tangled.event.pullRequest is still empty in the lexicons, so its fields are read loosely + const fields = trigger as Record; + const str = (key: string) => + typeof fields[key] === "string" ? (fields[key] as string) : ""; + const sourceRepo = str("sourceRepo"); + return { + kind: "pull_request", + targetRef: str("targetBranch"), + // fork ci labels are handled by the page resolving the name of the fork first + sourceLabel: str("sourceBranch"), + ...(sourceRepo && sourceRepo !== repoDid ? { sourceRepo } : {}) + }; + } + default: + return { kind: "manual" }; } - return { kind: "manual" }; }; const durationOf = (workflow: Pipeline.Workflow): number => { @@ -66,8 +78,9 @@ export const toPipelineSummary = (pipeline: Pipeline.Main, repoDid?: string): Pi createdAt: pipeline.createdAt ?? "", trigger: toTrigger(pipeline.trigger, repoDid), workflows: (pipeline.workflows ?? []).map((workflow) => ({ - name: workflow.name, - status: workflow.status as PipelineStatus, + // id is the key logs and getPipeline go by, name is optional + name: workflow.id, + status: (workflow.status === "scheduled" ? "pending" : workflow.status) as PipelineStatus, duration: durationOf(workflow), error: workflow.error, startedAt: workflow.startedAt, diff --git a/web/src/lib/api/spindleLogs.test.ts b/web/src/lib/api/spindleLogs.test.ts index a539b5831..dc47e5869 100644 --- a/web/src/lib/api/spindleLogs.test.ts +++ b/web/src/lib/api/spindleLogs.test.ts @@ -74,14 +74,16 @@ describe("decodeLogFrame", () => { describe("pipelineLogsUrl", () => { it("upgrades the spindle's scheme and repeats the workflow param", () => { - expect(pipelineLogsUrl("spindle.tangled.org", "3lz", ["build", "test"])).toBe( - "wss://spindle.tangled.org/xrpc/sh.tangled.ci.subscribePipelineLogs?pipeline=3lz&workflows=build&workflows=test" + expect( + pipelineLogsUrl("spindle.tangled.org", "did:plc:thisrepo", "3lz", ["build", "test"]) + ).toBe( + "wss://spindle.tangled.org/xrpc/org.tangled.ci.subscribePipelineLogs?repo=did%3Aplc%3Athisrepo&pipeline=3lz&workflows=build&workflows=test" ); }); it("stays plaintext for a local spindle", () => { - expect(pipelineLogsUrl("localhost:6555", "3lz", ["build"])).toBe( - "ws://localhost:6555/xrpc/sh.tangled.ci.subscribePipelineLogs?pipeline=3lz&workflows=build" + expect(pipelineLogsUrl("localhost:6555", "did:plc:thisrepo", "3lz", ["build"])).toBe( + "ws://localhost:6555/xrpc/org.tangled.ci.subscribePipelineLogs?repo=did%3Aplc%3Athisrepo&pipeline=3lz&workflows=build" ); }); }); diff --git a/web/src/lib/api/spindleLogs.ts b/web/src/lib/api/spindleLogs.ts index 5b52fe2d2..bea50e7d7 100644 --- a/web/src/lib/api/spindleLogs.ts +++ b/web/src/lib/api/spindleLogs.ts @@ -3,7 +3,7 @@ import { decodeFirst } from "@atcute/cbor"; import { spindleUrl } from "$lib/api/spindle"; -import type * as SubscribeLogs from "$lib/api/lexicons/types/sh/tangled/ci/subscribePipelineLogs"; +import type * as SubscribeLogs from "$lib/api/lexicons/types/org/tangled/ci/subscribePipelineLogs"; export type LogControl = SubscribeLogs.Control; @@ -46,11 +46,17 @@ export const decodeLogFrame = (bytes: Uint8Array): LogFrame | null => { } }; -export const pipelineLogsUrl = (host: string, pipeline: string, workflows: string[]): string => { +export const pipelineLogsUrl = ( + host: string, + repo: string, + pipeline: string, + workflows: string[] +): string => { const url = new URL( - "/xrpc/sh.tangled.ci.subscribePipelineLogs", + "/xrpc/org.tangled.ci.subscribePipelineLogs", `${spindleUrl(host).replace(/^http/, "ws")}/` ); + url.searchParams.set("repo", repo); url.searchParams.set("pipeline", pipeline); for (const workflow of workflows) url.searchParams.append("workflows", workflow); return url.toString(); @@ -64,11 +70,12 @@ export interface LogStreamHandlers { // opens log subscription, returns teardown function export const subscribePipelineLogs = ( host: string, + repo: string, pipeline: string, workflows: string[], handlers: LogStreamHandlers ): (() => void) => { - const socket = new WebSocket(pipelineLogsUrl(host, pipeline, workflows)); + const socket = new WebSocket(pipelineLogsUrl(host, repo, pipeline, workflows)); socket.binaryType = "arraybuffer"; let live = true; diff --git a/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte b/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte index da7ce6546..bc8bd3fdf 100644 --- a/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineList.stories.svelte @@ -50,7 +50,7 @@ expect(canvas.getByRole("button", { name: "3/3 passed" })).toBeInTheDocument() ); expect(refreshes).toHaveBeenCalledOnce(); - expect(refreshes.mock.calls[0][0]).toContain("sh.tangled.ci.queryPipelines"); + expect(refreshes.mock.calls[0][0]).toContain("org.tangled.ci.queryPipelines"); expect(refreshes.mock.calls[0][0]).toContain(`repo=${encodeURIComponent(repoDid)}`); }; diff --git a/web/src/lib/components/repo/pipelines/PipelineList.svelte b/web/src/lib/components/repo/pipelines/PipelineList.svelte index c916db625..6ff8ef7ef 100644 --- a/web/src/lib/components/repo/pipelines/PipelineList.svelte +++ b/web/src/lib/components/repo/pipelines/PipelineList.svelte @@ -3,7 +3,12 @@ import PipelineCard from "$lib/components/repo/pipelines/PipelineCard.svelte"; import PipelineEmptyState from "$lib/components/repo/pipelines/PipelineEmptyState.svelte"; import type { PipelineSummary } from "$lib/components/repo/types"; - import { createSpindleClient, queryPipelines, toPipelineSummary } from "$lib/api/spindle"; + import { + createSpindleClient, + queryPipelines, + toPipelineSummary, + triggersOf + } from "$lib/api/spindle"; import type { PipelineFilter } from "$lib/components/repo/pipelines/pipeline"; import { pollPipelineCollection } from "$lib/components/repo/pipelines/livePipelines"; import { untrack } from "svelte"; @@ -48,7 +53,7 @@ ctx, { repo: repoDid as Did, - kinds: filter === "all" ? undefined : [filter], + triggers: triggersOf(filter), limit: 30 }, { signal } diff --git a/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte b/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte index 196c3f0ed..79f9102d1 100644 --- a/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte +++ b/web/src/lib/components/repo/pipelines/WorkflowRunView.svelte @@ -8,6 +8,7 @@ import { createSpindleClient, getPipeline, toPipelineSummary } from "$lib/api/spindle"; import { pollPipelineCollection } from "$lib/components/repo/pipelines/livePipelines"; import { untrack } from "svelte"; + import type { Did } from "@atcute/lexicons"; // the log stream's effects need a sync context, so the page awaits before rendering this interface Props { @@ -32,7 +33,11 @@ return pollPipelineCollection( [pipeline], async (_pipelines, signal) => { - const result = await getPipeline(ctx, { pipeline: pipeline.id }, { signal }); + const result = await getPipeline( + ctx, + { repo: repoDid as Did, pipeline: pipeline.id }, + { signal } + ); if (result.repo !== repoDid) throw new Error("pipeline belongs to another repo"); return [{ ...pipeline, workflows: toPipelineSummary(result, repoDid).workflows }]; }, @@ -43,6 +48,7 @@ const stream = $derived( new LogStream({ host: spindle, + repo: repoDid, pipeline: pipeline.id, workflow: workflow.name, live: isLive(workflow.status) diff --git a/web/src/lib/components/repo/pipelines/logStream.svelte.ts b/web/src/lib/components/repo/pipelines/logStream.svelte.ts index 0c25e191c..865fdaf0e 100644 --- a/web/src/lib/components/repo/pipelines/logStream.svelte.ts +++ b/web/src/lib/components/repo/pipelines/logStream.svelte.ts @@ -5,6 +5,7 @@ export type LogStreamStatus = "connecting" | "streaming" | "done" | "dropped"; export interface LogStreamTarget { host: string; + repo: string; pipeline: string; workflow: string; live: boolean; @@ -32,8 +33,8 @@ export class LogStream { } open(): void { - const { host, pipeline, workflow, live } = this.#target; - this.#close = subscribePipelineLogs(host, pipeline, [workflow], { + const { host, repo, pipeline, workflow, live } = this.#target; + this.#close = subscribePipelineLogs(host, repo, pipeline, [workflow], { onFrame: (frame) => { if (this.status === "connecting") this.status = "streaming"; if (frame.kind === "error") { diff --git a/web/src/routes/[handle]/[repo]/commit-statuses/+server.ts b/web/src/routes/[handle]/[repo]/commit-statuses/+server.ts index 026145d73..d3e4ed188 100644 --- a/web/src/routes/[handle]/[repo]/commit-statuses/+server.ts +++ b/web/src/routes/[handle]/[repo]/commit-statuses/+server.ts @@ -30,7 +30,9 @@ export const GET: RequestHandler = async (event) => { // a spindle answering for another repo is misconfigured or lying if (pipeline.repo !== repoDid) continue; const summary = toPipelineSummary(pipeline, repoDid); - statuses[summary.sha] = summary; + // NOTE: a commit can have several runs; this keeps the first, which is the newest only + // because spindles return newest first. org.tangled.ci.queryPipelines doesn't promise that. + statuses[summary.sha] ??= summary; } return json(statuses); }; diff --git a/web/src/routes/[handle]/[repo]/pipelines/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/+page.ts index b81039125..91aa6a22b 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/+page.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/+page.ts @@ -1,4 +1,9 @@ -import { createSpindleClient, queryPipelines, toPipelineSummary } from "$lib/api/spindle"; +import { + createSpindleClient, + queryPipelines, + toPipelineSummary, + triggersOf +} from "$lib/api/spindle"; import { createBobbinClient } from "$lib/api/client"; import { resolveForkRepoLabels } from "$lib/api/repo"; import { settle, stream } from "$lib/api/load"; @@ -32,7 +37,7 @@ export const load: PageLoad = async (event) => { try { const spindlePage = await queryPipelines(ctx, { repo: repoDid as Did, - kinds: filter === "all" ? undefined : [filter], + triggers: triggersOf(filter), limit: fetchLimit }); return { page: spindlePage, spindleError: null }; diff --git a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts index d6f188fc2..474b3a280 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/[pipeline]/workflow/[workflow]/+page.ts @@ -2,6 +2,7 @@ import { error } from "@sveltejs/kit"; import { settle, stream } from "$lib/api/load"; import { createSpindleClient, getPipeline, toPipelineSummary } from "$lib/api/spindle"; import type { PageLoad } from "./$types"; +import type { Did } from "@atcute/lexicons"; export const load: PageLoad = async (event) => { const parent = await event.parent(); @@ -13,7 +14,10 @@ export const load: PageLoad = async (event) => { const ctx = createSpindleClient(spindle, event.fetch); - const result = await getPipeline(ctx, { pipeline: event.params.pipeline }).catch(() => { + const result = await getPipeline(ctx, { + repo: repoDid as Did, + pipeline: event.params.pipeline + }).catch(() => { error(404, `Could not load pipeline ${event.params.pipeline} from ${spindle}`); }); diff --git a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts index cfc7d9294..304944302 100644 --- a/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts +++ b/web/src/routes/[handle]/[repo]/pipelines/pipelines.test.ts @@ -1,21 +1,22 @@ 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 * as Pipeline from "$lib/api/lexicons/types/org/tangled/ci/pipeline"; import type { PipelineSummary } from "$lib/components/repo/types"; const REPO_DID = "did:plc:j5hmlfdrwkvtxm7cjmu7j2is"; -const pushPipeline = (overrides: Partial = {}): Pipeline.Main => +// event.push and pullRequest are still empty in the lexicons, so triggers are loose +const pushPipeline = (overrides: Record = {}): 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" + $type: "org.tangled.event.push", + ref: "refs/heads/master", + new: "6d78fffa", + old: "ebbd7e8d" }, workflows: [ { @@ -27,7 +28,7 @@ const pushPipeline = (overrides: Partial = {}): Pipeline.Main => } ], ...overrides - }) as Pipeline.Main; + }) as unknown as Pipeline.Main; const jsonResponse = (body: unknown): Response => new Response(JSON.stringify(body), { @@ -146,7 +147,7 @@ describe("pipelines load", () => { pipelines: [ pushPipeline({ trigger: { - $type: "sh.tangled.ci.trigger#pullRequest", + $type: "org.tangled.event.pullRequest", targetBranch: "master", sourceBranch: "fix", sourceSha: "c".repeat(40), @@ -195,7 +196,7 @@ describe("pipelines load", () => { pipelines: [ pushPipeline({ trigger: { - $type: "sh.tangled.ci.trigger#pullRequest", + $type: "org.tangled.event.pullRequest", targetBranch: "master", sourceBranch: "fix", sourceSha: "c".repeat(40), -- 2.54.0