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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
191 changes: 191 additions & 0 deletions __tests__/get-issues-api-selection.spec.ts
Original file line number Diff line number Diff line change
@@ -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'})
);
});
});
20 changes: 18 additions & 2 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
27 changes: 27 additions & 0 deletions src/classes/issues-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,35 @@ export class IssuesProcessor {

// grab issues from github in batches of 100
async getIssues(page: number): Promise<Issue[]> {
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<OctokitIssue>
)
);
}

const issueResult = await this.client.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
Expand Down