diff --git a/README.md b/README.md index 06c2a4723..373bf71a1 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Every argument is optional. | [ignore-updates](#ignore-updates) | Any update (update/comment) can reset the stale idle time on the issues/PRs | `false` | | [ignore-issue-updates](#ignore-issue-updates) | Override [ignore-updates](#ignore-updates) for issues only | | | [ignore-pr-updates](#ignore-pr-updates) | Override [ignore-updates](#ignore-updates) for PRs only | | +| [ignore-reactions](#ignore-reactions) | Any reaction can reset the stale idle time on the issues/PRs | | | [include-only-assigned](#include-only-assigned) | Process only assigned issues | `false` | ### List of output options @@ -541,6 +542,12 @@ Useful to override [ignore-updates](#ignore-updates) but only to ignore the upda Default value: unset +#### ignore-reactions + +If set to `false`, any reaction to an issue will be considered an update. + +Default value: unset + #### include-only-assigned If set to `true`, only the issues or the pull requests with an assignee will be marked as stale automatically. diff --git a/__tests__/any-of-labels.spec.ts b/__tests__/any-of-labels.spec.ts index d23242b31..4b64310ed 100644 --- a/__tests__/any-of-labels.spec.ts +++ b/__tests__/any-of-labels.spec.ts @@ -1144,6 +1144,7 @@ class IssuesProcessorBuilder { new StateMock(), async p => (p === 1 ? this._issues : []), async () => [], + async () => [], async () => new Date().toDateString() ); } diff --git a/__tests__/assignees.spec.ts b/__tests__/assignees.spec.ts index 94446b7ee..0b65782b5 100644 --- a/__tests__/assignees.spec.ts +++ b/__tests__/assignees.spec.ts @@ -53,6 +53,7 @@ describe('assignees options', (): void => { alwaysFalseStateMock, async p => (p === 1 ? testIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); }; diff --git a/__tests__/classes/issues-processor-mock.ts b/__tests__/classes/issues-processor-mock.ts index 3b2d488fe..2ed0eb6e4 100644 --- a/__tests__/classes/issues-processor-mock.ts +++ b/__tests__/classes/issues-processor-mock.ts @@ -1,6 +1,7 @@ import {Issue} from '../../src/classes/issue'; import {IssuesProcessor} from '../../src/classes/issues-processor'; import {IComment} from '../../src/interfaces/comment'; +import {IReaction} from '../../src/interfaces/reaction'; import {IIssuesProcessorOptions} from '../../src/interfaces/issues-processor-options'; import {IPullRequest} from '../../src/interfaces/pull-request'; import {IState} from '../../src/interfaces/state/state'; @@ -14,6 +15,10 @@ export class IssuesProcessorMock extends IssuesProcessor { issue: Issue, sinceDate: string ) => Promise, + listIssueReactions?: ( + issue: Issue, + sinceDate: string + ) => Promise, getLabelCreationDate?: ( issue: Issue, label: string @@ -30,6 +35,10 @@ export class IssuesProcessorMock extends IssuesProcessor { this.listIssueComments = listIssueComments; } + if (listIssueReactions) { + this.listIssueReactions = listIssueReactions; + } + if (getLabelCreationDate) { this.getLabelCreationDate = getLabelCreationDate; } diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 0265b6446..b171806a0 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -53,6 +53,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ ignoreUpdates: false, ignoreIssueUpdates: undefined, ignorePrUpdates: undefined, + ignoreReactions: undefined, exemptDraftPr: false, closeIssueReason: 'not_planned', includeOnlyAssigned: false diff --git a/__tests__/exempt-draft-pr.spec.ts b/__tests__/exempt-draft-pr.spec.ts index 49265a929..cbd87a1fc 100644 --- a/__tests__/exempt-draft-pr.spec.ts +++ b/__tests__/exempt-draft-pr.spec.ts @@ -128,6 +128,7 @@ class IssuesProcessorBuilder { alwaysFalseStateMock, async p => (p === 1 ? this._issues : []), async () => [], + async () => [], async () => new Date().toDateString(), async (): Promise => { return Promise.resolve({ diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 80d660e88..067d12c33 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -20,6 +20,7 @@ test('processing an issue with no label will make it stale and close it, if it i alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -52,6 +53,7 @@ test('processing an issue with no label and a start date as ECMAScript epoch in alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -84,6 +86,7 @@ test('processing an issue with no label and a start date as ECMAScript epoch in alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -116,6 +119,7 @@ test('processing an issue with no label and a start date as ECMAScript epoch in alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -148,6 +152,7 @@ test('processing an issue with no label and a start date as ECMAScript epoch in alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -180,6 +185,7 @@ test('processing an issue with no label and a start date as ISO 8601 being befor alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -212,6 +218,7 @@ test('processing an issue with no label and a start date as ISO 8601 being after alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -244,6 +251,7 @@ test('processing an issue with no label and a start date as RFC 2822 being befor alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -276,6 +284,7 @@ test('processing an issue with no label and a start date as RFC 2822 being after alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -300,6 +309,7 @@ test('processing an issue with no label will make it stale and close it, if it i alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -325,6 +335,7 @@ test('processing an issue with no label will make it stale and not close it, if alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -348,6 +359,7 @@ test('processing an issue with no label will make it stale and not close it if d alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -372,6 +384,7 @@ test('processing an issue with no label will make it stale and not close it if d alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -396,6 +409,7 @@ test('processing an issue with no label will not make it stale if days-before-st alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -421,6 +435,7 @@ test('processing an issue with no label will not make it stale if days-before-st alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -449,6 +464,7 @@ test('processing an issue with no label will make it stale but not close it', as alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -481,6 +497,7 @@ test('processing a stale issue will close it', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -513,6 +530,7 @@ test('processing a stale issue containing a space in the label will close it', a alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -545,6 +563,7 @@ test('processing a stale issue containing a slash in the label will close it', a alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -578,6 +597,7 @@ test('processing a stale issue will close it when days-before-issue-stale overri alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -610,6 +630,7 @@ test('processing a stale PR will close it', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -643,6 +664,7 @@ test('processing a stale PR will close it when days-before-pr-stale override day alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -676,6 +698,7 @@ test('processing a stale issue will close it even if configured not to mark as s alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -710,6 +733,7 @@ test('processing a stale issue will close it even if configured not to mark as s alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -743,6 +767,7 @@ test('processing a stale PR will close it even if configured not to mark as stal alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -777,6 +802,7 @@ test('processing a stale PR will close it even if configured not to mark as stal alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -834,6 +860,7 @@ test('stale closed issues will not be closed', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -863,6 +890,7 @@ test('closed prs will not be marked stale', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -892,6 +920,7 @@ test('stale closed prs will not be closed', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -950,6 +979,7 @@ test('stale locked issues will not be closed', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1008,6 +1038,7 @@ test('stale locked prs will not be closed', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1039,6 +1070,7 @@ test('exempt issue labels will not be marked stale', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1070,6 +1102,7 @@ test('exempt issue labels will not be marked stale (multi issue label with space alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1100,6 +1133,7 @@ test('exempt issue labels will not be marked stale (multi issue label)', async ( alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1150,6 +1184,7 @@ test('exempt pr labels will not be marked stale', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1199,6 +1234,7 @@ test('stale issues should not be closed if days is set to -1', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1236,6 +1272,7 @@ test('stale label should be removed if a comment was added to a stale issue', as body: 'Body' } ], // return a fake comment to indicate there was an update + async () => [], async () => new Date().toDateString() ); @@ -1247,6 +1284,122 @@ test('stale label should be removed if a comment was added to a stale issue', as expect(processor.removedLabelIssues).toHaveLength(1); }); +test('when the option "ignoreReactions" is set to false, stale label should be removed if a reaction was added to a stale issue', async () => { + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + ignoreReactions: false + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'An issue that should un-stale', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Stale'] + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => [ + { + content: '+1', + created_at: '2020-01-07T17:00:00Z' + } + ], // return a fake reaction to indicate there was an update + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.closedIssues).toHaveLength(0); + expect(processor.staleIssues).toHaveLength(0); + expect(processor.removedLabelIssues).toHaveLength(1); +}); + +test('when the option "ignoreReactions" is not set, stale label should not be removed if a reaction was added to a stale issue', async () => { + const opts = {...DefaultProcessorOptions, removeStaleWhenUpdated: true}; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'An issue that should un-stale', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Stale'] + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => [ + { + content: '+1', + created_at: '2020-01-07T17:00:00Z' + } + ], // return a fake reaction to indicate there was an update + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.closedIssues).toHaveLength(1); + expect(processor.staleIssues).toHaveLength(0); + expect(processor.removedLabelIssues).toHaveLength(0); +}); + +test('when the option "ignoreReactions" is set to true, stale label should not be removed if a reaction was added to a stale issue', async () => { + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + ignoreReactions: true + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'An issue that should un-stale', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Stale'] + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => [ + { + content: '+1', + created_at: '2020-01-07T17:00:00Z' + } + ], // return a fake reaction to indicate there was an update + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.closedIssues).toHaveLength(1); + expect(processor.staleIssues).toHaveLength(0); + expect(processor.removedLabelIssues).toHaveLength(0); +}); + test('when the option "labelsToAddWhenUnstale" is set, the labels should be added when unstale', async () => { expect.assertions(4); const opts = { @@ -1279,6 +1432,7 @@ test('when the option "labelsToAddWhenUnstale" is set, the labels should be adde body: 'Body' } ], // return a fake comment to indicate there was an update + async () => [], async () => new Date().toDateString() ); @@ -1325,6 +1479,7 @@ test('when the option "labelsToRemoveWhenStale" is set, the labels should be rem body: 'Body' } ], // return a fake comment to indicate there was an update + async () => [], async () => new Date().toDateString() ); @@ -1365,6 +1520,7 @@ test('stale label should not be removed if a comment was added by the bot (and t body: 'This issue is stale' } ], // return a fake comment to indicate there was an update by the bot + async () => [], async () => new Date().toDateString() ); @@ -1399,6 +1555,7 @@ test('stale label containing a space should be removed if a comment was added to alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [{user: {login: 'notme', type: 'User'}, body: 'Body'}], // return a fake comment to indicate there was an update + async () => [], async () => new Date().toDateString() ); @@ -1431,6 +1588,7 @@ test('stale issues should not be closed until after the closed number of days', alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1465,6 +1623,7 @@ test('stale issues should be closed if the closed nubmer of days (additive) is a alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1497,6 +1656,7 @@ test('stale issues should not be closed until after the closed number of days (l alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1530,6 +1690,7 @@ test('skips stale message on issues when stale-issue-message is empty', async () alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1575,6 +1736,7 @@ test('send stale message on issues when stale-issue-message is not empty', async alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1621,6 +1783,7 @@ test('skips stale message on prs when stale-pr-message is empty', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1667,6 +1830,7 @@ test('send stale message on prs when stale-pr-message is not empty', async () => alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1710,6 +1874,7 @@ test('git branch is deleted when option is enabled', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1741,6 +1906,7 @@ test('git branch is not deleted when issue is not pull request', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1774,6 +1940,7 @@ test('an issue without a milestone will be marked as stale', async () => { alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1809,6 +1976,7 @@ test('an issue without an exempted milestone will be marked as stale', async () alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1844,6 +2012,7 @@ test('an issue with an exempted milestone will not be marked as stale', async () alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1879,6 +2048,7 @@ test('an issue with an exempted milestone will not be marked as stale (multi mil alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1914,6 +2084,7 @@ test('an issue with an exempted milestone will not be marked as stale (multi mil alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1950,6 +2121,7 @@ test('an issue with an exempted milestone but without an exempted issue mileston alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -1986,6 +2158,7 @@ test('an issue with an exempted milestone but with another exempted issue milest alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2022,6 +2195,7 @@ test('an issue with an exempted milestone and with an exempted issue milestone w alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2050,6 +2224,7 @@ test('processing an issue opened since 2 days and with the option "daysBeforeIss alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2077,6 +2252,7 @@ test('processing an issue opened since 2 days and with the option "daysBeforeIss alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2104,6 +2280,7 @@ test('processing an issue opened since 2 days and with the option "daysBeforeIss alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2131,6 +2308,7 @@ test('processing an issue opened since 1 hour and with the option "daysBeforeIss alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toISOString() ); @@ -2158,6 +2336,7 @@ test('processing an issue opened since 4 hours and with the option "daysBeforeIs alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toISOString() ); @@ -2185,6 +2364,7 @@ test('processing an issue opened since 5 hours and with the option "daysBeforeIs alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toISOString() ); @@ -2220,6 +2400,7 @@ test('processing a pull request opened since 2 days and with the option "daysBef alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2255,6 +2436,7 @@ test('processing a pull request opened since 2 days and with the option "daysBef alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2290,6 +2472,7 @@ test('processing a pull request opened since 2 days and with the option "daysBef alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2325,6 +2508,7 @@ test('processing a pull request opened since 1 hour and with the option "daysBef alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toISOString() ); @@ -2360,6 +2544,7 @@ test('processing a pull request opened since 4 hours and with the option "daysBe alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toISOString() ); @@ -2395,6 +2580,7 @@ test('processing a pull request opened since 5 hours and with the option "daysBe alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toISOString() ); @@ -2433,6 +2619,7 @@ test('processing a previously closed issue with a close label will remove the cl alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2470,6 +2657,7 @@ test('processing a closed issue with a close label will not remove the close lab alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2507,6 +2695,7 @@ test('processing a locked issue with a close label will not remove the close lab alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2548,6 +2737,7 @@ test('processing an issue stale since less than the daysBeforeStale with a stale alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async (): Promise => Promise.resolve([]), + async () => [], async () => labelCreatedAt.toDateString() ); @@ -2590,6 +2780,7 @@ test('processing an issue stale since less than the daysBeforeStale without a st alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async (): Promise => Promise.resolve([]), + async () => [], async () => new Date().toDateString() ); @@ -2627,6 +2818,7 @@ test('processing a pull request to be stale with the "stalePrMessage" option set alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2664,6 +2856,7 @@ test('processing a pull request to be stale with the "stalePrMessage" option set alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2706,6 +2899,7 @@ test('processing an issue with the "includeOnlyAssigned" option and nonempty ass alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -2734,6 +2928,7 @@ test('processing an issue with the "includeOnlyAssigned" option set and no assig alwaysFalseStateMock, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); diff --git a/__tests__/milestones.spec.ts b/__tests__/milestones.spec.ts index 5acac1862..3cd38aa44 100644 --- a/__tests__/milestones.spec.ts +++ b/__tests__/milestones.spec.ts @@ -44,6 +44,7 @@ describe('milestones options', (): void => { alwaysFalseStateMock, async p => (p === 1 ? testIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); }; diff --git a/__tests__/only-labels.spec.ts b/__tests__/only-labels.spec.ts index e0142da2a..5d437b34f 100644 --- a/__tests__/only-labels.spec.ts +++ b/__tests__/only-labels.spec.ts @@ -1144,6 +1144,7 @@ class IssuesProcessorBuilder { alwaysFalseStateMock, async p => (p === 1 ? this._issues : []), async () => [], + async () => [], async () => new Date().toDateString() ); } diff --git a/__tests__/operations-per-run.spec.ts b/__tests__/operations-per-run.spec.ts index 6be0a3632..c0497021c 100644 --- a/__tests__/operations-per-run.spec.ts +++ b/__tests__/operations-per-run.spec.ts @@ -209,6 +209,7 @@ class SUT { alwaysFalseStateMock, async p => (p === 1 ? this._testIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); diff --git a/__tests__/remove-stale-when-updated.spec.ts b/__tests__/remove-stale-when-updated.spec.ts index 05f7b3041..1e1590f32 100644 --- a/__tests__/remove-stale-when-updated.spec.ts +++ b/__tests__/remove-stale-when-updated.spec.ts @@ -555,6 +555,7 @@ class IssuesProcessorBuilder { body: 'body' } ], + async () => [], async () => new Date().toDateString() ); } diff --git a/__tests__/state.spec.ts b/__tests__/state.spec.ts index 8c59d8614..0db4e54e0 100644 --- a/__tests__/state.spec.ts +++ b/__tests__/state.spec.ts @@ -51,6 +51,7 @@ describe('state', (): void => { state, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -78,6 +79,7 @@ describe('state', (): void => { state, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -117,6 +119,7 @@ describe('state', (): void => { state, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -159,6 +162,7 @@ describe('state', (): void => { state, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); @@ -197,12 +201,13 @@ describe('state', (): void => { state, async p => (p === 1 ? TestIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); await processor.processIssues(1); // make sure all issues are proceeded - expect(infoSpy.mock.calls[71][0]).toContain( + expect(infoSpy.mock.calls[73][0]).toContain( 'No more issues found to process. Exiting...' ); diff --git a/__tests__/updates-reset-stale.spec.ts b/__tests__/updates-reset-stale.spec.ts index ebc494d51..9475a8974 100644 --- a/__tests__/updates-reset-stale.spec.ts +++ b/__tests__/updates-reset-stale.spec.ts @@ -691,6 +691,7 @@ class SUT { alwaysFalseStateMock, async p => (p === 1 ? this._testIssueList : []), async () => [], + async () => [], async () => new Date().toDateString() ); diff --git a/action.yml b/action.yml index 251c93925..a16ed801e 100644 --- a/action.yml +++ b/action.yml @@ -200,6 +200,10 @@ inputs: description: 'Any update (update/comment) can reset the stale idle time on the pull requests. Override "ignore-updates" option regarding only the pull requests.' default: '' required: false + ignore-reactions: + description: 'Any reaction can reset the stale idle time on the issues/PRs.' + default: '' + required: false include-only-assigned: description: 'Only the issues or the pull requests with an assignee will be marked as stale automatically.' default: 'false' diff --git a/dist/index.js b/dist/index.js index cfcb005d8..7a27c4fd4 100644 --- a/dist/index.js +++ b/dist/index.js @@ -672,6 +672,38 @@ class IssuesProcessor { } }); } + // Grab reactions for an issue since a given date + listIssueReactions(issue, sinceDate) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + try { + this._consumeIssueOperation(issue); + (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsReactionsCount(); + let reactions = []; + let currentReactions = []; + let iterator = 1; + const daysSinceLastUpdated = (new Date().getTime() - new Date(sinceDate).getTime()) / + (1000 * 60 * 60 * 24); + do { + const response = yield this.client.rest.reactions.listForIssue({ + owner: github_1.context.repo.owner, + repo: github_1.context.repo.repo, + issue_number: issue.number, + per_page: 100, + page: iterator + }); + currentReactions = response.data; + reactions = [...reactions, ...currentReactions]; + iterator++; + } while (currentReactions.length !== 0); + return reactions.filter(reaction => IssuesProcessor._updatedSince(reaction.created_at, daysSinceLastUpdated)); + } + catch (error) { + this._logger.error(`List issue reactions error: ${error.message}`); + return Promise.resolve([]); + } + }); + } // grab issues from github in batches of 100 getIssues(page) { var _a; @@ -759,6 +791,8 @@ class IssuesProcessor { issueLogger.info(`$$type marked stale on: ${logger_service_1.LoggerService.cyan(markedStaleOn)}`); const issueHasCommentsSinceStale = yield this._hasCommentsSince(issue, markedStaleOn, staleMessage); issueLogger.info(`$$type has been commented on: ${logger_service_1.LoggerService.cyan(issueHasCommentsSinceStale)}`); + const issueHasReactionsSinceStale = yield this._hasReactionsSince(issue, markedStaleOn, this.options.ignoreReactions); + issueLogger.info(`$$type had a reaction: ${logger_service_1.LoggerService.cyan(issueHasReactionsSinceStale)}`); const daysBeforeClose = issue.isPullRequest ? this._getDaysBeforePrClose() : this._getDaysBeforeIssueClose(); @@ -781,7 +815,11 @@ class IssuesProcessor { issueLogger.info(`$$type has been updated since it was marked stale: ${logger_service_1.LoggerService.cyan(issueHasUpdateSinceStale)}`); // Should we un-stale this issue? if (shouldRemoveStaleWhenUpdated && - (issueHasUpdateSinceStale || issueHasCommentsSinceStale) && + (issueHasUpdateSinceStale || + issueHasCommentsSinceStale || + (this.options.ignoreReactions === false + ? issueHasReactionsSinceStale + : false)) && !issue.markedStaleThisRun) { issueLogger.info(`Remove the stale label since the $$type has been updated and the workflow should remove the stale label when updated`); yield this._removeStaleLabel(issue, staleLabel); @@ -797,7 +835,11 @@ class IssuesProcessor { } const issueHasUpdateInCloseWindow = IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose); issueLogger.info(`$$type has been updated in the last ${daysBeforeClose} days: ${logger_service_1.LoggerService.cyan(issueHasUpdateInCloseWindow)}`); - if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) { + if (!issueHasCommentsSinceStale && + !issueHasUpdateInCloseWindow && + (this.options.ignoreReactions === false + ? !issueHasReactionsSinceStale + : true)) { issueLogger.info(`Closing $$type because it was last updated on: ${logger_service_1.LoggerService.cyan(issue.updated_at)}`); yield this._closeIssue(issue, closeMessage, closeLabel); if (this.options.deleteBranch && issue.pull_request) { @@ -831,6 +873,21 @@ class IssuesProcessor { return filteredComments.length > 0; }); } + // find any reactions since the date + _hasReactionsSince(issue, sinceDate, ignoreReactions) { + return __awaiter(this, void 0, void 0, function* () { + const issueLogger = new issue_logger_1.IssueLogger(issue); + if (!sinceDate) { + return true; + } + if (ignoreReactions === true || ignoreReactions === undefined) { + return false; + } + issueLogger.info(`Checking for reactions on $$type since: ${logger_service_1.LoggerService.cyan(sinceDate)}`); + const reactions = yield this.listIssueReactions(issue, sinceDate); + return reactions.length > 0; + }); + } // Mark an issue as stale with a comment and a label _markStale(issue, staleMessage, staleLabel, skipMessage) { var _a, _b, _c; @@ -1830,6 +1887,7 @@ class Statistics { this.fetchedItemsCount = 0; this.fetchedItemsEventsCount = 0; this.fetchedItemsCommentsCount = 0; + this.fetchedItemsReactionsCount = 0; this.fetchedPullRequestsCount = 0; } incrementProcessedItemsCount(issue, increment = 1) { @@ -1900,6 +1958,10 @@ class Statistics { this.fetchedItemsCommentsCount += increment; return this; } + incrementFetchedItemsReactionsCount(increment = 1) { + this.fetchedItemsReactionsCount += increment; + return this; + } incrementFetchedPullRequestsCount(increment = 1) { this.fetchedPullRequestsCount += increment; return this; @@ -1918,6 +1980,7 @@ class Statistics { this._logFetchedItemsCount(); this._logFetchedItemsEventsCount(); this._logFetchedItemsCommentsCount(); + this._logFetchedItemsReactionsCount(); this._logFetchedPullRequestsCount(); this._logOperationsCount(); return this; @@ -2094,6 +2157,9 @@ class Statistics { _logFetchedItemsCommentsCount() { this._logCount('Fetched items comments', this.fetchedItemsCommentsCount); } + _logFetchedItemsReactionsCount() { + this._logCount('Fetched items reactions', this.fetchedItemsReactionsCount); + } _logFetchedPullRequestsCount() { this._logCount('Fetched pull requests', this.fetchedPullRequestsCount); } @@ -2220,6 +2286,7 @@ var Option; Option["IgnoreUpdates"] = "ignore-updates"; Option["IgnoreIssueUpdates"] = "ignore-issue-updates"; Option["IgnorePrUpdates"] = "ignore-pr-updates"; + Option["IgnoreReactions"] = "ignore-reactions"; Option["ExemptDraftPr"] = "exempt-draft-pr"; Option["CloseIssueReason"] = "close-issue-reason"; })(Option || (exports.Option = Option = {})); @@ -2565,6 +2632,7 @@ function _getAndValidateArgs() { ignoreUpdates: core.getInput('ignore-updates') === 'true', ignoreIssueUpdates: _toOptionalBoolean('ignore-issue-updates'), ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), + ignoreReactions: _toOptionalBoolean('ignore-reactions'), exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true' diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index a2c82e268..f8afe0025 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -62,6 +62,7 @@ describe('Issue', (): void => { ignoreUpdates: false, ignoreIssueUpdates: undefined, ignorePrUpdates: undefined, + ignoreReactions: undefined, exemptDraftPr: false, closeIssueReason: '', includeOnlyAssigned: false diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 31bbb99d6..630f1794c 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -14,6 +14,7 @@ import {IComment} from '../interfaces/comment'; import {IIssueEvent} from '../interfaces/issue-event'; import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; import {IPullRequest} from '../interfaces/pull-request'; +import {IReaction} from '../interfaces/reaction'; import {Assignees} from './assignees'; import {IgnoreUpdates} from './ignore-updates'; import {ExemptDraftPullRequest} from './exempt-draft-pull-request'; @@ -560,6 +561,41 @@ export class IssuesProcessor { } } + // Grab reactions for an issue since a given date + async listIssueReactions( + issue: Readonly, + sinceDate: Readonly + ): Promise { + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedItemsReactionsCount(); + let reactions: IReaction[] = []; + let currentReactions: IReaction[] = []; + let iterator = 1; + const daysSinceLastUpdated = + (new Date().getTime() - new Date(sinceDate).getTime()) / + (1000 * 60 * 60 * 24); + do { + const response = await this.client.rest.reactions.listForIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100, + page: iterator + }); + currentReactions = response.data; + reactions = [...reactions, ...currentReactions]; + iterator++; + } while (currentReactions.length !== 0); + return reactions.filter(reaction => + IssuesProcessor._updatedSince(reaction.created_at, daysSinceLastUpdated) + ); + } catch (error) { + this._logger.error(`List issue reactions error: ${error.message}`); + return Promise.resolve([]); + } + } + // grab issues from github in batches of 100 async getIssues(page: number): Promise { try { @@ -677,6 +713,17 @@ export class IssuesProcessor { )}` ); + const issueHasReactionsSinceStale: boolean = await this._hasReactionsSince( + issue, + markedStaleOn, + this.options.ignoreReactions + ); + issueLogger.info( + `$$type had a reaction: ${LoggerService.cyan( + issueHasReactionsSinceStale + )}` + ); + const daysBeforeClose: number = issue.isPullRequest ? this._getDaysBeforePrClose() : this._getDaysBeforeIssueClose(); @@ -728,7 +775,11 @@ export class IssuesProcessor { // Should we un-stale this issue? if ( shouldRemoveStaleWhenUpdated && - (issueHasUpdateSinceStale || issueHasCommentsSinceStale) && + (issueHasUpdateSinceStale || + issueHasCommentsSinceStale || + (this.options.ignoreReactions === false + ? issueHasReactionsSinceStale + : false)) && !issue.markedStaleThisRun ) { issueLogger.info( @@ -764,7 +815,13 @@ export class IssuesProcessor { )}` ); - if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) { + if ( + !issueHasCommentsSinceStale && + !issueHasUpdateInCloseWindow && + (this.options.ignoreReactions === false + ? !issueHasReactionsSinceStale + : true) + ) { issueLogger.info( `Closing $$type because it was last updated on: ${LoggerService.cyan( issue.updated_at @@ -823,6 +880,30 @@ export class IssuesProcessor { return filteredComments.length > 0; } + // find any reactions since the date + private async _hasReactionsSince( + issue: Issue, + sinceDate: string, + ignoreReactions: boolean | undefined + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + if (!sinceDate) { + return true; + } + + if (ignoreReactions === true || ignoreReactions === undefined) { + return false; + } + + issueLogger.info( + `Checking for reactions on $$type since: ${LoggerService.cyan(sinceDate)}` + ); + const reactions = await this.listIssueReactions(issue, sinceDate); + + return reactions.length > 0; + } + // Mark an issue as stale with a comment and a label private async _markStale( issue: Issue, diff --git a/src/classes/statistics.ts b/src/classes/statistics.ts index 321ea70d9..dcf06e45b 100644 --- a/src/classes/statistics.ts +++ b/src/classes/statistics.ts @@ -30,6 +30,7 @@ export class Statistics { fetchedItemsCount = 0; fetchedItemsEventsCount = 0; fetchedItemsCommentsCount = 0; + fetchedItemsReactionsCount = 0; fetchedPullRequestsCount = 0; incrementProcessedItemsCount( @@ -154,6 +155,14 @@ export class Statistics { return this; } + incrementFetchedItemsReactionsCount( + increment: Readonly = 1 + ): Statistics { + this.fetchedItemsReactionsCount += increment; + + return this; + } + incrementFetchedPullRequestsCount( increment: Readonly = 1 ): Statistics { @@ -176,6 +185,7 @@ export class Statistics { this._logFetchedItemsCount(); this._logFetchedItemsEventsCount(); this._logFetchedItemsCommentsCount(); + this._logFetchedItemsReactionsCount(); this._logFetchedPullRequestsCount(); this._logOperationsCount(); @@ -430,6 +440,10 @@ export class Statistics { this._logCount('Fetched items comments', this.fetchedItemsCommentsCount); } + private _logFetchedItemsReactionsCount(): void { + this._logCount('Fetched items reactions', this.fetchedItemsReactionsCount); + } + private _logFetchedPullRequestsCount(): void { this._logCount('Fetched pull requests', this.fetchedPullRequestsCount); } diff --git a/src/enums/option.ts b/src/enums/option.ts index 7a9bff026..265de4acb 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -47,6 +47,7 @@ export enum Option { IgnoreUpdates = 'ignore-updates', IgnoreIssueUpdates = 'ignore-issue-updates', IgnorePrUpdates = 'ignore-pr-updates', + IgnoreReactions = 'ignore-reactions', ExemptDraftPr = 'exempt-draft-pr', CloseIssueReason = 'close-issue-reason' } diff --git a/src/interfaces/issue.ts b/src/interfaces/issue.ts index defdb75d6..4e3b00b80 100644 --- a/src/interfaces/issue.ts +++ b/src/interfaces/issue.ts @@ -2,6 +2,7 @@ import {IsoDateString} from '../types/iso-date-string'; import {Assignee} from './assignee'; import {ILabel} from './label'; import {IMilestone} from './milestone'; +import {IReaction} from './reaction'; import {components} from '@octokit/openapi-types'; export interface IIssue { title: string; @@ -15,6 +16,7 @@ export interface IIssue { locked: boolean; milestone?: IMilestone | null; assignees?: Assignee[] | null; + reactions?: IReaction[] | null; } export type OctokitIssue = components['schemas']['issue']; diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 930992284..54804fa40 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -51,6 +51,7 @@ export interface IIssuesProcessorOptions { ignoreUpdates: boolean; ignoreIssueUpdates: boolean | undefined; ignorePrUpdates: boolean | undefined; + ignoreReactions: boolean | undefined; exemptDraftPr: boolean; closeIssueReason: string; includeOnlyAssigned: boolean; diff --git a/src/interfaces/reaction.ts b/src/interfaces/reaction.ts new file mode 100644 index 000000000..13af88fde --- /dev/null +++ b/src/interfaces/reaction.ts @@ -0,0 +1,4 @@ +export interface IReaction { + content: string; + created_at: string; +} diff --git a/src/main.ts b/src/main.ts index a7836c160..8ce5bd96f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -121,6 +121,7 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { ignoreUpdates: core.getInput('ignore-updates') === 'true', ignoreIssueUpdates: _toOptionalBoolean('ignore-issue-updates'), ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), + ignoreReactions: _toOptionalBoolean('ignore-reactions'), exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true'