Skip to content

Commit

Permalink
First commit of automerge public action
Browse files Browse the repository at this point in the history
  • Loading branch information
F-WRunTime committed Jan 18, 2024
0 parents commit 651e3f7
Show file tree
Hide file tree
Showing 5 changed files with 283 additions and 0 deletions.
18 changes: 18 additions & 0 deletions .github/workflows/basic-validation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Basic validation

on:
push:
branches:
- main
paths-ignore:
- '**.md'
pull_request:
paths-ignore:
- '**.md'

jobs:
call-basic-validation:
name: Basic validation
uses: actions/reusable-workflows/.github/workflows/basic-validation.yml@main
with:
node-version: '20'
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
Example workflow using Automerge across an Github Organization
==============================================================
This example workflow will run every 20 minutes and will automerge any PRs that are ready to be merged. It will also cancel any previous runs of the workflow if they are still running.

From the repository the workflow is running from it will require a JSON file called `automerge.json` that contains a list of repositories to run the workflow on. The JSON file should be in the following format:

```json
[
"repo1",
"repo2",
"repo3"
]
```
In .github/workflows/automerge.yml
```yaml
name: 'Automerger'
on:
workflow_dispatch:
schedule:
- cron: '*/20 * * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:

list:
name: 'List Repos'
runs-on: [linux]
outputs:
matrix: ${{ steps.list.outputs.value }}
steps:
- name: 'Check out devops repo'
uses: actions/checkout@v3
- id: list
name: 'List automerge repos'
run: echo "value=$(cat automerge.json | tr -d '\n')" >> $GITHUB_OUTPUT

automerge:
name: 'Automerge'
runs-on: [self-hosted, linux, flyweight]
needs: list
strategy:
fail-fast: false
matrix:
value: ${{fromJson(needs.list.outputs.matrix)}}
steps:
- name: 'Check out devops repo'
uses: actions/checkout@v3
- name: 'Automerge runtimeverification/${{ matrix.value }}'
uses: ./.github/actions/automerge
with:
repo: ${{ matrix.value }}
token: ${{ secrets.JENKINS_GITHUB_PAT }}
```
55 changes: 55 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: "Automerge"
author: "Runtime Verification Inc"
description: "Automatically merge pull requests meeting the requiresments of All Checks Pass, Review Approved, and Up-to-date. This action is a substituion for Github merge queues. Merge queues fails to adhere to passing checks. Automerge helps support a linear well tested PR before it is merged."
inputs:
list-of-repos:
description: 'List of repos to merge'
required: true
org:
description: 'Organization name under which to run on'
required: true
repo:
description: 'Repository name under Organization'
required: true
token:
description: 'Access token to be able to write to the repository'
required: true
outputs:
merged:
value: ${{ steps.automerge.outputs.merged }}
description: 'Whether or not the PR was merged'
error:
value: ${{ steps.automerge.outputs.error }}
description: 'Error message if the PR was not merged'
success:
value: ${{ steps.automerge.outputs.success }}
description: 'Success message if the PR was merged'
runs:
using: 'composite'
steps:
- name: Setup Python
uses: actions/[email protected]
with:
python-version: '3.x'

- name: Install dependencies
shell: bash {0}
run: |
pip install logging os subprocess sys github typing
- name: 'Check out repo: ${{ inputs.org }}/${{ inputs.repo }}'
uses: actions/checkout@v3
with:
token: ${{ inputs.token }}
repository: ${{ inputs.org }}/${{ inputs.repo }}
path: tmp-${{ inputs.repo }}
fetch-depth: 0

- name: 'Run automerger: ${{ inputs.org }}/${{ inputs.repo }}'
shell: bash {0}
env:
GITHUB_TOKEN: ${{ inputs.token }}
# TODO: for some reason, even though the script fails, the workflow doesn't
working-directory: tmp-${{ inputs.repo }}
run: python3 ../src/automerge.py --org ${{ inputs.org }} --repo ${{ inputs.repo }}

137 changes: 137 additions & 0 deletions src/automerge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env python3

import logging
import os
import subprocess
import sys
import argparse

from github import Github
from typing import Final

# Collect command arguments from argparse
parser = argparse.ArgumentParser(description='Automerge approved PRs.')
parser.add_argument('--repo', type=str, help='The repository to check.')
parser.add_argument('--org', type=str, help='The GitHub organization to check.')
args = parser.parse_args()

_LOGGER: Final = logging.getLogger(__name__)
_LOG_FORMAT: Final = '%(levelname)s %(asctime)s %(name)s - %(message)s'
logging.basicConfig(level=logging.INFO)

def pr_to_display_string(pr):
return f'- {pr.number}: {pr.title}'

def run_git_command(args: str) -> subprocess.CompletedProcess:
command = ['git'] + args.split(' ')
_LOGGER.debug(f'Running: {" ".join(command)}')
try:
res = subprocess.run(command, stdout=None, stderr=None, check=False, text=True)
except subprocess.CalledProcessError as err:
_LOGGER.error(f'Completed with status {err.returncode}: {" ".join(command)}')
raise
return res

github = Github(login_or_token=os.environ['GITHUB_TOKEN'])
repo = args.org/args.repo

# 1. Get PRs that are:
# - Open.
open_prs = []
for pr in github.get_repo(repo).get_pulls():
if pr.state == 'open':
open_prs.append(pr)
pr_string = '\n'.join(map(pr_to_display_string, open_prs))
_LOGGER.info(f' PRs:\n{pr_string}\n')
if not open_prs:
_LOGGER.info(f' Quitting.')

# 2. Get PRs that are:
# - Open,
# - Labeled as `automerge`, and
# - Approved.
automerge_prs = []
for pr in open_prs:
labels = [l.name for l in pr.get_labels()]
reviews = sorted([(r.state, r.submitted_at) for r in pr.get_reviews()], key=lambda x: x[1], reverse=True)
reviews = [state for state, _ in reviews]
if 'automerge' in labels:
approved = False
for i in reviews:
if i == 'APPROVED':
approved = True
break
if i != 'COMMENTED':
break
if approved:
automerge_prs.append(pr)
pr_string = '\n'.join(map(pr_to_display_string, automerge_prs))
_LOGGER.info(f' Automerge approved PRs:\n{pr_string}\n')
if not automerge_prs:
_LOGGER.info(' Quitting.')
sys.exit(0)

# 3. Get PRs that are:
# - Open,
# - Labelled as `automerge`,
# - Approved,
# - Up-to-date, and
# - Passing tests.
automerge_up_to_date_prs = []
for pr in automerge_prs:
is_up_to_date = run_git_command(f'merge-base --is-ancestor {pr.base.sha} {pr.head.sha}').returncode == 0
if pr.mergeable_state == 'clean' and is_up_to_date:
automerge_up_to_date_prs.append(pr)
pr_string = '\n'.join(map(pr_to_display_string, automerge_up_to_date_prs))
_LOGGER.info(f' Automerge approved up-to-date PRs:\n{pr_string}\n')

# 4. Get PRs that are:
# - Open,
# - Labelled as `automerge`,
# - Approved, and
# - Up-to-date.
# If so, merge one.
if automerge_up_to_date_prs:
pr = automerge_up_to_date_prs[0]
_LOGGER.info(f' Merging PR:\n{pr_to_display_string(pr)}\n')
pr.merge(merge_method='squash')
automerge_up_to_date_prs.pop(0)

# 5. Get PRs that are:
# - Open,
# - Labelled as `automerge`,
# - Approved,
# - Up-to-date, and
# - Pending tests.
# If so, quit (reduce build pressure).
automerge_up_to_date_pending_prs = []
for pr in automerge_prs:
is_up_to_date = run_git_command(f'merge-base --is-ancestor {pr.base.sha} {pr.head.sha}').returncode == 0
commit = [c for c in pr.get_commits() if c.sha == pr.head.sha][0]
is_failing = commit.get_combined_status().state == 'failure'
if pr.mergeable_state == 'blocked' and is_up_to_date and not is_failing:
print(commit.get_combined_status())
automerge_up_to_date_pending_prs.append(pr)
pr_string = '\n'.join(map(pr_to_display_string, automerge_up_to_date_pending_prs))
_LOGGER.info(f' Automerge approved up-to-date pending PRs:\n{pr_string}\n')
if automerge_up_to_date_pending_prs:
_LOGGER.info(' Quitting.')
sys.exit(0)

# 6. Get PRs that are:
# - Open,
# - Labelled as `automerge`,
# - Approved,
# - Out-of-date, and
# - Passing tests.
# If so, update the branch.
automerge_out_of_date_passing_prs = []
for pr in automerge_prs:
if pr.mergeable_state == 'behind':
automerge_out_of_date_passing_prs.append(pr)
pr_string = '\n'.join(map(pr_to_display_string, automerge_out_of_date_passing_prs))
_LOGGER.info(f' Automerge approved out-of-date passing PRs:\n{pr_string}\n')
if automerge_out_of_date_passing_prs:
pr = automerge_out_of_date_passing_prs[0]
_LOGGER.info(f' Updating PR:\n{pr_to_display_string(pr)}\n')
pr.update_branch()
18 changes: 18 additions & 0 deletions test/automerge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[
"algorand-sc-semantics"
, "amp"
, "audit-kontrol-template"
, "avm-semantics"
, "blockchain-k-plugin"
, "blockswap-server"
, "casper-cbc-proofs"
, "c-semantics"
, "erc20-verification"
, "ercx"
, "evm-semantics"
, "firefly"
, "firefly-web"
, "foundry-tests"
, "haskell-backend"
, "homebrew-k"
]

0 comments on commit 651e3f7

Please sign in to comment.