Files
webstudio/apps/builder/app/services/github-issue-report.server.ts
Oleg Isonen 659e953869 fix: Preserve transitions and animation preview behavior (#6602)
Transition variables no longer get copied into unrelated timing fields.
Generated Text/Stagger animations use the saved `slidingWindow` prop,
while older `charWindow` configurations still work. Easing moves in the
expected direction. Windows preview-install reports include the process
failure code when available.

Closes #6598
Ref #6593
Ref #6599
Ref #6594

The animation runtime lives in [sdk-components-animation
#38](https://github.com/webstudio-is/sdk-components-animation/pull/38).
The private PR is merged; this PR points to its merged commit. #6593 was
tested on its exact saved project in headless Chromium and closed as not
reproducible; the confirmed prop leak is fixed here. #6594 was closed
after a documented local repro attempt; this PR improves the next
report.

## Work

- [x] Reproduce the transition bug; add regression coverage in parsing
and generated CSS.
- [x] Preserve unresolved `transition` shorthands for the browser to
resolve.
- [x] Keep old `charWindow` values working in Text and Stagger
animations, with a red/green regression test in the linked private PR.
- [x] Reproduce and fix the saved animation prop mismatch in generated
helpers.
- [x] Verify text/stagger easing in unit tests and Chromium/Firefox.
- [x] Add safe preview-install process details for future Windows
reports.
- [x] Update Builder property help and both animation docs pages.
- [x] Run focused animation tests, typecheck, lint, package build, and
animation fixture build after the compatibility fix.
- [x] Point the animation submodule to the merged #38 commit.
- [x] Verify required CI on the final commit (all workflows passed; one
unrelated E2E shard passed on rerun).
- [x] Find the production share token and build #6593’s exact project
with its reported 0.307.0 packages.
- [x] Compare intermediate scroll styles on #6593’s exact project.
Visible targets animate in both builds; the leaked attributes disappear
with this fix.

Known limit: the reported static-target symptom in #6593 and the Windows
environment in #6594 were not reproduced. The confirmed animation prop
leak and reporting gap are fixed, without claiming the unreproduced
symptoms are solved.
2026-10-25 19:12:25 +01:00

344 lines
9.5 KiB
TypeScript

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) =>
`<!-- webstudio-issue-report:${deduplicationKey} -->`;
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 <Result>({
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<string>;
request?: typeof fetch;
}
): Promise<IssueReportResult> => {
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<GitHubSearchResponse>({
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<GitHubIssue>({
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,
}),
});
};