From 2c9eff69c6a8e146c81240f2ad2285d8c2ba4094 Mon Sep 17 00:00:00 2001 From: Ivan Lepekha Date: Wed, 19 Aug 2026 01:00:03 +0300 Subject: [PATCH 1/9] feat(github): revival of triage tools --- .github/TRIAGE_TOOLS.md | 23 ++++ .github/workflows/triage-tools.yaml | 201 ++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 .github/TRIAGE_TOOLS.md create mode 100644 .github/workflows/triage-tools.yaml diff --git a/.github/TRIAGE_TOOLS.md b/.github/TRIAGE_TOOLS.md new file mode 100644 index 0000000000..26384f8e7d --- /dev/null +++ b/.github/TRIAGE_TOOLS.md @@ -0,0 +1,23 @@ +# Ability to triage issues and PRs + +Trusted members of the repository can use this workflow to triage issues and PRs by writing next commands: +- `/triage` - adds the `needs-triage` label to the issue or PR. That should be used when user is unsure about adding a feature or closing the issue. +- `/triageoff` - removes the `needs-triage` label from the issue or PR. Label should be present +- `/np wontfix` - closes the issue with not planned status and tagging with 'wontfix' label. +- `/np notrelated` - closes the issue with not planned status and tagging with 'not related' label. Used in cases when the issue is not related to the project. +- `/np ` - closes the issue with not planned status. +- `/duplicate ` - closes the issue with duplicate status. GH API is bugged, so it won't add issue number to status, but GH Actions will leave a comment. + +Any of this command will delete your message leave a log by GH Actions to avoid any possible sabotage. + +# Notes for future + +- No, custom labels won't be supported due to maintaining clean structure of project. +- To become able to use this workflow, you should be a trusted member and included directly by owner of project or by PR from other trusted member. +- Any abuse of this workflow can and will lead to revoking of your access to this workflow and/or banning from the project. +- Note for Chris: I wanted to make something similar for discussions, but GH API has no docs for it, so result is next - triage for both issues and PRs and ability to close stupid issues from people who can't read. + +# Known bugs + +- I'm too lazy to make a proper check for command being at the start of the message, so if you write something like "I think this is a duplicate /duplicate 123" it will still work and your message will be deleted. So please write your comment and command in separate messages. If someone fixes this, delete this note. +- Writing "/np" without any word won't work because of regex, so please write "/np " instead of just "/np". If someone fixes this, delete this note. \ No newline at end of file diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml new file mode 100644 index 0000000000..ee83aee4c3 --- /dev/null +++ b/.github/workflows/triage-tools.yaml @@ -0,0 +1,201 @@ +name: Repo Contributors' Triage Tools + +on: + issue_comment: + types: [created, edited] + +jobs: + triage-tools: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + contents: read + steps: + - name: Process message + uses: actions/github-script@v9 + with: + script: | + const trustedUsers = ["ChrisTitusTech", "FluffyPunk", "FallenGME", "mewclouds", "MyDrift-user", "Real-MullaC", "CodingWonders", "og-mrk"]; + const commentAuthor = context.payload.comment.user.login; + + if (!trustedUsers.includes(commentAuthor)) { + console.log(`Comment author ${commentAuthor} is not a trusted user. Exiting.`); + return; + } + + const eventType = context.payload.pull_request ? "pull request" : "issue"; + + const comment = context.payload.comment; + const {owner, repo} = context.issue; + const issueNumber = context.issue.number; + + const triageMatch = comment.body.match(/\/triage\b/); + if (triageMatch) { + const issue = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber + }); + if (issue.data.labels.some(label => label.name === "needs-triage")) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The ${eventType} already has the 'needs-triage' label. For removing label, do /triageoff.` + }); + } else { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ["needs-triage"] + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor} has asked maintainer for a triage on this ${eventType}.` + }); + } + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + const triageOffMatch = comment.body.match(/\/triageoff\b/); + if (triageOffMatch) { + const issue = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber + }); + if (issue.data.labels.some(label => label.name === "needs-triage")) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: "needs-triage" + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor} removed triage request from this issue.` + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The issue is not requested for triage.` + }); + } + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + const notPlannedMatch = comment.body.match(/\/np\s+(\w+)/); + if (notPlannedMatch && eventType === "issue") { + switch (notPlannedMatch[1]) { + case "wontfix": + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "not_planned", + labels: ["wontfix"] + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor} marked this ${eventType} as not fixable` + }); + break; + case "notrelated": + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "not_planned", + labels: ["not-related"] + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor} marked this ${eventType} as not related to the project` + }); + break; + default: + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "not_planned" + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor} marked this ${eventType} as not planned.` + }); + break; + }; + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } + const duplicateMatch = comment.body.match(/\/duplicate\s+(\S+)/); + if (duplicateMatch && eventType === "issue") { + const duplicateIssueNumber = parseInt(duplicateMatch[1].replace("#", "")); + let duplicateIssue; + try { + const response = await github.rest.issues.get({ + owner, + repo, + issue_number: duplicateIssueNumber + }); + duplicateIssue = response; + } catch (error) { + duplicateIssue = null; + } + if (duplicateIssue) { + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "duplicate", + duplicate_issue_id: duplicateIssue.id + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor} marked this ${eventType} as a duplicate of #${duplicateIssueNumber}.` + }); + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `The issue #${duplicateIssueNumber} does not exist.` + }); + return; + } + } \ No newline at end of file From 75bf1420fcc312721ed3e5dc1a9d75a982727ebb Mon Sep 17 00:00:00 2001 From: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:09:02 +0300 Subject: [PATCH 2/9] fix(github): workflow doc fix --- .github/TRIAGE_TOOLS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/TRIAGE_TOOLS.md b/.github/TRIAGE_TOOLS.md index 26384f8e7d..681f8d4f0d 100644 --- a/.github/TRIAGE_TOOLS.md +++ b/.github/TRIAGE_TOOLS.md @@ -20,4 +20,4 @@ Any of this command will delete your message leave a log by GH Actions to avoid # Known bugs - I'm too lazy to make a proper check for command being at the start of the message, so if you write something like "I think this is a duplicate /duplicate 123" it will still work and your message will be deleted. So please write your comment and command in separate messages. If someone fixes this, delete this note. -- Writing "/np" without any word won't work because of regex, so please write "/np " instead of just "/np". If someone fixes this, delete this note. \ No newline at end of file +- Writing "/np" without any word won't work because of regex, so please write `/np ` instead of just "/np". If someone fixes this, delete this note. From 68e802096db097b843c9a4b44dada4bf6e0fada4 Mon Sep 17 00:00:00 2001 From: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:31:34 +0300 Subject: [PATCH 3/9] fix(github): PR detection --- .github/workflows/triage-tools.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml index ee83aee4c3..fbfcecf154 100644 --- a/.github/workflows/triage-tools.yaml +++ b/.github/workflows/triage-tools.yaml @@ -24,7 +24,7 @@ jobs: return; } - const eventType = context.payload.pull_request ? "pull request" : "issue"; + const eventType = context.payload.issue.pull_request ? "pull request" : "issue"; const comment = context.payload.comment; const {owner, repo} = context.issue; @@ -82,14 +82,14 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} removed triage request from this issue.` + body: `${commentAuthor} removed triage request from this ${eventType}.` }); } else { await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, - body: `The issue is not requested for triage.` + body: `The ${eventType} is not requested for triage.` }); } await github.rest.issues.deleteComment({ @@ -198,4 +198,4 @@ jobs: }); return; } - } \ No newline at end of file + } From 42bf699a52f659986ade4107ec171d3249431946 Mon Sep 17 00:00:00 2001 From: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:33:09 +0300 Subject: [PATCH 4/9] fix(gh): wontfix preserve bug label Removed state_reason from issue update when adding wontfix label. --- .github/workflows/triage-tools.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml index fbfcecf154..efc30d1298 100644 --- a/.github/workflows/triage-tools.yaml +++ b/.github/workflows/triage-tools.yaml @@ -102,13 +102,18 @@ jobs: if (notPlannedMatch && eventType === "issue") { switch (notPlannedMatch[1]) { case "wontfix": + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ["wontfix"] + }); await github.rest.issues.update({ owner, repo, issue_number: issueNumber, state: "closed", - state_reason: "not_planned", - labels: ["wontfix"] + state_reason: "not_planned" }); await github.rest.issues.createComment({ owner, From 7fb12c515ab20401c08365fd7455d482cd57cff4 Mon Sep 17 00:00:00 2001 From: Ivan Lepekha Date: Wed, 19 Aug 2026 01:39:46 +0300 Subject: [PATCH 5/9] fix(gh): doc cleaning --- .github/TRIAGE_TOOLS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/TRIAGE_TOOLS.md b/.github/TRIAGE_TOOLS.md index 681f8d4f0d..b4efc67c42 100644 --- a/.github/TRIAGE_TOOLS.md +++ b/.github/TRIAGE_TOOLS.md @@ -6,18 +6,18 @@ Trusted members of the repository can use this workflow to triage issues and PRs - `/np wontfix` - closes the issue with not planned status and tagging with 'wontfix' label. - `/np notrelated` - closes the issue with not planned status and tagging with 'not related' label. Used in cases when the issue is not related to the project. - `/np ` - closes the issue with not planned status. -- `/duplicate ` - closes the issue with duplicate status. GH API is bugged, so it won't add issue number to status, but GH Actions will leave a comment. +- `/duplicate ` - closes the issue with duplicate status. GH API is bugged and sometimes it adds link to status, sometimes not. -Any of this command will delete your message leave a log by GH Actions to avoid any possible sabotage. +Issuing a command will delete your message leave a log by GH Actions to avoid any possible sabotage. # Notes for future - No, custom labels won't be supported due to maintaining clean structure of project. -- To become able to use this workflow, you should be a trusted member and included directly by owner of project or by PR from other trusted member. +- To become able to use this workflow, you should be included directly by owner of project or by PR from other trusted member. - Any abuse of this workflow can and will lead to revoking of your access to this workflow and/or banning from the project. - Note for Chris: I wanted to make something similar for discussions, but GH API has no docs for it, so result is next - triage for both issues and PRs and ability to close stupid issues from people who can't read. # Known bugs - I'm too lazy to make a proper check for command being at the start of the message, so if you write something like "I think this is a duplicate /duplicate 123" it will still work and your message will be deleted. So please write your comment and command in separate messages. If someone fixes this, delete this note. -- Writing "/np" without any word won't work because of regex, so please write `/np ` instead of just "/np". If someone fixes this, delete this note. +- Writing "/np" without any word won't work because of regex, so please write `/np ` instead of just "/np". If someone fixes this, delete this note. \ No newline at end of file From 2c4f1ed46ca9733da0b770941b4e2f6a972e0ec4 Mon Sep 17 00:00:00 2001 From: Ivan Lepekha Date: Wed, 19 Aug 2026 01:42:32 +0300 Subject: [PATCH 6/9] one more thing --- .github/TRIAGE_TOOLS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/TRIAGE_TOOLS.md b/.github/TRIAGE_TOOLS.md index b4efc67c42..9951636190 100644 --- a/.github/TRIAGE_TOOLS.md +++ b/.github/TRIAGE_TOOLS.md @@ -20,4 +20,5 @@ Issuing a command will delete your message leave a log by GH Actions to avoid an # Known bugs - I'm too lazy to make a proper check for command being at the start of the message, so if you write something like "I think this is a duplicate /duplicate 123" it will still work and your message will be deleted. So please write your comment and command in separate messages. If someone fixes this, delete this note. -- Writing "/np" without any word won't work because of regex, so please write `/np ` instead of just "/np". If someone fixes this, delete this note. \ No newline at end of file +- Writing "/np" without any word won't work because of regex, so please write `/np ` instead of just "/np". If someone fixes this, delete this note. +- Writing two commands in one message will be unpredictable. Don't be a lazy head. Write separate comments. \ No newline at end of file From 5dd352e7388fe011f1643662f1040f3b1af0e19e Mon Sep 17 00:00:00 2001 From: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:04:15 +0300 Subject: [PATCH 7/9] fix(gh): prevent Gabi moment That's the only thing I will agree with this piece of hardware --- .github/workflows/triage-tools.yaml | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml index efc30d1298..bf8735dc03 100644 --- a/.github/workflows/triage-tools.yaml +++ b/.github/workflows/triage-tools.yaml @@ -16,11 +16,20 @@ jobs: uses: actions/github-script@v9 with: script: | - const trustedUsers = ["ChrisTitusTech", "FluffyPunk", "FallenGME", "mewclouds", "MyDrift-user", "Real-MullaC", "CodingWonders", "og-mrk"]; - const commentAuthor = context.payload.comment.user.login; + const trustedUsers = [ + 7896101, // ChrisTitusTech + 57459428, // FluffyPunk + 125669256", // FallenGME + 90123670, // mewclouds + 121827219, // MyDrift-user + 17331812, // Callum + 101426328, // CodingWonders + 70659536 // og-mrk + ]; + const commentAuthor = context.payload.comment.user; - if (!trustedUsers.includes(commentAuthor)) { - console.log(`Comment author ${commentAuthor} is not a trusted user. Exiting.`); + if (!trustedUsers.includes(commentAuthor.id)) { + console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`); return; } @@ -55,7 +64,7 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} has asked maintainer for a triage on this ${eventType}.` + body: `${commentAuthor.login} has asked maintainer for a triage on this ${eventType}.` }); } await github.rest.issues.deleteComment({ @@ -82,7 +91,7 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} removed triage request from this ${eventType}.` + body: `${commentAuthor.login} removed triage request from this ${eventType}.` }); } else { await github.rest.issues.createComment({ @@ -119,7 +128,7 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} marked this ${eventType} as not fixable` + body: `${commentAuthor.login} marked this ${eventType} as not fixable` }); break; case "notrelated": @@ -135,7 +144,7 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} marked this ${eventType} as not related to the project` + body: `${commentAuthor.login} marked this ${eventType} as not related to the project` }); break; default: @@ -150,7 +159,7 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} marked this ${eventType} as not planned.` + body: `${commentAuthor.login} marked this ${eventType} as not planned.` }); break; }; @@ -187,7 +196,7 @@ jobs: owner, repo, issue_number: issueNumber, - body: `${commentAuthor} marked this ${eventType} as a duplicate of #${duplicateIssueNumber}.` + body: `${commentAuthor.login} marked this ${eventType} as a duplicate of #${duplicateIssueNumber}.` }); await github.rest.issues.deleteComment({ owner, From 04231da79c68c5f85c9232838faffc379964a136 Mon Sep 17 00:00:00 2001 From: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:04:28 +0300 Subject: [PATCH 8/9] Fix syntax error in triage-tools.yaml --- .github/workflows/triage-tools.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml index bf8735dc03..d3c2cc62ac 100644 --- a/.github/workflows/triage-tools.yaml +++ b/.github/workflows/triage-tools.yaml @@ -19,7 +19,7 @@ jobs: const trustedUsers = [ 7896101, // ChrisTitusTech 57459428, // FluffyPunk - 125669256", // FallenGME + 125669256, // FallenGME 90123670, // mewclouds 121827219, // MyDrift-user 17331812, // Callum From cde2e84b1471975796c1e74184e343523275b289 Mon Sep 17 00:00:00 2001 From: Chris Titus Date: Wed, 19 Aug 2026 15:49:58 -0500 Subject: [PATCH 9/9] fix(github): restrict triage commands to issues --- .github/TRIAGE_TOOLS.md | 24 - .github/workflows/triage-tools.yaml | 411 +++++++++--------- docs/astro.config.mjs | 1 + .../docs/code-reference/issue-triage.mdx | 26 ++ pester/triage-tools.Tests.ps1 | 68 +++ 5 files changed, 299 insertions(+), 231 deletions(-) delete mode 100644 .github/TRIAGE_TOOLS.md create mode 100644 docs/src/content/docs/code-reference/issue-triage.mdx create mode 100644 pester/triage-tools.Tests.ps1 diff --git a/.github/TRIAGE_TOOLS.md b/.github/TRIAGE_TOOLS.md deleted file mode 100644 index 9951636190..0000000000 --- a/.github/TRIAGE_TOOLS.md +++ /dev/null @@ -1,24 +0,0 @@ -# Ability to triage issues and PRs - -Trusted members of the repository can use this workflow to triage issues and PRs by writing next commands: -- `/triage` - adds the `needs-triage` label to the issue or PR. That should be used when user is unsure about adding a feature or closing the issue. -- `/triageoff` - removes the `needs-triage` label from the issue or PR. Label should be present -- `/np wontfix` - closes the issue with not planned status and tagging with 'wontfix' label. -- `/np notrelated` - closes the issue with not planned status and tagging with 'not related' label. Used in cases when the issue is not related to the project. -- `/np ` - closes the issue with not planned status. -- `/duplicate ` - closes the issue with duplicate status. GH API is bugged and sometimes it adds link to status, sometimes not. - -Issuing a command will delete your message leave a log by GH Actions to avoid any possible sabotage. - -# Notes for future - -- No, custom labels won't be supported due to maintaining clean structure of project. -- To become able to use this workflow, you should be included directly by owner of project or by PR from other trusted member. -- Any abuse of this workflow can and will lead to revoking of your access to this workflow and/or banning from the project. -- Note for Chris: I wanted to make something similar for discussions, but GH API has no docs for it, so result is next - triage for both issues and PRs and ability to close stupid issues from people who can't read. - -# Known bugs - -- I'm too lazy to make a proper check for command being at the start of the message, so if you write something like "I think this is a duplicate /duplicate 123" it will still work and your message will be deleted. So please write your comment and command in separate messages. If someone fixes this, delete this note. -- Writing "/np" without any word won't work because of regex, so please write `/np ` instead of just "/np". If someone fixes this, delete this note. -- Writing two commands in one message will be unpredictable. Don't be a lazy head. Write separate comments. \ No newline at end of file diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml index d3c2cc62ac..99cd5d4bad 100644 --- a/.github/workflows/triage-tools.yaml +++ b/.github/workflows/triage-tools.yaml @@ -1,215 +1,212 @@ -name: Repo Contributors' Triage Tools +name: Repo Contributors' Issue Triage Tools on: - issue_comment: - types: [created, edited] + issue_comment: + types: [created, edited] jobs: - triage-tools: - runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write - contents: read - steps: - - name: Process message - uses: actions/github-script@v9 - with: - script: | - const trustedUsers = [ - 7896101, // ChrisTitusTech - 57459428, // FluffyPunk - 125669256, // FallenGME - 90123670, // mewclouds - 121827219, // MyDrift-user - 17331812, // Callum - 101426328, // CodingWonders - 70659536 // og-mrk - ]; - const commentAuthor = context.payload.comment.user; + triage-tools: + if: ${{ !github.event.issue.pull_request }} + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: none + contents: none + steps: + - name: Process issue command + uses: actions/github-script@v9 + with: + script: | + const trustedUsers = [ + 7896101, // ChrisTitusTech + 57459428, // FluffyPunk + 125669256, // FallenGME + 90123670, // mewclouds + 121827219, // MyDrift-user + 17331812, // Callum + 101426328, // CodingWonders + 70659536 // og-mrk + ]; - if (!trustedUsers.includes(commentAuthor.id)) { - console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`); - return; - } + if (context.payload.issue.pull_request) { + console.log("Pull request comments are not supported. Exiting."); + return; + } - const eventType = context.payload.issue.pull_request ? "pull request" : "issue"; + const comment = context.payload.comment; + const commentAuthor = comment.user; - const comment = context.payload.comment; - const {owner, repo} = context.issue; - const issueNumber = context.issue.number; + if (!trustedUsers.includes(commentAuthor.id)) { + console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`); + return; + } - const triageMatch = comment.body.match(/\/triage\b/); - if (triageMatch) { - const issue = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber - }); - if (issue.data.labels.some(label => label.name === "needs-triage")) { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The ${eventType} already has the 'needs-triage' label. For removing label, do /triageoff.` - }); - } else { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ["needs-triage"] - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `${commentAuthor.login} has asked maintainer for a triage on this ${eventType}.` - }); - } - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: comment.id - }); - } - const triageOffMatch = comment.body.match(/\/triageoff\b/); - if (triageOffMatch) { - const issue = await github.rest.issues.get({ - owner, - repo, - issue_number: issueNumber - }); - if (issue.data.labels.some(label => label.name === "needs-triage")) { - await github.rest.issues.removeLabel({ - owner, - repo, - issue_number: issueNumber, - name: "needs-triage" - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `${commentAuthor.login} removed triage request from this ${eventType}.` - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The ${eventType} is not requested for triage.` - }); - } - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: comment.id - }); - } - const notPlannedMatch = comment.body.match(/\/np\s+(\w+)/); - if (notPlannedMatch && eventType === "issue") { - switch (notPlannedMatch[1]) { - case "wontfix": - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ["wontfix"] - }); - await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - state_reason: "not_planned" - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `${commentAuthor.login} marked this ${eventType} as not fixable` - }); - break; - case "notrelated": - await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - state_reason: "not_planned", - labels: ["not-related"] - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `${commentAuthor.login} marked this ${eventType} as not related to the project` - }); - break; - default: - await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - state_reason: "not_planned" - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `${commentAuthor.login} marked this ${eventType} as not planned.` - }); - break; - }; - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: comment.id - }); - } - const duplicateMatch = comment.body.match(/\/duplicate\s+(\S+)/); - if (duplicateMatch && eventType === "issue") { - const duplicateIssueNumber = parseInt(duplicateMatch[1].replace("#", "")); - let duplicateIssue; - try { - const response = await github.rest.issues.get({ - owner, - repo, - issue_number: duplicateIssueNumber - }); - duplicateIssue = response; - } catch (error) { - duplicateIssue = null; - } - if (duplicateIssue) { - await github.rest.issues.update({ - owner, - repo, - issue_number: issueNumber, - state: "closed", - state_reason: "duplicate", - duplicate_issue_id: duplicateIssue.id - }); - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `${commentAuthor.login} marked this ${eventType} as a duplicate of #${duplicateIssueNumber}.` - }); - await github.rest.issues.deleteComment({ - owner, - repo, - comment_id: comment.id - }); - } else { - await github.rest.issues.createComment({ - owner, - repo, - issue_number: issueNumber, - body: `The issue #${duplicateIssueNumber} does not exist.` - }); - return; - } - } + const {owner, repo} = context.repo; + const issueNumber = context.issue.number; + const command = comment.body.trim().toLowerCase(); + const triageMatch = command.match(/^\/triage$/); + const triageOffMatch = command.match(/^\/triageoff$/); + const notPlannedMatch = command.match(/^\/np(?:\s+(\w+))?$/); + const duplicateMatch = command.match(/^\/duplicate\s+#?(\d+)$/); + + if (triageMatch) { + const issue = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber + }); + + if (issue.data.labels.some(label => label.name === "needs-triage")) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: "This issue already has the 'needs-triage' label. Use /triageoff to remove it." + }); + } else { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ["needs-triage"] + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor.login} requested maintainer triage for this issue.` + }); + } + } else if (triageOffMatch) { + const issue = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber + }); + + if (issue.data.labels.some(label => label.name === "needs-triage")) { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: "needs-triage" + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor.login} removed the triage request from this issue.` + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: "This issue is not currently marked for triage." + }); + } + } else if (notPlannedMatch) { + const reason = notPlannedMatch[1]; + + if (reason === "wontfix") { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ["wontfix"] + }); + } else if (reason === "notrelated") { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: ["not-related"] + }); + } + + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "not_planned" + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor.login} closed this issue as not planned.` + }); + } else if (duplicateMatch) { + const duplicateIssueNumber = Number(duplicateMatch[1]); + + if (!Number.isSafeInteger(duplicateIssueNumber) || duplicateIssueNumber < 1) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: "The duplicate target must be a positive issue number." + }); + } else if (duplicateIssueNumber === issueNumber) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: "An issue cannot be marked as a duplicate of itself." + }); + } else { + let duplicateIssue; + try { + const response = await github.rest.issues.get({ + owner, + repo, + issue_number: duplicateIssueNumber + }); + duplicateIssue = response.data; + } catch (error) { + if (error.status !== 404) { + throw error; + } + } + + if (duplicateIssue?.pull_request) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `#${duplicateIssueNumber} is a pull request, not an issue.` + }); + } else if (duplicateIssue) { + await github.rest.issues.update({ + owner, + repo, + issue_number: issueNumber, + state: "closed", + state_reason: "duplicate", + duplicate_issue_id: duplicateIssue.id + }); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `${commentAuthor.login} closed this issue as a duplicate of #${duplicateIssueNumber}.` + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: `Issue #${duplicateIssueNumber} does not exist.` + }); + } + } + } else { + console.log("Comment does not contain a supported issue command. Exiting."); + return; + } + + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id + }); diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 54409baea6..942e17b201 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -56,6 +56,7 @@ export default defineConfig({ label: 'Code Reference', items: [ { label: 'Architecture & Design', slug: 'code-reference/architecture' }, + { label: 'Issue Triage Commands', slug: 'code-reference/issue-triage' }, { label: 'Tweaks Reference', items: [{ autogenerate: { directory: 'code-reference/tweaks' } }] }, { label: 'Features Reference', items: [{ autogenerate: { directory: 'code-reference/features' } }] }, ], diff --git a/docs/src/content/docs/code-reference/issue-triage.mdx b/docs/src/content/docs/code-reference/issue-triage.mdx new file mode 100644 index 0000000000..060c3698e9 --- /dev/null +++ b/docs/src/content/docs/code-reference/issue-triage.mdx @@ -0,0 +1,26 @@ +--- +title: Issue Triage Commands +description: Commands trusted maintainers can use to label and close GitHub issues. +--- + +Trusted repository members whose numeric GitHub user IDs are listed in the issue triage workflow can run the commands below. These commands work only on issues. Comments on pull requests are ignored, and the workflow token has no pull-request write permission. + +## Commands + +- `/triage` adds the `needs-triage` label. +- `/triageoff` removes the `needs-triage` label when it is present. +- `/np` closes the issue as not planned. +- `/np wontfix` adds the `wontfix` label without replacing existing labels, then closes the issue as not planned. +- `/np notrelated` adds the `not-related` label without replacing existing labels, then closes the issue as not planned. +- `/np ` closes the issue as not planned without adding a label for unrecognized reasons. +- `/duplicate ` closes the issue as a duplicate of another issue. The positive issue number may optionally start with `#`; pull request numbers are rejected. + +## Command handling + +Put exactly one command in the comment. Leading and trailing whitespace is allowed, but text before or after the command is not. Command matching is case-insensitive, and malformed issue numbers are rejected. + +After a command is handled, the workflow deletes the command comment and leaves an audit comment describing the result. A missing duplicate target or a request to duplicate an issue into itself is reported without closing the issue. Unexpected GitHub API failures stop the workflow and leave the command comment available for retry. + +## Access + +Access is granted by adding the trusted member's immutable numeric GitHub user ID to `.github/workflows/triage-tools.yaml` through a pull request. Abuse can result in removal from the allowlist or other repository moderation action. diff --git a/pester/triage-tools.Tests.ps1 b/pester/triage-tools.Tests.ps1 new file mode 100644 index 0000000000..ae8bd88ebd --- /dev/null +++ b/pester/triage-tools.Tests.ps1 @@ -0,0 +1,68 @@ +#=========================================================================== +# Tests - GitHub Issue Triage Workflow +#=========================================================================== + +BeforeAll { + $script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + $script:workflowPath = Join-Path $script:repoRoot ".github\workflows\triage-tools.yaml" + $script:guidePath = Join-Path $script:repoRoot "docs\src\content\docs\code-reference\issue-triage.mdx" + $script:workflow = Get-Content -Path $script:workflowPath -Raw + $script:guide = Get-Content -Path $script:guidePath -Raw +} + +Describe "GitHub issue triage workflow" { + It "cannot run against pull requests" { + $script:workflow | Should -Match '(?m)^\s+if:\s+\$\{\{\s*!github\.event\.issue\.pull_request\s*\}\}\s*$' + $script:workflow | Should -Match 'if \(context\.payload\.issue\.pull_request\)' + $script:workflow | Should -Match '(?m)^\s+issues:\s+write\s*$' + $script:workflow | Should -Match '(?m)^\s+pull-requests:\s+none\s*$' + $script:workflow | Should -Match '(?m)^\s+contents:\s+none\s*$' + $script:workflow | Should -Not -Match '(?m)^\s+pull-requests:\s+write\s*$' + } + + It "authorizes trusted users by immutable numeric ID" { + $script:workflow | Should -Match 'trustedUsers\.includes\(commentAuthor\.id\)' + $script:workflow | Should -Not -Match 'trustedUsers\.includes\(commentAuthor\.login\)' + } + + It "matches one complete command at a time" { + $script:workflow.Contains('const command = comment.body.trim().toLowerCase();') | Should -BeTrue + $script:workflow.Contains('const triageMatch = command.match(/^\/triage$/);') | Should -BeTrue + $script:workflow.Contains('const triageOffMatch = command.match(/^\/triageoff$/);') | Should -BeTrue + $script:workflow.Contains('const notPlannedMatch = command.match(/^\/np(?:\s+(\w+))?$/);') | Should -BeTrue + $script:workflow.Contains('const duplicateMatch = command.match(/^\/duplicate\s+#?(\d+)$/);') | Should -BeTrue + $script:workflow.Contains('} else if (triageOffMatch) {') | Should -BeTrue + $script:workflow.Contains('} else if (notPlannedMatch) {') | Should -BeTrue + $script:workflow.Contains('} else if (duplicateMatch) {') | Should -BeTrue + } + + It "preserves existing labels for not-related closures" { + $notRelatedBlock = [regex]::Match( + $script:workflow, + '(?s)reason === "notrelated".*?github\.rest\.issues\.addLabels\(\{.*?labels: \["not-related"\].*?github\.rest\.issues\.update' + ) + + $notRelatedBlock.Success | Should -BeTrue + $notRelatedBlock.Value | Should -Not -Match 'issues\.update\(\{.*?labels:' + } + + It "validates duplicate targets before closing the issue" { + $script:workflow.Contains('const duplicateIssueNumber = Number(duplicateMatch[1]);') | Should -BeTrue + $script:workflow | Should -Match 'Number\.isSafeInteger\(duplicateIssueNumber\)' + $script:workflow | Should -Match 'duplicateIssueNumber === issueNumber' + $script:workflow | Should -Match 'duplicateIssue = response\.data' + $script:workflow | Should -Match 'duplicateIssue\?\.pull_request' + $script:workflow | Should -Match 'error\.status !== 404' + $script:workflow | Should -Match 'throw error' + $script:workflow | Should -Match 'duplicate_issue_id: duplicateIssue\.id' + } + + It "keeps the maintainer guide in the documentation site" { + Test-Path -Path (Join-Path $script:repoRoot ".github\TRIAGE_TOOLS.md") | Should -BeFalse + $script:guide | Should -Match 'These commands work only on issues\.' + $script:guide | Should -Match '`not-related` label without replacing existing labels' + + $astroConfig = Get-Content -Path (Join-Path $script:repoRoot "docs\astro.config.mjs") -Raw + $astroConfig | Should -Match "slug: 'code-reference/issue-triage'" + } +}