/* oxlint-disable no-console */ import { setTimeout as delay } from "node:timers/promises"; import { z } from "zod"; import { previewName } from "./deployment.ts"; const collection = "org.tangled.feed.comment"; const createRecord = "com.atproto.repo.createRecord"; const previewResult = z.object({ type: z.literal("preview"), version: z.literal(1), preview_name: z.string(), preview_urls: z.array(z.url()), deployment_urls: z.array(z.url()) }); const pipelineResult = z.object({ id: z.string(), repo: z.string(), commit: z.string(), trigger: z.object({ $type: z.literal("org.tangled.event.pullRequest"), pull: z.string(), sourceBranch: z.string(), targetBranch: z.literal("main"), sourceSha: z.string() }) }); const ticketResult = z.object({ uri: z.string(), cid: z.string().min(1), value: z.object({ $type: z.literal("org.tangled.track.ticket"), state: z.enum(["open", "closed", "completed"]), targetBranch: z.literal("main") }) }); function origin(value: string): string { const url = new URL(value); if ( url.protocol !== "https:" || url.username || url.password || url.search || url.hash || url.pathname !== "/" ) { throw new Error("Tangled comment services must be HTTPS origins without credentials or paths."); } return url.origin; } async function request(url: URL, options: RequestInit = {}): Promise { const attempts = (options.method ?? "GET") === "GET" ? 3 : 1; const service = `${url.origin}${url.pathname}`; for (let attempt = 1; attempt <= attempts; attempt += 1) { let response: Response | undefined; try { response = await fetch(url, { ...options, redirect: "error", signal: AbortSignal.timeout(30_000) }); // Never include response bodies: authentication responses can contain credentials. if (!response.ok) { throw new Error("Unsuccessful response"); } return await response.json(); } catch (error) { const timedOut = error instanceof Error && error.name === "TimeoutError"; const retryable = response ? [408, 429, 500, 502, 503, 504].includes(response.status) : timedOut || error instanceof TypeError; let reason = timedOut ? "timed out after 30 seconds" : "request failed"; if (response) { reason = response.ok ? "returned invalid JSON" : `failed (HTTP ${response.status})`; } const message = `Tangled ${service} ${reason}`; if (!retryable || attempt === attempts) { // Fetch errors can expose credentials or response data through their cause. // oxlint-disable-next-line preserve-caught-error throw new Error(`${message} after ${attempt} attempt(s).`); } console.info(`${message}; retrying (${attempt + 1}/${attempts}).`); await delay(attempt * 1000); } } throw new Error("Tangled request exhausted its attempts."); } function endpoint(service: string, method: string, params?: Record): URL { const url = new URL(`/xrpc/${method}`, service); url.search = new URLSearchParams(params).toString(); return url; } function post(service: string, method: string, body: unknown, token?: string): Promise { return request(endpoint(service, method), { method: "POST", headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) }); } export async function commentOnPreview(output: string, branch: string, sha: string): Promise { const deployment = previewResult.parse(JSON.parse(output)); if (deployment.preview_name !== previewName(branch)) { throw new Error("Unexpected deployed Preview name."); } const url = deployment.preview_urls.find((value) => { const parsed = new URL(value); return ( parsed.protocol === "https:" && parsed.hostname.endsWith(".workers.dev") && !parsed.username && !parsed.password && parsed.pathname === "/" && !parsed.search && !parsed.hash ); }); if (!url) { throw new Error("Cloudflare returned no workers.dev Preview URL."); } console.info(`Preview ready: ${url}`); const password = process.env.TANGLED_COMMENT_APP_PASSWORD; if (!password) { console.info( "PR comments are disabled: configure TANGLED_COMMENT_APP_PASSWORD to enable them." ); return; } const config = z .object({ pds: z.string().min(1), identifier: z.string().min(1), spindle: z.string().min(1), knot: z.string().min(1), repo: z.string().startsWith("did:"), pipeline: z.string().min(1) }) .parse({ pds: process.env.TANGLED_COMMENT_PDS_URL, identifier: process.env.TANGLED_COMMENT_IDENTIFIER, spindle: process.env.TANGLED_COMMENT_SPINDLE_URL, knot: process.env.TANGLED_REPO_KNOT, repo: process.env.TANGLED_REPO_REPO_DID, pipeline: process.env.TANGLED_PIPELINE_ID }); if (!/^[a-f0-9]{40,64}$/.test(sha)) { throw new Error("Invalid preview commit SHA."); } const pds = origin(config.pds); const spindle = origin(config.spindle); const knot = origin(`https://${config.knot}`); const pipeline = pipelineResult.parse( await request( endpoint(spindle, "org.tangled.ci.getPipeline", { repo: config.repo, pipeline: config.pipeline }) ) ); if ( pipeline.id !== config.pipeline || pipeline.repo !== config.repo || pipeline.commit !== sha || pipeline.trigger.sourceBranch !== branch || pipeline.trigger.sourceSha !== sha ) { throw new Error("Tangled pipeline does not match this preview deployment."); } const { pull } = pipeline.trigger; const prefix = `at://${config.repo}/org.tangled.track.ticket/`; const rkey = pull.startsWith(prefix) ? pull.slice(prefix.length) : ""; if (!/^[a-zA-Z0-9._~:-]+$/.test(rkey)) { throw new Error("Unexpected PR record in Tangled pipeline."); } const ticket = ticketResult.parse( await request( endpoint(knot, "com.atproto.repo.getRecord", { repo: config.repo, collection: "org.tangled.track.ticket", rkey }) ) ); if (ticket.uri !== pull) { throw new Error("Tangled returned a different PR record."); } if (ticket.value.state !== "open") { console.info("Skipping preview comment: the PR has closed."); return; } const session = z.object({ accessJwt: z.string().min(1) }).parse( await post(pds, "com.atproto.server.createSession", { identifier: config.identifier, password }) ); const expires = String(Math.floor(Date.now() / 1000) + 60); const auth = z.object({ token: z.string().min(1) }).parse( await request( endpoint(pds, "com.atproto.server.getServiceAuth", { aud: config.repo, lxm: createRecord, exp: expires }), { headers: { Authorization: `Bearer ${session.accessJwt}` } } ) ); await post( knot, createRecord, { repo: config.repo, collection, record: { $type: collection, subject: { $type: "com.atproto.repo.strongRef", uri: pull, cid: ticket.cid }, body: { $type: "org.tangled.markup.markdown", text: `Preview ready: [Open preview](${url})\n\nCommit: \`${sha}\`` }, createdAt: new Date().toISOString() } }, auth.token ); console.info("Posted PR preview comment."); }