Skip to content

Commit 847e21f

Browse files
authored
Merge pull request #6250 from sonalgaud12/ci-pr
Improves the `PR Category Check` workflow
2 parents 6eef8ad + d4545e8 commit 847e21f

1 file changed

Lines changed: 285 additions & 1 deletion

File tree

.github/workflows/pr-category-check.yml

Lines changed: 285 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ on:
1313
permissions:
1414
pull-requests: write
1515
contents: read
16+
issues: write
1617

1718
jobs:
1819
check-pr-category:
@@ -69,6 +70,11 @@ jobs:
6970
const uncheckedCategories = categories.filter(cat => !cat.pattern.test(prBody));
7071
7172
// --- Step 1: Fail CI if no category is selected ---
73+
if (!prBody.trim()) {
74+
core.setFailed('PR description is empty. Please fill in the PR template and check at least one category checkbox.');
75+
return;
76+
}
77+
7278
if (checkedCategories.length === 0) {
7379
const message = [
7480
'## PR Category Required',
@@ -94,7 +100,8 @@ jobs:
94100
}
95101
96102
// --- Step 2: Ensure labels exist with proper colors ---
97-
const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({
103+
// Paginated to correctly handle repos with more than 100 labels.
104+
const existingLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
98105
owner: context.repo.owner,
99106
repo: context.repo.repo,
100107
per_page: 100,
@@ -167,3 +174,280 @@ jobs:
167174
const selected = checkedCategories.map(c => c.displayName).join(', ');
168175
core.info(`PR categories selected: ${selected}`);
169176
core.info(`Labels synced: ${labelsToAdd.join(', ')}`);
177+
178+
- name: Apply PR size label
179+
if: success()
180+
uses: actions/github-script@v7
181+
with:
182+
script: |
183+
const prNumber = context.payload.pull_request.number;
184+
const additions = context.payload.pull_request.additions;
185+
const deletions = context.payload.pull_request.deletions;
186+
const totalChanges = additions + deletions;
187+
188+
const sizeTiers = [
189+
{ label: 'size/XS', color: '3CBF00', description: 'Extra small: < 10 lines changed', max: 9 },
190+
{ label: 'size/S', color: '5D9801', description: 'Small: 10-49 lines changed', max: 49 },
191+
{ label: 'size/M', color: 'EEB800', description: 'Medium: 50-249 lines changed', max: 249 },
192+
{ label: 'size/L', color: 'EA8700', description: 'Large: 250-499 lines changed', max: 499 },
193+
{ label: 'size/XL', color: 'E05000', description: 'Extra large: 500-999 lines changed', max: 999 },
194+
{ label: 'size/XXL', color: 'D73A4A', description: 'XXL: 1000+ lines changed', max: Infinity },
195+
];
196+
197+
// size/XXL has max: Infinity so find() always matches; no fallback is needed.
198+
const applicable = sizeTiers.find(t => totalChanges <= t.max);
199+
core.info(`PR has ${totalChanges} total line changes -> ${applicable.label}`);
200+
201+
// Ensure all size labels exist in the repo (paginated to handle > 100 labels).
202+
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
203+
owner: context.repo.owner,
204+
repo: context.repo.repo,
205+
per_page: 100,
206+
});
207+
const repoLabelNames = repoLabels.map(l => l.name);
208+
209+
for (const tier of sizeTiers) {
210+
if (!repoLabelNames.includes(tier.label)) {
211+
try {
212+
await github.rest.issues.createLabel({
213+
owner: context.repo.owner,
214+
repo: context.repo.repo,
215+
name: tier.label,
216+
color: tier.color,
217+
description: tier.description,
218+
});
219+
} catch (e) {
220+
core.warning(`Could not create label "${tier.label}": ${e.message}`);
221+
}
222+
}
223+
}
224+
225+
// Sync: remove stale size labels, add applicable one
226+
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
227+
owner: context.repo.owner,
228+
repo: context.repo.repo,
229+
issue_number: prNumber,
230+
});
231+
const currentLabelNames = currentLabels.map(l => l.name);
232+
233+
for (const tier of sizeTiers) {
234+
if (tier.label !== applicable.label && currentLabelNames.includes(tier.label)) {
235+
try {
236+
await github.rest.issues.removeLabel({
237+
owner: context.repo.owner,
238+
repo: context.repo.repo,
239+
issue_number: prNumber,
240+
name: tier.label,
241+
});
242+
} catch (e) {
243+
core.warning(`Could not remove label "${tier.label}": ${e.message}`);
244+
}
245+
}
246+
}
247+
248+
if (!currentLabelNames.includes(applicable.label)) {
249+
try {
250+
await github.rest.issues.addLabels({
251+
owner: context.repo.owner,
252+
repo: context.repo.repo,
253+
issue_number: prNumber,
254+
labels: [applicable.label],
255+
});
256+
core.info(`Added size label: "${applicable.label}"`);
257+
} catch (e) {
258+
core.warning(`Could not add label "${applicable.label}": ${e.message}`);
259+
}
260+
}
261+
262+
- name: Apply PR area labels
263+
if: success()
264+
uses: actions/github-script@v7
265+
with:
266+
script: |
267+
const prNumber = context.payload.pull_request.number;
268+
269+
// Fetch all changed files (paginated)
270+
const files = await github.paginate(github.rest.pulls.listFiles, {
271+
owner: context.repo.owner,
272+
repo: context.repo.repo,
273+
pull_number: prNumber,
274+
per_page: 100,
275+
});
276+
const filePaths = files.map(f => f.filename);
277+
278+
const areaMappings = [
279+
{ label: 'area/javascript', color: 'F7DF1E', description: 'Changes to JS source files', pattern: /^js\// },
280+
{ label: 'area/css', color: '264DE4', description: 'Changes to CSS/SASS style files', pattern: /^css\// },
281+
{ label: 'area/plugins', color: '6E40C9', description: 'Changes to plugin files', pattern: /^plugins\// },
282+
{ label: 'area/docs', color: '0075CA', description: 'Changes to documentation', pattern: /^(Docs\/|README|CONTRIBUTING|.*\.md$)/i },
283+
{ label: 'area/tests', color: '3B82F6', description: 'Changes to test files', pattern: /^(cypress\/|jest\.config|.*\.test\.|.*\.spec\.)/i },
284+
{ label: 'area/i18n', color: 'E4E669', description: 'Changes to localization files', pattern: /^(locales|po)\// },
285+
{ label: 'area/assets', color: 'D93F0B', description: 'Changes to images, sounds, or fonts', pattern: /^(images|sounds|fonts|header-icons|screenshots)\// },
286+
{ label: 'area/ci-cd', color: '0052CC', description: 'Changes to CI/CD workflows', pattern: /^\.github\// },
287+
{ label: 'area/lib', color: 'BFD4F2', description: 'Changes to library files', pattern: /^lib\// },
288+
// Explicit extension list prevents .md files from matching and colliding with area/docs.
289+
{ label: 'area/core', color: 'E11D48', description: 'Changes to core app entry files', pattern: /^(index|sw|script|env)\.(js|ts|mjs|cjs|html|json)$/i },
290+
];
291+
292+
const touchedAreas = areaMappings.filter(area =>
293+
filePaths.some(fp => area.pattern.test(fp))
294+
);
295+
core.info(`Areas touched: ${touchedAreas.map(a => a.label).join(', ') || 'none'}`);
296+
297+
// Ensure all area labels exist in the repo (paginated to handle > 100 labels).
298+
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
299+
owner: context.repo.owner,
300+
repo: context.repo.repo,
301+
per_page: 100,
302+
});
303+
const repoLabelNames = repoLabels.map(l => l.name);
304+
305+
for (const area of areaMappings) {
306+
if (!repoLabelNames.includes(area.label)) {
307+
try {
308+
await github.rest.issues.createLabel({
309+
owner: context.repo.owner,
310+
repo: context.repo.repo,
311+
name: area.label,
312+
color: area.color,
313+
description: area.description,
314+
});
315+
} catch (e) {
316+
core.warning(`Could not create label "${area.label}": ${e.message}`);
317+
}
318+
}
319+
}
320+
321+
// Sync area labels on the PR
322+
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
323+
owner: context.repo.owner,
324+
repo: context.repo.repo,
325+
issue_number: prNumber,
326+
});
327+
const currentLabelNames = currentLabels.map(l => l.name);
328+
const touchedLabelNames = touchedAreas.map(a => a.label);
329+
330+
for (const area of touchedAreas) {
331+
if (!currentLabelNames.includes(area.label)) {
332+
try {
333+
await github.rest.issues.addLabels({
334+
owner: context.repo.owner,
335+
repo: context.repo.repo,
336+
issue_number: prNumber,
337+
labels: [area.label],
338+
});
339+
core.info(`Added area label: "${area.label}"`);
340+
} catch (e) {
341+
core.warning(`Could not add label "${area.label}": ${e.message}`);
342+
}
343+
}
344+
}
345+
346+
for (const area of areaMappings) {
347+
if (!touchedLabelNames.includes(area.label) && currentLabelNames.includes(area.label)) {
348+
try {
349+
await github.rest.issues.removeLabel({
350+
owner: context.repo.owner,
351+
repo: context.repo.repo,
352+
issue_number: prNumber,
353+
name: area.label,
354+
});
355+
core.info(`Removed area label: "${area.label}"`);
356+
} catch (e) {
357+
core.warning(`Could not remove label "${area.label}": ${e.message}`);
358+
}
359+
}
360+
}
361+
362+
- name: Check release notes
363+
if: success()
364+
uses: actions/github-script@v7
365+
with:
366+
script: |
367+
const prNumber = context.payload.pull_request.number;
368+
const prBody = context.payload.pull_request.body || '';
369+
370+
// Detect a "## Release Notes" heading with non-empty, meaningful content.
371+
// Uses [\r\n]+ to handle Windows (CRLF) and Unix (LF) line endings.
372+
// Content that is only "N/A", "none", or an HTML comment counts as absent.
373+
const rnPattern = /#+\s*release\s+notes\s*[\r\n]+([\s\S]+?)(?=\n#|\s*$)/i;
374+
const match = prBody.match(rnPattern);
375+
const rnContent = match ? match[1].trim() : '';
376+
// Strip all HTML comments first, then check if anything meaningful remains.
377+
// This correctly handles trailing text after a comment and multiple comments.
378+
const stripped = rnContent.replace(/<!--[\s\S]*?-->/g, '').trim();
379+
const isPlaceholder = !stripped || /^(n\/a|none)$/i.test(stripped);
380+
const hasReleaseNotes = rnContent.length > 0 && !isPlaceholder;
381+
382+
const rnLabel = 'release-notes';
383+
const noRnLabel = 'needs-release-notes';
384+
const labelDefs = [
385+
{ label: rnLabel, color: '0E8A16', description: 'PR includes release notes' },
386+
{ label: noRnLabel, color: 'FBCA04', description: 'PR is missing a release notes section' },
387+
];
388+
389+
// Ensure both labels exist in the repo (paginated to handle > 100 labels).
390+
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
391+
owner: context.repo.owner,
392+
repo: context.repo.repo,
393+
per_page: 100,
394+
});
395+
const repoLabelNames = repoLabels.map(l => l.name);
396+
397+
for (const def of labelDefs) {
398+
if (!repoLabelNames.includes(def.label)) {
399+
try {
400+
await github.rest.issues.createLabel({
401+
owner: context.repo.owner,
402+
repo: context.repo.repo,
403+
name: def.label,
404+
color: def.color,
405+
description: def.description,
406+
});
407+
} catch (e) {
408+
core.warning(`Could not create label "${def.label}": ${e.message}`);
409+
}
410+
}
411+
}
412+
413+
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
414+
owner: context.repo.owner,
415+
repo: context.repo.repo,
416+
issue_number: prNumber,
417+
});
418+
const currentLabelNames = currentLabels.map(l => l.name);
419+
420+
const labelToAdd = hasReleaseNotes ? rnLabel : noRnLabel;
421+
const labelToRemove = hasReleaseNotes ? noRnLabel : rnLabel;
422+
423+
if (!currentLabelNames.includes(labelToAdd)) {
424+
try {
425+
await github.rest.issues.addLabels({
426+
owner: context.repo.owner,
427+
repo: context.repo.repo,
428+
issue_number: prNumber,
429+
labels: [labelToAdd],
430+
});
431+
} catch (e) {
432+
core.warning(`Could not add label "${labelToAdd}": ${e.message}`);
433+
}
434+
}
435+
436+
if (currentLabelNames.includes(labelToRemove)) {
437+
try {
438+
await github.rest.issues.removeLabel({
439+
owner: context.repo.owner,
440+
repo: context.repo.repo,
441+
issue_number: prNumber,
442+
name: labelToRemove,
443+
});
444+
} catch (e) {
445+
core.warning(`Could not remove label "${labelToRemove}": ${e.message}`);
446+
}
447+
}
448+
449+
if (hasReleaseNotes) {
450+
core.info('Release notes section found.');
451+
} else {
452+
core.warning('No release notes section found. Add a "## Release Notes" heading with content.');
453+
}

0 commit comments

Comments
 (0)