Skip to content

Commit

Permalink
Retrieve Issues and PRs using a search filter
Browse files Browse the repository at this point in the history
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: actions#1143
  • Loading branch information
gtjoseph committed Mar 14, 2024
1 parent 3f3b017 commit c04cbed
Show file tree
Hide file tree
Showing 9 changed files with 126 additions and 2 deletions.
19 changes: 19 additions & 0 deletions README.md
Expand Up @@ -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 | |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions __tests__/constants/default-processor-options.ts
Expand Up @@ -19,6 +19,7 @@ export const DefaultProcessorOptions: IIssuesProcessorOptions = Object.freeze({
exemptIssueLabels: '',
stalePrLabel: 'Stale',
closePrLabel: '',
onlyMatchingFilter: '',
exemptPrLabels: '',
onlyLabels: '',
onlyIssueLabels: '',
Expand Down
4 changes: 4 additions & 0 deletions action.yml
Expand Up @@ -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: ''
Expand Down
51 changes: 50 additions & 1 deletion dist/index.js
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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'),
Expand Down
1 change: 1 addition & 0 deletions src/classes/issue.spec.ts
Expand Up @@ -29,6 +29,7 @@ describe('Issue', (): void => {
exemptPrLabels: '',
onlyLabels: '',
onlyIssueLabels: '',
onlyMatchingFilter: '',
onlyPrLabels: '',
anyOfLabels: '',
anyOfIssueLabels: '',
Expand Down
49 changes: 48 additions & 1 deletion src/classes/issues-processor.ts
Expand Up @@ -106,7 +106,7 @@ export class IssuesProcessor {

async processIssues(page: Readonly<number> = 1): Promise<number> {
// 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(
Expand Down Expand Up @@ -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<Issue[]> {
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<OctokitIssue>)
);
} 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<Issue[]> {
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(
Expand Down
1 change: 1 addition & 0 deletions src/enums/option.ts
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/issues-processor-options.ts
Expand Up @@ -14,6 +14,7 @@ export interface IIssuesProcessorOptions {
daysBeforePrClose: number; // Could be NaN
staleIssueLabel: string;
closeIssueLabel: string;
onlyMatchingFilter: string;
exemptIssueLabels: string;
stalePrLabel: string;
closePrLabel: string;
Expand Down
1 change: 1 addition & 0 deletions src/main.ts
Expand Up @@ -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'),
Expand Down

0 comments on commit c04cbed

Please sign in to comment.