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 63d3273b7 feat(ci): add /pin and /unpin triage commands (#4060)
63d3273b7 is described below
commit 63d3273b714c168102dde52b88fe3fc50edd73b9
Author: Maxim Levkov <[email protected]>
AuthorDate: Fri Sep 4 16:29:34 2026 -0700
feat(ci): add /pin and /unpin triage commands (#4060)
---
.github/workflows/pr-triage-apply.yml | 106 +++++++++++++++++++++++++++++++++-
CONTRIBUTING.md | 2 +
2 files changed, 106 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/pr-triage-apply.yml
b/.github/workflows/pr-triage-apply.yml
index 3c5e7e6b1..d5840f4f3 100644
--- a/.github/workflows/pr-triage-apply.yml
+++ b/.github/workflows/pr-triage-apply.yml
@@ -189,6 +189,10 @@ jobs:
script: |
const LABEL_REVIEW = 'S-waiting-on-review';
const LABEL_AUTHOR = 'S-waiting-on-author';
+ // Orthogonal to the S-* pair rather than a third state: `pinned`
+ // is the only label stale-prs.yml exempts, so it is added and
+ // removed on its own and never goes through replaceStateLabel.
+ const LABEL_PINNED = 'pinned';
const COMMITTER_ASSOCS = new Set(['MEMBER', 'COLLABORATOR',
'OWNER']);
// Wider gate for the implicit changes_requested -> author
// flip: a formal "Request changes" review is a deliberate,
@@ -228,6 +232,7 @@ jobs:
'- `/ready` - back to `S-waiting-on-review` after addressing
feedback',
'- `/author` - flip to `S-waiting-on-author` while you finish
changes',
'- `/request-review @user-or-team` - request a reviewer',
+ '- `/pin` - exempt the PR from the stale bot, `/unpin` to undo',
'',
'See
[CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md#pr-triage-commands)
for details.',
].join('\n');
@@ -300,6 +305,72 @@ jobs:
}), `setLabels ${add ?? '(clear S-*)'}`);
};
+ // Create the label when absent rather than assume it exists.
+ // stale-prs.yml names `pinned` as its only exemption, but no
+ // repo config guarantees the label itself was ever created, and
+ // an exemption naming a label nothing can carry is inert.
+ // Whether the labels API materializes an unknown name is
+ // undocumented, so do it explicitly. Needs `issues: write`.
+ const ensurePinnedLabel = async () => {
+ try {
+ await withRetry(() => github.rest.issues.getLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: LABEL_PINNED,
+ }), `getLabel ${LABEL_PINNED}`);
+ return true;
+ } catch (e) {
+ if (e.status !== 404) {
+ core.warning(`${LABEL_PINNED}: lookup failed: ${e.message}`);
+ return false;
+ }
+ }
+ try {
+ await withRetry(() => github.rest.issues.createLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ name: LABEL_PINNED,
+ color: 'd4c5f9',
+ description: 'Exempt from the stale bot',
+ }), `createLabel ${LABEL_PINNED}`);
+ core.info(`created label ${LABEL_PINNED}`);
+ return true;
+ } catch (e) {
+ // 422 means a concurrent run created it between our get and
create.
+ if (e.status === 422) return true;
+ core.warning(`${LABEL_PINNED}: create failed: ${e.message}`);
+ return false;
+ }
+ };
+
+ // Add-only / remove-only, so unlike replaceStateLabel there is no
+ // list-then-PUT window an unrelated label change can be lost in.
+ const setPinned = async (on) => {
+ if (on) {
+ if (!await ensurePinnedLabel()) return false;
+ await withRetry(() => github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ labels: [LABEL_PINNED],
+ }), `addLabels ${LABEL_PINNED}`);
+ return true;
+ }
+ try {
+ await withRetry(() => github.rest.issues.removeLabel({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: prNumber,
+ name: LABEL_PINNED,
+ }), `removeLabel ${LABEL_PINNED}`);
+ } catch (e) {
+ // 404 is the PR not carrying the label, or the label not
existing
+ // at all. Both mean it is already in the state /unpin asks
for.
+ if (e.status !== 404) throw e;
+ }
+ return true;
+ };
+
// 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
@@ -504,7 +575,7 @@ jobs:
// 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;
+ const COMMAND_RE = /^[
\t]*\/(request-review|ready|author|unpin|pin)(?:\s|$)/m;
if (!COMMAND_RE.test(body) && !reviewWantsAuthor) {
core.info('no command in body and no actionable review state,
skipping');
return;
@@ -569,6 +640,8 @@ jobs:
let sawReassign = false;
let sawReady = false;
let sawAuthor = false;
+ let sawPin = false;
+ let sawUnpin = 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.
@@ -650,6 +723,34 @@ jobs:
}
continue;
}
+ // Same gate as /ready and /request-review: the PR author or a
+ // committer. Exempting a PR from the stale bot is reversible and
+ // visible as a label, so it does not need the wider /author
gate.
+ if (!sawPin && /^\/pin(?:\s|$)/.test(line)) {
+ sawPin = true;
+ if (!(isCommitter || isPrAuthor)) {
+ core.info(`/pin: ignored, ${commentAuthor} lacks
permission`);
+ denied = true;
+ } else if (await setPinned(true)) {
+ core.info(`/pin: added ${LABEL_PINNED}`);
+ applied = true;
+ } else {
+ await reply('`/pin`: could not apply the `pinned` label.');
+ }
+ continue;
+ }
+ if (!sawUnpin && /^\/unpin(?:\s|$)/.test(line)) {
+ sawUnpin = true;
+ if (!(isCommitter || isPrAuthor)) {
+ core.info(`/unpin: ignored, ${commentAuthor} lacks
permission`);
+ denied = true;
+ } else {
+ await setPinned(false);
+ core.info(`/unpin: removed ${LABEL_PINNED}`);
+ applied = true;
+ }
+ continue;
+ }
}
if (reviewers.size > 0 || teamReviewers.size > 0) {
@@ -699,7 +800,8 @@ jobs:
}
}
- if (!sawReassign && !sawReady && !sawAuthor && !reviewWantsAuthor)
{
+ if (!sawReassign && !sawReady && !sawAuthor && !sawPin && !sawUnpin
+ && !reviewWantsAuthor) {
core.info('no command matched');
}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f4540816f..70eaea20c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -146,6 +146,8 @@ line in a regular PR comment (not an inline review reply):
| `/ready` | author or maintainer |
mark `S-waiting-on-review` |
| `/author` | maintainer or returning contributor |
mark `S-waiting-on-author` |
| `/request-review @user-or-team ...` | author or maintainer |
request review from the listed `@user` / `@org/team` handles |
+| `/pin` | author or maintainer |
add `pinned`, exempting the PR from the stale bot |
+| `/unpin` | author or maintainer |
remove `pinned` |
Some labels move on their own: opening or marking a non-draft PR ready sets
`S-waiting-on-review`; a "Request changes" review sets `S-waiting-on-author`;