mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 19:03:26 +00:00
Add canvas-specific intake validation for external plugins (#2319)
* Add canvas-aware checks to external plugin intake Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0f03fd92-3bfa-4c67-a709-177fbd46c40e * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,8 @@ body:
|
|||||||
- Public submissions are **GitHub-only** in v1.
|
- Public submissions are **GitHub-only** in v1.
|
||||||
- The plugin must live in a **public GitHub repository**.
|
- The plugin must live in a **public GitHub repository**.
|
||||||
- Provide an immutable **ref**, **sha**, or both for review.
|
- 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.
|
- Do **not** open a PR that edits `plugins/external.json` directly.
|
||||||
- type: input
|
- type: input
|
||||||
id: plugin-name
|
id: plugin-name
|
||||||
@@ -106,7 +108,7 @@ body:
|
|||||||
id: keywords
|
id: keywords
|
||||||
attributes:
|
attributes:
|
||||||
label: Keywords
|
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: |
|
placeholder: |
|
||||||
automation
|
automation
|
||||||
github
|
github
|
||||||
|
|||||||
@@ -559,7 +559,11 @@ jobs:
|
|||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
issueNumber: context.issue.number,
|
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 -->';
|
const marker = '<!-- external-plugin-mark-ready-override -->';
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ jobs:
|
|||||||
color: 'FEF2C0',
|
color: 'FEF2C0',
|
||||||
description: 'Public external plugin submission'
|
description: 'Public external plugin submission'
|
||||||
},
|
},
|
||||||
|
'external-plugin-canvas': {
|
||||||
|
color: '1D76DB',
|
||||||
|
description: 'External plugin submission includes a canvas extension'
|
||||||
|
},
|
||||||
'hooks': {
|
'hooks': {
|
||||||
color: 'C2E0C6',
|
color: 'C2E0C6',
|
||||||
description: 'PR touches hooks'
|
description: 'PR touches hooks'
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ export const EXTERNAL_PLUGIN_INTAKE_LABELS = Object.freeze({
|
|||||||
color: "FEF2C0",
|
color: "FEF2C0",
|
||||||
description: "Public external plugin submission",
|
description: "Public external plugin submission",
|
||||||
},
|
},
|
||||||
|
"external-plugin-canvas": {
|
||||||
|
color: "1D76DB",
|
||||||
|
description: "External plugin submission includes a canvas extension",
|
||||||
|
},
|
||||||
"awaiting-review": {
|
"awaiting-review": {
|
||||||
color: "FBCA04",
|
color: "FBCA04",
|
||||||
description: "Submission is waiting for automated intake validation",
|
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([
|
const EXTERNAL_PLUGIN_INTAKE_SYNC_LABELS = Object.freeze([
|
||||||
"external-plugin",
|
"external-plugin",
|
||||||
|
"external-plugin-canvas",
|
||||||
"awaiting-review",
|
"awaiting-review",
|
||||||
"ready-for-review",
|
"ready-for-review",
|
||||||
"requires-submitter-fixes",
|
"requires-submitter-fixes",
|
||||||
@@ -129,6 +134,9 @@ export async function applyExternalPluginIntakeEvaluation({
|
|||||||
rejected: new Set(["external-plugin", "rejected"]),
|
rejected: new Set(["external-plugin", "rejected"]),
|
||||||
};
|
};
|
||||||
const desiredLabels = desiredLabelsByState[state] ?? desiredLabelsByState.rejected;
|
const desiredLabels = desiredLabelsByState[state] ?? desiredLabelsByState.rejected;
|
||||||
|
if (evaluation.isCanvasPlugin) {
|
||||||
|
desiredLabels.add("external-plugin-canvas");
|
||||||
|
}
|
||||||
|
|
||||||
await syncExternalPluginIntakeLabels({
|
await syncExternalPluginIntakeLabels({
|
||||||
github,
|
github,
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ const FIELD_TITLES = Object.freeze({
|
|||||||
const LEGACY_FIELD_TITLES = Object.freeze({
|
const LEGACY_FIELD_TITLES = Object.freeze({
|
||||||
immutableRef: "Immutable ref to review",
|
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) {
|
function normalizeMultilineText(value) {
|
||||||
return String(value ?? "").replace(/\r\n/g, "\n");
|
return String(value ?? "").replace(/\r\n/g, "\n");
|
||||||
@@ -116,6 +123,29 @@ function parseKeywords(value) {
|
|||||||
return keywords.length > 0 ? keywords : undefined;
|
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) {
|
function parseChecklist(value) {
|
||||||
const checked = new Set();
|
const checked = new Set();
|
||||||
const normalized = normalizeMultilineText(value);
|
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) {
|
function encodeRepoPath(repo) {
|
||||||
const [owner, name] = String(repo).split("/");
|
const [owner, name] = String(repo).split("/");
|
||||||
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
|
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
|
||||||
@@ -273,6 +330,7 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ref) {
|
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) {
|
export function parseExternalPluginIssueBody(body) {
|
||||||
const sections = parseIssueFormSections(body);
|
const sections = parseIssueFormSections(body);
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -606,6 +760,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
|
|||||||
const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" });
|
const validationResult = validateExternalPlugin(parsed.plugin, 0, { policy: "publicSubmission" });
|
||||||
errors.push(...validationResult.errors.map(toSubmissionError));
|
errors.push(...validationResult.errors.map(toSubmissionError));
|
||||||
warnings.push(...validationResult.warnings.map(toSubmissionError));
|
warnings.push(...validationResult.warnings.map(toSubmissionError));
|
||||||
|
const isCanvasPlugin = hasCanvasKeyword(parsed.plugin);
|
||||||
|
|
||||||
if (parsed.plugin?.name) {
|
if (parsed.plugin?.name) {
|
||||||
const matchingName = duplicateNames.find(
|
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);
|
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 dedupedErrors = [...new Set(errors)];
|
||||||
const dedupedWarnings = [...new Set(warnings)];
|
const dedupedWarnings = [...new Set(warnings)];
|
||||||
const valid = dedupedErrors.length === 0;
|
const valid = dedupedErrors.length === 0;
|
||||||
@@ -687,6 +846,7 @@ export async function evaluateExternalPluginIssue({ issue, token, runId, owner,
|
|||||||
errors: dedupedErrors,
|
errors: dedupedErrors,
|
||||||
warnings: dedupedWarnings,
|
warnings: dedupedWarnings,
|
||||||
plugin: parsed.plugin,
|
plugin: parsed.plugin,
|
||||||
|
isCanvasPlugin,
|
||||||
commentBody,
|
commentBody,
|
||||||
commentMarker: marker,
|
commentMarker: marker,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user