From 619b1936d3d6a9fca3ccf7d808e2793d66b87fb6 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Sun, 18 Oct 2026 07:15:33 -0400 Subject: [PATCH] github: triage bot, area and type labels, needs-triage, /triage and needs-info Adapted from SolidCraft's triage bot: PR paths and issue titles map to FieldCraft's area:* labels, the issue forms give the type, /triage understands labels with spaces (high, S/M/L, in-progress), and short bug reports without steps are asked once and closed after 14 days. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- .github/workflows/triage.yml | 86 ++++++++ scripts/triage/needs-info.js | 178 +++++++++++++++++ scripts/triage/needs-info.test.js | 145 ++++++++++++++ scripts/triage/triage.js | 315 ++++++++++++++++++++++++++++++ scripts/triage/triage.test.js | 202 +++++++++++++++++++ 5 files changed, 926 insertions(+) create mode 100644 .github/workflows/triage.yml create mode 100644 scripts/triage/needs-info.js create mode 100644 scripts/triage/needs-info.test.js create mode 100644 scripts/triage/triage.js create mode 100644 scripts/triage/triage.test.js diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml new file mode 100644 index 0000000..6e957c8 --- /dev/null +++ b/.github/workflows/triage.yml @@ -0,0 +1,86 @@ +# Labels new issues and PRs from fixed rules, `/triage` for maintainers, asks short bug reports +# for reproduction steps (`needs-info`, closed after 14 days unanswered), and a manual backfill +# (CONTRIBUTING.md, "Labels and triage"). The logic is in scripts/triage/ (tested in ci.yml). +name: Triage + +on: + issues: + types: [opened, labeled, edited] + issue_comment: + types: [created] + # pull_request_target so PRs from forks can be labelled. It runs the base branch's + # scripts/triage and only lists the PR's changed paths; the PR's code is never checked out. + pull_request_target: + types: [opened] + schedule: + - cron: '41 5 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Only report what would be labelled' + type: boolean + default: true + +permissions: + contents: read # to check out scripts/triage + issues: write + pull-requests: write + +concurrency: + group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }} + cancel-in-progress: false + +jobs: + triage: + name: Triage bot + runs-on: ubuntu-24.04 + timeout-minutes: 30 + # Filtered here so the common events (every comment, every label) start no runner. + if: >- + (github.event_name == 'issue_comment' && + github.event.comment.user.type != 'Bot' && startsWith(github.event.comment.body, '/triage')) || + ((github.event_name == 'issue_comment' || github.event.action == 'edited') && + contains(github.event.issue.labels.*.name, 'needs-info') && + github.event.sender.login == github.event.issue.user.login) || + (github.event.action == 'labeled' && startsWith(github.event.label.name, 'priority') && + contains(github.event.issue.labels.*.name, 'needs-triage')) || + (github.event_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited') + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts/triage + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + DRY_RUN: ${{ inputs.dry_run }} + with: + script: | + const triage = require('./scripts/triage/triage.js'); + const info = require('./scripts/triage/needs-info.js'); + const event = context.eventName; + const action = context.payload.action; + const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); + const th = (...names) => names.map((data) => ({ data, header: true })); + if (event === 'issue_comment') { + core.info(await triage.handleComment({ github, context })); + await info.handleReply({ github, context }); + } else if (event === 'issues' && action === 'labeled') { + await triage.handleLabeled({ github, context }); + } else if (event === 'issues' && action === 'edited') { + await info.handleReply({ github, context }); + } else if (event === 'schedule') { + const closed = await info.sweep({ github, context }); + core.info(`Closed for missing information: ${closed.join(', ') || 'none'}`); + } else if (event === 'workflow_dispatch') { + const dryRun = process.env.DRY_RUN !== 'false'; + const plan = await triage.backfill({ github, context, dryRun }); + const asks = await info.backfill({ github, context, dryRun }); + await core.summary + .addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`) + .addTable([th('Item', 'Kind', 'Labels', 'Title'), ...plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)])]) + .addHeading(dryRun ? `needs-info dry run: ${asks.length} would be asked` : `needs-info: ${asks.length} asked`) + .addTable([th('Issue', 'Asks', 'Title'), ...asks.map((p) => [`#${p.number}`, esc(p.asks.join(', ')), esc(p.title)])]) + .write(); + } else { + core.info(`Labelled: ${(await triage.handleOpened({ github, context })).join(', ') || 'nothing'}`); + if (event === 'issues') await info.handleOpened({ github, context }); + } diff --git a/scripts/triage/needs-info.js b/scripts/triage/needs-info.js new file mode 100644 index 0000000..8ea2dc8 --- /dev/null +++ b/scripts/triage/needs-info.js @@ -0,0 +1,178 @@ +// `needs-info`: a short hand-written bug report without reproduction steps is asked for them once. +// The author's reply (or an edit that adds them) clears the label; the daily sweep closes what +// stays unanswered for INFO_DAYS. Reports filed from the app (Help › Report a Bug, +// crates/engine/src/bug_report.rs) carry their session, crash report and version, so they are +// never asked. Used by .github/workflows/triage.yml. +'use strict'; + +const { ensureLabel, isMaintainer, labelNames, section } = require('./triage.js'); + +const LABEL = 'needs-info'; +const LABEL_COLOR = 'd4c5f9'; +const LABEL_DESCRIPTION = 'waiting on the reporter for reproduction steps'; +const INFO_DAYS = 14; +const DAY = 24 * 3600 * 1000; +const MAX_TEXT = 20000; + +function repoArgs(context) { + return { owner: context.repo.owner, repo: context.repo.repo }; +} + +// What only the app's own reports carry (crates/engine/src/bug_report.rs). +const FROM_APP = /Replay the attached session|^Crash report `|^Backtrace \(top\):|^Session \(session\.json\):/m; + +// Requests, questions and plans, which aren't reproduced. +const NOT_A_BUG = /^\s*\[(feat|feature|change|enhancement)|feature request|suggestion|\?\s*$|^\s*(tracker|refactor|support|add|allow|how|why|request|missing)\b/i; +// A report this long describes what it saw; only short ones are asked. Counted without the +// form's headings and its `_No response_` placeholders. +const SHORT = 300; +const ATTACHMENT = /user-attachments\/|\.(fieldcraft|solidcraft|inp|bdf|k|vtu|step|stp|3mf|stl|json)\b/i; +const COMMAND = /["`][a-z_]+\.[a-z_][a-z_.]*["`]|\bfieldcraft(-cli)?\s+(--sample|run|snapshot)\b/; +const VERSION = /\bv?\d+\.\d+\.\d+\b|\b[0-9a-f]{7,40}\b/i; + +function prose(body) { + return body + .split('\n') + .filter((l) => !/^#{2,3}\s/.test(l) && l.trim() !== '_No response_') + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// What a bug report lacks: [] when it can be reproduced. +function missing(title, body) { + const t = String(title || ''); + const b = String(body || '').slice(0, MAX_TEXT).replace(//g, ''); + if (NOT_A_BUG.test(t) || FROM_APP.test(b) || prose(b).length >= SHORT) return []; + const steps = `${section(b, 'Reproduction')}\n${section(b, 'Steps to reproduce')}`.replace(/_No response_/g, ''); + const reproducible = + steps.replace(/\s+/g, '').length >= 30 || ATTACHMENT.test(b) || COMMAND.test(b) || /^\s*1[.)]\s/m.test(b); + if (reproducible) return []; + const asks = ['steps']; + if (!VERSION.test(`${t}\n${b}`)) asks.push('version'); + return asks; +} + +function askBody(asks) { + const lines = ['Thanks for the report! To reproduce it we need a little more:']; + lines.push( + '- **Steps to reproduce**: numbered steps from a sample (`fieldcraft --sample cantilever`) or a script ' + + '(`fieldcraft-cli run script.json`), and the `.fieldcraft` or `.solidcraft` file that shows it if it is your own model. ' + + '**Help › Report a Bug** in the app fills this in for you.', + ); + if (asks.includes('version')) lines.push('- **FieldCraft version** (or commit) and your OS.'); + lines.push( + '', + `Reply here or edit the issue and it goes back to the triage queue. If we don't hear back within ${INFO_DAYS} days it is closed; reopen it any time.`, + ); + return lines.join('\n'); +} + +// What to ask the reporter of `issue`, or [] (maintainers' and bots' issues are never asked). +async function asksFor(github, context, issue, cache) { + if (issue.state !== 'open' || issue.pull_request || issue.locked) return []; + const names = labelNames(issue); + if (names.includes(LABEL) || names.includes('enhancement')) return []; // the feature form labels its issues + const asks = missing(issue.title, issue.body); + if (!asks.length || !issue.user || issue.user.type === 'Bot') return []; + const login = issue.user.login; + if (!cache.has(login)) cache.set(login, await isMaintainer(github, context, login)); + return cache.get(login) ? [] : asks; +} + +async function ask(github, context, issue, asks) { + await ensureLabel(github, context, LABEL, LABEL_COLOR, LABEL_DESCRIPTION); + await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: [LABEL] }); + await github.rest.issues.createComment({ ...repoArgs(context), issue_number: issue.number, body: askBody(asks) }); +} + +// issues.opened. +async function handleOpened({ github, context }) { + const issue = context.payload.issue; + if (!issue) return false; + const asks = await asksFor(github, context, issue, new Map()); + if (!asks.length) return false; + await ask(github, context, issue, asks); + return true; +} + +// Workflow dispatch: the same ask over the open issues filed before the bot. With `dryRun` it +// changes nothing and returns what it would ask. +async function backfill({ github, context, dryRun = true }) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + ...repoArgs(context), + state: 'open', + per_page: 100, + }); + const cache = new Map(); + const plan = []; + for (const issue of issues) { + const asks = await asksFor(github, context, issue, cache); + if (!asks.length) continue; + plan.push({ number: issue.number, title: issue.title, asks }); + if (!dryRun) await ask(github, context, issue, asks); + } + return plan; +} + +async function clear(github, context, number) { + try { + await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: number, name: LABEL }); + } catch (e) { + if (e.status !== 404) throw e; + } +} + +// issue_comment.created / issues.edited: the reporter answered. +async function handleReply({ github, context }) { + const { issue, comment } = context.payload; + if (!issue || !labelNames(issue).includes(LABEL)) return false; + const author = issue.user && issue.user.login; + const actor = comment ? comment.user.login : context.payload.sender && context.payload.sender.login; + if (!author || actor !== author) return false; + if (!comment && missing(issue.title, issue.body).length) return false; // an edit that still lacks steps + await clear(github, context, issue.number); + return true; +} + +// When the label was last added, from the timeline; the issue's update time if it can't be found. +async function labeledAt(github, context, issue) { + const events = await github.paginate(github.rest.issues.listEventsForTimeline, { + ...repoArgs(context), + issue_number: issue.number, + per_page: 100, + }); + let at = null; + for (const ev of events) if (ev.event === 'labeled' && ev.label && ev.label.name === LABEL) at = ev.created_at; + return new Date(at || issue.updated_at).getTime(); +} + +// The daily sweep: close what has waited INFO_DAYS for its reporter. +async function sweep({ github, context, now = Date.now() }) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + ...repoArgs(context), + state: 'open', + labels: LABEL, + per_page: 100, + }); + const closed = []; + for (const issue of issues) { + if (issue.pull_request || issue.locked) continue; + if (now - (await labeledAt(github, context, issue)) < INFO_DAYS * DAY) continue; + await github.rest.issues.createComment({ + ...repoArgs(context), + issue_number: issue.number, + body: `Closing for now: we couldn't reproduce this without more detail. Reopen it with steps to reproduce and we'll pick it up.`, + }); + await github.rest.issues.update({ + ...repoArgs(context), + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + closed.push(issue.number); + } + return closed; +} + +module.exports = { LABEL, missing, handleOpened, handleReply, sweep, backfill }; diff --git a/scripts/triage/needs-info.test.js b/scripts/triage/needs-info.test.js new file mode 100644 index 0000000..3eea0dc --- /dev/null +++ b/scripts/triage/needs-info.test.js @@ -0,0 +1,145 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const info = require('./needs-info.js'); + +const repo = { owner: 'tangency-labs', repo: 'fieldcraft' }; +const NOW = Date.parse('2026-10-18T12:00:00Z'); +const DAY = 24 * 3600 * 1000; +const ago = (ms) => new Date(NOW - ms).toISOString(); + +function fake(world = {}) { + const calls = []; + const rec = (name) => async (args) => { + calls.push([name, args]); + return { data: {} }; + }; + const github = { + paginate: async (fn, args) => (await fn(args)).data, + rest: { + repos: { + getCollaboratorPermissionLevel: async ({ username }) => ({ + data: { permission: (world.perms || {})[username] || 'none' }, + }), + }, + issues: { + getLabel: async () => ({ data: {} }), + createLabel: rec('createLabel'), + addLabels: rec('addLabels'), + removeLabel: rec('removeLabel'), + createComment: rec('comment'), + update: rec('update'), + listForRepo: async () => ({ data: world.issues || [] }), + listEventsForTimeline: async () => ({ data: world.timeline || [] }), + }, + }, + }; + return { github, calls }; +} +const find = (calls, n) => calls.find((c) => c[0] === n); +const labels = (names) => names.map((name) => ({ name })); +const long = 'x'.repeat(400); + +const form = (observed, reproduction, version = '_No response_') => + `### Observed failure\n\n${observed}\n\n### Reproduction\n\n${reproduction}\n\n### Evidence\n\n_No response_\n\n### Expected behaviour\n\nworks\n\n### Version and platform\n\n${version}`; + +test('missing: short reports without steps are asked, with the version when it is absent', () => { + assert.deepEqual(info.missing('Solve does not work', 'The solver is not working.'), ['steps', 'version']); + assert.deepEqual(info.missing('Solve does not work', 'Broken on v0.6.0.'), ['steps']); + assert.deepEqual(info.missing('Hex mesh broken', form('it fails', 'mesh it')), ['steps', 'version']); + assert.deepEqual(info.missing('Hex mesh broken', form('it fails', 'mesh it', 'v0.6.0, macOS')), ['steps']); +}); + +test('missing: reproducible, long, app-filed and non-bug issues are left alone', () => { + assert.deepEqual(info.missing('Mesh fails', form('x', '1. fieldcraft --sample cantilever\n2. Mesh ▸ Tet')), []); + assert.deepEqual(info.missing('Mesh fails', 'see bracket.fieldcraft attached'), []); + assert.deepEqual(info.missing('Mesh fails', 'runs `mesh.tet.generate` and fails'), []); + assert.deepEqual(info.missing('Mesh fails', long), []); + assert.deepEqual(info.missing('Internal error in solve.static', form('x', 'Replay the attached session (3 command(s)): `fieldcraft-cli run session.json`.')), []); + assert.deepEqual(info.missing('FieldCraft closed unexpectedly', form('x', '', 'v0.6.0') + '\nCrash report `abc` (panic), seen 1 time(s)'), []); + assert.deepEqual(info.missing('Feature request: dark mode', 'please'), []); + assert.deepEqual(info.missing('[FEAT] composites', 'please'), []); + assert.deepEqual(info.missing('How do I apply a pressure?', ''), []); +}); + +const opened = (title, body, login = 'reporter') => ({ + repo, + payload: { issue: { number: 4, state: 'open', title, body, labels: [], user: { login, type: 'User' } } }, +}); + +test('an opened thin report is labelled and asked once', async () => { + const { github, calls } = fake(); + assert.equal(await info.handleOpened({ github, context: opened('Solve broken', 'does not work') }), true); + assert.deepEqual(find(calls, 'addLabels')[1].labels, ['needs-info']); + const body = find(calls, 'comment')[1].body; + assert.match(body, /Steps to reproduce/); + assert.match(body, /FieldCraft version/); + assert.match(body, /14 days/); +}); + +test('a maintainer\'s note is never asked', async () => { + const w = fake({ perms: { jyviko: 'admin' } }); + assert.equal(await info.handleOpened({ github: w.github, context: opened('Solve broken', 'x', 'jyviko') }), false); + assert.equal(w.calls.length, 0); +}); + +const reply = (login, { comment = true, body = 'x', issueLabels = ['needs-info'] } = {}) => ({ + repo, + payload: { + issue: { number: 4, title: 'Solve broken', body, labels: labels(issueLabels), user: { login: 'reporter' } }, + ...(comment ? { comment: { user: { login } } } : { sender: { login } }), + }, +}); + +test('the reporter\'s comment clears needs-info; someone else\'s does not', async () => { + let w = fake(); + assert.equal(await info.handleReply({ github: w.github, context: reply('reporter') }), true); + assert.equal(find(w.calls, 'removeLabel')[1].name, 'needs-info'); + w = fake(); + assert.equal(await info.handleReply({ github: w.github, context: reply('bystander') }), false); + w = fake(); + assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { issueLabels: [] }) }), false); + assert.equal(w.calls.length, 0); +}); + +test('an edit clears needs-info only once the steps are there', async () => { + let w = fake(); + assert.equal(await info.handleReply({ github: w.github, context: reply('reporter', { comment: false, body: 'still broken' }) }), false); + w = fake(); + const fixed = reply('reporter', { comment: false, body: '## Reproduction\n1. open the cantilever sample\n2. solve it' }); + assert.equal(await info.handleReply({ github: w.github, context: fixed }), true); +}); + +test('the sweep closes what waited 14 days, and keeps younger ones', async () => { + const issue = { number: 4, locked: false, updated_at: ago(30 * DAY), labels: labels(['needs-info']) }; + const labeled = (at) => ({ event: 'labeled', label: { name: 'needs-info' }, created_at: at }); + let w = fake({ issues: [issue], timeline: [labeled(ago(15 * DAY))] }); + assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), [4]); + assert.deepEqual(find(w.calls, 'update')[1], { ...repo, issue_number: 4, state: 'closed', state_reason: 'not_planned' }); + w = fake({ issues: [issue], timeline: [labeled(ago(20 * DAY)), labeled(ago(3 * DAY))] }); + assert.deepEqual(await info.sweep({ github: w.github, context: { repo }, now: NOW }), []); + assert.equal(w.calls.length, 0); +}); + +test('backfill asks open thin reports once, and the dry run writes nothing', async () => { + const issues = [ + { number: 1, state: 'open', title: 'Solve broken', body: 'nope', labels: [], user: { login: 'a', type: 'User' } }, + { number: 2, state: 'open', title: 'Solve broken', body: 'nope', labels: labels(['needs-info']), user: { login: 'a' } }, + { number: 3, state: 'open', title: 'x', body: 'nope', labels: [], pull_request: {}, user: { login: 'a' } }, + ]; + let w = fake({ issues }); + const plan = await info.backfill({ github: w.github, context: { repo }, dryRun: true }); + assert.deepEqual(plan.map((p) => p.number), [1]); + assert.equal(w.calls.length, 0); + w = fake({ issues }); + await info.backfill({ github: w.github, context: { repo }, dryRun: false }); + assert.equal(find(w.calls, 'addLabels')[1].issue_number, 1); +}); + +test('an issue from the feature form is never asked for steps', async () => { + const { github, calls } = fake(); + const ctx = opened('Dark mode', '### What you need and why\nwork at night'); + ctx.payload.issue.labels = labels(['enhancement']); + assert.equal(await info.handleOpened({ github, context: ctx }), false); + assert.equal(calls.length, 0); +}); diff --git a/scripts/triage/triage.js b/scripts/triage/triage.js new file mode 100644 index 0000000..66c4440 --- /dev/null +++ b/scripts/triage/triage.js @@ -0,0 +1,315 @@ +// The triage bot's logic, used by .github/workflows/triage.yml through actions/github-script. +// New issues and PRs get `area:*` and type labels from fixed rules; an issue nobody has given a +// priority yet carries `needs-triage`. Maintainers set labels with `/triage`. The rules only ever +// add labels, so a person's correction is never undone. +// Everything takes the github-script `github` client and `context`, so the tests can pass fakes. +'use strict'; + +const NEEDS_TRIAGE = 'needs-triage'; +const NEEDS_TRIAGE_COLOR = 'ededed'; +const NEEDS_TRIAGE_DESCRIPTION = 'no priority yet; a maintainer sorts it with /triage'; +const MAX_TOKENS = 10; +const MAX_TEXT = 20000; + +// Changed paths of a PR → area. First match wins per file. +const PATH_AREAS = [ + [/^crates\/mesh\//, 'area:mesh'], + [/^crates\/lbm[^/]*\//, 'area:cfd'], + [/^crates\/(solver|fem|explicit)\//, 'area:solver'], + [/^(crates\/ui-egui|apps\/[^/]+)\//, 'area:ui'], + [/^crates\/(io|decks)\//, 'area:io'], + [/^(docs\/|[^/]+\.md$)/, 'area:docs'], + [/^(\.github\/|xtask\/|packaging\/|scripts\/|tools\/)/, 'area:infra'], +]; + +// Command id prefix (the in-app reports quote it: "Internal error in mesh.tet.generate") → area. +const COMMAND_AREAS = { + mesh: 'area:mesh', + geometry: 'area:mesh', + model: 'area:solver', + interaction: 'area:solver', + solve: 'area:solver', + file: 'area:io', + deck: 'area:io', + results: 'area:ui', + view: 'area:ui', +}; + +// Words in an issue's title → area. Conservative: a miss leaves the issue for a person. +const WORD_AREAS = [ + [/\b(mesh(es|ing|er)?|tet(rahedral|s)?|hex(ahedral|es)?|octree|jacobian|sliver|element quality|remesh)\b/i, 'area:mesh'], + [/\b(lbm|lattice boltzmann|cfd|fluid|flow|turbulen(ce|t)|velocity field)\b/i, 'area:cfd'], + [/\b(solver?|solves?|solving|explicit|implicit|static|frequency|modal|buckling|contact|plasticity|hyperelastic|convergence|newton|material|thermal)\b/i, 'area:solver'], + [/\b(abaqus|nastran|ls-?dyna|deck|vtk|vtu|paraview|gmsh|step|stl|3mf|csv|import|export)\b/i, 'area:io'], + [/\b(toolbar|dialog|viewport|panel|button|shortcut|menu|window|legend|plot|theme)\b/i, 'area:ui'], + [/\b(docs?|documentation|readme|tutorial|typo)\b/i, 'area:docs'], + [/\b(ci|release|packaging|flatpak|installer|homebrew|winget)\b/i, 'area:infra'], +]; + +const CRASH = /^\s*crash\b|\bpanic(ked)?\b|^\s*internal error\b|\bclosed unexpectedly\b/i; +const FEATURE = /^\s*\[?feature( request)?\]?\b/i; +// The issue forms' own headings (.github/ISSUE_TEMPLATE): their labels are set by GitHub when the +// form is used, so these only matter for the backfill and for issues edited into shape. +const BUG_FORM = /^###\s*observed failure\s*$/im; +const FEATURE_FORM = /^###\s*what you need and why\s*$/im; + +function repoArgs(context) { + return { owner: context.repo.owner, repo: context.repo.repo }; +} + +// The text under a `## ` heading (`###` in issue forms), up to the next heading. +function section(body, name) { + const lines = body.split('\n'); + const start = lines.findIndex((l) => new RegExp(`^#{2,3}\\s*${name}\\s*$`, 'i').test(l.trim())); + if (start < 0) return ''; + const rest = lines.slice(start + 1); + const end = rest.findIndex((l) => /^#{2,3}\s/.test(l)); + return (end < 0 ? rest : rest.slice(0, end)).join('\n'); +} + +function labelNames(item) { + return (item.labels || []).map((l) => (typeof l === 'string' ? l : l.name)); +} + +function isPriority(name) { + return /^priority\s*:/.test(name); +} + +function hasPriority(names) { + return names.some(isPriority); +} + +// Labels for an issue from its title and body. +function classifyIssue(title, body) { + const t = String(title || '').slice(0, 500); + const b = String(body || '').slice(0, MAX_TEXT); + const labels = new Set(); + if (CRASH.test(t) || BUG_FORM.test(b)) { + labels.add('bug'); + } else if (FEATURE.test(t) || FEATURE_FORM.test(b)) { + labels.add('enhancement'); + } + // The title names the feature; a command id in the title (the in-app report's) is the fallback. + // The body is not read: a report's session quotes every earlier command too. + let area = null; + for (const [re, a] of WORD_AREAS) { + if (re.test(t)) { + area = a; + break; + } + } + if (!area) { + const m = /\b([a-z_]+)\.[a-z_]+(\.[a-z_]+)*\b/.exec(t); + area = (m && COMMAND_AREAS[m[1]]) || null; + } + if (area) labels.add(area); + return [...labels]; +} + +// Labels for a PR from its changed paths. +function classifyPaths(paths) { + const labels = new Set(); + for (const p of paths) { + for (const [re, area] of PATH_AREAS) { + if (re.test(p)) { + labels.add(area); + break; + } + } + } + return [...labels].sort(); +} + +async function ensureLabel(github, context, name, color, description) { + try { + await github.rest.issues.getLabel({ ...repoArgs(context), name }); + } catch (e) { + if (e.status !== 404) throw e; + try { + await github.rest.issues.createLabel({ ...repoArgs(context), name, color, description }); + } catch (e2) { + if (e2.status !== 422) throw e2; // created meanwhile + } + } +} + +async function pullPaths(github, context, number) { + const files = await github.paginate(github.rest.pulls.listFiles, { + ...repoArgs(context), + pull_number: number, + per_page: 100, + }); + return files.map((f) => f.filename); +} + +// The labels the rules would add to an issue or PR that it doesn't already carry. +async function proposal(github, context, item) { + const current = labelNames(item); + let wanted; + if (item.pull_request) { + wanted = classifyPaths(await pullPaths(github, context, item.number)); + } else { + wanted = classifyIssue(item.title, item.body); + if (!hasPriority(current) && !hasPriority(wanted)) wanted.push(NEEDS_TRIAGE); + } + return wanted.filter((l) => !current.includes(l)); +} + +async function apply(github, context, number, labels) { + if (!labels.length) return; + if (labels.includes(NEEDS_TRIAGE)) { + await ensureLabel(github, context, NEEDS_TRIAGE, NEEDS_TRIAGE_COLOR, NEEDS_TRIAGE_DESCRIPTION); + } + await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: number, labels }); +} + +// issues.opened / pull_request_target.opened. +async function handleOpened({ github, context }) { + const issue = context.payload.issue; + const pull = context.payload.pull_request; + const item = issue || (pull && { ...pull, pull_request: {} }); + if (!item || item.state !== 'open') return []; + const labels = await proposal(github, context, item); + await apply(github, context, item.number, labels); + return labels; +} + +// issues.labeled: a priority settles triage. +async function handleLabeled({ github, context }) { + const issue = context.payload.issue; + const label = context.payload.label; + if (!issue || !label || !isPriority(label.name)) return false; + if (!labelNames(issue).includes(NEEDS_TRIAGE)) return false; + try { + await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name: NEEDS_TRIAGE }); + } catch (e) { + if (e.status !== 404) throw e; + } + return true; +} + +// `/triage area:mesh high S bug -enhancement`: tokens name existing labels, matched without +// regard to case, `-`/`_` for spaces or the space after a colon (`in-progress`, `size:S`); `high` +// is short for `priority: high` and `S`/`M`/`L` for `size: S`…, each replacing any other label of +// its kind; a leading `-` removes. +const ALIASES = { high: 'priority: high', s: 'size: S', m: 'size: M', l: 'size: L' }; + +function parseTriage(body) { + const line = String(body || '').trimStart().split('\n')[0]; + const m = /^\/triage(\s.*)?$/.exec(line.trimEnd()); + if (!m) return null; + const add = []; + const remove = []; + for (const raw of (m[1] || '').trim().split(/\s+/).filter(Boolean).slice(0, MAX_TOKENS)) { + const minus = raw.startsWith('-'); + let name = minus ? raw.slice(1) : raw; + name = ALIASES[name.toLowerCase()] || name; + if (name) (minus ? remove : add).push(name); + } + return { add, remove }; +} + +function normalize(name) { + return String(name).toLowerCase().replace(/[-_\s]+/g, ' ').replace(/\s*:\s*/g, ':').trim(); +} + +// The label kind that replaces its siblings: `priority` or `size`. +function kind(name) { + const m = /^(priority|size)\s*:/.exec(name); + return m ? m[1] : null; +} + +async function isMaintainer(github, context, login) { + if (login === context.repo.owner) return true; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...repoArgs(context), username: login }); + return ['admin', 'write', 'maintain'].includes(data.permission); + } catch (e) { + return false; + } +} + +async function react(github, context, content) { + await github.rest.reactions.createForIssueComment({ + ...repoArgs(context), + comment_id: context.payload.comment.id, + content, + }); +} + +// issue_comment.created. +async function handleComment({ github, context }) { + const { issue, comment } = context.payload; + if (!issue || !comment || comment.user.type === 'Bot' || comment.user.login.endsWith('[bot]')) return 'skip'; + const cmd = parseTriage(comment.body); + if (!cmd) return 'skip'; + if (!(await isMaintainer(github, context, comment.user.login))) { + await react(github, context, 'eyes'); + return 'denied'; + } + const known = new Map( + (await github.paginate(github.rest.issues.listLabelsForRepo, { ...repoArgs(context), per_page: 100 })).map((l) => [ + normalize(l.name), + l.name, + ]), + ); + const resolve = (n) => known.get(normalize(n)); + const unknown = [...cmd.add, ...cmd.remove].filter((n) => !resolve(n)); + if (unknown.length) { + await react(github, context, 'confused'); + await github.rest.issues.createComment({ + ...repoArgs(context), + issue_number: issue.number, + body: `No such label: ${unknown.map((n) => '`' + n + '`').join(', ')}. Nothing was changed.`, + }); + return 'unknown'; + } + const addNames = cmd.add.map(resolve); + const current = labelNames(issue); + const remove = new Set(cmd.remove.map(resolve)); + for (const k of new Set(addNames.map(kind).filter(Boolean))) { + for (const n of current) if (kind(n) === k && !addNames.includes(n)) remove.add(n); + } + if (addNames.length || remove.size) remove.add(NEEDS_TRIAGE); + for (const name of remove) { + if (!current.includes(name)) continue; + await github.rest.issues.removeLabel({ ...repoArgs(context), issue_number: issue.number, name }); + } + const add = addNames.filter((n) => !current.includes(n)); + if (add.length) await github.rest.issues.addLabels({ ...repoArgs(context), issue_number: issue.number, labels: add }); + await react(github, context, '+1'); + return 'triage'; +} + +// Workflow dispatch: the rules over every open issue and PR with no labels at all. With `dryRun` +// it changes nothing and returns what it would do. +async function backfill({ github, context, dryRun = true }) { + const items = await github.paginate(github.rest.issues.listForRepo, { + ...repoArgs(context), + state: 'open', + per_page: 100, + }); + const plan = []; + for (const item of items) { + if (labelNames(item).length || item.locked) continue; + const labels = await proposal(github, context, item); + plan.push({ number: item.number, pull: Boolean(item.pull_request), title: item.title, labels }); + if (!dryRun) await apply(github, context, item.number, labels); + } + return plan; +} + +module.exports = { + NEEDS_TRIAGE, + classifyIssue, + classifyPaths, + ensureLabel, + isMaintainer, + labelNames, + section, + parseTriage, + handleOpened, + handleLabeled, + handleComment, + backfill, +}; diff --git a/scripts/triage/triage.test.js b/scripts/triage/triage.test.js new file mode 100644 index 0000000..03c46e7 --- /dev/null +++ b/scripts/triage/triage.test.js @@ -0,0 +1,202 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const triage = require('./triage.js'); + +const repo = { owner: 'tangency-labs', repo: 'fieldcraft' }; + +// A fake github-script client that records writes. `world` describes the repo. +function fake(world = {}) { + const calls = []; + const rec = (name) => async (args) => { + calls.push([name, args]); + return { data: {} }; + }; + const github = { + paginate: async (fn, args) => (await fn(args)).data, + rest: { + repos: { + getCollaboratorPermissionLevel: async ({ username }) => ({ + data: { permission: (world.perms || {})[username] || 'none' }, + }), + }, + pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) }, + reactions: { createForIssueComment: rec('react') }, + issues: { + getLabel: async () => { + if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 }); + return { data: {} }; + }, + createLabel: rec('createLabel'), + addLabels: rec('addLabels'), + removeLabel: rec('removeLabel'), + createComment: rec('comment'), + listForRepo: async () => ({ data: world.items || [] }), + listLabelsForRepo: async () => ({ + data: (world.labels || ['bug', 'enhancement', 'area:mesh', 'priority: high', 'size: S', 'size: M', 'in progress', 'needs-triage']).map( + (name) => ({ name }), + ), + }), + }, + }, + }; + return { github, calls }; +} + +const find = (calls, n) => calls.find((c) => c[0] === n); +const all = (calls, n) => calls.filter((c) => c[0] === n); +const labels = (names) => names.map((name) => ({ name })); + +test('classifyIssue: in-app reports get bug and the area from the title or its command', () => { + assert.deepEqual(triage.classifyIssue('Internal error in mesh.tet.generate', ''), ['bug', 'area:mesh']); + assert.deepEqual(triage.classifyIssue('FieldCraft closed unexpectedly in solve.static', ''), ['bug', 'area:solver']); + assert.deepEqual(triage.classifyIssue('Internal error in results.show', ''), ['bug', 'area:ui']); + assert.deepEqual(triage.classifyIssue('Crash on startup', ''), ['bug']); +}); + +test('classifyIssue: the forms give the type, the title the area', () => { + const bug = '### Observed failure\nwrong\n\n### Reproduction\n1. x\n'; + assert.deepEqual(triage.classifyIssue('Lattice Boltzmann diverges at Re 200', bug), ['bug', 'area:cfd']); + const feat = '### Current behaviour\nnone\n\n### What you need and why\nVTU\n'; + assert.deepEqual(triage.classifyIssue('ParaView export', feat), ['enhancement', 'area:io']); + assert.deepEqual(triage.classifyIssue('Feature request: dark theme', ''), ['enhancement', 'area:ui']); + assert.deepEqual(triage.classifyIssue('Hex meshing of thin walls', ''), ['area:mesh']); + assert.deepEqual(triage.classifyIssue('Typo in the docs', ''), ['area:docs']); +}); + +test('classifyIssue: the body\'s session never gives the area, unmatched text gets nothing', () => { + assert.deepEqual(triage.classifyIssue('Wrong numbers', '"mesh.tet.generate"\n"solve.static"'), []); + assert.deepEqual(triage.classifyIssue('Refactor with types', 'words'), []); + assert.deepEqual(triage.classifyIssue(undefined, undefined), []); +}); + +test('classifyPaths maps changed files to areas', () => { + assert.deepEqual( + triage.classifyPaths([ + 'crates/mesh/src/a.rs', + 'crates/lbm-gpu/b.rs', + 'crates/fem/c.rs', + 'apps/fieldcraft-cli/d.rs', + 'crates/decks/e.rs', + 'docs/oracle.md', + 'README.md', + '.github/workflows/ci.yml', + 'Cargo.lock', + ]), + ['area:cfd', 'area:docs', 'area:infra', 'area:io', 'area:mesh', 'area:solver', 'area:ui'], + ); + assert.deepEqual(triage.classifyPaths(['crates/engine/src/cmd/x.rs']), []); +}); + +test('parseTriage', () => { + assert.deepEqual(triage.parseTriage('/triage area:mesh high S -enhancement'), { + add: ['area:mesh', 'priority: high', 'size: S'], + remove: ['enhancement'], + }); + assert.deepEqual(triage.parseTriage('/triage'), { add: [], remove: [] }); + assert.equal(triage.parseTriage('/triaged'), null); + assert.equal(triage.parseTriage('please /triage'), null); + assert.equal(triage.parseTriage('/triage ' + 'bug '.repeat(50)).add.length, 10); +}); + +test('an opened issue gets its labels plus needs-triage, which is created if missing', async () => { + const { github, calls } = fake({ labelMissing: true }); + const issue = { number: 5, state: 'open', title: 'Internal error in mesh.hex.octree', body: '', labels: [] }; + assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), [ + 'bug', + 'area:mesh', + 'needs-triage', + ]); + assert.ok(find(calls, 'createLabel')); + assert.deepEqual(find(calls, 'addLabels')[1].labels, ['bug', 'area:mesh', 'needs-triage']); +}); + +test('an opened issue keeps the labels it was filed with and skips needs-triage with a priority', async () => { + const { github, calls } = fake(); + const issue = { number: 5, state: 'open', title: 'Solver diverges', body: '', labels: labels(['bug', 'priority: high']) }; + assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { issue } } }), ['area:solver']); + assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:solver']); +}); + +test('an opened PR is labelled from its paths and never gets needs-triage', async () => { + const { github, calls } = fake({ files: ['crates/mesh/src/tet.rs'] }); + const pull_request = { number: 9, state: 'open', title: 'Mesh: thing', body: '', labels: [] }; + assert.deepEqual(await triage.handleOpened({ github, context: { repo, payload: { pull_request } } }), ['area:mesh']); + assert.deepEqual(find(calls, 'addLabels')[1], { ...repo, issue_number: 9, labels: ['area:mesh'] }); +}); + +test('a priority label clears needs-triage', async () => { + const { github, calls } = fake(); + const issue = { number: 5, labels: labels(['needs-triage', 'priority: high']) }; + assert.equal(await triage.handleLabeled({ github, context: { repo, payload: { issue, label: { name: 'priority: high' } } } }), true); + assert.equal(find(calls, 'removeLabel')[1].name, 'needs-triage'); + const other = fake(); + assert.equal( + await triage.handleLabeled({ github: other.github, context: { repo, payload: { issue, label: { name: 'bug' } } } }), + false, + ); + assert.equal(other.calls.length, 0); +}); + +const comment = (body, login = 'carol', issueLabels = []) => ({ + repo, + payload: { + issue: { number: 5, labels: labels(issueLabels) }, + comment: { id: 1, body, user: { login, type: 'User' } }, + }, +}); + +test('/triage by a maintainer sets labels, replaces the size and clears needs-triage', async () => { + const { github, calls } = fake({ perms: { jyviko: 'admin' } }); + const ctx = comment('/triage area:mesh HIGH S', 'jyviko', ['size: M', 'needs-triage', 'bug']); + assert.equal(await triage.handleComment({ github, context: ctx }), 'triage'); + assert.deepEqual(all(calls, 'removeLabel').map((c) => c[1].name).sort(), ['needs-triage', 'size: M']); + assert.deepEqual(find(calls, 'addLabels')[1].labels, ['area:mesh', 'priority: high', 'size: S']); + assert.equal(find(calls, 'react')[1].content, '+1'); +}); + +test('/triage matches labels with spaces written with dashes', async () => { + const { github, calls } = fake({ perms: { jyviko: 'admin' } }); + assert.equal(await triage.handleComment({ github, context: comment('/triage -in-progress', 'jyviko', ['in progress']) }), 'triage'); + assert.equal(find(calls, 'removeLabel')[1].name, 'in progress'); +}); + +test('/triage from a non-maintainer or with an unknown label changes nothing', async () => { + let w = fake(); + assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage bug', 'mallory') }), 'denied'); + assert.equal(find(w.calls, 'addLabels'), undefined); + w = fake({ perms: { carol: 'admin' } }); + assert.equal(await triage.handleComment({ github: w.github, context: comment('/triage area:nope') }), 'unknown'); + assert.equal(find(w.calls, 'addLabels'), undefined); + assert.match(find(w.calls, 'comment')[1].body, /area:nope/); +}); + +test('bots and other comments are ignored', async () => { + const { github, calls } = fake(); + const bot = comment('/triage bug'); + bot.payload.comment.user = { login: 'x[bot]', type: 'Bot' }; + assert.equal(await triage.handleComment({ github, context: bot }), 'skip'); + assert.equal(await triage.handleComment({ github, context: comment('hello') }), 'skip'); + assert.equal(calls.length, 0); +}); + +test('backfill only touches unlabelled items, and the dry run writes nothing', async () => { + const items = [ + { number: 1, title: 'Internal error in mesh.tet.generate', body: '', labels: [] }, + { number: 2, title: 'Already sorted', body: '', labels: labels(['bug']) }, + { number: 3, title: 'x', body: '', labels: [], pull_request: {} }, + ]; + let w = fake({ items, files: ['crates/io/src/step.rs'] }); + const plan = await triage.backfill({ github: w.github, context: { repo }, dryRun: true }); + assert.deepEqual( + plan.map((p) => [p.number, p.labels]), + [ + [1, ['bug', 'area:mesh', 'needs-triage']], + [3, ['area:io']], + ], + ); + assert.equal(w.calls.length, 0); + w = fake({ items, files: ['crates/io/src/step.rs'] }); + await triage.backfill({ github: w.github, context: { repo }, dryRun: false }); + assert.deepEqual(all(w.calls, 'addLabels').map((c) => c[1].issue_number), [1, 3]); +});