diff --git a/.github/workflows/triage-tools.yaml b/.github/workflows/triage-tools.yaml new file mode 100644 index 00000000..99cd5d4b --- /dev/null +++ b/.github/workflows/triage-tools.yaml @@ -0,0 +1,212 @@ +name: Repo Contributors' Issue Triage Tools + +on: + issue_comment: + types: [created, edited] + +jobs: + 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 (context.payload.issue.pull_request) { + console.log("Pull request comments are not supported. Exiting."); + return; + } + + const comment = context.payload.comment; + const commentAuthor = comment.user; + + if (!trustedUsers.includes(commentAuthor.id)) { + console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`); + 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 54409bae..942e17b2 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 00000000..060c3698 --- /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 00000000..ae8bd88e --- /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'" + } +}