diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..6acf9bed --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Jest Tests", + "type": "node", + "request": "launch", + "program": "${workspaceRoot}/node_modules/jest/bin/jest", + "args": [ + "-i" + ], + "preLaunchTask": "tsc: build - tsconfig.json", + "internalConsoleOptions": "openOnSessionStart", + "console": "integratedTerminal", + "outFiles": [ + "${workspaceRoot}/build/dist/**/*" + ] + } + ] +} \ No newline at end of file diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 7efd4f92..1940809f 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -43,11 +43,17 @@ const DefaultProcessorOptions: IssueProcessorOptions = { exemptPrLabels: '', onlyLabels: '', operationsPerRun: 100, - debugOnly: true + debugOnly: true, + removeStaleWhenUpdated: false }; test('empty issue list results in 1 operation', async () => { - const processor = new IssueProcessor(DefaultProcessorOptions, async () => []); + const processor = new IssueProcessor( + DefaultProcessorOptions, + async () => [], + async (num, dt) => [], + async (issue, label) => new Date().toDateString() + ); // process our fake issue list const operationsLeft = await processor.processIssues(1); @@ -58,11 +64,14 @@ test('empty issue list results in 1 operation', async () => { test('processing an issue with no label will make it stale', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z') + generateIssue(1, 'An issue with no label', '2020-01-01T17:00:00Z') ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -74,11 +83,20 @@ test('processing an issue with no label will make it stale', async () => { test('processing a stale issue will close it', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Stale']) + generateIssue( + 1, + 'A stale issue that should be closed', + '2020-01-01T17:00:00Z', + false, + ['Stale'] + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -90,11 +108,20 @@ test('processing a stale issue will close it', async () => { test('processing a stale PR will close it', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first PR', '2020-01-01T17:00:00Z', true, ['Stale']) + generateIssue( + 1, + 'A stale PR that should be closed', + '2020-01-01T17:00:00Z', + true, + ['Stale'] + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -106,11 +133,20 @@ test('processing a stale PR will close it', async () => { test('closed issues will not be marked stale', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, [], true) + generateIssue( + 1, + 'A closed issue that will not be marked', + '2020-01-01T17:00:00Z', + false, + [], + true + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [] ); // process our fake issue list @@ -122,11 +158,21 @@ test('closed issues will not be marked stale', async () => { test('stale closed issues will not be closed', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Stale'], true) + generateIssue( + 1, + 'A stale closed issue', + '2020-01-01T17:00:00Z', + false, + ['Stale'], + true + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -138,11 +184,21 @@ test('stale closed issues will not be closed', async () => { test('closed prs will not be marked stale', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first PR', '2020-01-01T17:00:00Z', true, [], true) + generateIssue( + 1, + 'A closed PR that will not be marked', + '2020-01-01T17:00:00Z', + true, + [], + true + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -154,11 +210,21 @@ test('closed prs will not be marked stale', async () => { test('stale closed prs will not be closed', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first PR', '2020-01-01T17:00:00Z', true, ['Stale'], true) + generateIssue( + 1, + 'A stale closed PR that will not be closed again', + '2020-01-01T17:00:00Z', + true, + ['Stale'], + true + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -170,7 +236,15 @@ test('stale closed prs will not be closed', async () => { test('locked issues will not be marked stale', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, [], false, true) + generateIssue( + 1, + 'A locked issue that will not be stale', + '2020-01-01T17:00:00Z', + false, + [], + false, + true + ) ]; const processor = new IssueProcessor(DefaultProcessorOptions, async p => @@ -186,11 +260,22 @@ test('locked issues will not be marked stale', async () => { test('stale locked issues will not be closed', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Stale'], false, true) + generateIssue( + 1, + 'A stale locked issue that will not be closed', + '2020-01-01T17:00:00Z', + false, + ['Stale'], + false, + true + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -202,7 +287,15 @@ test('stale locked issues will not be closed', async () => { test('locked prs will not be marked stale', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first PR', '2020-01-01T17:00:00Z', true, [], false, true) + generateIssue( + 1, + 'A locked PR that will not be marked stale', + '2020-01-01T17:00:00Z', + true, + [], + false, + true + ) ]; const processor = new IssueProcessor(DefaultProcessorOptions, async p => @@ -218,11 +311,22 @@ test('locked prs will not be marked stale', async () => { test('stale locked prs will not be closed', async () => { const TestIssueList: Issue[] = [ - generateIssue(1, 'My first PR', '2020-01-01T17:00:00Z', true, ['Stale'], false, true) + generateIssue( + 1, + 'A stale locked PR that will not be closed', + '2020-01-01T17:00:00Z', + true, + ['Stale'], + false, + true + ) ]; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + DefaultProcessorOptions, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -239,11 +343,14 @@ test('exempt issue labels will not be marked stale', async () => { ]) ]; - let opts = DefaultProcessorOptions; + const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt'; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + opts, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -258,11 +365,14 @@ test('exempt issue labels will not be marked stale (multi issue label with space generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool']) ]; - let opts = DefaultProcessorOptions; + const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt, Cool, None'; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + opts, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -277,11 +387,14 @@ test('exempt issue labels will not be marked stale (multi issue label)', async ( generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool']) ]; - let opts = DefaultProcessorOptions; + const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Exempt,Cool,None'; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + opts, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -298,11 +411,14 @@ test('exempt pr labels will not be marked stale', async () => { generateIssue(3, 'Another issue', '2020-01-01T17:00:00Z', false) ]; - let opts = DefaultProcessorOptions; + const opts = {...DefaultProcessorOptions}; opts.exemptIssueLabels = 'Cool'; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + opts, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -320,11 +436,14 @@ test('stale issues should not be closed if days is set to -1', async () => { generateIssue(3, 'Another issue', '2020-01-01T17:00:00Z', false, ['Stale']) ]; - let opts = DefaultProcessorOptions; + const opts = {...DefaultProcessorOptions}; opts.daysBeforeClose = -1; - const processor = new IssueProcessor(DefaultProcessorOptions, async p => - p == 1 ? TestIssueList : [] + const processor = new IssueProcessor( + opts, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [], + async (issue, label) => new Date().toDateString() ); // process our fake issue list @@ -332,3 +451,32 @@ test('stale issues should not be closed if days is set to -1', async () => { expect(processor.closedIssues.length).toEqual(0); }); + +test('stale label should be removed if a comment was added to a stale issue', async () => { + const TestIssueList: Issue[] = [ + generateIssue( + 1, + 'An issue that should un-stale', + '2020-01-01T17:00:00Z', + false, + ['Stale'] + ) + ]; + + const opts = DefaultProcessorOptions; + opts.removeStaleWhenUpdated = true; + + const processor = new IssueProcessor( + opts, + async p => (p == 1 ? TestIssueList : []), + async (num, dt) => [{user: {type: 'User'}}], // return a fake comment so indicate there was an update + async (issue, label) => new Date().toDateString() + ); + + // process our fake issue list + await processor.processIssues(1); + + expect(processor.closedIssues.length).toEqual(0); + expect(processor.staleIssues.length).toEqual(0); + expect(processor.removedLabelIssues.length).toEqual(1); +}); diff --git a/action.yml b/action.yml index 0ea0f8ac..9cd6dbb4 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: true debug-only: description: 'Run the processor in debug mode without actually performing any operations on live issues.' default: false diff --git a/dist/index.js b/dist/index.js index eec4ac4d..33f2755a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3756,6 +3756,7 @@ function getAndValidateArgs() { exemptPrLabels: core.getInput('exempt-pr-labels'), onlyLabels: core.getInput('only-labels'), operationsPerRun: parseInt(core.getInput('operations-per-run', { required: true })), + removeStaleWhenUpdated: !(core.getInput('remove-stale-when-updated') === 'false'), debugOnly: core.getInput('debug-only') === 'true' }; for (const numberInput of [ @@ -8447,16 +8448,23 @@ const github = __importStar(__webpack_require__(469)); * Handle processing of issues for staleness/closure. */ class IssueProcessor { - constructor(options, getIssues) { + constructor(options, getIssues, listIssueComments, getLabelCreationDate) { this.operationsLeft = 0; this.staleIssues = []; this.closedIssues = []; + this.removedLabelIssues = []; this.options = options; this.operationsLeft = options.operationsPerRun; this.client = new github.GitHub(options.repoToken); if (getIssues) { this.getIssues = getIssues; } + if (listIssueComments) { + this.listIssueComments = listIssueComments; + } + if (getLabelCreationDate) { + this.getLabelCreationDate = getLabelCreationDate; + } if (this.options.debugOnly) { core.warning('Executing in debug mode. Debug output will be written but no issues will be processed.'); } @@ -8490,23 +8498,23 @@ class IssueProcessor { core.debug(`Skipping ${issueType} due to empty stale message`); continue; } + if (issue.state === 'closed') { + core.debug(`Skipping ${issueType} because it is closed`); + continue; // don't process closed issues + } + if (issue.locked) { + core.debug(`Skipping ${issueType} because it is locked`); + continue; // don't process locked issues + } if (exemptLabels.some((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}`); - yield this.closeIssue(issue); - this.operationsLeft -= 1; - } - else { - core.debug(`Ignoring stale ${issueType} because it was updated recenlty`); - } + yield this.processStaleIssue(issue, issueType, staleLabel); } - else if (IssueProcessor.wasLastUpdatedBefore(issue, this.options.daysBeforeStale)) { + else if (!IssueProcessor.updatedSince(issue.updated_at, this.options.daysBeforeStale)) { core.debug(`Marking ${issueType} stale because it was last updated on ${issue.updated_at}`); yield this.markStale(issue, staleMessage, staleLabel); this.operationsLeft -= 2; @@ -8516,6 +8524,57 @@ class IssueProcessor { return this.processIssues(page + 1); }); } + // handle all of the stale issue logic when we find a stale issue + processStaleIssue(issue, issueType, staleLabel) { + return __awaiter(this, void 0, void 0, function* () { + if (this.options.daysBeforeClose < 0) { + return; // nothing to do because we aren't closing stale issues + } + const markedStaleOn = yield this.getLabelCreationDate(issue, staleLabel); + const issueHasComments = yield this.isIssueStillStale(issue, markedStaleOn); + const issueHasUpdate = 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}`); + yield this.closeIssue(issue); + } + else { + if (this.options.removeStaleWhenUpdated) { + yield this.removeLabel(issue, staleLabel); + } + core.debug(`Ignoring stale ${issueType} because it was updated recenlty`); + } + }); + } + // checks to see if a given issue is still stale (has had activity on it) + isIssueStillStale(issue, sinceDate) { + return __awaiter(this, void 0, void 0, function* () { + 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 = yield this.listIssueComments(issue.number, sinceDate); + // if there are any user comments returned, issue is not stale anymore + return comments.filter(comment => comment.user.type === 'User').length > 0; + }); + } + // grab comments for an issue since a given date + listIssueComments(issueNumber, sinceDate) { + return __awaiter(this, void 0, void 0, function* () { + // find any comments since date on the given issue + const comments = yield this.client.issues.listComments({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issueNumber, + since: sinceDate + }); + return comments.data; + }); + } // grab issues from github in baches of 100 getIssues(page) { return __awaiter(this, void 0, void 0, function* () { @@ -8535,6 +8594,7 @@ class IssueProcessor { return __awaiter(this, void 0, void 0, function* () { core.debug(`Marking issue #${issue.number} - ${issue.title} as stale`); this.staleIssues.push(issue); + this.operationsLeft -= 2; if (this.options.debugOnly) { return; } @@ -8552,11 +8612,12 @@ class IssueProcessor { }); }); } - /// Close an issue based on staleness + // Close an issue based on staleness closeIssue(issue) { return __awaiter(this, void 0, void 0, function* () { core.debug(`Closing issue #${issue.number} - ${issue.title} for being stale`); this.closedIssues.push(issue); + this.operationsLeft -= 1; if (this.options.debugOnly) { return; } @@ -8568,14 +8629,49 @@ class IssueProcessor { }); }); } + // Remove a label from an issue + removeLabel(issue, label) { + return __awaiter(this, void 0, void 0, function* () { + core.debug(`Removing label ${label} from issue #${issue.number} - ${issue.title}`); + this.removedLabelIssues.push(issue); + this.operationsLeft -= 1; + if (this.options.debugOnly) { + return; + } + yield 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 + }); + }); + } + // 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/ + getLabelCreationDate(issue, label) { + return __awaiter(this, void 0, void 0, function* () { + 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 = yield this.client.paginate(options); + const reversedEvents = events.reverse(); + const staleLabeledEvent = reversedEvents.find(event => event.event === 'labeled' && event.label.name === label); + return staleLabeledEvent.created_at; + }); + } static isLabeled(issue, label) { const labelComparer = l => label.localeCompare(l.name, undefined, { sensitivity: 'accent' }) === 0; return issue.labels.filter(labelComparer).length > 0; } - static wasLastUpdatedBefore(issue, num_days) { + static updatedSince(timestamp, num_days) { const daysInMillis = 1000 * 60 * 60 * 24 * num_days; - const millisSinceLastUpdated = new Date().getTime() - new Date(issue.updated_at).getTime(); - return millisSinceLastUpdated >= daysInMillis; + const millisSinceLastUpdated = new Date().getTime() - new Date(timestamp).getTime(); + return millisSinceLastUpdated < daysInMillis; } static parseCommaSeparatedString(s) { // String.prototype.split defaults to [''] when called on an empty string diff --git a/src/IssueProcessor.ts b/src/IssueProcessor.ts index c4370746..d8426117 100644 --- a/src/IssueProcessor.ts +++ b/src/IssueProcessor.ts @@ -14,6 +14,20 @@ export interface Issue { locked: boolean; } +export interface User { + type: string; +} + +export interface Comment { + user: User; +} + +export interface IssueEvent { + created_at: string; + event: string; + label: Label; +} + export interface Label { name: string; } @@ -30,6 +44,7 @@ export interface IssueProcessorOptions { exemptPrLabels: string; onlyLabels: string; operationsPerRun: number; + removeStaleWhenUpdated: boolean; debugOnly: boolean; } @@ -43,10 +58,16 @@ export class IssueProcessor { readonly staleIssues: Issue[] = []; readonly closedIssues: Issue[] = []; + readonly removedLabelIssues: Issue[] = []; constructor( options: IssueProcessorOptions, - getIssues?: (page: number) => Promise + getIssues?: (page: number) => Promise, + listIssueComments?: ( + issueNumber: number, + sinceDate: string + ) => Promise, + getLabelCreationDate?: (issue: Issue, label: string) => Promise ) { this.options = options; this.operationsLeft = options.operationsPerRun; @@ -56,6 +77,14 @@ export class IssueProcessor { this.getIssues = getIssues; } + if (listIssueComments) { + this.listIssueComments = listIssueComments; + } + + if (getLabelCreationDate) { + this.getLabelCreationDate = getLabelCreationDate; + } + if (this.options.debugOnly) { core.warning( 'Executing in debug mode. Debug output will be written but no issues will be processed.' @@ -123,25 +152,12 @@ 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 +171,87 @@ 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`); + } + } + + // 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.listIssueComments(issue.number, sinceDate); + + // if there are any user comments returned, issue is not stale anymore + return comments.filter(comment => comment.user.type === 'User').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 + const comments = await this.client.issues.listComments({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + issue_number: issueNumber, + since: sinceDate + }); + + return comments.data; + } + // grab issues from github in baches of 100 private async getIssues(page: number): Promise { const issueResult: OctoKitIssueList = await this.client.issues.listForRepo( @@ -181,6 +278,8 @@ export class IssueProcessor { this.staleIssues.push(issue); + this.operationsLeft -= 2; + if (this.options.debugOnly) { return; } @@ -200,7 +299,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 +307,8 @@ export class IssueProcessor { this.closedIssues.push(issue); + this.operationsLeft -= 1; + if (this.options.debugOnly) { return; } @@ -220,17 +321,67 @@ 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.removedLabelIssues.push(issue); + + 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 + }); + } + + // 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(); - return millisSinceLastUpdated >= daysInMillis; + new Date().getTime() - new Date(timestamp).getTime(); + + return millisSinceLastUpdated < daysInMillis; } private static parseCommaSeparatedString(s: string): string[] { diff --git a/src/main.ts b/src/main.ts index ebf61e48..ebc5a8a3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -32,6 +32,9 @@ function getAndValidateArgs(): IssueProcessorOptions { operationsPerRun: parseInt( core.getInput('operations-per-run', {required: true}) ), + removeStaleWhenUpdated: !( + core.getInput('remove-stale-when-updated') === 'false' + ), debugOnly: core.getInput('debug-only') === 'true' }; diff --git a/tsconfig.json b/tsconfig.json index 353d2ea1..a3871fa7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ "strict": true, /* Enable all strict type-checking options. */ "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + //"sourceMap": true }, "exclude": ["node_modules", "**/*.test.ts"] } \ No newline at end of file