| #!/usr/bin/env python |
| |
| # The service provided by this script is not critical, but it shares a GitHub |
| # API request quota with critical services. For this reason, all requests to |
| # the GitHub API are preceded by a "guard" which verifies that the subsequent |
| # request will not deplete the shared quota. |
| # |
| # In effect, this script will fail rather than interfere with the operation of |
| # critical services. |
| |
| import argparse |
| import json |
| import logging |
| import os |
| import subprocess |
| import time |
| |
| import requests |
| |
| # The ratio of "requests remaining" to "total request quota" below which this |
| # script should refuse to interact with the GitHub.com API |
| API_RATE_LIMIT_THRESHOLD = 0.2 |
| # The GitHub Pull Request label which indicates that a Pull Request is expected |
| # to be actively mirrored by the preview server |
| LABEL = 'safe for preview' |
| # The number of seconds to wait between attempts to verify that a submission |
| # preview is available on the Pull Request preview server |
| POLLING_PERIOD = 5 |
| # Pull Requests from authors with the following associations to the project |
| # should automatically receive previews |
| # |
| # https://developer.github.com/v4/enum/commentauthorassociation/ (equivalent |
| # documentation for the REST API was not available at the time of writing) |
| TRUSTED_AUTHOR_ASSOCIATIONS = ('COLLABORATOR', 'MEMBER', 'OWNER') |
| # These GitHub accounts are not associated with individuals, and the Pull |
| # Requests they submit rarely require a preview. |
| AUTOMATION_GITHUB_USERS = ( |
| 'autofoolip', 'chromium-wpt-export-bot', 'moz-wptsync-bot', |
| 'servo-wpt-sync' |
| ) |
| DEPLOYMENT_PREFIX = 'wpt-preview-' |
| |
| logging.basicConfig(level=logging.INFO) |
| logger = logging.getLogger(__name__) |
| |
| def gh_request(method_name, url, body=None, media_type=None): |
| github_token = os.environ['DEPLOY_TOKEN'] |
| |
| kwargs = { |
| 'headers': { |
| 'Authorization': 'token {}'.format(github_token), |
| 'Accept': media_type or 'application/vnd.github.v3+json' |
| } |
| } |
| method = getattr(requests, method_name.lower()) |
| |
| if body is not None: |
| kwargs['json'] = body |
| |
| logger.info('Issuing request: %s %s', method_name.upper(), url) |
| |
| resp = method(url, **kwargs) |
| |
| logger.info('Response status code: %s', resp.status_code) |
| |
| resp.raise_for_status() |
| |
| return resp.json() |
| |
| def guard(resource): |
| '''Decorate a `Project` instance method which interacts with the GitHub |
| API, ensuring that the subsequent request will not deplete the relevant |
| allowance. This verification does not itself influence rate limiting: |
| |
| > Accessing this endpoint does not count against your REST API rate limit. |
| |
| https://developer.github.com/v3/rate_limit/ |
| ''' |
| def guard_decorator(func): |
| def wrapped(self, *args, **kwargs): |
| limits = gh_request('GET', '{}/rate_limit'.format(self._host)) |
| |
| values = limits['resources'].get(resource) |
| |
| remaining = values['remaining'] |
| limit = values['limit'] |
| |
| logger.info( |
| 'Limit for "%s" resource: %s/%s', resource, remaining, limit |
| ) |
| |
| if limit and float(remaining) / limit < API_RATE_LIMIT_THRESHOLD: |
| raise Exception( |
| 'Exiting to avoid GitHub.com API request throttling.' |
| ) |
| |
| return func(self, *args, **kwargs) |
| return wrapped |
| return guard_decorator |
| |
| class Project(object): |
| def __init__(self, host, github_project): |
| self._host = host |
| self._github_project = github_project |
| |
| @guard('search') |
| def get_pull_requests(self, updated_since): |
| window_start = time.strftime('%Y-%m-%dT%H:%M:%SZ', updated_since) |
| url = '{}/search/issues?q=repo:{}+is:pr+updated:>{}'.format( |
| self._host, self._github_project, window_start |
| ) |
| |
| logger.info( |
| 'Searching for Pull Requests updated since %s', window_start |
| ) |
| |
| data = gh_request('GET', url) |
| |
| logger.info('Found %d Pull Requests', len(data['items'])) |
| |
| if data['incomplete_results']: |
| raise Exception('Incomplete results') |
| |
| return data['items'] |
| |
| @guard('core') |
| def pull_request_is_from_fork(self, pull_request): |
| pr_number = pull_request['number'] |
| url = '{}/repos/{}/pulls/{}'.format( |
| self._host, self._github_project, pr_number |
| ) |
| |
| logger.info('Checking if pull request %s is from a fork', pr_number) |
| |
| data = gh_request('GET', url) |
| |
| repo_name = data['head']['repo']['full_name'] |
| is_fork = repo_name != self._github_project |
| |
| logger.info( |
| 'Pull request %s is from \'%s\'. Is a fork: %s', |
| pr_number, repo_name, is_fork |
| ) |
| |
| return is_fork |
| |
| @guard('core') |
| def create_ref(self, refspec, revision): |
| url = '{}/repos/{}/git/refs'.format(self._host, self._github_project) |
| |
| logger.info('Creating ref "%s" (%s)', refspec, revision) |
| |
| gh_request('POST', url, { |
| 'ref': 'refs/{}'.format(refspec), |
| 'sha': revision |
| }) |
| |
| @guard('core') |
| def update_ref(self, refspec, revision): |
| url = '{}/repos/{}/git/refs/{}'.format( |
| self._host, self._github_project, refspec |
| ) |
| |
| logger.info('Updating ref "%s" (%s)', refspec, revision) |
| |
| gh_request('PATCH', url, {'sha': revision}) |
| |
| @guard('core') |
| def create_deployment(self, pull_request, revision): |
| url = '{}/repos/{}/deployments'.format( |
| self._host, self._github_project |
| ) |
| # The Pull Request preview system only exposes one Deployment for a |
| # given Pull Request. Identifying the Deployment by the Pull Request |
| # number ensures that GitHub.com automatically responds to new |
| # Deployments by designating prior Deployments as "inactive" |
| environment = DEPLOYMENT_PREFIX + str(pull_request['number']) |
| |
| logger.info('Creating Deployment "%s" for "%s"', environment, revision) |
| |
| return gh_request('POST', url, { |
| 'ref': revision, |
| 'environment': environment, |
| 'auto_merge': False, |
| # Pull Request previews are created regardless of GitHub Commit |
| # Status Checks, so Status Checks should be ignored when creating |
| # GitHub Deployments. |
| 'required_contexts': [] |
| }, 'application/vnd.github.ant-man-preview+json') |
| |
| @guard('core') |
| def get_deployment(self, revision): |
| url = '{}/repos/{}/deployments?sha={}'.format( |
| self._host, self._github_project, revision |
| ) |
| |
| deployments = gh_request('GET', url) |
| |
| return deployments.pop() if len(deployments) else None |
| |
| @guard('core') |
| def update_deployment(self, target, deployment, state, description=''): |
| if state in ('pending', 'success'): |
| pr_number = deployment['environment'][len(DEPLOYMENT_PREFIX):] |
| environment_url = '{}/{}'.format(target, pr_number) |
| else: |
| environment_url = None |
| url = '{}/repos/{}/deployments/{}/statuses'.format( |
| self._host, self._github_project, deployment['id'] |
| ) |
| |
| gh_request('POST', url, { |
| 'state': state, |
| 'description': description, |
| 'environment_url': environment_url |
| }, 'application/vnd.github.ant-man-preview+json') |
| |
| class Remote(object): |
| def __init__(self, github_project): |
| # The repository in the GitHub Actions environment is configured with |
| # a remote whose URL uses unauthenticated HTTPS, making it unsuitable |
| # for pushing changes. |
| self._token = os.environ['DEPLOY_TOKEN'] |
| |
| def get_revision(self, refspec): |
| output = subprocess.check_output([ |
| 'git', |
| '-c', |
| 'credential.username={}'.format(self._token), |
| '-c', |
| 'core.askPass=true', |
| 'ls-remote', |
| 'origin', |
| 'refs/{}'.format(refspec) |
| ]) |
| |
| if not output: |
| return None |
| |
| return output.decode('utf-8').split()[0] |
| |
| def delete_ref(self, refspec): |
| full_ref = 'refs/{}'.format(refspec) |
| |
| logger.info('Deleting ref "%s"', refspec) |
| |
| subprocess.check_call([ |
| 'git', |
| '-c', |
| 'credential.username={}'.format(self._token), |
| '-c', |
| 'core.askPass=true', |
| 'push', |
| 'origin', |
| '--delete', |
| full_ref |
| ]) |
| |
| def is_open(pull_request): |
| return not pull_request['closed_at'] |
| |
| def has_mirroring_label(pull_request): |
| for label in pull_request['labels']: |
| if label['name'] == LABEL: |
| return True |
| |
| return False |
| |
| def should_be_mirrored(project, pull_request): |
| return ( |
| is_open(pull_request) and ( |
| has_mirroring_label(pull_request) or ( |
| pull_request['user']['login'] not in AUTOMATION_GITHUB_USERS and |
| pull_request['author_association'] in TRUSTED_AUTHOR_ASSOCIATIONS |
| ) |
| ) and |
| # Query this last as it requires another API call to verify |
| not project.pull_request_is_from_fork(pull_request) |
| ) |
| |
| def is_deployed(host, deployment): |
| worktree_name = deployment['environment'][len(DEPLOYMENT_PREFIX):] |
| response = requests.get( |
| '{}/.git/worktrees/{}/HEAD'.format(host, worktree_name) |
| ) |
| |
| if response.status_code != 200: |
| return False |
| |
| return response.text.strip() == deployment['sha'] |
| |
| def synchronize(host, github_project, window): |
| '''Inspect all Pull Requests which have been modified in a given window of |
| time. Add or remove the "preview" label and update or delete the relevant |
| git refs according to the status of each Pull Request.''' |
| |
| project = Project(host, github_project) |
| remote = Remote(github_project) |
| |
| pull_requests = project.get_pull_requests( |
| time.gmtime(time.time() - window) |
| ) |
| |
| for pull_request in pull_requests: |
| logger.info('Processing Pull Request #%(number)d', pull_request) |
| |
| refspec_trusted = 'prs-trusted-for-preview/{number}'.format( |
| **pull_request |
| ) |
| refspec_open = 'prs-open/{number}'.format(**pull_request) |
| revision_latest = remote.get_revision( |
| 'pull/{number}/head'.format(**pull_request) |
| ) |
| revision_trusted = remote.get_revision(refspec_trusted) |
| revision_open = remote.get_revision(refspec_open) |
| |
| if should_be_mirrored(project, pull_request): |
| logger.info('Pull Request should be mirrored') |
| |
| if revision_trusted is None: |
| project.create_ref(refspec_trusted, revision_latest) |
| elif revision_trusted != revision_latest: |
| project.update_ref(refspec_trusted, revision_latest) |
| |
| if revision_open is None: |
| project.create_ref(refspec_open, revision_latest) |
| elif revision_open != revision_latest: |
| project.update_ref(refspec_open, revision_latest) |
| |
| if project.get_deployment(revision_latest) is None: |
| project.create_deployment( |
| pull_request, revision_latest |
| ) |
| else: |
| logger.info('Pull Request should not be mirrored') |
| |
| if not has_mirroring_label(pull_request) and revision_trusted is not None: |
| remote.delete_ref(refspec_trusted) |
| |
| if revision_open is not None and not is_open(pull_request): |
| remote.delete_ref(refspec_open) |
| |
| def detect(host, github_project, target, timeout): |
| '''Manage the status of a GitHub Deployment by polling the Pull Request |
| preview website until the Deployment is complete or a timeout is |
| reached.''' |
| |
| project = Project(host, github_project) |
| |
| with open(os.environ['GITHUB_EVENT_PATH']) as handle: |
| data = json.loads(handle.read()) |
| |
| logger.info('Event data: %s', json.dumps(data, indent=2)) |
| |
| deployment = data['deployment'] |
| |
| if not deployment['environment'].startswith(DEPLOYMENT_PREFIX): |
| logger.info( |
| 'Deployment environment "%s" is unrecognized. Exiting.', |
| deployment['environment'] |
| ) |
| return |
| |
| message = 'Waiting up to {} seconds for Deployment {} to be available on {}'.format( |
| timeout, deployment['environment'], target |
| ) |
| logger.info(message) |
| project.update_deployment(target, deployment, 'pending', message) |
| |
| start = time.time() |
| |
| while not is_deployed(target, deployment): |
| if time.time() - start > timeout: |
| message = 'Deployment did not become available after {} seconds'.format(timeout) |
| project.update_deployment(target, deployment, 'error', message) |
| raise Exception(message) |
| |
| time.sleep(POLLING_PERIOD) |
| |
| result = project.update_deployment(target, deployment, 'success') |
| logger.info(json.dumps(result, indent=2)) |
| |
| if __name__ == '__main__': |
| parser = argparse.ArgumentParser( |
| description='''Synchronize the state of a GitHub.com project with the |
| underlying git repository in order to support a externally-hosted |
| Pull Request preview system. Communicate the state of that system |
| via GitHub Deployments associated with each Pull Request.''' |
| ) |
| parser.add_argument( |
| '--host', required=True, help='the location of the GitHub API server' |
| ) |
| parser.add_argument( |
| '--github-project', |
| required=True, |
| help='''the GitHub organization and GitHub project name, separated by |
| a forward slash (e.g. "web-platform-tests/wpt")''' |
| ) |
| subparsers = parser.add_subparsers(title='subcommands') |
| |
| parser_sync = subparsers.add_parser( |
| 'synchronize', help=synchronize.__doc__ |
| ) |
| parser_sync.add_argument( |
| '--window', |
| type=int, |
| required=True, |
| help='''the number of seconds prior to the current moment within which |
| to search for GitHub Pull Requests. Any Pull Requests updated in |
| this time frame will be considered for synchronization.''' |
| ) |
| parser_sync.set_defaults(func=synchronize) |
| |
| parser_detect = subparsers.add_parser('detect', help=detect.__doc__) |
| parser_detect.add_argument( |
| '--target', |
| required=True, |
| help='''the URL of the website to which submission previews are |
| expected to become available''' |
| ) |
| parser_detect.add_argument( |
| '--timeout', |
| type=int, |
| required=True, |
| help='''the number of seconds to wait for a submission preview to |
| become available before reporting a GitHub Deployment failure''' |
| ) |
| parser_detect.set_defaults(func=detect) |
| |
| values = dict(vars(parser.parse_args())) |
| values.pop('func')(**values) |