Add support for adding & removing labels when no longer stale (#468)

* Add support for adding & removing labels when no longer stale

* Add remove/addLabelsWhenUpdatedFromStale to relevant spec files. Modify arguments to remove ambiguity in 'labels' var & parameter

* Change parameters for clarity, let autoformat do its thing

* PR feedback: More useful logging when removing labels

* Wrap client calls in try catches

* Use Unstale in variable names

* Don't run add label logic under debug

* Add test for labels added to unstale issues

* PR Feedback: logging

* Update README

* Rename vars to labels-to-add/remove-when-unstale

* Apply doc suggestions from code review

Co-authored-by: Geoffrey Testelin <geoffrey.testelin@gmail.com>

* PR Feedback

Co-authored-by: Geoffrey Testelin <geoffrey.testelin@gmail.com>
This commit is contained in:
Ben Villalobos 2021-06-08 06:31:20 -07:00 committed by GitHub
parent 52f5648db3
commit b1da9e1fb1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 212 additions and 11 deletions

View File

@ -43,6 +43,8 @@ Every argument is optional.
| [remove-stale-when-updated](#remove-stale-when-updated) | Remove stale label from issues/PRs on updates/comments | `true` | | [remove-stale-when-updated](#remove-stale-when-updated) | Remove stale label from issues/PRs on updates/comments | `true` |
| [remove-issue-stale-when-updated](#remove-issue-stale-when-updated) | Remove stale label from issues on updates/comments | | | [remove-issue-stale-when-updated](#remove-issue-stale-when-updated) | Remove stale label from issues on updates/comments | |
| [remove-pr-stale-when-updated](#remove-pr-stale-when-updated) | Remove stale label from PRs on updates/comments | | | [remove-pr-stale-when-updated](#remove-pr-stale-when-updated) | Remove stale label from PRs on updates/comments | |
| [labels-to-add-when-unstale](#labels-to-add-when-unstale) | Add specified labels from issues/PRs when they become unstale | |
| [labels-to-remove-when-unstale](#labels-to-remove-when-unstale) | Remove specified labels from issues/PRs when they become unstale | |
| [debug-only](#debug-only) | Dry-run | `false` | | [debug-only](#debug-only) | Dry-run | `false` |
| [ascending](#ascending) | Order to get issues/PRs | `false` | | [ascending](#ascending) | Order to get issues/PRs | `false` |
| [start-date](#start-date) | Skip stale action for issues/PRs created before it | | | [start-date](#start-date) | Skip stale action for issues/PRs created before it | |
@ -307,6 +309,20 @@ Override [remove-stale-when-updated](#remove-stale-when-updated) but only to aut
Default value: unset Default value: unset
#### labels-to-add-when-unstale
A comma delimited list of labels to add when a stale issue or pull request receives activity and has the [stale-issue-label](#stale-issue-label) or [stale-pr-label](#stale-pr-label) removed from it.
Default value: unset
#### labels-to-remove-when-unstale
A comma delimited list of labels to remove when a stale issue or pull request receives activity and has the [stale-issue-label](#stale-issue-label) or [stale-pr-label](#stale-pr-label) removed from it.
Warning: each label results in a unique API call which can drastically consume the limit of [operations-per-run](#operations-per-run).
Default value: unset
#### debug-only #### debug-only
Run the stale workflow as dry-run. Run the stale workflow as dry-run.

View File

@ -44,5 +44,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
exemptAllAssignees: false, exemptAllAssignees: false,
exemptAllIssueAssignees: undefined, exemptAllIssueAssignees: undefined,
exemptAllPrAssignees: undefined, exemptAllPrAssignees: undefined,
enableStatistics: true enableStatistics: true,
labelsToRemoveWhenUnstale: '',
labelsToAddWhenUnstale: ''
}); });

View File

@ -1255,6 +1255,50 @@ test('stale label should be removed if a comment was added to a stale issue', as
expect(processor.removedLabelIssues).toHaveLength(1); expect(processor.removedLabelIssues).toHaveLength(1);
}); });
test('when the option "labelsToAddWhenUnstale" is set, the labels should be added when unstale', async () => {
expect.assertions(4);
const opts = {
...DefaultProcessorOptions,
removeStaleWhenUpdated: true,
labelsToAddWhenUnstale: 'test'
};
const TestIssueList: Issue[] = [
generateIssue(
opts,
1,
'An issue that should have labels added to it when unstale',
'2020-01-01T17:00:00Z',
'2020-01-01T17:00:00Z',
false,
['Stale']
)
];
const processor = new IssuesProcessorMock(
opts,
async () => 'abot',
async p => (p === 1 ? TestIssueList : []),
async () => [
{
user: {
login: 'notme',
type: 'User'
}
}
], // return a fake comment to indicate there was an update
async () => new Date().toDateString()
);
// process our fake issue list
await processor.processIssues(1);
expect(processor.closedIssues).toHaveLength(0);
expect(processor.staleIssues).toHaveLength(0);
// Stale should have been removed
expect(processor.removedLabelIssues).toHaveLength(1);
// Some label should have been added
expect(processor.addedLabelIssues).toHaveLength(1);
});
test('stale label should not be removed if a comment was added by the bot (and the issue should be closed)', async () => { test('stale label should not be removed if a comment was added by the bot (and the issue should be closed)', async () => {
const opts = {...DefaultProcessorOptions, removeStaleWhenUpdated: true}; const opts = {...DefaultProcessorOptions, removeStaleWhenUpdated: true};
github.context.actor = 'abot'; github.context.actor = 'abot';

58
dist/index.js vendored
View File

@ -253,6 +253,7 @@ class IssuesProcessor {
this.closedIssues = []; this.closedIssues = [];
this.deletedBranchIssues = []; this.deletedBranchIssues = [];
this.removedLabelIssues = []; this.removedLabelIssues = [];
this.addedLabelIssues = [];
this.options = options; this.options = options;
this.client = github_1.getOctokit(this.options.repoToken); this.client = github_1.getOctokit(this.options.repoToken);
this.operations = new stale_operations_1.StaleOperations(this.options); this.operations = new stale_operations_1.StaleOperations(this.options);
@ -299,6 +300,8 @@ class IssuesProcessor {
else { else {
this._logger.info(`${logger_service_1.LoggerService.yellow('Processing the batch of issues')} ${logger_service_1.LoggerService.cyan(`#${page}`)} ${logger_service_1.LoggerService.yellow('containing')} ${logger_service_1.LoggerService.cyan(issues.length)} ${logger_service_1.LoggerService.yellow(`issue${issues.length > 1 ? 's' : ''}...`)}`); this._logger.info(`${logger_service_1.LoggerService.yellow('Processing the batch of issues')} ${logger_service_1.LoggerService.cyan(`#${page}`)} ${logger_service_1.LoggerService.yellow('containing')} ${logger_service_1.LoggerService.cyan(issues.length)} ${logger_service_1.LoggerService.yellow(`issue${issues.length > 1 ? 's' : ''}...`)}`);
} }
const labelsToAddWhenUnstale = words_to_list_1.wordsToList(this.options.labelsToAddWhenUnstale);
const labelsToRemoveWhenUnstale = words_to_list_1.wordsToList(this.options.labelsToRemoveWhenUnstale);
for (const issue of issues.values()) { for (const issue of issues.values()) {
// Stop the processing if no more operations remains // Stop the processing if no more operations remains
if (!this.operations.hasRemainingOperations()) { if (!this.operations.hasRemainingOperations()) {
@ -306,7 +309,7 @@ class IssuesProcessor {
} }
const issueLogger = new issue_logger_1.IssueLogger(issue); const issueLogger = new issue_logger_1.IssueLogger(issue);
yield issueLogger.grouping(`$$type #${issue.number}`, () => __awaiter(this, void 0, void 0, function* () { yield issueLogger.grouping(`$$type #${issue.number}`, () => __awaiter(this, void 0, void 0, function* () {
yield this.processIssue(issue, actor); yield this.processIssue(issue, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale);
})); }));
} }
if (!this.operations.hasRemainingOperations()) { if (!this.operations.hasRemainingOperations()) {
@ -320,7 +323,7 @@ class IssuesProcessor {
return this.processIssues(page + 1); return this.processIssues(page + 1);
}); });
} }
processIssue(issue, actor) { processIssue(issue, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale) {
var _a; var _a;
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
(_a = this._statistics) === null || _a === void 0 ? void 0 : _a.incrementProcessedItemsCount(issue); (_a = this._statistics) === null || _a === void 0 ? void 0 : _a.incrementProcessedItemsCount(issue);
@ -469,7 +472,7 @@ class IssuesProcessor {
// Process the issue if it was marked stale // Process the issue if it was marked stale
if (issue.isStale) { if (issue.isStale) {
issueLogger.info(`This $$type is already stale`); issueLogger.info(`This $$type is already stale`);
yield this._processStaleIssue(issue, staleLabel, actor, closeMessage, closeLabel); yield this._processStaleIssue(issue, staleLabel, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, closeMessage, closeLabel);
} }
IssuesProcessor._endIssueProcessing(issue); IssuesProcessor._endIssueProcessing(issue);
}); });
@ -561,7 +564,7 @@ class IssuesProcessor {
}); });
} }
// handle all of the stale issue logic when we find a stale issue // handle all of the stale issue logic when we find a stale issue
_processStaleIssue(issue, staleLabel, actor, closeMessage, closeLabel) { _processStaleIssue(issue, staleLabel, actor, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, closeMessage, closeLabel) {
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
const issueLogger = new issue_logger_1.IssueLogger(issue); const issueLogger = new issue_logger_1.IssueLogger(issue);
const markedStaleOn = (yield this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at; const markedStaleOn = (yield this.getLabelCreationDate(issue, staleLabel)) || issue.updated_at;
@ -586,6 +589,9 @@ class IssuesProcessor {
if (shouldRemoveStaleWhenUpdated && issueHasComments) { if (shouldRemoveStaleWhenUpdated && issueHasComments) {
issueLogger.info(`Remove the stale label since the $$type has a comment and the workflow should remove the stale label when updated`); issueLogger.info(`Remove the stale label since the $$type has a comment and the workflow should remove the stale label when updated`);
yield this._removeStaleLabel(issue, staleLabel); yield this._removeStaleLabel(issue, staleLabel);
// Are there labels to remove or add when an issue is no longer stale?
yield this._removeLabelsWhenUnstale(issue, labelsToRemoveWhenUnstale);
yield this._addLabelsWhenUnstale(issue, labelsToAddWhenUnstale);
issueLogger.info(`Skipping the process since the $$type is now un-stale`); issueLogger.info(`Skipping the process since the $$type is now un-stale`);
return; // Nothing to do because it is no longer stale return; // Nothing to do because it is no longer stale
} }
@ -854,6 +860,44 @@ class IssuesProcessor {
} }
return this.options.removeStaleWhenUpdated; return this.options.removeStaleWhenUpdated;
} }
_removeLabelsWhenUnstale(issue, removeLabels) {
return __awaiter(this, void 0, void 0, function* () {
if (!removeLabels.length) {
return;
}
const issueLogger = new issue_logger_1.IssueLogger(issue);
issueLogger.info(`Removing all the labels specified via the ${this._logger.createOptionLink(option_1.Option.LabelsToRemoveWhenUnstale)} option.`);
for (const label of removeLabels.values()) {
yield this._removeLabel(issue, label);
}
});
}
_addLabelsWhenUnstale(issue, labelsToAdd) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
if (!labelsToAdd.length) {
return;
}
const issueLogger = new issue_logger_1.IssueLogger(issue);
issueLogger.info(`Adding all the labels specified via the ${this._logger.createOptionLink(option_1.Option.LabelsToAddWhenUnstale)} option.`);
this.addedLabelIssues.push(issue);
try {
this.operations.consumeOperation();
(_a = this._statistics) === null || _a === void 0 ? void 0 : _a.incrementAddedItemsLabel(issue);
if (!this.options.debugOnly) {
yield this.client.issues.addLabels({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
issue_number: issue.number,
labels: labelsToAdd
});
}
}
catch (error) {
this._logger.error(`Error when adding labels after updated from stale: ${error.message}`);
}
});
}
_removeStaleLabel(issue, staleLabel) { _removeStaleLabel(issue, staleLabel) {
var _a; var _a;
return __awaiter(this, void 0, void 0, function* () { return __awaiter(this, void 0, void 0, function* () {
@ -1692,6 +1736,8 @@ var Option;
Option["ExemptAllIssueAssignees"] = "exempt-all-issue-assignees"; Option["ExemptAllIssueAssignees"] = "exempt-all-issue-assignees";
Option["ExemptAllPrAssignees"] = "exempt-all-pr-assignees"; Option["ExemptAllPrAssignees"] = "exempt-all-pr-assignees";
Option["EnableStatistics"] = "enable-statistics"; Option["EnableStatistics"] = "enable-statistics";
Option["LabelsToRemoveWhenUnstale"] = "labels-to-remove-when-unstale";
Option["LabelsToAddWhenUnstale"] = "labels-to-add-when-unstale";
})(Option = exports.Option || (exports.Option = {})); })(Option = exports.Option || (exports.Option = {}));
@ -1975,7 +2021,9 @@ function _getAndValidateArgs() {
exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true', exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true',
exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'), exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'),
exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees'), exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees'),
enableStatistics: core.getInput('enable-statistics') === 'true' enableStatistics: core.getInput('enable-statistics') === 'true',
labelsToRemoveWhenUnstale: core.getInput('labels-to-remove-when-unstale'),
labelsToAddWhenUnstale: core.getInput('labels-to-add-when-unstale')
}; };
for (const numberInput of [ for (const numberInput of [
'days-before-stale', 'days-before-stale',

View File

@ -55,7 +55,9 @@ describe('Issue', (): void => {
exemptAllAssignees: false, exemptAllAssignees: false,
exemptAllIssueAssignees: undefined, exemptAllIssueAssignees: undefined,
exemptAllPrAssignees: undefined, exemptAllPrAssignees: undefined,
enableStatistics: false enableStatistics: false,
labelsToRemoveWhenUnstale: '',
labelsToAddWhenUnstale: ''
}; };
issueInterface = { issueInterface = {
title: 'dummy-title', title: 'dummy-title',

View File

@ -75,6 +75,7 @@ export class IssuesProcessor {
readonly closedIssues: Issue[] = []; readonly closedIssues: Issue[] = [];
readonly deletedBranchIssues: Issue[] = []; readonly deletedBranchIssues: Issue[] = [];
readonly removedLabelIssues: Issue[] = []; readonly removedLabelIssues: Issue[] = [];
readonly addedLabelIssues: Issue[] = [];
constructor(options: IIssuesProcessorOptions) { constructor(options: IIssuesProcessorOptions) {
this.options = options; this.options = options;
@ -127,6 +128,13 @@ export class IssuesProcessor {
); );
} }
const labelsToAddWhenUnstale: string[] = wordsToList(
this.options.labelsToAddWhenUnstale
);
const labelsToRemoveWhenUnstale: string[] = wordsToList(
this.options.labelsToRemoveWhenUnstale
);
for (const issue of issues.values()) { for (const issue of issues.values()) {
// Stop the processing if no more operations remains // Stop the processing if no more operations remains
if (!this.operations.hasRemainingOperations()) { if (!this.operations.hasRemainingOperations()) {
@ -135,7 +143,12 @@ export class IssuesProcessor {
const issueLogger: IssueLogger = new IssueLogger(issue); const issueLogger: IssueLogger = new IssueLogger(issue);
await issueLogger.grouping(`$$type #${issue.number}`, async () => { await issueLogger.grouping(`$$type #${issue.number}`, async () => {
await this.processIssue(issue, actor); await this.processIssue(
issue,
actor,
labelsToAddWhenUnstale,
labelsToRemoveWhenUnstale
);
}); });
} }
@ -169,7 +182,12 @@ export class IssuesProcessor {
return this.processIssues(page + 1); return this.processIssues(page + 1);
} }
async processIssue(issue: Issue, actor: string): Promise<void> { async processIssue(
issue: Issue,
actor: string,
labelsToAddWhenUnstale: Readonly<string>[],
labelsToRemoveWhenUnstale: Readonly<string>[]
): Promise<void> {
this._statistics?.incrementProcessedItemsCount(issue); this._statistics?.incrementProcessedItemsCount(issue);
const issueLogger: IssueLogger = new IssueLogger(issue); const issueLogger: IssueLogger = new IssueLogger(issue);
@ -438,6 +456,8 @@ export class IssuesProcessor {
issue, issue,
staleLabel, staleLabel,
actor, actor,
labelsToAddWhenUnstale,
labelsToRemoveWhenUnstale,
closeMessage, closeMessage,
closeLabel closeLabel
); );
@ -549,6 +569,8 @@ export class IssuesProcessor {
issue: Issue, issue: Issue,
staleLabel: string, staleLabel: string,
actor: string, actor: string,
labelsToAddWhenUnstale: Readonly<string>[],
labelsToRemoveWhenUnstale: Readonly<string>[],
closeMessage?: string, closeMessage?: string,
closeLabel?: string closeLabel?: string
) { ) {
@ -608,6 +630,10 @@ export class IssuesProcessor {
); );
await this._removeStaleLabel(issue, staleLabel); await this._removeStaleLabel(issue, staleLabel);
// Are there labels to remove or add when an issue is no longer stale?
await this._removeLabelsWhenUnstale(issue, labelsToRemoveWhenUnstale);
await this._addLabelsWhenUnstale(issue, labelsToAddWhenUnstale);
issueLogger.info(`Skipping the process since the $$type is now un-stale`); issueLogger.info(`Skipping the process since the $$type is now un-stale`);
return; // Nothing to do because it is no longer stale return; // Nothing to do because it is no longer stale
@ -956,6 +982,63 @@ export class IssuesProcessor {
return this.options.removeStaleWhenUpdated; return this.options.removeStaleWhenUpdated;
} }
private async _removeLabelsWhenUnstale(
issue: Issue,
removeLabels: Readonly<string>[]
): Promise<void> {
if (!removeLabels.length) {
return;
}
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(
`Removing all the labels specified via the ${this._logger.createOptionLink(
Option.LabelsToRemoveWhenUnstale
)} option.`
);
for (const label of removeLabels.values()) {
await this._removeLabel(issue, label);
}
}
private async _addLabelsWhenUnstale(
issue: Issue,
labelsToAdd: Readonly<string>[]
): Promise<void> {
if (!labelsToAdd.length) {
return;
}
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(
`Adding all the labels specified via the ${this._logger.createOptionLink(
Option.LabelsToAddWhenUnstale
)} option.`
);
this.addedLabelIssues.push(issue);
try {
this.operations.consumeOperation();
this._statistics?.incrementAddedItemsLabel(issue);
if (!this.options.debugOnly) {
await this.client.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labelsToAdd
});
}
} catch (error) {
this._logger.error(
`Error when adding labels after updated from stale: ${error.message}`
);
}
}
private async _removeStaleLabel( private async _removeStaleLabel(
issue: Issue, issue: Issue,
staleLabel: Readonly<string> staleLabel: Readonly<string>

View File

@ -40,5 +40,7 @@ export enum Option {
ExemptAllAssignees = 'exempt-all-assignees', ExemptAllAssignees = 'exempt-all-assignees',
ExemptAllIssueAssignees = 'exempt-all-issue-assignees', ExemptAllIssueAssignees = 'exempt-all-issue-assignees',
ExemptAllPrAssignees = 'exempt-all-pr-assignees', ExemptAllPrAssignees = 'exempt-all-pr-assignees',
EnableStatistics = 'enable-statistics' EnableStatistics = 'enable-statistics',
LabelsToRemoveWhenUnstale = 'labels-to-remove-when-unstale',
LabelsToAddWhenUnstale = 'labels-to-add-when-unstale'
} }

View File

@ -45,4 +45,6 @@ export interface IIssuesProcessorOptions {
exemptAllIssueAssignees: boolean | undefined; exemptAllIssueAssignees: boolean | undefined;
exemptAllPrAssignees: boolean | undefined; exemptAllPrAssignees: boolean | undefined;
enableStatistics: boolean; enableStatistics: boolean;
labelsToRemoveWhenUnstale: string;
labelsToAddWhenUnstale: string;
} }

View File

@ -81,7 +81,9 @@ function _getAndValidateArgs(): IIssuesProcessorOptions {
exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true', exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true',
exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'), exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'),
exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees'), exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees'),
enableStatistics: core.getInput('enable-statistics') === 'true' enableStatistics: core.getInput('enable-statistics') === 'true',
labelsToRemoveWhenUnstale: core.getInput('labels-to-remove-when-unstale'),
labelsToAddWhenUnstale: core.getInput('labels-to-add-when-unstale')
}; };
for (const numberInput of [ for (const numberInput of [