diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ad570ef70..e3c58d1e17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -290,8 +290,8 @@ jobs: run: python3 -I scripts/ci/test_affected.py - name: Merge-of-main detection self-test run: python3 -I scripts/ci/test_main_merge.py - - name: Claim and triage bot tests - run: node --test scripts/claim/*.test.js scripts/triage/*.test.js + - name: Claim, triage and kernel-paused bot tests + run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js # Fast feedback for a pull request that has not been approved for the full CI yet. quick-compile: diff --git a/.github/workflows/kernel-paused.yml b/.github/workflows/kernel-paused.yml new file mode 100644 index 0000000000..aee37d9b1d --- /dev/null +++ b/.github/workflows/kernel-paused.yml @@ -0,0 +1,42 @@ +# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel +# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is +# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml). +name: Kernel paused + +on: + issues: + types: [opened] + # pull_request_target so PRs from forks can be labelled. It runs the base branch's + # scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out. + pull_request_target: + types: [opened] + discussion: + types: [created] + +permissions: + contents: read # to check out scripts/kernel-paused + issues: write + pull-requests: read # to list a PR's changed paths + discussions: write + +concurrency: + group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }} + cancel-in-progress: false + +jobs: + paused: + name: Kernel paused bot + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: scripts/kernel-paused + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const paused = require('./scripts/kernel-paused/kernel-paused.js'); + const done = context.eventName === 'discussion' + ? await paused.handleDiscussion({ github, context }) + : await paused.handleOpened({ github, context }); + core.info(done); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0d1b842fc..cc3123957c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,6 +15,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH). +## Kernel work is paused + +We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull +requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and +lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes +`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused` +label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss +are still taken as issues, and everything outside the kernel carries on as normal. + ## Get it running (about 10 minutes on a fast machine) You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web diff --git a/scripts/kernel-paused/kernel-paused.js b/scripts/kernel-paused/kernel-paused.js new file mode 100644 index 0000000000..480a943789 --- /dev/null +++ b/scripts/kernel-paused/kernel-paused.js @@ -0,0 +1,89 @@ +'use strict'; +// While a new geometry kernel is evaluated, new issues, pull requests and discussions about +// kernel work get the `kernel-paused` label, so a maintainer finds them and answers in person +// (CONTRIBUTING.md, "Kernel work is paused"). Reports of crashes and data loss are left alone, +// and so is anyone who can push to the repository. + +const LABEL = 'kernel-paused'; +const LABEL_COLOR = 'b60205'; +const LABEL_DESCRIPTION = 'Kernel work is paused while a new geometry kernel is evaluated'; + +// Kernel operations named in a title. "Hole" and "shell" are left out on purpose: they name UI +// work too, and a pull request that changes the kernel is caught by its files. +const TOPIC = + /\b(booleans?|fillets?|chamfers?|offsets?|sweeps?|lofts?|threads?|step|stp|iges|igs|tessellat\w*|kernel|truck|b-?rep)\b/i; +// Crashes and lost work come first whatever the kernel is. +const URGENT = /\b(crash\w*|panic\w*|data loss|lost (my )?work|corrupt\w*)\b/i; +// What a pull request changes that makes it kernel work. +const KERNEL_PATHS = ['crates/kernel/', 'vendor/truck']; + +function isTopic(title) { + return TOPIC.test(title || ''); +} + +function isUrgent(title, body) { + return URGENT.test(`${title || ''}\n${body || ''}`); +} + +function touchesKernel(files) { + return files.some((f) => KERNEL_PATHS.some((p) => f.startsWith(p))); +} + +// Whether the author can push to the repository (maintainers and their agents are not paused). +async function canPush({ github, context }, username) { + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ ...context.repo, username }); + return ['admin', 'maintain', 'write'].includes(data.permission); + } catch { + return false; + } +} + +async function ensureLabel({ github, context }) { + try { + await github.rest.issues.getLabel({ ...context.repo, name: LABEL }); + } catch (e) { + if (e.status !== 404) throw e; + await github.rest.issues.createLabel({ ...context.repo, name: LABEL, color: LABEL_COLOR, description: LABEL_DESCRIPTION }); + } +} + +// A new issue or pull request: returns what was done, for the log. +async function handleOpened({ github, context }) { + const item = context.payload.pull_request || context.payload.issue; + if (!item) return 'nothing to do'; + const pull = Boolean(context.payload.pull_request); + if (isUrgent(item.title, item.body)) return `#${item.number}: a crash or data-loss report, left alone`; + if (await canPush({ github, context }, item.user.login)) return `#${item.number}: by a maintainer, left alone`; + let kernel = isTopic(item.title); + if (pull && !kernel) { + const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number: item.number, per_page: 100 }); + kernel = touchesKernel(files.map((f) => f.filename)); + } + if (!kernel) return `#${item.number}: not kernel work`; + await ensureLabel({ github, context }); + await github.rest.issues.addLabels({ ...context.repo, issue_number: item.number, labels: [LABEL] }); + return `#${item.number}: labelled ${LABEL}`; +} + +// A new discussion. +async function handleDiscussion({ github, context }) { + const d = context.payload.discussion; + if (!d) return 'nothing to do'; + if (!isTopic(d.title) || isUrgent(d.title, d.body)) return `discussion #${d.number}: left alone`; + if (await canPush({ github, context }, d.user.login)) return `discussion #${d.number}: by a maintainer, left alone`; + await ensureLabel({ github, context }); + const { repository } = await github.graphql( + 'query($owner: String!, $repo: String!, $name: String!) { repository(owner: $owner, name: $repo) { label(name: $name) { id } } }', + { ...context.repo, name: LABEL }, + ); + if (repository && repository.label) { + await github.graphql('mutation($id: ID!, $labels: [ID!]!) { addLabelsToLabelable(input: {labelableId: $id, labelIds: $labels}) { clientMutationId } }', { + id: d.node_id, + labels: [repository.label.id], + }); + } + return `discussion #${d.number}: labelled ${LABEL}`; +} + +module.exports = { LABEL, isTopic, isUrgent, touchesKernel, handleOpened, handleDiscussion }; diff --git a/scripts/kernel-paused/kernel-paused.test.js b/scripts/kernel-paused/kernel-paused.test.js new file mode 100644 index 0000000000..910ad2b154 --- /dev/null +++ b/scripts/kernel-paused/kernel-paused.test.js @@ -0,0 +1,88 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const paused = require('./kernel-paused.js'); + +const repo = { owner: 'tangency-labs', repo: 'solidcraft' }; + +// 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, + graphql: async (query, args) => { + calls.push([query.trim().split(/[\s({]/)[0] === 'query' ? 'query' : query.match(/\{\s*(\w+)/)[1], args]); + return { repository: { label: { id: 'L1' } } }; + }, + rest: { + repos: { + getCollaboratorPermissionLevel: async ({ username }) => ({ data: { permission: (world.perms || {})[username] || 'read' } }), + }, + pulls: { listFiles: async () => ({ data: (world.files || []).map((filename) => ({ filename })) }) }, + issues: { + getLabel: async () => { + if (world.labelMissing) throw Object.assign(new Error('nf'), { status: 404 }); + return { data: {} }; + }, + createLabel: rec('createLabel'), + addLabels: rec('addLabels'), + createComment: rec('comment'), + }, + }, + }; + return { github, calls }; +} + +const names = (calls) => calls.map((c) => c[0]); + +test('kernel topics are named in titles; hole and shell are not', () => { + for (const t of ['Fillet fails on a cone', 'Booleans: cut leaves a sliver', 'STEP import drops a face', 'Tessellation hangs']) { + assert.ok(paused.isTopic(t), t); + } + for (const t of ['Hole dialog: diameter field', 'Shell command icon', 'Sketch: dimension text', 'Stepper widget']) { + assert.ok(!paused.isTopic(t), t); + } +}); + +test('a kernel issue is labelled, nothing more', async () => { + const { github, calls } = fake({ labelMissing: true }); + const context = { repo, payload: { issue: { number: 5, title: 'Chamfer on a spline edge fails', body: '', user: { login: 'someone' } } } }; + await paused.handleOpened({ github, context }); + assert.deepEqual(names(calls), ['createLabel', 'addLabels']); +}); + +test('crashes, data loss and maintainers are left alone', async () => { + for (const body of ['The app crashed when I filleted this', 'I lost my work after a boolean']) { + const { github, calls } = fake(); + await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 6, title: 'Fillet', body, user: { login: 'someone' } } } } }); + assert.deepEqual(calls, [], body); + } + const { github, calls } = fake({ perms: { owner: 'admin' } }); + await paused.handleOpened({ github, context: { repo, payload: { issue: { number: 7, title: 'Fillet', body: '', user: { login: 'owner' } } } } }); + assert.deepEqual(calls, []); +}); + +test('a pull request that changes the kernel is labelled, whatever its title', async () => { + const { github, calls } = fake({ files: ['docs/a.md', 'crates/kernel/src/blend.rs'] }); + const pr = { number: 8, title: 'Speed up a hot path', body: '', draft: false, node_id: 'PR_1', user: { login: 'someone' } }; + await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } }); + assert.deepEqual(names(calls), ['addLabels']); +}); + +test('a pull request outside the kernel is left alone', async () => { + const { github, calls } = fake({ files: ['crates/ui-egui/src/dialogs.rs'] }); + const pr = { number: 9, title: 'Dialog spacing', body: '', draft: false, node_id: 'PR_2', user: { login: 'someone' } }; + await paused.handleOpened({ github, context: { repo, payload: { pull_request: pr } } }); + assert.deepEqual(calls, []); +}); + +test('a kernel discussion is labelled', async () => { + const { github, calls } = fake(); + const d = { number: 3, title: 'Ideas for better booleans', body: '', node_id: 'D_1', user: { login: 'someone' } }; + await paused.handleDiscussion({ github, context: { repo, payload: { discussion: d } } }); + assert.deepEqual(names(calls), ['query', 'addLabelsToLabelable']); +});