fix: handle milliseconds in ISO 8601 rate limit reset timestamps #4724
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Labeler | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened] | |
| concurrency: | |
| group: pr-labeler-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| label: | |
| name: Auto Label PR | |
| runs-on: ubuntu-latest | |
| # Security: Prevent fork PRs from modifying labels (they don't have write access) | |
| if: github.event.pull_request.head.repo.full_name == github.repository | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Label PR | |
| uses: actions/github-script@v8 | |
| with: | |
| retries: 3 | |
| retry-exempt-status-codes: 400,401,403,404,422 | |
| script: | | |
| // ═══════════════════════════════════════════════════════════════ | |
| // CONFIGURATION - Single source of truth for all settings | |
| // ═══════════════════════════════════════════════════════════════ | |
| const CONFIG = { | |
| // Size thresholds (lines changed) | |
| SIZE_THRESHOLDS: { | |
| XS: 10, | |
| S: 100, | |
| M: 500, | |
| L: 1000 | |
| }, | |
| // Conventional commit type mappings | |
| TYPE_MAP: Object.freeze({ | |
| 'feat': 'feature', | |
| 'fix': 'bug', | |
| 'docs': 'documentation', | |
| 'refactor': 'refactor', | |
| 'test': 'test', | |
| 'ci': 'ci', | |
| 'chore': 'chore', | |
| 'perf': 'performance', | |
| 'style': 'style', | |
| 'build': 'build' | |
| }), | |
| // Area detection paths | |
| AREA_PATHS: Object.freeze({ | |
| frontend: 'apps/frontend/', | |
| backend: 'apps/backend/', | |
| ci: '.github/' | |
| }), | |
| // Label definitions | |
| LABELS: Object.freeze({ | |
| SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'], | |
| AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'] | |
| }), | |
| // Pagination | |
| MAX_FILES_PER_PAGE: 100 | |
| }; | |
| // ═══════════════════════════════════════════════════════════════ | |
| // HELPER FUNCTIONS - Small, focused, single responsibility | |
| // ═══════════════════════════════════════════════════════════════ | |
| /** | |
| * Safely parse conventional commit type from PR title | |
| * @param {string} title - PR title | |
| * @returns {{type: string|null, isBreaking: boolean}} | |
| */ | |
| function parseConventionalCommit(title) { | |
| if (!title || typeof title !== 'string') { | |
| return { type: null, isBreaking: false }; | |
| } | |
| // Limit input length to prevent ReDoS attacks | |
| const safeTitle = title.slice(0, 200); | |
| const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/); | |
| if (!match) { | |
| return { type: null, isBreaking: false }; | |
| } | |
| return { | |
| type: match[1].toLowerCase(), | |
| isBreaking: match[3] === '!' | |
| }; | |
| } | |
| /** | |
| * Determine size label based on lines changed | |
| * @param {number} totalLines - Total lines changed | |
| * @returns {string} Size label | |
| */ | |
| function determineSizeLabel(totalLines) { | |
| const { SIZE_THRESHOLDS } = CONFIG; | |
| if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS'; | |
| if (totalLines < SIZE_THRESHOLDS.S) return 'size/S'; | |
| if (totalLines < SIZE_THRESHOLDS.M) return 'size/M'; | |
| if (totalLines < SIZE_THRESHOLDS.L) return 'size/L'; | |
| return 'size/XL'; | |
| } | |
| /** | |
| * Detect areas affected by file changes | |
| * @param {Array} files - List of changed files | |
| * @returns {{frontend: boolean, backend: boolean, ci: boolean}} | |
| */ | |
| function detectAreas(files) { | |
| const areas = { frontend: false, backend: false, ci: false }; | |
| const { AREA_PATHS } = CONFIG; | |
| for (const file of files) { | |
| const path = file.filename || ''; | |
| if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true; | |
| if (path.startsWith(AREA_PATHS.backend)) areas.backend = true; | |
| if (path.startsWith(AREA_PATHS.ci)) areas.ci = true; | |
| } | |
| return areas; | |
| } | |
| /** | |
| * Determine area label based on detected areas | |
| * @param {{frontend: boolean, backend: boolean, ci: boolean}} areas | |
| * @returns {string|null} Area label or null | |
| */ | |
| function determineAreaLabel(areas) { | |
| if (areas.frontend && areas.backend) return 'area/fullstack'; | |
| if (areas.frontend) return 'area/frontend'; | |
| if (areas.backend) return 'area/backend'; | |
| if (areas.ci) return 'area/ci'; | |
| return null; | |
| } | |
| /** | |
| * Remove labels from PR (with error handling) | |
| * @param {Array} labels - Labels to remove | |
| * @param {number} prNumber - PR number | |
| */ | |
| async function removeLabels(labels, prNumber) { | |
| const { owner, repo } = context.repo; | |
| await Promise.allSettled(labels.map(async (label) => { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| name: label | |
| }); | |
| console.log(` ✓ Removed: ${label}`); | |
| } catch (e) { | |
| // 404 means label wasn't present - that's fine | |
| if (e.status !== 404) { | |
| console.log(` ⚠ Failed to remove ${label}: ${e.message}`); | |
| } | |
| } | |
| })); | |
| } | |
| /** | |
| * Add labels to PR (with error handling) | |
| * @param {Array} labels - Labels to add | |
| * @param {number} prNumber - PR number | |
| */ | |
| async function addLabels(labels, prNumber) { | |
| if (labels.length === 0) return; | |
| const { owner, repo } = context.repo; | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: prNumber, | |
| labels | |
| }); | |
| console.log(` ✓ Added: ${labels.join(', ')}`); | |
| } catch (e) { | |
| if (e.status === 404) { | |
| core.warning(`One or more labels do not exist. Create them in repository settings.`); | |
| } else { | |
| throw e; | |
| } | |
| } | |
| } | |
| /** | |
| * Fetch PR files with full pagination support | |
| * @param {number} prNumber - PR number | |
| * @returns {Array} List of all files (paginated) | |
| */ | |
| async function fetchPRFiles(prNumber) { | |
| const { owner, repo } = context.repo; | |
| try { | |
| // Use paginate to fetch ALL files, not just first 100 | |
| const files = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE } | |
| ); | |
| return files; | |
| } catch (e) { | |
| console.log(` ⚠ Could not fetch files: ${e.message}`); | |
| return []; | |
| } | |
| } | |
| // ═══════════════════════════════════════════════════════════════ | |
| // MAIN LOGIC - Orchestrates the labeling process | |
| // ═══════════════════════════════════════════════════════════════ | |
| const { owner, repo } = context.repo; | |
| const pr = context.payload.pull_request; | |
| const prNumber = pr.number; | |
| const title = pr.title || ''; | |
| console.log(`::group::PR #${prNumber} - Auto-labeling`); | |
| console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`); | |
| console.log(`Action: ${context.payload.action}`); | |
| const labelsToAdd = new Set(); | |
| const labelsToRemove = new Set(); | |
| // 1. Parse conventional commit type | |
| const { type, isBreaking } = parseConventionalCommit(title); | |
| if (type && CONFIG.TYPE_MAP[type]) { | |
| labelsToAdd.add(CONFIG.TYPE_MAP[type]); | |
| console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`); | |
| } else { | |
| console.log(` ℹ️ No conventional commit prefix detected`); | |
| } | |
| if (isBreaking) { | |
| labelsToAdd.add('breaking-change'); | |
| console.log(` ⚠️ Breaking change detected`); | |
| } | |
| // 2. Detect areas from changed files | |
| const files = await fetchPRFiles(prNumber); | |
| const areas = detectAreas(files); | |
| const areaLabel = determineAreaLabel(areas); | |
| if (areaLabel) { | |
| labelsToAdd.add(areaLabel); | |
| CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l)); | |
| console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`); | |
| } | |
| // 3. Calculate size label | |
| const totalLines = (pr.additions || 0) + (pr.deletions || 0); | |
| const sizeLabel = determineSizeLabel(totalLines); | |
| labelsToAdd.add(sizeLabel); | |
| CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l)); | |
| console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`); | |
| console.log('::endgroup::'); | |
| // 4. Apply label changes | |
| console.log(`::group::Applying labels`); | |
| // Remove labels that should be replaced (exclude ones we're adding) | |
| const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l)); | |
| await removeLabels(removeList, prNumber); | |
| // Add new labels | |
| await addLabels([...labelsToAdd], prNumber); | |
| console.log('::endgroup::'); | |
| console.log(`✅ PR #${prNumber} labeled successfully`); | |
| // 5. Write job summary | |
| const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none'; | |
| const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other'; | |
| await core.summary | |
| .addHeading(`PR #${prNumber} Auto-Labels`, 3) | |
| .addTable([ | |
| [{ data: 'Category', header: true }, { data: 'Label', header: true }], | |
| ['Type', summaryType], | |
| ['Area', summaryArea], | |
| ['Size', sizeLabel] | |
| ]) | |
| .addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`) | |
| .write(); |