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 4f61ec9
Show file tree
Hide file tree
Showing 14 changed files with 246 additions and 50 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
3 changes: 2 additions & 1 deletion __tests__/functions/generate-issue.ts
Expand Up @@ -39,6 +39,7 @@ export function generateIssue(
login: assignee,
type: 'User'
};
})
}),
repository_url: 'https://api.github.com/repos/dummy/dummy'
});
}
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
127 changes: 103 additions & 24 deletions dist/index.js
Expand Up @@ -272,6 +272,7 @@ exports.Issue = void 0;
const is_labeled_1 = __nccwpck_require__(6792);
const is_pull_request_1 = __nccwpck_require__(5400);
const operations_1 = __nccwpck_require__(7957);
const owner_repo_1 = __nccwpck_require__(6226);
class Issue {
constructor(options, issue) {
this.operations = new operations_1.Operations();
Expand All @@ -287,8 +288,10 @@ class Issue {
this.locked = issue.locked;
this.milestone = issue.milestone;
this.assignees = issue.assignees || [];
this.repository_url = issue.repository_url;
this.isStale = (0, is_labeled_1.isLabeled)(this, this.staleLabel);
this.markedStaleThisRun = false;
this.owner_repo = new owner_repo_1.OwnerRepo(issue.repository_url || '');
}
get isPullRequest() {
return (0, is_pull_request_1.isPullRequest)(this);
Expand Down Expand Up @@ -426,7 +429,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 @@ -659,8 +662,8 @@ class IssuesProcessor {
this._consumeIssueOperation(issue);
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsCommentsCount();
const comments = yield this.client.rest.issues.listComments({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
since: sinceDate
});
Expand All @@ -687,13 +690,62 @@ class IssuesProcessor {
page
});
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsCount(issueResult.data.length);
this._logger.info(logger_service_1.LoggerService.green(`Retrieved ${issueResult.data.length} issues/PRs for repo ${github_1.context.repo.owner}/${github_1.context.repo.repo}`));
return issueResult.data.map((issue) => new issue_1.Issue(this.options, issue));
}
catch (error) {
throw Error(`Getting issues was blocked by the error: ${error.message}`);
}
});
}
// 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);
this._logger.info(logger_service_1.LoggerService.green(`Retrieved ${issueResult.data.total_count} issues/PRs for search '${search}'`));
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 All @@ -704,8 +756,8 @@ class IssuesProcessor {
this._consumeIssueOperation(issue);
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedItemsEventsCount();
const options = this.client.rest.issues.listEvents.endpoint.merge({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
per_page: 100,
issue_number: issue.number
});
Expand All @@ -728,8 +780,8 @@ class IssuesProcessor {
this._consumeIssueOperation(issue);
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementFetchedPullRequestsCount();
const pullRequest = yield this.client.rest.pulls.get({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
pull_number: issue.number
});
return pullRequest.data;
Expand Down Expand Up @@ -848,8 +900,8 @@ class IssuesProcessor {
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementAddedItemsComment(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.createComment({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
body: staleMessage
});
Expand All @@ -865,8 +917,8 @@ class IssuesProcessor {
(_c = this.statistics) === null || _c === void 0 ? void 0 : _c.incrementStaleItemsCount(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.addLabels({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
labels: [staleLabel]
});
Expand All @@ -891,8 +943,8 @@ class IssuesProcessor {
this.addedCloseCommentIssues.push(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.createComment({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
body: closeMessage
});
Expand All @@ -908,8 +960,8 @@ class IssuesProcessor {
(_b = this.statistics) === null || _b === void 0 ? void 0 : _b.incrementAddedItemsLabel(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.addLabels({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
labels: [closeLabel]
});
Expand All @@ -924,8 +976,8 @@ class IssuesProcessor {
(_c = this.statistics) === null || _c === void 0 ? void 0 : _c.incrementClosedItemsCount(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.update({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: this.options.closeIssueReason || undefined
Expand Down Expand Up @@ -955,15 +1007,15 @@ class IssuesProcessor {
const branch = pullRequest.head.ref;
if (pullRequest.head.repo === null ||
pullRequest.head.repo.full_name ===
`${github_1.context.repo.owner}/${github_1.context.repo.repo}`) {
`${issue.owner_repo.owner}/${issue.owner_repo.repo}`) {
issueLogger.info(`Deleting the branch "${logger_service_1.LoggerService.cyan(branch)}" from closed $$type`);
try {
this._consumeIssueOperation(issue);
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementDeletedBranchesCount();
if (!this.options.debugOnly) {
yield this.client.rest.git.deleteRef({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
ref: `heads/${branch}`
});
}
Expand All @@ -989,8 +1041,8 @@ class IssuesProcessor {
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementDeletedItemsLabelsCount(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.removeLabel({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
name: label
});
Expand Down Expand Up @@ -1089,8 +1141,8 @@ class IssuesProcessor {
(_a = this.statistics) === null || _a === void 0 ? void 0 : _a.incrementAddedItemsLabel(issue);
if (!this.options.debugOnly) {
yield this.client.rest.issues.addLabels({
owner: github_1.context.repo.owner,
repo: github_1.context.repo.repo,
owner: issue.owner_repo.owner,
repo: issue.owner_repo.repo,
issue_number: issue.number,
labels: labelsToAdd
});
Expand Down Expand Up @@ -1499,6 +1551,31 @@ class Operations {
exports.Operations = Operations;


/***/ }),

/***/ 6226:
/***/ ((__unused_webpack_module, exports) => {

"use strict";

Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.OwnerRepo = void 0;
class OwnerRepo {
constructor(repo_url) {
const m = repo_url.match(/.*\/([^/]+)\/(.+)$/);
if (!m) {
this.owner = '';
this.repo = '';
}
else {
this.owner = m[1];
this.repo = m[2];
}
}
}
exports.OwnerRepo = OwnerRepo;


/***/ }),

/***/ 7069:
Expand Down Expand Up @@ -2185,6 +2262,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 +2604,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
5 changes: 4 additions & 1 deletion src/classes/issue.spec.ts
Expand Up @@ -2,6 +2,7 @@ import {IUserAssignee} from '../interfaces/assignee';
import {IIssue} from '../interfaces/issue';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {ILabel} from '../interfaces/label';
import {IOwnerRepo} from '../interfaces/owner-repo';
import {IMilestone} from '../interfaces/milestone';
import {Issue} from './issue';

Expand Down Expand Up @@ -29,6 +30,7 @@ describe('Issue', (): void => {
exemptPrLabels: '',
onlyLabels: '',
onlyIssueLabels: '',
onlyMatchingFilter: '',
onlyPrLabels: '',
anyOfLabels: '',
anyOfIssueLabels: '',
Expand Down Expand Up @@ -88,7 +90,8 @@ describe('Issue', (): void => {
login: 'dummy-login',
type: 'User'
}
]
],
repository_url: 'https://api.github.com/repos/dummy/dummy'
};
issue = new Issue(optionsInterface, issueInterface);
});
Expand Down
6 changes: 6 additions & 0 deletions src/classes/issue.ts
Expand Up @@ -4,9 +4,11 @@ import {Assignee} from '../interfaces/assignee';
import {IIssue, OctokitIssue} from '../interfaces/issue';
import {IIssuesProcessorOptions} from '../interfaces/issues-processor-options';
import {ILabel} from '../interfaces/label';
import {IOwnerRepo} from '../interfaces/owner-repo';
import {IMilestone} from '../interfaces/milestone';
import {IsoDateString} from '../types/iso-date-string';
import {Operations} from './operations';
import {OwnerRepo} from './owner-repo';

export class Issue implements IIssue {
readonly title: string;
Expand All @@ -20,8 +22,10 @@ export class Issue implements IIssue {
readonly locked: boolean;
readonly milestone?: IMilestone | null;
readonly assignees: Assignee[];
readonly repository_url?: string;
isStale: boolean;
markedStaleThisRun: boolean;
readonly owner_repo: IOwnerRepo;
operations = new Operations();
private readonly _options: IIssuesProcessorOptions;

Expand All @@ -41,8 +45,10 @@ export class Issue implements IIssue {
this.locked = issue.locked;
this.milestone = issue.milestone;
this.assignees = issue.assignees || [];
this.repository_url = issue.repository_url;
this.isStale = isLabeled(this, this.staleLabel);
this.markedStaleThisRun = false;
this.owner_repo = new OwnerRepo(issue.repository_url || '');
}

get isPullRequest(): boolean {
Expand Down

0 comments on commit 4f61ec9

Please sign in to comment.