Something went wrong. Try again.
Find the cast and crew shared by two movies or TV shows crosscredit.haltcase.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278/* 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<unknown> { 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<string, string>): 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<unknown> { 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<void> { 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.");}