import { createSign } from "node:crypto"; import type { IssueReportInput, IssueReportResult, } from "@webstudio-is/protocol"; import env from "~/env/env.server"; type GitHubIssue = { number: number; html_url: string; }; type GitHubSearchResponse = { items?: GitHubIssue[]; }; const githubApiVersion = "2022-11-28"; const reportRepository = "webstudio-is/webstudio"; const getReportMarker = (deduplicationKey: string) => ``; const formatItems = (items: readonly string[]) => items.map((item, index) => `${index + 1}. ${item}`).join("\n"); const formatRuntime = (runtime: IssueReportInput["runtime"]) => [ "## Technical runtime", "", `- CLI: ${runtime?.cliVersion ?? "unknown"}`, `- Node.js: ${runtime?.nodeVersion ?? "unknown"}`, ...(runtime?.projectId === undefined ? [] : [`- Project ID: \`${runtime.projectId}\``]), ...(runtime?.recentTargets === undefined ? [] : runtime.recentTargets.flatMap(({ tool, entityIds }) => [ `- Recent targeted tool: \`${tool}\``, ...entityIds.map(({ field, id }) => ` - ${field}: \`${id}\``), ])), ...(runtime === undefined ? [] : [ `- Operating system: ${runtime.os} ${runtime.osVersion} (${runtime.architecture})`, `- Execution mode: ${runtime.executionMode}`, `- API contract: ${runtime.apiContractVersion}`, ...(runtime.serverApiContractVersion === undefined ? [] : [`- Server API contract: ${runtime.serverApiContractVersion}`]), `- Bundle contract: ${runtime.bundleVersion ?? "unknown"}`, ]), ...(runtime?.session === undefined ? [] : [ `- Session namespace issues: stale=${runtime.session.staleNamespaces.join(",") || "none"}; missing=${runtime.session.missingNamespaces.join(",") || "none"}; invalidated=${runtime.session.invalidatedNamespaces.join(",") || "none"}`, ]), ...(runtime?.preview === undefined ? [] : [ `- Preview: stale=${runtime.preview.stale}; rendered=${runtime.preview.hasRenderedVersion}; version matches session=${runtime.preview.renderedVersionMatchesSession ?? "unknown"}`, ]), ].join("\n"); const formatFailureDiagnostics = (runtime: IssueReportInput["runtime"]) => { const failure = runtime?.recentFailure; if (failure === undefined) { return ""; } const issues = failure.issues ?? []; const previewInstall = failure.previewInstall; const previewInstallDetails = [ previewInstall?.processErrorCode, previewInstall?.exitCode === undefined ? undefined : `exit code ${previewInstall.exitCode}`, previewInstall?.terminated ? "process terminated" : undefined, ] .filter(Boolean) .join("; "); return [ "## Captured failure diagnostics", "", `- Tool: \`${failure.tool}\``, `- Error code: \`${failure.code}\``, ...(failure.errorType === undefined ? [] : [`- Error type: ${failure.errorType}`]), ...(failure.requestId === undefined ? [] : [`- Request ID: \`${failure.requestId}\``]), ...(failure.httpStatus === undefined ? [] : [`- HTTP status: \`${failure.httpStatus}\``]), ...(failure.elapsedMs === undefined ? [] : [`- Duration: \`${failure.elapsedMs}ms\``]), ...(failure.entityIds === undefined ? [] : failure.entityIds.map(({ field, id }) => `- ${field}: \`${id}\``)), ...(failure.response === undefined ? [] : [ `- Response: ${failure.response.format}; envelope=${failure.response.envelope}${failure.response.batchSize === undefined ? "" : `; batch size=${failure.response.batchSize}`}`, ]), ...(failure.browser === undefined ? [] : [ ...(failure.browser.exitSignal !== undefined ? [`- Browser exit: ${failure.browser.exitSignal}`] : failure.browser.exitCode === undefined ? [] : [`- Browser exit: code ${failure.browser.exitCode}`]), ...(failure.browser.attempts === undefined ? [] : [ `- Browser attempts: ${failure.browser.attempts.map((attempt) => `${attempt.browser} (${attempt.source})`).join(", ")}`, ]), ]), ...(previewInstallDetails === "" ? [] : [`- Preview install: ${previewInstallDetails}`]), ...(issues.length === 0 ? [] : [ "- Invalid fields:", ...issues.map( (issue) => ` - \`${issue.path.join(".") || "input"}\`: \`${issue.code}\` (${issue.constraint})` ), ]), "", ].join("\n"); }; export const formatIssueReport = ({ agent, category, deduplicationKey, report, runtime, trigger, }: IssueReportInput) => `${getReportMarker(deduplicationKey)} ## User story ${report.userStory} ## Summary ${report.summary} ## What the agent tried ${formatItems(report.attemptedWorkflow)} ## Expected behavior ${report.expectedBehavior} ## Actual result ${report.actualResult} ## Recovery attempts ${formatItems(report.recoveryAttempts)} ## User impact ${report.userImpact} ## Technical context ${report.technicalContext} ## Agent environment - Client: ${agent.client}${agent.clientVersion === undefined ? "" : ` ${agent.clientVersion}`} - Provider: ${agent.provider ?? "unknown"} - Model: ${agent.model} - Reasoning effort: ${agent.reasoningEffort} - Trigger: ${trigger} - Category: ${category} ${formatRuntime(runtime)} ${formatFailureDiagnostics(runtime)} ## Acceptance criteria ${formatItems(report.acceptanceCriteria)} `; const requestGitHub = async ({ body, method, path, request, token, }: { body?: object; method?: "GET" | "POST"; path: string; request: typeof fetch; token: string; }) => { const response = await request(new URL(path, "https://api.github.com"), { method, headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": githubApiVersion, ...(body === undefined ? {} : { "Content-Type": "application/json" }), }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); if (response.ok === false) { throw new Error( `GitHub issue reporting failed with HTTP ${response.status}.` ); } return (await response.json()) as Result; }; const encodeJwtPart = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); export const createGitHubAppJwt = ({ appId, privateKey, now = Date.now(), }: { appId: string; privateKey: string; now?: number; }) => { const issuedAt = Math.floor(now / 1000) - 60; const unsignedToken = [ encodeJwtPart({ alg: "RS256", typ: "JWT" }), encodeJwtPart({ iss: appId, iat: issuedAt, exp: issuedAt + 10 * 60 }), ].join("."); const signer = createSign("RSA-SHA256"); signer.update(unsignedToken); signer.end(); const signature = signer .sign(privateKey.split("\\n").join("\n")) .toString("base64url"); return `${unsignedToken}.${signature}`; }; export const createGitHubInstallationToken = async ({ appId, createJwt = createGitHubAppJwt, privateKey, request = fetch, }: { appId: string; createJwt?: typeof createGitHubAppJwt; privateKey: string; request?: typeof fetch; }) => { const appJwt = createJwt({ appId, privateKey }); const installation = await requestGitHub<{ id?: unknown }>({ path: `/repos/${reportRepository}/installation`, request, token: appJwt, }); if (typeof installation.id !== "number") { throw new Error("GitHub App installation was not found."); } const result = await requestGitHub<{ token?: unknown }>({ method: "POST", path: `/app/installations/${installation.id}/access_tokens`, request, token: appJwt, }); if (typeof result.token !== "string" || result.token.length === 0) { throw new Error("GitHub App did not return an installation token."); } return result.token; }; export const publishIssueReport = async ( input: IssueReportInput, { getInstallationToken, request = fetch, }: { getInstallationToken: () => Promise; request?: typeof fetch; } ): Promise => { const token = await getInstallationToken(); const searchUrl = new URL("https://api.github.com/search/issues"); searchUrl.searchParams.set( "q", `repo:${reportRepository} is:issue in:body "${getReportMarker(input.deduplicationKey)}"` ); const search = await requestGitHub({ path: `${searchUrl.pathname}${searchUrl.search}`, request, token, }); const existing = search.items?.[0]; if (existing !== undefined) { return { status: "existing", issueNumber: existing.number, issueUrl: existing.html_url, }; } const created = await requestGitHub({ method: "POST", path: `/repos/${reportRepository}/issues`, request, token, body: { title: input.title, body: formatIssueReport(input), }, }); return { status: "created", issueNumber: created.number, issueUrl: created.html_url, }; }; export const publishConfiguredIssueReport = async (input: IssueReportInput) => { const appId = env.GITHUB_ISSUE_REPORT_APP_ID; const privateKey = env.GITHUB_ISSUE_REPORT_PRIVATE_KEY; if (appId === undefined || privateKey === undefined) { throw new Error("GitHub issue reporting is not configured."); } return await publishIssueReport(input, { getInstallationToken: async () => await createGitHubInstallationToken({ appId, privateKey, }), }); };