diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 0265b644..74af70e2 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 80d660e8..7ad9e10a 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 d55f8547..27b3a35a 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 b9063183..f3ca750e 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 486c6a78..b77b2b9a 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(); + 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 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.` - ) - ); + return millisSinceLastUpdated <= daysInMillis; } - if (this.options.enableStatistics) { - this.statistics = new Statistics(); - } - } + private static _endIssueProcessing(issue: Issue): void { + const consumedOperationsCount: number = + issue.operations.getConsumedOperationsCount(); - async processIssues(page: Readonly = 1): Promise { - // get the next batch of issues - const issues: Issue[] = await this.getIssues(page); + if (consumedOperationsCount > 0) { + const issueLogger: IssueLogger = new IssueLogger(issue); - 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 - ); - - 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' - ); - continue; - } - await issueLogger.grouping(`$$type #${issue.number}`, async () => { - await this.processIssue( - issue, - labelsToAddWhenUnstale, - labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale - ); - }); - 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; - } - - 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 - } - - if (issue.locked) { - issueLogger.info(`Skipping this $$type because it is locked`); - IssuesProcessor._endIssueProcessing(issue); - return; // Don't process locked issues - } - - 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 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); + issueLogger.info( + LoggerService.cyan(consumedOperationsCount), + `operation${consumedOperationsCount > 1 ? 's' : '' + } consumed for this $$type` + ); } - ); - - 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` - ); } - issueLogger.info( - `Days before $$type stale: ${LoggerService.cyan(daysBeforeStale)}` - ); + private static _getCloseLabelUsedOptionName( + issue: Readonly + ): Option.ClosePrLabel | Option.CloseIssueLabel { + return issue.isPullRequest ? Option.ClosePrLabel : Option.CloseIssueLabel; + } - const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale); + 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; - // Try to remove the close label when not close/locked issue or PR - await this._removeCloseLabel(issue, closeLabel); + 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); - 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` + this._logger.info( + LoggerService.yellow(`Starting the stale action process...`) ); - 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`); - } - - 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 - } - - 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 (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 (!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` - ); - } - - 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); + if (this.options.enableStatistics) { + this.statistics = new Statistics(); } - ) - ) { - 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`); + async processIssues(page: Readonly = 1): Promise { + // get the next batch of issues + const issues: Issue[] = await this.getIssues(page); - const shouldIgnoreUpdates: boolean = new IgnoreUpdates( - this.options, - issue - ).shouldIgnoreUpdates(); + if (issues.length <= 0) { + this._logger.info( + LoggerService.green(`No more issues found to process. Exiting...`) + ); + this.statistics + ?.setOperationsCount(this.operations.getConsumedOperationsCount()) + .logStats(); - // Should this issue be marked as stale? - let shouldBeStale: boolean; + this.state.reset(); - // 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)})` - ); + return this.operations.getRemainingOperationsCount(); } 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)})` - ); + 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 (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, - labelsToAddWhenUnstale, - labelsToRemoveWhenUnstale, - labelsToRemoveWhenStale, - closeMessage, - closeLabel - ); - } - - 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; - } - - return staleLabeledEvent.created_at; - } - - 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 - 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` - ); - } - - 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 - )}` - ); - - // 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 - } - - // Now start closing logic - if (daysBeforeClose < 0) { - return; // Nothing to do because we aren't closing stale 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 (!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` + const labelsToRemoveWhenStale: string[] = wordsToList( + this.options.labelsToRemoveWhenStale ); - await this._deleteBranch(issue); - this.deletedBranchIssues.push(issue); - } - } else { - issueLogger.info( - `Stale $$type is not old enough to close yet (hasComments? ${issueHasCommentsSinceStale}, 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); + const labelsToAddWhenUnstale: string[] = wordsToList( + this.options.labelsToAddWhenUnstale + ); + const labelsToRemoveWhenUnstale: string[] = wordsToList( + this.options.labelsToRemoveWhenUnstale + ); + const labelsToRemoveWhenRotten: string[] = wordsToList( + this.options.labelsToRemoveWhenRotten + ); - issueLogger.info( - `Checking for comments on $$type since: ${LoggerService.cyan(sinceDate)}` - ); + const labelsToAddWhenUnrotten: string[] = wordsToList( + this.options.labelsToAddWhenUnrotten + ); + const labelsToRemoveWhenUnrotten: string[] = wordsToList( + this.options.labelsToRemoveWhenUnrotten + ); - if (!sinceDate) { - return true; + 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.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.')}` + ); + + // Do the next batch + return this.processIssues(page + 1); } - // find any comments since the date - const comments = await this.listIssueComments(issue, sinceDate); + async processIssue( + issue: Issue, + labelsToAddWhenUnstale: Readonly[], + labelsToRemoveWhenUnstale: Readonly[], + labelsToRemoveWhenStale: Readonly[], + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[] + ): Promise { + this.statistics?.incrementProcessedItemsCount(issue); - const filteredComments = comments.filter( - comment => - comment.user?.type === 'User' && - comment.body?.toLowerCase() !== staleMessage.toLowerCase() - ); + const issueLogger: IssueLogger = new IssueLogger(issue); + issueLogger.info( + `Found this $$type last updated at: ${LoggerService.cyan( + issue.updated_at + )}` + ); - issueLogger.info( - `Comments that are not the stale comment or another bot: ${LoggerService.cyan( - filteredComments.length - )}` - ); + // 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 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); + if (issue.state === 'closed') { + issueLogger.info(`Skipping this $$type because it is closed`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process closed issues + } - issueLogger.info(`Marking this $$type as stale`); - this.staleIssues.push(issue); + if (issue.locked) { + issueLogger.info(`Skipping this $$type because it is locked`); + IssuesProcessor._endIssueProcessing(issue); + return; // Don't process locked issues + } - // 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 (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 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` + ); + } + + issueLogger.info( + `Days before $$type stale: ${LoggerService.cyan(daysBeforeStale)}` + ); + + 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 + ); + + 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 + } + + 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` + ); + } + + 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, + ); + } + + 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`); - if (!skipMessage) { - try { this._consumeIssueOperation(issue); - this.statistics?.incrementAddedItemsComment(issue); - - if (!this.options.debugOnly) { - await this.client.rest.issues.createComment({ + this.statistics?.incrementFetchedItemsEventsCount(); + const options = this.client.rest.issues.listEvents.endpoint.merge({ 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] + per_page: 100, + issue_number: issue.number }); - } - } catch (error) { - issueLogger.error(`Error when adding a label: ${error.message}`); - } - } - // Close an issue based on staleness - private async _closeIssue( - issue: Issue, - closeMessage?: string, - closeLabel?: string - ): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + const events: IIssueEvent[] = await this.client.paginate(options); + const reversedEvents = events.reverse(); - issueLogger.info(`Closing $$type for being stale`); - this.closedIssues.push(issue); + const staleLabeledEvent = reversedEvents.find( + event => + event.event === 'labeled' && + cleanLabel(event.label.name) === cleanLabel(label) + ); - 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 - }); + if (!staleLabeledEvent) { + // Must be old rather than labeled + return undefined; } - } catch (error) { - issueLogger.error(`Error when creating a comment: ${error.message}`); - } + + return staleLabeledEvent.created_at; } - if (closeLabel) { - try { - this._consumeIssueOperation(issue); - this.statistics?.incrementAddedItemsLabel(issue); + async getPullRequest(issue: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(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] - }); + 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}`); } - } 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}`); + 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}`); + } } - } - // Delete the branch on closed pull request - private async _deleteBranch(issue: Issue): Promise { - const issueLogger: IssueLogger = new IssueLogger(issue); + // 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); - issueLogger.info(`Delete + // 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` + ); + } + + // 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 + )}` + ); + + // 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, + ) + } + + } + private async _processRottenIssue( + issue: Issue, + rottenLabel: string, + rottenMessage: string, + labelsToAddWhenUnrotten: Readonly[], + labelsToRemoveWhenUnrotten: Readonly[], + labelsToRemoveWhenRotten: Readonly[], + closeMessage?: string, + closeLabel?: string + ) { + const issueLogger: IssueLogger = new IssueLogger(issue); + // We can get the label creation date from the getLableCreationDate function + const markedRottenOn: string = + (await this.getLabelCreationDate(issue, rottenLabel)) || issue.updated_at; + issueLogger.info( + `$$type marked rotten on: ${LoggerService.cyan(markedRottenOn)}` + ); + + const issueHasCommentsSinceRotten: boolean = await this._hasCommentsSince( + issue, + markedRottenOn, + rottenMessage + ); + issueLogger.info( + `$$type has been commented on: ${LoggerService.cyan( + issueHasCommentsSinceRotten + )}` + ); + + const daysBeforeClose: number = issue.isPullRequest + ? this._getDaysBeforePrClose() + : this._getDaysBeforeIssueClose(); + + issueLogger.info( + `Days before $$type close: ${LoggerService.cyan(daysBeforeClose)}` + ); + + const shouldRemoveRottenWhenUpdated: boolean = + this._shouldRemoveRottenWhenUpdated(issue); + + issueLogger.info( + `The option ${issueLogger.createOptionLink( + this._getRemoveRottenWhenUpdatedUsedOptionName(issue) + )} is: ${LoggerService.cyan(shouldRemoveRottenWhenUpdated)}` + ); + + if (shouldRemoveRottenWhenUpdated) { + issueLogger.info(`The rotten label should not be removed`); + } else { + issueLogger.info( + `The rotten label should be removed if all conditions met` + ); + } + + if (issue.markedRottenThisRun) { + issueLogger.info(`marked rotten this run, so don't check for updates`); + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenRotten, + Option.LabelsToRemoveWhenRotten + ); + } + + // The issue.updated_at and markedRottenOn are not always exactly in sync (they can be off by a second or 2) + // isDateMoreRecentThan makes sure they are not the same date within a certain tolerance (15 seconds in this case) + const issueHasUpdateSinceRotten = isDateMoreRecentThan( + new Date(issue.updated_at), + new Date(markedRottenOn), + 15 + ); + + issueLogger.info( + `$$type has been updated since it was marked rotten: ${LoggerService.cyan( + issueHasUpdateSinceRotten + )}` + ); + + // Should we un-rotten this issue? + if ( + shouldRemoveRottenWhenUpdated && + (issueHasUpdateSinceRotten || issueHasCommentsSinceRotten) && + !issue.markedRottenThisRun + ) { + issueLogger.info( + `Remove the rotten label since the $$type has been updated and the workflow should remove the stale label when updated` + ); + await this._removeRottenLabel(issue, rottenLabel); + + // Are there labels to remove or add when an issue is no longer rotten? + // This logic takes care of removing labels when unrotten + await this._removeLabelsOnStatusTransition( + issue, + labelsToRemoveWhenUnrotten, + Option.LabelsToRemoveWhenUnrotten + ); + await this._addLabelsWhenUnrotten(issue, labelsToAddWhenUnrotten); + + issueLogger.info(`Skipping the process since the $$type is now un-rotten`); + + return; // Nothing to do because it is no longer rotten + } + + // Now start closing logic + if (daysBeforeClose < 0) { + return; // Nothing to do because we aren't closing 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( + `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; + } + + // 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}`); + } + } + + 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}`); + } + } + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsLabel(issue); + this.statistics?.incrementStaleItemsCount(issue); + + if (!this.options.debugOnly) { + await this.client.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: [rottenLabel] + }); + } + } catch (error) { + issueLogger.error(`Error when adding a label: ${error.message}`); + } + } + + + // Close an issue based on staleness + private async _closeIssue( + 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}`); + } + } + + // Delete the branch on closed pull request + private async _deleteBranch(issue: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info(`Delete branch from closed $ $type - ${issue.title}`); - const pullRequest: IPullRequest | undefined | void = - await this.getPullRequest(issue); + 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; - - 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}` - }); + if (!pullRequest) { + issueLogger.info( + `Not deleting this branch as no pull request was found for this $$type` + ); + return; } - } catch (error) { - issueLogger.error( - `Error when deleting the branch "${LoggerService.cyan( - branch - )}" from $$type: ${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 + }` + ); + } + } + + // 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...` ); - } - } else { - issueLogger.warning( - `Deleting the branch "${LoggerService.cyan( - branch - )}" has skipped because it belongs to other repo ${ - pullRequest.head.repo.full_name - }` - ); - } - } + this.removedLabelIssues.push(issue); - // 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); + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementDeletedItemsLabelsCount(issue); - issueLogger.info( - `${ - isSubStep ? LoggerService.white('├── ') : '' - }Removing the label "${LoggerService.cyan(label)}" from this $$type...` - ); - this.removedLabelIssues.push(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 + }); + } - 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; - } + 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)}"` + ); + } } - 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; - } + private _getDaysBeforeIssueStale(): number { + return isNaN(this.options.daysBeforeIssueStale) + ? this.options.daysBeforeStale + : this.options.daysBeforeIssueStale; } - return this.options.anyOfLabels; - } - - private _shouldRemoveStaleWhenUpdated(issue: Issue): boolean { - if (issue.isPullRequest) { - if (isBoolean(this.options.removePrStaleWhenUpdated)) { - return this.options.removePrStaleWhenUpdated; - } - - return this.options.removeStaleWhenUpdated; + 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 (isBoolean(this.options.removeIssueStaleWhenUpdated)) { - return this.options.removeIssueStaleWhenUpdated; + private _getDaysBeforePrRotten(): number { + return isNaN(this.options.daysBeforePrStale) + ? this.options.daysBeforeStale + : this.options.daysBeforePrStale; } - return this.options.removeStaleWhenUpdated; - } - - private async _removeLabelsOnStatusTransition( - issue: Issue, - removeLabels: Readonly[], - staleStatus: Option - ): Promise { - if (!removeLabels.length) { - return; + private _getDaysBeforeIssueClose(): number { + return isNaN(this.options.daysBeforeIssueClose) + ? this.options.daysBeforeClose + : this.options.daysBeforeIssueClose; } - const issueLogger: IssueLogger = new IssueLogger(issue); - - 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); - } - } - - private async _addLabelsWhenUnstale( - issue: Issue, - labelsToAdd: Readonly[] - ): Promise { - if (!labelsToAdd.length) { - return; + private _getDaysBeforePrClose(): number { + return isNaN(this.options.daysBeforePrClose) + ? this.options.daysBeforeClose + : this.options.daysBeforePrClose; } - const issueLogger: IssueLogger = new IssueLogger(issue); - issueLogger.info( - `Adding all the labels specified via the ${this._logger.createOptionLink( - Option.LabelsToAddWhenUnstale - )} option.` - ); + 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; + } + } - 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 _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(); + return this.options.onlyLabels; } - 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 _getRemoveStaleWhenUpdatedUsedOptionName( - issue: Readonly - ): - | Option.RemovePrStaleWhenUpdated - | Option.RemoveStaleWhenUpdated - | Option.RemoveIssueStaleWhenUpdated { - if (issue.isPullRequest) { - if (isBoolean(this.options.removePrStaleWhenUpdated)) { - return Option.RemovePrStaleWhenUpdated; - } - - return Option.RemoveStaleWhenUpdated; + private _isIncludeOnlyAssigned(issue: Issue): boolean { + return this.options.includeOnlyAssigned && !issue.hasAssignees; } - if (isBoolean(this.options.removeIssueStaleWhenUpdated)) { - return Option.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.anyOfLabels; } - return Option.RemoveStaleWhenUpdated; - } + 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; + } + private _shouldRemoveRottenWhenUpdated(issue: Issue): boolean { + if (issue.isPullRequest) { + if (isBoolean(this.options.removePrRottenWhenUpdated)) { + return this.options.removePrRottenWhenUpdated; + } + + return this.options.removeRottenWhenUpdated; + } + + if (isBoolean(this.options.removeIssueRottenWhenUpdated)) { + return this.options.removeIssueRottenWhenUpdated; + } + + return this.options.removeRottenWhenUpdated; + } + + private async _removeLabelsOnStatusTransition( + issue: Issue, + removeLabels: Readonly[], + staleStatus: Option + ): Promise { + if (!removeLabels.length) { + return; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); + + 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); + } + } + + + 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 _addLabelsWhenUnrotten( + issue: Issue, + labelsToAdd: Readonly[] + ): Promise { + if (!labelsToAdd.length) { + return; + } + + const issueLogger: IssueLogger = new IssueLogger(issue); + + issueLogger.info( + `Adding all the labels specified via the ${this._logger.createOptionLink( + Option.LabelsToAddWhenUnrotten + )} option.` + ); + + // TODO: this might need to be changed to a set to avoiod repetition + this.addedLabelIssues.push(issue); + + try { + this._consumeIssueOperation(issue); + this.statistics?.incrementAddedItemsLabel(issue); + if (!this.options.debugOnly) { + await this.client.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + labels: labelsToAdd + }); + } + } catch (error) { + this._logger.error( + `Error when adding labels after updated from rotten: ${error.message}` + ); + } + } + private async _removeStaleLabel( + issue: Issue, + staleLabel: Readonly + ): 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(); + } + + 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; + } + + 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.RemoveRottenWhenUpdated; + } } diff --git a/src/classes/statistics.ts b/src/classes/statistics.ts index 321ea70d..3d137797 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 7a9bff02..45f466e3 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 93099228..f99840e0 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 a7836c16..4f4b51c0 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)); }