From cd198e6d880b4c578147fbeb70226d43cba69e86 Mon Sep 17 00:00:00 2001 From: Ross Brodbeck Date: Sat, 2 May 2020 06:04:09 -0400 Subject: [PATCH] First pass at ignorning issues with comments and allowing removal of the stale label --- action.yml | 3 + src/IssueProcessor.ts | 145 ++++++++++++++++++++++++++++++++++++------ src/main.ts | 1 + 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/action.yml b/action.yml index 0ea0f8ac..cc9d8bd4 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,9 @@ inputs: operations-per-run: description: 'The maximum number of operations per run, used to control rate limiting.' default: 30 + remove-stale-when-updated: + description: 'Remove stale labels from issues when they are updated or commented on.' + default: false debug-only: description: 'Run the processor in debug mode without actually performing any operations on live issues.' default: false diff --git a/src/IssueProcessor.ts b/src/IssueProcessor.ts index b911e098..22fc2bf2 100644 --- a/src/IssueProcessor.ts +++ b/src/IssueProcessor.ts @@ -14,6 +14,12 @@ export interface Issue { locked: boolean; } +export interface IssueEvent { + created_at: string; + event: string; + label: Label; +} + export interface Label { name: string; } @@ -30,6 +36,7 @@ export interface IssueProcessorOptions { exemptPrLabels: string; onlyLabels: string; operationsPerRun: number; + removeStaleWhenUpdated: boolean; debugOnly: boolean; } @@ -123,25 +130,9 @@ export class IssueProcessor { 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` - ); - } + await this.processStaleIssue(issue, issueType, staleLabel); } else if ( - IssueProcessor.wasLastUpdatedBefore(issue, this.options.daysBeforeStale) + IssueProcessor.updatedSince(issue.updated_at, this.options.daysBeforeStale) ) { core.debug( `Marking ${issueType} stale because it was last updated on ${issue.updated_at}` @@ -155,6 +146,38 @@ export class IssueProcessor { return this.processIssues(page + 1); } + // handle all of the stale issue logic when we find a stale issue + private async processStaleIssue(issue: Issue, issueType: string, staleLabel: string) { + if (this.options.daysBeforeClose < 0) { + return; // nothing to do because we aren't closing stale issues + } + + const markedStaleOn: string = await this.getLabelCreationDate(issue, staleLabel); + const issueHasComments: boolean = await this.isIssueStillStale(issue, markedStaleOn); + const issueHasUpdate: boolean = IssueProcessor.updatedSince( + issue.updated_at, + this.options.daysBeforeClose + ); + + core.debug(`Issue #${issue.number} marked stale on: ${markedStaleOn}`); + core.debug(`Issue #${issue.number} has been updated: ${issueHasUpdate}`); + core.debug(`Issue #${issue.number} has been commented on: ${issueHasComments}`); + + if (!issueHasComments && !issueHasUpdate) { + core.debug( + `Closing ${issueType} because it was last updated on ${issue.updated_at}` + ); + await this.closeIssue(issue); + } else { + if (this.options.removeStaleWhenUpdated) { + await this.removeLabel(issue, staleLabel); + } + core.debug( + `Ignoring stale ${issueType} because it was updated recenlty` + ); + } + } + // grab issues from github in baches of 100 private async getIssues(page: number): Promise { const issueResult: OcotoKitIssueList = await this.client.issues.listForRepo( @@ -181,6 +204,8 @@ export class IssueProcessor { this.staleIssues.push(issue); + this.operationsLeft -= 2; + if (this.options.debugOnly) { return; } @@ -200,7 +225,7 @@ export class IssueProcessor { }); } - /// Close an issue based on staleness + // Close an issue based on staleness private async closeIssue(issue: Issue): Promise { core.debug( `Closing issue #${issue.number} - ${issue.title} for being stale` @@ -208,6 +233,8 @@ export class IssueProcessor { this.closedIssues.push(issue); + this.operationsLeft -= 1; + if (this.options.debugOnly) { return; } @@ -220,16 +247,92 @@ export class IssueProcessor { }); } + // Remove a label from an issue + private async removeLabel(issue: Issue, label: string): Promise { + core.debug( + `Removing label ${label} from issue #${issue.number} - ${issue.title}` + ); + + this.operationsLeft -= 1; + + if (this.options.debugOnly) { + return; + } + + await this.client.issues.removeLabel({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + name: encodeURIComponent(label), // A label can have a "?" in the name + }) + } + + // checks to see if a given issue is still stale (has had activity on it) + private async isIssueStillStale( + issue: Issue, + sinceDate: string + ): Promise { + core.debug( + `Checking for comments on issue #${issue.number} since ${sinceDate} to see if it is still stale` + ); + + if (!sinceDate) { + return true; // if no date was provided then the issue was marked stale a long time ago + } + + this.operationsLeft -= 1; + + // find any comments since the stale label + const comments = await this.client.issues.listComments({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issue.number, + since: sinceDate + }); + + // if there are any user comments returned, issue is not stale anymore + return comments.data.filter(comment => comment.user.type === "User").length > 0 + } + + // 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 { + core.debug( + `Checking for label ${label} on issue #${issue.number}` + ); + + this.operationsLeft -= 1; + + const options = this.client.issues.listEvents.endpoint.merge({ + owner: github.context.repo.owner, + repo: github.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 + ); + + return staleLabeledEvent!.created_at + } + private static isLabeled(issue: Issue, label: string): boolean { const labelComparer: (l: Label) => 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 { + private static updatedSince(timestamp: string, num_days: number): boolean { const daysInMillis = 1000 * 60 * 60 * 24 * num_days; const millisSinceLastUpdated = - new Date().getTime() - new Date(issue.updated_at).getTime(); + new Date().getTime() - new Date(timestamp).getTime(); return millisSinceLastUpdated >= daysInMillis; } diff --git a/src/main.ts b/src/main.ts index ebf61e48..b94120bd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,7 @@ function getAndValidateArgs(): IssueProcessorOptions { operationsPerRun: parseInt( core.getInput('operations-per-run', {required: true}) ), + removeStaleWhenUpdated: core.getInput('remove-stale-when-updated') === 'true', debugOnly: core.getInput('debug-only') === 'true' };