feat(assignees): add 6 new options to avoid stale for assignees (#327)

* feat(assignees): add new option to avoid stale for assignees

closes #271

* test: add more coverage

* docs: fix readme format issue

* docs: reorder and enhance typo

* docs(contributing): add more information about the npm scripts
This commit is contained in:
Geoffrey Testelin
2021-02-28 12:15:08 +01:00
committed by GitHub
parent 996798eb71
commit ec96ff65b0
24 changed files with 2021 additions and 244 deletions

View File

@@ -0,0 +1,594 @@
import {DefaultProcessorOptions} from '../../__tests__/constants/default-processor-options';
import {generateIIssue} from '../../__tests__/functions/generate-iissue';
import {IIssue} from '../interfaces/issue';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {Issue} from './issue';
import {Assignees} from './assignees';
describe('Assignees', (): void => {
let assignees: Assignees;
let optionsInterface: IIssuesProcessorOptions;
let issue: Issue;
let issueInterface: IIssue;
beforeEach((): void => {
optionsInterface = {...DefaultProcessorOptions};
issueInterface = generateIIssue();
});
describe('shouldExemptAssignees()', (): 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 an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptAssignees = '';
});
describe('when the given options are not configured to exempt an issue with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueAssignees = '';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
});
describe('when the given options are configured to exempt an issue with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueAssignees =
'dummy-exempt-issue-assignee';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee different than the exempt issue assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt issue assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-issue-assignee'
}
];
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(true);
});
});
});
});
describe('when the given options are configured to exempt an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptAssignees = 'dummy-exempt-assignee';
});
describe('when the given options are not configured to exempt an issue with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueAssignees = '';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee different than the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-assignee'
}
];
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(true);
});
});
});
describe('when the given options are configured to exempt an issue with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptIssueAssignees =
'dummy-exempt-issue-assignee';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee different than the exempt issue assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt issue assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-issue-assignee'
}
];
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(true);
});
});
describe('when the given issue does have an assignee different than the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-assignee'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
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 an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptAssignees = '';
});
describe('when the given options are not configured to exempt a pull request with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrAssignees = '';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
});
describe('when the given options are configured to exempt a pull request with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrAssignees = 'dummy-exempt-pr-assignee';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee different than the exempt pull request assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt pull request assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-pr-assignee'
}
];
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(true);
});
});
});
});
describe('when the given options are configured to exempt an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptAssignees = 'dummy-exempt-assignee';
});
describe('when the given options are not configured to exempt a pull request with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrAssignees = '';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee different than the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-assignee'
}
];
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(true);
});
});
});
describe('when the given options are configured to exempt a pull request with an assignee', (): void => {
beforeEach((): void => {
optionsInterface.exemptPrAssignees = 'dummy-exempt-pr-assignee';
});
describe('when the given issue does not have an assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee different than the exempt pull request assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt pull request assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-pr-assignee'
}
];
});
it('should return true', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(true);
});
});
describe('when the given issue does have an assignee different than the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
describe('when the given issue does have an assignee equaling the exempt assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-exempt-assignee'
}
];
});
it('should return false', (): void => {
expect.assertions(1);
issue = new Issue(optionsInterface, issueInterface);
assignees = new Assignees(optionsInterface, issue);
const result = assignees.shouldExemptAssignees();
expect(result).toStrictEqual(false);
});
});
});
});
});
});
});

251
src/classes/assignees.ts Normal file
View File

@@ -0,0 +1,251 @@
import deburr from 'lodash.deburr';
import {wordsToList} from '../functions/words-to-list';
import {IAssignee} from '../interfaces/assignee';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {Issue} from './issue';
import {IssueLogger} from './loggers/issue-logger';
type CleanAssignee = string;
export class Assignees {
private static _cleanAssignee(assignee: Readonly<string>): CleanAssignee {
return deburr(assignee.toLowerCase());
}
private readonly _options: IIssuesProcessorOptions;
private readonly _issue: Issue;
private readonly _issueLogger: IssueLogger;
constructor(options: Readonly<IIssuesProcessorOptions>, issue: Issue) {
this._options = options;
this._issue = issue;
this._issueLogger = new IssueLogger(issue);
}
shouldExemptAssignees(): boolean {
if (!this._issue.hasAssignees) {
this._issueLogger.info('This $$type has no assignee');
this._logSkip();
return false;
}
if (this._shouldExemptAllAssignees()) {
this._issueLogger.info(
'Skipping $$type because it has an exempt assignee'
);
return true;
}
const exemptAssignees: string[] = this._getExemptAssignees();
if (exemptAssignees.length === 0) {
this._issueLogger.info(
`No option was specified to skip the stale process for this $$type`
);
this._logSkip();
return false;
}
this._issueLogger.info(
`Found ${exemptAssignees.length} assignee${
exemptAssignees.length > 1 ? 's' : ''
} on this $$type`
);
const hasExemptAssignee: boolean = exemptAssignees.some(
(exemptAssignee: Readonly<string>): boolean =>
this._hasAssignee(exemptAssignee)
);
if (!hasExemptAssignee) {
this._issueLogger.info(
'No assignee on this $$type can exempt the stale process'
);
this._logSkip();
} else {
this._issueLogger.info(
'Skipping this $$type because it has an exempt assignee'
);
}
return hasExemptAssignee;
}
private _getExemptAssignees(): string[] {
return this._issue.isPullRequest
? this._getExemptPullRequestAssignees()
: this._getExemptIssueAssignees();
}
private _getExemptIssueAssignees(): string[] {
if (this._options.exemptIssueAssignees === '') {
this._issueLogger.info(
'The option "exemptIssueAssignees" is disabled. No specific assignee can skip the stale process for this $$type'
);
if (this._options.exemptAssignees === '') {
this._issueLogger.info(
'The option "exemptAssignees" is disabled. No specific assignee can skip the stale process for this $$type'
);
return [];
}
const exemptAssignees: string[] = wordsToList(
this._options.exemptAssignees
);
this._issueLogger.info(
`The option "exemptAssignees" is set. ${
exemptAssignees.length
} assignee${
exemptAssignees.length === 1 ? '' : 's'
} can skip the stale process for this $$type`
);
return exemptAssignees;
}
const exemptAssignees: string[] = wordsToList(
this._options.exemptIssueAssignees
);
this._issueLogger.info(
`The option "exemptIssueAssignees" is set. ${
exemptAssignees.length
} assignee${
exemptAssignees.length === 1 ? '' : 's'
} can skip the stale process for this $$type`
);
return exemptAssignees;
}
private _getExemptPullRequestAssignees(): string[] {
if (this._options.exemptPrAssignees === '') {
this._issueLogger.info(
'The option "exemptPrAssignees" is disabled. No specific assignee can skip the stale process for this $$type'
);
if (this._options.exemptAssignees === '') {
this._issueLogger.info(
'The option "exemptAssignees" is disabled. No specific assignee can skip the stale process for this $$type'
);
return [];
}
const exemptAssignees: string[] = wordsToList(
this._options.exemptAssignees
);
this._issueLogger.info(
`The option "exemptAssignees" is set. ${
exemptAssignees.length
} assignee${
exemptAssignees.length === 1 ? '' : 's'
} can skip the stale process for this $$type`
);
return exemptAssignees;
}
const exemptAssignees: string[] = wordsToList(
this._options.exemptPrAssignees
);
this._issueLogger.info(
`The option "exemptPrAssignees" is set. ${
exemptAssignees.length
} assignee${
exemptAssignees.length === 1 ? '' : 's'
} can skip the stale process for this $$type`
);
return exemptAssignees;
}
private _hasAssignee(assignee: Readonly<string>): boolean {
const cleanAssignee: CleanAssignee = Assignees._cleanAssignee(assignee);
return this._issue.assignees.some(
(issueAssignee: Readonly<IAssignee>): boolean => {
const isSameAssignee: boolean =
cleanAssignee === Assignees._cleanAssignee(issueAssignee.login);
if (isSameAssignee) {
this._issueLogger.info(
`@${issueAssignee.login} is assigned on this $$type and is an exempt assignee`
);
}
return isSameAssignee;
}
);
}
private _shouldExemptAllAssignees(): boolean {
return this._issue.isPullRequest
? this._shouldExemptAllPullRequestAssignees()
: this._shouldExemptAllIssueAssignees();
}
private _shouldExemptAllIssueAssignees(): boolean {
if (this._options.exemptAllIssueAssignees === true) {
this._issueLogger.info(
'The option "exemptAllIssueAssignees" is enabled. Any assignee on this $$type will skip the stale process'
);
return true;
} else if (this._options.exemptAllIssueAssignees === false) {
this._issueLogger.info(
'The option "exemptAllIssueAssignees" is disabled. Only some specific assignees on this $$type will skip the stale process'
);
return false;
}
this._logExemptAllAssigneesOption();
return this._options.exemptAllAssignees;
}
private _shouldExemptAllPullRequestAssignees(): boolean {
if (this._options.exemptAllPrAssignees === true) {
this._issueLogger.info(
'The option "exemptAllPrAssignees" is enabled. Any assignee on this $$type will skip the stale process'
);
return true;
} else if (this._options.exemptAllPrAssignees === false) {
this._issueLogger.info(
'The option "exemptAllPrAssignees" is disabled. Only some specific assignees on this $$type will skip the stale process'
);
return false;
}
this._logExemptAllAssigneesOption();
return this._options.exemptAllAssignees;
}
private _logExemptAllAssigneesOption(): void {
if (this._options.exemptAllAssignees) {
this._issueLogger.info(
'The option "exemptAllAssignees" is enabled. Any assignee on this $$type will skip the stale process'
);
} else {
this._issueLogger.info(
'The option "exemptAllAssignees" is disabled. Only some specific assignees on this $$type will skip the stale process'
);
}
}
private _logSkip(): void {
this._issueLogger.info('Skip the assignees checks');
}
}

View File

@@ -1,3 +1,4 @@
import {IAssignee} from '../interfaces/assignee';
import {IIssue} from '../interfaces/issue';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {ILabel} from '../interfaces/label';
@@ -25,10 +26,7 @@ describe('Issue', (): void => {
debugOnly: false,
deleteBranch: false,
exemptIssueLabels: '',
exemptIssueMilestones: '',
exemptMilestones: '',
exemptPrLabels: '',
exemptPrMilestones: '',
onlyLabels: '',
operationsPerRun: 0,
removeStaleWhenUpdated: false,
@@ -40,9 +38,18 @@ describe('Issue', (): void => {
startDate: undefined,
stalePrLabel: 'dummy-stale-pr-label',
staleIssueLabel: 'dummy-stale-issue-label',
exemptMilestones: '',
exemptIssueMilestones: '',
exemptPrMilestones: '',
exemptAllMilestones: false,
exemptAllIssueMilestones: undefined,
exemptAllPrMilestones: undefined
exemptAllPrMilestones: undefined,
exemptAssignees: '',
exemptIssueAssignees: '',
exemptPrAssignees: '',
exemptAllAssignees: false,
exemptAllIssueAssignees: undefined,
exemptAllPrAssignees: undefined
};
issueInterface = {
title: 'dummy-title',
@@ -59,7 +66,12 @@ describe('Issue', (): void => {
locked: false,
milestone: {
title: 'dummy-milestone'
}
},
assignees: [
{
login: 'dummy-login'
}
]
};
issue = new Issue(optionsInterface, issueInterface);
});
@@ -125,56 +137,14 @@ describe('Issue', (): void => {
} 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 assignees with the given issue assignees', (): void => {
expect.assertions(1);
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');
});
expect(issue.assignees).toStrictEqual([
{
login: 'dummy-login'
} as IAssignee
]);
});
describe('when the given issue does not contains the stale label', (): void => {
@@ -209,4 +179,104 @@ describe('Issue', (): void => {
});
});
});
describe('get isPullRequest', (): void => {
describe('when the issue pull_request is not set', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
issue = new Issue(optionsInterface, issueInterface);
});
it('should return false', (): void => {
expect.assertions(1);
const result = issue.isPullRequest;
expect(result).toStrictEqual(false);
});
});
describe('when the issue pull_request is set', (): void => {
beforeEach((): void => {
issueInterface.pull_request = {};
issue = new Issue(optionsInterface, issueInterface);
});
it('should return true', (): void => {
expect.assertions(1);
const result = issue.isPullRequest;
expect(result).toStrictEqual(true);
});
});
});
describe('get staleLabel', (): void => {
describe('when the issue is not a pull request', (): void => {
beforeEach((): void => {
issueInterface.pull_request = undefined;
issue = new Issue(optionsInterface, issueInterface);
});
it('should return the issue stale label', (): void => {
expect.assertions(1);
const result = issue.staleLabel;
expect(result).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 return the pull request stale label', (): void => {
expect.assertions(1);
const result = issue.staleLabel;
expect(result).toStrictEqual('dummy-stale-pr-label');
});
});
});
describe('get hasAssignees', (): void => {
describe('when the issue has no assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [];
issue = new Issue(optionsInterface, issueInterface);
});
it('should return false', (): void => {
expect.assertions(1);
const result = issue.hasAssignees;
expect(result).toStrictEqual(false);
});
});
describe('when the issue has at least one assignee', (): void => {
beforeEach((): void => {
issueInterface.assignees = [
{
login: 'dummy-login'
}
];
issue = new Issue(optionsInterface, issueInterface);
});
it('should return true', (): void => {
expect.assertions(1);
const result = issue.hasAssignees;
expect(result).toStrictEqual(true);
});
});
});
});

View File

@@ -1,5 +1,6 @@
import {isLabeled} from '../functions/is-labeled';
import {isPullRequest} from '../functions/is-pull-request';
import {IAssignee} from '../interfaces/assignee';
import {IIssue} from '../interfaces/issue';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {ILabel} from '../interfaces/label';
@@ -17,8 +18,7 @@ export class Issue implements IIssue {
readonly state: string;
readonly locked: boolean;
readonly milestone: IMilestone | undefined;
readonly isPullRequest: boolean;
readonly staleLabel: string;
readonly assignees: IAssignee[];
isStale: boolean;
constructor(
@@ -35,12 +35,23 @@ export class Issue implements IIssue {
this.state = issue.state;
this.locked = issue.locked;
this.milestone = issue.milestone;
this.assignees = issue.assignees;
this.isPullRequest = isPullRequest(this);
this.staleLabel = this._getStaleLabel();
this.isStale = isLabeled(this, this.staleLabel);
}
get isPullRequest(): boolean {
return isPullRequest(this);
}
get staleLabel(): string {
return this._getStaleLabel();
}
get hasAssignees(): boolean {
return this.assignees.length > 0;
}
private _getStaleLabel(): string {
return this.isPullRequest
? this._options.stalePrLabel

View File

@@ -15,6 +15,7 @@ import {IIssue} from '../interfaces/issue';
import {IIssueEvent} from '../interfaces/issue-event';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {IPullRequest} from '../interfaces/pull-request';
import {Assignees} from './assignees';
import {Issue} from './issue';
import {IssueLogger} from './loggers/issue-logger';
import {Logger} from './loggers/logger';
@@ -97,9 +98,7 @@ export class IssuesProcessor {
for (const issue of issues.values()) {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(
`Found issue: issue #${issue.number} last updated ${issue.updated_at} (is pr? ${issue.isPullRequest})`
);
issueLogger.info(`Found this $$type last updated ${issue.updated_at}`);
// calculate string based messages for this issue
const staleMessage: string = issue.isPullRequest
@@ -122,26 +121,22 @@ export class IssuesProcessor {
? this._getDaysBeforePrStale()
: this._getDaysBeforeIssueStale();
if (issue.isPullRequest) {
issueLogger.info(`Days before pull request stale: ${daysBeforeStale}`);
} else {
issueLogger.info(`Days before issue stale: ${daysBeforeStale}`);
}
issueLogger.info(`Days before $$type stale: ${daysBeforeStale}`);
const shouldMarkAsStale: boolean = shouldMarkWhenStale(daysBeforeStale);
if (!staleMessage && shouldMarkAsStale) {
issueLogger.info(`Skipping ${issueType} due to empty stale message`);
issueLogger.info(`Skipping $$type due to empty stale message`);
continue;
}
if (issue.state === 'closed') {
issueLogger.info(`Skipping ${issueType} because it is closed`);
issueLogger.info(`Skipping $$type because it is closed`);
continue; // don't process closed issues
}
if (issue.locked) {
issueLogger.info(`Skipping ${issueType} because it is locked`);
issueLogger.info(`Skipping $$type because it is locked`);
continue; // don't process locked issues
}
@@ -164,14 +159,14 @@ export class IssuesProcessor {
}
issueLogger.info(
`Issue created the ${getHumanizedDate(createdAt)} (${
`$$type created the ${getHumanizedDate(createdAt)} (${
issue.created_at
})`
);
if (!isDateMoreRecentThan(createdAt, startDate)) {
issueLogger.info(
`Skipping ${issueType} because it was created before the specified start date`
`Skipping $$type because it was created before the specified start date`
);
continue; // don't process issues which were created before the start date
@@ -179,9 +174,9 @@ export class IssuesProcessor {
}
if (issue.isStale) {
issueLogger.info(`This issue has a stale label`);
issueLogger.info(`This $$type has a stale label`);
} else {
issueLogger.info(`This issue hasn't a stale label`);
issueLogger.info(`This $$type hasn't a stale label`);
}
const exemptLabels: string[] = wordsToList(
@@ -200,9 +195,7 @@ export class IssuesProcessor {
await this._removeStaleLabel(issue, staleLabel);
}
issueLogger.info(
`Skipping ${issueType} because it has an exempt label`
);
issueLogger.info(`Skipping $$type because it has an exempt label`);
continue; // don't process exempt issues
}
@@ -210,11 +203,17 @@ export class IssuesProcessor {
if (milestones.shouldExemptMilestones()) {
issueLogger.info(
`Skipping ${issueType} because it has an exempt milestone`
`Skipping $$type because it has an exempted milestone`
);
continue; // don't process exempt milestones
}
const assignees: Assignees = new Assignees(this.options, issue);
if (assignees.shouldExemptAssignees()) {
continue; // don't process exempt assignees
}
// should this issue be marked stale?
const shouldBeStale = !IssuesProcessor._updatedSince(
issue.updated_at,
@@ -224,7 +223,7 @@ export class IssuesProcessor {
// determine if this issue needs to be marked stale first
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`
`Marking $$type 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);
issue.isStale = true; // this issue is now considered stale
@@ -236,7 +235,7 @@ export class IssuesProcessor {
// process the issue if it was marked stale
if (issue.isStale) {
issueLogger.info(`Found a stale ${issueType}`);
issueLogger.info(`Found a stale $$type`);
await this._processStaleIssue(
issue,
issueType,
@@ -271,37 +270,27 @@ export class IssuesProcessor {
const issueLogger: IssueLogger = new IssueLogger(issue);
const markedStaleOn: string =
(await this._getLabelCreationDate(issue, staleLabel)) || issue.updated_at;
issueLogger.info(
`Issue #${issue.number} marked stale on: ${markedStaleOn}`
);
issueLogger.info(`$$type marked stale on: ${markedStaleOn}`);
const issueHasComments: boolean = await this._hasCommentsSince(
issue,
markedStaleOn,
actor
);
issueLogger.info(
`Issue #${issue.number} has been commented on: ${issueHasComments}`
);
issueLogger.info(`$$type has been commented on: ${issueHasComments}`);
const isPr: boolean = isPullRequest(issue);
const daysBeforeClose: number = isPr
? this._getDaysBeforePrClose()
: this._getDaysBeforeIssueClose();
if (isPr) {
issueLogger.info(`Days before pull request close: ${daysBeforeClose}`);
} else {
issueLogger.info(`Days before issue close: ${daysBeforeClose}`);
}
issueLogger.info(`Days before $$type close: ${daysBeforeClose}`);
const issueHasUpdate: boolean = IssuesProcessor._updatedSince(
issue.updated_at,
daysBeforeClose
);
issueLogger.info(
`Issue #${issue.number} has been updated: ${issueHasUpdate}`
);
issueLogger.info(`$$type has been updated: ${issueHasUpdate}`);
// should we un-stale this issue?
if (this.options.removeStaleWhenUpdated && issueHasComments) {
@@ -315,20 +304,20 @@ export class IssuesProcessor {
if (!issueHasComments && !issueHasUpdate) {
issueLogger.info(
`Closing ${issueType} because it was last updated on ${issue.updated_at}`
`Closing $$type because it was last updated on ${issue.updated_at}`
);
await this._closeIssue(issue, closeMessage, closeLabel);
if (this.options.deleteBranch && issue.pull_request) {
issueLogger.info(
`Deleting branch for #${issue.number} as delete-branch option was specified`
`Deleting branch for as delete-branch option was specified`
);
await this._deleteBranch(issue);
this.deletedBranchIssues.push(issue);
}
} else {
issueLogger.info(
`Stale ${issueType} is not old enough to close yet (hasComments? ${issueHasComments}, hasUpdate? ${issueHasUpdate})`
`Stale $$type is not old enough to close yet (hasComments? ${issueHasComments}, hasUpdate? ${issueHasUpdate})`
);
}
}
@@ -341,9 +330,7 @@ export class IssuesProcessor {
): Promise<boolean> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(
`Checking for comments on issue #${issue.number} since ${sinceDate}`
);
issueLogger.info(`Checking for comments on $$type since ${sinceDate}`);
if (!sinceDate) {
return true;
@@ -433,7 +420,7 @@ export class IssuesProcessor {
): Promise<void> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(`Marking issue #${issue.number} as stale`);
issueLogger.info(`Marking $$type as stale`);
this.staleIssues.push(issue);
@@ -481,7 +468,7 @@ export class IssuesProcessor {
): Promise<void> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(`Closing issue #${issue.number} for being stale`);
issueLogger.info(`Closing $$type for being stale`);
this.closedIssues.push(issue);
@@ -525,7 +512,7 @@ export class IssuesProcessor {
state: 'closed'
});
} catch (error) {
issueLogger.error(`Error updating an issue: ${error.message}`);
issueLogger.error(`Error updating this $$type: ${error.message}`);
}
}
@@ -544,9 +531,7 @@ export class IssuesProcessor {
return pullRequest.data;
} catch (error) {
issueLogger.error(
`Error getting pull request ${issue.number}: ${error.message}`
);
issueLogger.error(`Error getting this $$type: ${error.message}`);
}
}
@@ -554,9 +539,7 @@ export class IssuesProcessor {
private async _deleteBranch(issue: Issue): Promise<void> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(
`Delete branch from closed issue #${issue.number} - ${issue.title}`
);
issueLogger.info(`Delete branch from closed $$type - ${issue.title}`);
if (this.options.debugOnly) {
return;
@@ -566,15 +549,13 @@ export class IssuesProcessor {
if (!pullRequest) {
issueLogger.info(
`Not deleting branch as pull request not found for issue ${issue.number}`
`Not deleting branch as pull request not found for this $$type`
);
return;
}
const branch = pullRequest.head.ref;
issueLogger.info(
`Deleting branch ${branch} from closed issue #${issue.number}`
);
issueLogger.info(`Deleting branch ${branch} from closed $$type`);
this._operationsLeft -= 1;
@@ -586,7 +567,7 @@ export class IssuesProcessor {
});
} catch (error) {
issueLogger.error(
`Error deleting branch ${branch} from issue #${issue.number}: ${error.message}`
`Error deleting branch ${branch} from $$type: ${error.message}`
);
}
}
@@ -595,7 +576,7 @@ export class IssuesProcessor {
private async _removeLabel(issue: Issue, label: string): Promise<void> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(`Removing label "${label}" from issue #${issue.number}`);
issueLogger.info(`Removing label "${label}" from $$type`);
this.removedLabelIssues.push(issue);
@@ -626,7 +607,7 @@ export class IssuesProcessor {
): Promise<string | undefined> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(`Checking for label on issue #${issue.number}`);
issueLogger.info(`Checking for label on $$type`);
this._operationsLeft -= 1;
@@ -682,9 +663,7 @@ export class IssuesProcessor {
): Promise<void> {
const issueLogger: IssueLogger = new IssueLogger(issue);
issueLogger.info(
`Issue #${issue.number} is no longer stale. Removing stale label.`
);
issueLogger.info(`$$type is no longer stale. Removing stale label.`);
return this._removeLabel(issue, staleLabel);
}

View File

@@ -1,3 +1,5 @@
import {DefaultProcessorOptions} from '../../../__tests__/constants/default-processor-options';
import {generateIIssue} from '../../../__tests__/functions/generate-iissue';
import {Issue} from '../issue';
import {IssueLogger} from './issue-logger';
import * as core from '@actions/core';
@@ -5,21 +7,20 @@ import * as core from '@actions/core';
describe('IssueLogger', (): void => {
let issue: Issue;
let issueLogger: IssueLogger;
let message: string;
beforeEach((): void => {
issue = {
number: 8
} as Issue;
issueLogger = new IssueLogger(issue);
});
let coreWarningSpy: jest.SpyInstance;
describe('warning()', (): void => {
let message: string;
let coreWarningSpy: jest.SpyInstance;
beforeEach((): void => {
message = 'dummy-message';
issue = new Issue(
DefaultProcessorOptions,
generateIIssue({
number: 8
})
);
issueLogger = new IssueLogger(issue);
coreWarningSpy = jest.spyOn(core, 'warning').mockImplementation();
});
@@ -35,12 +36,17 @@ describe('IssueLogger', (): void => {
});
describe('info()', (): void => {
let message: string;
let coreInfoSpy: jest.SpyInstance;
beforeEach((): void => {
message = 'dummy-message';
issue = new Issue(
DefaultProcessorOptions,
generateIIssue({
number: 8
})
);
issueLogger = new IssueLogger(issue);
coreInfoSpy = jest.spyOn(core, 'info').mockImplementation();
});
@@ -56,12 +62,17 @@ describe('IssueLogger', (): void => {
});
describe('error()', (): void => {
let message: string;
let coreErrorSpy: jest.SpyInstance;
beforeEach((): void => {
message = 'dummy-message';
issue = new Issue(
DefaultProcessorOptions,
generateIIssue({
number: 8
})
);
issueLogger = new IssueLogger(issue);
coreErrorSpy = jest.spyOn(core, 'error').mockImplementation();
});
@@ -75,4 +86,82 @@ describe('IssueLogger', (): void => {
expect(coreErrorSpy).toHaveBeenCalledWith('[#8] dummy-message');
});
});
it('should prefix the message with the issue number', (): void => {
expect.assertions(2);
message = 'dummy-message';
issue = new Issue(
DefaultProcessorOptions,
generateIIssue({
number: 123
})
);
issueLogger = new IssueLogger(issue);
coreWarningSpy = jest.spyOn(core, 'warning').mockImplementation();
issueLogger.warning(message);
expect(coreWarningSpy).toHaveBeenCalledTimes(1);
expect(coreWarningSpy).toHaveBeenCalledWith('[#123] dummy-message');
});
it.each`
pull_request | replacement
${{key: 'value'}} | ${'pull request'}
${{}} | ${'pull request'}
${null} | ${'issue'}
${undefined} | ${'issue'}
`(
'should replace the special tokens "$$type" with the corresponding type',
({pull_request, replacement}): void => {
expect.assertions(2);
message = 'The $$type will stale! $$type will soon be closed!';
issue = new Issue(
DefaultProcessorOptions,
generateIIssue({
number: 8,
pull_request
})
);
issueLogger = new IssueLogger(issue);
coreWarningSpy = jest.spyOn(core, 'warning').mockImplementation();
issueLogger.warning(message);
expect(coreWarningSpy).toHaveBeenCalledTimes(1);
expect(coreWarningSpy).toHaveBeenCalledWith(
`[#8] The ${replacement} will stale! ${replacement} will soon be closed!`
);
}
);
it.each`
pull_request | replacement
${{key: 'value'}} | ${'Pull request'}
${{}} | ${'Pull request'}
${null} | ${'Issue'}
${undefined} | ${'Issue'}
`(
'should replace the special token "$$type" with the corresponding type with first letter as uppercase',
({pull_request, replacement}): void => {
expect.assertions(2);
message = '$$type will stale';
issue = new Issue(
DefaultProcessorOptions,
generateIIssue({
number: 8,
pull_request
})
);
issueLogger = new IssueLogger(issue);
coreWarningSpy = jest.spyOn(core, 'warning').mockImplementation();
issueLogger.warning(message);
expect(coreWarningSpy).toHaveBeenCalledTimes(1);
expect(coreWarningSpy).toHaveBeenCalledWith(
`[#8] ${replacement} will stale`
);
}
);
});

View File

@@ -2,6 +2,19 @@ import * as core from '@actions/core';
import {Issue} from '../issue';
import {Logger} from './logger';
/**
* @description
* Each log will prefix the message with the issue number
*
* @example
* warning('No stale') => "[#123] No stale"
*
* Each log method can have special tokens:
* - $$type => will replace this by either "pull request" or "issue" depending of the type of issue
*
* @example
* warning('The $$type will stale') => "The pull request will stale"
*/
export class IssueLogger implements Logger {
private readonly _issue: Issue;
@@ -10,15 +23,31 @@ export class IssueLogger implements Logger {
}
warning(message: Readonly<string>): void {
core.warning(this._prefixWithIssueNumber(message));
core.warning(this._format(message));
}
info(message: Readonly<string>): void {
core.info(this._prefixWithIssueNumber(message));
core.info(this._format(message));
}
error(message: Readonly<string>): void {
core.error(this._prefixWithIssueNumber(message));
core.error(this._format(message));
}
private _replaceTokens(message: Readonly<string>): string {
return this._replaceTypeToken(message);
}
private _replaceTypeToken(message: Readonly<string>): string {
return message
.replace(
/^\$\$type/,
this._issue.isPullRequest ? 'Pull request' : 'Issue'
)
.replace(
/\$\$type/g,
this._issue.isPullRequest ? 'pull request' : 'issue'
);
}
private _prefixWithIssueNumber(message: Readonly<string>): string {
@@ -28,4 +57,8 @@ export class IssueLogger implements Logger {
private _getIssueNumber(): number {
return this._issue.number;
}
private _format(message: Readonly<string>): string {
return this._prefixWithIssueNumber(this._replaceTokens(message));
}
}

View File

@@ -1,3 +1,5 @@
import {DefaultProcessorOptions} from '../../__tests__/constants/default-processor-options';
import {generateIIssue} from '../../__tests__/functions/generate-iissue';
import {IIssue} from '../interfaces/issue';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {Issue} from './issue';
@@ -10,51 +12,8 @@ describe('Milestones', (): void => {
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: '',
exemptAllMilestones: false,
exemptAllIssueMilestones: undefined,
exemptAllPrMilestones: undefined
};
issueInterface = {
created_at: '',
locked: false,
milestone: undefined,
number: 0,
pull_request: undefined,
state: '',
title: '',
updated_at: '',
labels: []
};
optionsInterface = {...DefaultProcessorOptions};
issueInterface = generateIIssue();
});
describe('shouldExemptMilestones()', (): void => {

View File

@@ -6,8 +6,8 @@ import {Issue} from './issue';
type CleanMilestone = string;
export class Milestones {
private static _cleanMilestone(label: Readonly<string>): CleanMilestone {
return deburr(label.toLowerCase());
private static _cleanMilestone(milestone: Readonly<string>): CleanMilestone {
return deburr(milestone.toLowerCase());
}
private readonly _options: IIssuesProcessorOptions;

View File

@@ -0,0 +1,3 @@
export interface IAssignee {
login: string;
}

View File

@@ -1,4 +1,5 @@
import {IsoDateString} from '../types/iso-date-string';
import {IAssignee} from './assignee';
import {ILabel} from './label';
import {IMilestone} from './milestone';
@@ -12,4 +13,5 @@ export interface IIssue {
state: string;
locked: boolean;
milestone: IMilestone | undefined;
assignees: IAssignee[];
}

View File

@@ -33,4 +33,10 @@ export interface IIssuesProcessorOptions {
exemptAllMilestones: boolean;
exemptAllIssueMilestones: boolean | undefined;
exemptAllPrMilestones: boolean | undefined;
exemptAssignees: string;
exemptIssueAssignees: string;
exemptPrAssignees: string;
exemptAllAssignees: boolean;
exemptAllIssueAssignees: boolean | undefined;
exemptAllPrAssignees: boolean | undefined;
}

View File

@@ -59,7 +59,13 @@ function _getAndValidateArgs(): IIssuesProcessorOptions {
exemptPrMilestones: core.getInput('exempt-pr-milestones'),
exemptAllMilestones: core.getInput('exempt-all-milestones') === 'true',
exemptAllIssueMilestones: _toOptionalBoolean('exempt-all-issue-milestones'),
exemptAllPrMilestones: _toOptionalBoolean('exempt-all-pr-milestones')
exemptAllPrMilestones: _toOptionalBoolean('exempt-all-pr-milestones'),
exemptAssignees: core.getInput('exempt-assignees'),
exemptIssueAssignees: core.getInput('exempt-issue-assignees'),
exemptPrAssignees: core.getInput('exempt-pr-assignees'),
exemptAllAssignees: core.getInput('exempt-all-assignees') === 'true',
exemptAllIssueAssignees: _toOptionalBoolean('exempt-all-issue-assignees'),
exemptAllPrAssignees: _toOptionalBoolean('exempt-all-pr-assignees')
};
for (const numberInput of [