diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 0265b6446..5d61c1300 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -6,18 +6,25 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ repoToken: 'none', staleIssueMessage: 'This issue is stale', stalePrMessage: 'This PR is stale', + rottenIssueMessage: 'This issue is rotten', + rottenPrMessage: 'This PR is rotten', closeIssueMessage: 'This issue is being closed', closePrMessage: 'This PR is being closed', daysBeforeStale: 1, + daysBeforeRotten: -1, daysBeforeIssueStale: NaN, daysBeforePrStale: NaN, + daysBeforeIssueRotten: NaN, + daysBeforePrRotten: NaN, daysBeforeClose: 30, daysBeforeIssueClose: NaN, daysBeforePrClose: NaN, staleIssueLabel: 'Stale', + rottenIssueLabel: 'Rotten', closeIssueLabel: '', exemptIssueLabels: '', stalePrLabel: 'Stale', + rottenPrLabel: 'Rotten', closePrLabel: '', exemptPrLabels: '', onlyLabels: '', @@ -31,6 +38,9 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ removeStaleWhenUpdated: false, removeIssueStaleWhenUpdated: undefined, removePrStaleWhenUpdated: undefined, + removeRottenWhenUpdated: false, + removeIssueRottenWhenUpdated: undefined, + removePrRottenWhenUpdated: undefined, ascending: false, deleteBranch: false, startDate: '', @@ -50,6 +60,9 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ labelsToRemoveWhenStale: '', labelsToRemoveWhenUnstale: '', labelsToAddWhenUnstale: '', + labelsToRemoveWhenRotten: '', + labelsToRemoveWhenUnrotten: '', + labelsToAddWhenUnrotten: '', ignoreUpdates: false, ignoreIssueUpdates: undefined, ignorePrUpdates: undefined, diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 80d660e88..d540b3e48 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -159,11 +159,12 @@ test('processing an issue with no label and a start date as ECMAScript epoch in }); test('processing an issue with no label and a start date as ISO 8601 being before the issue creation date will make it stale and close it when it is old enough and days-before-close is set to 0', async () => { - expect.assertions(2); + expect.assertions(3); const january2000 = '2000-01-01T00:00:00Z'; const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 0, + daysBeforeRotten: 0, startDate: january2000.toString() }; const TestIssueList: Issue[] = [ @@ -187,6 +188,7 @@ test('processing an issue with no label and a start date as ISO 8601 being befor await processor.processIssues(1); expect(processor.staleIssues.length).toStrictEqual(1); + expect(processor.rottenIssues.length).toStrictEqual(1); expect(processor.closedIssues.length).toStrictEqual(1); }); @@ -222,6 +224,39 @@ test('processing an issue with no label and a start date as ISO 8601 being after expect(processor.closedIssues.length).toStrictEqual(0); }); +test('processing an issue with no label and a start date as ISO 8601 being after the issue creation date will not make it stale , rotten or close it when it is old enough and days-before-close is set to 0', async () => { + expect.assertions(3); + const january2021 = '2021-01-01T00:00:00Z'; + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeClose: 0, + startDate: january2021.toString() + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'An issue with no label', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z' + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues.length).toStrictEqual(0); + expect(processor.rottenIssues.length).toStrictEqual(0); + expect(processor.closedIssues.length).toStrictEqual(0); +}); + test('processing an issue with no label and a start date as RFC 2822 being before the issue creation date will make it stale and close it when it is old enough and days-before-close is set to 0', async () => { expect.assertions(2); const january2000 = 'January 1, 2000 00:00:00'; @@ -290,6 +325,7 @@ test('processing an issue with no label will make it stale and close it, if it i const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 1, + daysBeforeRotten: 0, daysBeforeIssueClose: 0 }; const TestIssueList: Issue[] = [ @@ -307,6 +343,7 @@ test('processing an issue with no label will make it stale and close it, if it i await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); expect(processor.closedIssues).toHaveLength(1); expect(processor.deletedBranchIssues).toHaveLength(0); }); @@ -459,10 +496,11 @@ test('processing an issue with no label will make it stale but not close it', as expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale issue will close it', async () => { +test('processing a stale issue will rot it but not close it, given days before rotten is > -1', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - daysBeforeClose: 30 + daysBeforeClose: 30, + daysBeforeRotten: 0 }; const TestIssueList: Issue[] = [ generateIssue( @@ -488,13 +526,15 @@ test('processing a stale issue will close it', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale issue containing a space in the label will close it', async () => { +test('processing a stale issue containing a space in the label will rotten it but not close it, given days before rotten is > -1', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - staleIssueLabel: 'state: stale' + staleIssueLabel: 'state: stale', + daysBeforeRotten: 0 }; const TestIssueList: Issue[] = [ generateIssue( @@ -520,13 +560,15 @@ test('processing a stale issue containing a space in the label will close it', a await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale issue containing a slash in the label will close it', async () => { +test('processing a stale issue containing a slash in the label will rotten it but not close it', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - staleIssueLabel: 'lifecycle/stale' + staleIssueLabel: 'lifecycle/stale', + daysBeforeRotten: 0 }; const TestIssueList: Issue[] = [ generateIssue( @@ -552,20 +594,21 @@ test('processing a stale issue containing a slash in the label will close it', a await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale issue will close it when days-before-issue-stale override days-before-stale', async () => { +test('processing a stale issue will rotten it but not close it when days-before-issue-rotten override days-before-rotten', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - daysBeforeClose: 30, - daysBeforeIssueStale: 30 + daysBeforeRotten: -1, + daysBeforeIssueRotten: 30 }; const TestIssueList: Issue[] = [ generateIssue( opts, 1, - 'A stale issue that should be closed', + 'A stale issue that should be rotten', '2020-01-01T17:00:00Z', '2020-01-01T17:00:00Z', false, @@ -585,13 +628,14 @@ test('processing a stale issue will close it when days-before-issue-stale overri await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale PR will close it', async () => { +test('processing a stale PR will rotten it but not close it', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - daysBeforeClose: 30 + daysBeforePrRotten: 30 }; const TestIssueList: Issue[] = [ generateIssue( @@ -617,13 +661,49 @@ test('processing a stale PR will close it', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale PR will close it when days-before-pr-stale override days-before-stale', async () => { +test('processing a stale PR will rotten it it when days-before-pr-rotten override days-before-rotten', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeRotten: 30, + daysBeforePrRotten: 30 + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'A stale PR that should be closed', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + true, + ['Stale'] + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); +}); + +test('processing a stale PR will rotten it but not close it when days-before-pr-stale override days-before-stale', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, daysBeforeClose: 30, + daysBeforeRotten: 0, daysBeforePrClose: 30 }; const TestIssueList: Issue[] = [ @@ -650,13 +730,15 @@ test('processing a stale PR will close it when days-before-pr-stale override day await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale issue will close it even if configured not to mark as stale', async () => { +test('processing a stale issue will rotten it even if configured not to mark as stale', async () => { const opts = { ...DefaultProcessorOptions, daysBeforeStale: -1, + daysBeforeRotten: 0, staleIssueMessage: '' }; const TestIssueList: Issue[] = [ @@ -683,13 +765,16 @@ test('processing a stale issue will close it even if configured not to mark as s await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); 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, + daysBeforeRotten: 0, + daysBeforeIssueStale: -1, staleIssueMessage: '' }; @@ -717,13 +802,15 @@ test('processing a stale issue will close it even if configured not to mark as s await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('processing a stale PR will close it even if configured not to mark as stale', async () => { +test('processing a stale PR will rotten it even if configured not to mark as stale', async () => { const opts = { ...DefaultProcessorOptions, daysBeforeStale: -1, + daysBeforeRotten: 0, stalePrMessage: '' }; const TestIssueList: Issue[] = [ @@ -750,14 +837,52 @@ test('processing a stale PR will close it even if configured not to mark as stal await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); +}); + +test('processing a stale PR will close it even if configured not to mark as stale or rotten', async () => { + const opts = { + ...DefaultProcessorOptions, + daysBeforeStale: -1, + stalePrMessage: '', + daysBeforeRotten: -1 + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'An issue with no label', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + true, + ['Stale'] + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(1); }); -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 () => { +test('processing a stale PR will rotten 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, + daysBeforeRotten: 0, + stalePrMessage: '' }; const TestIssueList: Issue[] = [ @@ -784,10 +909,11 @@ test('processing a stale PR will close it even if configured not to mark as stal await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); }); -test('closed issues will not be marked stale', async () => { +test('closed issues will not be marked stale or rotten', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -812,10 +938,41 @@ test('closed issues will not be marked stale', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); -test('stale closed issues will not be closed', async () => { +test('rotten closed issues will not be closed', async () => { + const TestIssueList: Issue[] = [ + generateIssue( + DefaultProcessorOptions, + 1, + 'A rotten closed issue', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Rotten'], + true + ) + ]; + const processor = new IssuesProcessorMock( + DefaultProcessorOptions, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); + expect(processor.closedIssues).toHaveLength(0); +}); + +test('stale closed issues will not be closed or rotten', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -841,10 +998,11 @@ test('stale closed issues will not be closed', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); -test('closed prs will not be marked stale', async () => { +test('closed prs will not be marked stale or rotten', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -870,6 +1028,7 @@ test('closed prs will not be marked stale', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); @@ -902,7 +1061,7 @@ test('stale closed prs will not be closed', async () => { expect(processor.closedIssues).toHaveLength(0); }); -test('locked issues will not be marked stale', async () => { +test('locked issues will not be marked stale or rotten', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -927,10 +1086,11 @@ test('locked issues will not be marked stale', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); -test('stale locked issues will not be closed', async () => { +test('stale locked issues will not be rotten or closed', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -957,10 +1117,42 @@ test('stale locked issues will not be closed', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); -test('locked prs will not be marked stale', async () => { +test('rotten locked issues will not be rotten or closed', async () => { + const TestIssueList: Issue[] = [ + generateIssue( + DefaultProcessorOptions, + 1, + 'A stale locked issue that will not be closed', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + false, + ['Rotten'], + false, + true + ) + ]; + const processor = new IssuesProcessorMock( + DefaultProcessorOptions, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); + expect(processor.closedIssues).toHaveLength(0); +}); + +test('locked prs will not be marked stale or rotten', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -985,10 +1177,11 @@ test('locked prs will not be marked stale', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); -test('stale locked prs will not be closed', async () => { +test('stale locked prs will not be rotten or closed', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -1015,11 +1208,12 @@ test('stale locked prs will not be closed', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); -test('exempt issue labels will not be marked stale', async () => { - expect.assertions(3); +test('exempt issue labels will not be marked stale or rotten', async () => { + expect.assertions(4); const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt'; const TestIssueList: Issue[] = [ @@ -1046,11 +1240,12 @@ test('exempt issue labels will not be marked stale', async () => { await processor.processIssues(1); expect(processor.staleIssues.length).toStrictEqual(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues.length).toStrictEqual(0); expect(processor.removedLabelIssues.length).toStrictEqual(0); }); -test('exempt issue labels will not be marked stale (multi issue label with spaces)', async () => { +test('exempt issue labels will not be marked stale or rotten (multi issue label with spaces)', async () => { const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt, Cool, None'; const TestIssueList: Issue[] = [ @@ -1077,6 +1272,7 @@ test('exempt issue labels will not be marked stale (multi issue label with space await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.closedIssues).toHaveLength(0); }); @@ -1210,7 +1406,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, removeStaleWhenUpdated: true}; + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + daysBeforeRotten: 0 + }; const TestIssueList: Issue[] = [ generateIssue( opts, @@ -1337,8 +1537,12 @@ test('when the option "labelsToRemoveWhenStale" is set, the labels should be rem expect(processor.removedLabelIssues).toHaveLength(1); }); -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, removeStaleWhenUpdated: true}; +test('stale label should not be removed if a comment was added by the bot, given that it does not get rotten', async () => { + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + daysBeforeRotten: -1 + }; github.context.actor = 'abot'; const TestIssueList: Issue[] = [ generateIssue( @@ -1372,6 +1576,7 @@ test('stale label should not be removed if a comment was added by the bot (and t await processor.processIssues(1); expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.staleIssues).toHaveLength(0); expect(processor.removedLabelIssues).toHaveLength(0); }); @@ -1442,10 +1647,10 @@ test('stale issues should not be closed until after the closed number of days', expect(processor.staleIssues).toHaveLength(1); }); -test('stale issues should be closed if the closed nubmer of days (additive) is also passed', async () => { +test('stale issues should be rotten if the rotten 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 + opts.daysBeforeRotten = 1; // rotten after 6 days const lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 7); const TestIssueList: Issue[] = [ @@ -1471,8 +1676,9 @@ test('stale issues should be closed if the closed nubmer of days (additive) is a // process our fake issue list await processor.processIssues(1); - expect(processor.closedIssues).toHaveLength(1); - expect(processor.removedLabelIssues).toHaveLength(0); + expect(processor.closedIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(1); + expect(processor.removedLabelIssues).toHaveLength(1); // the stale label should be removed on rotten label being added expect(processor.staleIssues).toHaveLength(0); }); @@ -1690,8 +1896,12 @@ test('send stale message on prs when stale-pr-message is not empty', async () => ); }); -test('git branch is deleted when option is enabled', async () => { - const opts = {...DefaultProcessorOptions, deleteBranch: true}; +test('git branch is deleted when option is enabled and days before rotten is set to -1', async () => { + const opts = { + ...DefaultProcessorOptions, + deleteBranch: true, + daysBeforeRotten: -1 + }; const isPullRequest = true; const TestIssueList: Issue[] = [ generateIssue( @@ -1721,8 +1931,12 @@ test('git branch is deleted when option is enabled', async () => { expect(processor.deletedBranchIssues).toHaveLength(1); }); -test('git branch is not deleted when issue is not pull request', async () => { - const opts = {...DefaultProcessorOptions, deleteBranch: true}; +test('git branch is not deleted when issue is not pull request and days before rotten is set to -1', async () => { + const opts = { + ...DefaultProcessorOptions, + deleteBranch: true, + daysBeforeRotten: -1 + }; const isPullRequest = false; const TestIssueList: Issue[] = [ generateIssue( @@ -2516,13 +2730,14 @@ test('processing a locked issue with a close label will not remove the close lab expect(processor.removedLabelIssues).toHaveLength(0); }); -test('processing an issue stale since less than the daysBeforeStale with a stale label created after daysBeforeClose should close the issue', async () => { - expect.assertions(3); +test('processing an issue stale since less than the daysBeforeStale with a stale label created after daysBeforeRotten should rotten the issue', async () => { + expect.assertions(4); const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, staleIssueLabel: 'stale-label', daysBeforeStale: 30, daysBeforeClose: 7, + daysBeforeRotten: 0, closeIssueMessage: 'close message', removeStaleWhenUpdated: false }; @@ -2554,9 +2769,10 @@ test('processing an issue stale since less than the daysBeforeStale with a stale // process our fake issue list await processor.processIssues(1); - expect(processor.removedLabelIssues).toHaveLength(0); + expect(processor.removedLabelIssues).toHaveLength(1); // The stale label should be removed on adding the rotten label + expect(processor.rottenIssues).toHaveLength(1); // Expected at 0 by the user expect(processor.deletedBranchIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(1); // Expected at 0 by the user + expect(processor.closedIssues).toHaveLength(0); }); test('processing an issue stale since less than the daysBeforeStale without a stale label should close the issue', async () => { @@ -2566,6 +2782,8 @@ test('processing an issue stale since less than the daysBeforeStale without a st staleIssueLabel: 'stale-label', daysBeforeStale: 30, daysBeforeClose: 7, + daysBeforeRotten: 0, + closeIssueMessage: 'close message', removeStaleWhenUpdated: false }; @@ -2601,13 +2819,14 @@ test('processing an issue stale since less than the daysBeforeStale without a st expect(processor.closedIssues).toHaveLength(0); }); -test('processing a pull request to be stale with the "stalePrMessage" option set will send a PR comment', async () => { +test('processing a pull request to be stale with the "stalePrMessage" option set will send a PR comment, given that days before rotten is set to -1', async () => { expect.assertions(3); const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, stalePrMessage: 'This PR is stale', daysBeforeStale: 10, - daysBeforePrStale: 1 + daysBeforePrStale: 1, + daysBeforeRotten: -1 }; const issueDate = new Date(); issueDate.setDate(issueDate.getDate() - 2); @@ -2638,12 +2857,52 @@ test('processing a pull request to be stale with the "stalePrMessage" option set expect(processor.statistics?.addedPullRequestsCommentsCount).toStrictEqual(1); }); -test('processing a pull request to be stale with the "stalePrMessage" option set to empty will not send a PR comment', async () => { +test('processing a pull request to be stale with the "stalePrMessage" option set will send two PR comments, given that days before rotten is set to 0', async () => { + expect.assertions(3); + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + stalePrMessage: 'This PR is stale', + daysBeforeStale: 10, + daysBeforePrStale: 1, + daysBeforeRotten: 0 + }; + const issueDate = new Date(); + issueDate.setDate(issueDate.getDate() - 2); + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'A pull request with no label and a stale message', + issueDate.toDateString(), + issueDate.toDateString(), + false, + true + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); + expect(processor.statistics?.addedPullRequestsCommentsCount).toStrictEqual(2); +}); + +test('processing a pull request to be stale with the "stalePrMessage" option set to empty will not send a PR comment, given that "rottenPRMessage" is also an empty string and days before rotten is not -1', async () => { expect.assertions(3); const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, stalePrMessage: '', + rottenPrMessage: '', daysBeforeStale: 10, + daysBeforeRotten: 0, daysBeforePrStale: 1 }; const issueDate = new Date(); @@ -2675,6 +2934,45 @@ test('processing a pull request to be stale with the "stalePrMessage" option set expect(processor.statistics?.addedPullRequestsCommentsCount).toStrictEqual(0); }); +test('processing a pull request to be stale with the "stalePrMessage" option set to empty will send a PR comment from "rottenPRMessage" given that it is also an empty string', async () => { + expect.assertions(3); + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + stalePrMessage: '', + daysBeforeStale: 10, + daysBeforeRotten: 0, + + daysBeforePrStale: 1 + }; + const issueDate = new Date(); + issueDate.setDate(issueDate.getDate() - 2); + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'A pull request with no label and a stale message', + issueDate.toDateString(), + issueDate.toDateString(), + false, + true + ) + ]; + const processor = new IssuesProcessorMock( + opts, + alwaysFalseStateMock, + async p => (p === 1 ? TestIssueList : []), + async () => [], + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.staleIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); + expect(processor.statistics?.addedPullRequestsCommentsCount).toStrictEqual(1); +}); + test('processing an issue with the "includeOnlyAssigned" option and nonempty assignee list will stale the issue', async () => { const issueDate = new Date(); issueDate.setDate(issueDate.getDate() - 2); diff --git a/__tests__/operations-per-run.spec.ts b/__tests__/operations-per-run.spec.ts index 6be0a3632..7b038fe75 100644 --- a/__tests__/operations-per-run.spec.ts +++ b/__tests__/operations-per-run.spec.ts @@ -13,7 +13,7 @@ describe('operations-per-run option', (): void => { sut = new SUT(); }); - describe('when one issue should be stale within 10 days and updated 20 days ago', (): void => { + describe('when one issue should be stale within 10 days and updated 20 days ago and days before rotten is -1', (): void => { beforeEach((): void => { sut.staleIn(10).newIssue().updated(20); }); diff --git a/__tests__/state.spec.ts b/__tests__/state.spec.ts index 8c59d8614..af5c9151c 100644 --- a/__tests__/state.spec.ts +++ b/__tests__/state.spec.ts @@ -202,7 +202,7 @@ describe('state', (): void => { await processor.processIssues(1); // make sure all issues are proceeded - expect(infoSpy.mock.calls[71][0]).toContain( + expect(infoSpy.mock.calls[77][0]).toContain( 'No more issues found to process. Exiting...' ); diff --git a/action.yml b/action.yml index d55f8547c..27b3a35aa 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ -name: 'Close Stale Issues' +name: 'Close, Rotten and Stale Issues' description: 'Close issues and pull requests with no recent activity' -author: 'GitHub' +author: 'M Viswanath Sai' inputs: repo-token: description: 'Token for the repository. Can be passed in using `{{ secrets.GITHUB_TOKEN }}`.' @@ -12,6 +12,12 @@ inputs: stale-pr-message: description: 'The message to post on the pull request when tagging it. If none provided, will not mark pull requests stale.' required: false + rotten-issue-message: + description: 'The message to post on the issue when tagging it. If none provided, will not mark issues rotten.' + required: false + rotten-pr-message: + description: 'The message to post on the pull request when tagging it. If none provided, will not mark pull requests rotten.' + required: false close-issue-message: description: 'The message to post on the issue when closing it. If none provided, will not comment when closing an issue.' required: false @@ -21,17 +27,27 @@ inputs: days-before-stale: description: 'The number of days old an issue or a pull request can be before marking it stale. Set to -1 to never mark issues or pull requests as stale automatically.' required: false - default: '60' + default: '90' 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 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 only the pull requests.' required: false + days-before-rotten: + description: 'The number of days old an issue or a pull request can be before marking it rotten. Set to -1 to never mark issues or pull requests as rotten automatically.' + required: false + default: '30' + days-before-issue-rotten: + description: 'The number of days old an issue can be before marking it rotten. Set to -1 to never mark issues as rotten automatically. Override "days-before-rotten" option regarding only the issues.' + required: false + days-before-pr-rotten: + description: 'The number of days old a pull request can be before marking it rotten. Set to -1 to never mark pull requests as rotten automatically. Override "days-before-rotten" 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' + default: '30' 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 only the issues.' required: false @@ -42,6 +58,10 @@ inputs: description: 'The label to apply when an issue is stale.' required: false default: 'Stale' + rotten-issue-label: + description: 'The label to apply when an issue is rotten.' + required: false + default: 'Rotten' close-issue-label: description: 'The label to apply when an issue is closed.' required: false @@ -57,6 +77,10 @@ inputs: description: 'The label to apply when a pull request is stale.' default: 'Stale' required: false + rotten-pr-label: + description: 'The label to apply when a pull request is rotten.' + default: 'Rotten' + required: false close-pr-label: description: 'The label to apply when a pull request is closed.' required: false @@ -128,6 +152,18 @@ inputs: description: 'Remove stale labels from pull requests when they are updated or commented on. Override "remove-stale-when-updated" option regarding only the pull requests.' default: '' required: false + remove-rotten-when-updated: + description: 'Remove rotten labels from issues and pull requests when they are updated or commented on.' + default: 'true' + required: false + remove-issue-rotten-when-updated: + description: 'Remove rotten labels from issues when they are updated or commented on. Override "remove-rotten-when-updated" option regarding only the issues.' + default: '' + required: false + remove-pr-rotten-when-updated: + description: 'Remove rotten labels from pull requests when they are updated or commented on. Override "remove-rotten-when-updated" option regarding only the pull requests.' + default: '' + required: false debug-only: description: 'Run the processor in debug mode without actually performing any operations on live issues.' default: 'false' @@ -188,6 +224,18 @@ inputs: description: 'A comma delimited list of labels to remove when an issue or pull request becomes unstale.' default: '' required: false + labels-to-add-when-unrotten: + description: 'A comma delimited list of labels to add when an issue or pull request becomes unrotten.' + default: '' + required: false + labels-to-remove-when-rotten: + description: 'A comma delimited list of labels to remove when an issue or pull request becomes rotten.' + default: '' + required: false + labels-to-remove-when-unrotten: + description: 'A comma delimited list of labels to remove when an issue or pull request becomes unrotten.' + default: '' + required: false ignore-updates: description: 'Any update (update/comment) can reset the stale idle time on the issues and pull requests.' default: 'false' diff --git a/dist/index.js b/dist/index.js index f2786a0f6..6ac1a940a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -288,7 +288,9 @@ class Issue { this.milestone = issue.milestone; this.assignees = issue.assignees || []; this.isStale = (0, is_labeled_1.isLabeled)(this, this.staleLabel); + this.isRotten = (0, is_labeled_1.isLabeled)(this, this.rottenLabel); this.markedStaleThisRun = false; + this.markedRottenThisRun = false; } get isPullRequest() { return (0, is_pull_request_1.isPullRequest)(this); @@ -296,6 +298,9 @@ class Issue { get staleLabel() { return this._getStaleLabel(); } + get rottenLabel() { + return this._getRottenLabel(); + } get hasAssignees() { return this.assignees.length > 0; } @@ -304,6 +309,11 @@ class Issue { ? this._options.stalePrLabel : this._options.staleIssueLabel; } + _getRottenLabel() { + return this.isPullRequest + ? this._options.rottenPrLabel + : this._options.rottenIssueLabel; + } } exports.Issue = Issue; function mapLabels(labels) { @@ -403,6 +413,7 @@ class IssuesProcessor { } constructor(options, state) { this.staleIssues = []; + this.rottenIssues = []; this.closedIssues = []; this.deletedBranchIssues = []; this.removedLabelIssues = []; @@ -439,6 +450,9 @@ class IssuesProcessor { const labelsToRemoveWhenStale = (0, words_to_list_1.wordsToList)(this.options.labelsToRemoveWhenStale); const labelsToAddWhenUnstale = (0, words_to_list_1.wordsToList)(this.options.labelsToAddWhenUnstale); const labelsToRemoveWhenUnstale = (0, words_to_list_1.wordsToList)(this.options.labelsToRemoveWhenUnstale); + const labelsToRemoveWhenRotten = (0, words_to_list_1.wordsToList)(this.options.labelsToRemoveWhenRotten); + const labelsToAddWhenUnrotten = (0, words_to_list_1.wordsToList)(this.options.labelsToAddWhenUnrotten); + const labelsToRemoveWhenUnrotten = (0, words_to_list_1.wordsToList)(this.options.labelsToRemoveWhenUnrotten); for (const issue of issues.values()) { // Stop the processing if no more operations remains if (!this.operations.hasRemainingOperations()) { @@ -450,7 +464,7 @@ class IssuesProcessor { continue; } yield issueLogger.grouping(`$$type #${issue.number}`, () => __awaiter(this, void 0, void 0, function* () { - yield this.processIssue(issue, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale); + yield this.processIssue(issue, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten); })); this.state.addIssueToProcessed(issue); } @@ -465,7 +479,7 @@ class IssuesProcessor { return this.processIssues(page + 1); }); } - processIssue(issue, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale) { + processIssue(issue, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten) { var _a; return __awaiter(this, void 0, void 0, function* () { (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementProcessedItemsCount(issue); @@ -475,12 +489,21 @@ class IssuesProcessor { const staleMessage = issue.isPullRequest ? this.options.stalePrMessage : this.options.staleIssueMessage; + const rottenMessage = issue.isPullRequest + ? this.options.rottenPrMessage + : this.options.rottenIssueMessage; const closeMessage = issue.isPullRequest ? this.options.closePrMessage : this.options.closeIssueMessage; + const skipRottenMessage = issue.isPullRequest + ? this.options.rottenPrMessage.length === 0 + : this.options.rottenIssueMessage.length === 0; const staleLabel = issue.isPullRequest ? this.options.stalePrLabel : this.options.staleIssueLabel; + const rottenLabel = issue.isPullRequest + ? this.options.rottenPrLabel + : this.options.rottenIssueLabel; const closeLabel = issue.isPullRequest ? this.options.closePrLabel : this.options.closeIssueLabel; @@ -546,11 +569,18 @@ class IssuesProcessor { return; // Don't process issues which were created before the start date } } + // Check if the issue is stale, if not, check if it is rotten and then log the findings. if (issue.isStale) { issueLogger.info(`This $$type includes a stale label`); } else { issueLogger.info(`This $$type does not include a stale label`); + if (issue.isRotten) { + issueLogger.info(`This $$type includes a rotten label`); + } + else { + issueLogger.info(`This $$type does not include a rotten label`); + } } const exemptLabels = (0, words_to_list_1.wordsToList)(issue.isPullRequest ? this.options.exemptPrLabels @@ -601,51 +631,57 @@ class IssuesProcessor { IssuesProcessor._endIssueProcessing(issue); return; // Don't process draft PR } + // Here we are looking into if the issue is stale or not, and then adding the label. This same code will also be used for the rotten label. // Determine if this issue needs to be marked stale first if (!issue.isStale) { issueLogger.info(`This $$type is not stale`); - const shouldIgnoreUpdates = new ignore_updates_1.IgnoreUpdates(this.options, issue).shouldIgnoreUpdates(); - // Should this issue be marked as stale? - let shouldBeStale; - // Ignore the last update and only use the creation date - if (shouldIgnoreUpdates) { - shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale); + if (issue.isRotten) { + yield this._processRottenIssue(issue, rottenLabel, rottenMessage, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten, closeMessage, closeLabel); } - // Use the last update to check if we need to stale else { - shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale); - } - if (shouldBeStale) { + const shouldIgnoreUpdates = new ignore_updates_1.IgnoreUpdates(this.options, issue).shouldIgnoreUpdates(); + // Should this issue be marked as stale? + let shouldBeStale; + // Ignore the last update and only use the creation date if (shouldIgnoreUpdates) { - issueLogger.info(`This $$type should be stale based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.created_at))} (${logger_service_1.LoggerService.cyan(issue.created_at)})`); - } - else { - issueLogger.info(`This $$type should be stale based on the last update date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.updated_at))} (${logger_service_1.LoggerService.cyan(issue.updated_at)})`); - } - if (shouldMarkAsStale) { - issueLogger.info(`This $$type should be marked as stale based on the option ${issueLogger.createOptionLink(this._getDaysBeforeStaleUsedOptionName(issue))} (${logger_service_1.LoggerService.cyan(daysBeforeStale)})`); - yield this._markStale(issue, staleMessage, staleLabel, skipMessage); - issue.isStale = true; // This issue is now considered stale - issue.markedStaleThisRun = true; - issueLogger.info(`This $$type is now stale`); + shouldBeStale = !IssuesProcessor._updatedSince(issue.created_at, daysBeforeStale); } + // Use the last update to check if we need to stale else { - issueLogger.info(`This $$type should not be marked as stale based on the option ${issueLogger.createOptionLink(this._getDaysBeforeStaleUsedOptionName(issue))} (${logger_service_1.LoggerService.cyan(daysBeforeStale)})`); + shouldBeStale = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeStale); } - } - else { - if (shouldIgnoreUpdates) { - issueLogger.info(`This $$type should not be stale based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.created_at))} (${logger_service_1.LoggerService.cyan(issue.created_at)})`); + if (shouldBeStale) { + if (shouldIgnoreUpdates) { + issueLogger.info(`This $$type should be stale based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.created_at))} (${logger_service_1.LoggerService.cyan(issue.created_at)})`); + } + else { + issueLogger.info(`This $$type should be stale based on the last update date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.updated_at))} (${logger_service_1.LoggerService.cyan(issue.updated_at)})`); + } + if (shouldMarkAsStale) { + issueLogger.info(`This $$type should be marked as stale based on the option ${issueLogger.createOptionLink(this._getDaysBeforeStaleUsedOptionName(issue))} (${logger_service_1.LoggerService.cyan(daysBeforeStale)})`); + yield this._markStale(issue, staleMessage, staleLabel, skipMessage); + issue.isStale = true; // This issue is now considered stale + issue.markedStaleThisRun = true; + issueLogger.info(`This $$type is now stale`); + } + else { + issueLogger.info(`This $$type should not be marked as stale based on the option ${issueLogger.createOptionLink(this._getDaysBeforeStaleUsedOptionName(issue))} (${logger_service_1.LoggerService.cyan(daysBeforeStale)})`); + } } else { - issueLogger.info(`This $$type should not be stale based on the last update date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.updated_at))} (${logger_service_1.LoggerService.cyan(issue.updated_at)})`); + if (shouldIgnoreUpdates) { + issueLogger.info(`This $$type should not be stale based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.created_at))} (${logger_service_1.LoggerService.cyan(issue.created_at)})`); + } + else { + issueLogger.info(`This $$type should not be stale based on the last update date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.updated_at))} (${logger_service_1.LoggerService.cyan(issue.updated_at)})`); + } } } } // Process the issue if it was marked stale if (issue.isStale) { issueLogger.info(`This $$type is already stale`); - yield this._processStaleIssue(issue, staleLabel, staleMessage, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, closeMessage, closeLabel); + yield this._processStaleIssue(issue, staleLabel, staleMessage, rottenLabel, rottenMessage, closeLabel, closeMessage, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten, skipRottenMessage); } IssuesProcessor._endIssueProcessing(issue); }); @@ -752,17 +788,23 @@ class IssuesProcessor { }); } // handle all of the stale issue logic when we find a stale issue - _processStaleIssue(issue, staleLabel, staleMessage, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, closeMessage, closeLabel) { + // This whole thing needs to be altered, to be calculated based on the days to rotten, rather than days to close or whatever + _processStaleIssue(issue, staleLabel, staleMessage, rottenLabel, rottenMessage, closeLabel, closeMessage, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten, skipMessage) { return __awaiter(this, void 0, void 0, function* () { const issueLogger = new issue_logger_1.IssueLogger(issue); + let issueHasClosed = false; + // We can get the label creation date from the getLableCreationDate function const markedStaleOn = (yield this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; 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 daysBeforeRotten = issue.isPullRequest + ? this._getDaysBeforePrRotten() + : this._getDaysBeforeIssueRotten(); const daysBeforeClose = issue.isPullRequest ? this._getDaysBeforePrClose() : this._getDaysBeforeIssueClose(); - issueLogger.info(`Days before $$type close: ${logger_service_1.LoggerService.cyan(daysBeforeClose)}`); + issueLogger.info(`Days before $$type rotten: ${logger_service_1.LoggerService.cyan(daysBeforeRotten)}`); const shouldRemoveStaleWhenUpdated = this._shouldRemoveStaleWhenUpdated(issue); issueLogger.info(`The option ${issueLogger.createOptionLink(this._getRemoveStaleWhenUpdatedUsedOptionName(issue))} is: ${logger_service_1.LoggerService.cyan(shouldRemoveStaleWhenUpdated)}`); if (shouldRemoveStaleWhenUpdated) { @@ -771,6 +813,7 @@ class IssuesProcessor { else { issueLogger.info(`The stale label should be removed if all conditions met`); } + // we will need to use a variation of this for the rotten state if (issue.markedStaleThisRun) { issueLogger.info(`marked stale this run, so don't check for updates`); yield this._removeLabelsOnStatusTransition(issue, labelsToRemoveWhenStale, option_1.Option.LabelsToRemoveWhenStale); @@ -791,13 +834,124 @@ class IssuesProcessor { issueLogger.info(`Skipping the process since the $$type is now un-stale`); return; // Nothing to do because it is no longer stale } + if (daysBeforeRotten < 0) { + if (daysBeforeClose < 0) { + issueLogger.info(`Stale $$type cannot be rotten or closed because days before rotten: ${daysBeforeRotten}, and days before close: ${daysBeforeClose}`); + return; + } + else { + issueLogger.info(`Closing issue without rottening it because days before $$type rotten: ${logger_service_1.LoggerService.cyan(daysBeforeRotten)}`); + 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 (!issueHasUpdateInCloseWindow && !issueHasCommentsSinceStale) { + issueLogger.info(`Closing $$type because it was last updated on: ${logger_service_1.LoggerService.cyan(issue.updated_at)}`); + yield this._closeIssue(issue, closeMessage, closeLabel); + issueHasClosed = true; + if (this.options.deleteBranch && issue.pull_request) { + issueLogger.info(`Deleting the branch since the option ${issueLogger.createOptionLink(option_1.Option.DeleteBranch)} is enabled`); + yield this._deleteBranch(issue); + this.deletedBranchIssues.push(issue); + } + } + else { + issueLogger.info(`Stale $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceStale}, hasUpdate? ${issueHasUpdateInCloseWindow})`); + } + } + } + // TODO: make a function for shouldMarkWhenRotten + const shouldMarkAsRotten = (0, should_mark_when_stale_1.shouldMarkWhenStale)(daysBeforeRotten); + if (issueHasClosed) { + issueLogger.info(`Issue $$type has been closed, no need to process it further.`); + return; + } + if (!issue.isRotten) { + issueLogger.info(`This $$type is not rotten`); + const shouldIgnoreUpdates = new ignore_updates_1.IgnoreUpdates(this.options, issue).shouldIgnoreUpdates(); + const shouldBeRotten = !IssuesProcessor._updatedSince(issue.updated_at, daysBeforeRotten); + if (shouldBeRotten) { + if (shouldIgnoreUpdates) { + issueLogger.info(`This $$type should be rotten based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.created_at))} (${logger_service_1.LoggerService.cyan(issue.created_at)})`); + } + else { + issueLogger.info(`This $$type should be rotten based on the last update date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.updated_at))} (${logger_service_1.LoggerService.cyan(issue.updated_at)})`); + } + if (shouldMarkAsRotten) { + issueLogger.info(`This $$type should be marked as rotten based on the option ${issueLogger.createOptionLink(this._getDaysBeforeRottenUsedOptionName(issue))} (${logger_service_1.LoggerService.cyan(daysBeforeRotten)})`); + // remove the stale label before marking the issue as rotten + yield this._removeStaleLabel(issue, staleLabel); + yield this._markRotten(issue, rottenMessage, rottenLabel, skipMessage); + issue.isRotten = true; // This issue is now considered rotten + issue.markedRottenThisRun = true; + issueLogger.info(`This $$type is now rotten`); + } + else { + issueLogger.info(`This $$type should not be marked as rotten based on the option ${issueLogger.createOptionLink(this._getDaysBeforeStaleUsedOptionName(issue))} (${logger_service_1.LoggerService.cyan(daysBeforeRotten)})`); + } + } + else { + if (shouldIgnoreUpdates) { + issueLogger.info(`This $$type is not old enough to be rotten based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.created_at))} (${logger_service_1.LoggerService.cyan(issue.created_at)})`); + } + else { + issueLogger.info(`This $$type is not old enough to be rotten based on the creation date the ${(0, get_humanized_date_1.getHumanizedDate)(new Date(issue.updated_at))} (${logger_service_1.LoggerService.cyan(issue.updated_at)})`); + } + } + } + if (issue.isRotten) { + issueLogger.info(`This $$type is already rotten`); + // process the rotten issues + this._processRottenIssue(issue, rottenLabel, rottenMessage, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten, closeMessage, closeLabel); + } + }); + } + _processRottenIssue(issue, rottenLabel, rottenMessage, labelsToAddWhenUnrotten, labelsToRemoveWhenUnrotten, labelsToRemoveWhenRotten, closeMessage, closeLabel) { + return __awaiter(this, void 0, void 0, function* () { + const issueLogger = new issue_logger_1.IssueLogger(issue); + // We can get the label creation date from the getLableCreationDate function + const markedRottenOn = (yield this.getLabelCreationDate(issue, rottenLabel)) || issue.updated_at; + issueLogger.info(`$$type marked rotten on: ${logger_service_1.LoggerService.cyan(markedRottenOn)}`); + const issueHasCommentsSinceRotten = yield this._hasCommentsSince(issue, markedRottenOn, rottenMessage); + issueLogger.info(`$$type has been commented on: ${logger_service_1.LoggerService.cyan(issueHasCommentsSinceRotten)}`); + const daysBeforeClose = issue.isPullRequest + ? this._getDaysBeforePrClose() + : this._getDaysBeforeIssueClose(); + issueLogger.info(`Days before $$type close: ${logger_service_1.LoggerService.cyan(daysBeforeClose)}`); + const shouldRemoveRottenWhenUpdated = this._shouldRemoveRottenWhenUpdated(issue); + issueLogger.info(`The option ${issueLogger.createOptionLink(this._getRemoveRottenWhenUpdatedUsedOptionName(issue))} is: ${logger_service_1.LoggerService.cyan(shouldRemoveRottenWhenUpdated)}`); + if (shouldRemoveRottenWhenUpdated) { + issueLogger.info(`The rotten label should not be removed`); + } + else { + issueLogger.info(`The rotten label should be removed if all conditions met`); + } + if (issue.markedRottenThisRun) { + issueLogger.info(`marked rotten this run, so don't check for updates`); + yield this._removeLabelsOnStatusTransition(issue, labelsToRemoveWhenRotten, option_1.Option.LabelsToRemoveWhenRotten); + } + // The issue.updated_at and markedRottenOn are not always exactly in sync (they can be off by a second or 2) + // isDateMoreRecentThan makes sure they are not the same date within a certain tolerance (15 seconds in this case) + const issueHasUpdateSinceRotten = (0, is_date_more_recent_than_1.isDateMoreRecentThan)(new Date(issue.updated_at), new Date(markedRottenOn), 15); + issueLogger.info(`$$type has been updated since it was marked rotten: ${logger_service_1.LoggerService.cyan(issueHasUpdateSinceRotten)}`); + // Should we un-rotten this issue? + if (shouldRemoveRottenWhenUpdated && + (issueHasUpdateSinceRotten || issueHasCommentsSinceRotten) && + !issue.markedRottenThisRun) { + issueLogger.info(`Remove the rotten label since the $$type has been updated and the workflow should remove the stale label when updated`); + yield this._removeRottenLabel(issue, rottenLabel); + // Are there labels to remove or add when an issue is no longer rotten? + // This logic takes care of removing labels when unrotten + yield this._removeLabelsOnStatusTransition(issue, labelsToRemoveWhenUnrotten, option_1.Option.LabelsToRemoveWhenUnrotten); + yield this._addLabelsWhenUnrotten(issue, labelsToAddWhenUnrotten); + issueLogger.info(`Skipping the process since the $$type is now un-rotten`); + return; // Nothing to do because it is no longer rotten + } // Now start closing logic if (daysBeforeClose < 0) { - return; // Nothing to do because we aren't closing stale issues + return; // Nothing to do because we aren't closing rotten issues } 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 (!issueHasCommentsSinceRotten && !issueHasUpdateInCloseWindow) { 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) { @@ -807,7 +961,7 @@ class IssuesProcessor { } } else { - issueLogger.info(`Stale $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceStale}, hasUpdate? ${issueHasUpdateInCloseWindow})`); + issueLogger.info(`Rotten $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceRotten}, hasUpdate? ${issueHasUpdateInCloseWindow})`); } }); } @@ -877,12 +1031,57 @@ class IssuesProcessor { } }); } + _markRotten(issue, rottenMessage, rottenLabel, skipMessage) { + var _a, _b, _c; + return __awaiter(this, void 0, void 0, function* () { + const issueLogger = new issue_logger_1.IssueLogger(issue); + issueLogger.info(`Marking this $$type as rotten`); + this.rottenIssues.push(issue); + // if the issue is being marked rotten, the updated date should be changed to right now + // so that close calculations work correctly + const newUpdatedAtDate = new Date(); + issue.updated_at = newUpdatedAtDate.toString(); + if (!skipMessage) { + try { + this._consumeIssueOperation(issue); + (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementAddedItemsComment(issue); + if (!this.options.debugOnly) { + yield this.client.rest.issues.createComment({ + owner: github_1.context.repo.owner, + repo: github_1.context.repo.repo, + issue_number: issue.number, + body: rottenMessage + }); + } + } + catch (error) { + issueLogger.error(`Error when creating a comment: ${error.message}`); + } + } + try { + this._consumeIssueOperation(issue); + (_b = this.statistics) === null || _b === void 0 ? void 0 : _b.incrementAddedItemsLabel(issue); + (_c = this.statistics) === null || _c === void 0 ? void 0 : _c.incrementStaleItemsCount(issue); + if (!this.options.debugOnly) { + yield this.client.rest.issues.addLabels({ + owner: github_1.context.repo.owner, + repo: github_1.context.repo.repo, + issue_number: issue.number, + labels: [rottenLabel] + }); + } + } + catch (error) { + issueLogger.error(`Error when adding a label: ${error.message}`); + } + }); + } // Close an issue based on staleness _closeIssue(issue, closeMessage, closeLabel) { var _a, _b, _c; return __awaiter(this, void 0, void 0, function* () { const issueLogger = new issue_logger_1.IssueLogger(issue); - issueLogger.info(`Closing $$type for being stale`); + issueLogger.info(`Closing $$type for being stale/rotten`); this.closedIssues.push(issue); if (closeMessage) { try { @@ -1012,6 +1211,16 @@ class IssuesProcessor { ? this.options.daysBeforeStale : this.options.daysBeforePrStale; } + _getDaysBeforeIssueRotten() { + return isNaN(this.options.daysBeforeIssueRotten) + ? this.options.daysBeforeRotten + : this.options.daysBeforeIssueRotten; + } + _getDaysBeforePrRotten() { + return isNaN(this.options.daysBeforePrRotten) + ? this.options.daysBeforeRotten + : this.options.daysBeforePrRotten; + } _getDaysBeforeIssueClose() { return isNaN(this.options.daysBeforeIssueClose) ? this.options.daysBeforeClose @@ -1063,6 +1272,18 @@ class IssuesProcessor { } return this.options.removeStaleWhenUpdated; } + _shouldRemoveRottenWhenUpdated(issue) { + if (issue.isPullRequest) { + if ((0, is_boolean_1.isBoolean)(this.options.removePrRottenWhenUpdated)) { + return this.options.removePrRottenWhenUpdated; + } + return this.options.removeRottenWhenUpdated; + } + if ((0, is_boolean_1.isBoolean)(this.options.removeIssueRottenWhenUpdated)) { + return this.options.removeIssueRottenWhenUpdated; + } + return this.options.removeRottenWhenUpdated; + } _removeLabelsOnStatusTransition(issue, removeLabels, staleStatus) { return __awaiter(this, void 0, void 0, function* () { if (!removeLabels.length) { @@ -1101,6 +1322,33 @@ class IssuesProcessor { } }); } + _addLabelsWhenUnrotten(issue, labelsToAdd) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + if (!labelsToAdd.length) { + return; + } + const issueLogger = new issue_logger_1.IssueLogger(issue); + issueLogger.info(`Adding all the labels specified via the ${this._logger.createOptionLink(option_1.Option.LabelsToAddWhenUnrotten)} option.`); + // TODO: this might need to be changed to a set to avoiod repetition + this.addedLabelIssues.push(issue); + try { + this._consumeIssueOperation(issue); + (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementAddedItemsLabel(issue); + if (!this.options.debugOnly) { + yield this.client.rest.issues.addLabels({ + owner: github_1.context.repo.owner, + repo: github_1.context.repo.repo, + issue_number: issue.number, + labels: labelsToAdd + }); + } + } + catch (error) { + this._logger.error(`Error when adding labels after updated from rotten: ${error.message}`); + } + }); + } _removeStaleLabel(issue, staleLabel) { var _a; return __awaiter(this, void 0, void 0, function* () { @@ -1110,6 +1358,15 @@ class IssuesProcessor { (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementUndoStaleItemsCount(issue); }); } + _removeRottenLabel(issue, rottenLabel) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + const issueLogger = new issue_logger_1.IssueLogger(issue); + issueLogger.info(`The $$type is no longer rotten. Removing the rotten label...`); + yield this._removeLabel(issue, rottenLabel); + (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementUndoRottenItemsCount(issue); + }); + } _removeCloseLabel(issue, closeLabel) { var _a; return __awaiter(this, void 0, void 0, function* () { @@ -1150,6 +1407,21 @@ class IssuesProcessor { ? option_1.Option.DaysBeforeStale : option_1.Option.DaysBeforePrStale; } + _getDaysBeforeRottenUsedOptionName(issue) { + return issue.isPullRequest + ? this._getDaysBeforePrRottenUsedOptionName() + : this._getDaysBeforeIssueRottenUsedOptionName(); + } + _getDaysBeforeIssueRottenUsedOptionName() { + return isNaN(this.options.daysBeforeIssueRotten) + ? option_1.Option.DaysBeforeRotten + : option_1.Option.DaysBeforeIssueRotten; + } + _getDaysBeforePrRottenUsedOptionName() { + return isNaN(this.options.daysBeforePrRotten) + ? option_1.Option.DaysBeforeRotten + : option_1.Option.DaysBeforePrRotten; + } _getRemoveStaleWhenUpdatedUsedOptionName(issue) { if (issue.isPullRequest) { if ((0, is_boolean_1.isBoolean)(this.options.removePrStaleWhenUpdated)) { @@ -1162,6 +1434,18 @@ class IssuesProcessor { } return option_1.Option.RemoveStaleWhenUpdated; } + _getRemoveRottenWhenUpdatedUsedOptionName(issue) { + if (issue.isPullRequest) { + if ((0, is_boolean_1.isBoolean)(this.options.removePrRottenWhenUpdated)) { + return option_1.Option.RemovePrRottenWhenUpdated; + } + return option_1.Option.RemoveRottenWhenUpdated; + } + if ((0, is_boolean_1.isBoolean)(this.options.removeIssueRottenWhenUpdated)) { + return option_1.Option.RemoveIssueRottenWhenUpdated; + } + return option_1.Option.RemoveRottenWhenUpdated; + } } exports.IssuesProcessor = IssuesProcessor; @@ -1815,6 +2099,10 @@ class Statistics { this.stalePullRequestsCount = 0; this.undoStaleIssuesCount = 0; this.undoStalePullRequestsCount = 0; + this.rottenIssuesCount = 0; + this.rottenPullRequestsCount = 0; + this.undoRottenIssuesCount = 0; + this.undoRottenPullRequestsCount = 0; this.operationsCount = 0; this.closedIssuesCount = 0; this.closedPullRequestsCount = 0; @@ -1850,6 +2138,12 @@ class Statistics { } return this._incrementUndoStaleIssuesCount(increment); } + incrementUndoRottenItemsCount(issue, increment = 1) { + if (issue.isPullRequest) { + return this._incrementUndoRottenPullRequestsCount(increment); + } + return this._incrementUndoRottenIssuesCount(increment); + } setOperationsCount(operationsCount) { this.operationsCount = operationsCount; return this; @@ -1942,6 +2236,14 @@ class Statistics { this.undoStaleIssuesCount += increment; return this; } + _incrementUndoRottenPullRequestsCount(increment = 1) { + this.undoRottenPullRequestsCount += increment; + return this; + } + _incrementUndoRottenIssuesCount(increment = 1) { + this.undoRottenIssuesCount += increment; + return this; + } _incrementUndoStalePullRequestsCount(increment = 1) { this.undoStalePullRequestsCount += increment; return this; @@ -2175,18 +2477,25 @@ var Option; Option["RepoToken"] = "repo-token"; Option["StaleIssueMessage"] = "stale-issue-message"; Option["StalePrMessage"] = "stale-pr-message"; + Option["RottenIssueMessage"] = "rotten-issue-message"; + Option["RottenPrMessage"] = "rotten-pr-message"; Option["CloseIssueMessage"] = "close-issue-message"; Option["ClosePrMessage"] = "close-pr-message"; Option["DaysBeforeStale"] = "days-before-stale"; Option["DaysBeforeIssueStale"] = "days-before-issue-stale"; Option["DaysBeforePrStale"] = "days-before-pr-stale"; + Option["DaysBeforeRotten"] = "days-before-rotten"; + Option["DaysBeforeIssueRotten"] = "days-before-issue-rotten"; + Option["DaysBeforePrRotten"] = "days-before-pr-rotten"; Option["DaysBeforeClose"] = "days-before-close"; Option["DaysBeforeIssueClose"] = "days-before-issue-close"; Option["DaysBeforePrClose"] = "days-before-pr-close"; Option["StaleIssueLabel"] = "stale-issue-label"; + Option["RottenIssueLabel"] = "rotten-issue-label"; Option["CloseIssueLabel"] = "close-issue-label"; Option["ExemptIssueLabels"] = "exempt-issue-labels"; Option["StalePrLabel"] = "stale-pr-label"; + Option["RottenPrLabel"] = "rotten-pr-label"; Option["ClosePrLabel"] = "close-pr-label"; Option["ExemptPrLabels"] = "exempt-pr-labels"; Option["OnlyLabels"] = "only-labels"; @@ -2197,6 +2506,9 @@ var Option; Option["RemoveStaleWhenUpdated"] = "remove-stale-when-updated"; Option["RemoveIssueStaleWhenUpdated"] = "remove-issue-stale-when-updated"; Option["RemovePrStaleWhenUpdated"] = "remove-pr-stale-when-updated"; + Option["RemoveRottenWhenUpdated"] = "remove-rotten-when-updated"; + Option["RemoveIssueRottenWhenUpdated"] = "remove-issue-rotten-when-updated"; + Option["RemovePrRottenWhenUpdated"] = "remove-pr-rotten-when-updated"; Option["DebugOnly"] = "debug-only"; Option["Ascending"] = "ascending"; Option["DeleteBranch"] = "delete-branch"; @@ -2217,6 +2529,9 @@ var Option; Option["LabelsToRemoveWhenStale"] = "labels-to-remove-when-stale"; Option["LabelsToRemoveWhenUnstale"] = "labels-to-remove-when-unstale"; Option["LabelsToAddWhenUnstale"] = "labels-to-add-when-unstale"; + Option["LabelsToRemoveWhenRotten"] = "labels-to-remove-when-rotten"; + Option["LabelsToRemoveWhenUnrotten"] = "labels-to-remove-when-unrotten"; + Option["LabelsToAddWhenUnrotten"] = "labels-to-add-when-unrotten"; Option["IgnoreUpdates"] = "ignore-updates"; Option["IgnoreIssueUpdates"] = "ignore-issue-updates"; Option["IgnorePrUpdates"] = "ignore-pr-updates"; @@ -2503,7 +2818,7 @@ function _run() { core.info(`Github API rate remaining: ${rateLimitAtEnd.remaining}; reset at: ${rateLimitAtEnd.reset}`); } yield state.persist(); - yield processOutput(issueProcessor.staleIssues, issueProcessor.closedIssues); + yield processOutput(issueProcessor.staleIssues, issueProcessor.rottenIssues, issueProcessor.closedIssues); } catch (error) { core.error(error); @@ -2516,18 +2831,25 @@ function _getAndValidateArgs() { repoToken: core.getInput('repo-token'), staleIssueMessage: core.getInput('stale-issue-message'), stalePrMessage: core.getInput('stale-pr-message'), + rottenIssueMessage: core.getInput('rotten-issue-message'), + rottenPrMessage: core.getInput('rotten-pr-message'), closeIssueMessage: core.getInput('close-issue-message'), closePrMessage: core.getInput('close-pr-message'), daysBeforeStale: parseFloat(core.getInput('days-before-stale', { required: true })), + daysBeforeRotten: parseFloat(core.getInput('days-before-rotten', { required: true })), daysBeforeIssueStale: parseFloat(core.getInput('days-before-issue-stale')), daysBeforePrStale: parseFloat(core.getInput('days-before-pr-stale')), + daysBeforeIssueRotten: parseFloat(core.getInput('days-before-issue-rotten')), + daysBeforePrRotten: parseFloat(core.getInput('days-before-pr-rotten')), daysBeforeClose: parseInt(core.getInput('days-before-close', { required: true })), daysBeforeIssueClose: parseInt(core.getInput('days-before-issue-close')), daysBeforePrClose: parseInt(core.getInput('days-before-pr-close')), staleIssueLabel: core.getInput('stale-issue-label', { required: true }), + rottenIssueLabel: core.getInput('rotten-issue-label', { required: true }), closeIssueLabel: core.getInput('close-issue-label'), exemptIssueLabels: core.getInput('exempt-issue-labels'), stalePrLabel: core.getInput('stale-pr-label', { required: true }), + rottenPrLabel: core.getInput('rotten-pr-label', { required: true }), closePrLabel: core.getInput('close-pr-label'), exemptPrLabels: core.getInput('exempt-pr-labels'), onlyLabels: core.getInput('only-labels'), @@ -2540,6 +2862,9 @@ function _getAndValidateArgs() { removeStaleWhenUpdated: !(core.getInput('remove-stale-when-updated') === 'false'), removeIssueStaleWhenUpdated: _toOptionalBoolean('remove-issue-stale-when-updated'), removePrStaleWhenUpdated: _toOptionalBoolean('remove-pr-stale-when-updated'), + removeRottenWhenUpdated: !(core.getInput('remove-rotten-when-updated') === 'false'), + removeIssueRottenWhenUpdated: _toOptionalBoolean('remove-issue-rotten-when-updated'), + removePrRottenWhenUpdated: _toOptionalBoolean('remove-pr-rotten-when-updated'), debugOnly: core.getInput('debug-only') === 'true', ascending: core.getInput('ascending') === 'true', deleteBranch: core.getInput('delete-branch') === 'true', @@ -2562,6 +2887,9 @@ function _getAndValidateArgs() { labelsToRemoveWhenStale: core.getInput('labels-to-remove-when-stale'), labelsToRemoveWhenUnstale: core.getInput('labels-to-remove-when-unstale'), labelsToAddWhenUnstale: core.getInput('labels-to-add-when-unstale'), + labelsToRemoveWhenRotten: core.getInput('labels-to-remove-when-rotten'), + labelsToRemoveWhenUnrotten: core.getInput('labels-to-remove-when-unrotten'), + labelsToAddWhenUnrotten: core.getInput('labels-to-add-when-unrotten'), ignoreUpdates: core.getInput('ignore-updates') === 'true', ignoreIssueUpdates: _toOptionalBoolean('ignore-issue-updates'), ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), @@ -2576,6 +2904,13 @@ function _getAndValidateArgs() { throw new Error(errorMessage); } } + for (const numberInput of ['days-before-rotten']) { + if (isNaN(parseFloat(core.getInput(numberInput)))) { + const errorMessage = `Option "${numberInput}" did not parse to a valid float`; + core.setFailed(errorMessage); + throw new Error(errorMessage); + } + } for (const numberInput of ['days-before-close', 'operations-per-run']) { if (isNaN(parseInt(core.getInput(numberInput)))) { const errorMessage = `Option "${numberInput}" did not parse to a valid integer`; @@ -2601,9 +2936,10 @@ function _getAndValidateArgs() { } return args; } -function processOutput(staledIssues, closedIssues) { +function processOutput(staledIssues, rottenIssues, closedIssues) { return __awaiter(this, void 0, void 0, function* () { core.setOutput('staled-issues-prs', JSON.stringify(staledIssues)); + core.setOutput('rotten-issues-prs', JSON.stringify(rottenIssues)); core.setOutput('closed-issues-prs', JSON.stringify(closedIssues)); }); } diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index a2c82e268..2b2af9e93 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -20,9 +20,12 @@ describe('Issue', (): void => { daysBeforeClose: 0, daysBeforeIssueClose: 0, daysBeforeIssueStale: 0, + daysBeforeIssueRotten: 0, daysBeforePrClose: 0, daysBeforePrStale: 0, + daysBeforePrRotten: 0, daysBeforeStale: 0, + daysBeforeRotten: 0, debugOnly: false, deleteBranch: false, exemptIssueLabels: '', @@ -37,12 +40,19 @@ describe('Issue', (): void => { removeStaleWhenUpdated: false, removeIssueStaleWhenUpdated: undefined, removePrStaleWhenUpdated: undefined, + removeRottenWhenUpdated: false, + removeIssueRottenWhenUpdated: undefined, + removePrRottenWhenUpdated: undefined, repoToken: '', staleIssueMessage: '', stalePrMessage: '', + rottenIssueMessage: '', + rottenPrMessage: '', startDate: undefined, stalePrLabel: 'dummy-stale-pr-label', staleIssueLabel: 'dummy-stale-issue-label', + rottenPrLabel: 'dummy-rotten-pr-label', + rottenIssueLabel: 'dummy-rotten-issue-label', exemptMilestones: '', exemptIssueMilestones: '', exemptPrMilestones: '', @@ -59,6 +69,9 @@ describe('Issue', (): void => { labelsToRemoveWhenStale: '', labelsToRemoveWhenUnstale: '', labelsToAddWhenUnstale: '', + labelsToRemoveWhenRotten: '', + labelsToRemoveWhenUnrotten: '', + labelsToAddWhenUnrotten: '', ignoreUpdates: false, ignoreIssueUpdates: undefined, ignorePrUpdates: undefined, diff --git a/src/classes/issue.ts b/src/classes/issue.ts index b90631835..173bb900a 100644 --- a/src/classes/issue.ts +++ b/src/classes/issue.ts @@ -21,7 +21,9 @@ export class Issue implements IIssue { readonly milestone?: IMilestone | null; readonly assignees: Assignee[]; isStale: boolean; + isRotten: boolean; markedStaleThisRun: boolean; + markedRottenThisRun: boolean; operations = new Operations(); private readonly _options: IIssuesProcessorOptions; @@ -42,7 +44,9 @@ export class Issue implements IIssue { this.milestone = issue.milestone; this.assignees = issue.assignees || []; this.isStale = isLabeled(this, this.staleLabel); + this.isRotten = isLabeled(this, this.rottenLabel); this.markedStaleThisRun = false; + this.markedRottenThisRun = false; } get isPullRequest(): boolean { @@ -52,6 +56,9 @@ export class Issue implements IIssue { get staleLabel(): string { return this._getStaleLabel(); } + get rottenLabel(): string { + return this._getRottenLabel(); + } get hasAssignees(): boolean { return this.assignees.length > 0; @@ -62,6 +69,11 @@ export class Issue implements IIssue { ? this._options.stalePrLabel : this._options.staleIssueLabel; } + private _getRottenLabel(): string { + return this.isPullRequest + ? this._options.rottenPrLabel + : this._options.rottenIssueLabel; + } } function mapLabels(labels: (string | ILabel)[] | ILabel[]): ILabel[] { diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 486c6a78a..b7f31bd9d 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -69,6 +69,7 @@ export class IssuesProcessor { readonly client: InstanceType; readonly options: IIssuesProcessorOptions; readonly staleIssues: Issue[] = []; + readonly rottenIssues: Issue[] = []; readonly closedIssues: Issue[] = []; readonly deletedBranchIssues: Issue[] = []; readonly removedLabelIssues: Issue[] = []; @@ -141,6 +142,16 @@ export class IssuesProcessor { const labelsToRemoveWhenUnstale: string[] = wordsToList( this.options.labelsToRemoveWhenUnstale ); + const labelsToRemoveWhenRotten: string[] = wordsToList( + this.options.labelsToRemoveWhenRotten + ); + + const labelsToAddWhenUnrotten: string[] = wordsToList( + this.options.labelsToAddWhenUnrotten + ); + const labelsToRemoveWhenUnrotten: string[] = wordsToList( + this.options.labelsToRemoveWhenUnrotten + ); for (const issue of issues.values()) { // Stop the processing if no more operations remains @@ -160,7 +171,10 @@ export class IssuesProcessor { issue, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale + labelsToRemoveWhenStale, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten ); }); this.state.addIssueToProcessed(issue); @@ -200,7 +214,10 @@ export class IssuesProcessor { issue: Issue, labelsToAddWhenUnstale: Readonly[], labelsToRemoveWhenUnstale: Readonly[], - labelsToRemoveWhenStale: Readonly[] + labelsToRemoveWhenStale: Readonly[], + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[] ): Promise { this.statistics?.incrementProcessedItemsCount(issue); @@ -215,12 +232,21 @@ export class IssuesProcessor { const staleMessage: string = issue.isPullRequest ? this.options.stalePrMessage : this.options.staleIssueMessage; + const rottenMessage: string = issue.isPullRequest + ? this.options.rottenPrMessage + : this.options.rottenIssueMessage; const closeMessage: string = issue.isPullRequest ? this.options.closePrMessage : this.options.closeIssueMessage; + const skipRottenMessage = issue.isPullRequest + ? this.options.rottenPrMessage.length === 0 + : this.options.rottenIssueMessage.length === 0; const staleLabel: string = issue.isPullRequest ? this.options.stalePrLabel : this.options.staleIssueLabel; + const rottenLabel: string = issue.isPullRequest + ? this.options.rottenPrLabel + : this.options.rottenIssueLabel; const closeLabel: string = issue.isPullRequest ? this.options.closePrLabel : this.options.closeIssueLabel; @@ -342,10 +368,16 @@ export class IssuesProcessor { } } + // Check if the issue is stale, if not, check if it is rotten and then log the findings. if (issue.isStale) { issueLogger.info(`This $$type includes a stale label`); } else { issueLogger.info(`This $$type does not include a stale label`); + if (issue.isRotten) { + issueLogger.info(`This $$type includes a rotten label`); + } else { + issueLogger.info(`This $$type does not include a rotten label`); + } } const exemptLabels: string[] = wordsToList( @@ -445,78 +477,92 @@ export class IssuesProcessor { return; // Don't process draft PR } + // Here we are looking into if the issue is stale or not, and then adding the label. This same code will also be used for the rotten label. // Determine if this issue needs to be marked stale first if (!issue.isStale) { issueLogger.info(`This $$type is not stale`); - const shouldIgnoreUpdates: boolean = new IgnoreUpdates( - this.options, - issue - ).shouldIgnoreUpdates(); - - // Should this issue be marked as stale? - let shouldBeStale: boolean; - - // Ignore the last update and only use the creation date - if (shouldIgnoreUpdates) { - shouldBeStale = !IssuesProcessor._updatedSince( - issue.created_at, - daysBeforeStale - ); - } - // Use the last update to check if we need to stale - else { - shouldBeStale = !IssuesProcessor._updatedSince( - issue.updated_at, - daysBeforeStale + if (issue.isRotten) { + await this._processRottenIssue( + issue, + rottenLabel, + rottenMessage, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + closeMessage, + closeLabel ); - } + } else { + const shouldIgnoreUpdates: boolean = new IgnoreUpdates( + this.options, + issue + ).shouldIgnoreUpdates(); - if (shouldBeStale) { + // Should this issue be marked as stale? + let shouldBeStale: boolean; + + // Ignore the last update and only use the creation date if (shouldIgnoreUpdates) { - issueLogger.info( - `This $$type should be stale based on the creation date the ${getHumanizedDate( - new Date(issue.created_at) - )} (${LoggerService.cyan(issue.created_at)})` - ); - } else { - issueLogger.info( - `This $$type should be stale based on the last update date the ${getHumanizedDate( - new Date(issue.updated_at) - )} (${LoggerService.cyan(issue.updated_at)})` + shouldBeStale = !IssuesProcessor._updatedSince( + issue.created_at, + daysBeforeStale ); } - - if (shouldMarkAsStale) { - issueLogger.info( - `This $$type should be marked as stale based on the option ${issueLogger.createOptionLink( - this._getDaysBeforeStaleUsedOptionName(issue) - )} (${LoggerService.cyan(daysBeforeStale)})` - ); - await this._markStale(issue, staleMessage, staleLabel, skipMessage); - issue.isStale = true; // This issue is now considered stale - issue.markedStaleThisRun = true; - issueLogger.info(`This $$type is now stale`); - } else { - issueLogger.info( - `This $$type should not be marked as stale based on the option ${issueLogger.createOptionLink( - this._getDaysBeforeStaleUsedOptionName(issue) - )} (${LoggerService.cyan(daysBeforeStale)})` + // Use the last update to check if we need to stale + else { + shouldBeStale = !IssuesProcessor._updatedSince( + issue.updated_at, + daysBeforeStale ); } - } else { - if (shouldIgnoreUpdates) { - issueLogger.info( - `This $$type should not be stale based on the creation date the ${getHumanizedDate( - new Date(issue.created_at) - )} (${LoggerService.cyan(issue.created_at)})` - ); + + if (shouldBeStale) { + if (shouldIgnoreUpdates) { + issueLogger.info( + `This $$type should be stale based on the creation date the ${getHumanizedDate( + new Date(issue.created_at) + )} (${LoggerService.cyan(issue.created_at)})` + ); + } else { + issueLogger.info( + `This $$type should be stale based on the last update date the ${getHumanizedDate( + new Date(issue.updated_at) + )} (${LoggerService.cyan(issue.updated_at)})` + ); + } + + if (shouldMarkAsStale) { + issueLogger.info( + `This $$type should be marked as stale based on the option ${issueLogger.createOptionLink( + this._getDaysBeforeStaleUsedOptionName(issue) + )} (${LoggerService.cyan(daysBeforeStale)})` + ); + await this._markStale(issue, staleMessage, staleLabel, skipMessage); + issue.isStale = true; // This issue is now considered stale + issue.markedStaleThisRun = true; + issueLogger.info(`This $$type is now stale`); + } else { + issueLogger.info( + `This $$type should not be marked as stale based on the option ${issueLogger.createOptionLink( + this._getDaysBeforeStaleUsedOptionName(issue) + )} (${LoggerService.cyan(daysBeforeStale)})` + ); + } } else { - issueLogger.info( - `This $$type should not be stale based on the last update date the ${getHumanizedDate( - new Date(issue.updated_at) - )} (${LoggerService.cyan(issue.updated_at)})` - ); + if (shouldIgnoreUpdates) { + issueLogger.info( + `This $$type should not be stale based on the creation date the ${getHumanizedDate( + new Date(issue.created_at) + )} (${LoggerService.cyan(issue.created_at)})` + ); + } else { + issueLogger.info( + `This $$type should not be stale based on the last update date the ${getHumanizedDate( + new Date(issue.updated_at) + )} (${LoggerService.cyan(issue.updated_at)})` + ); + } } } } @@ -528,11 +574,17 @@ export class IssuesProcessor { issue, staleLabel, staleMessage, + rottenLabel, + rottenMessage, + closeLabel, + closeMessage, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale, - closeMessage, - closeLabel + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + skipRottenMessage ); } @@ -650,17 +702,28 @@ export class IssuesProcessor { } // handle all of the stale issue logic when we find a stale issue + // This whole thing needs to be altered, to be calculated based on the days to rotten, rather than days to close or whatever private async _processStaleIssue( issue: Issue, staleLabel: string, staleMessage: string, + rottenLabel: string, + rottenMessage: string, + closeLabel: string, + closeMessage: string, labelsToAddWhenUnstale: Readonly[], labelsToRemoveWhenUnstale: Readonly[], labelsToRemoveWhenStale: Readonly[], - closeMessage?: string, - closeLabel?: string + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[], + skipMessage: boolean ) { const issueLogger: IssueLogger = new IssueLogger(issue); + + let issueHasClosed: boolean = false; + + // We can get the label creation date from the getLableCreationDate function const markedStaleOn: string = (await this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; issueLogger.info( @@ -678,12 +741,15 @@ export class IssuesProcessor { )}` ); + const daysBeforeRotten: number = issue.isPullRequest + ? this._getDaysBeforePrRotten() + : this._getDaysBeforeIssueRotten(); + const daysBeforeClose: number = issue.isPullRequest ? this._getDaysBeforePrClose() : this._getDaysBeforeIssueClose(); - issueLogger.info( - `Days before $$type close: ${LoggerService.cyan(daysBeforeClose)}` + `Days before $$type rotten: ${LoggerService.cyan(daysBeforeRotten)}` ); const shouldRemoveStaleWhenUpdated: boolean = @@ -703,6 +769,7 @@ export class IssuesProcessor { ); } + // we will need to use a variation of this for the rotten state if (issue.markedStaleThisRun) { issueLogger.info(`marked stale this run, so don't check for updates`); await this._removeLabelsOnStatusTransition( @@ -750,9 +817,254 @@ export class IssuesProcessor { return; // Nothing to do because it is no longer stale } + if (daysBeforeRotten < 0) { + if (daysBeforeClose < 0) { + issueLogger.info( + `Stale $$type cannot be rotten or closed because days before rotten: ${daysBeforeRotten}, and days before close: ${daysBeforeClose}` + ); + return; + } else { + issueLogger.info( + `Closing issue without rottening it because days before $$type rotten: ${LoggerService.cyan( + daysBeforeRotten + )}` + ); + + const issueHasUpdateInCloseWindow: boolean = + IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose); + issueLogger.info( + `$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan( + issueHasUpdateInCloseWindow + )}` + ); + if (!issueHasUpdateInCloseWindow && !issueHasCommentsSinceStale) { + issueLogger.info( + `Closing $$type because it was last updated on: ${LoggerService.cyan( + issue.updated_at + )}` + ); + await this._closeIssue(issue, closeMessage, closeLabel); + + issueHasClosed = true; + + if (this.options.deleteBranch && issue.pull_request) { + issueLogger.info( + `Deleting the branch since the option ${issueLogger.createOptionLink( + Option.DeleteBranch + )} is enabled` + ); + await this._deleteBranch(issue); + this.deletedBranchIssues.push(issue); + } + } else { + issueLogger.info( + `Stale $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceStale}, hasUpdate? ${issueHasUpdateInCloseWindow})` + ); + } + } + } + + // TODO: make a function for shouldMarkWhenRotten + const shouldMarkAsRotten: boolean = shouldMarkWhenStale(daysBeforeRotten); + + if (issueHasClosed) { + issueLogger.info( + `Issue $$type has been closed, no need to process it further.` + ); + return; + } + + if (!issue.isRotten) { + issueLogger.info(`This $$type is not rotten`); + + const shouldIgnoreUpdates: boolean = new IgnoreUpdates( + this.options, + issue + ).shouldIgnoreUpdates(); + + const shouldBeRotten: boolean = !IssuesProcessor._updatedSince( + issue.updated_at, + daysBeforeRotten + ); + + if (shouldBeRotten) { + if (shouldIgnoreUpdates) { + issueLogger.info( + `This $$type should be rotten based on the creation date the ${getHumanizedDate( + new Date(issue.created_at) + )} (${LoggerService.cyan(issue.created_at)})` + ); + } else { + issueLogger.info( + `This $$type should be rotten based on the last update date the ${getHumanizedDate( + new Date(issue.updated_at) + )} (${LoggerService.cyan(issue.updated_at)})` + ); + } + + if (shouldMarkAsRotten) { + issueLogger.info( + `This $$type should be marked as rotten based on the option ${issueLogger.createOptionLink( + this._getDaysBeforeRottenUsedOptionName(issue) + )} (${LoggerService.cyan(daysBeforeRotten)})` + ); + // remove the stale label before marking the issue as rotten + await this._removeStaleLabel(issue, staleLabel); + + await this._markRotten( + issue, + rottenMessage, + rottenLabel, + skipMessage + ); + issue.isRotten = true; // This issue is now considered rotten + issue.markedRottenThisRun = true; + issueLogger.info(`This $$type is now rotten`); + } else { + issueLogger.info( + `This $$type should not be marked as rotten based on the option ${issueLogger.createOptionLink( + this._getDaysBeforeStaleUsedOptionName(issue) + )} (${LoggerService.cyan(daysBeforeRotten)})` + ); + } + } else { + if (shouldIgnoreUpdates) { + issueLogger.info( + `This $$type is not old enough to be rotten based on the creation date the ${getHumanizedDate( + new Date(issue.created_at) + )} (${LoggerService.cyan(issue.created_at)})` + ); + } else { + issueLogger.info( + `This $$type is not old enough to be rotten based on the creation date the ${getHumanizedDate( + new Date(issue.updated_at) + )} (${LoggerService.cyan(issue.updated_at)})` + ); + } + } + } + if (issue.isRotten) { + issueLogger.info(`This $$type is already rotten`); + // process the rotten issues + this._processRottenIssue( + issue, + rottenLabel, + rottenMessage, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + closeMessage, + closeLabel + ); + } + } + private async _processRottenIssue( + issue: Issue, + rottenLabel: string, + rottenMessage: string, + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[], + closeMessage?: string, + closeLabel?: string + ) { + const issueLogger: IssueLogger = new IssueLogger(issue); + // We can get the label creation date from the getLableCreationDate function + const markedRottenOn: string = + (await this.getLabelCreationDate(issue, rottenLabel)) || issue.updated_at; + issueLogger.info( + `$$type marked rotten on: ${LoggerService.cyan(markedRottenOn)}` + ); + + const issueHasCommentsSinceRotten: boolean = await this._hasCommentsSince( + issue, + markedRottenOn, + rottenMessage + ); + issueLogger.info( + `$$type has been commented on: ${LoggerService.cyan( + issueHasCommentsSinceRotten + )}` + ); + + const daysBeforeClose: number = issue.isPullRequest + ? this._getDaysBeforePrClose() + : this._getDaysBeforeIssueClose(); + + issueLogger.info( + `Days before $$type close: ${LoggerService.cyan(daysBeforeClose)}` + ); + + const shouldRemoveRottenWhenUpdated: boolean = + this._shouldRemoveRottenWhenUpdated(issue); + + issueLogger.info( + `The option ${issueLogger.createOptionLink( + this._getRemoveRottenWhenUpdatedUsedOptionName(issue) + )} is: ${LoggerService.cyan(shouldRemoveRottenWhenUpdated)}` + ); + + if (shouldRemoveRottenWhenUpdated) { + issueLogger.info(`The rotten label should not be removed`); + } else { + issueLogger.info( + `The rotten label should be removed if all conditions met` + ); + } + + if (issue.markedRottenThisRun) { + issueLogger.info(`marked rotten this run, so don't check for updates`); + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenRotten, + Option.LabelsToRemoveWhenRotten + ); + } + + // The issue.updated_at and markedRottenOn are not always exactly in sync (they can be off by a second or 2) + // isDateMoreRecentThan makes sure they are not the same date within a certain tolerance (15 seconds in this case) + const issueHasUpdateSinceRotten = isDateMoreRecentThan( + new Date(issue.updated_at), + new Date(markedRottenOn), + 15 + ); + + issueLogger.info( + `$$type has been updated since it was marked rotten: ${LoggerService.cyan( + issueHasUpdateSinceRotten + )}` + ); + + // Should we un-rotten this issue? + if ( + shouldRemoveRottenWhenUpdated && + (issueHasUpdateSinceRotten || issueHasCommentsSinceRotten) && + !issue.markedRottenThisRun + ) { + issueLogger.info( + `Remove the rotten label since the $$type has been updated and the workflow should remove the stale label when updated` + ); + await this._removeRottenLabel(issue, rottenLabel); + + // Are there labels to remove or add when an issue is no longer rotten? + // This logic takes care of removing labels when unrotten + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenUnrotten, + Option.LabelsToRemoveWhenUnrotten + ); + await this._addLabelsWhenUnrotten(issue, labelsToAddWhenUnrotten); + + issueLogger.info( + `Skipping the process since the $$type is now un-rotten` + ); + + return; // Nothing to do because it is no longer rotten + } + // Now start closing logic if (daysBeforeClose < 0) { - return; // Nothing to do because we aren't closing stale issues + return; // Nothing to do because we aren't closing rotten issues } const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince( @@ -765,7 +1077,7 @@ export class IssuesProcessor { )}` ); - if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) { + if (!issueHasCommentsSinceRotten && !issueHasUpdateInCloseWindow) { issueLogger.info( `Closing $$type because it was last updated on: ${LoggerService.cyan( issue.updated_at @@ -784,7 +1096,7 @@ export class IssuesProcessor { } } else { issueLogger.info( - `Stale $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceStale}, hasUpdate? ${issueHasUpdateInCloseWindow})` + `Rotten $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceRotten}, hasUpdate? ${issueHasUpdateInCloseWindow})` ); } } @@ -876,6 +1188,57 @@ export class IssuesProcessor { issueLogger.error(`Error when adding a label: ${error.message}`); } } + private async _markRotten( + issue: Issue, + rottenMessage: string, + rottenLabel: string, + skipMessage: boolean + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Marking this $$type as rotten`); + this.rottenIssues.push(issue); + + // if the issue is being marked rotten, the updated date should be changed to right now + // so that close calculations work correctly + const newUpdatedAtDate: Date = new Date(); + issue.updated_at = newUpdatedAtDate.toString(); + + if (!skipMessage) { + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsComment(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: rottenMessage + }); + } + } catch (error) { + issueLogger.error(`Error when creating a comment: ${error.message}`); + } + } + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsLabel(issue); + this.statistics?.incrementStaleItemsCount(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [rottenLabel] + }); + } + } catch (error) { + issueLogger.error(`Error when adding a label: ${error.message}`); + } + } // Close an issue based on staleness private async _closeIssue( @@ -885,7 +1248,7 @@ export class IssuesProcessor { ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); - issueLogger.info(`Closing $$type for being stale`); + issueLogger.info(`Closing $$type for being stale/rotten`); this.closedIssues.push(issue); if (closeMessage) { @@ -1056,6 +1419,17 @@ export class IssuesProcessor { ? this.options.daysBeforeStale : this.options.daysBeforePrStale; } + private _getDaysBeforeIssueRotten(): number { + return isNaN(this.options.daysBeforeIssueRotten) + ? this.options.daysBeforeRotten + : this.options.daysBeforeIssueRotten; + } + + private _getDaysBeforePrRotten(): number { + return isNaN(this.options.daysBeforePrRotten) + ? this.options.daysBeforeRotten + : this.options.daysBeforePrRotten; + } private _getDaysBeforeIssueClose(): number { return isNaN(this.options.daysBeforeIssueClose) @@ -1116,6 +1490,21 @@ export class IssuesProcessor { return this.options.removeStaleWhenUpdated; } + private _shouldRemoveRottenWhenUpdated(issue: Issue): boolean { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrRottenWhenUpdated)) { + return this.options.removePrRottenWhenUpdated; + } + + return this.options.removeRottenWhenUpdated; + } + + if (isBoolean(this.options.removeIssueRottenWhenUpdated)) { + return this.options.removeIssueRottenWhenUpdated; + } + + return this.options.removeRottenWhenUpdated; + } private async _removeLabelsOnStatusTransition( issue: Issue, @@ -1175,6 +1564,42 @@ export class IssuesProcessor { } } + private async _addLabelsWhenUnrotten( + issue: Issue, + labelsToAdd: Readonly[] + ): Promise { + if (!labelsToAdd.length) { + return; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `Adding all the labels specified via the ${this._logger.createOptionLink( + Option.LabelsToAddWhenUnrotten + )} option.` + ); + + // TODO: this might need to be changed to a set to avoiod repetition + this.addedLabelIssues.push(issue); + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsLabel(issue); + if (!this.options.debugOnly) { + await this.client.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: labelsToAdd + }); + } + } catch (error) { + this._logger.error( + `Error when adding labels after updated from rotten: ${error.message}` + ); + } + } private async _removeStaleLabel( issue: Issue, staleLabel: Readonly @@ -1188,6 +1613,19 @@ export class IssuesProcessor { await this._removeLabel(issue, staleLabel); this.statistics?.incrementUndoStaleItemsCount(issue); } + private async _removeRottenLabel( + issue: Issue, + rottenLabel: Readonly + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `The $$type is no longer rotten. Removing the rotten label...` + ); + + await this._removeLabel(issue, rottenLabel); + this.statistics?.incrementUndoRottenItemsCount(issue); + } private async _removeCloseLabel( issue: Issue, @@ -1266,6 +1704,32 @@ export class IssuesProcessor { : Option.DaysBeforePrStale; } + private _getDaysBeforeRottenUsedOptionName( + issue: Readonly + ): + | Option.DaysBeforeRotten + | Option.DaysBeforeIssueRotten + | Option.DaysBeforePrRotten { + return issue.isPullRequest + ? this._getDaysBeforePrRottenUsedOptionName() + : this._getDaysBeforeIssueRottenUsedOptionName(); + } + + private _getDaysBeforeIssueRottenUsedOptionName(): + | Option.DaysBeforeRotten + | Option.DaysBeforeIssueRotten { + return isNaN(this.options.daysBeforeIssueRotten) + ? Option.DaysBeforeRotten + : Option.DaysBeforeIssueRotten; + } + + private _getDaysBeforePrRottenUsedOptionName(): + | Option.DaysBeforeRotten + | Option.DaysBeforePrRotten { + return isNaN(this.options.daysBeforePrRotten) + ? Option.DaysBeforeRotten + : Option.DaysBeforePrRotten; + } private _getRemoveStaleWhenUpdatedUsedOptionName( issue: Readonly ): @@ -1286,4 +1750,24 @@ export class IssuesProcessor { return Option.RemoveStaleWhenUpdated; } + private _getRemoveRottenWhenUpdatedUsedOptionName( + issue: Readonly + ): + | Option.RemovePrRottenWhenUpdated + | Option.RemoveRottenWhenUpdated + | Option.RemoveIssueRottenWhenUpdated { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrRottenWhenUpdated)) { + return Option.RemovePrRottenWhenUpdated; + } + + return Option.RemoveRottenWhenUpdated; + } + + if (isBoolean(this.options.removeIssueRottenWhenUpdated)) { + return Option.RemoveIssueRottenWhenUpdated; + } + + return Option.RemoveRottenWhenUpdated; + } } diff --git a/src/classes/statistics.ts b/src/classes/statistics.ts index 321ea70d9..3e6bba3d2 100644 --- a/src/classes/statistics.ts +++ b/src/classes/statistics.ts @@ -15,6 +15,10 @@ export class Statistics { stalePullRequestsCount = 0; undoStaleIssuesCount = 0; undoStalePullRequestsCount = 0; + rottenIssuesCount = 0; + rottenPullRequestsCount = 0; + undoRottenIssuesCount = 0; + undoRottenPullRequestsCount = 0; operationsCount = 0; closedIssuesCount = 0; closedPullRequestsCount = 0; @@ -65,6 +69,17 @@ export class Statistics { return this._incrementUndoStaleIssuesCount(increment); } + incrementUndoRottenItemsCount( + issue: Readonly, + increment: Readonly = 1 + ): Statistics { + if (issue.isPullRequest) { + return this._incrementUndoRottenPullRequestsCount(increment); + } + + return this._incrementUndoRottenIssuesCount(increment); + } + setOperationsCount(operationsCount: Readonly): Statistics { this.operationsCount = operationsCount; @@ -222,6 +237,21 @@ export class Statistics { return this; } + private _incrementUndoRottenPullRequestsCount( + increment: Readonly = 1 + ): Statistics { + this.undoRottenPullRequestsCount += increment; + + return this; + } + private _incrementUndoRottenIssuesCount( + increment: Readonly = 1 + ): Statistics { + this.undoRottenIssuesCount += increment; + + return this; + } + private _incrementUndoStalePullRequestsCount( increment: Readonly = 1 ): Statistics { diff --git a/src/enums/option.ts b/src/enums/option.ts index 7a9bff026..f27ff881b 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -2,18 +2,25 @@ export enum Option { RepoToken = 'repo-token', StaleIssueMessage = 'stale-issue-message', StalePrMessage = 'stale-pr-message', + RottenIssueMessage = 'rotten-issue-message', + RottenPrMessage = 'rotten-pr-message', CloseIssueMessage = 'close-issue-message', ClosePrMessage = 'close-pr-message', DaysBeforeStale = 'days-before-stale', DaysBeforeIssueStale = 'days-before-issue-stale', DaysBeforePrStale = 'days-before-pr-stale', + DaysBeforeRotten = 'days-before-rotten', + DaysBeforeIssueRotten = 'days-before-issue-rotten', + DaysBeforePrRotten = 'days-before-pr-rotten', DaysBeforeClose = 'days-before-close', DaysBeforeIssueClose = 'days-before-issue-close', DaysBeforePrClose = 'days-before-pr-close', StaleIssueLabel = 'stale-issue-label', + RottenIssueLabel = 'rotten-issue-label', CloseIssueLabel = 'close-issue-label', ExemptIssueLabels = 'exempt-issue-labels', StalePrLabel = 'stale-pr-label', + RottenPrLabel = 'rotten-pr-label', ClosePrLabel = 'close-pr-label', ExemptPrLabels = 'exempt-pr-labels', OnlyLabels = 'only-labels', @@ -24,6 +31,9 @@ export enum Option { RemoveStaleWhenUpdated = 'remove-stale-when-updated', RemoveIssueStaleWhenUpdated = 'remove-issue-stale-when-updated', RemovePrStaleWhenUpdated = 'remove-pr-stale-when-updated', + RemoveRottenWhenUpdated = 'remove-rotten-when-updated', + RemoveIssueRottenWhenUpdated = 'remove-issue-rotten-when-updated', + RemovePrRottenWhenUpdated = 'remove-pr-rotten-when-updated', DebugOnly = 'debug-only', Ascending = 'ascending', DeleteBranch = 'delete-branch', @@ -44,6 +54,9 @@ export enum Option { LabelsToRemoveWhenStale = 'labels-to-remove-when-stale', LabelsToRemoveWhenUnstale = 'labels-to-remove-when-unstale', LabelsToAddWhenUnstale = 'labels-to-add-when-unstale', + LabelsToRemoveWhenRotten = 'labels-to-remove-when-rotten', + LabelsToRemoveWhenUnrotten = 'labels-to-remove-when-unrotten', + LabelsToAddWhenUnrotten = 'labels-to-add-when-unrotten', IgnoreUpdates = 'ignore-updates', IgnoreIssueUpdates = 'ignore-issue-updates', IgnorePrUpdates = 'ignore-pr-updates', diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 930992284..8789489ac 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -4,18 +4,25 @@ export interface IIssuesProcessorOptions { repoToken: string; staleIssueMessage: string; stalePrMessage: string; + rottenIssueMessage: string; + rottenPrMessage: string; closeIssueMessage: string; closePrMessage: string; daysBeforeStale: number; daysBeforeIssueStale: number; // Could be NaN daysBeforePrStale: number; // Could be NaN + daysBeforeRotten: number; + daysBeforeIssueRotten: number; // Could be NaN + daysBeforePrRotten: number; // Could be NaN daysBeforeClose: number; daysBeforeIssueClose: number; // Could be NaN daysBeforePrClose: number; // Could be NaN staleIssueLabel: string; + rottenIssueLabel: string; closeIssueLabel: string; exemptIssueLabels: string; stalePrLabel: string; + rottenPrLabel: string; closePrLabel: string; exemptPrLabels: string; onlyLabels: string; @@ -28,6 +35,9 @@ export interface IIssuesProcessorOptions { removeStaleWhenUpdated: boolean; removeIssueStaleWhenUpdated: boolean | undefined; removePrStaleWhenUpdated: boolean | undefined; + removeRottenWhenUpdated: boolean; + removeIssueRottenWhenUpdated: boolean | undefined; + removePrRottenWhenUpdated: boolean | undefined; debugOnly: boolean; ascending: boolean; deleteBranch: boolean; @@ -48,6 +58,9 @@ export interface IIssuesProcessorOptions { labelsToRemoveWhenStale: string; labelsToRemoveWhenUnstale: string; labelsToAddWhenUnstale: string; + labelsToRemoveWhenRotten: string; + labelsToRemoveWhenUnrotten: string; + labelsToAddWhenUnrotten: string; ignoreUpdates: boolean; ignoreIssueUpdates: boolean | undefined; ignorePrUpdates: boolean | undefined; diff --git a/src/main.ts b/src/main.ts index a7836c160..ea0b82150 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,6 +46,7 @@ async function _run(): Promise { await processOutput( issueProcessor.staleIssues, + issueProcessor.rottenIssues, issueProcessor.closedIssues ); } catch (error) { @@ -59,22 +60,33 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { repoToken: core.getInput('repo-token'), staleIssueMessage: core.getInput('stale-issue-message'), stalePrMessage: core.getInput('stale-pr-message'), + rottenIssueMessage: core.getInput('rotten-issue-message'), + rottenPrMessage: core.getInput('rotten-pr-message'), closeIssueMessage: core.getInput('close-issue-message'), closePrMessage: core.getInput('close-pr-message'), daysBeforeStale: parseFloat( core.getInput('days-before-stale', {required: true}) ), + daysBeforeRotten: parseFloat( + core.getInput('days-before-rotten', {required: true}) + ), daysBeforeIssueStale: parseFloat(core.getInput('days-before-issue-stale')), daysBeforePrStale: parseFloat(core.getInput('days-before-pr-stale')), + daysBeforeIssueRotten: parseFloat( + core.getInput('days-before-issue-rotten') + ), + daysBeforePrRotten: parseFloat(core.getInput('days-before-pr-rotten')), daysBeforeClose: parseInt( core.getInput('days-before-close', {required: true}) ), daysBeforeIssueClose: parseInt(core.getInput('days-before-issue-close')), daysBeforePrClose: parseInt(core.getInput('days-before-pr-close')), staleIssueLabel: core.getInput('stale-issue-label', {required: true}), + rottenIssueLabel: core.getInput('rotten-issue-label', {required: true}), closeIssueLabel: core.getInput('close-issue-label'), exemptIssueLabels: core.getInput('exempt-issue-labels'), stalePrLabel: core.getInput('stale-pr-label', {required: true}), + rottenPrLabel: core.getInput('rotten-pr-label', {required: true}), closePrLabel: core.getInput('close-pr-label'), exemptPrLabels: core.getInput('exempt-pr-labels'), onlyLabels: core.getInput('only-labels'), @@ -95,6 +107,15 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { removePrStaleWhenUpdated: _toOptionalBoolean( 'remove-pr-stale-when-updated' ), + removeRottenWhenUpdated: !( + core.getInput('remove-rotten-when-updated') === 'false' + ), + removeIssueRottenWhenUpdated: _toOptionalBoolean( + 'remove-issue-rotten-when-updated' + ), + removePrRottenWhenUpdated: _toOptionalBoolean( + 'remove-pr-rotten-when-updated' + ), debugOnly: core.getInput('debug-only') === 'true', ascending: core.getInput('ascending') === 'true', deleteBranch: core.getInput('delete-branch') === 'true', @@ -118,6 +139,9 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { labelsToRemoveWhenStale: core.getInput('labels-to-remove-when-stale'), labelsToRemoveWhenUnstale: core.getInput('labels-to-remove-when-unstale'), labelsToAddWhenUnstale: core.getInput('labels-to-add-when-unstale'), + labelsToRemoveWhenRotten: core.getInput('labels-to-remove-when-rotten'), + labelsToRemoveWhenUnrotten: core.getInput('labels-to-remove-when-unrotten'), + labelsToAddWhenUnrotten: core.getInput('labels-to-add-when-unrotten'), ignoreUpdates: core.getInput('ignore-updates') === 'true', ignoreIssueUpdates: _toOptionalBoolean('ignore-issue-updates'), ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), @@ -133,6 +157,13 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { throw new Error(errorMessage); } } + for (const numberInput of ['days-before-rotten']) { + if (isNaN(parseFloat(core.getInput(numberInput)))) { + const errorMessage = `Option "${numberInput}" did not parse to a valid float`; + core.setFailed(errorMessage); + throw new Error(errorMessage); + } + } for (const numberInput of ['days-before-close', 'operations-per-run']) { if (isNaN(parseInt(core.getInput(numberInput)))) { @@ -167,9 +198,11 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { async function processOutput( staledIssues: Issue[], + rottenIssues: Issue[], closedIssues: Issue[] ): Promise { core.setOutput('staled-issues-prs', JSON.stringify(staledIssues)); + core.setOutput('rotten-issues-prs', JSON.stringify(rottenIssues)); core.setOutput('closed-issues-prs', JSON.stringify(closedIssues)); }