mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-08 18:19:14 +00:00
Merge remote-tracking branch 'upstream/main' into add-daily-focus-board-skill
This commit is contained in:
@@ -166,6 +166,34 @@
|
||||
"description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
{
|
||||
"name": "brainmaxxing",
|
||||
"description": "Codebase-grounded concept-mastery quizzes with an interactive BrainMax Canvas dashboard.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Julia Muiruri",
|
||||
"url": "https://juliawakiru.dev"
|
||||
},
|
||||
"repository": "https://github.com/juliamuiruri4/brainmaxxing",
|
||||
"homepage": "https://github.com/juliamuiruri4/brainmaxxing",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"assessment",
|
||||
"canvas",
|
||||
"developer-education",
|
||||
"learning",
|
||||
"quiz",
|
||||
"skills-mastery",
|
||||
"software-engineering",
|
||||
"student-developer"
|
||||
],
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "juliamuiruri4/brainmaxxing",
|
||||
"ref": "v1.0.0",
|
||||
"sha": "8a8a102ff3ab8736f015764207cdb7e68fcd5ecf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "cast-imaging",
|
||||
"source": "plugins/cast-imaging",
|
||||
@@ -776,7 +804,7 @@
|
||||
{
|
||||
"name": "microsoft-foundry",
|
||||
"description": "Skills and interactive Copilot canvas for designing, configuring, testing and deploying agents to Microsoft Foundry.",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://www.microsoft.com"
|
||||
@@ -797,7 +825,7 @@
|
||||
"source": "github",
|
||||
"repo": "microsoft/foundry-toolkit",
|
||||
"path": "microsoft-foundry",
|
||||
"sha": "478668e6c62035d702ad3405361d4d229f31ca03"
|
||||
"sha": "b4ffffc810130893117a7b8f797e0a33102e66de"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1189,6 +1217,30 @@
|
||||
"description": "Stop being the switchboard between your AI agents — direct a team. The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it.",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
{
|
||||
"name": "timebase",
|
||||
"description": "Connect your AI agent to TimeBase, a high-performance database for financial market and other time-series data, via MCP tools and skills for QQL query and TimeBase client code generation.",
|
||||
"version": "0.1.2",
|
||||
"author": {
|
||||
"name": "EPAM Systems",
|
||||
"url": "https://www.epam.com/"
|
||||
},
|
||||
"repository": "https://github.com/epam/TimeBase-Agent-Plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"timebase",
|
||||
"time-series",
|
||||
"db",
|
||||
"qql",
|
||||
"financial-data"
|
||||
],
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "epam/TimeBase-Agent-Plugins",
|
||||
"ref": "v0.1.2",
|
||||
"sha": "846b341a17becbf4d0cd43374588fc6ab1930d0b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tiny-tool-town-submitter",
|
||||
"source": "extensions/tiny-tool-town-submitter",
|
||||
|
||||
@@ -279,17 +279,19 @@ jobs:
|
||||
const vallyLintStatus = escapeMarkdownTableCell(quality.vally_lint_status || 'not_run');
|
||||
const smokeStatus = escapeMarkdownTableCell(quality.smoke_status || 'not_run');
|
||||
const versionMatchStatus = escapeMarkdownTableCell(quality.version_match_status || 'not_run');
|
||||
const refShaConsistencyStatus = escapeMarkdownTableCell(quality.ref_sha_consistency_status || 'not_run');
|
||||
const canvasStructureStatus = escapeMarkdownTableCell(quality.canvas_structure_status || 'not_run');
|
||||
const overallStatus = escapeMarkdownTableCell(quality.overall_status || 'not_run');
|
||||
return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`;
|
||||
return `| ${name} | ${vallyLintStatus} | ${smokeStatus} | ${versionMatchStatus} | ${refShaConsistencyStatus} | ${canvasStructureStatus} | ${overallStatus} | ${sourceCell} |`;
|
||||
})
|
||||
: ['| _none_ | not_run | not_run | not_run | not_run | not_run | _n/a_ |'];
|
||||
: ['| _none_ | not_run | not_run | not_run | not_run | not_run | not_run | _n/a_ |'];
|
||||
const failureDetails = checkedPlugins.flatMap((entry) => {
|
||||
const name = String(entry?.name || 'unknown');
|
||||
const quality = entry?.quality || {};
|
||||
const shouldShowVally = quality.vally_lint_status === 'fail' || quality.vally_lint_status === 'infra_error' || String(quality.vally_lint_output || '').trim().length > 0;
|
||||
const shouldShowSmoke = quality.smoke_status === 'fail' || quality.smoke_status === 'infra_error' || String(quality.smoke_output || '').trim().length > 0;
|
||||
const shouldShowVersionMatch = quality.version_match_status === 'fail' || quality.version_match_status === 'infra_error' || String(quality.version_match_output || '').trim().length > 0;
|
||||
const shouldShowRefShaConsistency = quality.ref_sha_consistency_status === 'fail' || quality.ref_sha_consistency_status === 'infra_error' || String(quality.ref_sha_consistency_output || '').trim().length > 0;
|
||||
const shouldShowCanvasStructure = quality.canvas_structure_status === 'fail' || quality.canvas_structure_status === 'infra_error' || String(quality.canvas_structure_output || '').trim().length > 0;
|
||||
|
||||
const details = [];
|
||||
@@ -302,6 +304,9 @@ jobs:
|
||||
if (shouldShowVersionMatch) {
|
||||
details.push(formatGateOutput(name, 'version match', quality.version_match_status, quality.version_match_output));
|
||||
}
|
||||
if (shouldShowRefShaConsistency) {
|
||||
details.push(formatGateOutput(name, 'ref/sha consistency', quality.ref_sha_consistency_status, quality.ref_sha_consistency_output));
|
||||
}
|
||||
if (shouldShowCanvasStructure) {
|
||||
details.push(formatGateOutput(name, 'canvas structure', quality.canvas_structure_status, quality.canvas_structure_output));
|
||||
}
|
||||
@@ -317,8 +322,8 @@ jobs:
|
||||
'',
|
||||
'### Per-plugin quality summary',
|
||||
'',
|
||||
'| Plugin | vally lint | install smoke test | version match | canvas structure | overall | source tree |',
|
||||
'|---|---|---|---|---|---|---|',
|
||||
'| Plugin | vally lint | install smoke test | version match | ref/sha consistency | canvas structure | overall | source tree |',
|
||||
'|---|---|---|---|---|---|---|---|',
|
||||
...rows,
|
||||
'',
|
||||
...(failureDetails.length > 0
|
||||
|
||||
+205
-29
@@ -5,6 +5,7 @@ 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 -->";
|
||||
@@ -293,9 +294,23 @@ function encodeRepoPath(repo) {
|
||||
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`);
|
||||
@@ -333,6 +348,19 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
|
||||
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -347,6 +375,8 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
|
||||
`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;
|
||||
}
|
||||
|
||||
@@ -362,6 +392,38 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -380,7 +442,92 @@ async function validateRemoteRepository(repo, { ref, sha }, errors, warnings, to
|
||||
}
|
||||
}
|
||||
|
||||
async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
|
||||
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;
|
||||
@@ -471,41 +618,51 @@ async function validateCanvasPluginMetadata(plugin, errors, warnings, token) {
|
||||
);
|
||||
}
|
||||
|
||||
const extensionContainerPath = joinRepoPath(pluginRoot, "extensions");
|
||||
const extensionContainerResponse = await fetchGitHubFile(repo, extensionContainerPath, releaseLocator, token);
|
||||
if (extensionContainerResponse.kind === "notFound") {
|
||||
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 (extensionContainerResponse.kind === "apiError") {
|
||||
warnings.push(
|
||||
`submission: could not verify "extensions" directory in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
|
||||
);
|
||||
} else if (
|
||||
!(
|
||||
extensionContainerResponse.data?.type === "dir"
|
||||
|| Array.isArray(extensionContainerResponse.data)
|
||||
)
|
||||
) {
|
||||
} else if (extensionsTree.status === "notDirectory") {
|
||||
errors.push(
|
||||
`submission: "extensions" must be a directory in ${releaseLocatorDescription}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extensionEntryPath = joinRepoPath(pluginRoot, "extensions", "extension.mjs");
|
||||
const extensionEntryResponse = await fetchGitHubFile(repo, extensionEntryPath, releaseLocator, token);
|
||||
if (extensionEntryResponse.kind === "notFound") {
|
||||
errors.push(
|
||||
`submission: plugins tagged with "canvas" must include "extensions/extension.mjs" at ${releaseLocatorDescription}`,
|
||||
);
|
||||
} else if (extensionEntryResponse.kind === "apiError") {
|
||||
warnings.push(
|
||||
`submission: could not verify "extensions/extension.mjs" in GitHub repository "${repo}" at ${releaseLocatorDescription}; a maintainer should re-run intake`,
|
||||
);
|
||||
} else if (extensionEntryResponse.data?.type !== "file") {
|
||||
errors.push(
|
||||
`submission: "extensions/extension.mjs" must be a file 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);
|
||||
@@ -629,12 +786,14 @@ function normalizeQualityGateResult(rawResult) {
|
||||
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: "",
|
||||
vally_lint_output: "",
|
||||
smoke_output: "",
|
||||
version_match_output: "",
|
||||
ref_sha_consistency_output: "",
|
||||
canvas_structure_output: "",
|
||||
};
|
||||
|
||||
@@ -652,6 +811,7 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
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._";
|
||||
|
||||
@@ -663,6 +823,7 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
`| vally lint | ${vallyState} |`,
|
||||
`| install smoke test | ${smokeState} |`,
|
||||
`| version match | ${versionMatchState} |`,
|
||||
`| ref/sha consistency | ${refShaConsistencyState} |`,
|
||||
`| canvas structure | ${canvasStructureState} |`,
|
||||
"",
|
||||
summaryText,
|
||||
@@ -713,6 +874,21 @@ function buildQualityGatesCommentSection(qualityResult) {
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import { evaluateExternalPluginIssue, validateCanvasPluginMetadata } from "./external-plugin-intake.mjs";
|
||||
|
||||
const REPO = "owner/repo";
|
||||
const SHA = "0123456789abcdef0123456789abcdef01234567";
|
||||
const PLUGIN_ROOT = "plugins/upgrade-agent";
|
||||
|
||||
const TREE_PLUGINS = "tree-plugins";
|
||||
const TREE_UPGRADE_AGENT = "tree-upgrade-agent";
|
||||
const TREE_EXTENSIONS = "tree-extensions";
|
||||
|
||||
function fileNode(content) {
|
||||
return { type: "file", content: Buffer.from(content, "utf8").toString("base64") };
|
||||
}
|
||||
|
||||
function treeEntry(path, type, sha) {
|
||||
return { path, type, mode: type === "tree" ? "040000" : "100644", sha: sha ?? "deadbeef" };
|
||||
}
|
||||
|
||||
function treeResponse(entries, { truncated = false } = {}) {
|
||||
return { status: 200, data: { sha: "resolved", truncated, tree: entries } };
|
||||
}
|
||||
|
||||
function decodeContentsPath(url) {
|
||||
const { pathname } = new URL(url);
|
||||
const afterContents = pathname.split("/contents/")[1] ?? "";
|
||||
return afterContents
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((segment) => decodeURIComponent(segment))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function decodeTreeish(url) {
|
||||
const match = String(url).match(/\/git\/trees\/([^/?]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
async function withMockedFetch({ contents = {}, trees = {} }, run) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
const requestUrl = String(url);
|
||||
let route;
|
||||
if (requestUrl.includes("/git/trees/")) {
|
||||
route = trees[decodeTreeish(requestUrl)] ?? { status: 404, data: {} };
|
||||
} else {
|
||||
route = contents[decodeContentsPath(requestUrl)] ?? { status: 404, data: {} };
|
||||
}
|
||||
return {
|
||||
ok: route.status >= 200 && route.status < 300,
|
||||
status: route.status,
|
||||
json: async () => route.data ?? {},
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
const canvasManifest = fileNode(
|
||||
JSON.stringify({
|
||||
name: "upgrade-agent",
|
||||
version: "1.0.0",
|
||||
description: "Canvas plugin",
|
||||
logo: "assets/preview.png",
|
||||
}),
|
||||
);
|
||||
|
||||
function baseContents(extra) {
|
||||
return {
|
||||
[`${PLUGIN_ROOT}/.github/plugin/plugin.json`]: { status: 200, data: canvasManifest },
|
||||
[`${PLUGIN_ROOT}/assets/preview.png`]: { status: 200, data: fileNode("binary") },
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
// Wires up the directory-walk that resolves plugins/upgrade-agent/extensions to a tree SHA.
|
||||
// `extensionsEntry` controls what the walk finds at the final ".../extensions" step, and
|
||||
// `extensionsSubtree` is the recursive listing returned for that resolved tree SHA.
|
||||
function buildTrees({ extensionsEntry, extensionsSubtree, overrides } = {}) {
|
||||
const trees = {
|
||||
[SHA]: treeResponse([treeEntry("plugins", "tree", TREE_PLUGINS)]),
|
||||
[TREE_PLUGINS]: treeResponse([treeEntry("upgrade-agent", "tree", TREE_UPGRADE_AGENT)]),
|
||||
[TREE_UPGRADE_AGENT]: treeResponse([
|
||||
extensionsEntry ?? treeEntry("extensions", "tree", TREE_EXTENSIONS),
|
||||
]),
|
||||
};
|
||||
if (extensionsSubtree) {
|
||||
trees[TREE_EXTENSIONS] = extensionsSubtree;
|
||||
}
|
||||
return { ...trees, ...overrides };
|
||||
}
|
||||
|
||||
function makePlugin() {
|
||||
return {
|
||||
name: "upgrade-agent",
|
||||
keywords: ["canvas"],
|
||||
source: { source: "github", repo: REPO, sha: SHA, path: PLUGIN_ROOT },
|
||||
};
|
||||
}
|
||||
|
||||
async function runValidation({ contents, trees }) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
await withMockedFetch({ contents: baseContents(contents), trees }, () =>
|
||||
validateCanvasPluginMetadata(makePlugin(), errors, warnings, null),
|
||||
);
|
||||
return { errors, warnings };
|
||||
}
|
||||
|
||||
test("validateCanvasPluginMetadata accepts a nested extension entry point", async () => {
|
||||
const { errors, warnings } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsSubtree: treeResponse([
|
||||
treeEntry("modernize-dashboard", "tree"),
|
||||
treeEntry("modernize-dashboard/extension.mjs", "blob"),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata accepts a flat extension entry point", async () => {
|
||||
const { errors, warnings } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsSubtree: treeResponse([treeEntry("extension.mjs", "blob")]),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata rejects when no extension.mjs exists flat or nested", async () => {
|
||||
const { errors } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsSubtree: treeResponse([
|
||||
treeEntry("modernize-dashboard", "tree"),
|
||||
treeEntry("modernize-dashboard/index.mjs", "blob"),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
errors.some((message) => /must include a canvas extension entry point/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata rejects when the extensions directory is missing", async () => {
|
||||
const { errors } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsEntry: treeEntry("other-dir", "tree", "tree-other"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
errors.some((message) => /must include an "extensions" directory/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata rejects when extensions is a file rather than a directory", async () => {
|
||||
const { errors } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsEntry: treeEntry("extensions", "blob", "blob-extensions"),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
errors.some((message) => /"extensions" must be a directory/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata rejects when extensions/extension.mjs is a directory", async () => {
|
||||
const { errors } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsSubtree: treeResponse([
|
||||
treeEntry("extension.mjs", "tree"),
|
||||
treeEntry("extension.mjs/placeholder.txt", "blob"),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
errors.some((message) => /"extensions\/extension\.mjs" must be a file/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata surfaces an unverifiable result when the tree walk errors", async () => {
|
||||
const { errors, warnings } = await runValidation({
|
||||
trees: buildTrees({
|
||||
overrides: { [SHA]: { status: 500, data: {} } },
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(
|
||||
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata treats a truncated walk level as unverifiable", async () => {
|
||||
const { errors, warnings } = await runValidation({
|
||||
trees: buildTrees({
|
||||
overrides: {
|
||||
[TREE_UPGRADE_AGENT]: treeResponse(
|
||||
[treeEntry("extensions", "tree", TREE_EXTENSIONS)],
|
||||
{ truncated: true },
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(
|
||||
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata treats a truncated extensions subtree without an entry point as unverifiable", async () => {
|
||||
const { errors, warnings } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsSubtree: treeResponse([treeEntry("modernize-dashboard", "tree")], { truncated: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(
|
||||
warnings.some((message) => /could not verify the canvas extension entry point/.test(message)),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateCanvasPluginMetadata accepts an entry point found within a truncated subtree", async () => {
|
||||
const { errors, warnings } = await runValidation({
|
||||
trees: buildTrees({
|
||||
extensionsSubtree: treeResponse(
|
||||
[
|
||||
treeEntry("modernize-dashboard", "tree"),
|
||||
treeEntry("modernize-dashboard/extension.mjs", "blob"),
|
||||
],
|
||||
{ truncated: true },
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ref/sha consistency tests (evaluateExternalPluginIssue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ORIGINAL_FETCH = global.fetch;
|
||||
const INTAKE_REPO = "octo/example";
|
||||
const RESOLVED_REF_SHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const PROVIDED_SHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = ORIGINAL_FETCH;
|
||||
});
|
||||
|
||||
function buildIssueBody({ ref, sha }) {
|
||||
return [
|
||||
"<!-- external-plugin-submission -->",
|
||||
"### Plugin name",
|
||||
"",
|
||||
"intake-ref-sha-consistency-test-plugin",
|
||||
"",
|
||||
"### Short description",
|
||||
"",
|
||||
"Test plugin for external intake validation.",
|
||||
"",
|
||||
"### GitHub repository",
|
||||
"",
|
||||
INTAKE_REPO,
|
||||
"",
|
||||
"### Plugin path inside the repository",
|
||||
"",
|
||||
"_No response_",
|
||||
"",
|
||||
"### Ref to review",
|
||||
"",
|
||||
ref,
|
||||
"",
|
||||
"### Commit SHA to review",
|
||||
"",
|
||||
sha,
|
||||
"",
|
||||
"### Version",
|
||||
"",
|
||||
"1.2.3",
|
||||
"",
|
||||
"### License identifier",
|
||||
"",
|
||||
"MIT",
|
||||
"",
|
||||
"### Author name",
|
||||
"",
|
||||
"Copilot Test",
|
||||
"",
|
||||
"### Author URL",
|
||||
"",
|
||||
"_No response_",
|
||||
"",
|
||||
"### Homepage URL",
|
||||
"",
|
||||
"_No response_",
|
||||
"",
|
||||
"### Keywords",
|
||||
"",
|
||||
"testing",
|
||||
"",
|
||||
"### Additional notes for reviewers",
|
||||
"",
|
||||
"_No response_",
|
||||
"",
|
||||
"### Submission checklist",
|
||||
"",
|
||||
"- [x] The plugin lives in a public GitHub repository.",
|
||||
"- [x] The ref and/or sha I provided is immutable (release tag and/or full 40-character commit SHA), not a branch.",
|
||||
"- [x] This submission follows this repository's contribution, security, and responsible AI policies.",
|
||||
"- [x] This plugin is not already listed in the Awesome Copilot marketplace.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function jsonResponse(payload, { status = 200 } = {}) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 404 ? "Not Found" : "OK",
|
||||
headers: new Map(),
|
||||
async json() {
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installMockFetch() {
|
||||
global.fetch = async (url) => {
|
||||
const requestUrl = String(url);
|
||||
if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}`) {
|
||||
return jsonResponse({ private: false, archived: false });
|
||||
}
|
||||
|
||||
if (
|
||||
requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/commits/${PROVIDED_SHA}` ||
|
||||
requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/commits/${RESOLVED_REF_SHA}`
|
||||
) {
|
||||
const sha = requestUrl.endsWith(RESOLVED_REF_SHA) ? RESOLVED_REF_SHA : PROVIDED_SHA;
|
||||
return jsonResponse({ sha });
|
||||
}
|
||||
|
||||
if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/git/ref/tags/v1.2.3`) {
|
||||
return jsonResponse({ object: { type: "tag", sha: "cccccccccccccccccccccccccccccccccccccccc" } });
|
||||
}
|
||||
|
||||
if (requestUrl === `https://api.github.com/repos/${INTAKE_REPO}/commits/v1.2.3`) {
|
||||
return jsonResponse({ sha: RESOLVED_REF_SHA });
|
||||
}
|
||||
|
||||
return jsonResponse({}, { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
test("evaluateExternalPluginIssue fails when ref and sha resolve to different commits", async () => {
|
||||
installMockFetch();
|
||||
const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: PROVIDED_SHA }) };
|
||||
|
||||
const result = await evaluateExternalPluginIssue({ issue });
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(
|
||||
result.commentBody,
|
||||
/must reference the same commit \(ref "v1\.2\.3" resolves to "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sha is "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("evaluateExternalPluginIssue passes when ref and sha resolve to the same commit", async () => {
|
||||
installMockFetch();
|
||||
const issue = { body: buildIssueBody({ ref: "v1.2.3", sha: RESOLVED_REF_SHA }) };
|
||||
|
||||
const result = await evaluateExternalPluginIssue({ issue });
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(
|
||||
result.errors.some((error) => error.includes("must reference the same commit")),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -74,12 +74,14 @@ function createValidationFailureQuality(errors) {
|
||||
vally_lint_status: "fail",
|
||||
smoke_status: "not_run",
|
||||
version_match_status: "not_run",
|
||||
ref_sha_consistency_status: "not_run",
|
||||
canvas_structure_status: "not_run",
|
||||
failure_class: "submitter_fixes",
|
||||
summary: "Plugin entry failed external.json validation. Fix the listed errors and re-run quality checks.",
|
||||
vally_lint_output: output,
|
||||
smoke_output: "Install smoke test skipped due to external.json validation errors.",
|
||||
version_match_output: "Version match skipped due to external.json validation errors.",
|
||||
ref_sha_consistency_output: "Ref/SHA consistency check skipped due to external.json validation errors.",
|
||||
canvas_structure_output: "Canvas structure check skipped due to external.json validation errors.",
|
||||
};
|
||||
}
|
||||
@@ -107,7 +109,7 @@ export async function runExternalPluginPrQualityGates(plugins) {
|
||||
? "No changed external plugin entries were detected in plugins/external.json."
|
||||
: checkedPlugins
|
||||
.map((entry) =>
|
||||
`- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}`
|
||||
`- ${entry.name}: vally-lint=${entry.quality.vally_lint_status}, install-smoke=${entry.quality.smoke_status}, version-match=${entry.quality.version_match_status}, ref-sha-consistency=${entry.quality.ref_sha_consistency_status}, canvas-structure=${entry.quality.canvas_structure_status}, overall=${entry.quality.overall_status}`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "path";
|
||||
import { Writable } from "stream";
|
||||
import { spawnSync } from "child_process";
|
||||
import { runLint, LintConsoleReporter } from "@microsoft/vally";
|
||||
import { evaluateRefShaConsistency, normalizeCommitSha } from "./lib/external-plugin-source-ref-sha.mjs";
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 12000;
|
||||
const EXTERNAL_CANVAS_KEYWORD = "canvas";
|
||||
@@ -384,6 +385,7 @@ function readPluginManifestAtLocator(repoDir, readRef, locator, normalizedPlugin
|
||||
message: `Invalid JSON in "${manifestPath}" at "${locator}": ${error.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (isMissingPathAtLocator(showResult.output)) {
|
||||
@@ -402,6 +404,87 @@ function readPluginManifestAtLocator(repoDir, readRef, locator, normalizedPlugin
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCommitShaAtReadRef(repoDir, readRef, locator) {
|
||||
const revParse = runCommand("git", ["rev-parse", `${readRef}^{commit}`], { cwd: repoDir });
|
||||
if (revParse.exitCode !== 0) {
|
||||
return {
|
||||
status: "fail",
|
||||
commitSha: null,
|
||||
output: `source.ref "${locator}" does not identify a commit (it may point to a tag object, tree, or blob); only commit-backed refs are supported`,
|
||||
};
|
||||
}
|
||||
|
||||
const commitSha = normalizeCommitSha(revParse.stdout);
|
||||
if (!commitSha) {
|
||||
return {
|
||||
status: "infra_error",
|
||||
commitSha: null,
|
||||
output: `Unable to parse commit SHA for "${locator}" from "${readRef}".`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "pass",
|
||||
commitSha,
|
||||
output: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function runRefShaConsistencyGate(repoDir, plugin, primaryFetchSpec) {
|
||||
const sourceRef = typeof plugin?.source?.ref === "string" ? plugin.source.ref.trim() : "";
|
||||
const sourceSha = typeof plugin?.source?.sha === "string" ? plugin.source.sha.trim() : "";
|
||||
if (!sourceRef || !sourceSha) {
|
||||
return {
|
||||
status: "not_run",
|
||||
output: "Ref/SHA consistency gate skipped because one of source.ref or source.sha was not provided.",
|
||||
};
|
||||
}
|
||||
|
||||
const refResult = resolveLocatorReadRef(repoDir, sourceRef, primaryFetchSpec);
|
||||
if (refResult.status === "fail") {
|
||||
return {
|
||||
status: "fail",
|
||||
output: refResult.output,
|
||||
};
|
||||
}
|
||||
|
||||
if (refResult.status === "infra_error") {
|
||||
return {
|
||||
status: "infra_error",
|
||||
output: refResult.output,
|
||||
};
|
||||
}
|
||||
|
||||
const commitResult = resolveCommitShaAtReadRef(repoDir, refResult.readRef, sourceRef);
|
||||
if (commitResult.status !== "pass") {
|
||||
return commitResult;
|
||||
}
|
||||
|
||||
const consistency = evaluateRefShaConsistency({
|
||||
ref: sourceRef,
|
||||
sha: sourceSha,
|
||||
resolvedRefCommitSha: commitResult.commitSha,
|
||||
});
|
||||
if (!consistency.comparable) {
|
||||
return {
|
||||
status: "not_run",
|
||||
output: "Ref/SHA consistency gate skipped because source.sha is not a full 40-character commit SHA.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!consistency.matches) {
|
||||
return {
|
||||
status: "fail",
|
||||
output: `source.ref "${sourceRef}" resolves to "${consistency.normalizedRefCommitSha}", which does not match source.sha "${sourceSha}".`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "pass",
|
||||
output: `source.ref "${sourceRef}" resolves to the same commit as source.sha "${sourceSha}".`,
|
||||
};
|
||||
}
|
||||
|
||||
export function runVersionMatchGate(repoDir, plugin, primaryFetchSpec) {
|
||||
const expectedVersion = String(plugin?.version ?? "").trim();
|
||||
const normalizedPluginPath = normalizePluginPath(plugin?.source?.path || "/");
|
||||
@@ -529,6 +612,91 @@ function checkPathExistsAtLocator(repoDir, readRef, locator, repoPath, expectedT
|
||||
};
|
||||
}
|
||||
|
||||
function listTreeEntries(repoDir, readRef, locator, treePath, { recursive = false } = {}) {
|
||||
// Parse the full, untruncated tree listing directly. runCommand()/truncateOutput()
|
||||
// would cap stdout at MAX_OUTPUT_LENGTH and silently drop later entries, and the
|
||||
// default (non-"-z") output quotes unusual names; "-z" gives raw, NUL-delimited records.
|
||||
// "-r -t" recurses in a single process and still lists intermediate tree objects, so a
|
||||
// whole subtree can be inspected without spawning one git process per candidate path.
|
||||
const args = recursive
|
||||
? ["ls-tree", "-r", "-t", "-z", `${readRef}:${treePath}`]
|
||||
: ["ls-tree", "-z", `${readRef}:${treePath}`];
|
||||
const result = spawnSync("git", args, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const detail = truncateOutput(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
||||
return {
|
||||
entries: [],
|
||||
output: `Unable to list directory "${treePath}" at "${locator}": ${detail}`,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (const record of String(result.stdout ?? "").split("\0")) {
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tabIndex = record.indexOf("\t");
|
||||
if (tabIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const meta = record.slice(0, tabIndex).trim().split(/\s+/);
|
||||
const name = record.slice(tabIndex + 1);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({ type: meta[1] ?? "", name });
|
||||
}
|
||||
|
||||
return { entries, output: "" };
|
||||
}
|
||||
|
||||
function locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir) {
|
||||
// Enumerate the extensions subtree with a single recursive git process rather than
|
||||
// spawning a git cat-file per candidate directory, so discovery stays bounded no matter
|
||||
// how many folders an untrusted repository packs under "extensions/". "-r" yields paths
|
||||
// relative to extensionsDir, so nested entry points appear as "<name>/extension.mjs".
|
||||
const listing = listTreeEntries(repoDir, readRef, locator, extensionsDir, { recursive: true });
|
||||
if (listing.output) {
|
||||
return { entryPoint: null, output: listing.output };
|
||||
}
|
||||
|
||||
let flatIsBlob = false;
|
||||
let flatIsTree = false;
|
||||
let nestedEntryPoint = null;
|
||||
for (const entry of listing.entries) {
|
||||
if (entry.name === "extension.mjs") {
|
||||
if (entry.type === "blob") {
|
||||
flatIsBlob = true;
|
||||
} else if (entry.type === "tree") {
|
||||
flatIsTree = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const segments = entry.name.split("/");
|
||||
if (segments.length === 2 && segments[1] === "extension.mjs" && entry.type === "blob" && !nestedEntryPoint) {
|
||||
nestedEntryPoint = toPosixPath(extensionsDir, segments[0], "extension.mjs");
|
||||
}
|
||||
}
|
||||
|
||||
if (flatIsBlob) {
|
||||
return { entryPoint: toPosixPath(extensionsDir, "extension.mjs"), output: "" };
|
||||
}
|
||||
if (nestedEntryPoint) {
|
||||
return { entryPoint: nestedEntryPoint, output: "" };
|
||||
}
|
||||
|
||||
return { entryPoint: null, output: "", flatKindMismatch: flatIsTree };
|
||||
}
|
||||
|
||||
export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
|
||||
if (!hasCanvasKeyword(plugin)) {
|
||||
return {
|
||||
@@ -589,23 +757,25 @@ export function runCanvasStructureGate(repoDir, plugin, primaryFetchSpec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extensionEntryCheck = checkPathExistsAtLocator(repoDir, readRef, locator, extensionEntryPoint, "blob");
|
||||
const extensionEntryCheck = locateCanvasEntryPoint(repoDir, readRef, locator, extensionsDir);
|
||||
if (extensionEntryCheck.output) {
|
||||
hasInfraError = true;
|
||||
messages.push(`- ${locator}: ${extensionEntryCheck.output}`);
|
||||
continue;
|
||||
}
|
||||
if (!extensionEntryCheck.exists) {
|
||||
if (!extensionEntryCheck.entryPoint) {
|
||||
hasFailure = true;
|
||||
if (extensionEntryCheck.kindMismatch) {
|
||||
if (extensionEntryCheck.flatKindMismatch) {
|
||||
messages.push(`- ${locator}: "${extensionEntryPoint}" must be a file.`);
|
||||
} else {
|
||||
messages.push(`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}".`);
|
||||
messages.push(
|
||||
`- ${locator}: missing required canvas extension entry point "${extensionEntryPoint}" (or a nested "${extensionsDir}/<extension>/extension.mjs").`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryPoint}".`);
|
||||
messages.push(`- ${locator}: found "${extensionsDir}" with entry point "${extensionEntryCheck.entryPoint}".`);
|
||||
}
|
||||
|
||||
if (hasInfraError) {
|
||||
@@ -658,12 +828,14 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
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: "",
|
||||
vally_lint_output: "",
|
||||
smoke_output: "",
|
||||
version_match_output: "",
|
||||
ref_sha_consistency_output: "",
|
||||
canvas_structure_output: "",
|
||||
};
|
||||
|
||||
@@ -676,6 +848,7 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
result.vally_lint_status = "fail";
|
||||
result.smoke_status = "fail";
|
||||
result.version_match_status = "fail";
|
||||
result.ref_sha_consistency_status = "not_run";
|
||||
result.canvas_structure_status = hasCanvasKeyword(plugin) ? "fail" : "not_run";
|
||||
result.overall_status = "fail";
|
||||
result.failure_class = "submitter_fixes";
|
||||
@@ -691,6 +864,10 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
result.version_match_status = versionMatchResult.status;
|
||||
result.version_match_output = versionMatchResult.output;
|
||||
|
||||
const refShaConsistencyResult = runRefShaConsistencyGate(repoDir, plugin, fetchSpec);
|
||||
result.ref_sha_consistency_status = refShaConsistencyResult.status;
|
||||
result.ref_sha_consistency_output = refShaConsistencyResult.output;
|
||||
|
||||
const canvasStructureResult = runCanvasStructureGate(repoDir, plugin, fetchSpec);
|
||||
result.canvas_structure_status = canvasStructureResult.status;
|
||||
result.canvas_structure_output = canvasStructureResult.output;
|
||||
@@ -707,6 +884,7 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
result.vally_lint_status,
|
||||
result.smoke_status,
|
||||
result.version_match_status,
|
||||
result.ref_sha_consistency_status,
|
||||
result.canvas_structure_status,
|
||||
]);
|
||||
result.failure_class = toFailureClass(result.overall_status);
|
||||
@@ -714,6 +892,7 @@ export async function runExternalPluginQualityGates(plugin) {
|
||||
`- vally lint: ${result.vally_lint_status}`,
|
||||
`- install smoke test: ${result.smoke_status}`,
|
||||
`- version match: ${result.version_match_status}`,
|
||||
`- ref/sha consistency: ${result.ref_sha_consistency_status}`,
|
||||
`- canvas structure: ${result.canvas_structure_status}`,
|
||||
`- overall: ${result.overall_status}`,
|
||||
].join("\n");
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "os";
|
||||
import path from "path";
|
||||
import { spawnSync } from "child_process";
|
||||
import { after, test } from "node:test";
|
||||
import { runCanvasStructureGate, runVersionMatchGate } from "./external-plugin-quality-gates.mjs";
|
||||
import { runCanvasStructureGate, runRefShaConsistencyGate, runVersionMatchGate } from "./external-plugin-quality-gates.mjs";
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
@@ -100,6 +100,90 @@ test("runCanvasStructureGate fails when extension entrypoint path is a directory
|
||||
assert.match(result.output, /"extensions\/extension\.mjs" must be a file/);
|
||||
});
|
||||
|
||||
test("runCanvasStructureGate passes when extension lives in a nested subfolder", () => {
|
||||
const repoDir = createTempRepo();
|
||||
fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, "extensions", "modernize-dashboard", "extension.mjs"),
|
||||
"export default {};\n",
|
||||
);
|
||||
const sha = commitAll(repoDir, "Add nested canvas extension");
|
||||
|
||||
const plugin = {
|
||||
name: "canvas-plugin",
|
||||
keywords: ["canvas"],
|
||||
source: {
|
||||
source: "github",
|
||||
repo: "owner/repo",
|
||||
sha,
|
||||
},
|
||||
};
|
||||
|
||||
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.match(result.output, /entry point "extensions\/modernize-dashboard\/extension\.mjs"/);
|
||||
});
|
||||
|
||||
test("runCanvasStructureGate fails when no extension.mjs exists flat or nested", () => {
|
||||
const repoDir = createTempRepo();
|
||||
fs.mkdirSync(path.join(repoDir, "extensions", "modernize-dashboard"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, "extensions", "modernize-dashboard", "index.mjs"),
|
||||
"export default {};\n",
|
||||
);
|
||||
const sha = commitAll(repoDir, "Add extensions directory without entry point");
|
||||
|
||||
const plugin = {
|
||||
name: "canvas-plugin",
|
||||
keywords: ["canvas"],
|
||||
source: {
|
||||
source: "github",
|
||||
repo: "owner/repo",
|
||||
sha,
|
||||
},
|
||||
};
|
||||
|
||||
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "fail");
|
||||
assert.match(result.output, /missing required canvas extension entry point/);
|
||||
});
|
||||
|
||||
test("runCanvasStructureGate finds a nested extension listed past the legacy output cap", () => {
|
||||
const repoDir = createTempRepo();
|
||||
// Many sibling directories push the real extension past the ~12 KB stdout cap that the
|
||||
// previous truncating implementation applied, which would silently drop it from the listing.
|
||||
// Long names inflate each git ls-tree record so fewer directories are needed to exceed the cap.
|
||||
for (let index = 0; index < 160; index += 1) {
|
||||
const filler = path.join(
|
||||
repoDir,
|
||||
"extensions",
|
||||
`filler-directory-that-pads-the-tree-listing-${String(index).padStart(4, "0")}`,
|
||||
);
|
||||
fs.mkdirSync(filler, { recursive: true });
|
||||
fs.writeFileSync(path.join(filler, "readme.txt"), "filler\n");
|
||||
}
|
||||
fs.mkdirSync(path.join(repoDir, "extensions", "zzz-real-extension"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(repoDir, "extensions", "zzz-real-extension", "extension.mjs"),
|
||||
"export default {};\n",
|
||||
);
|
||||
const sha = commitAll(repoDir, "Add nested extension after many siblings");
|
||||
|
||||
const plugin = {
|
||||
name: "canvas-plugin",
|
||||
keywords: ["canvas"],
|
||||
source: {
|
||||
source: "github",
|
||||
repo: "owner/repo",
|
||||
sha,
|
||||
},
|
||||
};
|
||||
|
||||
const result = runCanvasStructureGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "pass");
|
||||
assert.match(result.output, /entry point "extensions\/zzz-real-extension\/extension\.mjs"/);
|
||||
});
|
||||
|
||||
// Regression tests for issue #2397: a tag-name locator (e.g. "v1.0.0") must be
|
||||
// readable by the version-match and canvas-structure gates. `git fetch origin <tag>`
|
||||
// only updates FETCH_HEAD and never creates a local `refs/tags/<tag>`, so reading via
|
||||
@@ -214,3 +298,38 @@ test("runCanvasStructureGate passes when the primary locator is a tag ref", () =
|
||||
assert.equal(result.status, "pass", result.output);
|
||||
assert.match(result.output, /- v1\.0\.0: found "extensions"/);
|
||||
});
|
||||
|
||||
test("runRefShaConsistencyGate fails when ref and sha point to different commits", () => {
|
||||
const remoteDir = initRemoteRepo();
|
||||
writeValidPluginContent(remoteDir);
|
||||
const firstSha = commitAll(remoteDir, "Add plugin manifest v1");
|
||||
runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0");
|
||||
fs.writeFileSync(path.join(remoteDir, "README.md"), "v2\n");
|
||||
const secondSha = commitAll(remoteDir, "Add plugin manifest v2");
|
||||
|
||||
const repoDir = cloneSubmissionRepo(remoteDir, secondSha);
|
||||
const plugin = {
|
||||
name: "tag-plugin",
|
||||
source: { source: "github", repo: "owner/repo", ref: "v1.0.0", sha: secondSha },
|
||||
};
|
||||
|
||||
const result = runRefShaConsistencyGate(repoDir, plugin, secondSha);
|
||||
assert.equal(result.status, "fail", result.output);
|
||||
assert.match(result.output, new RegExp(`resolves to "${firstSha}"`));
|
||||
});
|
||||
|
||||
test("runRefShaConsistencyGate passes when ref and sha point to the same commit", () => {
|
||||
const remoteDir = initRemoteRepo();
|
||||
writeValidPluginContent(remoteDir);
|
||||
const sha = commitAll(remoteDir, "Add plugin manifest");
|
||||
runGit(remoteDir, "tag", "-a", "v1.0.0", "-m", "release 1.0.0");
|
||||
|
||||
const repoDir = cloneSubmissionRepo(remoteDir, sha);
|
||||
const plugin = {
|
||||
name: "tag-plugin",
|
||||
source: { source: "github", repo: "owner/repo", ref: "v1.0.0", sha },
|
||||
};
|
||||
|
||||
const result = runRefShaConsistencyGate(repoDir, plugin, sha);
|
||||
assert.equal(result.status, "pass", result.output);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function normalizeCommitSha(value) {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return /^[0-9a-f]{40}$/.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function evaluateRefShaConsistency({ ref, sha, resolvedRefCommitSha }) {
|
||||
const normalizedSha = normalizeCommitSha(sha);
|
||||
const normalizedRefCommitSha = normalizeCommitSha(resolvedRefCommitSha);
|
||||
|
||||
if (!normalizedSha || !normalizedRefCommitSha) {
|
||||
return {
|
||||
comparable: false,
|
||||
matches: true,
|
||||
normalizedSha,
|
||||
normalizedRefCommitSha,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
comparable: true,
|
||||
matches: normalizedSha === normalizedRefCommitSha,
|
||||
normalizedSha,
|
||||
normalizedRefCommitSha,
|
||||
ref: typeof ref === "string" ? ref.trim() : "",
|
||||
sha: typeof sha === "string" ? sha.trim() : "",
|
||||
};
|
||||
}
|
||||
+54
-2
@@ -78,6 +78,34 @@
|
||||
"path": ".github/plugins/azure-skills"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "brainmaxxing",
|
||||
"description": "Codebase-grounded concept-mastery quizzes with an interactive BrainMax Canvas dashboard.",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Julia Muiruri",
|
||||
"url": "https://juliawakiru.dev"
|
||||
},
|
||||
"repository": "https://github.com/juliamuiruri4/brainmaxxing",
|
||||
"homepage": "https://github.com/juliamuiruri4/brainmaxxing",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"assessment",
|
||||
"canvas",
|
||||
"developer-education",
|
||||
"learning",
|
||||
"quiz",
|
||||
"skills-mastery",
|
||||
"software-engineering",
|
||||
"student-developer"
|
||||
],
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "juliamuiruri4/brainmaxxing",
|
||||
"ref": "v1.0.0",
|
||||
"sha": "8a8a102ff3ab8736f015764207cdb7e68fcd5ecf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "chrome-devtools-plugin",
|
||||
"description": "Reliable automation, in-depth debugging, and performance analysis in Chrome using Chrome DevTools and Puppeteer.",
|
||||
@@ -502,7 +530,7 @@
|
||||
{
|
||||
"name": "microsoft-foundry",
|
||||
"description": "Skills and interactive Copilot canvas for designing, configuring, testing and deploying agents to Microsoft Foundry.",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"author": {
|
||||
"name": "Microsoft",
|
||||
"url": "https://www.microsoft.com"
|
||||
@@ -523,7 +551,7 @@
|
||||
"source": "github",
|
||||
"repo": "microsoft/foundry-toolkit",
|
||||
"path": "microsoft-foundry",
|
||||
"sha": "478668e6c62035d702ad3405361d4d229f31ca03"
|
||||
"sha": "b4ffffc810130893117a7b8f797e0a33102e66de"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -681,6 +709,30 @@
|
||||
"ref": "2.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timebase",
|
||||
"description": "Connect your AI agent to TimeBase, a high-performance database for financial market and other time-series data, via MCP tools and skills for QQL query and TimeBase client code generation.",
|
||||
"version": "0.1.2",
|
||||
"author": {
|
||||
"name": "EPAM Systems",
|
||||
"url": "https://www.epam.com/"
|
||||
},
|
||||
"repository": "https://github.com/epam/TimeBase-Agent-Plugins",
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
"timebase",
|
||||
"time-series",
|
||||
"db",
|
||||
"qql",
|
||||
"financial-data"
|
||||
],
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "epam/TimeBase-Agent-Plugins",
|
||||
"ref": "v0.1.2",
|
||||
"sha": "846b341a17becbf4d0cd43374588fc6ab1930d0b"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ui-theme-designer",
|
||||
"description": "Equips AI coding agents with knowledge and tooling for working with UI theme designer, the SAP Design System, and SAP Fiori design tokens.",
|
||||
|
||||
Reference in New Issue
Block a user