Skip to content

Commit

Permalink
Implement cron based labeler
Browse files Browse the repository at this point in the history
Use actions/stale as an example of how to implement pagination and
rate limiting support and adapt it to the actions/labeler repository.

The cron based labeler sorts the pull request by date updated in
descending order and processes them subject to the rate limit. The
idea is that if the cron job runs often enough it will process all
the pull requests since the most recently updated ones are processed
first.
  • Loading branch information
fjeremic committed Apr 17, 2020
1 parent 0406f24 commit 1896129
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 179 deletions.
74 changes: 72 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,72 @@
# cron-labeler
An action for automatically labelling pull requests from forked repositories using a rate limit aware cron job.
# Cron Pull Request Labeler

Cron pull request labeler is an extension of [actions/labeler](https://github.com/actions/labeler) which triages PRs based on the paths that are modified in the PR using a periodic cron job.

The [actions/labeler](https://github.com/actions/labeler) GitHub Action runs into the following issue (further described in [actions/labeler#12](https://github.com/actions/labeler/issues/12)) when the check runs on a pull request originating from a _forked_ repository:

```
##[error] HttpError: Resource not accessible by integration
##[error] Resource not accessible by integration
##[error] Node run failed with exit code 1
```

This is a fairly restrictive limitation in the GitHub Pull Request Workflow which many open source projects follow.

This project circumvents this limitation by running the GitHub Action as a cron job on the target repository. The cron job continuously monitors the pull requests of the target repository and adds the appropriate labels in a rate limiting aware manner with pagination support. The idea is that if this action is run often enough it will keep labeling the most recently updated pull requests, and eventually all pull requests will have been labeled.

## Usage

### Create `.github/labeler.yml`

Create a `.github/labeler.yml` file with a list of labels and [minimatch](https://github.com/isaacs/minimatch) globs to match to apply the label.

The key is the name of the label in your repository that you want to add (eg: "merge conflict", "needs-updating") and the value is the path (glob) of the changed files (eg: `src/**/*`, `tests/*.spec.js`)

#### Basic Examples

```yml
# Add 'label1' to any changes within 'example' folder or any subfolders
label1:
- example/**/*

# Add 'label2' to any file changes within 'example2' folder
label2: example2/*
```
#### Common Examples
```yml
# Add 'repo' label to any root file changes
repo:
- ./*

# Add '@domain/core' label to any change within the 'core' package
@domain/core:
- package/core/*
- package/core/**/*

# Add 'test' label to any change to *.spec.js files within the source dir
test:
- src/**/*.spec.js
```
### Create Workflow
Create a workflow (eg: `.github/workflows/labeler.yml` see [Creating a Workflow file](https://help.github.com/en/articles/configuring-a-workflow#creating-a-workflow-file)) to utilize the labeler action running every 10 minutes:

```
name: "Cron Pull Request Labeler"
on:
schedule:
- cron: "*/10 * * * *"

jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: fjeremic/cron-labeler@0.1.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
```
_Note: This grants access to the `GITHUB_TOKEN` so the action can make calls to GitHub's rest API._
12 changes: 9 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
name: 'Labeler'
description: 'Add labels to new pull requests based on the files that are changed'
author: 'GitHub'
name: 'Cron Labeler'
description: 'An action for automatically labelling pull requests from forked repositories using a rate limit aware cron job.'
author: 'Filip Jeremic'
branding:
icon: 'tag'
color: 'blue'
inputs:
repo-token:
description: 'The GITHUB_TOKEN secret'
configuration-path:
description: 'The path for the label configurations'
default: '.github/labeler.yml'
operations-per-run:
description: 'The maximum number of operations per run, used to control rate limiting'
default: 30
runs:
using: 'node12'
main: 'dist/index.js'
160 changes: 82 additions & 78 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6210,91 +6210,92 @@ const minimatch_1 = __webpack_require__(595);
function run() {
return __awaiter(this, void 0, void 0, function* () {
try {
const token = core.getInput('repo-token', { required: true });
const configPath = core.getInput('configuration-path', { required: true });
const prNumber = getPrNumber();
if (!prNumber) {
console.log('Could not get pull request number from context, exiting');
return;
}
const client = new github.GitHub(token);
core.debug(`fetching changed files for pr #${prNumber}`);
const changedFiles = yield getChangedFiles(client, prNumber);
const labelGlobs = yield getLabelGlobs(client, configPath);
const labels = [];
for (const [label, globs] of labelGlobs.entries()) {
core.debug(`processing ${label}`);
if (checkGlobs(changedFiles, globs)) {
labels.push(label);
const args = getAndValidateArgs();
const client = new github.GitHub(args.repoToken);
const contents = yield client.repos.getContents({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
path: args.configurationPath,
ref: github.context.sha
});
args.operationsPerRun -= 1;
const configurationContent = Buffer.from(contents.data.content, contents.data.encoding).toString();
// loads (hopefully) a `{[label:string]: string | string[]}`, but is `any`:
const configObject = yaml.safeLoad(configurationContent);
// transform `any` => `Map<string,string[]>` or throw if yaml is malformed:
const labelGlobs = new Map();
for (const label in configObject) {
if (typeof configObject[label] === "string") {
labelGlobs.set(label, [configObject[label]]);
}
else if (configObject[label] instanceof Array) {
labelGlobs.set(label, configObject[label]);
}
else {
throw Error(`found unexpected type for label ${label} (should be string or array of globs)`);
}
}
if (labels.length > 0) {
yield addLabels(client, prNumber, labels);
}
yield processPrs(client, labelGlobs, args, args.operationsPerRun);
}
catch (error) {
core.error(error);
core.setFailed(error.message);
}
});
}
function getPrNumber() {
const pullRequest = github.context.payload.pull_request;
if (!pullRequest) {
return undefined;
}
return pullRequest.number;
}
function getChangedFiles(client, prNumber) {
function processPrs(client, labelGlobs, args, operationsLeft, page = 1) {
return __awaiter(this, void 0, void 0, function* () {
const listFilesResponse = yield client.pulls.listFiles({
const prs = yield client.pulls.list({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: prNumber
state: "open",
sort: "updated",
direction: "desc",
per_page: 100,
page
});
const changedFiles = listFilesResponse.data.map(f => f.filename);
core.debug('found changed files:');
for (const file of changedFiles) {
core.debug(' ' + file);
operationsLeft -= 1;
if (prs.data.length === 0 || operationsLeft === 0) {
return operationsLeft;
}
for (const pr of prs.data.values()) {
core.debug(`found pr #${pr.number}: ${pr.title}`);
core.debug(`fetching changed files for pr #${pr.number}`);
const listFilesResponse = yield client.pulls.listFiles({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
pull_number: pr.number
});
operationsLeft -= 1;
const changedFiles = listFilesResponse.data.map(f => f.filename);
core.debug("found changed files:");
for (const file of changedFiles) {
core.debug(" " + file);
}
const labels = [];
for (const [label, globs] of labelGlobs.entries()) {
core.debug(`processing label ${label}`);
if (checkGlobs(changedFiles, globs)) {
labels.push(label);
}
}
if (labels.length > 0) {
yield client.issues.addLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: pr.number,
labels: labels
});
operationsLeft -= 1;
}
if (operationsLeft <= 0) {
core.warning(`performed ${args.operationsPerRun} operations, exiting to avoid rate limit`);
return 0;
}
}
return changedFiles;
});
}
function getLabelGlobs(client, configurationPath) {
return __awaiter(this, void 0, void 0, function* () {
const configurationContent = yield fetchContent(client, configurationPath);
// loads (hopefully) a `{[label:string]: string | string[]}`, but is `any`:
const configObject = yaml.safeLoad(configurationContent);
// transform `any` => `Map<string,string[]>` or throw if yaml is malformed:
return getLabelGlobMapFromObject(configObject);
});
}
function fetchContent(client, repoPath) {
return __awaiter(this, void 0, void 0, function* () {
const response = yield client.repos.getContents({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
path: repoPath,
ref: github.context.sha
});
return Buffer.from(response.data.content, response.data.encoding).toString();
return yield processPrs(client, labelGlobs, args, operationsLeft, page + 1);
});
}
function getLabelGlobMapFromObject(configObject) {
const labelGlobs = new Map();
for (const label in configObject) {
if (typeof configObject[label] === 'string') {
labelGlobs.set(label, [configObject[label]]);
}
else if (configObject[label] instanceof Array) {
labelGlobs.set(label, configObject[label]);
}
else {
throw Error(`found unexpected type for label ${label} (should be string or array of globs)`);
}
}
return labelGlobs;
}
function checkGlobs(changedFiles, globs) {
for (const glob of globs) {
core.debug(` checking pattern ${glob}`);
Expand All @@ -6309,15 +6310,18 @@ function checkGlobs(changedFiles, globs) {
}
return false;
}
function addLabels(client, prNumber, labels) {
return __awaiter(this, void 0, void 0, function* () {
yield client.issues.addLabels({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
issue_number: prNumber,
labels: labels
});
});
function getAndValidateArgs() {
const args = {
repoToken: core.getInput("repo-token", { required: true }),
configurationPath: core.getInput("configuration-path"),
operationsPerRun: parseInt(core.getInput("operations-per-run", { required: true }))
};
for (const numberInput of ["operations-per-run"]) {
if (isNaN(parseInt(core.getInput(numberInput)))) {
throw Error(`input ${numberInput} did not parse to a valid integer`);
}
}
return args;
}
run();

Expand Down
Loading

0 comments on commit 1896129

Please sign in to comment.