Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/ISSUE_TEMPLATE/external-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/external-plugin-command-router.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<!-- external-plugin-mark-ready-override -->';
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/setup-labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
8 changes: 8 additions & 0 deletions eng/external-plugin-intake-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
160 changes: 160 additions & 0 deletions eng/external-plugin-intake.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Comment thread
Copilot marked this conversation as resolved.
Comment on lines +59 to +63

function normalizeMultilineText(value) {
return String(value ?? "").replace(/\r\n/g, "\n");
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ?? "")}`;
Expand Down Expand Up @@ -273,6 +330,7 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
);
}
}

}

if (!ref) {
Expand Down Expand Up @@ -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;
}
Comment on lines +414 to +417

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 = [];
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down Expand Up @@ -687,6 +846,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
errors: dedupedErrors,
warnings: dedupedWarnings,
plugin: parsed.plugin,
isCanvasPlugin,
commentBody,
commentMarker: marker,
};
Expand Down
Loading