1
# Copyright (C) 2018 Breezy Developers
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
"""Support for GitHub."""
19
from __future__ import absolute_import
23
from .propose import (
29
PrerequisiteBranchUnsupported,
30
CommitMessageUnsupported,
35
branch as _mod_branch,
40
version_string as breezy_version,
42
from ...config import AuthenticationConfig, GlobalStack, config_dir
43
from ...git.urls import git_url_to_bzr_url
44
from ...i18n import gettext
45
from ...sixish import PY3
46
from ...trace import note
47
from ...transport.http import default_user_agent
48
from ...lazy_import import lazy_import
49
lazy_import(globals(), """
50
from github import Github
54
def store_github_token(scheme, host, token):
55
with open(os.path.join(config_dir(), 'github.conf'), 'w') as f:
59
def retrieve_github_token(scheme, host):
60
path = os.path.join(config_dir(), 'github.conf')
61
if not os.path.exists(path):
63
with open(path, 'r') as f:
64
return f.read().strip()
67
def determine_title(description):
68
return description.splitlines()[0]
71
class NotGitHubUrl(errors.BzrError):
73
_fmt = "Not a GitHub URL: %(url)s"
75
def __init__(self, url):
76
errors.BzrError.__init__(self)
80
class GitHubLoginRequired(HosterLoginRequired):
82
_fmt = "Action requires GitHub login."
88
user_agent = default_user_agent()
89
auth = AuthenticationConfig()
91
credentials = auth.get_credentials('https', 'github.com')
92
if credentials is not None:
93
return Github(credentials['user'], credentials['password'],
94
user_agent=user_agent)
96
# TODO(jelmer): token = auth.get_token('https', 'github.com')
97
token = retrieve_github_token('https', 'github.com')
99
return Github(token, user_agent=user_agent)
101
note('Accessing GitHub anonymously. To log in, run \'brz gh-login\'.')
102
return Github(user_agent=user_agent)
105
class GitHubMergeProposal(MergeProposal):
107
def __init__(self, pr):
112
return self._pr.html_url
114
def _branch_from_part(self, part):
115
return github_url_to_bzr_url(part.repo.html_url, part.ref)
117
def get_source_branch_url(self):
118
return self._branch_from_part(self._pr.head)
120
def get_target_branch_url(self):
121
return self._branch_from_part(self._pr.base)
123
def get_description(self):
126
def get_commit_message(self):
129
def set_description(self, description):
130
self._pr.edit(body=description, title=determine_title(description))
133
return self._pr.merged
136
self._pr.edit(state='closed')
139
def parse_github_url(url):
140
(scheme, user, password, host, port, path) = urlutils.parse_url(
142
if host != 'github.com':
143
raise NotGitHubUrl(url)
144
(owner, repo_name) = path.strip('/').split('/')
145
if repo_name.endswith('.git'):
146
repo_name = repo_name[:-4]
147
return owner, repo_name
150
def parse_github_branch_url(branch):
151
url = urlutils.split_segment_parameters(branch.user_url)[0]
152
owner, repo_name = parse_github_url(url)
153
return owner, repo_name, branch.name
156
def github_url_to_bzr_url(url, branch_name):
158
branch_name = branch_name.encode('utf-8')
159
return urlutils.join_segment_parameters(
160
git_url_to_bzr_url(url), {"branch": branch_name})
163
def convert_github_error(fn):
164
def convert(self, *args, **kwargs):
167
return fn(self, *args, **kwargs)
168
except github.GithubException as e:
170
raise GitHubLoginRequired(self)
175
class GitHub(Hoster):
179
supports_merge_proposal_labels = True
180
supports_merge_proposal_commit_message = False
187
# TODO(jelmer): Can we get the default URL from the Python API package
189
return "https://github.com"
192
self.gh = connect_github()
194
@convert_github_error
195
def publish_derived(self, local_branch, base_branch, name, project=None,
196
owner=None, revision_id=None, overwrite=False,
199
base_owner, base_project, base_branch_name = parse_github_branch_url(base_branch)
200
base_repo = self.gh.get_repo('%s/%s' % (base_owner, base_project))
202
owner = self.gh.get_user().login
204
project = base_repo.name
206
remote_repo = self.gh.get_repo('%s/%s' % (owner, project))
208
except github.UnknownObjectException:
209
base_repo = self.gh.get_repo('%s/%s' % (base_owner, base_project))
210
if owner == self.gh.get_user().login:
211
owner_obj = self.gh.get_user()
213
owner_obj = self.gh.get_organization(owner)
214
remote_repo = owner_obj.create_fork(base_repo)
215
note(gettext('Forking new repository %s from %s') %
216
(remote_repo.html_url, base_repo.html_url))
218
note(gettext('Reusing existing repository %s') % remote_repo.html_url)
219
remote_dir = controldir.ControlDir.open(git_url_to_bzr_url(remote_repo.ssh_url))
221
push_result = remote_dir.push_branch(
222
local_branch, revision_id=revision_id, overwrite=overwrite,
224
except errors.NoRoundtrippingSupport:
227
push_result = remote_dir.push_branch(
228
local_branch, revision_id=revision_id,
229
overwrite=overwrite, name=name, lossy=True)
230
return push_result.target_branch, github_url_to_bzr_url(
231
remote_repo.html_url, name)
233
@convert_github_error
234
def get_push_url(self, branch):
235
owner, project, branch_name = parse_github_branch_url(branch)
236
repo = self.gh.get_repo('%s/%s' % (owner, project))
237
return github_url_to_bzr_url(repo.ssh_url, branch_name)
239
@convert_github_error
240
def get_derived_branch(self, base_branch, name, project=None, owner=None):
242
base_owner, base_project, base_branch_name = parse_github_branch_url(base_branch)
243
base_repo = self.gh.get_repo('%s/%s' % (base_owner, base_project))
245
owner = self.gh.get_user().login
247
project = base_repo.name
249
remote_repo = self.gh.get_repo('%s/%s' % (owner, project))
250
full_url = github_url_to_bzr_url(remote_repo.ssh_url, name)
251
return _mod_branch.Branch.open(full_url)
252
except github.UnknownObjectException:
253
raise errors.NotBranchError('https://github.com/%s/%s' % (owner, project))
255
@convert_github_error
256
def get_proposer(self, source_branch, target_branch):
257
return GitHubMergeProposalBuilder(self.gh, source_branch, target_branch)
259
@convert_github_error
260
def iter_proposals(self, source_branch, target_branch, status='open'):
261
(source_owner, source_repo_name, source_branch_name) = (
262
parse_github_branch_url(source_branch))
263
(target_owner, target_repo_name, target_branch_name) = (
264
parse_github_branch_url(target_branch))
265
target_repo = self.gh.get_repo(
266
"%s/%s" % (target_owner, target_repo_name))
272
for pull in target_repo.get_pulls(
273
head=target_branch_name,
274
state=state[status]):
275
if (status == 'closed' and pull.merged or
276
status == 'merged' and not pull.merged):
278
if pull.head.ref != source_branch_name:
280
if pull.head.repo is None:
281
# Repo has gone the way of the dodo
283
if (pull.head.repo.owner.login != source_owner or
284
pull.head.repo.name != source_repo_name):
286
yield GitHubMergeProposal(pull)
288
def hosts(self, branch):
290
parse_github_branch_url(branch)
297
def probe_from_url(cls, url, possible_transports=None):
299
parse_github_url(url)
301
raise UnsupportedHoster(url)
305
def iter_instances(cls):
308
@convert_github_error
309
def iter_my_proposals(self, status='open'):
312
query.append('is:open')
313
elif status == 'closed':
314
query.append('is:unmerged')
315
# Also use "is:closed" otherwise unmerged open pull requests are
317
query.append('is:closed')
318
elif status == 'merged':
319
query.append('is:merged')
320
query.append('author:%s' % self.gh.get_user().login)
321
for issue in self.gh.search_issues(query=' '.join(query)):
322
yield GitHubMergeProposal(issue.as_pull_request())
325
class GitHubMergeProposalBuilder(MergeProposalBuilder):
327
def __init__(self, gh, source_branch, target_branch):
329
self.source_branch = source_branch
330
self.target_branch = target_branch
331
(self.target_owner, self.target_repo_name, self.target_branch_name) = (
332
parse_github_branch_url(self.target_branch))
333
(self.source_owner, self.source_repo_name, self.source_branch_name) = (
334
parse_github_branch_url(self.source_branch))
336
def get_infotext(self):
337
"""Determine the initial comment for the merge proposal."""
339
info.append("Merge %s into %s:%s\n" % (
340
self.source_branch_name, self.target_owner,
341
self.target_branch_name))
342
info.append("Source: %s\n" % self.source_branch.user_url)
343
info.append("Target: %s\n" % self.target_branch.user_url)
346
def get_initial_body(self):
347
"""Get a body for the proposal for the user to modify.
349
:return: a str or None.
353
def create_proposal(self, description, reviewers=None, labels=None,
354
prerequisite_branch=None, commit_message=None):
355
"""Perform the submission."""
356
if prerequisite_branch is not None:
357
raise PrerequisiteBranchUnsupported(self)
358
# Note that commit_message is ignored, since github doesn't support it.
360
# TODO(jelmer): Probe for right repo name
361
if self.target_repo_name.endswith('.git'):
362
self.target_repo_name = self.target_repo_name[:-4]
363
target_repo = self.gh.get_repo("%s/%s" % (self.target_owner, self.target_repo_name))
364
# TODO(jelmer): Allow setting title explicitly?
365
title = determine_title(description)
366
# TOOD(jelmer): Set maintainers_can_modify?
368
pull_request = target_repo.create_pull(
369
title=title, body=description,
370
head="%s:%s" % (self.source_owner, self.source_branch_name),
371
base=self.target_branch_name)
372
except github.GithubException as e:
374
raise MergeProposalExists(self.source_branch.user_url)
377
for reviewer in reviewers:
378
pull_request.assignees.append(
379
self.gh.get_user(reviewer))
382
pull_request.issue.labels.append(label)
383
return GitHubMergeProposal(pull_request)