diff --git a/README.md b/README.md index e509b285d..31b0d4823 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/__tests__/classes/issues-processor-mock.ts b/__tests__/classes/issues-processor-mock.ts index c02fbf9de..bfc86208d 100644 --- a/__tests__/classes/issues-processor-mock.ts +++ b/__tests__/classes/issues-processor-mock.ts @@ -27,7 +27,8 @@ export class IssuesProcessorMock extends IssuesProcessor { staleLabel: string, events: IIssueEvent[] ) => Promise, - getPullRequest?: (issue: Issue) => Promise + getPullRequest?: (issue: Issue) => Promise, + hasOpenLinkedPullRequest?: (issue: Issue) => Promise ) { super(options, state); @@ -60,5 +61,9 @@ export class IssuesProcessorMock extends IssuesProcessor { if (getPullRequest) { this.getPullRequest = getPullRequest; } + + if (hasOpenLinkedPullRequest) { + this.hasOpenLinkedPullRequest = hasOpenLinkedPullRequest; + } } } diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 48fb51b11..1cf0ee7b3 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -56,5 +56,6 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ ignorePrUpdates: undefined, exemptDraftPr: false, closeIssueReason: 'not_planned', - includeOnlyAssigned: false + includeOnlyAssigned: false, + exemptIssuesWithOpenLinkedPr: false }); diff --git a/__tests__/exempt-issues-with-open-linked-pr.spec.ts b/__tests__/exempt-issues-with-open-linked-pr.spec.ts new file mode 100644 index 000000000..a9c7a2c56 --- /dev/null +++ b/__tests__/exempt-issues-with-open-linked-pr.spec.ts @@ -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 => { + 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 => { + expect.assertions(1); + let calls = 0; + issuesProcessor = issuesProcessorBuilder + .toStaleIssues([{number: 11}]) + .withLinkedPullRequestCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + expect.assertions(1); + issuesProcessor = issuesProcessorBuilder + .toStaleIssues([{number: 23}]) + .withLinkedPullRequestCallback(async (): Promise => { + // 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 = + async (): Promise => 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 => hasOne; + + return this; + } + + withLinkedPullRequestCallback( + callback: (issue: Issue) => Promise + ): IssuesProcessorBuilder { + this._hasOpenLinkedPullRequest = callback; + + return this; + } + + issuesOrPrs(issues: Partial[]): IssuesProcessorBuilder { + this._issues = issues.map( + (issue: Readonly>, index: Readonly): 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[]): IssuesProcessorBuilder { + this.issuesOrPrs( + issues.map((issue: Readonly>): Partial => { + return { + ...issue, + pull_request: {key: 'value'} + }; + }) + ); + + return this; + } + + private _staleDates(issues: Partial[]): Partial[] { + return issues.map((issue: Readonly>): Partial => { + return { + ...issue, + updated_at: '2020-01-01T17:00:00Z', + created_at: '2020-01-01T17:00:00Z' + }; + }); + } + + toStaleIssues(issues: Partial[]): IssuesProcessorBuilder { + this.issuesOrPrs(this._staleDates(issues)); + + return this; + } + + toStalePrs(issues: Partial[]): 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 + ); + } +} diff --git a/action.yml b/action.yml index b3354e9d5..83866e0fd 100644 --- a/action.yml +++ b/action.yml @@ -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.' diff --git a/dist/index.js b/dist/index.js index 3171f8fb5..345cdaca1 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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 @@ -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; @@ -51569,6 +51601,7 @@ function getSortField(sortOption) { + /*** @@ -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`); @@ -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 { @@ -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)))) { diff --git a/src/classes/exempt-linked-pull-request.ts b/src/classes/exempt-linked-pull-request.ts new file mode 100644 index 000000000..6635926a2 --- /dev/null +++ b/src/classes/exempt-linked-pull-request.ts @@ -0,0 +1,52 @@ +import {Option} from '../enums/option.js'; +import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options.js'; +import {LoggerService} from '../services/logger.service.js'; +import {Issue} from './issue.js'; +import {IssueLogger} from './loggers/issue-logger.js'; + +export class ExemptLinkedPullRequest { + private readonly _options: IIssuesProcessorOptions; + private readonly _issue: Issue; + private readonly _issueLogger: IssueLogger; + + constructor(options: Readonly, issue: Issue) { + this._options = options; + this._issue = issue; + this._issueLogger = new IssueLogger(issue); + } + + async shouldExemptLinkedPullRequest( + hasOpenLinkedPullRequestCallback: () => Promise + ): Promise { + // 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; + } +} diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 62189bf16..85abf66da 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -16,6 +16,7 @@ import {IPullRequest} from '../interfaces/pull-request.js'; import {Assignees} from './assignees.js'; import {IgnoreUpdates} from './ignore-updates.js'; import {ExemptDraftPullRequest} from './exempt-draft-pull-request.js'; +import {ExemptLinkedPullRequest} from './exempt-linked-pull-request.js'; import {Issue} from './issue.js'; import {IssueLogger} from './loggers/issue-logger.js'; import {Logger} from './loggers/logger.js'; @@ -462,6 +463,23 @@ export class IssuesProcessor { 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: ExemptLinkedPullRequest = + new ExemptLinkedPullRequest(this.options, issue); + + if ( + await exemptLinkedPullRequest.shouldExemptLinkedPullRequest( + async (): Promise => { + 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`); @@ -701,6 +719,49 @@ export class IssuesProcessor { } } + // 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: Issue): Promise { + const issueLogger: IssueLogger = new IssueLogger(issue); + + try { + this._consumeIssueOperation(issue); + + const response = await this.client.graphql<{ + repository: { + issue: {closedByPullRequestsReferences: {totalCount: number}} | null; + }; + }>( + `query ($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + closedByPullRequestsReferences(first: 1, includeClosedPrs: false) { + totalCount + } + } + } + }`, + { + owner: context.repo.owner, + repo: 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(): Promise { const logger: Logger = new Logger(); diff --git a/src/enums/option.ts b/src/enums/option.ts index 3c1bb5158..7b6b06900 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -50,5 +50,6 @@ export enum Option { IgnorePrUpdates = 'ignore-pr-updates', ExemptDraftPr = 'exempt-draft-pr', CloseIssueReason = 'close-issue-reason', - OnlyIssueTypes = 'only-issue-types' + OnlyIssueTypes = 'only-issue-types', + ExemptIssuesWithOpenLinkedPr = 'exempt-issues-with-open-linked-pr' } diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 4ca6511e2..2b8e2c454 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -56,4 +56,5 @@ export interface IIssuesProcessorOptions { closeIssueReason: string; includeOnlyAssigned: boolean; onlyIssueTypes?: string; + exemptIssuesWithOpenLinkedPr: boolean; } diff --git a/src/main.ts b/src/main.ts index 228dbc916..501b1f457 100644 --- a/src/main.ts +++ b/src/main.ts @@ -125,7 +125,9 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', - onlyIssueTypes: core.getInput('only-issue-types') + onlyIssueTypes: core.getInput('only-issue-types'), + exemptIssuesWithOpenLinkedPr: + core.getInput('exempt-issues-with-open-linked-pr') === 'true' }; for (const numberInput of ['days-before-stale']) {