Skip to content

Commit

Permalink
Issue #479: git machete github * should work with custom GitHub URL (
Browse files Browse the repository at this point in the history
  • Loading branch information
amalota authored May 18, 2022
1 parent 9f95ea7 commit 91fc20b
Show file tree
Hide file tree
Showing 12 changed files with 147 additions and 73 deletions.
3 changes: 2 additions & 1 deletion RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## New in git-machete 3.10.0

- added: boolean git config key `machete.status.extraSpaceBeforeBranchName` that enables configurable rendering of `status` command
- added: boolean git config key `machete.status.extraSpaceBeforeBranchName` that enable configurable rendering of `status` command
- added: 3 git config keys `machete.github.{remote,organization,repository}` that enable `git machete github *` subcommands to work with custom GitHub URLs

## New in git-machete 3.9.1

Expand Down
1 change: 1 addition & 0 deletions docs/source/cli_help/anno.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ If invoked with ``-H`` or ``--sync-github-prs``, annotates the branches based on
Any existing annotations are overwritten for the branches that have an opened PR; annotations for the other branches remain untouched.

.. include:: github_api_access.rst
.. include:: github_config_keys.rst

In any other case, sets the annotation for the given/current branch to the given <annotation text>.
If multiple <annotation text>'s are passed to the command, they are concatenated with a single space.
Expand Down
1 change: 1 addition & 0 deletions docs/source/cli_help/clean.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ No branch will be deleted unless explicitly confirmed by the user (or unless ``-
Equivalent of ``git machete github sync`` if invoked with ``-H`` or ``--checkout-my-github-prs``.

.. include:: github_api_access.rst
.. include:: github_config_keys.rst

**Options:**

Expand Down
2 changes: 2 additions & 0 deletions docs/source/cli_help/github.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ where ``<subcommand>`` is one of: ``anno-prs``, ``checkout-prs``, ``create-pr``,
Creates, checks out and manages GitHub PRs while keeping them reflected in branch definition file.

.. include:: github_api_access.rst
.. include:: github_config_keys.rst

``anno-prs``:

Annotates the branches based on their corresponding GitHub PR numbers and authors.
Any existing annotations are overwritten for the branches that have an opened PR; annotations for the other branches remain untouched.
Equivalent to ``git machete anno --sync-github-prs``.


``checkout-prs [--all | --by=<github-login> | --mine | <PR-number-1> ... <PR-number-N>]``:

Check out the head branch of the given pull requests (specified by numbers or by a flag),
Expand Down
3 changes: 2 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
'completion.rst',
'description.rst',
'learning_materials.rst',
'github_api_access.rst'
'github_api_access.rst',
'github_config_keys.rst'
]


Expand Down
23 changes: 23 additions & 0 deletions docs/source/github_config_keys.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.. _github_config_keys:

.. note::

GitHub API server URL will be inferred from ``git remote``.
You can override this by setting the following git config keys:
Remote name
E.g. ``machete.github.remote`` = ``origin``

Organization name
E.g. ``machete.github.organization`` = ``VirtusLab``

Repository name
E.g. ``machete.github.repository`` = ``git-machete``

To do this, run ``git config --local --edit`` and add the following section:

.. code-block:: ini
[machete "github"]
organization = <organization_name>
repository = <repo_name>
remote = <remote_name>
72 changes: 30 additions & 42 deletions git_machete/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
add_assignees_to_pull_request, add_reviewers_to_pull_request,
create_pull_request, checkout_pr_refs, derive_pull_request_by_head, derive_pull_requests,
get_github_token_possible_providers, get_parsed_github_remote_url, get_pull_request_by_number_or_none, GitHubPullRequest,
is_github_remote_url, set_base_of_pull_request, set_milestone_of_pull_request)
is_github_remote_url, RemoteAndOrganizationAndRepository, set_base_of_pull_request, set_milestone_of_pull_request)
from git_machete.utils import (
get_pretty_choices, flat_map, excluding, fmt, tupled, warn, debug, bold,
colored, underline, dim, get_second, AnsiEscapeCodes)
Expand Down Expand Up @@ -1386,33 +1386,7 @@ def is_excluded_reflog_subject(hash_: str, gs_: str) -> bool:
return result

def sync_annotations_to_github_prs(self) -> None:

url_for_remote: Dict[str, str] = {remote: self.__git.get_url_of_remote(remote) for remote in
self.__git.get_remotes()}
if not url_for_remote:
raise MacheteException(fmt('No remotes defined for this repository (see `git remote`)'))

optional_org_name_for_github_remote: Dict[str, Optional[Tuple[str, str]]] = {
remote: get_parsed_github_remote_url(url) for remote, url in url_for_remote.items()}
org_name_for_github_remote: Dict[str, Tuple[str, str]] = {remote: org_name for remote, org_name in
optional_org_name_for_github_remote.items() if
org_name}
if not org_name_for_github_remote:
raise MacheteException(
fmt('Remotes are defined for this repository, but none of them '
'corresponds to GitHub (see `git remote -v` for details)'))

org: str = ''
repo: str = ''
if len(org_name_for_github_remote) == 1:
org, repo = list(org_name_for_github_remote.values())[0]
elif len(org_name_for_github_remote) > 1:
if 'origin' in org_name_for_github_remote:
org, repo = org_name_for_github_remote['origin']
else:
raise MacheteException(
f'Multiple non-origin remotes correspond to GitHub in this repository: '
f'{", ".join(org_name_for_github_remote.keys())}, aborting')
remote, org, repo = self.__derive_remote_and_github_org_and_repo()
current_user: Optional[str] = git_machete.github.derive_current_user_login()
debug('Current GitHub user is ' + (current_user or '<none>'))
pr: GitHubPullRequest
Expand Down Expand Up @@ -1880,7 +1854,7 @@ def checkout_github_prs(self,
org: str
repo: str
remote: str
remote, (org, repo) = self.__derive_remote_and_github_org_and_repo()
remote, org, repo = self.__derive_remote_and_github_org_and_repo()
current_user: Optional[str] = git_machete.github.derive_current_user_login()
if not current_user and my_opened_prs:
msg = ("Could not determine current user name, please check that the GitHub API token provided by one of the: "
Expand Down Expand Up @@ -2030,14 +2004,14 @@ def __get_added_remote_name_or_none(self, remote_url: str) -> Optional[str]:
for remote, url in url_for_remote.items():
url = url if url.endswith('.git') else url + '.git'
remote_url = remote_url if remote_url.endswith('.git') else remote_url + '.git'
if is_github_remote_url(url) and get_parsed_github_remote_url(url) == get_parsed_github_remote_url(remote_url):
if is_github_remote_url(url) and get_parsed_github_remote_url(url, remote) == get_parsed_github_remote_url(remote_url, remote):
return remote
return None

def retarget_github_pr(self, head: LocalBranchShortName) -> None:
org: str
repo: str
_, (org, repo) = self.__derive_remote_and_github_org_and_repo()
_, org, repo = self.__derive_remote_and_github_org_and_repo()

debug(f'organization is {org}, repository is {repo}')

Expand All @@ -2060,30 +2034,44 @@ def retarget_github_pr(self, head: LocalBranchShortName) -> None:
else:
print(fmt(f'The base branch of PR #{pr.number} is already `{new_base}`'))

def __derive_remote_and_github_org_and_repo(self) -> Tuple[str, Tuple[str, str]]:
def __derive_remote_and_github_org_and_repo(self) -> RemoteAndOrganizationAndRepository:
remote_and_organization_and_repository_for_custom_url = self.__get_remote_and_organization_and_repository_name_for_custom_url()
if all(remote_and_organization_and_repository_for_custom_url):
return remote_and_organization_and_repository_for_custom_url

url_for_remote: Dict[str, str] = {
remote: self.__git.get_url_of_remote(remote) for remote in self.__git.get_remotes()
}
if not url_for_remote:
raise MacheteException(fmt('No remotes defined for this repository (see `git remote`)'))

org_and_repo_for_github_remote: Dict[str, Tuple[str, str]] = {
remote: get_parsed_github_remote_url(url) for remote, url in url_for_remote.items() if is_github_remote_url(url)
remote_and_github_org_and_repo: Dict[str, RemoteAndOrganizationAndRepository] = {
remote: get_parsed_github_remote_url(url, remote) for remote, url in url_for_remote.items() if is_github_remote_url(url)
}
if not org_and_repo_for_github_remote:
if not remote_and_github_org_and_repo:
raise MacheteException(
fmt('Remotes are defined for this repository, but none of them '
'corresponds to GitHub (see `git remote -v` for details)\n'))
'corresponds to GitHub (see `git remote -v` for details). \n'
'It is possible that you are using custom GitHub URL.\n'
'If that is the case, you can provide repository information explicitly, via these 3 git config keys: '
'`machete.github.{remote,organization,repository}`\n'))

if len(org_and_repo_for_github_remote) == 1:
return list(org_and_repo_for_github_remote.items())[0]
if len(remote_and_github_org_and_repo) == 1:
return remote_and_github_org_and_repo[list(remote_and_github_org_and_repo.keys())[0]]

if 'origin' in org_and_repo_for_github_remote:
return 'origin', org_and_repo_for_github_remote['origin']
if 'origin' in remote_and_github_org_and_repo:
return remote_and_github_org_and_repo['origin']

raise MacheteException(
f'Multiple non-origin remotes correspond to GitHub in this repository: '
f'{", ".join(org_and_repo_for_github_remote.keys())}, aborting')
f'{", ".join(remote_and_github_org_and_repo.keys())}, aborting. \n'
f'You can also select the repository by providing 3 git config keys: '
'`machete.github.{remote,organization,repository}`\n')

def __get_remote_and_organization_and_repository_name_for_custom_url(self) -> RemoteAndOrganizationAndRepository:
return RemoteAndOrganizationAndRepository(remote=self.__git.get_config_attr_or_none("machete.github.remote"),
organization=self.__git.get_config_attr_or_none("machete.github.organization"),
repository=self.__git.get_config_attr_or_none("machete.github.repository"))

def create_github_pr(
self, *, head: LocalBranchShortName, opt_draft: bool, opt_onto: Optional[LocalBranchShortName]) -> None:
Expand All @@ -2096,7 +2084,7 @@ def create_github_pr(
raise MacheteException(f'Could not determine base branch for PR. Branch `{head}` is a root branch.')
org: str
repo: str
remote, (org, repo) = self.__derive_remote_and_github_org_and_repo()
remote, org, repo = self.__derive_remote_and_github_org_and_repo()
print(f"Fetching {remote}...")
self.__git.fetch_remote(remote)
if '/'.join([remote, base]) not in self.__git.get_remote_branches():
Expand Down
60 changes: 37 additions & 23 deletions git_machete/docs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import textwrap
from typing import Dict
from git_machete.constants import DISCOVER_DEFAULT_FRESH_BRANCH_COUNT

Expand Down Expand Up @@ -30,6 +31,29 @@
"version": "Display the version and exit"
}

github_api_access = '''To allow GitHub API access for private repositories (and also to perform side-effecting actions like opening a PR,
even in case of public repositories), a GitHub API token with `repo` scope is required, see https://github.com/settings/tokens.
This will be resolved from the first of:
1. `GITHUB_TOKEN` env var,
2. content of the `.github-token` file in the home directory (`~`),
3. current auth token from the `gh` GitHub CLI,
4. current auth token from the `hub` GitHub CLI.'''

github_config_keys = '''GitHub API server URL will be inferred from `git remote`.
You can override this by setting the following git config keys:
Remote name
E.g. `machete.github.remote` = `origin`
Organization name
E.g. `machete.github.organization` = `VirtusLab`
Repository name
E.g. `machete.github.repository` = `git-machete`
To do this, run `git config --local --edit` and add the following section:
[machete "github"]
organization = <organization_name>
repository = <repo_name>
remote = <remote_name>'''

long_docs: Dict[str, str] = {
"add": """
<b>Usage: git machete add [-o|--onto=<target-upstream-branch>] [-R|--as-root] [-y|--yes] [<branch>]</b>
Expand Down Expand Up @@ -112,7 +136,7 @@
<b>-y, --yes</b> Don't ask for confirmation whether to fast-forward the current branch or whether to slide-out the downstream.
Fails if the current branch has more than one green-edge downstream branch.
""",
"anno": """
"anno": f"""
<b>Usage:
git machete anno [-b|--branch=<branch>] [<annotation text>]
git machete anno -H|--sync-github-prs</b>
Expand All @@ -126,12 +150,9 @@
If invoked with `-H` or `--sync-github-prs`, annotates the branches based on their corresponding GitHub PR numbers and authors.
Any existing annotations are overwritten for the branches that have an opened PR; annotations for the other branches remain untouched.
To allow GitHub API access for private repositories (and also to perform side-effecting actions like opening a PR, even in case of public repositories),
a GitHub API token with `repo` scope is required, see `https://github.com/settings/tokens`. This will be resolved from the first of:
1. `GITHUB_TOKEN` env var,
2. content of the `.github-token` file in the home directory (`~`),
3. current auth token from the `gh` GitHub CLI,
4. current auth token from the `hub` GitHub CLI.
{textwrap.indent(github_api_access, " ")}
{textwrap.indent(github_config_keys, " ")}
In any other case, sets the annotation for the given/current branch to the given argument.
If multiple arguments are passed to the command, they are concatenated with a single space.
Expand All @@ -142,9 +163,8 @@
<b>-b, --branch=<branch></b> Branch to set the annotation for.
<b>-H, --sync-github-prs</b> Annotate with GitHub PR numbers and authors where applicable.
""",
"clean": """
<b>Usage:
git machete clean [-c|--checkout-my-github-prs] [-y|--yes]
"clean": f"""
<b>Usage: git machete clean [-c|--checkout-my-github-prs] [-y|--yes]</b>
Synchronizes with the remote repository:
1. if invoked with `-H` or `--checkout-my-github-prs`, checks out open PRs for the current user associated with the GitHub token and also traverses the chain of pull requests upwards, adding branches one by one to git-machete and checks them out locally as well,
Expand All @@ -154,13 +174,9 @@
No branch will be deleted unless explicitly confirmed by the user (or unless `-y/--yes` option is passed).
Equivalent of `git machete github sync` if invoked with `-H` or `--checkout-my-github-prs`.
To allow GitHub API access for private repositories (and also to perform side-effecting actions like opening a PR, even in case of public repositories),
a GitHub API token with `repo` scope is required, see https://github.com/settings/tokens. This will be resolved from the first of:
{textwrap.indent(github_api_access, " ")}
1. `GITHUB_TOKEN` env var,
2. content of the `.github-token` file in the home directory (`~`),
3. current auth token from the `gh` GitHub CLI,
4. current auth token from the `hub` GitHub CLI.
{textwrap.indent(github_config_keys, " ")}
**Options:**
<b>--c, --checkout-my-github-prs</b> Checkout your open PRs into local branches.
Expand Down Expand Up @@ -327,18 +343,16 @@
Tabs or any number of spaces can be used as indentation.
It's only important to be consistent wrt. the sequence of characters used for indentation between all lines.
""",
"github": """
"github": f"""
<b>Usage: git machete github <subcommand></b>
where <subcommand> is one of: `anno-prs`, `checkout-prs`, `create-pr`, `retarget-pr`, `sync`.
Creates, checks out and manages GitHub PRs while keeping them reflected in branch definition file.
To allow GitHub API access for private repositories (and also to perform side-effecting actions like opening a PR, even in case of public repositories),
a GitHub API token with `repo` scope is required, see `https://github.com/settings/tokens`. This will be resolved from the first of:
1. `GITHUB_TOKEN` env var,
2. content of the .github-token file in the home directory (`~`),
3. current auth token from the `gh` GitHub CLI,
4. current auth token from the `hub` GitHub CLI.
{textwrap.indent(github_api_access, " ")}
{textwrap.indent(github_config_keys, " ")}
<b>`anno-prs`:</b>
Expand Down
2 changes: 1 addition & 1 deletion git_machete/git_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def get_remotes(self) -> List[str]:
return self.__remotes_cached

def get_url_of_remote(self, remote: str) -> str:
return self._popen_git("config", "--get", f"remote.{remote}.url").strip() # 'git remote get-url' method has only been added in git v2.5.1
return self.get_config_attr_or_none(f"remote.{remote}.url").strip() # 'git remote get-url' method has only been added in git v2.5.1

def fetch_remote(self, remote: str) -> None:
if remote not in self.__fetch_done_for:
Expand Down
15 changes: 11 additions & 4 deletions git_machete/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, NamedTuple, Optional
import urllib.request
import urllib.error

from git_machete.utils import debug, fmt
from git_machete.exceptions import MacheteException, UnprocessableEntityHTTPError
from git_machete.git_operations import GitContext, LocalBranchShortName


GITHUB_TOKEN_ENV_VAR = 'GITHUB_TOKEN'
# GitHub Enterprise deployments use alternate domains.
# The logic in this module will need to be expanded to detect
Expand All @@ -42,6 +41,12 @@ def __repr__(self) -> str:
return f"PR #{self.number} by {self.user}: {self.head} -> {self.base}"


class RemoteAndOrganizationAndRepository(NamedTuple):
remote: str
organization: str
repository: str


def __parse_pr_json(pr_json: Any) -> GitHubPullRequest:
return GitHubPullRequest(number=int(pr_json['number']),
user=pr_json['user']['login'],
Expand Down Expand Up @@ -254,12 +259,14 @@ def is_github_remote_url(url: str) -> bool:
return any((re.match(pattern, url) for pattern in GITHUB_REMOTE_PATTERNS))


def get_parsed_github_remote_url(url: str) -> Optional[Tuple[str, str]]:
def get_parsed_github_remote_url(url: str, remote: str) -> Optional[RemoteAndOrganizationAndRepository]:

for pattern in GITHUB_REMOTE_PATTERNS:
match = re.match(pattern, url)
if match:
return match.group(1), match.group(2)
return RemoteAndOrganizationAndRepository(remote=remote,
organization=match.group(1),
repository=match.group(2))
return None


Expand Down
4 changes: 4 additions & 0 deletions tests/mockers.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ def add_remote(self, remote: str, url: str) -> "GitRepositorySandbox":
self.execute(f'git remote add {remote} {url}')
return self

def remove_remote(self, remote: str) -> "GitRepositorySandbox":
self.execute(f'git remote remove {remote}')
return self

def add_git_config_key(self, key: str, value: str) -> "GitRepositorySandbox":
self.execute(f'git config {key} {value}')
return self
Expand Down
Loading

0 comments on commit 91fc20b

Please sign in to comment.