fix: automate auto labeling based on comments (#812)

* fix: automate auto labeling based on comments

* resolve comments

* fix approved workflow to auto label

* enhance yml

* fix: improve error handling and align verdicts with backend outputs

- Replace broad catch blocks with proper 404-only suppression, log
  warnings for network/auth/rate-limit errors using core.warning
- Update VERDICTS map: rename REJECTED to BLOCKED with 'AC: Blocked'
  label to match backend outputs
- Remove unused RE_REVIEW entry (manual-only, no backend output)
- Simplify APPROVED regex by removing unused 🟢 emoji
- Remove unconditional CI status reset from require-re-review job
  to avoid race conditions with update-ci-status job
- Add null safety checks (e && e.status) for consistent error handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address security vulnerabilities and improve workflow robustness

Security fixes:
- Remove non-[bot] usernames from TRUSTED_BOT_ACCOUNTS (spoofing vulnerability)
- Verify bot account type via comment.user.type === 'Bot' (authorization bypass)
- Tighten parseVerdict regex patterns using \s* instead of .* wildcards

Robustness improvements:
- Throw errors instead of warning on label removal failures (prevents conflicting labels)
- Remove try-catch from fetchCheckRuns to let retries handle transient failures
- Implement pagination for check runs (>100 checks support)
- Implement pagination for PR files (>100 files support)
- Update status to 'Checking' when checks are incomplete (prevents stale labels)

Documentation:
- Document intentional STATUS_LABELS/REVIEW_LABELS duplication across jobs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add pagination for check runs in check-status-command job

Replace single-page listForRef call with github.paginate to handle
repositories with >100 check runs on a single commit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: sync REVIEW_LABELS and improve error handling in require-re-review

- Add missing 'AC: Reviewed' to REVIEW_LABELS in check-status-command job
  to match update-review-status job and avoid maintenance confusion
- Change removeLabel error handling in require-re-review to throw on
  non-404 errors, preventing 'AC: Approved' and 'AC: Needs Re-review'
  from coexisting when label removal fails

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
Alex
2026-01-08 13:22:49 +01:00
committed by GitHub
parent a74bd8656e
commit 32e8fee3b2
4 changed files with 865 additions and 450 deletions
-227
View File
@@ -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();
+320
View File
@@ -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();
-72
View File
@@ -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`);
+545 -151
View File
@@ -5,187 +5,581 @@ on:
workflows: [CI, Lint, Quality Security] workflows: [CI, Lint, Quality Security]
types: [completed] 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: permissions:
pull-requests: write pull-requests: write
checks: read 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: 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 runs-on: ubuntu-latest
# Only run if this workflow_run is associated with a PR if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
if: github.event.workflow_run.pull_requests[0] != null
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- name: Check all required checks and update label - 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(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
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 uses: actions/github-script@v7
with: with:
retries: 3 retries: 3
retry-exempt-status-codes: 400,401,403,404,422 retry-exempt-status-codes: 400,401,403,404,422
script: | script: |
const { owner, repo } = context.repo; const { owner, repo } = context.repo;
const prNumber = context.payload.workflow_run.pull_requests[0].number; const prNumber = context.payload.pull_request.number;
const headSha = context.payload.workflow_run.head_sha; const pusher = context.payload.sender.login;
const triggerWorkflow = context.payload.workflow_run.name;
// ═══════════════════════════════════════════════════════════════════════ console.log(`PR #${prNumber} - New commits by: ${pusher}`);
// 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'
];
const statusLabels = { // Get current labels
checking: '🔄 Checking', const { data: labels } = await github.rest.issues.listLabelsOnIssue({
passed: '✅ Ready for Review', owner, repo, issue_number: prNumber
failed: '❌ Checks Failed' });
}; const labelNames = labels.map(l => l.name);
console.log(`::group::PR #${prNumber} - Checking required checks`); // Check if PR was approved
console.log(`Triggered by: ${triggerWorkflow}`); const wasApproved = labelNames.includes('AC: Approved');
console.log(`Head SHA: ${headSha}`);
console.log(`Required checks: ${requiredChecks.length}`);
console.log('');
// Fetch all check runs for this commit if (!wasApproved) {
let allCheckRuns = []; 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 { try {
const { data } = await github.rest.checks.listForRef({ await github.rest.issues.removeLabel({
owner, owner, repo, issue_number: prNumber, name: 'AC: Approved'
repo,
ref: headSha,
per_page: 100
}); });
allCheckRuns = data.check_runs; console.log(' Removed: AC: Approved');
console.log(`Found ${allCheckRuns.length} total check runs`); } catch (e) {
} catch (error) { if (e && e.status !== 404) {
// Add warning annotation so maintainers are alerted // Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels)
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`); core.error(`Failed to remove 'AC: Approved' label: ${e.message}`);
console.log(`::error::Failed to fetch check runs: ${error.message}`); throw e;
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;
} }
} }
// Print results table // Add AC: Needs Re-review
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
try { try {
await github.rest.issues.addLabels({ await github.rest.issues.addLabels({
owner, owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
repo,
issue_number: prNumber,
labels: [newLabel]
}); });
console.log(` ✓ Added: ${newLabel}`); console.log(' Added: AC: Needs Re-review');
} catch (e) { } catch (e) {
if (e.status === 404) { if (e && e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`); 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.',
'',
'---',
`<sub>Triggered by push from @${pusher}</sub>`
];
// Summary await github.rest.issues.createComment({
const passedCount = results.filter(r => r.status === '✅ Passed').length; owner, repo, issue_number: prNumber, body: commentLines.join('\n')
const skippedCount = results.filter(r => r.skipped).length; });
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) { console.log(`✅ Posted re-review notification to PR #${prNumber}`);
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();