Remove stale labels and ignore stale issues with comments (#58)

* Ignore issues that have recent comments and ignore the stale label. Defaulting to this behavior but added an option to turn it off.
* Fix up tests a bit to make this runnable. Add a test for the logic.
* Add vscode debugging configuration.
This commit is contained in:
Ross Brodbeck 2020-05-11 10:46:03 -04:00 committed by GitHub
parent 4f9b6a7a5c
commit 3838b887be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 507 additions and 85 deletions

20
.vscode/launch.json vendored Normal file
View File

@ -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/**/*"
]
}
]
}

View File

@ -43,11 +43,17 @@ const DefaultProcessorOptions: IssueProcessorOptions = {
exemptPrLabels: '', exemptPrLabels: '',
onlyLabels: '', onlyLabels: '',
operationsPerRun: 100, operationsPerRun: 100,
debugOnly: true debugOnly: true,
removeStaleWhenUpdated: false
}; };
test('empty issue list results in 1 operation', async () => { 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 // process our fake issue list
const operationsLeft = await processor.processIssues(1); 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 () => { test('processing an issue with no label will make it stale', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('processing a stale issue will close it', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('processing a stale PR will close it', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('closed issues will not be marked stale', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => []
); );
// process our fake issue list // 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 () => { test('stale closed issues will not be closed', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('closed prs will not be marked stale', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('stale closed prs will not be closed', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('locked issues will not be marked stale', async () => {
const TestIssueList: Issue[] = [ 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 => 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 () => { test('stale locked issues will not be closed', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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 () => { test('locked prs will not be marked stale', async () => {
const TestIssueList: Issue[] = [ 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 => 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 () => { test('stale locked prs will not be closed', async () => {
const TestIssueList: Issue[] = [ 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 => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] DefaultProcessorOptions,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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'; opts.exemptIssueLabels = 'Exempt';
const processor = new IssueProcessor(DefaultProcessorOptions, async p => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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']) generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool'])
]; ];
let opts = DefaultProcessorOptions; const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Exempt, Cool, None'; opts.exemptIssueLabels = 'Exempt, Cool, None';
const processor = new IssueProcessor(DefaultProcessorOptions, async p => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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']) generateIssue(1, 'My first issue', '2020-01-01T17:00:00Z', false, ['Cool'])
]; ];
let opts = DefaultProcessorOptions; const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Exempt,Cool,None'; opts.exemptIssueLabels = 'Exempt,Cool,None';
const processor = new IssueProcessor(DefaultProcessorOptions, async p => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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) generateIssue(3, 'Another issue', '2020-01-01T17:00:00Z', false)
]; ];
let opts = DefaultProcessorOptions; const opts = {...DefaultProcessorOptions};
opts.exemptIssueLabels = 'Cool'; opts.exemptIssueLabels = 'Cool';
const processor = new IssueProcessor(DefaultProcessorOptions, async p => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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']) generateIssue(3, 'Another issue', '2020-01-01T17:00:00Z', false, ['Stale'])
]; ];
let opts = DefaultProcessorOptions; const opts = {...DefaultProcessorOptions};
opts.daysBeforeClose = -1; opts.daysBeforeClose = -1;
const processor = new IssueProcessor(DefaultProcessorOptions, async p => const processor = new IssueProcessor(
p == 1 ? TestIssueList : [] opts,
async p => (p == 1 ? TestIssueList : []),
async (num, dt) => [],
async (issue, label) => new Date().toDateString()
); );
// process our fake issue list // 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); 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);
});

View File

@ -33,6 +33,9 @@ inputs:
operations-per-run: operations-per-run:
description: 'The maximum number of operations per run, used to control rate limiting.' description: 'The maximum number of operations per run, used to control rate limiting.'
default: 30 default: 30
remove-stale-when-updated:
description: 'Remove stale labels from issues when they are updated or commented on.'
default: true
debug-only: debug-only:
description: 'Run the processor in debug mode without actually performing any operations on live issues.' description: 'Run the processor in debug mode without actually performing any operations on live issues.'
default: false default: false

126
dist/index.js vendored
View File

@ -3756,6 +3756,7 @@ function getAndValidateArgs() {
exemptPrLabels: core.getInput('exempt-pr-labels'), exemptPrLabels: core.getInput('exempt-pr-labels'),
onlyLabels: core.getInput('only-labels'), onlyLabels: core.getInput('only-labels'),
operationsPerRun: parseInt(core.getInput('operations-per-run', { required: true })), operationsPerRun: parseInt(core.getInput('operations-per-run', { required: true })),
removeStaleWhenUpdated: !(core.getInput('remove-stale-when-updated') === 'false'),
debugOnly: core.getInput('debug-only') === 'true' debugOnly: core.getInput('debug-only') === 'true'
}; };
for (const numberInput of [ for (const numberInput of [
@ -8447,16 +8448,23 @@ const github = __importStar(__webpack_require__(469));
* Handle processing of issues for staleness/closure. * Handle processing of issues for staleness/closure.
*/ */
class IssueProcessor { class IssueProcessor {
constructor(options, getIssues) { constructor(options, getIssues, listIssueComments, getLabelCreationDate) {
this.operationsLeft = 0; this.operationsLeft = 0;
this.staleIssues = []; this.staleIssues = [];
this.closedIssues = []; this.closedIssues = [];
this.removedLabelIssues = [];
this.options = options; this.options = options;
this.operationsLeft = options.operationsPerRun; this.operationsLeft = options.operationsPerRun;
this.client = new github.GitHub(options.repoToken); this.client = new github.GitHub(options.repoToken);
if (getIssues) { if (getIssues) {
this.getIssues = getIssues; this.getIssues = getIssues;
} }
if (listIssueComments) {
this.listIssueComments = listIssueComments;
}
if (getLabelCreationDate) {
this.getLabelCreationDate = getLabelCreationDate;
}
if (this.options.debugOnly) { if (this.options.debugOnly) {
core.warning('Executing in debug mode. Debug output will be written but no issues will be processed.'); 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`); core.debug(`Skipping ${issueType} due to empty stale message`);
continue; 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))) { if (exemptLabels.some((exemptLabel) => IssueProcessor.isLabeled(issue, exemptLabel))) {
core.debug(`Skipping ${issueType} because it has an exempt label`); core.debug(`Skipping ${issueType} because it has an exempt label`);
continue; // don't process exempt issues continue; // don't process exempt issues
} }
if (IssueProcessor.isLabeled(issue, staleLabel)) { if (IssueProcessor.isLabeled(issue, staleLabel)) {
core.debug(`Found a stale ${issueType}`); core.debug(`Found a stale ${issueType}`);
if (this.options.daysBeforeClose >= 0 && yield this.processStaleIssue(issue, issueType, staleLabel);
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 { else if (!IssueProcessor.updatedSince(issue.updated_at, this.options.daysBeforeStale)) {
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}`); core.debug(`Marking ${issueType} stale because it was last updated on ${issue.updated_at}`);
yield this.markStale(issue, staleMessage, staleLabel); yield this.markStale(issue, staleMessage, staleLabel);
this.operationsLeft -= 2; this.operationsLeft -= 2;
@ -8516,6 +8524,57 @@ class IssueProcessor {
return this.processIssues(page + 1); 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 // grab issues from github in baches of 100
getIssues(page) { getIssues(page) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
@ -8535,6 +8594,7 @@ class IssueProcessor {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
core.debug(`Marking issue #${issue.number} - ${issue.title} as stale`); core.debug(`Marking issue #${issue.number} - ${issue.title} as stale`);
this.staleIssues.push(issue); this.staleIssues.push(issue);
this.operationsLeft -= 2;
if (this.options.debugOnly) { if (this.options.debugOnly) {
return; return;
} }
@ -8552,11 +8612,12 @@ class IssueProcessor {
}); });
}); });
} }
/// Close an issue based on staleness // Close an issue based on staleness
closeIssue(issue) { closeIssue(issue) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
core.debug(`Closing issue #${issue.number} - ${issue.title} for being stale`); core.debug(`Closing issue #${issue.number} - ${issue.title} for being stale`);
this.closedIssues.push(issue); this.closedIssues.push(issue);
this.operationsLeft -= 1;
if (this.options.debugOnly) { if (this.options.debugOnly) {
return; 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) { static isLabeled(issue, label) {
const labelComparer = l => label.localeCompare(l.name, undefined, { sensitivity: 'accent' }) === 0; const labelComparer = l => label.localeCompare(l.name, undefined, { sensitivity: 'accent' }) === 0;
return issue.labels.filter(labelComparer).length > 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 daysInMillis = 1000 * 60 * 60 * 24 * num_days;
const millisSinceLastUpdated = new Date().getTime() - new Date(issue.updated_at).getTime(); const millisSinceLastUpdated = new Date().getTime() - new Date(timestamp).getTime();
return millisSinceLastUpdated >= daysInMillis; return millisSinceLastUpdated < daysInMillis;
} }
static parseCommaSeparatedString(s) { static parseCommaSeparatedString(s) {
// String.prototype.split defaults to [''] when called on an empty string // String.prototype.split defaults to [''] when called on an empty string

View File

@ -14,6 +14,20 @@ export interface Issue {
locked: boolean; 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 { export interface Label {
name: string; name: string;
} }
@ -30,6 +44,7 @@ export interface IssueProcessorOptions {
exemptPrLabels: string; exemptPrLabels: string;
onlyLabels: string; onlyLabels: string;
operationsPerRun: number; operationsPerRun: number;
removeStaleWhenUpdated: boolean;
debugOnly: boolean; debugOnly: boolean;
} }
@ -43,10 +58,16 @@ export class IssueProcessor {
readonly staleIssues: Issue[] = []; readonly staleIssues: Issue[] = [];
readonly closedIssues: Issue[] = []; readonly closedIssues: Issue[] = [];
readonly removedLabelIssues: Issue[] = [];
constructor( constructor(
options: IssueProcessorOptions, options: IssueProcessorOptions,
getIssues?: (page: number) => Promise<Issue[]> getIssues?: (page: number) => Promise<Issue[]>,
listIssueComments?: (
issueNumber: number,
sinceDate: string
) => Promise<Comment[]>,
getLabelCreationDate?: (issue: Issue, label: string) => Promise<string>
) { ) {
this.options = options; this.options = options;
this.operationsLeft = options.operationsPerRun; this.operationsLeft = options.operationsPerRun;
@ -56,6 +77,14 @@ export class IssueProcessor {
this.getIssues = getIssues; this.getIssues = getIssues;
} }
if (listIssueComments) {
this.listIssueComments = listIssueComments;
}
if (getLabelCreationDate) {
this.getLabelCreationDate = getLabelCreationDate;
}
if (this.options.debugOnly) { if (this.options.debugOnly) {
core.warning( core.warning(
'Executing in debug mode. Debug output will be written but no issues will be processed.' '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)) { if (IssueProcessor.isLabeled(issue, staleLabel)) {
core.debug(`Found a stale ${issueType}`); core.debug(`Found a stale ${issueType}`);
if ( await this.processStaleIssue(issue, issueType, staleLabel);
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 ( } else if (
IssueProcessor.wasLastUpdatedBefore(issue, this.options.daysBeforeStale) !IssueProcessor.updatedSince(
issue.updated_at,
this.options.daysBeforeStale
)
) { ) {
core.debug( core.debug(
`Marking ${issueType} stale because it was last updated on ${issue.updated_at}` `Marking ${issueType} stale because it was last updated on ${issue.updated_at}`
@ -155,6 +171,87 @@ export class IssueProcessor {
return this.processIssues(page + 1); 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<boolean> {
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<Comment[]> {
// 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 // grab issues from github in baches of 100
private async getIssues(page: number): Promise<Issue[]> { private async getIssues(page: number): Promise<Issue[]> {
const issueResult: OctoKitIssueList = await this.client.issues.listForRepo( const issueResult: OctoKitIssueList = await this.client.issues.listForRepo(
@ -181,6 +278,8 @@ export class IssueProcessor {
this.staleIssues.push(issue); this.staleIssues.push(issue);
this.operationsLeft -= 2;
if (this.options.debugOnly) { if (this.options.debugOnly) {
return; 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<void> { private async closeIssue(issue: Issue): Promise<void> {
core.debug( core.debug(
`Closing issue #${issue.number} - ${issue.title} for being stale` `Closing issue #${issue.number} - ${issue.title} for being stale`
@ -208,6 +307,8 @@ export class IssueProcessor {
this.closedIssues.push(issue); this.closedIssues.push(issue);
this.operationsLeft -= 1;
if (this.options.debugOnly) { if (this.options.debugOnly) {
return; return;
} }
@ -220,17 +321,67 @@ export class IssueProcessor {
}); });
} }
// Remove a label from an issue
private async removeLabel(issue: Issue, label: string): Promise<void> {
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<string> {
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 { private static isLabeled(issue: Issue, label: string): boolean {
const labelComparer: (l: Label) => boolean = l => const labelComparer: (l: Label) => boolean = l =>
label.localeCompare(l.name, undefined, {sensitivity: 'accent'}) === 0; label.localeCompare(l.name, undefined, {sensitivity: 'accent'}) === 0;
return issue.labels.filter(labelComparer).length > 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 daysInMillis = 1000 * 60 * 60 * 24 * num_days;
const millisSinceLastUpdated = const millisSinceLastUpdated =
new Date().getTime() - new Date(issue.updated_at).getTime(); new Date().getTime() - new Date(timestamp).getTime();
return millisSinceLastUpdated >= daysInMillis;
return millisSinceLastUpdated < daysInMillis;
} }
private static parseCommaSeparatedString(s: string): string[] { private static parseCommaSeparatedString(s: string): string[] {

View File

@ -32,6 +32,9 @@ function getAndValidateArgs(): IssueProcessorOptions {
operationsPerRun: parseInt( operationsPerRun: parseInt(
core.getInput('operations-per-run', {required: true}) core.getInput('operations-per-run', {required: true})
), ),
removeStaleWhenUpdated: !(
core.getInput('remove-stale-when-updated') === 'false'
),
debugOnly: core.getInput('debug-only') === 'true' debugOnly: core.getInput('debug-only') === 'true'
}; };

View File

@ -6,7 +6,8 @@
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ "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. */ "strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ "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"] "exclude": ["node_modules", "**/*.test.ts"]
} }