diff --git a/packages/app/server/routes/check.post.ts b/packages/app/server/routes/check.post.ts index cb243622..dbbe8146 100644 --- a/packages/app/server/routes/check.post.ts +++ b/packages/app/server/routes/check.post.ts @@ -1,44 +1,70 @@ export default eventHandler(async (event) => { - const data = await readRawBody(event); - const workflowsBucket = useWorkflowsBucket(event); + try { + const data = await readRawBody(event); + const workflowsBucket = useWorkflowsBucket(event); - const { owner, repo, key } = JSON.parse(data!); + const { owner, repo, key } = JSON.parse(data!); - const app = useOctokitApp(event); + const app = useOctokitApp(event); - let authenticated = false; + let authenticated = false; - try { - await app.octokit.request("GET /repos/{owner}/{repo}/installation", { - owner, - repo, - }); - authenticated = true; - } catch {} + try { + await app.octokit.request("GET /repos/{owner}/{repo}/installation", { + owner, + repo, + }); + authenticated = true; + } catch {} - try { - await app.octokit.request("GET /orgs/{org}/installation", { - org: owner, - }); - authenticated = true; - } catch {} + try { + await app.octokit.request("GET /orgs/{org}/installation", { + org: owner, + }); + authenticated = true; + } catch {} - if (!authenticated) { - throw createError({ - statusCode: 404, - fatal: true, - message: `The app https://github.com/apps/pkg-pr-new is not installed on ${owner}/${repo}.`, - }); - } + if (!authenticated) { + throw createError({ + statusCode: 404, + fatal: true, + message: `The app https://github.com/apps/pkg-pr-new is not installed on ${owner}/${repo}.`, + }); + } + + const workflowData = await workflowsBucket.getItem(key); + + if (!workflowData) { + throw createError({ + statusCode: 404, + fatal: true, + message: `There is no workflow defined for ${key}`, + }); + } + return { sha: workflowData.sha }; + } catch (error: unknown) { + console.error("Check route error:", error); + + if (error && typeof error === "object" && "statusCode" in error) { + throw error; + } - const workflowData = await workflowsBucket.getItem(key); + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred during check"; + const stack = error instanceof Error ? error.stack : undefined; - if (!workflowData) { throw createError({ - statusCode: 404, - fatal: true, - message: `There is no workflow defined for ${key}`, + statusCode: 500, + statusMessage: "Internal Server Error", + data: { + error: true, + message, + stack, + originalError: error, + type: "check_error", + }, }); } - return { sha: workflowData.sha }; }); diff --git a/packages/app/server/routes/publish.post.ts b/packages/app/server/routes/publish.post.ts index f3b686f9..e1d4cc67 100644 --- a/packages/app/server/routes/publish.post.ts +++ b/packages/app/server/routes/publish.post.ts @@ -10,328 +10,361 @@ import { generateTemplateHtml } from "../utils/template"; import { joinKeys } from "unstorage"; export default eventHandler(async (event) => { - const origin = getRequestURL(event).origin; - const { - "sb-run-id": runIdHeader, - "sb-key": key, - "sb-shasums": shasumsHeader, - "sb-comment": commentHeader, - "sb-compact": compactHeader, - "sb-bin": binHeader, - "sb-package-manager": packageManagerHeader, - "sb-only-templates": onlyTemplatesHeader, - } = getHeaders(event); - const compact = compactHeader === "true"; - const onlyTemplates = onlyTemplatesHeader === "true"; - const comment: Comment = (commentHeader ?? "update") as Comment; - const bin = binHeader === "true"; - const packageManager: PackageManager = - (packageManagerHeader as PackageManager) || "npm"; - - if (!key || !runIdHeader || !shasumsHeader) { - throw createError({ - statusCode: 400, - message: - "sb-commit-timestamp, sb-key and sb-shasums headers are required", - }); - } - const runId = Number(runIdHeader); - const workflowsBucket = useWorkflowsBucket(event); - const debugBucket = useDebugBucket(event); - const workflowData = await workflowsBucket.getItem(key); - const webhookDebugData = await debugBucket.getItem(key); - - if (!workflowData) { - throw createError({ - statusCode: 404, - fatal: true, - message: `There is no workflow defined for ${key}`, - }); - } + try { + const origin = getRequestURL(event).origin; + const { + "sb-run-id": runIdHeader, + "sb-key": key, + "sb-shasums": shasumsHeader, + "sb-comment": commentHeader, + "sb-compact": compactHeader, + "sb-bin": binHeader, + "sb-package-manager": packageManagerHeader, + "sb-only-templates": onlyTemplatesHeader, + } = getHeaders(event); + const compact = compactHeader === "true"; + const onlyTemplates = onlyTemplatesHeader === "true"; + const comment: Comment = (commentHeader ?? "update") as Comment; + const bin = binHeader === "true"; + const packageManager: PackageManager = + (packageManagerHeader as PackageManager) || "npm"; + + if (!key || !runIdHeader || !shasumsHeader) { + throw createError({ + statusCode: 400, + message: + "sb-commit-timestamp, sb-key and sb-shasums headers are required", + }); + } + const runId = Number(runIdHeader); + const workflowsBucket = useWorkflowsBucket(event); + const debugBucket = useDebugBucket(event); + const workflowData = await workflowsBucket.getItem(key); + const webhookDebugData = await debugBucket.getItem(key); + + if (!workflowData) { + throw createError({ + statusCode: 404, + fatal: true, + message: `There is no workflow defined for ${key}`, + }); + } - const whitelisted = await isWhitelisted( - workflowData.owner, - workflowData.repo, - ); - const contentLength = Number(getHeader(event, "content-length")); + const whitelisted = await isWhitelisted( + workflowData.owner, + workflowData.repo, + ); + const contentLength = Number(getHeader(event, "content-length")); + + // 20mb limit for now + if (!whitelisted && contentLength > 1024 * 1024 * 20) { + // Payload too large + throw createError({ + statusCode: 413, + message: + "Max payload limit is 20mb! Feel free to apply for the whitelist: https://github.com/stackblitz-labs/pkg.pr.new/blob/main/.whitelist", + }); + } - // 20mb limit for now - if (!whitelisted && contentLength > 1024 * 1024 * 20) { - // Payload too large - throw createError({ - statusCode: 413, - message: - "Max payload limit is 20mb! Feel free to apply for the whitelist: https://github.com/stackblitz-labs/pkg.pr.new/blob/main/.whitelist", - }); - } + const shasums: Record = JSON.parse(shasumsHeader); + const formData = await readFormData(event); + const packages = [...formData.keys()].filter((k) => + k.startsWith("package:"), + ); + const packagesWithoutPrefix = packages.map((p) => + p.slice("package:".length), + ); + const templateAssets = [...formData.keys()].filter((k) => + k.startsWith("template:"), + ); - const shasums: Record = JSON.parse(shasumsHeader); - const formData = await readFormData(event); - const packages = [...formData.keys()].filter((k) => k.startsWith("package:")); - const packagesWithoutPrefix = packages.map((p) => p.slice("package:".length)); - const templateAssets = [...formData.keys()].filter((k) => - k.startsWith("template:"), - ); + if (packages.length === 0) { + throw createError({ + statusCode: 400, + message: "No packages", + }); + } - if (packages.length === 0) { - throw createError({ - statusCode: 400, - message: "No packages", - }); - } + const { appId } = useRuntimeConfig(event); + const cursorBucket = useCursorsBucket(event); - const { appId } = useRuntimeConfig(event); - const cursorBucket = useCursorsBucket(event); + if (!(await workflowsBucket.hasItem(key))) { + throw createError({ + statusCode: 401, + message: + "Try publishing from a github workflow! Also make sure you install https://github.com/apps/pkg-pr-new GitHub app on the repo", + }); + } - if (!(await workflowsBucket.hasItem(key))) { - throw createError({ - statusCode: 401, - message: - "Try publishing from a github workflow! Also make sure you install https://github.com/apps/pkg-pr-new GitHub app on the repo", - }); - } + const baseKey = `${workflowData.owner}:${workflowData.repo}`; - const baseKey = `${workflowData.owner}:${workflowData.repo}`; + const cursorKey = `${baseKey}:${workflowData.ref}`; - const cursorKey = `${baseKey}:${workflowData.ref}`; + const currentCursor = await cursorBucket.getItem(cursorKey); - const currentCursor = await cursorBucket.getItem(cursorKey); + let lastPackageKey: string; + await Promise.all( + packages.map((packageNameWithPrefix, i) => { + const packageName = packageNameWithPrefix.slice("package:".length); + const packageKey = `${baseKey}:${workflowData.sha}:${packageName}`; - let lastPackageKey: string; - await Promise.all( - packages.map((packageNameWithPrefix, i) => { - const packageName = packageNameWithPrefix.slice("package:".length); - const packageKey = `${baseKey}:${workflowData.sha}:${packageName}`; + const file = formData.get(packageNameWithPrefix)!; + if (file instanceof File) { + lastPackageKey = + i === packages.length - 1 + ? joinKeys(usePackagesBucket.base, packageKey) + : lastPackageKey; - const file = formData.get(packageNameWithPrefix)!; - if (file instanceof File) { - lastPackageKey = - i === packages.length - 1 - ? joinKeys(usePackagesBucket.base, packageKey) - : lastPackageKey; + const stream = file.stream(); + return setItemStream( + event, + usePackagesBucket.base, + packageKey, + stream, + { + sha1: shasums[packageName], + }, + ); + } + return null; + }), + ); - const stream = file.stream(); - return setItemStream( - event, - usePackagesBucket.base, - packageKey, - stream, - { - sha1: shasums[packageName], - }, - ); - } - return null; - }), - ); - - const templatesMap = new Map>(); - - let lastTemplateKey: string; - await Promise.all( - templateAssets.map((templateAssetWithPrefix, i) => { - const file = formData.get(templateAssetWithPrefix)!; - const [template, encodedTemplateAsset] = templateAssetWithPrefix - .slice("template:".length) - .split(":"); - const templateAsset = decodeURIComponent(encodedTemplateAsset); - - const isBinary = !(typeof file === "string"); - const uuid = randomUUID(); + const templatesMap = new Map>(); + + let lastTemplateKey: string; + await Promise.all( + templateAssets.map((templateAssetWithPrefix, i) => { + const file = formData.get(templateAssetWithPrefix)!; + const [template, encodedTemplateAsset] = templateAssetWithPrefix + .slice("template:".length) + .split(":"); + const templateAsset = decodeURIComponent(encodedTemplateAsset); + + const isBinary = !(typeof file === "string"); + const uuid = randomUUID(); + + templatesMap.set(template, { + ...templatesMap.get(template), + [templateAsset]: isBinary + ? new URL(`/template/${uuid}`, origin).href + : file, + }); + + if (isBinary) { + lastTemplateKey = + i === templateAssets.length - 1 + ? joinKeys(useTemplatesBucket.base, uuid) + : lastTemplateKey; + + const stream = file.stream(); + return setItemStream(event, useTemplatesBucket.base, uuid, stream); + } + return null; + }), + ); - templatesMap.set(template, { - ...templatesMap.get(template), - [templateAsset]: isBinary - ? new URL(`/template/${uuid}`, origin).href - : file, - }); + const templatesBucket = useTemplatesBucket(event); - if (isBinary) { - lastTemplateKey = - i === templateAssets.length - 1 - ? joinKeys(useTemplatesBucket.base, uuid) - : lastTemplateKey; + const textEncoder = new TextEncoder(); + const templatesHtmlMap: Record = {}; - const stream = file.stream(); - return setItemStream(event, useTemplatesBucket.base, uuid, stream); - } - return null; - }), - ); + for (const [template, files] of templatesMap) { + const html = generateTemplateHtml(template, files); + const uuid = randomUUID(); + await templatesBucket.setItemRaw(uuid, textEncoder.encode(html)); + templatesHtmlMap[template] = new URL(`/template/${uuid}`, origin).href; + } - const templatesBucket = useTemplatesBucket(event); + if (!currentCursor || currentCursor.timestamp < runId) { + await cursorBucket.setItem(cursorKey, { + sha: workflowData.sha, + timestamp: runId, + }); + } - const textEncoder = new TextEncoder(); - const templatesHtmlMap: Record = {}; + await workflowsBucket.removeItem(key); - for (const [template, files] of templatesMap) { - const html = generateTemplateHtml(template, files); - const uuid = randomUUID(); - await templatesBucket.setItemRaw(uuid, textEncoder.encode(html)); - templatesHtmlMap[template] = new URL(`/template/${uuid}`, origin).href; - } + const urls = packagesWithoutPrefix.map((packageName) => + generatePublishUrl("sha", origin, packageName, workflowData, compact), + ); - if (!currentCursor || currentCursor.timestamp < runId) { - await cursorBucket.setItem(cursorKey, { - sha: workflowData.sha, - timestamp: runId, - }); - } + const installation = await useOctokitInstallation( + event, + workflowData.owner, + workflowData.repo, + ); - await workflowsBucket.removeItem(key); - - const urls = packagesWithoutPrefix.map((packageName) => - generatePublishUrl("sha", origin, packageName, workflowData, compact), - ); - - const installation = await useOctokitInstallation( - event, - workflowData.owner, - workflowData.repo, - ); - - const checkName = "Continuous Releases"; - const { - data: { check_runs }, - } = await installation.request( - "GET /repos/{owner}/{repo}/commits/{ref}/check-runs", - { - check_name: checkName, - owner: workflowData.owner, - repo: workflowData.repo, - ref: workflowData.sha, - app_id: Number(appId), - }, - ); - - let checkRunUrl = check_runs[0]?.html_url ?? ""; - - if (check_runs.length === 0) { + const checkName = "Continuous Releases"; const { - data: { html_url }, - } = await installation.request("POST /repos/{owner}/{repo}/check-runs", { - name: checkName, - owner: workflowData.owner, - repo: workflowData.repo, - head_sha: workflowData.sha, - output: { - title: "Successful", - summary: "Published successfully.", - text: generateCommitPublishMessage( - origin, - templatesHtmlMap, - packagesWithoutPrefix, - workflowData, - compact, - packageManager, - bin, - ), - }, - conclusion: "success", - }); - checkRunUrl = html_url!; - } - - if ( - isPullRequest(workflowData.ref) && - (await getPullRequestState(installation, workflowData)) === "open" - ) { - let prevComment: OctokitComponents["schemas"]["issue-comment"]; - - await installation.paginate( - "GET /repos/{owner}/{repo}/issues/{issue_number}/comments", + data: { check_runs }, + } = await installation.request( + "GET /repos/{owner}/{repo}/commits/{ref}/check-runs", { + check_name: checkName, owner: workflowData.owner, repo: workflowData.repo, - issue_number: Number(workflowData.ref), - }, - ({ data }, done) => { - for (const c of data) { - if (c.performed_via_github_app?.id === Number(appId)) { - prevComment = c; - done(); - break; - } - } - return []; + ref: workflowData.sha, + app_id: Number(appId), }, ); - if (comment !== "off") { + let checkRunUrl = check_runs[0]?.html_url ?? ""; + + if (check_runs.length === 0) { const { - data: { permissions }, - } = await installation.request("GET /repos/{owner}/{repo}/installation", { + data: { html_url }, + } = await installation.request("POST /repos/{owner}/{repo}/check-runs", { + name: checkName, owner: workflowData.owner, repo: workflowData.repo, + head_sha: workflowData.sha, + output: { + title: "Successful", + summary: "Published successfully.", + text: generateCommitPublishMessage( + origin, + templatesHtmlMap, + packagesWithoutPrefix, + workflowData, + compact, + packageManager, + bin, + ), + }, + conclusion: "success", }); + checkRunUrl = html_url!; + } - try { - if (comment === "update" && prevComment!) { - await installation.request( - "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", - { - owner: workflowData.owner, - repo: workflowData.repo, - comment_id: prevComment.id, - body: generatePullRequestPublishMessage( - origin, - templatesHtmlMap, - packagesWithoutPrefix, - workflowData, - compact, - onlyTemplates, - checkRunUrl, - packageManager, - "ref", - bin, - ), - }, - ); - } else { - await installation.request( - "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", - { - owner: workflowData.owner, - repo: workflowData.repo, - issue_number: Number(workflowData.ref), - body: generatePullRequestPublishMessage( - origin, - templatesHtmlMap, - packagesWithoutPrefix, - workflowData, - compact, - onlyTemplates, - checkRunUrl, - packageManager, - comment === "update" ? "ref" : "sha", - bin, - ), - }, - ); + if ( + isPullRequest(workflowData.ref) && + (await getPullRequestState(installation, workflowData)) === "open" + ) { + let prevComment: OctokitComponents["schemas"]["issue-comment"]; + + await installation.paginate( + "GET /repos/{owner}/{repo}/issues/{issue_number}/comments", + { + owner: workflowData.owner, + repo: workflowData.repo, + issue_number: Number(workflowData.ref), + }, + ({ data }, done) => { + for (const c of data) { + if (c.performed_via_github_app?.id === Number(appId)) { + prevComment = c; + done(); + break; + } + } + return []; + }, + ); + + if (comment !== "off") { + const { + data: { permissions }, + } = await installation.request( + "GET /repos/{owner}/{repo}/installation", + { + owner: workflowData.owner, + repo: workflowData.repo, + }, + ); + + try { + if (comment === "update" && prevComment!) { + await installation.request( + "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}", + { + owner: workflowData.owner, + repo: workflowData.repo, + comment_id: prevComment.id, + body: generatePullRequestPublishMessage( + origin, + templatesHtmlMap, + packagesWithoutPrefix, + workflowData, + compact, + onlyTemplates, + checkRunUrl, + packageManager, + "ref", + bin, + ), + }, + ); + } else { + await installation.request( + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments", + { + owner: workflowData.owner, + repo: workflowData.repo, + issue_number: Number(workflowData.ref), + body: generatePullRequestPublishMessage( + origin, + templatesHtmlMap, + packagesWithoutPrefix, + workflowData, + compact, + onlyTemplates, + checkRunUrl, + packageManager, + comment === "update" ? "ref" : "sha", + bin, + ), + }, + ); + } + } catch (error) { + console.error("failed to create/update comment", error, permissions); } - } catch (error) { - console.error("failed to create/update comment", error, permissions); } } - } - event.waitUntil( - iterateAndDelete(event, usePackagesBucket.base, lastPackageKey!), - ); - event.waitUntil( - iterateAndDelete(event, useTemplatesBucket.base, lastTemplateKey!), - ); - - return { - ok: true, - urls, - debug: { - workflowData, - key, - runId, - webhookDebug: webhookDebugData, - }, - }; + event.waitUntil( + iterateAndDelete(event, usePackagesBucket.base, lastPackageKey!), + ); + event.waitUntil( + iterateAndDelete(event, useTemplatesBucket.base, lastTemplateKey!), + ); + + return { + ok: true, + urls, + debug: { + workflowData, + key, + runId, + webhookDebug: webhookDebugData, + }, + }; + } catch (error: unknown) { + console.error("Publish route error:", error); + + if (error && typeof error === "object" && "statusCode" in error) { + throw error; + } + + const message = + error instanceof Error + ? error.message + : "An unexpected error occurred during publishing"; + const stack = error instanceof Error ? error.stack : undefined; + + throw createError({ + statusCode: 500, + statusMessage: "Internal Server Error", + data: { + error: true, + message, + stack, + originalError: error, + type: "publish_error", + }, + }); + } }); async function getPullRequestState(