Repository: actions/stale
Pull request #468 (2021-05-26 00:23:42)

Add support for adding & removing labels when no longer stale
Fixes https://github.com/actions/stale/issues/460

## Changes
Add support for:
- labels-to-remove-when-unstale
- labels-to-add-when-unstale

## Context
In order to prevent scenarios like [this](https://github.com/actions/stale/issues/288) and to reduce the amount of upkeep required for maintainers, allow the action to remove/add labels to prevent marking issues stale over and over again. 

Example of an ideal scenario is this:

1. User submits issue
2. We respond and need author feedback, apply that label
3. Issue goes stale via automation
4. Author (or someone else with the same issue) responds suggesting it's still an issue
5. Automation should remove the `needs-author-feedback` label and add some `author-responded` or `needs-dev-response` label

This allows automation to ONLY run on `needs-author-feedback` issues, or be exempt on `author-responded` issues.

BenVillalobos: I noticed there was no easy way to check if specific labels were added / removed from issues, so I kept the "make sure some label was added to an issue" test similar to the others, where it just checks that _an_ issue had a label added to it when we unstale it.

It's harder to do that for unstale-issue-has-label-removed without redesigning some of the code, which is more than I'd like to chew. I could add a test that removes a label on unstale and check if the array that contains "issues that had labels removed from them" contains two issues (_removeLabel was called twice and therefore added the same item twice), but it's not ideal.
BenVillalobos: Just rebased off of main. When running `npm run all` I get some diffs that look to be mostly auto formatting. Should I push them up as a single commit or leave these out?

![image](https://user-images.githubusercontent.com/4691428/121073790-d7cd4b00-c787-11eb-8a4f-b938c3967951.png)

C0ZEN: @BenVillalobos you need to commit all those changes (probably due to a recent npm i which updated deps due to unpinned dependencies).

luketomlinson: @BenVillalobos some conflicts FYI

Patch:
diff --git a/README.md b/README.md
index 07a5b98..8435797 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/dist/index.js b/dist/index.js
index aeedcb7..859c6a8 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 1fd8690..4d44ddc 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 e5e963a..2ca165a 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<void> {
+  async processIssue(
+    issue: Issue,
+    actor: string,
+    labelsToAddWhenUnstale: Readonly<string>[],
+    labelsToRemoveWhenUnstale: Readonly<string>[]
+  ): Promise<void> {
     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<string>[],
+    labelsToRemoveWhenUnstale: Readonly<string>[],
     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<string>[]
+  ): Promise<void> {
+    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<string>[]
+  ): Promise<void> {
+    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<string>
diff --git a/src/enums/option.ts b/src/enums/option.ts
index 0ff794a..b127fc8 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 6ce79db..d77ee07 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 98e6674..c759fe3 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 [


Test patch:
diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts
index 3806765..d598775 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 1c56531..303ca95 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';