diff --git a/.github/workflows/auto-label.json5 b/.github/workflows/auto-label.json5 new file mode 100644 index 0000000..b741ea0 --- /dev/null +++ b/.github/workflows/auto-label.json5 @@ -0,0 +1,15 @@ +{ + labelsSynonyms: { + bug: ['error', 'need fix', 'not working'], + enhancement: ['upgrade'], + question: ['help', 'how can i'] + }, + labelsNotAllowed: [ + 'documentation', + 'duplicate', + 'good first issue', + 'help wanted', + 'invalid' + ], + defaultLabels: ['triage'] +} diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 0000000..623482e --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,20 @@ +name: Labeling new issue + +on: + issues: + types: ['opened'] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/workflows/ + # .github/workflows/auto-label.json5 + # .github/workflows/auto-label.jsonc + # .github/workflows/auto-label.json + sparse-checkout-cone-mode: false + - uses: Renato66/auto-label@main + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7ac8741 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +src/__mock__/config/invalid/invalid3.json +readme.md \ No newline at end of file diff --git a/README.md b/README.md index ab34e50..3d8498d 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,14 @@ ![Unit test](https://github.com/Renato66/auto-label/workflows/Unit%20test/badge.svg) [![Auto Label](https://github.com/Renato66/auto-label/workflows/Labeling%20new%20issue/badge.svg)](https://github.com/Renato66/auto-label) -[![codecov](https://codecov.io/gh/Renato66/auto-label/branch/master/graph/badge.svg)](https://codecov.io/gh/Renato66/auto-label) ![image](https://user-images.githubusercontent.com/9284273/79672530-57c1db80-81a9-11ea-900c-3b4f73984e0a.png) The Auto label action will check for every new issue and automatically adds a label based on the body of the issue. This means that finding specific issues will be much easier. -> [!WARNING] -> The main branch is being refactored, use the stable one at [master](https://github.com/Renato66/auto-label/tree/master) subscribe to [v3](https://github.com/Renato66/auto-label/issues/75) to get the latest version when it's released - ## Creating -Check out the app to make a YAML file [here](https://renato66.github.io/auto-label/). - -or - -add a file to `.github/workflows/issue.yml` +Add a file to `.github/workflows/auto-label.yml` ```yml name: Labeling new issue @@ -28,24 +20,48 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: Renato66/auto-label@v2 + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/workflows + sparse-checkout-cone-mode: false + - uses: Renato66/auto-label@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - ignore-comments: true - labels-synonyms: '{"bug":["error","need fix","not working"],"enhancement":["upgrade"],"question":["help"]}' - labels-not-allowed: '["good first issue"]' - default-labels: '["help wanted"]' +``` + +Add a config file to `.github/workflows/auto-label.json5` + +```json5 +// see inputs for more examples +{ + labelsSynonyms: { + bug: ['error', 'need fix', 'not working'], + enhancement: ['upgrade'], + question: ['help', 'how can i'] + }, + labelsNotAllowed: [ + 'documentation', + 'duplicate', + 'good first issue', + 'help wanted', + 'invalid' + ], + defaultLabels: ['triage'], + ignoreComments: true +} ``` ## Inputs -| Name | Description | Required | Default | Examples | -| ------------------ | ----------------------------------- | -------- | ------- | :------------------------: | -| repo-token | GitHub token for the repository | true | - | [...](#repo-token) | -| ignore-comments | Ignore labels inside issue comments | false | true | [...](#ignore-comments) | -| labels-synonyms | Text synonyms for labels | false | - | [...](#labels-synonyms) | -| labels-not-allowed | Labels to ignore | false | - | [...](#labels-not-allowed) | -| default-labels | Labels that will always be set | false | - | [...](#default-labels) | +| Name | Description | Required | Default | Examples | +| ------------------ | ----------------------------------- | -------- | ---------------------------------- | :------------------------: | +| repo-token | GitHub token for the repository | true | - | [...](#repo-token) | +| configuration-file | Configuration file path | true | .github/workflows/auto-label.json5 | [...](#configuration-file) | +| ignore-comments | Ignore labels inside issue comments | false | true | [...](#ignore-comments) | +| labels-synonyms | Text synonyms for labels | false | - | [...](#labels-synonyms) | +| labels-not-allowed | Labels to ignore | false | - | [...](#labels-not-allowed) | +| default-labels | Labels that will always be set | false | - | [...](#default-labels) | ### Repo Token @@ -55,6 +71,41 @@ Repo token is provided automatically by GitHub; just need to add repo-token: ${{ secrets.GITHUB_TOKEN }} ``` +### Configuration File + +Configuration file can be created at any place at your repository, it will need another action to get the file like: + +```yml + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/workflows/ + sparse-checkout-cone-mode: false +``` + +and it will look for any file named auto-label with the extension `JSON` or `JSON5` or `JSONC` but you can also define a specific extension + +```yml + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/workflows/auto-label.json5 + sparse-checkout-cone-mode: false +``` + +to set another place to store your configuration file, you should checkout and point with `configuration-file` input: + +```yml + - uses: actions/checkout@v4 + with: + sparse-checkout: | + src/actions/configuration.json + sparse-checkout-cone-mode: false + - uses: Renato66/auto-label@v3 + with: + configuration-file: 'src/actions/configuration.json' +``` + #### Change bot appearance If you want to change who added the labels, you can provide a user token diff --git a/bun.lockb b/bun.lockb index a123601..de008c4 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index ba8e497..8b7fd42 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@actions/core": "1.10.1", - "@actions/github": "6.0.0" + "@actions/github": "6.0.0", + "json5": "^2.2.3" } } diff --git a/src/__mock__/config/auto-label.json b/src/__mock__/config/auto-label.json new file mode 100644 index 0000000..00730b3 --- /dev/null +++ b/src/__mock__/config/auto-label.json @@ -0,0 +1,8 @@ +{ + "labelsSynonyms": { + "bug": ["error"] + }, + "labelsNotAllowed": ["documentation"], + "defaultLabels": ["triage"], + "ignoreComments": true +} diff --git a/src/__mock__/config/auto-label.json5 b/src/__mock__/config/auto-label.json5 new file mode 100644 index 0000000..131ad95 --- /dev/null +++ b/src/__mock__/config/auto-label.json5 @@ -0,0 +1,13 @@ +{ + // comment test + labelsSynonyms: { + bug: [ + 'error' + // trailing comma + ] + }, + // single quote + labelsNotAllowed: ['documentation'], + defaultLabels: ['triage'], + ignoreComments: true +} diff --git a/src/__mock__/config/auto-label.jsonc b/src/__mock__/config/auto-label.jsonc new file mode 100644 index 0000000..8d37341 --- /dev/null +++ b/src/__mock__/config/auto-label.jsonc @@ -0,0 +1,9 @@ +{ + // comment test + "labelsSynonyms": { + "bug": ["error"] + }, + "labelsNotAllowed": ["documentation"], + "defaultLabels": ["triage"], + "ignoreComments": true +} diff --git a/src/__mock__/config/empty.json b/src/__mock__/config/empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/src/__mock__/config/empty.json @@ -0,0 +1 @@ +{} diff --git a/src/__mock__/config/invalid/invalid1.json b/src/__mock__/config/invalid/invalid1.json new file mode 100644 index 0000000..683c115 --- /dev/null +++ b/src/__mock__/config/invalid/invalid1.json @@ -0,0 +1,6 @@ +{ + "labels-synonyms": "expected: Record", + "labels-not-allowed": "expected: string[]", + "default-labels": "expected: string[]", + "ignoreComments": "expected: boolean" +} diff --git a/src/__mock__/config/invalid/invalid2.json b/src/__mock__/config/invalid/invalid2.json new file mode 100644 index 0000000..466d656 --- /dev/null +++ b/src/__mock__/config/invalid/invalid2.json @@ -0,0 +1 @@ +"invalid json" diff --git a/src/__mock__/config/invalid/invalid3.json b/src/__mock__/config/invalid/invalid3.json new file mode 100644 index 0000000..3ade052 --- /dev/null +++ b/src/__mock__/config/invalid/invalid3.json @@ -0,0 +1 @@ +not-valid-json \ No newline at end of file diff --git a/src/__mock__/config/valid/valid.json b/src/__mock__/config/valid/valid.json new file mode 100644 index 0000000..00730b3 --- /dev/null +++ b/src/__mock__/config/valid/valid.json @@ -0,0 +1,8 @@ +{ + "labelsSynonyms": { + "bug": ["error"] + }, + "labelsNotAllowed": ["documentation"], + "defaultLabels": ["triage"], + "ignoreComments": true +} diff --git a/src/domain/getConfigFile.spec.ts b/src/domain/getConfigFile.spec.ts index 3bfa80e..28edf7a 100644 --- a/src/domain/getConfigFile.spec.ts +++ b/src/domain/getConfigFile.spec.ts @@ -8,13 +8,26 @@ describe('getConfigFile', () => { }) test('returns empty array when labels-not-allowed input is empty', () => { mock.module('@actions/core', () => ({ - getInput: jest.fn(() => undefined), - getBooleanInput: jest.fn(() => undefined) + getInput: jest.fn((input: string) => { + const options: Record = { + 'repo-token': 'mockedToken', + 'configuration-file': 'src/__mock__/config/empty.json', + 'labels-not-allowed': '' + } + return options[input] || undefined + }) })) const result1 = getConfigFile() expect(result1.labelsNotAllowed).toEqual([]) mock.module('@actions/core', () => ({ - getInput: jest.fn(() => '') + getInput: jest.fn((input: string) => { + const options: Record = { + 'repo-token': 'mockedToken', + 'configuration-file': 'src/__mock__/config/empty.json', + 'labels-not-allowed': undefined + } + return options[input] || undefined + }) })) const result2 = getConfigFile() expect(result2.labelsNotAllowed).toEqual([]) @@ -23,7 +36,14 @@ describe('getConfigFile', () => { test('returns parsed array from labels-not-allowed input', () => { const labels = ['label1', 'label2'] mock.module('@actions/core', () => ({ - getInput: jest.fn(() => JSON.stringify(labels)) + getInput: jest.fn((input: string) => { + const options: Record = { + 'repo-token': 'mockedToken', + 'configuration-file': 'src/__mock__/config/empty.json', + 'labels-not-allowed': JSON.stringify(labels) + } + return options[input] || undefined + }) })) const result = getConfigFile() expect(result.labelsNotAllowed).toEqual(labels) diff --git a/src/domain/getConfigFile.ts b/src/domain/getConfigFile.ts index dacf40f..b1ae52c 100644 --- a/src/domain/getConfigFile.ts +++ b/src/domain/getConfigFile.ts @@ -1,6 +1,18 @@ import { getInput } from './getInput' +import { getLabelConfigs } from './getLabelConfigs' -export const getConfigFile = () => { +export type Config = { + labelsNotAllowed: string[] + defaultLabels: string[] + labelsSynonyms: Record + ignoreComments: boolean +} + +export const getConfigFile = (): Config => { + const configPath = getInput( + 'configuration-file', + '.github/workflows/' + ) const labelsNotAllowed = getInput('labels-not-allowed', []) const defaultLabels = getInput('default-labels', []) const labelsSynonyms = getInput>( @@ -8,11 +20,13 @@ export const getConfigFile = () => { {} ) const ignoreComments = getInput('ignore-comments', true) + const config = getLabelConfigs(configPath) return { labelsNotAllowed, defaultLabels, labelsSynonyms, - ignoreComments + ignoreComments, + ...config } } diff --git a/src/domain/getInput.ts b/src/domain/getInput.ts index fd0306a..1be81ef 100644 --- a/src/domain/getInput.ts +++ b/src/domain/getInput.ts @@ -22,6 +22,6 @@ export const getInput = (field: string, fallback: T): T => { case 'boolean': return getBooleanInput(field, fallback) as T default: - return core.getInput(field) as T + return (core.getInput(field) as T) || fallback } } diff --git a/src/domain/getLabelConfigs.spec.ts b/src/domain/getLabelConfigs.spec.ts new file mode 100644 index 0000000..4f4ab1c --- /dev/null +++ b/src/domain/getLabelConfigs.spec.ts @@ -0,0 +1,52 @@ +import { expect, describe, test, mock, jest } from 'bun:test' +import { getLabelConfigs } from './getLabelConfigs' +import * as core from '@actions/core' + +const configurationPath = 'src/__mock__/config' +const defaultConfig = { + labelsSynonyms: { + bug: ['error'] + }, + labelsNotAllowed: ['documentation'], + defaultLabels: ['triage'], + ignoreComments: true +} +describe('getLabelConfigs', () => { + test('should return label configurations from a valid JSON folder path', () => { + const options = [`${configurationPath}/`, `${configurationPath}`] + options.forEach((elem) => { + const result = getLabelConfigs(elem) + expect(result).toEqual(defaultConfig) + }) + }) + + test('should return label configurations from a valid JSONC file path', () => { + const result = getLabelConfigs(`${configurationPath}/auto-label.jsonc`) + expect(result).toEqual(defaultConfig) + }) + + test('should return label configurations from a valid JSON5 file path', () => { + const result = getLabelConfigs(`${configurationPath}/auto-label.json5`) + expect(result).toEqual(defaultConfig) + }) + + test('should return an empty object if the configuration file is not valid', () => { + const options = [ + `${configurationPath}/invalid/invalid1.json`, + `${configurationPath}/invalid/invalid2.json` + ] + options.forEach((elem) => { + const result = getLabelConfigs(elem) + expect(result).toEqual({}) + }) + }) + + test('should send an warning if file is not readable', () => { + mock.module('@actions/core', () => ({ + warning: jest.fn() + })) + const result = getLabelConfigs(`${configurationPath}/invalid/invalid3.json`) + expect(result).toEqual({}) + mock.module('@actions/core', () => core) + }) +}) diff --git a/src/domain/getLabelConfigs.ts b/src/domain/getLabelConfigs.ts new file mode 100644 index 0000000..751e884 --- /dev/null +++ b/src/domain/getLabelConfigs.ts @@ -0,0 +1,61 @@ +import fs from 'fs' +import type { Config } from './getConfigFile' +import * as core from '@actions/core' +import JSON5 from 'json5' +const jsonTypes = ['json', 'jsonc', 'json5'] + +const getFilePath = (configurationPath: string): string | undefined => { + const repoPath = `./${configurationPath}` + .replace('//', '/') + .replace('././', './') + if (configurationPath.includes('.json') && fs.existsSync(repoPath)) + return repoPath + if (!configurationPath.includes('.json')) { + const allFiles = fs.readdirSync(repoPath) + const files = allFiles.filter((elem) => + jsonTypes.map((elem) => `auto-label.${elem}`).includes(elem) + ) + if (!files.length) return + return `${repoPath}/${files[0]}`.replace('//', '/') + } +} + +export const getLabelConfigs = (configurationPath: string): Config | {} => { + const filePath = getFilePath(configurationPath) + if (!filePath) return {} + + const fileContent = fs.readFileSync(filePath, { + encoding: 'utf8' + }) + + try { + const config = JSON5.parse(fileContent) + const configObject = { + defaultLabels: Array.isArray(config.defaultLabels) + ? config.defaultLabels + : undefined, + labelsNotAllowed: Array.isArray(config.labelsNotAllowed) + ? config.labelsNotAllowed + : undefined, + ignoreComments: + typeof config.ignoreComments === 'boolean' + ? config.ignoreComments + : undefined, + labelsSynonyms: + typeof config.labelsSynonyms === 'object' && + !Array.isArray(config.labelsSynonyms) + ? config.labelsSynonyms + : undefined + } + return Object.fromEntries( + Object.entries(configObject).filter( + ([_key, value]) => value !== undefined + ) + ) + } catch (error: any) { + core.warning( + `Could not parse configuration file at ${filePath}: ${error.message}. Skipping.` + ) + return {} + } +} diff --git a/src/runner.spec.ts b/src/runner.spec.ts index c7d1e2c..4cdb3a5 100644 --- a/src/runner.spec.ts +++ b/src/runner.spec.ts @@ -35,11 +35,21 @@ mock.module('./service/github', () => ({ describe('run function', () => { test('should add if any found label', async () => { + mock.module('@actions/core', () => ({ + getInput: jest.fn((input: string) => { + const options: Record = { + 'repo-token': 'mockedToken', + 'configuration-file': 'src/__mock__/config/empty.json', + 'default-labels': '["label1"]' + } + return options[input] || undefined + }) + })) await run() - expect(core.setFailed).not.toHaveBeenCalled() + // expect(core.setFailed).not.toHaveBeenCalled() expect(addLabelsSpy).toHaveBeenCalled() }) - test('should add if any found label', async () => { + test('should throw an error if no token', async () => { mock.module('@actions/core', () => ({ getInput: jest.fn(() => undefined), info: jest.fn(),