From 039a6ff5b2c0a29709123d296969bd2cd4027272 Mon Sep 17 00:00:00 2001 From: dawn Date: Fri, 11 Sep 2026 02:07:23 +0300 Subject: [PATCH] web: pick a spindle and manage CI secrets from repo settings Signed-off-by: dawn --- web/src/lib/api/collaborators.ts | 11 +- web/src/lib/api/repoCreationTargets.ts | 10 +- web/src/lib/api/secrets.test.ts | 56 ++++++ web/src/lib/api/secrets.ts | 52 +++++ .../[repo]/settings/pipelines/+page.svelte | 189 +++++++++++------- .../[repo]/settings/pipelines/+page.ts | 16 ++ .../pipelines/secrets/new/+page.svelte | 78 ++++---- 7 files changed, 294 insertions(+), 118 deletions(-) create mode 100644 web/src/lib/api/secrets.test.ts create mode 100644 web/src/lib/api/secrets.ts create mode 100644 web/src/routes/[handle]/[repo]/settings/pipelines/+page.ts diff --git a/web/src/lib/api/collaborators.ts b/web/src/lib/api/collaborators.ts index df5792c15..206e74a2f 100644 --- a/web/src/lib/api/collaborators.ts +++ b/web/src/lib/api/collaborators.ts @@ -27,14 +27,9 @@ export const listCollaborators = async ( const page = { ...init, max: MAX_COLLABORATORS }; const [accepted, invited] = await Promise.all([ collect(ctx, "sh.tangled.repo.listCollaborators", { subject, limit: 100 }, page), - collect( - ctx, - "sh.tangled.repo.listCollaboratorInvites", - { subject, limit: 100 }, - page - ) - // fail open; knots and viewers without invite support reject the lookup - .catch(() => []) + collect(ctx, "sh.tangled.repo.listCollaboratorInvites", { subject, limit: 100 }, page) + // fail open; knots and viewers without invite support reject the lookup + .catch(() => []) ]); const seen = new Set([repo.ownerDid]); for (const item of accepted) { diff --git a/web/src/lib/api/repoCreationTargets.ts b/web/src/lib/api/repoCreationTargets.ts index 2c48ee1a7..48290f377 100644 --- a/web/src/lib/api/repoCreationTargets.ts +++ b/web/src/lib/api/repoCreationTargets.ts @@ -51,12 +51,12 @@ const targetNames = ( return [...names].sort((a, b) => a.localeCompare(b)); }; -export const availableKnots = async (ctx: BobbinContext, did: Did): Promise => { +export const availableKnots = async (ctx: BobbinContext, did: string): Promise => { const [owned, memberships] = await Promise.all([ allPages((cursor) => ok( ctx.xrpc.call(listKnotsSchema, { - params: { subject: did, limit: 1_000, cursor } + params: { subject: did as Did, limit: 1_000, cursor } }) ) ), @@ -71,19 +71,19 @@ export const availableKnots = async (ctx: BobbinContext, did: Did): Promise => { +export const availableSpindles = async (ctx: BobbinContext, did: string): Promise => { const [owned, memberships] = await Promise.all([ allPages((cursor) => ok( ctx.xrpc.call(listSpindlesSchema, { - params: { subject: did, limit: 1_000, cursor } + params: { subject: did as Did, limit: 1_000, cursor } }) ) ), allPages((cursor) => ok( ctx.xrpc.call(listSpindleMembersSchema, { - params: { subject: did, limit: 1_000, cursor } + params: { subject: did as Did, limit: 1_000, cursor } }) ) ) diff --git a/web/src/lib/api/secrets.test.ts b/web/src/lib/api/secrets.test.ts new file mode 100644 index 000000000..3cd468bcd --- /dev/null +++ b/web/src/lib/api/secrets.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ResourceUri } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; + +vi.mock("$lib/auth/agent", () => ({ + createClient: () => ({}), + mintServiceAuth: async () => "token", + serviceDidForHost: (host: string) => `did:web:${host}`, + hostForServiceDid: () => null +})); + +const { addSecret, listSecrets, removeSecret } = await import("$lib/api/secrets"); + +const agent = { sub: "did:plc:owner" } as unknown as OAuthUserAgent; +const repo = "at://did:plc:owner/sh.tangled.repo/core" as ResourceUri; + +const spindle = (body: unknown) => { + const fetch = vi.fn( + async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" } + }) + ); + vi.stubGlobal("fetch", fetch); + return fetch; +}; + +describe("pipeline secrets", () => { + it("asks the repo's spindle over https and tolerates a null list", async () => { + const fetch = spindle({ secrets: null }); + + await expect(listSecrets(agent, "spindle.test", repo)).resolves.toEqual([]); + const asked = new URL(String(fetch.mock.calls[0][0])); + expect(new Headers(fetch.mock.calls[0][1]?.headers).get("authorization")).toBe( + "Bearer token" + ); + expect(asked.origin).toBe("https://spindle.test"); + expect(asked.pathname).toBe("/xrpc/sh.tangled.repo.listSecrets"); + expect(asked.searchParams.get("repo")).toBe(repo); + }); + + it("keys writes by the repo record uri", async () => { + const fetch = spindle({}); + + await addSecret(agent, "https://spindle.test", repo, "API_KEY", "value"); + expect(JSON.parse(String(fetch.mock.calls[0][1]?.body))).toEqual({ + repo, + key: "API_KEY", + value: "value" + }); + + await removeSecret(agent, "https://spindle.test", repo, "API_KEY"); + expect(JSON.parse(String(fetch.mock.calls[1][1]?.body))).toEqual({ repo, key: "API_KEY" }); + }); +}); diff --git a/web/src/lib/api/secrets.ts b/web/src/lib/api/secrets.ts new file mode 100644 index 000000000..9f30dce20 --- /dev/null +++ b/web/src/lib/api/secrets.ts @@ -0,0 +1,52 @@ +import { ok } from "@atcute/client"; +import type { ResourceUri } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { serviceClient } from "$lib/api/_request"; +import { mainSchema as addSecretSchema } from "$lib/api/lexicons/types/sh/tangled/repo/addSecret"; +import { + mainSchema as listSecretsSchema, + type Secret +} from "$lib/api/lexicons/types/sh/tangled/repo/listSecrets"; +import { mainSchema as removeSecretSchema } from "$lib/api/lexicons/types/sh/tangled/repo/removeSecret"; + +export type { Secret }; + +export const listSecrets = async ( + agent: OAuthUserAgent, + spindle: string, + repo: string +): Promise => { + const { secrets } = await ok( + serviceClient(agent, spindle).xrpc.call(listSecretsSchema, { + params: { repo: repo as ResourceUri } + }) + ); + return secrets ?? []; +}; + +export const addSecret = async ( + agent: OAuthUserAgent, + spindle: string, + repo: string, + key: string, + value: string +): Promise => { + await ok( + serviceClient(agent, spindle).xrpc.call(addSecretSchema, { + input: { repo: repo as ResourceUri, key, value } + }) + ); +}; + +export const removeSecret = async ( + agent: OAuthUserAgent, + spindle: string, + repo: string, + key: string +): Promise => { + await ok( + serviceClient(agent, spindle).xrpc.call(removeSecretSchema, { + input: { repo: repo as ResourceUri, key } + }) + ); +}; diff --git a/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte b/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte index 508a5ff0a..9040f444b 100644 --- a/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/pipelines/+page.svelte @@ -1,118 +1,169 @@ - - {#snippet skeleton()} - - {/snippet} - - {@const repo = await data.repo} - {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} - + + + - - {#snippet action()} + + {#snippet action()} +
+ {#if owner} + + {/if} - {/snippet} +
+ {/snippet} - - - chosen, (value) => (picked = value)} + options={[ + { value: NO_SPINDLE, label: "No spindle" }, + ...options.map((option) => ({ value: option })) + ]} + label="Spindle" + class="w-full sm:w-80" + /> +
+ {:else} + + {repo.spindle ?? "No spindle"} + + {/if} + + + - - {#snippet action()} - - {/snippet} + + {#snippet action()} + + {/snippet} + {#if !repo.spindle} + + {:else if secrets.loading} + + {:else if secrets.error} + + {:else if (secrets.data ?? []).length === 0} + + {:else} - {#each secrets as secret (secret.name)} + {#each secrets.data ?? [] as secret (secret.key)} {#snippet label()} - {secret.name} + {secret.key} - {secret.added} - - {secret.by} + Added by + {/snippet} - drop.run(secret.key)}>Delete {/each} - - + {/if} + diff --git a/web/src/routes/[handle]/[repo]/settings/pipelines/+page.ts b/web/src/routes/[handle]/[repo]/settings/pipelines/+page.ts new file mode 100644 index 000000000..72812c807 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/pipelines/+page.ts @@ -0,0 +1,16 @@ +import { createBobbinClient } from "$lib/api/client"; +import { stream } from "$lib/api/load"; +import { availableSpindles } from "$lib/api/repoCreationTargets"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const viewerDid = parent.auth?.did; + if (!viewerDid) return { spindles: [] }; + + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + return { spindles: stream(availableSpindles(ctx, viewerDid).catch(() => [])) }; +}; diff --git a/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte index 1fb97e932..ae4b232cd 100644 --- a/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/pipelines/secrets/new/+page.svelte @@ -1,11 +1,12 @@ - - {#snippet skeleton()} - - {/snippet} - - {@const repo = await data.repo} - {@const back = `/${repo.ownerHandle}/${repo.name}/settings/pipelines`} - - - - - - - - - + + + + + + + + + + + - - - - - - + + + + + -- 2.51.2