mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 14:03:33 +09:00
Cloning a Collection can leave its item bindings attached to the original Collection. Content-mode template insertion also drops fixed image attributes, rich-text editing can remove inline images, and YouTube previews retain the previous thumbnail after a URL edit. Reuse the Builder fragment-copy path for cloning, preserve exact fixed props from the same Content Block template, protect mixed image/text content from destructive editing, and refresh video previews. Direct component metadata lookup now includes hidden required parts without adding them to component lists. Unsupported Resource fields now return validation errors instead of unchanged success. Destructive confirmation failures retain their reason through the existing issue-report pipeline, and reports include the negotiated server API contract alongside the CLI contract. Contract versions still use the existing serialized-schema hash algorithm. ## Verification - [x] Reproduce each behavior fix and demonstrate regression tests fail before/pass after. - [x] Verify native Content template insertion, content editing, and reload through isolated local Builder E2E. - [x] Check copy permissions: reject arbitrary design values, reused/mismatched prop keys, changed clone structure, and copies from another Content Block. - [x] Remove the unused clone implementation and reuse the UI's copy/remapping logic. - [x] Review documentation impact; existing instructions remain accurate, no documentation update needed. - [x] Regenerate fixture builds from committed snapshots; generated fixture files remain unchanged. - [x] Complete generated API/docs checks, 2,492 project-build tests, 88 protocol tests, 99 focused CLI tests, package typechecks, lint, and formatting. - [x] Full Luna/low evaluation suite: 6/6 fixtures pass after preparing the Content runtime build. Existing baselines are incompatible with this model override; they were not updated. - [x] Independent Astra review: fixed and re-tested Global Root cloning and protected-prop overwrite findings; final review has no findings. - [x] Required CI passes: all 19 main-workflow jobs, visual regression, submodules, and PR lint. Closes #6531 Closes #6519 Closes #6521 Ref #6523 Ref #6524 Ref #6525 Ref #6526 Ref #6527 Ref #6528 Ref #6529
319 lines
8.6 KiB
TypeScript
319 lines
8.6 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 === 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 ?? [];
|
|
return [
|
|
"## Captured failure diagnostics",
|
|
"",
|
|
`- Tool: \`${failure.tool}\``,
|
|
`- Error code: \`${failure.code}\``,
|
|
...(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(", ")}`,
|
|
]),
|
|
]),
|
|
...(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,
|
|
}),
|
|
});
|
|
};
|