Repository: actions/stale (ts)

Issue:
'Label does not exist' error when removing issues from stale state
Apparently starting from version v3.0.9 (based on the date it started to happen) I started to get the following error when the actions was removing the stale label of an issue:
```
##[error]Error removing a label: Label does not exist
```

This was the first run it happened: https://github.com/RocketChat/Rocket.Chat/runs/907729444?check_suite_focus=true#step:3:401

This is letting all issues still marked as stable, not sure it is also preventing them to be closed.

As [you can see](https://github.com/RocketChat/Rocket.Chat/actions/runs/181500400/workflow#L37) we use the `stat: stale` label, which is [still valid](https://github.com/RocketChat/Rocket.Chat/labels/stat%3A%20stale)

It took me a long time to notice this, as we have too many issues 🙈 

Pull request:
fix(remove-label): do not encode the label to make sure to remove it
Remove the `encodeURIComponent` when calling the GitHub API to remove a label.
The documentation is not specifying anything regarding special comments so it could work but I was not able to test it.

Fixes #173.

Patch:
diff --git a/action.yml b/action.yml
index 9b6cf43..ea6c6d1 100644
--- a/action.yml
+++ b/action.yml
@@ -56,6 +56,9 @@ inputs:
   skip-stale-issue-message:
     description: 'Skip adding stale message when marking an issue as stale.'
     default: false
+  delete-branch:
+    description: 'Delete the git branch after closing a stale pull request.'
+    default: false
 runs:
   using: 'node12'
   main: 'dist/index.js'
diff --git a/src/IssueProcessor.ts b/src/IssueProcessor.ts
index 77f3b09..329543a 100644
--- a/src/IssueProcessor.ts
+++ b/src/IssueProcessor.ts
@@ -1,5 +1,6 @@
 import * as core from '@actions/core';
 import {context, getOctokit} from '@actions/github';
+import {GitHub} from '@actions/github/lib/utils';
 import {GetResponseTypeFromEndpointMethod} from '@octokit/types';
 import {isLabeled} from './functions/is-labeled';
 import {labelsToList} from './functions/labels-to-list';
@@ -14,6 +15,13 @@ export interface Issue {
   locked: boolean;
 }
 
+export interface PullRequest {
+  number: number;
+  head: {
+    ref: string;
+  };
+}
+
 export interface User {
   type: string;
   login: string;
@@ -54,18 +62,20 @@ export interface IssueProcessorOptions {
   ascending: boolean;
   skipStaleIssueMessage: boolean;
   skipStalePrMessage: boolean;
+  deleteBranch: boolean;
 }
 
 /***
  * Handle processing of issues for staleness/closure.
  */
 export class IssueProcessor {
-  readonly client: any; // need to make this the correct type
+  readonly client: InstanceType<typeof GitHub>;
   readonly options: IssueProcessorOptions;
   private operationsLeft = 0;
 
   readonly staleIssues: Issue[] = [];
   readonly closedIssues: Issue[] = [];
+  readonly deletedBranchIssues: Issue[] = [];
   readonly removedLabelIssues: Issue[] = [];
 
   constructor(
@@ -167,7 +177,13 @@ export class IssueProcessor {
       }
 
       // does this issue have a stale label?
-      let isStale = isLabeled(issue, staleLabel);
+      let isStale: boolean = isLabeled(issue, staleLabel);
+
+      if (isStale) {
+        core.info(`This issue has a stale label`);
+      } else {
+        core.info(`This issue hasn't a stale label`);
+      }
 
       // should this issue be marked stale?
       const shouldBeStale = !IssueProcessor.updatedSince(
@@ -250,6 +266,14 @@ export class IssueProcessor {
         `Closing ${issueType} because it was last updated on ${issue.updated_at}`
       );
       await this.closeIssue(issue, closeMessage, closeLabel);
+
+      if (this.options.deleteBranch && issue.pull_request) {
+        core.info(
+          `Deleting branch for #${issue.number} as delete-branch option was specified`
+        );
+        await this.deleteBranch(issue);
+        this.deletedBranchIssues.push(issue);
+      }
     } else {
       core.info(
         `Stale ${issueType} is not old enough to close yet (hasComments? ${issueHasComments}, hasUpdate? ${issueHasUpdate})`
@@ -432,14 +456,70 @@ export class IssueProcessor {
     }
   }
 
+  private async getPullRequest(
+    pullNumber: number
+  ): Promise<PullRequest | undefined> {
+    this.operationsLeft -= 1;
+
+    try {
+      const pullRequest = await this.client.pulls.get({
+        owner: context.repo.owner,
+        repo: context.repo.repo,
+        pull_number: pullNumber
+      });
+
+      return pullRequest.data;
+    } catch (error) {
+      core.error(`Error getting pull request ${pullNumber}: ${error.message}`);
+    }
+  }
+
+  // Delete the branch on closed pull request
+  private async deleteBranch(issue: Issue): Promise<void> {
+    core.info(
+      `Delete branch from closed issue #${issue.number} - ${issue.title}`
+    );
+
+    if (this.options.debugOnly) {
+      return;
+    }
+
+    const pullRequest = await this.getPullRequest(issue.number);
+
+    if (!pullRequest) {
+      core.info(
+        `Not deleting branch as pull request not found for issue ${issue.number}`
+      );
+      return;
+    }
+
+    const branch = pullRequest.head.ref;
+    core.info(`Deleting branch ${branch} from closed issue #${issue.number}`);
+
+    this.operationsLeft -= 1;
+
+    try {
+      await this.client.git.deleteRef({
+        owner: context.repo.owner,
+        repo: context.repo.repo,
+        ref: `heads/${branch}`
+      });
+    } catch (error) {
+      core.error(
+        `Error deleting branch ${branch} from issue #${issue.number}: ${error.message}`
+      );
+    }
+  }
+
   // Remove a label from an issue
   private async removeLabel(issue: Issue, label: string): Promise<void> {
-    core.info(`Removing label from issue #${issue.number}`);
+    core.info(`Removing label "${label}" from issue #${issue.number}`);
 
     this.removedLabelIssues.push(issue);
 
     this.operationsLeft -= 1;
 
+    // @todo remove the debug only to be able to test the code below
     if (this.options.debugOnly) {
       return;
     }
@@ -449,7 +529,7 @@ export class IssueProcessor {
         owner: context.repo.owner,
         repo: context.repo.repo,
         issue_number: issue.number,
-        name: encodeURIComponent(label) // A label can have a "?" in the name
+        name: label
       });
     } catch (error) {
       core.error(`Error removing a label: ${error.message}`);
diff --git a/src/functions/is-labeled.ts b/src/functions/is-labeled.ts
index 97ee5e3..5ad332a 100644
--- a/src/functions/is-labeled.ts
+++ b/src/functions/is-labeled.ts
@@ -1,6 +1,8 @@
 import deburr from 'lodash.deburr';
 import {Issue, Label} from '../IssueProcessor';
 
+type CleanLabel = string;
+
 /**
  * @description
  * Check if the label is listed as a label of the issue
@@ -19,6 +21,6 @@ export function isLabeled(
   });
 }
 
-function cleanLabel(label: Readonly<string>): string {
+function cleanLabel(label: Readonly<string>): CleanLabel {
   return deburr(label.toLowerCase());
 }
diff --git a/src/main.ts b/src/main.ts
index b1e0dc3..1cd914a 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -42,7 +42,8 @@ function getAndValidateArgs(): IssueProcessorOptions {
     debugOnly: core.getInput('debug-only') === 'true',
     ascending: core.getInput('ascending') === 'true',
     skipStalePrMessage: core.getInput('skip-stale-pr-message') === 'true',
-    skipStaleIssueMessage: core.getInput('skip-stale-issue-message') === 'true'
+    skipStaleIssueMessage: core.getInput('skip-stale-issue-message') === 'true',
+    deleteBranch: core.getInput('delete-branch') === 'true'
   };
 
   for (const numberInput of [


Test patch:
diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts
index 85fe705..495f2a9 100644
--- a/__tests__/main.test.ts
+++ b/__tests__/main.test.ts
@@ -1,11 +1,8 @@
-import * as core from '@actions/core';
 import * as github from '@actions/github';
-import {Octokit} from '@octokit/rest';
 
 import {
   IssueProcessor,
   Issue,
-  Label,
   IssueProcessorOptions
 } from '../src/IssueProcessor';
 
@@ -51,7 +48,8 @@ const DefaultProcessorOptions: IssueProcessorOptions = Object.freeze({
   removeStaleWhenUpdated: false,
   ascending: false,
   skipStaleIssueMessage: false,
-  skipStalePrMessage: false
+  skipStalePrMessage: false,
+  deleteBranch: false
 });
 
 test('empty issue list results in 1 operation', async () => {
@@ -89,6 +87,7 @@ test('processing an issue with no label will make it stale and close it, if it i
 
   expect(processor.staleIssues.length).toEqual(1);
   expect(processor.closedIssues.length).toEqual(1);
+  expect(processor.deletedBranchIssues.length).toEqual(0);
 });
 
 test('processing an issue with no label will make it stale and not close it if days-before-close is set to > 0', async () => {
@@ -187,6 +186,66 @@ test('processing a stale issue will close it', async () => {
   expect(processor.closedIssues.length).toEqual(1);
 });
 
+test('processing a stale issue containing a space in the label will close it', async () => {
+  const TestIssueList: Issue[] = [
+    generateIssue(
+      1,
+      'A stale issue that should be closed',
+      '2020-01-01T17:00:00Z',
+      false,
+      ['state: stale']
+    )
+  ];
+
+  const opts: IssueProcessorOptions = {
+    ...DefaultProcessorOptions,
+    staleIssueLabel: 'state: stale'
+  };
+
+  const processor = new IssueProcessor(
+    opts,
+    async p => (p == 1 ? TestIssueList : []),
+    async (num, dt) => [],
+    async (issue, label) => new Date().toDateString()
+  );
+
+  // process our fake issue list
+  await processor.processIssues(1);
+
+  expect(processor.staleIssues.length).toEqual(0);
+  expect(processor.closedIssues.length).toEqual(1);
+});
+
+test('processing a stale issue containing a slash in the label will close it', async () => {
+  const TestIssueList: Issue[] = [
+    generateIssue(
+      1,
+      'A stale issue that should be closed',
+      '2020-01-01T17:00:00Z',
+      false,
+      ['lifecycle/stale']
+    )
+  ];
+
+  const opts: IssueProcessorOptions = {
+    ...DefaultProcessorOptions,
+    staleIssueLabel: 'lifecycle/stale'
+  };
+
+  const processor = new IssueProcessor(
+    opts,
+    async p => (p == 1 ? TestIssueList : []),
+    async (num, dt) => [],
+    async (issue, label) => new Date().toDateString()
+  );
+
+  // process our fake issue list
+  await processor.processIssues(1);
+
+  expect(processor.staleIssues.length).toEqual(0);
+  expect(processor.closedIssues.length).toEqual(1);
+});
+
 test('processing a stale PR will close it', async () => {
   const TestIssueList: Issue[] = [
     generateIssue(
@@ -648,6 +707,38 @@ test('stale label should not be removed if a comment was added by the bot (and t
   expect(processor.removedLabelIssues.length).toEqual(0);
 });
 
+test('stale label containing a space should be removed if a comment was added to a stale issue', async () => {
+  const TestIssueList: Issue[] = [
+    generateIssue(
+      1,
+      'An issue that should un-stale',
+      '2020-01-01T17:00:00Z',
+      false,
+      ['stat: stale']
+    )
+  ];
+
+  const opts: IssueProcessorOptions = {
+    ...DefaultProcessorOptions,
+    removeStaleWhenUpdated: true,
+    staleIssueLabel: 'stat: stale'
+  };
+
+  const processor = new IssueProcessor(
+    opts,
+    async p => (p == 1 ? TestIssueList : []),
+    async (num, dt) => [{user: {login: 'notme', type: 'User'}}], // return a fake comment to indicate there was an update
+    async (issue, label) => new Date().toDateString()
+  );
+
+  // process our fake issue list
+  await processor.processIssues(1);
+
+  expect(processor.closedIssues.length).toEqual(0);
+  expect(processor.staleIssues.length).toEqual(0);
+  expect(processor.removedLabelIssues.length).toEqual(1);
+});
+
 test('stale issues should not be closed until after the closed number of days', async () => {
   let lastUpdate = new Date();
   lastUpdate.setDate(lastUpdate.getDate() - 5);
@@ -895,3 +986,59 @@ test('not providing stalePrMessage takes precedence over skipStalePrMessage', as
   expect(processor.removedLabelIssues.length).toEqual(0);
   expect(processor.staleIssues.length).toEqual(0);
 });
+
+test('git branch is deleted when option is enabled', async () => {
+  const opts = {...DefaultProcessorOptions, deleteBranch: true};
+  const isPullRequest = true;
+  const TestIssueList: Issue[] = [
+    generateIssue(
+      1,
+      'An issue that should have its branch deleted',
+      '2020-01-01T17:00:00Z',
+      isPullRequest,
+      ['Stale']
+    )
+  ];
+
+  const processor = new IssueProcessor(
+    opts,
+    async p => (p == 1 ? TestIssueList : []),
+    async (num, dt) => [],
+    async (issue, label) => new Date().toDateString()
+  );
+
+  await processor.processIssues(1);
+
+  expect(processor.closedIssues.length).toEqual(1);
+  expect(processor.removedLabelIssues.length).toEqual(0);
+  expect(processor.staleIssues.length).toEqual(0);
+  expect(processor.deletedBranchIssues.length).toEqual(1);
+});
+
+test('git branch is not deleted when issue is not pull request', async () => {
+  const opts = {...DefaultProcessorOptions, deleteBranch: true};
+  const isPullRequest = false;
+  const TestIssueList: Issue[] = [
+    generateIssue(
+      1,
+      'An issue that should not have its branch deleted',
+      '2020-01-01T17:00:00Z',
+      isPullRequest,
+      ['Stale']
+    )
+  ];
+
+  const processor = new IssueProcessor(
+    opts,
+    async p => (p == 1 ? TestIssueList : []),
+    async (num, dt) => [],
+    async (issue, label) => new Date().toDateString()
+  );
+
+  await processor.processIssues(1);
+
+  expect(processor.closedIssues.length).toEqual(1);
+  expect(processor.removedLabelIssues.length).toEqual(0);
+  expect(processor.staleIssues.length).toEqual(0);
+  expect(processor.deletedBranchIssues.length).toEqual(0);
+});