diff --git a/README.md b/README.md index 4710e9478..f483543f1 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Every argument is optional. | [exempt-all-issue-assignees](#exempt-all-issue-assignees) | Override [exempt-all-assignees](#exempt-all-assignees) for issues only | | | [exempt-all-pr-assignees](#exempt-all-pr-assignees) | Override [exempt-all-assignees](#exempt-all-assignees) for PRs only | | | [exempt-draft-pr](#exempt-draft-pr) | Skip the stale action for draft PRs | `false` | +| [only-draft-pr](#only-draft-pr) | Only process draft PRs (skip non-draft PRs) | `false` | | [enable-statistics](#enable-statistics) | Display statistics in the logs | `true` | | [ignore-updates](#ignore-updates) | Any update (update/comment) can reset the stale idle time on the issues/PRs | `false` | | [ignore-issue-updates](#ignore-issue-updates) | Override [ignore-updates](#ignore-updates) for issues only | | @@ -523,6 +524,13 @@ If set to `true`, the pull requests currently in draft will not be marked as sta Default value: `false` Required Permission: `pull-requests: read` +#### only-draft-pr + +If set to `true`, only draft pull requests will be processed. Non-draft pull requests will be skipped. +This is the inverse of [exempt-draft-pr](#exempt-draft-pr). + +Default value: `false` + #### enable-statistics Collects and display statistics at the end of the stale workflow logs to get a summary of what happened during the run. diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 4ea04cca8..6ede05338 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -55,6 +55,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ ignoreIssueUpdates: undefined, ignorePrUpdates: undefined, exemptDraftPr: false, + onlyDraftPr: false, closeIssueReason: 'not_planned', includeOnlyAssigned: false }); diff --git a/__tests__/only-draft-pr.spec.ts b/__tests__/only-draft-pr.spec.ts new file mode 100644 index 000000000..f406a71b6 --- /dev/null +++ b/__tests__/only-draft-pr.spec.ts @@ -0,0 +1,177 @@ +import {Issue} from '../src/classes/issue'; +import {IIssue} from '../src/interfaces/issue'; +import {IIssuesProcessorOptions} from '../src/interfaces/issues-processor-options'; +import {IPullRequest} from '../src/interfaces/pull-request'; +import {IssuesProcessorMock} from './classes/issues-processor-mock'; +import {DefaultProcessorOptions} from './constants/default-processor-options'; +import {generateIssue} from './functions/generate-issue'; +import {alwaysFalseStateMock} from './classes/state-mock'; + +let issuesProcessorBuilder: IssuesProcessorBuilder; +let issuesProcessor: IssuesProcessorMock; + +describe('only-draft-pr option', (): void => { + beforeEach((): void => { + issuesProcessorBuilder = new IssuesProcessorBuilder(); + }); + + describe('when the option "only-draft-pr" is disabled', (): void => { + beforeEach((): void => { + issuesProcessorBuilder.processAllPrs(); + }); + + test('should stale the non-draft pull request', async (): Promise => { + expect.assertions(1); + issuesProcessor = issuesProcessorBuilder + .toStalePrs([ + { + draft: false, + number: 10 + } + ]) + .build(); + + await issuesProcessor.processIssues(); + + expect(issuesProcessor.staleIssues).toHaveLength(1); + }); + + test('should stale the draft pull request', async (): Promise => { + expect.assertions(1); + issuesProcessor = issuesProcessorBuilder + .toStalePrs([ + { + draft: true, + number: 20 + } + ]) + .build(); + + await issuesProcessor.processIssues(); + + expect(issuesProcessor.staleIssues).toHaveLength(1); + }); + }); + + describe('when the option "only-draft-pr" is enabled', (): void => { + beforeEach((): void => { + issuesProcessorBuilder.onlyDraftPr(); + }); + + test('should not stale the non-draft pull request', async (): Promise => { + expect.assertions(1); + issuesProcessor = issuesProcessorBuilder + .toStalePrs([ + { + draft: false, + number: 30 + } + ]) + .build(); + + await issuesProcessor.processIssues(); + + expect(issuesProcessor.staleIssues).toHaveLength(0); + }); + + test('should stale the draft pull request', async (): Promise => { + expect.assertions(1); + issuesProcessor = issuesProcessorBuilder + .toStalePrs([ + { + draft: true, + number: 40 + } + ]) + .build(); + + await issuesProcessor.processIssues(); + + expect(issuesProcessor.staleIssues).toHaveLength(1); + }); + }); +}); + +class IssuesProcessorBuilder { + private _options: IIssuesProcessorOptions = { + ...DefaultProcessorOptions + }; + private _issues: Issue[] = []; + + processAllPrs(): IssuesProcessorBuilder { + this._options.onlyDraftPr = false; + + return this; + } + + onlyDraftPr(): IssuesProcessorBuilder { + this._options.onlyDraftPr = true; + + 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; + } + + toStalePrs(issues: Partial[]): IssuesProcessorBuilder { + this.prs( + issues.map((issue: Readonly>): Partial => { + return { + ...issue, + updated_at: '2020-01-01T17:00:00Z', + created_at: '2020-01-01T17:00:00Z' + }; + }) + ); + + return this; + } + + build(): IssuesProcessorMock { + return new IssuesProcessorMock( + this._options, + alwaysFalseStateMock, + async p => (p === 1 ? this._issues : []), + async () => [], + async () => new Date().toDateString(), + async (): Promise => { + return Promise.resolve({ + number: 0, + draft: true, + head: { + ref: 'ref', + repo: null + } + }); + } + ); + } +} diff --git a/action.yml b/action.yml index b3354e9d5..0547ee65e 100644 --- a/action.yml +++ b/action.yml @@ -176,6 +176,10 @@ inputs: description: 'Exempt draft pull requests from being marked as stale. Default to false.' default: 'false' required: false + only-draft-pr: + description: 'Only process draft pull requests (skip non-draft PRs). Default to false.' + default: 'false' + required: false enable-statistics: description: 'Display some statistics at the end regarding the stale workflow (only when the logs are enabled).' default: 'true' diff --git a/dist/index.js b/dist/index.js index 3f5b72646..ea884a4b6 100644 --- a/dist/index.js +++ b/dist/index.js @@ -380,6 +380,7 @@ const words_to_list_1 = __nccwpck_require__(1883); const assignees_1 = __nccwpck_require__(7236); const ignore_updates_1 = __nccwpck_require__(2935); const exempt_draft_pull_request_1 = __nccwpck_require__(854); +const only_draft_pull_request_1 = __nccwpck_require__(4525); const issue_1 = __nccwpck_require__(4783); const issue_logger_1 = __nccwpck_require__(2984); const logger_1 = __nccwpck_require__(6212); @@ -621,6 +622,12 @@ class IssuesProcessor { IssuesProcessor._endIssueProcessing(issue); return; // Don't process draft PR } + // Skip non-draft PRs if only-draft-prs option is enabled + const onlyDraftPullRequest = new only_draft_pull_request_1.OnlyDraftPullRequest(this.options, issue); + if (onlyDraftPullRequest.shouldSkipNonDraftPullRequest()) { + IssuesProcessor._endIssueProcessing(issue); + return; // Only process draft PRs + } // Determine if this issue needs to be marked stale first if (!issue.isStale) { issueLogger.info(`This $$type is not stale`); @@ -1500,6 +1507,44 @@ class Milestones { exports.Milestones = Milestones; +/***/ }), + +/***/ 4525: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.OnlyDraftPullRequest = void 0; +const option_1 = __nccwpck_require__(5931); +const logger_service_1 = __nccwpck_require__(1973); +const issue_logger_1 = __nccwpck_require__(2984); +class OnlyDraftPullRequest { + constructor(options, issue) { + this._options = options; + this._issue = issue; + this._issueLogger = new issue_logger_1.IssueLogger(issue); + } + shouldSkipNonDraftPullRequest() { + var _a; + if (this._issue.isPullRequest) { + if (this._options.onlyDraftPr) { + this._issueLogger.info(`The option ${this._issueLogger.createOptionLink(option_1.Option.OnlyDraftPr)} is enabled`); + if (((_a = this._issue) === null || _a === void 0 ? void 0 : _a.draft) !== true) { + this._issueLogger.info(logger_service_1.LoggerService.white('└──'), `Skip this $$type because it is not a draft and only draft PRs should be processed`); + return true; + } + else { + this._issueLogger.info(logger_service_1.LoggerService.white('└──'), `Continuing the process for this $$type because it is a draft`); + } + } + } + return false; + } +} +exports.OnlyDraftPullRequest = OnlyDraftPullRequest; + + /***/ }), /***/ 7957: @@ -2257,6 +2302,7 @@ var Option; Option["IgnoreIssueUpdates"] = "ignore-issue-updates"; Option["IgnorePrUpdates"] = "ignore-pr-updates"; Option["ExemptDraftPr"] = "exempt-draft-pr"; + Option["OnlyDraftPr"] = "only-draft-pr"; Option["CloseIssueReason"] = "close-issue-reason"; Option["OnlyIssueTypes"] = "only-issue-types"; })(Option || (exports.Option = Option = {})); @@ -2623,6 +2669,7 @@ function _getAndValidateArgs() { ignoreIssueUpdates: _toOptionalBoolean('ignore-issue-updates'), ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', + onlyDraftPr: core.getInput('only-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', onlyIssueTypes: core.getInput('only-issue-types') diff --git a/package-lock.json b/package-lock.json index a1369c1f4..ea6f02e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -428,7 +428,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.5.tgz", "integrity": "sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -1489,7 +1488,6 @@ "version": "4.2.4", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-4.2.4.tgz", "integrity": "sha512-rYKilwgzQ7/imScn3M9/pFfUf4I1AZEH3KhyJmtPdE2zfaXAn2mFfUy4FbKewzc2We5y/LlKLj36fWJLKC2SIQ==", - "peer": true, "dependencies": { "@octokit/auth-token": "^3.0.0", "@octokit/graphql": "^5.0.0", @@ -1935,7 +1933,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.2.tgz", "integrity": "sha512-3+9OGAWHhk4O1LlcwLBONbdXsAhLjyCFogJY/cWy2lxdVJ2JrcTF2pTGMaLl2AE7U1l31n8Py4a8bx5DLf/0dQ==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", "@typescript-eslint/scope-manager": "6.13.2", @@ -1971,7 +1968,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.2.tgz", "integrity": "sha512-MUkcC+7Wt/QOGeVlM8aGGJZy1XV5YKjTpq9jK6r6/iLsGXhBVaGP5N0UYvFsu9BFlSpwY9kMretzdBH01rkRXg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.13.2", "@typescript-eslint/types": "6.13.2", @@ -2152,7 +2148,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2467,7 +2462,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -3432,7 +3426,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4882,7 +4875,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -7501,7 +7493,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index 5a5bf32df..2df64fb99 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -64,6 +64,7 @@ describe('Issue', (): void => { ignoreIssueUpdates: undefined, ignorePrUpdates: undefined, exemptDraftPr: false, + onlyDraftPr: false, closeIssueReason: '', includeOnlyAssigned: false }; diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 26419173d..9ec8343d9 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -17,6 +17,7 @@ import {IPullRequest} from '../interfaces/pull-request'; import {Assignees} from './assignees'; import {IgnoreUpdates} from './ignore-updates'; import {ExemptDraftPullRequest} from './exempt-draft-pull-request'; +import {OnlyDraftPullRequest} from './only-draft-pull-request'; import {Issue} from './issue'; import {IssueLogger} from './loggers/issue-logger'; import {Logger} from './loggers/logger'; @@ -463,6 +464,17 @@ export class IssuesProcessor { return; // Don't process draft PR } + // Skip non-draft PRs if only-draft-prs option is enabled + const onlyDraftPullRequest: OnlyDraftPullRequest = new OnlyDraftPullRequest( + this.options, + issue + ); + + if (onlyDraftPullRequest.shouldSkipNonDraftPullRequest()) { + IssuesProcessor._endIssueProcessing(issue); + return; // Only process draft PRs + } + // Determine if this issue needs to be marked stale first if (!issue.isStale) { issueLogger.info(`This $$type is not stale`); diff --git a/src/classes/only-draft-pull-request.ts b/src/classes/only-draft-pull-request.ts new file mode 100644 index 000000000..5c0f7b83e --- /dev/null +++ b/src/classes/only-draft-pull-request.ts @@ -0,0 +1,45 @@ +import {Option} from '../enums/option'; +import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options'; +import {LoggerService} from '../services/logger.service'; +import {Issue} from './issue'; +import {IssueLogger} from './loggers/issue-logger'; + +export class OnlyDraftPullRequest { + 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); + } + + shouldSkipNonDraftPullRequest(): boolean { + if (this._issue.isPullRequest) { + if (this._options.onlyDraftPr) { + this._issueLogger.info( + `The option ${this._issueLogger.createOptionLink( + Option.OnlyDraftPr + )} is enabled` + ); + + if (this._issue?.draft !== true) { + this._issueLogger.info( + LoggerService.white('└──'), + `Skip this $$type because it is not a draft and only draft PRs should be processed` + ); + + return true; + } else { + this._issueLogger.info( + LoggerService.white('└──'), + `Continuing the process for this $$type because it is a draft` + ); + } + } + } + + return false; + } +} diff --git a/src/enums/option.ts b/src/enums/option.ts index 3c1bb5158..ee4bf36f9 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -49,6 +49,7 @@ export enum Option { IgnoreIssueUpdates = 'ignore-issue-updates', IgnorePrUpdates = 'ignore-pr-updates', ExemptDraftPr = 'exempt-draft-pr', + OnlyDraftPr = 'only-draft-pr', CloseIssueReason = 'close-issue-reason', OnlyIssueTypes = 'only-issue-types' } diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 273ae461c..bc9211ce0 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -53,6 +53,7 @@ export interface IIssuesProcessorOptions { ignoreIssueUpdates: boolean | undefined; ignorePrUpdates: boolean | undefined; exemptDraftPr: boolean; + onlyDraftPr: boolean; closeIssueReason: string; includeOnlyAssigned: boolean; onlyIssueTypes?: string; diff --git a/src/main.ts b/src/main.ts index 92a33ab58..74dfe35c9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -123,6 +123,7 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { ignoreIssueUpdates: _toOptionalBoolean('ignore-issue-updates'), ignorePrUpdates: _toOptionalBoolean('ignore-pr-updates'), exemptDraftPr: core.getInput('exempt-draft-pr') === 'true', + onlyDraftPr: core.getInput('only-draft-pr') === 'true', closeIssueReason: core.getInput('close-issue-reason'), includeOnlyAssigned: core.getInput('include-only-assigned') === 'true', onlyIssueTypes: core.getInput('only-issue-types')