Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions .github/workflows/triage-tools.yaml
Original file line number Diff line number Diff line change
@@ -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
});
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }] },
],
Expand Down
26 changes: 26 additions & 0 deletions docs/src/content/docs/code-reference/issue-triage.mdx
Original file line number Diff line number Diff line change
@@ -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 <reason>` closes the issue as not planned without adding a label for unrecognized reasons.
- `/duplicate <issue number>` 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.
68 changes: 68 additions & 0 deletions pester/triage-tools.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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'"
}
}
Loading