diff --git a/Default.sublime-commands b/Default.sublime-commands index 34c8090c9..cceb293c0 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -136,6 +136,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" diff --git a/bitbucket/__init__.py b/bitbucket/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bitbucket/bitbucket.py b/bitbucket/bitbucket.py new file mode 100644 index 000000000..71e331fbb --- /dev/null +++ b/bitbucket/bitbucket.py @@ -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)) diff --git a/bitbucket/commands/__init__.py b/bitbucket/commands/__init__.py new file mode 100644 index 000000000..889677e9f --- /dev/null +++ b/bitbucket/commands/__init__.py @@ -0,0 +1 @@ +from .open_on_remote import * diff --git a/bitbucket/commands/open_on_remote.py b/bitbucket/commands/open_on_remote.py new file mode 100644 index 000000000..1f6af5521 --- /dev/null +++ b/bitbucket/commands/open_on_remote.py @@ -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()) diff --git a/bitbucket/git_mixins/__init__.py b/bitbucket/git_mixins/__init__.py new file mode 100644 index 000000000..52f25ca6c --- /dev/null +++ b/bitbucket/git_mixins/__init__.py @@ -0,0 +1 @@ +from .remotes import * diff --git a/bitbucket/git_mixins/remotes.py b/bitbucket/git_mixins/remotes.py new file mode 100644 index 000000000..603ca2135 --- /dev/null +++ b/bitbucket/git_mixins/remotes.py @@ -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 diff --git a/core/exceptions.py b/core/exceptions.py index 69c6d56c9..1cc954a2b 100644 --- a/core/exceptions.py +++ b/core/exceptions.py @@ -19,3 +19,7 @@ class FailedGithubRequest(GitSavvyError): class FailedGitLabRequest(GitSavvyError): pass + + +class FailedBitbucketRequest(GitSavvyError): + pass diff --git a/git_savvy.py b/git_savvy.py index ad81f4d95..d6bd8920d 100644 --- a/git_savvy.py +++ b/git_savvy.py @@ -39,3 +39,4 @@ def reload_codecs(): from .core.interfaces import * from .github.commands import * from .gitlab.commands import * + from .bitbucket.commands import * diff --git a/github/github.py b/github/github.py index c060d1ba5..b333bbf25 100644 --- a/github/github.py +++ b/github/github.py @@ -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"): diff --git a/gitlab/gitlab.py b/gitlab/gitlab.py index cc7d384a0..c28063ea1 100644 --- a/gitlab/gitlab.py +++ b/gitlab/gitlab.py @@ -30,14 +30,12 @@ def remote_to_url(remote): """ Parse out a GitLab HTTP URL from a remote URI: - r1 = remote_to_url("git://gitlab.com/asfaltboy/GitSavvy.git") - assert r1 == "https://gitlab.com/asfaltboy/GitSavvy.git" - - r2 = remote_to_url("git@gitlab.com:asfaltboy/GitSavvy.git") - assert r2 == "https://gitlab.com/asfaltboy/GitSavvy.git" - - r3 = remote_to_url("https://gitlab.com/asfaltboy/GitSavvy.git") - assert r3 == "https://gitlab.com/asfaltboy/GitSavvy.git" + >>> remote_to_url("git://gitlab.com/asfaltboy/GitSavvy.git") + 'https://gitlab.com/asfaltboy/GitSavvy' + >>> remote_to_url("git@gitlab.com:asfaltboy/GitSavvy.git") + 'https://gitlab.com/asfaltboy/GitSavvy' + >>> remote_to_url("https://gitlab.com/asfaltboy/GitSavvy.git") + 'https://gitlab.com/asfaltboy/GitSavvy' """ if remote.endswith(".git"): @@ -83,14 +81,14 @@ def open_file_in_browser(rel_path, remote, commit_hash, start_line=None, end_lin """ Open the URL corresponding to the provided `rel_path` on `remote`. """ - github_repo = parse_remote(remote) - if not github_repo: + gitlab_repo = parse_remote(remote) + if not gitlab_repo: return None line_numbers = "#L{}-{}".format(start_line, end_line) if start_line is not None else "" url = "{repo_url}/blob/{commit_hash}/{path}{lines}".format( - repo_url=github_repo.url, + repo_url=gitlab_repo.url, commit_hash=commit_hash, path=rel_path, lines=line_numbers @@ -101,22 +99,22 @@ def open_file_in_browser(rel_path, remote, commit_hash, start_line=None, end_lin def open_repo(remote): """ - Open the GitHub repo in a new browser window, given the specified remote. + Open the GitLab repo in a new browser window, given the specified remote. """ - github_repo = parse_remote(remote) - if not github_repo: + gitlab_repo = parse_remote(remote) + if not gitlab_repo: return None - open_in_browser(github_repo.url) + open_in_browser(gitlab_repo.url) def open_issues(remote): """ - Open the GitHub issues in a new browser window, given the specified remote. + Open the GitLab issues in a new browser window, given the specified remote. """ - github_repo = parse_remote(remote) - if not github_repo: + gitlab_repo = parse_remote(remote) + if not gitlab_repo: return None - open_in_browser("{}/issues".format(github_repo.url)) + open_in_browser("{}/issues".format(gitlab_repo.url)) def get_api_fqdn(gitlab_repo): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py new file mode 100644 index 000000000..9dd745226 --- /dev/null +++ b/tests/test_docstrings.py @@ -0,0 +1,24 @@ +import doctest +import pkgutil + +import GitSavvy + + +def load_tests(loader, tests, ignore): + package_iterator = pkgutil.walk_packages(GitSavvy.__path__, 'GitSavvy.') + + for pkg_loader, module_name, is_pkg in package_iterator: + + if module_name.startswith('GitSavvy.tests'): + continue + + module = pkg_loader.find_module(module_name).load_module(module_name) + + try: + module_tests = doctest.DocTestSuite(module) + except ValueError: + continue + + tests.addTests(module_tests) + + return tests