diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml deleted file mode 100644 index ac6775e7..00000000 --- a/.github/workflows/pr-auto-label.yml +++ /dev/null @@ -1,227 +0,0 @@ -name: PR Auto Label - -on: - pull_request: - types: [opened, synchronize, reopened] - -# Cancel in-progress runs for the same PR -concurrency: - group: pr-auto-label-${{ 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 - # Don't run on fork PRs (they can't write labels) - if: github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 5 - steps: - - name: Auto-label PR - uses: actions/github-script@v7 - with: - retries: 3 - retry-exempt-status-codes: 400,401,403,404,422 - script: | - 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}`); - - const labelsToAdd = new Set(); - const labelsToRemove = new Set(); - - // ═══════════════════════════════════════════════════════════════ - // TYPE LABELS (from PR title - Conventional Commits) - // ═══════════════════════════════════════════════════════════════ - const typeMap = { - 'feat': 'feature', - 'fix': 'bug', - 'docs': 'documentation', - 'refactor': 'refactor', - 'test': 'test', - 'ci': 'ci', - 'chore': 'chore', - 'perf': 'performance', - 'style': 'style', - 'build': 'build' - }; - - const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/); - if (typeMatch) { - const type = typeMatch[1].toLowerCase(); - const isBreaking = typeMatch[3] === '!'; - - if (typeMap[type]) { - labelsToAdd.add(typeMap[type]); - console.log(` 📝 Type: ${type} → ${typeMap[type]}`); - } - - if (isBreaking) { - labelsToAdd.add('breaking-change'); - console.log(` ⚠️ Breaking change detected`); - } - } else { - console.log(` ⚠️ No conventional commit prefix found in title`); - } - - // ═══════════════════════════════════════════════════════════════ - // AREA LABELS (from changed files) - // ═══════════════════════════════════════════════════════════════ - let files = []; - try { - const { data } = await github.rest.pulls.listFiles({ - owner, - repo, - pull_number: prNumber, - per_page: 100 - }); - files = data; - } catch (e) { - console.log(` ⚠️ Could not fetch files: ${e.message}`); - } - - const areas = { - frontend: false, - backend: false, - ci: false, - docs: false, - tests: false - }; - - for (const file of files) { - const path = file.filename; - if (path.startsWith('apps/frontend/')) areas.frontend = true; - if (path.startsWith('apps/backend/')) areas.backend = true; - if (path.startsWith('.github/')) areas.ci = true; - if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true; - if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true; - } - - // Determine area label (mutually exclusive) - const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci']; - - if (areas.frontend && areas.backend) { - labelsToAdd.add('area/fullstack'); - areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l)); - console.log(` 📁 Area: fullstack (${files.length} files)`); - } else if (areas.frontend) { - labelsToAdd.add('area/frontend'); - areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l)); - console.log(` 📁 Area: frontend (${files.length} files)`); - } else if (areas.backend) { - labelsToAdd.add('area/backend'); - areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l)); - console.log(` 📁 Area: backend (${files.length} files)`); - } else if (areas.ci) { - labelsToAdd.add('area/ci'); - areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l)); - console.log(` 📁 Area: ci (${files.length} files)`); - } - - // ═══════════════════════════════════════════════════════════════ - // SIZE LABELS (from lines changed) - // ═══════════════════════════════════════════════════════════════ - const additions = pr.additions || 0; - const deletions = pr.deletions || 0; - const totalLines = additions + deletions; - - const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL']; - let sizeLabel; - - if (totalLines < 10) sizeLabel = 'size/XS'; - else if (totalLines < 100) sizeLabel = 'size/S'; - else if (totalLines < 500) sizeLabel = 'size/M'; - else if (totalLines < 1000) sizeLabel = 'size/L'; - else sizeLabel = 'size/XL'; - - labelsToAdd.add(sizeLabel); - sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l)); - console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`); - - console.log('::endgroup::'); - - // ═══════════════════════════════════════════════════════════════ - // APPLY LABELS - // ═══════════════════════════════════════════════════════════════ - console.log(`::group::Applying labels`); - - // Remove old labels (in parallel) - const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l)); - if (removeArray.length > 0) { - const removePromises = removeArray.map(async (label) => { - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: prNumber, - name: label - }); - console.log(` ✓ Removed: ${label}`); - } catch (e) { - if (e.status !== 404) { - console.log(` ⚠ Could not remove ${label}: ${e.message}`); - } - } - }); - await Promise.all(removePromises); - } - - // Add new labels - const addArray = [...labelsToAdd]; - if (addArray.length > 0) { - try { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: addArray - }); - console.log(` ✓ Added: ${addArray.join(', ')}`); - } catch (e) { - // Some labels might not exist - if (e.status === 404) { - core.warning(`Some labels do not exist. Please create them in repository settings.`); - // Try adding one by one - for (const label of addArray) { - try { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: [label] - }); - } catch (e2) { - console.log(` ⚠ Label '${label}' does not exist`); - } - } - } else { - throw e; - } - } - } - - console.log('::endgroup::'); - - // Summary - console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`); - - // Write job summary - core.summary - .addHeading(`PR #${prNumber} Auto-Labels`, 3) - .addTable([ - [{data: 'Category', header: true}, {data: 'Label', header: true}], - ['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'], - ['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'], - ['Size', sizeLabel] - ]) - .addRaw(`\n**Files changed:** ${files.length}\n`) - .addRaw(`**Lines:** +${additions} / -${deletions}\n`); - await core.summary.write(); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 00000000..989eaec5 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,320 @@ +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@v7 + 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'], + STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'], + REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review'] + }), + + // 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 || ''; + const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened'; + + 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)`); + + // 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes) + // Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete + if (isNewPR) { + labelsToAdd.add('🔄 Checking'); + CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l)); + console.log(` 🔄 Status: Checking`); + } else { + console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`); + } + + // 5. Add review label for new PRs only + if (isNewPR) { + labelsToAdd.add('Missing AC Approval'); + console.log(` ⏳ Review: Missing AC Approval`); + } + + console.log('::endgroup::'); + + // 6. 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`); + + // 7. 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], + ['Status', isNewPR ? '🔄 Checking' : '(unchanged)'], + ['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)'] + ]) + .addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`) + .write(); diff --git a/.github/workflows/pr-status-check.yml b/.github/workflows/pr-status-check.yml deleted file mode 100644 index 95c6239e..00000000 --- a/.github/workflows/pr-status-check.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: PR Status Check - -on: - pull_request: - types: [opened, synchronize, reopened] - -# Cancel in-progress runs for the same PR -concurrency: - group: pr-status-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - pull-requests: write - -jobs: - mark-checking: - name: Set Checking Status - runs-on: ubuntu-latest - # Don't run on fork PRs (they can't write labels) - if: github.event.pull_request.head.repo.full_name == github.repository - timeout-minutes: 5 - steps: - - name: Update PR status label - uses: actions/github-script@v7 - with: - retries: 3 - retry-exempt-status-codes: 400,401,403,404,422 - script: | - const { owner, repo } = context.repo; - const prNumber = context.payload.pull_request.number; - const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed']; - - console.log(`::group::PR #${prNumber} - Setting status to Checking`); - - // Remove old status labels (parallel for speed) - const removePromises = statusLabels.map(async (label) => { - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: prNumber, - name: label - }); - console.log(` ✓ Removed: ${label}`); - } catch (e) { - if (e.status !== 404) { - console.log(` ⚠ Could not remove ${label}: ${e.message}`); - } - } - }); - - await Promise.all(removePromises); - - // Add checking label - try { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: ['🔄 Checking'] - }); - console.log(` ✓ Added: 🔄 Checking`); - } catch (e) { - // Label might not exist - create helpful error - if (e.status === 404) { - core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`); - } - throw e; - } - - console.log('::endgroup::'); - console.log(`✅ PR #${prNumber} marked as checking`); diff --git a/.github/workflows/pr-status-gate.yml b/.github/workflows/pr-status-gate.yml index b28b896d..69cb9bd5 100644 --- a/.github/workflows/pr-status-gate.yml +++ b/.github/workflows/pr-status-gate.yml @@ -5,187 +5,581 @@ on: workflows: [CI, Lint, Quality Security] types: [completed] + issue_comment: + types: [created, edited] + + pull_request: + types: [synchronize] + +concurrency: + group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + permissions: pull-requests: write checks: read +env: + # Shared configuration - single source of truth + REQUIRED_CHECKS: | + CI / test-frontend + CI / test-python (3.12) + CI / test-python (3.13) + Lint / python + Quality Security / CodeQL (javascript-typescript) + Quality Security / CodeQL (python) + Quality Security / Python Security (Bandit) + Quality Security / Security Summary + jobs: - update-status: - name: Update PR Status + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 1: CI STATUS (triggered by workflow_run) + # Updates CI status labels when monitored workflows complete + # ═══════════════════════════════════════════════════════════════════════════ + update-ci-status: + name: Update CI Status runs-on: ubuntu-latest - # Only run if this workflow_run is associated with a PR - if: github.event.workflow_run.pull_requests[0] != null + if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null timeout-minutes: 5 + steps: - name: Check all required checks and update label + uses: actions/github-script@v7 + env: + REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }} + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + // NOTE: STATUS_LABELS is intentionally duplicated across jobs. + // GitHub Actions jobs run in isolated contexts and cannot share runtime constants. + // If label values change, update ALL occurrences: update-ci-status, check-status-command + const STATUS_LABELS = Object.freeze({ + CHECKING: '🔄 Checking', + PASSED: '✅ Ready for Review', + FAILED: '❌ Checks Failed' + }); + + const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + + async function fetchCheckRuns(sha) { + const { owner, repo } = context.repo; + // Let the configured retries (retries: 3) handle transient failures + // Don't catch errors - allow them to propagate for retry logic + const checkRuns = await github.paginate( + github.rest.checks.listForRef, + { owner, repo, ref: sha, per_page: 100 }, + (response) => response.data + ); + return checkRuns; + } + + function analyzeChecks(checkRuns) { + const results = []; + let allComplete = true; + let anyFailed = false; + + for (const checkName of REQUIRED_CHECKS) { + const check = checkRuns.find(c => c.name === checkName); + + if (!check) { + results.push({ name: checkName, status: '⏳ Pending', complete: false }); + allComplete = false; + } else if (check.status !== 'completed') { + results.push({ name: checkName, status: '🔄 Running', complete: false }); + allComplete = false; + } else if (check.conclusion === 'success') { + results.push({ name: checkName, status: '✅ Passed', complete: true }); + } else if (check.conclusion === 'skipped') { + results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true }); + } else { + results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true }); + anyFailed = true; + } + } + return { allComplete, anyFailed, results }; + } + + async function updateStatusLabels(prNumber, newLabel) { + const { owner, repo } = context.repo; + const allLabels = Object.values(STATUS_LABELS); + + // Remove all status labels first - throw on non-404 errors to prevent conflicting labels + for (const label of allLabels) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label }); + } catch (e) { + if (e && e.status !== 404) { + // Throw to prevent adding new label if removal failed (could cause conflicting labels) + throw new Error(`Failed to remove label '${label}': ${e.message}`); + } + } + } + + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] }); + } catch (e) { + if (e && e.status === 404) { + core.warning(`Label '${newLabel}' does not exist`); + } else { + throw e; + } + } + } + + // Main logic + const prNumber = context.payload.workflow_run.pull_requests[0].number; + const headSha = context.payload.workflow_run.head_sha; + const triggerWorkflow = context.payload.workflow_run.name; + + console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`); + + const checkRuns = await fetchCheckRuns(headSha); + console.log(`Found ${checkRuns.length} check runs`); + const { allComplete, anyFailed, results } = analyzeChecks(checkRuns); + + for (const r of results) { + console.log(` ${r.status} ${r.name}`); + } + + if (!allComplete) { + const pending = results.filter(r => !r.complete).length; + console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`); + // Update to CHECKING status if checks are still running (prevents stale Ready/Failed status) + await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING); + return; + } + + const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED; + await updateStatusLabels(prNumber, newLabel); + + const passedCount = results.filter(r => r.status === '✅ Passed').length; + const failedCount = results.filter(r => r.failed).length; + + if (anyFailed) { + console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`); + } else { + console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`); + } + + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 2: /check-status COMMAND + # Manual status check - anyone can trigger by commenting /check-status + # ═══════════════════════════════════════════════════════════════════════════ + check-status-command: + name: Check Status Command + runs-on: ubuntu-latest + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/check-status') + timeout-minutes: 5 + + steps: + - name: Run status check and post report + uses: actions/github-script@v7 + env: + REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }} + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + // NOTE: STATUS_LABELS is intentionally duplicated across jobs. + // GitHub Actions jobs run in isolated contexts and cannot share runtime constants. + // If label values change, update ALL occurrences: update-ci-status, check-status-command + const STATUS_LABELS = Object.freeze({ + CHECKING: '🔄 Checking', + PASSED: '✅ Ready for Review', + FAILED: '❌ Checks Failed' + }); + + // NOTE: REVIEW_LABELS is intentionally duplicated across jobs. + // If label values change, update ALL occurrences: check-status-command, update-review-status + const REVIEW_LABELS = Object.freeze([ + 'Missing AC Approval', + 'AC: Approved', + 'AC: Changes Requested', + 'AC: Blocked', + 'AC: Needs Re-review', + 'AC: Reviewed' + ]); + + const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + + const { owner, repo } = context.repo; + const prNumber = context.payload.issue.number; + const requestedBy = context.payload.comment.user.login; + + // Get PR details + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber + }); + const headSha = pr.head.sha; + + console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`); + + // Fetch check runs with pagination to handle >100 checks + const checkRuns = await github.paginate( + github.rest.checks.listForRef, + { owner, repo, ref: headSha, per_page: 100 }, + (response) => response.data + ); + console.log(`Found ${checkRuns.length} check runs`); + + // Analyze results + const results = []; + let allComplete = true; + let anyFailed = false; + + for (const checkName of REQUIRED_CHECKS) { + const check = checkRuns.find(c => c.name === checkName); + + if (!check) { + results.push({ name: checkName, emoji: '⏳', complete: false }); + allComplete = false; + } else if (check.status !== 'completed') { + results.push({ name: checkName, emoji: '🔄', complete: false }); + allComplete = false; + } else if (check.conclusion === 'success') { + results.push({ name: checkName, emoji: '✅', complete: true }); + } else if (check.conclusion === 'skipped') { + results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true }); + } else { + results.push({ name: checkName, emoji: '❌', complete: true, failed: true }); + anyFailed = true; + } + } + + // Get current labels + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber + }); + const labelNames = currentLabels.map(l => l.name); + const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None'; + const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None'; + + // Update label if all checks complete + let newStatusLabel = STATUS_LABELS.CHECKING; + let statusChanged = false; + + if (allComplete) { + newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED; + + if (newStatusLabel !== currentStatusLabel) { + statusChanged = true; + // Remove all status labels first - throw on non-404 errors to prevent conflicting labels + for (const label of Object.values(STATUS_LABELS)) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label }); + } catch (e) { + if (e && e.status !== 404) { + throw new Error(`Failed to remove label '${label}': ${e.message}`); + } + } + } + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] }); + } + } + + // Build status report + const passedCount = results.filter(r => r.emoji === '✅').length; + let statusEmoji = '🔄'; + if (allComplete && !anyFailed) statusEmoji = '✅'; + else if (allComplete && anyFailed) statusEmoji = '❌'; + + const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n'); + + const lines = [ + `## ${statusEmoji} PR Status Report`, + '', + `| Label | Value |`, + `|-------|-------|`, + `| CI Status | ${newStatusLabel} |`, + `| AC Review | ${currentReviewLabel} |`, + '' + ]; + + if (statusChanged) { + lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``); + lines.push(''); + } + + lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`); + lines.push(''); + lines.push('| Status | Check |'); + lines.push('|--------|-------|'); + lines.push(checksTable); + lines.push(''); + lines.push('---'); + lines.push(`Triggered by \`/check-status\` from @${requestedBy}`); + + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: lines.join('\n') + }); + + console.log(`✅ Posted status report to PR #${prNumber}`); + + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 3: AUTO-CLAUDE REVIEW + # Processes Auto-Claude review comments from trusted sources + # Security: Only bots and collaborators can update labels + # ═══════════════════════════════════════════════════════════════════════════ + update-review-status: + name: Update Review Status + runs-on: ubuntu-latest + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + !contains(github.event.comment.body, '/check-status') + timeout-minutes: 5 + + steps: + - name: Check for Auto-Claude review + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + // Security configuration + // SECURITY: Only [bot] suffixed accounts are protected by GitHub. + // Regular usernames can be registered by anyone and are NOT trusted. + const TRUSTED_BOT_ACCOUNTS = Object.freeze([ + 'github-actions[bot]', + 'auto-claude[bot]' + ]); + + const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([ + 'COLLABORATOR', + 'MEMBER', + 'OWNER' + ]); + + const IDENTIFIER_PATTERNS = Object.freeze([ + '🤖 Auto Claude PR Review', + 'Auto Claude Review', + 'Auto-Claude Review' + ]); + + // SECURITY: Regex patterns are tightened to prevent false matches + // Using \s* instead of .* and requiring specific emoji + verdict format + const VERDICTS = Object.freeze({ + APPROVED: { + patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'], + // Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE + regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i, + label: 'AC: Approved' + }, + CHANGES_REQUESTED: { + patterns: ['NEEDS REVISION', 'Needs Revision'], + // Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠 + regex: /Merge Verdict:\s*🟠/, + label: 'AC: Changes Requested' + }, + BLOCKED: { + patterns: ['BLOCKED'], + // Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴 + regex: /Merge Verdict:\s*🔴/, + label: 'AC: Blocked' + } + }); + + // NOTE: REVIEW_LABELS is intentionally duplicated across jobs. + // GitHub Actions jobs run in isolated contexts and cannot share runtime constants. + // If label values change, update ALL occurrences: check-status-command, update-review-status + const REVIEW_LABELS = Object.freeze([ + 'Missing AC Approval', + 'AC: Approved', + 'AC: Changes Requested', + 'AC: Blocked', + 'AC: Needs Re-review', + 'AC: Reviewed' + ]); + + // Helper functions + // SECURITY: Verify both username AND account type to prevent spoofing + function isTrustedBot(username, userType) { + const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase()); + // Only trust if it's a known bot account AND GitHub confirms it's a Bot type + return isKnownBot && userType === 'Bot'; + } + + function isTrustedAssociation(assoc) { + return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc); + } + + function isAutoClaudeComment(body) { + return IDENTIFIER_PATTERNS.some(p => body.includes(p)); + } + + function parseVerdict(body) { + const safeBody = body.slice(0, 5000); + for (const [key, config] of Object.entries(VERDICTS)) { + const patternMatch = config.patterns.some(p => safeBody.includes(p)); + const regexMatch = config.regex && config.regex.test(safeBody); + if (patternMatch || regexMatch) { + return { verdict: key, label: config.label }; + } + } + return null; + } + + async function updateReviewLabels(prNumber, newLabel) { + const { owner, repo } = context.repo; + + // Remove all review labels first - throw on non-404 errors to prevent conflicting labels + for (const label of REVIEW_LABELS) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label }); + console.log(` Removed: ${label}`); + } catch (e) { + if (e && e.status !== 404) { + // Throw to prevent adding new label if removal failed (could cause conflicting labels) + throw new Error(`Failed to remove label '${label}': ${e.message}`); + } + } + } + + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] }); + console.log(` Added: ${newLabel}`); + } catch (e) { + if (e && e.status === 404) { + core.warning(`Label '${newLabel}' does not exist`); + } else { + throw e; + } + } + } + + // Main logic + const prNumber = context.payload.issue.number; + const comment = context.payload.comment; + const commenter = comment.user.login; + const commenterType = comment.user.type; + const authorAssociation = comment.author_association; + const body = comment.body || ''; + + console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`); + + // Security checks + // SECURITY: Bot status requires BOTH username match AND verified Bot type + const isBot = isTrustedBot(commenter, commenterType); + const isCollaborator = isTrustedAssociation(authorAssociation); + const isACComment = isAutoClaudeComment(body); + + console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`); + + if (!isBot && !isCollaborator) { + console.log('Skipping: Not a trusted bot or collaborator'); + return; + } + + if (!isACComment) { + console.log('Skipping: Not an Auto-Claude comment'); + return; + } + + const verdictResult = parseVerdict(body); + if (!verdictResult) { + console.log('Skipping: Could not parse verdict'); + return; + } + + console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`); + await updateReviewLabels(prNumber, verdictResult.label); + console.log(`✅ PR #${prNumber} review status updated`); + + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 4: RE-REVIEW ON PUSH + # When new commits pushed after AC approval, require re-review + # ═══════════════════════════════════════════════════════════════════════════ + require-re-review: + name: Require Re-review on Push + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.action == 'synchronize' + timeout-minutes: 5 + + steps: + - name: Check and reset AC approval if needed uses: actions/github-script@v7 with: retries: 3 retry-exempt-status-codes: 400,401,403,404,422 script: | const { owner, repo } = context.repo; - const prNumber = context.payload.workflow_run.pull_requests[0].number; - const headSha = context.payload.workflow_run.head_sha; - const triggerWorkflow = context.payload.workflow_run.name; + const prNumber = context.payload.pull_request.number; + const pusher = context.payload.sender.login; - // ═══════════════════════════════════════════════════════════════════════ - // REQUIRED CHECK RUNS - Job-level checks (not workflow-level) - // ═══════════════════════════════════════════════════════════════════════ - // Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}" - // - // To find check names: Go to PR → Checks tab → copy exact name - // To update: Edit this list when workflow jobs are added/renamed/removed - // - // Last validated: 2026-01-02 - // ═══════════════════════════════════════════════════════════════════════ - const requiredChecks = [ - // CI workflow (ci.yml) - 3 checks - 'CI / test-frontend', - 'CI / test-python (3.12)', - 'CI / test-python (3.13)', - // Lint workflow (lint.yml) - 1 check - 'Lint / python', - // Quality Security workflow (quality-security.yml) - 4 checks - 'Quality Security / CodeQL (javascript-typescript)', - 'Quality Security / CodeQL (python)', - 'Quality Security / Python Security (Bandit)', - 'Quality Security / Security Summary' - ]; + console.log(`PR #${prNumber} - New commits by: ${pusher}`); - const statusLabels = { - checking: '🔄 Checking', - passed: '✅ Ready for Review', - failed: '❌ Checks Failed' - }; + // Get current labels + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber + }); + const labelNames = labels.map(l => l.name); - console.log(`::group::PR #${prNumber} - Checking required checks`); - console.log(`Triggered by: ${triggerWorkflow}`); - console.log(`Head SHA: ${headSha}`); - console.log(`Required checks: ${requiredChecks.length}`); - console.log(''); + // Check if PR was approved + const wasApproved = labelNames.includes('AC: Approved'); - // Fetch all check runs for this commit - let allCheckRuns = []; + if (!wasApproved) { + console.log('PR was not AC-approved, no action needed'); + return; + } + + console.log('PR was AC-approved, resetting to require re-review'); + + // Remove AC: Approved - throw on non-404 errors to prevent conflicting labels try { - const { data } = await github.rest.checks.listForRef({ - owner, - repo, - ref: headSha, - per_page: 100 + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'AC: Approved' }); - allCheckRuns = data.check_runs; - console.log(`Found ${allCheckRuns.length} total check runs`); - } catch (error) { - // Add warning annotation so maintainers are alerted - core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`); - console.log(`::error::Failed to fetch check runs: ${error.message}`); - console.log('::endgroup::'); - return; - } - - let allComplete = true; - let anyFailed = false; - const results = []; - - // Check each required check - for (const checkName of requiredChecks) { - const check = allCheckRuns.find(c => c.name === checkName); - - if (!check) { - results.push({ name: checkName, status: '⏳ Pending', complete: false }); - allComplete = false; - } else if (check.status !== 'completed') { - results.push({ name: checkName, status: '🔄 Running', complete: false }); - allComplete = false; - } else if (check.conclusion === 'success') { - results.push({ name: checkName, status: '✅ Passed', complete: true }); - } else if (check.conclusion === 'skipped') { - // Skipped checks are treated as passed (e.g., path filters, conditional jobs) - results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true }); - } else { - results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true }); - anyFailed = true; + console.log(' Removed: AC: Approved'); + } catch (e) { + if (e && e.status !== 404) { + // Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels) + core.error(`Failed to remove 'AC: Approved' label: ${e.message}`); + throw e; } } - // Print results table - console.log(''); - console.log('Check Status:'); - console.log('─'.repeat(70)); - for (const r of results) { - const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name; - console.log(` ${r.status.padEnd(12)} ${shortName}`); - } - console.log('─'.repeat(70)); - console.log('::endgroup::'); - - // Only update label if all required checks are complete - if (!allComplete) { - const pending = results.filter(r => !r.complete).length; - console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`); - return; - } - - // Determine final label - const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed; - - console.log(`::group::Updating PR #${prNumber} label`); - - // Remove old status labels - for (const label of Object.values(statusLabels)) { - try { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: prNumber, - name: label - }); - console.log(` ✓ Removed: ${label}`); - } catch (e) { - if (e.status !== 404) { - console.log(` ⚠ Could not remove ${label}: ${e.message}`); - } - } - } - - // Add final status label + // Add AC: Needs Re-review try { await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: [newLabel] + owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review'] }); - console.log(` ✓ Added: ${newLabel}`); + console.log(' Added: AC: Needs Re-review'); } catch (e) { - if (e.status === 404) { - core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`); + if (e && e.status === 404) { + core.warning("Label 'AC: Needs Re-review' does not exist"); + } else { + throw e; } - throw e; } - console.log('::endgroup::'); + // Post notification comment + const commentLines = [ + '## 🔄 Re-review Required', + '', + 'New commits were pushed after Auto-Claude approval.', + '', + '| Previous | Current |', + '|----------|---------|', + '| `AC: Approved` | `AC: Needs Re-review` |', + '', + 'Please run Auto-Claude review again or request a manual review.', + '', + '---', + `Triggered by push from @${pusher}` + ]; - // Summary - const passedCount = results.filter(r => r.status === '✅ Passed').length; - const skippedCount = results.filter(r => r.skipped).length; - const failedCount = results.filter(r => r.failed).length; + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: commentLines.join('\n') + }); - if (anyFailed) { - console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`); - core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`); - core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`); - } else { - const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : ''; - const totalSuccessful = passedCount + skippedCount; - console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`); - core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`); - core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`); - } - - // Add results to summary - core.summary.addTable([ - [{data: 'Check', header: true}, {data: 'Status', header: true}], - ...results.map(r => [r.name, r.status]) - ]); - await core.summary.write(); + console.log(`✅ Posted re-review notification to PR #${prNumber}`);