forked from ruxailab/RUXAILAB
-
Notifications
You must be signed in to change notification settings - Fork 0
395 lines (338 loc) · 16 KB
/
pr-auto-label.yml
File metadata and controls
395 lines (338 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
name: PR Auto Labeler
on:
pull_request_target:
types:
- opened
- synchronize
- edited
permissions:
pull-requests: write
issues: write
contents: read
jobs:
auto-label:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Auto-label PR
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const prNumber = pr.number;
const prTitle = pr.title.toLowerCase();
const prBody = pr.body || "";
const labelsToAdd = [];
// Get ALL files changed in PR using pagination
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
per_page: 100,
});
// Calculate total lines changed (excluding lockfiles)
let totalAdditions = 0;
let totalDeletions = 0;
const changedPaths = [];
for (const file of files) {
// Ignore lockfiles for complexity/size stats to avoid noise
if (file.filename === 'package-lock.json' || file.filename === 'yarn.lock') {
continue;
}
changedPaths.push(file.filename);
totalAdditions += file.additions;
totalDeletions += file.deletions;
}
const fileCount = changedPaths.length;
const totalChanges = totalAdditions + totalDeletions;
console.log(`Stats (excluding lockfiles): ${fileCount} files, ${totalChanges} lines changed (${totalAdditions} additions, ${totalDeletions} deletions)`);
// 1. Size labels based on lines changed
if (totalChanges < 10) {
labelsToAdd.push("size/XS");
} else if (totalChanges < 100) {
labelsToAdd.push("size/S");
} else if (totalChanges < 500) {
labelsToAdd.push("size/M");
} else if (totalChanges < 1000) {
labelsToAdd.push("size/L");
} else {
labelsToAdd.push("size/XL");
}
// Complexity labels based on file count
if (fileCount < 5) {
labelsToAdd.push("low-complexity");
} else if (fileCount >= 5 && fileCount <= 10) {
labelsToAdd.push("medium-complexity");
} else {
labelsToAdd.push("high-complexity");
}
// Type labels based on PR title
if (prTitle.startsWith("fix:") || prTitle.includes("fix(")) {
labelsToAdd.push("fix");
} else if (prTitle.startsWith("feat:") || prTitle.includes("feat(")) {
labelsToAdd.push("new-feature");
}
// 2. Component-specific labels based on changed files
const hasVueComponents = changedPaths.some(p => p.includes("src/") && (p.endsWith(".vue") || p.includes("/components/")));
const hasFunctions = changedPaths.some(p => p.startsWith("functions/"));
const hasTests = changedPaths.some(p => p.includes("test") || p.includes("spec") || p.includes("cypress/") || p.includes("playwright/"));
const hasDocs = changedPaths.some(p => p.includes("README") || p.includes("docs/") || p.endsWith(".md"));
const hasWorkflows = changedPaths.some(p => p.includes(".github/workflows/"));
const hasAssets = changedPaths.some(p => p.includes("/assets/") || p.match(/\.(png|jpg|jpeg|svg|gif|ico)$/));
// Feature-specific labels based on UX folders
const hasAccessibility = changedPaths.some(p => p.includes("ux/accessibility") || p.includes("ux/Accessibility"));
const hasCardSorting = changedPaths.some(p => p.includes("ux/CardSorting"));
const hasHeuristic = changedPaths.some(p => p.includes("ux/Heuristic"));
const hasUserTest = changedPaths.some(p => p.includes("ux/UserTest"));
if (hasVueComponents) labelsToAdd.push("ui/ux");
if (hasFunctions) labelsToAdd.push("backend");
if (hasTests) labelsToAdd.push("testing");
if (hasDocs) labelsToAdd.push("documentation");
if (hasWorkflows) labelsToAdd.push("ci/cd");
if (hasAssets) labelsToAdd.push("assets");
if (hasAccessibility) labelsToAdd.push("accessibility");
if (hasCardSorting) labelsToAdd.push("card-sorting");
if (hasHeuristic) labelsToAdd.push("heuristic");
if (hasUserTest) labelsToAdd.push("user-test");
// 5. PR Description Validation
const descriptionIssues = [];
// Detect bot PRs
const BOT_LOGINS = new Set(['dependabot[bot]', 'github-actions[bot]']);
const isBot = BOT_LOGINS.has(pr.user.login) || pr.user.type === 'Bot';
// Skip validation for bot PRs
if (!isBot) {
// Check minimum description length
const lengthValid = prBody.trim().length >= 20;
if (!lengthValid) {
descriptionIssues.push("- Description is too short (minimum 20 characters)");
}
// Check for issue reference and validate it
const issueRefMatch = prBody.match(/(?:fix|close|resolve)(?:es|ed|s)?[\s:]*#(\d+)/i);
let hasIssueRef = false;
if (!issueRefMatch) {
descriptionIssues.push("- Missing issue reference (e.g., 'Fixes #123')");
} else {
const issueNumber = parseInt(issueRefMatch[1]);
try {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
if (issue.state === 'closed') {
descriptionIssues.push(`- Referenced issue #${issueNumber} is closed. Please reference an open issue.`);
} else {
hasIssueRef = true;
}
} catch (error) {
if (error.status === 404) {
descriptionIssues.push(`- Referenced issue #${issueNumber} does not exist in this repository.`);
} else {
console.log(`Error checking issue #${issueNumber}: ${error.message}`);
hasIssueRef = true;
}
}
}
// Get existing comments
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existingComment = comments.find(c => c.body && c.body.includes("PR Description Issues Detected"));
// Both rules satisfied - remove label and comment
if (lengthValid && hasIssueRef) {
// Try to remove label
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: "needs-description",
});
console.log("Removed needs-description label");
} catch (error) {
if (error.status !== 404) {
console.log(`Could not remove label: ${error.message}`);
}
}
// Delete comment if exists
if (existingComment) {
try {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
});
console.log("Deleted validation comment");
} catch (error) {
console.log(`Could not delete comment: ${error.message}`);
}
}
}
// At least one rule fails - add label and comment
else if (descriptionIssues.length > 0) {
labelsToAdd.push("needs-description");
const warningComment = `⚠️ **PR Description Issues Detected**\n\n${descriptionIssues.join("\n")}\n\nPlease update the PR description to address these issues.`;
try {
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: warningComment,
});
console.log("Updated description validation comment");
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: warningComment,
});
console.log("Posted description validation comment");
}
} catch (error) {
console.log(`Could not manage comment: ${error.message}`);
}
}
}
// Refetch PR to get current labels
const { data: updatedPR } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
const existingLabels = updatedPR.labels.map(l => l.name);
// DEFINE MANAGED LABELS
// These are labels that this script "owns". If they are not in labelsToAdd, they will be removed.
const sizeLabels = ["size/XS", "size/S", "size/M", "size/L", "size/XL"];
const complexityLabels = ["low-complexity", "medium-complexity", "high-complexity"];
const typeLabels = ["fix", "new-feature"];
const componentLabels = [
"ui/ux", "backend", "testing", "documentation", "ci/cd", "assets",
"accessibility", "card-sorting", "heuristic", "user-test"
];
const allManagedLabels = [...sizeLabels, ...complexityLabels, ...typeLabels, ...componentLabels];
// Remove ANY managed label that is NOT in the calculated list
for (const label of allManagedLabels) {
if (existingLabels.includes(label) && !labelsToAdd.includes(label)) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: label,
});
console.log(`Removed label: ${label}`);
} catch (error) {
console.log(`Could not remove label ${label}: ${error.message}`);
}
}
}
// Add new labels
const newLabels = labelsToAdd.filter(l => !existingLabels.includes(l));
if (newLabels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: newLabels,
});
console.log(`Added labels: ${newLabels.join(", ")}`);
} else {
console.log("No new labels to add");
}
// 6. Limit open PRs per author (anti-spam)
const MAX_OPEN_PRS = 2;
// Skip for bots
if (!isBot) {
const { data: openPRs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
per_page: 100,
});
const userOpenPRs = openPRs.filter(p =>
p.user.login === pr.user.login && p.number !== prNumber
);
if (userOpenPRs.length >= MAX_OPEN_PRS) {
const spamWarning = `
🚫 **Too many open Pull Requests**
You already have **${userOpenPRs.length + 1} open PRs** in this repository.
Please finish or close existing PRs before opening new ones.
`;
// Avoid duplicate comments
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const alreadyWarned = comments.some(c =>
c.body && c.body.includes("Too many open Pull Requests")
);
if (!alreadyWarned) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: spamWarning,
});
}
labelsToAdd.push("pr-limit-exceeded");
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: "closed",
});
// Hard fail to block merge
throw new Error("Author exceeded max number of open PRs");
}
}
// 7. Media requirement: at least one image or video
if (!isBot) {
const mediaFileRegex = /\.(png|jpe?g|gif|webp|mp4|mov|webm)$/i;
const hasMediaFile = changedPaths.some(p => mediaFileRegex.test(p));
// Match media URLs with optional query strings (e.g., .png?jwt=...)
const mediaUrlRegex = /\.(png|jpe?g|gif|webp|mp4|mov|webm)(\?[^\s)]*)?/i;
// Match GitHub's user-content CDN URLs (private images/videos, user-attachments, etc.)
const githubAssetRegex = /https?:\/\/(user-images\.githubusercontent\.com|private-user-images\.githubusercontent\.com|github\.com\/(user-attachments\/assets|[^/]+\/[^/]+\/assets))\//i;
const hasMediaLink = mediaUrlRegex.test(prBody) || githubAssetRegex.test(prBody);
if (!hasMediaFile && !hasMediaLink) {
const mediaWarning = `
📸 **Media required**
This PR must include **at least one image or video**:
- as a changed file (png, jpg, gif, mp4, etc), or
- as a link in the PR description.
Please add media to help reviewers understand the change.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const alreadyWarned = comments.some(c =>
c.body && c.body.includes("Media required")
);
if (!alreadyWarned) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: mediaWarning,
});
}
labelsToAdd.push("needs-media");
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: "closed",
});
throw new Error("PR closed due to missing required media");
}
}