diff --git a/src/IssueProcessor.ts b/src/IssueProcessor.ts new file mode 100644 index 00000000..451d5c42 --- /dev/null +++ b/src/IssueProcessor.ts @@ -0,0 +1,171 @@ +import * as core from '@actions/core'; +import * as github from '@actions/github'; +import { Octokit } from '@octokit/rest'; + +type Issue = Octokit.IssuesListForRepoResponseItem; +type IssueLabel = Octokit.IssuesListForRepoResponseItemLabelsItem; +type IssueList = Octokit.Response; + +export interface IssueProcessorOptions { + repoToken: string; + staleIssueMessage: string; + stalePrMessage: string; + daysBeforeStale: number; + daysBeforeClose: number; + staleIssueLabel: string; + exemptIssueLabel: string; + stalePrLabel: string; + exemptPrLabel: string; + onlyLabels: string; + operationsPerRun: number; + debugOnly: boolean; + } + +/*** + * Handle processing of issues for staleness/closure. + */ +export class IssueProcessor { + readonly client: github.GitHub; + readonly options: IssueProcessorOptions; + private operationsLeft: number = 0; + + constructor(options: IssueProcessorOptions) { + this.options = options; + this.client = new github.GitHub(options.repoToken); + } + + public async processIssues(page: number = 1): Promise { + if (this.options.debugOnly) { + core.warning('Executing in debug mode. Debug output will be written but no issues will be processed.'); + } + + if (this.operationsLeft <= 0) { + core.warning('Reached max number of operations to process. Exiting.'); + return 0; + } + + // get the next batch of issues + const issues: IssueList = await this.getIssues(page); + + if (issues.data.length <= 0) { + core.debug('No more issues found to process. Exiting.'); + return this.operationsLeft; + } + + for (const issue of issues.data.values()) { + const isPr: boolean = !!issue.pull_request; + + core.debug(`Found issue: issue #${issue.number} - ${issue.title} last updated ${issue.updated_at} (is pr? ${isPr})`); + + // calculate string based messages for this issue + const staleMessage: string = isPr ? this.options.stalePrMessage : this.options.staleIssueMessage; + const staleLabel: string = isPr ? this.options.stalePrLabel : this.options.staleIssueLabel; + const exemptLabel: string = isPr ? this.options.exemptPrLabel : this.options.exemptIssueLabel; + const issueType: string = isPr ? 'pr' : 'issue'; + + if (!staleMessage) { + core.debug(`Skipping ${issueType} due to empty stale message`); + continue; + } + + if (exemptLabel && IssueProcessor.isLabeled(issue, exemptLabel)) { + core.debug(`Skipping ${issueType} because it has an exempt label`); + continue; // don't process exempt issues + } + + if (!IssueProcessor.isLabeled(issue, staleLabel)) { + core.debug(`Found a stale ${issueType}`); + if (this.options.daysBeforeClose >= 0 && + IssueProcessor.wasLastUpdatedBefore(issue, this.options.daysBeforeClose)) + { + core.debug(`Closing ${issueType} because it was last updated on ${issue.updated_at}`) + await this.closeIssue(issue); + this.operationsLeft -= 1; + } else { + core.debug(`Ignoring stale ${issueType} because it was updated recenlty`); + } + } else if (IssueProcessor.wasLastUpdatedBefore(issue, this.options.daysBeforeStale)) { + core.debug(`Marking ${issueType} stale because it was last updated on ${issue.updated_at}`) + await this.markStale( + issue, + staleMessage, + staleLabel + ); + this.operationsLeft -= 2; + } + } + + // do the next batch + return await this.processIssues(page + 1); + } + + // grab issues from github in baches of 100 + private async getIssues(page: number): Promise { + return this.client.issues.listForRepo({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + state: 'open', + labels: this.options.onlyLabels, + per_page: 100, + page + }); + } + + // Mark an issue as stale with a comment and a label + private async markStale( + issue: Issue, + staleMessage: string, + staleLabel: string + ): Promise { + core.debug(`Marking issue #${issue.number} - ${issue.title} as stale`); + + if (this.options.debugOnly) { + return; + } + + await this.client.issues.createComment({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + body: staleMessage + }); + + await this.client.issues.addLabels({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + labels: [staleLabel] + }); + } + + /// Close an issue based on staleness + private async closeIssue( + issue: Issue + ): Promise { + core.debug(`Closing issue #${issue.number} - ${issue.title} for being stale`); + + if (this.options.debugOnly) { + return; + } + + await this.client.issues.update({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + state: 'closed' + }); + } + + private static isLabeled(issue: Issue, label: string): boolean { + const labelComparer: (l: IssueLabel) => boolean = l => + label.localeCompare(l.name, undefined, {sensitivity: 'accent'}) === 0; + return issue.labels.filter(labelComparer).length > 0; + } + + private static wasLastUpdatedBefore(issue: Issue, num_days: number): boolean { + const daysInMillis = 1000 * 60 * 60 * 24 * num_days; + const millisSinceLastUpdated = + new Date().getTime() - new Date(issue.updated_at).getTime(); + return millisSinceLastUpdated >= daysInMillis; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 4dd9c669..004380a2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,163 +1,22 @@ import * as core from '@actions/core'; -import * as github from '@actions/github'; -import {Octokit} from '@octokit/rest'; +import { IssueProcessor, IssueProcessorOptions } from './IssueProcessor'; -type Issue = Octokit.IssuesListForRepoResponseItem; -type IssueLabel = Octokit.IssuesListForRepoResponseItemLabelsItem; - -interface Args { - repoToken: string; - staleIssueMessage: string; - stalePrMessage: string; - daysBeforeStale: number; - daysBeforeClose: number; - staleIssueLabel: string; - exemptIssueLabels: string; - stalePrLabel: string; - exemptPrLabels: string; - onlyLabels: string; - operationsPerRun: number; +interface Args extends IssueProcessorOptions { } async function run(): Promise { try { const args = getAndValidateArgs(); - const client = new github.GitHub(args.repoToken); - await processIssues(client, args, args.operationsPerRun); + const processor: IssueProcessor = new IssueProcessor(args); + await processor.processIssues(); + } catch (error) { core.error(error); core.setFailed(error.message); } } -async function processIssues( - client: github.GitHub, - args: Args, - operationsLeft: number, - page: number = 1 -): Promise { - const issues = await client.issues.listForRepo({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - state: 'open', - labels: args.onlyLabels, - per_page: 100, - page - }); - - operationsLeft -= 1; - - if (issues.data.length === 0 || operationsLeft === 0) { - return operationsLeft; - } - - for (const issue of issues.data.values()) { - core.debug(`found issue: ${issue.title} last updated ${issue.updated_at}`); - const isPr = !!issue.pull_request; - - const staleMessage = isPr ? args.stalePrMessage : args.staleIssueMessage; - if (!staleMessage) { - core.debug(`skipping ${isPr ? 'pr' : 'issue'} due to empty message`); - continue; - } - - const staleLabel = isPr ? args.stalePrLabel : args.staleIssueLabel; - const exemptLabels = parseCommaSeparatedString( - isPr ? args.exemptPrLabels : args.exemptIssueLabels - ); - - if (exemptLabels.some(exemptLabel => isLabeled(issue, exemptLabel))) { - continue; - } else if (isLabeled(issue, staleLabel)) { - if ( - args.daysBeforeClose >= 0 && - wasLastUpdatedBefore(issue, args.daysBeforeClose) - ) { - operationsLeft -= await closeIssue(client, issue); - } else { - continue; - } - } else if (wasLastUpdatedBefore(issue, args.daysBeforeStale)) { - operationsLeft -= await markStale( - client, - issue, - staleMessage, - staleLabel - ); - } - - if (operationsLeft <= 0) { - core.warning( - `performed ${args.operationsPerRun} operations, exiting to avoid rate limit` - ); - return 0; - } - } - return await processIssues(client, args, operationsLeft, page + 1); -} - -function isLabeled(issue: Issue, label: string): boolean { - const labelComparer: (l: IssueLabel) => boolean = l => - label.localeCompare(l.name, undefined, {sensitivity: 'accent'}) === 0; - return issue.labels.filter(labelComparer).length > 0; -} - -function wasLastUpdatedBefore(issue: Issue, num_days: number): boolean { - const daysInMillis = 1000 * 60 * 60 * 24 * num_days; - const millisSinceLastUpdated = - new Date().getTime() - new Date(issue.updated_at).getTime(); - return millisSinceLastUpdated >= daysInMillis; -} - -async function markStale( - client: github.GitHub, - issue: Issue, - staleMessage: string, - staleLabel: string -): Promise { - core.debug(`marking issue${issue.title} as stale`); - - await client.issues.createComment({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - issue_number: issue.number, - body: staleMessage - }); - - await client.issues.addLabels({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - issue_number: issue.number, - labels: [staleLabel] - }); - - return 2; // operations performed -} - -async function closeIssue( - client: github.GitHub, - issue: Issue -): Promise { - core.debug(`closing issue ${issue.title} for being stale`); - - await client.issues.update({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - issue_number: issue.number, - state: 'closed' - }); - - return 1; // operations performed -} - -function parseCommaSeparatedString(s: string): string[] { - // String.prototype.split defaults to [''] when called on an empty string - // In this case, we'd prefer to just return an empty array indicating no labels - if (!s.length) return []; - return s.split(','); -} - function getAndValidateArgs(): Args { const args = { repoToken: core.getInput('repo-token', {required: true}), @@ -170,13 +29,14 @@ function getAndValidateArgs(): Args { core.getInput('days-before-close', {required: true}) ), staleIssueLabel: core.getInput('stale-issue-label', {required: true}), - exemptIssueLabels: core.getInput('exempt-issue-labels'), + exemptIssueLabel: core.getInput('exempt-issue-label'), stalePrLabel: core.getInput('stale-pr-label', {required: true}), - exemptPrLabels: core.getInput('exempt-pr-labels'), + exemptPrLabel: core.getInput('exempt-pr-label'), onlyLabels: core.getInput('only-labels'), operationsPerRun: parseInt( core.getInput('operations-per-run', {required: true}) - ) + ), + debugOnly: (core.getInput('debug-only') === 'true') }; for (const numberInput of [