Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 76 additions & 82 deletions scripts/api-diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const changes = {
removed: {}, // Group by path
modified: {}, // Group by path
components: new Set(), // Track changed components
affectedByComponents: {} // Track paths affected by component changes
affectedByComponents: new Map() // Track path/method combinations affected by component changes
};

// Helper function to track component references
Expand Down Expand Up @@ -61,22 +61,21 @@ function findAffectedPaths() {
if (changes.components.size === 0) return;

Object.entries(currentSpec.paths || {}).forEach(([path, methods]) => {
const affectedMethods = [];
Object.entries(methods).forEach(([method, details]) => {
const usedComponents = new Set();
findComponentRefs(details, usedComponents);

for (const comp of usedComponents) {
if (changes.components.has(comp)) {
affectedMethods.push(method.toUpperCase());
if (!changes.affectedByComponents[path]) {
changes.affectedByComponents[path] = {
methods: new Set(),
const key = `${path}::${method.toUpperCase()}`;
if (!changes.affectedByComponents.has(key)) {
changes.affectedByComponents.set(key, {
path,
method: method.toUpperCase(),
components: new Set()
};
});
}
changes.affectedByComponents[path].methods.add(method.toUpperCase());
changes.affectedByComponents[path].components.add(comp);
changes.affectedByComponents.get(key).components.add(comp);
}
}
});
Expand Down Expand Up @@ -241,9 +240,6 @@ function findComponentUsage(details, componentName) {

// Generate markdown release notes
function generateReleaseNotes() {
let releaseDescription = '';


const sections = [];

// Added endpoints
Expand All @@ -257,84 +253,85 @@ function generateReleaseNotes() {
sections.push(section);
}

// Helper function to generate route modification details
function generateModifiedRouteDetails(path, changes) {
let details = '';
const methodsToProcess = new Set();
// Modified endpoints
if (Object.keys(changes.modified).length > 0 || changes.affectedByComponents.size > 0) {
let section = '## Modified\n';

// First show all directly modified paths
Object.entries(changes.modified)
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([path, methodChanges]) => {
methodChanges
.sort((a, b) => a.method.localeCompare(b.method))
.forEach(({method, changes: methodChanges}) => {
section += `- [${method}] \`${path}\`\n`;
methodChanges.sort().forEach(change => {
section += ` - ${change}\n`;
});
});
});

// Then handle component-affected paths
const componentAffectedPaths = new Map();

// 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));
for (const [_, value] of changes.affectedByComponents) {
const { path, method, components } = value;
// Skip if this path/method was already shown in direct modifications
if (changes.modified[path]?.some(m => m.method === method)) continue;

if (!componentAffectedPaths.has(path)) {
componentAffectedPaths.set(path, new Map());
}
componentAffectedPaths.get(path).set(method, Array.from(components));
}

// Process each method
Array.from(methodsToProcess)
.sort()
.forEach(method => {
details += `- [${method}] \`${path}\`\n`;

// Add direct changes
const directChanges = changes.modified[path]?.find(m => m.method === method);
if (directChanges) {
directChanges.changes.sort().forEach(change => {
details += ` - ${change}\n`;
});
}
// Show first 5 component-affected paths
const sortedComponentPaths = Array.from(componentAffectedPaths.keys()).sort();
const visibleComponentPaths = sortedComponentPaths.slice(0, 5);

// Add a blank line before component-affected paths if there were direct modifications
if (Object.keys(changes.modified).length > 0 && visibleComponentPaths.length > 0) {
section += '\n';
}

// Add component changes
if (changes.affectedByComponents[path]?.methods.has(method)) {
visibleComponentPaths.forEach(path => {
const methods = componentAffectedPaths.get(path);
Array.from(methods.entries())
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([method, components]) => {
section += `- [${method}] \`${path}\`\n`;
const methodDetails = currentSpec.paths[path][method.toLowerCase()];
Array.from(changes.affectedByComponents[path].components)
components
.sort()
.forEach(component => {
const usageLocations = findComponentUsage(methodDetails, component).sort();
details += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`;
if (usageLocations.length > 0) {
section += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`;
}
});
}
});
return details;
}

// 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) {
// Collapse remaining component-affected paths
const remainingPaths = sortedComponentPaths.slice(5);
if (remainingPaths.length > 0) {
section += '\n<details><summary>Show more routes affected by component changes...</summary>\n\n';
const remainingPaths = new Set();
remainingEntries.forEach(({path}) => remainingPaths.add(path));
Array.from(remainingPaths).sort().forEach(path => {
section += generateModifiedRouteDetails(path, changes);
remainingPaths.forEach(path => {
const methods = componentAffectedPaths.get(path);
Array.from(methods.entries())
.sort(([a], [b]) => a.localeCompare(b))
.forEach(([method, components]) => {
section += `- [${method}] \`${path}\`\n`;
const methodDetails = currentSpec.paths[path][method.toLowerCase()];
components
.sort()
.forEach(component => {
const usageLocations = findComponentUsage(methodDetails, component).sort();
if (usageLocations.length > 0) {
section += ` - \`${component}\` modified in ${usageLocations.join(', ')}\n`;
}
});
});
});
section += '</details>\n';
}
Expand All @@ -353,17 +350,14 @@ function generateReleaseNotes() {
sections.push(section);
}


// Sort sections alphabetically and combine
sections.sort((a, b) => {
const titleA = a.split('\n')[0];
const titleB = b.split('\n')[0];
return titleA.localeCompare(titleB);
});

releaseDescription += sections.join('\n');

return releaseDescription;
return sections.join('\n');
}

// Main execution
Expand Down
104 changes: 104 additions & 0 deletions tests/fixtures/modified-components-in-multiple-methods/current.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{
"openapi": "3.0.0",
"info": {
"title": "Test API",
"description": "A sample API for testing"
},
"servers": [
{
"url": "https://api.example.com/v1"
}
],
"paths": {
"/user": {
"get": {
"summary": "Get a list of users",
"responses": {
"200": {
"description": "A list of users",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/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/UserCreated"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"User": {
"type": "object",
"properties": {
"userId": {
"type": "string"
},
"name": {
"type": "string"
},
"email": {
"$ref": "#/components/schemas/Email"
}
}
},
"UserCreated": {
"type": "object",
"properties": {
"userId": {
"type": "string"
}
}
},
"Email": {
"type": "string",
"format": "email"
},
"NewUser": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Modified
- [GET] `/user`
- `User` modified in responses
- [POST] `/user`
- `UserCreated` modified in responses
Loading