Schedule GitHub Pull Request merges using Actions
Tags: github, devops, javascript
Published: 2020-01-03 21:14:55

There is currently no built-in feature to schedule a pull request merge on GitHub. But a little [GitHub Action](https://github.com/features/actions) magic can fill in this gap.

In this post, I'll explain 

1. How to set pull request status to "pending" and
2. How to run a script on a schedule using GitHub Actions.

If you want to explore the final code, check out the [Schedule Action on GitHub](https://github.com/gr2m/merge-schedule-action#readme).

# How it works

In order to merge a pull request on a specified future date, you need two things

1. Set the date for the merge
2. Do the actual merge on the specified date

For the first step, the action will look for a string in the pull request description that looks like this

```markdown
/schedule 2020-02-20
```

If it exists, it creates a pending status with an explanatory description.

The 2nd step is a script that runs on a special [`schedule` event](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/configuring-a-workflow#triggering-a-workflow-with-events). It will look for the string above in all open pull requests. If it finds any, it will merge the pull requests that are scheduled to be merged today.

## Set the date for the merge

The workflow file for the first step will look like this, you can create it as `.github/workflows/schedule-merge.yml`, the file name does not matter, only the directory.

```yml
name: Schedule Merge
on:
  pull_request:
    types:
      - opened
      - edited
jobs:
  schedule:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: 12
      - run: "npm ci"
      - run: "node ./.github/actions/schedule-merge.js"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

The workflow is triggered each time a pull request is created or edited (such as a description update). Then it checks out the source code of your repository, it sets up Node and installs your dependencies as defined in `./package.json`. Then it runs the `./.github/actions/schedule.js` file that we still need to create, and passes in the [`GITHUB_TOKEN`](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/authenticating-with-the-github_token).

A simplified `./.github/actions/schedule-merge.js` file would look like this (See [gr2m/merge-schedule-action/lib/handle_pull_request.js](https://github.com/gr2m/merge-schedule-action/blob/38d34a13ba7f6ce92ec5acd54d00095d3bef0a42/lib/handle_pull_request.js) for a complete version).

```js
// make sure to `npm install @octokit/action`
const { Octokit } = require("@octokit/action");

scheduleMerge()

async function scheduleMerge() {
  const octokit = new Octokit();
  
  // retrieve the full `pull_request` event payload
  const eventPayload = require(process.env.GITHUB_EVENT_PATH);

  // find and parse the date string from the `/schedule ...` command
  const datestring = getScheduleDateString(eventPayload.pull_request.body);

  if (!datestring) {
    console.log(`No /schedule command found`);
    return;
  }

  // Create a check run using the REST API
  // https://developer.github.com/v3/checks/runs/#create-a-check-run
  const { data } = await octokit.request('POST /repos/:owner/:repo/check-runs', {
    owner: eventPayload.repository.owner.login,
    repo: eventPayload.repository.name,
    name: "Merge Schedule",
    head_sha: eventPayload.pull_request.head.sha,
    status: "in_progress",
    output: {
      title: `Scheduled to me merged on ${datestring}`,
      summary: "TO BE DONE: add useful summary"
    }
  });
  console.log(`Check run created: ${data.html_url}`);
}
```

Now each time you create a pull request or update its description, the action will run and set a pending status if a `/schedule ...` command is found.

## Merge a scheduled pull request

You can create the workflow file for the 2nd step as `.github/workflows/merge-scheduled.yml`. The `cron` syntax is rather complicated, but the https://crontab.guru/ website can be a great help.

```yml
name: Merge Scheduled Pull Requests
on:
  schedule:
    # https://crontab.guru/every-day-8am
    - cron: 0 8 * * *

jobs:
  merge:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: 12
      - run: "npm ci"
      - run: "node ./.github/actions/merge.js"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

A simplified `./.github/actions/merge.js` file would look like this (See [gr2m/merge-schedule-action/lib/handle_schedule.js](https://github.com/gr2m/merge-schedule-action/blob/38d34a13ba7f6ce92ec5acd54d00095d3bef0a42/lib/handle_schedule.js) for a complete version).

```js
const { Octokit } = require("@octokit/action");

merge()

async function merge() {
  const octokit = new Octokit();
  const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/");

  const pullRequests = await octokit.paginate(
    "GET /repos/:owner/:repo/pulls",
    {
      owner,
      repo,
      state: "open"
    },
    ({ data }) => {
      return data.filter(hasScheduleCommand).map(pullRequest => {
          return {
            number: pullRequest.number,
            html_url: pullRequest.html_url,
            scheduledDate: getScheduleDateString(pullRequest.body)
          };
        });
    }
  );

  console.log(`${pullRequests.length} scheduled pull requests found`);

  if (pullRequests.length === 0) {
    return;
  }

  const duePullRequests = pullRequests.filter(
    pullRequest => new Date(pullRequest.scheduledDate) < new Date()
  );

  console.log(`${duePullRequests.length} due pull requests found`);

  if (duePullRequests.length === 0) {
    return;
  }

  for await (const pullRequest of duePullRequests) {
    await octokit.pulls.merge({
      owner,
      repo,
      pull_number: pullRequest.number
    });
    console.log(`${pullRequest.html_url} merged`);
  }
}
```

## Combining the workflow files

If you like, you can combine the two workflow files above into one, then run the correct script based on the event. It would look like this

```yml
name: Merge Scheduled Pull Requests
on:
  pull_request:
    types:
      - opened
      - edited
  schedule:
    # https://crontab.guru/every-day-8am
    - cron: 0 8 * * *

jobs:
  schedule:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: 12
      - run: "npm ci"
      - run: "node ./.github/actions/schedule-merge.js"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  merge:
    runs-on: ubuntu-latest
    if: github.event_name == 'schedule'
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v1
        with:
          node-version: 12
      - run: "npm ci"
      - run: "node ./.github/actions/merge.js"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

Learn more about the GitHub Action workflow syntax on [GitHub help](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions).

You can find my `merge-schedule` action on the GitHub Marketplace: https://github.com/marketplace/actions/merge-schedule