From de612266b491af3af291e4841a9c4193d84e1059 Mon Sep 17 00:00:00 2001 From: dawn Date: Wed, 5 Aug 2026 11:56:54 +0300 Subject: [PATCH] web/api: add spindle pipeline client the ci lexicons already generated, this adds the client side: a spindle context built from the repo record's host, typed queryPipelines/getPipeline wrappers, and the mapping from sh.tangled.ci.pipeline to the PipelineSummary the pipeline components already take. also refreshes the generated lexicons, repo.languages can answer null now. Signed-off-by: dawn --- web/src/lib/api/gitclient.ts | 2 +- .../types/sh/tangled/repo/countForks.ts | 2 +- .../lexicons/types/sh/tangled/repo/create.ts | 6 +- .../lexicons/types/sh/tangled/repo/delete.ts | 6 +- .../types/sh/tangled/repo/getRepoByName.ts | 2 +- .../lexicons/types/sh/tangled/repo/issue.ts | 8 ++ .../types/sh/tangled/repo/languages.ts | 2 +- .../lexicons/types/sh/tangled/repo/pull.ts | 8 ++ web/src/lib/api/spindle.test.ts | 79 +++++++++++++++++++ web/src/lib/api/spindle.ts | 71 +++++++++++++++++ 10 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/api/spindle.test.ts create mode 100644 web/src/lib/api/spindle.ts diff --git a/web/src/lib/api/gitclient.ts b/web/src/lib/api/gitclient.ts index e7ba9c3d..82092f73 100644 --- a/web/src/lib/api/gitclient.ts +++ b/web/src/lib/api/gitclient.ts @@ -100,7 +100,7 @@ export const tag = (target: GitTarget, name: string) => export const languages = ( target: GitTarget, ref: string -): Promise<{ languages?: { name: string; size: number }[] }> => +): Promise<{ languages?: { name: string; size: number }[] | null }> => target.via === "mirror" ? knotMirrorLanguages(target.ctx, { repo: target.repo, ref }) : knotLanguages(target.ctx, { repo: target.repo, ref }); diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/countForks.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/countForks.ts index 740e177c..df3eb08e 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/countForks.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/countForks.ts @@ -5,7 +5,7 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.repo.countForks", { params: /*#__PURE__*/ v.object({ /** - * Repo DID to count forks of. Repos that never got a DID cannot be counted. + * Repo DID to count forks of. */ subject: /*#__PURE__*/ v.didString(), }), diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/create.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/create.ts index b5973e87..5bbc3afc 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/create.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/create.ts @@ -22,7 +22,7 @@ const _mainSchema = /*#__PURE__*/ v.procedure("sh.tangled.repo.create", { /** * Rkey of the repository record */ - rkey: /*#__PURE__*/ v.string(), + rkey: /*#__PURE__*/ v.recordKeyString(), /** * A source URL to clone from, populate this when forking or importing a repository. */ @@ -32,6 +32,10 @@ const _mainSchema = /*#__PURE__*/ v.procedure("sh.tangled.repo.create", { output: { type: "lex", schema: /*#__PURE__*/ v.object({ + /** + * Multibase-encoded public signing key the knot holds for this repository + */ + key: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), repoDid: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.didString()), }), }, diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/delete.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/delete.ts index ed9616ba..938b9a35 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/delete.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/delete.ts @@ -11,6 +11,10 @@ const _mainSchema = /*#__PURE__*/ v.procedure("sh.tangled.repo.delete", { * DID of the repository owner */ did: /*#__PURE__*/ v.didString(), + /** + * Admin-only. Delete even though the repository record still exists on the owner's PDS. + */ + force: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.boolean()), /** * Name of the repository to delete */ @@ -18,7 +22,7 @@ const _mainSchema = /*#__PURE__*/ v.procedure("sh.tangled.repo.delete", { /** * Rkey of the repository record */ - rkey: /*#__PURE__*/ v.string(), + rkey: /*#__PURE__*/ v.recordKeyString(), }), }, output: null, diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/getRepoByName.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/getRepoByName.ts index 003dcc99..f8c50575 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/getRepoByName.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/getRepoByName.ts @@ -5,7 +5,7 @@ import type {} from "@atcute/lexicons/ambient"; const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.repo.getRepoByName", { params: /*#__PURE__*/ v.object({ /** - * Name of the repo as it appears in its url. Repos without a name are reachable by their rkey. + * Name of the repo as it appears in its url. */ name: /*#__PURE__*/ v.string(), /** diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/issue.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/issue.ts index cf3c2e9f..4a35f1d8 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/issue.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/issue.ts @@ -6,6 +6,14 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.tidString(), /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.literal("sh.tangled.repo.issue"), + blobs: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/*"]), + ]), + ), + ), body: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), createdAt: /*#__PURE__*/ v.datetimeString(), mentions: /*#__PURE__*/ v.optional( diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/languages.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/languages.ts index 9a668b2a..a19ac84a 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/languages.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/languages.ts @@ -49,7 +49,7 @@ const _mainSchema = /*#__PURE__*/ v.query("sh.tangled.repo.languages", { type: "lex", schema: /*#__PURE__*/ v.object({ get languages() { - return /*#__PURE__*/ v.array(languageSchema); + return /*#__PURE__*/ v.nullable(/*#__PURE__*/ v.array(languageSchema)); }, /** * The git reference used diff --git a/web/src/lib/api/lexicons/types/sh/tangled/repo/pull.ts b/web/src/lib/api/lexicons/types/sh/tangled/repo/pull.ts index 6f8a6c7f..b2fbb47c 100644 --- a/web/src/lib/api/lexicons/types/sh/tangled/repo/pull.ts +++ b/web/src/lib/api/lexicons/types/sh/tangled/repo/pull.ts @@ -6,6 +6,14 @@ const _mainSchema = /*#__PURE__*/ v.record( /*#__PURE__*/ v.tidString(), /*#__PURE__*/ v.object({ $type: /*#__PURE__*/ v.literal("sh.tangled.repo.pull"), + blobs: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.blob(), [ + /*#__PURE__*/ v.blobSize(1000000), + /*#__PURE__*/ v.blobAccept(["image/*"]), + ]), + ), + ), body: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), createdAt: /*#__PURE__*/ v.datetimeString(), dependentOn: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.resourceUriString()), diff --git a/web/src/lib/api/spindle.test.ts b/web/src/lib/api/spindle.test.ts new file mode 100644 index 00000000..5ac96a3b --- /dev/null +++ b/web/src/lib/api/spindle.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { spindleUrl, toPipelineSummary } from "./spindle"; +import type * as Pipeline from "./lexicons/types/sh/tangled/ci/pipeline"; + +describe("spindleUrl", () => { + it("gives bare hosts https and localhost plain http", () => { + expect(spindleUrl("spindle.tangled.org")).toBe("https://spindle.tangled.org"); + expect(spindleUrl("localhost:6555")).toBe("http://localhost:6555"); + }); + + it("keeps an explicit scheme and drops the trailing slash", () => { + expect(spindleUrl("http://spindle.test/")).toBe("http://spindle.test"); + }); +}); + +const workflow = (over: Partial = {}): Pipeline.Workflow => ({ + id: "build", + name: "build", + status: "success", + ...over +}); + +const pipeline = (over: Partial): Pipeline.Main => + ({ + id: "3lz", + commit: "9f2c1ab4d8e7350f6b1c2d9e4a5b6c7d8e9f0a1b", + trigger: { $type: "sh.tangled.ci.trigger#manual", sha: "9f2c1ab" }, + workflows: [workflow()], + ...over + }) 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", + ref: "refs/heads/master", + newSha: "a".repeat(40), + oldSha: "b".repeat(40) + } + }) + ); + expect(summary.trigger).toEqual({ kind: "push", targetRef: "master" }); + }); + + it("reads a pull request's target and source branches", () => { + const summary = toPipelineSummary( + pipeline({ + trigger: { + $type: "sh.tangled.ci.trigger#pullRequest", + targetBranch: "master", + sourceBranch: "fix-timeout", + sourceSha: "c".repeat(40) + } + }) + ); + 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({ + workflows: [ + workflow({ + startedAt: "2026-08-04T09:00:00.000Z", + finishedAt: "2026-08-04T09:01:12.000Z" + }), + workflow({ name: "test", status: "running", startedAt: "2026-08-04T09:00:00.000Z" }) + ] + }) + ); + expect(summary.workflows.map((w) => w.duration)).toEqual([72_000, 0]); + }); +}); diff --git a/web/src/lib/api/spindle.ts b/web/src/lib/api/spindle.ts new file mode 100644 index 00000000..10eed0a7 --- /dev/null +++ b/web/src/lib/api/spindle.ts @@ -0,0 +1,71 @@ +import { createBobbinClient, type BobbinContext, type QueryValue } from "./client"; +import type { XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; +import type * as GetPipeline from "./lexicons/types/sh/tangled/ci/getPipeline"; +import type * as Pipeline from "./lexicons/types/sh/tangled/ci/pipeline"; +import type * as QueryPipelines from "./lexicons/types/sh/tangled/ci/queryPipelines"; +import type { PipelineStatus, PipelineSummary, PipelineTrigger } from "$lib/components/repo/types"; + +const asParams = (params: object): Record => + params as unknown as Record; + +export const spindleUrl = (host: string): string => { + const raw = host.trim().replace(/\/+$/, ""); + if (raw.includes("://")) return raw; + return raw.startsWith("localhost:") ? `http://${raw}` : `https://${raw}`; +}; + +export const createSpindleClient = (host: string, fetch: typeof globalThis.fetch): BobbinContext => + createBobbinClient({ serviceUrl: spindleUrl(host), fetch }); + +export const queryPipelines = ( + ctx: BobbinContext, + params: QueryPipelines.$params, + init?: XrpcRequestInit +) => jsonGet(ctx, "sh.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); + +const toTrigger = (trigger: Pipeline.Main["trigger"]): 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, + // 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 ?? "" + }; + } + return { kind: "manual" }; +}; + +const durationOf = (workflow: Pipeline.Workflow): number => { + if (!workflow.startedAt || !workflow.finishedAt) return 0; + const started = Date.parse(workflow.startedAt); + const finished = Date.parse(workflow.finishedAt); + if (Number.isNaN(started) || Number.isNaN(finished)) return 0; + return Math.max(0, finished - started); +}; + +export const toPipelineSummary = (pipeline: Pipeline.Main): PipelineSummary => ({ + id: pipeline.id, + sha: pipeline.commit, + createdAt: pipeline.createdAt ?? "", + trigger: toTrigger(pipeline.trigger), + workflows: (pipeline.workflows ?? []).map((workflow) => ({ + name: workflow.name, + status: workflow.status as PipelineStatus, + duration: durationOf(workflow), + error: workflow.error + })) +}); -- 2.51.2