name: PR triage labels # Auto-applies author-class labels at PR open and toggles lifecycle labels # on review/synchronize events. Labels are defined in .github/labels.yml # and synced by .github/workflows/labels-sync.yml. # # Also applies path-derived topic labels (documentation, tests, docker, # github-actions, ...) that feed release-notes categorization. These were # historically applied by the AI code reviewer, which became label-only # opt-in (#4955), so triage now owns the mechanical subset. Topic labels # are applied on opened and ready_for_review only, and are additive-only: # a label a maintainer removed is not re-added on every push, and # semantic labels (bugfix, feature, security, performance...) stay # human/AI-applied. # # Uses pull_request (not pull_request_target) — fork PRs get a read-only # token, so label calls return 403. The script catches 403 and continues # so fork PRs don't show a failing check; maintainers can apply labels # manually for fork PRs that need them. We accept the no-op behavior on # forks vs the security cost of pull_request_target running with secrets # on fork code. # # CODEOWNERS list below is mirrored in .github/CODEOWNERS global owners # (line 6) — keep both in sync when the maintainer roster changes. on: pull_request: types: [opened, synchronize, ready_for_review] pull_request_review: types: [submitted, dismissed] # No concurrency group — intentionally omitted. # Previous attempt (#3554, reverted #3599) used cancel-in-progress which # killed in-progress PR runs before they produced useful results. Race # between synchronize and pull_request_review can produce transient # label flapping in rare cases; maintainers can correct manually. permissions: {} # Minimal top-level for OSSF Scorecard Token-Permissions jobs: triage: runs-on: ubuntu-latest timeout-minutes: 5 permissions: issues: write # PR labels live on the underlying issue resource pull-requests: read # listFiles for path-derived topic labels steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Apply triage labels uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | // Keep CODEOWNERS list in sync with .github/CODEOWNERS line 6. const CODEOWNERS = ['LearningCircuit', 'hashedviking', 'djpetti']; // Known AI-bot accounts that don't carry the [bot] suffix. const KNOWN_BOTS = ['moltenbot000', 'mseep-ai', 'Nexus-Digital-Automations']; const pr = context.payload.pull_request; const issueNumber = pr.number; const author = pr.user.login; const association = pr.author_association; const isBot = author.endsWith('[bot]') || KNOWN_BOTS.includes(author); const isInternal = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(association); // 403 on label calls is the expected outcome for fork PRs — // pull_request gives forks a read-only token by design. We // log and continue so the workflow run stays green and a // maintainer can apply labels manually instead of seeing // a red check on every fork contribution. const isReadOnlyTokenError = (err) => { if (err.status !== 403) return false; console.log(`Label call returned 403 (read-only token, likely a fork PR). Skipping.`); return true; }; const addLabels = async (labels) => { if (!labels.length) return; try { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels, }); } catch (err) { if (!isReadOnlyTokenError(err)) throw err; } }; const removeLabel = async (name) => { try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, name, }); } catch (err) { if (err.status === 404) return; // label wasn't applied if (!isReadOnlyTokenError(err)) throw err; } }; // Path-derived topic labels for release-notes categorization. // Mechanical mappings only — a rule belongs here iff the label // follows from *which files* changed, never from what the // change means. First-match-per-rule over all changed files. const TOPIC_RULES = [ [/^\.github\/workflows\//, 'github-actions'], [/^(\.github\/|\.pre-commit-hooks\/|\.pre-commit-config\.yaml$|scripts\/ci\/)/, 'ci-cd'], [/^(Dockerfile|docker-compose|cookiecutter-docker\/|unraid-templates\/)/, 'docker'], [/^(docs\/|[^/]+\.md$)/, 'documentation'], [/^tests\//, 'tests'], [/^src\/local_deep_research\/database\//, 'database'], [/^src\/local_deep_research\/(defaults|settings)\//, 'configuration'], [/^(pyproject\.toml|pdm\.lock|package\.json|package-lock\.json)$/, 'dependencies'], [/^src\/local_deep_research\/(advanced_search_system|web_search_engines)\//, 'research'], [/^src\/.*\.py$/, 'python'], [/^src\/.*\.(js|mjs)$/, 'javascript'], [/^src\/.*\.css$/, 'css'], ]; const topicLabels = async () => { const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: issueNumber, per_page: 100, }); const labels = new Set(); for (const f of files) { for (const [pattern, label] of TOPIC_RULES) { if (pattern.test(f.filename)) labels.add(label); } } return [...labels]; }; const action = context.payload.action; const eventName = context.eventName; if (eventName === 'pull_request' && action === 'opened') { const labels = []; if (isBot) labels.push('bot'); if (!isInternal && !isBot) labels.push('external-contributor'); if (association === 'FIRST_TIME_CONTRIBUTOR') labels.push('first-time-contributor'); if (!isInternal) labels.push('needs-codeowner-review'); labels.push(...await topicLabels()); await addLabels(labels); return; } if (eventName === 'pull_request' && (action === 'synchronize' || action === 'ready_for_review')) { const current = pr.labels.map((l) => l.name); if (current.includes('awaiting-author')) { await removeLabel('awaiting-author'); await addLabels(['awaiting-codeowner']); } // Draft → ready is a deliberate "the diff is final" moment, so // recompute topic labels once there. Deliberately NOT done on // synchronize: that would re-add labels a maintainer removed, // on every push. if (action === 'ready_for_review') { await addLabels(await topicLabels()); } return; } if (eventName === 'pull_request_review' && action === 'submitted') { const review = context.payload.review; const reviewer = review.user.login; // Strict CODEOWNERS-only check. The hardcoded list above must // mirror .github/CODEOWNERS line 6. We deliberately do NOT // accept any OWNER/MEMBER/COLLABORATOR association as a // codeowner: branch protection here may later adopt // require_code_owner_reviews=true, at which point clearing // needs-codeowner-review on a non-codeowner MEMBER review // would be a security-relevant mislabel. if (!CODEOWNERS.includes(reviewer)) return; if (review.state === 'approved') { await removeLabel('needs-codeowner-review'); await removeLabel('awaiting-codeowner'); // Also clear awaiting-author: a codeowner can resolve // their own changes_requested review by approving without // an intervening synchronize (e.g., the author convinced // them via comments). await removeLabel('awaiting-author'); } else if (review.state === 'changes_requested') { await removeLabel('needs-codeowner-review'); await removeLabel('awaiting-codeowner'); await addLabels(['awaiting-author']); } // 'commented' → no-op. return; } if (eventName === 'pull_request_review' && action === 'dismissed') { // GitHub sets review.state to "dismissed" on this event — the // original state is not preserved (github/docs#20216). Use the // awaiting-author label as the discriminator: it's only set by // a codeowner's changes_requested review, so its presence is // proof the dismissal is the one we care about. Dismissals of // approval/comment reviews are naturally no-ops because // awaiting-author won't be present. const review = context.payload.review; if (!CODEOWNERS.includes(review.user.login)) return; const current = pr.labels.map((l) => l.name); if (current.includes('awaiting-author')) { await removeLabel('awaiting-author'); await addLabels(['needs-codeowner-review']); } return; }