ci: add custom triage action, label-author into triage workflow

This commit is contained in:
Elliot DeNolf
2024-10-29 11:49:04 -04:00
parent e365300bee
commit b7d65ab717
15 changed files with 80550 additions and 91 deletions

13
.github/actions/triage/.eslintrc.js vendored Normal file
View File

@@ -0,0 +1,13 @@
module.exports = {
env: {
es6: true,
node: true,
},
extends: ['eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
}

8
.github/actions/triage/.prettierrc.js vendored Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 100,
parser: 'typescript',
semi: false,
singleQuote: true,
trailingComma: 'all',
arrowParens: 'avoid',
}

22
.github/actions/triage/LICENSE vendored Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024 Payload <info@payloadcms.com>. All modification and additions are copyright of Payload.
---
Original license:
ISC License
Copyright (c) 2023, Balázs Orbán
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

21
.github/actions/triage/README.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# Triage
Modified version of https://github.com/balazsorban44/nissuer
## Modifications
- Port to TypeScript
- Remove issue locking
- Remove reproduction blocklist
- Uses `@vercel/ncc` for packaging
## Development
> [!IMPORTANT]
> Whenever a modification is made to the action, the action built to `dist` must be committed to the repository.
This is done by running:
```sh
pnpm build
```

40
.github/actions/triage/action.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Triage
description: Initial triage for issues
inputs:
reproduction-comment:
description: 'Either a string or a path to a .md file inside the repository. Example: ".github/invalid-reproduction.md"'
default: '.github/invalid-reproduction.md'
reproduction-hosts:
description: 'Comma-separated list of hostnames that are allowed for reproductions. Example: "github.com,codesandbox.io"'
default: github.com
reproduction-invalid-label:
description: 'Label to apply to issues without a valid reproduction. Example: "invalid-reproduction"'
default: 'invalid-reproduction'
reproduction-issue-labels:
description: 'Comma-separated list of issue labels. If configured, only verify reproduction URLs of issues with one of these labels present. Adding a comma at the end will handle non-labeled issues as invalid. Example: "bug,", will consider issues with the label "bug" or no label.'
default: ''
reproduction-link-section:
description: 'A regular expression string with "(.*)" matching a valid URL in the issue body. The result is trimmed. Example: "### Link to reproduction(.*)### To reproduce"'
default: '### Link to reproduction(.*)### To reproduce'
tag-only:
description: Log and tag only. Do not perform closing or commenting actions.
default: false
runs:
using: "composite"
steps:
- name: Checkout code
if: ${{ github.event_name != 'pull_request' }}
uses: actions/checkout@v4
- name: Run action
run: node ${{ github.action_path }}/dist/index.js
shell: sh
# https://github.com/actions/runner/issues/665#issuecomment-676581170
env:
"INPUT_REPRODUCTION_COMMENT": ${{inputs.reproduction-comment}}
"INPUT_REPRODUCTION_HOSTS": ${{inputs.reproduction-hosts}}
"INPUT_REPRODUCTION_INVALID_LABEL": ${{inputs.reproduction-invalid-label}}
"INPUT_REPRODUCTION_ISSUE_LABELS": ${{inputs.reproduction-issue-labels}}
"INPUT_REPRODUCTION_LINK_SECTION": ${{inputs.reproduction-link-section}}
"INPUT_TAG_ONLY": ${{inputs.tag-only}}

34048
.github/actions/triage/dist/index.js vendored Normal file

File diff suppressed because one or more lines are too long

7
.github/actions/triage/jest.config.js vendored Normal file
View File

@@ -0,0 +1,7 @@
module.exports = {
testEnvironment: 'node',
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/dist/'],
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest'],
},
}

34
.github/actions/triage/package.json vendored Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "triage",
"version": "0.0.0",
"description": "GitHub Action to triage new issues",
"main": "dist/index.js",
"license": "MIT",
"private": true,
"scripts": {
"clean": "rimraf dist",
"build": "pnpm build:typecheck && pnpm build:ncc",
"build:ncc": "ncc build src/index.ts -t -o dist",
"build:typecheck": "tsc",
"test": "jest"
},
"dependencies": {
"@actions/core": "^1.3.0",
"@actions/github": "^5.0.0"
},
"devDependencies": {
"@octokit/webhooks-types": "^7.5.1",
"@swc/jest": "^0.2.36",
"@types/jest": "^27.5.2",
"@types/node": "^20.16.5",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"@vercel/ncc": "0.38.1",
"concurrently": "^8.2.2",
"eslint": "^7.32.0",
"jest": "^29.7.0",
"prettier": "^3.3.3",
"ts-jest": "^26.5.6",
"typescript": "^4.9.5"
}
}

5419
.github/actions/triage/pnpm-lock.yaml generated vendored Normal file

File diff suppressed because it is too large Load Diff

195
.github/actions/triage/src/index.ts vendored Normal file
View File

@@ -0,0 +1,195 @@
import { debug, error, getBooleanInput, getInput, info, setFailed } from '@actions/core'
import { context, getOctokit } from '@actions/github'
import { readFile, access } from 'node:fs/promises'
import { join } from 'node:path'
// Ensure GITHUB_TOKEN and GITHUB_WORKSPACE are present
if (!process.env.GITHUB_TOKEN) throw new TypeError('No GITHUB_TOKEN provided')
if (!process.env.GITHUB_WORKSPACE) throw new TypeError('Not a GitHub workspace')
// Define the configuration object
interface Config {
invalidLink: {
comment: string
bugLabels: string[]
hosts: string[]
label: string
linkSection: string
}
tagOnly: boolean
token: string
workspace: string
}
const config: Config = {
invalidLink: {
comment: getInput('reproduction_comment') || '.github/invalid-reproduction.md',
bugLabels: getInput('reproduction_issue_labels')
.split(',')
.map(l => l.trim()),
hosts: (getInput('reproduction_hosts') || 'github.com').split(',').map(h => h.trim()),
label: getInput('reproduction_invalid_label') || 'invalid-reproduction',
linkSection:
getInput('reproduction_link_section') || '### Link to reproduction(.*)### To reproduce',
},
tagOnly: getBooleanOrUndefined('tag_only') || false,
token: process.env.GITHUB_TOKEN,
workspace: process.env.GITHUB_WORKSPACE,
}
// Attempt to parse JSON, return parsed object or error
function tryParse(json: string): Record<string, unknown> {
try {
return JSON.parse(json)
} catch (e) {
setFailed(`Could not parse JSON: ${e instanceof Error ? e.message : e}`)
return {}
}
}
// Retrieves a boolean input or undefined based on environment variables
function getBooleanOrUndefined(value: string): boolean | undefined {
const variable = process.env[`INPUT_${value.toUpperCase()}`]
return variable === undefined || variable === '' ? undefined : getBooleanInput(value)
}
// Returns the appropriate label match type
function getLabelMatch(value: string | undefined): 'name' | 'description' {
return value === 'name' ? 'name' : 'description'
}
// Function to check if an issue contains a valid reproduction link
async function checkValidReproduction(): Promise<void> {
const { issue, action } = context.payload as {
issue: { number: number; body: string; labels: { name: string }[] } | undefined
action: string
}
if (action !== 'opened' || !issue?.body) return
const labels = issue.labels.map(l => l.name)
const issueMatchingLabel =
labels.length &&
config.invalidLink.bugLabels.length &&
labels.some(l => config.invalidLink.bugLabels.includes(l))
if (!issueMatchingLabel) {
info(
`Issue #${issue.number} does not match required labels: ${config.invalidLink.bugLabels.join(', ')}`,
)
info(`Issue labels: ${labels.join(', ')}`)
return
}
info(`Issue #${issue.number} labels: ${labels.join(', ')}`)
const { rest: client } = getOctokit(config.token)
const common = { ...context.repo, issue_number: issue.number }
const labelsToRemove = labels.filter(l => config.invalidLink.bugLabels.includes(l))
if (await isValidReproduction(issue.body)) {
await Promise.all(
labelsToRemove.map(label => client.issues.removeLabel({ ...common, name: label })),
)
return info(`Issue #${issue.number} contains a valid reproduction 💚`)
}
info(`Invalid reproduction, issue will be closed/labeled/commented...`)
// Adjust labels
await Promise.all(
labelsToRemove.map(label => client.issues.removeLabel({ ...common, name: label })),
)
info(`Issue #${issue.number} - validate label removed`)
await client.issues.addLabels({ ...common, labels: [config.invalidLink.label] })
info(`Issue #${issue.number} - labeled`)
// If tagOnly, do not close or comment
if (config.tagOnly) {
info('Tag-only enabled, no closing/commenting actions taken')
return
}
// Perform closing and commenting actions
await client.issues.update({ ...common, state: 'closed' })
info(`Issue #${issue.number} - closed`)
const comment = join(config.workspace, config.invalidLink.comment)
await client.issues.createComment({ ...common, body: await getCommentBody(comment) })
info(`Issue #${issue.number} - commented`)
}
/**
* Determine if an issue contains a valid/accessible link to a reproduction.
*
* Returns `true` if the link is valid.
* @param body - The body content of the issue
*/
async function isValidReproduction(body: string): Promise<boolean> {
const linkSectionRe = new RegExp(config.invalidLink.linkSection, 'is')
const link = body.match(linkSectionRe)?.[1]?.trim()
if (!link) {
info('Missing link')
info(`Link section regex: ${linkSectionRe}`)
info(`Link section: ${body}`)
return false
}
info(`Checking validity of link: ${link}`)
if (!URL.canParse(link)) {
info(`Invalid URL: ${link}`)
return false
}
const url = new URL(link)
if (!config.invalidLink.hosts.includes(url.hostname)) {
info('Link did not match allowed reproduction hosts')
return false
}
try {
// Verify that the link can be accessed
const response = await fetch(link)
const isOk = response.status < 400 || response.status >= 500
info(`Link status: ${response.status}`)
if (!isOk) {
info(`Link returned status ${response.status}`)
}
return isOk
} catch (error) {
info(`Error fetching link: ${(error as Error).message}`)
return false
}
}
/**
* Return either a file's content or a string
* @param {string} pathOrComment
*/
async function getCommentBody(pathOrComment: string) {
try {
await access(pathOrComment)
return await readFile(pathOrComment, 'utf8')
} catch (error: any) {
if (error.code === 'ENOENT') return pathOrComment
throw error
}
}
async function run() {
const { token, workspace, ...safeConfig } = config
info('Configuration:')
info(JSON.stringify(safeConfig, null, 2))
await checkValidReproduction()
}
run().catch(setFailed)

40597
.github/actions/triage/triage.patch vendored Normal file

File diff suppressed because one or more lines are too long

15
.github/actions/triage/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["es2020.string"],
"noEmit": true,
"strict": true,
"noUnusedLocals": false, // Undo this
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"downlevelIteration": true,
"skipLibCheck": true,
},
"exclude": ["src/**/*.test.ts"]
}

49
.github/pnpm-lock.yaml generated vendored
View File

@@ -55,6 +55,55 @@ importers:
specifier: ^4.9.5
version: 4.9.5
actions/triage:
dependencies:
'@actions/core':
specifier: ^1.3.0
version: 1.10.1
'@actions/github':
specifier: ^5.0.0
version: 5.1.1
devDependencies:
'@octokit/webhooks-types':
specifier: ^7.5.1
version: 7.5.1
'@swc/jest':
specifier: ^0.2.36
version: 0.2.36(@swc/core@1.7.23)
'@types/jest':
specifier: ^27.5.2
version: 27.5.2
'@types/node':
specifier: ^20.16.5
version: 20.16.5
'@typescript-eslint/eslint-plugin':
specifier: ^4.33.0
version: 4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@4.9.5))(eslint@7.32.0)(typescript@4.9.5)
'@typescript-eslint/parser':
specifier: ^4.33.0
version: 4.33.0(eslint@7.32.0)(typescript@4.9.5)
'@vercel/ncc':
specifier: 0.38.1
version: 0.38.1
concurrently:
specifier: ^8.2.2
version: 8.2.2
eslint:
specifier: ^7.32.0
version: 7.32.0
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.16.5)(node-notifier@8.0.2)
prettier:
specifier: ^3.3.3
version: 3.3.3
ts-jest:
specifier: ^26.5.6
version: 26.5.6(jest@29.7.0(@types/node@20.16.5)(node-notifier@8.0.2))(typescript@4.9.5)
typescript:
specifier: ^4.9.5
version: 4.9.5
packages:
'@actions/core@1.10.1':

View File

@@ -1,81 +0,0 @@
name: label-author
on:
pull_request:
types: [opened]
issues:
types: [opened]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
debug-context:
runs-on: ubuntu-latest
steps:
- name: View context attributes
uses: actions/github-script@v7
with:
script: console.log(context)
label-created-by:
name: Label pr/issue on opening
runs-on: ubuntu-latest
steps:
- name: Tag with 'created-by'
uses: actions/github-script@v7
if: github.event.action == 'opened'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const payloadTeamUsernames = [
'denolfe',
'jmikrut',
'DanRibbens',
'jacobsfletch',
'JarrodMFlesch',
'AlessioGr',
'JessChowdhury',
'kendelljoseph',
'PatrikKozak',
'tylandavis',
'paulpopus',
'GermanJablo',
];
const type = context.payload.pull_request ? 'pull_request' : 'issue';
const isTeamMember = payloadTeamUsernames
.map(n => n.toLowerCase())
.includes(context.payload[type].user.login.toLowerCase());
if (isTeamMember) {
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['created-by: Payload team'],
});
console.log(`Added 'created-by: Payload team' label`);
return;
}
const association = context.payload[type].author_association;
let label = ''
if (association === 'MEMBER' || association === 'OWNER') {
label = 'created-by: Payload team';
} else if (association === 'CONTRIBUTOR') {
label = 'created-by: Contributor';
}
if (!label) return;
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: [label],
});
console.log(`Added '${label}' label.`);

View File

@@ -1,6 +1,9 @@
name: triage
on:
pull_request:
types:
- opened
issues:
types:
- opened
@@ -9,21 +12,90 @@ env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
permissions:
contents: read
issues: write
pull-requests: write
jobs:
triage:
name: nissuer
if: false # Disable after adjusting scenarios which this should be applied
debug-context:
runs-on: ubuntu-latest
steps:
- uses: balazsorban44/nissuer@1.10.0
- name: View context attributes
uses: actions/github-script@v7
with:
script: console.log({ context })
label-created-by:
name: label-on-open
runs-on: ubuntu-latest
steps:
- name: Tag with 'created-by'
uses: actions/github-script@v7
if: github.event.action == 'opened'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const payloadTeamUsernames = [
'denolfe',
'jmikrut',
'DanRibbens',
'jacobsfletch',
'JarrodMFlesch',
'AlessioGr',
'JessChowdhury',
'kendelljoseph',
'PatrikKozak',
'tylandavis',
'paulpopus',
'GermanJablo',
];
const type = context.payload.pull_request ? 'pull_request' : 'issue';
const isTeamMember = payloadTeamUsernames
.map(n => n.toLowerCase())
.includes(context.payload[type].user.login.toLowerCase());
if (isTeamMember) {
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['created-by: Payload team'],
});
console.log(`Added 'created-by: Payload team' label`);
return;
}
const association = context.payload[type].author_association;
let label = ''
if (association === 'MEMBER' || association === 'OWNER') {
label = 'created-by: Payload team';
} else if (association === 'CONTRIBUTOR') {
label = 'created-by: Contributor';
}
if (!label) return;
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: [label],
});
console.log(`Added '${label}' label.`);
triage:
name: initial-triage
if: github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/triage
with:
label-area-prefix: ""
label-area-match: "name"
label-area-section: 'Which area\(s\) are affected\? \(Select all that apply\)(.*)### Environment Info'
reproduction-comment: '.github/comments/invalid-reproduction.md'
reproduction-blocklist: 'github.com/\\w*/?$,github.com$'
reproduction-link-section: '### Link to the code that reproduces this issue(.*)### Reproduction Steps'
reproduction-invalid-label: 'invalid-reproduction'
reproduction-issue-labels: 'status: needs-triage,'
reproduction-issue-labels: 'validate-reproduction'
tag-only: 'true'