diff --git a/.github/workflows/hold.yml b/.github/workflows/hold.yml
new file mode 100644
index 00000000..f0633b59
--- /dev/null
+++ b/.github/workflows/hold.yml
@@ -0,0 +1,29 @@
+# Fails while a pull request has the do-not-merge/hold label (set with /hold).
+# Labels set by the triage bot don't start workflows, so after /hold or /unhold
+# the bot re-runs this workflow (see hack/triage/run.js); that is why the label
+# is read from the API rather than from the event payload.
+
+name: hold
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, labeled, unlabeled]
+
+permissions: {}
+
+jobs:
+ hold:
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: read
+ steps:
+ - name: Fail while the pull request is on hold
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR: ${{ github.event.pull_request.number }}
+ run: |
+ labels=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/labels" --jq '.[].name')
+ if grep -qx 'do-not-merge/hold' <<<"$labels"; then
+ echo "::error::This pull request is on hold. Comment /unhold to release it."
+ exit 1
+ fi
diff --git a/.github/workflows/triage-commands.yml b/.github/workflows/triage-commands.yml
new file mode 100644
index 00000000..6671544b
--- /dev/null
+++ b/.github/workflows/triage-commands.yml
@@ -0,0 +1,69 @@
+# Prow-style slash commands for issues and PRs: /triage accepted, /kind bug, /assign,
+# and on PRs /lgtm, /approve, /hold, /retest, /cc, ...
+# See documentation/triage-commands.md for the command list.
+#
+# Security: both events run in the context of the default branch, and the job only
+# checks out hack/triage from it. Pull request code is never checked out or run;
+# commands are read from the comment text only.
+
+name: triage-commands
+
+on:
+ issues:
+ types: [opened]
+ issue_comment:
+ types: [created]
+
+# Explicitly disable all default GITHUB_TOKEN permissions at the workflow level.
+# Each job then declares only the minimal required permissions.
+permissions: {}
+
+jobs:
+ label-new-issue:
+ if: github.event_name == 'issues'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+ steps:
+ - name: Checkout triage scripts
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
+ with:
+ sparse-checkout: hack/triage
+ persist-credentials: false
+
+ - name: Add needs-triage label
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { labelNewIssue } = require('./hack/triage/run.js')
+ await labelNewIssue({ github, context })
+
+ commands:
+ if: >-
+ github.event_name == 'issue_comment' &&
+ github.event.comment.user.type != 'Bot' &&
+ contains(github.event.comment.body, '/')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+ # /retest re-runs failed workflow runs of a pull request.
+ actions: write
+ # No concurrency group: GitHub keeps only one pending run per group and
+ # cancels the others, which silently drops commands from quick comments.
+ # Each run reads the current labels instead.
+ steps:
+ - name: Checkout triage scripts
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
+ with:
+ sparse-checkout: hack/triage
+ persist-credentials: false
+
+ - name: Apply slash commands
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { run } = require('./hack/triage/run.js')
+ await run({ github, context, core })
diff --git a/.github/workflows/triage-tests.yml b/.github/workflows/triage-tests.yml
new file mode 100644
index 00000000..ec2c32bd
--- /dev/null
+++ b/.github/workflows/triage-tests.yml
@@ -0,0 +1,29 @@
+# Unit tests for the triage bot in hack/triage. They only use Node's built-in
+# test runner, so no dependencies are installed.
+
+name: triage-tests
+
+on:
+ push:
+ paths:
+ - 'hack/triage/**'
+ - '.github/workflows/triage-tests.yml'
+ pull_request:
+ paths:
+ - 'hack/triage/**'
+ - '.github/workflows/triage-tests.yml'
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run triage tests
+ run: node --test hack/triage/test/*.test.js
diff --git a/.github/workflows/welcome-new-users.yml b/.github/workflows/welcome-new-users.yml
index 1a1e6d2e..b3e2517d 100644
--- a/.github/workflows/welcome-new-users.yml
+++ b/.github/workflows/welcome-new-users.yml
@@ -32,7 +32,9 @@ jobs:
Welcome to the Microcks community! 💖
Thanks and congrats 🎉 for opening your first issue here! Be sure to follow the issue template or please update it accordingly.
-
+
+ 🏷️ A maintainer will triage it soon. If you'd like to work on it, please wait for the `triage/accepted` label before opening a pull request. See the [triage commands](https://github.com/microcks/microcks-cli/blob/master/documentation/triage-commands.md).
+
📢 If you're using Microcks in your organization, please [add your company name to this list](https://github.com/microcks/.github/blob/main/ADOPTERS.md). 🙏 It really helps the project to gain momentum and credibility. It's a small contribution back to the project with a big impact.
If you need to know why and how to add yourself to the list, please read the blog post ["Join the Microcks Adopters list and Empower the vibrant open source Community 🙌"](https://microcks.io/blog/join-adopters-list/)
@@ -60,7 +62,9 @@ jobs:
Welcome to the Microcks community! 💖
Thanks and congrats 🎉 for opening your first pull request here! Be sure to follow the pull request template or please update it accordingly.
-
+
+ 🏷️ Please link the issue this fixes; we review pull requests for issues labelled `triage/accepted` first. See the [triage commands](https://github.com/microcks/microcks-cli/blob/master/documentation/triage-commands.md).
+
Hope you have a great time there!
welcome-pr-merged:
diff --git a/documentation/triage-commands.md b/documentation/triage-commands.md
new file mode 100644
index 00000000..0fca0f0b
--- /dev/null
+++ b/documentation/triage-commands.md
@@ -0,0 +1,72 @@
+# Triage commands
+
+Issues and pull requests in this repository can be triaged with Prow-style slash commands, similar to Kubernetes. Write the command at the start of a line in a comment. You can put several commands in one comment, one per line. The bot reacts with 👍 when the commands are applied, and replies if one could not be applied.
+
+## Triage flow
+
+1. Every new issue gets the `needs-triage` label.
+2. A maintainer or contributor checks it and comments `/triage accepted`, or `/triage needs-information` if more details are needed from the reporter.
+3. Once an issue has `triage/accepted`, anyone can open a pull request that references it. Issues are not assigned: the first complete pull request is accepted. If one is already open, please collaborate there instead of opening another.
+
+Please wait for `triage/accepted` before starting work. It avoids spending time on a fix or feature that the maintainers would not merge.
+
+## Commands for everyone
+
+| Command | Effect |
+| --- | --- |
+| `/close`, `/reopen` | Closes or reopens an issue or pull request you opened. You can only reopen it if you closed it yourself; if a maintainer closed it, ask them. |
+
+## Commands for contributors and maintainers
+
+Contributors are people who already have commits in this repository. They can help triage issues and pull requests opened by others, but not their own.
+
+| Command | Effect |
+| --- | --- |
+| `/triage accepted` | Adds `triage/accepted` and removes `needs-triage`. |
+| `/triage needs-information` | Adds `triage/needs-information` and removes `needs-triage`. |
+| `/kind bug\|feature\|enhancement\|task\|question` | Adds the matching `kind/*` label. |
+| `/area cli\|connectors\|build\|docs\|packaging` | Adds the matching `component/*` label. |
+| `/platform windows\|linux\|macos` | Adds the matching `platform/*` label. |
+| `/good-first-issue` | Adds `good first issue`. On issues, needs `triage/accepted` first. |
+| `/help` | Adds `help wanted`. On issues, needs `triage/accepted` first. |
+
+## Commands for maintainers
+
+Maintainers are people with write access to this repository. They can use every command above, including on their own issues, and the following ones to correct or manage triage.
+
+| Command | Effect |
+| --- | --- |
+| `/remove-triage` | Removes the `triage/*` labels and adds `needs-triage` back. |
+| `/remove-kind`, `/remove-area`, `/remove-platform` | Removes the matching label, for example `/remove-kind bug`. |
+| `/remove-good-first-issue`, `/remove-help` | Removes `good first issue` or `help wanted`. |
+| `/keep-open` | Adds `keep-open`, so the stale bot skips the item. `/remove-keep-open` removes it. |
+| `/assign [@user]`, `/unassign [@user]` | Assigns or unassigns yourself or someone else, for example for mentorship work. GitHub only lets you assign people who have commented on the issue or have access to the repository. |
+| `/close`, `/reopen` | Closes or reopens any issue or pull request. `/reopen` also removes the `status/*` label. |
+| `/close duplicate\|wontfix\|invalid` | Closes with a reason and adds the matching `status/*` label. On issues it also sets GitHub's close reason (duplicate or not planned). |
+| `/priority critical-urgent\|important-soon\|important-longterm\|backlog` | Sets the `priority/*` label, replacing any other priority. `/remove-priority` removes it. |
+| `/milestone
` | Sets an open GitHub milestone, for example `/milestone 1.0.3`. `/milestone clear` or `/remove-milestone` removes it. |
+
+## Pull request commands
+
+These commands only work in pull request comments. `lgtm`, `approved` and `do-not-merge/hold` are review signals for maintainers: merging still needs an approving GitHub review from a code owner.
+
+| Command | Who | Effect |
+| --- | --- | --- |
+| `/lgtm` | Contributors and maintainers, not on their own pull request | Adds `lgtm`. |
+| `/approve` | Maintainers, not on their own pull request | Adds `approved`. |
+| `/remove-lgtm` (`/lgtm cancel`), `/remove-approve` (`/approve cancel`) | Maintainers | Removes `lgtm` or `approved`. |
+| `/hold` | Contributors, maintainers and the author | Adds `do-not-merge/hold`. The `hold` check fails while the label is set. |
+| `/unhold` (`/hold cancel`) | Maintainers | Removes `do-not-merge/hold`. |
+| `/retest` | Maintainers and the author | Re-runs the failed checks on the latest commit. Re-running from the Actions tab needs write access, so this lets contributors retry a flaky check (for example a network error during `go mod download`) without pushing an empty commit. |
+| `/cc @user`, `/uncc @user` | Contributors, maintainers and the author | Requests or removes a review from someone. |
+
+Commands inside code blocks, quoted lines (`> /kind bug`) and HTML comments are ignored, and unknown commands are ignored silently. If a command can't be applied, the bot reacts with 😕 and explains why, once per person and issue.
+
+## Example
+
+```text
+/triage accepted
+/kind bug
+/area connectors
+/good-first-issue
+```
diff --git a/hack/triage/parse.js b/hack/triage/parse.js
new file mode 100644
index 00000000..31640b79
--- /dev/null
+++ b/hack/triage/parse.js
@@ -0,0 +1,65 @@
+/*
+ * Copyright The Microcks Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+// Reads slash commands from comment text.
+
+const MAX_COMMANDS = 10;
+
+/**
+ * @typedef {object} Command
+ * @property {string} name command name without the slash, lowercased
+ * @property {string[]} args arguments, with a leading "@" removed
+ */
+
+/**
+ * Extracts slash commands from a comment body. Commands must start a line;
+ * lines inside fenced code blocks and quoted lines ("> /kind bug") are
+ * ignored so that quoting a previous comment does not re-run it.
+ *
+ * @param {string | null | undefined} body
+ * @returns {Command[]}
+ */
+function parseCommands(body) {
+ const commands = [];
+ let inFence = false;
+ // GitHub doesn't render HTML comments, so commands inside them would run
+ // without reviewers seeing them. An unclosed comment hides the rest.
+ const visible = String(body || '').replace(/|$)/g, '');
+ for (const raw of visible.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (line.startsWith('```') || line.startsWith('~~~')) {
+ inFence = !inFence;
+ continue;
+ }
+ if (inFence || !line.startsWith('/')) {
+ continue;
+ }
+ const [head, ...args] = line.split(/\s+/);
+ const name = head.slice(1).toLowerCase();
+ if (!/^[a-z][a-z-]*$/.test(name)) {
+ continue;
+ }
+ commands.push({ name, args: args.map((a) => a.replace(/^@/, '')).filter(Boolean) });
+ if (commands.length >= MAX_COMMANDS) {
+ break;
+ }
+ }
+ return commands;
+}
+
+module.exports = { parseCommands };
diff --git a/hack/triage/plan.js b/hack/triage/plan.js
new file mode 100644
index 00000000..94a3db7f
--- /dev/null
+++ b/hack/triage/plan.js
@@ -0,0 +1,366 @@
+/*
+ * Copyright The Microcks Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+// Decides what a set of commands does. All permission rules live here; no
+// GitHub API calls are made.
+
+const NEEDS_TRIAGE = 'needs-triage';
+const TRIAGE_ACCEPTED = 'triage/accepted';
+// Pull request review signals. They are labels only: merging still needs an
+// approving GitHub review from a code owner, and a maintainer merges by hand.
+// TODO: merge automatically once a PR has lgtm and approved, no hold and green
+// checks, like Prow's Tide. The master ruleset requires a code owner review,
+// so the bot would need a bypass there (or a GitHub App) first; that needs
+// maintainer agreement.
+const LGTM = 'lgtm';
+const APPROVED = 'approved';
+const HOLD = 'do-not-merge/hold';
+const CANCEL = { lgtm: 'remove-lgtm', approve: 'remove-approve', hold: 'unhold' };
+
+// Command argument -> repository label. Only these labels can be set.
+const TRIAGE = {
+ accepted: TRIAGE_ACCEPTED,
+ 'needs-information': 'triage/needs-information',
+};
+const KIND = {
+ bug: 'kind/bug',
+ feature: 'kind/feature',
+ enhancement: 'kind/enhancement',
+ task: 'kind/task',
+ question: 'kind/question',
+};
+const AREA = {
+ cli: 'component/cli',
+ connectors: 'component/connectors',
+ build: 'component/build',
+ docs: 'component/documentation',
+ packaging: 'component/packaging',
+};
+const PLATFORM = {
+ windows: 'platform/windows',
+ linux: 'platform/linux',
+ macos: 'platform/macos',
+};
+const FLAGS = {
+ help: 'help wanted',
+ 'good-first-issue': 'good first issue',
+ 'keep-open': 'keep-open',
+};
+const PRIORITY = {
+ 'critical-urgent': 'priority/critical-urgent',
+ 'important-soon': 'priority/important-soon',
+ 'important-longterm': 'priority/important-longterm',
+ backlog: 'priority/backlog',
+};
+const CLOSE_REASON = {
+ duplicate: { label: 'status/duplicate', stateReason: 'duplicate' },
+ wontfix: { label: 'status/wontfix', stateReason: 'not_planned' },
+ invalid: { label: 'status/invalid', stateReason: 'not_planned' },
+};
+
+/**
+ * @typedef {object} Context
+ * @property {string} commenter login of the person who wrote the comment
+ * @property {string | undefined} author login of the issue or pull request author
+ * @property {boolean} isMaintainer commenter has write access or more
+ * @property {boolean} isContributor commenter already has commits in the repository
+ * @property {boolean} isPullRequest the comment is on a pull request
+ * @property {string[]} labels current labels of the issue or pull request
+ * @property {string} [closedBy] login of the person who last closed it, if any
+ */
+
+/**
+ * @typedef {object} Plan
+ * @property {string[]} addLabels
+ * @property {string[]} removeLabels
+ * @property {string[]} assign
+ * @property {string[]} unassign
+ * @property {string[]} reviewers review requests to add
+ * @property {string[]} unreviewers review requests to remove
+ * @property {boolean} retest re-run the failed checks of the pull request
+ * @property {'open' | 'closed' | null} state
+ * @property {'duplicate' | 'not_planned' | null} stateReason why an issue is closed
+ * @property {{ title: string | null } | null} milestone milestone to set (title null clears it), or null for no change
+ * @property {string[]} errors explanations for commands that were not applied
+ */
+
+/**
+ * Turns parsed commands into the changes to apply. Pure function so it can be
+ * unit tested without the GitHub API.
+ *
+ * Maintainers can use every command. Contributors (people with commits in the
+ * repository) can add triage and label values, but not on their own issues;
+ * removing labels stays with maintainers so they can correct a triage.
+ *
+ * @param {import('./parse').Command[]} commands
+ * @param {Context} ctx
+ * @returns {Plan}
+ */
+function plan(commands, ctx) {
+ const labels = new Set(ctx.labels);
+ /** @type {Plan} */
+ const out = {
+ addLabels: [], removeLabels: [], assign: [], unassign: [], reviewers: [], unreviewers: [], retest: false, state: null, stateReason: null, milestone: null, errors: [],
+ };
+
+ const add = (label) => {
+ if (!labels.has(label)) {
+ labels.add(label);
+ out.addLabels.push(label);
+ }
+ out.removeLabels = out.removeLabels.filter((l) => l !== label);
+ };
+ const remove = (label) => {
+ if (labels.has(label)) {
+ labels.delete(label);
+ out.removeLabels.push(label);
+ }
+ out.addLabels = out.addLabels.filter((l) => l !== label);
+ };
+ const isAuthor = Boolean(ctx.author) && ctx.author.toLowerCase() === ctx.commenter.toLowerCase();
+ const needMaintainer = (cmd) => {
+ if (!ctx.isMaintainer) {
+ out.errors.push(`\`/${cmd}\` can only be used by maintainers.`);
+ return false;
+ }
+ return true;
+ };
+ const needTriager = (cmd) => {
+ if (ctx.isMaintainer) return true;
+ if (!ctx.isContributor) {
+ out.errors.push(`\`/${cmd}\` can be used by maintainers and by people who have contributed to this repository.`);
+ return false;
+ }
+ if (isAuthor) {
+ out.errors.push(`\`/${cmd}\` can't be used on your own ${ctx.isPullRequest ? 'pull request' : 'issue'}. Another contributor or a maintainer will triage it.`);
+ return false;
+ }
+ return true;
+ };
+ const needPullRequest = (cmd) => {
+ if (!ctx.isPullRequest) {
+ out.errors.push(`\`/${cmd}\` can only be used on pull requests.`);
+ return false;
+ }
+ return true;
+ };
+ // Maintainers, contributors and the pull request author.
+ const needParticipant = (cmd) => {
+ if (ctx.isMaintainer || ctx.isContributor || isAuthor) return true;
+ out.errors.push(`\`/${cmd}\` can be used by maintainers, by people who have contributed to this repository, and by the author.`);
+ return false;
+ };
+ const mapped = (cmd, table, arg) => {
+ const label = table[String(arg || '').toLowerCase()];
+ if (!label) {
+ out.errors.push(`\`/${cmd}\` expects one of: ${Object.keys(table).map((k) => `\`${k}\``).join(', ')}.`);
+ }
+ return label;
+ };
+ const labelCommand = (name, table, args) => {
+ const removing = name.startsWith('remove-');
+ const base = removing ? name.slice('remove-'.length) : name;
+ if (!(removing ? needMaintainer(name) : needTriager(name))) return;
+ if (args.length === 0) {
+ out.errors.push(`\`/${name}\` expects one of: ${Object.keys(table).map((k) => `\`${k}\``).join(', ')}.`);
+ return;
+ }
+ for (const arg of args) {
+ const label = mapped(base, table, arg);
+ if (label) (removing ? remove : add)(label);
+ }
+ };
+
+ for (const command of commands) {
+ const { args } = command;
+ const name = CANCEL[command.name] && String(args[0]).toLowerCase() === 'cancel' ? CANCEL[command.name] : command.name;
+ switch (name) {
+ case 'triage': {
+ if (!needTriager(name)) break;
+ const label = mapped(name, TRIAGE, args[0]);
+ if (!label) break;
+ for (const l of Object.values(TRIAGE)) if (l !== label) remove(l);
+ remove(NEEDS_TRIAGE);
+ add(label);
+ break;
+ }
+ case 'remove-triage':
+ if (!needMaintainer(name)) break;
+ for (const l of Object.values(TRIAGE)) remove(l);
+ add(NEEDS_TRIAGE);
+ break;
+ case 'kind':
+ case 'remove-kind':
+ labelCommand(name, KIND, args);
+ break;
+ case 'area':
+ case 'remove-area':
+ labelCommand(name, AREA, args);
+ break;
+ case 'platform':
+ case 'remove-platform':
+ labelCommand(name, PLATFORM, args);
+ break;
+ case 'help':
+ case 'good-first-issue':
+ case 'keep-open': {
+ // keep-open exempts an item from the stale bot, so it stays with maintainers.
+ if (!(name === 'keep-open' ? needMaintainer(name) : needTriager(name))) break;
+ if (name !== 'keep-open' && !ctx.isPullRequest && !labels.has(TRIAGE_ACCEPTED)) {
+ out.errors.push(`\`/${name}\` needs the issue to be accepted first: use \`/triage accepted\`.`);
+ break;
+ }
+ add(FLAGS[name]);
+ break;
+ }
+ case 'remove-help':
+ case 'remove-good-first-issue':
+ case 'remove-keep-open':
+ if (!needMaintainer(name)) break;
+ remove(FLAGS[name.slice('remove-'.length)]);
+ break;
+ // Microcks doesn't assign issues to contributors: the first complete pull request is accepted.
+ case 'assign':
+ case 'unassign': {
+ if (!ctx.isMaintainer) {
+ out.errors.push('Issues are not assigned here. Once an issue has `triage/accepted`, open a pull request that references it; the first complete one is accepted.');
+ break;
+ }
+ out[name].push(...(args.length ? args : [ctx.commenter]));
+ break;
+ }
+ case 'close':
+ case 'reopen': {
+ if (!ctx.isMaintainer && !isAuthor) {
+ out.errors.push(`\`/${name}\` can only be used by maintainers or the author.`);
+ break;
+ }
+ // Like GitHub itself, the author can only reopen what they closed, not
+ // an item a maintainer closed (for example as out of scope).
+ const closedBySomeoneElse = Boolean(ctx.closedBy) && ctx.closedBy.toLowerCase() !== ctx.commenter.toLowerCase();
+ if (name === 'reopen' && !ctx.isMaintainer && closedBySomeoneElse) {
+ out.errors.push('`/reopen` can\'t reopen an item a maintainer closed. Please ask a maintainer if you think it should be reopened.');
+ break;
+ }
+ if (name === 'reopen') {
+ for (const { label } of Object.values(CLOSE_REASON)) remove(label);
+ out.state = 'open';
+ break;
+ }
+ if (args.length) {
+ const reason = CLOSE_REASON[args[0].toLowerCase()];
+ if (!reason) {
+ out.errors.push(`\`/close\` expects nothing or one of: ${Object.keys(CLOSE_REASON).map((k) => `\`${k}\``).join(', ')}.`);
+ break;
+ }
+ // A reason is a maintainer's judgement, even on the author's own item.
+ if (!ctx.isMaintainer) {
+ out.errors.push('`/close` with a reason can only be used by maintainers. Use `/close` on its own.');
+ break;
+ }
+ add(reason.label);
+ if (!ctx.isPullRequest) out.stateReason = reason.stateReason;
+ }
+ out.state = 'closed';
+ break;
+ }
+ case 'priority': {
+ if (!needMaintainer(name)) break;
+ const label = mapped(name, PRIORITY, args[0]);
+ if (!label) break;
+ for (const l of Object.values(PRIORITY)) if (l !== label) remove(l);
+ add(label);
+ break;
+ }
+ case 'milestone':
+ case 'remove-milestone': {
+ if (!needMaintainer(name)) break;
+ // "/milestone clear" is the Prow spelling of "/remove-milestone".
+ const clear = name === 'remove-milestone' || (args.length === 1 && args[0].toLowerCase() === 'clear');
+ if (!clear && args.length === 0) {
+ out.errors.push('`/milestone` expects a milestone title, for example `/milestone 1.0.3`, or `clear`.');
+ break;
+ }
+ // The title is checked against the repository's open milestones when applied.
+ out.milestone = { title: clear ? null : args.join(' ') };
+ break;
+ }
+ case 'remove-priority':
+ if (!needMaintainer(name)) break;
+ for (const l of Object.values(PRIORITY)) remove(l);
+ break;
+ case 'lgtm':
+ if (!needPullRequest(name)) break;
+ if (isAuthor) {
+ out.errors.push('`/lgtm` can\'t be used on your own pull request.');
+ break;
+ }
+ if (!needTriager(name)) break;
+ add(LGTM);
+ break;
+ case 'approve':
+ if (!needPullRequest(name) || !needMaintainer(name)) break;
+ // GitHub doesn't let authors approve their own pull request either.
+ if (isAuthor) {
+ out.errors.push('`/approve` can\'t be used on your own pull request. Another maintainer will review it.');
+ break;
+ }
+ add(APPROVED);
+ break;
+ case 'remove-lgtm':
+ case 'remove-approve':
+ if (!needPullRequest(name) || !needMaintainer(name)) break;
+ remove(name === 'remove-lgtm' ? LGTM : APPROVED);
+ break;
+ case 'hold':
+ if (!needPullRequest(name) || !needParticipant(name)) break;
+ add(HOLD);
+ break;
+ case 'unhold':
+ // Anyone involved can ask to stop a merge, but only a maintainer lifts
+ // it, so a contributor can't undo a maintainer's hold.
+ if (!needPullRequest(name) || !needMaintainer(name)) break;
+ remove(HOLD);
+ break;
+ case 'retest':
+ if (!needPullRequest(name)) break;
+ if (!ctx.isMaintainer && !isAuthor) {
+ out.errors.push('`/retest` can only be used by maintainers or the author.');
+ break;
+ }
+ out.retest = true;
+ break;
+ case 'cc':
+ case 'uncc':
+ if (!needPullRequest(name) || !needParticipant(name)) break;
+ if (args.length === 0) {
+ out.errors.push(`\`/${name}\` expects one or more users, for example \`/${name} @user\`.`);
+ break;
+ }
+ (name === 'cc' ? out.reviewers : out.unreviewers).push(...args);
+ break;
+ default:
+ // Unknown commands are ignored, like Prow does, so "/path/to/file"
+ // or other text starting with a slash does not trigger a reply.
+ break;
+ }
+ }
+ return out;
+}
+
+module.exports = { plan, NEEDS_TRIAGE, HOLD };
diff --git a/hack/triage/run.js b/hack/triage/run.js
new file mode 100644
index 00000000..f8a47fe8
--- /dev/null
+++ b/hack/triage/run.js
@@ -0,0 +1,230 @@
+/*
+ * Copyright The Microcks Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// @ts-check
+
+// Entry points called from actions/github-script. They read the event,
+// ask plan() what to do and apply it through the GitHub API.
+
+const { parseCommands } = require('./parse');
+const { plan, NEEDS_TRIAGE, HOLD } = require('./plan');
+
+/**
+ * The parts of actions/github-script's arguments that are used here. The
+ * Octokit client is left untyped so no type packages need to be installed.
+ *
+ * @typedef {{ rest: any, paginate: any }} GitHub
+ * @typedef {{ repo: { owner: string, repo: string }, payload: any }} Context
+ * @typedef {{ warning: (message: string) => void }} Core
+ */
+
+const DOCS_URL = 'https://github.com/microcks/microcks-cli/blob/master/documentation/triage-commands.md';
+const HOLD_WORKFLOW = 'hold.yml';
+
+const MAINTAINER_PERMISSIONS = ['admin', 'maintain', 'write'];
+const CONTRIBUTOR_ASSOCIATIONS = ['CONTRIBUTOR', 'COLLABORATOR', 'MEMBER', 'OWNER'];
+
+/**
+ * @param {GitHub} github
+ * @param {string} owner
+ * @param {string} repo
+ * @param {string} username
+ * @returns {Promise}
+ */
+async function isMaintainer(github, owner, repo, username) {
+ try {
+ const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
+ return MAINTAINER_PERMISSIONS.includes(data.permission);
+ } catch (err) {
+ // 404 means the user is not a collaborator.
+ if (err.status === 404) return false;
+ throw err;
+ }
+}
+
+/**
+ * Applies the slash commands of an issue_comment event.
+ *
+ * @param {{ github: GitHub, context: Context, core: Core }} args
+ */
+async function run({ github, context, core }) {
+ const { comment, issue } = context.payload;
+ if (!comment || !issue || comment.user.type === 'Bot') return;
+
+ const commands = parseCommands(comment.body);
+ if (commands.length === 0) return;
+
+ const { owner, repo } = context.repo;
+ const issue_number = issue.number;
+ const commenter = comment.user.login;
+
+ const maintainer = await isMaintainer(github, owner, repo, commenter);
+ // The event payload is a snapshot from when the comment was posted; an
+ // earlier comment's run may have changed labels since, so read them again.
+ const { data: current } = await github.rest.issues.get({ owner, repo, issue_number });
+ const result = plan(commands, {
+ commenter,
+ author: issue.user && issue.user.login,
+ isMaintainer: maintainer,
+ isContributor: maintainer || CONTRIBUTOR_ASSOCIATIONS.includes(comment.author_association),
+ isPullRequest: Boolean(issue.pull_request),
+ labels: (current.labels || []).map((l) => (typeof l === 'string' ? l : l.name)),
+ closedBy: current.closed_by ? current.closed_by.login : undefined,
+ });
+
+ const failures = [...result.errors];
+ const attempt = async (what, fn) => {
+ try {
+ await fn();
+ } catch (err) {
+ core.warning(`${what}: ${err.message}`);
+ failures.push(`Could not ${what}: ${err.message}`);
+ }
+ };
+
+ for (const name of result.removeLabels) {
+ await attempt(`remove label \`${name}\``, () => github.rest.issues.removeLabel({ owner, repo, issue_number, name }));
+ }
+ if (result.addLabels.length) {
+ await attempt(`add ${result.addLabels.map((l) => `\`${l}\``).join(', ')}`, () =>
+ github.rest.issues.addLabels({ owner, repo, issue_number, labels: result.addLabels }));
+ }
+ if (result.unassign.length) {
+ await attempt('unassign', () => github.rest.issues.removeAssignees({ owner, repo, issue_number, assignees: result.unassign }));
+ }
+ if (result.assign.length) {
+ await attempt('assign', async () => {
+ const { data } = await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: result.assign });
+ const assigned = new Set((data.assignees || []).map((a) => a.login.toLowerCase()));
+ const missing = result.assign.filter((u) => !assigned.has(u.toLowerCase()));
+ if (missing.length) {
+ throw new Error(`${missing.map((u) => `@${u}`).join(', ')} cannot be assigned. They need to comment on this issue first.`);
+ }
+ });
+ }
+ if (result.unreviewers.length) {
+ await attempt('remove review requests', () =>
+ github.rest.pulls.removeRequestedReviewers({ owner, repo, pull_number: issue_number, reviewers: result.unreviewers }));
+ }
+ if (result.reviewers.length) {
+ await attempt('request reviews', () =>
+ github.rest.pulls.requestReviewers({ owner, repo, pull_number: issue_number, reviewers: result.reviewers }));
+ }
+ if (result.retest) {
+ await attempt('re-run failed checks', async () => {
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number });
+ const { data } = await github.rest.actions.listWorkflowRunsForRepo({ owner, repo, head_sha: pr.head.sha, per_page: 100 });
+ const failed = data.workflow_runs.filter((r) => ['failure', 'cancelled', 'timed_out'].includes(r.conclusion));
+ if (failed.length === 0) {
+ throw new Error('there are no failed checks on the latest commit.');
+ }
+ for (const r of failed) {
+ await github.rest.actions.reRunWorkflowFailedJobs({ owner, repo, run_id: r.id });
+ }
+ });
+ }
+ if (result.state) {
+ await attempt(result.state === 'closed' ? 'close' : 'reopen', () =>
+ github.rest.issues.update({
+ owner, repo, issue_number, state: result.state, ...(result.stateReason ? { state_reason: result.stateReason } : {}),
+ }));
+ }
+ // Labels set with GITHUB_TOKEN don't start workflows, so re-run the hold
+ // check by hand for it to see the new label.
+ if ([...result.addLabels, ...result.removeLabels].includes(HOLD)) {
+ await attempt('update the hold check', async () => {
+ const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number });
+ const { data } = await github.rest.actions.listWorkflowRuns({
+ owner, repo, workflow_id: HOLD_WORKFLOW, head_sha: pr.head.sha, per_page: 1,
+ });
+ const [latest] = data.workflow_runs;
+ if (latest) {
+ await github.rest.actions.reRunWorkflow({ owner, repo, run_id: latest.id });
+ }
+ });
+ }
+
+ if (result.milestone) {
+ const { title } = result.milestone;
+ await attempt(title === null ? 'clear the milestone' : 'set the milestone', async () => {
+ let number = null;
+ if (title !== null) {
+ const open = await github.paginate(github.rest.issues.listMilestones, { owner, repo, state: 'open', per_page: 100 });
+ const match = open.find((m) => m.title.toLowerCase() === title.toLowerCase());
+ if (!match) {
+ // The title is not echoed back, so a comment can't inject text into the reply.
+ throw new Error(`no open milestone has that title. Open milestones: ${open.map((m) => `\`${m.title}\``).join(', ') || 'none'}.`);
+ }
+ number = match.number;
+ }
+ await github.rest.issues.update({ owner, repo, issue_number, milestone: number });
+ });
+ }
+
+ const changed = result.addLabels.length || result.removeLabels.length || result.assign.length
+ || result.unassign.length || result.reviewers.length || result.unreviewers.length || result.retest || result.state
+ || result.milestone;
+ if (changed && failures.length === 0) {
+ await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content: '+1' });
+ }
+ if (failures.length) {
+ await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: comment.id, content: 'confused' });
+ const greeting = `@${commenter} thanks! Some commands were not applied`;
+ // Reply once per person and issue, so repeated commands can't make the
+ // bot spam the thread; later failures only get the reaction.
+ if (!maintainer && await alreadyReplied(github, owner, repo, issue_number, greeting)) return;
+ const lines = failures.map((f) => `- ${f}`).join('\n');
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body: `${greeting}:\n\n${lines}\n\nSee the [triage commands](${DOCS_URL}) for details.`,
+ });
+ }
+}
+
+/**
+ * @param {GitHub} github
+ * @param {string} owner
+ * @param {string} repo
+ * @param {number} issue_number
+ * @param {string} greeting start of the bot's reply to this commenter
+ * @returns {Promise}
+ */
+async function alreadyReplied(github, owner, repo, issue_number, greeting) {
+ const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 });
+ return comments.some((c) => c.user && c.user.type === 'Bot' && String(c.body).startsWith(greeting));
+}
+
+/**
+ * Adds needs-triage to newly opened issues, unless a maintainer already
+ * triaged them through a label.
+ *
+ * @param {{ github: GitHub, context: Context }} args
+ */
+async function labelNewIssue({ github, context }) {
+ const { issue } = context.payload;
+ if (!issue) return;
+ const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
+ if (labels.some((l) => l.startsWith('triage/'))) return;
+ await github.rest.issues.addLabels({
+ ...context.repo,
+ issue_number: issue.number,
+ labels: [NEEDS_TRIAGE],
+ });
+}
+
+module.exports = { run, labelNewIssue };
diff --git a/hack/triage/test/parse.test.js b/hack/triage/test/parse.test.js
new file mode 100644
index 00000000..e52dc843
--- /dev/null
+++ b/hack/triage/test/parse.test.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright The Microcks Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Run with: node --test hack/triage/test/*.test.js
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { parseCommands } = require('../parse');
+
+test('parseCommands reads commands at the start of lines', () => {
+ assert.deepEqual(parseCommands('Looks good.\n/triage accepted\n /kind bug\n/assign @dave'), [
+ { name: 'triage', args: ['accepted'] },
+ { name: 'kind', args: ['bug'] },
+ { name: 'assign', args: ['dave'] },
+ ]);
+});
+
+test('parseCommands ignores code blocks, quotes, inline slashes and paths', () => {
+ const body = [
+ '```', '/close', '```',
+ '> /kind bug',
+ 'please run `/assign`',
+ 'see cmd/test.go',
+ '/usr/local/bin/microcks',
+ ].join('\n');
+ assert.deepEqual(parseCommands(body), []);
+});
+
+test('parseCommands ignores commands hidden in HTML comments', () => {
+ assert.deepEqual(parseCommands('Nice!\n\n/kind bug\n\n