From f12698889da762a054348f322551d32e5bc478bd Mon Sep 17 00:00:00 2001 From: Greg Chadwick Date: Sat, 6 May 2023 19:22:46 +0100 Subject: [PATCH] GH actions testing --- .github/workflows/pr_rtl_change_check.yml | 47 ++++++ BLOCKFILE | 8 + ci/scripts/check-pr-changes-allowed.py | 183 ++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 .github/workflows/pr_rtl_change_check.yml create mode 100644 BLOCKFILE create mode 100755 ci/scripts/check-pr-changes-allowed.py diff --git a/.github/workflows/pr_rtl_change_check.yml b/.github/workflows/pr_rtl_change_check.yml new file mode 100644 index 00000000000000..4055c2c7317760 --- /dev/null +++ b/.github/workflows/pr_rtl_change_check.yml @@ -0,0 +1,47 @@ +name: 'Testing GH Actions' + +# **What it does**: Renders the content of every page and check all internal links. +# **Why we have it**: To make sure all links connect correctly. +# **Who does it impact**: Docs content. + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + +permissions: + contents: read + # Needed for the 'trilom/file-changes-action' action + pull-requests: read + +jobs: + check-links: + runs-on: "ubuntu-latest" + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Install python requirements + run: | + audo apt update + sudo apt install -y python3-pip + pip install requests + + # Creates file "$/files.json", among others + - name: Gather files changed + uses: trilom/file-changes-action@a6ca26c14274c33b15e6499323aac178af06ad4b + with: + fileOutput: 'json' + + # For verification + - name: Show files changed + run: cat $HOME/files.json + + - name: Check for blocked changs + run: | + ./ci/scripts/check-pr-changes-allowed.py $HOME/files.json \ + --gh-repo ${{ github.action_repository }} \ + --gh-token ${{ secrets.GITHUB_TOKEN }} \ + --pr-ref ${{ github.ref }} diff --git a/BLOCKFILE b/BLOCKFILE new file mode 100644 index 00000000000000..54e2f4407ccba4 --- /dev/null +++ b/BLOCKFILE @@ -0,0 +1,8 @@ +# A test block file + +hw/ip/*/rtl/* +hw/ip_templates/*/rtl/* +hw/top_earlgrey/rtl/* +hw/top_earlgrey/ip/*/rtl/* +hw/top_earlgrey/ip_autogen/*/rtl/* + diff --git a/ci/scripts/check-pr-changes-allowed.py b/ci/scripts/check-pr-changes-allowed.py new file mode 100755 index 00000000000000..988ff7bf89bae4 --- /dev/null +++ b/ci/scripts/check-pr-changes-allowed.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# Copyright lowRISC contributors. +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +from fnmatch import fnmatch +import json +import argparse +import sys +import re +import requests + +# Number of authorizations required from committers to override a block +NUM_AUTHS_REQD = 2 + +GH_API_URL = 'https://api.github.com/repos/' + +def load_blockfile(blockfile_name): + blocklist = [] + with open(blockfile_name, 'r') as blockfile: + for line in blockfile.readlines(): + # Remove everything following a '#' for comments + line = line.partition('#')[0] + # Remove leading and trailing whitespace + line = line.strip() + + # If anything remains after the above add it to the list + if line != '': + blocklist.append(line) + + return blocklist + +def load_and_validate_changes(changes_json_file): + with open(changes_json_file, 'r') as changes_json: + changes = json.load(changes_json) + + if type(changes) != list: + raise ValueError('Changes JSON must be a list at top-level') + + for change in changes: + if type(change) != str: + raise ValueError(f'Saw value {change} in changes JSON which is not ' + 'a string. Changes JSON must be a single list of ' + 'strings') + + return changes + +def check_changes_against_blocklist(changes, blocklist): + blocked_changes = {} + + for change in changes: + # Create list of patterns from blocklist that match change + blocked = list(filter(lambda x: x is not None, + map(lambda b: b if fnmatch(change, b) else None, blocklist))) + + # If any match patterns exist add them to the blocked_changes dictionary + if blocked: + blocked_changes[change] = blocked + + return blocked_changes + + +pr_ref_re = re.compile(r'refs\/pull\/(\d+)\/merge') +def get_pr_number_from_ref(pr_ref): + pr_ref_match = pr_ref_re.match(pr_ref) + + if pr_ref_match: + return int(pr_ref_match.group(1)) + + raise ValueError(f'{pr_ref} is not a valid PR ref') + +def fetch_pr_comments(gh_token, repo_name, pr_number): + headers = { + 'Accept': 'application/vnd.github+json', + 'Authorization': f'Bearer {gh_token}', + 'X-GitHub-Api-Version': '2022-11-28' + } + + pr_comment_url = f'{GH_API_URL}{repo_name}/issues/{pr_number}/comments' + pr_comment_request = requests.get(pr_comment_url, headers=headers) + + comments_json_list = pr_comment_request.json() + # Return pairs with commenter handle as the first element and the comment + # body as the second + return [(c['user']['login'], c['body']) for c in comments_json_list] + +committer_re = re.compile(r'\* ([\w\s-]+) \(([\w-]+)\)') + +def load_committers(committers_filename): + with open(committers_filename, 'r') as committers_file: + committer_tuples = committer_re.findall(committers_file.read()) + + committers_dict = {ct[1].lower(): ct[0] for ct in committer_tuples} + + if 'githubid' in committers_dict: + del committers_dict['githubid'] + + return committers_dict + +authorize_re = re.compile(r'^CHANGE AUTHORIZED: (.*)$', re.MULTILINE) + +def get_authorized_changes(comments, committers): + file_change_authorizations = {} + + for comment_author, comment_body in comments: + comment_author = comment_author.lower() + + if comment_author not in committers: + continue + + file_authorizations = authorize_re.findall(comment_body) + for filename in file_authorizations: + filename = filename.strip() + + if filename not in file_change_authorizations: + file_change_authorizations[filename] = [comment_author] + else: + file_change_authorizations[filename].append(comment_author) + + return file_change_authorizations + +def main(): + arg_parser = argparse.ArgumentParser( + prog='check-pr-changes-allowed', + description='''Checks a list of changed files supplied as a json + list against blocked file patterns. If any match + exits with code 1 and the PR should be blocked.''') + + arg_parser.add_argument('changes_json_file', metavar='changes-json-file', + help='''JSON file containing a list of changed files''') + + arg_parser.add_argument('--block-file', default='BLOCKFILE', + help='''Plain text file containing path patterns that when matched + indicate a blocked file. One pattern per line''') + + arg_parser.add_argument('--pr-ref', + help='git ref of the PR requesting the change') + + arg_parser.add_argument('--gh-token', + help='''A github access token used to read PR comments for override + commands''') + + arg_parser.add_argument('--gh-repo', + help='Name of the repository on github to read PR comments from', + default='lowrisc/opentitan') + + args = arg_parser.parse_args() + + blocklist = load_blockfile(args.block_file) + changes = load_and_validate_changes(args.changes_json_file) + blocked_changes = check_changes_against_blocklist(changes, blocklist) + + if (args.gh_token is not None and args.pr_ref is not None): + committers = load_committers('COMMITTERS') + pr_number = get_pr_number_from_ref(args.pr_ref) + pr_comments = fetch_pr_comments(args.gh_token, args.gh_repo, + pr_number) + authorized_changes = get_authorized_changes(pr_comments, committers) + + block_changes_files = list(blocked_changes.keys()) + for change in block_changes_files: + if (change in authorized_changes + and len(authorized_changes[change]) >= NUM_AUTHS_REQD): + del blocked_changes[change] + + authorizers = ', '.join([f'{committers[handle]} ({handle})' + for handle in authorized_changes[change]]) + print(f'{change} change is authorized by {authorizers}') + + if blocked_changes: + for change, block_patterns in blocked_changes.items(): + patterns_str = ' '.join(block_patterns) + print(f'{change} blocked by pattern(s): {patterns_str}') + + print('UNAUTHORIZED CHANGES PRESENT, PR cannot be merged!') + return 1 + + print('No unauthorized changes, clear to merge') + return 0 + +if __name__ == '__main__': + sys.exit(main()) +