From 96cebd269584195d8441c7867b2a41e3c628fd9c Mon Sep 17 00:00:00 2001 From: TESTELIN Geoffrey Date: Sun, 17 Jan 2021 21:56:18 +0100 Subject: [PATCH] feat(exempt): add new options to exempt the milestones closes #270 --- README.md | 21 + __tests__/main.test.ts | 465 ++++++++---------- action.yml | 20 +- src/IssueProcessor.ts | 121 ++--- src/classes/issue.ts | 45 ++ .../{ => loggers}/issue-logger.spec.ts | 2 +- src/classes/{ => loggers}/issue-logger.ts | 4 +- src/classes/{ => loggers}/logger.spec.ts | 0 src/classes/{ => loggers}/logger.ts | 0 src/classes/milestones.ts | 55 +++ src/functions/is-labeled.spec.ts | 2 +- src/functions/is-labeled.ts | 3 +- src/functions/is-pull-request.spec.ts | 2 +- src/functions/is-pull-request.ts | 2 +- src/functions/labels-to-list.spec.ts | 141 ------ src/functions/labels-to-list.ts | 23 - src/functions/words-to-list.spec.ts | 137 ++++++ src/functions/words-to-list.ts | 23 + src/interfaces/issue.ts | 13 + src/interfaces/milestone.ts | 3 + src/main.ts | 5 +- 21 files changed, 582 insertions(+), 505 deletions(-) create mode 100644 src/classes/issue.ts rename src/classes/{ => loggers}/issue-logger.spec.ts (97%) rename src/classes/{ => loggers}/issue-logger.ts (89%) rename src/classes/{ => loggers}/logger.spec.ts (100%) rename src/classes/{ => loggers}/logger.ts (100%) create mode 100644 src/classes/milestones.ts delete mode 100644 src/functions/labels-to-list.spec.ts delete mode 100644 src/functions/labels-to-list.ts create mode 100644 src/functions/words-to-list.spec.ts create mode 100644 src/functions/words-to-list.ts create mode 100644 src/interfaces/issue.ts create mode 100644 src/interfaces/milestone.ts diff --git a/README.md b/README.md index dd19ebf45..4754737b5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ $ npm test | `close-pr-label` | Label to apply on the closing pr. | Optional | | `exempt-issue-labels` | Labels on an issue exempted from being marked as stale. | Optional | | `exempt-pr-labels` | Labels on the pr exempted from being marked as stale. | Optional | +| `exempt-milestones` | Milestones on an issue or a pr exempted from being marked as stale. | Optional | +| `exempt-issue-milestones` | Milestones on an issue exempted from being marked as stale (override `exempt-milestones`). | Optional | +| `exempt-pr-milestones` | Milestones on the pr exempted from being marked as stale (override `exempt-milestones`). | Optional | | `only-labels` | Only labels checked for stale issue/pr. | Optional | | `operations-per-run` | Maximum number of operations per run (GitHub API CRUD related). _Defaults to **30**_ | Optional | | `remove-stale-when-updated` | Remove stale label from issue/pr on updates or comments. _Defaults to **true**_ | Optional | @@ -181,6 +184,24 @@ jobs: start-date: '2020-18-04T00:00:00Z' // ISO 8601 or RFC 2822 ``` +Avoid stale for specific milestones: + +```yaml +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '30 1 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v3 + with: + exempt-issue-milestones: 'future,alpha,beta' + exempt-pr-milestones: 'bugfix,improvement' +``` + ### Debugging To see debug output from this action, you must set the secret `ACTIONS_STEP_DEBUG` to `true` in your repository. You can run this action in debug only mode (no actions will be taken on your issues) by passing `debug-only` `true` as an argument to the action. diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 55bdb163d..6302d6314 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,12 +1,11 @@ import * as github from '@actions/github'; -import { - Issue, - IssueProcessor, - IssueProcessorOptions -} from '../src/IssueProcessor'; +import {Issue} from '../src/classes/issue'; + +import {IssueProcessor, IssueProcessorOptions} from '../src/IssueProcessor'; import {IsoDateString} from '../src/types/iso-date-string'; function generateIssue( + options: IssueProcessorOptions, id: number, title: string, updatedAt: IsoDateString, @@ -14,9 +13,10 @@ function generateIssue( isPullRequest: boolean = false, labels: string[] = [], isClosed: boolean = false, - isLocked: boolean = false + isLocked: boolean = false, + milestone = '' ): Issue { - return { + return new Issue(options, { number: id, labels: labels.map(l => { return {name: l}; @@ -26,8 +26,11 @@ function generateIssue( updated_at: updatedAt, pull_request: isPullRequest ? {} : null, state: isClosed ? 'closed' : 'open', - locked: isLocked - }; + locked: isLocked, + milestone: { + title: milestone + } + }); } const DefaultProcessorOptions: IssueProcessorOptions = Object.freeze({ @@ -56,7 +59,10 @@ const DefaultProcessorOptions: IssueProcessorOptions = Object.freeze({ skipStaleIssueMessage: false, skipStalePrMessage: false, deleteBranch: false, - startDate: '' + startDate: '', + exemptMilestones: '', + exemptIssueMilestones: '', + exemptPrMilestones: '' }); test('empty issue list results in 1 operation', async () => { @@ -76,15 +82,13 @@ test('empty issue list results in 1 operation', async () => { }); test('processing an issue with no label will make it stale and close it, if it is old enough only if days-before-close is set to 0', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 0 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -349,16 +353,14 @@ test('processing an issue with no label and a start date as RFC 2822 being after }); test('processing an issue with no label will make it stale and close it, if it is old enough only if days-before-close is set to > 0 and days-before-issue-close is set to 0', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 1, daysBeforeIssueClose: 0 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -376,16 +378,14 @@ test('processing an issue with no label will make it stale and close it, if it i }); test('processing an issue with no label will make it stale and not close it, if it is old enough only if days-before-close is set to > 0 and days-before-issue-close is set to > 0', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 1, daysBeforeIssueClose: 1 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -402,15 +402,13 @@ test('processing an issue with no label will make it stale and not close it, if }); test('processing an issue with no label will make it stale and not close it if days-before-close is set to > 0', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 15 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -427,16 +425,14 @@ test('processing an issue with no label will make it stale and not close it if d }); test('processing an issue with no label will make it stale and not close it if days-before-close is set to -1 and days-before-issue-close is set to > 0', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: -1, daysBeforeIssueClose: 15 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -453,16 +449,14 @@ test('processing an issue with no label will make it stale and not close it if d }); test('processing an issue with no label will not make it stale if days-before-stale is set to -1', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, staleIssueMessage: '', daysBeforeStale: -1 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -479,17 +473,15 @@ test('processing an issue with no label will not make it stale if days-before-st }); test('processing an issue with no label will not make it stale if days-before-stale and days-before-issue-stale are set to -1', async () => { - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') - ]; - const opts: IssueProcessorOptions = { ...DefaultProcessorOptions, staleIssueMessage: '', daysBeforeStale: -1, daysBeforeIssueStale: -1 }; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z') + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -510,11 +502,14 @@ test('processing an issue with no label will make it stale but not close it', as // stale but not close-able, based on default settings let issueDate = new Date(); issueDate.setDate(issueDate.getDate() - 2); - const TestIssueList: Issue[] = [ - generateIssue(1, 'An issue with no label', issueDate.toDateString()) + generateIssue( + DefaultProcessorOptions, + 1, + 'An issue with no label', + issueDate.toDateString() + ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -531,8 +526,13 @@ test('processing an issue with no label will make it stale but not close it', as }); test('processing a stale issue will close it', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeClose: 30 + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'A stale issue that should be closed', '2020-01-01T17:00:00Z', @@ -541,12 +541,6 @@ test('processing a stale issue will close it', async () => { ['Stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - daysBeforeClose: 30 - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -563,8 +557,13 @@ test('processing a stale issue will close it', async () => { }); test('processing a stale issue containing a space in the label will close it', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + staleIssueLabel: 'state: stale' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'A stale issue that should be closed', '2020-01-01T17:00:00Z', @@ -573,12 +572,6 @@ test('processing a stale issue containing a space in the label will close it', a ['state: stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - staleIssueLabel: 'state: stale' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -595,8 +588,13 @@ test('processing a stale issue containing a space in the label will close it', a }); test('processing a stale issue containing a slash in the label will close it', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + staleIssueLabel: 'lifecycle/stale' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'A stale issue that should be closed', '2020-01-01T17:00:00Z', @@ -605,12 +603,6 @@ test('processing a stale issue containing a slash in the label will close it', a ['lifecycle/stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - staleIssueLabel: 'lifecycle/stale' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -627,8 +619,14 @@ test('processing a stale issue containing a slash in the label will close it', a }); test('processing a stale issue will close it when days-before-issue-stale override days-before-stale', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeClose: 30, + daysBeforeIssueStale: 30 + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'A stale issue that should be closed', '2020-01-01T17:00:00Z', @@ -637,13 +635,6 @@ test('processing a stale issue will close it when days-before-issue-stale overri ['Stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - daysBeforeClose: 30, - daysBeforeIssueStale: 30 - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -660,8 +651,13 @@ test('processing a stale issue will close it when days-before-issue-stale overri }); test('processing a stale PR will close it', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeClose: 30 + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'A stale PR that should be closed', '2020-01-01T17:00:00Z', @@ -670,12 +666,6 @@ test('processing a stale PR will close it', async () => { ['Stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - daysBeforeClose: 30 - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -692,8 +682,14 @@ test('processing a stale PR will close it', async () => { }); test('processing a stale PR will close it when days-before-pr-stale override days-before-stale', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeClose: 30, + daysBeforePrClose: 30 + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'A stale PR that should be closed', '2020-01-01T17:00:00Z', @@ -702,13 +698,6 @@ test('processing a stale PR will close it when days-before-pr-stale override day ['Stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - daysBeforeClose: 30, - daysBeforePrClose: 30 - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -725,8 +714,14 @@ test('processing a stale PR will close it when days-before-pr-stale override day }); test('processing a stale issue will close it even if configured not to mark as stale', async () => { + const opts = { + ...DefaultProcessorOptions, + daysBeforeStale: -1, + staleIssueMessage: '' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z', @@ -735,13 +730,6 @@ test('processing a stale issue will close it even if configured not to mark as s ['Stale'] ) ]; - - const opts = { - ...DefaultProcessorOptions, - daysBeforeStale: -1, - staleIssueMessage: '' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -758,8 +746,15 @@ test('processing a stale issue will close it even if configured not to mark as s }); test('processing a stale issue will close it even if configured not to mark as stale when days-before-issue-stale override days-before-stale', async () => { + const opts = { + ...DefaultProcessorOptions, + daysBeforeStale: 0, + daysBeforeIssueStale: -1, + staleIssueMessage: '' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z', @@ -768,14 +763,6 @@ test('processing a stale issue will close it even if configured not to mark as s ['Stale'] ) ]; - - const opts = { - ...DefaultProcessorOptions, - daysBeforeStale: 0, - daysBeforeIssueStale: -1, - staleIssueMessage: '' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -792,8 +779,14 @@ test('processing a stale issue will close it even if configured not to mark as s }); test('processing a stale PR will close it even if configured not to mark as stale', async () => { + const opts = { + ...DefaultProcessorOptions, + daysBeforeStale: -1, + stalePrMessage: '' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z', @@ -802,13 +795,6 @@ test('processing a stale PR will close it even if configured not to mark as stal ['Stale'] ) ]; - - const opts = { - ...DefaultProcessorOptions, - daysBeforeStale: -1, - stalePrMessage: '' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -825,8 +811,15 @@ test('processing a stale PR will close it even if configured not to mark as stal }); test('processing a stale PR will close it even if configured not to mark as stale when days-before-pr-stale override days-before-stale', async () => { + const opts = { + ...DefaultProcessorOptions, + daysBeforeStale: 0, + daysBeforePrStale: -1, + stalePrMessage: '' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue with no label', '2020-01-01T17:00:00Z', @@ -835,14 +828,6 @@ test('processing a stale PR will close it even if configured not to mark as stal ['Stale'] ) ]; - - const opts = { - ...DefaultProcessorOptions, - daysBeforeStale: 0, - daysBeforePrStale: -1, - stalePrMessage: '' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -861,6 +846,7 @@ test('processing a stale PR will close it even if configured not to mark as stal test('closed issues will not be marked stale', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A closed issue that will not be marked', '2020-01-01T17:00:00Z', @@ -870,7 +856,6 @@ test('closed issues will not be marked stale', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -888,6 +873,7 @@ test('closed issues will not be marked stale', async () => { test('stale closed issues will not be closed', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A stale closed issue', '2020-01-01T17:00:00Z', @@ -897,7 +883,6 @@ test('stale closed issues will not be closed', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -916,6 +901,7 @@ test('stale closed issues will not be closed', async () => { test('closed prs will not be marked stale', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A closed PR that will not be marked', '2020-01-01T17:00:00Z', @@ -925,7 +911,6 @@ test('closed prs will not be marked stale', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -944,6 +929,7 @@ test('closed prs will not be marked stale', async () => { test('stale closed prs will not be closed', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A stale closed PR that will not be closed again', '2020-01-01T17:00:00Z', @@ -953,7 +939,6 @@ test('stale closed prs will not be closed', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -972,6 +957,7 @@ test('stale closed prs will not be closed', async () => { test('locked issues will not be marked stale', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A locked issue that will not be stale', '2020-01-01T17:00:00Z', @@ -982,7 +968,6 @@ test('locked issues will not be marked stale', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -999,6 +984,7 @@ test('locked issues will not be marked stale', async () => { test('stale locked issues will not be closed', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A stale locked issue that will not be closed', '2020-01-01T17:00:00Z', @@ -1009,7 +995,6 @@ test('stale locked issues will not be closed', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -1028,6 +1013,7 @@ test('stale locked issues will not be closed', async () => { test('locked prs will not be marked stale', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A locked PR that will not be marked stale', '2020-01-01T17:00:00Z', @@ -1038,7 +1024,6 @@ test('locked prs will not be marked stale', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -1055,6 +1040,7 @@ test('locked prs will not be marked stale', async () => { test('stale locked prs will not be closed', async () => { const TestIssueList: Issue[] = [ generateIssue( + DefaultProcessorOptions, 1, 'A stale locked PR that will not be closed', '2020-01-01T17:00:00Z', @@ -1065,7 +1051,6 @@ test('stale locked prs will not be closed', async () => { true ) ]; - const processor = new IssueProcessor( DefaultProcessorOptions, async () => 'abot', @@ -1083,20 +1068,13 @@ test('stale locked prs will not be closed', async () => { test('exempt issue labels will not be marked stale', async () => { expect.assertions(3); - const TestIssueList: Issue[] = [ - generateIssue( - 1, - 'My first issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - false, - ['Exempt'] - ) - ]; - const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt'; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'My first issue', '2020-01-01T17:00:00Z', '2020-01-01T17:00:00Z', false, [ + 'Exempt' + ]) + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -1114,20 +1092,13 @@ test('exempt issue labels will not be marked stale', async () => { }); test('exempt issue labels will not be marked stale (multi issue label with spaces)', async () => { - const TestIssueList: Issue[] = [ - generateIssue( - 1, - 'My first issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - false, - ['Cool'] - ) - ]; - const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt, Cool, None'; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'My first issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false, [ + 'Cool' + ]) + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -1144,20 +1115,13 @@ test('exempt issue labels will not be marked stale (multi issue label with space }); test('exempt issue labels will not be marked stale (multi issue label)', async () => { - const TestIssueList: Issue[] = [ - generateIssue( - 1, - 'My first issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - false, - ['Cool'] - ) - ]; - const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt,Cool,None'; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'My first issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false, [ + 'Cool' + ]) + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -1175,35 +1139,17 @@ test('exempt issue labels will not be marked stale (multi issue label)', async ( }); test('exempt pr labels will not be marked stale', async () => { - const TestIssueList: Issue[] = [ - generateIssue( - 1, - 'My first issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - false, - ['Cool'] - ), - generateIssue( - 2, - 'My first PR', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - true, - ['Cool'] - ), - generateIssue( - 3, - 'Another issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - false - ) - ]; - const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Cool'; - + const TestIssueList: Issue[] = [ + generateIssue(opts, 1, 'My first issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false, [ + 'Cool' + ]), + generateIssue(opts, 2, 'My first PR', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', true, [ + 'Cool' + ]), + generateIssue(opts, 3, 'Another issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false) + ]; const processor = new IssueProcessor( opts, async () => 'abot', @@ -1220,18 +1166,15 @@ test('exempt pr labels will not be marked stale', async () => { test('exempt issue labels will not be marked stale and will remove the existing stale label', async () => { expect.assertions(3); + const opts = {...DefaultProcessorOptions}; + opts.exemptIssueLabels = 'Exempt'; const TestIssueList: Issue[] = [ generateIssue( - 1, - 'My first issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', + opts, 1, 'My first issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false, ['Exempt', 'Stale'] ) ]; - const opts = {...DefaultProcessorOptions}; - opts.exemptIssueLabels = 'Exempt'; const processor = new IssueProcessor( opts, async () => 'abot', @@ -1256,36 +1199,24 @@ test('exempt issue labels will not be marked stale and will remove the existing }); test('stale issues should not be closed if days is set to -1', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeClose = -1; const TestIssueList: Issue[] = [ generateIssue( - 1, - 'My first issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', + opts, 1, 'My first issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false, ['Stale'] ), generateIssue( - 2, - 'My first PR', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - true, - ['Stale'] + opts, 2, 'My first PR','2020-01-01T17:00:00Z', '2020-01-01T17:00:00Z', true, [ + 'Stale' + ] ), generateIssue( - 3, - 'Another issue', - '2020-01-01T17:00:00Z', - '2020-01-01T17:00:00Z', - false, - ['Stale'] - ) + opts, 3, 'Another issue', '2020-01-01T17:00:00Z','2020-01-01T17:00:00Z', false, [ + 'Stale' + ]) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeClose = -1; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1302,8 +1233,11 @@ test('stale issues should not be closed if days is set to -1', async () => { }); test('stale label should be removed if a comment was added to a stale issue', async () => { + const opts = {...DefaultProcessorOptions}; + opts.removeStaleWhenUpdated = true; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should un-stale', '2020-01-01T17:00:00Z', @@ -1312,10 +1246,6 @@ test('stale label should be removed if a comment was added to a stale issue', as ['Stale'] ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.removeStaleWhenUpdated = true; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1340,9 +1270,12 @@ test('stale label should be removed if a comment was added to a stale issue', as }); test('stale label should not be removed if a comment was added by the bot (and the issue should be closed)', async () => { + const opts = {...DefaultProcessorOptions}; + opts.removeStaleWhenUpdated = true; github.context.actor = 'abot'; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should stay stale', '2020-01-01T17:00:00Z', @@ -1351,10 +1284,6 @@ test('stale label should not be removed if a comment was added by the bot (and t ['Stale'] ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.removeStaleWhenUpdated = true; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1379,8 +1308,14 @@ test('stale label should not be removed if a comment was added by the bot (and t }); test('stale label containing a space should be removed if a comment was added to a stale issue', async () => { + const opts: IssueProcessorOptions = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + staleIssueLabel: 'stat: stale' + }; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should un-stale', '2020-01-01T17:00:00Z', @@ -1389,13 +1324,6 @@ test('stale label containing a space should be removed if a comment was added to ['stat: stale'] ) ]; - - const opts: IssueProcessorOptions = { - ...DefaultProcessorOptions, - removeStaleWhenUpdated: true, - staleIssueLabel: 'stat: stale' - }; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1413,10 +1341,14 @@ test('stale label containing a space should be removed if a comment was added to }); test('stale issues should not be closed until after the closed number of days', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 1; // closes after 6 days let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 5); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be marked stale but not closed', lastUpdate.toString(), @@ -1424,11 +1356,6 @@ test('stale issues should not be closed until after the closed number of days', false ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 1; // closes after 6 days - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1446,10 +1373,14 @@ test('stale issues should not be closed until after the closed number of days', }); test('stale issues should be closed if the closed nubmer of days (additive) is also passed', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 1; // closes after 6 days let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 7); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be stale and closed', lastUpdate.toString(), @@ -1458,11 +1389,6 @@ test('stale issues should be closed if the closed nubmer of days (additive) is a ['Stale'] ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 1; // closes after 6 days - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1480,10 +1406,14 @@ test('stale issues should be closed if the closed nubmer of days (additive) is a }); test('stale issues should not be closed until after the closed number of days (long)', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 20; // closes after 25 days let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 10); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be marked stale but not closed', lastUpdate.toString(), @@ -1491,11 +1421,6 @@ test('stale issues should not be closed until after the closed number of days (l false ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 20; // closes after 25 days - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1513,10 +1438,15 @@ test('stale issues should not be closed until after the closed number of days (l }); test('skips stale message on issues when skip-stale-issue-message is set', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 20; // closes after 25 days + opts.skipStaleIssueMessage = true; let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 10); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be marked stale but not closed', lastUpdate.toString(), @@ -1524,12 +1454,6 @@ test('skips stale message on issues when skip-stale-issue-message is set', async false ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 20; // closes after 25 days - opts.skipStaleIssueMessage = true; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1559,10 +1483,15 @@ test('skips stale message on issues when skip-stale-issue-message is set', async }); test('skips stale message on prs when skip-stale-pr-message is set', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 20; // closes after 25 days + opts.skipStalePrMessage = true; let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 10); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be marked stale but not closed', lastUpdate.toString(), @@ -1570,12 +1499,6 @@ test('skips stale message on prs when skip-stale-pr-message is set', async () => true ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 20; // closes after 25 days - opts.skipStalePrMessage = true; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1605,10 +1528,16 @@ test('skips stale message on prs when skip-stale-pr-message is set', async () => }); test('not providing state takes precedence over skipStaleIssueMessage', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 20; // closes after 25 days + opts.skipStalePrMessage = true; + opts.staleIssueMessage = ''; let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 10); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be marked stale but not closed', lastUpdate.toString(), @@ -1616,13 +1545,6 @@ test('not providing state takes precedence over skipStaleIssueMessage', async () false ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 20; // closes after 25 days - opts.skipStalePrMessage = true; - opts.staleIssueMessage = ''; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1640,10 +1562,16 @@ test('not providing state takes precedence over skipStaleIssueMessage', async () }); test('not providing stalePrMessage takes precedence over skipStalePrMessage', async () => { + const opts = {...DefaultProcessorOptions}; + opts.daysBeforeStale = 5; // stale after 5 days + opts.daysBeforeClose = 20; // closes after 25 days + opts.skipStalePrMessage = true; + opts.stalePrMessage = ''; let lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 10); const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should be marked stale but not closed', lastUpdate.toString(), @@ -1651,13 +1579,6 @@ test('not providing stalePrMessage takes precedence over skipStalePrMessage', as true ) ]; - - const opts = {...DefaultProcessorOptions}; - opts.daysBeforeStale = 5; // stale after 5 days - opts.daysBeforeClose = 20; // closes after 25 days - opts.skipStalePrMessage = true; - opts.stalePrMessage = ''; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1679,6 +1600,7 @@ test('git branch is deleted when option is enabled', async () => { const isPullRequest = true; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should have its branch deleted', '2020-01-01T17:00:00Z', @@ -1687,7 +1609,6 @@ test('git branch is deleted when option is enabled', async () => { ['Stale'] ) ]; - const processor = new IssueProcessor( opts, async () => 'abot', @@ -1709,6 +1630,7 @@ test('git branch is not deleted when issue is not pull request', async () => { const isPullRequest = false; const TestIssueList: Issue[] = [ generateIssue( + opts, 1, 'An issue that should not have its branch deleted', '2020-01-01T17:00:00Z', @@ -1717,7 +1639,6 @@ test('git branch is not deleted when issue is not pull request', async () => { ['Stale'] ) ]; - const processor = new IssueProcessor( opts, async () => 'abot', diff --git a/action.yml b/action.yml index 1c90b6191..41f667a7f 100644 --- a/action.yml +++ b/action.yml @@ -23,20 +23,20 @@ inputs: required: false default: '60' days-before-issue-stale: - description: 'The number of days old an issue can be before marking it stale. Set to -1 to never mark issues as stale automatically. Override "days-before-stale" option regarding the issues only.' + description: 'The number of days old an issue can be before marking it stale. Set to -1 to never mark issues as stale automatically. Override "days-before-stale" option regarding only the issues.' required: false days-before-pr-stale: - description: 'The number of days old a pull request can be before marking it stale. Set to -1 to never mark pull requests as stale automatically. Override "days-before-stale" option regarding the pull requests only.' + description: 'The number of days old a pull request can be before marking it stale. Set to -1 to never mark pull requests as stale automatically. Override "days-before-stale" option regarding only the pull requests.' required: false days-before-close: description: 'The number of days to wait to close an issue or a pull request after it being marked stale. Set to -1 to never close stale issues or pull requests.' required: false default: '7' days-before-issue-close: - description: 'The number of days to wait to close an issue after it being marked stale. Set to -1 to never close stale issues. Override "days-before-close" option regarding the issues only.' + description: 'The number of days to wait to close an issue after it being marked stale. Set to -1 to never close stale issues. Override "days-before-close" option regarding only the issues.' required: false days-before-pr-close: - description: 'The number of days to wait to close a pull request after it being marked stale. Set to -1 to never close stale pull requests. Override "days-before-close" option regarding the pull requests only.' + description: 'The number of days to wait to close a pull request after it being marked stale. Set to -1 to never close stale pull requests. Override "days-before-close" option regarding only the pull requests.' required: false stale-issue-label: description: 'The label to apply when an issue is stale.' @@ -60,6 +60,18 @@ inputs: description: 'The labels that mean a pull request is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2")' default: '' required: false + exempt-milestones: + description: 'The milestones that mean an issue or a pr is exempt from being marked stale. Separate multiple milestones with commas (eg. "milestone1,milestone2")' + default: '' + required: false + exempt-issue-milestones: + description: 'The milestones that mean an issue is exempt from being marked stale. Separate multiple milestones with commas (eg. "milestone1,milestone2"). Override "exempt-milestones" option regarding only the issue.' + default: '' + required: false + exempt-pr-milestones: + description: 'The milestones that mean a pull request is exempt from being marked stale. Separate multiple milestones with commas (eg. "milestone1,milestone2"). Override "exempt-milestones" option regarding only the pull requests.' + default: '' + required: false only-labels: description: 'Only issues or pull requests with all of these labels are checked if stale. Defaults to `[]` (disabled) and can be a comma-separated list of labels.' default: '' diff --git a/src/IssueProcessor.ts b/src/IssueProcessor.ts index e8a3a0e21..65bdf6e7c 100644 --- a/src/IssueProcessor.ts +++ b/src/IssueProcessor.ts @@ -1,30 +1,22 @@ import {context, getOctokit} from '@actions/github'; import {GitHub} from '@actions/github/lib/utils'; import {GetResponseTypeFromEndpointMethod} from '@octokit/types'; +import {Issue} from './classes/issue'; +import {IssueLogger} from './classes/loggers/issue-logger'; +import {Logger} from './classes/loggers/logger'; +import {Milestones} from './classes/milestones'; import {IssueType} from './enums/issue-type'; import {getHumanizedDate} from './functions/dates/get-humanized-date'; import {isDateMoreRecentThan} from './functions/dates/is-date-more-recent-than'; import {isValidDate} from './functions/dates/is-valid-date'; import {getIssueType} from './functions/get-issue-type'; -import {IssueLogger} from './classes/issue-logger'; -import {Logger} from './classes/logger'; import {isLabeled} from './functions/is-labeled'; import {isPullRequest} from './functions/is-pull-request'; -import {labelsToList} from './functions/labels-to-list'; import {shouldMarkWhenStale} from './functions/should-mark-when-stale'; import {IsoDateString} from './types/iso-date-string'; import {IsoOrRfcDateString} from './types/iso-or-rfc-date-string'; - -export interface Issue { - title: string; - number: number; - created_at: IsoDateString; - updated_at: IsoDateString; - labels: Label[]; - pull_request: any; - state: string; - locked: boolean; -} +import {wordsToList} from './functions/words-to-list'; +import {IIssue} from './interfaces/issue'; export interface PullRequest { number: number; @@ -79,10 +71,11 @@ export interface IssueProcessorOptions { skipStalePrMessage: boolean; deleteBranch: boolean; startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822 + exemptMilestones: string; + exemptIssueMilestones: string; + exemptPrMilestones: string; } -const logger: Logger = new Logger(); - /*** * Handle processing of issues for staleness/closure. */ @@ -95,13 +88,14 @@ export class IssueProcessor { return millisSinceLastUpdated <= daysInMillis; } + private readonly _logger: Logger = new Logger(); + private _operationsLeft = 0; readonly client: InstanceType; readonly options: IssueProcessorOptions; readonly staleIssues: Issue[] = []; readonly closedIssues: Issue[] = []; readonly deletedBranchIssues: Issue[] = []; readonly removedLabelIssues: Issue[] = []; - private operationsLeft = 0; constructor( options: IssueProcessorOptions, @@ -117,7 +111,7 @@ export class IssueProcessor { ) => Promise ) { this.options = options; - this.operationsLeft = options.operationsPerRun; + this._operationsLeft = options.operationsPerRun; this.client = getOctokit(options.repoToken); if (getActor) { @@ -137,7 +131,7 @@ export class IssueProcessor { } if (this.options.debugOnly) { - logger.warning( + this._logger.warning( 'Executing in debug mode. Debug output will be written but no issues will be processed.' ); } @@ -146,49 +140,45 @@ export class IssueProcessor { async processIssues(page = 1): Promise { // get the next batch of issues const issues: Issue[] = await this._getIssues(page); - this.operationsLeft -= 1; + this._operationsLeft -= 1; const actor: string = await this._getActor(); if (issues.length <= 0) { - logger.info('---'); - logger.info('No more issues found to process. Exiting.'); - return this.operationsLeft; + this._logger.info('---'); + this._logger.info('No more issues found to process. Exiting.'); + return this._operationsLeft; } for (const issue of issues.values()) { const issueLogger: IssueLogger = new IssueLogger(issue); - const isPr = isPullRequest(issue); issueLogger.info( - `Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${isPr})` + `Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${issue.isPullRequest})` ); // calculate string based messages for this issue - const staleMessage: string = isPr + const staleMessage: string = issue.isPullRequest ? this.options.stalePrMessage : this.options.staleIssueMessage; - const closeMessage: string = isPr + const closeMessage: string = issue.isPullRequest ? this.options.closePrMessage : this.options.closeIssueMessage; - const staleLabel: string = isPr + const staleLabel: string = issue.isPullRequest ? this.options.stalePrLabel : this.options.staleIssueLabel; - const closeLabel: string = isPr + const closeLabel: string = issue.isPullRequest ? this.options.closePrLabel : this.options.closeIssueLabel; - const exemptLabels: string[] = labelsToList( - isPr ? this.options.exemptPrLabels : this.options.exemptIssueLabels - ); - const skipMessage = isPr + const skipMessage = issue.isPullRequest ? this.options.skipStalePrMessage : this.options.skipStaleIssueMessage; - const issueType: IssueType = getIssueType(isPr); - const daysBeforeStale: number = isPr + const issueType: IssueType = getIssueType(issue.isPullRequest); + const daysBeforeStale: number = issue.isPullRequest ? this._getDaysBeforePrStale() : this._getDaysBeforeIssueStale(); - if (isPr) { + if (issue.isPullRequest) { issueLogger.info(`Days before pull request stale: ${daysBeforeStale}`); } else { issueLogger.info(`Days before issue stale: ${daysBeforeStale}`); @@ -244,21 +234,24 @@ export class IssueProcessor { } } - // Does this issue have a stale label? - let isStale: boolean = isLabeled(issue, staleLabel); - - if (isStale) { + if (issue.isStale) { issueLogger.info(`This issue has a stale label`); } else { issueLogger.info(`This issue hasn't a stale label`); } + const exemptLabels: string[] = wordsToList( + issue.isPullRequest + ? this.options.exemptPrLabels + : this.options.exemptIssueLabels + ); + if ( exemptLabels.some((exemptLabel: Readonly): boolean => isLabeled(issue, exemptLabel) ) ) { - if (isStale) { + if (issue.isStale) { issueLogger.info(`An exempt label was added after the stale label.`); await this._removeStaleLabel(issue, staleLabel); } @@ -269,6 +262,15 @@ export class IssueProcessor { continue; // don't process exempt issues } + const milestones: Milestones = new Milestones(this.options, issue); + + if (milestones.shouldExemptMilestones()) { + issueLogger.info( + `Skipping ${issueType} because it has an exempt milestone` + ); + continue; // don't process exempt milestones + } + // should this issue be marked stale? const shouldBeStale = !IssueProcessor._updatedSince( issue.updated_at, @@ -276,16 +278,16 @@ export class IssueProcessor { ); // determine if this issue needs to be marked stale first - if (!isStale && shouldBeStale && shouldMarkAsStale) { + if (!issue.isStale && shouldBeStale && shouldMarkAsStale) { issueLogger.info( `Marking ${issueType} stale because it was last updated on ${issue.updated_at} and it does not have a stale label` ); await this._markStale(issue, staleMessage, staleLabel, skipMessage); - isStale = true; // this issue is now considered stale + issue.isStale = true; // this issue is now considered stale } // process the issue if it was marked stale - if (isStale) { + if (issue.isStale) { issueLogger.info(`Found a stale ${issueType}`); await this._processStaleIssue( issue, @@ -298,8 +300,10 @@ export class IssueProcessor { } } - if (this.operationsLeft <= 0) { - logger.warning('Reached max number of operations to process. Exiting.'); + if (this._operationsLeft <= 0) { + this._logger.warning( + 'Reached max number of operations to process. Exiting.' + ); return 0; } @@ -427,7 +431,7 @@ export class IssueProcessor { }); return comments.data; } catch (error) { - logger.error(`List issue comments error: ${error.message}`); + this._logger.error(`List issue comments error: ${error.message}`); return Promise.resolve([]); } } @@ -444,7 +448,7 @@ export class IssueProcessor { return actor.data.login; } - // grab issues from github in baches of 100 + // grab issues from github in batches of 100 private async _getIssues(page: number): Promise { // generate type for response const endpoint = this.client.issues.listForRepo; @@ -462,9 +466,12 @@ export class IssueProcessor { page } ); - return issueResult.data; + + return issueResult.data.map( + (issue: Readonly): Issue => new Issue(this.options, issue) + ); } catch (error) { - logger.error(`Get issues for repo error: ${error.message}`); + this._logger.error(`Get issues for repo error: ${error.message}`); return Promise.resolve([]); } } @@ -482,7 +489,7 @@ export class IssueProcessor { this.staleIssues.push(issue); - this.operationsLeft -= 2; + this._operationsLeft -= 2; // if the issue is being marked stale, the updated date should be changed to right now // so that close calculations work correctly @@ -530,7 +537,7 @@ export class IssueProcessor { this.closedIssues.push(issue); - this.operationsLeft -= 1; + this._operationsLeft -= 1; if (this.options.debugOnly) { return; @@ -578,7 +585,7 @@ export class IssueProcessor { issue: Issue ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); - this.operationsLeft -= 1; + this._operationsLeft -= 1; try { const pullRequest = await this.client.pulls.get({ @@ -621,7 +628,7 @@ export class IssueProcessor { `Deleting branch ${branch} from closed issue #${issue.number}` ); - this.operationsLeft -= 1; + this._operationsLeft -= 1; try { await this.client.git.deleteRef({ @@ -644,7 +651,7 @@ export class IssueProcessor { this.removedLabelIssues.push(issue); - this.operationsLeft -= 1; + this._operationsLeft -= 1; // @todo remove the debug only to be able to test the code below if (this.options.debugOnly) { @@ -673,7 +680,7 @@ export class IssueProcessor { issueLogger.info(`Checking for label on issue #${issue.number}`); - this.operationsLeft -= 1; + this._operationsLeft -= 1; const options = this.client.issues.listEvents.endpoint.merge({ owner: context.repo.owner, @@ -722,7 +729,7 @@ export class IssueProcessor { } private async _removeStaleLabel( - issue: Readonly, + issue: Issue, staleLabel: Readonly ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); diff --git a/src/classes/issue.ts b/src/classes/issue.ts new file mode 100644 index 000000000..a1f09c7d7 --- /dev/null +++ b/src/classes/issue.ts @@ -0,0 +1,45 @@ +import {isLabeled} from '../functions/is-labeled'; +import {isPullRequest} from '../functions/is-pull-request'; +import {IIssue} from '../interfaces/issue'; +import {IMilestone} from '../interfaces/milestone'; +import {IssueProcessorOptions, Label} from '../IssueProcessor'; + +export class Issue implements IIssue { + private readonly _options: IssueProcessorOptions; + readonly title: string; + readonly number: number; + updated_at: string; + readonly labels: Label[]; + readonly pull_request: any; + readonly state: string; + readonly locked: boolean; + readonly milestone: IMilestone; + readonly isPullRequest: boolean; + isStale: boolean; + readonly staleLabel: string; + + constructor( + options: Readonly, + issue: Readonly + ) { + this._options = options; + this.title = issue.title; + this.number = issue.number; + this.updated_at = issue.updated_at; + this.labels = issue.labels; + this.pull_request = issue.pull_request; + this.state = issue.state; + this.locked = issue.locked; + this.milestone = issue.milestone; + + this.isPullRequest = isPullRequest(this); + this.staleLabel = this._getStaleLabel(); + this.isStale = isLabeled(this, this.staleLabel); + } + + private _getStaleLabel(): string { + return this.isPullRequest + ? this._options.stalePrLabel + : this._options.staleIssueLabel; + } +} diff --git a/src/classes/issue-logger.spec.ts b/src/classes/loggers/issue-logger.spec.ts similarity index 97% rename from src/classes/issue-logger.spec.ts rename to src/classes/loggers/issue-logger.spec.ts index 23d4b9a1d..27e7f90b0 100644 --- a/src/classes/issue-logger.spec.ts +++ b/src/classes/loggers/issue-logger.spec.ts @@ -1,4 +1,4 @@ -import {Issue} from '../IssueProcessor'; +import {Issue} from '../issue'; import {IssueLogger} from './issue-logger'; import * as core from '@actions/core'; diff --git a/src/classes/issue-logger.ts b/src/classes/loggers/issue-logger.ts similarity index 89% rename from src/classes/issue-logger.ts rename to src/classes/loggers/issue-logger.ts index bbdbae776..979c231fa 100644 --- a/src/classes/issue-logger.ts +++ b/src/classes/loggers/issue-logger.ts @@ -1,11 +1,11 @@ import * as core from '@actions/core'; -import {Issue} from '../IssueProcessor'; +import {Issue} from '../issue'; import {Logger} from './logger'; export class IssueLogger implements Logger { private readonly _issue: Issue; - constructor(issue: Readonly) { + constructor(issue: Issue) { this._issue = issue; } diff --git a/src/classes/logger.spec.ts b/src/classes/loggers/logger.spec.ts similarity index 100% rename from src/classes/logger.spec.ts rename to src/classes/loggers/logger.spec.ts diff --git a/src/classes/logger.ts b/src/classes/loggers/logger.ts similarity index 100% rename from src/classes/logger.ts rename to src/classes/loggers/logger.ts diff --git a/src/classes/milestones.ts b/src/classes/milestones.ts new file mode 100644 index 000000000..955f85199 --- /dev/null +++ b/src/classes/milestones.ts @@ -0,0 +1,55 @@ +import deburr from 'lodash.deburr'; +import {wordsToList} from '../functions/words-to-list'; +import {IssueProcessorOptions} from '../IssueProcessor'; +import {Issue} from './issue'; + +type CleanMilestone = string; + +export class Milestones { + private static _cleanMilestone(label: Readonly): CleanMilestone { + return deburr(label.toLowerCase()); + } + + private readonly _options: IssueProcessorOptions; + private readonly _issue: Issue; + + constructor(options: Readonly, issue: Issue) { + this._options = options; + this._issue = issue; + } + + shouldExemptMilestones(): boolean { + const exemptMilestones: string[] = this._getExemptMilestones(); + + return exemptMilestones.some((exemptMilestone: Readonly): boolean => + this._hasMilestone(exemptMilestone) + ); + } + + private _getExemptMilestones(): string[] { + return wordsToList( + this._issue.isPullRequest + ? this._getExemptPullRequestMilestones() + : this._getExemptIssueMilestones() + ); + } + + private _getExemptIssueMilestones(): string { + return this._options.exemptIssueMilestones !== '' + ? this._options.exemptIssueMilestones + : this._options.exemptMilestones; + } + + private _getExemptPullRequestMilestones(): string { + return this._options.exemptPrMilestones !== '' + ? this._options.exemptPrMilestones + : this._options.exemptMilestones; + } + + private _hasMilestone(milestone: Readonly): boolean { + return ( + Milestones._cleanMilestone(milestone) === + Milestones._cleanMilestone(this._issue.milestone.title) + ); + } +} diff --git a/src/functions/is-labeled.spec.ts b/src/functions/is-labeled.spec.ts index fbabb3dbf..249fcd08a 100644 --- a/src/functions/is-labeled.spec.ts +++ b/src/functions/is-labeled.spec.ts @@ -1,4 +1,4 @@ -import {Issue} from '../IssueProcessor'; +import {Issue} from '../classes/issue'; import {isLabeled} from './is-labeled'; describe('isLabeled()', (): void => { diff --git a/src/functions/is-labeled.ts b/src/functions/is-labeled.ts index 751117546..7ac932338 100644 --- a/src/functions/is-labeled.ts +++ b/src/functions/is-labeled.ts @@ -1,5 +1,6 @@ import deburr from 'lodash.deburr'; -import {Issue, Label} from '../IssueProcessor'; +import {Issue} from '../classes/issue'; +import {Label} from '../IssueProcessor'; import {CleanLabel} from '../types/clean-label'; /** diff --git a/src/functions/is-pull-request.spec.ts b/src/functions/is-pull-request.spec.ts index cfa20d604..b0851f1d1 100644 --- a/src/functions/is-pull-request.spec.ts +++ b/src/functions/is-pull-request.spec.ts @@ -1,4 +1,4 @@ -import {Issue} from '../IssueProcessor'; +import {Issue} from '../classes/issue'; import {isPullRequest} from './is-pull-request'; describe('isPullRequest()', (): void => { diff --git a/src/functions/is-pull-request.ts b/src/functions/is-pull-request.ts index 4c3ae99de..1ba0017c4 100644 --- a/src/functions/is-pull-request.ts +++ b/src/functions/is-pull-request.ts @@ -1,4 +1,4 @@ -import {Issue} from '../IssueProcessor'; +import {Issue} from '../classes/issue'; export function isPullRequest(issue: Readonly): boolean { return !!issue.pull_request; diff --git a/src/functions/labels-to-list.spec.ts b/src/functions/labels-to-list.spec.ts deleted file mode 100644 index f70c28140..000000000 --- a/src/functions/labels-to-list.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import {labelsToList} from './labels-to-list'; - -describe('labelsToList()', (): void => { - let labels: string; - - describe('when the given labels is empty', (): void => { - beforeEach((): void => { - labels = ''; - }); - - it('should return an empty list of labels', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual([]); - }); - }); - - describe('when the given labels is a simple label', (): void => { - beforeEach((): void => { - labels = 'label'; - }); - - it('should return a list of one label', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual(['label']); - }); - }); - - describe('when the given labels is a label with extra spaces before and after', (): void => { - beforeEach((): void => { - labels = ' label '; - }); - - it('should return a list of one label and remove all spaces before and after', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual(['label']); - }); - }); - - describe('when the given labels is a kebab case label', (): void => { - beforeEach((): void => { - labels = 'kebab-case-label'; - }); - - it('should return a list of one label', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual(['kebab-case-label']); - }); - }); - - describe('when the given labels is two kebab case labels separated with a comma', (): void => { - beforeEach((): void => { - labels = 'kebab-case-label-1,kebab-case-label-2'; - }); - - it('should return a list of two labels', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual([ - 'kebab-case-label-1', - 'kebab-case-label-2' - ]); - }); - }); - - describe('when the given labels is a multiple word label', (): void => { - beforeEach((): void => { - labels = 'label like a sentence'; - }); - - it('should return a list of one label', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual(['label like a sentence']); - }); - }); - - describe('when the given labels is two multiple word labels separated with a comma', (): void => { - beforeEach((): void => { - labels = 'label like a sentence, another label like a sentence'; - }); - - it('should return a list of two labels', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual([ - 'label like a sentence', - 'another label like a sentence' - ]); - }); - }); - - describe('when the given labels is a multiple word label with %20 spaces', (): void => { - beforeEach((): void => { - labels = 'label%20like%20a%20sentence'; - }); - - it('should return a list of one label', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual(['label%20like%20a%20sentence']); - }); - }); - - describe('when the given labels is two multiple word labels with %20 spaces separated with a comma', (): void => { - beforeEach((): void => { - labels = - 'label%20like%20a%20sentence,another%20label%20like%20a%20sentence'; - }); - - it('should return a list of two labels', (): void => { - expect.assertions(1); - - const result = labelsToList(labels); - - expect(result).toStrictEqual([ - 'label%20like%20a%20sentence', - 'another%20label%20like%20a%20sentence' - ]); - }); - }); -}); diff --git a/src/functions/labels-to-list.ts b/src/functions/labels-to-list.ts deleted file mode 100644 index 59ac5de62..000000000 --- a/src/functions/labels-to-list.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @description - * Transform a string of comma separated labels - * to an array of labels - * - * @example - * labelsToList('label') => ['label'] - * labelsToList('label,label') => ['label', 'label'] - * labelsToList('kebab-label') => ['kebab-label'] - * labelsToList('kebab%20label') => ['kebab%20label'] - * labelsToList('label with words') => ['label with words'] - * - * @param {Readonly} labels A string of comma separated labels - * - * @return {string[]} A list of labels - */ -export function labelsToList(labels: Readonly): string[] { - if (!labels.length) { - return []; - } - - return labels.split(',').map(l => l.trim()); -} diff --git a/src/functions/words-to-list.spec.ts b/src/functions/words-to-list.spec.ts new file mode 100644 index 000000000..06f75768e --- /dev/null +++ b/src/functions/words-to-list.spec.ts @@ -0,0 +1,137 @@ +import {wordsToList} from './words-to-list'; + +describe('wordsToList()', (): void => { + let words: string; + + describe('when the given words is empty', (): void => { + beforeEach((): void => { + words = ''; + }); + + it('should return an empty list of words', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual([]); + }); + }); + + describe('when the given words is a simple word', (): void => { + beforeEach((): void => { + words = 'word'; + }); + + it('should return a list of one word', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual(['word']); + }); + }); + + describe('when the given words is a word with extra spaces before and after', (): void => { + beforeEach((): void => { + words = ' word '; + }); + + it('should return a list of one word and remove all spaces before and after', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual(['word']); + }); + }); + + describe('when the given words is a kebab case word', (): void => { + beforeEach((): void => { + words = 'kebab-case-word'; + }); + + it('should return a list of one word', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual(['kebab-case-word']); + }); + }); + + describe('when the given words is two kebab case words separated with a comma', (): void => { + beforeEach((): void => { + words = 'kebab-case-word-1,kebab-case-word-2'; + }); + + it('should return a list of two words', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual(['kebab-case-word-1', 'kebab-case-word-2']); + }); + }); + + describe('when the given words is a multiple word word', (): void => { + beforeEach((): void => { + words = 'word like a sentence'; + }); + + it('should return a list of one word', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual(['word like a sentence']); + }); + }); + + describe('when the given words is two multiple word words separated with a comma', (): void => { + beforeEach((): void => { + words = 'word like a sentence, another word like a sentence'; + }); + + it('should return a list of two words', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual([ + 'word like a sentence', + 'another word like a sentence' + ]); + }); + }); + + describe('when the given words is a multiple word word with %20 spaces', (): void => { + beforeEach((): void => { + words = 'word%20like%20a%20sentence'; + }); + + it('should return a list of one word', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual(['word%20like%20a%20sentence']); + }); + }); + + describe('when the given words is two multiple word words with %20 spaces separated with a comma', (): void => { + beforeEach((): void => { + words = 'word%20like%20a%20sentence,another%20word%20like%20a%20sentence'; + }); + + it('should return a list of two words', (): void => { + expect.assertions(1); + + const result = wordsToList(words); + + expect(result).toStrictEqual([ + 'word%20like%20a%20sentence', + 'another%20word%20like%20a%20sentence' + ]); + }); + }); +}); diff --git a/src/functions/words-to-list.ts b/src/functions/words-to-list.ts new file mode 100644 index 000000000..8eb3701d3 --- /dev/null +++ b/src/functions/words-to-list.ts @@ -0,0 +1,23 @@ +/** + * @description + * Transform a string of comma separated words + * to an array of words + * + * @example + * wordsToList('label') => ['label'] + * wordsToList('label,label') => ['label', 'label'] + * wordsToList('kebab-label') => ['kebab-label'] + * wordsToList('kebab%20label') => ['kebab%20label'] + * wordsToList('label with words') => ['label with words'] + * + * @param {Readonly} words A string of comma separated words + * + * @return {string[]} A list of words + */ +export function wordsToList(words: Readonly): string[] { + if (!words.length) { + return []; + } + + return words.split(',').map((word: Readonly): string => word.trim()); +} diff --git a/src/interfaces/issue.ts b/src/interfaces/issue.ts new file mode 100644 index 000000000..e1c9a2ae6 --- /dev/null +++ b/src/interfaces/issue.ts @@ -0,0 +1,13 @@ +import {Label} from '../IssueProcessor'; +import {IMilestone} from './milestone'; + +export interface IIssue { + title: string; + number: number; + updated_at: string; + labels: Label[]; + pull_request: any; + state: string; + locked: boolean; + milestone: IMilestone; +} diff --git a/src/interfaces/milestone.ts b/src/interfaces/milestone.ts new file mode 100644 index 000000000..d0ff24251 --- /dev/null +++ b/src/interfaces/milestone.ts @@ -0,0 +1,3 @@ +export interface IMilestone { + title: string; +} diff --git a/src/main.ts b/src/main.ts index 2e44657da..cfe2acb99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,7 +52,10 @@ function getAndValidateArgs(): IssueProcessorOptions { startDate: core.getInput('start-date') !== '' ? core.getInput('start-date') - : undefined + : undefined, + exemptMilestones: core.getInput('exempt-milestones'), + exemptIssueMilestones: core.getInput('exempt-issue-milestones'), + exemptPrMilestones: core.getInput('exempt-pr-milestones') }; for (const numberInput of [