Repository: VirtusLab/git-machete (python)

Issue:
`git machete github anno-prs` doesn't work with custom GitHub URL
# Overview

I've stumbled upon some issues with GitHub recently: _sometimes_ for _some_ repos, `git push` wouldn't work. With trial-and-error, I found a reliable workaround for me:

```shellsession
$ git remote -v
origin	github:VirtuslabRnD/nfs-core.git (fetch)
origin	github:VirtuslabRnD/nfs-core.git (push)

$ cat ~/.ssh/config
Host github
    Hostname        ssh.github.com
    Port            443
    User            git
    IdentityFile    ~/.ssh/id_ed25519
    IdentitiesOnly  yes
```

However, this setup breaks branch discovery:
```shellsession
$ git machete github anno-prs -v
git rev-parse --git-dir
git for-each-ref --format=%\(refname\)$'\t'%\(objectname\)$'\t'%\(tree\)$'\t'%\(committerdate:raw\) refs/remotes
git for-each-ref --format=%\(refname\)$'\t'%\(objectname\)$'\t'%\(tree\)$'\t'%\(committerdate:raw\)$'\t'%\(upstream\) refs/heads
git remote
git config --get remote.origin.url
Remotes are defined for this repository, but none of them corresponds to GitHub (see git remote -v for details)
```

## `git` and `git machete` versions

```shellsession
$ git --version
git version 2.35.1

$ git machete --version
git machete version 3.7.2
```

Pull request:
Issue #479: `git machete github *` should work with custom GitHub URL
closes #479 

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 195ad76..64c9190 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -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
 
diff --git a/docs/source/cli_help/anno.rst b/docs/source/cli_help/anno.rst
index aaa85c4..57f775a 100644
--- a/docs/source/cli_help/anno.rst
+++ b/docs/source/cli_help/anno.rst
@@ -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.
diff --git a/docs/source/cli_help/clean.rst b/docs/source/cli_help/clean.rst
index c77c182..063df51 100644
--- a/docs/source/cli_help/clean.rst
+++ b/docs/source/cli_help/clean.rst
@@ -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:**
 
diff --git a/docs/source/cli_help/github.rst b/docs/source/cli_help/github.rst
index 10efed2..31fafa3 100644
--- a/docs/source/cli_help/github.rst
+++ b/docs/source/cli_help/github.rst
@@ -13,6 +13,7 @@ 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``:
 
@@ -20,6 +21,7 @@ Creates, checks out and manages GitHub PRs while keeping them reflected in branc
   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),
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 4bf76a9..340151c 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -47,7 +47,8 @@ exclude_patterns = [
     'completion.rst',
     'description.rst',
     'learning_materials.rst',
-    'github_api_access.rst'
+    'github_api_access.rst',
+    'github_config_keys.rst'
 ]
 
 
diff --git a/docs/source/github_config_keys.rst b/docs/source/github_config_keys.rst
new file mode 100644
index 0000000..4558d64
--- /dev/null
+++ b/docs/source/github_config_keys.rst
@@ -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>
diff --git a/git_machete/client.py b/git_machete/client.py
index 3405137..57f8cac 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -20,7 +20,7 @@ from git_machete.github import (
     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)
@@ -1386,33 +1386,7 @@ class MacheteClient:
         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
@@ -1880,7 +1854,7 @@ class MacheteClient:
         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: "
@@ -2030,14 +2004,14 @@ class MacheteClient:
         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}')
 
@@ -2060,30 +2034,44 @@ class MacheteClient:
         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:
@@ -2096,7 +2084,7 @@ class MacheteClient:
             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():
diff --git a/git_machete/docs.py b/git_machete/docs.py
index d520d7b..ab16b25 100644
--- a/git_machete/docs.py
+++ b/git_machete/docs.py
@@ -1,3 +1,4 @@
+import textwrap
 from typing import Dict
 from git_machete.constants import DISCOVER_DEFAULT_FRESH_BRANCH_COUNT
 
@@ -30,6 +31,29 @@ short_docs: Dict[str, str] = {
     "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>
@@ -112,7 +136,7 @@ long_docs: Dict[str, str] = {
           <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>
@@ -126,12 +150,9 @@ long_docs: Dict[str, str] = {
         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.
@@ -142,9 +163,8 @@ long_docs: Dict[str, str] = {
           <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,
@@ -154,13 +174,9 @@ long_docs: Dict[str, str] = {
         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.
@@ -327,18 +343,16 @@ long_docs: Dict[str, str] = {
         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>
 
diff --git a/git_machete/git_operations.py b/git_machete/git_operations.py
index 546dd42..10a43a1 100644
--- a/git_machete/git_operations.py
+++ b/git_machete/git_operations.py
@@ -339,7 +339,7 @@ class GitContext:
         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:
diff --git a/git_machete/github.py b/git_machete/github.py
index 72af595..139bd83 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -7,7 +7,7 @@ import re
 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
 
@@ -15,7 +15,6 @@ 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
@@ -42,6 +41,12 @@ class GitHubPullRequest(object):
         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'],
@@ -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
 
 


Test patch:
diff --git a/tests/mockers.py b/tests/mockers.py
index f7cad10..7e76788 100644
--- a/tests/mockers.py
+++ b/tests/mockers.py
@@ -103,6 +103,10 @@ class 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
diff --git a/tests/test_github.py b/tests/test_github.py
index a3621fd..3af713a 100644
--- a/tests/test_github.py
+++ b/tests/test_github.py
@@ -202,6 +202,38 @@ class TestGithub:
             """,
         )
 
+        # Test anno-prs using custom remote URL provided by git config keys
+        (
+            self.repo_sandbox
+                .remove_remote('new_origin')
+                .add_git_config_key('machete.github.remote', 'custom_origin')
+                .add_git_config_key('machete.github.organization', 'custom_user')
+                .add_git_config_key('machete.github.repository', 'custom_repo')
+        )
+
+        launch_command("discover", "-y")
+        launch_command('github', 'anno-prs')
+        assert_command(
+            ["status"],
+            """
+            master
+            |
+            o-hotfix/add-trigger (diverged from origin)
+              |
+              o-ignore-trailing *  PR #3 (github_user) (diverged from & older than origin)
+
+            develop
+            |
+            x-allow-ownership-link  PR #7 (github_user) (ahead of origin)
+            | |
+            | x-build-chain (untracked)
+            |
+            o-call-ws  PR #31 (github_user) (ahead of origin)
+              |
+              x-drop-constraint (untracked)
+            """,
+        )
+
     git_api_state_for_test_create_pr = MockGitHubAPIState(
         [
             {
@@ -705,7 +737,7 @@ class TestGithub:
         # check against wrong pr number
         repo: str
         org: str
-        (org, repo) = get_parsed_github_remote_url(self.repo_sandbox.remote_path)
+        _, org, repo = get_parsed_github_remote_url(self.repo_sandbox.remote_path, remote='origin')
         expected_error_message = f"PR #100 is not found in repository `{org}/{repo}`"
         with pytest.raises(MacheteException) as e:
             launch_command('github', 'checkout-prs', '100')