pr-size-labeler.ymlβ’13.2 kB
name: PR Size Labeler
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel previous runs on new commits
concurrency:
group: pr-size-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
# Environment variables for comment markers
env:
SIZE_COMMENT_MARKER: 'π Large PR Detected'
LEGACY_COMMENT_MARKER: 'π PR Size Analysis'
jobs:
size-label:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
disable-sudo: true
egress-policy: audit
allowed-endpoints: >
api.github.com:443
github.com:443
- name: Checkout PR
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate PR size and apply label
uses: actions/github-script@v7
with:
script: |
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function withRetries(fn, label, { attempts = 3, baseDelayMs = 1000 } = {}) {
let lastError;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
const status = error?.status || error?.response?.status;
// Retry only on 5xx or network failures
const retryable = !status || status >= 500;
if (!retryable || attempt === attempts) {
throw error;
}
const delay = baseDelayMs * attempt;
core.warning(`Attempt ${attempt} for ${label} failed (${error.message || error}). Retrying in ${delay}ms...`);
await sleep(delay);
}
}
throw lastError;
}
const { data: pr } = await withRetries(() => github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
}), 'github.rest.pulls.get');
const ignorePatterns = [
/\/dist\//,
/\/build\//,
/\.min\./,
/-lock\.\w+$/,
/package-lock\.json$/,
/pnpm-lock\.yaml$/,
/yarn\.lock$/,
/go\.sum$/,
/Cargo\.lock$/,
/\.snap$/
];
const isIgnoredFile = (path) => ignorePatterns.some((pattern) => pattern.test(path));
const fetchAllFiles = async () => {
const files = [];
let page = 1;
while (true) {
const { data } = await withRetries(() => github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
page,
}), `github.rest.pulls.listFiles page ${page}`);
files.push(...data);
if (data.length < 100) break;
page += 1;
}
return files;
};
const files = await fetchAllFiles();
const totalFiles = pr.changed_files ?? files.length ?? 0;
const totalLines = (pr.additions ?? 0) + (pr.deletions ?? 0);
let effectiveLines = 0;
let effectiveFiles = 0;
for (const file of files) {
const { filename, additions = 0, deletions = 0, status } = file;
if (isIgnoredFile(filename)) continue;
if (status === 'renamed' && additions === 0 && deletions === 0) continue;
const loc = additions + deletions;
effectiveLines += loc;
effectiveFiles += 1;
}
const normalizedLines = effectiveLines || totalLines || 0;
const normalizedFiles = effectiveFiles || totalFiles || 0;
const determineLinesBucket = (loc) => {
if (loc <= 49) return 'size/XS';
if (loc <= 149) return 'size/S';
if (loc <= 399) return 'size/M';
if (loc <= 799) return 'size/L';
if (loc <= 1499) return 'size/XL';
return 'size/XXL';
};
const determineFilesBucket = (count) => {
if (count <= 2) return 'size/XS';
if (count <= 5) return 'size/S';
if (count <= 10) return 'size/M';
if (count <= 20) return 'size/L';
if (count <= 40) return 'size/XL';
return 'size/XXL';
};
const severityOrder = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL', 'size/XXL'];
const maxLabel = (a, b) => severityOrder.indexOf(a) >= severityOrder.indexOf(b) ? a : b;
const linesBucket = determineLinesBucket(normalizedLines);
const filesBucket = determineFilesBucket(normalizedFiles);
const sizeLabel = maxLabel(linesBucket, filesBucket);
let sizeColor = '';
let sizeDescription = '';
switch (sizeLabel) {
case 'size/XS':
sizeColor = '00ff00';
sizeDescription = 'Extra Small: β€50 lines or β€2 files';
break;
case 'size/S':
sizeColor = '7cfc00';
sizeDescription = 'Small: 51-149 lines or β€5 files';
break;
case 'size/M':
sizeColor = 'ffff00';
sizeDescription = 'Medium: 150-399 lines or β€10 files';
break;
case 'size/L':
sizeColor = 'ff8c00';
sizeDescription = 'Large: 400-799 lines or β€20 files';
break;
case 'size/XL':
sizeColor = 'ff4500';
sizeDescription = 'XL: 800-1499 lines or β€40 files';
break;
case 'size/XXL':
default:
sizeColor = '8b0000';
sizeDescription = 'XXL: 1500+ lines or 41+ files';
break;
}
console.log(`PR size: ${sizeLabel} (effective ${normalizedFiles} files / ${normalizedLines} lines, raw ${totalFiles} files / ${totalLines} lines)`);
// Check existing labels to avoid unnecessary changes
const existingLabels = await withRetries(() => github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
}), 'github.rest.issues.listLabelsOnIssue');
const currentSizeLabels = existingLabels.data.filter(label => label.name.startsWith('size/'));
const hasCorrectLabel = currentSizeLabels.some(label => label.name === sizeLabel);
if (hasCorrectLabel && currentSizeLabels.length === 1) {
console.log(`Correct size label '${sizeLabel}' already applied, skipping update`);
} else {
// Remove incorrect size labels
for (const label of currentSizeLabels) {
if (label.name !== sizeLabel) {
console.log(`Removing incorrect label: ${label.name}`);
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: label.name
});
}
}
// Create the size label if it doesn't exist
if (!hasCorrectLabel) {
try {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: sizeLabel,
color: sizeColor,
description: sizeDescription
});
} catch (error) {
// Label might already exist, that's fine
console.log(`Label ${sizeLabel} might already exist: ${error.message}`);
}
// Apply the new size label
console.log(`Adding new size label: ${sizeLabel}`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: [sizeLabel]
});
}
}
// Inline pagination helper
async function fetchAllComments() {
let allComments = [];
let page = 1;
while (true) {
const { data } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
page: page
});
allComments = allComments.concat(data);
if (data.length < 100) break;
page++;
}
return allComments;
}
// Centralized comment body builder
function buildSizeComment(effectiveFiles, effectiveLines, totalFiles, totalLines, sizeLabel) {
return `## ${{ env.SIZE_COMMENT_MARKER }}
**Current Size:** \`${sizeLabel}\`
| Metric | Effective | Raw |
| --- | --- | --- |
| Files changed | ${effectiveFiles} | ${totalFiles} |
| Lines changed | ${effectiveLines} | ${totalLines} |
${['size/XL','size/XXL'].includes(sizeLabel) ? `
### β οΈ Large PR Considerations:
- Consider breaking into smaller, focused PRs
- Review time may be longer
- Increased risk of merge conflicts
**If this PR must remain large, ensure it has:**
- [ ] Clear description of all changes
- [ ] Comprehensive test coverage
- [ ] Breaking changes documented
` : ''}
*Last updated: ${new Date().toISOString()}*`;
}
// ALWAYS refresh or create comment (runs regardless of label changes)
try {
const allComments = await fetchAllComments();
const existingComment = allComments.find(c =>
c.body && (
c.body.includes(process.env.SIZE_COMMENT_MARKER) ||
c.body.includes(process.env.LEGACY_COMMENT_MARKER)
)
);
const commentBody = buildSizeComment(normalizedFiles, normalizedLines, totalFiles, totalLines, sizeLabel);
if (existingComment) {
// Update existing comment with fresh data
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: commentBody
});
console.log('Updated existing size comment with fresh data');
} else if (sizeLabel === 'size/XL' || sizeLabel === 'size/L') {
// Create new comment for L/XL sizes
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: commentBody
});
console.log('Created new size comment');
}
} catch (error) {
console.log('Error handling size comment:', error.message);
}
console.log(`Applied label: ${sizeLabel}`);
core.setOutput('size-label', sizeLabel);
core.setOutput('effective-files', String(normalizedFiles));
core.setOutput('effective-lines', String(normalizedLines));
core.setOutput('total-files', String(totalFiles));
core.setOutput('total-lines', String(totalLines));
return {
sizeLabel,
effectiveFiles: normalizedFiles,
effectiveLines: normalizedLines,
totalFiles,
totalLines,
};
- name: Output size information
run: |
echo "PR Size Analysis Complete"
echo "Size label: ${{ steps.size-label.outputs.size-label }}"
echo "Effective files: ${{ steps.size-label.outputs.effective-files }}"
echo "Effective lines: ${{ steps.size-label.outputs.effective-lines }}"
echo "Total files: ${{ steps.size-label.outputs.total-files }}"
echo "Total lines: ${{ steps.size-label.outputs.total-lines }}"
echo "This helps Claude choose the appropriate analysis mode automatically."