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
121 changes: 121 additions & 0 deletions scripts/api-diff.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,102 @@ const changes = {
added: {}, // Group by path
removed: {}, // Group by path
modified: {}, // Group by path
renamed: {}, // Track renamed endpoints
components: new Set(), // Track changed components
affectedByComponents: new Map() // Track path/method combinations affected by component changes
};

function checkSimilarity(endpoint1, endpoint2) {
// Required matches: HTTP method and operationId
if (endpoint1.method.toLowerCase() !== endpoint2.method.toLowerCase() ||
!endpoint1.details.operationId ||
!endpoint2.details.operationId ||
endpoint1.details.operationId !== endpoint2.details.operationId) {
return false;
}

let similarityScore = 0;

// Similar response structure
if (JSON.stringify(endpoint1.details.responses) === JSON.stringify(endpoint2.details.responses)) {
similarityScore += 2;
}

// Similar parameters
if (JSON.stringify(endpoint1.details.parameters) === JSON.stringify(endpoint2.details.parameters)) {
similarityScore += 2;
}

// Similar request body
if (JSON.stringify(endpoint1.details.requestBody) === JSON.stringify(endpoint2.details.requestBody)) {
similarityScore += 2;
}

// Similar summary/description if they exist
if (endpoint1.details.summary && endpoint2.details.summary && endpoint1.details.summary === endpoint2.details.summary) {
similarityScore += 1;
}
if (endpoint1.details.description && endpoint1.details.description && endpoint1.details.description === endpoint2.details.description) {
similarityScore += 1;
}

return similarityScore >= 4;
}

function detectRenamedEndpoints() {
const removedEndpoints = [];
const addedEndpoints = [];

// Collect all removed endpoints
Object.entries(changes.removed).forEach(([path, methods]) => {
methods.forEach(method => {
removedEndpoints.push({
path,
method,
details: previousSpec.paths[path][method.toLowerCase()]
});
});
});

// Collect all added endpoints
Object.entries(changes.added).forEach(([path, methods]) => {
methods.forEach(method => {
addedEndpoints.push({
path,
method,
details: currentSpec.paths[path][method.toLowerCase()]
});
});
});

// Compare removed and added endpoints to find similarities
removedEndpoints.forEach(removedEndpoint => {
addedEndpoints.forEach(addedEndpoint => {
if (checkSimilarity(removedEndpoint, addedEndpoint)) {
// Remove from added and removed lists
changes.added[addedEndpoint.path].delete(addedEndpoint.method);
if (changes.added[addedEndpoint.path].size === 0) {
delete changes.added[addedEndpoint.path];
}

changes.removed[removedEndpoint.path].delete(removedEndpoint.method);
if (changes.removed[removedEndpoint.path].size === 0) {
delete changes.removed[removedEndpoint.path];
}

// Add to renamed list
if (!changes.renamed[removedEndpoint.path]) {
changes.renamed[removedEndpoint.path] = {
newPath: addedEndpoint.path,
methods: new Set()
};
}
changes.renamed[removedEndpoint.path].methods.add(addedEndpoint.method);
}
});
});
}

// Helper function to track component references
function findComponentRefs(obj, components, spec = currentSpec) {
if (!obj) return;
Expand Down Expand Up @@ -353,6 +445,34 @@ function generateReleaseNotes() {
sections.push(section);
}


// Add renamed endpoints section
if (Object.keys(changes.renamed).length > 0) {
let section = '## Renamed\n';

// Group by old path and new path combination
const groupedRenames = {};
Object.entries(changes.renamed).forEach(([oldPath, {newPath, methods}]) => {
const key = `${oldPath}→${newPath}`;
if (!groupedRenames[key]) {
groupedRenames[key] = {
oldPath,
newPath,
methods: new Set()
};
}
methods.forEach(method => groupedRenames[key].methods.add(method));
});

Object.values(groupedRenames)
.sort((a, b) => a.oldPath.localeCompare(b.oldPath))
.forEach(({oldPath, newPath, methods}) => {
const methodsList = Array.from(methods).sort().join('] [');
section += `- [${methodsList}] \`${oldPath}\` → \`${newPath}\`\n`;
});
sections.push(section);
}

// Sort sections alphabetically and combine
sections.sort((a, b) => {
const titleA = a.split('\n')[0];
Expand All @@ -367,6 +487,7 @@ function generateReleaseNotes() {
compareComponents();
findAffectedPaths();
comparePaths();
detectRenamedEndpoints();
const releaseDescription = generateReleaseNotes();

// Write release notes to markdown file
Expand Down
76 changes: 76 additions & 0 deletions tests/fixtures/renamed-route-new-operation-id/current.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"description": "A sample API to for testing",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1"
}
],
"paths": {
"/user": {
"post": {
"summary": "Creates a new user profile",
"operationId": "createUser",
"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": {
"type": "string"
}
}
},
"NewUser": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}
5 changes: 5 additions & 0 deletions tests/fixtures/renamed-route-new-operation-id/expected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Added
- [POST] `/user`

## Removed
- [POST] `/users`
76 changes: 76 additions & 0 deletions tests/fixtures/renamed-route-new-operation-id/previous.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"description": "A sample API to for testing",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1"
}
],
"paths": {
"/users": {
"post": {
"summary": "Creates a new user profile",
"operationId": "createNewUser",
"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": {
"type": "string"
}
}
},
"NewUser": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}
76 changes: 76 additions & 0 deletions tests/fixtures/renamed-route/current.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"openapi": "3.0.3",
"info": {
"title": "Test API",
"description": "A sample API to for testing",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com/v1"
}
],
"paths": {
"/user": {
"post": {
"summary": "Creates a new user profile",
"operationId": "createUser",
"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": {
"type": "string"
}
}
},
"NewUser": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}
2 changes: 2 additions & 0 deletions tests/fixtures/renamed-route/expected.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
## Renamed
- [POST] `/users` → `/user`
Loading