import {context, getOctokit} from '@actions/github'; import {GitHub} from '@actions/github/lib/utils'; import {GetResponseTypeFromEndpointMethod} from '@octokit/types'; import {Issue} from './classes/issue'; import {IssueLogger} from './classes/loggers/issue-logger'; import {Logger} from './classes/loggers/logger'; import {Milestones} from './classes/milestones'; import {IssueType} from './enums/issue-type'; 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 {getIssueType} from './functions/get-issue-type'; import {isLabeled} from './functions/is-labeled'; import {isPullRequest} from './functions/is-pull-request'; import {shouldMarkWhenStale} from './functions/should-mark-when-stale'; import {IsoOrRfcDateString} from './types/iso-or-rfc-date-string'; import {wordsToList} from './functions/words-to-list'; import {IIssue} from './interfaces/issue'; export interface PullRequest { number: number; head: { ref: string; }; } export interface User { type: string; login: string; } export interface Comment { user: User; } export interface IssueEvent { created_at: string; event: string; label: Label; } export interface Label { name: string; } export interface IssueProcessorOptions { 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; operationsPerRun: number; removeStaleWhenUpdated: boolean; debugOnly: boolean; ascending: boolean; skipStaleIssueMessage: boolean; skipStalePrMessage: boolean; deleteBranch: boolean; startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822 exemptMilestones: string; exemptIssueMilestones: string; exemptPrMilestones: string; } /*** * Handle processing of issues for staleness/closure. */ export class IssueProcessor { 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 readonly _logger: Logger = new Logger(); private _operationsLeft = 0; readonly client: InstanceType; readonly options: IssueProcessorOptions; readonly staleIssues: Issue[] = []; readonly closedIssues: Issue[] = []; readonly deletedBranchIssues: Issue[] = []; readonly removedLabelIssues: Issue[] = []; constructor( options: IssueProcessorOptions, getActor?: () => Promise, getIssues?: (page: number) => Promise, listIssueComments?: ( issueNumber: number, sinceDate: string ) => Promise, getLabelCreationDate?: ( issue: Issue, label: string ) => Promise ) { this.options = options; this._operationsLeft = options.operationsPerRun; this.client = getOctokit(options.repoToken); if (getActor) { this._getActor = getActor; } if (getIssues) { this._getIssues = getIssues; } if (listIssueComments) { this._listIssueComments = listIssueComments; } if (getLabelCreationDate) { this._getLabelCreationDate = getLabelCreationDate; } if (this.options.debugOnly) { this._logger.warning( 'Executing in debug mode. Debug output will be written but no issues will be processed.' ); } } async processIssues(page = 1): Promise { // get the next batch of issues const issues: Issue[] = await this._getIssues(page); this._operationsLeft -= 1; const actor: string = await this._getActor(); if (issues.length <= 0) { this._logger.info('---'); this._logger.info('No more issues found to process. Exiting.'); return this._operationsLeft; } for (const issue of issues.values()) { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info( `Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${issue.isPullRequest})` ); // 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.skipStalePrMessage : this.options.skipStaleIssueMessage; const issueType: IssueType = getIssueType(issue.isPullRequest); const daysBeforeStale: number = issue.isPullRequest ? this._getDaysBeforePrStale() : this._getDaysBeforeIssueStale(); if (issue.isPullRequest) { issueLogger.info(`Days before pull request stale: ${daysBeforeStale}`); } else { issueLogger.info(`Days before issue stale: ${daysBeforeStale}`); } const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale); if (!staleMessage && shouldMarkAsStale) { issueLogger.info(`Skipping ${issueType} due to empty stale message`); continue; } if (issue.state === 'closed') { issueLogger.info(`Skipping ${issueType} because it is closed`); continue; // don't process closed issues } if (issue.locked) { issueLogger.info(`Skipping ${issueType} because it is locked`); continue; // don't process locked issues } 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)} (${ this.options.startDate })` ); // Expecting that GitHub will always set a creation date on the issues and PRs // But you never know! if (!isValidDate(createdAt)) { throw new Error( `Invalid issue field: "created_at". Expected a valid date` ); } issueLogger.info( `Issue created the ${getHumanizedDate(createdAt)} (${ issue.created_at })` ); if (!isDateMoreRecentThan(createdAt, startDate)) { issueLogger.info( `Skipping ${issueType} because it was created before the specified start date` ); continue; // don't process issues which were created before the start date } } if (issue.isStale) { issueLogger.info(`This issue has a stale label`); } else { issueLogger.info(`This issue hasn't a stale label`); } const exemptLabels: string[] = wordsToList( issue.isPullRequest ? this.options.exemptPrLabels : this.options.exemptIssueLabels ); if ( exemptLabels.some((exemptLabel: Readonly): boolean => isLabeled(issue, exemptLabel) ) ) { if (issue.isStale) { issueLogger.info(`An exempt label was added after the stale label.`); await this._removeStaleLabel(issue, staleLabel); } issueLogger.info( `Skipping ${issueType} because it has an exempt label` ); continue; // don't process exempt issues } const milestones: Milestones = new Milestones(this.options, issue); if (milestones.shouldExemptMilestones()) { issueLogger.info( `Skipping ${issueType} because it has an exempt milestone` ); continue; // don't process exempt milestones } // should this issue be marked stale? const shouldBeStale = !IssueProcessor._updatedSince( issue.updated_at, this.options.daysBeforeStale ); // determine if this issue needs to be marked stale first if (!issue.isStale && shouldBeStale && shouldMarkAsStale) { issueLogger.info( `Marking ${issueType} stale because it was last updated on ${issue.updated_at} and it does not have a stale label` ); await this._markStale(issue, staleMessage, staleLabel, skipMessage); issue.isStale = true; // this issue is now considered stale } // process the issue if it was marked stale if (issue.isStale) { issueLogger.info(`Found a stale ${issueType}`); await this._processStaleIssue( issue, issueType, staleLabel, actor, closeMessage, closeLabel ); } } if (this._operationsLeft <= 0) { this._logger.warning( 'Reached max number of operations to process. Exiting.' ); return 0; } // do the next batch return this.processIssues(page + 1); } // handle all of the stale issue logic when we find a stale issue private async _processStaleIssue( issue: Issue, issueType: IssueType, staleLabel: string, actor: string, closeMessage?: string, closeLabel?: string ) { const issueLogger: IssueLogger = new IssueLogger(issue); const markedStaleOn: string = (await this._getLabelCreationDate(issue, staleLabel)) || issue.updated_at; issueLogger.info( `Issue #${issue.number} marked stale on: ${markedStaleOn}` ); const issueHasComments: boolean = await this._hasCommentsSince( issue, markedStaleOn, actor ); issueLogger.info( `Issue #${issue.number} has been commented on: ${issueHasComments}` ); const isPr: boolean = isPullRequest(issue); const daysBeforeClose: number = isPr ? this._getDaysBeforePrClose() : this._getDaysBeforeIssueClose(); if (isPr) { issueLogger.info(`Days before pull request close: ${daysBeforeClose}`); } else { issueLogger.info(`Days before issue close: ${daysBeforeClose}`); } const issueHasUpdate: boolean = IssueProcessor._updatedSince( issue.updated_at, daysBeforeClose ); issueLogger.info( `Issue #${issue.number} has been updated: ${issueHasUpdate}` ); // should we un-stale this issue? if (this.options.removeStaleWhenUpdated && issueHasComments) { await this._removeStaleLabel(issue, staleLabel); } // now start closing logic if (daysBeforeClose < 0) { return; // nothing to do because we aren't closing stale issues } if (!issueHasComments && !issueHasUpdate) { issueLogger.info( `Closing ${issueType} because it was last updated on ${issue.updated_at}` ); await this._closeIssue(issue, closeMessage, closeLabel); if (this.options.deleteBranch && issue.pull_request) { issueLogger.info( `Deleting branch for #${issue.number} as delete-branch option was specified` ); await this._deleteBranch(issue); this.deletedBranchIssues.push(issue); } } else { issueLogger.info( `Stale ${issueType} is not old enough to close yet (hasComments? ${issueHasComments}, hasUpdate? ${issueHasUpdate})` ); } } // checks to see if a given issue is still stale (has had activity on it) private async _hasCommentsSince( issue: Issue, sinceDate: string, actor: string ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info( `Checking for comments on issue #${issue.number} since ${sinceDate}` ); if (!sinceDate) { return true; } // find any comments since the date const comments = await this._listIssueComments(issue.number, sinceDate); const filteredComments = comments.filter( comment => comment.user.type === 'User' && comment.user.login !== actor ); issueLogger.info( `Comments not made by actor or another bot: ${filteredComments.length}` ); // if there are any user comments returned return filteredComments.length > 0; } // grab comments for an issue since a given date private async _listIssueComments( issueNumber: number, sinceDate: string ): Promise { // find any comments since date on the given issue try { const comments = await this.client.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, since: sinceDate }); return comments.data; } catch (error) { this._logger.error(`List issue comments error: ${error.message}`); return Promise.resolve([]); } } // get the actor from the GitHub token or context private async _getActor(): Promise { let actor; try { actor = await this.client.users.getAuthenticated(); } catch (error) { return context.actor; } return actor.data.login; } // grab issues from github in batches of 100 private async _getIssues(page: number): Promise { // generate type for response const endpoint = this.client.issues.listForRepo; type OctoKitIssueList = GetResponseTypeFromEndpointMethod; try { const issueResult: OctoKitIssueList = await this.client.issues.listForRepo( { owner: context.repo.owner, repo: context.repo.repo, state: 'open', labels: this.options.onlyLabels, per_page: 100, direction: this.options.ascending ? 'asc' : 'desc', page } ); return issueResult.data.map( (issue: Readonly): Issue => new Issue(this.options, issue) ); } catch (error) { this._logger.error(`Get issues for repo error: ${error.message}`); return Promise.resolve([]); } } // Mark an issue as stale with a comment and a label private async _markStale( issue: Issue, staleMessage: string, staleLabel: string, skipMessage: boolean ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info(`Marking issue #${issue.number} as stale`); this.staleIssues.push(issue); this._operationsLeft -= 2; // 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.options.debugOnly) { return; } if (!skipMessage) { try { await this.client.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: staleMessage }); } catch (error) { issueLogger.error(`Error creating a comment: ${error.message}`); } } try { await this.client.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: [staleLabel] }); } catch (error) { issueLogger.error(`Error 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 issue #${issue.number} for being stale`); this.closedIssues.push(issue); this._operationsLeft -= 1; if (this.options.debugOnly) { return; } if (closeMessage) { try { await this.client.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, body: closeMessage }); } catch (error) { issueLogger.error(`Error creating a comment: ${error.message}`); } } if (closeLabel) { try { await this.client.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, labels: [closeLabel] }); } catch (error) { issueLogger.error(`Error adding a label: ${error.message}`); } } try { await this.client.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, state: 'closed' }); } catch (error) { issueLogger.error(`Error updating an issue: ${error.message}`); } } private async _getPullRequest( issue: Issue ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); this._operationsLeft -= 1; try { const pullRequest = await this.client.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: issue.number }); return pullRequest.data; } catch (error) { issueLogger.error( `Error getting pull request ${issue.number}: ${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 issue #${issue.number} - ${issue.title}` ); if (this.options.debugOnly) { return; } const pullRequest = await this._getPullRequest(issue); if (!pullRequest) { issueLogger.info( `Not deleting branch as pull request not found for issue ${issue.number}` ); return; } const branch = pullRequest.head.ref; issueLogger.info( `Deleting branch ${branch} from closed issue #${issue.number}` ); this._operationsLeft -= 1; try { await this.client.git.deleteRef({ owner: context.repo.owner, repo: context.repo.repo, ref: `heads/${branch}` }); } catch (error) { issueLogger.error( `Error deleting branch ${branch} from issue #${issue.number}: ${error.message}` ); } } // Remove a label from an issue private async _removeLabel(issue: Issue, label: string): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info(`Removing label "${label}" from issue #${issue.number}`); this.removedLabelIssues.push(issue); this._operationsLeft -= 1; // @todo remove the debug only to be able to test the code below if (this.options.debugOnly) { return; } try { await this.client.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issue.number, name: label }); } catch (error) { issueLogger.error(`Error removing a label: ${error.message}`); } } // returns the creation date of a given label on an issue (or nothing if no label existed) ///see https://developer.github.com/v3/activity/events/ private async _getLabelCreationDate( issue: Issue, label: string ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info(`Checking for label on issue #${issue.number}`); this._operationsLeft -= 1; const options = this.client.issues.listEvents.endpoint.merge({ owner: context.repo.owner, repo: context.repo.repo, per_page: 100, issue_number: issue.number }); const events: IssueEvent[] = await this.client.paginate(options); const reversedEvents = events.reverse(); const staleLabeledEvent = reversedEvents.find( event => event.event === 'labeled' && event.label.name === label ); if (!staleLabeledEvent) { // Must be old rather than labeled return undefined; } return staleLabeledEvent.created_at; } 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 async _removeStaleLabel( issue: Issue, staleLabel: Readonly ): Promise { const issueLogger: IssueLogger = new IssueLogger(issue); issueLogger.info( `Issue #${issue.number} is no longer stale. Removing stale label.` ); return this._removeLabel(issue, staleLabel); } }