Skip to content

Commit

Permalink
Merge 55bf39f into 690e501
Browse files Browse the repository at this point in the history
  • Loading branch information
asfaltboy authored Sep 21, 2019
2 parents 690e501 + 55bf39f commit 915059e
Show file tree
Hide file tree
Showing 13 changed files with 387 additions and 25 deletions.
13 changes: 13 additions & 0 deletions Default.sublime-commands
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@
"caption": "gitlab: review merge request",
"command": "gs_gitlab_merge_request"
},
{
"caption": "bitbucket: open file on remote",
"command": "gs_bitbucket_open_file_on_remote",
"args": { "preselect": true }
},
{
"caption": "bitbucket: open issues",
"command": "gs_bitbucket_open_issues"
},
{
"caption": "bitbucket: open repo",
"command": "gs_bitbucket_open_repo"
},
{
"caption": "git: remote add",
"command": "gs_remote_add"
Expand Down
Empty file added bitbucket/__init__.py
Empty file.
119 changes: 119 additions & 0 deletions bitbucket/bitbucket.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""
Bitbucket methods that are functionally separate from anything Sublime-related.
"""

import re
from collections import namedtuple
from functools import partial, lru_cache
from webbrowser import open as open_in_browser

from ..common import interwebs, util
from ..core.exceptions import FailedBitbucketRequest
from ..core.settings import GitSavvySettings


BITBUCKET_PER_PAGE_MAX = 100
BITBUCKET_ERROR_TEMPLATE = "Error {action} Bitbucket: {payload}"
AUTH_ERROR_TEMPLATE = """Error {action} Bitbucket, access was denied!
Please ensure you have created a Bitbucket API token and added it to
your settings, as described in the documentation:
https://github.com/divmain/GitSavvy/blob/master/docs/bitbucket.md#setup
"""

BitbucketRepo = namedtuple("BitbucketRepo", ("url", "fqdn", "owner", "repo", "token"))


@lru_cache()
def remote_to_url(remote):
"""
Parse out a Bitbucket HTTP URL from a remote URI:
>>> remote_to_url("git://bitbucket.org/pasha_savchenko/GitSavvy.git")
'https://bitbucket.org/pasha_savchenko/GitSavvy'
>>> remote_to_url("git@bitbucket.org:pasha_savchenko/gitsavvy.git")
'https://bitbucket.org/pasha_savchenko/gitsavvy'
>>> remote_to_url("https://pasha_savchenko@bitbucket.org/pasha_savchenko/GitSavvy.git")
'https://pasha_savchenko@bitbucket.org/pasha_savchenko/GitSavvy'
"""

if remote.endswith(".git"):
remote = remote[:-4]

if remote.startswith("git@"):
return remote.replace(":", "/").replace("git@", "https://")
elif remote.startswith("git://"):
return remote.replace("git://", "https://")
elif remote.startswith("http"):
return remote
else:
util.debug.log_error('Cannot parse remote "%s" to url' % remote)
return None


@lru_cache()
def parse_remote(remote):
"""
Given a line of output from `git remote -v`, parse the string and return
an object with original url, FQDN, owner, repo, and the token to use for
this particular FQDN (if available).
"""
url = remote_to_url(remote)
if not url:
return None

match = re.match(r"https?://([a-zA-Z-\.0-9]+)/([a-zA-Z-\._0-9]+)/([a-zA-Z-\._0-9]+)/?", url)

if not match:
util.log_error('Invalid Bitbucket url: %s' % url)
return None

fqdn, owner, repo = match.groups()

api_tokens = GitSavvySettings().get("api_tokens")
token = api_tokens and api_tokens.get(fqdn, None) or None

return BitbucketRepo(url, fqdn, owner, repo, token)


def open_file_in_browser(rel_path, remote, commit_hash, start_line=None, end_line=None):
"""
Open the URL corresponding to the provided `rel_path` on `remote`.
"""
bitbucket_repo = parse_remote(remote)
if not bitbucket_repo:
return None

line_numbers = "#lines-{}:{}".format(start_line, end_line) if start_line is not None else ""

url = "{repo_url}/src/{commit_hash}/{path}{lines}".format(
repo_url=bitbucket_repo.url,
commit_hash=commit_hash,
path=rel_path,
lines=line_numbers
)

open_in_browser(url)


def open_repo(remote):
"""
Open the GitHub repo in a new browser window, given the specified remote.
"""
bitbucket_repo = parse_remote(remote)
if not bitbucket_repo:
return None
open_in_browser(bitbucket_repo.url)


def open_issues(remote):
"""
Open the GitHub issues in a new browser window, given the specified remote.
"""
bitbucket_repo = parse_remote(remote)
if not bitbucket_repo:
return None
open_in_browser("{}/issues".format(bitbucket_repo.url))
1 change: 1 addition & 0 deletions bitbucket/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .open_on_remote import *
140 changes: 140 additions & 0 deletions bitbucket/commands/open_on_remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import sublime
from sublime_plugin import TextCommand

from ...core.git_command import GitCommand
from ..bitbucket import open_file_in_browser # , open_repo, open_issues
from ..bitbucket import open_repo
from ..bitbucket import open_issues

from .. import git_mixins
from ...core.ui_mixins.quick_panel import show_remote_panel


EARLIER_COMMIT_PROMPT = ("The remote chosen may not contain the commit. "
"Open the file {} before?")


class GsBitbucketOpenFileOnRemoteCommand(TextCommand, GitCommand, git_mixins.BitbucketRemotesMixin):

"""
Open a new browser window to the web-version of the currently opened
(or specified) file. If `preselect` is `True`, include the selected
lines in the request. If the active tracked remote is the same as the
integrated remote, open browser directly, if not, display a list to remotes
to choose from.
At present, this only supports bitbucket.org and Bitbucket enterprise.
"""

def run(self, edit, remote=None, preselect=False, fpath=None):
sublime.set_timeout_async(
lambda: self.run_async(remote, preselect, fpath))

def run_async(self, remote, preselect, fpath):
self.fpath = fpath or self.get_rel_path()
self.preselect = preselect

self.remotes = self.get_remotes()

if not remote:
remote = self.guess_bitbucket_remote()

if remote:
self.open_file_on_remote(remote)
else:
show_remote_panel(self.open_file_on_remote)

def open_file_on_remote(self, remote):
if not remote:
return

fpath = self.fpath
if isinstance(fpath, str):
fpath = [fpath]
remote_url = self.remotes[remote]

if self.view.settings().get("git_savvy.show_file_at_commit_view"):
# if it is a show_file_at_commit_view, get the hash from settings
commit_hash = self.view.settings().get("git_savvy.show_file_at_commit_view.commit")
else:
commit_hash = self.get_commit_hash_for_head()

base_hash = commit_hash

# check if the remote contains the commit hash
if remote not in self.remotes_containing_commit(commit_hash):
upstream = self.get_upstream_for_active_branch()
if upstream:
merge_base = self.git("merge-base", commit_hash, upstream).strip()
if merge_base and remote in self.remotes_containing_commit(merge_base):
count = self.git(
"rev-list", "--count", "{}..{}".format(merge_base, commit_hash)).strip()
if not sublime.ok_cancel_dialog(EARLIER_COMMIT_PROMPT.format(
count + (" commit" if count == "1" else " commits"))):
return

commit_hash = merge_base

start_line = None
end_line = None

if self.preselect and len(fpath) == 1:
selections = self.view.sel()
if len(selections) >= 1:
first_selection = selections[0]
last_selection = selections[-1]
# Git lines are 1-indexed; Sublime rows are 0-indexed.
start_line = self.view.rowcol(first_selection.begin())[0] + 1
end_line = self.view.rowcol(last_selection.end())[0] + 1

# forward line number if the opening commit is the merge base
if base_hash != commit_hash:
start_line = self.find_matching_lineno(
base_hash, commit_hash, line=start_line, file_path=fpath[0])
end_line = self.find_matching_lineno(
base_hash, commit_hash, line=end_line, file_path=fpath[0])

for p in fpath:
open_file_in_browser(
p,
remote_url,
commit_hash,
start_line=start_line,
end_line=end_line
)


class GsBitbucketOpenRepoCommand(TextCommand, GitCommand, git_mixins.BitbucketRemotesMixin):

"""
Open a new browser window to the Bitbucket remote repository.
"""

def run(self, edit, remote=None):
sublime.set_timeout_async(lambda: self.run_async(remote))

def run_async(self, remote):
self.remotes = self.get_remotes()

if not remote:
remote = self.guess_bitbucket_remote()

if remote:
open_repo(self.remotes[remote])
else:
show_remote_panel(self.on_remote_selection)

def on_remote_selection(self, remote):
if not remote:
return
open_repo(self.remotes[remote])


class GsBitbucketOpenIssuesCommand(TextCommand, GitCommand, git_mixins.BitbucketRemotesMixin):

"""
Open a new browser window to the Bitbucket remote repository's issues page.
"""

def run(self, edit):
open_issues(self.get_integrated_remote_url())
1 change: 1 addition & 0 deletions bitbucket/git_mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .remotes import *
61 changes: 61 additions & 0 deletions bitbucket/git_mixins/remotes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class BitbucketRemotesMixin():

def get_integrated_branch_name(self):
configured_branch_name = self.git(
"config",
"--local",
"--get",
"GitSavvy.bbBranch",
throw_on_stderr=False
).strip()
if configured_branch_name:
return configured_branch_name
else:
return "master"

def get_integrated_remote_name(self):
configured_remote_name = self.git(
"config",
"--local",
"--get",
"GitSavvy.bbRemote",
throw_on_stderr=False
).strip()
remotes = self.get_remotes()

if len(remotes) == 0:
raise ValueError("Bitbucket integration will not function when no remotes defined.")

if configured_remote_name and configured_remote_name in remotes:
return configured_remote_name
elif len(remotes) == 1:
return list(remotes.keys())[0]
elif "origin" in remotes:
return "origin"
elif self.get_upstream_for_active_branch():
# fall back to the current active remote
return self.get_upstream_for_active_branch().split("/")[0]
else:
raise ValueError("Cannot determine Bitbucket integrated remote.")

def get_integrated_remote_url(self):
configured_remote_name = self.get_integrated_remote_name()
remotes = self.get_remotes()
return remotes[configured_remote_name]

def guess_bitbucket_remote(self):
upstream = self.get_upstream_for_active_branch()
integrated_remote = self.get_integrated_remote_name()
remotes = self.get_remotes()

if len(self.remotes) == 1:
return list(remotes.keys())[0]
elif upstream:
tracked_remote = upstream.split("/")[0] if upstream else None

if tracked_remote and tracked_remote == integrated_remote:
return tracked_remote
else:
return None
else:
return integrated_remote
4 changes: 4 additions & 0 deletions core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ class FailedGithubRequest(GitSavvyError):

class FailedGitLabRequest(GitSavvyError):
pass


class FailedBitbucketRequest(GitSavvyError):
pass
1 change: 1 addition & 0 deletions git_savvy.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ def reload_codecs():
from .core.interfaces import *
from .github.commands import *
from .gitlab.commands import *
from .bitbucket.commands import *
12 changes: 6 additions & 6 deletions github/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ def remote_to_url(remote):
"""
Parse out a Github HTTP URL from a remote URI:
r1 = remote_to_url("git://github.com/divmain/GitSavvy.git")
assert r1 == "https://github.com/divmain/GitSavvy.git"
>>> r1 = remote_to_url("git://github.com/divmain/GitSavvy.git")
>>> assert r1 == "https://github.com/divmain/GitSavvy"
r2 = remote_to_url("git@github.com:divmain/GitSavvy.git")
assert r2 == "https://github.com/divmain/GitSavvy.git"
>>> r2 = remote_to_url("git@github.com:divmain/GitSavvy.git")
>>> assert r2 == "https://github.com/divmain/GitSavvy"
r3 = remote_to_url("https://github.com/divmain/GitSavvy.git")
assert r3 == "https://github.com/divmain/GitSavvy.git"
>>> r3 = remote_to_url("https://github.com/divmain/GitSavvy.git")
>>> assert r3 == "https://github.com/divmain/GitSavvy"
"""

if remote.endswith(".git"):
Expand Down
Loading

0 comments on commit 915059e

Please sign in to comment.