Skip to main content
Glama
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."

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server