mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Kernel paused: label new kernel issues, PRs and discussions for a maintainer to answer (#3994)
While a new geometry kernel is evaluated, a workflow labels new issues, pull requests and discussions about kernel work kernel-paused, so a maintainer finds them and answers in person. A pull request counts when it changes crates/kernel/ or vendor/truck*, anything else when its title names a kernel operation. Crash and data-loss reports and anyone who can push are left alone. CONTRIBUTING.md says so. Claude-Session: https://claude.ai/code/session_01LGeCa5jWWdroMWvXxN3LLM Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in New Issue
Block a user