feat(exempt): add new options to exempt the milestones (#279)

* feat(exempt): add new options to exempt the milestones

closes #270

* test(milestones): add coverage

* test(issue): add coverage

* chore(rebase): fix all errors due to the rebase

also made some changes regarding the change I made with the lint scripts and prettier. I did not saw that some scripts were already here and I created to more to keep the old ones as well

* test(milestone): add coverage

* chore(index): update index

* fix(checks): remove checks over optional number options

the code was actually handling the case where the values are NaN so it's fine
This commit is contained in:
Geoffrey Testelin 2021-01-19 11:54:16 +01:00 committed by GitHub
parent 1b9f13b607
commit f71123a6f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 2184 additions and 596 deletions

View File

@ -43,6 +43,9 @@ $ npm test
| `close-pr-label` | Label to apply on the closing pr. | Optional |
| `exempt-issue-labels` | Labels on an issue exempted from being marked as stale. | Optional |
| `exempt-pr-labels` | Labels on the pr exempted from being marked as stale. | Optional |
| `exempt-milestones` | Milestones on an issue or a pr exempted from being marked as stale. | Optional |
| `exempt-issue-milestones` | Milestones on an issue exempted from being marked as stale (override `exempt-milestones`). | Optional |
| `exempt-pr-milestones` | Milestones on the pr exempted from being marked as stale (override `exempt-milestones`). | Optional |
| `only-labels` | Only labels checked for stale issue/pr. | Optional |
| `operations-per-run` | Maximum number of operations per run (GitHub API CRUD related). _Defaults to **30**_ | Optional |
| `remove-stale-when-updated` | Remove stale label from issue/pr on updates or comments. _Defaults to **true**_ | Optional |
@ -181,6 +184,24 @@ jobs:
start-date: '2020-18-04T00:00:00Z' // ISO 8601 or RFC 2822
```
Avoid stale for specific milestones:
```yaml
name: 'Close stale issues and PRs'
on:
schedule:
- cron: '30 1 * * *'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v3
with:
exempt-issue-milestones: 'future,alpha,beta'
exempt-pr-milestones: 'bugfix,improvement'
```
### Debugging
To see debug output from this action, you must set the secret `ACTIONS_STEP_DEBUG` to `true` in your repository. You can run this action in debug only mode (no actions will be taken on your issues) by passing `debug-only` `true` as an argument to the action.

File diff suppressed because it is too large Load Diff

View File

@ -23,20 +23,20 @@ inputs:
required: false
default: '60'
days-before-issue-stale:
description: 'The number of days old an issue can be before marking it stale. Set to -1 to never mark issues as stale automatically. Override "days-before-stale" option regarding the issues only.'
description: 'The number of days old an issue can be before marking it stale. Set to -1 to never mark issues as stale automatically. Override "days-before-stale" option regarding only the issues.'
required: false
days-before-pr-stale:
description: 'The number of days old a pull request can be before marking it stale. Set to -1 to never mark pull requests as stale automatically. Override "days-before-stale" option regarding the pull requests only.'
description: 'The number of days old a pull request can be before marking it stale. Set to -1 to never mark pull requests as stale automatically. Override "days-before-stale" option regarding only the pull requests.'
required: false
days-before-close:
description: 'The number of days to wait to close an issue or a pull request after it being marked stale. Set to -1 to never close stale issues or pull requests.'
required: false
default: '7'
days-before-issue-close:
description: 'The number of days to wait to close an issue after it being marked stale. Set to -1 to never close stale issues. Override "days-before-close" option regarding the issues only.'
description: 'The number of days to wait to close an issue after it being marked stale. Set to -1 to never close stale issues. Override "days-before-close" option regarding only the issues.'
required: false
days-before-pr-close:
description: 'The number of days to wait to close a pull request after it being marked stale. Set to -1 to never close stale pull requests. Override "days-before-close" option regarding the pull requests only.'
description: 'The number of days to wait to close a pull request after it being marked stale. Set to -1 to never close stale pull requests. Override "days-before-close" option regarding only the pull requests.'
required: false
stale-issue-label:
description: 'The label to apply when an issue is stale.'
@ -60,6 +60,18 @@ inputs:
description: 'The labels that mean a pull request is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2")'
default: ''
required: false
exempt-milestones:
description: 'The milestones that mean an issue or a pr is exempt from being marked stale. Separate multiple milestones with commas (eg. "milestone1,milestone2")'
default: ''
required: false
exempt-issue-milestones:
description: 'The milestones that mean an issue is exempt from being marked stale. Separate multiple milestones with commas (eg. "milestone1,milestone2"). Override "exempt-milestones" option regarding only the issue.'
default: ''
required: false
exempt-pr-milestones:
description: 'The milestones that mean a pull request is exempt from being marked stale. Separate multiple milestones with commas (eg. "milestone1,milestone2"). Override "exempt-milestones" option regarding only the pull requests.'
default: ''
required: false
only-labels:
description: 'Only issues or pull requests with all of these labels are checked if stale. Defaults to `[]` (disabled) and can be a comma-separated list of labels.'
default: ''

261
dist/index.js vendored
View File

@ -19,29 +19,31 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.IssueProcessor = void 0;
const github_1 = __nccwpck_require__(5438);
const issue_1 = __nccwpck_require__(4783);
const issue_logger_1 = __nccwpck_require__(2984);
const logger_1 = __nccwpck_require__(6212);
const milestones_1 = __nccwpck_require__(4601);
const get_humanized_date_1 = __nccwpck_require__(965);
const is_date_more_recent_than_1 = __nccwpck_require__(1473);
const is_valid_date_1 = __nccwpck_require__(891);
const get_issue_type_1 = __nccwpck_require__(5153);
const issue_logger_1 = __nccwpck_require__(1699);
const logger_1 = __nccwpck_require__(8236);
const is_labeled_1 = __nccwpck_require__(6792);
const is_pull_request_1 = __nccwpck_require__(5400);
const labels_to_list_1 = __nccwpck_require__(9107);
const should_mark_when_stale_1 = __nccwpck_require__(2461);
const logger = new logger_1.Logger();
const words_to_list_1 = __nccwpck_require__(1883);
/***
* Handle processing of issues for staleness/closure.
*/
class IssueProcessor {
constructor(options, getActor, getIssues, listIssueComments, getLabelCreationDate) {
this._logger = new logger_1.Logger();
this._operationsLeft = 0;
this.staleIssues = [];
this.closedIssues = [];
this.deletedBranchIssues = [];
this.removedLabelIssues = [];
this.operationsLeft = 0;
this.options = options;
this.operationsLeft = options.operationsPerRun;
this._operationsLeft = options.operationsPerRun;
this.client = github_1.getOctokit(options.repoToken);
if (getActor) {
this._getActor = getActor;
@ -56,7 +58,7 @@ class IssueProcessor {
this._getLabelCreationDate = getLabelCreationDate;
}
if (this.options.debugOnly) {
logger.warning('Executing in debug mode. Debug output will be written but no issues will be processed.');
this._logger.warning('Executing in debug mode. Debug output will be written but no issues will be processed.');
}
}
static _updatedSince(timestamp, num_days) {
@ -68,39 +70,37 @@ class IssueProcessor {
return __awaiter(this, void 0, void 0, function* () {
// get the next batch of issues
const issues = yield this._getIssues(page);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
const actor = yield this._getActor();
if (issues.length <= 0) {
logger.info('---');
logger.info('No more issues found to process. Exiting.');
return this.operationsLeft;
this._logger.info('---');
this._logger.info('No more issues found to process. Exiting.');
return this._operationsLeft;
}
for (const issue of issues.values()) {
const issueLogger = new issue_logger_1.IssueLogger(issue);
const isPr = is_pull_request_1.isPullRequest(issue);
issueLogger.info(`Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${isPr})`);
issueLogger.info(`Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${issue.isPullRequest})`);
// calculate string based messages for this issue
const staleMessage = isPr
const staleMessage = issue.isPullRequest
? this.options.stalePrMessage
: this.options.staleIssueMessage;
const closeMessage = isPr
const closeMessage = issue.isPullRequest
? this.options.closePrMessage
: this.options.closeIssueMessage;
const staleLabel = isPr
const staleLabel = issue.isPullRequest
? this.options.stalePrLabel
: this.options.staleIssueLabel;
const closeLabel = isPr
const closeLabel = issue.isPullRequest
? this.options.closePrLabel
: this.options.closeIssueLabel;
const exemptLabels = labels_to_list_1.labelsToList(isPr ? this.options.exemptPrLabels : this.options.exemptIssueLabels);
const skipMessage = isPr
const skipMessage = issue.isPullRequest
? this.options.skipStalePrMessage
: this.options.skipStaleIssueMessage;
const issueType = get_issue_type_1.getIssueType(isPr);
const daysBeforeStale = isPr
const issueType = get_issue_type_1.getIssueType(issue.isPullRequest);
const daysBeforeStale = issue.isPullRequest
? this._getDaysBeforePrStale()
: this._getDaysBeforeIssueStale();
if (isPr) {
if (issue.isPullRequest) {
issueLogger.info(`Days before pull request stale: ${daysBeforeStale}`);
}
else {
@ -134,38 +134,44 @@ class IssueProcessor {
continue; // don't process issues which were created before the start date
}
}
// Does this issue have a stale label?
let isStale = is_labeled_1.isLabeled(issue, staleLabel);
if (isStale) {
if (issue.isStale) {
issueLogger.info(`This issue has a stale label`);
}
else {
issueLogger.info(`This issue hasn't a stale label`);
}
const exemptLabels = words_to_list_1.wordsToList(issue.isPullRequest
? this.options.exemptPrLabels
: this.options.exemptIssueLabels);
if (exemptLabels.some((exemptLabel) => is_labeled_1.isLabeled(issue, exemptLabel))) {
if (isStale) {
if (issue.isStale) {
issueLogger.info(`An exempt label was added after the stale label.`);
yield this._removeStaleLabel(issue, staleLabel);
}
issueLogger.info(`Skipping ${issueType} because it has an exempt label`);
continue; // don't process exempt issues
}
const milestones = new milestones_1.Milestones(this.options, issue);
if (milestones.shouldExemptMilestones()) {
issueLogger.info(`Skipping ${issueType} because it has an exempt milestone`);
continue; // don't process exempt milestones
}
// should this issue be marked stale?
const shouldBeStale = !IssueProcessor._updatedSince(issue.updated_at, this.options.daysBeforeStale);
// determine if this issue needs to be marked stale first
if (!isStale && shouldBeStale && shouldMarkAsStale) {
if (!issue.isStale && shouldBeStale && shouldMarkAsStale) {
issueLogger.info(`Marking ${issueType} stale because it was last updated on ${issue.updated_at} and it does not have a stale label`);
yield this._markStale(issue, staleMessage, staleLabel, skipMessage);
isStale = true; // this issue is now considered stale
issue.isStale = true; // this issue is now considered stale
}
// process the issue if it was marked stale
if (isStale) {
if (issue.isStale) {
issueLogger.info(`Found a stale ${issueType}`);
yield this._processStaleIssue(issue, issueType, staleLabel, actor, closeMessage, closeLabel);
}
}
if (this.operationsLeft <= 0) {
logger.warning('Reached max number of operations to process. Exiting.');
if (this._operationsLeft <= 0) {
this._logger.warning('Reached max number of operations to process. Exiting.');
return 0;
}
// do the next batch
@ -244,7 +250,7 @@ class IssueProcessor {
return comments.data;
}
catch (error) {
logger.error(`List issue comments error: ${error.message}`);
this._logger.error(`List issue comments error: ${error.message}`);
return Promise.resolve([]);
}
});
@ -262,7 +268,7 @@ class IssueProcessor {
return actor.data.login;
});
}
// grab issues from github in baches of 100
// grab issues from github in batches of 100
_getIssues(page) {
return __awaiter(this, void 0, void 0, function* () {
// generate type for response
@ -277,10 +283,10 @@ class IssueProcessor {
direction: this.options.ascending ? 'asc' : 'desc',
page
});
return issueResult.data;
return issueResult.data.map((issue) => new issue_1.Issue(this.options, issue));
}
catch (error) {
logger.error(`Get issues for repo error: ${error.message}`);
this._logger.error(`Get issues for repo error: ${error.message}`);
return Promise.resolve([]);
}
});
@ -291,7 +297,7 @@ class IssueProcessor {
const issueLogger = new issue_logger_1.IssueLogger(issue);
issueLogger.info(`Marking issue #${issue.number} as stale`);
this.staleIssues.push(issue);
this.operationsLeft -= 2;
this._operationsLeft -= 2;
// if the issue is being marked stale, the updated date should be changed to right now
// so that close calculations work correctly
const newUpdatedAtDate = new Date();
@ -331,7 +337,7 @@ class IssueProcessor {
const issueLogger = new issue_logger_1.IssueLogger(issue);
issueLogger.info(`Closing issue #${issue.number} for being stale`);
this.closedIssues.push(issue);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
if (this.options.debugOnly) {
return;
}
@ -377,7 +383,7 @@ class IssueProcessor {
_getPullRequest(issue) {
return __awaiter(this, void 0, void 0, function* () {
const issueLogger = new issue_logger_1.IssueLogger(issue);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
try {
const pullRequest = yield this.client.pulls.get({
owner: github_1.context.repo.owner,
@ -406,7 +412,7 @@ class IssueProcessor {
}
const branch = pullRequest.head.ref;
issueLogger.info(`Deleting branch ${branch} from closed issue #${issue.number}`);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
try {
yield this.client.git.deleteRef({
owner: github_1.context.repo.owner,
@ -425,7 +431,7 @@ class IssueProcessor {
const issueLogger = new issue_logger_1.IssueLogger(issue);
issueLogger.info(`Removing label "${label}" from issue #${issue.number}`);
this.removedLabelIssues.push(issue);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
// @todo remove the debug only to be able to test the code below
if (this.options.debugOnly) {
return;
@ -449,7 +455,7 @@ class IssueProcessor {
return __awaiter(this, void 0, void 0, function* () {
const issueLogger = new issue_logger_1.IssueLogger(issue);
issueLogger.info(`Checking for label on issue #${issue.number}`);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
const options = this.client.issues.listEvents.endpoint.merge({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
@ -499,7 +505,43 @@ exports.IssueProcessor = IssueProcessor;
/***/ }),
/***/ 1699:
/***/ 4783:
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.Issue = void 0;
const is_labeled_1 = __nccwpck_require__(6792);
const is_pull_request_1 = __nccwpck_require__(5400);
class Issue {
constructor(options, issue) {
this._options = options;
this.title = issue.title;
this.number = issue.number;
this.created_at = issue.created_at;
this.updated_at = issue.updated_at;
this.labels = issue.labels;
this.pull_request = issue.pull_request;
this.state = issue.state;
this.locked = issue.locked;
this.milestone = issue.milestone;
this.isPullRequest = is_pull_request_1.isPullRequest(this);
this.staleLabel = this._getStaleLabel();
this.isStale = is_labeled_1.isLabeled(this, this.staleLabel);
}
_getStaleLabel() {
return this.isPullRequest
? this._options.stalePrLabel
: this._options.staleIssueLabel;
}
}
exports.Issue = Issue;
/***/ }),
/***/ 2984:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
@ -551,7 +593,7 @@ exports.IssueLogger = IssueLogger;
/***/ }),
/***/ 8236:
/***/ 6212:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
@ -592,6 +634,58 @@ class Logger {
exports.Logger = Logger;
/***/ }),
/***/ 4601:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.Milestones = void 0;
const lodash_deburr_1 = __importDefault(__nccwpck_require__(1601));
const words_to_list_1 = __nccwpck_require__(1883);
class Milestones {
constructor(options, issue) {
this._options = options;
this._issue = issue;
}
static _cleanMilestone(label) {
return lodash_deburr_1.default(label.toLowerCase());
}
shouldExemptMilestones() {
const exemptMilestones = this._getExemptMilestones();
return exemptMilestones.some((exemptMilestone) => this._hasMilestone(exemptMilestone));
}
_getExemptMilestones() {
return words_to_list_1.wordsToList(this._issue.isPullRequest
? this._getExemptPullRequestMilestones()
: this._getExemptIssueMilestones());
}
_getExemptIssueMilestones() {
return this._options.exemptIssueMilestones !== ''
? this._options.exemptIssueMilestones
: this._options.exemptMilestones;
}
_getExemptPullRequestMilestones() {
return this._options.exemptPrMilestones !== ''
? this._options.exemptPrMilestones
: this._options.exemptMilestones;
}
_hasMilestone(milestone) {
if (!this._issue.milestone) {
return false;
}
return (Milestones._cleanMilestone(milestone) ===
Milestones._cleanMilestone(this._issue.milestone.title));
}
}
exports.Milestones = Milestones;
/***/ }),
/***/ 9639:
@ -707,12 +801,12 @@ exports.isLabeled = void 0;
const lodash_deburr_1 = __importDefault(__nccwpck_require__(1601));
/**
* @description
* Check if the label is listed as a label of the issue
* Check if the given label is listed as a label of the given issue
*
* @param {Readonly<Issue>} issue A GitHub issue containing some labels
* @param {Readonly<string>} label The label to check the presence with
*
* @return {boolean} Return true when the given label is also in the issue labels
* @return {boolean} Return true when the given label is also in the given issue labels
*/
function isLabeled(issue, label) {
return !!issue.labels.find((issueLabel) => {
@ -740,40 +834,6 @@ function isPullRequest(issue) {
exports.isPullRequest = isPullRequest;
/***/ }),
/***/ 9107:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.labelsToList = void 0;
/**
* @description
* Transform a string of comma separated labels
* to an array of labels
*
* @example
* labelsToList('label') => ['label']
* labelsToList('label,label') => ['label', 'label']
* labelsToList('kebab-label') => ['kebab-label']
* labelsToList('kebab%20label') => ['kebab%20label']
* labelsToList('label with words') => ['label with words']
*
* @param {Readonly<string>} labels A string of comma separated labels
*
* @return {string[]} A list of labels
*/
function labelsToList(labels) {
if (!labels.length) {
return [];
}
return labels.split(',').map(l => l.trim());
}
exports.labelsToList = labelsToList;
/***/ }),
/***/ 2461:
@ -789,6 +849,40 @@ function shouldMarkWhenStale(daysBeforeStale) {
exports.shouldMarkWhenStale = shouldMarkWhenStale;
/***/ }),
/***/ 1883:
/***/ ((__unused_webpack_module, exports) => {
"use strict";
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.wordsToList = void 0;
/**
* @description
* Transform a string of comma separated words
* to an array of words
*
* @example
* wordsToList('label') => ['label']
* wordsToList('label,label') => ['label', 'label']
* wordsToList('kebab-label') => ['kebab-label']
* wordsToList('kebab%20label') => ['kebab%20label']
* wordsToList('label with words') => ['label with words']
*
* @param {Readonly<string>} words A string of comma separated words
*
* @return {string[]} A list of words
*/
function wordsToList(words) {
if (!words.length) {
return [];
}
return words.split(',').map((word) => word.trim());
}
exports.wordsToList = wordsToList;
/***/ }),
/***/ 3109:
@ -870,15 +964,14 @@ function getAndValidateArgs() {
deleteBranch: core.getInput('delete-branch') === 'true',
startDate: core.getInput('start-date') !== ''
? core.getInput('start-date')
: undefined
: undefined,
exemptMilestones: core.getInput('exempt-milestones'),
exemptIssueMilestones: core.getInput('exempt-issue-milestones'),
exemptPrMilestones: core.getInput('exempt-pr-milestones')
};
for (const numberInput of [
'days-before-stale',
'days-before-issue-stale',
'days-before-pr-stale',
'days-before-close',
'days-before-issue-close',
'days-before-pr-close',
'operations-per-run'
]) {
if (isNaN(parseInt(core.getInput(numberInput)))) {

View File

@ -6,10 +6,12 @@
"main": "lib/main.js",
"scripts": {
"build": "tsc",
"format": "prettier --write **/*.ts",
"format-check": "prettier --check **/*.ts",
"lint": "prettier --check --ignore-unknown **/*.{md,json,yml,ts} && eslint src/**/*.ts",
"lint:fix": "prettier --write --ignore-unknown **/*.{md,json,yml,ts} && eslint src/**/*.ts --fix",
"format": "prettier --write --ignore-unknown **/*.{md,json,yml,ts}",
"format-check": "prettier --check --ignore-unknown **/*.{md,json,yml,ts}",
"lint": "eslint src/**/*.ts",
"lint:fix": "eslint src/**/*.ts --fix",
"lint:all": "npm run format-check && npm run lint",
"lint:all:fix": "npm run format && npm run lint:fix",
"pack": "ncc build",
"test": "jest",
"all": "npm run build && npm run format && npm run lint && npm run pack && npm test"

View File

@ -1,30 +1,21 @@
import {context, getOctokit} from '@actions/github';
import {GitHub} from '@actions/github/lib/utils';
import {GetResponseTypeFromEndpointMethod} from '@octokit/types';
import {Issue} from './classes/issue';
import {IssueLogger} from './classes/loggers/issue-logger';
import {Logger} from './classes/loggers/logger';
import {Milestones} from './classes/milestones';
import {IssueType} from './enums/issue-type';
import {getHumanizedDate} from './functions/dates/get-humanized-date';
import {isDateMoreRecentThan} from './functions/dates/is-date-more-recent-than';
import {isValidDate} from './functions/dates/is-valid-date';
import {getIssueType} from './functions/get-issue-type';
import {IssueLogger} from './classes/issue-logger';
import {Logger} from './classes/logger';
import {isLabeled} from './functions/is-labeled';
import {isPullRequest} from './functions/is-pull-request';
import {labelsToList} from './functions/labels-to-list';
import {shouldMarkWhenStale} from './functions/should-mark-when-stale';
import {IsoDateString} from './types/iso-date-string';
import {IsoOrRfcDateString} from './types/iso-or-rfc-date-string';
export interface Issue {
title: string;
number: number;
created_at: IsoDateString;
updated_at: IsoDateString;
labels: Label[];
pull_request: any;
state: string;
locked: boolean;
}
import {wordsToList} from './functions/words-to-list';
import {IIssue} from './interfaces/issue';
export interface PullRequest {
number: number;
@ -79,10 +70,11 @@ export interface IssueProcessorOptions {
skipStalePrMessage: boolean;
deleteBranch: boolean;
startDate: IsoOrRfcDateString | undefined; // Should be ISO 8601 or RFC 2822
exemptMilestones: string;
exemptIssueMilestones: string;
exemptPrMilestones: string;
}
const logger: Logger = new Logger();
/***
* Handle processing of issues for staleness/closure.
*/
@ -95,13 +87,14 @@ export class IssueProcessor {
return millisSinceLastUpdated <= daysInMillis;
}
private readonly _logger: Logger = new Logger();
private _operationsLeft = 0;
readonly client: InstanceType<typeof GitHub>;
readonly options: IssueProcessorOptions;
readonly staleIssues: Issue[] = [];
readonly closedIssues: Issue[] = [];
readonly deletedBranchIssues: Issue[] = [];
readonly removedLabelIssues: Issue[] = [];
private operationsLeft = 0;
constructor(
options: IssueProcessorOptions,
@ -117,7 +110,7 @@ export class IssueProcessor {
) => Promise<string | undefined>
) {
this.options = options;
this.operationsLeft = options.operationsPerRun;
this._operationsLeft = options.operationsPerRun;
this.client = getOctokit(options.repoToken);
if (getActor) {
@ -137,7 +130,7 @@ export class IssueProcessor {
}
if (this.options.debugOnly) {
logger.warning(
this._logger.warning(
'Executing in debug mode. Debug output will be written but no issues will be processed.'
);
}
@ -146,49 +139,45 @@ export class IssueProcessor {
async processIssues(page = 1): Promise<number> {
// get the next batch of issues
const issues: Issue[] = await this._getIssues(page);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
const actor: string = await this._getActor();
if (issues.length <= 0) {
logger.info('---');
logger.info('No more issues found to process. Exiting.');
return this.operationsLeft;
this._logger.info('---');
this._logger.info('No more issues found to process. Exiting.');
return this._operationsLeft;
}
for (const issue of issues.values()) {
const issueLogger: IssueLogger = new IssueLogger(issue);
const isPr = isPullRequest(issue);
issueLogger.info(
`Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${isPr})`
`Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${issue.isPullRequest})`
);
// calculate string based messages for this issue
const staleMessage: string = isPr
const staleMessage: string = issue.isPullRequest
? this.options.stalePrMessage
: this.options.staleIssueMessage;
const closeMessage: string = isPr
const closeMessage: string = issue.isPullRequest
? this.options.closePrMessage
: this.options.closeIssueMessage;
const staleLabel: string = isPr
const staleLabel: string = issue.isPullRequest
? this.options.stalePrLabel
: this.options.staleIssueLabel;
const closeLabel: string = isPr
const closeLabel: string = issue.isPullRequest
? this.options.closePrLabel
: this.options.closeIssueLabel;
const exemptLabels: string[] = labelsToList(
isPr ? this.options.exemptPrLabels : this.options.exemptIssueLabels
);
const skipMessage = isPr
const skipMessage = issue.isPullRequest
? this.options.skipStalePrMessage
: this.options.skipStaleIssueMessage;
const issueType: IssueType = getIssueType(isPr);
const daysBeforeStale: number = isPr
const issueType: IssueType = getIssueType(issue.isPullRequest);
const daysBeforeStale: number = issue.isPullRequest
? this._getDaysBeforePrStale()
: this._getDaysBeforeIssueStale();
if (isPr) {
if (issue.isPullRequest) {
issueLogger.info(`Days before pull request stale: ${daysBeforeStale}`);
} else {
issueLogger.info(`Days before issue stale: ${daysBeforeStale}`);
@ -244,21 +233,24 @@ export class IssueProcessor {
}
}
// Does this issue have a stale label?
let isStale: boolean = isLabeled(issue, staleLabel);
if (isStale) {
if (issue.isStale) {
issueLogger.info(`This issue has a stale label`);
} else {
issueLogger.info(`This issue hasn't a stale label`);
}
const exemptLabels: string[] = wordsToList(
issue.isPullRequest
? this.options.exemptPrLabels
: this.options.exemptIssueLabels
);
if (
exemptLabels.some((exemptLabel: Readonly<string>): boolean =>
isLabeled(issue, exemptLabel)
)
) {
if (isStale) {
if (issue.isStale) {
issueLogger.info(`An exempt label was added after the stale label.`);
await this._removeStaleLabel(issue, staleLabel);
}
@ -269,6 +261,15 @@ export class IssueProcessor {
continue; // don't process exempt issues
}
const milestones: Milestones = new Milestones(this.options, issue);
if (milestones.shouldExemptMilestones()) {
issueLogger.info(
`Skipping ${issueType} because it has an exempt milestone`
);
continue; // don't process exempt milestones
}
// should this issue be marked stale?
const shouldBeStale = !IssueProcessor._updatedSince(
issue.updated_at,
@ -276,16 +277,16 @@ export class IssueProcessor {
);
// determine if this issue needs to be marked stale first
if (!isStale && shouldBeStale && shouldMarkAsStale) {
if (!issue.isStale && shouldBeStale && shouldMarkAsStale) {
issueLogger.info(
`Marking ${issueType} stale because it was last updated on ${issue.updated_at} and it does not have a stale label`
);
await this._markStale(issue, staleMessage, staleLabel, skipMessage);
isStale = true; // this issue is now considered stale
issue.isStale = true; // this issue is now considered stale
}
// process the issue if it was marked stale
if (isStale) {
if (issue.isStale) {
issueLogger.info(`Found a stale ${issueType}`);
await this._processStaleIssue(
issue,
@ -298,8 +299,10 @@ export class IssueProcessor {
}
}
if (this.operationsLeft <= 0) {
logger.warning('Reached max number of operations to process. Exiting.');
if (this._operationsLeft <= 0) {
this._logger.warning(
'Reached max number of operations to process. Exiting.'
);
return 0;
}
@ -427,7 +430,7 @@ export class IssueProcessor {
});
return comments.data;
} catch (error) {
logger.error(`List issue comments error: ${error.message}`);
this._logger.error(`List issue comments error: ${error.message}`);
return Promise.resolve([]);
}
}
@ -444,7 +447,7 @@ export class IssueProcessor {
return actor.data.login;
}
// grab issues from github in baches of 100
// grab issues from github in batches of 100
private async _getIssues(page: number): Promise<Issue[]> {
// generate type for response
const endpoint = this.client.issues.listForRepo;
@ -462,9 +465,12 @@ export class IssueProcessor {
page
}
);
return issueResult.data;
return issueResult.data.map(
(issue: Readonly<IIssue>): Issue => new Issue(this.options, issue)
);
} catch (error) {
logger.error(`Get issues for repo error: ${error.message}`);
this._logger.error(`Get issues for repo error: ${error.message}`);
return Promise.resolve([]);
}
}
@ -482,7 +488,7 @@ export class IssueProcessor {
this.staleIssues.push(issue);
this.operationsLeft -= 2;
this._operationsLeft -= 2;
// if the issue is being marked stale, the updated date should be changed to right now
// so that close calculations work correctly
@ -530,7 +536,7 @@ export class IssueProcessor {
this.closedIssues.push(issue);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
if (this.options.debugOnly) {
return;
@ -578,7 +584,7 @@ export class IssueProcessor {
issue: Issue
): Promise<PullRequest | undefined> {
const issueLogger: IssueLogger = new IssueLogger(issue);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
try {
const pullRequest = await this.client.pulls.get({
@ -621,7 +627,7 @@ export class IssueProcessor {
`Deleting branch ${branch} from closed issue #${issue.number}`
);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
try {
await this.client.git.deleteRef({
@ -644,7 +650,7 @@ export class IssueProcessor {
this.removedLabelIssues.push(issue);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
// @todo remove the debug only to be able to test the code below
if (this.options.debugOnly) {
@ -673,7 +679,7 @@ export class IssueProcessor {
issueLogger.info(`Checking for label on issue #${issue.number}`);
this.operationsLeft -= 1;
this._operationsLeft -= 1;
const options = this.client.issues.listEvents.endpoint.merge({
owner: context.repo.owner,
@ -722,7 +728,7 @@ export class IssueProcessor {
}
private async _removeStaleLabel(
issue: Readonly<Issue>,
issue: Issue,
staleLabel: Readonly<string>
): Promise<void> {
const issueLogger: IssueLogger = new IssueLogger(issue);

208
src/classes/issue.spec.ts Normal file
View File

@ -0,0 +1,208 @@
import {IIssue} from '../interfaces/issue';
import {IMilestone} from '../interfaces/milestone';
import {IssueProcessorOptions, Label} from '../IssueProcessor';
import {Issue} from './issue';
describe('Issue', (): void => {
let issue: Issue;
let optionsInterface: IssueProcessorOptions;
let issueInterface: IIssue;
beforeEach((): void => {
optionsInterface = {
ascending: false,
closeIssueLabel: '',
closeIssueMessage: '',
closePrLabel: '',
closePrMessage: '',
daysBeforeClose: 0,
daysBeforeIssueClose: 0,
daysBeforeIssueStale: 0,
daysBeforePrClose: 0,
daysBeforePrStale: 0,
daysBeforeStale: 0,
debugOnly: false,
deleteBranch: false,
exemptIssueLabels: '',
exemptIssueMilestones: '',
exemptMilestones: '',
exemptPrLabels: '',
exemptPrMilestones: '',
onlyLabels: '',
operationsPerRun: 0,
removeStaleWhenUpdated: false,
repoToken: '',
skipStaleIssueMessage: false,
skipStalePrMessage: false,
staleIssueMessage: '',
stalePrMessage: '',
startDate: undefined,
stalePrLabel: 'dummy-stale-pr-label',
staleIssueLabel: 'dummy-stale-issue-label'
};
issueInterface = {
title: 'dummy-title',
number: 8,
created_at: 'dummy-created-at',
updated_at: 'dummy-updated-at',
labels: [
{
name: 'dummy-name'
}
],
pull_request: {},
state: 'dummy-state',
locked: false,
milestone: {
title: 'dummy-milestone'
}
};
issue = new Issue(optionsInterface, issueInterface);
});
describe('constructor()', (): void => {
it('should set the title with the given issue title', (): void => {
expect.assertions(1);
expect(issue.title).toStrictEqual('dummy-title');
});
it('should set the number with the given issue number', (): void => {
expect.assertions(1);
expect(issue.number).toStrictEqual(8);
});
it('should set the created_at with the given issue created_at', (): void => {
expect.assertions(1);
expect(issue.created_at).toStrictEqual('dummy-created-at');
});
it('should set the updated_at with the given issue updated_at', (): void => {
expect.assertions(1);
expect(issue.updated_at).toStrictEqual('dummy-updated-at');
});
it('should set the labels with the given issue labels', (): void => {
expect.assertions(1);
expect(issue.labels).toStrictEqual([
{
name: 'dummy-name'
} as Label
]);
});
it('should set the pull_request with the given issue pull_request', (): void => {
expect.assertions(1);
expect(issue.pull_request).toStrictEqual({});
});
it('should set the state with the given issue state', (): void => {
expect.assertions(1);
expect(issue.state).toStrictEqual('dummy-state');
});
it('should set the locked with the given issue locked', (): void => {
expect.assertions(1);
expect(issue.locked).toStrictEqual(false);
});
it('should set the milestone with the given issue milestone', (): void => {
expect.assertions(1);
expect(issue.milestone).toStrictEqual({
title: 'dummy-milestone'
} as IMilestone);
});
describe('when the given issue pull_request is not set', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
issue = new Issue(optionsInterface, issueInterface);
});
it('should set the isPullRequest to false', (): void => {
expect.assertions(1);
expect(issue.isPullRequest).toStrictEqual(false);
});
});
describe('when the given issue pull_request is set', (): void => {
beforeEach((): void => {
issueInterface.pull_request = {};
issue = new Issue(optionsInterface, issueInterface);
});
it('should set the isPullRequest to true', (): void => {
expect.assertions(1);
expect(issue.isPullRequest).toStrictEqual(true);
});
});
describe('when the given issue is not a pull request', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
issue = new Issue(optionsInterface, issueInterface);
});
it('should set the staleLabel with the given option staleIssueLabel', (): void => {
expect.assertions(1);
expect(issue.staleLabel).toStrictEqual('dummy-stale-issue-label');
});
});
describe('when the given issue is a pull request', (): void => {
beforeEach((): void => {
issueInterface.pull_request = {};
issue = new Issue(optionsInterface, issueInterface);
});
it('should set the staleLabel with the given option stalePrLabel', (): void => {
expect.assertions(1);
expect(issue.staleLabel).toStrictEqual('dummy-stale-pr-label');
});
});
describe('when the given issue does not contains the stale label', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
issueInterface.labels = [];
issue = new Issue(optionsInterface, issueInterface);
});
it('should set the isStale to false', (): void => {
expect.assertions(1);
expect(issue.isStale).toStrictEqual(false);
});
});
describe('when the given issue contains the stale label', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
issueInterface.labels = [
{
name: 'dummy-stale-issue-label'
} as Label
];
issue = new Issue(optionsInterface, issueInterface);
});
it('should set the isStale to true', (): void => {
expect.assertions(1);
expect(issue.isStale).toStrictEqual(true);
});
});
});
});

48
src/classes/issue.ts Normal file
View File

@ -0,0 +1,48 @@
import {isLabeled} from '../functions/is-labeled';
import {isPullRequest} from '../functions/is-pull-request';
import {IIssue} from '../interfaces/issue';
import {IMilestone} from '../interfaces/milestone';
import {IssueProcessorOptions, Label} from '../IssueProcessor';
import {IsoDateString} from '../types/iso-date-string';
export class Issue implements IIssue {
private readonly _options: IssueProcessorOptions;
readonly title: string;
readonly number: number;
created_at: IsoDateString;
updated_at: IsoDateString;
readonly labels: Label[];
readonly pull_request: Object | null | undefined;
readonly state: string;
readonly locked: boolean;
readonly milestone: IMilestone | undefined;
readonly isPullRequest: boolean;
readonly staleLabel: string;
isStale: boolean;
constructor(
options: Readonly<IssueProcessorOptions>,
issue: Readonly<IIssue>
) {
this._options = options;
this.title = issue.title;
this.number = issue.number;
this.created_at = issue.created_at;
this.updated_at = issue.updated_at;
this.labels = issue.labels;
this.pull_request = issue.pull_request;
this.state = issue.state;
this.locked = issue.locked;
this.milestone = issue.milestone;
this.isPullRequest = isPullRequest(this);
this.staleLabel = this._getStaleLabel();
this.isStale = isLabeled(this, this.staleLabel);
}
private _getStaleLabel(): string {
return this.isPullRequest
? this._options.stalePrLabel
: this._options.staleIssueLabel;
}
}

View File

@ -1,4 +1,4 @@
import {Issue} from '../IssueProcessor';
import {Issue} from '../issue';
import {IssueLogger} from './issue-logger';
import * as core from '@actions/core';

View File

@ -1,11 +1,11 @@
import * as core from '@actions/core';
import {Issue} from '../IssueProcessor';
import {Issue} from '../issue';
import {Logger} from './logger';
export class IssueLogger implements Logger {
private readonly _issue: Issue;
constructor(issue: Readonly<Issue>) {
constructor(issue: Issue) {
this._issue = issue;
}

View File

@ -0,0 +1,596 @@
import {IIssue} from '../interfaces/issue';
import {IssueProcessorOptions} from '../IssueProcessor';
import {Issue} from './issue';
import {Milestones} from './milestones';
describe('Milestones', (): void => {
let milestones: Milestones;
let optionsInterface: IssueProcessorOptions;
let issue: Issue;
let issueInterface: IIssue;
beforeEach((): void => {
optionsInterface = {
ascending: false,
closeIssueLabel: '',
closeIssueMessage: '',
closePrLabel: '',
closePrMessage: '',
daysBeforeClose: 0,
daysBeforeIssueClose: 0,
daysBeforeIssueStale: 0,
daysBeforePrClose: 0,
daysBeforePrStale: 0,
daysBeforeStale: 0,
debugOnly: false,
deleteBranch: false,
exemptIssueLabels: '',
exemptPrLabels: '',
onlyLabels: '',
operationsPerRun: 0,
removeStaleWhenUpdated: false,
repoToken: '',
skipStaleIssueMessage: false,
skipStalePrMessage: false,
staleIssueLabel: '',
staleIssueMessage: '',
stalePrLabel: '',
stalePrMessage: '',
startDate: undefined,
exemptIssueMilestones: '',
exemptPrMilestones: '',
exemptMilestones: ''
};
issueInterface = {
created_at: '',
locked: false,
milestone: undefined,
number: 0,
pull_request: undefined,
state: '',
title: '',
updated_at: '',
labels: []
};
});
describe('shouldExemptMilestones()', (): void => {
describe('when the given issue is not a pull request', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
});
describe('when the given options are not configured to exempt a milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptMilestones = '';
});
describe('when the given options are not configured to exempt an issue milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueMilestones = '';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
});
describe('when the given options are configured to exempt an issue milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueMilestones =
'dummy-exempt-issue-milestone';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone different than the exempt issue milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt issue milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-issue-milestone'
};
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(true);
});
});
});
});
describe('when the given options are configured to exempt a milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptMilestones = 'dummy-exempt-milestone';
});
describe('when the given options are not configured to exempt an issue milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueMilestones = '';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone different than the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-milestone'
};
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(true);
});
});
});
describe('when the given options are configured to exempt an issue milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueMilestones =
'dummy-exempt-issue-milestone';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone different than the exempt issue milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt issue milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-issue-milestone'
};
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(true);
});
});
describe('when the given issue does have a milestone different than the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-milestone'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
});
});
});
describe('when the given issue is a pull request', (): void => {
beforeEach((): void => {
issueInterface.pull_request = {};
});
describe('when the given options are not configured to exempt a milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptMilestones = '';
});
describe('when the given options are not configured to exempt a pull request milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrMilestones = '';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
});
describe('when the given options are configured to exempt a pull request milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrMilestones = 'dummy-exempt-pr-milestone';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone different than the exempt pull request milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt pull request milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-pr-milestone'
};
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(true);
});
});
});
});
describe('when the given options are configured to exempt a milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptMilestones = 'dummy-exempt-milestone';
});
describe('when the given options are not configured to exempt a pull request milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrMilestones = '';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone different than the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-milestone'
};
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(true);
});
});
});
describe('when the given options are configured to exempt a pull request milestone', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrMilestones = 'dummy-exempt-pr-milestone';
});
describe('when the given issue does not have a milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = undefined;
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone different than the exempt pull request milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt pull request milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-pr-milestone'
};
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(true);
});
});
describe('when the given issue does have a milestone different than the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-title'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have a milestone equaling the exempt milestone', (): void => {
beforeEach((): void => {
issueInterface.milestone = {
title: 'dummy-exempt-milestone'
};
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
milestones = new Milestones(optionsInterface, issue);
const result = milestones.shouldExemptMilestones();
expect(result).toStrictEqual(false);
});
});
});
});
});
});
});

59
src/classes/milestones.ts Normal file
View File

@ -0,0 +1,59 @@
import deburr from 'lodash.deburr';
import {wordsToList} from '../functions/words-to-list';
import {IssueProcessorOptions} from '../IssueProcessor';
import {Issue} from './issue';
type CleanMilestone = string;
export class Milestones {
private static _cleanMilestone(label: Readonly<string>): CleanMilestone {
return deburr(label.toLowerCase());
}
private readonly _options: IssueProcessorOptions;
private readonly _issue: Issue;
constructor(options: Readonly<IssueProcessorOptions>, issue: Issue) {
this._options = options;
this._issue = issue;
}
shouldExemptMilestones(): boolean {
const exemptMilestones: string[] = this._getExemptMilestones();
return exemptMilestones.some((exemptMilestone: Readonly<string>): boolean =>
this._hasMilestone(exemptMilestone)
);
}
private _getExemptMilestones(): string[] {
return wordsToList(
this._issue.isPullRequest
? this._getExemptPullRequestMilestones()
: this._getExemptIssueMilestones()
);
}
private _getExemptIssueMilestones(): string {
return this._options.exemptIssueMilestones !== ''
? this._options.exemptIssueMilestones
: this._options.exemptMilestones;
}
private _getExemptPullRequestMilestones(): string {
return this._options.exemptPrMilestones !== ''
? this._options.exemptPrMilestones
: this._options.exemptMilestones;
}
private _hasMilestone(milestone: Readonly<string>): boolean {
if (!this._issue.milestone) {
return false;
}
return (
Milestones._cleanMilestone(milestone) ===
Milestones._cleanMilestone(this._issue.milestone.title)
);
}
}

View File

@ -1,4 +1,4 @@
import {Issue} from '../IssueProcessor';
import {Issue} from '../classes/issue';
import {isLabeled} from './is-labeled';
describe('isLabeled()', (): void => {

View File

@ -1,15 +1,16 @@
import deburr from 'lodash.deburr';
import {Issue, Label} from '../IssueProcessor';
import {Issue} from '../classes/issue';
import {Label} from '../IssueProcessor';
import {CleanLabel} from '../types/clean-label';
/**
* @description
* Check if the label is listed as a label of the issue
* Check if the given label is listed as a label of the given issue
*
* @param {Readonly<Issue>} issue A GitHub issue containing some labels
* @param {Readonly<string>} label The label to check the presence with
*
* @return {boolean} Return true when the given label is also in the issue labels
* @return {boolean} Return true when the given label is also in the given issue labels
*/
export function isLabeled(
issue: Readonly<Issue>,

View File

@ -1,4 +1,4 @@
import {Issue} from '../IssueProcessor';
import {Issue} from '../classes/issue';
import {isPullRequest} from './is-pull-request';
describe('isPullRequest()', (): void => {

View File

@ -1,4 +1,4 @@
import {Issue} from '../IssueProcessor';
import {Issue} from '../classes/issue';
export function isPullRequest(issue: Readonly<Issue>): boolean {
return !!issue.pull_request;

View File

@ -1,141 +0,0 @@
import {labelsToList} from './labels-to-list';
describe('labelsToList()', (): void => {
let labels: string;
describe('when the given labels is empty', (): void => {
beforeEach((): void => {
labels = '';
});
it('should return an empty list of labels', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual([]);
});
});
describe('when the given labels is a simple label', (): void => {
beforeEach((): void => {
labels = 'label';
});
it('should return a list of one label', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual(['label']);
});
});
describe('when the given labels is a label with extra spaces before and after', (): void => {
beforeEach((): void => {
labels = ' label ';
});
it('should return a list of one label and remove all spaces before and after', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual(['label']);
});
});
describe('when the given labels is a kebab case label', (): void => {
beforeEach((): void => {
labels = 'kebab-case-label';
});
it('should return a list of one label', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual(['kebab-case-label']);
});
});
describe('when the given labels is two kebab case labels separated with a comma', (): void => {
beforeEach((): void => {
labels = 'kebab-case-label-1,kebab-case-label-2';
});
it('should return a list of two labels', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual([
'kebab-case-label-1',
'kebab-case-label-2'
]);
});
});
describe('when the given labels is a multiple word label', (): void => {
beforeEach((): void => {
labels = 'label like a sentence';
});
it('should return a list of one label', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual(['label like a sentence']);
});
});
describe('when the given labels is two multiple word labels separated with a comma', (): void => {
beforeEach((): void => {
labels = 'label like a sentence, another label like a sentence';
});
it('should return a list of two labels', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual([
'label like a sentence',
'another label like a sentence'
]);
});
});
describe('when the given labels is a multiple word label with %20 spaces', (): void => {
beforeEach((): void => {
labels = 'label%20like%20a%20sentence';
});
it('should return a list of one label', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual(['label%20like%20a%20sentence']);
});
});
describe('when the given labels is two multiple word labels with %20 spaces separated with a comma', (): void => {
beforeEach((): void => {
labels =
'label%20like%20a%20sentence,another%20label%20like%20a%20sentence';
});
it('should return a list of two labels', (): void => {
expect.assertions(1);
const result = labelsToList(labels);
expect(result).toStrictEqual([
'label%20like%20a%20sentence',
'another%20label%20like%20a%20sentence'
]);
});
});
});

View File

@ -1,23 +0,0 @@
/**
* @description
* Transform a string of comma separated labels
* to an array of labels
*
* @example
* labelsToList('label') => ['label']
* labelsToList('label,label') => ['label', 'label']
* labelsToList('kebab-label') => ['kebab-label']
* labelsToList('kebab%20label') => ['kebab%20label']
* labelsToList('label with words') => ['label with words']
*
* @param {Readonly<string>} labels A string of comma separated labels
*
* @return {string[]} A list of labels
*/
export function labelsToList(labels: Readonly<string>): string[] {
if (!labels.length) {
return [];
}
return labels.split(',').map(l => l.trim());
}

View File

@ -0,0 +1,137 @@
import {wordsToList} from './words-to-list';
describe('wordsToList()', (): void => {
let words: string;
describe('when the given words is empty', (): void => {
beforeEach((): void => {
words = '';
});
it('should return an empty list of words', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual([]);
});
});
describe('when the given words is a simple word', (): void => {
beforeEach((): void => {
words = 'word';
});
it('should return a list of one word', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual(['word']);
});
});
describe('when the given words is a word with extra spaces before and after', (): void => {
beforeEach((): void => {
words = ' word ';
});
it('should return a list of one word and remove all spaces before and after', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual(['word']);
});
});
describe('when the given words is a kebab case word', (): void => {
beforeEach((): void => {
words = 'kebab-case-word';
});
it('should return a list of one word', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual(['kebab-case-word']);
});
});
describe('when the given words is two kebab case words separated with a comma', (): void => {
beforeEach((): void => {
words = 'kebab-case-word-1,kebab-case-word-2';
});
it('should return a list of two words', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual(['kebab-case-word-1', 'kebab-case-word-2']);
});
});
describe('when the given words is a multiple word word', (): void => {
beforeEach((): void => {
words = 'word like a sentence';
});
it('should return a list of one word', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual(['word like a sentence']);
});
});
describe('when the given words is two multiple word words separated with a comma', (): void => {
beforeEach((): void => {
words = 'word like a sentence, another word like a sentence';
});
it('should return a list of two words', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual([
'word like a sentence',
'another word like a sentence'
]);
});
});
describe('when the given words is a multiple word word with %20 spaces', (): void => {
beforeEach((): void => {
words = 'word%20like%20a%20sentence';
});
it('should return a list of one word', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual(['word%20like%20a%20sentence']);
});
});
describe('when the given words is two multiple word words with %20 spaces separated with a comma', (): void => {
beforeEach((): void => {
words = 'word%20like%20a%20sentence,another%20word%20like%20a%20sentence';
});
it('should return a list of two words', (): void => {
expect.assertions(1);
const result = wordsToList(words);
expect(result).toStrictEqual([
'word%20like%20a%20sentence',
'another%20word%20like%20a%20sentence'
]);
});
});
});

View File

@ -0,0 +1,23 @@
/**
* @description
* Transform a string of comma separated words
* to an array of words
*
* @example
* wordsToList('label') => ['label']
* wordsToList('label,label') => ['label', 'label']
* wordsToList('kebab-label') => ['kebab-label']
* wordsToList('kebab%20label') => ['kebab%20label']
* wordsToList('label with words') => ['label with words']
*
* @param {Readonly<string>} words A string of comma separated words
*
* @return {string[]} A list of words
*/
export function wordsToList(words: Readonly<string>): string[] {
if (!words.length) {
return [];
}
return words.split(',').map((word: Readonly<string>): string => word.trim());
}

15
src/interfaces/issue.ts Normal file
View File

@ -0,0 +1,15 @@
import {Label} from '../IssueProcessor';
import {IsoDateString} from '../types/iso-date-string';
import {IMilestone} from './milestone';
export interface IIssue {
title: string;
number: number;
created_at: IsoDateString;
updated_at: IsoDateString;
labels: Label[];
pull_request: Object | null | undefined;
state: string;
locked: boolean;
milestone: IMilestone | undefined;
}

View File

@ -0,0 +1,3 @@
export interface IMilestone {
title: string;
}

View File

@ -52,16 +52,15 @@ function getAndValidateArgs(): IssueProcessorOptions {
startDate:
core.getInput('start-date') !== ''
? core.getInput('start-date')
: undefined
: undefined,
exemptMilestones: core.getInput('exempt-milestones'),
exemptIssueMilestones: core.getInput('exempt-issue-milestones'),
exemptPrMilestones: core.getInput('exempt-pr-milestones')
};
for (const numberInput of [
'days-before-stale',
'days-before-issue-stale',
'days-before-pr-stale',
'days-before-close',
'days-before-issue-close',
'days-before-pr-close',
'operations-per-run'
]) {
if (isNaN(parseInt(core.getInput(numberInput)))) {