mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-27 17:11:44 +00:00
feat: add public external plugin workflows (#1723)
* feat: add external plugin submission workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * minor adjustment to contributing guide * fix: address external plugin review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reverting some changes to the readme.agents.md file * fix: address follow-up review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: tighten external plugin workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
import {
|
||||
EXTERNAL_PLUGINS_FILE,
|
||||
readExternalPlugins,
|
||||
validateExternalPlugins,
|
||||
} from "./external-plugin-validation.mjs";
|
||||
import { evaluateExternalPluginIssue } from "./external-plugin-intake.mjs";
|
||||
|
||||
export const DECISION_COMMANDS = Object.freeze({
|
||||
approve: "/approve",
|
||||
reject: "/reject",
|
||||
});
|
||||
|
||||
function normalizeValue(value) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRepositoryUrl(value) {
|
||||
const normalized = normalizeValue(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.replace(/^https:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function normalizePathValue(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function parseDecisionCommand(body) {
|
||||
const match = String(body ?? "").match(/(?:^|\n)\s*\/(approve|reject)(?=\s|$)([\s\S]*)$/i);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const command = match[1].toLowerCase();
|
||||
const reason = match[2]?.trim() || undefined;
|
||||
|
||||
return {
|
||||
command,
|
||||
reason: command === "reject" ? reason : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function slugifyPluginName(value) {
|
||||
const slug = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return slug || "external-plugin";
|
||||
}
|
||||
|
||||
function readLocalPluginNames() {
|
||||
const pluginsDir = path.join(ROOT_FOLDER, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(pluginsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
function pluginsMatch(left, right) {
|
||||
const leftName = normalizeValue(left?.name);
|
||||
const rightName = normalizeValue(right?.name);
|
||||
const leftRepo = normalizeValue(left?.source?.repo);
|
||||
const rightRepo = normalizeValue(right?.source?.repo);
|
||||
const leftPath = normalizePathValue(left?.source?.path);
|
||||
const rightPath = normalizePathValue(right?.source?.path);
|
||||
const leftRepository = normalizeRepositoryUrl(left?.repository);
|
||||
const rightRepository = normalizeRepositoryUrl(right?.repository);
|
||||
|
||||
if (leftName && rightName && leftName === rightName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const repoMatches = leftRepo && rightRepo && leftRepo === rightRepo;
|
||||
const repositoryMatches = leftRepository && rightRepository && leftRepository === rightRepository;
|
||||
const pathKnown = Boolean(leftPath || rightPath);
|
||||
const pathMatches = leftPath === rightPath;
|
||||
|
||||
if ((repoMatches || repositoryMatches) && pathKnown && pathMatches) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function upsertExternalPlugin(plugin, { filePath = EXTERNAL_PLUGINS_FILE } = {}) {
|
||||
const { plugins, errors } = readExternalPlugins({
|
||||
filePath,
|
||||
localPluginNames: readLocalPluginNames(),
|
||||
policy: "marketplace",
|
||||
});
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join("\n"));
|
||||
}
|
||||
|
||||
const updatedPlugins = [...plugins];
|
||||
const existingIndex = updatedPlugins.findIndex((existingPlugin) => pluginsMatch(existingPlugin, plugin));
|
||||
const action = existingIndex === -1 ? "inserted" : "updated";
|
||||
|
||||
if (existingIndex === -1) {
|
||||
updatedPlugins.push(plugin);
|
||||
} else {
|
||||
updatedPlugins[existingIndex] = plugin;
|
||||
}
|
||||
|
||||
updatedPlugins.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: "base" }));
|
||||
|
||||
const { errors: validationErrors } = validateExternalPlugins(updatedPlugins, {
|
||||
localPluginNames: readLocalPluginNames(),
|
||||
policy: "marketplace",
|
||||
});
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(validationErrors.join("\n"));
|
||||
}
|
||||
|
||||
const changed = JSON.stringify(updatedPlugins) !== JSON.stringify(plugins);
|
||||
if (changed) {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`);
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
changed,
|
||||
plugin,
|
||||
};
|
||||
}
|
||||
|
||||
function readCliArgs(argv) {
|
||||
const args = {};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const key = argv[index];
|
||||
if (!key.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
args[key.slice(2)] = argv[index + 1];
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||
|
||||
if (isCli) {
|
||||
const [command, eventPath] = process.argv.slice(2);
|
||||
|
||||
if (command !== "approve" || !eventPath) {
|
||||
console.error("Usage: node ./eng/external-plugin-approval.mjs approve <github-event.json> [--file <path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = readCliArgs(process.argv.slice(4));
|
||||
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
||||
const evaluation = await evaluateExternalPluginIssue({
|
||||
issue: event.issue,
|
||||
token: process.env.GITHUB_TOKEN,
|
||||
});
|
||||
|
||||
if (!evaluation.valid) {
|
||||
console.error(evaluation.errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = upsertExternalPlugin(evaluation.plugin, { filePath: args.file });
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/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";
|
||||
|
||||
const ISSUE_FORM_MARKER = "<!-- external-plugin-submission -->";
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
|
||||
const REQUIRED_CHECKLIST_ITEMS = [
|
||||
"The plugin lives in a public GitHub repository.",
|
||||
"The ref I provided is an immutable release tag or full 40-character commit SHA, not a branch.",
|
||||
"This submission follows this repository's contribution, security, and responsible AI policies.",
|
||||
"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: "Immutable ref 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",
|
||||
});
|
||||
|
||||
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 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: ");
|
||||
}
|
||||
|
||||
async function fetchGitHubJson(apiPath, token) {
|
||||
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}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return { ok: false, status: 404, data: null };
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
function encodeRepoPath(repo) {
|
||||
const [owner, name] = String(repo).split("/");
|
||||
return `${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}`;
|
||||
}
|
||||
|
||||
async function validateRemoteRepository(repo, ref, errors, warnings, token) {
|
||||
const encodedRepo = encodeRepoPath(repo);
|
||||
const repositoryResponse = await fetchGitHubJson(`/repos/${encodedRepo}`, token);
|
||||
|
||||
if (!repositoryResponse.ok) {
|
||||
if (repositoryResponse.status === 404) {
|
||||
errors.push(`submission: GitHub repository "${repo}" was not found`);
|
||||
} else {
|
||||
errors.push(`submission: could not inspect GitHub repository "${repo}" (HTTP ${repositoryResponse.status})`);
|
||||
}
|
||||
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 (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^[0-9a-f]{40}$/i.test(ref)) {
|
||||
const commitResponse = await fetchGitHubJson(`/repos/${encodedRepo}/commits/${encodeURIComponent(ref)}`, token);
|
||||
if (!commitResponse.ok) {
|
||||
errors.push(`submission: commit "${ref}" was not found in GitHub repository "${repo}"`);
|
||||
}
|
||||
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.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^[0-9a-f]+$/i.test(ref) && ref.length !== 40) {
|
||||
errors.push('submission: commit SHAs in "Immutable ref to review" must use the full 40-character SHA');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tagResponse.ok) {
|
||||
errors.push(`submission: tag "${ref}" was not found in GitHub repository "${repo}"`);
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
const immutableRef = requiredField(FIELD_TITLES.immutableRef);
|
||||
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));
|
||||
|
||||
for (const item of REQUIRED_CHECKLIST_ITEMS) {
|
||||
if (!checkedItems.has(item)) {
|
||||
errors.push(`submission: checklist item must be checked: "${item}"`);
|
||||
}
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
markerPresent: normalizeMultilineText(body).includes(ISSUE_FORM_MARKER),
|
||||
errors,
|
||||
plugin,
|
||||
additionalNotes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function evaluateExternalPluginIssue({ issue, token } = {}) {
|
||||
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));
|
||||
|
||||
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) {
|
||||
await validateRemoteRepository(parsed.plugin.source.repo, parsed.plugin.source.ref, errors, warnings, token);
|
||||
}
|
||||
|
||||
const dedupedErrors = [...new Set(errors)];
|
||||
const dedupedWarnings = [...new Set(warnings)];
|
||||
const valid = dedupedErrors.length === 0;
|
||||
const marker = "<!-- external-plugin-intake -->";
|
||||
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 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}`,
|
||||
`- **Ref:** ${parsed.plugin.source.ref}`,
|
||||
`- **Keywords:** ${normalizedKeywords}`,
|
||||
"",
|
||||
"### Canonical external.json payload",
|
||||
"",
|
||||
payload,
|
||||
"",
|
||||
"### Reviewer notes",
|
||||
"",
|
||||
notes,
|
||||
dedupedWarnings.length > 0
|
||||
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
|
||||
: "",
|
||||
].filter(Boolean).join("\n")
|
||||
: [
|
||||
marker,
|
||||
"## ❌ External plugin intake failed",
|
||||
"",
|
||||
"This submission did not pass automated intake validation, so the issue has been closed.",
|
||||
"Update the issue form, then reopen the issue to run intake validation again.",
|
||||
"",
|
||||
"### Required fixes",
|
||||
"",
|
||||
...dedupedErrors.map((error) => `- ${error}`),
|
||||
dedupedWarnings.length > 0
|
||||
? ["", "### Warnings", "", ...dedupedWarnings.map((warning) => `- ${warning}`)].join("\n")
|
||||
: "",
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return {
|
||||
valid,
|
||||
markerPresent: parsed.markerPresent,
|
||||
errors: dedupedErrors,
|
||||
warnings: dedupedWarnings,
|
||||
plugin: parsed.plugin,
|
||||
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>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
|
||||
const result = await evaluateExternalPluginIssue({ issue: event.issue, token: process.env.GITHUB_TOKEN });
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { EXTERNAL_PLUGINS_FILE, readExternalPlugins } from "./external-plugin-validation.mjs";
|
||||
import { parseExternalPluginIssueBody } from "./external-plugin-intake.mjs";
|
||||
|
||||
export const REREVIEW_REPORT_MARKER = "<!-- external-plugin-rereview-report -->";
|
||||
|
||||
export const REREVIEW_LABELS = Object.freeze({
|
||||
due: "re-review-due",
|
||||
followUp: "re-review-follow-up",
|
||||
removed: "removed",
|
||||
});
|
||||
|
||||
export const REREVIEW_COMMANDS = Object.freeze({
|
||||
keep: "/re-review-keep",
|
||||
needsChanges: "/re-review-needs-changes",
|
||||
remove: "/re-review-remove",
|
||||
});
|
||||
|
||||
function normalizeValue(value) {
|
||||
return String(value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRepositoryUrl(value) {
|
||||
const normalized = normalizeValue(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized
|
||||
.replace(/^https:\/\/github\.com\//, "")
|
||||
.replace(/\.git$/i, "")
|
||||
.replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function normalizePathValue(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function stripIssueTitlePrefix(title) {
|
||||
return String(title ?? "")
|
||||
.trim()
|
||||
.replace(/^\[\s*external plugin\s*\]\s*:\s*/i, "")
|
||||
.replace(/^(external plugin(?: submission)?|public external plugin)(?:\s*[:-]\s*|\s+)/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function firstMatch(body, patterns) {
|
||||
for (const pattern of patterns) {
|
||||
const match = body.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fallbackSubmissionData(issue) {
|
||||
const body = String(issue?.body ?? "");
|
||||
const title = stripIssueTitlePrefix(issue?.title);
|
||||
const sourceRepo = firstMatch(body, [
|
||||
/https:\/\/github\.com\/([^/\s]+\/[^/\s)]+)/i,
|
||||
/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\b/,
|
||||
]);
|
||||
|
||||
return {
|
||||
pluginName: title || undefined,
|
||||
sourceRepo: sourceRepo ? normalizeRepositoryUrl(sourceRepo) : undefined,
|
||||
repository: sourceRepo ? `https://github.com/${normalizeRepositoryUrl(sourceRepo)}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractSubmissionData(issue) {
|
||||
const parsed = parseExternalPluginIssueBody(issue?.body ?? "");
|
||||
const fallback = fallbackSubmissionData(issue);
|
||||
const plugin = parsed.plugin ?? {};
|
||||
|
||||
return {
|
||||
pluginName: plugin.name ?? fallback.pluginName,
|
||||
sourceRepo: plugin.source?.repo ?? fallback.sourceRepo,
|
||||
sourcePath: plugin.source?.path,
|
||||
repository: plugin.repository ?? fallback.repository,
|
||||
ref: plugin.source?.ref,
|
||||
};
|
||||
}
|
||||
|
||||
function pluginMatchesSubmission(plugin, submission) {
|
||||
const pluginName = normalizeValue(plugin?.name);
|
||||
const submissionName = normalizeValue(submission.pluginName);
|
||||
const pluginRepo = normalizeValue(plugin?.source?.repo);
|
||||
const submissionRepo = normalizeValue(submission.sourceRepo);
|
||||
const pluginPath = normalizePathValue(plugin?.source?.path);
|
||||
const submissionPath = normalizePathValue(submission.sourcePath);
|
||||
const pluginRepository = normalizeRepositoryUrl(plugin?.repository);
|
||||
const submissionRepository = normalizeRepositoryUrl(submission.repository);
|
||||
|
||||
const nameMatch = pluginName && submissionName && pluginName === submissionName;
|
||||
const repoMatch = pluginRepo && submissionRepo && pluginRepo === submissionRepo;
|
||||
const repositoryMatch = pluginRepository && submissionRepository && pluginRepository === submissionRepository;
|
||||
const pathProvided = Boolean(submissionPath);
|
||||
const pathMatch = pluginPath === submissionPath;
|
||||
|
||||
if (nameMatch && pathProvided) {
|
||||
return pathMatch && (repoMatch || repositoryMatch || !submissionRepo);
|
||||
}
|
||||
|
||||
if (nameMatch && (repoMatch || repositoryMatch || !submissionRepo)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((repoMatch || repositoryMatch) && pathProvided) {
|
||||
return pathMatch && (!submissionName || nameMatch);
|
||||
}
|
||||
|
||||
if ((repoMatch || repositoryMatch) && submissionName && nameMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchExternalPluginForIssue(issue, plugins) {
|
||||
const submission = extractSubmissionData(issue);
|
||||
const exactMatch = plugins.find((plugin) => pluginMatchesSubmission(plugin, submission));
|
||||
if (exactMatch) {
|
||||
return {
|
||||
plugin: exactMatch,
|
||||
submission,
|
||||
matchReason: "exact",
|
||||
};
|
||||
}
|
||||
|
||||
const byName = submission.pluginName
|
||||
? plugins.find((plugin) => normalizeValue(plugin?.name) === normalizeValue(submission.pluginName))
|
||||
: undefined;
|
||||
if (byName) {
|
||||
return {
|
||||
plugin: byName,
|
||||
submission,
|
||||
matchReason: "name",
|
||||
};
|
||||
}
|
||||
|
||||
const repoMatches = submission.sourceRepo
|
||||
? plugins.filter((plugin) => normalizeValue(plugin?.source?.repo) === normalizeValue(submission.sourceRepo))
|
||||
: [];
|
||||
if (repoMatches.length === 1) {
|
||||
return {
|
||||
plugin: repoMatches[0],
|
||||
submission,
|
||||
matchReason: "repo",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plugin: undefined,
|
||||
submission,
|
||||
matchReason: "none",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRereviewCommand(body) {
|
||||
const match = String(body ?? "").match(/(?:^|\n)\s*\/re-review-(keep|needs-changes|remove)(?=\s|$)/i);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (match[1].toLowerCase()) {
|
||||
case "keep":
|
||||
return "keep";
|
||||
case "needs-changes":
|
||||
return "needs-changes";
|
||||
case "remove":
|
||||
return "remove";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function slugifyPluginName(value) {
|
||||
const slug = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return slug || "external-plugin";
|
||||
}
|
||||
|
||||
export function removePluginFromExternalJson({ pluginName, sourceRepo, filePath = EXTERNAL_PLUGINS_FILE } = {}) {
|
||||
const { plugins, errors } = readExternalPlugins({ filePath, policy: "marketplace" });
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join("\n"));
|
||||
}
|
||||
|
||||
const normalizedPluginName = normalizeValue(pluginName);
|
||||
const normalizedSourceRepo = normalizeValue(sourceRepo);
|
||||
const matchIndex = plugins.findIndex((plugin) => {
|
||||
const nameMatches = normalizedPluginName && normalizeValue(plugin?.name) === normalizedPluginName;
|
||||
const repoMatches = normalizedSourceRepo && normalizeValue(plugin?.source?.repo) === normalizedSourceRepo;
|
||||
|
||||
if (normalizedPluginName && normalizedSourceRepo) {
|
||||
return nameMatches && repoMatches;
|
||||
}
|
||||
|
||||
return Boolean(nameMatches || repoMatches);
|
||||
});
|
||||
|
||||
if (matchIndex === -1) {
|
||||
throw new Error(`Could not find external plugin "${pluginName || sourceRepo}" in ${path.relative(process.cwd(), filePath)}`);
|
||||
}
|
||||
|
||||
const updatedPlugins = [...plugins];
|
||||
const [removedPlugin] = updatedPlugins.splice(matchIndex, 1);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(updatedPlugins, null, 2)}\n`);
|
||||
|
||||
return removedPlugin;
|
||||
}
|
||||
|
||||
function readCliArgs(argv) {
|
||||
const args = {};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const key = argv[index];
|
||||
if (!key.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = argv[index + 1];
|
||||
args[key.slice(2)] = value;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
||||
|
||||
if (isCli) {
|
||||
const [command] = process.argv.slice(2);
|
||||
|
||||
if (command !== "remove") {
|
||||
console.error("Usage: node ./eng/external-plugin-rereview.mjs remove --plugin-name <name> [--source-repo <owner/repo>] [--file <path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const args = readCliArgs(process.argv.slice(3));
|
||||
|
||||
if (!args["plugin-name"] && !args["source-repo"]) {
|
||||
console.error("Provide --plugin-name or --source-repo when removing an external plugin.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const removedPlugin = removePluginFromExternalJson({
|
||||
pluginName: args["plugin-name"],
|
||||
sourceRepo: args["source-repo"],
|
||||
filePath: args.file,
|
||||
});
|
||||
|
||||
process.stdout.write(`${JSON.stringify(removedPlugin, null, 2)}\n`);
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
|
||||
export const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json");
|
||||
|
||||
export const EXTERNAL_PLUGIN_POLICIES = Object.freeze({
|
||||
marketplace: Object.freeze({
|
||||
allowedSourceTypes: ["github"],
|
||||
requireAuthor: true,
|
||||
requireRepository: true,
|
||||
requireKeywords: true,
|
||||
requireLicense: false,
|
||||
requireImmutableRef: false,
|
||||
}),
|
||||
publicSubmission: Object.freeze({
|
||||
allowedSourceTypes: ["github"],
|
||||
requireAuthor: true,
|
||||
requireRepository: true,
|
||||
requireKeywords: true,
|
||||
requireLicense: true,
|
||||
requireImmutableRef: true,
|
||||
}),
|
||||
});
|
||||
|
||||
function resolvePolicy(policy) {
|
||||
if (!policy) {
|
||||
return EXTERNAL_PLUGIN_POLICIES.marketplace;
|
||||
}
|
||||
|
||||
if (typeof policy === "string") {
|
||||
const resolved = EXTERNAL_PLUGIN_POLICIES[policy];
|
||||
if (!resolved) {
|
||||
throw new Error(`Unknown external plugin validation policy "${policy}"`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return {
|
||||
...EXTERNAL_PLUGIN_POLICIES.marketplace,
|
||||
...policy,
|
||||
};
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function validatePluginName(name, prefix, errors) {
|
||||
if (!isNonEmptyString(name)) {
|
||||
errors.push(`${prefix}: "name" is required and must be a non-empty string`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.length > 50) {
|
||||
errors.push(`${prefix}: "name" must be 50 characters or fewer`);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
errors.push(`${prefix}: "name" must contain only lowercase letters, numbers, and hyphens`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescription(description, prefix, errors) {
|
||||
if (!isNonEmptyString(description)) {
|
||||
errors.push(`${prefix}: "description" is required and must be a non-empty string`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (description.length > 500) {
|
||||
errors.push(`${prefix}: "description" must be 500 characters or fewer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateVersion(version, prefix, errors) {
|
||||
if (!isNonEmptyString(version)) {
|
||||
errors.push(`${prefix}: "version" is required and must be a non-empty string`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (version.length > 100) {
|
||||
errors.push(`${prefix}: "version" must be 100 characters or fewer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateKeywords(keywords, prefix, errors, warnings, required) {
|
||||
if (keywords === undefined) {
|
||||
if (required) {
|
||||
errors.push(`${prefix}: "keywords" is required and must be an array of lowercase tags`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(keywords)) {
|
||||
errors.push(`${prefix}: "keywords" must be an array`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keywords.length > 10) {
|
||||
errors.push(`${prefix}: "keywords" must contain no more than 10 entries`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < keywords.length; i++) {
|
||||
const keyword = keywords[i];
|
||||
if (!isNonEmptyString(keyword)) {
|
||||
errors.push(`${prefix}: "keywords[${i}]" must be a non-empty string`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(keyword)) {
|
||||
errors.push(`${prefix}: "keywords[${i}]" must contain only lowercase letters, numbers, and hyphens`);
|
||||
}
|
||||
|
||||
if (keyword.length > 30) {
|
||||
errors.push(`${prefix}: "keywords[${i}]" must be 30 characters or fewer`);
|
||||
}
|
||||
}
|
||||
|
||||
if (keywords.length === 0) {
|
||||
if (required) {
|
||||
errors.push(`${prefix}: "keywords" must contain at least one entry`);
|
||||
} else {
|
||||
warnings.push(`${prefix}: "keywords" is empty; at least one keyword is recommended for discovery`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateHttpsUrl(value, fieldName, prefix, errors, options = {}) {
|
||||
if (!isNonEmptyString(value)) {
|
||||
errors.push(`${prefix}: "${fieldName}" must be a non-empty string`);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
errors.push(`${prefix}: "${fieldName}" must be a valid URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
errors.push(`${prefix}: "${fieldName}" must use https`);
|
||||
}
|
||||
|
||||
if (options.githubOnly && parsed.hostname !== "github.com") {
|
||||
errors.push(`${prefix}: "${fieldName}" must point to https://github.com/...`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAuthor(author, prefix, errors, required) {
|
||||
if (author === undefined) {
|
||||
if (required) {
|
||||
errors.push(`${prefix}: "author" is required`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!author || typeof author !== "object" || Array.isArray(author)) {
|
||||
errors.push(`${prefix}: "author" must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(author.name)) {
|
||||
errors.push(`${prefix}: "author.name" is required and must be a non-empty string`);
|
||||
}
|
||||
|
||||
if (author.url !== undefined) {
|
||||
validateHttpsUrl(author.url, "author.url", prefix, errors);
|
||||
}
|
||||
}
|
||||
|
||||
function validateLicense(license, prefix, errors, required) {
|
||||
if (license === undefined) {
|
||||
if (required) {
|
||||
errors.push(`${prefix}: "license" is required`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(license)) {
|
||||
errors.push(`${prefix}: "license" must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRepository(repository, prefix, errors, required) {
|
||||
if (repository === undefined) {
|
||||
if (required) {
|
||||
errors.push(`${prefix}: "repository" is required`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
validateHttpsUrl(repository, "repository", prefix, errors, { githubOnly: true });
|
||||
}
|
||||
|
||||
function validateHomepage(homepage, prefix, errors) {
|
||||
if (homepage === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateHttpsUrl(homepage, "homepage", prefix, errors);
|
||||
}
|
||||
|
||||
function validateRelativePath(pathValue, prefix, errors) {
|
||||
if (!isNonEmptyString(pathValue)) {
|
||||
errors.push(`${prefix}: "source.path" must be a non-empty string when provided`);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(pathValue);
|
||||
const segments = pathValue.split("/");
|
||||
|
||||
if (pathValue.startsWith("/") || pathValue.startsWith("../") || normalized !== pathValue || segments.includes("..")) {
|
||||
errors.push(`${prefix}: "source.path" must be a safe relative path inside the repository`);
|
||||
}
|
||||
|
||||
if (pathValue.includes("\\")) {
|
||||
errors.push(`${prefix}: "source.path" must use forward slashes`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateImmutableRef(ref, prefix, errors) {
|
||||
if (!isNonEmptyString(ref)) {
|
||||
errors.push(`${prefix}: "source.ref" must be a non-empty string when provided`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.startsWith("refs/heads/")) {
|
||||
errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch ref`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (["main", "master", "develop", "development", "dev", "trunk"].includes(ref)) {
|
||||
errors.push(`${prefix}: "source.ref" must be a tag or commit SHA, not a branch name`);
|
||||
}
|
||||
|
||||
if (ref.startsWith("refs/") && !ref.startsWith("refs/tags/")) {
|
||||
errors.push(`${prefix}: "source.ref" must be a tag ref or commit SHA`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGitHubSource(source, prefix, errors, requireImmutableRef) {
|
||||
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||
errors.push(`${prefix}: "source" must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source.source !== "github") {
|
||||
errors.push(`${prefix}: "source.source" must be "github"`);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(source.repo)) {
|
||||
errors.push(`${prefix}: "source.repo" is required and must be a non-empty string`);
|
||||
} else if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(source.repo)) {
|
||||
errors.push(`${prefix}: "source.repo" must be in "owner/repo" format`);
|
||||
}
|
||||
|
||||
if (source.path !== undefined) {
|
||||
validateRelativePath(source.path, prefix, errors);
|
||||
}
|
||||
|
||||
if (source.ref !== undefined) {
|
||||
validateImmutableRef(source.ref, prefix, errors);
|
||||
} else if (requireImmutableRef) {
|
||||
errors.push(`${prefix}: "source.ref" is required for public external plugin submissions`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateExternalPlugin(plugin, index, options = {}) {
|
||||
const policy = resolvePolicy(options.policy ?? options);
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const prefix = `external.json[${index}]`;
|
||||
|
||||
if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) {
|
||||
return {
|
||||
errors: [`${prefix}: entry must be an object`],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
validatePluginName(plugin.name, prefix, errors);
|
||||
validateDescription(plugin.description, prefix, errors);
|
||||
validateVersion(plugin.version, prefix, errors);
|
||||
validateAuthor(plugin.author, prefix, errors, policy.requireAuthor);
|
||||
validateRepository(plugin.repository, prefix, errors, policy.requireRepository);
|
||||
validateHomepage(plugin.homepage, prefix, errors);
|
||||
validateLicense(plugin.license, prefix, errors, policy.requireLicense);
|
||||
validateKeywords(plugin.keywords ?? plugin.tags, prefix, errors, warnings, policy.requireKeywords);
|
||||
|
||||
if (plugin.tags !== undefined && plugin.keywords === undefined) {
|
||||
warnings.push(`${prefix}: prefer "keywords" over legacy "tags"`);
|
||||
}
|
||||
|
||||
if (!plugin.source) {
|
||||
errors.push(`${prefix}: "source" is required`);
|
||||
} else if (typeof plugin.source === "string") {
|
||||
errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`);
|
||||
} else if (!policy.allowedSourceTypes.includes(plugin.source.source)) {
|
||||
errors.push(`${prefix}: "source.source" must be one of: ${policy.allowedSourceTypes.join(", ")}`);
|
||||
} else if (plugin.source.source === "github") {
|
||||
validateGitHubSource(plugin.source, prefix, errors, policy.requireImmutableRef);
|
||||
}
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
export function validateExternalPlugins(plugins, options = {}) {
|
||||
const policy = resolvePolicy(options.policy ?? options);
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const localNames = new Map(
|
||||
(options.localPluginNames ?? []).map((name) => [String(name).toLowerCase(), String(name)])
|
||||
);
|
||||
const seenExternalNames = new Map();
|
||||
|
||||
if (!Array.isArray(plugins)) {
|
||||
return {
|
||||
errors: ["external.json must contain an array"],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
plugins.forEach((plugin, index) => {
|
||||
const result = validateExternalPlugin(plugin, index, { policy });
|
||||
errors.push(...result.errors);
|
||||
warnings.push(...result.warnings);
|
||||
|
||||
if (!isNonEmptyString(plugin?.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedName = plugin.name.toLowerCase();
|
||||
const duplicateIndex = seenExternalNames.get(normalizedName);
|
||||
if (duplicateIndex !== undefined) {
|
||||
errors.push(`external.json[${index}]: duplicate plugin name "${plugin.name}" already used by external.json[${duplicateIndex}]`);
|
||||
} else {
|
||||
seenExternalNames.set(normalizedName, index);
|
||||
}
|
||||
|
||||
const localDuplicate = localNames.get(normalizedName);
|
||||
if (localDuplicate) {
|
||||
errors.push(`external.json[${index}]: plugin name "${plugin.name}" conflicts with local plugin "${localDuplicate}"`);
|
||||
}
|
||||
});
|
||||
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
export function readExternalPlugins(options = {}) {
|
||||
const filePath = options.filePath ?? EXTERNAL_PLUGINS_FILE;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {
|
||||
plugins: [],
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
let plugins;
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
plugins = JSON.parse(content);
|
||||
} catch (error) {
|
||||
return {
|
||||
plugins: [],
|
||||
errors: [`Error reading ${path.basename(filePath)}: ${error.message}`],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { errors, warnings } = validateExternalPlugins(plugins, options);
|
||||
return { plugins, errors, warnings };
|
||||
}
|
||||
@@ -3,84 +3,11 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
import { readExternalPlugins } from "./external-plugin-validation.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
const EXTERNAL_PLUGINS_FILE = path.join(ROOT_FOLDER, "plugins", "external.json");
|
||||
const MARKETPLACE_FILE = path.join(ROOT_FOLDER, ".github/plugin", "marketplace.json");
|
||||
|
||||
/**
|
||||
* Validate an external plugin entry has required fields and a non-local source
|
||||
* @param {object} plugin - External plugin entry
|
||||
* @param {number} index - Index in the array (for error messages)
|
||||
* @returns {string[]} - Array of validation error messages
|
||||
*/
|
||||
function validateExternalPlugin(plugin, index) {
|
||||
const errors = [];
|
||||
const prefix = `external.json[${index}]`;
|
||||
|
||||
if (!plugin.name || typeof plugin.name !== "string") {
|
||||
errors.push(`${prefix}: "name" is required and must be a string`);
|
||||
}
|
||||
if (!plugin.description || typeof plugin.description !== "string") {
|
||||
errors.push(`${prefix}: "description" is required and must be a string`);
|
||||
}
|
||||
if (!plugin.version || typeof plugin.version !== "string") {
|
||||
errors.push(`${prefix}: "version" is required and must be a string`);
|
||||
}
|
||||
|
||||
if (!plugin.source) {
|
||||
errors.push(`${prefix}: "source" is required`);
|
||||
} else if (typeof plugin.source === "string") {
|
||||
errors.push(`${prefix}: "source" must be an object (local file paths are not allowed for external plugins)`);
|
||||
} else if (typeof plugin.source === "object") {
|
||||
if (!plugin.source.source) {
|
||||
errors.push(`${prefix}: "source.source" is required (e.g. "github", "url", "npm", "pip")`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`${prefix}: "source" must be an object`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read external plugin entries from external.json
|
||||
* @returns {Array} - Array of external plugin entries (merged as-is)
|
||||
*/
|
||||
function readExternalPlugins() {
|
||||
if (!fs.existsSync(EXTERNAL_PLUGINS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(EXTERNAL_PLUGINS_FILE, "utf8");
|
||||
const plugins = JSON.parse(content);
|
||||
if (!Array.isArray(plugins)) {
|
||||
console.warn("Warning: external.json must contain an array");
|
||||
return [];
|
||||
}
|
||||
|
||||
// Validate each entry
|
||||
let hasErrors = false;
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const errors = validateExternalPlugin(plugins[i], i);
|
||||
if (errors.length > 0) {
|
||||
errors.forEach(e => console.error(`Error: ${e}`));
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
if (hasErrors) {
|
||||
console.error("Error: external.json contains invalid entries");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
} catch (error) {
|
||||
console.error(`Error reading external.json: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read plugin metadata from plugin.json file
|
||||
* @param {string} pluginDir - Path to plugin directory
|
||||
@@ -142,16 +69,20 @@ function generateMarketplace() {
|
||||
}
|
||||
|
||||
// Read external plugins and merge as-is
|
||||
const externalPlugins = readExternalPlugins();
|
||||
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
|
||||
localPluginNames: plugins.map((plugin) => plugin.name),
|
||||
policy: "marketplace",
|
||||
});
|
||||
externalWarnings.forEach((warning) => console.warn(`Warning: ${warning}`));
|
||||
if (externalErrors.length > 0) {
|
||||
externalErrors.forEach((error) => console.error(`Error: ${error}`));
|
||||
console.error("Error: external.json contains invalid entries");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (externalPlugins.length > 0) {
|
||||
console.log(`\nFound ${externalPlugins.length} external plugins`);
|
||||
|
||||
// Warn on duplicate names
|
||||
const localNames = new Set(plugins.map(p => p.name));
|
||||
for (const ext of externalPlugins) {
|
||||
if (localNames.has(ext.name)) {
|
||||
console.warn(`Warning: external plugin "${ext.name}" has the same name as a local plugin`);
|
||||
}
|
||||
plugins.push(ext);
|
||||
console.log(`✓ Added external plugin: ${ext.name}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ROOT_FOLDER } from "./constants.mjs";
|
||||
import { readExternalPlugins } from "./external-plugin-validation.mjs";
|
||||
|
||||
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
|
||||
|
||||
@@ -222,8 +223,24 @@ function validatePlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nValidating external plugin catalog...");
|
||||
const { plugins: externalPlugins, errors: externalErrors, warnings: externalWarnings } = readExternalPlugins({
|
||||
localPluginNames: pluginDirs,
|
||||
policy: "marketplace",
|
||||
});
|
||||
|
||||
externalWarnings.forEach((warning) => console.warn(`⚠️ ${warning}`));
|
||||
|
||||
if (externalErrors.length > 0) {
|
||||
console.error("❌ external.json:");
|
||||
externalErrors.forEach((error) => console.error(` - ${error}`));
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.log(`✅ external.json is valid (${externalPlugins.length} external plugins)`);
|
||||
}
|
||||
|
||||
if (!hasErrors) {
|
||||
console.log(`\n✅ All ${pluginDirs.length} plugins are valid`);
|
||||
console.log(`\n✅ All ${pluginDirs.length} plugins and the external catalog are valid`);
|
||||
}
|
||||
|
||||
return !hasErrors;
|
||||
|
||||
Reference in New Issue
Block a user