Auto-prefix & Label Issues #703
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Auto-prefix & Label Issues | |
| on: | |
| issues: | |
| types: [opened, edited] | |
| schedule: | |
| - cron: '0 0 * * *' # daily catch-up at midnight UTC | |
| permissions: | |
| issues: write | |
| concurrency: | |
| group: auto-prefix-issues-${{ github.event.issue.number || 'scheduled' }} | |
| cancel-in-progress: true | |
| jobs: | |
| prefix_and_label: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Ensure labels exist, then prefix titles & add labels | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| // ── 1. Ensure required labels exist ────────────────────────── | |
| const required = [ | |
| { name: 'bug', color: 'd73a4a', description: "Something isn't working" }, | |
| { name: 'enhancement', color: 'a2eeef', description: 'New feature or request' } | |
| ]; | |
| const { data: existingLabels } = await github.rest.issues.listLabelsForRepo({ | |
| owner, repo, per_page: 100 | |
| }); | |
| const existingNames = new Set(existingLabels.map(l => l.name)); | |
| for (const lbl of required) { | |
| if (!existingNames.has(lbl.name)) { | |
| await github.rest.issues.createLabel({ | |
| owner, repo, | |
| name: lbl.name, | |
| color: lbl.color, | |
| description: lbl.description | |
| }); | |
| console.log(`Created label "${lbl.name}"`); | |
| } | |
| } | |
| // ── 2. Keyword lists ───────────────────────────────────────── | |
| // Bug keywords have higher priority when both match | |
| const bugWords = [ | |
| 'bug', 'error', 'crash', 'broken', 'fault', 'defect', | |
| 'regression', 'problem', 'fail', 'failed', 'failure', | |
| 'not working', "doesn't work", "won't work", 'exception', | |
| 'unexpected', 'incorrect' | |
| ]; | |
| const enhancementWords = [ | |
| 'add', 'feature', 'improve', 'enhancement', 'request', | |
| 'support', 'implement', 'upgrade', 'extend', 'proposal', | |
| 'new', 'enhance', 'suggestion' | |
| ]; | |
| // Word-boundary match to avoid false positives | |
| // e.g. "address" should not match "add" | |
| function matchesKeyword(text, words) { | |
| const lower = text.toLowerCase(); | |
| return words.some(w => { | |
| if (w.includes(' ')) return lower.includes(w); // multi-word phrases: substring match | |
| const re = new RegExp(`\\b${w}\\b`, 'i'); | |
| return re.test(text); | |
| }); | |
| } | |
| function classify(title, body) { | |
| const text = `${title} ${body || ''}`; | |
| const isBug = matchesKeyword(text, bugWords); | |
| const isEnhancement = matchesKeyword(text, enhancementWords); | |
| if (isBug && isEnhancement) { | |
| // Tiebreak: check title first — if title clearly says bug, it's a bug | |
| if (matchesKeyword(title, bugWords)) return 'bug'; | |
| if (matchesKeyword(title, enhancementWords)) return 'enhancement'; | |
| return 'bug'; // default tiebreak: bugs are more urgent | |
| } | |
| if (isBug) return 'bug'; | |
| if (isEnhancement) return 'enhancement'; | |
| return null; | |
| } | |
| // ── 3. Determine which issues to process ───────────────────── | |
| let issuesToProcess = []; | |
| if (context.eventName === 'issues') { | |
| // Single issue event — process just that issue | |
| issuesToProcess = [context.payload.issue]; | |
| } else { | |
| // Scheduled run — process all open issues | |
| const allIssues = await github.paginate( | |
| github.rest.issues.listForRepo, | |
| { owner, repo, state: 'open', per_page: 100 } | |
| ); | |
| // Filter out pull requests (GitHub returns PRs via the issues API) | |
| issuesToProcess = allIssues.filter(i => !i.pull_request); | |
| } | |
| // ── 4. Process each issue ──────────────────────────────────── | |
| for (const issue of issuesToProcess) { | |
| const origTitle = issue.title; | |
| const body = issue.body || ''; | |
| const currentLabels = issue.labels.map(l => l.name); | |
| // Skip if already prefixed | |
| if (/^\[(bug|enhancement)\]/i.test(origTitle)) continue; | |
| // Skip if already labeled with bug or enhancement | |
| if (currentLabels.includes('bug') || currentLabels.includes('enhancement')) continue; | |
| const kind = classify(origTitle, body); | |
| if (!kind) continue; | |
| const prefix = kind === 'bug' ? '[bug]' : '[enhancement]'; | |
| // Update title | |
| await github.rest.issues.update({ | |
| owner, repo, | |
| issue_number: issue.number, | |
| title: `${prefix} ${origTitle}` | |
| }); | |
| console.log(`#${issue.number}: prefixed with ${prefix}`); | |
| // Add label | |
| await github.rest.issues.addLabels({ | |
| owner, repo, | |
| issue_number: issue.number, | |
| labels: [kind] | |
| }); | |
| console.log(`#${issue.number}: added label "${kind}"`); | |
| } |