forked from 1b-agent/fieldcraft-upstream
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
9fce2f7bab
commit
619b1936d3
5 files changed
+926
No files matched your search
@@ -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(/<!--[\s\S]*?-->/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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 `## <name>` 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,
|
||||
};
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in new issue
Block a user