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}`);