Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Do not close pinned issues #1049

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
109 changes: 55 additions & 54 deletions README.md

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion __tests__/classes/issues-processor-mock.ts
Expand Up @@ -4,6 +4,7 @@ import {IComment} from '../../src/interfaces/comment';
import {IIssuesProcessorOptions} from '../../src/interfaces/issues-processor-options';
import {IPullRequest} from '../../src/interfaces/pull-request';
import {IState} from '../../src/interfaces/state/state';
import {IIssueEvent} from '../../src/interfaces/issue-event';

export class IssuesProcessorMock extends IssuesProcessor {
constructor(
Expand All @@ -18,7 +19,8 @@ export class IssuesProcessorMock extends IssuesProcessor {
issue: Issue,
label: string
) => Promise<string | undefined>,
getPullRequest?: (issue: Issue) => Promise<IPullRequest | undefined | void>
getPullRequest?: (issue: Issue) => Promise<IPullRequest | undefined | void>,
getIssueEvents?: (issue: Issue) => Promise<IIssueEvent[]>
) {
super(options, state);

Expand All @@ -37,5 +39,9 @@ export class IssuesProcessorMock extends IssuesProcessor {
if (getPullRequest) {
this.getPullRequest = getPullRequest;
}

if (getIssueEvents) {
this.getIssueEvents = getIssueEvents;
}
}
}
1 change: 1 addition & 0 deletions __tests__/constants/default-processor-options.ts
Expand Up @@ -17,6 +17,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
staleIssueLabel: 'Stale',
closeIssueLabel: '',
exemptIssueLabels: '',
exemptPinnedIssues: false,
stalePrLabel: 'Stale',
closePrLabel: '',
exemptPrLabels: '',
Expand Down
89 changes: 89 additions & 0 deletions __tests__/pinned.spec.ts
@@ -0,0 +1,89 @@
import {IIssuesProcessorOptions} from '../src/interfaces/issues-processor-options';
import {DefaultProcessorOptions} from './constants/default-processor-options';
import {Issue} from '../src/classes/issue';
import {generateIssue} from './functions/generate-issue';
import {IssuesProcessorMock} from './classes/issues-processor-mock';
import {alwaysFalseStateMock} from './classes/state-mock';
import {IIssueEvent} from '../src/interfaces/issue-event';

const opts: IIssuesProcessorOptions = {
...DefaultProcessorOptions,
daysBeforeClose: 0
};
const testIssueList = async (page: number): Promise<Issue[]> => {
return page == 1
? [
generateIssue(opts, 1, 'Issue 1', '2020-01-01T17:00:00Z'),
generateIssue(opts, 2, 'Issue 2', '2020-01-01T17:00:00Z')
]
: [];
};

const pinnedEvent: IIssueEvent = {
created_at: '2020-01-01T17:00:00Z',
event: 'pinned',
label: {}
};
const unpinnedEvent: IIssueEvent = {
created_at: '2020-01-01T17:00:00Z',
event: 'unpinned',
label: {}
};
describe('exempt-pinned-issues options', (): void => {
it('pinned issues should be skipped if exemptPinnedIssues true', async () => {
const processor = new IssuesProcessorMock(
{...opts, exemptPinnedIssues: true},
alwaysFalseStateMock,
testIssueList,
async () => [],
async () => new Date().toDateString(),
async (issue: Issue) => undefined,
async (issue: Issue) => (issue.number === 1 ? [pinnedEvent] : [])
);
await processor.processIssues(1);
expect(processor.staleIssues).toHaveLength(1);
});

it('pinned issues should not be skipped if exemptPinnedIssues false', async () => {
const processor = new IssuesProcessorMock(
{...opts, exemptPinnedIssues: false},
alwaysFalseStateMock,
testIssueList,
async () => [],
async () => new Date().toDateString(),
async (issue: Issue) => undefined,
async (issue: Issue) => (issue.number === 1 ? [pinnedEvent] : [])
);
await processor.processIssues(1);
expect(processor.staleIssues).toHaveLength(2);
});

it('pinned issues should not be skipped if exemptPinnedIssues true but it was unpinned', async () => {
const processor = new IssuesProcessorMock(
{...opts, exemptPinnedIssues: true},
alwaysFalseStateMock,
testIssueList,
async () => [],
async () => new Date().toDateString(),
async (issue: Issue) => undefined,
async (issue: Issue) =>
issue.number === 1 ? [unpinnedEvent, pinnedEvent] : []
);
await processor.processIssues(1);
expect(processor.staleIssues).toHaveLength(2);
});
it('pinned issues should not be skipped if exemptPinnedIssues true and it was unpinned and pinned', async () => {
const processor = new IssuesProcessorMock(
{...opts, exemptPinnedIssues: true},
alwaysFalseStateMock,
testIssueList,
async () => [],
async () => new Date().toDateString(),
async (issue: Issue) => undefined,
async (issue: Issue) =>
issue.number === 1 ? [pinnedEvent, unpinnedEvent, pinnedEvent] : []
);
await processor.processIssues(1);
expect(processor.staleIssues).toHaveLength(1);
});
});
4 changes: 4 additions & 0 deletions action.yml
Expand Up @@ -164,6 +164,10 @@ inputs:
description: 'Exempt all issues with assignees from being marked as stale. Override "exempt-all-assignees" option regarding only the issues.'
default: ''
required: false
exempt-pinned-issues:
description: 'Exempt pinned issues from being marked as stale. Default to false.'
default: 'false'
required: false
exempt-all-pr-assignees:
description: 'Exempt all pull requests with assignees from being marked as stale. Override "exempt-all-assignees" option regarding only the pull requests.'
default: ''
Expand Down
52 changes: 43 additions & 9 deletions dist/index.js
Expand Up @@ -408,6 +408,8 @@ class IssuesProcessor {
this.addedLabelIssues = [];
this.addedCloseCommentIssues = [];
this._logger = new logger_1.Logger();
this._lastIssueEvents = [];
this._lastIssueEventsIssueId = -1;
this.options = options;
this.state = state;
this.client = (0, github_1.getOctokit)(this.options.repoToken, undefined, plugin_retry_1.retry);
Expand Down Expand Up @@ -464,6 +466,34 @@ class IssuesProcessor {
return this.processIssues(page + 1);
});
}
getIssueEvents(issue) {
return __awaiter(this, void 0, void 0, function* () {
if (issue.number !== this._lastIssueEventsIssueId) {
const options = this.client.rest.issues.listEvents.endpoint.merge({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
per_page: 100,
issue_number: issue.number
});
const events = yield this.client.paginate(options);
this._lastIssueEvents = events.reverse();
this._lastIssueEventsIssueId = issue.number;
}
return this._lastIssueEvents;
});
}
getPinnedStatus(issue) {
return __awaiter(this, void 0, void 0, function* () {
const events = yield this.getIssueEvents(issue);
const pinnedEvent = events.findIndex(event => event.event === 'pinned');
if (pinnedEvent == -1)
return false;
const unpinnedEvent = events.findIndex(event => event.event === 'unpinned');
if (unpinnedEvent == -1)
return true;
return pinnedEvent < unpinnedEvent;
});
}
processIssue(issue, labelsToAddWhenUnstale, labelsToRemoveWhenUnstale, labelsToRemoveWhenStale) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
Expand Down Expand Up @@ -504,6 +534,11 @@ class IssuesProcessor {
IssuesProcessor._endIssueProcessing(issue);
return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list
}
if (yield this._isSkipPinned(issue)) {
issueLogger.info('Skipping this issue because it is pinned');
IssuesProcessor._endIssueProcessing(issue);
return; // Don't process pinned issues
}
const onlyLabels = (0, words_to_list_1.wordsToList)(this._getOnlyLabels(issue));
if (onlyLabels.length > 0) {
issueLogger.info(`The option ${issueLogger.createOptionLink(option_1.Option.OnlyLabels)} was specified to only process issues and pull requests with all those labels (${logger_service_1.LoggerService.cyan(onlyLabels.length)})`);
Expand Down Expand Up @@ -702,15 +737,8 @@ class IssuesProcessor {
issueLogger.info(`Checking for label on this $$type`);
this._consumeIssueOperation(issue);
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsEventsCount();
const options = this.client.rest.issues.listEvents.endpoint.merge({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
per_page: 100,
issue_number: issue.number
});
const events = yield this.client.paginate(options);
const reversedEvents = events.reverse();
const staleLabeledEvent = reversedEvents.find(event => event.event === 'labeled' &&
const events = yield this.getIssueEvents(issue);
const staleLabeledEvent = events.find(event => event.event === 'labeled' &&
(0, clean_label_1.cleanLabel)(event.label.name) === (0, clean_label_1.cleanLabel)(label));
if (!staleLabeledEvent) {
// Must be old rather than labeled
Expand Down Expand Up @@ -1025,6 +1053,11 @@ class IssuesProcessor {
_isIncludeOnlyAssigned(issue) {
return this.options.includeOnlyAssigned && !issue.hasAssignees;
}
_isSkipPinned(issue) {
return __awaiter(this, void 0, void 0, function* () {
return (this.options.exemptPinnedIssues && (yield this.getPinnedStatus(issue)));
});
}
_getAnyOfLabels(issue) {
if (issue.isPullRequest) {
if (this.options.anyOfPrLabels !== '') {
Expand Down Expand Up @@ -2461,6 +2494,7 @@ function _getAndValidateArgs() {
staleIssueLabel: core.getInput('stale-issue-label', { required: true }),
closeIssueLabel: core.getInput('close-issue-label'),
exemptIssueLabels: core.getInput('exempt-issue-labels'),
exemptPinnedIssues: core.getInput('exempt-pinned-issues') === 'true',
stalePrLabel: core.getInput('stale-pr-label', { required: true }),
closePrLabel: core.getInput('close-pr-label'),
exemptPrLabels: core.getInput('exempt-pr-labels'),
Expand Down
1 change: 1 addition & 0 deletions src/classes/issue.spec.ts
Expand Up @@ -26,6 +26,7 @@ describe('Issue', (): void => {
debugOnly: false,
deleteBranch: false,
exemptIssueLabels: '',
exemptPinnedIssues: false,
exemptPrLabels: '',
onlyLabels: '',
onlyIssueLabels: '',
Expand Down
53 changes: 43 additions & 10 deletions src/classes/issues-processor.ts
Expand Up @@ -193,6 +193,35 @@ export class IssuesProcessor {
return this.processIssues(page + 1);
}

private _lastIssueEvents: IIssueEvent[] = [];
private _lastIssueEventsIssueId = -1;
async getIssueEvents(issue: Issue): Promise<IIssueEvent[]> {
if (issue.number !== this._lastIssueEventsIssueId) {
const options = this.client.rest.issues.listEvents.endpoint.merge({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
issue_number: issue.number
});
const events: IIssueEvent[] = await this.client.paginate(options);
this._lastIssueEvents = events.reverse();
this._lastIssueEventsIssueId = issue.number;
}
return this._lastIssueEvents;
}

async getPinnedStatus(issue: Issue): Promise<boolean> {
const events = await this.getIssueEvents(issue);
const pinnedEvent = events.findIndex(event => event.event === 'pinned');

if (pinnedEvent == -1) return false;

const unpinnedEvent = events.findIndex(event => event.event === 'unpinned');
if (unpinnedEvent == -1) return true;

return pinnedEvent < unpinnedEvent;
}

async processIssue(
issue: Issue,
labelsToAddWhenUnstale: Readonly<string>[],
Expand Down Expand Up @@ -248,6 +277,12 @@ export class IssuesProcessor {
return; // If the issue has an 'include-only-assigned' option set, process only issues with nonempty assignees list
}

if (await this._isSkipPinned(issue)) {
issueLogger.info('Skipping this issue because it is pinned');
IssuesProcessor._endIssueProcessing(issue);
return; // Don't process pinned issues
}

const onlyLabels: string[] = wordsToList(this._getOnlyLabels(issue));

if (onlyLabels.length > 0) {
Expand Down Expand Up @@ -593,17 +628,9 @@ export class IssuesProcessor {

this._consumeIssueOperation(issue);
this.statistics?.incrementFetchedItemsEventsCount();
const options = this.client.rest.issues.listEvents.endpoint.merge({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
issue_number: issue.number
});

const events: IIssueEvent[] = await this.client.paginate(options);
const reversedEvents = events.reverse();

const staleLabeledEvent = reversedEvents.find(
const events = await this.getIssueEvents(issue);
const staleLabeledEvent = events.find(
event =>
event.event === 'labeled' &&
cleanLabel(event.label.name) === cleanLabel(label)
Expand Down Expand Up @@ -1074,6 +1101,12 @@ export class IssuesProcessor {
return this.options.includeOnlyAssigned && !issue.hasAssignees;
}

private async _isSkipPinned(issue: Issue): Promise<boolean> {
return (
this.options.exemptPinnedIssues && (await this.getPinnedStatus(issue))
);
}

private _getAnyOfLabels(issue: Issue): string {
if (issue.isPullRequest) {
if (this.options.anyOfPrLabels !== '') {
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/issues-processor-options.ts
Expand Up @@ -15,6 +15,7 @@ export interface IIssuesProcessorOptions {
staleIssueLabel: string;
closeIssueLabel: string;
exemptIssueLabels: string;
exemptPinnedIssues: boolean;
stalePrLabel: string;
closePrLabel: string;
exemptPrLabels: string;
Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Expand Up @@ -47,6 +47,7 @@ function _getAndValidateArgs(): IIssuesProcessorOptions {
staleIssueLabel: core.getInput('stale-issue-label', {required: true}),
closeIssueLabel: core.getInput('close-issue-label'),
exemptIssueLabels: core.getInput('exempt-issue-labels'),
exemptPinnedIssues: core.getInput('exempt-pinned-issues') === 'true',
stalePrLabel: core.getInput('stale-pr-label', {required: true}),
closePrLabel: core.getInput('close-pr-label'),
exemptPrLabels: core.getInput('exempt-pr-labels'),
Expand Down