From 9e2995bba578fd33bd04a248dd32fe7d8c37aed4 Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Tue, 27 Feb 2024 17:53:19 +0530 Subject: [PATCH 1/7] Add rotten functionality Add processRottenIssue Add Options to support rotten issues Update main.ts to process rotten related inputs Update the Option enum to include rotten related variables Update issue.ts to include function to get the rotten label Add helper functions for rotten related options --- .../constants/default-processor-options.ts | 13 + __tests__/main.spec.ts | 40 +- action.yml | 56 +- src/classes/issue.ts | 142 +- src/classes/issues-processor.ts | 2739 ++++++++++------- src/classes/statistics.ts | 31 + src/enums/option.ts | 13 + src/interfaces/issues-processor-options.ts | 121 +- src/main.ts | 31 + 9 files changed, 1921 insertions(+), 1265 deletions(-) diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 0265b6446..74af70e2f 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: 0, 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..7ad9e10a3 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); }); @@ -488,6 +525,7 @@ test('processing a stale issue will close it', async () => { await processor.processIssues(1); expect(processor.staleIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(1); expect(processor.closedIssues).toHaveLength(1); }); 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/src/classes/issue.ts b/src/classes/issue.ts index b90631835..f3ca750ed 100644 --- a/src/classes/issue.ts +++ b/src/classes/issue.ts @@ -1,76 +1,88 @@ -import {isLabeled} from '../functions/is-labeled'; -import {isPullRequest} from '../functions/is-pull-request'; -import {Assignee} from '../interfaces/assignee'; -import {IIssue, OctokitIssue} from '../interfaces/issue'; -import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; -import {ILabel} from '../interfaces/label'; -import {IMilestone} from '../interfaces/milestone'; -import {IsoDateString} from '../types/iso-date-string'; -import {Operations} from './operations'; +import { isLabeled } from '../functions/is-labeled'; +import { isPullRequest } from '../functions/is-pull-request'; +import { Assignee } from '../interfaces/assignee'; +import { IIssue, OctokitIssue } from '../interfaces/issue'; +import { IIssuesProcessorOptions } from '../interfaces/issues-processor-options'; +import { ILabel } from '../interfaces/label'; +import { IMilestone } from '../interfaces/milestone'; +import { IsoDateString } from '../types/iso-date-string'; +import { Operations } from './operations'; export class Issue implements IIssue { - readonly title: string; - readonly number: number; - created_at: IsoDateString; - updated_at: IsoDateString; - readonly draft: boolean; - readonly labels: ILabel[]; - readonly pull_request: object | null | undefined; - readonly state: string | 'closed' | 'open'; - readonly locked: boolean; - readonly milestone?: IMilestone | null; - readonly assignees: Assignee[]; - isStale: boolean; - markedStaleThisRun: boolean; - operations = new Operations(); - private readonly _options: IIssuesProcessorOptions; + readonly title: string; + readonly number: number; + created_at: IsoDateString; + updated_at: IsoDateString; + readonly draft: boolean; + readonly labels: ILabel[]; + readonly pull_request: object | null | undefined; + readonly state: string | 'closed' | 'open'; + readonly locked: boolean; + readonly milestone?: IMilestone | null; + readonly assignees: Assignee[]; + isStale: boolean; + isRotten: boolean; + markedStaleThisRun: boolean; + markedRottenThisRun: boolean; + operations = new Operations(); + private readonly _options: IIssuesProcessorOptions; - constructor( - options: Readonly, - issue: Readonly | Readonly - ) { - this._options = options; - this.title = issue.title; - this.number = issue.number; - this.created_at = issue.created_at; - this.updated_at = issue.updated_at; - this.draft = Boolean(issue.draft); - this.labels = mapLabels(issue.labels); - this.pull_request = issue.pull_request; - this.state = issue.state; - this.locked = issue.locked; - this.milestone = issue.milestone; - this.assignees = issue.assignees || []; - this.isStale = isLabeled(this, this.staleLabel); - this.markedStaleThisRun = false; - } + constructor( + options: Readonly, + issue: Readonly | Readonly + ) { + this._options = options; + this.title = issue.title; + this.number = issue.number; + this.created_at = issue.created_at; + this.updated_at = issue.updated_at; + this.draft = Boolean(issue.draft); + this.labels = mapLabels(issue.labels); + this.pull_request = issue.pull_request; + this.state = issue.state; + this.locked = issue.locked; + 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 { - return isPullRequest(this); - } + get isPullRequest(): boolean { + return isPullRequest(this); + } - get staleLabel(): string { - return this._getStaleLabel(); - } + get staleLabel(): string { + return this._getStaleLabel(); + } + get rottenLabel(): string { + return this._getRottenLabel(); + } - get hasAssignees(): boolean { - return this.assignees.length > 0; - } + get hasAssignees(): boolean { + return this.assignees.length > 0; + } - private _getStaleLabel(): string { - return this.isPullRequest - ? this._options.stalePrLabel - : this._options.staleIssueLabel; - } + private _getStaleLabel(): string { + return this.isPullRequest + ? 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[] { - return labels.map(label => { - if (typeof label == 'string') { - return { - name: label - }; - } - return label; - }); + return labels.map(label => { + if (typeof label == 'string') { + return { + name: label + }; + } + return label; + }); } diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 486c6a78a..b77b2b9a7 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -1,1289 +1,1746 @@ import * as core from '@actions/core'; -import {context, getOctokit} from '@actions/github'; -import {GitHub} from '@actions/github/lib/utils'; -import {Option} from '../enums/option'; -import {getHumanizedDate} from '../functions/dates/get-humanized-date'; -import {isDateMoreRecentThan} from '../functions/dates/is-date-more-recent-than'; -import {isValidDate} from '../functions/dates/is-valid-date'; -import {isBoolean} from '../functions/is-boolean'; -import {isLabeled} from '../functions/is-labeled'; -import {cleanLabel} from '../functions/clean-label'; -import {shouldMarkWhenStale} from '../functions/should-mark-when-stale'; -import {wordsToList} from '../functions/words-to-list'; -import {IComment} from '../interfaces/comment'; -import {IIssueEvent} from '../interfaces/issue-event'; -import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; -import {IPullRequest} from '../interfaces/pull-request'; -import {Assignees} from './assignees'; -import {IgnoreUpdates} from './ignore-updates'; -import {ExemptDraftPullRequest} from './exempt-draft-pull-request'; -import {Issue} from './issue'; -import {IssueLogger} from './loggers/issue-logger'; -import {Logger} from './loggers/logger'; -import {Milestones} from './milestones'; -import {StaleOperations} from './stale-operations'; -import {Statistics} from './statistics'; -import {LoggerService} from '../services/logger.service'; -import {OctokitIssue} from '../interfaces/issue'; -import {retry} from '@octokit/plugin-retry'; -import {IState} from '../interfaces/state/state'; -import {IRateLimit} from '../interfaces/rate-limit'; -import {RateLimit} from './rate-limit'; +import { context, getOctokit } from '@actions/github'; +import { GitHub } from '@actions/github/lib/utils'; +import { Option } from '../enums/option'; +import { getHumanizedDate } from '../functions/dates/get-humanized-date'; +import { isDateMoreRecentThan } from '../functions/dates/is-date-more-recent-than'; +import { isValidDate } from '../functions/dates/is-valid-date'; +import { isBoolean } from '../functions/is-boolean'; +import { isLabeled } from '../functions/is-labeled'; +import { cleanLabel } from '../functions/clean-label'; +import { shouldMarkWhenStale } from '../functions/should-mark-when-stale'; +import { wordsToList } from '../functions/words-to-list'; +import { IComment } from '../interfaces/comment'; +import { IIssueEvent } from '../interfaces/issue-event'; +import { IIssuesProcessorOptions } from '../interfaces/issues-processor-options'; +import { IPullRequest } from '../interfaces/pull-request'; +import { Assignees } from './assignees'; +import { IgnoreUpdates } from './ignore-updates'; +import { ExemptDraftPullRequest } from './exempt-draft-pull-request'; +import { Issue } from './issue'; +import { IssueLogger } from './loggers/issue-logger'; +import { Logger } from './loggers/logger'; +import { Milestones } from './milestones'; +import { StaleOperations } from './stale-operations'; +import { Statistics } from './statistics'; +import { LoggerService } from '../services/logger.service'; +import { OctokitIssue } from '../interfaces/issue'; +import { retry } from '@octokit/plugin-retry'; +import { IState } from '../interfaces/state/state'; +import { IRateLimit } from '../interfaces/rate-limit'; +import { RateLimit } from './rate-limit'; /*** * Handle processing of issues for staleness/closure. */ export class IssuesProcessor { - private static _updatedSince(timestamp: string, num_days: number): boolean { - const daysInMillis = 1000 * 60 * 60 * 24 * num_days; - const millisSinceLastUpdated = - new Date().getTime() - new Date(timestamp).getTime(); - - return millisSinceLastUpdated <= daysInMillis; - } - - private static _endIssueProcessing(issue: Issue): void { - const consumedOperationsCount: number = - issue.operations.getConsumedOperationsCount(); - - if (consumedOperationsCount > 0) { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - LoggerService.cyan(consumedOperationsCount), - `operation${ - consumedOperationsCount > 1 ? 's' : '' - } consumed for this $$type` - ); + private static _updatedSince(timestamp: string, num_days: number): boolean { + const daysInMillis = 1000 * 60 * 60 * 24 * num_days; + const millisSinceLastUpdated = + new Date().getTime() - new Date(timestamp).getTime(); + + return millisSinceLastUpdated <= daysInMillis; } - } - - private static _getCloseLabelUsedOptionName( - issue: Readonly - ): Option.ClosePrLabel | Option.CloseIssueLabel { - return issue.isPullRequest ? Option.ClosePrLabel : Option.CloseIssueLabel; - } - - readonly operations: StaleOperations; - readonly client: InstanceType; - readonly options: IIssuesProcessorOptions; - readonly staleIssues: Issue[] = []; - readonly closedIssues: Issue[] = []; - readonly deletedBranchIssues: Issue[] = []; - readonly removedLabelIssues: Issue[] = []; - readonly addedLabelIssues: Issue[] = []; - readonly addedCloseCommentIssues: Issue[] = []; - readonly statistics: Statistics | undefined; - private readonly _logger: Logger = new Logger(); - private readonly state: IState; - - constructor(options: IIssuesProcessorOptions, state: IState) { - this.options = options; - this.state = state; - this.client = getOctokit(this.options.repoToken, undefined, retry); - this.operations = new StaleOperations(this.options); - - this._logger.info( - LoggerService.yellow(`Starting the stale action process...`) - ); - - if (this.options.debugOnly) { - this._logger.warning( - LoggerService.yellowBright(`Executing in debug mode!`) - ); - this._logger.warning( - LoggerService.yellowBright( - `The debug output will be written but no issues/PRs will be processed.` - ) - ); + + private static _endIssueProcessing(issue: Issue): void { + const consumedOperationsCount: number = + issue.operations.getConsumedOperationsCount(); + + if (consumedOperationsCount > 0) { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + LoggerService.cyan(consumedOperationsCount), + `operation${consumedOperationsCount > 1 ? 's' : '' + } consumed for this $$type` + ); + } } - if (this.options.enableStatistics) { - this.statistics = new Statistics(); + private static _getCloseLabelUsedOptionName( + issue: Readonly + ): Option.ClosePrLabel | Option.CloseIssueLabel { + return issue.isPullRequest ? Option.ClosePrLabel : Option.CloseIssueLabel; } - } - - async processIssues(page: Readonly = 1): Promise { - // get the next batch of issues - const issues: Issue[] = await this.getIssues(page); - - if (issues.length <= 0) { - this._logger.info( - LoggerService.green(`No more issues found to process. Exiting...`) - ); - this.statistics - ?.setOperationsCount(this.operations.getConsumedOperationsCount()) - .logStats(); - - this.state.reset(); - - return this.operations.getRemainingOperationsCount(); - } else { - this._logger.info( - `${LoggerService.yellow( - 'Processing the batch of issues ' - )} ${LoggerService.cyan(`#${page}`)} ${LoggerService.yellow( - ' containing ' - )} ${LoggerService.cyan(issues.length)} ${LoggerService.yellow( - ` issue${issues.length > 1 ? 's' : ''}...` - )}` - ); + + readonly operations: StaleOperations; + readonly client: InstanceType; + readonly options: IIssuesProcessorOptions; + readonly staleIssues: Issue[] = []; + readonly rottenIssues: Issue[] = []; + readonly closedIssues: Issue[] = []; + readonly deletedBranchIssues: Issue[] = []; + readonly removedLabelIssues: Issue[] = []; + readonly addedLabelIssues: Issue[] = []; + readonly addedCloseCommentIssues: Issue[] = []; + readonly statistics: Statistics | undefined; + private readonly _logger: Logger = new Logger(); + private readonly state: IState; + + constructor(options: IIssuesProcessorOptions, state: IState) { + this.options = options; + this.state = state; + this.client = getOctokit(this.options.repoToken, undefined, retry); + this.operations = new StaleOperations(this.options); + + this._logger.info( + LoggerService.yellow(`Starting the stale action process...`) + ); + + if (this.options.debugOnly) { + this._logger.warning( + LoggerService.yellowBright(`Executing in debug mode!`) + ); + this._logger.warning( + LoggerService.yellowBright( + `The debug output will be written but no issues/PRs will be processed.` + ) + ); + } + + if (this.options.enableStatistics) { + this.statistics = new Statistics(); + } } - const labelsToRemoveWhenStale: string[] = wordsToList( - this.options.labelsToRemoveWhenStale - ); - - 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()) { - break; - } - - const issueLogger: IssueLogger = new IssueLogger(issue); - if (this.state.isIssueProcessed(issue)) { - issueLogger.info( - ' $$type skipped due being processed during the previous run' + async processIssues(page: Readonly = 1): Promise { + // get the next batch of issues + const issues: Issue[] = await this.getIssues(page); + + if (issues.length <= 0) { + this._logger.info( + LoggerService.green(`No more issues found to process. Exiting...`) + ); + this.statistics + ?.setOperationsCount(this.operations.getConsumedOperationsCount()) + .logStats(); + + this.state.reset(); + + return this.operations.getRemainingOperationsCount(); + } else { + this._logger.info( + `${LoggerService.yellow( + 'Processing the batch of issues ' + )} ${LoggerService.cyan(`#${page}`)} ${LoggerService.yellow( + ' containing ' + )} ${LoggerService.cyan(issues.length)} ${LoggerService.yellow( + ` issue${issues.length > 1 ? 's' : ''}...` + )}` + ); + } + + const labelsToRemoveWhenStale: string[] = wordsToList( + this.options.labelsToRemoveWhenStale ); - continue; - } - await issueLogger.grouping(`$$type #${issue.number}`, async () => { - await this.processIssue( - issue, - labelsToAddWhenUnstale, - labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale + + const labelsToAddWhenUnstale: string[] = wordsToList( + this.options.labelsToAddWhenUnstale + ); + const labelsToRemoveWhenUnstale: string[] = wordsToList( + this.options.labelsToRemoveWhenUnstale + ); + const labelsToRemoveWhenRotten: string[] = wordsToList( + this.options.labelsToRemoveWhenRotten ); - }); - this.state.addIssueToProcessed(issue); - } - if (!this.operations.hasRemainingOperations()) { - this._logger.warning( - LoggerService.yellowBright(`No more operations left! Exiting...`) - ); - this._logger.warning( - `${LoggerService.yellowBright( - 'If you think that not enough issues were processed you could try to increase the quantity related to the ' - )} ${this._logger.createOptionLink( - Option.OperationsPerRun - )} ${LoggerService.yellowBright( - ' option which is currently set to ' - )} ${LoggerService.cyan(this.options.operationsPerRun)}` - ); - this.statistics - ?.setOperationsCount(this.operations.getConsumedOperationsCount()) - .logStats(); - - return 0; - } + const labelsToAddWhenUnrotten: string[] = wordsToList( + this.options.labelsToAddWhenUnrotten + ); + const labelsToRemoveWhenUnrotten: string[] = wordsToList( + this.options.labelsToRemoveWhenUnrotten + ); - this._logger.info( - `${LoggerService.green('Batch ')} ${LoggerService.cyan( - `#${page}` - )} ${LoggerService.green(' processed.')}` - ); - - // Do the next batch - return this.processIssues(page + 1); - } - - async processIssue( - issue: Issue, - labelsToAddWhenUnstale: Readonly[], - labelsToRemoveWhenUnstale: Readonly[], - labelsToRemoveWhenStale: Readonly[] - ): Promise { - this.statistics?.incrementProcessedItemsCount(issue); - - const issueLogger: IssueLogger = new IssueLogger(issue); - issueLogger.info( - `Found this $$type last updated at: ${LoggerService.cyan( - issue.updated_at - )}` - ); - - // calculate string based messages for this issue - const staleMessage: string = issue.isPullRequest - ? this.options.stalePrMessage - : this.options.staleIssueMessage; - const closeMessage: string = issue.isPullRequest - ? this.options.closePrMessage - : this.options.closeIssueMessage; - const staleLabel: string = issue.isPullRequest - ? this.options.stalePrLabel - : this.options.staleIssueLabel; - const closeLabel: string = issue.isPullRequest - ? this.options.closePrLabel - : this.options.closeIssueLabel; - const skipMessage = issue.isPullRequest - ? this.options.stalePrMessage.length === 0 - : this.options.staleIssueMessage.length === 0; - const daysBeforeStale: number = issue.isPullRequest - ? this._getDaysBeforePrStale() - : this._getDaysBeforeIssueStale(); - - if (issue.state === 'closed') { - issueLogger.info(`Skipping this $$type because it is closed`); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process closed issues - } + for (const issue of issues.values()) { + // Stop the processing if no more operations remains + if (!this.operations.hasRemainingOperations()) { + break; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); + if (this.state.isIssueProcessed(issue)) { + issueLogger.info( + ' $$type skipped due being processed during the previous run' + ); + continue; + } + await issueLogger.grouping(`$$type #${issue.number}`, async () => { + await this.processIssue( + issue, + labelsToAddWhenUnstale, + labelsToRemoveWhenUnstale, + labelsToRemoveWhenStale, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten + ); + }); + this.state.addIssueToProcessed(issue); + } - if (issue.locked) { - issueLogger.info(`Skipping this $$type because it is locked`); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process locked issues - } + if (!this.operations.hasRemainingOperations()) { + this._logger.warning( + LoggerService.yellowBright(`No more operations left! Exiting...`) + ); + this._logger.warning( + `${LoggerService.yellowBright( + 'If you think that not enough issues were processed you could try to increase the quantity related to the ' + )} ${this._logger.createOptionLink( + Option.OperationsPerRun + )} ${LoggerService.yellowBright( + ' option which is currently set to ' + )} ${LoggerService.cyan(this.options.operationsPerRun)}` + ); + this.statistics + ?.setOperationsCount(this.operations.getConsumedOperationsCount()) + .logStats(); + + return 0; + } + + this._logger.info( + `${LoggerService.green('Batch ')} ${LoggerService.cyan( + `#${page}` + )} ${LoggerService.green(' processed.')}` + ); - if (this._isIncludeOnlyAssigned(issue)) { - issueLogger.info( - `Skipping this $$type because its assignees list is empty` - ); - IssuesProcessor._endIssueProcessing(issue); - return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list + // Do the next batch + return this.processIssues(page + 1); } - const onlyLabels: string[] = wordsToList(this._getOnlyLabels(issue)); + async processIssue( + issue: Issue, + labelsToAddWhenUnstale: Readonly[], + labelsToRemoveWhenUnstale: Readonly[], + labelsToRemoveWhenStale: Readonly[], + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[] + ): Promise { + this.statistics?.incrementProcessedItemsCount(issue); + + const issueLogger: IssueLogger = new IssueLogger(issue); + issueLogger.info( + `Found this $$type last updated at: ${LoggerService.cyan( + issue.updated_at + )}` + ); + + // calculate string based messages for this issue + 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; + const skipMessage = issue.isPullRequest + ? this.options.stalePrMessage.length === 0 + : this.options.staleIssueMessage.length === 0; + const daysBeforeStale: number = issue.isPullRequest + ? this._getDaysBeforePrStale() + : this._getDaysBeforeIssueStale(); + + + if (issue.state === 'closed') { + issueLogger.info(`Skipping this $$type because it is closed`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process closed issues + } + + if (issue.locked) { + issueLogger.info(`Skipping this $$type because it is locked`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process locked issues + } - if (onlyLabels.length > 0) { - issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.OnlyLabels - )} was specified to only process issues and pull requests with all those labels (${LoggerService.cyan( - onlyLabels.length - )})` - ); + if (this._isIncludeOnlyAssigned(issue)) { + issueLogger.info( + `Skipping this $$type because its assignees list is empty` + ); + IssuesProcessor._endIssueProcessing(issue); + return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list + } - const hasAllWhitelistedLabels: boolean = onlyLabels.every( - (label: Readonly): boolean => { - return isLabeled(issue, label); + const onlyLabels: string[] = wordsToList(this._getOnlyLabels(issue)); + + if (onlyLabels.length > 0) { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.OnlyLabels + )} was specified to only process issues and pull requests with all those labels (${LoggerService.cyan( + onlyLabels.length + )})` + ); + + const hasAllWhitelistedLabels: boolean = onlyLabels.every( + (label: Readonly): boolean => { + return isLabeled(issue, label); + } + ); + + if (!hasAllWhitelistedLabels) { + issueLogger.info( + LoggerService.white('└──'), + `Skipping this $$type because it doesn't have all the required labels` + ); + + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process issues without all of the required labels + } else { + issueLogger.info( + LoggerService.white('├──'), + `All the required labels are present on this $$type` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); + } + } else { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.OnlyLabels + )} was not specified` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); } - ); - if (!hasAllWhitelistedLabels) { issueLogger.info( - LoggerService.white('└──'), - `Skipping this $$type because it doesn't have all the required labels` + `Days before $$type stale: ${LoggerService.cyan(daysBeforeStale)}` ); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process issues without all of the required labels - } else { - issueLogger.info( - LoggerService.white('├──'), - `All the required labels are present on this $$type` + const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale); + + // Try to remove the close label when not close/locked issue or PR + await this._removeCloseLabel(issue, closeLabel); + + if (this.options.startDate) { + const startDate: Date = new Date(this.options.startDate); + const createdAt: Date = new Date(issue.created_at); + + issueLogger.info( + `A start date was specified for the ${getHumanizedDate( + startDate + )} (${LoggerService.cyan(this.options.startDate)})` + ); + + // Expecting that GitHub will always set a creation date on the issues and PRs + // But you never know! + if (!isValidDate(createdAt)) { + IssuesProcessor._endIssueProcessing(issue); + core.setFailed( + new Error(`Invalid issue field: "created_at". Expected a valid date`) + ); + } + + issueLogger.info( + `$$type created the ${getHumanizedDate( + createdAt + )} (${LoggerService.cyan(issue.created_at)})` + ); + + if (!isDateMoreRecentThan(createdAt, startDate)) { + issueLogger.info( + `Skipping this $$type because it was created before the specified start date` + ); + + IssuesProcessor._endIssueProcessing(issue); + 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: string[] = wordsToList( + issue.isPullRequest + ? this.options.exemptPrLabels + : this.options.exemptIssueLabels ); - issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` + + const hasExemptLabel = exemptLabels.some((exemptLabel: Readonly) => + isLabeled(issue, exemptLabel) ); - } - } else { - issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.OnlyLabels - )} was not specified` - ); - issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` - ); - } - issueLogger.info( - `Days before $$type stale: ${LoggerService.cyan(daysBeforeStale)}` - ); + if (hasExemptLabel) { + issueLogger.info( + `Skipping this $$type because it contains an exempt label, see ${issueLogger.createOptionLink( + issue.isPullRequest ? Option.ExemptPrLabels : Option.ExemptIssueLabels + )} for more details` + ); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt issues + } - const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale); + const anyOfLabels: string[] = wordsToList(this._getAnyOfLabels(issue)); + + if (anyOfLabels.length > 0) { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.AnyOfLabels + )} was specified to only process the issues and pull requests with one of those labels (${LoggerService.cyan( + anyOfLabels.length + )})` + ); + + const hasOneOfWhitelistedLabels: boolean = anyOfLabels.some( + (label: Readonly): boolean => { + return isLabeled(issue, label); + } + ); + + if (!hasOneOfWhitelistedLabels) { + issueLogger.info( + LoggerService.white('└──'), + `Skipping this $$type because it doesn't have one of the required labels` + ); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process issues without any of the required labels + } else { + issueLogger.info( + LoggerService.white('├──'), + `One of the required labels is present on this $$type` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); + } + } else { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.AnyOfLabels + )} was not specified` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); + } - // Try to remove the close label when not close/locked issue or PR - await this._removeCloseLabel(issue, closeLabel); + const milestones: Milestones = new Milestones(this.options, issue); - if (this.options.startDate) { - const startDate: Date = new Date(this.options.startDate); - const createdAt: Date = new Date(issue.created_at); + if (milestones.shouldExemptMilestones()) { + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt milestones + } - issueLogger.info( - `A start date was specified for the ${getHumanizedDate( - startDate - )} (${LoggerService.cyan(this.options.startDate)})` - ); + const assignees: Assignees = new Assignees(this.options, issue); - // Expecting that GitHub will always set a creation date on the issues and PRs - // But you never know! - if (!isValidDate(createdAt)) { - IssuesProcessor._endIssueProcessing(issue); - core.setFailed( - new Error(`Invalid issue field: "created_at". Expected a valid date`) - ); - } + if (assignees.shouldExemptAssignees()) { + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt assignees + } - issueLogger.info( - `$$type created the ${getHumanizedDate( - createdAt - )} (${LoggerService.cyan(issue.created_at)})` - ); + // Ignore draft PR + // Note that this check is so far below because it cost one read operation + // So it's simply better to do all the stale checks which don't cost more operation before this one + const exemptDraftPullRequest: ExemptDraftPullRequest = + new ExemptDraftPullRequest(this.options, issue); + + if ( + await exemptDraftPullRequest.shouldExemptDraftPullRequest( + async (): Promise => { + return this.getPullRequest(issue); + } + ) + ) { + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process draft PR + } - if (!isDateMoreRecentThan(createdAt, startDate)) { - issueLogger.info( - `Skipping this $$type because it was created before the specified start date` - ); + // 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`); + + if (issue.isRotten) { + await this._processRottenIssue( + issue, + rottenLabel, + rottenMessage, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + closeMessage, + closeLabel + ); + } + else { + 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 (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 { + 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)})` + ); + } + } + } + } + + // Process the issue if it was marked stale + if (issue.isStale) { + issueLogger.info(`This $$type is already stale`); + await this._processStaleIssue( + issue, + staleLabel, + staleMessage, + rottenLabel, + rottenMessage, + closeLabel, + closeMessage, + labelsToAddWhenUnstale, + labelsToRemoveWhenUnstale, + labelsToRemoveWhenStale, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + skipRottenMessage, + ); + } IssuesProcessor._endIssueProcessing(issue); - return; // Don't process issues which were created before the start date - } } - if (issue.isStale) { - issueLogger.info(`This $$type includes a stale label`); - } else { - issueLogger.info(`This $$type does not include a stale label`); + // Grab comments for an issue since a given date + async listIssueComments( + issue: Readonly, + sinceDate: Readonly + ): Promise { + // Find any comments since date on the given issue + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedItemsCommentsCount(); + const comments = await this.client.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + since: sinceDate + }); + return comments.data; + } catch (error) { + this._logger.error(`List issue comments error: ${error.message}`); + return Promise.resolve([]); + } + } + + // grab issues from github in batches of 100 + async getIssues(page: number): Promise { + try { + this.operations.consumeOperation(); + const issueResult = await this.client.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + direction: this.options.ascending ? 'asc' : 'desc', + page + }); + this.statistics?.incrementFetchedItemsCount(issueResult.data.length); + + return issueResult.data.map( + (issue): Issue => + new Issue(this.options, issue as Readonly) + ); + } catch (error) { + throw Error(`Getting issues was blocked by the error: ${error.message}`); + } } - const exemptLabels: string[] = wordsToList( - issue.isPullRequest - ? this.options.exemptPrLabels - : this.options.exemptIssueLabels - ); - - const hasExemptLabel = exemptLabels.some((exemptLabel: Readonly) => - isLabeled(issue, exemptLabel) - ); - - if (hasExemptLabel) { - issueLogger.info( - `Skipping this $$type because it contains an exempt label, see ${issueLogger.createOptionLink( - issue.isPullRequest ? Option.ExemptPrLabels : Option.ExemptIssueLabels - )} for more details` - ); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process exempt issues + // returns the creation date of a given label on an issue (or nothing if no label existed) + ///see https://developer.github.com/v3/activity/events/ + async getLabelCreationDate( + issue: Issue, + label: string + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Checking for label on this $$type`); + + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedItemsEventsCount(); + const options = this.client.rest.issues.listEvents.endpoint.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + issue_number: issue.number + }); + + const events: IIssueEvent[] = await this.client.paginate(options); + const reversedEvents = events.reverse(); + + const staleLabeledEvent = reversedEvents.find( + event => + event.event === 'labeled' && + cleanLabel(event.label.name) === cleanLabel(label) + ); + + if (!staleLabeledEvent) { + // Must be old rather than labeled + return undefined; + } + + return staleLabeledEvent.created_at; } - const anyOfLabels: string[] = wordsToList(this._getAnyOfLabels(issue)); + async getPullRequest(issue: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedPullRequestsCount(); - if (anyOfLabels.length > 0) { - issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.AnyOfLabels - )} was specified to only process the issues and pull requests with one of those labels (${LoggerService.cyan( - anyOfLabels.length - )})` - ); + const pullRequest = await this.client.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number + }); - const hasOneOfWhitelistedLabels: boolean = anyOfLabels.some( - (label: Readonly): boolean => { - return isLabeled(issue, label); + return pullRequest.data; + } catch (error) { + issueLogger.error(`Error when getting this $$type: ${error.message}`); } - ); + } + + async getRateLimit(): Promise { + const logger: Logger = new Logger(); + try { + const rateLimitResult = await this.client.rest.rateLimit.get(); + return new RateLimit(rateLimitResult.data.rate); + } catch (error) { + logger.error(`Error when getting rateLimit: ${error.message}`); + } + } - if (!hasOneOfWhitelistedLabels) { + // 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[], + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[], + skipMessage: boolean + ) { + const issueLogger: IssueLogger = new IssueLogger(issue); + + // We can get the label creation date from the getLableCreationDate function + const markedStaleOn: string = + (await this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; issueLogger.info( - LoggerService.white('└──'), - `Skipping this $$type because it doesn't have one of the required labels` + `$$type marked stale on: ${LoggerService.cyan(markedStaleOn)}` + ); + + const issueHasCommentsSinceStale: boolean = await this._hasCommentsSince( + issue, + markedStaleOn, + staleMessage ); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process issues without any of the required labels - } else { issueLogger.info( - LoggerService.white('├──'), - `One of the required labels is present on this $$type` + `$$type has been commented on: ${LoggerService.cyan( + issueHasCommentsSinceStale + )}` ); + + const daysBeforeRotten: number = issue.isPullRequest + ? this._getDaysBeforePrRotten() + : this._getDaysBeforeIssueRotten(); + + const daysBeforeClose: number = issue.isPullRequest + ? this._getDaysBeforePrClose() + : this._getDaysBeforeIssueClose(); issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` + `Days before $$type rotten: ${LoggerService.cyan(daysBeforeRotten)}` ); - } - } else { - issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.AnyOfLabels - )} was not specified` - ); - issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` - ); - } - const milestones: Milestones = new Milestones(this.options, issue); + const shouldRemoveStaleWhenUpdated: boolean = + this._shouldRemoveStaleWhenUpdated(issue); - if (milestones.shouldExemptMilestones()) { - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process exempt milestones - } + issueLogger.info( + `The option ${issueLogger.createOptionLink( + this._getRemoveStaleWhenUpdatedUsedOptionName(issue) + )} is: ${LoggerService.cyan(shouldRemoveStaleWhenUpdated)}` + ); + + if (shouldRemoveStaleWhenUpdated) { + issueLogger.info(`The stale label should not be removed`); + } else { + issueLogger.info( + `The stale label should be removed if all conditions met` + ); + } - const assignees: Assignees = new Assignees(this.options, issue); + // 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( + issue, + labelsToRemoveWhenStale, + Option.LabelsToRemoveWhenStale + ); + } - if (assignees.shouldExemptAssignees()) { - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process exempt assignees - } + // The issue.updated_at and markedStaleOn 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 issueHasUpdateSinceStale = isDateMoreRecentThan( + new Date(issue.updated_at), + new Date(markedStaleOn), + 15 + ); - // Ignore draft PR - // Note that this check is so far below because it cost one read operation - // So it's simply better to do all the stale checks which don't cost more operation before this one - const exemptDraftPullRequest: ExemptDraftPullRequest = - new ExemptDraftPullRequest(this.options, issue); + issueLogger.info( + `$$type has been updated since it was marked stale: ${LoggerService.cyan( + issueHasUpdateSinceStale + )}` + ); - if ( - await exemptDraftPullRequest.shouldExemptDraftPullRequest( - async (): Promise => { - return this.getPullRequest(issue); + // Should we un-stale this issue? + if ( + shouldRemoveStaleWhenUpdated && + (issueHasUpdateSinceStale || issueHasCommentsSinceStale) && + !issue.markedStaleThisRun + ) { + issueLogger.info( + `Remove the stale label since the $$type has been updated and the workflow should remove the stale label when updated` + ); + await this._removeStaleLabel(issue, staleLabel); + + // Are there labels to remove or add when an issue is no longer stale? + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenUnstale, + Option.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 + } + + if (daysBeforeRotten < 0) { + if (daysBeforeClose < 0) { + return; + } + else { + let issueHasUpdateInCloseWindow: boolean + issueHasUpdateInCloseWindow = !IssuesProcessor._updatedSince( + issue.updated_at, + daysBeforeClose + ); + if (!issueHasUpdateInCloseWindow && !issueHasCommentsSinceStale) { + issueLogger.info( + `Closing $$type because it was last updated on: ${LoggerService.cyan( + issue.updated_at + )}` + ); + await this._closeIssue(issue, closeMessage, closeLabel); + + 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 (!issue.isRotten) { + issueLogger.info(`This $$type is not rotten`); + + const shouldIgnoreUpdates: boolean = new IgnoreUpdates( + this.options, + issue + ).shouldIgnoreUpdates(); + + let shouldBeRotten: boolean; + shouldBeRotten = !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)})` + ); + 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, + ) } - ) - ) { - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process draft PR - } - // Determine if this issue needs to be marked stale first - if (!issue.isStale) { - issueLogger.info(`This $$type is not stale`); + } + 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 shouldIgnoreUpdates: boolean = new IgnoreUpdates( - this.options, - issue - ).shouldIgnoreUpdates(); + const issueHasCommentsSinceRotten: boolean = await this._hasCommentsSince( + issue, + markedRottenOn, + rottenMessage + ); + issueLogger.info( + `$$type has been commented on: ${LoggerService.cyan( + issueHasCommentsSinceRotten + )}` + ); - // Should this issue be marked as stale? - let shouldBeStale: boolean; + const daysBeforeClose: number = issue.isPullRequest + ? this._getDaysBeforePrClose() + : this._getDaysBeforeIssueClose(); - // Ignore the last update and only use the creation date - if (shouldIgnoreUpdates) { - shouldBeStale = !IssuesProcessor._updatedSince( - issue.created_at, - daysBeforeStale + issueLogger.info( + `Days before $$type close: ${LoggerService.cyan(daysBeforeClose)}` ); - } - // Use the last update to check if we need to stale - else { - shouldBeStale = !IssuesProcessor._updatedSince( - issue.updated_at, - daysBeforeStale + + const shouldRemoveRottenWhenUpdated: boolean = + this._shouldRemoveRottenWhenUpdated(issue); + + issueLogger.info( + `The option ${issueLogger.createOptionLink( + this._getRemoveRottenWhenUpdatedUsedOptionName(issue) + )} is: ${LoggerService.cyan(shouldRemoveRottenWhenUpdated)}` ); - } - - 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`); + + if (shouldRemoveRottenWhenUpdated) { + issueLogger.info(`The rotten label should not be removed`); } else { - issueLogger.info( - `This $$type should not be marked as stale based on the option ${issueLogger.createOptionLink( - this._getDaysBeforeStaleUsedOptionName(issue) - )} (${LoggerService.cyan(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)})` - ); + 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 rotten issues + } + + const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince( + issue.updated_at, + daysBeforeClose + ); + issueLogger.info( + `$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan( + issueHasUpdateInCloseWindow + )}` + ); + + if (!issueHasCommentsSinceRotten && !issueHasUpdateInCloseWindow) { + issueLogger.info( + `Closing $$type because it was last updated on: ${LoggerService.cyan( + issue.updated_at + )}` + ); + await this._closeIssue(issue, closeMessage, closeLabel); + + 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( - `This $$type should not be stale based on the last update date the ${getHumanizedDate( - new Date(issue.updated_at) - )} (${LoggerService.cyan(issue.updated_at)})` - ); + issueLogger.info( + `Rotten $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceRotten}, hasUpdate? ${issueHasUpdateInCloseWindow})` + ); } - } } - // Process the issue if it was marked stale - if (issue.isStale) { - issueLogger.info(`This $$type is already stale`); - await this._processStaleIssue( - issue, - staleLabel, - staleMessage, - labelsToAddWhenUnstale, - labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale, - closeMessage, - closeLabel - ); + // checks to see if a given issue is still stale (has had activity on it) + private async _hasCommentsSince( + issue: Issue, + sinceDate: string, + staleMessage: string + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `Checking for comments on $$type since: ${LoggerService.cyan(sinceDate)}` + ); + + if (!sinceDate) { + return true; + } + + // find any comments since the date + const comments = await this.listIssueComments(issue, sinceDate); + + const filteredComments = comments.filter( + comment => + comment.user?.type === 'User' && + comment.body?.toLowerCase() !== staleMessage.toLowerCase() + ); + + issueLogger.info( + `Comments that are not the stale comment or another bot: ${LoggerService.cyan( + filteredComments.length + )}` + ); + + // if there are any user comments returned + return filteredComments.length > 0; } - IssuesProcessor._endIssueProcessing(issue); - } - - // Grab comments for an issue since a given date - async listIssueComments( - issue: Readonly, - sinceDate: Readonly - ): Promise { - // Find any comments since date on the given issue - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementFetchedItemsCommentsCount(); - const comments = await this.client.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - since: sinceDate - }); - return comments.data; - } catch (error) { - this._logger.error(`List issue comments error: ${error.message}`); - return Promise.resolve([]); + // Mark an issue as stale with a comment and a label + private async _markStale( + issue: Issue, + staleMessage: string, + staleLabel: string, + skipMessage: boolean + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Marking this $$type as stale`); + this.staleIssues.push(issue); + + // if the issue is being marked stale, 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: staleMessage + }); + } + } 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: [staleLabel] + }); + } + } catch (error) { + issueLogger.error(`Error when adding a label: ${error.message}`); + } } - } - - // grab issues from github in batches of 100 - async getIssues(page: number): Promise { - try { - this.operations.consumeOperation(); - const issueResult = await this.client.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100, - direction: this.options.ascending ? 'asc' : 'desc', - page - }); - this.statistics?.incrementFetchedItemsCount(issueResult.data.length); - - return issueResult.data.map( - (issue): Issue => - new Issue(this.options, issue as Readonly) - ); - } catch (error) { - throw Error(`Getting issues was blocked by the error: ${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}`); + } } - } - - // returns the creation date of a given label on an issue (or nothing if no label existed) - ///see https://developer.github.com/v3/activity/events/ - async getLabelCreationDate( - issue: Issue, - label: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info(`Checking for label on this $$type`); - - this._consumeIssueOperation(issue); - this.statistics?.incrementFetchedItemsEventsCount(); - const options = this.client.rest.issues.listEvents.endpoint.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - issue_number: issue.number - }); - - const events: IIssueEvent[] = await this.client.paginate(options); - const reversedEvents = events.reverse(); - - const staleLabeledEvent = reversedEvents.find( - event => - event.event === 'labeled' && - cleanLabel(event.label.name) === cleanLabel(label) - ); - - if (!staleLabeledEvent) { - // Must be old rather than labeled - return undefined; + + + // Close an issue based on staleness + private async _closeIssue( + issue: Issue, + closeMessage?: string, + closeLabel?: string + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Closing $$type for being stale`); + this.closedIssues.push(issue); + + if (closeMessage) { + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsComment(issue); + this.addedCloseCommentIssues.push(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: closeMessage + }); + } + } catch (error) { + issueLogger.error(`Error when creating a comment: ${error.message}`); + } + } + + if (closeLabel) { + 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: [closeLabel] + }); + } + } catch (error) { + issueLogger.error(`Error when adding a label: ${error.message}`); + } + } + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementClosedItemsCount(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: this.options.closeIssueReason || undefined + }); + } + } catch (error) { + issueLogger.error(`Error when updating this $$type: ${error.message}`); + } } - return staleLabeledEvent.created_at; - } + // Delete the branch on closed pull request + private async _deleteBranch(issue: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); - async getPullRequest(issue: Issue): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + issueLogger.info(`Delete + branch from closed $ + $type + - + ${issue.title}`); - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementFetchedPullRequestsCount(); + const pullRequest: IPullRequest | undefined | void = + await this.getPullRequest(issue); - const pullRequest = await this.client.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: issue.number - }); + if (!pullRequest) { + issueLogger.info( + `Not deleting this branch as no pull request was found for this $$type` + ); + return; + } - return pullRequest.data; - } catch (error) { - issueLogger.error(`Error when getting this $$type: ${error.message}`); - } - } - - async getRateLimit(): Promise { - const logger: Logger = new Logger(); - try { - const rateLimitResult = await this.client.rest.rateLimit.get(); - return new RateLimit(rateLimitResult.data.rate); - } catch (error) { - logger.error(`Error when getting rateLimit: ${error.message}`); + const branch = pullRequest.head.ref; + + if ( + pullRequest.head.repo === null || + pullRequest.head.repo.full_name === + `${context.repo.owner}/${context.repo.repo}` + ) { + issueLogger.info( + `Deleting the branch "${LoggerService.cyan(branch)}" from closed $$type` + ); + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementDeletedBranchesCount(); + + if (!this.options.debugOnly) { + await this.client.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}` + }); + } + } catch (error) { + issueLogger.error( + `Error when deleting the branch "${LoggerService.cyan( + branch + )}" from $$type: ${error.message}` + ); + } + } else { + issueLogger.warning( + `Deleting the branch "${LoggerService.cyan( + branch + )}" has skipped because it belongs to other repo ${pullRequest.head.repo.full_name + }` + ); + } } - } - - // handle all of the stale issue logic when we find a stale issue - private async _processStaleIssue( - issue: Issue, - staleLabel: string, - staleMessage: string, - labelsToAddWhenUnstale: Readonly[], - labelsToRemoveWhenUnstale: Readonly[], - labelsToRemoveWhenStale: Readonly[], - closeMessage?: string, - closeLabel?: string - ) { - const issueLogger: IssueLogger = new IssueLogger(issue); - const markedStaleOn: string = - (await this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; - issueLogger.info( - `$$type marked stale on: ${LoggerService.cyan(markedStaleOn)}` - ); - - const issueHasCommentsSinceStale: boolean = await this._hasCommentsSince( - issue, - markedStaleOn, - staleMessage - ); - issueLogger.info( - `$$type has been commented on: ${LoggerService.cyan( - issueHasCommentsSinceStale - )}` - ); - - const daysBeforeClose: number = issue.isPullRequest - ? this._getDaysBeforePrClose() - : this._getDaysBeforeIssueClose(); - - issueLogger.info( - `Days before $$type close: ${LoggerService.cyan(daysBeforeClose)}` - ); - - const shouldRemoveStaleWhenUpdated: boolean = - this._shouldRemoveStaleWhenUpdated(issue); - - issueLogger.info( - `The option ${issueLogger.createOptionLink( - this._getRemoveStaleWhenUpdatedUsedOptionName(issue) - )} is: ${LoggerService.cyan(shouldRemoveStaleWhenUpdated)}` - ); - - if (shouldRemoveStaleWhenUpdated) { - issueLogger.info(`The stale label should not be removed`); - } else { - issueLogger.info( - `The stale label should be removed if all conditions met` - ); + + // Remove a label from an issue or a pull request + private async _removeLabel( + issue: Issue, + label: string, + isSubStep: Readonly = false + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `${isSubStep ? LoggerService.white('├── ') : '' + }Removing the label "${LoggerService.cyan(label)}" from this $$type...` + ); + this.removedLabelIssues.push(issue); + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementDeletedItemsLabelsCount(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + } + + issueLogger.info( + `${isSubStep ? LoggerService.white('└── ') : '' + }The label "${LoggerService.cyan(label)}" was removed` + ); + } catch (error) { + issueLogger.error( + `${isSubStep ? LoggerService.white('└── ') : '' + }Error when removing the label: "${LoggerService.cyan(error.message)}"` + ); + } } - if (issue.markedStaleThisRun) { - issueLogger.info(`marked stale this run, so don't check for updates`); - await this._removeLabelsOnStatusTransition( - issue, - labelsToRemoveWhenStale, - Option.LabelsToRemoveWhenStale - ); + private _getDaysBeforeIssueStale(): number { + return isNaN(this.options.daysBeforeIssueStale) + ? this.options.daysBeforeStale + : this.options.daysBeforeIssueStale; } - // The issue.updated_at and markedStaleOn 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 issueHasUpdateSinceStale = isDateMoreRecentThan( - new Date(issue.updated_at), - new Date(markedStaleOn), - 15 - ); - - issueLogger.info( - `$$type has been updated since it was marked stale: ${LoggerService.cyan( - issueHasUpdateSinceStale - )}` - ); - - // Should we un-stale this issue? - if ( - shouldRemoveStaleWhenUpdated && - (issueHasUpdateSinceStale || issueHasCommentsSinceStale) && - !issue.markedStaleThisRun - ) { - issueLogger.info( - `Remove the stale label since the $$type has been updated and the workflow should remove the stale label when updated` - ); - await this._removeStaleLabel(issue, staleLabel); - - // Are there labels to remove or add when an issue is no longer stale? - await this._removeLabelsOnStatusTransition( - issue, - labelsToRemoveWhenUnstale, - Option.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 + private _getDaysBeforePrStale(): number { + return isNaN(this.options.daysBeforePrStale) + ? this.options.daysBeforeStale + : this.options.daysBeforePrStale; + } + private _getDaysBeforeIssueRotten(): number { + return isNaN(this.options.daysBeforeIssueRotten) + ? this.options.daysBeforeRotten + : this.options.daysBeforeIssueRotten; } - // Now start closing logic - if (daysBeforeClose < 0) { - return; // Nothing to do because we aren't closing stale issues + private _getDaysBeforePrRotten(): number { + return isNaN(this.options.daysBeforePrStale) + ? this.options.daysBeforeStale + : this.options.daysBeforePrStale; } - const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince( - issue.updated_at, - daysBeforeClose - ); - issueLogger.info( - `$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan( - issueHasUpdateInCloseWindow - )}` - ); - - if (!issueHasCommentsSinceStale && !issueHasUpdateInCloseWindow) { - issueLogger.info( - `Closing $$type because it was last updated on: ${LoggerService.cyan( - issue.updated_at - )}` - ); - await this._closeIssue(issue, closeMessage, closeLabel); - - 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})` - ); + private _getDaysBeforeIssueClose(): number { + return isNaN(this.options.daysBeforeIssueClose) + ? this.options.daysBeforeClose + : this.options.daysBeforeIssueClose; } - } - - // checks to see if a given issue is still stale (has had activity on it) - private async _hasCommentsSince( - issue: Issue, - sinceDate: string, - staleMessage: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `Checking for comments on $$type since: ${LoggerService.cyan(sinceDate)}` - ); - - if (!sinceDate) { - return true; + + private _getDaysBeforePrClose(): number { + return isNaN(this.options.daysBeforePrClose) + ? this.options.daysBeforeClose + : this.options.daysBeforePrClose; } - // find any comments since the date - const comments = await this.listIssueComments(issue, sinceDate); - - const filteredComments = comments.filter( - comment => - comment.user?.type === 'User' && - comment.body?.toLowerCase() !== staleMessage.toLowerCase() - ); - - issueLogger.info( - `Comments that are not the stale comment or another bot: ${LoggerService.cyan( - filteredComments.length - )}` - ); - - // if there are any user comments returned - return filteredComments.length > 0; - } - - // Mark an issue as stale with a comment and a label - private async _markStale( - issue: Issue, - staleMessage: string, - staleLabel: string, - skipMessage: boolean - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info(`Marking this $$type as stale`); - this.staleIssues.push(issue); - - // if the issue is being marked stale, 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: staleMessage - }); + private _getOnlyLabels(issue: Issue): string { + if (issue.isPullRequest) { + if (this.options.onlyPrLabels !== '') { + return this.options.onlyPrLabels; + } + } else { + if (this.options.onlyIssueLabels !== '') { + return this.options.onlyIssueLabels; + } } - } catch (error) { - issueLogger.error(`Error when creating a comment: ${error.message}`); - } + + return this.options.onlyLabels; } - 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: [staleLabel] - }); - } - } catch (error) { - issueLogger.error(`Error when adding a label: ${error.message}`); + private _isIncludeOnlyAssigned(issue: Issue): boolean { + return this.options.includeOnlyAssigned && !issue.hasAssignees; } - } - // Close an issue based on staleness - private async _closeIssue( - issue: Issue, - closeMessage?: string, - closeLabel?: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + private _getAnyOfLabels(issue: Issue): string { + if (issue.isPullRequest) { + if (this.options.anyOfPrLabels !== '') { + return this.options.anyOfPrLabels; + } + } else { + if (this.options.anyOfIssueLabels !== '') { + return this.options.anyOfIssueLabels; + } + } + + return this.options.anyOfLabels; + } - issueLogger.info(`Closing $$type for being stale`); - this.closedIssues.push(issue); + private _shouldRemoveStaleWhenUpdated(issue: Issue): boolean { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrStaleWhenUpdated)) { + return this.options.removePrStaleWhenUpdated; + } - if (closeMessage) { - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementAddedItemsComment(issue); - this.addedCloseCommentIssues.push(issue); + return this.options.removeStaleWhenUpdated; + } - if (!this.options.debugOnly) { - await this.client.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: closeMessage - }); + if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { + return this.options.removeIssueStaleWhenUpdated; } - } catch (error) { - issueLogger.error(`Error when creating a comment: ${error.message}`); - } + + return this.options.removeStaleWhenUpdated; } + private _shouldRemoveRottenWhenUpdated(issue: Issue): boolean { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrRottenWhenUpdated)) { + return this.options.removePrRottenWhenUpdated; + } - if (closeLabel) { - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementAddedItemsLabel(issue); + return this.options.removeRottenWhenUpdated; + } - if (!this.options.debugOnly) { - await this.client.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - labels: [closeLabel] - }); + if (isBoolean(this.options.removeIssueRottenWhenUpdated)) { + return this.options.removeIssueRottenWhenUpdated; } - } catch (error) { - issueLogger.error(`Error when adding a label: ${error.message}`); - } - } - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementClosedItemsCount(issue); - - if (!this.options.debugOnly) { - await this.client.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: this.options.closeIssueReason || undefined - }); - } - } catch (error) { - issueLogger.error(`Error when updating this $$type: ${error.message}`); + return this.options.removeRottenWhenUpdated; } - } - // Delete the branch on closed pull request - private async _deleteBranch(issue: Issue): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + private async _removeLabelsOnStatusTransition( + issue: Issue, + removeLabels: Readonly[], + staleStatus: Option + ): Promise { + if (!removeLabels.length) { + return; + } - issueLogger.info(`Delete - branch from closed $ - $type - - - ${issue.title}`); + const issueLogger: IssueLogger = new IssueLogger(issue); - const pullRequest: IPullRequest | undefined | void = - await this.getPullRequest(issue); + issueLogger.info( + `Removing all the labels specified via the ${this._logger.createOptionLink( + staleStatus + )} option.` + ); - if (!pullRequest) { - issueLogger.info( - `Not deleting this branch as no pull request was found for this $$type` - ); - return; + for (const label of removeLabels.values()) { + await this._removeLabel(issue, label); + } } - const branch = pullRequest.head.ref; - if ( - pullRequest.head.repo === null || - pullRequest.head.repo.full_name === - `${context.repo.owner}/${context.repo.repo}` - ) { - issueLogger.info( - `Deleting the branch "${LoggerService.cyan(branch)}" from closed $$type` - ); + private async _addLabelsWhenUnstale( + issue: Issue, + labelsToAdd: Readonly[] + ): Promise { + if (!labelsToAdd.length) { + return; + } - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementDeletedBranchesCount(); + const issueLogger: IssueLogger = new IssueLogger(issue); - if (!this.options.debugOnly) { - await this.client.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${branch}` - }); - } - } catch (error) { - issueLogger.error( - `Error when deleting the branch "${LoggerService.cyan( - branch - )}" from $$type: ${error.message}` + issueLogger.info( + `Adding all the labels specified via the ${this._logger.createOptionLink( + Option.LabelsToAddWhenUnstale + )} option.` ); - } - } else { - issueLogger.warning( - `Deleting the branch "${LoggerService.cyan( - branch - )}" has skipped because it belongs to other repo ${ - pullRequest.head.repo.full_name - }` - ); - } - } - - // Remove a label from an issue or a pull request - private async _removeLabel( - issue: Issue, - label: string, - isSubStep: Readonly = false - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `${ - isSubStep ? LoggerService.white('├── ') : '' - }Removing the label "${LoggerService.cyan(label)}" from this $$type...` - ); - this.removedLabelIssues.push(issue); - - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementDeletedItemsLabelsCount(issue); - - if (!this.options.debugOnly) { - await this.client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - } - - issueLogger.info( - `${ - isSubStep ? LoggerService.white('└── ') : '' - }The label "${LoggerService.cyan(label)}" was removed` - ); - } catch (error) { - issueLogger.error( - `${ - isSubStep ? LoggerService.white('└── ') : '' - }Error when removing the label: "${LoggerService.cyan(error.message)}"` - ); - } - } - - private _getDaysBeforeIssueStale(): number { - return isNaN(this.options.daysBeforeIssueStale) - ? this.options.daysBeforeStale - : this.options.daysBeforeIssueStale; - } - - private _getDaysBeforePrStale(): number { - return isNaN(this.options.daysBeforePrStale) - ? this.options.daysBeforeStale - : this.options.daysBeforePrStale; - } - - private _getDaysBeforeIssueClose(): number { - return isNaN(this.options.daysBeforeIssueClose) - ? this.options.daysBeforeClose - : this.options.daysBeforeIssueClose; - } - - private _getDaysBeforePrClose(): number { - return isNaN(this.options.daysBeforePrClose) - ? this.options.daysBeforeClose - : this.options.daysBeforePrClose; - } - - private _getOnlyLabels(issue: Issue): string { - if (issue.isPullRequest) { - if (this.options.onlyPrLabels !== '') { - return this.options.onlyPrLabels; - } - } else { - if (this.options.onlyIssueLabels !== '') { - return this.options.onlyIssueLabels; - } - } - return this.options.onlyLabels; - } - - private _isIncludeOnlyAssigned(issue: Issue): boolean { - return this.options.includeOnlyAssigned && !issue.hasAssignees; - } - - private _getAnyOfLabels(issue: Issue): string { - if (issue.isPullRequest) { - if (this.options.anyOfPrLabels !== '') { - return this.options.anyOfPrLabels; - } - } else { - if (this.options.anyOfIssueLabels !== '') { - return this.options.anyOfIssueLabels; - } + 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 stale: ${error.message}` + ); + } } - return this.options.anyOfLabels; - } + private async _addLabelsWhenUnrotten( + issue: Issue, + labelsToAdd: Readonly[] + ): Promise { + if (!labelsToAdd.length) { + return; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); - private _shouldRemoveStaleWhenUpdated(issue: Issue): boolean { - if (issue.isPullRequest) { - if (isBoolean(this.options.removePrStaleWhenUpdated)) { - return this.options.removePrStaleWhenUpdated; - } + issueLogger.info( + `Adding all the labels specified via the ${this._logger.createOptionLink( + Option.LabelsToAddWhenUnrotten + )} option.` + ); - return this.options.removeStaleWhenUpdated; + // 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 + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); - if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { - return this.options.removeIssueStaleWhenUpdated; + issueLogger.info( + `The $$type is no longer stale. Removing the stale label...` + ); + + await this._removeLabel(issue, staleLabel); + this.statistics?.incrementUndoStaleItemsCount(issue); } + private async _removeRottenLabel( + issue: Issue, + rottenLabel: Readonly + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); - return this.options.removeStaleWhenUpdated; - } + issueLogger.info( + `The $$type is no longer rotten. Removing the rotten label...` + ); - private async _removeLabelsOnStatusTransition( - issue: Issue, - removeLabels: Readonly[], - staleStatus: Option - ): Promise { - if (!removeLabels.length) { - return; + await this._removeLabel(issue, rottenLabel); + this.statistics?.incrementUndoRottenItemsCount(issue); } - const issueLogger: IssueLogger = new IssueLogger(issue); + private async _removeCloseLabel( + issue: Issue, + closeLabel: Readonly + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `The $$type is not closed nor locked. Trying to remove the close label...` + ); + + if (!closeLabel) { + issueLogger.info( + LoggerService.white('├──'), + `The ${issueLogger.createOptionLink( + IssuesProcessor._getCloseLabelUsedOptionName(issue) + )} option was not set` + ); + issueLogger.info( + LoggerService.white('└──'), + `Skipping the removal of the close label` + ); + + return Promise.resolve(); + } + + if (isLabeled(issue, closeLabel)) { + issueLogger.info( + LoggerService.white('├──'), + `The $$type has a close label "${LoggerService.cyan( + closeLabel + )}". Removing the close label...` + ); + + await this._removeLabel(issue, closeLabel, true); + this.statistics?.incrementDeletedCloseItemsLabelsCount(issue); + } else { + issueLogger.info( + LoggerService.white('└──'), + `There is no close label on this $$type. Skipping` + ); + + return Promise.resolve(); + } + } - issueLogger.info( - `Removing all the labels specified via the ${this._logger.createOptionLink( - staleStatus - )} option.` - ); + private _consumeIssueOperation(issue: Readonly): void { + this.operations.consumeOperation(); + issue.operations.consumeOperation(); + } - for (const label of removeLabels.values()) { - await this._removeLabel(issue, label); + private _getDaysBeforeStaleUsedOptionName( + issue: Readonly + ): + | Option.DaysBeforeStale + | Option.DaysBeforeIssueStale + | Option.DaysBeforePrStale { + return issue.isPullRequest + ? this._getDaysBeforePrStaleUsedOptionName() + : this._getDaysBeforeIssueStaleUsedOptionName(); } - } - - private async _addLabelsWhenUnstale( - issue: Issue, - labelsToAdd: Readonly[] - ): Promise { - if (!labelsToAdd.length) { - return; + + private _getDaysBeforeIssueStaleUsedOptionName(): + | Option.DaysBeforeStale + | Option.DaysBeforeIssueStale { + return isNaN(this.options.daysBeforeIssueStale) + ? Option.DaysBeforeStale + : Option.DaysBeforeIssueStale; } - 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._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 stale: ${error.message}` - ); + private _getDaysBeforePrStaleUsedOptionName(): + | Option.DaysBeforeStale + | Option.DaysBeforePrStale { + return isNaN(this.options.daysBeforePrStale) + ? Option.DaysBeforeStale + : Option.DaysBeforePrStale; } - } - - private async _removeStaleLabel( - issue: Issue, - staleLabel: Readonly - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `The $$type is no longer stale. Removing the stale label...` - ); - - await this._removeLabel(issue, staleLabel); - this.statistics?.incrementUndoStaleItemsCount(issue); - } - - private async _removeCloseLabel( - issue: Issue, - closeLabel: Readonly - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `The $$type is not closed nor locked. Trying to remove the close label...` - ); - - if (!closeLabel) { - issueLogger.info( - LoggerService.white('├──'), - `The ${issueLogger.createOptionLink( - IssuesProcessor._getCloseLabelUsedOptionName(issue) - )} option was not set` - ); - issueLogger.info( - LoggerService.white('└──'), - `Skipping the removal of the close label` - ); - - return Promise.resolve(); + + private _getDaysBeforeRottenUsedOptionName( + issue: Readonly + ): + | Option.DaysBeforeRotten + | Option.DaysBeforeIssueRotten + | Option.DaysBeforePrRotten { + return issue.isPullRequest + ? this._getDaysBeforePrRottenUsedOptionName() + : this._getDaysBeforeIssueRottenUsedOptionName(); } - if (isLabeled(issue, closeLabel)) { - issueLogger.info( - LoggerService.white('├──'), - `The $$type has a close label "${LoggerService.cyan( - closeLabel - )}". Removing the close label...` - ); - - await this._removeLabel(issue, closeLabel, true); - this.statistics?.incrementDeletedCloseItemsLabelsCount(issue); - } else { - issueLogger.info( - LoggerService.white('└──'), - `There is no close label on this $$type. Skipping` - ); - - return Promise.resolve(); + private _getDaysBeforeIssueRottenUsedOptionName(): + | Option.DaysBeforeRotten + | Option.DaysBeforeIssueRotten { + return isNaN(this.options.daysBeforeIssueRotten) + ? Option.DaysBeforeRotten + : Option.DaysBeforeIssueRotten; } - } - - private _consumeIssueOperation(issue: Readonly): void { - this.operations.consumeOperation(); - issue.operations.consumeOperation(); - } - - private _getDaysBeforeStaleUsedOptionName( - issue: Readonly - ): - | Option.DaysBeforeStale - | Option.DaysBeforeIssueStale - | Option.DaysBeforePrStale { - return issue.isPullRequest - ? this._getDaysBeforePrStaleUsedOptionName() - : this._getDaysBeforeIssueStaleUsedOptionName(); - } - - private _getDaysBeforeIssueStaleUsedOptionName(): - | Option.DaysBeforeStale - | Option.DaysBeforeIssueStale { - return isNaN(this.options.daysBeforeIssueStale) - ? Option.DaysBeforeStale - : Option.DaysBeforeIssueStale; - } - - private _getDaysBeforePrStaleUsedOptionName(): - | Option.DaysBeforeStale - | Option.DaysBeforePrStale { - return isNaN(this.options.daysBeforePrStale) - ? Option.DaysBeforeStale - : Option.DaysBeforePrStale; - } - - private _getRemoveStaleWhenUpdatedUsedOptionName( - issue: Readonly - ): - | Option.RemovePrStaleWhenUpdated - | Option.RemoveStaleWhenUpdated - | Option.RemoveIssueStaleWhenUpdated { - if (issue.isPullRequest) { - if (isBoolean(this.options.removePrStaleWhenUpdated)) { - return Option.RemovePrStaleWhenUpdated; - } - - return Option.RemoveStaleWhenUpdated; + + private _getDaysBeforePrRottenUsedOptionName(): + | Option.DaysBeforeRotten + | Option.DaysBeforePrRotten { + return isNaN(this.options.daysBeforePrRotten) + ? Option.DaysBeforeRotten + : Option.DaysBeforePrRotten; } + private _getRemoveStaleWhenUpdatedUsedOptionName( + issue: Readonly + ): + | Option.RemovePrStaleWhenUpdated + | Option.RemoveStaleWhenUpdated + | Option.RemoveIssueStaleWhenUpdated { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrStaleWhenUpdated)) { + return Option.RemovePrStaleWhenUpdated; + } + + return Option.RemoveStaleWhenUpdated; + } + + if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { + return Option.RemoveIssueStaleWhenUpdated; + } - if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { - return Option.RemoveIssueStaleWhenUpdated; + 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.RemoveStaleWhenUpdated; - } + return Option.RemoveRottenWhenUpdated; + } } diff --git a/src/classes/statistics.ts b/src/classes/statistics.ts index 321ea70d9..3d1377978 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,18 @@ 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 +238,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..45f466e32 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-unstale', + LabelsToAddWhenUnrotten = 'labels-to-add-when-unstale', 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..f99840e0f 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -1,57 +1,70 @@ -import {IsoOrRfcDateString} from '../types/iso-or-rfc-date-string'; +import { IsoOrRfcDateString } from '../types/iso-or-rfc-date-string'; export interface IIssuesProcessorOptions { - repoToken: string; - staleIssueMessage: string; - stalePrMessage: string; - closeIssueMessage: string; - closePrMessage: string; - daysBeforeStale: number; - daysBeforeIssueStale: number; // Could be NaN - daysBeforePrStale: number; // Could be NaN - daysBeforeClose: number; - daysBeforeIssueClose: number; // Could be NaN - daysBeforePrClose: number; // Could be NaN - staleIssueLabel: string; - closeIssueLabel: string; - exemptIssueLabels: string; - stalePrLabel: string; - closePrLabel: string; - exemptPrLabels: string; - onlyLabels: string; - onlyIssueLabels: string; - onlyPrLabels: string; - anyOfLabels: string; - anyOfIssueLabels: string; - anyOfPrLabels: string; - operationsPerRun: number; - removeStaleWhenUpdated: boolean; - removeIssueStaleWhenUpdated: boolean | undefined; - removePrStaleWhenUpdated: boolean | undefined; - debugOnly: boolean; - ascending: boolean; - deleteBranch: boolean; - startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822 - exemptMilestones: string; - exemptIssueMilestones: string; - exemptPrMilestones: string; - exemptAllMilestones: boolean; - exemptAllIssueMilestones: boolean | undefined; - exemptAllPrMilestones: boolean | undefined; - exemptAssignees: string; - exemptIssueAssignees: string; - exemptPrAssignees: string; - exemptAllAssignees: boolean; - exemptAllIssueAssignees: boolean | undefined; - exemptAllPrAssignees: boolean | undefined; - enableStatistics: boolean; - labelsToRemoveWhenStale: string; - labelsToRemoveWhenUnstale: string; - labelsToAddWhenUnstale: string; - ignoreUpdates: boolean; - ignoreIssueUpdates: boolean | undefined; - ignorePrUpdates: boolean | undefined; - exemptDraftPr: boolean; - closeIssueReason: string; - includeOnlyAssigned: boolean; + 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; + onlyIssueLabels: string; + onlyPrLabels: string; + anyOfLabels: string; + anyOfIssueLabels: string; + anyOfPrLabels: string; + operationsPerRun: number; + removeStaleWhenUpdated: boolean; + removeIssueStaleWhenUpdated: boolean | undefined; + removePrStaleWhenUpdated: boolean | undefined; + removeRottenWhenUpdated: boolean; + removeIssueRottenWhenUpdated: boolean | undefined; + removePrRottenWhenUpdated: boolean | undefined; + debugOnly: boolean; + ascending: boolean; + deleteBranch: boolean; + startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822 + exemptMilestones: string; + exemptIssueMilestones: string; + exemptPrMilestones: string; + exemptAllMilestones: boolean; + exemptAllIssueMilestones: boolean | undefined; + exemptAllPrMilestones: boolean | undefined; + exemptAssignees: string; + exemptIssueAssignees: string; + exemptPrAssignees: string; + exemptAllAssignees: boolean; + exemptAllIssueAssignees: boolean | undefined; + exemptAllPrAssignees: boolean | undefined; + enableStatistics: boolean; + labelsToRemoveWhenStale: string; + labelsToRemoveWhenUnstale: string; + labelsToAddWhenUnstale: string; + labelsToRemoveWhenRotten: string; + labelsToRemoveWhenUnrotten: string; + labelsToAddWhenUnrotten: string; + ignoreUpdates: boolean; + ignoreIssueUpdates: boolean | undefined; + ignorePrUpdates: boolean | undefined; + exemptDraftPr: boolean; + closeIssueReason: string; + includeOnlyAssigned: boolean; } diff --git a/src/main.ts b/src/main.ts index a7836c160..4f4b51c0d 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,31 @@ 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 +105,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 +137,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 +155,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 +196,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)); } From 834a5c7eb3d002c75a017666f6827426fc29d0fb Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Sun, 3 Mar 2024 23:40:59 +0530 Subject: [PATCH 2/7] Add tests --- .../constants/default-processor-options.ts | 2 +- __tests__/main.spec.ts | 356 +++++++++++++++--- __tests__/operations-per-run.spec.ts | 2 +- __tests__/state.spec.ts | 2 +- src/classes/issue.spec.ts | 13 + src/classes/issues-processor.ts | 35 +- 6 files changed, 348 insertions(+), 62 deletions(-) diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 74af70e2f..5d61c1300 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -11,7 +11,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ closeIssueMessage: 'This issue is being closed', closePrMessage: 'This PR is being closed', daysBeforeStale: 1, - daysBeforeRotten: 0, + daysBeforeRotten: -1, daysBeforeIssueStale: NaN, daysBeforePrStale: NaN, daysBeforeIssueRotten: NaN, diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 7ad9e10a3..790313fca 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -496,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( @@ -526,13 +527,14 @@ test('processing a stale issue will close it', async () => { expect(processor.staleIssues).toHaveLength(0); expect(processor.rottenIssues).toHaveLength(1); - expect(processor.closedIssues).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( @@ -558,13 +560,16 @@ 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( @@ -590,20 +595,22 @@ 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, @@ -623,13 +630,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( @@ -655,13 +663,51 @@ 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[] = [ @@ -688,13 +734,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[] = [ @@ -721,13 +769,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: '' }; @@ -755,14 +806,51 @@ 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, - stalePrMessage: '' + daysBeforeRotten: 0, + stalePrMessage: '', + }; + 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(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( @@ -788,14 +876,17 @@ 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(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[] = [ @@ -822,10 +913,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, @@ -850,10 +942,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, @@ -879,10 +1002,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, @@ -908,6 +1032,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); }); @@ -940,7 +1065,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, @@ -965,10 +1090,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, @@ -995,10 +1121,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('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', async () => { +test('locked prs will not be marked stale or rotten', async () => { const TestIssueList: Issue[] = [ generateIssue( DefaultProcessorOptions, @@ -1023,10 +1181,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, @@ -1053,11 +1212,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[] = [ @@ -1084,11 +1244,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[] = [ @@ -1115,6 +1276,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); }); @@ -1248,7 +1410,8 @@ 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, @@ -1375,8 +1538,9 @@ 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 (and the issue should be rotten)', async () => { + const opts = {...DefaultProcessorOptions, removeStaleWhenUpdated: true, daysBeforeRotten: 0, + }; github.context.actor = 'abot'; const TestIssueList: Issue[] = [ generateIssue( @@ -1409,7 +1573,8 @@ test('stale label should not be removed if a comment was added by the bot (and t // process our fake issue list await processor.processIssues(1); - expect(processor.closedIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(1); expect(processor.staleIssues).toHaveLength(0); expect(processor.removedLabelIssues).toHaveLength(0); }); @@ -1480,10 +1645,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; // closes after 6 days const lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 7); const TestIssueList: Issue[] = [ @@ -1509,7 +1674,8 @@ 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.closedIssues).toHaveLength(0); + expect(processor.rottenIssues).toHaveLength(1); expect(processor.removedLabelIssues).toHaveLength(0); expect(processor.staleIssues).toHaveLength(0); }); @@ -1728,8 +1894,8 @@ 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( @@ -1759,8 +1925,8 @@ 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( @@ -2554,13 +2720,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 }; @@ -2593,8 +2760,9 @@ test('processing an issue stale since less than the daysBeforeStale with a stale await processor.processIssues(1); expect(processor.removedLabelIssues).toHaveLength(0); + 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 () => { @@ -2604,6 +2772,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 }; @@ -2639,13 +2809,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); @@ -2676,12 +2847,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(); @@ -2713,6 +2924,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..fd7d89a89 100644 --- a/__tests__/state.spec.ts +++ b/__tests__/state.spec.ts @@ -175,7 +175,7 @@ describe('state', (): void => { it('state should be reset if all issues are proceeded', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - daysBeforeClose: 0 + daysBeforeClose: 0, }; const testIssue1 = generateIssue( opts, 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/issues-processor.ts b/src/classes/issues-processor.ts index b77b2b9a7..12f0e46ee 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -724,6 +724,8 @@ export class IssuesProcessor { ) { const issueLogger: IssueLogger = new IssueLogger(issue); + var 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; @@ -820,14 +822,26 @@ export class IssuesProcessor { 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)}` + ); + let issueHasUpdateInCloseWindow: boolean - issueHasUpdateInCloseWindow = !IssuesProcessor._updatedSince( + issueHasUpdateInCloseWindow = 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( @@ -836,6 +850,8 @@ export class IssuesProcessor { ); 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( @@ -856,6 +872,13 @@ export class IssuesProcessor { // 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`); @@ -918,7 +941,7 @@ export class IssuesProcessor { } } } - if(issue.isRotten){ + if (issue.isRotten) { issueLogger.info(`This $$type is already rotten`); // process the rotten issues this._processRottenIssue( @@ -1223,7 +1246,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) { @@ -1397,9 +1420,9 @@ export class IssuesProcessor { } private _getDaysBeforePrRotten(): number { - return isNaN(this.options.daysBeforePrStale) - ? this.options.daysBeforeStale - : this.options.daysBeforePrStale; + return isNaN(this.options.daysBeforePrRotten) + ? this.options.daysBeforeRotten + : this.options.daysBeforePrRotten; } private _getDaysBeforeIssueClose(): number { From e0c4e25b76f119d5e291f82d085e945182ac1ae6 Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Tue, 5 Mar 2024 19:03:19 +0530 Subject: [PATCH 3/7] Update tests to correctly check state --- __tests__/main.spec.ts | 33 ++++++++++++++++++++++----------- __tests__/state.spec.ts | 4 ++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index 790313fca..a4fbdc13f 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -569,7 +569,6 @@ test('processing a stale issue containing a slash in the label will rotten it bu ...DefaultProcessorOptions, staleIssueLabel: 'lifecycle/stale', daysBeforeRotten: 0 - }; const TestIssueList: Issue[] = [ generateIssue( @@ -604,7 +603,6 @@ test('processing a stale issue will rotten it but not close it when days-before- ...DefaultProcessorOptions, daysBeforeRotten: -1, daysBeforeIssueRotten: 30 - }; const TestIssueList: Issue[] = [ generateIssue( @@ -667,7 +665,6 @@ test('processing a stale PR will rotten it but not close it', async () => { expect(processor.closedIssues).toHaveLength(0); }); - test('processing a stale PR will rotten it it when days-before-pr-rotten override days-before-rotten', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, @@ -815,7 +812,7 @@ test('processing a stale PR will rotten it even if configured not to mark as sta ...DefaultProcessorOptions, daysBeforeStale: -1, daysBeforeRotten: 0, - stalePrMessage: '', + stalePrMessage: '' }; const TestIssueList: Issue[] = [ generateIssue( @@ -850,7 +847,7 @@ test('processing a stale PR will close it even if configured not to mark as stal ...DefaultProcessorOptions, daysBeforeStale: -1, stalePrMessage: '', - daysBeforeRotten: -1, + daysBeforeRotten: -1 }; const TestIssueList: Issue[] = [ generateIssue( @@ -1410,7 +1407,10 @@ 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, daysBeforeRotten: 0, + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + daysBeforeRotten: 0 }; const TestIssueList: Issue[] = [ generateIssue( @@ -1539,7 +1539,10 @@ test('when the option "labelsToRemoveWhenStale" is set, the labels should be rem }); test('stale label should not be removed if a comment was added by the bot (and the issue should be rotten)', async () => { - const opts = {...DefaultProcessorOptions, removeStaleWhenUpdated: true, daysBeforeRotten: 0, + const opts = { + ...DefaultProcessorOptions, + removeStaleWhenUpdated: true, + daysBeforeRotten: 0 }; github.context.actor = 'abot'; const TestIssueList: Issue[] = [ @@ -1895,7 +1898,11 @@ test('send stale message on prs when stale-pr-message is not empty', async () => }); 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 opts = { + ...DefaultProcessorOptions, + deleteBranch: true, + daysBeforeRotten: -1 + }; const isPullRequest = true; const TestIssueList: Issue[] = [ generateIssue( @@ -1926,7 +1933,11 @@ test('git branch is deleted when option is enabled and days before rotten is set }); 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 opts = { + ...DefaultProcessorOptions, + deleteBranch: true, + daysBeforeRotten: -1 + }; const isPullRequest = false; const TestIssueList: Issue[] = [ generateIssue( @@ -2762,7 +2773,7 @@ test('processing an issue stale since less than the daysBeforeStale with a stale expect(processor.removedLabelIssues).toHaveLength(0); expect(processor.rottenIssues).toHaveLength(1); // Expected at 0 by the user expect(processor.deletedBranchIssues).toHaveLength(0); - expect(processor.closedIssues).toHaveLength(0); + expect(processor.closedIssues).toHaveLength(0); }); test('processing an issue stale since less than the daysBeforeStale without a stale label should close the issue', async () => { @@ -2816,7 +2827,7 @@ test('processing a pull request to be stale with the "stalePrMessage" option set stalePrMessage: 'This PR is stale', daysBeforeStale: 10, daysBeforePrStale: 1, - daysBeforeRotten: -1, + daysBeforeRotten: -1 }; const issueDate = new Date(); issueDate.setDate(issueDate.getDate() - 2); diff --git a/__tests__/state.spec.ts b/__tests__/state.spec.ts index fd7d89a89..af5c9151c 100644 --- a/__tests__/state.spec.ts +++ b/__tests__/state.spec.ts @@ -175,7 +175,7 @@ describe('state', (): void => { it('state should be reset if all issues are proceeded', async () => { const opts: IIssuesProcessorOptions = { ...DefaultProcessorOptions, - daysBeforeClose: 0, + daysBeforeClose: 0 }; const testIssue1 = generateIssue( opts, @@ -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...' ); From 8fb3b504a0d5321467185460e12715a3c00aad77 Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Tue, 5 Mar 2024 19:03:44 +0530 Subject: [PATCH 4/7] fix linting --- src/classes/issue.ts | 154 +- src/classes/issues-processor.ts | 3183 ++++++++++---------- src/classes/statistics.ts | 1 - src/enums/option.ts | 4 +- src/interfaces/issues-processor-options.ts | 134 +- src/main.ts | 4 +- 6 files changed, 1741 insertions(+), 1739 deletions(-) diff --git a/src/classes/issue.ts b/src/classes/issue.ts index f3ca750ed..173bb900a 100644 --- a/src/classes/issue.ts +++ b/src/classes/issue.ts @@ -1,88 +1,88 @@ -import { isLabeled } from '../functions/is-labeled'; -import { isPullRequest } from '../functions/is-pull-request'; -import { Assignee } from '../interfaces/assignee'; -import { IIssue, OctokitIssue } from '../interfaces/issue'; -import { IIssuesProcessorOptions } from '../interfaces/issues-processor-options'; -import { ILabel } from '../interfaces/label'; -import { IMilestone } from '../interfaces/milestone'; -import { IsoDateString } from '../types/iso-date-string'; -import { Operations } from './operations'; +import {isLabeled} from '../functions/is-labeled'; +import {isPullRequest} from '../functions/is-pull-request'; +import {Assignee} from '../interfaces/assignee'; +import {IIssue, OctokitIssue} from '../interfaces/issue'; +import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; +import {ILabel} from '../interfaces/label'; +import {IMilestone} from '../interfaces/milestone'; +import {IsoDateString} from '../types/iso-date-string'; +import {Operations} from './operations'; export class Issue implements IIssue { - readonly title: string; - readonly number: number; - created_at: IsoDateString; - updated_at: IsoDateString; - readonly draft: boolean; - readonly labels: ILabel[]; - readonly pull_request: object | null | undefined; - readonly state: string | 'closed' | 'open'; - readonly locked: boolean; - readonly milestone?: IMilestone | null; - readonly assignees: Assignee[]; - isStale: boolean; - isRotten: boolean; - markedStaleThisRun: boolean; - markedRottenThisRun: boolean; - operations = new Operations(); - private readonly _options: IIssuesProcessorOptions; + readonly title: string; + readonly number: number; + created_at: IsoDateString; + updated_at: IsoDateString; + readonly draft: boolean; + readonly labels: ILabel[]; + readonly pull_request: object | null | undefined; + readonly state: string | 'closed' | 'open'; + readonly locked: boolean; + readonly milestone?: IMilestone | null; + readonly assignees: Assignee[]; + isStale: boolean; + isRotten: boolean; + markedStaleThisRun: boolean; + markedRottenThisRun: boolean; + operations = new Operations(); + private readonly _options: IIssuesProcessorOptions; - constructor( - options: Readonly, - issue: Readonly | Readonly - ) { - this._options = options; - this.title = issue.title; - this.number = issue.number; - this.created_at = issue.created_at; - this.updated_at = issue.updated_at; - this.draft = Boolean(issue.draft); - this.labels = mapLabels(issue.labels); - this.pull_request = issue.pull_request; - this.state = issue.state; - this.locked = issue.locked; - 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; - } + constructor( + options: Readonly, + issue: Readonly | Readonly + ) { + this._options = options; + this.title = issue.title; + this.number = issue.number; + this.created_at = issue.created_at; + this.updated_at = issue.updated_at; + this.draft = Boolean(issue.draft); + this.labels = mapLabels(issue.labels); + this.pull_request = issue.pull_request; + this.state = issue.state; + this.locked = issue.locked; + 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 { - return isPullRequest(this); - } + get isPullRequest(): boolean { + return isPullRequest(this); + } - get staleLabel(): string { - return this._getStaleLabel(); - } - get rottenLabel(): string { - return this._getRottenLabel(); - } + get staleLabel(): string { + return this._getStaleLabel(); + } + get rottenLabel(): string { + return this._getRottenLabel(); + } - get hasAssignees(): boolean { - return this.assignees.length > 0; - } + get hasAssignees(): boolean { + return this.assignees.length > 0; + } - private _getStaleLabel(): string { - return this.isPullRequest - ? this._options.stalePrLabel - : this._options.staleIssueLabel; - } - private _getRottenLabel(): string { - return this.isPullRequest - ? this._options.rottenPrLabel - : this._options.rottenIssueLabel; - } + private _getStaleLabel(): string { + return this.isPullRequest + ? 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[] { - return labels.map(label => { - if (typeof label == 'string') { - return { - name: label - }; - } - return label; - }); + return labels.map(label => { + if (typeof label == 'string') { + return { + name: label + }; + } + return label; + }); } diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 12f0e46ee..05423aafe 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -1,1769 +1,1770 @@ import * as core from '@actions/core'; -import { context, getOctokit } from '@actions/github'; -import { GitHub } from '@actions/github/lib/utils'; -import { Option } from '../enums/option'; -import { getHumanizedDate } from '../functions/dates/get-humanized-date'; -import { isDateMoreRecentThan } from '../functions/dates/is-date-more-recent-than'; -import { isValidDate } from '../functions/dates/is-valid-date'; -import { isBoolean } from '../functions/is-boolean'; -import { isLabeled } from '../functions/is-labeled'; -import { cleanLabel } from '../functions/clean-label'; -import { shouldMarkWhenStale } from '../functions/should-mark-when-stale'; -import { wordsToList } from '../functions/words-to-list'; -import { IComment } from '../interfaces/comment'; -import { IIssueEvent } from '../interfaces/issue-event'; -import { IIssuesProcessorOptions } from '../interfaces/issues-processor-options'; -import { IPullRequest } from '../interfaces/pull-request'; -import { Assignees } from './assignees'; -import { IgnoreUpdates } from './ignore-updates'; -import { ExemptDraftPullRequest } from './exempt-draft-pull-request'; -import { Issue } from './issue'; -import { IssueLogger } from './loggers/issue-logger'; -import { Logger } from './loggers/logger'; -import { Milestones } from './milestones'; -import { StaleOperations } from './stale-operations'; -import { Statistics } from './statistics'; -import { LoggerService } from '../services/logger.service'; -import { OctokitIssue } from '../interfaces/issue'; -import { retry } from '@octokit/plugin-retry'; -import { IState } from '../interfaces/state/state'; -import { IRateLimit } from '../interfaces/rate-limit'; -import { RateLimit } from './rate-limit'; +import {context, getOctokit} from '@actions/github'; +import {GitHub} from '@actions/github/lib/utils'; +import {Option} from '../enums/option'; +import {getHumanizedDate} from '../functions/dates/get-humanized-date'; +import {isDateMoreRecentThan} from '../functions/dates/is-date-more-recent-than'; +import {isValidDate} from '../functions/dates/is-valid-date'; +import {isBoolean} from '../functions/is-boolean'; +import {isLabeled} from '../functions/is-labeled'; +import {cleanLabel} from '../functions/clean-label'; +import {shouldMarkWhenStale} from '../functions/should-mark-when-stale'; +import {wordsToList} from '../functions/words-to-list'; +import {IComment} from '../interfaces/comment'; +import {IIssueEvent} from '../interfaces/issue-event'; +import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; +import {IPullRequest} from '../interfaces/pull-request'; +import {Assignees} from './assignees'; +import {IgnoreUpdates} from './ignore-updates'; +import {ExemptDraftPullRequest} from './exempt-draft-pull-request'; +import {Issue} from './issue'; +import {IssueLogger} from './loggers/issue-logger'; +import {Logger} from './loggers/logger'; +import {Milestones} from './milestones'; +import {StaleOperations} from './stale-operations'; +import {Statistics} from './statistics'; +import {LoggerService} from '../services/logger.service'; +import {OctokitIssue} from '../interfaces/issue'; +import {retry} from '@octokit/plugin-retry'; +import {IState} from '../interfaces/state/state'; +import {IRateLimit} from '../interfaces/rate-limit'; +import {RateLimit} from './rate-limit'; /*** * Handle processing of issues for staleness/closure. */ export class IssuesProcessor { - private static _updatedSince(timestamp: string, num_days: number): boolean { - const daysInMillis = 1000 * 60 * 60 * 24 * num_days; - const millisSinceLastUpdated = - new Date().getTime() - new Date(timestamp).getTime(); - - return millisSinceLastUpdated <= daysInMillis; + private static _updatedSince(timestamp: string, num_days: number): boolean { + const daysInMillis = 1000 * 60 * 60 * 24 * num_days; + const millisSinceLastUpdated = + new Date().getTime() - new Date(timestamp).getTime(); + + return millisSinceLastUpdated <= daysInMillis; + } + + private static _endIssueProcessing(issue: Issue): void { + const consumedOperationsCount: number = + issue.operations.getConsumedOperationsCount(); + + if (consumedOperationsCount > 0) { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + LoggerService.cyan(consumedOperationsCount), + `operation${ + consumedOperationsCount > 1 ? 's' : '' + } consumed for this $$type` + ); + } + } + + private static _getCloseLabelUsedOptionName( + issue: Readonly + ): Option.ClosePrLabel | Option.CloseIssueLabel { + return issue.isPullRequest ? Option.ClosePrLabel : Option.CloseIssueLabel; + } + + readonly operations: StaleOperations; + readonly client: InstanceType; + readonly options: IIssuesProcessorOptions; + readonly staleIssues: Issue[] = []; + readonly rottenIssues: Issue[] = []; + readonly closedIssues: Issue[] = []; + readonly deletedBranchIssues: Issue[] = []; + readonly removedLabelIssues: Issue[] = []; + readonly addedLabelIssues: Issue[] = []; + readonly addedCloseCommentIssues: Issue[] = []; + readonly statistics: Statistics | undefined; + private readonly _logger: Logger = new Logger(); + private readonly state: IState; + + constructor(options: IIssuesProcessorOptions, state: IState) { + this.options = options; + this.state = state; + this.client = getOctokit(this.options.repoToken, undefined, retry); + this.operations = new StaleOperations(this.options); + + this._logger.info( + LoggerService.yellow(`Starting the stale action process...`) + ); + + if (this.options.debugOnly) { + this._logger.warning( + LoggerService.yellowBright(`Executing in debug mode!`) + ); + this._logger.warning( + LoggerService.yellowBright( + `The debug output will be written but no issues/PRs will be processed.` + ) + ); } - private static _endIssueProcessing(issue: Issue): void { - const consumedOperationsCount: number = - issue.operations.getConsumedOperationsCount(); - - if (consumedOperationsCount > 0) { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - LoggerService.cyan(consumedOperationsCount), - `operation${consumedOperationsCount > 1 ? 's' : '' - } consumed for this $$type` - ); - } + if (this.options.enableStatistics) { + this.statistics = new Statistics(); + } + } + + async processIssues(page: Readonly = 1): Promise { + // get the next batch of issues + const issues: Issue[] = await this.getIssues(page); + + if (issues.length <= 0) { + this._logger.info( + LoggerService.green(`No more issues found to process. Exiting...`) + ); + this.statistics + ?.setOperationsCount(this.operations.getConsumedOperationsCount()) + .logStats(); + + this.state.reset(); + + return this.operations.getRemainingOperationsCount(); + } else { + this._logger.info( + `${LoggerService.yellow( + 'Processing the batch of issues ' + )} ${LoggerService.cyan(`#${page}`)} ${LoggerService.yellow( + ' containing ' + )} ${LoggerService.cyan(issues.length)} ${LoggerService.yellow( + ` issue${issues.length > 1 ? 's' : ''}...` + )}` + ); } - private static _getCloseLabelUsedOptionName( - issue: Readonly - ): Option.ClosePrLabel | Option.CloseIssueLabel { - return issue.isPullRequest ? Option.ClosePrLabel : Option.CloseIssueLabel; - } - - readonly operations: StaleOperations; - readonly client: InstanceType; - readonly options: IIssuesProcessorOptions; - readonly staleIssues: Issue[] = []; - readonly rottenIssues: Issue[] = []; - readonly closedIssues: Issue[] = []; - readonly deletedBranchIssues: Issue[] = []; - readonly removedLabelIssues: Issue[] = []; - readonly addedLabelIssues: Issue[] = []; - readonly addedCloseCommentIssues: Issue[] = []; - readonly statistics: Statistics | undefined; - private readonly _logger: Logger = new Logger(); - private readonly state: IState; - - constructor(options: IIssuesProcessorOptions, state: IState) { - this.options = options; - this.state = state; - this.client = getOctokit(this.options.repoToken, undefined, retry); - this.operations = new StaleOperations(this.options); - - this._logger.info( - LoggerService.yellow(`Starting the stale action process...`) + const labelsToRemoveWhenStale: string[] = wordsToList( + this.options.labelsToRemoveWhenStale + ); + + const labelsToAddWhenUnstale: string[] = wordsToList( + this.options.labelsToAddWhenUnstale + ); + 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 + if (!this.operations.hasRemainingOperations()) { + break; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); + if (this.state.isIssueProcessed(issue)) { + issueLogger.info( + ' $$type skipped due being processed during the previous run' ); + continue; + } + await issueLogger.grouping(`$$type #${issue.number}`, async () => { + await this.processIssue( + issue, + labelsToAddWhenUnstale, + labelsToRemoveWhenUnstale, + labelsToRemoveWhenStale, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten + ); + }); + this.state.addIssueToProcessed(issue); + } - if (this.options.debugOnly) { - this._logger.warning( - LoggerService.yellowBright(`Executing in debug mode!`) - ); - this._logger.warning( - LoggerService.yellowBright( - `The debug output will be written but no issues/PRs will be processed.` - ) - ); - } + if (!this.operations.hasRemainingOperations()) { + this._logger.warning( + LoggerService.yellowBright(`No more operations left! Exiting...`) + ); + this._logger.warning( + `${LoggerService.yellowBright( + 'If you think that not enough issues were processed you could try to increase the quantity related to the ' + )} ${this._logger.createOptionLink( + Option.OperationsPerRun + )} ${LoggerService.yellowBright( + ' option which is currently set to ' + )} ${LoggerService.cyan(this.options.operationsPerRun)}` + ); + this.statistics + ?.setOperationsCount(this.operations.getConsumedOperationsCount()) + .logStats(); + + return 0; + } - if (this.options.enableStatistics) { - this.statistics = new Statistics(); - } + this._logger.info( + `${LoggerService.green('Batch ')} ${LoggerService.cyan( + `#${page}` + )} ${LoggerService.green(' processed.')}` + ); + + // Do the next batch + return this.processIssues(page + 1); + } + + async processIssue( + issue: Issue, + labelsToAddWhenUnstale: Readonly[], + labelsToRemoveWhenUnstale: Readonly[], + labelsToRemoveWhenStale: Readonly[], + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[] + ): Promise { + this.statistics?.incrementProcessedItemsCount(issue); + + const issueLogger: IssueLogger = new IssueLogger(issue); + issueLogger.info( + `Found this $$type last updated at: ${LoggerService.cyan( + issue.updated_at + )}` + ); + + // calculate string based messages for this issue + 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; + const skipMessage = issue.isPullRequest + ? this.options.stalePrMessage.length === 0 + : this.options.staleIssueMessage.length === 0; + const daysBeforeStale: number = issue.isPullRequest + ? this._getDaysBeforePrStale() + : this._getDaysBeforeIssueStale(); + + if (issue.state === 'closed') { + issueLogger.info(`Skipping this $$type because it is closed`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process closed issues } - async processIssues(page: Readonly = 1): Promise { - // get the next batch of issues - const issues: Issue[] = await this.getIssues(page); + if (issue.locked) { + issueLogger.info(`Skipping this $$type because it is locked`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process locked issues + } - if (issues.length <= 0) { - this._logger.info( - LoggerService.green(`No more issues found to process. Exiting...`) - ); - this.statistics - ?.setOperationsCount(this.operations.getConsumedOperationsCount()) - .logStats(); + if (this._isIncludeOnlyAssigned(issue)) { + issueLogger.info( + `Skipping this $$type because its assignees list is empty` + ); + IssuesProcessor._endIssueProcessing(issue); + return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list + } - this.state.reset(); + const onlyLabels: string[] = wordsToList(this._getOnlyLabels(issue)); - return this.operations.getRemainingOperationsCount(); - } else { - this._logger.info( - `${LoggerService.yellow( - 'Processing the batch of issues ' - )} ${LoggerService.cyan(`#${page}`)} ${LoggerService.yellow( - ' containing ' - )} ${LoggerService.cyan(issues.length)} ${LoggerService.yellow( - ` issue${issues.length > 1 ? 's' : ''}...` - )}` - ); + if (onlyLabels.length > 0) { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.OnlyLabels + )} was specified to only process issues and pull requests with all those labels (${LoggerService.cyan( + onlyLabels.length + )})` + ); + + const hasAllWhitelistedLabels: boolean = onlyLabels.every( + (label: Readonly): boolean => { + return isLabeled(issue, label); } + ); - const labelsToRemoveWhenStale: string[] = wordsToList( - this.options.labelsToRemoveWhenStale + if (!hasAllWhitelistedLabels) { + issueLogger.info( + LoggerService.white('└──'), + `Skipping this $$type because it doesn't have all the required labels` ); - const labelsToAddWhenUnstale: string[] = wordsToList( - this.options.labelsToAddWhenUnstale - ); - const labelsToRemoveWhenUnstale: string[] = wordsToList( - this.options.labelsToRemoveWhenUnstale + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process issues without all of the required labels + } else { + issueLogger.info( + LoggerService.white('├──'), + `All the required labels are present on this $$type` ); - const labelsToRemoveWhenRotten: string[] = wordsToList( - this.options.labelsToRemoveWhenRotten + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` ); + } + } else { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.OnlyLabels + )} was not specified` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); + } - const labelsToAddWhenUnrotten: string[] = wordsToList( - this.options.labelsToAddWhenUnrotten - ); - const labelsToRemoveWhenUnrotten: string[] = wordsToList( - this.options.labelsToRemoveWhenUnrotten - ); + issueLogger.info( + `Days before $$type stale: ${LoggerService.cyan(daysBeforeStale)}` + ); - for (const issue of issues.values()) { - // Stop the processing if no more operations remains - if (!this.operations.hasRemainingOperations()) { - break; - } - - const issueLogger: IssueLogger = new IssueLogger(issue); - if (this.state.isIssueProcessed(issue)) { - issueLogger.info( - ' $$type skipped due being processed during the previous run' - ); - continue; - } - await issueLogger.grouping(`$$type #${issue.number}`, async () => { - await this.processIssue( - issue, - labelsToAddWhenUnstale, - labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale, - labelsToAddWhenUnrotten, - labelsToRemoveWhenUnrotten, - labelsToRemoveWhenRotten - ); - }); - this.state.addIssueToProcessed(issue); - } + const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale); - if (!this.operations.hasRemainingOperations()) { - this._logger.warning( - LoggerService.yellowBright(`No more operations left! Exiting...`) - ); - this._logger.warning( - `${LoggerService.yellowBright( - 'If you think that not enough issues were processed you could try to increase the quantity related to the ' - )} ${this._logger.createOptionLink( - Option.OperationsPerRun - )} ${LoggerService.yellowBright( - ' option which is currently set to ' - )} ${LoggerService.cyan(this.options.operationsPerRun)}` - ); - this.statistics - ?.setOperationsCount(this.operations.getConsumedOperationsCount()) - .logStats(); + // Try to remove the close label when not close/locked issue or PR + await this._removeCloseLabel(issue, closeLabel); - return 0; - } + if (this.options.startDate) { + const startDate: Date = new Date(this.options.startDate); + const createdAt: Date = new Date(issue.created_at); - this._logger.info( - `${LoggerService.green('Batch ')} ${LoggerService.cyan( - `#${page}` - )} ${LoggerService.green(' processed.')}` - ); + issueLogger.info( + `A start date was specified for the ${getHumanizedDate( + startDate + )} (${LoggerService.cyan(this.options.startDate)})` + ); - // Do the next batch - return this.processIssues(page + 1); - } + // Expecting that GitHub will always set a creation date on the issues and PRs + // But you never know! + if (!isValidDate(createdAt)) { + IssuesProcessor._endIssueProcessing(issue); + core.setFailed( + new Error(`Invalid issue field: "created_at". Expected a valid date`) + ); + } - async processIssue( - issue: Issue, - labelsToAddWhenUnstale: Readonly[], - labelsToRemoveWhenUnstale: Readonly[], - labelsToRemoveWhenStale: Readonly[], - labelsToAddWhenUnrotten: Readonly[], - labelsToRemoveWhenUnrotten: Readonly[], - labelsToRemoveWhenRotten: Readonly[] - ): Promise { - this.statistics?.incrementProcessedItemsCount(issue); + issueLogger.info( + `$$type created the ${getHumanizedDate( + createdAt + )} (${LoggerService.cyan(issue.created_at)})` + ); - const issueLogger: IssueLogger = new IssueLogger(issue); + if (!isDateMoreRecentThan(createdAt, startDate)) { issueLogger.info( - `Found this $$type last updated at: ${LoggerService.cyan( - issue.updated_at - )}` + `Skipping this $$type because it was created before the specified start date` ); - // calculate string based messages for this issue - 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; - const skipMessage = issue.isPullRequest - ? this.options.stalePrMessage.length === 0 - : this.options.staleIssueMessage.length === 0; - const daysBeforeStale: number = issue.isPullRequest - ? this._getDaysBeforePrStale() - : this._getDaysBeforeIssueStale(); - - - if (issue.state === 'closed') { - issueLogger.info(`Skipping this $$type because it is closed`); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process closed issues - } - - if (issue.locked) { - issueLogger.info(`Skipping this $$type because it is locked`); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process locked issues - } + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process issues which were created before the start date + } + } - if (this._isIncludeOnlyAssigned(issue)) { - issueLogger.info( - `Skipping this $$type because its assignees list is empty` - ); - IssuesProcessor._endIssueProcessing(issue); - return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list - } + // 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 onlyLabels: string[] = wordsToList(this._getOnlyLabels(issue)); + const exemptLabels: string[] = wordsToList( + issue.isPullRequest + ? this.options.exemptPrLabels + : this.options.exemptIssueLabels + ); + + const hasExemptLabel = exemptLabels.some((exemptLabel: Readonly) => + isLabeled(issue, exemptLabel) + ); + + if (hasExemptLabel) { + issueLogger.info( + `Skipping this $$type because it contains an exempt label, see ${issueLogger.createOptionLink( + issue.isPullRequest ? Option.ExemptPrLabels : Option.ExemptIssueLabels + )} for more details` + ); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt issues + } - if (onlyLabels.length > 0) { - issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.OnlyLabels - )} was specified to only process issues and pull requests with all those labels (${LoggerService.cyan( - onlyLabels.length - )})` - ); + const anyOfLabels: string[] = wordsToList(this._getAnyOfLabels(issue)); - const hasAllWhitelistedLabels: boolean = onlyLabels.every( - (label: Readonly): boolean => { - return isLabeled(issue, label); - } - ); + if (anyOfLabels.length > 0) { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.AnyOfLabels + )} was specified to only process the issues and pull requests with one of those labels (${LoggerService.cyan( + anyOfLabels.length + )})` + ); - if (!hasAllWhitelistedLabels) { - issueLogger.info( - LoggerService.white('└──'), - `Skipping this $$type because it doesn't have all the required labels` - ); - - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process issues without all of the required labels - } else { - issueLogger.info( - LoggerService.white('├──'), - `All the required labels are present on this $$type` - ); - issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` - ); - } - } else { - issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.OnlyLabels - )} was not specified` - ); - issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` - ); + const hasOneOfWhitelistedLabels: boolean = anyOfLabels.some( + (label: Readonly): boolean => { + return isLabeled(issue, label); } + ); + if (!hasOneOfWhitelistedLabels) { issueLogger.info( - `Days before $$type stale: ${LoggerService.cyan(daysBeforeStale)}` + LoggerService.white('└──'), + `Skipping this $$type because it doesn't have one of the required labels` ); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process issues without any of the required labels + } else { + issueLogger.info( + LoggerService.white('├──'), + `One of the required labels is present on this $$type` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); + } + } else { + issueLogger.info( + `The option ${issueLogger.createOptionLink( + Option.AnyOfLabels + )} was not specified` + ); + issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type` + ); + } - const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale); - - // Try to remove the close label when not close/locked issue or PR - await this._removeCloseLabel(issue, closeLabel); - - if (this.options.startDate) { - const startDate: Date = new Date(this.options.startDate); - const createdAt: Date = new Date(issue.created_at); - - issueLogger.info( - `A start date was specified for the ${getHumanizedDate( - startDate - )} (${LoggerService.cyan(this.options.startDate)})` - ); - - // Expecting that GitHub will always set a creation date on the issues and PRs - // But you never know! - if (!isValidDate(createdAt)) { - IssuesProcessor._endIssueProcessing(issue); - core.setFailed( - new Error(`Invalid issue field: "created_at". Expected a valid date`) - ); - } + const milestones: Milestones = new Milestones(this.options, issue); - issueLogger.info( - `$$type created the ${getHumanizedDate( - createdAt - )} (${LoggerService.cyan(issue.created_at)})` - ); + if (milestones.shouldExemptMilestones()) { + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt milestones + } - if (!isDateMoreRecentThan(createdAt, startDate)) { - issueLogger.info( - `Skipping this $$type because it was created before the specified start date` - ); + const assignees: Assignees = new Assignees(this.options, issue); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process issues which were created before the start date - } - } + if (assignees.shouldExemptAssignees()) { + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process exempt assignees + } + // Ignore draft PR + // Note that this check is so far below because it cost one read operation + // So it's simply better to do all the stale checks which don't cost more operation before this one + const exemptDraftPullRequest: ExemptDraftPullRequest = + new ExemptDraftPullRequest(this.options, issue); - // 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`); - } + if ( + await exemptDraftPullRequest.shouldExemptDraftPullRequest( + async (): Promise => { + return this.getPullRequest(issue); } + ) + ) { + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process draft PR + } - const exemptLabels: string[] = wordsToList( - issue.isPullRequest - ? this.options.exemptPrLabels - : this.options.exemptIssueLabels - ); - - const hasExemptLabel = exemptLabels.some((exemptLabel: Readonly) => - isLabeled(issue, exemptLabel) + // 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`); + + if (issue.isRotten) { + await this._processRottenIssue( + issue, + rottenLabel, + rottenMessage, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + closeMessage, + closeLabel ); + } else { + 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 (hasExemptLabel) { + if (shouldBeStale) { + if (shouldIgnoreUpdates) { issueLogger.info( - `Skipping this $$type because it contains an exempt label, see ${issueLogger.createOptionLink( - issue.isPullRequest ? Option.ExemptPrLabels : Option.ExemptIssueLabels - )} for more details` + `This $$type should be stale based on the creation date the ${getHumanizedDate( + new Date(issue.created_at) + )} (${LoggerService.cyan(issue.created_at)})` ); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process exempt issues - } - - const anyOfLabels: string[] = wordsToList(this._getAnyOfLabels(issue)); - - if (anyOfLabels.length > 0) { + } else { issueLogger.info( - `The option ${issueLogger.createOptionLink( - Option.AnyOfLabels - )} was specified to only process the issues and pull requests with one of those labels (${LoggerService.cyan( - anyOfLabels.length - )})` + `This $$type should be stale based on the last update date the ${getHumanizedDate( + new Date(issue.updated_at) + )} (${LoggerService.cyan(issue.updated_at)})` ); + } - const hasOneOfWhitelistedLabels: boolean = anyOfLabels.some( - (label: Readonly): boolean => { - return isLabeled(issue, label); - } + if (shouldMarkAsStale) { + issueLogger.info( + `This $$type should be marked as stale based on the option ${issueLogger.createOptionLink( + this._getDaysBeforeStaleUsedOptionName(issue) + )} (${LoggerService.cyan(daysBeforeStale)})` ); - - if (!hasOneOfWhitelistedLabels) { - issueLogger.info( - LoggerService.white('└──'), - `Skipping this $$type because it doesn't have one of the required labels` - ); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process issues without any of the required labels - } else { - issueLogger.info( - LoggerService.white('├──'), - `One of the required labels is present on this $$type` - ); - issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` - ); - } - } else { + 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( - `The option ${issueLogger.createOptionLink( - Option.AnyOfLabels - )} was not specified` + `This $$type should not be marked as stale based on the option ${issueLogger.createOptionLink( + this._getDaysBeforeStaleUsedOptionName(issue) + )} (${LoggerService.cyan(daysBeforeStale)})` ); + } + } else { + if (shouldIgnoreUpdates) { issueLogger.info( - LoggerService.white('└──'), - `Continuing the process for this $$type` + `This $$type should not be stale based on the creation date the ${getHumanizedDate( + new Date(issue.created_at) + )} (${LoggerService.cyan(issue.created_at)})` ); - } - - const milestones: Milestones = new Milestones(this.options, issue); - - if (milestones.shouldExemptMilestones()) { - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process exempt milestones - } - - const assignees: Assignees = new Assignees(this.options, issue); - - if (assignees.shouldExemptAssignees()) { - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process exempt assignees - } - - // Ignore draft PR - // Note that this check is so far below because it cost one read operation - // So it's simply better to do all the stale checks which don't cost more operation before this one - const exemptDraftPullRequest: ExemptDraftPullRequest = - new ExemptDraftPullRequest(this.options, issue); - - if ( - await exemptDraftPullRequest.shouldExemptDraftPullRequest( - async (): Promise => { - return this.getPullRequest(issue); - } - ) - ) { - 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`); - - if (issue.isRotten) { - await this._processRottenIssue( - issue, - rottenLabel, - rottenMessage, - labelsToAddWhenUnrotten, - labelsToRemoveWhenUnrotten, - labelsToRemoveWhenRotten, - closeMessage, - closeLabel - ); - } - else { - 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 (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 { - 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)})` - ); - } - } - } - } - - // Process the issue if it was marked stale - if (issue.isStale) { - issueLogger.info(`This $$type is already stale`); - await this._processStaleIssue( - issue, - staleLabel, - staleMessage, - rottenLabel, - rottenMessage, - closeLabel, - closeMessage, - labelsToAddWhenUnstale, - labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale, - labelsToAddWhenUnrotten, - labelsToRemoveWhenUnrotten, - labelsToRemoveWhenRotten, - skipRottenMessage, + } 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)})` ); + } } - - IssuesProcessor._endIssueProcessing(issue); + } } - // Grab comments for an issue since a given date - async listIssueComments( - issue: Readonly, - sinceDate: Readonly - ): Promise { - // Find any comments since date on the given issue - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementFetchedItemsCommentsCount(); - const comments = await this.client.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - since: sinceDate - }); - return comments.data; - } catch (error) { - this._logger.error(`List issue comments error: ${error.message}`); - return Promise.resolve([]); - } + // Process the issue if it was marked stale + if (issue.isStale) { + issueLogger.info(`This $$type is already stale`); + await this._processStaleIssue( + issue, + staleLabel, + staleMessage, + rottenLabel, + rottenMessage, + closeLabel, + closeMessage, + labelsToAddWhenUnstale, + labelsToRemoveWhenUnstale, + labelsToRemoveWhenStale, + labelsToAddWhenUnrotten, + labelsToRemoveWhenUnrotten, + labelsToRemoveWhenRotten, + skipRottenMessage + ); } - // grab issues from github in batches of 100 - async getIssues(page: number): Promise { - try { - this.operations.consumeOperation(); - const issueResult = await this.client.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100, - direction: this.options.ascending ? 'asc' : 'desc', - page - }); - this.statistics?.incrementFetchedItemsCount(issueResult.data.length); - - return issueResult.data.map( - (issue): Issue => - new Issue(this.options, issue as Readonly) - ); - } catch (error) { - throw Error(`Getting issues was blocked by the error: ${error.message}`); - } + IssuesProcessor._endIssueProcessing(issue); + } + + // Grab comments for an issue since a given date + async listIssueComments( + issue: Readonly, + sinceDate: Readonly + ): Promise { + // Find any comments since date on the given issue + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedItemsCommentsCount(); + const comments = await this.client.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + since: sinceDate + }); + return comments.data; + } catch (error) { + this._logger.error(`List issue comments error: ${error.message}`); + return Promise.resolve([]); + } + } + + // grab issues from github in batches of 100 + async getIssues(page: number): Promise { + try { + this.operations.consumeOperation(); + const issueResult = await this.client.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + direction: this.options.ascending ? 'asc' : 'desc', + page + }); + this.statistics?.incrementFetchedItemsCount(issueResult.data.length); + + return issueResult.data.map( + (issue): Issue => + new Issue(this.options, issue as Readonly) + ); + } catch (error) { + throw Error(`Getting issues was blocked by the error: ${error.message}`); + } + } + + // returns the creation date of a given label on an issue (or nothing if no label existed) + ///see https://developer.github.com/v3/activity/events/ + async getLabelCreationDate( + issue: Issue, + label: string + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Checking for label on this $$type`); + + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedItemsEventsCount(); + const options = this.client.rest.issues.listEvents.endpoint.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + issue_number: issue.number + }); + + const events: IIssueEvent[] = await this.client.paginate(options); + const reversedEvents = events.reverse(); + + const staleLabeledEvent = reversedEvents.find( + event => + event.event === 'labeled' && + cleanLabel(event.label.name) === cleanLabel(label) + ); + + if (!staleLabeledEvent) { + // Must be old rather than labeled + return undefined; } - // returns the creation date of a given label on an issue (or nothing if no label existed) - ///see https://developer.github.com/v3/activity/events/ - async getLabelCreationDate( - issue: Issue, - label: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + return staleLabeledEvent.created_at; + } - issueLogger.info(`Checking for label on this $$type`); + async getPullRequest(issue: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); - this._consumeIssueOperation(issue); - this.statistics?.incrementFetchedItemsEventsCount(); - const options = this.client.rest.issues.listEvents.endpoint.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - issue_number: issue.number - }); + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementFetchedPullRequestsCount(); - const events: IIssueEvent[] = await this.client.paginate(options); - const reversedEvents = events.reverse(); + const pullRequest = await this.client.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issue.number + }); - const staleLabeledEvent = reversedEvents.find( - event => - event.event === 'labeled' && - cleanLabel(event.label.name) === cleanLabel(label) - ); - - if (!staleLabeledEvent) { - // Must be old rather than labeled - return undefined; - } - - return staleLabeledEvent.created_at; + return pullRequest.data; + } catch (error) { + issueLogger.error(`Error when getting this $$type: ${error.message}`); } - - async getPullRequest(issue: Issue): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementFetchedPullRequestsCount(); - - const pullRequest = await this.client.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: issue.number - }); - - return pullRequest.data; - } catch (error) { - issueLogger.error(`Error when getting this $$type: ${error.message}`); - } + } + + async getRateLimit(): Promise { + const logger: Logger = new Logger(); + try { + const rateLimitResult = await this.client.rest.rateLimit.get(); + return new RateLimit(rateLimitResult.data.rate); + } catch (error) { + logger.error(`Error when getting rateLimit: ${error.message}`); + } + } + + // 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[], + 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( + `$$type marked stale on: ${LoggerService.cyan(markedStaleOn)}` + ); + + const issueHasCommentsSinceStale: boolean = await this._hasCommentsSince( + issue, + markedStaleOn, + staleMessage + ); + issueLogger.info( + `$$type has been commented on: ${LoggerService.cyan( + issueHasCommentsSinceStale + )}` + ); + + const daysBeforeRotten: number = issue.isPullRequest + ? this._getDaysBeforePrRotten() + : this._getDaysBeforeIssueRotten(); + + const daysBeforeClose: number = issue.isPullRequest + ? this._getDaysBeforePrClose() + : this._getDaysBeforeIssueClose(); + issueLogger.info( + `Days before $$type rotten: ${LoggerService.cyan(daysBeforeRotten)}` + ); + + const shouldRemoveStaleWhenUpdated: boolean = + this._shouldRemoveStaleWhenUpdated(issue); + + issueLogger.info( + `The option ${issueLogger.createOptionLink( + this._getRemoveStaleWhenUpdatedUsedOptionName(issue) + )} is: ${LoggerService.cyan(shouldRemoveStaleWhenUpdated)}` + ); + + if (shouldRemoveStaleWhenUpdated) { + issueLogger.info(`The stale label should not be removed`); + } else { + issueLogger.info( + `The stale label should be removed if all conditions met` + ); } - async getRateLimit(): Promise { - const logger: Logger = new Logger(); - try { - const rateLimitResult = await this.client.rest.rateLimit.get(); - return new RateLimit(rateLimitResult.data.rate); - } catch (error) { - logger.error(`Error when getting rateLimit: ${error.message}`); - } + // 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( + issue, + labelsToRemoveWhenStale, + Option.LabelsToRemoveWhenStale + ); } - // 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[], - labelsToAddWhenUnrotten: Readonly[], - labelsToRemoveWhenUnrotten: Readonly[], - labelsToRemoveWhenRotten: Readonly[], - skipMessage: boolean + // The issue.updated_at and markedStaleOn 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 issueHasUpdateSinceStale = isDateMoreRecentThan( + new Date(issue.updated_at), + new Date(markedStaleOn), + 15 + ); + + issueLogger.info( + `$$type has been updated since it was marked stale: ${LoggerService.cyan( + issueHasUpdateSinceStale + )}` + ); + + // Should we un-stale this issue? + if ( + shouldRemoveStaleWhenUpdated && + (issueHasUpdateSinceStale || issueHasCommentsSinceStale) && + !issue.markedStaleThisRun ) { - const issueLogger: IssueLogger = new IssueLogger(issue); - - var issueHasClosed: boolean = false + issueLogger.info( + `Remove the stale label since the $$type has been updated and the workflow should remove the stale label when updated` + ); + await this._removeStaleLabel(issue, staleLabel); + + // Are there labels to remove or add when an issue is no longer stale? + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenUnstale, + Option.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 + } - // We can get the label creation date from the getLableCreationDate function - const markedStaleOn: string = - (await this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; + if (daysBeforeRotten < 0) { + if (daysBeforeClose < 0) { issueLogger.info( - `$$type marked stale on: ${LoggerService.cyan(markedStaleOn)}` - ); - - const issueHasCommentsSinceStale: boolean = await this._hasCommentsSince( - issue, - markedStaleOn, - staleMessage + `Stale $$type cannot be rotten or closed because days before rotten: ${daysBeforeRotten}, and days before close: ${daysBeforeClose}` ); + return; + } else { issueLogger.info( - `$$type has been commented on: ${LoggerService.cyan( - issueHasCommentsSinceStale - )}` + `Closing issue without rottening it because days before $$type rotten: ${LoggerService.cyan( + daysBeforeRotten + )}` ); - const daysBeforeRotten: number = issue.isPullRequest - ? this._getDaysBeforePrRotten() - : this._getDaysBeforeIssueRotten(); - - const daysBeforeClose: number = issue.isPullRequest - ? this._getDaysBeforePrClose() - : this._getDaysBeforeIssueClose(); + const issueHasUpdateInCloseWindow: boolean = + IssuesProcessor._updatedSince(issue.updated_at, daysBeforeClose); issueLogger.info( - `Days before $$type rotten: ${LoggerService.cyan(daysBeforeRotten)}` + `$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan( + issueHasUpdateInCloseWindow + )}` ); - - const shouldRemoveStaleWhenUpdated: boolean = - this._shouldRemoveStaleWhenUpdated(issue); - - issueLogger.info( - `The option ${issueLogger.createOptionLink( - this._getRemoveStaleWhenUpdatedUsedOptionName(issue) - )} is: ${LoggerService.cyan(shouldRemoveStaleWhenUpdated)}` - ); - - if (shouldRemoveStaleWhenUpdated) { - issueLogger.info(`The stale label should not be removed`); - } 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`); - await this._removeLabelsOnStatusTransition( - issue, - labelsToRemoveWhenStale, - Option.LabelsToRemoveWhenStale - ); - } - - // The issue.updated_at and markedStaleOn 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 issueHasUpdateSinceStale = isDateMoreRecentThan( - new Date(issue.updated_at), - new Date(markedStaleOn), - 15 - ); - - issueLogger.info( - `$$type has been updated since it was marked stale: ${LoggerService.cyan( - issueHasUpdateSinceStale + if (!issueHasUpdateInCloseWindow && !issueHasCommentsSinceStale) { + issueLogger.info( + `Closing $$type because it was last updated on: ${LoggerService.cyan( + issue.updated_at )}` - ); - - // Should we un-stale this issue? - if ( - shouldRemoveStaleWhenUpdated && - (issueHasUpdateSinceStale || issueHasCommentsSinceStale) && - !issue.markedStaleThisRun - ) { - issueLogger.info( - `Remove the stale label since the $$type has been updated and the workflow should remove the stale label when updated` - ); - await this._removeStaleLabel(issue, staleLabel); - - // Are there labels to remove or add when an issue is no longer stale? - await this._removeLabelsOnStatusTransition( - issue, - labelsToRemoveWhenUnstale, - Option.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 - } - - 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)}` - ); - - let issueHasUpdateInCloseWindow: boolean - issueHasUpdateInCloseWindow = 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})` - ); - } - } - } + ); + await this._closeIssue(issue, closeMessage, closeLabel); - // TODO: make a function for shouldMarkWhenRotten - const shouldMarkAsRotten: boolean = shouldMarkWhenStale(daysBeforeRotten); + issueHasClosed = true; - if (issueHasClosed) { + if (this.options.deleteBranch && issue.pull_request) { issueLogger.info( - `Issue $$type has been closed, no need to process it further.` + `Deleting the branch since the option ${issueLogger.createOptionLink( + Option.DeleteBranch + )} is enabled` ); - return; + await this._deleteBranch(issue); + this.deletedBranchIssues.push(issue); + } + } else { + issueLogger.info( + `Stale $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceStale}, hasUpdate? ${issueHasUpdateInCloseWindow})` + ); } + } + } - if (!issue.isRotten) { - issueLogger.info(`This $$type is not rotten`); - - const shouldIgnoreUpdates: boolean = new IgnoreUpdates( - this.options, - issue - ).shouldIgnoreUpdates(); - - let shouldBeRotten: boolean; - shouldBeRotten = !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)})` - ); - 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, - ) - } + // 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; } - 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( + 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)})` + ); + await this._markRotten( 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`); + 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( - `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 - ); + 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` + ); + } - // 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`); + if (issue.markedRottenThisRun) { + issueLogger.info(`marked rotten this run, so don't check for updates`); + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenRotten, + Option.LabelsToRemoveWhenRotten + ); + } - return; // Nothing to do because it is no longer rotten - } + // 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 rotten issues - } + // Now start closing logic + if (daysBeforeClose < 0) { + return; // Nothing to do because we aren't closing rotten issues + } - const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince( - issue.updated_at, - daysBeforeClose - ); + const issueHasUpdateInCloseWindow: boolean = IssuesProcessor._updatedSince( + issue.updated_at, + daysBeforeClose + ); + issueLogger.info( + `$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan( + issueHasUpdateInCloseWindow + )}` + ); + + if (!issueHasCommentsSinceRotten && !issueHasUpdateInCloseWindow) { + issueLogger.info( + `Closing $$type because it was last updated on: ${LoggerService.cyan( + issue.updated_at + )}` + ); + await this._closeIssue(issue, closeMessage, closeLabel); + + if (this.options.deleteBranch && issue.pull_request) { issueLogger.info( - `$$type has been updated in the last ${daysBeforeClose} days: ${LoggerService.cyan( - issueHasUpdateInCloseWindow - )}` + `Deleting the branch since the option ${issueLogger.createOptionLink( + Option.DeleteBranch + )} is enabled` ); + await this._deleteBranch(issue); + this.deletedBranchIssues.push(issue); + } + } else { + issueLogger.info( + `Rotten $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceRotten}, hasUpdate? ${issueHasUpdateInCloseWindow})` + ); + } + } + + // checks to see if a given issue is still stale (has had activity on it) + private async _hasCommentsSince( + issue: Issue, + sinceDate: string, + staleMessage: string + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `Checking for comments on $$type since: ${LoggerService.cyan(sinceDate)}` + ); + + if (!sinceDate) { + return true; + } - if (!issueHasCommentsSinceRotten && !issueHasUpdateInCloseWindow) { - issueLogger.info( - `Closing $$type because it was last updated on: ${LoggerService.cyan( - issue.updated_at - )}` - ); - await this._closeIssue(issue, closeMessage, closeLabel); - - 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( - `Rotten $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceRotten}, hasUpdate? ${issueHasUpdateInCloseWindow})` - ); + // find any comments since the date + const comments = await this.listIssueComments(issue, sinceDate); + + const filteredComments = comments.filter( + comment => + comment.user?.type === 'User' && + comment.body?.toLowerCase() !== staleMessage.toLowerCase() + ); + + issueLogger.info( + `Comments that are not the stale comment or another bot: ${LoggerService.cyan( + filteredComments.length + )}` + ); + + // if there are any user comments returned + return filteredComments.length > 0; + } + + // Mark an issue as stale with a comment and a label + private async _markStale( + issue: Issue, + staleMessage: string, + staleLabel: string, + skipMessage: boolean + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Marking this $$type as stale`); + this.staleIssues.push(issue); + + // if the issue is being marked stale, 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: staleMessage + }); } + } catch (error) { + issueLogger.error(`Error when creating a comment: ${error.message}`); + } } - // checks to see if a given issue is still stale (has had activity on it) - private async _hasCommentsSince( - issue: Issue, - sinceDate: string, - staleMessage: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `Checking for comments on $$type since: ${LoggerService.cyan(sinceDate)}` - ); + 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: [staleLabel] + }); + } + } catch (error) { + 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 (!sinceDate) { - return true; + 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}`); + } + } - // find any comments since the date - const comments = await this.listIssueComments(issue, sinceDate); - - const filteredComments = comments.filter( - comment => - comment.user?.type === 'User' && - comment.body?.toLowerCase() !== staleMessage.toLowerCase() - ); + 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}`); + } + } - issueLogger.info( - `Comments that are not the stale comment or another bot: ${LoggerService.cyan( - filteredComments.length - )}` - ); + // Close an issue based on staleness + private async _closeIssue( + issue: Issue, + closeMessage?: string, + closeLabel?: string + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); - // if there are any user comments returned - return filteredComments.length > 0; - } - - // Mark an issue as stale with a comment and a label - private async _markStale( - issue: Issue, - staleMessage: string, - staleLabel: string, - skipMessage: boolean - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info(`Marking this $$type as stale`); - this.staleIssues.push(issue); - - // if the issue is being marked stale, 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: staleMessage - }); - } - } catch (error) { - issueLogger.error(`Error when creating a comment: ${error.message}`); - } - } + issueLogger.info(`Closing $$type for being stale/rotten`); + this.closedIssues.push(issue); - 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: [staleLabel] - }); - } - } catch (error) { - 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}`); - } - } + if (closeMessage) { + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsComment(issue); + this.addedCloseCommentIssues.push(issue); - 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}`); + if (!this.options.debugOnly) { + await this.client.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: closeMessage + }); } + } catch (error) { + issueLogger.error(`Error when creating a comment: ${error.message}`); + } } + if (closeLabel) { + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsLabel(issue); - // Close an issue based on staleness - private async _closeIssue( - issue: Issue, - closeMessage?: string, - closeLabel?: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info(`Closing $$type for being stale/rotten`); - this.closedIssues.push(issue); - - if (closeMessage) { - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementAddedItemsComment(issue); - this.addedCloseCommentIssues.push(issue); - - if (!this.options.debugOnly) { - await this.client.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - body: closeMessage - }); - } - } catch (error) { - issueLogger.error(`Error when creating a comment: ${error.message}`); - } - } - - if (closeLabel) { - 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: [closeLabel] - }); - } - } catch (error) { - issueLogger.error(`Error when adding a label: ${error.message}`); - } + if (!this.options.debugOnly) { + await this.client.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [closeLabel] + }); } + } catch (error) { + issueLogger.error(`Error when adding a label: ${error.message}`); + } + } - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementClosedItemsCount(issue); - - if (!this.options.debugOnly) { - await this.client.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - state: 'closed', - state_reason: this.options.closeIssueReason || undefined - }); - } - } catch (error) { - issueLogger.error(`Error when updating this $$type: ${error.message}`); - } + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementClosedItemsCount(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + state: 'closed', + state_reason: this.options.closeIssueReason || undefined + }); + } + } catch (error) { + issueLogger.error(`Error when updating this $$type: ${error.message}`); } + } - // Delete the branch on closed pull request - private async _deleteBranch(issue: Issue): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + // Delete the branch on closed pull request + private async _deleteBranch(issue: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); - issueLogger.info(`Delete + issueLogger.info(`Delete branch from closed $ $type - ${issue.title}`); - const pullRequest: IPullRequest | undefined | void = - await this.getPullRequest(issue); - - if (!pullRequest) { - issueLogger.info( - `Not deleting this branch as no pull request was found for this $$type` - ); - return; - } - - const branch = pullRequest.head.ref; + const pullRequest: IPullRequest | undefined | void = + await this.getPullRequest(issue); - if ( - pullRequest.head.repo === null || - pullRequest.head.repo.full_name === - `${context.repo.owner}/${context.repo.repo}` - ) { - issueLogger.info( - `Deleting the branch "${LoggerService.cyan(branch)}" from closed $$type` - ); - - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementDeletedBranchesCount(); - - if (!this.options.debugOnly) { - await this.client.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `heads/${branch}` - }); - } - } catch (error) { - issueLogger.error( - `Error when deleting the branch "${LoggerService.cyan( - branch - )}" from $$type: ${error.message}` - ); - } - } else { - issueLogger.warning( - `Deleting the branch "${LoggerService.cyan( - branch - )}" has skipped because it belongs to other repo ${pullRequest.head.repo.full_name - }` - ); - } + if (!pullRequest) { + issueLogger.info( + `Not deleting this branch as no pull request was found for this $$type` + ); + return; } - // Remove a label from an issue or a pull request - private async _removeLabel( - issue: Issue, - label: string, - isSubStep: Readonly = false - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `${isSubStep ? LoggerService.white('├── ') : '' - }Removing the label "${LoggerService.cyan(label)}" from this $$type...` - ); - this.removedLabelIssues.push(issue); - - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementDeletedItemsLabelsCount(issue); - - if (!this.options.debugOnly) { - await this.client.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issue.number, - name: label - }); - } + const branch = pullRequest.head.ref; - issueLogger.info( - `${isSubStep ? LoggerService.white('└── ') : '' - }The label "${LoggerService.cyan(label)}" was removed` - ); - } catch (error) { - issueLogger.error( - `${isSubStep ? LoggerService.white('└── ') : '' - }Error when removing the label: "${LoggerService.cyan(error.message)}"` - ); - } - } + if ( + pullRequest.head.repo === null || + pullRequest.head.repo.full_name === + `${context.repo.owner}/${context.repo.repo}` + ) { + issueLogger.info( + `Deleting the branch "${LoggerService.cyan(branch)}" from closed $$type` + ); - private _getDaysBeforeIssueStale(): number { - return isNaN(this.options.daysBeforeIssueStale) - ? this.options.daysBeforeStale - : this.options.daysBeforeIssueStale; - } + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementDeletedBranchesCount(); - private _getDaysBeforePrStale(): number { - return isNaN(this.options.daysBeforePrStale) - ? this.options.daysBeforeStale - : this.options.daysBeforePrStale; - } - private _getDaysBeforeIssueRotten(): number { - return isNaN(this.options.daysBeforeIssueRotten) - ? this.options.daysBeforeRotten - : this.options.daysBeforeIssueRotten; + if (!this.options.debugOnly) { + await this.client.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branch}` + }); + } + } catch (error) { + issueLogger.error( + `Error when deleting the branch "${LoggerService.cyan( + branch + )}" from $$type: ${error.message}` + ); + } + } else { + issueLogger.warning( + `Deleting the branch "${LoggerService.cyan( + branch + )}" has skipped because it belongs to other repo ${ + pullRequest.head.repo.full_name + }` + ); } - - private _getDaysBeforePrRotten(): number { - return isNaN(this.options.daysBeforePrRotten) - ? this.options.daysBeforeRotten - : this.options.daysBeforePrRotten; + } + + // Remove a label from an issue or a pull request + private async _removeLabel( + issue: Issue, + label: string, + isSubStep: Readonly = false + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `${ + isSubStep ? LoggerService.white('├── ') : '' + }Removing the label "${LoggerService.cyan(label)}" from this $$type...` + ); + this.removedLabelIssues.push(issue); + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementDeletedItemsLabelsCount(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + name: label + }); + } + + issueLogger.info( + `${ + isSubStep ? LoggerService.white('└── ') : '' + }The label "${LoggerService.cyan(label)}" was removed` + ); + } catch (error) { + issueLogger.error( + `${ + isSubStep ? LoggerService.white('└── ') : '' + }Error when removing the label: "${LoggerService.cyan(error.message)}"` + ); } - - private _getDaysBeforeIssueClose(): number { - return isNaN(this.options.daysBeforeIssueClose) - ? this.options.daysBeforeClose - : this.options.daysBeforeIssueClose; + } + + private _getDaysBeforeIssueStale(): number { + return isNaN(this.options.daysBeforeIssueStale) + ? this.options.daysBeforeStale + : this.options.daysBeforeIssueStale; + } + + private _getDaysBeforePrStale(): number { + return isNaN(this.options.daysBeforePrStale) + ? 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) + ? this.options.daysBeforeClose + : this.options.daysBeforeIssueClose; + } + + private _getDaysBeforePrClose(): number { + return isNaN(this.options.daysBeforePrClose) + ? this.options.daysBeforeClose + : this.options.daysBeforePrClose; + } + + private _getOnlyLabels(issue: Issue): string { + if (issue.isPullRequest) { + if (this.options.onlyPrLabels !== '') { + return this.options.onlyPrLabels; + } + } else { + if (this.options.onlyIssueLabels !== '') { + return this.options.onlyIssueLabels; + } } - private _getDaysBeforePrClose(): number { - return isNaN(this.options.daysBeforePrClose) - ? this.options.daysBeforeClose - : this.options.daysBeforePrClose; + return this.options.onlyLabels; + } + + private _isIncludeOnlyAssigned(issue: Issue): boolean { + return this.options.includeOnlyAssigned && !issue.hasAssignees; + } + + private _getAnyOfLabels(issue: Issue): string { + if (issue.isPullRequest) { + if (this.options.anyOfPrLabels !== '') { + return this.options.anyOfPrLabels; + } + } else { + if (this.options.anyOfIssueLabels !== '') { + return this.options.anyOfIssueLabels; + } } + return this.options.anyOfLabels; + } - private _getOnlyLabels(issue: Issue): string { - if (issue.isPullRequest) { - if (this.options.onlyPrLabels !== '') { - return this.options.onlyPrLabels; - } - } else { - if (this.options.onlyIssueLabels !== '') { - return this.options.onlyIssueLabels; - } - } + private _shouldRemoveStaleWhenUpdated(issue: Issue): boolean { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrStaleWhenUpdated)) { + return this.options.removePrStaleWhenUpdated; + } - return this.options.onlyLabels; + return this.options.removeStaleWhenUpdated; } - private _isIncludeOnlyAssigned(issue: Issue): boolean { - return this.options.includeOnlyAssigned && !issue.hasAssignees; + if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { + return this.options.removeIssueStaleWhenUpdated; } - private _getAnyOfLabels(issue: Issue): string { - if (issue.isPullRequest) { - if (this.options.anyOfPrLabels !== '') { - return this.options.anyOfPrLabels; - } - } else { - if (this.options.anyOfIssueLabels !== '') { - return this.options.anyOfIssueLabels; - } - } + return this.options.removeStaleWhenUpdated; + } + private _shouldRemoveRottenWhenUpdated(issue: Issue): boolean { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrRottenWhenUpdated)) { + return this.options.removePrRottenWhenUpdated; + } - return this.options.anyOfLabels; + return this.options.removeRottenWhenUpdated; } - private _shouldRemoveStaleWhenUpdated(issue: Issue): boolean { - if (issue.isPullRequest) { - if (isBoolean(this.options.removePrStaleWhenUpdated)) { - return this.options.removePrStaleWhenUpdated; - } - - return this.options.removeStaleWhenUpdated; - } - - if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { - return this.options.removeIssueStaleWhenUpdated; - } - - return this.options.removeStaleWhenUpdated; + if (isBoolean(this.options.removeIssueRottenWhenUpdated)) { + return this.options.removeIssueRottenWhenUpdated; } - 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; + } - return this.options.removeRottenWhenUpdated; + private async _removeLabelsOnStatusTransition( + issue: Issue, + removeLabels: Readonly[], + staleStatus: Option + ): Promise { + if (!removeLabels.length) { + return; } - private async _removeLabelsOnStatusTransition( - issue: Issue, - removeLabels: Readonly[], - staleStatus: Option - ): Promise { - if (!removeLabels.length) { - return; - } + const issueLogger: IssueLogger = new IssueLogger(issue); - const issueLogger: IssueLogger = new IssueLogger(issue); + issueLogger.info( + `Removing all the labels specified via the ${this._logger.createOptionLink( + staleStatus + )} option.` + ); - issueLogger.info( - `Removing all the labels specified via the ${this._logger.createOptionLink( - staleStatus - )} option.` - ); - - for (const label of removeLabels.values()) { - await this._removeLabel(issue, label); - } + 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._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 stale: ${error.message}` - ); - } + } + + private async _addLabelsWhenUnstale( + issue: Issue, + labelsToAdd: Readonly[] + ): Promise { + if (!labelsToAdd.length) { + return; } - 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}` - ); - } + 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._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 stale: ${error.message}` + ); } - private async _removeStaleLabel( - issue: Issue, - staleLabel: Readonly - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `The $$type is no longer stale. Removing the stale label...` - ); - - await this._removeLabel(issue, staleLabel); - this.statistics?.incrementUndoStaleItemsCount(issue); + } + + private async _addLabelsWhenUnrotten( + issue: Issue, + labelsToAdd: Readonly[] + ): Promise { + if (!labelsToAdd.length) { + return; } - 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); + 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 _removeCloseLabel( - issue: Issue, - closeLabel: Readonly - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); - - issueLogger.info( - `The $$type is not closed nor locked. Trying to remove the close label...` - ); - - if (!closeLabel) { - issueLogger.info( - LoggerService.white('├──'), - `The ${issueLogger.createOptionLink( - IssuesProcessor._getCloseLabelUsedOptionName(issue) - )} option was not set` - ); - issueLogger.info( - LoggerService.white('└──'), - `Skipping the removal of the close label` - ); - - return Promise.resolve(); - } - - if (isLabeled(issue, closeLabel)) { - issueLogger.info( - LoggerService.white('├──'), - `The $$type has a close label "${LoggerService.cyan( - closeLabel - )}". Removing the close label...` - ); - - await this._removeLabel(issue, closeLabel, true); - this.statistics?.incrementDeletedCloseItemsLabelsCount(issue); - } else { - issueLogger.info( - LoggerService.white('└──'), - `There is no close label on this $$type. Skipping` - ); - - return Promise.resolve(); - } + } + private async _removeStaleLabel( + issue: Issue, + staleLabel: Readonly + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `The $$type is no longer stale. Removing the stale label...` + ); + + 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, + closeLabel: Readonly + ): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `The $$type is not closed nor locked. Trying to remove the close label...` + ); + + if (!closeLabel) { + issueLogger.info( + LoggerService.white('├──'), + `The ${issueLogger.createOptionLink( + IssuesProcessor._getCloseLabelUsedOptionName(issue) + )} option was not set` + ); + issueLogger.info( + LoggerService.white('└──'), + `Skipping the removal of the close label` + ); + + return Promise.resolve(); } - private _consumeIssueOperation(issue: Readonly): void { - this.operations.consumeOperation(); - issue.operations.consumeOperation(); - } - - private _getDaysBeforeStaleUsedOptionName( - issue: Readonly - ): - | Option.DaysBeforeStale - | Option.DaysBeforeIssueStale - | Option.DaysBeforePrStale { - return issue.isPullRequest - ? this._getDaysBeforePrStaleUsedOptionName() - : this._getDaysBeforeIssueStaleUsedOptionName(); - } - - private _getDaysBeforeIssueStaleUsedOptionName(): - | Option.DaysBeforeStale - | Option.DaysBeforeIssueStale { - return isNaN(this.options.daysBeforeIssueStale) - ? Option.DaysBeforeStale - : Option.DaysBeforeIssueStale; - } - - private _getDaysBeforePrStaleUsedOptionName(): - | Option.DaysBeforeStale - | Option.DaysBeforePrStale { - return isNaN(this.options.daysBeforePrStale) - ? Option.DaysBeforeStale - : 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 - ): - | Option.RemovePrStaleWhenUpdated - | Option.RemoveStaleWhenUpdated - | Option.RemoveIssueStaleWhenUpdated { - if (issue.isPullRequest) { - if (isBoolean(this.options.removePrStaleWhenUpdated)) { - return Option.RemovePrStaleWhenUpdated; - } - - return Option.RemoveStaleWhenUpdated; - } - - if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { - return Option.RemoveIssueStaleWhenUpdated; - } - - return Option.RemoveStaleWhenUpdated; + if (isLabeled(issue, closeLabel)) { + issueLogger.info( + LoggerService.white('├──'), + `The $$type has a close label "${LoggerService.cyan( + closeLabel + )}". Removing the close label...` + ); + + await this._removeLabel(issue, closeLabel, true); + this.statistics?.incrementDeletedCloseItemsLabelsCount(issue); + } else { + issueLogger.info( + LoggerService.white('└──'), + `There is no close label on this $$type. Skipping` + ); + + return Promise.resolve(); + } + } + + private _consumeIssueOperation(issue: Readonly): void { + this.operations.consumeOperation(); + issue.operations.consumeOperation(); + } + + private _getDaysBeforeStaleUsedOptionName( + issue: Readonly + ): + | Option.DaysBeforeStale + | Option.DaysBeforeIssueStale + | Option.DaysBeforePrStale { + return issue.isPullRequest + ? this._getDaysBeforePrStaleUsedOptionName() + : this._getDaysBeforeIssueStaleUsedOptionName(); + } + + private _getDaysBeforeIssueStaleUsedOptionName(): + | Option.DaysBeforeStale + | Option.DaysBeforeIssueStale { + return isNaN(this.options.daysBeforeIssueStale) + ? Option.DaysBeforeStale + : Option.DaysBeforeIssueStale; + } + + private _getDaysBeforePrStaleUsedOptionName(): + | Option.DaysBeforeStale + | Option.DaysBeforePrStale { + return isNaN(this.options.daysBeforePrStale) + ? Option.DaysBeforeStale + : 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 + ): + | Option.RemovePrStaleWhenUpdated + | Option.RemoveStaleWhenUpdated + | Option.RemoveIssueStaleWhenUpdated { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrStaleWhenUpdated)) { + return Option.RemovePrStaleWhenUpdated; + } + + 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.removeIssueStaleWhenUpdated)) { + return Option.RemoveIssueStaleWhenUpdated; + } - if (isBoolean(this.options.removeIssueRottenWhenUpdated)) { - return Option.RemoveIssueRottenWhenUpdated; - } + 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; + } - 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 3d1377978..3e6bba3d2 100644 --- a/src/classes/statistics.ts +++ b/src/classes/statistics.ts @@ -80,7 +80,6 @@ export class Statistics { return this._incrementUndoRottenIssuesCount(increment); } - setOperationsCount(operationsCount: Readonly): Statistics { this.operationsCount = operationsCount; diff --git a/src/enums/option.ts b/src/enums/option.ts index 45f466e32..f27ff881b 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -55,8 +55,8 @@ export enum Option { LabelsToRemoveWhenUnstale = 'labels-to-remove-when-unstale', LabelsToAddWhenUnstale = 'labels-to-add-when-unstale', LabelsToRemoveWhenRotten = 'labels-to-remove-when-rotten', - LabelsToRemoveWhenUnrotten = 'labels-to-remove-when-unstale', - LabelsToAddWhenUnrotten = 'labels-to-add-when-unstale', + 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 f99840e0f..8789489ac 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -1,70 +1,70 @@ -import { IsoOrRfcDateString } from '../types/iso-or-rfc-date-string'; +import {IsoOrRfcDateString} from '../types/iso-or-rfc-date-string'; 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; - onlyIssueLabels: string; - onlyPrLabels: string; - anyOfLabels: string; - anyOfIssueLabels: string; - anyOfPrLabels: string; - operationsPerRun: number; - removeStaleWhenUpdated: boolean; - removeIssueStaleWhenUpdated: boolean | undefined; - removePrStaleWhenUpdated: boolean | undefined; - removeRottenWhenUpdated: boolean; - removeIssueRottenWhenUpdated: boolean | undefined; - removePrRottenWhenUpdated: boolean | undefined; - debugOnly: boolean; - ascending: boolean; - deleteBranch: boolean; - startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822 - exemptMilestones: string; - exemptIssueMilestones: string; - exemptPrMilestones: string; - exemptAllMilestones: boolean; - exemptAllIssueMilestones: boolean | undefined; - exemptAllPrMilestones: boolean | undefined; - exemptAssignees: string; - exemptIssueAssignees: string; - exemptPrAssignees: string; - exemptAllAssignees: boolean; - exemptAllIssueAssignees: boolean | undefined; - exemptAllPrAssignees: boolean | undefined; - enableStatistics: boolean; - labelsToRemoveWhenStale: string; - labelsToRemoveWhenUnstale: string; - labelsToAddWhenUnstale: string; - labelsToRemoveWhenRotten: string; - labelsToRemoveWhenUnrotten: string; - labelsToAddWhenUnrotten: string; - ignoreUpdates: boolean; - ignoreIssueUpdates: boolean | undefined; - ignorePrUpdates: boolean | undefined; - exemptDraftPr: boolean; - closeIssueReason: string; - includeOnlyAssigned: boolean; + 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; + onlyIssueLabels: string; + onlyPrLabels: string; + anyOfLabels: string; + anyOfIssueLabels: string; + anyOfPrLabels: string; + operationsPerRun: number; + removeStaleWhenUpdated: boolean; + removeIssueStaleWhenUpdated: boolean | undefined; + removePrStaleWhenUpdated: boolean | undefined; + removeRottenWhenUpdated: boolean; + removeIssueRottenWhenUpdated: boolean | undefined; + removePrRottenWhenUpdated: boolean | undefined; + debugOnly: boolean; + ascending: boolean; + deleteBranch: boolean; + startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822 + exemptMilestones: string; + exemptIssueMilestones: string; + exemptPrMilestones: string; + exemptAllMilestones: boolean; + exemptAllIssueMilestones: boolean | undefined; + exemptAllPrMilestones: boolean | undefined; + exemptAssignees: string; + exemptIssueAssignees: string; + exemptPrAssignees: string; + exemptAllAssignees: boolean; + exemptAllIssueAssignees: boolean | undefined; + exemptAllPrAssignees: boolean | undefined; + enableStatistics: boolean; + labelsToRemoveWhenStale: string; + labelsToRemoveWhenUnstale: string; + labelsToAddWhenUnstale: string; + labelsToRemoveWhenRotten: string; + labelsToRemoveWhenUnrotten: string; + labelsToAddWhenUnrotten: string; + ignoreUpdates: boolean; + ignoreIssueUpdates: boolean | undefined; + ignorePrUpdates: boolean | undefined; + exemptDraftPr: boolean; + closeIssueReason: string; + includeOnlyAssigned: boolean; } diff --git a/src/main.ts b/src/main.ts index 4f4b51c0d..ea0b82150 100644 --- a/src/main.ts +++ b/src/main.ts @@ -72,7 +72,9 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { ), daysBeforeIssueStale: parseFloat(core.getInput('days-before-issue-stale')), daysBeforePrStale: parseFloat(core.getInput('days-before-pr-stale')), - daysBeforeIssueRotten: parseFloat(core.getInput('days-before-issue-rotten')), + 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}) From 830455db9045b5d6dcdc9ba9fab15bcd5c7b21a1 Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Tue, 5 Mar 2024 19:03:58 +0530 Subject: [PATCH 5/7] Update dist dir --- dist/index.js | 410 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 372 insertions(+), 38 deletions(-) diff --git a/dist/index.js b/dist/index.js index f2786a0f6..1e527af80 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,122 @@ 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)})`); + 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 +959,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 +1029,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 +1209,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 +1270,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 +1320,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 +1356,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 +1405,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 +1432,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 +2097,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 +2136,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 +2234,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 +2475,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 +2504,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 +2527,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 +2816,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 +2829,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 +2860,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 +2885,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 +2902,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 +2934,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)); }); } From 3e5b8eb1ec02991a733a81b50308e8a74f5e430b Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Tue, 5 Mar 2024 23:33:58 +0530 Subject: [PATCH 6/7] Update issue-processor to remove Stale label when Rotten label is added --- __tests__/main.spec.ts | 15 +++++++-------- src/classes/issues-processor.ts | 3 +++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/__tests__/main.spec.ts b/__tests__/main.spec.ts index a4fbdc13f..d540b3e48 100644 --- a/__tests__/main.spec.ts +++ b/__tests__/main.spec.ts @@ -704,7 +704,6 @@ test('processing a stale PR will rotten it but not close it when days-before-pr- ...DefaultProcessorOptions, daysBeforeClose: 30, daysBeforeRotten: 0, - daysBeforePrClose: 30 }; const TestIssueList: Issue[] = [ @@ -1538,11 +1537,11 @@ 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 rotten)', async () => { +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: 0 + daysBeforeRotten: -1 }; github.context.actor = 'abot'; const TestIssueList: Issue[] = [ @@ -1576,8 +1575,8 @@ test('stale label should not be removed if a comment was added by the bot (and t // process our fake issue list await processor.processIssues(1); - expect(processor.closedIssues).toHaveLength(0); - expect(processor.rottenIssues).toHaveLength(1); + expect(processor.closedIssues).toHaveLength(1); + expect(processor.rottenIssues).toHaveLength(0); expect(processor.staleIssues).toHaveLength(0); expect(processor.removedLabelIssues).toHaveLength(0); }); @@ -1651,7 +1650,7 @@ test('stale issues should not be closed until after the closed number of days', 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.daysBeforeRotten = 1; // closes after 6 days + opts.daysBeforeRotten = 1; // rotten after 6 days const lastUpdate = new Date(); lastUpdate.setDate(lastUpdate.getDate() - 7); const TestIssueList: Issue[] = [ @@ -1679,7 +1678,7 @@ test('stale issues should be rotten if the rotten nubmer of days (additive) is a expect(processor.closedIssues).toHaveLength(0); expect(processor.rottenIssues).toHaveLength(1); - expect(processor.removedLabelIssues).toHaveLength(0); + expect(processor.removedLabelIssues).toHaveLength(1); // the stale label should be removed on rotten label being added expect(processor.staleIssues).toHaveLength(0); }); @@ -2770,7 +2769,7 @@ 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(0); diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 05423aafe..b7f31bd9d 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -908,6 +908,9 @@ export class IssuesProcessor { 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, From cffda38cb3c3024034c5cfef5422bba4e603658c Mon Sep 17 00:00:00 2001 From: mviswanathsai Date: Tue, 5 Mar 2024 23:34:09 +0530 Subject: [PATCH 7/7] updated dist --- dist/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dist/index.js b/dist/index.js index 1e527af80..6ac1a940a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -877,6 +877,8 @@ class IssuesProcessor { } 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;