From 651e3f70724f8c835c3c77fb0676f39e77cf3c17 Mon Sep 17 00:00:00 2001 From: F-WRunTime Date: Wed, 17 Jan 2024 21:37:23 -0700 Subject: [PATCH] First commit of automerge public action --- .github/workflows/basic-validation.yaml | 18 ++++ README.md | 55 ++++++++++ action.yaml | 55 ++++++++++ src/automerge.py | 137 ++++++++++++++++++++++++ test/automerge.json | 18 ++++ 5 files changed, 283 insertions(+) create mode 100644 .github/workflows/basic-validation.yaml create mode 100644 README.md create mode 100644 action.yaml create mode 100644 src/automerge.py create mode 100644 test/automerge.json diff --git a/.github/workflows/basic-validation.yaml b/.github/workflows/basic-validation.yaml new file mode 100644 index 0000000..f86b369 --- /dev/null +++ b/.github/workflows/basic-validation.yaml @@ -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' \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e46007 --- /dev/null +++ b/README.md @@ -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 }} +``` \ No newline at end of file diff --git a/action.yaml b/action.yaml new file mode 100644 index 0000000..363a649 --- /dev/null +++ b/action.yaml @@ -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/setup-python@v5.0.0 + 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 }} + diff --git a/src/automerge.py b/src/automerge.py new file mode 100644 index 0000000..0f0e836 --- /dev/null +++ b/src/automerge.py @@ -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() diff --git a/test/automerge.json b/test/automerge.json new file mode 100644 index 0000000..70bf22a --- /dev/null +++ b/test/automerge.json @@ -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" +] \ No newline at end of file