diff --git a/.github/ISSUE_TEMPLATE/external-plugin.yml b/.github/ISSUE_TEMPLATE/external-plugin.yml index 3daf8193..819d1f2d 100644 --- a/.github/ISSUE_TEMPLATE/external-plugin.yml +++ b/.github/ISSUE_TEMPLATE/external-plugin.yml @@ -15,6 +15,8 @@ body: - Public submissions are **GitHub-only** in v1. - The plugin must live in a **public GitHub repository**. - Provide an immutable **ref**, **sha**, or both for review. + - If your plugin includes a canvas extension, include the **canvas** keyword. + - Canvas plugins are validated for `logo: "assets/preview.png"` using the submitted immutable **sha** or **ref**. - Do **not** open a PR that edits `plugins/external.json` directly. - type: input id: plugin-name @@ -106,7 +108,7 @@ body: id: keywords attributes: label: Keywords - description: Comma-separated or newline-separated lowercase tags. + description: Comma-separated or newline-separated lowercase tags. Include `canvas` if the plugin contains a canvas extension. placeholder: | automation github diff --git a/.github/workflows/external-plugin-command-router.yml b/.github/workflows/external-plugin-command-router.yml index d657065d..1b8c317a 100644 --- a/.github/workflows/external-plugin-command-router.yml +++ b/.github/workflows/external-plugin-command-router.yml @@ -559,7 +559,11 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issueNumber: context.issue.number, - desiredLabels: new Set(['external-plugin', 'ready-for-review']) + desiredLabels: new Set([ + 'external-plugin', + 'ready-for-review', + ...(labelNames.has('external-plugin-canvas') ? ['external-plugin-canvas'] : []) + ]) }); const marker = ''; diff --git a/.github/workflows/setup-labels.yml b/.github/workflows/setup-labels.yml index 89887520..9f6121ee 100644 --- a/.github/workflows/setup-labels.yml +++ b/.github/workflows/setup-labels.yml @@ -44,6 +44,10 @@ jobs: color: 'FEF2C0', description: 'Public external plugin submission' }, + 'external-plugin-canvas': { + color: '1D76DB', + description: 'External plugin submission includes a canvas extension' + }, 'hooks': { color: 'C2E0C6', description: 'PR touches hooks' diff --git a/eng/external-plugin-intake-state.mjs b/eng/external-plugin-intake-state.mjs index f9351281..b73c7216 100644 --- a/eng/external-plugin-intake-state.mjs +++ b/eng/external-plugin-intake-state.mjs @@ -3,6 +3,10 @@ export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({ color: "FEF2C0", description: "Public external plugin submission", }, + "external-plugin-canvas": { + color: "1D76DB", + description: "External plugin submission includes a canvas extension", + }, "awaiting-review": { color: "FBCA04", description: "Submission is waiting for automated intake validation", @@ -27,6 +31,7 @@ export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({ const EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS = Object.freeze([ "external-plugin", + "external-plugin-canvas", "awaiting-review", "ready-for-review", "requires-submitter-fixes", @@ -129,6 +134,9 @@ export async function applyExternalPluginIntakeEvaluation({ rejected: new Set(["external-plugin", "rejected"]), }; const desiredLabels = desiredLabelsByState[state] ?? desiredLabelsByState.rejected; + if (evaluation.isCanvasPlugin) { + desiredLabels.add("external-plugin-canvas"); + } await syncExternalPluginIntakeLabels({ github, diff --git a/eng/external-plugin-intake.mjs b/eng/external-plugin-intake.mjs index bea5102d..794e36f5 100644 --- a/eng/external-plugin-intake.mjs +++ b/eng/external-plugin-intake.mjs @@ -54,6 +54,13 @@ const FIELD_TITLES = Object.freeze({ const LEGACY_FIELD_TITLES = Object.freeze({ immutableRef: "Immutable ref to review", }); +const EXTERNAL_CANVAS_KEYWORD = "canvas"; +const EXTERNAL_CANVAS_PREVIEW_PATH = "assets/preview.png"; +const EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS = Object.freeze([ + ".github/plugin/plugin.json", + ".plugin/plugin.json", + "plugin.json", +]); function normalizeMultilineText(value) { return String(value ?? "").replace(/\r\n/g, "\n"); @@ -116,6 +123,29 @@ function parseKeywords(value) { return keywords.length > 0 ? keywords : undefined; } +function hasCanvasKeyword(plugin) { + return (plugin?.keywords ?? []).some( + (keyword) => String(keyword).trim().toLowerCase() === EXTERNAL_CANVAS_KEYWORD, + ); +} + +function normalizeRepoRelativePath(value) { + const normalized = stripNoResponse(value); + if (!normalized || normalized === "/") { + return ""; + } + + return normalized.replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +function joinRepoPath(...segments) { + return segments + .map((segment) => String(segment ?? "").trim()) + .filter(Boolean) + .join("/") + .replace(/\/+/g, "/"); +} + function parseChecklist(value) { const checked = new Set(); const normalized = normalizeMultilineText(value); @@ -231,6 +261,33 @@ async function fetchGitHubJson(apiPath, token) { } } +function encodeRepoContentPath(value) { + return String(value) + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => encodeURIComponent(segment)) + .join("/"); +} + +async function fetchGitHubFile(repo, filePath, ref, token) { + const encodedRepo = encodeRepoPath(repo); + const encodedPath = encodeRepoContentPath(filePath); + return fetchGitHubJson( + `/repos/${encodedRepo}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`, + token, + ); +} + +function decodeGitHubFileContent(fileResponse) { + const encodedContent = fileResponse?.data?.content; + if (!encodedContent || typeof encodedContent !== "string") { + return null; + } + + const normalized = encodedContent.replace(/\n/g, ""); + return Buffer.from(normalized, "base64").toString("utf8"); +} + function encodeRepoPath(repo) { const [owner, name] = String(repo).split("/"); return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`; @@ -273,6 +330,7 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to ); } } + } if (!ref) { @@ -322,6 +380,102 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to } } +async function validateCanvasPluginMetadata(plugin, errors, warnings, token) { + const repo = plugin?.source?.repo; + const sha = plugin?.source?.sha; + const ref = plugin?.source?.ref; + const releaseLocator = sha || ref; + const releaseLocatorDescription = sha ? `commit "${sha}"` : `ref "${ref}"`; + const pluginRoot = normalizeRepoRelativePath(plugin?.source?.path); + + if (!releaseLocator) { + errors.push('submission: plugins tagged with "canvas" must provide "Ref to review" and/or "Commit SHA to review"'); + return; + } + + if (!repo) { + return; + } + + let manifest = null; + let manifestPath = null; + let sawManifestApiError = false; + + const manifestCandidates = EXTERNAL_PLUGIN_ROOT_MANIFEST_PATHS.map((relativePath) => + joinRepoPath(pluginRoot, relativePath), + ); + + for (const candidatePath of manifestCandidates) { + const response = await fetchGitHubFile(repo, candidatePath, releaseLocator, token); + if (response.kind === "notFound") { + continue; + } + + if (response.kind === "apiError") { + sawManifestApiError = true; + continue; + } + + if (response.data?.type !== "file") { + continue; + } + + const decoded = decodeGitHubFileContent(response); + if (!decoded) { + errors.push(`submission: could not decode plugin manifest "${candidatePath}" at ${releaseLocatorDescription}`); + return; + } + + try { + manifest = JSON.parse(decoded); + manifestPath = candidatePath; + break; + } catch (error) { + errors.push( + `submission: plugin manifest "${candidatePath}" at ${releaseLocatorDescription} is not valid JSON (${error.message})`, + ); + return; + } + } + + if (!manifest) { + if (sawManifestApiError) { + warnings.push( + `submission: could not verify canvas plugin manifest in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`, + ); + return; + } + + const expectedPaths = manifestCandidates.map((candidatePath) => `"${candidatePath}"`).join(", "); + errors.push( + `submission: plugins tagged with "canvas" must include a manifest at one of ${expectedPaths} in ${releaseLocatorDescription}`, + ); + return; + } + + if (manifest.logo !== EXTERNAL_CANVAS_PREVIEW_PATH) { + errors.push( + `submission: plugins tagged with "canvas" must set "logo" to "${EXTERNAL_CANVAS_PREVIEW_PATH}" in "${manifestPath}"`, + ); + } + + const previewPath = joinRepoPath(pluginRoot, EXTERNAL_CANVAS_PREVIEW_PATH); + const previewResponse = await fetchGitHubFile(repo, previewPath, releaseLocator, token); + if (previewResponse.kind === "notFound") { + errors.push( + `submission: plugins tagged with "canvas" must include "${EXTERNAL_CANVAS_PREVIEW_PATH}" at ${releaseLocatorDescription}`, + ); + } else if (previewResponse.kind === "apiError") { + warnings.push( + `submission: could not verify "${EXTERNAL_CANVAS_PREVIEW_PATH}" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`, + ); + } else if (previewResponse.data?.type !== "file") { + errors.push( + `submission: "${EXTERNAL_CANVAS_PREVIEW_PATH}" must be a file in ${releaseLocatorDescription}`, + ); + } +} + export function parseExternalPluginIssueBody(body) { const sections = parseIssueFormSections(body); const errors = []; @@ -606,6 +760,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner, const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" }); errors.push(...validationResult.errors.map(toSubmissionError)); warnings.push(...validationResult.warnings.map(toSubmissionError)); + const isCanvasPlugin = hasCanvasKeyword(parsed.plugin); if (parsed.plugin?.name) { const matchingName = duplicateNames.find( @@ -620,6 +775,10 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner, await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source, errors, warnings, token); } + if (isCanvasPlugin) { + await validateCanvasPluginMetadata(parsed.plugin, errors, warnings, token); + } + const dedupedErrors = [...new Set(errors)]; const dedupedWarnings = [...new Set(warnings)]; const valid = dedupedErrors.length === 0; @@ -687,6 +846,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner, errors: dedupedErrors, warnings: dedupedWarnings, plugin: parsed.plugin, + isCanvasPlugin, commentBody, commentMarker: marker, };