diff --git a/README.md b/README.md index eb65b46b9..f82dac969 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Every argument is optional. | [close-issue-reason](#close-issue-reason) | Reason to use when closing issues | `not_planned` | | [stale-pr-label](#stale-pr-label) | Label to apply on staled PRs | `Stale` | | [close-pr-label](#close-pr-label) | Label to apply on closed PRs | | +| [only-matching-filter](#only-matching-filter) | Only issues/PRs matching the search filter(s) will be retrieved and tested | | | [exempt-issue-labels](#exempt-issue-labels) | Labels on issues exempted from stale | | | [exempt-pr-labels](#exempt-pr-labels) | Labels on PRs exempted from stale | | | [only-labels](#only-labels) | Only issues/PRs with ALL these labels are checked | | @@ -258,6 +259,24 @@ It will be automatically removed if the pull requests are no longer closed nor l Default value: unset Required Permission: `pull-requests: write` +#### only-matching-filter + +One or more standard [GitHub Issues and Pull Requests search filters](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests) +which will be used to retrieve the set of issues/PRs to test and take action on. Normally, all open issues/PRs in the context's owner/repo are retrieved. + +GitHub only allows boolean logic and grouping in a Code Search not in Issues and Pull Requests search so there's no way to do an "OR" operation but you can get around this to +a limited degree by specifying multiple search requests separated by ` || `. Each request is run separately and the results are accumulated and duplicates +removed before any further processing is done. + +Each request is checked to ensure it contains an `owner:`, `org:`, `user:` or `repo:` search term. If it doesn't, the search will automatically be scoped to +the owner and repository in the context. This prevents accidental global searches. If the request doesn't already contain an `is:open` search term, it will automatically be added as well. + +Example: To retrieve all of the open PRs in your organization that have a review state of `changes_requested` or a label named `submitter-action-required`, you'd use: +`only-matching-filter: 'org:myorg is:pr is:open review:changes_requested || org:myorg is:pr is:open label:submitter-action-required'`. +From this set, all of the other label, milestone, date, assignee, etc. filters will be applied before taking any action. + +Default value: unset + #### exempt-issue-labels Comma separated list of labels that can be assigned to issues to exclude them from being marked as stale diff --git a/__tests__/constants/default-processor-options.ts b/__tests__/constants/default-processor-options.ts index 0265b6446..72056694f 100644 --- a/__tests__/constants/default-processor-options.ts +++ b/__tests__/constants/default-processor-options.ts @@ -19,6 +19,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({ exemptIssueLabels: '', stalePrLabel: 'Stale', closePrLabel: '', + onlyMatchingFilter: '', exemptPrLabels: '', onlyLabels: '', onlyIssueLabels: '', diff --git a/action.yml b/action.yml index d55f8547c..f943c41e9 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,10 @@ inputs: close-issue-label: description: 'The label to apply when an issue is closed.' required: false + only-matching-filter: + description: 'Only issues/PRs matching the search filter(s) will be retrieved and tested' + default: '' + required: false exempt-issue-labels: description: 'The labels that mean an issue is exempt from being marked stale. Separate multiple labels with commas (eg. "label1,label2").' default: '' diff --git a/dist/index.js b/dist/index.js index f2786a0f6..38ba26bec 100644 --- a/dist/index.js +++ b/dist/index.js @@ -426,7 +426,7 @@ class IssuesProcessor { var _a, _b; return __awaiter(this, void 0, void 0, function* () { // get the next batch of issues - const issues = yield this.getIssues(page); + const issues = yield this.getIssuesWrapper(page); if (issues.length <= 0) { this._logger.info(logger_service_1.LoggerService.green(`No more issues found to process. Exiting...`)); (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.setOperationsCount(this.operations.getConsumedOperationsCount()).logStats(); @@ -694,6 +694,53 @@ class IssuesProcessor { } }); } + // grab issues and/or prs from github in batches of 100 using search filter + getIssuesByFilter(page, search) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + try { + this.operations.consumeOperation(); + const issueResult = yield this.client.rest.search.issuesAndPullRequests({ + q: search, + per_page: 100, + direction: this.options.ascending ? 'asc' : 'desc', + page + }); + (_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsCount(issueResult.data.total_count); + return issueResult.data.items.map((issue) => new issue_1.Issue(this.options, issue)); + } + catch (error) { + throw Error(`Getting issues was blocked by the error: ${error.message}`); + } + }); + } + _removeDupIssues(issues) { + return issues.reduce(function (a, b) { + if (!a.find(o => o.number == b.number)) + a.push(b); + return a; + }, []); + } + getIssuesWrapper(page) { + return __awaiter(this, void 0, void 0, function* () { + if (!this.options.onlyMatchingFilter) { + return this.getIssues(page); + } + const filter = this.options.onlyMatchingFilter; + const results = []; + for (let term of filter.split('||')) { + if (term.search(/repo:|owner:|org:|user:/) < 0) { + term = `repo:${github_1.context.repo.owner}/${github_1.context.repo.repo} ${this.options.onlyMatchingFilter}`; + } + if (term.search(/is:open/) < 0) { + term += ' is:open'; + } + const r = yield this.getIssuesByFilter(page, term); + results.push(...r); + } + return this._removeDupIssues(results); + }); + } // returns the creation date of a given label on an issue (or nothing if no label existed) ///see https://developer.github.com/v3/activity/events/ getLabelCreationDate(issue, label) { @@ -2185,6 +2232,7 @@ var Option; Option["DaysBeforePrClose"] = "days-before-pr-close"; Option["StaleIssueLabel"] = "stale-issue-label"; Option["CloseIssueLabel"] = "close-issue-label"; + Option["OnlyMatchingFilter"] = "only-matching-filter"; Option["ExemptIssueLabels"] = "exempt-issue-labels"; Option["StalePrLabel"] = "stale-pr-label"; Option["ClosePrLabel"] = "close-pr-label"; @@ -2526,6 +2574,7 @@ function _getAndValidateArgs() { daysBeforePrClose: parseInt(core.getInput('days-before-pr-close')), staleIssueLabel: core.getInput('stale-issue-label', { required: true }), closeIssueLabel: core.getInput('close-issue-label'), + onlyMatchingFilter: core.getInput('only-matching-filter'), exemptIssueLabels: core.getInput('exempt-issue-labels'), stalePrLabel: core.getInput('stale-pr-label', { required: true }), closePrLabel: core.getInput('close-pr-label'), diff --git a/src/classes/issue.spec.ts b/src/classes/issue.spec.ts index a2c82e268..ba2f967b9 100644 --- a/src/classes/issue.spec.ts +++ b/src/classes/issue.spec.ts @@ -29,6 +29,7 @@ describe('Issue', (): void => { exemptPrLabels: '', onlyLabels: '', onlyIssueLabels: '', + onlyMatchingFilter: '', onlyPrLabels: '', anyOfLabels: '', anyOfIssueLabels: '', diff --git a/src/classes/issues-processor.ts b/src/classes/issues-processor.ts index 486c6a78a..e9820898e 100644 --- a/src/classes/issues-processor.ts +++ b/src/classes/issues-processor.ts @@ -106,7 +106,7 @@ export class IssuesProcessor { async processIssues(page: Readonly = 1): Promise { // get the next batch of issues - const issues: Issue[] = await this.getIssues(page); + const issues: Issue[] = await this.getIssuesWrapper(page); if (issues.length <= 0) { this._logger.info( @@ -584,6 +584,53 @@ export class IssuesProcessor { } } + // grab issues and/or prs from github in batches of 100 using search filter + async getIssuesByFilter(page: number, search: string): Promise { + try { + this.operations.consumeOperation(); + const issueResult = await this.client.rest.search.issuesAndPullRequests({ + q: search, + per_page: 100, + direction: this.options.ascending ? 'asc' : 'desc', + page + }); + this.statistics?.incrementFetchedItemsCount(issueResult.data.total_count); + + return issueResult.data.items.map( + (issue): Issue => + new Issue(this.options, issue as Readonly) + ); + } catch (error) { + throw Error(`Getting issues was blocked by the error: ${error.message}`); + } + } + + private _removeDupIssues(issues: Issue[]): Issue[] { + return issues.reduce(function (a: Issue[], b: Issue) { + if (!a.find(o => o.number == b.number)) a.push(b); + return a; + }, []); + } + + async getIssuesWrapper(page: number): Promise { + if (!this.options.onlyMatchingFilter) { + return this.getIssues(page); + } + const filter = this.options.onlyMatchingFilter; + const results: Issue[] = []; + for (let term of filter.split('||')) { + if (term.search(/repo:|owner:|org:|user:/) < 0) { + term = `repo:${context.repo.owner}/${context.repo.repo} ${this.options.onlyMatchingFilter}`; + } + if (term.search(/is:open/) < 0) { + term += ' is:open'; + } + const r: Issue[] = await this.getIssuesByFilter(page, term); + results.push(...r); + } + return this._removeDupIssues(results); + } + // returns the creation date of a given label on an issue (or nothing if no label existed) ///see https://developer.github.com/v3/activity/events/ async getLabelCreationDate( diff --git a/src/enums/option.ts b/src/enums/option.ts index 7a9bff026..b77fd5fdb 100644 --- a/src/enums/option.ts +++ b/src/enums/option.ts @@ -12,6 +12,7 @@ export enum Option { DaysBeforePrClose = 'days-before-pr-close', StaleIssueLabel = 'stale-issue-label', CloseIssueLabel = 'close-issue-label', + OnlyMatchingFilter = 'only-matching-filter', ExemptIssueLabels = 'exempt-issue-labels', StalePrLabel = 'stale-pr-label', ClosePrLabel = 'close-pr-label', diff --git a/src/interfaces/issues-processor-options.ts b/src/interfaces/issues-processor-options.ts index 930992284..a11771681 100644 --- a/src/interfaces/issues-processor-options.ts +++ b/src/interfaces/issues-processor-options.ts @@ -14,6 +14,7 @@ export interface IIssuesProcessorOptions { daysBeforePrClose: number; // Could be NaN staleIssueLabel: string; closeIssueLabel: string; + onlyMatchingFilter: string; exemptIssueLabels: string; stalePrLabel: string; closePrLabel: string; diff --git a/src/main.ts b/src/main.ts index a7836c160..85435732d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -73,6 +73,7 @@ function _getAndValidateArgs(): IIssuesProcessorOptions { daysBeforePrClose: parseInt(core.getInput('days-before-pr-close')), staleIssueLabel: core.getInput('stale-issue-label', {required: true}), closeIssueLabel: core.getInput('close-issue-label'), + onlyMatchingFilter: core.getInput('only-matching-filter'), exemptIssueLabels: core.getInput('exempt-issue-labels'), stalePrLabel: core.getInput('stale-pr-label', {required: true}), closePrLabel: core.getInput('close-pr-label'),