diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..74b17312 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,41 @@ +name: Bug report +description: Something in SolidCraft does the wrong thing +labels: [bug] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report this! Inside the app, **Report an Issue** fills most of this in for you, with the command, its settings and your environment. + - type: textarea + id: what + attributes: + label: What happened + description: What did you expect, and what happened instead? + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: | + What you clicked or typed, in order. For a problem on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can. + placeholder: | + 1. Sketch a circle on the XY plane + 2. Extrude it 10 mm + 3. Fillet the top edge with 6 mm + validations: + required: true + - type: input + id: version + attributes: + label: SolidCraft version and OS + description: Help → About shows the version. + placeholder: 0.15.0, Windows 11 + validations: + required: true + - type: textarea + id: media + attributes: + label: Screenshots or recording + description: | + For anything you can see in the app, a screenshot or short recording of a small example helps the most. Please leave out private designs and personal details. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index f61dae08..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Bug report -about: Help reproduce and fix a SolidCraft problem -title: '' -labels: '' -assignees: '' ---- - -## Behavior - -What did you expect, and what happened instead? - -## Reproduction - -Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data. - -When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can. - -## Screenshots of the behavior (when observable) - -For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful. - -Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8ec0c2e9..6cdea1b2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,5 @@ +# Blank issues stay on: the app's Report an Issue opens issues/new?title=…&body=… with the report +# filled in (crates/ui-egui/src/issue_report.rs), which GitHub drops when blank issues are off. blank_issues_enabled: true contact_links: - name: Questions and chat diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 00000000..631d9cb9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,16 @@ +name: Feature request +description: Something SolidCraft should do +labels: [enhancement] +body: + - type: textarea + id: goal + attributes: + label: What are you trying to do + description: The part or workflow you have in mind, and where SolidCraft gets in the way today. + validations: + required: true + - type: textarea + id: idea + attributes: + label: How you imagine it working + description: Optional. Describe the behaviour in your own words; please don't paste another product's documentation. diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 111b3ca1..00b8c9e8 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -1,17 +1,19 @@ -# Labels new issues and PRs from fixed rules, `/triage` for maintainers, and a manual backfill -# over everything still unlabelled (CONTRIBUTING.md, "Labels and triage"). The logic is in -# scripts/triage/triage.js (tested in ci.yml). +# 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] + 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: @@ -25,7 +27,7 @@ permissions: pull-requests: write concurrency: - group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'backfill' }} + group: triage-${{ github.event.issue.number || github.event.pull_request.number || 'repo' }} cancel-in-progress: false jobs: @@ -37,9 +39,12 @@ jobs: 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_name != 'issue_comment' && github.event.action != 'labeled' && github.event.action != 'edited') steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -50,20 +55,32 @@ jobs: 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 })); - } else if (event === 'issues' && context.payload.action === 'labeled') { + 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 esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); - const rows = plan.map((p) => [`#${p.number}`, p.pull ? 'PR' : 'issue', esc(p.labels.join(', ') || '—'), esc(p.title)]); + const asks = await info.backfill({ github, context, dryRun }); await core.summary .addHeading(dryRun ? `Backfill dry run: ${plan.length} unlabelled` : `Backfill: ${plan.length} labelled`) - .addTable([[{ data: 'Item', header: true }, { data: 'Kind', header: true }, { data: 'Labels', header: true }, { data: 'Title', header: true }], ...rows]) + .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/CONTRIBUTING.md b/CONTRIBUTING.md index 0f6037b4..1c0f5b75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -317,6 +317,12 @@ correct them freely and it won't put them back. a priority, an issue carries `needs-triage`; setting a priority removes it. - **Maintainers** comment `/triage area:kernel P1` (any existing label; `P0`–`P3` for a priority, which replaces the old one; `-label` removes one) to sort an issue or PR. +- **Short bug reports without steps to reproduce** get `needs-info` and one comment asking for + the steps (and the version, when it's missing). An app report that arrived empty is asked to + paste the report the app copied to the clipboard. The reporter's reply, or an edit that adds the + steps, removes the label; an issue that stays in `needs-info` for 14 days is closed, and can be + reopened any time. Feature requests, app reports with their details, and maintainers' issues + are never asked. - `needs-triage` is the queue: [open issues with it](https://github.com/tangency-labs/solidcraft/issues?q=is%3Aopen+label%3Aneeds-triage). ## Where help is wanted most diff --git a/scripts/triage/needs-info.js b/scripts/triage/needs-info.js new file mode 100644 index 00000000..0676447e --- /dev/null +++ b/scripts/triage/needs-info.js @@ -0,0 +1,180 @@ +// `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 carry their command, settings and +// environment, 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 }; +} + +// Sections only the app's own reports have. +function fromApp(body) { + return ['Settings and selection', 'Backtrace', 'Recent commands'].some((s) => section(body, s).trim()); +} + +// Requests and plans, which aren't reproduced. Crash reports carry the crash folder's contents. +const NOT_A_BUG = /^\s*\[(feat|feature|change|enhancement)|feature request|suggestion|\?\s*$|^\s*(tracker|refactor|support|add|allow|crash|how|why|request|missing)\b/i; +// The app's report title. A long report opens the browser without its body and is copied to +// the clipboard instead (crates/ui-egui/src/issue_report.rs, draft_url), so an empty one wasn't pasted. +const APP_TITLE = /^[A-Z][A-Z ]+:\s*(operation failed|unexpected result)\b|^Modeling: unexpected result/; +// A report this long describes what it saw; only short ones are asked. +const SHORT = 300; +const ATTACHMENT = /user-attachments\/|\.solidcraft\b|repro\.json|\.(step|stp|3mf|stl)\b/i; +const COMMAND = /["`][a-z_]+\.[a-z_][a-z_.]*["`]/; +const VERSION = /\b\d+\.\d+\.\d+\b|\b[0-9a-f]{7,40}\b/i; + +// 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) || fromApp(b) || b.replace(/\s+/g, ' ').trim().length >= SHORT) return []; + const steps = `${section(b, 'Reproduction')}\n${section(b, 'Steps to reproduce')}`; + const reproducible = + steps.replace(/\s+/g, '').length >= 30 || ATTACHMENT.test(b) || COMMAND.test(b) || /^\s*1[.)]\s/m.test(b); + if (reproducible) return []; + if (APP_TITLE.test(t) && !b.trim()) return ['paste']; + const asks = ['steps']; + if (!VERSION.test(`${t}\n${b}`)) asks.push('version'); + return asks; +} + +function askBody(asks) { + if (asks.includes('paste')) { + return [ + 'Thanks for the report! It arrived empty: when a report is too long for the link, SolidCraft copies it to the clipboard instead.', + 'Please paste it here (or use **Report an Issue** again and paste before submitting).', + '', + `If we don't hear back within ${INFO_DAYS} days this is closed; reopen it any time.`, + ].join('\n'); + } + const lines = ['Thanks for the report! To reproduce it we need a little more:']; + lines.push( + '- **Steps to reproduce**: what you clicked or typed, in order. For a problem on a particular design, ' + + '`solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script; attach that, or the design.', + ); + if (asks.includes('version')) lines.push('- **SolidCraft version** (Help → About) 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 00000000..e838e14c --- /dev/null +++ b/scripts/triage/needs-info.test.js @@ -0,0 +1,148 @@ +'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: 'solidcraft' }; +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); + +test('missing: short reports without steps are asked, with the version when it is absent', () => { + assert.deepEqual(info.missing('Loft does not work', 'The Loft operation is not working.'), ['steps', 'version']); + assert.deepEqual(info.missing('Loft does not work', 'Broken on 0.15.0.'), ['steps']); +}); + +test('missing: empty app reports are asked to paste the copied report', () => { + assert.deepEqual(info.missing('EXTRUDE: operation failed', ''), ['paste']); + assert.deepEqual(info.missing('Modeling: unexpected result', ' '), ['paste']); +}); + +test('missing: reproducible, long, app-filed and non-bug issues are left alone', () => { + assert.deepEqual(info.missing('Loft fails', '## Reproduction\n1. sketch two circles\n2. loft them'), []); + assert.deepEqual(info.missing('Loft fails', 'see design.solidcraft attached'), []); + assert.deepEqual(info.missing('Loft fails', 'runs `solid.loft` and fails'), []); + assert.deepEqual(info.missing('Loft fails', long), []); + assert.deepEqual(info.missing('FILLET: operation failed', '## Settings and selection\n[["solid.fillet"]]'), []); + assert.deepEqual(info.missing('Feature request: dark mode', 'please'), []); + assert.deepEqual(info.missing('[FEAT] threads', 'please'), []); + assert.deepEqual(info.missing('How do I draw a polygon?', ''), []); + assert.deepEqual(info.missing('Crash: SolidCraft 0.15.0 ended without a clean exit', ''), []); +}); + +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('Loft 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, /SolidCraft version/); + assert.match(body, /14 days/); +}); + +test('an empty app report is asked to paste, a maintainer\'s note is never asked', async () => { + let w = fake(); + await info.handleOpened({ github: w.github, context: opened('EXTRUDE: operation failed', '') }); + assert.match(find(w.calls, 'comment')[1].body, /clipboard/); + w = fake({ perms: { carol: 'write' } }); + assert.equal(await info.handleOpened({ github: w.github, context: opened('Loft broken', 'x', 'carol') }), false); + assert.equal(w.calls.length, 0); +}); + +const reply = (login, { comment = true, body = 'x', issueLabels = ['needs-info'] } = {}) => ({ + repo, + payload: { + issue: { number: 4, title: 'Loft 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. sketch a circle\n2. loft it to a point' }); + 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: 'Loft broken', body: 'nope', labels: [], user: { login: 'a', type: 'User' } }, + { number: 2, state: 'open', title: 'Loft 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 are you trying to do\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 index f00a13ce..ba994e28 100644 --- a/scripts/triage/triage.js +++ b/scripts/triage/triage.js @@ -66,13 +66,13 @@ function repoArgs(context) { return { owner: context.repo.owner, repo: context.repo.repo }; } -// The text under a `## ` heading, up to the next heading. +// 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(`^##\\s*${name}\\s*$`, 'i').test(l.trim())); + 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) => /^##\s/.test(l)); + const end = rest.findIndex((l) => /^#{2,3}\s/.test(l)); return (end < 0 ? rest : rest.slice(0, end)).join('\n'); } @@ -129,18 +129,13 @@ function classifyPaths(paths) { return [...labels].sort(); } -async function ensureNeedsTriage(github, context) { +async function ensureLabel(github, context, name, color, description) { try { - await github.rest.issues.getLabel({ ...repoArgs(context), name: NEEDS_TRIAGE }); + 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: NEEDS_TRIAGE, - color: NEEDS_TRIAGE_COLOR, - description: NEEDS_TRIAGE_DESCRIPTION, - }); + await github.rest.issues.createLabel({ ...repoArgs(context), name, color, description }); } catch (e2) { if (e2.status !== 422) throw e2; // created meanwhile } @@ -171,7 +166,9 @@ async function proposal(github, context, item) { async function apply(github, context, number, labels) { if (!labels.length) return; - if (labels.includes(NEEDS_TRIAGE)) await ensureNeedsTriage(github, context); + 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 }); } @@ -296,6 +293,10 @@ module.exports = { NEEDS_TRIAGE, classifyIssue, classifyPaths, + ensureLabel, + isMaintainer, + labelNames, + section, parseTriage, handleOpened, handleLabeled,