diff --git a/src/index.ts b/src/index.ts index 6b38fbd..579d9e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; import * as tools from "./tools/index.js"; const server = new McpServer({ @@ -14,6 +15,33 @@ Object.values(tools).forEach(tool => { server.tool(tool.name, tool.description, tool.inputSchema, tool.handler); }); +// Register MCP Prompts +server.registerPrompt( + 'upgrade-from-version', + { + title: 'Upgrade UI5 Web Components', + description: 'Generate a step-by-step migration checklist for upgrading UI5 Web Components between versions', + argsSchema: { + fromVersion: z.string().describe('Current version (e.g., "2.6.0")'), + toVersion: z.string().optional().default('latest').describe('Target version (e.g., "2.8.0" or "latest")'), + }, + }, + ({ fromVersion, toVersion }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: + `I am upgrading UI5 Web Components from version ${fromVersion} to ${toVersion ?? 'latest'}. ` + + `Use get_upgrade_guidance to analyze breaking changes, then provide a step-by-step migration ` + + `checklist. Include code examples for any renamed APIs or changed component behaviors.`, + }, + }, + ], + }) +); + async function main() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/get_upgrade_guidance/changelog_processor.ts b/src/tools/get_upgrade_guidance/changelog_processor.ts new file mode 100644 index 0000000..0d1c9eb --- /dev/null +++ b/src/tools/get_upgrade_guidance/changelog_processor.ts @@ -0,0 +1,203 @@ +import { makeNpmRequest, NPM_REGISTRY_BASE, USER_AGENT } from '../../utils.js'; +import type { NpmPackageData, VersionSection, ChangelogEntry, UpgradeGuidance } from '../../types.js'; + +const GITHUB_RAW_CHANGELOG_URL = + 'https://raw.githubusercontent.com/SAP/ui5-webcomponents/main/CHANGELOG.md'; + +// # [2.21.0-rc.4](https://github.com/UI5/webcomponents/compare/...) (2026-04-02) +const VERSION_HEADING_RE = /^# \[(\d[\w.-]*)\].*?\((\d{4}-\d{2}-\d{2})\)/; +// ### Bug Fixes / ### Features / ### BREAKING CHANGES +const SUBSECTION_RE = /^### (.+)/; +// * **ui5-button:** description text ([#NNN](...)) ([hash](...)) +const ENTRY_RE = /^\* \*\*([^*]+)\*\*:? ?(.+)/; + +export async function fetchChangelog(): Promise { + try { + const response = await fetch(GITHUB_RAW_CHANGELOG_URL, { + headers: { 'User-Agent': USER_AGENT }, + }); + if (!response.ok) return null; + return await response.text(); + } catch { + return null; + } +} + +export async function resolveLatestVersion(): Promise { + const data = await makeNpmRequest( + `${NPM_REGISTRY_BASE}/@ui5/webcomponents/latest` + ); + return data?.version ?? null; +} + +// Returns negative if a < b, 0 if equal, positive if a > b +// Handles pre-release versions: 2.0.0-rc.1 < 2.0.0 +export function compareSemver(a: string, b: string): number { + const parseVersion = (v: string) => { + const [mainPart, prePart] = v.split('-', 2); + const nums = (mainPart ?? '').split('.').map(n => parseInt(n, 10) || 0); + return { nums, pre: prePart ?? null }; + }; + + const pa = parseVersion(a); + const pb = parseVersion(b); + + for (let i = 0; i < 3; i++) { + const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0); + if (diff !== 0) return diff; + } + + // Same numeric version: pre-release sorts before release + if (pa.pre && !pb.pre) return -1; + if (!pa.pre && pb.pre) return 1; + if (pa.pre && pb.pre) return pa.pre.localeCompare(pb.pre); + return 0; +} + +// Range: (fromVersion, toVersion] — exclusive lower, inclusive upper +export function parseChangelogRange( + content: string, + fromVersion: string, + toVersion: string +): VersionSection[] { + const sections: VersionSection[] = []; + let currentSection: VersionSection | null = null; + type SubsectionType = 'breakingChanges' | 'features' | 'bugFixes' | null; + let currentSubsection: SubsectionType = null; + + for (const line of content.split('\n')) { + const versionMatch = VERSION_HEADING_RE.exec(line); + if (versionMatch) { + const version = versionMatch[1]!; + const date = versionMatch[2]!; + + // Stop when we reach a version at or below fromVersion + if (compareSemver(version, fromVersion) <= 0) { + break; + } + + // Only collect versions within range: (fromVersion, toVersion] + if (compareSemver(version, toVersion) <= 0) { + currentSection = { version, date, breakingChanges: [], features: [], bugFixes: [] }; + sections.push(currentSection); + currentSubsection = null; + } else { + // Version is above toVersion — not in range yet, keep scanning + currentSection = null; + currentSubsection = null; + } + continue; + } + + if (!currentSection) continue; + + const subsectionMatch = SUBSECTION_RE.exec(line); + if (subsectionMatch) { + const name = subsectionMatch[1]!.trim().toLowerCase(); + if (name === 'breaking changes') { + currentSubsection = 'breakingChanges'; + } else if (name === 'features') { + currentSubsection = 'features'; + } else if (name === 'bug fixes') { + currentSubsection = 'bugFixes'; + } else { + currentSubsection = null; + } + continue; + } + + if (!currentSubsection) continue; + + const entryMatch = ENTRY_RE.exec(line); + if (entryMatch) { + // Strip trailing PR/commit links: ([#NNN](link)) ([hash](link)) + const rawDescription = entryMatch[2]!.replace(/\s*\(\[.*?\]\(.*?\)\)+\s*$/g, '').trim(); + const entry: ChangelogEntry = { + component: entryMatch[1]!.replace(/:$/, '').trim(), + description: rawDescription, + }; + currentSection[currentSubsection].push(entry); + } + } + + return sections; +} + +export function formatUpgradeGuidance(guidance: UpgradeGuidance): string { + const { fromVersion, toVersion, sections, hasBreakingChanges } = guidance; + + const totalFeatures = sections.reduce((sum, s) => sum + s.features.length, 0); + const totalBugFixes = sections.reduce((sum, s) => sum + s.bugFixes.length, 0); + const totalBreaking = sections.reduce((sum, s) => sum + s.breakingChanges.length, 0); + + const lines: string[] = [ + `# UI5 Web Components Upgrade Guide: v${fromVersion} → v${toVersion}`, + '', + '## Summary', + '', + `- **Versions analyzed:** ${sections.map(s => `v${s.version}`).join(', ')}`, + `- **Breaking changes:** ${totalBreaking}`, + `- **New features:** ${totalFeatures}`, + `- **Bug fixes:** ${totalBugFixes}`, + '', + ]; + + if (!hasBreakingChanges) { + lines.push('> No breaking changes found in this range. This upgrade should be safe.\n'); + } + + // Breaking changes section + const sectionsWithBreaking = sections.filter(s => s.breakingChanges.length > 0); + if (sectionsWithBreaking.length > 0) { + lines.push('## Breaking Changes\n'); + for (const section of sectionsWithBreaking) { + lines.push(`### v${section.version} (${section.date})\n`); + // Group by component + const byComponent = new Map(); + for (const entry of section.breakingChanges) { + const existing = byComponent.get(entry.component) ?? []; + existing.push(entry.description); + byComponent.set(entry.component, existing); + } + for (const [component, descriptions] of byComponent) { + lines.push(`**${component}**`); + for (const desc of descriptions) { + lines.push(`- ${desc}`); + } + lines.push(''); + } + } + } + + // Features section + const sectionsWithFeatures = sections.filter(s => s.features.length > 0); + if (sectionsWithFeatures.length > 0) { + lines.push('## New Features\n'); + for (const section of sectionsWithFeatures) { + lines.push(`### v${section.version} (${section.date})\n`); + for (const entry of section.features) { + lines.push(`- **${entry.component}:** ${entry.description}`); + } + lines.push(''); + } + } + + // Migration checklist — one item per unique component with breaking changes + if (hasBreakingChanges) { + const affectedComponents = new Set(); + for (const section of sections) { + for (const entry of section.breakingChanges) { + affectedComponents.add(entry.component); + } + } + + lines.push('## Migration Checklist\n'); + lines.push('Review and update each affected component in your codebase:\n'); + for (const component of affectedComponents) { + lines.push(`- [ ] Update usage of \`${component}\``); + } + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/src/tools/get_upgrade_guidance/get_upgrade_guidance.ts b/src/tools/get_upgrade_guidance/get_upgrade_guidance.ts new file mode 100644 index 0000000..6143488 --- /dev/null +++ b/src/tools/get_upgrade_guidance/get_upgrade_guidance.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { createTextResponse, handleToolError } from '../../utils.js'; +import type { UpgradeGuidance } from '../../types.js'; +import { + fetchChangelog, + parseChangelogRange, + formatUpgradeGuidance, + resolveLatestVersion, + compareSemver, +} from './changelog_processor.js'; + +const VERSION_RE = /^[\da-zA-Z.-]+$/; + +type GetUpgradeGuidancePayload = { + fromVersion: string; + toVersion?: string; +}; + +export const getUpgradeGuidanceTool = { + name: 'get_upgrade_guidance', + description: + 'Get upgrade guidance and breaking change summary for UI5 Web Components between two versions. Fetches the CHANGELOG from GitHub and returns a structured migration guide.', + inputSchema: { + fromVersion: z + .string() + .describe('Starting version to upgrade from (e.g., "2.6.0")'), + toVersion: z + .string() + .optional() + .default('latest') + .describe('Target version to upgrade to (e.g., "2.8.0" or "latest")'), + }, + handler: async ({ fromVersion, toVersion = 'latest' }: GetUpgradeGuidancePayload) => { + try { + if (!VERSION_RE.test(fromVersion)) { + return createTextResponse( + `Access denied: fromVersion "${fromVersion}" contains invalid characters. Use a semver string (e.g., "2.6.0").` + ); + } + if (!VERSION_RE.test(toVersion)) { + return createTextResponse( + `Access denied: toVersion "${toVersion}" contains invalid characters. Use a semver string (e.g., "2.8.0") or "latest".` + ); + } + + let resolvedToVersion = toVersion; + if (toVersion === 'latest') { + const latest = await resolveLatestVersion(); + if (!latest) { + return createTextResponse( + 'Could not resolve latest version from npm registry. Please specify an explicit version.' + ); + } + resolvedToVersion = latest; + } + + if (compareSemver(fromVersion, resolvedToVersion) >= 0) { + return createTextResponse( + `fromVersion "${fromVersion}" must be less than toVersion "${resolvedToVersion}".` + ); + } + + const changelog = await fetchChangelog(); + if (!changelog) { + return createTextResponse( + 'Could not fetch the UI5 Web Components CHANGELOG from GitHub. ' + + 'Please try again or check https://github.com/SAP/ui5-webcomponents/blob/main/CHANGELOG.md' + ); + } + + const sections = parseChangelogRange(changelog, fromVersion, resolvedToVersion); + + if (sections.length === 0) { + return createTextResponse( + `No changelog entries found between v${fromVersion} and v${resolvedToVersion}. ` + + 'The versions may not exist in the current CHANGELOG (which covers recent releases only). ' + + 'For older version history, see https://github.com/SAP/ui5-webcomponents/blob/main/CHANGELOG.md' + ); + } + + const guidance: UpgradeGuidance = { + fromVersion, + toVersion: resolvedToVersion, + sections, + hasBreakingChanges: sections.some(s => s.breakingChanges.length > 0), + }; + + return createTextResponse(formatUpgradeGuidance(guidance)); + } catch (error) { + return handleToolError(error, 'Error retrieving upgrade guidance'); + } + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 78f0d41..8d8474c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,3 +2,4 @@ export { getComponentApiTool } from './get_component_api/get_component_api.js'; export { getGuidelinesTool } from './get_guidelines/get_guidelines.js'; export { listDocsTool } from './get_docs/list_docs.js'; export { getDocTool } from './get_docs/get_doc.js'; +export { getUpgradeGuidanceTool } from './get_upgrade_guidance/get_upgrade_guidance.js'; diff --git a/src/types.ts b/src/types.ts index bd3ef67..b5553cf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,3 +74,23 @@ export interface NpmPackageData { tarball: string; }; } + +export interface ChangelogEntry { + component: string; + description: string; +} + +export interface VersionSection { + version: string; + date: string; + breakingChanges: ChangelogEntry[]; + features: ChangelogEntry[]; + bugFixes: ChangelogEntry[]; +} + +export interface UpgradeGuidance { + fromVersion: string; + toVersion: string; + sections: VersionSection[]; + hasBreakingChanges: boolean; +} diff --git a/test/tools/changelog_processor.test.ts b/test/tools/changelog_processor.test.ts new file mode 100644 index 0000000..6f8d1e1 --- /dev/null +++ b/test/tools/changelog_processor.test.ts @@ -0,0 +1,220 @@ +import anyTest, { TestFn } from 'ava'; +import { + compareSemver, + parseChangelogRange, + formatUpgradeGuidance, +} from '../../src/tools/get_upgrade_guidance/changelog_processor.js'; +import type { UpgradeGuidance } from '../../src/types.js'; + +const test = anyTest as TestFn; + +// Minimal fixture CHANGELOG string for unit tests +const FIXTURE_CHANGELOG = `# Change Log + +# [2.21.0](https://github.com/UI5/webcomponents/compare/v2.20.0...v2.21.0) (2024-04-01) + + +### Bug Fixes + +* **ui5-input:** fix placeholder overlap ([#100](https://github.com/UI5/webcomponents/issues/100)) ([abc1234](https://github.com/UI5/webcomponents/commit/abc1234)) + + +### Features + +* **ui5-table:** add sticky columns support ([#101](https://github.com/UI5/webcomponents/issues/101)) ([def5678](https://github.com/UI5/webcomponents/commit/def5678)) + + +### BREAKING CHANGES + +* **ui5-button:** The \`design\` property default changed from \`Default\` to \`Transparent\` ([#102](https://github.com/UI5/webcomponents/issues/102)) ([fed9012](https://github.com/UI5/webcomponents/commit/fed9012)) + + +# [2.20.0](https://github.com/UI5/webcomponents/compare/v2.19.0...v2.20.0) (2024-03-15) + + +### Bug Fixes + +* **ui5-dialog:** fix scroll behavior on iOS ([#90](https://github.com/UI5/webcomponents/issues/90)) ([111aaaa](https://github.com/UI5/webcomponents/commit/111aaaa)) + + +### Features + +* **ui5-avatar:** add initials support ([#91](https://github.com/UI5/webcomponents/issues/91)) ([222bbbb](https://github.com/UI5/webcomponents/commit/222bbbb)) + + +# [2.19.0](https://github.com/UI5/webcomponents/compare/v2.18.0...v2.19.0) (2024-02-28) + + +### Bug Fixes + +* **ui5-select:** fix dropdown alignment ([#80](https://github.com/UI5/webcomponents/issues/80)) ([333cccc](https://github.com/UI5/webcomponents/commit/333cccc)) +`; + +// --- compareSemver tests --- + +test('compareSemver: 2.7.0 > 2.6.0', t => { + t.true(compareSemver('2.7.0', '2.6.0') > 0); +}); + +test('compareSemver: 2.6.1 > 2.6.0', t => { + t.true(compareSemver('2.6.1', '2.6.0') > 0); +}); + +test('compareSemver: 2.6.0 < 2.7.0', t => { + t.true(compareSemver('2.6.0', '2.7.0') < 0); +}); + +test('compareSemver: equal versions return 0', t => { + t.is(compareSemver('2.6.0', '2.6.0'), 0); +}); + +test('compareSemver: pre-release 2.0.0-rc.1 < 2.0.0', t => { + t.true(compareSemver('2.0.0-rc.1', '2.0.0') < 0); +}); + +test('compareSemver: pre-release 2.0.0 > 2.0.0-rc.4', t => { + t.true(compareSemver('2.0.0', '2.0.0-rc.4') > 0); +}); + +test('compareSemver: pre-release ordering by string: rc.1 < rc.2', t => { + t.true(compareSemver('2.0.0-rc.1', '2.0.0-rc.2') < 0); +}); + +test('compareSemver: major version difference dominates', t => { + t.true(compareSemver('3.0.0', '2.99.99') > 0); +}); + +// --- parseChangelogRange tests --- + +test('parseChangelogRange: extracts versions within range', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.19.0', '2.21.0'); + t.is(sections.length, 2); + t.is(sections[0]!.version, '2.21.0'); + t.is(sections[1]!.version, '2.20.0'); +}); + +test('parseChangelogRange: excludes fromVersion (exclusive lower bound)', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.19.0', '2.21.0'); + const versions = sections.map(s => s.version); + t.false(versions.includes('2.19.0')); +}); + +test('parseChangelogRange: includes toVersion (inclusive upper bound)', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.19.0', '2.21.0'); + const versions = sections.map(s => s.version); + t.true(versions.includes('2.21.0')); +}); + +test('parseChangelogRange: returns empty array when no versions in range', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.21.0', '2.22.0'); + t.is(sections.length, 0); +}); + +test('parseChangelogRange: returns empty array when range is below available history', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '1.0.0', '1.5.0'); + t.is(sections.length, 0); +}); + +test('parseChangelogRange: parses breaking changes into correct section', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.20.0', '2.21.0'); + t.is(sections.length, 1); + t.is(sections[0]!.breakingChanges.length, 1); + t.is(sections[0]!.breakingChanges[0]!.component, 'ui5-button'); +}); + +test('parseChangelogRange: parses component name from bold prefix', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.20.0', '2.21.0'); + t.is(sections[0]!.features[0]!.component, 'ui5-table'); +}); + +test('parseChangelogRange: strips trailing PR/commit links from description', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.20.0', '2.21.0'); + const desc = sections[0]!.bugFixes[0]!.description; + t.false(desc.includes('(#100)')); + t.false(desc.includes('abc1234')); + t.true(desc.includes('fix placeholder overlap')); +}); + +test('parseChangelogRange: handles version section with no BREAKING CHANGES subsection', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.19.0', '2.20.0'); + t.is(sections.length, 1); + t.is(sections[0]!.breakingChanges.length, 0); +}); + +test('parseChangelogRange: parses date correctly', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.20.0', '2.21.0'); + t.is(sections[0]!.date, '2024-04-01'); +}); + +test('parseChangelogRange: single-version range returns one section', t => { + const sections = parseChangelogRange(FIXTURE_CHANGELOG, '2.20.0', '2.21.0'); + t.is(sections.length, 1); + t.is(sections[0]!.version, '2.21.0'); +}); + +// --- formatUpgradeGuidance tests --- + +const makeGuidance = (overrides: Partial = {}): UpgradeGuidance => ({ + fromVersion: '2.20.0', + toVersion: '2.21.0', + sections: [ + { + version: '2.21.0', + date: '2024-04-01', + breakingChanges: [{ component: 'ui5-button', description: 'Design default changed' }], + features: [{ component: 'ui5-table', description: 'Sticky columns added' }], + bugFixes: [{ component: 'ui5-input', description: 'Placeholder overlap fixed' }], + }, + ], + hasBreakingChanges: true, + ...overrides, +}); + +test('formatUpgradeGuidance: includes from/to version in title', t => { + const result = formatUpgradeGuidance(makeGuidance()); + t.true(result.includes('v2.20.0 → v2.21.0')); +}); + +test('formatUpgradeGuidance: shows breaking changes section when present', t => { + const result = formatUpgradeGuidance(makeGuidance()); + t.true(result.includes('## Breaking Changes')); + t.true(result.includes('ui5-button')); +}); + +test('formatUpgradeGuidance: shows "no breaking changes" message when none', t => { + const guidance = makeGuidance({ + sections: [{ version: '2.21.0', date: '2024-04-01', breakingChanges: [], features: [], bugFixes: [] }], + hasBreakingChanges: false, + }); + const result = formatUpgradeGuidance(guidance); + t.true(result.includes('No breaking changes found')); + t.false(result.includes('## Breaking Changes')); +}); + +test('formatUpgradeGuidance: generates migration checklist', t => { + const result = formatUpgradeGuidance(makeGuidance()); + t.true(result.includes('## Migration Checklist')); + t.true(result.includes('- [ ] Update usage of `ui5-button`')); +}); + +test('formatUpgradeGuidance: includes summary counts', t => { + const result = formatUpgradeGuidance(makeGuidance()); + t.true(result.includes('**Breaking changes:** 1')); + t.true(result.includes('**New features:** 1')); + t.true(result.includes('**Bug fixes:** 1')); +}); + +test('formatUpgradeGuidance: shows features section', t => { + const result = formatUpgradeGuidance(makeGuidance()); + t.true(result.includes('## New Features')); + t.true(result.includes('ui5-table')); +}); + +test('formatUpgradeGuidance: no migration checklist when no breaking changes', t => { + const guidance = makeGuidance({ + sections: [{ version: '2.21.0', date: '2024-04-01', breakingChanges: [], features: [], bugFixes: [] }], + hasBreakingChanges: false, + }); + const result = formatUpgradeGuidance(guidance); + t.false(result.includes('## Migration Checklist')); +});