Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Every argument is optional.
| [include-only-assigned](#include-only-assigned) | Process only assigned issues | `false` |
| [sort-by](#sort-by) | What to sort issues and PRs by | `created` |
| [only-issue-types](#only-issue-types) | Only issues with a matching type are processed as stale/closed. | |
| [exempt-issues-with-open-linked-pr](#exempt-issues-with-open-linked-pr) | Exempt issues that an open PR will close when merged | `false` |

### List of output options

Expand Down Expand Up @@ -577,6 +578,18 @@ This option does not affect PRs.

Default value: unset

#### exempt-issues-with-open-linked-pr

If set to `true`, an issue is left alone while an open pull request is linked to it in a way that will close it once merged (the link shown in the issue's "Development" section, usually created by a `Closes #123` line in the pull request). Work is still happening on the pull request, so the issue is not really inactive.

Only linked pull requests count. A pull request that merely mentions the issue does not exempt it.

Because this check costs one extra operation per issue, it only runs once every cheaper check has been passed, and only for issues that would otherwise be marked stale. It is disabled by default so that existing workflows keep the same [operations-per-run](#operations-per-run) budget.

This option does not affect PRs.

Default value: `false`

### Usage

See also [action.yml](./action.yml) for a comprehensive list of all the options.
Expand Down
7 changes: 6 additions & 1 deletion __tests__/classes/issues-processor-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export class IssuesProcessorMock extends IssuesProcessor {
staleLabel: string,
events: IIssueEvent[]
) => Promise<boolean>,
getPullRequest?: (issue: Issue) => Promise<IPullRequest | undefined | void>
getPullRequest?: (issue: Issue) => Promise<IPullRequest | undefined | void>,
hasOpenLinkedPullRequest?: (issue: Issue) => Promise<boolean>
) {
super(options, state);

Expand Down Expand Up @@ -60,5 +61,9 @@ export class IssuesProcessorMock extends IssuesProcessor {
if (getPullRequest) {
this.getPullRequest = getPullRequest;
}

if (hasOpenLinkedPullRequest) {
this.hasOpenLinkedPullRequest = hasOpenLinkedPullRequest;
}
}
}
3 changes: 2 additions & 1 deletion __tests__/constants/default-processor-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
ignorePrUpdates: undefined,
exemptDraftPr: false,
closeIssueReason: 'not_planned',
includeOnlyAssigned: false
includeOnlyAssigned: false,
exemptIssuesWithOpenLinkedPr: false
});
210 changes: 210 additions & 0 deletions __tests__/exempt-issues-with-open-linked-pr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import {beforeEach, describe, expect, test} from '@jest/globals';
import {Issue} from '../src/classes/issue.js';
import {IIssue} from '../src/interfaces/issue.js';
import {IIssuesProcessorOptions} from '../src/interfaces/issues-processor-options.js';
import {IssuesProcessorMock} from './classes/issues-processor-mock.js';
import {DefaultProcessorOptions} from './constants/default-processor-options.js';
import {generateIssue} from './functions/generate-issue.js';
import {alwaysFalseStateMock} from './classes/state-mock.js';

let issuesProcessorBuilder: IssuesProcessorBuilder;
let issuesProcessor: IssuesProcessorMock;

describe('exempt-issues-with-open-linked-pr option', (): void => {
beforeEach((): void => {
issuesProcessorBuilder = new IssuesProcessorBuilder();
});

describe('when the option "exempt-issues-with-open-linked-pr" is disabled', (): void => {
beforeEach((): void => {
issuesProcessorBuilder.processIssuesWithOpenLinkedPr();
});

test('should stale the issue even if an open pull request will close it', async (): Promise<void> => {
expect.assertions(1);
issuesProcessor = issuesProcessorBuilder
.toStaleIssues([{number: 10}])
.withOpenLinkedPullRequest(true)
.build();

await issuesProcessor.processIssues();

expect(issuesProcessor.staleIssues).toHaveLength(1);
});

test('should not consume an extra operation to look for linked pull requests', async (): Promise<void> => {
expect.assertions(1);
let calls = 0;
issuesProcessor = issuesProcessorBuilder
.toStaleIssues([{number: 11}])
.withLinkedPullRequestCallback(async (): Promise<boolean> => {
calls++;

return true;
})
.build();

await issuesProcessor.processIssues();

expect(calls).toStrictEqual(0);
});
});

describe('when the option "exempt-issues-with-open-linked-pr" is enabled', (): void => {
beforeEach((): void => {
issuesProcessorBuilder.exemptIssuesWithOpenLinkedPr();
});

test('should not stale the issue when an open pull request will close it', async (): Promise<void> => {
expect.assertions(1);
issuesProcessor = issuesProcessorBuilder
.toStaleIssues([{number: 20}])
.withOpenLinkedPullRequest(true)
.build();

await issuesProcessor.processIssues();

expect(issuesProcessor.staleIssues).toHaveLength(0);
});

test('should stale the issue when no open pull request will close it', async (): Promise<void> => {
expect.assertions(1);
issuesProcessor = issuesProcessorBuilder
.toStaleIssues([{number: 21}])
.withOpenLinkedPullRequest(false)
.build();

await issuesProcessor.processIssues();

expect(issuesProcessor.staleIssues).toHaveLength(1);
});

test('should stale the pull request because a pull request cannot have a linked pull request', async (): Promise<void> => {
expect.assertions(1);
issuesProcessor = issuesProcessorBuilder
.toStalePrs([{number: 22}])
.withOpenLinkedPullRequest(true)
.build();

await issuesProcessor.processIssues();

expect(issuesProcessor.staleIssues).toHaveLength(1);
});

test('should stale the issue when the linked pull requests cannot be fetched', async (): Promise<void> => {
expect.assertions(1);
issuesProcessor = issuesProcessorBuilder
.toStaleIssues([{number: 23}])
.withLinkedPullRequestCallback(async (): Promise<boolean> => {
// Mirrors the processor swallowing the API error and carrying on
return false;
})
.build();

await issuesProcessor.processIssues();

expect(issuesProcessor.staleIssues).toHaveLength(1);
});
});
});

class IssuesProcessorBuilder {
private _options: IIssuesProcessorOptions = {
...DefaultProcessorOptions
};
private _issues: Issue[] = [];
private _hasOpenLinkedPullRequest: (issue: Issue) => Promise<boolean> =
async (): Promise<boolean> => false;

processIssuesWithOpenLinkedPr(): IssuesProcessorBuilder {
this._options.exemptIssuesWithOpenLinkedPr = false;

return this;
}

exemptIssuesWithOpenLinkedPr(): IssuesProcessorBuilder {
this._options.exemptIssuesWithOpenLinkedPr = true;

return this;
}

withOpenLinkedPullRequest(hasOne: boolean): IssuesProcessorBuilder {
this._hasOpenLinkedPullRequest = async (): Promise<boolean> => hasOne;

return this;
}

withLinkedPullRequestCallback(
callback: (issue: Issue) => Promise<boolean>
): IssuesProcessorBuilder {
this._hasOpenLinkedPullRequest = callback;

return this;
}

issuesOrPrs(issues: Partial<IIssue>[]): IssuesProcessorBuilder {
this._issues = issues.map(
(issue: Readonly<Partial<IIssue>>, index: Readonly<number>): Issue =>
generateIssue(
this._options,
issue.number ?? index,
issue.title ?? 'dummy-title',
issue.updated_at ?? new Date().toDateString(),
issue.created_at ?? new Date().toDateString(),
!!issue.draft,
!!issue.pull_request,
issue.labels ? issue.labels.map(label => label.name || '') : []
)
);

return this;
}

prs(issues: Partial<IIssue>[]): IssuesProcessorBuilder {
this.issuesOrPrs(
issues.map((issue: Readonly<Partial<IIssue>>): Partial<IIssue> => {
return {
...issue,
pull_request: {key: 'value'}
};
})
);

return this;
}

private _staleDates(issues: Partial<IIssue>[]): Partial<IIssue>[] {
return issues.map((issue: Readonly<Partial<IIssue>>): Partial<IIssue> => {
return {
...issue,
updated_at: '2020-01-01T17:00:00Z',
created_at: '2020-01-01T17:00:00Z'
};
});
}

toStaleIssues(issues: Partial<IIssue>[]): IssuesProcessorBuilder {
this.issuesOrPrs(this._staleDates(issues));

return this;
}

toStalePrs(issues: Partial<IIssue>[]): IssuesProcessorBuilder {
this.prs(this._staleDates(issues));

return this;
}

build(): IssuesProcessorMock {
return new IssuesProcessorMock(
this._options,
alwaysFalseStateMock,
async p => (p === 1 ? this._issues : []),
async () => [],
async () => new Date().toDateString(),
undefined,
undefined,
this._hasOpenLinkedPullRequest
);
}
}
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ inputs:
description: 'Only issues with a matching type are processed as stale/closed. Defaults to `[]` (disabled) and can be a comma-separated list of issue types.'
default: ''
required: false
exempt-issues-with-open-linked-pr:
description: 'Exempt issues that an open pull request will close when merged. Only linked pull requests count, not simple mentions. Costs one extra operation per issue that reaches this check, so it is disabled by default.'
default: 'false'
required: false
outputs:
closed-issues-prs:
description: 'List of all closed issues and pull requests.'
Expand Down
74 changes: 73 additions & 1 deletion dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49476,6 +49476,7 @@ var Option;
Option["ExemptDraftPr"] = "exempt-draft-pr";
Option["CloseIssueReason"] = "close-issue-reason";
Option["OnlyIssueTypes"] = "only-issue-types";
Option["ExemptIssuesWithOpenLinkedPr"] = "exempt-issues-with-open-linked-pr";
})(Option || (Option = {}));

;// CONCATENATED MODULE: ./lib/functions/dates/get-humanized-date.js
Expand Down Expand Up @@ -50841,6 +50842,37 @@ class ExemptDraftPullRequest {
}
}

;// CONCATENATED MODULE: ./lib/classes/exempt-linked-pull-request.js



class ExemptLinkedPullRequest {
_options;
_issue;
_issueLogger;
constructor(options, issue) {
this._options = options;
this._issue = issue;
this._issueLogger = new IssueLogger(issue);
}
async shouldExemptLinkedPullRequest(hasOpenLinkedPullRequestCallback) {
// Pull requests are not linked to other pull requests, so there is nothing to check
if (this._issue.isPullRequest) {
return false;
}
if (!this._options.exemptIssuesWithOpenLinkedPr) {
return false;
}
this._issueLogger.info(`The option ${this._issueLogger.createOptionLink(Option.ExemptIssuesWithOpenLinkedPr)} is enabled`);
if (await hasOpenLinkedPullRequestCallback()) {
this._issueLogger.info(LoggerService.white('└──'), `Skip the $$type checks because an open pull request will close it when merged`);
return true;
}
this._issueLogger.info(LoggerService.white('└──'), `Continuing the process for this $$type because no open pull request will close it`);
return false;
}
}

;// CONCATENATED MODULE: ./lib/functions/is-pull-request.js
function isPullRequest(issue) {
return !!issue.pull_request;
Expand Down Expand Up @@ -51569,6 +51601,7 @@ function getSortField(sortOption) {






/***
Expand Down Expand Up @@ -51806,6 +51839,16 @@ class IssuesProcessor {
IssuesProcessor._endIssueProcessing(issue);
return; // Don't process draft PR
}
// Ignore issues which an open pull request will close when merged
// Just like the draft PR check above, this one costs one read operation,
// so it only runs once every cheaper check has been passed
const exemptLinkedPullRequest = new ExemptLinkedPullRequest(this.options, issue);
if (await exemptLinkedPullRequest.shouldExemptLinkedPullRequest(async () => {
return this.hasOpenLinkedPullRequest(issue);
})) {
IssuesProcessor._endIssueProcessing(issue);
return; // Don't process issues with an open linked PR
}
// Determine if this issue needs to be marked stale first
if (!issue.isStale) {
issueLogger.info(`This $$type is not stale`);
Expand Down Expand Up @@ -51958,6 +52001,34 @@ class IssuesProcessor {
issueLogger.error(`Error when getting this $$type: ${error.message}`);
}
}
// Returns true when at least one open pull request is linked to the issue in a
// way that will close it once merged (the "Development" link, not a mere mention)
async hasOpenLinkedPullRequest(issue) {
const issueLogger = new IssueLogger(issue);
try {
this._consumeIssueOperation(issue);
const response = await this.client.graphql(`query ($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
closedByPullRequestsReferences(first: 1, includeClosedPrs: false) {
totalCount
}
}
}
}`, {
owner: github_context.repo.owner,
repo: github_context.repo.repo,
number: issue.number
});
return ((response.repository?.issue?.closedByPullRequestsReferences
.totalCount ?? 0) > 0);
}
catch (error) {
issueLogger.error(`Error when getting the linked pull requests of this $$type: ${error.message}`);
// Keep processing the $$type as usual rather than silently exempting it
return false;
}
}
async getRateLimit() {
const logger = new Logger();
try {
Expand Down Expand Up @@ -106636,7 +106707,8 @@ function _getAndValidateArgs() {
exemptDraftPr: getInput('exempt-draft-pr') === 'true',
closeIssueReason: getInput('close-issue-reason'),
includeOnlyAssigned: getInput('include-only-assigned') === 'true',
onlyIssueTypes: getInput('only-issue-types')
onlyIssueTypes: getInput('only-issue-types'),
exemptIssuesWithOpenLinkedPr: getInput('exempt-issues-with-open-linked-pr') === 'true'
};
for (const numberInput of ['days-before-stale']) {
if (isNaN(parseFloat(getInput(numberInput)))) {
Expand Down
Loading