Skip to content

Commit dec9ebe

Browse files
Merge pull request #52 from BitGo/detect-renamed-endpoints
feat: add detection for renamed endpoints
2 parents 43996f3 + 66dcf6d commit dec9ebe

File tree

7 files changed

+432
-0
lines changed

7 files changed

+432
-0
lines changed

scripts/api-diff.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,102 @@ const changes = {
99
added: {}, // Group by path
1010
removed: {}, // Group by path
1111
modified: {}, // Group by path
12+
renamed: {}, // Track renamed endpoints
1213
components: new Set(), // Track changed components
1314
affectedByComponents: new Map() // Track path/method combinations affected by component changes
1415
};
1516

17+
function checkSimilarity(endpoint1, endpoint2) {
18+
// Required matches: HTTP method and operationId
19+
if (endpoint1.method.toLowerCase() !== endpoint2.method.toLowerCase() ||
20+
!endpoint1.details.operationId ||
21+
!endpoint2.details.operationId ||
22+
endpoint1.details.operationId !== endpoint2.details.operationId) {
23+
return false;
24+
}
25+
26+
let similarityScore = 0;
27+
28+
// Similar response structure
29+
if (JSON.stringify(endpoint1.details.responses) === JSON.stringify(endpoint2.details.responses)) {
30+
similarityScore += 2;
31+
}
32+
33+
// Similar parameters
34+
if (JSON.stringify(endpoint1.details.parameters) === JSON.stringify(endpoint2.details.parameters)) {
35+
similarityScore += 2;
36+
}
37+
38+
// Similar request body
39+
if (JSON.stringify(endpoint1.details.requestBody) === JSON.stringify(endpoint2.details.requestBody)) {
40+
similarityScore += 2;
41+
}
42+
43+
// Similar summary/description if they exist
44+
if (endpoint1.details.summary && endpoint2.details.summary && endpoint1.details.summary === endpoint2.details.summary) {
45+
similarityScore += 1;
46+
}
47+
if (endpoint1.details.description && endpoint1.details.description && endpoint1.details.description === endpoint2.details.description) {
48+
similarityScore += 1;
49+
}
50+
51+
return similarityScore >= 4;
52+
}
53+
54+
function detectRenamedEndpoints() {
55+
const removedEndpoints = [];
56+
const addedEndpoints = [];
57+
58+
// Collect all removed endpoints
59+
Object.entries(changes.removed).forEach(([path, methods]) => {
60+
methods.forEach(method => {
61+
removedEndpoints.push({
62+
path,
63+
method,
64+
details: previousSpec.paths[path][method.toLowerCase()]
65+
});
66+
});
67+
});
68+
69+
// Collect all added endpoints
70+
Object.entries(changes.added).forEach(([path, methods]) => {
71+
methods.forEach(method => {
72+
addedEndpoints.push({
73+
path,
74+
method,
75+
details: currentSpec.paths[path][method.toLowerCase()]
76+
});
77+
});
78+
});
79+
80+
// Compare removed and added endpoints to find similarities
81+
removedEndpoints.forEach(removedEndpoint => {
82+
addedEndpoints.forEach(addedEndpoint => {
83+
if (checkSimilarity(removedEndpoint, addedEndpoint)) {
84+
// Remove from added and removed lists
85+
changes.added[addedEndpoint.path].delete(addedEndpoint.method);
86+
if (changes.added[addedEndpoint.path].size === 0) {
87+
delete changes.added[addedEndpoint.path];
88+
}
89+
90+
changes.removed[removedEndpoint.path].delete(removedEndpoint.method);
91+
if (changes.removed[removedEndpoint.path].size === 0) {
92+
delete changes.removed[removedEndpoint.path];
93+
}
94+
95+
// Add to renamed list
96+
if (!changes.renamed[removedEndpoint.path]) {
97+
changes.renamed[removedEndpoint.path] = {
98+
newPath: addedEndpoint.path,
99+
methods: new Set()
100+
};
101+
}
102+
changes.renamed[removedEndpoint.path].methods.add(addedEndpoint.method);
103+
}
104+
});
105+
});
106+
}
107+
16108
// Helper function to track component references
17109
function findComponentRefs(obj, components, spec = currentSpec) {
18110
if (!obj) return;
@@ -353,6 +445,34 @@ function generateReleaseNotes() {
353445
sections.push(section);
354446
}
355447

448+
449+
// Add renamed endpoints section
450+
if (Object.keys(changes.renamed).length > 0) {
451+
let section = '## Renamed\n';
452+
453+
// Group by old path and new path combination
454+
const groupedRenames = {};
455+
Object.entries(changes.renamed).forEach(([oldPath, {newPath, methods}]) => {
456+
const key = `${oldPath}${newPath}`;
457+
if (!groupedRenames[key]) {
458+
groupedRenames[key] = {
459+
oldPath,
460+
newPath,
461+
methods: new Set()
462+
};
463+
}
464+
methods.forEach(method => groupedRenames[key].methods.add(method));
465+
});
466+
467+
Object.values(groupedRenames)
468+
.sort((a, b) => a.oldPath.localeCompare(b.oldPath))
469+
.forEach(({oldPath, newPath, methods}) => {
470+
const methodsList = Array.from(methods).sort().join('] [');
471+
section += `- [${methodsList}] \`${oldPath}\` → \`${newPath}\`\n`;
472+
});
473+
sections.push(section);
474+
}
475+
356476
// Sort sections alphabetically and combine
357477
sections.sort((a, b) => {
358478
const titleA = a.split('\n')[0];
@@ -367,6 +487,7 @@ function generateReleaseNotes() {
367487
compareComponents();
368488
findAffectedPaths();
369489
comparePaths();
490+
detectRenamedEndpoints();
370491
const releaseDescription = generateReleaseNotes();
371492

372493
// Write release notes to markdown file
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"openapi": "3.0.3",
3+
"info": {
4+
"title": "Test API",
5+
"description": "A sample API to for testing",
6+
"version": "1.0.0"
7+
},
8+
"servers": [
9+
{
10+
"url": "https://api.example.com/v1"
11+
}
12+
],
13+
"paths": {
14+
"/user": {
15+
"post": {
16+
"summary": "Creates a new user profile",
17+
"operationId": "createUser",
18+
"requestBody": {
19+
"required": true,
20+
"content": {
21+
"application/json": {
22+
"schema": {
23+
"$ref": "#/components/schemas/NewUser"
24+
}
25+
}
26+
}
27+
},
28+
"responses": {
29+
"201": {
30+
"description": "User created successfully",
31+
"content": {
32+
"application/json": {
33+
"schema": {
34+
"$ref": "#/components/schemas/User"
35+
}
36+
}
37+
}
38+
}
39+
}
40+
}
41+
}
42+
},
43+
"components": {
44+
"schemas": {
45+
"User": {
46+
"type": "object",
47+
"properties": {
48+
"id": {
49+
"type": "string"
50+
},
51+
"name": {
52+
"type": "string"
53+
},
54+
"email": {
55+
"type": "string"
56+
}
57+
}
58+
},
59+
"NewUser": {
60+
"type": "object",
61+
"required": [
62+
"name",
63+
"email"
64+
],
65+
"properties": {
66+
"name": {
67+
"type": "string"
68+
},
69+
"email": {
70+
"type": "string"
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
## Added
2+
- [POST] `/user`
3+
4+
## Removed
5+
- [POST] `/users`
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"openapi": "3.0.3",
3+
"info": {
4+
"title": "Test API",
5+
"description": "A sample API to for testing",
6+
"version": "1.0.0"
7+
},
8+
"servers": [
9+
{
10+
"url": "https://api.example.com/v1"
11+
}
12+
],
13+
"paths": {
14+
"/users": {
15+
"post": {
16+
"summary": "Creates a new user profile",
17+
"operationId": "createNewUser",
18+
"requestBody": {
19+
"required": true,
20+
"content": {
21+
"application/json": {
22+
"schema": {
23+
"$ref": "#/components/schemas/NewUser"
24+
}
25+
}
26+
}
27+
},
28+
"responses": {
29+
"201": {
30+
"description": "User created successfully",
31+
"content": {
32+
"application/json": {
33+
"schema": {
34+
"$ref": "#/components/schemas/User"
35+
}
36+
}
37+
}
38+
}
39+
}
40+
}
41+
}
42+
},
43+
"components": {
44+
"schemas": {
45+
"User": {
46+
"type": "object",
47+
"properties": {
48+
"id": {
49+
"type": "string"
50+
},
51+
"name": {
52+
"type": "string"
53+
},
54+
"email": {
55+
"type": "string"
56+
}
57+
}
58+
},
59+
"NewUser": {
60+
"type": "object",
61+
"required": [
62+
"name",
63+
"email"
64+
],
65+
"properties": {
66+
"name": {
67+
"type": "string"
68+
},
69+
"email": {
70+
"type": "string"
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{
2+
"openapi": "3.0.3",
3+
"info": {
4+
"title": "Test API",
5+
"description": "A sample API to for testing",
6+
"version": "1.0.0"
7+
},
8+
"servers": [
9+
{
10+
"url": "https://api.example.com/v1"
11+
}
12+
],
13+
"paths": {
14+
"/user": {
15+
"post": {
16+
"summary": "Creates a new user profile",
17+
"operationId": "createUser",
18+
"requestBody": {
19+
"required": true,
20+
"content": {
21+
"application/json": {
22+
"schema": {
23+
"$ref": "#/components/schemas/NewUser"
24+
}
25+
}
26+
}
27+
},
28+
"responses": {
29+
"201": {
30+
"description": "User created successfully",
31+
"content": {
32+
"application/json": {
33+
"schema": {
34+
"$ref": "#/components/schemas/User"
35+
}
36+
}
37+
}
38+
}
39+
}
40+
}
41+
}
42+
},
43+
"components": {
44+
"schemas": {
45+
"User": {
46+
"type": "object",
47+
"properties": {
48+
"id": {
49+
"type": "string"
50+
},
51+
"name": {
52+
"type": "string"
53+
},
54+
"email": {
55+
"type": "string"
56+
}
57+
}
58+
},
59+
"NewUser": {
60+
"type": "object",
61+
"required": [
62+
"name",
63+
"email"
64+
],
65+
"properties": {
66+
"name": {
67+
"type": "string"
68+
},
69+
"email": {
70+
"type": "string"
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
## Renamed
2+
- [POST] `/users``/user`

0 commit comments

Comments
 (0)