This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new d158089aa feat(ci): label PR review state via slash commands and
lifecycle (#3231)
d158089aa is described below
commit d158089aa5e9b2a0bc4d76455be180ab493a80c0
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu May 14 13:06:30 2026 +0200
feat(ci): label PR review state via slash commands and lifecycle (#3231)
---
.github/CODEOWNERS | 2 +-
.github/workflows/_detect.yml | 2 +-
.github/workflows/pr-triage.yml | 351 ++++++++++++++++++++++++++++++++++++++++
.github/workflows/pre-merge.yml | 2 +-
.github/workflows/publish.yml | 2 +-
CONTRIBUTING.md | 107 ++++++++++++
6 files changed, 462 insertions(+), 4 deletions(-)
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 35cc5645d..1720bb1f4 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1 +1 @@
-.github/** @apache/iggy-committers
+* @apache/iggy-committers
diff --git a/.github/workflows/_detect.yml b/.github/workflows/_detect.yml
index ff11fe903..41a37ecb8 100644
--- a/.github/workflows/_detect.yml
+++ b/.github/workflows/_detect.yml
@@ -91,7 +91,7 @@ jobs:
- name: Build matrices
id: mk
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
with:
script: |
const componentsJson = `${{ steps.config.outputs.components }}`;
diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml
new file mode 100644
index 000000000..2229ae791
--- /dev/null
+++ b/.github/workflows/pr-triage.yml
@@ -0,0 +1,351 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+name: PR Triage
+
+# Comment-driven PR triage and lifecycle labels. Inspired by
+# rust-lang/triagebot UX.
+#
+# Comment commands (parsed line-by-line in PR comments):
+# /request-review @u [@u2 ...] — request review from one or more
+# @users or @org/teams; the command may
+# also repeat across lines
+# /ready — flip state to S-waiting-on-review
+# /author — flip state to S-waiting-on-author
+#
+# Auth gate:
+# /request-review, /ready -> repo COLLABORATOR/OWNER, or PR author
+# /author -> repo COLLABORATOR/OWNER only
+#
+# Lifecycle (pull_request_target):
+# opened (non-draft) -> add S-waiting-on-review (only if no S-* present)
+# ready_for_review -> add S-waiting-on-review (only if no S-* present)
+# converted_to_draft -> remove both S-* labels
+# closed (merged or not) -> remove both S-* labels
+#
+# SECURITY:
+# - issue_comment.created and pull_request_target are the ONLY triggers.
+# - No actions/checkout of any ref. PR-controlled code is never executed.
+# - The default GITHUB_TOKEN is never written to outputs, env files, or
+# passed to user-supplied programs. The workflow only calls the GitHub
+# REST API via actions/github-script.
+# - pull_request_target is used so fork-PR labels can be applied with the
+# base-repo's write token. This is safe because we never run fork code:
+# no checkout, no exec of PR contents, no token export. See
+#
https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_target:
+ types: [opened, ready_for_review, converted_to_draft, closed]
+
+permissions:
+ pull-requests: write
+ issues: write
+ contents: read
+
+concurrency:
+ # cancel-in-progress: false keeps the active run, but GH still replaces
+ # any pending run with the latest arrival on burst commands. Net effect
+ # under N rapid commands: the active run plus the latest arrival land,
+ # everything queued in between is dropped. Acceptable because /ready
+ # and /author are idempotent (state is reflected in the final label)
+ # and /request-review is the only non-idempotent command; a 3-command
+ # /request-review burst in <1s lands at most 2 reviewers, larger bursts
+ # lose proportionally more. Pending-queue retention key (queue:) is not
+ # yet in the GH Actions schema; revisit when available.
+ group: pr-triage-${{ github.event.pull_request.number ||
github.event.issue.number }}
+ cancel-in-progress: false
+
+jobs:
+ triage:
+ if: |
+ (github.event_name == 'issue_comment' && github.event.issue.pull_request
!= null)
+ || github.event_name == 'pull_request_target'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Dispatch
+ uses: actions/github-script@v9
+ env:
+ COMMENT_BODY: ${{ github.event.comment.body }}
+ COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
+ COMMENT_ASSOC: ${{ github.event.comment.author_association }}
+ PR_NUMBER: ${{ github.event.pull_request.number ||
github.event.issue.number }}
+ with:
+ script: |
+ const LABEL_REVIEW = 'S-waiting-on-review';
+ const LABEL_AUTHOR = 'S-waiting-on-author';
+ const COMMITTER_ASSOCS = new Set(['COLLABORATOR', 'OWNER']);
+ const prNumber = Number(process.env.PR_NUMBER);
+
+ const removeLabelIfPresent = async (name) => {
+ try {
+ await github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ name,
+ });
+ } catch (e) {
+ if (e.status !== 404) throw e;
+ }
+ };
+
+ const setLabels = async (add, remove) => {
+ // Invariant: "neither S-* label" is recoverable (next command
+ // sets the correct one); "both S-* labels" is NOT (lifecycle
+ // hasState gate skips when any S-* is present, so a stuck
+ // both-labels state never gets cleaned up). Remove-first is
+ // strictly safer than add-first under crash mid-op.
+ //
+ // Transient 5xx on the add half would leave the PR in a
+ // label-less limbo that lifecycle hooks don't re-enter (they
+ // only fire on opened/ready_for_review). On add failure
+ // re-add the removed label to restore the prior single-label
+ // state, then surface the original error.
+ await removeLabelIfPresent(remove);
+ try {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: [add],
+ });
+ } catch (e) {
+ try {
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: [remove],
+ });
+ } catch (rollbackErr) {
+ core.warning(`setLabels rollback failed:
${rollbackErr.message}`);
+ }
+ throw e;
+ }
+ };
+
+ // ----- pull_request_target lifecycle -----
+ if (context.eventName === 'pull_request_target') {
+ const action = context.payload.action;
+ const pr = context.payload.pull_request;
+
+ core.info(`lifecycle event=${action} draft=${pr.draft}`);
+
+ if (action === 'closed' || action === 'converted_to_draft') {
+ // Always attempt the DELETE: pr.labels is the webhook
+ // snapshot and can disagree with current state via
+ // out-of-band edits or in-flight comment-handler races.
+ // removeLabelIfPresent swallows 404 so two no-op calls on
+ // a label-less PR are cheap.
+ await removeLabelIfPresent(LABEL_REVIEW);
+ await removeLabelIfPresent(LABEL_AUTHOR);
+ core.info(`lifecycle: cleared S-* labels (${action})`);
+ return;
+ }
+
+ if (action === 'opened' || action === 'ready_for_review') {
+ if (pr.draft) {
+ core.info('lifecycle: draft PR, no label');
+ return;
+ }
+ // Read live labels rather than the webhook-frozen
+ // pr.labels — a comment-driven /author or /ready may have
+ // landed between the lifecycle event and this run.
+ const live = await github.rest.issues.listLabelsOnIssue({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ per_page: 100,
+ });
+ const liveNames = live.data.map(l => l.name);
+ const hasState = liveNames.includes(LABEL_REVIEW)
+ || liveNames.includes(LABEL_AUTHOR);
+ core.info(`lifecycle: has_state=${hasState}`);
+ if (hasState) {
+ core.info('lifecycle: S-* label already present, no change');
+ return;
+ }
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: [LABEL_REVIEW],
+ });
+ core.info(`lifecycle: added ${LABEL_REVIEW}`);
+ return;
+ }
+
+ core.info(`lifecycle: unhandled action ${action}`);
+ return;
+ }
+
+ // ----- issue_comment commands -----
+ const body = process.env.COMMENT_BODY || '';
+
+ // Skip everything if no command line is present. Avoids a
+ // pulls.get on every drive-by PR comment. Leading whitespace
+ // is tolerated so that " /ready" matches the same way the
+ // line-by-line dispatch (which trims) does. The trailing
+ // `(?:\s|$)` rejects suffixed prose like `/ready-to-merge` —
+ // `\b` fires at hyphen/slash and would silently flip state.
+ const COMMAND_RE = /^[
\t]*\/(request-review|ready|author)(?:\s|$)/m;
+ if (!COMMAND_RE.test(body)) {
+ core.info('no command in body, skipping');
+ return;
+ }
+
+ const commentAuthor = process.env.COMMENT_AUTHOR;
+ const commentAssoc = process.env.COMMENT_ASSOC;
+ // Bot-suffixed accounts (e.g. dependabot[bot]) cannot drive
+ // commands as committer or PR author — would let bots
+ // self-flip review state.
+ const isBot = /\[bot\]$/.test(commentAuthor);
+ const isCommitter = COMMITTER_ASSOCS.has(commentAssoc) && !isBot;
+
+ // Live state read. payload.issue.state is the webhook-frozen
+ // value at delivery; comment-while-open + close-arriving-later
+ // would otherwise re-label a now-closed PR. Run on both
+ // committer and non-committer paths — correctness over the
+ // single API call saved on the committer fast-path.
+ let prData;
+ try {
+ prData = await github.rest.pulls.get({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber,
+ });
+ } catch (e) {
+ if (e.status === 404) {
+ core.info('PR not found (deleted or transferred), skipping');
+ return;
+ }
+ throw e;
+ }
+
+ if (prData.data.state === 'closed') {
+ core.info('PR is closed, skipping');
+ return;
+ }
+
+ const prAuthor = prData.data.user.login;
+ const isPrAuthor = commentAuthor === prAuthor && !isBot;
+
+ core.info(`comment_author=${commentAuthor} assoc=${commentAssoc} `
+
+ `committer=${isCommitter} pr_author=${isPrAuthor}
bot=${isBot}`);
+
+ const lines = body.split(/\r?\n/).map(l => l.trim());
+ let sawReassign = false;
+ let sawReady = false;
+ let sawAuthor = false;
+
+ // /request-review handles accumulate across every matching line
+ // (and every handle on a line); the requestReviewers call is
+ // deferred until after the scan so N handles cost one API call,
+ // not N. Sets dedupe handles repeated within the same comment.
+ const reviewers = new Set();
+ const teamReviewers = new Set();
+
+ // GH username: alnum + hyphen, no leading/trailing hyphen.
+ // Optional team slug: '/' then alnum + underscore + hyphen.
+ // Fully anchored so a malformed handle (empty slug @foo/, extra
+ // segment @foo/bar/baz) is rejected per-token, not silently
+ // truncated.
+ const HANDLE_RE =
/^@([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\/[A-Za-z0-9_-]+)?)$/;
+
+ for (const line of lines) {
+ // Unlike /ready and /author, /request-review may legitimately
+ // repeat across lines, so no first-match-wins guard and no
+ // early loop break.
+ const rr = line.match(/^\/request-review(?:\s+(.*))?$/);
+ if (rr) {
+ sawReassign = true;
+ if (!(isCommitter || isPrAuthor)) {
+ core.info(`/request-review: ignored, ${commentAuthor} lacks
permission`);
+ continue;
+ }
+ const rest = (rr[1] || '').trim();
+ if (!rest) {
+ core.info('/request-review: no reviewer specified');
+ continue;
+ }
+ for (const token of rest.split(/\s+/)) {
+ const hm = token.match(HANDLE_RE);
+ if (!hm) {
+ core.warning(`/request-review: ignoring malformed handle
"${token}"`);
+ continue;
+ }
+ const handle = hm[1];
+ // Team slugs go through team_reviewers as bare slug, no org.
+ if (handle.includes('/')) {
+ teamReviewers.add(handle.split('/')[1]);
+ } else {
+ reviewers.add(handle);
+ }
+ }
+ continue;
+ }
+ if (!sawReady && /^\/ready(?:\s|$)/.test(line)) {
+ sawReady = true;
+ if (!(isCommitter || isPrAuthor)) {
+ core.info(`/ready: ignored, ${commentAuthor} lacks
permission`);
+ } else {
+ await setLabels(LABEL_REVIEW, LABEL_AUTHOR);
+ core.info(`/ready: ${LABEL_REVIEW} <- ${LABEL_AUTHOR}`);
+ }
+ continue;
+ }
+ if (!sawAuthor && /^\/author(?:\s|$)/.test(line)) {
+ sawAuthor = true;
+ if (!isCommitter) {
+ core.info(`/author: ignored, ${commentAuthor} not a
committer`);
+ } else {
+ await setLabels(LABEL_AUTHOR, LABEL_REVIEW);
+ core.info(`/author: ${LABEL_AUTHOR} <- ${LABEL_REVIEW}`);
+ }
+ continue;
+ }
+ }
+
+ if (reviewers.size > 0 || teamReviewers.size > 0) {
+ const params = {
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: prNumber,
+ };
+ if (reviewers.size > 0) params.reviewers = [...reviewers];
+ if (teamReviewers.size > 0) params.team_reviewers =
[...teamReviewers];
+ const requested = [...reviewers, ...teamReviewers].join(', ');
+ try {
+ await github.rest.pulls.requestReviewers(params);
+ core.info(`/request-review: requested ${requested}`);
+ } catch (e) {
+ // GitHub rejects the whole batch on a single bad handle
+ // (non-collaborator, unknown team) with one 422. Per-handle
+ // calls would isolate the failure but multiply API calls
+ // for a rare path; the run log names the rejected set and
+ // the commenter re-posts.
+ core.warning(`/request-review: failed to request
[${requested}]: ${e.message}`);
+ }
+ }
+
+ if (!sawReassign && !sawReady && !sawAuthor) {
+ core.info('no command matched');
+ }
diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml
index 0cac3a234..1e9d5312d 100644
--- a/.github/workflows/pre-merge.yml
+++ b/.github/workflows/pre-merge.yml
@@ -199,7 +199,7 @@ jobs:
steps:
- name: Get job execution times
id: times
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
with:
script: |
const jobs = await github.rest.actions.listJobsForWorkflowRun({
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index c8a29a088..0c246145e 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -305,7 +305,7 @@ jobs:
- name: Build matrix from inputs
id: mk
- uses: actions/github-script@v8
+ uses: actions/github-script@v9
with:
script: |
const componentsB64 = '${{ steps.cfg.outputs.components_b64 }}';
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index a10060c65..0bb53a1d5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -102,6 +102,113 @@ chore(integration): remove streaming tests superseded by
API-level coverage
Keep subject under 72 chars. Use body for details if needed.
+## PR Triage Commands
+
+You can move a PR around the review queue by posting a slash command in the
+PR conversation. The pattern is similar to `rust-lang/triagebot`. The
+machinery lives in
[`./.github/workflows/pr-triage.yml`](./.github/workflows/pr-triage.yml)
+if you want to peek.
+
+### Commands
+
+| Command | Who can use it | What it does |
+| --- | --- | --- |
+| `/request-review @user-or-team ...` | the PR author or a maintainer |
Requests review from one or more `@user` or `@org/team` handles |
+| `/ready` | the PR author or a maintainer | Marks the PR
`S-waiting-on-review` |
+| `/author` | a maintainer | Marks the PR `S-waiting-on-author` |
+
+A "maintainer" here means someone with write access to apache/iggy
+(in practice, the `@apache/iggy-committers` team). Automated comments from
+bots like Dependabot do not run commands.
+
+Each command has to start its own line. Leading whitespace is fine, but
+prose like `please /ready` will not match. You can put more than one command
+in a single comment: `/request-review` plus `/ready`, or `/request-review`
+plus `/author`. `/request-review` may carry several handles on one line and
+may also repeat across lines; all of them are collected and requested
+together. For `/ready` and `/author`, the last one wins, so `/ready` then
+`/author` in the same comment ends up as `S-waiting-on-author`.
+
+### Typical flow
+
+1. You open a PR. CODEOWNERS pings `@apache/iggy-committers` automatically.
+2. A maintainer reviews. If they want changes, they comment `/author` and
+ the PR moves to your queue.
+3. You push fixes, then comment `/ready`. The PR moves back to the review
+ queue.
+4. Either side can comment `/request-review @somebody` to pull in a
+ specific person or team.
+
+To find PRs waiting on review, filter with
+`is:open is:pr label:S-waiting-on-review` on the Pulls tab.
+
+### Lifecycle automation
+
+Some labels are managed for you based on what happens to the PR:
+
+| When | What happens |
+| --- | --- |
+| You open a PR (not a draft) | Gets `S-waiting-on-review`, unless an `S-*`
label is already set |
+| You mark a draft "Ready for review" | Gets `S-waiting-on-review`, unless an
`S-*` label is already set |
+| You convert the PR back to a draft | Both `S-*` labels are removed |
+| The PR is closed or merged | Both `S-*` labels are removed |
+
+Reopening a PR does not re-label it. Drop a `/ready` or `/author` comment
+to put it back in a queue.
+
+Drafts are skipped by the automatic labelling, but `/ready` and `/author`
+still work on drafts if you want to signal intent before clicking "Ready
+for review".
+
+### Tips
+
+- **One comment per command burst.** To request several reviewers at once,
+ list them on a single `/request-review` line (or several lines) in one
+ comment. Posting separate comments back-to-back can lose the middle one
+ due to how CI runs are scheduled.
+- **Edits don't re-trigger.** If you typo a command and edit the comment, it
+ will not run. Post a new comment instead.
+- **Use the main conversation, not inline review replies.** Commands posted
+ as replies on a specific line of the diff are ignored. Post them in the
+ PR's main comment thread.
+- **Suffixes don't match.** `/ready-to-merge` or `/readyish` will not flip
+ state - only `/ready` followed by a space or end-of-line counts.
+
+### When something goes wrong
+
+The workflow never comments back. If your command didn't seem to do
+anything, open the PR's "Checks" or "Actions" tab and look at the
+`PR Triage` run for the comment you posted. The run log says exactly what
+it saw and why (no permission, unknown user, etc.).
+
+### Examples
+
+Mark your PR ready after addressing feedback:
+
+```text
+/ready
+```
+
+Ask the author to take another look:
+
+```text
+/author
+```
+
+Request a specific reviewer and mark ready in one comment:
+
+```text
+/request-review @somebody
+/ready
+```
+
+Request several reviewers in one go (do this instead of posting separate
+comments). They can share one line, span multiple lines, or both:
+
+```text
+/request-review @alice @bob @apache/iggy-committers
+```
+
## Close Policy
PRs may be closed if: