|
13 | 13 | permissions: |
14 | 14 | pull-requests: write |
15 | 15 | contents: read |
| 16 | + issues: write |
16 | 17 |
|
17 | 18 | jobs: |
18 | 19 | check-pr-category: |
|
69 | 70 | const uncheckedCategories = categories.filter(cat => !cat.pattern.test(prBody)); |
70 | 71 |
|
71 | 72 | // --- 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 | +
|
72 | 78 | if (checkedCategories.length === 0) { |
73 | 79 | const message = [ |
74 | 80 | '## PR Category Required', |
|
94 | 100 | } |
95 | 101 |
|
96 | 102 | // --- 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, { |
98 | 105 | owner: context.repo.owner, |
99 | 106 | repo: context.repo.repo, |
100 | 107 | per_page: 100, |
@@ -167,3 +174,280 @@ jobs: |
167 | 174 | const selected = checkedCategories.map(c => c.displayName).join(', '); |
168 | 175 | core.info(`PR categories selected: ${selected}`); |
169 | 176 | 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