diff --git a/README.md b/README.md index 07a5b989..84357971 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Every argument is optional. | [remove-stale-when-updated](#remove-stale-when-updated) | Remove stale label from issues/PRs on updates/comments | `true` | | [remove-issue-stale-when-updated](#remove-issue-stale-when-updated) | Remove stale label from issues on updates/comments | | | [remove-pr-stale-when-updated](#remove-pr-stale-when-updated) | Remove stale label from PRs on updates/comments | | +| [labels-to-add-when-unstale](#labels-to-add-when-unstale) | Add specified labels from issues/PRs when they become unstale | | +| [labels-to-remove-when-unstale](#labels-to-remove-when-unstale) | Remove specified labels from issues/PRs when they become unstale | | | [debug-only](#debug-only) | Dry-run | `false` | | [ascending](#ascending) | Order to get issues/PRs | `false` | | [start-date](#start-date) | Skip stale action for issues/PRs created before it | | @@ -307,6 +309,20 @@ Override [remove-stale-when-updated](#remove-stale-when-updated) but only to aut Default value: unset +#### labels-to-add-when-unstale + +A comma delimited list of labels to add when a stale issue or pull request receives activity and has the [stale-issue-label](#stale-issue-label) or [stale-pr-label](#stale-pr-label) removed from it. + +Default value: unset + +#### labels-to-remove-when-unstale + +A comma delimited list of labels to remove when a stale issue or pull request receives activity and has the [stale-issue-label](#stale-issue-label) or [stale-pr-label](#stale-pr-label) removed from it. + +Warning: each label results in a unique API call which can drastically consume the limit of [operations-per-run](#operations-per-run). + +Default value: unset + #### debug-only Run the stale workflow as dry-run. diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 38067653..d598775c 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -44,5 +44,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ exemptAllAssignees: false, exemptAllIssueAssignees: undefined, exemptAllPrAssignees: undefined, - enableStatistics: true + enableStatistics: true, + labelsToRemoveWhenUnstale: '', + labelsToAddWhenUnstale: '' }); diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 1c56531f..303ca955 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -1255,6 +1255,50 @@ test('stale label should be removed if a comment was added to a stale issue', as expect(processor.removedLabelIssues).toHaveLength(1); }); +test('when the option "labelsToAddWhenUnstale" is set, the labels should be added when unstale', async () => { + expect.assertions(4); + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + labelsToAddWhenUnstale: 'test' + }; + const TestIssueList: Issue[] = [ + generateIssue( + opts, + 1, + 'An issue that should have labels added to it when unstale', + '2020-01-01T17:00:00Z', + '2020-01-01T17:00:00Z', + false, + ['Stale'] + ) + ]; + const processor = new IssuesProcessorMock( + opts, + async () => 'abot', + async p => (p === 1 ? TestIssueList : []), + async () => [ + { + user: { + login: 'notme', + type: 'User' + } + } + ], // return a fake comment to indicate there was an update + async () => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.closedIssues).toHaveLength(0); + expect(processor.staleIssues).toHaveLength(0); + // Stale should have been removed + expect(processor.removedLabelIssues).toHaveLength(1); + // Some label should have been added + expect(processor.addedLabelIssues).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}; github.context.actor = 'abot'; diff --git a/dist/index.js b/dist/index.js index aeedcb77..859c6a8b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -253,6 +253,7 @@ class IssuesProcessor { this.closedIssues = []; this.deletedBranchIssues = []; this.removedLabelIssues = []; + this.addedLabelIssues = []; this.options = options; this.client = github_1.getOctokit(this.options.repoToken); this.operations = new stale_operations_1.StaleOperations(this.options); @@ -299,6 +300,8 @@ class IssuesProcessor { else { this._logger.info(`${logger_service_1.LoggerService.yellow('Processing the batch of issues')} ${logger_service_1.LoggerService.cyan(`#${page}`)} ${logger_service_1.LoggerService.yellow('containing')} ${logger_service_1.LoggerService.cyan(issues.length)} ${logger_service_1.LoggerService.yellow(`issue${issues.length > 1 ? 's' : ''}...`)}`); } + const labelsToAddWhenUnstale = words_to_list_1.wordsToList(this.options.labelsToAddWhenUnstale); + const labelsToRemoveWhenUnstale = words_to_list_1.wordsToList(this.options.labelsToRemoveWhenUnstale); for (const issue of issues.values()) { // Stop the processing if no more operations remains if (!this.operations.hasRemainingOperations()) { @@ -306,7 +309,7 @@ class IssuesProcessor { } const issueLogger = new issue_logger_1.IssueLogger(issue); yield issueLogger.grouping(`$$type #${issue.number}`, () => __awaiter(this, void 0, void 0, function* () { - yield this.processIssue(issue, actor); + yield this.processIssue(issue, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale); })); } if (!this.operations.hasRemainingOperations()) { @@ -320,7 +323,7 @@ class IssuesProcessor { return this.processIssues(page + 1); }); } - processIssue(issue, actor) { + processIssue(issue, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale) { var _a; return __awaiter(this, void 0, void 0, function* () { (_a = this._statistics) === null || _a === void 0 ? void 0 : _a.incrementProcessedItemsCount(issue); @@ -469,7 +472,7 @@ class IssuesProcessor { // Process the issue if it was marked stale if (issue.isStale) { issueLogger.info(`This $$type is already stale`); - yield this._processStaleIssue(issue, staleLabel, actor, closeMessage, closeLabel); + yield this._processStaleIssue(issue, staleLabel, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, closeMessage, closeLabel); } IssuesProcessor._endIssueProcessing(issue); }); @@ -561,7 +564,7 @@ class IssuesProcessor { }); } // handle all of the stale issue logic when we find a stale issue - _processStaleIssue(issue, staleLabel, actor, closeMessage, closeLabel) { + _processStaleIssue(issue, staleLabel, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, closeMessage, closeLabel) { return __awaiter(this, void 0, void 0, function* () { const issueLogger = new issue_logger_1.IssueLogger(issue); const markedStaleOn = (yield this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; @@ -586,6 +589,9 @@ class IssuesProcessor { if (shouldRemoveStaleWhenUpdated && issueHasComments) { issueLogger.info(`Remove the stale label since the $$type has a comment and the workflow should remove the stale label when updated`); yield this._removeStaleLabel(issue, staleLabel); + // Are there labels to remove or add when an issue is no longer stale? + yield this._removeLabelsWhenUnstale(issue, labelsToRemoveWhenUnstale); + yield this._addLabelsWhenUnstale(issue, labelsToAddWhenUnstale); issueLogger.info(`Skipping the process since the $$type is now un-stale`); return; // Nothing to do because it is no longer stale } @@ -854,6 +860,44 @@ class IssuesProcessor { } return this.options.removeStaleWhenUpdated; } + _removeLabelsWhenUnstale(issue, removeLabels) { + return __awaiter(this, void 0, void 0, function* () { + if (!removeLabels.length) { + return; + } + const issueLogger = new issue_logger_1.IssueLogger(issue); + issueLogger.info(`Removing all the labels specified via the ${this._logger.createOptionLink(option_1.Option.LabelsToRemoveWhenUnstale)} option.`); + for (const label of removeLabels.values()) { + yield this._removeLabel(issue, label); + } + }); + } + _addLabelsWhenUnstale(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.LabelsToAddWhenUnstale)} option.`); + this.addedLabelIssues.push(issue); + try { + this.operations.consumeOperation(); + (_a = this._statistics) === null || _a === void 0 ? void 0 : _a.incrementAddedItemsLabel(issue); + if (!this.options.debugOnly) { + yield this.client.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 stale: ${error.message}`); + } + }); + } _removeStaleLabel(issue, staleLabel) { var _a; return __awaiter(this, void 0, void 0, function* () { @@ -1692,6 +1736,8 @@ var Option; Option["ExemptAllIssueAssignees"] = "exempt-all-issue-assignees"; Option["ExemptAllPrAssignees"] = "exempt-all-pr-assignees"; Option["EnableStatistics"] = "enable-statistics"; + Option["LabelsToRemoveWhenUnstale"] = "labels-to-remove-when-unstale"; + Option["LabelsToAddWhenUnstale"] = "labels-to-add-when-unstale"; })(Option = exports.Option || (exports.Option = {})); @@ -1975,7 +2021,9 @@ function _getAndValidateArgs() { exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true', exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'), exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees'), - enableStatistics: core.getInput('enable-statistics') === 'true' + enableStatistics: core.getInput('enable-statistics') === 'true', + labelsToRemoveWhenUnstale: core.getInput('labels-to-remove-when-unstale'), + labelsToAddWhenUnstale: core.getInput('labels-to-add-when-unstale') }; for (const numberInput of [ 'days-before-stale', diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index 1fd8690f..4d44ddc5 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -55,7 +55,9 @@ describe('Issue', (): void => { exemptAllAssignees: false, exemptAllIssueAssignees: undefined, exemptAllPrAssignees: undefined, - enableStatistics: false + enableStatistics: false, + labelsToRemoveWhenUnstale: '', + labelsToAddWhenUnstale: '' }; issueInterface = { title: 'dummy-title', diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index e5e963aa..2ca165a4 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -75,6 +75,7 @@ export class IssuesProcessor { readonly closedIssues: Issue[] = []; readonly deletedBranchIssues: Issue[] = []; readonly removedLabelIssues: Issue[] = []; + readonly addedLabelIssues: Issue[] = []; constructor(options: IIssuesProcessorOptions) { this.options = options; @@ -127,6 +128,13 @@ export class IssuesProcessor { ); } + const labelsToAddWhenUnstale: string[] = wordsToList( + this.options.labelsToAddWhenUnstale + ); + const labelsToRemoveWhenUnstale: string[] = wordsToList( + this.options.labelsToRemoveWhenUnstale + ); + for (const issue of issues.values()) { // Stop the processing if no more operations remains if (!this.operations.hasRemainingOperations()) { @@ -135,7 +143,12 @@ export class IssuesProcessor { const issueLogger: IssueLogger = new IssueLogger(issue); await issueLogger.grouping(`$$type #${issue.number}`, async () => { - await this.processIssue(issue, actor); + await this.processIssue( + issue, + actor, + labelsToAddWhenUnstale, + labelsToRemoveWhenUnstale + ); }); } @@ -169,7 +182,12 @@ export class IssuesProcessor { return this.processIssues(page + 1); } - async processIssue(issue: Issue, actor: string): Promise { + async processIssue( + issue: Issue, + actor: string, + labelsToAddWhenUnstale: Readonly[], + labelsToRemoveWhenUnstale: Readonly[] + ): Promise { this._statistics?.incrementProcessedItemsCount(issue); const issueLogger: IssueLogger = new IssueLogger(issue); @@ -438,6 +456,8 @@ export class IssuesProcessor { issue, staleLabel, actor, + labelsToAddWhenUnstale, + labelsToRemoveWhenUnstale, closeMessage, closeLabel ); @@ -549,6 +569,8 @@ export class IssuesProcessor { issue: Issue, staleLabel: string, actor: string, + labelsToAddWhenUnstale: Readonly[], + labelsToRemoveWhenUnstale: Readonly[], closeMessage?: string, closeLabel?: string ) { @@ -608,6 +630,10 @@ export class IssuesProcessor { ); await this._removeStaleLabel(issue, staleLabel); + // Are there labels to remove or add when an issue is no longer stale? + await this._removeLabelsWhenUnstale(issue, labelsToRemoveWhenUnstale); + await this._addLabelsWhenUnstale(issue, labelsToAddWhenUnstale); + issueLogger.info(`Skipping the process since the $$type is now un-stale`); return; // Nothing to do because it is no longer stale @@ -956,6 +982,63 @@ export class IssuesProcessor { return this.options.removeStaleWhenUpdated; } + private async _removeLabelsWhenUnstale( + issue: Issue, + removeLabels: Readonly[] + ): Promise { + if (!removeLabels.length) { + return; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `Removing all the labels specified via the ${this._logger.createOptionLink( + Option.LabelsToRemoveWhenUnstale + )} option.` + ); + + for (const label of removeLabels.values()) { + await this._removeLabel(issue, label); + } + } + + private async _addLabelsWhenUnstale( + 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.LabelsToAddWhenUnstale + )} option.` + ); + + this.addedLabelIssues.push(issue); + + try { + this.operations.consumeOperation(); + this._statistics?.incrementAddedItemsLabel(issue); + if (!this.options.debugOnly) { + await this.client.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 stale: ${error.message}` + ); + } + } + private async _removeStaleLabel( issue: Issue, staleLabel: Readonly diff --git a/src/enums/option.ts b/src/enums/option.ts index 0ff794a0..b127fc8d 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -40,5 +40,7 @@ export enum Option { ExemptAllAssignees = 'exempt-all-assignees', ExemptAllIssueAssignees = 'exempt-all-issue-assignees', ExemptAllPrAssignees = 'exempt-all-pr-assignees', - EnableStatistics = 'enable-statistics' + EnableStatistics = 'enable-statistics', + LabelsToRemoveWhenUnstale = 'labels-to-remove-when-unstale', + LabelsToAddWhenUnstale = 'labels-to-add-when-unstale' } diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 6ce79db1..d77ee072 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -45,4 +45,6 @@ export interface IIssuesProcessorOptions { exemptAllIssueAssignees: boolean | undefined; exemptAllPrAssignees: boolean | undefined; enableStatistics: boolean; + labelsToRemoveWhenUnstale: string; + labelsToAddWhenUnstale: string; } diff --git a/src/main.ts b/src/main.ts index 98e66743..c759fe34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -81,7 +81,9 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true', exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'), exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees'), - enableStatistics: core.getInput('enable-statistics') === 'true' + enableStatistics: core.getInput('enable-statistics') === 'true', + labelsToRemoveWhenUnstale: core.getInput('labels-to-remove-when-unstale'), + labelsToAddWhenUnstale: core.getInput('labels-to-add-when-unstale') }; for (const numberInput of [