diff --git a/README.md b/README.md index 4710e9478..bfcc7c690 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ permissions: pull-requests: write ``` +**Pull requests only (no issue processing):** +When both [days-before-issue-stale](#days-before-issue-stale) and [days-before-issue-close](#days-before-issue-close) are set to `-1`, the action uses the pull requests API exclusively and does not require any `issues` permission: + +```yaml +permissions: + pull-requests: write +``` + You can find more information about the required permissions under the corresponding options that you wish to use. ## Statefulness @@ -163,6 +171,8 @@ Default value: `60` Useful to override [days-before-stale](#days-before-stale) but only for the idle number of days before marking the issues as stale. +If set to `-1` together with [days-before-issue-close](#days-before-issue-close) set to `-1`, the action will use the pull requests API exclusively to fetch items, avoiding the need for any `issues` permission in the workflow. + Default value: unset #### days-before-pr-stale @@ -190,6 +200,8 @@ Default value: `7` Override [days-before-close](#days-before-close) but only for the idle number of days before closing the stale issues. +If set to `-1` together with [days-before-issue-stale](#days-before-issue-stale) set to `-1`, the action will use the pull requests API exclusively to fetch items, avoiding the need for any `issues` permission in the workflow. + Default value: unset #### days-before-pr-close diff --git a/__tests__/get-issues-api-selection.spec.ts b/__tests__/get-issues-api-selection.spec.ts new file mode 100644 index 000000000..6f14dee29 --- /dev/null +++ b/__tests__/get-issues-api-selection.spec.ts @@ -0,0 +1,191 @@ +import {IssuesProcessor} from '../src/classes/issues-processor'; +import {IIssuesProcessorOptions} from '../src/interfaces/issues-processor-options'; +import {DefaultProcessorOptions} from './constants/default-processor-options'; +import {alwaysFalseStateMock} from './classes/state-mock'; + +jest.mock('@actions/github', () => ({ + context: { + repo: {owner: 'test-owner', repo: 'test-repo'} + }, + getOctokit: jest.fn(() => ({ + rest: { + issues: { + listForRepo: jest.fn().mockResolvedValue({data: []}), + listComments: jest.fn().mockResolvedValue({data: []}), + listEvents: { + endpoint: {merge: jest.fn().mockReturnValue({})} + }, + addLabels: jest.fn().mockResolvedValue({}), + removeLabel: jest.fn().mockResolvedValue({}), + createComment: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}) + }, + pulls: { + list: jest.fn().mockResolvedValue({data: []}), + get: jest.fn().mockResolvedValue({data: {}}) + } + }, + paginate: jest.fn().mockResolvedValue([]) + })) +})); + +function buildProcessor(opts: IIssuesProcessorOptions): { + processor: IssuesProcessor; + mockListForRepo: jest.Mock; + mockPullsList: jest.Mock; +} { + const processor = new IssuesProcessor(opts, alwaysFalseStateMock); + + const mockListForRepo = jest.fn().mockResolvedValue({data: []}); + const mockPullsList = jest.fn().mockResolvedValue({data: []}); + + (processor as any).client = { + rest: { + issues: {listForRepo: mockListForRepo}, + pulls: {list: mockPullsList} + } + }; + + return {processor, mockListForRepo, mockPullsList}; +} + +describe('getIssues API selection based on issue days configuration', () => { + test('uses issues.listForRepo when issue processing is enabled', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: 14, + daysBeforeIssueClose: 7 + }; + const {processor, mockListForRepo, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockListForRepo).toHaveBeenCalledTimes(1); + expect(mockPullsList).not.toHaveBeenCalled(); + }); + + test('uses pulls.list when days-before-issue-stale and days-before-issue-close are both -1', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: -1, + daysBeforeIssueClose: -1 + }; + const {processor, mockListForRepo, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockPullsList).toHaveBeenCalledTimes(1); + expect(mockListForRepo).not.toHaveBeenCalled(); + }); + + test('uses issues.listForRepo when only days-before-issue-stale is -1 but close is not', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: -1, + daysBeforeIssueClose: 7 + }; + const {processor, mockListForRepo, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockListForRepo).toHaveBeenCalledTimes(1); + expect(mockPullsList).not.toHaveBeenCalled(); + }); + + test('uses issues.listForRepo when only days-before-issue-close is -1 but stale is not', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: 14, + daysBeforeIssueClose: -1 + }; + const {processor, mockListForRepo, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockListForRepo).toHaveBeenCalledTimes(1); + expect(mockPullsList).not.toHaveBeenCalled(); + }); + + test('uses issues.listForRepo when days are NaN (falls back to daysBeforeStale)', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeStale: 14, + daysBeforeClose: 7, + daysBeforeIssueStale: NaN, + daysBeforeIssueClose: NaN + }; + const {processor, mockListForRepo, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockListForRepo).toHaveBeenCalledTimes(1); + expect(mockPullsList).not.toHaveBeenCalled(); + }); + + test('items returned via pulls.list are treated as pull requests', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: -1, + daysBeforeIssueClose: -1 + }; + const processor = new IssuesProcessor(opts, alwaysFalseStateMock); + + const fakePr = { + number: 42, + title: 'A pull request', + state: 'open', + locked: false, + draft: false, + labels: [], + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + milestone: null, + assignees: [] + }; + + (processor as any).client = { + rest: { + issues: {listForRepo: jest.fn()}, + pulls: {list: jest.fn().mockResolvedValue({data: [fakePr]})} + } + }; + + const issues = await processor.getIssues(1); + + expect(issues).toHaveLength(1); + expect(issues[0].isPullRequest).toBe(true); + expect(issues[0].number).toBe(42); + }); + + test('uses created sort for pulls.list when sortBy is comments (unsupported by pulls API)', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: -1, + daysBeforeIssueClose: -1, + sortBy: 'comments' + }; + const {processor, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockPullsList).toHaveBeenCalledWith( + expect.objectContaining({sort: 'created'}) + ); + }); + + test('passes updated sort through to pulls.list when sortBy is updated', async () => { + const opts: IIssuesProcessorOptions = { + ...DefaultProcessorOptions, + daysBeforeIssueStale: -1, + daysBeforeIssueClose: -1, + sortBy: 'updated' + }; + const {processor, mockPullsList} = buildProcessor(opts); + + await processor.getIssues(1); + + expect(mockPullsList).toHaveBeenCalledWith( + expect.objectContaining({sort: 'updated'}) + ); + }); +}); diff --git a/dist/index.js b/dist/index.js index 40ec38401..b5762095c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -694,10 +694,26 @@ class IssuesProcessor { } // grab issues from github in batches of 100 getIssues(page) { - var _a; + var _a, _b; return __awaiter(this, void 0, void 0, function* () { + const issuesDisabled = this._getDaysBeforeIssueStale() < 0 && + this._getDaysBeforeIssueClose() < 0; try { this.operations.consumeOperation(); + if (issuesDisabled) { + const sortField = (0, get_sort_field_1.getSortField)(this.options.sortBy); + const pullResult = yield this.client.rest.pulls.list({ + owner: github_1.context.repo.owner, + repo: github_1.context.repo.repo, + state: 'open', + per_page: 100, + direction: this.options.ascending ? 'asc' : 'desc', + sort: sortField === 'comments' ? 'created' : sortField, + page + }); + (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsCount(pullResult.data.length); + return pullResult.data.map((pr) => new issue_1.Issue(this.options, Object.assign(Object.assign({}, pr), { pull_request: {} }))); + } const issueResult = yield this.client.rest.issues.listForRepo({ owner: github_1.context.repo.owner, repo: github_1.context.repo.repo, @@ -707,7 +723,7 @@ class IssuesProcessor { sort: (0, get_sort_field_1.getSortField)(this.options.sortBy), page }); - (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsCount(issueResult.data.length); + (_b = this.statistics) === null || _b === void 0 ? void 0 : _b.incrementFetchedItemsCount(issueResult.data.length); return issueResult.data.map((issue) => new issue_1.Issue(this.options, issue)); } catch (error) { diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index c4e444fdc..89824d806 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -581,8 +581,35 @@ export class IssuesProcessor { // grab issues from github in batches of 100 async getIssues(page: number): Promise { + const issuesDisabled = + this._getDaysBeforeIssueStale() < 0 && + this._getDaysBeforeIssueClose() < 0; + try { this.operations.consumeOperation(); + + if (issuesDisabled) { + const sortField = getSortField(this.options.sortBy); + const pullResult = await this.client.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + direction: this.options.ascending ? 'asc' : 'desc', + sort: sortField === 'comments' ? 'created' : sortField, + page + }); + this.statistics?.incrementFetchedItemsCount(pullResult.data.length); + + return pullResult.data.map( + (pr): Issue => + new Issue( + this.options, + {...pr, pull_request: {}} as unknown as Readonly + ) + ); + } + const issueResult = await this.client.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo,