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 5e98ec900 ci: make PR triage react to reviews and survive transient 
errors (#3260)
5e98ec900 is described below

commit 5e98ec900d97a560bceb277ac3129681d59a5459
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Thu May 14 16:42:22 2026 +0200

    ci: make PR triage react to reviews and survive transient errors (#3260)
---
 .github/workflows/pr-triage.yml | 239 ++++++++++++++++++++++++++++++++--------
 CONTRIBUTING.md                 |  25 ++++-
 2 files changed, 209 insertions(+), 55 deletions(-)

diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml
index 309f438f8..d87d0431b 100644
--- a/.github/workflows/pr-triage.yml
+++ b/.github/workflows/pr-triage.yml
@@ -20,7 +20,7 @@ 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):
+# Comment commands (parsed line-by-line in PR comments and review bodies):
 #   /request-review @u [@u2 ...]   — request review from one or more
 #                                    @users or @org/teams; the command may
 #                                    also repeat across lines
@@ -31,6 +31,17 @@ name: PR Triage
 #   /request-review, /ready  -> org MEMBER / repo COLLABORATOR/OWNER, or PR 
author
 #   /author                  -> org MEMBER / repo COLLABORATOR/OWNER only
 #
+# Review state (pull_request_review.submitted):
+#   A changes_requested review by a committer acts as an implicit
+#   /author. An explicit command in the review body overrides it.
+#
+# Feedback:
+#   On issue_comment commands the workflow reacts on the comment: +1 when
+#   something was applied, "confused" when a recognized command was
+#   rejected for lack of permission. A failed /request-review posts a
+#   one-line reply naming the rejected handles. Review-triggered actions
+#   have no comment to react to and stay log-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)
@@ -38,19 +49,24 @@ name: PR Triage
 #   closed (merged or not) -> remove both S-* labels
 #
 # SECURITY:
-# - issue_comment.created and pull_request_target are the ONLY triggers.
+# - issue_comment.created, pull_request_review.submitted 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
+# - pull_request_target and pull_request_review run the base-repo workflow
+#   with a write token so fork-PR labels can be applied. This is safe
+#   because we never run fork code: no checkout, no exec of PR contents,
+#   no token export. The review body is treated as untrusted text, parsed
+#   only by the command regex. See
 #   
https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/
 
 on:
   issue_comment:
     types: [created]
+  pull_request_review:
+    types: [submitted]
   pull_request_target:
     types: [opened, ready_for_review, converted_to_draft, closed]
 
@@ -76,6 +92,7 @@ jobs:
   triage:
     if: |
       (github.event_name == 'issue_comment' && github.event.issue.pull_request 
!= null)
+      || github.event_name == 'pull_request_review'
       || github.event_name == 'pull_request_target'
     runs-on: ubuntu-latest
     timeout-minutes: 5
@@ -83,9 +100,10 @@ jobs:
       - 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 }}
+          COMMENT_BODY: ${{ github.event.comment.body || 
github.event.review.body }}
+          COMMENT_AUTHOR: ${{ github.event.comment.user.login || 
github.event.review.user.login }}
+          COMMENT_ASSOC: ${{ github.event.comment.author_association || 
github.event.review.author_association }}
+          COMMENT_ID: ${{ github.event.comment.id }}
           PR_NUMBER: ${{ github.event.pull_request.number || 
github.event.issue.number }}
         with:
           script: |
@@ -94,16 +112,47 @@ jobs:
             const COMMITTER_ASSOCS = new Set(['MEMBER', 'COLLABORATOR', 
'OWNER']);
             const prNumber = Number(process.env.PR_NUMBER);
 
+            // Retry transient GitHub API failures so a single 502 cannot
+            // abort a command. This matters most inside setLabels: a
+            // remove that throws skips the paired add, and an add that
+            // throws after the remove leaves the PR label-less — both
+            // are states the lifecycle hooks never re-enter. Transient =
+            // 5xx, 429, or a network error with no HTTP status. Anything
+            // else (404, 403, 422) is not retried and surfaces to the
+            // caller unchanged.
+            const withRetry = async (fn, label) => {
+              const delays = [500, 1500, 4000];
+              for (let attempt = 0; ; attempt++) {
+                try {
+                  return await fn();
+                } catch (e) {
+                  const s = e.status;
+                  const transient = s === undefined || s === 429
+                                 || (s >= 500 && s < 600);
+                  if (!transient || attempt >= delays.length) throw e;
+                  core.warning(`${label}: transient failure ` +
+                    `(${s ?? e.code ?? 'network'}), retry ` +
+                    `${attempt + 1}/${delays.length} in ${delays[attempt]}ms`);
+                  await new Promise(r => setTimeout(r, delays[attempt]));
+                }
+              }
+            };
+
+            // Returns true if a label was actually deleted, false if the
+            // label was already absent (404). Callers use this to decide
+            // whether a rollback re-add is warranted.
             const removeLabelIfPresent = async (name) => {
               try {
-                await github.rest.issues.removeLabel({
+                await withRetry(() => github.rest.issues.removeLabel({
                   owner: context.repo.owner,
                   repo: context.repo.repo,
                   issue_number: prNumber,
                   name,
-                });
+                }), `removeLabel ${name}`);
+                return true;
               } catch (e) {
                 if (e.status !== 404) throw e;
+                return false;
               }
             };
 
@@ -114,34 +163,70 @@ jobs:
               // 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);
+              // Both calls go through withRetry, so only a non-transient
+              // error or an outage outlasting the retry budget reaches
+              // these catch blocks. On add failure re-add the removed
+              // label to restore the prior single-label state -- but only
+              // when the remove actually deleted one. If `remove` was
+              // already absent (PR had neither S-* label), re-adding it
+              // would fabricate a label the PR never carried.
+              const removed = await removeLabelIfPresent(remove);
               try {
-                await github.rest.issues.addLabels({
+                await withRetry(() => github.rest.issues.addLabels({
                   owner: context.repo.owner,
                   repo: context.repo.repo,
                   issue_number: prNumber,
                   labels: [add],
-                });
+                }), `addLabels ${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}`);
+                if (removed) {
+                  try {
+                    await withRetry(() => github.rest.issues.addLabels({
+                      owner: context.repo.owner,
+                      repo: context.repo.repo,
+                      issue_number: prNumber,
+                      labels: [remove],
+                    }), `addLabels rollback ${remove}`);
+                  } catch (rollbackErr) {
+                    core.warning(`setLabels rollback failed: 
${rollbackErr.message}`);
+                  }
                 }
                 throw e;
               }
             };
 
+            // Best-effort commenter feedback. A failed reaction or reply
+            // must never fail the run, so each swallows its own errors.
+            // Reactions exist only for issue comments — review-triggered
+            // commands have no comment to react to, so commentId is null
+            // and react() is a no-op.
+            const commentId = Number(process.env.COMMENT_ID) || null;
+            const react = async (content) => {
+              if (!commentId) return;
+              try {
+                await withRetry(() => 
github.rest.reactions.createForIssueComment({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  comment_id: commentId,
+                  content,
+                }), `react ${content}`);
+              } catch (e) {
+                core.warning(`reaction ${content} failed: ${e.message}`);
+              }
+            };
+            const reply = async (text) => {
+              try {
+                await withRetry(() => github.rest.issues.createComment({
+                  owner: context.repo.owner,
+                  repo: context.repo.repo,
+                  issue_number: prNumber,
+                  body: text,
+                }), 'createComment');
+              } catch (e) {
+                core.warning(`reply failed: ${e.message}`);
+              }
+            };
+
             // ----- pull_request_target lifecycle -----
             if (context.eventName === 'pull_request_target') {
               const action = context.payload.action;
@@ -169,12 +254,12 @@ jobs:
                 // 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({
+                const live = await withRetry(() => 
github.rest.issues.listLabelsOnIssue({
                   owner: context.repo.owner,
                   repo: context.repo.repo,
                   issue_number: prNumber,
                   per_page: 100,
-                });
+                }), 'listLabelsOnIssue');
                 const liveNames = live.data.map(l => l.name);
                 const hasState = liveNames.includes(LABEL_REVIEW)
                               || liveNames.includes(LABEL_AUTHOR);
@@ -183,12 +268,12 @@ jobs:
                   core.info('lifecycle: S-* label already present, no change');
                   return;
                 }
-                await github.rest.issues.addLabels({
+                await withRetry(() => github.rest.issues.addLabels({
                   owner: context.repo.owner,
                   repo: context.repo.repo,
                   issue_number: prNumber,
                   labels: [LABEL_REVIEW],
-                });
+                }), `lifecycle addLabels ${LABEL_REVIEW}`);
                 core.info(`lifecycle: added ${LABEL_REVIEW}`);
                 return;
               }
@@ -197,18 +282,31 @@ jobs:
               return;
             }
 
-            // ----- issue_comment commands -----
+            // ----- issue_comment / pull_request_review commands -----
+            // Both event types feed the same path: COMMENT_BODY/_AUTHOR/
+            // _ASSOC resolve from github.event.comment or .review in the
+            // step env.
             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.
+            // A changes_requested review is an implicit /author: the ball
+            // is back with the author. Same committer-only gate as the
+            // /author command (checked below, once isCommitter is known).
+            // An explicit command in the review body still takes
+            // precedence. payload.review is absent for issue_comment.
+            const reviewState = context.payload.review?.state;
+            const reviewWantsAuthor = reviewState === 'changes_requested';
+
+            // Skip everything if there is neither a command line nor an
+            // actionable review state. Avoids a pulls.get on every
+            // drive-by comment and every approve/comment-only review.
+            // 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');
+            if (!COMMAND_RE.test(body) && !reviewWantsAuthor) {
+              core.info('no command in body and no actionable review state, 
skipping');
               return;
             }
 
@@ -227,11 +325,11 @@ jobs:
             // single API call saved on the committer fast-path.
             let prData;
             try {
-              prData = await github.rest.pulls.get({
+              prData = await withRetry(() => github.rest.pulls.get({
                 owner: context.repo.owner,
                 repo: context.repo.repo,
                 pull_number: prNumber,
-              });
+              }), 'pulls.get');
             } catch (e) {
               if (e.status === 404) {
                 core.info('PR not found (deleted or transferred), skipping');
@@ -255,6 +353,11 @@ jobs:
             let sawReassign = false;
             let sawReady = false;
             let sawAuthor = false;
+            // Outcome tracking for commenter feedback (see react() above).
+            // applied: at least one command took effect. denied: at least
+            // one recognized command was rejected for lack of permission.
+            let applied = false;
+            let denied = false;
 
             // /request-review handles accumulate across every matching line
             // (and every handle on a line); the requestReviewers call is
@@ -279,6 +382,7 @@ jobs:
                 sawReassign = true;
                 if (!(isCommitter || isPrAuthor)) {
                   core.info(`/request-review: ignored, ${commentAuthor} lacks 
permission`);
+                  denied = true;
                   continue;
                 }
                 const rest = (rr[1] || '').trim();
@@ -306,9 +410,11 @@ jobs:
                 sawReady = true;
                 if (!(isCommitter || isPrAuthor)) {
                   core.info(`/ready: ignored, ${commentAuthor} lacks 
permission`);
+                  denied = true;
                 } else {
                   await setLabels(LABEL_REVIEW, LABEL_AUTHOR);
                   core.info(`/ready: ${LABEL_REVIEW} <- ${LABEL_AUTHOR}`);
+                  applied = true;
                 }
                 continue;
               }
@@ -316,9 +422,11 @@ jobs:
                 sawAuthor = true;
                 if (!isCommitter) {
                   core.info(`/author: ignored, ${commentAuthor} not a 
committer`);
+                  denied = true;
                 } else {
                   await setLabels(LABEL_AUTHOR, LABEL_REVIEW);
                   core.info(`/author: ${LABEL_AUTHOR} <- ${LABEL_REVIEW}`);
+                  applied = true;
                 }
                 continue;
               }
@@ -334,18 +442,51 @@ jobs:
               if (teamReviewers.size > 0) params.team_reviewers = 
[...teamReviewers];
               const requested = [...reviewers, ...teamReviewers].join(', ');
               try {
-                await github.rest.pulls.requestReviewers(params);
+                await withRetry(
+                  () => github.rest.pulls.requestReviewers(params),
+                  'requestReviewers',
+                );
                 core.info(`/request-review: requested ${requested}`);
+                applied = true;
               } 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.
+                // Transient 5xx is retried by withRetry before reaching
+                // here. What lands in this catch is a non-transient
+                // failure — typically the 422 GitHub returns for the
+                // whole batch on a single bad handle (non-collaborator,
+                // unknown team). Surface it to the commenter; the run log
+                // also names the rejected set.
                 core.warning(`/request-review: failed to request 
[${requested}]: ${e.message}`);
+                const why = e.status === 422
+                  ? 'a handle is not a repo collaborator, or the team is 
unknown'
+                  : `GitHub API error (${e.status ?? 'network'})`;
+                await reply(`\`/request-review\`: could not request 
${requested} — ${why}.`);
               }
             }
 
-            if (!sawReassign && !sawReady && !sawAuthor) {
+            // Implicit /author from a changes_requested review. Skipped
+            // when the review body already carried an explicit /ready or
+            // /author — the command wins. Same committer-only gate as the
+            // /author command.
+            if (reviewWantsAuthor && !sawReady && !sawAuthor) {
+              if (!isCommitter) {
+                core.info(`review changes_requested: ignored, ${commentAuthor} 
not a committer`);
+              } else {
+                await setLabels(LABEL_AUTHOR, LABEL_REVIEW);
+                core.info(`review changes_requested: ${LABEL_AUTHOR} <- 
${LABEL_REVIEW}`);
+                applied = true;
+              }
+            }
+
+            if (!sawReassign && !sawReady && !sawAuthor && !reviewWantsAuthor) 
{
               core.info('no command matched');
             }
+
+            // Commenter feedback. denied wins over applied: if any part of
+            // the comment was rejected the commenter should see it, even
+            // when another command in the same comment worked. react() is
+            // a no-op for review-triggered runs (no comment to react to).
+            if (denied) {
+              await react('confused');
+            } else if (applied) {
+              await react('+1');
+            }
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0bb53a1d5..6e8b4336e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -132,8 +132,8 @@ together. For `/ready` and `/author`, the last one wins, so 
`/ready` then
 ### 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.
+2. A maintainer reviews. Submitting a "Request changes" review moves the
+   PR to your queue automatically; they can also comment `/author`.
 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
@@ -160,6 +160,13 @@ 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".
 
+### Review state
+
+Submitting a review with "Request changes" is treated as an implicit
+`/author`: the PR moves to `S-waiting-on-author`. Only maintainers can
+trigger this, the same as the `/author` command. If your review body also
+contains an explicit `/ready` or `/author`, that command wins.
+
 ### Tips
 
 - **One comment per command burst.** To request several reviewers at once,
@@ -176,10 +183,16 @@ for review".
 
 ### 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.).
+The workflow reacts on your comment so you get quick feedback: a 👍 means
+a command was applied, a 😕 means a command was recognized but you lacked
+permission to run it. A failed `/request-review` also posts a one-line
+reply naming the handles GitHub rejected. Commands posted in a review body
+(rather than a normal comment) cannot be reacted to, so they stay
+log-only.
+
+For the full story, 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
 

Reply via email to