From c04cbed0c25958eb060d85634c3ccd8eb4ced817 Mon Sep 17 00:00:00 2001 From: George Joseph Date: Thu, 14 Mar 2024 13:16:24 -0600 Subject: [PATCH] Retrieve Issues and PRs using a search filter Currently all open issues for context.repo.owner and context.repo.repo are retrieved using a simple call to client.rest.issues.listForRepo(); If we wanted to add other critera to determine staleness, like only considering PRs with a review state of "changes_requested", we'd have to make additional rest calls to get the reviews for each PR. This is fine but it only solves the issue for review state. Instead, this PR introduces a new action parameter named `only-matching-filter` which takes one or more standard GitHub Issue and Pull Request search strings. So instead of retrieving all open issues and PRs, you can limit the set to operate on by any criteria that GitHub supports. In the process, it opens up the ability to expand the set to include an entire organization or owner instead of just one repo. Example: Retrieve all open PRs for organization "myorg" that are in review state "changes_requested": `only-matching-filter: 'org:myorg is:pr is:open review:changes_requested'` Once that set is retrieved, all the other label, milestone, assignee, date, etc. filters are applied as usual. Although GitHub only allows boolean search critera in a Code search, you an get around that somewhat by specifying multiple search strings separated by ` || `. Example: Retrieve all open PRs for organization "myorg" that are in review state "changes_requested" or that have the label `submitter-action-required` assigned: (split onto two lines for clarity) ``` only-matching-filter: 'org:myorg is:pr is:open review:changes_requested || org:myorg is:pr is:open label:submitter-action-required' ``` Again, once that set is retrieved and duplicates filtered out, all the other label, milestone, assignee, date, etc. filters are applied as usual. If there aren't any `owner`, `org`, `user` or `repo` search terms in the filters, the search is automatically scoped to the context owner and repo. This prevents accidental global searches. `is:open` is also added if not already present. Resolves: #1143 --- README.md | 19 +++++++ .../constants/default-processor-options.ts | 1 + action.yml | 4 ++ dist/index.js | 51 ++++++++++++++++++- src/classes/issue.spec.ts | 1 + src/classes/issues-processor.ts | 49 +++++++++++++++++- src/enums/option.ts | 1 + src/interfaces/issues-processor-options.ts | 1 + src/main.ts | 1 + 9 files changed, 126 insertions(+), 2 deletions(-) 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'),