diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 59723d1b..b5d9b9b2 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -97,6 +97,10 @@ function formatDisplayName(value) { .join(" "); } +function normalizeText(value, fallback = "") { + return typeof value === "string" ? value.trim() : fallback; +} + /** * Find the latest git-modified date for any file under a directory. */ @@ -670,6 +674,76 @@ function generatePluginsData(gitDates) { /** * Generate canvas extensions metadata */ +function getExtensionAssetInfo(extensionDir, relPath, ref) { + const assetDir = path.join(extensionDir, "assets"); + + if (!fs.existsSync(assetDir)) { + return null; + } + + const imageExtensions = new Set([ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".gif", + ]); + + const preferredNames = [ + "preview.png", + "preview.jpg", + "preview.jpeg", + "preview.webp", + "preview.gif", + "screenshot.png", + "screenshot.jpg", + "screenshot.jpeg", + "screenshot.webp", + "screenshot.gif", + "image.png", + "image.jpg", + "image.jpeg", + "image.webp", + "image.gif", + ]; + + for (const candidate of preferredNames) { + const candidatePath = path.join(assetDir, candidate); + if (fs.existsSync(candidatePath)) { + const assetPath = `${relPath}/assets/${candidate}`; + return { + assetPath, + imageUrl: buildRepoImageUrl(assetPath, ref), + }; + } + } + + const files = fs + .readdirSync(assetDir) + .filter((file) => imageExtensions.has(path.extname(file).toLowerCase())) + .sort((a, b) => a.localeCompare(b)); + + if (files.length === 0) { + return null; + } + + const assetFile = files[0]; + const assetPath = `${relPath}/assets/${assetFile}`; + + return { + assetPath, + imageUrl: buildRepoImageUrl(assetPath, ref), + }; +} + +function buildRepoImageUrl(assetPath, ref) { + const encodedAssetPath = assetPath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `https://raw.githubusercontent.com/github/awesome-copilot/${ref}/${encodedAssetPath}`; +} + function generateExtensionsData(gitDates, commitSha) { const extensions = []; @@ -679,19 +753,87 @@ function generateExtensionsData(gitDates, commitSha) { const extensionDirs = fs .readdirSync(EXTENSIONS_DIR, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()); + .filter((entry) => { + if (!entry.isDirectory()) return false; + const extensionEntryPoint = path.join( + EXTENSIONS_DIR, + entry.name, + "extension.mjs" + ); + return fs.existsSync(extensionEntryPoint); + }); for (const dir of extensionDirs) { const relPath = `extensions/${dir.name}`; + const assetInfo = getExtensionAssetInfo( + path.join(EXTENSIONS_DIR, dir.name), + relPath, + commitSha + ); + extensions.push({ id: dir.name, name: formatDisplayName(dir.name), + description: "Canvas extension", path: relPath, ref: commitSha, lastUpdated: getDirectoryLastUpdated(gitDates, relPath), + imageUrl: assetInfo?.imageUrl || null, + assetPath: assetInfo?.assetPath || null, + installUrl: `https://github.com/github/awesome-copilot/tree/${commitSha}/${relPath.replace( + /\\/g, + "/" + )}`, + sourceUrl: null, + external: false, }); } + const externalJsonPath = path.join(EXTENSIONS_DIR, "external.json"); + if (fs.existsSync(externalJsonPath)) { + try { + const externalExtensions = JSON.parse( + fs.readFileSync(externalJsonPath, "utf-8") + ); + if (Array.isArray(externalExtensions)) { + for (const ext of externalExtensions) { + const name = normalizeText(ext?.name); + const installUrl = normalizeText(ext?.installUrl); + const sourceUrl = normalizeText(ext?.sourceUrl || installUrl); + if (!name || !installUrl) { + continue; + } + + const id = normalizeText(ext?.id || name.toLowerCase().replace(/\s+/g, "-")); + let imageUrl = normalizeText(ext?.imageUrl); + let assetPath = null; + const imagePath = normalizeText(ext?.imagePath); + if (!imageUrl && imagePath) { + const repoAssetPath = imagePath.replace(/\\/g, "/"); + imageUrl = buildRepoImageUrl(repoAssetPath, commitSha); + assetPath = repoAssetPath; + } + + extensions.push({ + id, + name, + description: normalizeText(ext?.description, "External canvas extension"), + path: null, + ref: null, + lastUpdated: null, + imageUrl: imageUrl || null, + assetPath, + installUrl, + sourceUrl: sourceUrl || null, + external: true, + }); + } + } + } catch (e) { + console.warn(`Failed to parse external extensions: ${e.message}`); + } + } + const sortedExtensions = extensions.sort((a, b) => a.name.localeCompare(b.name) ); diff --git a/extensions/accessibility-kanban/assets/preview.png b/extensions/accessibility-kanban/assets/preview.png new file mode 100644 index 00000000..3a2e8ae3 Binary files /dev/null and b/extensions/accessibility-kanban/assets/preview.png differ diff --git a/extensions/backlog-swipe-triage/README.md b/extensions/backlog-swipe-triage/README.md new file mode 100644 index 00000000..291e6ea9 --- /dev/null +++ b/extensions/backlog-swipe-triage/README.md @@ -0,0 +1,8 @@ +# Backlog Swipe Triage + +Swipe-driven backlog triage canvas for reviewing open issues, applying quick decisions, and starting implementation sessions. + +## Assets + +- `assets/preview.png` — preferred screenshot path for the triage experience. +- `assets/swipe-canvas-triage.png` — existing reference screenshot kept for compatibility. diff --git a/extensions/backlog-swipe-triage/assets/preview.png b/extensions/backlog-swipe-triage/assets/preview.png new file mode 100644 index 00000000..ee411be0 Binary files /dev/null and b/extensions/backlog-swipe-triage/assets/preview.png differ diff --git a/extensions/backlog-swipe-triage/extension.mjs b/extensions/backlog-swipe-triage/extension.mjs new file mode 100644 index 00000000..f60fb07f --- /dev/null +++ b/extensions/backlog-swipe-triage/extension.mjs @@ -0,0 +1,2169 @@ +import { createServer } from "node:http"; +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const servers = new Map(); +const extensionDir = fileURLToPath(new URL(".", import.meta.url)); +const artifactsDir = path.join(extensionDir, "artifacts"); +const stateFile = path.join(artifactsDir, "backlog-triage-state.json"); +const decisions = ["assign_agent", "needs_info", "not_now", "close", "ignore"]; +const execFileAsync = promisify(execFile); +const MAX_SYNC_ISSUES = 200; +const defaultFilters = { + timeWindow: "any", + labels: [], + assignees: [], + query: "", + sortBy: "updated-desc", +}; +const filterSchema = { + type: "object", + properties: { + timeWindow: { type: "string", enum: ["any", "1d", "3d", "7d", "14d", "30d", "90d"] }, + labels: { type: "array", items: { type: "string" } }, + assignees: { type: "array", items: { type: "string" } }, + query: { type: "string" }, + sortBy: { type: "string", enum: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"] }, + }, + additionalProperties: false, +}; +let activeSession = null; +const MAX_REQUEST_BODY_BYTES = 1024 * 1024; + +let storage = { boards: {} }; +let storageLoaded = false; +let persistStorageQueue = Promise.resolve(); + +async function ensureStorageLoaded() { + if (storageLoaded) { + return; + } + await fs.mkdir(artifactsDir, { recursive: true }); + try { + const raw = await fs.readFile(stateFile, "utf8"); + storage = JSON.parse(raw); + } catch (error) { + if (error && error.code !== "ENOENT") { + throw error; + } + storage = { boards: {} }; + } + storageLoaded = true; +} + +async function persistStorage() { + await fs.mkdir(artifactsDir, { recursive: true }); + const snapshot = JSON.stringify(storage, null, 2); + persistStorageQueue = persistStorageQueue + .catch(() => undefined) + .then(async () => { + const tempStateFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`; + await fs.writeFile(tempStateFile, snapshot, "utf8"); + await fs.rename(tempStateFile, stateFile); + }); + await persistStorageQueue; +} + +function normalizeText(value, fallback = "") { + return typeof value === "string" ? value.trim() : fallback; +} + +function escapeHtml(value) { + return normalizeText(value).replace(/[&<>"']/g, (char) => { + if (char === "&") return "&"; + if (char === "<") return "<"; + if (char === ">") return ">"; + if (char === '"') return """; + return "'"; + }); +} + +function normalizeStringArray(values) { + if (!Array.isArray(values)) { + return []; + } + return values.map((value) => normalizeText(value)).filter(Boolean); +} + +function normalizeFilters(raw, fallback = defaultFilters) { + const merged = raw && typeof raw === "object" ? { ...fallback, ...raw } : { ...fallback }; + const legacyAssignee = normalizeText(merged.assignee); + return { + timeWindow: ["any", "1d", "3d", "7d", "14d", "30d", "90d"].includes(merged.timeWindow) ? merged.timeWindow : "any", + labels: normalizeStringArray(merged.labels), + assignees: legacyAssignee ? [legacyAssignee] : normalizeStringArray(merged.assignees), + query: normalizeText(merged.query).toLowerCase(), + sortBy: ["updated-desc", "updated-asc", "created-desc", "created-asc", "title-asc", "random"].includes(merged.sortBy) + ? merged.sortBy + : "updated-desc", + }; +} + +function parseDateToMs(value) { + const timestamp = Date.parse(value || ""); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function getTimeWindowMs(timeWindow) { + if (timeWindow === "1d") return 1 * 24 * 60 * 60 * 1000; + if (timeWindow === "3d") return 3 * 24 * 60 * 60 * 1000; + if (timeWindow === "7d") return 7 * 24 * 60 * 60 * 1000; + if (timeWindow === "14d") return 14 * 24 * 60 * 60 * 1000; + if (timeWindow === "30d") return 30 * 24 * 60 * 60 * 1000; + if (timeWindow === "90d") return 90 * 24 * 60 * 60 * 1000; + return 0; +} + +function getIssueLabels(issue) { + return Array.isArray(issue?.labels) ? issue.labels.map((label) => normalizeText(label?.name).toLowerCase()).filter(Boolean) : []; +} + +function getIssueAssignees(issue) { + return Array.isArray(issue?.assignees) + ? issue.assignees.map((assignee) => normalizeText(assignee?.login).toLowerCase()).filter(Boolean) + : []; +} + +function issueMatchesFilters(issue, filters) { + const now = Date.now(); + const cutoffWindow = getTimeWindowMs(filters.timeWindow); + if (cutoffWindow > 0) { + const updatedAtMs = parseDateToMs(issue.updatedAt); + if (!updatedAtMs || now - updatedAtMs > cutoffWindow) { + return false; + } + } + + const issueLabels = getIssueLabels(issue); + const requiredLabels = filters.labels.map((label) => label.toLowerCase()); + if (requiredLabels.length > 0) { + if (!requiredLabels.some((label) => issueLabels.includes(label))) { + return false; + } + } + + const assigneeFilters = normalizeStringArray(filters.assignees).map((assignee) => assignee.toLowerCase()); + if (assigneeFilters.length > 0) { + const assignees = getIssueAssignees(issue); + const isUnassignedMatch = assigneeFilters.includes("unassigned") && assignees.length === 0; + const hasNamedMatch = assigneeFilters.some((wanted) => wanted !== "unassigned" && assignees.includes(wanted)); + if (!isUnassignedMatch && !hasNamedMatch) { + return false; + } + } + + if (filters.query) { + const haystack = `${normalizeText(issue.title)} ${normalizeText(issue.body || "")}`.toLowerCase(); + if (!haystack.includes(filters.query)) { + return false; + } + } + + return true; +} + +function sortIssues(issues, sortBy) { + const sorted = [...issues]; + if (sortBy === "random") { + for (let i = sorted.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [sorted[i], sorted[j]] = [sorted[j], sorted[i]]; + } + return sorted; + } + sorted.sort((left, right) => { + if (sortBy === "created-asc") { + return parseDateToMs(left.createdAt) - parseDateToMs(right.createdAt); + } + if (sortBy === "created-desc") { + return parseDateToMs(right.createdAt) - parseDateToMs(left.createdAt); + } + if (sortBy === "updated-asc") { + return parseDateToMs(left.updatedAt) - parseDateToMs(right.updatedAt); + } + if (sortBy === "title-asc") { + return normalizeText(left.title).localeCompare(normalizeText(right.title)); + } + return parseDateToMs(right.updatedAt) - parseDateToMs(left.updatedAt); + }); + return sorted; +} + +function normalizeItem(raw, index) { + const idFromInput = normalizeText(raw?.id); + const title = normalizeText(raw?.title, `Item ${index + 1}`); + const id = idFromInput || title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") || `item-${index + 1}`; + return { + id, + title, + description: normalizeText(raw?.description), + details: normalizeText(raw?.details), + repo: normalizeText(raw?.repo), + number: normalizeText(raw?.number), + url: normalizeText(raw?.url), + labels: normalizeStringArray(raw?.labels), + assignees: normalizeStringArray(raw?.assignees), + createdAt: normalizeText(raw?.createdAt), + updatedAt: normalizeText(raw?.updatedAt), + author: normalizeText(raw?.author), + }; +} + +function getOrCreateBoard(boardId) { + if (!storage.boards[boardId]) { + storage.boards[boardId] = { + id: boardId, + title: "Backlog Triage", + items: [], + decisions: {}, + workStatus: {}, + filters: { ...defaultFilters }, + updatedAt: new Date().toISOString(), + }; + } + if (!storage.boards[boardId].workStatus || typeof storage.boards[boardId].workStatus !== "object") { + storage.boards[boardId].workStatus = {}; + } + return storage.boards[boardId]; +} + +function setBoardItems(board, items, replace = true) { + const normalized = Array.isArray(items) ? items.map((item, index) => normalizeItem(item, index)) : []; + const repoFromItems = normalized.find((item) => normalizeText(item.repo)); + if (repoFromItems) { + board.repo = repoFromItems.repo; + } + if (replace) { + board.items = normalized; + } else { + const existingById = new Map(board.items.map((item) => [item.id, item])); + for (const item of normalized) { + existingById.set(item.id, item); + } + board.items = [...existingById.values()]; + } + board.updatedAt = new Date().toISOString(); +} + +function applyBoardDecision(board, itemId, decision, extra = {}) { + if (!decisions.includes(decision)) { + throw new Error(`Unsupported decision "${decision}"`); + } + const item = board.items.find((candidate) => candidate.id === itemId); + if (!item) { + throw new Error(`Item "${itemId}" not found on board "${board.id}"`); + } + board.decisions[itemId] = { + decision, + agent: normalizeText(extra.agent), + note: normalizeText(extra.note), + at: new Date().toISOString(), + }; + board.updatedAt = new Date().toISOString(); +} + +function resetBoardDecisions(board) { + board.decisions = {}; + board.updatedAt = new Date().toISOString(); +} + +function buildItemWorkStatus(board, item) { + const statuses = []; + const assignees = normalizeStringArray(item?.assignees); + if (assignees.length > 0) { + statuses.push({ label: `Assigned: ${assignees.join(", ")}` }); + } + const decision = board.decisions?.[item.id]; + const triageAgent = normalizeText(decision?.decision === "assign_agent" ? decision?.agent : ""); + if (assignees.length === 0 && triageAgent) { + statuses.push({ label: `Assigned in triage: ${triageAgent}` }); + } + const work = board.workStatus?.[item.id]; + if (work?.sessionState === "active") { + const sessionName = normalizeText(work.sessionName); + statuses.push({ label: sessionName ? `Session active: ${sessionName}` : "Session active" }); + } else if (work?.sessionState === "starting") { + statuses.push({ label: "Session starting" }); + } else if (work?.sessionState === "requested") { + const sessionName = normalizeText(work.sessionName); + statuses.push({ label: sessionName ? `Session requested: ${sessionName}` : "Session requested" }); + } + return statuses; +} + +function buildBoardState(board) { + const allLabels = [...new Set(board.items.flatMap((item) => (Array.isArray(item.labels) ? item.labels : [])))].sort((a, b) => + a.localeCompare(b), + ); + const hasUnassigned = board.items.some((item) => !Array.isArray(item.assignees) || item.assignees.length === 0); + const allAssignees = [ + ...new Set(board.items.flatMap((item) => (Array.isArray(item.assignees) ? item.assignees : []))), + ].sort((a, b) => a.localeCompare(b)); + if (hasUnassigned) { + allAssignees.unshift("unassigned"); + } + const pending = []; + const resolved = []; + for (const item of board.items) { + const itemWithStatus = { ...item, workStatus: buildItemWorkStatus(board, item) }; + const result = board.decisions[item.id]; + if (result) { + resolved.push({ ...itemWithStatus, result }); + } else { + pending.push(itemWithStatus); + } + } + return { + boardId: board.id, + title: board.title, + repo: normalizeText(board.repo), + syncedAt: normalizeText(board.syncedAt), + filters: normalizeFilters(board.filters, defaultFilters), + availableLabels: allLabels, + availableAssignees: allAssignees, + pending, + resolved, + decisionCounts: resolved.reduce((counts, item) => { + const key = item.result.decision; + counts[key] = (counts[key] || 0) + 1; + return counts; + }, {}), + updatedAt: board.updatedAt, + }; +} + +function buildIssueDetails(issue) { + const parts = []; + const author = normalizeText(issue.author?.login); + if (author) { + parts.push(`Author: ${author}`); + } + if (normalizeText(issue.createdAt)) { + parts.push(`Created: ${normalizeText(issue.createdAt).slice(0, 10)}`); + } + if (normalizeText(issue.updatedAt)) { + parts.push(`Updated: ${normalizeText(issue.updatedAt).slice(0, 10)}`); + } + return parts.join(" | "); +} + +function buildIssueDescription(issue) { + const body = normalizeText(issue.body); + if (!body) { + return ""; + } + const normalized = body + .replace(/\r/g, "") + .replace(/!\[.*?\]\(.*?\)/g, "") + .replace(/\n{2,}/g, "\n\n") + .trim(); + if (normalized.length <= 2200) { + return normalized; + } + return `${normalized.slice(0, 2197).trimEnd()}...`; +} + +async function runGhJson(args, cwd) { + const result = await execFileAsync("gh", args, { + cwd, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }); + return JSON.parse(result.stdout); +} + +async function runGh(args, cwd) { + const result = await execFileAsync("gh", args, { + cwd, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }); + return result.stdout; +} + +async function closeGithubIssue(board, item, note) { + const issueNumber = normalizeText(item?.number); + const repo = normalizeText(board?.repo || item?.repo); + if (!issueNumber || !repo) { + throw new Error("Cannot close issue on GitHub because repo or issue number is missing."); + } + const args = ["issue", "close", issueNumber, "--repo", repo]; + const comment = normalizeText(note); + if (comment) { + args.push("--comment", comment); + } + try { + await runGh(args, activeSession?.workspacePath || process.cwd()); + } catch (error) { + const stderr = normalizeText(error?.stderr || ""); + if (stderr.toLowerCase().includes("already closed")) { + return; + } + throw new Error(stderr || `Failed to close issue #${issueNumber} in ${repo}.`); + } +} + +async function commentGithubIssue(board, item, note) { + const repo = normalizeText(board?.repo || item?.repo); + const issueNumber = extractIssueNumber(item); + const comment = normalizeText(note); + if (!repo || !issueNumber) { + throw new Error("Cannot comment on issue because repo or issue number is missing."); + } + if (!comment) { + return; + } + try { + await runGh(["issue", "comment", issueNumber, "--repo", repo, "--body", comment], activeSession?.workspacePath || process.cwd()); + } catch (error) { + const stderr = normalizeText(error?.stderr || ""); + throw new Error(stderr || `Failed to comment on issue #${issueNumber} in ${repo}.`); + } +} + +function extractIssueNumber(item) { + const explicit = normalizeText(item?.number); + if (/^\d+$/.test(explicit)) { + return explicit; + } + const idMatch = normalizeText(item?.id).match(/^issue-(\d+)$/i); + if (idMatch) { + return idMatch[1]; + } + const titleMatch = normalizeText(item?.title).match(/^#(\d+)\b/); + if (titleMatch) { + return titleMatch[1]; + } + return ""; +} + +async function startImplementationSession(board, item, agent, note) { + if (!activeSession) { + throw new Error("Copilot session is unavailable for starting implementation sessions."); + } + const repo = normalizeText(board?.repo || item?.repo); + const issueNumber = extractIssueNumber(item); + if (!repo || !issueNumber) { + throw new Error("Cannot start implementation session because repo or issue number is missing."); + } + const rawTitle = normalizeText(item?.title); + const issueTitle = rawTitle.replace(new RegExp(`^#${issueNumber}\\s*`), "").trim() || rawTitle || `Issue #${issueNumber}`; + const summary = normalizeText(item?.description); + const kickoffLines = [ + `Implement GitHub issue #${issueNumber}: ${issueTitle}`, + `Repository: ${repo}`, + ]; + if (summary) { + kickoffLines.push(`Context: ${summary}`); + } + if (normalizeText(note)) { + kickoffLines.push(`Triage note: ${normalizeText(note)}`); + } + kickoffLines.push( + "Deliver a complete fix with code changes, run relevant validation, and open a PR-ready branch state with a concise summary.", + ); + const kickoffPrompt = kickoffLines.join("\n"); + const sessionRequest = [ + `Create a new implementation project session for GitHub issue #${issueNumber} in ${repo}.`, + "Use the open_issue_session tool with these exact fields:", + `- repo_full_name: ${JSON.stringify(repo)}`, + `- issue_number: ${Number(issueNumber)}`, + `- issue_title: ${JSON.stringify(issueTitle)}`, + '- kickoff_mode: "autopilot"', + '- coordinate_with_creator: true', + '- notify_on_idle: "once"', + `- kickoff_prompt: ${JSON.stringify(kickoffPrompt)}`, + "", + "After the tool call succeeds, reply with a one-line confirmation including the new session name.", + ].join("\n"); + await activeSession.send({ + prompt: sessionRequest, + mode: "immediate", + displayPrompt: `Start implementation session for #${issueNumber}`, + }); + return { + sessionState: "requested", + sessionName: `Issue #${issueNumber}`, + issueNumber, + agent: normalizeText(agent), + requestedAt: new Date().toISOString(), + }; +} + +function pruneDecisionsForCurrentItems(board) { + const currentIds = new Set(board.items.map((item) => item.id)); + for (const itemId of Object.keys(board.decisions)) { + if (!currentIds.has(itemId)) { + delete board.decisions[itemId]; + } + } + if (board.workStatus && typeof board.workStatus === "object") { + for (const itemId of Object.keys(board.workStatus)) { + if (!currentIds.has(itemId)) { + delete board.workStatus[itemId]; + } + } + } +} + +async function syncBoardFromRepo(board, filtersInput) { + const workspacePath = activeSession?.workspacePath; + let repo = normalizeText(board.repo); + if (!repo && workspacePath) { + const repoData = await runGhJson(["repo", "view", "--json", "nameWithOwner"], workspacePath); + repo = normalizeText(repoData?.nameWithOwner); + } + if (!repo) { + throw new Error("Repository is not configured. Open the canvas with a repo or call sync_from_repo with { repo: \"owner/name\" }."); + } + const filters = normalizeFilters(filtersInput, board.filters || defaultFilters); + + const issues = await runGhJson( + [ + "issue", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + String(MAX_SYNC_ISSUES), + "--json", + "number,title,url,labels,assignees,createdAt,updatedAt,author,body", + ], + workspacePath || process.cwd(), + ); + + const filteredIssues = Array.isArray(issues) ? sortIssues(issues.filter((issue) => issueMatchesFilters(issue, filters)), filters.sortBy) : []; + const items = filteredIssues.map((issue) => ({ + id: `issue-${issue.number}`, + title: `#${issue.number} ${normalizeText(issue.title, "Untitled issue")}`, + description: buildIssueDescription(issue), + details: buildIssueDetails(issue), + repo, + number: String(issue.number), + url: normalizeText(issue.url), + labels: Array.isArray(issue.labels) ? issue.labels.map((label) => normalizeText(label?.name)).filter(Boolean) : [], + assignees: Array.isArray(issue.assignees) ? issue.assignees.map((assignee) => normalizeText(assignee?.login)).filter(Boolean) : [], + createdAt: normalizeText(issue.createdAt), + updatedAt: normalizeText(issue.updatedAt), + author: normalizeText(issue.author?.login), + })); + + setBoardItems(board, items, true); + pruneDecisionsForCurrentItems(board); + board.source = "repo"; + board.repo = repo; + board.filters = filters; + board.syncedAt = new Date().toISOString(); +} + +function renderHtml(instanceId, title) { + const safeTitle = escapeHtml(title || "Backlog Swipe Triage"); + const safeInstanceId = escapeHtml(instanceId || "default"); + return ` + + + + + ${safeTitle} + + + +
+
+
+

${safeTitle}

+
+ Instance: ${safeInstanceId} + Loading board… +
+
+
+
+
+
+ + +
+
+ +
+ All labels +
+
+
+
+ +
+ All assignees +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+

+
Issue
+
+
+
+
+
+
+
+
+
+
+
+
+
Done
+
+
+
+
+
+ + Applying action… +
+
+
+
Swipe-up quick responses
+ + + + + + + + + +
+
+
+ + + + + +
+
+
+ Decision summary +
+
+
+ Swipe mappings: left=close, right=assign agent, + up=more options, down=ignore. Arrow keys work too. +
+
+ + +`; +} + +function readJson(req, maxBytes = MAX_REQUEST_BODY_BYTES) { + return new Promise((resolve, reject) => { + const chunks = []; + let totalBytes = 0; + let settled = false; + req.on("data", (chunk) => { + if (settled) { + return; + } + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + settled = true; + const error = new Error(`Request body exceeds ${maxBytes} bytes.`); + error.statusCode = 413; + req.destroy(error); + reject(error); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (settled) { + return; + } + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (error) { + error.statusCode = 400; + reject(error); + } + }); + req.on("error", (error) => { + if (settled) { + return; + } + settled = true; + reject(error); + }); + }); +} + +async function handleServerRequest(instanceId, req, res) { + const entry = servers.get(instanceId); + if (!entry) { + res.statusCode = 404; + res.end("Instance not found"); + return; + } + + await ensureStorageLoaded(); + const board = getOrCreateBoard(entry.boardId); + board.title = entry.title; + + if (req.method === "GET" && req.url === "/") { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(renderHtml(instanceId, board.title)); + return; + } + + if (req.method === "GET" && req.url === "/state") { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(buildBoardState(board))); + return; + } + + if (req.method === "POST" && req.url === "/sync") { + try { + const payload = await readJson(req); + const repo = normalizeText(payload?.repo); + if (repo) { + board.repo = repo; + } + if (payload?.resetDecisions === true) { + resetBoardDecisions(board); + } + await syncBoardFromRepo(board, payload?.filters); + await persistStorage(); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(buildBoardState(board))); + } catch (error) { + res.statusCode = error?.statusCode || 500; + res.end(error instanceof Error ? error.message : "Failed to sync from repo"); + } + return; + } + + if (req.method === "POST" && req.url === "/decision") { + let payload; + try { + payload = await readJson(req); + } catch (error) { + res.statusCode = error?.statusCode || 400; + res.end( + error?.statusCode === 413 + ? "Request body too large" + : "Invalid JSON payload", + ); + return; + } + + const itemId = normalizeText(payload?.itemId); + const decision = normalizeText(payload?.decision); + const item = board.items.find((candidate) => candidate.id === itemId); + if (!itemId || !decision) { + res.statusCode = 400; + res.end("itemId and decision are required"); + return; + } + if (!item) { + res.statusCode = 404; + res.end(`Item "${itemId}" not found`); + return; + } + if (decision === "close") { + await closeGithubIssue(board, item, payload?.note); + } + if (payload?.quickResponse === true && decision !== "close" && normalizeText(payload?.note)) { + await commentGithubIssue(board, item, payload?.note); + } + if (decision === "assign_agent") { + const sessionStatus = await startImplementationSession(board, item, payload?.agent, payload?.note); + board.workStatus[itemId] = { + ...sessionStatus, + agent: normalizeText(payload?.agent), + }; + } + + applyBoardDecision(board, itemId, decision, { + agent: payload?.agent, + note: payload?.note, + }); + await persistStorage(); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(buildBoardState(board))); + return; + } + + res.statusCode = 404; + res.end("Not found"); +} + +async function startServer(instanceId) { + const server = createServer((req, res) => { + handleServerRequest(instanceId, req, res).catch((error) => { + if (res.headersSent) { + res.end(); + return; + } + res.statusCode = error?.statusCode || 500; + res.end(error instanceof Error ? error.message : "Internal server error"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "backlog-swipe-triage", + displayName: "Backlog Swipe Triage", + description: "Tinder-style backlog triage with swipe directions for assign, needs info, not now, close, and ignore.", + inputSchema: { + type: "object", + properties: { + boardId: { type: "string", minLength: 1 }, + title: { type: "string", minLength: 1 }, + syncFromRepo: { type: "boolean" }, + repo: { type: "string", minLength: 1 }, + filters: filterSchema, + items: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + details: { type: "string" }, + repo: { type: "string" }, + number: { type: "string" }, + url: { type: "string" }, + }, + required: ["title"], + additionalProperties: true, + }, + }, + }, + additionalProperties: false, + }, + actions: [ + { + name: "sync_from_repo", + description: "Load open issues from the current repository into the triage board.", + inputSchema: { + type: "object", + properties: { + boardId: { type: "string", minLength: 1 }, + title: { type: "string" }, + repo: { type: "string", minLength: 1 }, + filters: filterSchema, + }, + required: ["boardId"], + additionalProperties: false, + }, + handler: async (ctx) => { + await ensureStorageLoaded(); + const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); + const title = normalizeText(ctx.input?.title); + if (title) { + board.title = title; + } + const repo = normalizeText(ctx.input?.repo); + if (repo) { + board.repo = repo; + } + await syncBoardFromRepo(board, ctx.input?.filters); + await persistStorage(); + return buildBoardState(board); + }, + }, + { + name: "seed_backlog", + description: "Seed or update backlog items for a triage board.", + inputSchema: { + type: "object", + properties: { + boardId: { type: "string", minLength: 1 }, + title: { type: "string" }, + replace: { type: "boolean" }, + items: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + details: { type: "string" }, + repo: { type: "string" }, + number: { type: "string" }, + url: { type: "string" }, + }, + required: ["title"], + additionalProperties: true, + }, + }, + }, + required: ["boardId", "items"], + additionalProperties: false, + }, + handler: async (ctx) => { + await ensureStorageLoaded(); + const boardId = normalizeText(ctx.input?.boardId, "default"); + const board = getOrCreateBoard(boardId); + const title = normalizeText(ctx.input?.title); + if (title) { + board.title = title; + } + setBoardItems(board, ctx.input?.items, ctx.input?.replace !== false); + await persistStorage(); + return buildBoardState(board); + }, + }, + { + name: "apply_decision", + description: "Apply a triage decision to a backlog item.", + inputSchema: { + type: "object", + properties: { + boardId: { type: "string", minLength: 1 }, + itemId: { type: "string", minLength: 1 }, + decision: { type: "string", enum: decisions }, + agent: { type: "string" }, + note: { type: "string" }, + commentOnIssue: { type: "boolean" }, + }, + required: ["boardId", "itemId", "decision"], + additionalProperties: false, + }, + handler: async (ctx) => { + await ensureStorageLoaded(); + const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); + const itemId = normalizeText(ctx.input?.itemId); + const item = board.items.find((candidate) => candidate.id === itemId); + const decision = normalizeText(ctx.input?.decision); + if (!item) { + throw new Error(`Item "${itemId}" not found`); + } + if (decision === "close") { + await closeGithubIssue(board, item, ctx.input?.note); + } + if (ctx.input?.commentOnIssue === true && decision !== "close" && normalizeText(ctx.input?.note)) { + await commentGithubIssue(board, item, ctx.input?.note); + } + if (decision === "assign_agent") { + const sessionStatus = await startImplementationSession(board, item, ctx.input?.agent, ctx.input?.note); + board.workStatus[itemId] = { + ...sessionStatus, + agent: normalizeText(ctx.input?.agent), + }; + } + applyBoardDecision(board, itemId, decision, { + agent: ctx.input?.agent, + note: ctx.input?.note, + }); + await persistStorage(); + return buildBoardState(board); + }, + }, + { + name: "get_board", + description: "Get pending and triaged items for a triage board.", + inputSchema: { + type: "object", + properties: { + boardId: { type: "string", minLength: 1 }, + }, + required: ["boardId"], + additionalProperties: false, + }, + handler: async (ctx) => { + await ensureStorageLoaded(); + const board = getOrCreateBoard(normalizeText(ctx.input?.boardId, "default")); + return buildBoardState(board); + }, + }, + ], + open: async (ctx) => { + await ensureStorageLoaded(); + const boardId = normalizeText(ctx.input?.boardId, "default"); + const board = getOrCreateBoard(boardId); + const title = normalizeText(ctx.input?.title, board.title || "Backlog Triage"); + board.title = title; + const repo = normalizeText(ctx.input?.repo); + if (repo) { + board.repo = repo; + } + if (ctx.input?.filters && typeof ctx.input.filters === "object") { + board.filters = normalizeFilters(ctx.input.filters, board.filters || defaultFilters); + } else if (!board.filters) { + board.filters = { ...defaultFilters }; + } + const syncFromRepo = ctx.input?.syncFromRepo !== false; + if (Array.isArray(ctx.input?.items) && ctx.input.items.length > 0) { + setBoardItems(board, ctx.input.items, true); + await persistStorage(); + } else if (syncFromRepo) { + await syncBoardFromRepo(board, board.filters); + await persistStorage(); + } + + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(ctx.instanceId); + servers.set(ctx.instanceId, entry); + } + entry.boardId = boardId; + entry.title = title; + return { + title, + status: "Swipe to triage backlog", + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); +activeSession = session; diff --git a/extensions/backlog-swipe-triage/package-lock.json b/extensions/backlog-swipe-triage/package-lock.json new file mode 100644 index 00000000..38325be1 --- /dev/null +++ b/extensions/backlog-swipe-triage/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "backlog-swipe-triage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backlog-swipe-triage", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.61.tgz", + "integrity": "sha512-E4f7YXTL2uUZY/ypnfsUruAeSgrHx3AGYEbm5N0DrpzPqoNAZqV6kHEWM4vu+W/nGvydIfPxmOTqaMEhM8r0Uw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.61", + "@github/copilot-darwin-x64": "1.0.61", + "@github/copilot-linux-arm64": "1.0.61", + "@github/copilot-linux-x64": "1.0.61", + "@github/copilot-linuxmusl-arm64": "1.0.61", + "@github/copilot-linuxmusl-x64": "1.0.61", + "@github/copilot-win32-arm64": "1.0.61", + "@github/copilot-win32-x64": "1.0.61" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.61.tgz", + "integrity": "sha512-10prvjHRXB0SD28NsIpzdNDgLquQYUwaH5Ev9KVdIWdBPAvlQsHmQ4JSCyD/UILc/nrrr02CKUgum+mZRKUKIg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.61.tgz", + "integrity": "sha512-NXUjageJ3mxDfHtXGYu//XhJ+dhJFYObT4R3jeWgIHhd+4lX7FlC754nwlBP/ZuVhJ3ND22JK9sua9d2F3Cbwg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.61.tgz", + "integrity": "sha512-dwB2+QSMr622JkePeK56M7YWXsTT/DQzKfpDq8Lk2kmGU052RZAarRmt8gcNm4anofN7pMSrqc3YHj1TM84MFw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.61.tgz", + "integrity": "sha512-q6n8R8oybvuCmmkP+43w809Wpud/wwRi/fFSZEYJagiNGmYJ00SDkrfJxHbZsAFMpaJC+oTswqzJHjRoZbO74w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.61.tgz", + "integrity": "sha512-yWo7JXnZS11eJpm68E1RWKMR47EwzPKj3V7GX0EMTd8Fw0T2Aurk9wt9p3c9w0v02nTO1DqJhi68KVWJPdVqvA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.61.tgz", + "integrity": "sha512-nHzx27Ac4B0fpD9CcmvyrGOBEMJ01CPRgVRP0yAl4wpU4cM2I6+9TPyfYThlWDqZqiUKGXC1ZRQ+B8cJREVGmA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", + "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.61", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.61.tgz", + "integrity": "sha512-k6knzI+K5HlZeJDS/yeJAfoYD4xcURWfuqunpTCyk1pDbIFxmrLSqR/TDi7KNlpsf883n5WqpnB06K5kysdHHQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.61", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.61.tgz", + "integrity": "sha512-L6NZ6o73VZFHd7OoRaztV3Prh1PbW9HXqYsAx+XywNALQvE1u489WBUC1ggfYBW5MTBCf8mxSkYQdb3Am2omsw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/backlog-swipe-triage/package.json b/extensions/backlog-swipe-triage/package.json new file mode 100644 index 00000000..15a0677a --- /dev/null +++ b/extensions/backlog-swipe-triage/package.json @@ -0,0 +1,10 @@ +{ + "name": "backlog-swipe-triage", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "description": "Swipe-driven backlog triage canvas for reviewing open issues and assigning implementation work.", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + } +} diff --git a/extensions/color-orb/assets/preview.png b/extensions/color-orb/assets/preview.png new file mode 100644 index 00000000..3294126a Binary files /dev/null and b/extensions/color-orb/assets/preview.png differ diff --git a/extensions/diagram-viewer/assets/preview.png b/extensions/diagram-viewer/assets/preview.png new file mode 100644 index 00000000..bf09d1ab Binary files /dev/null and b/extensions/diagram-viewer/assets/preview.png differ diff --git a/extensions/external-assets/coffilot-preview.png b/extensions/external-assets/coffilot-preview.png new file mode 100644 index 00000000..544fec6c Binary files /dev/null and b/extensions/external-assets/coffilot-preview.png differ diff --git a/extensions/external.json b/extensions/external.json new file mode 100644 index 00000000..5ce10ab6 --- /dev/null +++ b/extensions/external.json @@ -0,0 +1,10 @@ +[ + { + "id": "coffilot", + "name": "Coffilot", + "description": "Java-focused Copilot canvas extension from jdubois.", + "installUrl": "https://github.com/jdubois/coffilot", + "sourceUrl": "https://github.com/jdubois/coffilot", + "imagePath": "extensions/external-assets/coffilot-preview.png" + } +] diff --git a/extensions/feedback-themes/assets/preview.png b/extensions/feedback-themes/assets/preview.png new file mode 100644 index 00000000..deeeee87 Binary files /dev/null and b/extensions/feedback-themes/assets/preview.png differ diff --git a/extensions/gesture-review/assets/preview.png b/extensions/gesture-review/assets/preview.png new file mode 100644 index 00000000..61268730 Binary files /dev/null and b/extensions/gesture-review/assets/preview.png differ diff --git a/extensions/release-notes-showcase/README.md b/extensions/release-notes-showcase/README.md new file mode 100644 index 00000000..6192daf5 --- /dev/null +++ b/extensions/release-notes-showcase/README.md @@ -0,0 +1,7 @@ +# Release Notes Showcase + +Interactive canvas for composing, reviewing, and exporting release notes content. + +## Assets + +- `assets/preview.png` — screenshot preview used by the extensions gallery. diff --git a/extensions/release-notes-showcase/assets/preview.png b/extensions/release-notes-showcase/assets/preview.png new file mode 100644 index 00000000..c1818140 Binary files /dev/null and b/extensions/release-notes-showcase/assets/preview.png differ diff --git a/extensions/release-notes-showcase/extension.mjs b/extensions/release-notes-showcase/extension.mjs new file mode 100644 index 00000000..26306a7d --- /dev/null +++ b/extensions/release-notes-showcase/extension.mjs @@ -0,0 +1,7 @@ +import { joinSession } from "@github/copilot-sdk/extension"; + +import { releaseNotesShowcaseCanvas } from "./releaseNotesShowcase.mjs"; + +await joinSession({ + canvases: [releaseNotesShowcaseCanvas], +}); diff --git a/extensions/release-notes-showcase/package-lock.json b/extensions/release-notes-showcase/package-lock.json new file mode 100644 index 00000000..09f84a79 --- /dev/null +++ b/extensions/release-notes-showcase/package-lock.json @@ -0,0 +1,286 @@ +{ + "name": "release-notes-showcase", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "release-notes-showcase", + "version": "1.0.0", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz", + "integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "detect-libc": "^2.1.2", + "os-theme": "^0.0.8" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.63", + "@github/copilot-darwin-x64": "1.0.63", + "@github/copilot-linux-arm64": "1.0.63", + "@github/copilot-linux-x64": "1.0.63", + "@github/copilot-linuxmusl-arm64": "1.0.63", + "@github/copilot-linuxmusl-x64": "1.0.63", + "@github/copilot-win32-arm64": "1.0.63", + "@github/copilot-win32-x64": "1.0.63" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz", + "integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz", + "integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz", + "integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz", + "integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz", + "integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz", + "integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz", + "integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==", + "license": "MIT", + "dependencies": { + "@github/copilot": "^1.0.61", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz", + "integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.63", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz", + "integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@os-theme/darwin-arm64": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz", + "integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@os-theme/linux-x64": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz", + "integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@os-theme/win32-x64": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz", + "integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/os-theme": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz", + "integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==", + "license": "MIT", + "optionalDependencies": { + "@os-theme/darwin-arm64": "0.0.8", + "@os-theme/linux-x64": "0.0.8", + "@os-theme/win32-x64": "0.0.8" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/release-notes-showcase/package.json b/extensions/release-notes-showcase/package.json new file mode 100644 index 00000000..0bb276cf --- /dev/null +++ b/extensions/release-notes-showcase/package.json @@ -0,0 +1,10 @@ +{ + "name": "release-notes-showcase", + "version": "1.0.0", + "type": "module", + "main": "extension.mjs", + "description": "Release notes canvas for building polished release communications and exports.", + "dependencies": { + "@github/copilot-sdk": "1.0.1" + } +} diff --git a/extensions/release-notes-showcase/releaseNotesShowcase.mjs b/extensions/release-notes-showcase/releaseNotesShowcase.mjs new file mode 100644 index 00000000..d4a7fa28 --- /dev/null +++ b/extensions/release-notes-showcase/releaseNotesShowcase.mjs @@ -0,0 +1,2348 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CanvasError, createCanvas } from "@github/copilot-sdk/extension"; + +const servers = new Map(); + +const CANVAS_ID = "release-notes-showcase"; +const CANVAS_TITLE = "Release Notes Showcase"; + +const releaseNotesInputSchema = { + type: "object", + additionalProperties: false, + properties: { + releaseName: { type: "string" }, + version: { type: "string" }, + releaseDate: { type: "string" }, + tagline: { type: "string" }, + summary: { type: "string" }, + emailSubject: { type: "string" }, + emailPreheader: { type: "string" }, + heroStats: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + label: { type: "string" }, + value: { type: "string" }, + }, + required: ["label", "value"], + }, + }, + sections: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string" }, + kind: { + type: "string", + enum: ["feature", "improvement", "quality"], + }, + summary: { type: "string" }, + metric: { type: "string" }, + bullets: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["title", "summary"], + }, + }, + contributors: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + name: { type: "string" }, + githubHandle: { type: "string" }, + avatarUrl: { type: "string" }, + profileUrl: { type: "string" }, + area: { type: "string" }, + summary: { type: "string" }, + }, + required: ["name"], + }, + }, + communityThanks: { + type: "array", + items: { type: "string" }, + }, + otherChanges: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + label: { type: "string" }, + text: { type: "string" }, + }, + required: ["text"], + }, + }, + callToAction: { + type: "object", + additionalProperties: false, + properties: { + label: { type: "string" }, + url: { type: "string" }, + }, + required: ["label", "url"], + }, + }, +}; + +const exportInputSchema = { + type: "object", + additionalProperties: false, + properties: { + format: { + type: "string", + enum: ["html", "text", "both"], + }, + }, +}; + +let repositoryContext = resolveRepositoryContext("", ""); +let sampleRelease = Object.freeze(buildDefaultRelease(repositoryContext)); + +function buildDefaultRelease(context) { + const releaseName = context.displayName; + const version = "vNext"; + const releaseDate = new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(new Date()); + + return { + releaseName, + version, + releaseDate, + tagline: `No release data loaded yet for ${releaseName}.`, + summary: + "Use Release source to load a tag or draft unreleased changes from repository history.", + emailSubject: `${releaseName} ${version} - release highlights`, + emailPreheader: `Release draft for ${releaseName}.`, + heroStats: [ + { label: "Commits", value: "00" }, + { label: "Merged PRs", value: "00" }, + { label: "Closed issues", value: "00" }, + { label: "Repository", value: context.repoSlug }, + ], + sections: [], + contributors: [], + communityThanks: [], + otherChanges: [], + callToAction: { + label: "View repository", + url: context.repoUrl, + }, + }; +} + +function resolveRepositoryContext(preferredWorkingDirectory, sessionId) { + const extensionDir = dirname(fileURLToPath(import.meta.url)); + const sessionWorkingDirectory = readSessionWorkingDirectoryFromMetadata(sessionId); + const repoRoot = + findRepositoryRoot(preferredWorkingDirectory ?? "") || + findRepositoryRoot(sessionWorkingDirectory) || + findRepositoryRoot(process.cwd()) || + findRepositoryRoot(extensionDir); + const repoName = repoRoot ? basename(repoRoot) : "current-repository"; + const remoteUrl = repoRoot ? readRemoteOrigin(repoRoot) : ""; + const parsed = parseRepositorySlug(remoteUrl); + const repoSlug = parsed ?? repoName; + const slugLeaf = repoSlug.split("/").at(-1) || repoName; + const displayName = humanizeRepoName(slugLeaf); + const repoUrl = parsed ? `https://github.com/${parsed}` : "https://github.com/"; + + return { + repoRoot, + repoSlug, + displayName, + repoUrl, + }; +} + +function findRepositoryRoot(startPath) { + if (!startPath) { + return ""; + } + + let current = startPath; + + while (true) { + if (existsSync(join(current, ".git"))) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + return ""; + } + + current = parent; + } +} + +function readRemoteOrigin(repoRoot) { + const result = spawnSync("git", ["-C", repoRoot, "config", "--get", "remote.origin.url"], { + encoding: "utf8", + }); + + if (result.status !== 0 || typeof result.stdout !== "string") { + return ""; + } + + return result.stdout.trim(); +} + +function parseRepositorySlug(remoteUrl) { + if (!remoteUrl) { + return ""; + } + + const httpsMatch = remoteUrl.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i); + if (httpsMatch?.[1]) { + return httpsMatch[1]; + } + + const sshMatch = remoteUrl.match(/github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i); + if (sshMatch?.[1]) { + return sshMatch[1]; + } + + return ""; +} + +function humanizeRepoName(value) { + return value + .replace(/[-_]+/g, " ") + .trim() + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function readSessionWorkingDirectoryFromMetadata(sessionId) { + const resolvedSessionId = + pickString(sessionId, "") || + pickString(process.env.SESSION_ID, "") || + pickString(process.env.COPILOT_AGENT_SESSION_ID, ""); + if (!resolvedSessionId) { + return ""; + } + + const metadataPath = join( + homedir(), + ".copilot", + "session-state", + resolvedSessionId, + "vscode.metadata.json", + ); + const workspacePath = join( + homedir(), + ".copilot", + "session-state", + resolvedSessionId, + "workspace.yaml", + ); + + const candidatePaths = [metadataPath, workspacePath]; + for (const path of candidatePaths) { + if (!existsSync(path)) { + continue; + } + + let text = ""; + try { + text = readFileSync(path, "utf8"); + } catch { + continue; + } + + const match = text.match(/^cwd:\s*(.+)$/m); + if (match?.[1]?.trim()) { + return match[1].trim(); + } + } + + return ""; +} + +function runGit(repoRoot, args) { + if (!repoRoot) { + return ""; + } + + const result = spawnSync("git", ["-C", repoRoot, ...args], { + encoding: "utf8", + }); + + if (result.status !== 0 || typeof result.stdout !== "string") { + return ""; + } + + return result.stdout.trim(); +} + +function listReleaseTags(repoRoot) { + const output = runGit(repoRoot, ["tag", "--sort=-creatordate"]); + if (!output) { + return []; + } + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function readTagDate(repoRoot, tag) { + const output = runGit(repoRoot, ["log", "-1", "--date=short", "--format=%ad", tag]); + return output || ""; +} + +function readCommitSummaries(repoRoot, rangeExpr) { + const output = runGit(repoRoot, [ + "log", + "--max-count=250", + "--pretty=format:%s%x1f%an", + rangeExpr, + ]); + if (!output) { + return []; + } + + return output + .split(/\r?\n/) + .map((line) => line.split("\x1f")) + .filter((parts) => parts.length >= 2) + .map(([subject, author]) => ({ + subject: cleanCommitSubject(subject), + author: pickString(author, "Contributor"), + })) + .filter((entry) => entry.subject); +} + +function cleanCommitSubject(value) { + return pickString(value, "") + .replace(/^\w+(\([^)]+\))?!?:\s*/i, "") + .replace(/\s+\(#\d+\)\s*$/u, "") + .trim(); +} + +function classifyCommit(subject) { + const lower = subject.toLowerCase(); + if (/^(feat|feature)\b/.test(lower) || /add|introduce|support|new/.test(lower)) { + return "feature"; + } + + if (/^(fix|perf|refactor)\b/.test(lower) || /improv|stabil|reliab|optim/.test(lower)) { + return "improvement"; + } + + return "quality"; +} + +function toReleaseStateFromCommits(context, commits, options) { + const releaseName = context.displayName; + const version = options.version; + const releaseDate = options.releaseDate; + const commitCount = commits.length; + const mergedPulls = Array.isArray(options.mergedPulls) ? options.mergedPulls : []; + const closedIssues = Array.isArray(options.closedIssues) ? options.closedIssues : []; + + if (commitCount === 0) { + const emptyState = buildDefaultRelease(context); + return { + ...emptyState, + releaseName, + version, + releaseDate, + tagline: `No commit changes were detected for ${options.rangeLabel}.`, + summary: `There are no commits in ${options.rangeLabel}, so this draft starts from the repository template.`, + emailSubject: `${releaseName} ${version} - release highlights`, + emailPreheader: `No commit changes detected for ${options.rangeLabel}.`, + callToAction: { + label: options.callToActionLabel, + url: options.callToActionUrl, + }, + }; + } + + const buckets = { + feature: [], + improvement: [], + quality: [], + }; + + const contributorCounts = new Map(); + for (const commit of commits) { + const kind = classifyCommit(commit.subject); + buckets[kind].push(commit.subject); + contributorCounts.set(commit.author, (contributorCounts.get(commit.author) ?? 0) + 1); + } + + const sections = []; + if (mergedPulls.length > 0) { + sections.push({ + title: "Merged pull requests", + kind: "feature", + summary: `Pull requests merged since ${options.sinceLabel}.`, + metric: `${mergedPulls.length} merged`, + bullets: mergedPulls.slice(0, 6).map((pull) => `#${pull.number} ${pull.title}`), + }); + } + for (const kind of ["feature", "improvement", "quality"]) { + const entries = buckets[kind]; + if (entries.length === 0) { + continue; + } + + const kindTitle = + kind === "feature" + ? "Feature work shipped" + : kind === "improvement" + ? "Improvements and fixes" + : "Quality and maintenance updates"; + const kindSummary = + kind === "feature" + ? "New capabilities and user-facing improvements landed in this release." + : kind === "improvement" + ? "Stability, performance, and reliability updates were delivered." + : "Foundational cleanup and maintenance work strengthened the codebase."; + + sections.push({ + title: kindTitle, + kind, + summary: kindSummary, + metric: `${entries.length} commits`, + bullets: entries.slice(0, 6), + }); + } + + const sortedContributors = [...contributorCounts.entries()] + .sort((left, right) => right[1] - left[1]) + .slice(0, 6); + + const contributors = sortedContributors.map(([name, count]) => ({ + name, + githubHandle: "", + avatarUrl: "", + profileUrl: context.repoUrl, + area: count === 1 ? "1 commit" : `${count} commits`, + summary: `Contributed ${count} change${count === 1 ? "" : "s"} in ${options.rangeLabel}.`, + })); + + const otherChanges = commits.slice(0, 7).map((commit) => ({ + label: classifyCommit(commit.subject), + text: commit.subject, + })); + if (closedIssues.length > 0) { + otherChanges.unshift( + ...closedIssues.slice(0, 6).map((issue) => ({ + label: `Issue #${issue.number}`, + text: issue.title, + })), + ); + } + + const featureCount = buckets.feature.length; + + return { + releaseName, + version, + releaseDate, + tagline: `${commitCount} commits, ${mergedPulls.length} merged PRs, and ${closedIssues.length} closed issues since ${options.sinceLabel}.`, + summary: `This draft combines git history with merged pull requests and closed issues since ${options.sinceLabel}.`, + emailSubject: `${releaseName} ${version} - release highlights`, + emailPreheader: `${commitCount} commits, ${mergedPulls.length} merged PRs, and ${closedIssues.length} closed issues summarized from ${options.rangeLabel}.`, + heroStats: [ + { label: "Commits", value: padCount(commitCount) }, + { label: "Merged PRs", value: padCount(mergedPulls.length) }, + { label: "Closed issues", value: padCount(closedIssues.length) }, + { label: "Features", value: padCount(featureCount) }, + ], + sections: sections.length > 0 ? sections : buildDefaultRelease(context).sections, + contributors: contributors.length > 0 ? contributors : buildDefaultRelease(context).contributors, + communityThanks: [], + otherChanges, + callToAction: { + label: options.callToActionLabel, + url: options.callToActionUrl, + }, + }; +} + +function getGitHubToken() { + const direct = pickString(process.env.GITHUB_TOKEN, ""); + if (direct) { + return direct; + } + + const key = Object.keys(process.env).find((name) => + name.startsWith("COPILOT_GH_ACCOUNT_github_2E_com_"), + ); + return key ? pickString(process.env[key], "") : ""; +} + +async function fetchGithubJson(url) { + const headers = { + Accept: "application/vnd.github+json", + "User-Agent": "release-notes-showcase", + }; + const token = getGitHubToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + return []; + } + + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +function normalizeIsoDate(dateValue) { + if (!dateValue) { + return ""; + } + + if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) { + return `${dateValue}T00:00:00Z`; + } + + return dateValue; +} + +async function fetchUnreleasedGithubSignals(context, sinceDate) { + if (!context.repoSlug.includes("/")) { + return { mergedPulls: [], closedIssues: [] }; + } + + const sinceIso = normalizeIsoDate(sinceDate); + if (!sinceIso) { + return { mergedPulls: [], closedIssues: [] }; + } + + const [owner, repo] = context.repoSlug.split("/"); + const pullsUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=closed&sort=updated&direction=desc&per_page=100`; + const issuesUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=closed&since=${encodeURIComponent(sinceIso)}&sort=updated&direction=desc&per_page=100`; + + try { + const [pulls, issues] = await Promise.all([ + fetchGithubJson(pullsUrl), + fetchGithubJson(issuesUrl), + ]); + + const mergedPulls = pulls + .filter((pull) => isRecord(pull) && typeof pull.merged_at === "string") + .filter((pull) => Date.parse(pull.merged_at) >= Date.parse(sinceIso)) + .map((pull) => ({ + number: Number(pull.number) || 0, + title: pickString(pull.title, "Merged pull request"), + })) + .filter((pull) => pull.number > 0); + + const closedIssues = issues + .filter((issue) => isRecord(issue) && !issue.pull_request) + .filter((issue) => typeof issue.closed_at === "string") + .filter((issue) => Date.parse(issue.closed_at) >= Date.parse(sinceIso)) + .map((issue) => ({ + number: Number(issue.number) || 0, + title: pickString(issue.title, "Closed issue"), + })) + .filter((issue) => issue.number > 0); + + return { mergedPulls, closedIssues }; + } catch { + return { mergedPulls: [], closedIssues: [] }; + } +} + +async function buildReleaseFromRepository(context, mode, selectedTag) { + const tags = listReleaseTags(context.repoRoot); + const latestTag = tags[0] ?? ""; + + if (mode === "tag" && selectedTag && tags.includes(selectedTag)) { + const index = tags.indexOf(selectedTag); + const previousTag = index >= 0 && index < tags.length - 1 ? tags[index + 1] : ""; + const rangeExpr = previousTag ? `${previousTag}..${selectedTag}` : selectedTag; + const releaseDate = readTagDate(context.repoRoot, selectedTag) || sampleRelease.releaseDate; + const commits = readCommitSummaries(context.repoRoot, rangeExpr); + const releaseUrl = + context.repoUrl !== "https://github.com/" + ? `${context.repoUrl}/releases/tag/${encodeURIComponent(selectedTag)}` + : context.repoUrl; + + return toReleaseStateFromCommits(context, commits, { + version: selectedTag, + releaseDate, + rangeLabel: rangeExpr, + sinceLabel: previousTag || selectedTag, + callToActionLabel: `View ${selectedTag} release`, + callToActionUrl: releaseUrl, + }); + } + + const rangeExpr = latestTag ? `${latestTag}..HEAD` : "HEAD"; + const commits = readCommitSummaries(context.repoRoot, rangeExpr); + const latestTagDate = latestTag ? readTagDate(context.repoRoot, latestTag) : ""; + const unreleasedSignals = latestTagDate + ? await fetchUnreleasedGithubSignals(context, latestTagDate) + : { mergedPulls: [], closedIssues: [] }; + const compareUrl = + context.repoUrl !== "https://github.com/" && latestTag + ? `${context.repoUrl}/compare/${encodeURIComponent(latestTag)}...HEAD` + : context.repoUrl; + + return toReleaseStateFromCommits(context, commits, { + version: "vNext", + releaseDate: sampleRelease.releaseDate, + rangeLabel: rangeExpr, + sinceLabel: latestTag || "the beginning of the branch", + mergedPulls: unreleasedSignals.mergedPulls, + closedIssues: unreleasedSignals.closedIssues, + callToActionLabel: latestTag ? "Review unreleased commits" : "View repository", + callToActionUrl: compareUrl, + }); +} + +export const releaseNotesShowcaseCanvas = createCanvas({ + id: CANVAS_ID, + displayName: CANVAS_TITLE, + description: + "Presents release notes as a high-impact launch summary with contributor callouts and email-ready export output.", + inputSchema: releaseNotesInputSchema, + actions: [ + { + name: "export_email", + description: + "Returns email-ready subject, HTML, and text for the release notes currently shown in the canvas.", + inputSchema: exportInputSchema, + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) { + throw new CanvasError( + "canvas_state_missing", + "Open the release notes canvas before exporting email content.", + ); + } + + return buildExportPayload(entry.getState(), ctx.input); + }, + }, + { + name: "get_release_snapshot", + description: + "Returns a concise snapshot of the release story shown in the canvas.", + handler: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) { + throw new CanvasError( + "canvas_state_missing", + "Open the release notes canvas before requesting a snapshot.", + ); + } + + const state = entry.getState(); + return { + title: `${state.releaseName} ${state.version}`, + summary: state.summary, + sections: state.sections.map((section) => ({ + title: section.title, + kind: section.kind, + })), + contributors: state.contributors.map((contributor) => contributor.name), + }; + }, + }, + ], + open: async (ctx) => { + repositoryContext = resolveRepositoryContext(ctx.session?.workingDirectory, ctx.sessionId); + sampleRelease = Object.freeze(buildDefaultRelease(repositoryContext)); + const state = buildState(ctx.input); + + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(state); + servers.set(ctx.instanceId, entry); + } else { + entry.setState(state); + } + + return { + title: `${state.releaseName} ${state.version}`, + status: `${state.contributors.length} contributors highlighted`, + url: entry.url, + }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (!entry) { + return; + } + + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(resolve)); + }, +}); + +function buildState(input) { + const candidate = isRecord(input) ? input : {}; + const releaseName = pickString(candidate.releaseName, sampleRelease.releaseName); + const version = pickString(candidate.version, sampleRelease.version); + const summary = pickString(candidate.summary, sampleRelease.summary); + const sections = normalizeSections(candidate.sections); + const contributors = normalizeContributors(candidate.contributors); + const heroStats = normalizeHeroStats(candidate.heroStats, sections, contributors); + const emailSubject = pickString( + candidate.emailSubject, + `${releaseName} ${version} - release highlights`, + ); + + return { + releaseName, + version, + releaseDate: pickString(candidate.releaseDate, sampleRelease.releaseDate), + tagline: pickString(candidate.tagline, sampleRelease.tagline), + summary, + emailSubject, + emailPreheader: pickString(candidate.emailPreheader, summary), + heroStats, + sections, + contributors, + communityThanks: normalizeCommunityThanks(candidate.communityThanks), + otherChanges: normalizeOtherChanges(candidate.otherChanges), + callToAction: normalizeCallToAction(candidate.callToAction), + }; +} + +function normalizeCommunityThanks(value) { + if (!Array.isArray(value)) { + return []; + } + + const handles = value + .filter((handle) => typeof handle === "string") + .map((handle) => handle.trim().replace(/^@/, "")) + .filter((handle) => handle.length > 0); + + return handles; +} + +function normalizeOtherChanges(value) { + if (!Array.isArray(value) || value.length === 0) { + return []; + } + + const changes = value + .filter(isRecord) + .map((change) => ({ + label: pickString(change.label, ""), + text: pickString(change.text, ""), + })) + .filter((change) => change.text); + + return changes; +} + +function normalizeSections(value) { + if (!Array.isArray(value) || value.length === 0) { + return []; + } + + return value + .filter(isRecord) + .map((section) => { + const kind = isSectionKind(section.kind) ? section.kind : "feature"; + const bullets = toStringArray(section.bullets); + const title = pickString(section.title, ""); + const summary = pickString(section.summary, ""); + if (!title || !summary) { + return null; + } + + return { + title, + kind, + summary, + metric: pickString(section.metric, ""), + bullets, + }; + }) + .filter(Boolean); +} + +function normalizeContributors(value) { + if (!Array.isArray(value) || value.length === 0) { + return []; + } + + return value + .filter(isRecord) + .map((contributor) => { + const name = pickString(contributor.name, ""); + if (!name) { + return null; + } + + return { + name, + githubHandle: pickString(contributor.githubHandle, ""), + avatarUrl: pickString(contributor.avatarUrl, ""), + profileUrl: pickString(contributor.profileUrl, ""), + area: pickString(contributor.area, ""), + summary: pickString(contributor.summary, ""), + }; + }) + .filter(Boolean); +} + +function normalizeHeroStats(value, sections, contributors) { + if (Array.isArray(value) && value.length > 0) { + const stats = value + .filter(isRecord) + .map((stat) => ({ + label: pickString(stat.label, ""), + value: pickString(stat.value, ""), + })) + .filter((stat) => stat.label && stat.value); + + if (stats.length > 0) { + return stats; + } + } + + return [ + { + label: "Top features", + value: padCount(countByKind(sections, "feature")), + }, + { + label: "Core improvements", + value: padCount(countByKind(sections, "improvement")), + }, + { + label: "Contributors", + value: padCount(contributors.length), + }, + { + label: "Areas touched", + value: padCount(sections.length), + }, + ]; +} + +function normalizeCallToAction(value) { + if (isRecord(value)) { + return { + label: pickString(value.label, sampleRelease.callToAction.label), + url: pickString(value.url, sampleRelease.callToAction.url), + }; + } + + return { ...sampleRelease.callToAction }; +} + +function buildExportPayload(state, input) { + const format = isRecord(input) ? pickString(input.format, "both") : "both"; + const html = buildEmailHtml(state); + const text = buildEmailText(state); + const payload = { + subject: state.emailSubject, + preheader: state.emailPreheader, + fileNameBase: slugify(`${state.releaseName}-${state.version}-release-notes-email`), + }; + + if (format === "html") { + return { ...payload, html }; + } + + if (format === "text") { + return { ...payload, text }; + } + + return { ...payload, html, text }; +} + +function buildEmailHtml(state) { + const sectionRows = state.sections + .map((section) => { + const bullets = section.bullets + .map( + (bullet) => + `
  • ${escapeHtml(bullet)}
  • `, + ) + .join(""); + + return ` + + +
    + ${escapeHtml(kindLabel(section.kind))} +
    +

    + ${escapeHtml(section.title)} +

    +

    + ${escapeHtml(section.summary)} +

    +

    + ${escapeHtml(section.metric)} +

    + + + + `; + }) + .join(""); + + const contributorRows = state.contributors + .map( + (contributor) => ` + + + + + + +
    + + + + + +
    + ${escapeHtml(contributor.name)} + +

    + ${escapeHtml(contributor.name)} +

    +

    + ${escapeHtml(contributor.area)} +

    +

    + ${escapeHtml(contributor.summary)} +

    +
    +
    + + + `, + ) + .join(""); + + const otherChangesHtml = (state.otherChanges ?? []) + .map( + (change) => ` +
  • ${change.label ? `${escapeHtml(change.label)}: ` : ""}${escapeHtml(change.text)}
  • + `, + ) + .join(""); + + const communityHtml = (state.communityThanks ?? []) + .map( + (handle) => + `@${escapeHtml(handle)}`, + ) + .join(" · "); + + return ` + + + + + ${escapeHtml(state.emailSubject)} + + +
    + ${escapeHtml(state.emailPreheader)} +
    + + + + +
    + + + + + + + + + + + + + + + + + + + +
    +

    + ${escapeHtml(state.releaseDate)} +

    +

    + ${escapeHtml(`${state.releaseName} ${state.version}`)} +

    +

    + ${escapeHtml(state.tagline)} +

    +

    + ${escapeHtml(state.summary)} +

    +
    + + + ${state.heroStats + .map( + (stat) => ` + + `, + ) + .join("")} + +
    + + + + +
    +

    ${escapeHtml(stat.value)}

    +

    ${escapeHtml(stat.label)}

    +
    +
    +
    + ${sectionRows} +
    +

    + Also in this release +

    +
      + ${otherChangesHtml} +
    +
    +

    + Contributors in the spotlight +

    + ${contributorRows} +

    + Community thanks: ${communityHtml} +

    +
    + + ${escapeHtml(state.callToAction.label)} + +
    +
    + +`; +} + +function buildEmailText(state) { + const sectionText = state.sections + .map((section) => { + const bullets = section.bullets.map((bullet) => `- ${bullet}`).join("\n"); + return `${kindLabel(section.kind).toUpperCase()}: ${section.title}\n${section.summary}\n${bullets}`; + }) + .join("\n\n"); + + const contributorText = state.contributors + .map( + (contributor) => + `- ${contributor.name} (${contributor.area}): ${contributor.summary}`, + ) + .join("\n"); + + const otherChangesText = (state.otherChanges ?? []) + .map((change) => `- ${change.label ? `${change.label}: ` : ""}${change.text}`) + .join("\n"); + + const communityText = (state.communityThanks ?? []) + .map((handle) => `@${handle}`) + .join(", "); + + return `${state.releaseName} ${state.version} +${state.releaseDate} + +${state.tagline} + +${state.summary} + +Highlights +${state.heroStats.map((stat) => `- ${stat.label}: ${stat.value}`).join("\n")} + +${sectionText} + +Also in this release +${otherChangesText} + +Contributors in the spotlight +${contributorText} + +Community thanks: ${communityText} + +${state.callToAction.label}: ${state.callToAction.url}`; +} + +async function startServer(initialState) { + let state = initialState; + + const server = createServer(async (req, res) => { + const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1"); + + if (req.method === "GET" && requestUrl.pathname === "/") { + respondHtml(res, renderHtml(state)); + return; + } + + if (req.method === "POST" && requestUrl.pathname === "/actions/export-email") { + const body = await readJsonBody(req); + respondJson(res, buildExportPayload(state, body)); + return; + } + + if (req.method === "GET" && requestUrl.pathname === "/actions/release-options") { + const tags = listReleaseTags(repositoryContext.repoRoot); + respondJson(res, { + repository: repositoryContext.repoSlug, + tags: tags.map((tag) => ({ value: tag, label: tag })), + latestTag: tags[0] ?? "", + }); + return; + } + + if (req.method === "POST" && requestUrl.pathname === "/actions/load-release") { + const body = await readJsonBody(req); + const mode = pickString(body?.mode, "unreleased"); + const selectedTag = pickString(body?.tag, ""); + if (mode !== "unreleased" && mode !== "tag") { + respondJson(res, { error: "Invalid release mode." }, 400); + return; + } + + state = await buildReleaseFromRepository(repositoryContext, mode, selectedTag); + respondJson(res, { + title: `${state.releaseName} ${state.version}`, + summary: state.summary, + }); + return; + } + + respondJson(res, { error: "Not found" }, 404); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + return { + server, + url: `http://127.0.0.1:${port}/`, + getState() { + return state; + }, + setState(nextState) { + state = nextState; + }, + }; +} + +function renderHtml(state) { + const metricPalette = [ + { bg: "#f5e0dc", border: "rgba(220, 138, 120, 0.22)", value: "#dd7878" }, + { bg: "#dce7fb", border: "rgba(30, 102, 245, 0.22)", value: "#1e66f5" }, + { bg: "#e7f3e0", border: "rgba(64, 160, 43, 0.22)", value: "#40a02b" }, + { bg: "#efe3fb", border: "rgba(136, 57, 239, 0.22)", value: "#8839ef" }, + ]; + + const statCards = state.heroStats + .map((stat, index) => { + const tone = metricPalette[index % metricPalette.length]; + return ` +
    +
    ${escapeHtml(stat.value)}
    +
    ${escapeHtml(stat.label)}
    +
    + `; + }) + .join(""); + + const featureCards = state.sections + .map((section) => { + const bullets = section.bullets + .slice(0, 2) + .map((bullet) => `
  • ${escapeHtml(bullet)}
  • `) + .join(""); + + return ` +
    +
    +
    ${escapeHtml(kindLabel(section.kind))}
    +
    ${escapeHtml(section.metric)}
    +
    +

    ${escapeHtml(section.title)}

    +

    ${escapeHtml(section.summary)}

    + +
    + `; + }) + .join(""); + + const contributorCards = state.contributors + .map((contributor) => { + const avatar = contributor.avatarUrl + ? `${escapeHtml(contributor.name)}` + : `
    ${escapeHtml(getInitials(contributor.name))}
    `; + const profileHref = contributor.profileUrl || "#"; + const handle = contributor.githubHandle ? `@${contributor.githubHandle}` : ""; + + return ` +
    + ${avatar} +
    +
    + ${escapeHtml(contributor.name)} + ${escapeHtml(handle)} +
    +
    ${escapeHtml(contributor.area)}
    +

    ${escapeHtml(contributor.summary)}

    +
    +
    + `; + }) + .join(""); + + const communityChips = (state.communityThanks ?? []) + .map((handle) => { + const profile = `https://github.com/${encodeURIComponent(handle)}`; + const avatar = `https://github.com/${encodeURIComponent(handle)}.png?size=64`; + return ` + + @${escapeHtml(handle)} + @${escapeHtml(handle)} + + `; + }) + .join(""); + + const otherChangeRows = (state.otherChanges ?? []) + .map((change) => { + const label = change.label + ? `${escapeHtml(change.label)}` + : ""; + return `
  • ${label}${escapeHtml(change.text)}
  • `; + }) + .join(""); + + const featureHeadline = state.sections[0]?.title ?? "Release highlights"; + + return ` + + + + + ${escapeHtml(`${state.releaseName} ${state.version}`)} + + + +
    +
    +
    +
    +
    + + ${escapeHtml(state.releaseName)} repository +
    +
    ${escapeHtml(state.releaseDate)} · ✨ Fresh from the repo
    +

    ${escapeHtml(state.releaseName)} ${escapeHtml(state.version)}

    +

    ${escapeHtml(state.tagline)}

    +

    ${escapeHtml(state.summary)}

    +
    +
    +
    Top hit
    +
    ${escapeHtml(featureHeadline)}
    +
    + ${escapeHtml(state.callToAction.label)} +
    +
    + +
    +
    + +
    +
    +
    +

    Release source

    +

    Pick an existing tag, or draft unreleased work merged/closed since the latest tag.

    +
    +
    + +
    + +
    +
    +
    +
    +

    Top hits

    +

    A denser dashboard view of the biggest feature work, improvements, and quality moves in this release.

    +
    +
    +
    ${featureCards}
    +
    +
    +

    Also in this release

    +

    Smaller but mighty updates landing across the rest of the repository.

    +
    +
    +
      ${otherChangeRows}
    +
    + +
    + + + +
    +
    +
    +
    + + +`; +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + if (!body.trim()) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + req.on("error", reject); + }); +} + +function respondHtml(res, html) { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(html); +} + +function respondJson(res, payload, statusCode = 200) { + res.statusCode = statusCode; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(payload)); +} + +function pickString(value, fallback) { + return typeof value === "string" && value.trim() ? value.trim() : fallback; +} + +function toStringArray(value) { + return Array.isArray(value) + ? value + .filter((item) => typeof item === "string" && item.trim()) + .map((item) => item.trim()) + : []; +} + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isSectionKind(value) { + return value === "feature" || value === "improvement" || value === "quality"; +} + +function countByKind(sections, kind) { + return sections.filter((section) => section.kind === kind).length; +} + +function padCount(value) { + return String(value).padStart(2, "0"); +} + +function kindLabel(kind) { + if (kind === "feature") { + return "🚀 Feature work"; + } + + if (kind === "improvement") { + return "✨ Improvement"; + } + + return "🛡️ Quality"; +} + +function emailAccent(kind) { + if (kind === "feature") { + return { + chip: "#dbeafe", + ink: "#1d4ed8", + }; + } + + if (kind === "improvement") { + return { + chip: "#f3e8ff", + ink: "#7e22ce", + }; + } + + return { + chip: "#ffedd5", + ink: "#c2410c", + }; +} + +function getInitials(name) { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((segment) => segment[0]?.toUpperCase() ?? "") + .join(""); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function slugify(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/extensions/where-was-i/assets/preview.png b/extensions/where-was-i/assets/preview.png new file mode 100644 index 00000000..16336a74 Binary files /dev/null and b/extensions/where-was-i/assets/preview.png differ diff --git a/website/src/pages/extensions.astro b/website/src/pages/extensions.astro index 98e98a26..78ae6f27 100644 --- a/website/src/pages/extensions.astro +++ b/website/src/pages/extensions.astro @@ -36,6 +36,17 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
    + diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 5420b2ba..e10427da 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -40,6 +40,11 @@ const base = import.meta.env.BASE_URL; Community-contributed agents, instructions, and skills to enhance your GitHub Copilot experience

    +

    + + View the Awesome Copilot repository on GitHub + +

    diff --git a/website/src/scripts/pages/extensions-render.ts b/website/src/scripts/pages/extensions-render.ts index b872eef0..65e04c96 100644 --- a/website/src/scripts/pages/extensions-render.ts +++ b/website/src/scripts/pages/extensions-render.ts @@ -3,9 +3,15 @@ import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils"; export interface RenderableExtension { id: string; name: string; - path: string; - ref: string; + path?: string | null; + ref?: string | null; + description?: string; lastUpdated?: string | null; + imageUrl?: string | null; + assetPath?: string | null; + installUrl?: string | null; + sourceUrl?: string | null; + external?: boolean; } export type ExtensionSortOption = "title" | "lastUpdated"; @@ -36,37 +42,62 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string { } return items - .map( - (item) => ` + .map((item) => { + const installUrl = + item.installUrl || + (item.path && item.ref + ? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace( + /\\/g, + "/" + )}` + : ""); + const sourceUrl = + item.sourceUrl || (item.path ? getGitHubUrl(item.path) : ""); + + return `
    -
    -
    ${escapeHtml(item.name)}
    -
    Canvas extension
    -
    - ${getLastUpdatedHtml(item.lastUpdated)} -
    -
    -
    + ${ + item.imageUrl + ? `` + : `` + } +
    +
    ${escapeHtml(item.name)}
    +
    ${escapeHtml( + item.description || "Canvas extension" + )}
    +
    + ${ + item.external + ? 'External' + : "" + } + ${getLastUpdatedHtml(item.lastUpdated)} +
    +
    +
    - GitHub + ${ + sourceUrl + ? `Source` + : "" + }
    - ` - ) + `; + }) .join(""); } diff --git a/website/src/scripts/pages/extensions.ts b/website/src/scripts/pages/extensions.ts index c839ea02..f9dae4c5 100644 --- a/website/src/scripts/pages/extensions.ts +++ b/website/src/scripts/pages/extensions.ts @@ -27,6 +27,30 @@ let allItems: Extension[] = []; let currentSort: ExtensionSortOption = "title"; let actionHandlersReady = false; +function openPreviewModal(url: string, alt: string): void { + const modal = document.getElementById("extension-preview-modal"); + const image = document.getElementById("extension-preview-image") as HTMLImageElement | null; + const title = document.getElementById("extension-preview-title"); + + if (!modal || !image || !title) return; + + image.src = url; + image.alt = alt; + title.textContent = alt.replace(/ preview$/i, ""); + modal.classList.remove("hidden"); + modal.setAttribute("aria-hidden", "false"); + document.body.style.overflow = "hidden"; +} + +function closePreviewModal(): void { + const modal = document.getElementById("extension-preview-modal"); + if (!modal) return; + + modal.classList.add("hidden"); + modal.setAttribute("aria-hidden", "true"); + document.body.style.overflow = ""; +} + function applySortAndRender(): void { const countEl = document.getElementById("results-count"); const results = sortExtensions(allItems, currentSort); @@ -49,6 +73,20 @@ function setupActionHandlers(list: HTMLElement | null): void { list.addEventListener("click", async (event) => { const target = event.target as HTMLElement; + const thumbnailButton = target.closest( + ".resource-thumbnail-btn" + ) as HTMLButtonElement | null; + + if (thumbnailButton) { + event.preventDefault(); + event.stopPropagation(); + openPreviewModal( + thumbnailButton.dataset.previewUrl || "", + thumbnailButton.dataset.previewAlt || "Extension preview" + ); + return; + } + const installButton = target.closest( ".copy-install-url-btn" ) as HTMLButtonElement | null; @@ -57,6 +95,10 @@ function setupActionHandlers(list: HTMLElement | null): void { event.stopPropagation(); const installUrl = installButton.dataset.installUrl || ""; + if (!installUrl) { + showToast("No install URL available for this extension", "error"); + return; + } const success = await copyToClipboard(installUrl); showToast( success ? "Install URL copied!" : "Failed to copy install URL", @@ -64,6 +106,27 @@ function setupActionHandlers(list: HTMLElement | null): void { ); }); + const modal = document.getElementById("extension-preview-modal"); + const closeButton = document.getElementById("extension-preview-close"); + + if (modal) { + modal.addEventListener("click", (event) => { + if (event.target === modal) { + closePreviewModal(); + } + }); + } + + if (closeButton) { + closeButton.addEventListener("click", closePreviewModal); + } + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closePreviewModal(); + } + }); + actionHandlersReady = true; } diff --git a/website/src/styles/global.css b/website/src/styles/global.css index 81f21e07..04bd3718 100644 --- a/website/src/styles/global.css +++ b/website/src/styles/global.css @@ -1863,7 +1863,9 @@ body:has(#main-content) { .resource-preview { flex: 1; min-width: 0; - display: block; + display: flex; + align-items: flex-start; + gap: 16px; width: 100%; padding: 0; margin: 0; @@ -1874,7 +1876,56 @@ body:has(#main-content) { text-align: left; cursor: pointer; } - + +.resource-thumbnail-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + flex-shrink: 0; +} + +.resource-thumbnail-btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 4px; + border-radius: var(--border-radius); +} + +.resource-thumbnail { + width: clamp(120px, 24vw, 160px); + aspect-ratio: 16 / 10; + object-fit: cover; + border-radius: var(--border-radius); + border: 1px solid var(--color-glass-border); + background: var(--color-bg-tertiary); + flex-shrink: 0; + box-shadow: var(--shadow); + transition: transform var(--transition), box-shadow var(--transition); +} + +.resource-thumbnail-btn:hover .resource-thumbnail, +.resource-thumbnail-btn:focus-visible .resource-thumbnail { + transform: translateY(-1px); + box-shadow: var(--shadow-lg); +} + +.resource-thumbnail-placeholder { + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + background: + linear-gradient(135deg, rgba(133, 52, 243, 0.18), rgba(254, 76, 37, 0.08)), + var(--color-bg-tertiary); +} + .resource-preview:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; @@ -1930,6 +1981,72 @@ body:has(#main-content) { margin: 0px; } +.extension-preview-modal { + position: fixed; + inset: 0; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(5, 7, 15, 0.72); + backdrop-filter: blur(8px); +} + +.extension-preview-modal.hidden { + display: none; +} + +.extension-preview-dialog { + width: min(100%, 980px); + max-height: 90vh; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--color-bg-secondary); + border: 1px solid var(--color-glass-border); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.extension-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 0; +} + +.extension-preview-title { + margin: 0; + font-size: 1rem; + color: var(--color-text-emphasis); +} + +.extension-preview-close { + border: 1px solid var(--color-glass-border); + background: var(--color-bg-tertiary); +} + +.extension-preview-body { + padding: 0 18px 18px; + display: flex; + justify-content: center; + overflow: auto; +} + +.extension-preview-image { + display: block; + width: 100%; + max-width: 920px; + max-height: 72vh; + object-fit: contain; + border-radius: var(--border-radius); + border: 1px solid var(--color-glass-border); + background: var(--color-bg-tertiary); +} + /* Last Updated */ .last-updated { font-size: 12px; @@ -2081,6 +2198,20 @@ body:has(#main-content) { align-items: stretch; } + .resource-preview { + flex-direction: column; + gap: 12px; + } + + .resource-thumbnail-btn { + width: 100%; + } + + .resource-thumbnail { + width: min(100%, 320px); + max-width: 100%; + } + .resource-actions { justify-content: flex-end; }