Skip to content

Commit 90b277a

Browse files
committed
feat(Infra) if a file was moved, ensure there is a redirect
1 parent 8f7a5c5 commit 90b277a

File tree

4 files changed

+749
-0
lines changed

4 files changed

+749
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
name: Check Redirects on File Rename
2+
3+
on:
4+
pull_request:
5+
branches: [master]
6+
7+
jobs:
8+
check-redirects:
9+
name: Check redirects for renamed files
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: read
13+
pull-requests: write
14+
15+
steps:
16+
- name: Checkout repository
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0 # Need full history for git diff
20+
21+
- name: Install bun
22+
uses: oven-sh/setup-bun@v2
23+
with:
24+
bun-version: latest
25+
26+
- name: Set up git for diff
27+
run: |
28+
git fetch origin ${{ github.event.pull_request.base.ref }}:${{ github.event.pull_request.base.ref }}
29+
echo "GITHUB_BASE_REF=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
30+
echo "GITHUB_BASE_SHA=$(git rev-parse origin/${{ github.event.pull_request.base.ref }})" >> $GITHUB_ENV
31+
echo "GITHUB_SHA=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV
32+
33+
- name: Run redirect validation
34+
id: validate
35+
continue-on-error: true
36+
run: |
37+
set +e
38+
OUTPUT=$(bun scripts/check-redirects-on-rename.ts 2>&1)
39+
EXIT_CODE=$?
40+
set -e
41+
42+
echo "$OUTPUT"
43+
44+
# Extract JSON output if present
45+
if echo "$OUTPUT" | grep -q "---JSON_OUTPUT---"; then
46+
JSON_OUTPUT=$(echo "$OUTPUT" | sed -n '/---JSON_OUTPUT---/,/---JSON_OUTPUT---/p' | sed '1d;$d')
47+
echo "validation_result<<EOF" >> $GITHUB_OUTPUT
48+
echo "$JSON_OUTPUT" >> $GITHUB_OUTPUT
49+
echo "EOF" >> $GITHUB_OUTPUT
50+
echo "has_results=true" >> $GITHUB_OUTPUT
51+
else
52+
echo "has_results=false" >> $GITHUB_OUTPUT
53+
fi
54+
55+
# Save exit code
56+
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
57+
58+
- name: Post comment if redirects are missing
59+
if: steps.validate.outputs.exit_code == '1' && steps.validate.outputs.has_results == 'true'
60+
uses: actions/github-script@v7
61+
with:
62+
script: |
63+
const validationResultJson = `${{ steps.validate.outputs.validation_result }}`;
64+
let validationResult;
65+
try {
66+
validationResult = JSON.parse(validationResultJson);
67+
} catch (e) {
68+
console.error('Failed to parse validation result:', e);
69+
return;
70+
}
71+
72+
const missingRedirects = validationResult.missingRedirects || [];
73+
74+
if (missingRedirects.length === 0) {
75+
return;
76+
}
77+
78+
// Group by redirects array type
79+
const devDocsRedirects = missingRedirects.filter(mr => mr.isDeveloperDocs);
80+
const userDocsRedirects = missingRedirects.filter(mr => !mr.isDeveloperDocs);
81+
82+
let comment = '## ⚠️ Missing Redirects Detected\n\n';
83+
comment += 'This PR renames or moves MDX files, but some redirects may be missing from `redirects.js`.\n\n';
84+
comment += 'Please add the following redirects to ensure old URLs continue to work:\n\n';
85+
86+
if (userDocsRedirects.length > 0) {
87+
comment += '### User Docs Redirects (userDocsRedirects array)\n\n';
88+
comment += '```javascript\n';
89+
userDocsRedirects.forEach(mr => {
90+
comment += ` {\n`;
91+
comment += ` source: '${mr.oldUrl}',\n`;
92+
comment += ` destination: '${mr.newUrl}',\n`;
93+
comment += ` },\n`;
94+
});
95+
comment += '```\n\n';
96+
}
97+
98+
if (devDocsRedirects.length > 0) {
99+
comment += '### Developer Docs Redirects (developerDocsRedirects array)\n\n';
100+
comment += '```javascript\n';
101+
devDocsRedirects.forEach(mr => {
102+
comment += ` {\n`;
103+
comment += ` source: '${mr.oldUrl}',\n`;
104+
comment += ` destination: '${mr.newUrl}',\n`;
105+
comment += ` },\n`;
106+
});
107+
comment += '```\n\n';
108+
}
109+
110+
comment += '---\n';
111+
comment += '_Note: This is a warning and will not block your PR. However, adding redirects ensures old links continue to work._\n';
112+
113+
// Check for existing comments from this action
114+
const {data: comments} = await github.rest.issues.listComments({
115+
owner: context.repo.owner,
116+
repo: context.repo.repo,
117+
issue_number: context.issue.number,
118+
});
119+
120+
const existingComment = comments.find(comment =>
121+
comment.user.type === 'Bot' &&
122+
comment.body.includes('Missing Redirects Detected')
123+
);
124+
125+
if (existingComment) {
126+
// Update existing comment
127+
await github.rest.issues.updateComment({
128+
owner: context.repo.owner,
129+
repo: context.repo.repo,
130+
comment_id: existingComment.id,
131+
body: comment,
132+
});
133+
} else {
134+
// Create new comment
135+
await github.rest.issues.createComment({
136+
owner: context.repo.owner,
137+
repo: context.repo.repo,
138+
issue_number: context.issue.number,
139+
body: comment,
140+
});
141+
}
File renamed without changes.
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
5+
6+
import {
7+
filePathToUrls,
8+
parseRedirectsJs,
9+
redirectMatches,
10+
} from './check-redirects-on-rename';
11+
12+
// Mock redirects fixture
13+
const mockRedirectsJs = `
14+
const isDeveloperDocs = !!process.env.NEXT_PUBLIC_DEVELOPER_DOCS;
15+
16+
const developerDocsRedirects = [
17+
{
18+
source: '/sdk/old-path/',
19+
destination: '/sdk/new-path/',
20+
},
21+
];
22+
23+
const userDocsRedirects = [
24+
{
25+
source: '/platforms/javascript/old-guide/',
26+
destination: '/platforms/javascript/new-guide/',
27+
},
28+
{
29+
source: '/platforms/python/old-tutorial',
30+
destination: '/platforms/python/new-tutorial',
31+
},
32+
];
33+
`;
34+
35+
describe('filePathToUrls', () => {
36+
it('should convert docs file path to URLs', () => {
37+
const result = filePathToUrls('docs/platforms/javascript/index.mdx');
38+
expect(result.isDeveloperDocs).toBe(false);
39+
expect(result.urls).toContain('/platforms/javascript/');
40+
expect(result.urls).toContain('/platforms/javascript');
41+
});
42+
43+
it('should convert develop-docs file path to URLs', () => {
44+
const result = filePathToUrls('develop-docs/backend/api/index.mdx');
45+
expect(result.isDeveloperDocs).toBe(true);
46+
expect(result.urls).toContain('/backend/api/');
47+
expect(result.urls).toContain('/backend/api');
48+
});
49+
50+
it('should handle non-index files', () => {
51+
const result = filePathToUrls('docs/platforms/javascript/guide.mdx');
52+
expect(result.isDeveloperDocs).toBe(false);
53+
expect(result.urls).toContain('/platforms/javascript/guide');
54+
expect(result.urls).toContain('/platforms/javascript/guide/');
55+
});
56+
57+
it('should return empty for paths outside docs/develop-docs', () => {
58+
const result = filePathToUrls('scripts/something.mdx');
59+
expect(result.isDeveloperDocs).toBe(false);
60+
expect(result.urls).toEqual([]);
61+
});
62+
});
63+
64+
describe('parseRedirectsJs', () => {
65+
let tempFile: string;
66+
67+
beforeEach(() => {
68+
tempFile = path.join(process.cwd(), 'redirects-test-temp.js');
69+
});
70+
71+
afterEach(() => {
72+
if (fs.existsSync(tempFile)) {
73+
fs.unlinkSync(tempFile);
74+
}
75+
});
76+
77+
it('should parse developer docs redirects', () => {
78+
fs.writeFileSync(tempFile, mockRedirectsJs);
79+
const result = parseRedirectsJs(tempFile);
80+
expect(result.developerDocsRedirects).toHaveLength(1);
81+
expect(result.developerDocsRedirects[0].source).toBe('/sdk/old-path/');
82+
expect(result.developerDocsRedirects[0].destination).toBe('/sdk/new-path/');
83+
});
84+
85+
it('should parse user docs redirects', () => {
86+
fs.writeFileSync(tempFile, mockRedirectsJs);
87+
const result = parseRedirectsJs(tempFile);
88+
expect(result.userDocsRedirects).toHaveLength(2);
89+
expect(result.userDocsRedirects[0].source).toBe('/platforms/javascript/old-guide/');
90+
expect(result.userDocsRedirects[0].destination).toBe(
91+
'/platforms/javascript/new-guide/'
92+
);
93+
});
94+
95+
it('should return empty arrays for non-existent file', () => {
96+
const result = parseRedirectsJs('/nonexistent/file.js');
97+
expect(result.developerDocsRedirects).toEqual([]);
98+
expect(result.userDocsRedirects).toEqual([]);
99+
});
100+
101+
it('should parse real redirects.js file', () => {
102+
const result = parseRedirectsJs('redirects.js');
103+
// Should have some redirects
104+
expect(result.developerDocsRedirects.length).toBeGreaterThan(0);
105+
expect(result.userDocsRedirects.length).toBeGreaterThan(0);
106+
});
107+
});
108+
109+
describe('redirectMatches', () => {
110+
it('should match exact redirects', () => {
111+
const redirect = {
112+
source: '/old/path/',
113+
destination: '/new/path/',
114+
};
115+
expect(redirectMatches(redirect, '/old/path/', '/new/path/')).toBe(true);
116+
expect(redirectMatches(redirect, '/different/path/', '/new/path/')).toBe(false);
117+
});
118+
119+
it('should match redirects with path parameters', () => {
120+
const redirect = {
121+
source: '/platforms/:platform/old/:path*',
122+
destination: '/platforms/:platform/new/:path*',
123+
};
124+
expect(
125+
redirectMatches(
126+
redirect,
127+
'/platforms/javascript/old/guide',
128+
'/platforms/javascript/new/guide'
129+
)
130+
).toBe(true);
131+
expect(
132+
redirectMatches(
133+
redirect,
134+
'/platforms/python/old/tutorial/',
135+
'/platforms/python/new/tutorial/'
136+
)
137+
).toBe(true);
138+
});
139+
140+
it('should handle redirects with single path parameter', () => {
141+
const redirect = {
142+
source: '/platforms/:platform/old',
143+
destination: '/platforms/:platform/new',
144+
};
145+
expect(
146+
redirectMatches(redirect, '/platforms/javascript/old', '/platforms/javascript/new')
147+
).toBe(true);
148+
expect(
149+
redirectMatches(redirect, '/platforms/python/old', '/platforms/python/new')
150+
).toBe(true);
151+
});
152+
153+
it('should not match when source pattern does not match', () => {
154+
const redirect = {
155+
source: '/platforms/:platform/old',
156+
destination: '/platforms/:platform/new',
157+
};
158+
expect(
159+
redirectMatches(redirect, '/different/path', '/platforms/javascript/new')
160+
).toBe(false);
161+
});
162+
163+
it('should match destination with exact path when no params', () => {
164+
const redirect = {
165+
source: '/old/path',
166+
destination: '/new/exact/path',
167+
};
168+
expect(redirectMatches(redirect, '/old/path', '/new/exact/path')).toBe(true);
169+
expect(redirectMatches(redirect, '/old/path', '/different/path')).toBe(false);
170+
});
171+
});

0 commit comments

Comments
 (0)