blob: c6bc6aa617c7ea8489ed20b4c80ce1f226900d6a [file] [log] [blame]
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import base64
import json
import logging
import urllib2
from collections import namedtuple
from webkitpy.w3c.common import WPT_GH_ORG, WPT_GH_REPO_NAME
from webkitpy.common.memoized import memoized
_log = logging.getLogger(__name__)
API_BASE = 'https://api.github.com'
EXPORT_LABEL = 'chromium-export'
class WPTGitHub(object):
def __init__(self, host, user, token, pr_history_window=30):
self.host = host
self.user = user
self.token = token
assert self.user and self.token
self._pr_history_window = pr_history_window
def auth_token(self):
return base64.b64encode('{}:{}'.format(self.user, self.token))
def request(self, path, method, body=None):
assert path.startswith('/')
if body:
body = json.dumps(body)
response = self.host.web.request(
method=method,
url=API_BASE + path,
data=body,
headers={
'Accept': 'application/vnd.github.v3+json',
'Authorization': 'Basic {}'.format(self.auth_token()),
},
)
status_code = response.getcode()
try:
return json.load(response), status_code
except ValueError:
return None, status_code
def create_pr(self, remote_branch_name, desc_title, body):
"""Creates a PR on GitHub.
API doc: https://developer.github.com/v3/pulls/#create-a-pull-request
Returns:
A raw response object if successful, None if not.
"""
assert remote_branch_name
assert desc_title
assert body
path = '/repos/%s/%s/pulls' % (WPT_GH_ORG, WPT_GH_REPO_NAME)
body = {
'title': desc_title,
'body': body,
'head': remote_branch_name,
'base': 'master',
}
data, status_code = self.request(path, method='POST', body=body)
if status_code != 201:
return None
return data
def update_pr(self, pr_number, desc_title, body):
"""Updates a PR on GitHub.
API doc: https://developer.github.com/v3/pulls/#update-a-pull-request
Returns:
A raw response object if successful, None if not.
"""
path = '/repos/{}/{}/pulls/{}'.format(
WPT_GH_ORG,
WPT_GH_REPO_NAME,
pr_number
)
body = {
'title': desc_title,
'body': body,
}
data, status_code = self.request(path, method='PATCH', body=body)
if status_code != 201:
return None
return data
def add_label(self, number, label=EXPORT_LABEL):
path = '/repos/%s/%s/issues/%d/labels' % (
WPT_GH_ORG,
WPT_GH_REPO_NAME,
number
)
body = [label]
return self.request(path, method='POST', body=body)
def in_flight_pull_requests(self):
path = '/search/issues?q=repo:{}/{}%20is:open%20type:pr%20label:{}'.format(
WPT_GH_ORG,
WPT_GH_REPO_NAME,
EXPORT_LABEL
)
data, status_code = self.request(path, method='GET')
if status_code == 200:
return [self.make_pr_from_item(item) for item in data['items']]
else:
raise Exception('Non-200 status code (%s): %s' % (status_code, data))
def make_pr_from_item(self, item):
return PullRequest(
title=item['title'],
number=item['number'],
body=item['body'],
state=item['state'])
@memoized
def all_pull_requests(self):
# TODO(jeffcarp): Add pagination to fetch >99 PRs
assert self._pr_history_window <= 100, 'Maximum GitHub page size exceeded.'
path = (
'/search/issues'
'?q=repo:{}/{}%20type:pr%20label:{}'
'&page=1'
'&per_page={}'
).format(
WPT_GH_ORG,
WPT_GH_REPO_NAME,
EXPORT_LABEL,
self._pr_history_window
)
data, status_code = self.request(path, method='GET')
if status_code == 200:
return [self.make_pr_from_item(item) for item in data['items']]
else:
raise Exception('Non-200 status code (%s): %s' % (status_code, data))
def get_pr_branch(self, pr_number):
path = '/repos/{}/{}/pulls/{}'.format(
WPT_GH_ORG,
WPT_GH_REPO_NAME,
pr_number
)
data, status_code = self.request(path, method='GET')
if status_code == 200:
return data['head']['ref']
else:
raise Exception('Non-200 status code (%s): %s' % (status_code, data))
def merge_pull_request(self, pull_request_number):
path = '/repos/%s/%s/pulls/%d/merge' % (
WPT_GH_ORG,
WPT_GH_REPO_NAME,
pull_request_number
)
body = {
# This currently will noop because the feature is in an opt-in beta.
# Once it leaves beta this will start working.
'merge_method': 'rebase',
}
try:
data, status_code = self.request(path, method='PUT', body=body)
except urllib2.HTTPError as e:
if e.code == 405:
raise MergeError()
else:
raise
if status_code != 200:
raise Exception('Received non-200 status code (%d) while merging PR #%d' % (status_code, pull_request_number))
return data
def delete_remote_branch(self, remote_branch_name):
# TODO(jeffcarp): Unit test this method
path = '/repos/%s/%s/git/refs/heads/%s' % (
WPT_GH_ORG,
WPT_GH_REPO_NAME,
remote_branch_name
)
data, status_code = self.request(path, method='DELETE')
if status_code != 204:
# TODO(jeffcarp): Raise more specific exception (create MergeError class?)
raise Exception('Received non-204 status code attempting to delete remote branch: {}'.format(status_code))
return data
def pr_with_change_id(self, target_change_id):
for pull_request in self.all_pull_requests():
change_id = self.extract_metadata('Change-Id: ', pull_request.body)
if change_id == target_change_id:
return pull_request
return None
def pr_with_position(self, position):
for pull_request in self.all_pull_requests():
pr_commit_position = self.extract_metadata('Cr-Commit-Position: ', pull_request.body)
if position == pr_commit_position:
return pull_request
return None
def extract_metadata(self, tag, commit_body):
for line in commit_body.splitlines():
if line.startswith(tag):
return line[len(tag):]
return None
class MergeError(Exception):
"""An error specifically for when a PR cannot be merged.
This should only be thrown when GitHub returns status code 405,
indicating that the PR could not be merged.
"""
pass
PullRequest = namedtuple('PullRequest', ['title', 'number', 'body', 'state'])