From 714ed4792779ce254e4b161addc0f5eb4719f057 Mon Sep 17 00:00:00 2001 From: Benson Shen Date: Wed, 15 Jan 2025 12:41:19 -0500 Subject: [PATCH] feat: enhance release description - enhanced release description generation - added tests --- scripts/api-diff.js | 225 +++++++++++----- .../collapse-modified-components/current.json | 240 ++++++++++++++++++ .../collapse-modified-components/expected.md | 19 ++ .../previous.json | 240 ++++++++++++++++++ tests/fixtures/modified-component/expected.md | 2 +- .../modified-nested-components/current.json | 78 ++++++ .../modified-nested-components/expected.md | 3 + .../modified-nested-components/previous.json | 77 ++++++ 8 files changed, 824 insertions(+), 60 deletions(-) create mode 100644 tests/fixtures/collapse-modified-components/current.json create mode 100644 tests/fixtures/collapse-modified-components/expected.md create mode 100644 tests/fixtures/collapse-modified-components/previous.json create mode 100644 tests/fixtures/modified-nested-components/current.json create mode 100644 tests/fixtures/modified-nested-components/expected.md create mode 100644 tests/fixtures/modified-nested-components/previous.json diff --git a/scripts/api-diff.js b/scripts/api-diff.js index d8247df..5297e5e 100644 --- a/scripts/api-diff.js +++ b/scripts/api-diff.js @@ -14,13 +14,21 @@ const changes = { }; // Helper function to track component references -function findComponentRefs(obj, components) { +function findComponentRefs(obj, components, spec = currentSpec) { if (!obj) return; if (typeof obj === 'object') { if (obj['$ref'] && obj['$ref'].startsWith('#/components/')) { - components.add(obj['$ref'].split('/').pop()); + const componentName = obj['$ref'].split('/').pop(); + components.add(componentName); + + // Follow the reference to check nested components + const [_, category, name] = obj['$ref'].split('/'); + const referencedComponent = spec.components?.[category]?.[name]; + if (referencedComponent) { + findComponentRefs(referencedComponent, components, spec); + } } - Object.values(obj).forEach(value => findComponentRefs(value, components)); + Object.values(obj).forEach(value => findComponentRefs(value, components, spec)); } } @@ -31,9 +39,18 @@ function compareComponents() { for (const [category, components] of Object.entries(currComps)) { for (const [name, def] of Object.entries(components)) { - if (!prevComps[category]?.[name] || - JSON.stringify(prevComps[category][name]) !== JSON.stringify(def)) { + const prevDef = prevComps[category]?.[name]; + if (!prevDef || JSON.stringify(prevDef) !== JSON.stringify(def)) { changes.components.add(name); + + // Also check which components reference this changed component + Object.entries(currComps[category] || {}).forEach(([otherName, otherDef]) => { + const refsSet = new Set(); + findComponentRefs(otherDef, refsSet); + if (refsSet.has(name)) { + changes.components.add(otherName); + } + }); } } } @@ -110,6 +127,53 @@ function getChanges(previous, current) { return changes; } +// Helper function to check if a schema references a component or its dependencies +function schemaReferencesComponent(schema, componentName, visitedRefs = new Set()) { + if (!schema) return false; + + // Prevent infinite recursion + const schemaKey = JSON.stringify(schema); + if (visitedRefs.has(schemaKey)) return false; + visitedRefs.add(schemaKey); + + // Direct reference check + if (schema.$ref) { + const refPath = schema.$ref; + if (refPath === `#/components/schemas/${componentName}`) return true; + + // Follow the reference to check nested components + const [_, category, name] = refPath.split('/'); + const referencedComponent = currentSpec.components?.[category]?.[name]; + if (referencedComponent && schemaReferencesComponent(referencedComponent, componentName, visitedRefs)) { + return true; + } + } + + // Check combiners (oneOf, anyOf, allOf) + for (const combiner of ['oneOf', 'anyOf', 'allOf']) { + if (schema[combiner] && Array.isArray(schema[combiner])) { + if (schema[combiner].some(s => schemaReferencesComponent(s, componentName, visitedRefs))) { + return true; + } + } + } + + // Check properties if it's an object + if (schema.properties) { + if (Object.values(schema.properties).some(prop => + schemaReferencesComponent(prop, componentName, visitedRefs))) { + return true; + } + } + + // Check array items + if (schema.items && schemaReferencesComponent(schema.items, componentName, visitedRefs)) { + return true; + } + + return false; +} + // Helper function to detect where a component is used in an endpoint function findComponentUsage(details, componentName) { const usage = []; @@ -117,26 +181,37 @@ function findComponentUsage(details, componentName) { // Check parameters if (details.parameters) { const hasComponent = details.parameters.some(p => - (p.$ref && p.$ref.includes(componentName)) || - (p.schema && p.schema.$ref && p.schema.$ref.includes(componentName)) + (p.$ref && schemaReferencesComponent({ $ref: p.$ref }, componentName)) || + (p.schema && schemaReferencesComponent(p.schema, componentName)) ); if (hasComponent) usage.push('parameters'); } // Check requestBody - if (details.requestBody && - details.requestBody.content && - Object.values(details.requestBody.content).some(c => - c.schema && c.schema.$ref && c.schema.$ref.includes(componentName))) { - usage.push('requestBody'); + if (details.requestBody) { + let hasComponent = false; + if (details.requestBody.$ref) { + hasComponent = schemaReferencesComponent({ $ref: details.requestBody.$ref }, componentName); + } else if (details.requestBody.content) { + hasComponent = Object.values(details.requestBody.content).some(c => + c.schema && schemaReferencesComponent(c.schema, componentName) + ); + } + if (hasComponent) usage.push('requestBody'); } // Check responses - if (details.responses && - Object.values(details.responses).some(r => - r.content && Object.values(r.content).some(c => - c.schema && c.schema.$ref && c.schema.$ref.includes(componentName)))) { - usage.push('responses'); + if (details.responses) { + const hasComponent = Object.entries(details.responses).some(([code, r]) => { + if (r.$ref) return schemaReferencesComponent({ $ref: r.$ref }, componentName); + if (r.content) { + return Object.values(r.content).some(c => + c.schema && schemaReferencesComponent(c.schema, componentName) + ); + } + return false; + }); + if (hasComponent) usage.push('responses'); } return usage; @@ -160,56 +235,88 @@ function generateReleaseNotes() { sections.push(section); } - // Modified endpoints - if (Object.keys(changes.modified).length > 0 || Object.keys(changes.affectedByComponents).length > 0) { - let section = '## Modified\n'; + // Helper function to generate route modification details + function generateModifiedRouteDetails(path, changes) { + let details = ''; + const methodsToProcess = new Set(); - // Combine and sort all modified paths - const allModifiedPaths = new Set([ - ...Object.keys(changes.modified), - ...Object.keys(changes.affectedByComponents) - ]); + // Collect all affected methods + if (changes.modified[path]) { + changes.modified[path].forEach(({method}) => methodsToProcess.add(method)); + } + if (changes.affectedByComponents[path]) { + changes.affectedByComponents[path].methods.forEach(method => methodsToProcess.add(method)); + } - Array.from(allModifiedPaths) + // Process each method + Array.from(methodsToProcess) .sort() - .forEach(path => { - // Handle both direct modifications and component changes for each path - const methodsToProcess = new Set(); + .forEach(method => { + details += `- [${method}] \`${path}\`\n`; - // Collect all affected methods - if (changes.modified[path]) { - changes.modified[path].forEach(({method}) => methodsToProcess.add(method)); + // Add direct changes + const directChanges = changes.modified[path]?.find(m => m.method === method); + if (directChanges) { + directChanges.changes.sort().forEach(change => { + details += ` - ${change}\n`; + }); } - if (changes.affectedByComponents[path]) { - changes.affectedByComponents[path].methods.forEach(method => methodsToProcess.add(method)); + + // Add component changes + if (changes.affectedByComponents[path]?.methods.has(method)) { + const methodDetails = currentSpec.paths[path][method.toLowerCase()]; + Array.from(changes.affectedByComponents[path].components) + .sort() + .forEach(component => { + const usageLocations = findComponentUsage(methodDetails, component).sort(); + details += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`; + }); } + }); + return details; + } - // Process each method - Array.from(methodsToProcess) - .sort() - .forEach(method => { - section += `- [${method}] \`${path}\`\n`; - - // Add direct changes - const directChanges = changes.modified[path]?.find(m => m.method === method); - if (directChanges) { - directChanges.changes.sort().forEach(change => { - section += ` - ${change}\n`; - }); - } - - // Add component changes - if (changes.affectedByComponents[path]?.methods.has(method)) { - const methodDetails = currentSpec.paths[path][method.toLowerCase()]; - Array.from(changes.affectedByComponents[path].components) - .sort() - .forEach(component => { - const usageLocations = findComponentUsage(methodDetails, component).sort(); - section += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`; - }); - } - }); + // Modified endpoints + if (Object.keys(changes.modified).length > 0 || Object.keys(changes.affectedByComponents).length > 0) { + let section = '## Modified\n'; + + // First show all directly modified paths + const directlyModifiedPaths = Object.keys(changes.modified).sort(); + directlyModifiedPaths.forEach(path => { + section += generateModifiedRouteDetails(path, changes); + }); + + // Then show component-affected paths (but not ones that were directly modified) + const componentAffectedEntries = Object.entries(changes.affectedByComponents) + .filter(([path]) => !changes.modified[path]) // Only paths not already shown above + .flatMap(([path, details]) => + Array.from(details.methods).map(method => ({path, method})) + ) + .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); + + // Show first 5 component-affected method-path combinations + const visibleEntries = componentAffectedEntries.slice(0, 5); + const processedPaths = new Set(); + + visibleEntries.forEach(({path}) => { + if (!processedPaths.has(path)) { + section += generateModifiedRouteDetails(path, changes); + processedPaths.add(path); + } + }); + + // Collapse any remaining entries + const remainingEntries = componentAffectedEntries.slice(5); + if (remainingEntries.length > 0) { + section += '\n
Show more routes affected by component changes...\n\n'; + const remainingPaths = new Set(); + remainingEntries.forEach(({path}) => remainingPaths.add(path)); + Array.from(remainingPaths).sort().forEach(path => { + section += generateModifiedRouteDetails(path, changes); }); + section += '
\n'; + } + sections.push(section); } diff --git a/tests/fixtures/collapse-modified-components/current.json b/tests/fixtures/collapse-modified-components/current.json new file mode 100644 index 0000000..145520a --- /dev/null +++ b/tests/fixtures/collapse-modified-components/current.json @@ -0,0 +1,240 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "description": "A sample API for testing" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/user": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test1": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test2": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test3": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test4": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test5": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test6": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "username": { + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + } + }, + "Email": { + "type": "string", + "format": "email" + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/collapse-modified-components/expected.md b/tests/fixtures/collapse-modified-components/expected.md new file mode 100644 index 0000000..99c062e --- /dev/null +++ b/tests/fixtures/collapse-modified-components/expected.md @@ -0,0 +1,19 @@ +## Modified +- [POST] `/user` + - `User` modified in responses +- [POST] `/user/test1` + - `User` modified in responses +- [POST] `/user/test2` + - `User` modified in responses +- [POST] `/user/test3` + - `User` modified in responses +- [POST] `/user/test4` + - `User` modified in responses + +
Show more routes affected by component changes... + +- [POST] `/user/test5` + - `User` modified in responses +- [POST] `/user/test6` + - `User` modified in responses +
diff --git a/tests/fixtures/collapse-modified-components/previous.json b/tests/fixtures/collapse-modified-components/previous.json new file mode 100644 index 0000000..474c40f --- /dev/null +++ b/tests/fixtures/collapse-modified-components/previous.json @@ -0,0 +1,240 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "description": "A sample API for testing" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/user": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test1": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test2": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test3": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test4": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test5": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + }, + "/user/test6": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + } + }, + "Email": { + "type": "string", + "format": "email" + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/modified-component/expected.md b/tests/fixtures/modified-component/expected.md index 33ef17f..3ec4df5 100644 --- a/tests/fixtures/modified-component/expected.md +++ b/tests/fixtures/modified-component/expected.md @@ -1,4 +1,4 @@ ## Modified - [POST] `/user` - `NewUser` modified in requestBody - - `User` modified in requestBody, responses + - `User` modified in responses diff --git a/tests/fixtures/modified-nested-components/current.json b/tests/fixtures/modified-nested-components/current.json new file mode 100644 index 0000000..a041f8d --- /dev/null +++ b/tests/fixtures/modified-nested-components/current.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "description": "A sample API for testing" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/user": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + } + }, + "Email": { + "type": "string", + "format": "email" + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/modified-nested-components/expected.md b/tests/fixtures/modified-nested-components/expected.md new file mode 100644 index 0000000..46e8ee1 --- /dev/null +++ b/tests/fixtures/modified-nested-components/expected.md @@ -0,0 +1,3 @@ +## Modified +- [POST] `/user` + - `User` modified in responses diff --git a/tests/fixtures/modified-nested-components/previous.json b/tests/fixtures/modified-nested-components/previous.json new file mode 100644 index 0000000..ded4bdc --- /dev/null +++ b/tests/fixtures/modified-nested-components/previous.json @@ -0,0 +1,77 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API", + "description": "A sample API for testing" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/user": { + "post": { + "summary": "Create a new user profile", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + } + }, + "Email": { + "type": "string" + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +}