mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-07 18:03:02 +00:00
a7fdcd5006
* feat: migrate plugins and extensions to Agent Plugins v1.0.0 spec - Add \ to all 69 curated plugin manifests - Migrate all 18 extension manifests: add \, move logo into xtensions.com.github.copilot.logo namespace, remove top-level logo and string xtensions: '.' - Update eng/validate-plugins.mjs: require \, validate namespace-keyed extensions object for canvas extensions, widen name pattern to allow dots (spec §5.5, max 64 chars) - Update eng/materialize-plugins.mjs: emit spec-clean served manifests (only spec fields: \, name, version, description, author, homepage, repository, license, keywords, extensions) - Update eng/generate-website-data.mjs: read logo from namespace with fallback to top-level logo for compatibility - Update eng/create-plugin.mjs: scaffold emits \ - Add .github/workflows/validate-plugins.yml: blocking CI for PRs touching plugins/** or extensions/** - Add spec compliance check to external plugin quality gates: non-blocking warnings with ✅/⚠️/🛑 emoji legend - Update AGENTS.md: document new extension manifest shape, add \ to plugin checklist Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * refactor: consolidate canvas extension plugins - Move all extension plugin manifests from extensions/<name> to plugins/<name> - Keep extensions/<name> as reusable source only - Remove standalone extension discovery from marketplace and website plugin catalogs - Auto-bundle same-name extension sources during materialization - Add build-only extensions.json references for sharing extensions across plugins - Remove x-awesome-copilot extension metadata support - Update validation and contributor documentation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * feat: add canvas extension scaffolding skill - Add repo-local skill for creating canvas extension sources - Generate spec-compliant plugin manifests under plugins/ - Support registering reusable extensions with multiple plugins - Remove guidance for extension-local plugin manifests and custom fields Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * fix: align extension namespaces with current guidance - Use each extension ID as its manifest namespace key - Update validation and website generation to resolve extension-specific namespaces - Upsert plugin validation PR comments using the existing repository pattern Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * fix: use Copilot extension namespace - Adopt com.github.copilot for all canvas extension manifests - Require the namespace during validation and website generation - Update extension scaffolding guidance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * docs: regenerate plugin catalog after merge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * refactor(plugins): move manifests to plugin roots Use root plugin.json manifests and namespaced extension directories throughout local tooling, validation, generation, and contributor documentation. Restore materialize-plugins.mjs line breaks so the source remains readable in GitHub. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * feat(plugins): migrate manifests to namespaced composition Move repository composition metadata under com.github.awesome-copilot, materialize reusable extensions into the plugin extensions directory, and improve contributor and PR validation guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * fix(validation): address plugin review findings Restore executable build scripts, validate namespaced manifests and hook directories, improve README item counts, and manage validation comments across reruns. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Copilot-Session: 8f3a88cb-e01e-4760-8125-460490dc1a76
1155 lines
40 KiB
JavaScript
1155 lines
40 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { ROOT_FOLDER } from "./constants.mjs";
|
|
import { readExternalPlugins, validateExternalPlugin } from "./external-plugin-validation.mjs";
|
|
import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs";
|
|
|
|
export const ISSUE_FORM_MARKER = "<!-- external-plugin-submission -->";
|
|
export const EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER = "<!-- external-plugin-intake -->";
|
|
export const RERUN_INTAKE_COMMAND = "/rerun-intake";
|
|
export const MARK_READY_FOR_REVIEW_COMMAND = "/mark-ready-for-review";
|
|
const RERUN_INTAKE_COMMAND_PATTERN = new RegExp(
|
|
`^\\s*${RERUN_INTAKE_COMMAND.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
"m",
|
|
);
|
|
const MARK_READY_FOR_REVIEW_COMMAND_PATTERN = new RegExp(
|
|
`^\\s*${MARK_READY_FOR_REVIEW_COMMAND.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
"m",
|
|
);
|
|
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
|
|
|
// Each entry is a Set of equivalent checklist item texts (new + legacy aliases).
|
|
// A submission passes if the checked items contain at least one text from each Set.
|
|
const REQUIRED_CHECKLIST_ITEMS = [
|
|
new Set(["The plugin lives in a public GitHub repository."]),
|
|
new Set([
|
|
"The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch.",
|
|
// Legacy text used in the original issue template
|
|
"The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.",
|
|
]),
|
|
new Set(["This submission follows this repository's contribution, security, and responsible AI policies."]),
|
|
new Set(["This plugin is not already listed in the Awesome Copilot marketplace."]),
|
|
];
|
|
|
|
const FIELD_TITLES = Object.freeze({
|
|
pluginName: "Plugin name",
|
|
shortDescription: "Short description",
|
|
githubRepository: "GitHub repository",
|
|
pluginPath: "Plugin path inside the repository",
|
|
immutableRef: "Ref to review",
|
|
immutableSha: "Commit SHA to review",
|
|
version: "Version",
|
|
license: "License identifier",
|
|
authorName: "Author name",
|
|
authorUrl: "Author URL",
|
|
homepageUrl: "Homepage URL",
|
|
keywords: "Keywords",
|
|
additionalNotes: "Additional notes for reviewers",
|
|
submissionChecklist: "Submission checklist",
|
|
});
|
|
|
|
// Legacy field title used in the original issue template (before the ref/sha split)
|
|
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");
|
|
}
|
|
|
|
function stripNoResponse(value) {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = normalizeMultilineText(value).trim();
|
|
if (!normalized || normalized === "_No response_") {
|
|
return undefined;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function parseIssueFormSections(body) {
|
|
const normalized = normalizeMultilineText(body);
|
|
const sections = new Map();
|
|
const matches = [...normalized.matchAll(/^###\s+(.+)$/gm)];
|
|
|
|
for (let index = 0; index < matches.length; index += 1) {
|
|
const heading = matches[index][1].trim();
|
|
const start = matches[index].index + matches[index][0].length;
|
|
const end = index + 1 < matches.length ? matches[index + 1].index : normalized.length;
|
|
const content = normalized.slice(start, end).trim();
|
|
sections.set(heading, content);
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
function normalizeGitHubRepo(value) {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
const trimmed = value.trim();
|
|
const urlMatch = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?\/?$/i);
|
|
if (urlMatch) {
|
|
return urlMatch[1];
|
|
}
|
|
|
|
return trimmed.replace(/^github\.com\//i, "").replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
function parseKeywords(value) {
|
|
const normalized = stripNoResponse(value);
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
|
|
const keywords = normalized
|
|
.split(/[\n,]/)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean);
|
|
|
|
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);
|
|
|
|
for (const match of normalized.matchAll(/^- \[(x|X)\] (.+)$/gm)) {
|
|
checked.add(match[2].trim());
|
|
}
|
|
|
|
return checked;
|
|
}
|
|
|
|
function readLocalPluginNames() {
|
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
|
return [];
|
|
}
|
|
|
|
return fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => entry.name);
|
|
}
|
|
|
|
function toSubmissionError(message) {
|
|
return message.replace(/^external\.json\[0\]:\s*/, "submission: ");
|
|
}
|
|
|
|
function isGitHubRateLimitResponse(response, data) {
|
|
if (response.status === 429 || response.status === 503) {
|
|
return true;
|
|
}
|
|
|
|
if (response.status !== 403) {
|
|
return false;
|
|
}
|
|
|
|
const message = String(data?.message ?? "").toLowerCase();
|
|
return (
|
|
response.headers.get("retry-after") !== null ||
|
|
response.headers.get("x-ratelimit-remaining") === "0" ||
|
|
message.includes("rate limit") ||
|
|
message.includes("secondary rate limit")
|
|
);
|
|
}
|
|
|
|
function getGitHubApiErrorReason(response, data) {
|
|
const message = String(data?.message ?? "").toLowerCase();
|
|
|
|
if (response.status === 429) {
|
|
return "rate limited";
|
|
}
|
|
|
|
if (response.status === 503) {
|
|
if (message.includes("secondary rate limit")) {
|
|
return "secondary rate limited";
|
|
}
|
|
return "service unavailable";
|
|
}
|
|
|
|
if (response.status === 403 && isGitHubRateLimitResponse(response, data)) {
|
|
if (message.includes("secondary rate limit")) {
|
|
return "secondary rate limited";
|
|
}
|
|
return "rate limited";
|
|
}
|
|
|
|
if (response.status === 0) {
|
|
return "network error";
|
|
}
|
|
|
|
return response.statusText || `HTTP ${response.status}`;
|
|
}
|
|
|
|
async function fetchGitHubJson(apiPath, token) {
|
|
try {
|
|
const response = await fetch(`https://api.github.com${apiPath}`, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
"User-Agent": "awesome-copilot-external-plugin-intake",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
});
|
|
|
|
let data = null;
|
|
try {
|
|
data = await response.json();
|
|
} catch {
|
|
data = null;
|
|
}
|
|
|
|
if (response.ok) {
|
|
return { kind: "found", ok: true, status: response.status, data };
|
|
}
|
|
|
|
if (response.status === 404) {
|
|
return { kind: "notFound", ok: false, status: 404, data: null };
|
|
}
|
|
|
|
return {
|
|
kind: "apiError",
|
|
ok: false,
|
|
status: response.status,
|
|
data,
|
|
reason: getGitHubApiErrorReason(response, data),
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
kind: "apiError",
|
|
ok: false,
|
|
status: 0,
|
|
data: null,
|
|
reason: "network error",
|
|
error,
|
|
};
|
|
}
|
|
}
|
|
|
|
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 ?? "")}`;
|
|
}
|
|
|
|
async function resolveCommitSha(repo, locator, token) {
|
|
const encodedRepo = encodeRepoPath(repo);
|
|
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(locator)}`, token);
|
|
if (commitResponse.kind !== "found") {
|
|
return commitResponse;
|
|
}
|
|
|
|
return {
|
|
...commitResponse,
|
|
commitSha: normalizeCommitSha(commitResponse.data?.sha),
|
|
};
|
|
}
|
|
|
|
async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, token) {
|
|
const encodedRepo = encodeRepoPath(repo);
|
|
const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token);
|
|
const normalizedSha = normalizeCommitSha(sha);
|
|
|
|
if (repositoryResponse.kind === "notFound") {
|
|
errors.push(`submission: GitHub repository "${repo}" was not found`);
|
|
return;
|
|
}
|
|
|
|
if (repositoryResponse.kind === "apiError") {
|
|
const statusText = repositoryResponse.status ? `HTTP ${repositoryResponse.status}` : "network error";
|
|
warnings.push(
|
|
`submission: could not verify GitHub repository "${repo}" (${statusText}${repositoryResponse.reason ? ` — ${repositoryResponse.reason}` : ""}); a maintainer should re-run intake`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (repositoryResponse.data?.private) {
|
|
errors.push(`submission: GitHub repository "${repo}" must be public`);
|
|
}
|
|
|
|
if (repositoryResponse.data?.archived) {
|
|
warnings.push(`submission: GitHub repository "${repo}" is archived`);
|
|
}
|
|
|
|
if (sha) {
|
|
if (/^[0-9a-f]{40}$/i.test(sha)) {
|
|
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/commits/${encodeURIComponent(sha)}`, token);
|
|
if (commitResponse.kind === "notFound") {
|
|
errors.push(`submission: commit "${sha}" was not found in GitHub repository "${repo}"`);
|
|
} else if (commitResponse.kind === "apiError") {
|
|
const statusText = commitResponse.status ? `HTTP ${commitResponse.status}` : "network error";
|
|
warnings.push(
|
|
`submission: could not verify commit "${sha}" in GitHub repository "${repo}" (${statusText}${commitResponse.reason ? ` — ${commitResponse.reason}` : ""}); a maintainer should re-run intake`,
|
|
);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
function validateRefShaConsistency(refCommitSha) {
|
|
if (!normalizedSha || !refCommitSha) {
|
|
return;
|
|
}
|
|
|
|
const consistency = evaluateRefShaConsistency({ ref, sha, resolvedRefCommitSha: refCommitSha });
|
|
if (!consistency.matches) {
|
|
errors.push(
|
|
`submission: when both "Ref to review" and "Commit SHA to review" are provided, they must reference the same commit (ref "${ref}" resolves to "${consistency.normalizedRefCommitSha}", sha is "${sha}")`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!ref) {
|
|
return;
|
|
}
|
|
|
|
if (/^[0-9a-f]{40}$/i.test(ref)) {
|
|
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/commits/${encodeURIComponent(ref)}`, token);
|
|
if (commitResponse.kind === "notFound") {
|
|
errors.push(`submission: commit "${ref}" was not found in GitHub repository "${repo}"`);
|
|
} else if (commitResponse.kind === "apiError") {
|
|
const statusText = commitResponse.status ? `HTTP ${commitResponse.status}` : "network error";
|
|
warnings.push(
|
|
`submission: could not verify commit "${ref}" in GitHub repository "${repo}" (${statusText}${commitResponse.reason ? ` — ${commitResponse.reason}` : ""}); a maintainer should re-run intake`,
|
|
);
|
|
}
|
|
|
|
validateRefShaConsistency(normalizeCommitSha(ref));
|
|
return;
|
|
}
|
|
|
|
if (ref.startsWith("refs/heads/") || ["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) {
|
|
return;
|
|
}
|
|
|
|
if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) {
|
|
return;
|
|
}
|
|
|
|
const tagName = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : ref;
|
|
const tagResponse = await fetchGitHubJson(`/repos/${encodedRepo}/git/ref/tags/${encodeURIComponent(tagName)}`, token);
|
|
|
|
if (tagResponse.kind === "found") {
|
|
if (!normalizedSha) {
|
|
return;
|
|
}
|
|
|
|
const resolvedRefResponse = await resolveCommitSha(repo, ref, token);
|
|
if (resolvedRefResponse.kind === "notFound") {
|
|
errors.push(`submission: ref "${ref}" could not be resolved to a commit in GitHub repository "${repo}"`);
|
|
return;
|
|
}
|
|
|
|
if (resolvedRefResponse.kind === "apiError") {
|
|
if (resolvedRefResponse.status === 422) {
|
|
errors.push(
|
|
`submission: ref "${ref}" does not resolve to a commit in GitHub repository "${repo}" (it may point to a tag object, tree, or blob); only commit-backed refs are supported`,
|
|
);
|
|
return;
|
|
}
|
|
const statusText = resolvedRefResponse.status ? `HTTP ${resolvedRefResponse.status}` : "network error";
|
|
warnings.push(
|
|
`submission: could not resolve ref "${ref}" to a commit in GitHub repository "${repo}" (${statusText}${resolvedRefResponse.reason ? ` — ${resolvedRefResponse.reason}` : ""}); a maintainer should re-run intake`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!resolvedRefResponse.commitSha) {
|
|
warnings.push(
|
|
`submission: could not determine the commit SHA for ref "${ref}" in GitHub repository "${repo}"; a maintainer should re-run intake`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
validateRefShaConsistency(resolvedRefResponse.commitSha);
|
|
return;
|
|
}
|
|
|
|
if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) {
|
|
errors.push('submission: commit SHAs in "Ref to review" must use the full 40-character SHA or be submitted in "Commit SHA to review"');
|
|
return;
|
|
}
|
|
|
|
if (tagResponse.kind === "notFound") {
|
|
errors.push(`submission: tag "${ref}" was not found in GitHub repository "${repo}"`);
|
|
} else if (tagResponse.kind === "apiError") {
|
|
const statusText = tagResponse.status ? `HTTP ${tagResponse.status}` : "network error";
|
|
warnings.push(
|
|
`submission: could not verify tag "${ref}" in GitHub repository "${repo}" (${statusText}${tagResponse.reason ? ` — ${tagResponse.reason}` : ""}); a maintainer should re-run intake`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function buildGitTreePath(repo, treeish, { recursive = false } = {}) {
|
|
const encodedRepo = encodeRepoPath(repo);
|
|
const query = recursive ? "?recursive=1" : "";
|
|
return `/repos/${encodedRepo}/git/trees/${encodeURIComponent(treeish)}${query}`;
|
|
}
|
|
|
|
function normalizeTreeish(locator) {
|
|
const value = String(locator ?? "").trim();
|
|
// The Git Trees API takes the tree-ish as a single path segment. A full "refs/tags/<tag>"
|
|
// ref would break that, so reduce it to the bare tag name; commit SHAs and simple tag
|
|
// names pass through unchanged.
|
|
return value.startsWith("refs/tags/") ? value.slice("refs/tags/".length) : value;
|
|
}
|
|
|
|
// Resolve the tree SHA of a directory by walking the path one level at a time. Each hop is a
|
|
// non-recursive tree fetch of a single directory, so the work is bounded by the path depth and
|
|
// is independent of the overall repository size — unlike a root recursive fetch, which a large
|
|
// unrelated monorepo can push over the API's truncation limit and never validate.
|
|
async function resolveDirectoryTreeSha(repo, treeish, segments, token) {
|
|
let currentTreeish = treeish;
|
|
for (const segment of segments) {
|
|
const response = await fetchGitHubJson(buildGitTreePath(repo, currentTreeish), token);
|
|
if (response.kind !== "found" || !Array.isArray(response.data?.tree)) {
|
|
return { status: "apiError" };
|
|
}
|
|
if (response.data.truncated) {
|
|
// A single directory level exceeded the response limit; presence is unverifiable.
|
|
return { status: "apiError" };
|
|
}
|
|
|
|
const match = response.data.tree.find((entry) => entry?.path === segment);
|
|
if (!match) {
|
|
return { status: "missing" };
|
|
}
|
|
if (match.type !== "tree") {
|
|
return { status: "notDirectory" };
|
|
}
|
|
currentTreeish = match.sha;
|
|
}
|
|
|
|
return { status: "found", treeSha: currentTreeish };
|
|
}
|
|
|
|
// Inspect the (recursively fetched) "extensions" subtree for the plugin's canvas extension
|
|
// entry point. Paths are relative to "extensions/", so the flat form is "extension.mjs" and a
|
|
// nested form is "<name>/extension.mjs". Scoping the recursive fetch to this subtree keeps the
|
|
// lookup complete without depending on the size of the rest of the repository.
|
|
function analyzeCanvasExtensionSubtree(subtreeEntries) {
|
|
let flatIsBlob = false;
|
|
let flatIsTree = false;
|
|
let nestedEntryPath = null;
|
|
|
|
for (const entry of subtreeEntries) {
|
|
const entryPath = entry?.path;
|
|
if (typeof entryPath !== "string") {
|
|
continue;
|
|
}
|
|
|
|
if (entryPath === "extension.mjs") {
|
|
if (entry.type === "blob") {
|
|
flatIsBlob = true;
|
|
} else if (entry.type === "tree") {
|
|
flatIsTree = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const segments = entryPath.split("/");
|
|
if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob") {
|
|
nestedEntryPath = nestedEntryPath ?? `extensions/${entryPath}`;
|
|
}
|
|
}
|
|
|
|
if (flatIsBlob) {
|
|
return { status: "found", entryPath: "extensions/extension.mjs" };
|
|
}
|
|
if (nestedEntryPath) {
|
|
return { status: "found", entryPath: nestedEntryPath };
|
|
}
|
|
if (flatIsTree) {
|
|
return { status: "notFile" };
|
|
}
|
|
return { status: "notFound" };
|
|
}
|
|
|
|
export 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}"`,
|
|
);
|
|
}
|
|
|
|
if (manifest.extenions !== undefined) {
|
|
errors.push(
|
|
`submission: plugins tagged with "canvas" must use "extensions" (found misspelled key "extenions") in "${manifestPath}"`,
|
|
);
|
|
}
|
|
|
|
if (manifest.extensions !== undefined && manifest.extensions !== "extensions") {
|
|
errors.push(
|
|
`submission: plugins tagged with "canvas" may omit "extensions", but if provided it must be "extensions" in "${manifestPath}"`,
|
|
);
|
|
}
|
|
|
|
const unverifiableEntryPointWarning =
|
|
`submission: could not verify the canvas extension entry point in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`;
|
|
const extensionsSegments = [...(pluginRoot ? pluginRoot.split("/") : []), "extensions"];
|
|
const extensionsTree = await resolveDirectoryTreeSha(
|
|
repo,
|
|
normalizeTreeish(releaseLocator),
|
|
extensionsSegments,
|
|
token,
|
|
);
|
|
if (extensionsTree.status === "apiError") {
|
|
warnings.push(unverifiableEntryPointWarning);
|
|
} else if (extensionsTree.status === "missing") {
|
|
errors.push(
|
|
`submission: plugins tagged with "canvas" must include an "extensions" directory at ${releaseLocatorDescription}`,
|
|
);
|
|
} else if (extensionsTree.status === "notDirectory") {
|
|
errors.push(
|
|
`submission: "extensions" must be a directory in ${releaseLocatorDescription}`,
|
|
);
|
|
} else {
|
|
const subtreeResponse = await fetchGitHubJson(
|
|
buildGitTreePath(repo, extensionsTree.treeSha, { recursive: true }),
|
|
token,
|
|
);
|
|
if (subtreeResponse.kind !== "found" || !Array.isArray(subtreeResponse.data?.tree)) {
|
|
warnings.push(unverifiableEntryPointWarning);
|
|
} else {
|
|
const canvasStructure = analyzeCanvasExtensionSubtree(subtreeResponse.data.tree);
|
|
if (canvasStructure.status === "found") {
|
|
// Entry point located (flat or nested); nothing to report.
|
|
} else if (subtreeResponse.data.truncated) {
|
|
// Absence is only inconclusive if the (already extensions-scoped) subtree itself is
|
|
// truncated, which would take an implausibly large extensions directory; flag it as
|
|
// unverifiable rather than falsely rejecting.
|
|
warnings.push(unverifiableEntryPointWarning);
|
|
} else if (canvasStructure.status === "notFile") {
|
|
errors.push(
|
|
`submission: "extensions/extension.mjs" must be a file in ${releaseLocatorDescription}`,
|
|
);
|
|
} else {
|
|
errors.push(
|
|
`submission: plugins tagged with "canvas" must include a canvas extension entry point at "extensions/extension.mjs" or "extensions/<extension>/extension.mjs" at ${releaseLocatorDescription}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
|
|
function requiredField(title) {
|
|
const value = stripNoResponse(sections.get(title));
|
|
if (!value) {
|
|
errors.push(`submission: "${title}" is required`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const pluginName = requiredField(FIELD_TITLES.pluginName);
|
|
const shortDescription = requiredField(FIELD_TITLES.shortDescription);
|
|
const repoInput = normalizeGitHubRepo(requiredField(FIELD_TITLES.githubRepository));
|
|
// Support both the current field title and the legacy title used before the ref/sha split
|
|
const immutableRef = stripNoResponse(
|
|
sections.get(FIELD_TITLES.immutableRef) ?? sections.get(LEGACY_FIELD_TITLES.immutableRef),
|
|
);
|
|
const immutableSha = stripNoResponse(sections.get(FIELD_TITLES.immutableSha));
|
|
const version = requiredField(FIELD_TITLES.version);
|
|
const license = requiredField(FIELD_TITLES.license);
|
|
const authorName = requiredField(FIELD_TITLES.authorName);
|
|
|
|
const pluginPath = stripNoResponse(sections.get(FIELD_TITLES.pluginPath));
|
|
const authorUrl = stripNoResponse(sections.get(FIELD_TITLES.authorUrl));
|
|
const homepageUrl = stripNoResponse(sections.get(FIELD_TITLES.homepageUrl));
|
|
const keywords = parseKeywords(sections.get(FIELD_TITLES.keywords));
|
|
const additionalNotes = stripNoResponse(sections.get(FIELD_TITLES.additionalNotes));
|
|
const checkedItems = parseChecklist(sections.get(FIELD_TITLES.submissionChecklist));
|
|
|
|
if (!immutableRef && !immutableSha) {
|
|
errors.push(`submission: one of "${FIELD_TITLES.immutableRef}" or "${FIELD_TITLES.immutableSha}" is required`);
|
|
}
|
|
|
|
for (const equivalents of REQUIRED_CHECKLIST_ITEMS) {
|
|
let isChecked = false;
|
|
for (const text of equivalents) {
|
|
if (checkedItems.has(text)) {
|
|
isChecked = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!isChecked) {
|
|
// Report using the canonical (first) text in each equivalents Set
|
|
const [canonical] = equivalents;
|
|
errors.push(`submission: checklist item must be checked: "${canonical}"`);
|
|
}
|
|
}
|
|
|
|
const plugin = {
|
|
name: pluginName,
|
|
description: shortDescription,
|
|
version,
|
|
author: {
|
|
name: authorName,
|
|
...(authorUrl ? { url: authorUrl } : {}),
|
|
},
|
|
repository: repoInput ? `https://github.com/${repoInput}` : undefined,
|
|
...(homepageUrl ? { homepage: homepageUrl } : {}),
|
|
...(license ? { license } : {}),
|
|
...(keywords ? { keywords } : {}),
|
|
source: {
|
|
source: "github",
|
|
repo: repoInput,
|
|
...(pluginPath ? { path: pluginPath } : {}),
|
|
...(immutableRef ? { ref: immutableRef } : {}),
|
|
...(immutableSha ? { sha: immutableSha } : {}),
|
|
},
|
|
};
|
|
|
|
return {
|
|
markerPresent: normalizeMultilineText(body).includes(ISSUE_FORM_MARKER),
|
|
errors,
|
|
plugin,
|
|
additionalNotes,
|
|
};
|
|
}
|
|
|
|
export function parseRerunIntakeCommand(body) {
|
|
return RERUN_INTAKE_COMMAND_PATTERN.test(String(body ?? ""));
|
|
}
|
|
|
|
export function parseMarkReadyForReviewCommand(body) {
|
|
const text = String(body ?? "");
|
|
if (!MARK_READY_FOR_REVIEW_COMMAND_PATTERN.test(text)) {
|
|
return undefined;
|
|
}
|
|
|
|
const commandLine = text.split(/\r?\n/).find((line) => MARK_READY_FOR_REVIEW_COMMAND_PATTERN.test(line));
|
|
const reason = commandLine?.replace(MARK_READY_FOR_REVIEW_COMMAND_PATTERN, "").trim();
|
|
|
|
return {
|
|
command: MARK_READY_FOR_REVIEW_COMMAND,
|
|
reason: reason || undefined,
|
|
};
|
|
}
|
|
|
|
function normalizeQualityGateResult(rawResult) {
|
|
const defaults = {
|
|
overall_status: "not_run",
|
|
spec_compliance_status: "not_run",
|
|
vally_lint_status: "not_run",
|
|
smoke_status: "not_run",
|
|
version_match_status: "not_run",
|
|
ref_sha_consistency_status: "not_run",
|
|
canvas_structure_status: "not_run",
|
|
failure_class: "none",
|
|
summary: "",
|
|
spec_compliance_output: "",
|
|
vally_lint_output: "",
|
|
smoke_output: "",
|
|
version_match_output: "",
|
|
ref_sha_consistency_output: "",
|
|
canvas_structure_output: "",
|
|
};
|
|
|
|
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
|
|
return defaults;
|
|
}
|
|
|
|
return {
|
|
...defaults,
|
|
...rawResult,
|
|
};
|
|
}
|
|
|
|
function buildQualityGatesCommentSection(qualityResult) {
|
|
const formatStatus = (rawStatus, gate) => {
|
|
const status = String(rawStatus || "not_run");
|
|
if (status === "pass") {
|
|
return "✅ pass";
|
|
}
|
|
if (status === "warning" || (gate === "spec" && status === "fail")) {
|
|
return "⚠️ warning";
|
|
}
|
|
if (status === "fail" || status === "infra_error") {
|
|
return "🛑 fail";
|
|
}
|
|
return "⚪ not_run";
|
|
};
|
|
|
|
const specState = qualityResult.spec_compliance_status || "not_run";
|
|
const vallyState = qualityResult.vally_lint_status || "not_run";
|
|
const smokeState = qualityResult.smoke_status || "not_run";
|
|
const versionMatchState = qualityResult.version_match_status || "not_run";
|
|
const refShaConsistencyState = qualityResult.ref_sha_consistency_status || "not_run";
|
|
const canvasStructureState = qualityResult.canvas_structure_status || "not_run";
|
|
const summaryText = String(qualityResult.summary || "").trim() || "_No quality gate details were provided._";
|
|
|
|
const sections = [
|
|
"### Quality gate summary",
|
|
"",
|
|
"_Legend: ✅ pass · ⚠️ warning · 🛑 fail_",
|
|
"",
|
|
"| Gate | Status |",
|
|
"|---|---|",
|
|
`| spec compliance (non-blocking) | ${formatStatus(specState, "spec")} |`,
|
|
`| vally lint | ${formatStatus(vallyState, "vally")} |`,
|
|
`| install smoke test | ${formatStatus(smokeState, "smoke")} |`,
|
|
`| version match | ${formatStatus(versionMatchState, "version match")} |`,
|
|
`| ref/sha consistency | ${formatStatus(refShaConsistencyState, "ref/sha consistency")} |`,
|
|
`| canvas structure | ${formatStatus(canvasStructureState, "canvas structure")} |`,
|
|
"",
|
|
summaryText,
|
|
];
|
|
|
|
const specOutput = String(qualityResult.spec_compliance_output || "").trim();
|
|
if (specOutput) {
|
|
sections.push(
|
|
"",
|
|
"<details>",
|
|
`<summary>spec compliance output (${formatStatus(specState, "spec")})</summary>`,
|
|
"",
|
|
"```text",
|
|
specOutput,
|
|
"```",
|
|
"",
|
|
"</details>",
|
|
);
|
|
}
|
|
|
|
const vallyOutput = String(qualityResult.vally_lint_output || "").trim();
|
|
if (vallyOutput) {
|
|
sections.push(
|
|
"",
|
|
"<details>",
|
|
`<summary>vally lint output (${formatStatus(vallyState, "vally")})</summary>`,
|
|
"",
|
|
"```text",
|
|
vallyOutput,
|
|
"```",
|
|
"",
|
|
"</details>",
|
|
);
|
|
}
|
|
|
|
const smokeOutput = String(qualityResult.smoke_output || "").trim();
|
|
if (smokeOutput) {
|
|
sections.push(
|
|
"",
|
|
"<details>",
|
|
`<summary>install smoke test output (${formatStatus(smokeState, "smoke")})</summary>`,
|
|
"",
|
|
"```text",
|
|
smokeOutput,
|
|
"```",
|
|
"",
|
|
"</details>",
|
|
);
|
|
}
|
|
|
|
const versionMatchOutput = String(qualityResult.version_match_output || "").trim();
|
|
if (versionMatchOutput) {
|
|
sections.push(
|
|
"",
|
|
"<details>",
|
|
"<summary>Version match output</summary>",
|
|
"",
|
|
"```text",
|
|
versionMatchOutput,
|
|
"```",
|
|
"",
|
|
"</details>",
|
|
);
|
|
}
|
|
|
|
const refShaConsistencyOutput = String(qualityResult.ref_sha_consistency_output || "").trim();
|
|
if (refShaConsistencyOutput) {
|
|
sections.push(
|
|
"",
|
|
"<details>",
|
|
"<summary>Ref/SHA consistency output</summary>",
|
|
"",
|
|
"```text",
|
|
refShaConsistencyOutput,
|
|
"```",
|
|
"",
|
|
"</details>",
|
|
);
|
|
}
|
|
|
|
const canvasStructureOutput = String(qualityResult.canvas_structure_output || "").trim();
|
|
if (canvasStructureOutput) {
|
|
sections.push(
|
|
"",
|
|
"<details>",
|
|
"<summary>Canvas structure output</summary>",
|
|
"",
|
|
"```text",
|
|
canvasStructureOutput,
|
|
"```",
|
|
"",
|
|
"</details>",
|
|
);
|
|
}
|
|
|
|
return sections.join("\n");
|
|
}
|
|
|
|
function getIntakeStateFromQualityResult(baseResult, qualityResult) {
|
|
if (!baseResult.valid) {
|
|
return "requires-submitter-fixes";
|
|
}
|
|
|
|
if (qualityResult.failure_class === "submitter_fixes") {
|
|
return "requires-submitter-fixes";
|
|
}
|
|
|
|
if (qualityResult.failure_class === "infra") {
|
|
return "awaiting-review";
|
|
}
|
|
|
|
return "ready-for-review";
|
|
}
|
|
|
|
function buildMergedIntakeComment(baseResult, qualityResult, runId, owner, repo) {
|
|
if (!baseResult.valid) {
|
|
return baseResult.commentBody;
|
|
}
|
|
|
|
const marker = baseResult.commentMarker ?? EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER;
|
|
const qualitySection = buildQualityGatesCommentSection(qualityResult);
|
|
const runLink = runId && owner && repo ? `_[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})_` : "";
|
|
|
|
const hasSpecWarnings = String(qualityResult.spec_compliance_status || "") === "warning";
|
|
const intro =
|
|
qualityResult.failure_class === "submitter_fixes"
|
|
? "## 🛑 External plugin intake failed (submitter fixes required)"
|
|
: qualityResult.failure_class === "infra"
|
|
? "## 🛑 External plugin intake failed (quality checks could not complete)"
|
|
: hasSpecWarnings
|
|
? "## ⚠️ External plugin intake passed with spec warnings"
|
|
: "## ✅ External plugin intake passed";
|
|
|
|
const statusLine =
|
|
qualityResult.failure_class === "submitter_fixes"
|
|
? "This submission passed metadata validation, but quality gates found issues that must be fixed before it can move to maintainer review. Update the issue details or source plugin and then comment `/rerun-intake`."
|
|
: qualityResult.failure_class === "infra"
|
|
? "This submission passed metadata validation, but the automated quality checks hit an infrastructure issue. A maintainer should rerun intake or use the explicit override command after review."
|
|
: hasSpecWarnings
|
|
? "This submission passed blocking quality checks and is ready for maintainer review, but it has non-blocking Agent Plugins spec compliance warnings."
|
|
: "This submission passed automated intake validation and quality checks and is ready for maintainer review.";
|
|
|
|
return [
|
|
marker,
|
|
intro,
|
|
"",
|
|
statusLine,
|
|
"",
|
|
`- **Plugin:** ${baseResult.plugin?.name ?? "unknown"}`,
|
|
`- **Repository:** ${baseResult.plugin?.repository ?? "unknown"}`,
|
|
baseResult.plugin?.source?.ref ? `- **Ref:** [\`${baseResult.plugin.source.ref.replaceAll('\`', '\\\`')}\`](https://github.com/${encodeRepoPath(baseResult.plugin.source.repo)}/tree/${encodeURIComponent(baseResult.plugin.source.ref).replaceAll("%2F", "/")})` : undefined,
|
|
baseResult.plugin?.source?.sha ? `- **SHA:** [\`${baseResult.plugin.source.sha.replaceAll('\`', '\\\`')}\`](https://github.com/${encodeRepoPath(baseResult.plugin.source.repo)}/tree/${encodeURIComponent(baseResult.plugin.source.sha).replaceAll("%2F", "/")})` : undefined,
|
|
"",
|
|
qualitySection,
|
|
"",
|
|
"",
|
|
"### Canonical external.json payload",
|
|
"",
|
|
"",
|
|
"```json",
|
|
JSON.stringify(baseResult.plugin ?? {}, null, 2),
|
|
"```",
|
|
baseResult.warnings?.length
|
|
? ["", "### Warnings", "", ...baseResult.warnings.map((warning) => `- ${warning}`)].join("\n")
|
|
: "",
|
|
runLink ? `\n${runLink}` : "",
|
|
].join("\n");
|
|
}
|
|
|
|
export function applyQualityGateResult(baseEvaluation, qualityGateResult, runId, owner, repo) {
|
|
const baseResult = typeof baseEvaluation === "string" ? JSON.parse(baseEvaluation) : baseEvaluation;
|
|
const qualityResult = normalizeQualityGateResult(
|
|
typeof qualityGateResult === "string" ? JSON.parse(qualityGateResult) : qualityGateResult,
|
|
);
|
|
const intakeState = getIntakeStateFromQualityResult(baseResult, qualityResult);
|
|
|
|
return {
|
|
...baseResult,
|
|
qualityGates: qualityResult,
|
|
intakeState,
|
|
commentBody: buildMergedIntakeComment(baseResult, qualityResult, runId, owner, repo),
|
|
};
|
|
}
|
|
|
|
export async function evaluateExternalPluginIssue({ issue, token, runId, owner, repo } = {}) {
|
|
const issueBody = issue?.body ?? "";
|
|
const parsed = parseExternalPluginIssueBody(issueBody);
|
|
const errors = [...parsed.errors];
|
|
const warnings = [];
|
|
|
|
const localPluginNames = readLocalPluginNames();
|
|
const { plugins: existingExternalPlugins } = readExternalPlugins({ policy: "marketplace" });
|
|
const duplicateNames = [
|
|
...localPluginNames,
|
|
...existingExternalPlugins.map((plugin) => plugin.name).filter(Boolean),
|
|
];
|
|
|
|
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(
|
|
(name) => String(name).toLowerCase() === String(parsed.plugin.name).toLowerCase(),
|
|
);
|
|
if (matchingName) {
|
|
errors.push(`submission: plugin name "${parsed.plugin.name}" conflicts with existing plugin "${matchingName}"`);
|
|
}
|
|
}
|
|
|
|
if (parsed.plugin?.source?.repo && (parsed.plugin?.source?.ref || parsed.plugin?.source?.sha)) {
|
|
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;
|
|
const marker = EXTERNAL_PLUGIN_INTAKE_COMMENT_MARKER;
|
|
const normalizedKeywords = parsed.plugin?.keywords?.length ? parsed.plugin.keywords.join(", ") : "_None provided_";
|
|
const notes = parsed.additionalNotes ?? "_No additional notes provided._";
|
|
const payload = parsed.plugin
|
|
? [
|
|
"```json",
|
|
JSON.stringify(parsed.plugin, null, 2),
|
|
"```",
|
|
].join("\n")
|
|
: "```json\n{}\n```";
|
|
|
|
const runLink = runId && owner && repo ? `_[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})_` : "";
|
|
|
|
const commentBody = valid
|
|
? [
|
|
marker,
|
|
"## ✅ External plugin intake passed",
|
|
"",
|
|
`This submission passed automated intake validation and is ready for maintainer review.`,
|
|
"",
|
|
`- **Plugin:** ${parsed.plugin.name}`,
|
|
`- **Repository:** ${parsed.plugin.repository}`,
|
|
parsed.plugin.source.ref ? `- **Ref:** [\`${parsed.plugin.source.ref.replaceAll('\`', '\\\`')}\`](https://github.com/${encodeRepoPath(parsed.plugin.source.repo)}/tree/${encodeURIComponent(parsed.plugin.source.ref).replaceAll("%2F", "/")})` : undefined,
|
|
parsed.plugin.source.sha ? `- **SHA:** [\`${parsed.plugin.source.sha.replaceAll('\`', '\\\`')}\`](https://github.com/${encodeRepoPath(parsed.plugin.source.repo)}/tree/${encodeURIComponent(parsed.plugin.source.sha).replaceAll("%2F", "/")})` : undefined,
|
|
`- **Keywords:** ${normalizedKeywords}`,
|
|
"",
|
|
"",
|
|
"### Canonical external.json payload",
|
|
"",
|
|
"",
|
|
payload,
|
|
"",
|
|
"### Reviewer notes",
|
|
"",
|
|
"",
|
|
notes,
|
|
dedupedWarnings.length > 0
|
|
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
|
|
: "",
|
|
runLink ? `\n${runLink}` : "",
|
|
].join("\n")
|
|
: [
|
|
marker,
|
|
"## 🛑 External plugin intake failed (submitter fixes required)",
|
|
"",
|
|
"This submission did not pass automated intake validation and cannot move to maintainer review yet.",
|
|
`Edit the issue form to address the fixes below. Intake reruns automatically when the issue is edited, or the issue author/maintainer can comment \`${RERUN_INTAKE_COMMAND}\` to re-run on demand.`,
|
|
"",
|
|
"### Required fixes",
|
|
"",
|
|
...dedupedErrors.map((error) => `- ${error}`),
|
|
dedupedWarnings.length > 0
|
|
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
|
|
: "",
|
|
runLink ? `\n${runLink}` : "",
|
|
].join("\n");
|
|
|
|
return {
|
|
valid,
|
|
intakeState: valid ? "ready-for-review" : "requires-submitter-fixes",
|
|
markerPresent: parsed.markerPresent,
|
|
errors: dedupedErrors,
|
|
warnings: dedupedWarnings,
|
|
plugin: parsed.plugin,
|
|
isCanvasPlugin,
|
|
commentBody,
|
|
commentMarker: marker,
|
|
};
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const eventPath = process.argv[2];
|
|
if (!eventPath) {
|
|
console.error("Usage: node ./eng/external-plugin-intake.mjs <github-event.json> [runId] [owner] [repo]");
|
|
process.exit(1);
|
|
}
|
|
|
|
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
|
const runId = process.argv[3];
|
|
const owner = process.argv[4];
|
|
const repo = process.argv[5];
|
|
const result = await evaluateExternalPluginIssue({ issue: event.issue, token: process.env.GITHUB_TOKEN, runId, owner, repo });
|
|
process.stdout.write(JSON.stringify(result));
|
|
}
|