${escapeHtml(section.title)}
${escapeHtml(section.summary)}
- ${bullets}
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(section.summary)}
${escapeHtml(section.metric)}
|
${escapeHtml(section.summary)}
${escapeHtml(contributor.summary)}
${escapeHtml(state.tagline)}
${escapeHtml(state.summary)}
Pick an existing tag, or draft unreleased work merged/closed since the latest tag.
A denser dashboard view of the biggest feature work, improvements, and quality moves in this release.
Smaller but mighty updates landing across the rest of the repository.