Repository: VirtusLab/git-machete
Pull request #800 (2023-01-31 11:10:45)

Extract `GitHubClient` class

amalota: @PawelLipski Ready for review

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index a2ef386..81191d8 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -5,7 +5,7 @@
 ## New in git-machete 3.15.0
 
 - improved: formatting in `git machete` command prompts and outputs
-- added: `slide-out=no` branch qualifier that control the slide-out behaviour of `git machete traverse`
+- added: `slide-out=no` branch qualifier that controls the slide-out behaviour of `git machete traverse`
 
 ## New in git-machete 3.14.3
 
diff --git a/git_machete/client.py b/git_machete/client.py
index 0909fbc..c9181cb 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -7,8 +7,6 @@ import sys
 from collections import OrderedDict
 from typing import Callable, Dict, Generator, List, Optional, Tuple
 
-import git_machete.github
-import git_machete.options
 from git_machete import git_config_keys, utils
 from git_machete.annotation import Annotation
 from git_machete.constants import (DISCOVER_DEFAULT_FRESH_BRANCH_COUNT,
@@ -21,18 +19,9 @@ from git_machete.git_operations import (HEAD, AnyBranchName, AnyRevision,
                                         FullCommitHash, GitContext,
                                         GitLogEntry, LocalBranchShortName,
                                         RemoteBranchShortName)
-from git_machete.github import (DEFAULT_GITHUB_DOMAIN, GitHubPullRequest,
+from git_machete.github import (GitHubClient, GitHubPullRequest, GitHubToken,
                                 RemoteAndOrganizationAndRepository,
-                                add_assignees_to_pull_request,
-                                add_reviewers_to_pull_request,
-                                checkout_pr_refs, create_pull_request,
-                                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,
-                                is_github_remote_url, set_base_of_pull_request,
-                                set_milestone_of_pull_request)
+                                is_github_remote_url)
 from git_machete.utils import (
     AnsiEscapeCodes, SyncToParentStatus, bold, colored, debug, dim, excluding,
     flat_map, fmt, get_pretty_choices, get_second,
@@ -1474,11 +1463,12 @@ class MacheteClient:
 
     def sync_annotations_to_github_prs(self) -> None:
         domain = self.__derive_github_domain()
-        _, org, repo = self.__derive_remote_and_github_org_and_repo(domain)
+        remote_org_repo = self.__derive_remote_and_github_org_and_repo(domain=domain)
+        github_client = GitHubClient(domain=domain, organization=remote_org_repo.organization, repository=remote_org_repo.repository)
         print('Checking for open GitHub PRs... ', end='', flush=True)
-        current_user: Optional[str] = git_machete.github.derive_current_user_login(domain)
+        current_user: Optional[str] = github_client.derive_current_user_login()
         debug('Current GitHub user is ' + (bold(current_user) or '<none>'))
-        all_open_prs: List[GitHubPullRequest] = derive_pull_requests(domain, org, repo)
+        all_open_prs: List[GitHubPullRequest] = github_client.derive_pull_requests()
         print(fmt('<green><b>OK</b></green>'))
         self.__sync_annotations_to_definition_file(all_open_prs, current_user)
 
@@ -1973,45 +1963,41 @@ class MacheteClient:
                             verbose: bool = False,
                             fail_on_missing_current_user_for_my_opened_prs: bool = True
                             ) -> None:
-        org: str
-        repo: str
-        remote: str
         domain = self.__derive_github_domain()
-        remote, org, repo = self.__derive_remote_and_github_org_and_repo(domain)
+        remote_org_repo = self.__derive_remote_and_github_org_and_repo(domain=domain)
+        github_client = GitHubClient(domain=domain, organization=remote_org_repo.organization, repository=remote_org_repo.repository)
         print('Checking for open GitHub PRs... ', end='', flush=True)
 
-        current_user: Optional[str] = git_machete.github.derive_current_user_login(domain)
+        current_user: Optional[str] = github_client.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: "
-                   f"{get_github_token_possible_providers()}is valid.")
+                   f"{GitHubToken.get_possible_providers()}is valid.")
             if fail_on_missing_current_user_for_my_opened_prs:
                 warn(msg)
                 return
             else:
                 raise MacheteException(msg)
-        all_open_prs: List[GitHubPullRequest] = derive_pull_requests(domain, org, repo)
+        all_open_prs: List[GitHubPullRequest] = github_client.derive_pull_requests()
         print(fmt('<green><b>OK</b></green>'))
 
         applicable_prs: List[GitHubPullRequest] = self.__get_applicable_pull_requests(pr_nos,
                                                                                       all_opened_prs_from_github=all_open_prs,
-                                                                                      domain=domain,
-                                                                                      org=org,
-                                                                                      repo=repo,
+                                                                                      github_client=github_client,
                                                                                       all=all_opened_prs,
                                                                                       my=my_opened_prs,
                                                                                       by=opened_by,
                                                                                       user=current_user)
 
-        debug(f'organization is {org}, repository is {repo}')
+        debug(f'organization is {remote_org_repo.organization}, repository is {remote_org_repo.repository}')
         if verbose:
-            print(f"Fetching {bold(remote)}...")
-        self.__git.fetch_remote(remote)
+            print(f"Fetching {bold(remote_org_repo.remote)}...")
+        self.__git.fetch_remote(remote_org_repo.remote)
 
         pr: Optional[GitHubPullRequest] = None
         checked_out_prs: List[GitHubPullRequest] = []
         for pr in sorted(applicable_prs, key=lambda x: x.number):
             if pr.full_repository_name:
-                if '/'.join([remote, pr.head]) not in self.__git.get_remote_branches():
+                if '/'.join([remote_org_repo.remote, pr.head]) not in self.__git.get_remote_branches():
                     remote_already_added: Optional[str] = self.__get_added_remote_name_or_none(domain, pr.repository_url)
                     if not remote_already_added:
                         remote_from_pr: str = pr.full_repository_name.split('/')[0]
@@ -2020,7 +2006,7 @@ class MacheteClient:
                         remote_to_fetch: str = remote_from_pr
                     else:
                         remote_to_fetch = remote_already_added
-                    if remote != remote_to_fetch:
+                    if remote_org_repo.remote != remote_to_fetch:
                         if verbose:
                             print(f"Fetching {bold(remote_to_fetch)}...")
                         self.__git.fetch_remote(remote_to_fetch)
@@ -2033,7 +2019,7 @@ class MacheteClient:
                      f'No remote tracking data will be set up for {bold(pr.head)} branch.')
                 if verbose:
                     print(fmt(f"Checking out {bold(pr.head)} locally..."))
-                checkout_pr_refs(self.__git, remote, pr.number, LocalBranchShortName.of(pr.head))
+                github_client.checkout_pr_refs(self.__git, remote_org_repo.remote, pr.number, LocalBranchShortName.of(pr.head))
                 self.flush_caches()
             if pr.state == 'closed':
                 warn(f'Pull request #{bold(str(pr.number))} is already closed.')
@@ -2081,9 +2067,7 @@ class MacheteClient:
     @staticmethod
     def __get_applicable_pull_requests(prs_list: Optional[List[int]],
                                        all_opened_prs_from_github: List[GitHubPullRequest],
-                                       domain: str,
-                                       org: str,
-                                       repo: str,
+                                       github_client: GitHubClient,
                                        all: bool,
                                        my: bool,
                                        by: Optional[str],
@@ -2096,30 +2080,35 @@ class MacheteClient:
                 if _pr:
                     result.append(_pr)
                 else:
-                    pr_from_github = get_pull_request_by_number_or_none(domain, pr_no, org, repo)
+                    pr_from_github = github_client.get_pull_request_by_number_or_none(pr_no)
                     if pr_from_github:
                         result.append(pr_from_github)
                     else:
-                        raise MacheteException(f"PR #{bold(str(pr_no))} is not found in repository {bold(org)}/{bold(repo)}")
+                        raise MacheteException(f"PR #{bold(str(pr_no))} is not found in repository "
+                                               f"{bold(github_client.organization)}/{bold(github_client.repository)}")
             if not result:
                 raise MacheteException(
-                    f"Given PRs: {', '.join(map(str, prs_list))} are not found in repository {bold(org)}/{bold(repo)}")
+                    f"Given PRs: {', '.join(map(str, prs_list))} are not found in repository "
+                    f"{bold(github_client.organization)}/{bold(github_client.repository)}")
             return result
         if all:
             if not all_opened_prs_from_github:
-                warn(f"Currently there are no pull requests opened in repository {bold(org)}/{bold(repo)}")
+                warn(f"Currently there are no pull requests opened in repository "
+                     f"{bold(github_client.organization)}/{bold(github_client.repository)}")
                 return []
             return all_opened_prs_from_github
         elif my and user:
             result = [pr for pr in all_opened_prs_from_github if pr.user == user]
             if not result:
-                warn(f"Current user {bold(user)} has no open pull request in repository {bold(org)}/{bold(repo)}")
+                warn(f"Current user {bold(user)} has no open pull request in repository "
+                     f"{bold(github_client.organization)}/{bold(github_client.repository)}")
                 return []
             return result
         elif by:
             result = [pr for pr in all_opened_prs_from_github if pr.user == by]
             if not result:
-                warn(f"User {bold(by)} has no open pull request in repository {bold(org)}/{bold(repo)}")
+                warn(f"User {bold(by)} has no open pull request in repository "
+                     f"{bold(github_client.organization)}/{bold(github_client.repository)}")
                 return []
             return result
         return []
@@ -2136,22 +2125,23 @@ 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(github_domain, url) and get_parsed_github_remote_url(github_domain, url, remote) == \
-                    get_parsed_github_remote_url(github_domain, remote_url, remote):
+            if is_github_remote_url(github_domain, url) and \
+                    RemoteAndOrganizationAndRepository.from_url(github_domain, url, remote) == \
+                    RemoteAndOrganizationAndRepository.from_url(github_domain, remote_url, remote):
                 return remote
         return None
 
     def retarget_github_pr(self, head: LocalBranchShortName) -> None:
-        org: str
-        repo: str
-        domain: str = self.__derive_github_domain()
-        _, org, repo = self.__derive_remote_and_github_org_and_repo(domain, branch_used_for_tracking_data=head)
+        domain = self.__derive_github_domain()
+        remote_org_repo = self.__derive_remote_and_github_org_and_repo(domain=domain, branch_used_for_tracking_data=head)
+        github_client = GitHubClient(domain=domain, organization=remote_org_repo.organization, repository=remote_org_repo.repository)
 
-        debug(f'organization is {org}, repository is {repo}')
+        debug(f'organization is {remote_org_repo.organization}, repository is {remote_org_repo.repository}')
 
-        pr: Optional[GitHubPullRequest] = derive_pull_request_by_head(domain, org, repo, head)
+        pr: Optional[GitHubPullRequest] = github_client.derive_pull_request_by_head(head)
         if not pr:
-            raise MacheteException(f'No PR is opened in {bold(org)}/{bold(repo)} for branch {bold(head)}')
+            raise MacheteException(f'No PR is opened in {bold(remote_org_repo.organization)}/{bold(remote_org_repo.organization)} '
+                                   f'for branch {bold(head)}')
         debug(f'found {pr}')
 
         new_base: Optional[LocalBranchShortName] = self.up_branch.get(LocalBranchShortName.of(head))
@@ -2163,7 +2153,7 @@ class MacheteClient:
                 f' so that {bold(head)} is a child of {bold(pr.base)}.')
 
         if pr.base != new_base:
-            set_base_of_pull_request(domain, org, repo, pr.number, base=new_base)
+            github_client.set_base_of_pull_request(pr.number, base=new_base)
             print(f'The base branch of PR #{bold(str(pr.number))} has been switched to {bold(new_base)}')
         else:
             print(f'The base branch of PR #{bold(str(pr.number))} is already {bold(new_base)}')
@@ -2175,14 +2165,14 @@ class MacheteClient:
         self.save_definition_file()
 
     def __derive_github_domain(self) -> str:
-        return self.__git.get_config_attr_or_none(key=git_config_keys.GITHUB_DOMAIN) or DEFAULT_GITHUB_DOMAIN
+        return self.__git.get_config_attr_or_none(key=git_config_keys.GITHUB_DOMAIN) or GitHubClient.DEFAULT_GITHUB_DOMAIN
 
     def __derive_remote_and_github_org_and_repo(self,
                                                 domain: str,
                                                 branch_used_for_tracking_data: LocalBranchShortName = None
                                                 ) -> RemoteAndOrganizationAndRepository:
-        remote_and_organization_and_repository_from_config = self.__get_remote_and_organization_and_repository_name_from_config()
-        if all(remote_and_organization_and_repository_from_config):
+        remote_and_organization_and_repository_from_config = RemoteAndOrganizationAndRepository.from_config(self.__git)
+        if remote_and_organization_and_repository_from_config:
             return remote_and_organization_and_repository_from_config
 
         url_for_remote: Dict[str, str] = {
@@ -2191,12 +2181,12 @@ class MacheteClient:
         if not url_for_remote:
             raise MacheteException(fmt('No remotes defined for this repository (see `git remote`)'))
 
-        remote_and_github_org_and_repo: Dict[str, RemoteAndOrganizationAndRepository] = {
-            remote: get_parsed_github_remote_url(domain, url, remote)
+        remote_and_organization_and_repository_from_urls: Dict[str, RemoteAndOrganizationAndRepository] = {
+            remote: RemoteAndOrganizationAndRepository.from_url(domain, url, remote)
             for remote, url in url_for_remote.items() if is_github_remote_url(domain, url)
         }
 
-        if not remote_and_github_org_and_repo:
+        if not remote_and_organization_and_repository_from_urls:
             raise MacheteException(
                 fmt('Remotes are defined for this repository, but none of them '
                     'seems to correspond to GitHub (see `git remote -v` for details). \n'
@@ -2204,30 +2194,25 @@ class MacheteClient:
                     'If that is the case, you can provide repository information explicitly via some or all of git config keys: '
                     '`machete.github.{domain,remote,organization,repository}.`\n'))  # noqa: FS003
 
-        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 len(remote_and_organization_and_repository_from_urls) == 1:
+            return remote_and_organization_and_repository_from_urls[list(remote_and_organization_and_repository_from_urls.keys())[0]]
 
-        if 'origin' in remote_and_github_org_and_repo:
-            return remote_and_github_org_and_repo['origin']
+        if 'origin' in remote_and_organization_and_repository_from_urls:
+            return remote_and_organization_and_repository_from_urls['origin']
 
-        if len(remote_and_github_org_and_repo) > 1 and branch_used_for_tracking_data is not None:
+        if len(remote_and_organization_and_repository_from_urls) > 1 and branch_used_for_tracking_data is not None:
             remote_for_fetching_of_branch = self.__git.get_combined_remote_for_fetching_of_branch(
                 branch=branch_used_for_tracking_data,
-                remotes=list(remote_and_github_org_and_repo.keys()))
+                remotes=list(remote_and_organization_and_repository_from_urls.keys()))
             if remote_for_fetching_of_branch is not None:
-                return remote_and_github_org_and_repo[remote_for_fetching_of_branch]
+                return remote_and_organization_and_repository_from_urls[remote_for_fetching_of_branch]
 
         raise MacheteException(
             f'Multiple non-origin remotes correspond to GitHub in this repository: '
-            f'{", ".join(remote_and_github_org_and_repo.keys())} -> aborting. \n'
+            f'{", ".join(remote_and_organization_and_repository_from_urls.keys())} -> aborting. \n'
             f'You can also select the repository by providing some or all of git config keys: '
             '`machete.github.{domain,remote,organization,repository}`.\n')  # noqa: FS003
 
-    def __get_remote_and_organization_and_repository_name_from_config(self) -> RemoteAndOrganizationAndRepository:
-        return RemoteAndOrganizationAndRepository(remote=self.__git.get_config_attr_or_none(key=git_config_keys.GITHUB_REMOTE),
-                                                  organization=self.__git.get_config_attr_or_none(key=git_config_keys.GITHUB_ORGANIZATION),
-                                                  repository=self.__git.get_config_attr_or_none(key=git_config_keys.GITHUB_REPOSITORY))
-
     def create_github_pr(
             self,
             *,
@@ -2242,25 +2227,24 @@ class MacheteClient:
         base: Optional[LocalBranchShortName] = self.up_branch.get(LocalBranchShortName.of(head))
         if not base:
             raise MacheteException(f'Could not determine base branch for PR. Branch {bold(head)} is a root branch.')
-        org: str
-        repo: str
         domain = self.__derive_github_domain()
-        remote, org, repo = self.__derive_remote_and_github_org_and_repo(domain, branch_used_for_tracking_data=head)
-        print(f"Fetching {bold(remote)}...")
-        self.__git.fetch_remote(remote)
-        if '/'.join([remote, base]) not in self.__git.get_remote_branches():
+        remote_org_repo = self.__derive_remote_and_github_org_and_repo(domain=domain, branch_used_for_tracking_data=head)
+        github_client = GitHubClient(domain=domain, organization=remote_org_repo.organization, repository=remote_org_repo.repository)
+        print(f"Fetching {bold(remote_org_repo.remote)}...")
+        self.__git.fetch_remote(remote_org_repo.remote)
+        if '/'.join([remote_org_repo.remote, base]) not in self.__git.get_remote_branches():
             warn(f'Base branch for this PR ({bold(base)}) is not found on remote, pushing...')
             self.handle_untracked_branch(
                 branch=base,
-                new_remote=remote,
+                new_remote=remote_org_repo.remote,
                 is_called_from_traverse=False,
                 is_called_from_create_pr=True,
                 opt_push_tracked=False,
                 opt_push_untracked=True,
                 opt_yes=False)
 
-        current_user: Optional[str] = git_machete.github.derive_current_user_login(domain)
-        debug(f'organization is {org}, repository is {repo}')
+        current_user: Optional[str] = github_client.derive_current_user_login()
+        debug(f'organization is {remote_org_repo.organization}, repository is {remote_org_repo.repository}')
         debug('current GitHub user is ' + (current_user or '<none>'))
 
         fork_point = self.fork_point(head, use_overrides=True, opt_no_detect_squash_merges=False)
@@ -2272,20 +2256,20 @@ class MacheteClient:
 
         ok_str = '<green><b>OK</b></green>'
         print(f'Creating a {"draft " if opt_draft else ""}PR from {bold(head)} to {bold(base)}... ', end='', flush=True)
-        pr: GitHubPullRequest = create_pull_request(domain, org, repo, head=head, base=base, title=commits[0].subject,
-                                                    description=description, draft=opt_draft)
+        pr: GitHubPullRequest = github_client.create_pull_request(head=head, base=base, title=commits[0].subject,
+                                                                  description=description, draft=opt_draft)
         print(fmt(f'{ok_str}, see `{pr.html_url}`'))
 
         milestone_path: str = self.__git.get_main_git_subpath('info', 'milestone')
         milestone: str = utils.slurp_file_or_empty(milestone_path).strip()
         if milestone:
             print(f'Setting milestone of PR #{bold(str(pr.number))} to {bold(milestone)}... ', end='', flush=True)
-            set_milestone_of_pull_request(domain, org, repo, pr.number, milestone=milestone)
+            github_client.set_milestone_of_pull_request(pr.number, milestone=milestone)
             print(fmt(ok_str))
 
         if current_user:
             print(f'Adding {bold(current_user)} as assignee to PR #{bold(str(pr.number))}... ', end='', flush=True)
-            add_assignees_to_pull_request(domain, org, repo, pr.number, [current_user])
+            github_client.add_assignees_to_pull_request(pr.number, [current_user])
             print(fmt(ok_str))
 
         reviewers_path = self.__git.get_main_git_subpath('info', 'reviewers')
@@ -2295,7 +2279,7 @@ class MacheteClient:
                   f'as reviewer{"s" if len(reviewers) > 1 else ""} to PR #{bold(str(pr.number))}... ',
                   end='', flush=True)
             try:
-                add_reviewers_to_pull_request(domain, org, repo, pr.number, reviewers)
+                github_client.add_reviewers_to_pull_request(pr.number, reviewers)
             except UnprocessableEntityHTTPError as e:
                 if 'Reviews may only be requested from collaborators.' in e.msg:
                     warn(f"There are some invalid reviewers in {self.__git.get_main_git_subpath('info', 'reviewers')} file.\n"
diff --git a/git_machete/github.py b/git_machete/github.py
index e3c103c..2355c40 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -8,27 +8,14 @@ import urllib.error
 # Deliberately NOT using much more convenient `requests` to avoid external dependencies in production code
 import urllib.request
 from pathlib import Path
-from typing import Any, Dict, List, NamedTuple, Optional
+from typing import Any, Dict, List, Optional
 
+from git_machete import git_config_keys
 from git_machete.exceptions import (MacheteException,
                                     UnprocessableEntityHTTPError)
 from git_machete.git_operations import GitContext, LocalBranchShortName
 from git_machete.utils import bold, debug, fmt, warn
 
-GITHUB_TOKEN_ENV_VAR = 'GITHUB_TOKEN'
-DEFAULT_GITHUB_DOMAIN = "github.com"
-
-
-def github_remote_url_patterns(domain: str) -> List[str]:
-    # GitHub DOES NOT allow trailing `.git` suffix in the repository name (also applies to multiple repetitions e.g. `repo_name.git.git`)
-    domain_regex = re.escape(domain)
-    return [
-        f"^https://.*@{domain_regex}/(.*)/(.*)$",
-        f"^https://{domain_regex}/(.*)/(.*)$",
-        f"^git@{domain_regex}:(.*)/(.*)$",
-        f"^ssh://git@{domain_regex}/(.*)/(.*)$"
-    ]
-
 
 class GitHubPullRequest(object):
     def __init__(self,
@@ -41,57 +28,129 @@ class GitHubPullRequest(object):
                  full_repository_name: str,
                  repository_url: str
                  ) -> None:
-        self.number: int = number
-        self.user: str = user
-        self.base: str = base
-        self.head: str = head
-        self.html_url: str = html_url
-        self.state: str = state
-        self.full_repository_name: str = full_repository_name
-        self.repository_url: str = repository_url
+        self.number = number
+        self.user = user
+        self.base = base
+        self.head = head
+        self.html_url = html_url
+        self.state = state
+        self.full_repository_name = full_repository_name
+        self.repository_url = repository_url
+
+    @classmethod
+    def from_json(cls,
+                  pr_json: Dict[str, Any]
+                  ) -> "GitHubPullRequest":
+        return cls(number=int(pr_json['number']),
+                   user=pr_json['user']['login'],
+                   base=pr_json['base']['ref'],
+                   head=pr_json['head']['ref'],
+                   html_url=pr_json['html_url'],
+                   state=pr_json['state'],
+                   full_repository_name=pr_json['head']['repo']['full_name'] if pr_json['head']['repo'] else None,
+                   repository_url=pr_json['head']['repo']['html_url'] if pr_json['head']['repo'] else None)
 
     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'],
-                             base=pr_json['base']['ref'],
-                             head=pr_json['head']['ref'],
-                             html_url=pr_json['html_url'],
-                             state=pr_json['state'],
-                             full_repository_name=pr_json['head']['repo']['full_name'] if pr_json['head']['repo'] else None,
-                             repository_url=pr_json['head']['repo']['html_url'] if pr_json['head']['repo'] else None)
-
-
-class GithubTokenAndTokenProvider(NamedTuple):
-    token: str
-    token_provider: str
+class RemoteAndOrganizationAndRepository:
+    def __init__(self,
+                 remote: Optional[str],
+                 organization: Optional[str],
+                 repository: Optional[str]
+                 ) -> None:
+        self.remote = remote
+        self.organization = organization
+        self.repository = repository
+
+    def __bool__(self) -> bool:
+        return self.remote is not None and self.organization is not None and self.repository is not None
+
+    @classmethod
+    def from_config(cls,
+                    git: GitContext
+                    ) -> "RemoteAndOrganizationAndRepository":
+        return RemoteAndOrganizationAndRepository(remote=git.get_config_attr_or_none(key=git_config_keys.GITHUB_REMOTE),
+                                                  organization=git.get_config_attr_or_none(key=git_config_keys.GITHUB_ORGANIZATION),
+                                                  repository=git.get_config_attr_or_none(key=git_config_keys.GITHUB_REPOSITORY))
+
+    @classmethod
+    def from_url(cls,
+                 domain: str,
+                 url: str,
+                 remote: str
+                 ) -> Optional["RemoteAndOrganizationAndRepository"]:
+        for pattern in github_remote_url_patterns(domain):
+            match = re.match(pattern, url)
+            if match:
+                org = match.group(1)
+                repo = match.group(2)
+                return RemoteAndOrganizationAndRepository(remote=remote,
+                                                          organization=org,
+                                                          repository=repo if repo[-4:] != '.git' else repo[:-4])
+        return RemoteAndOrganizationAndRepository(remote=None,
+                                                  organization=None,
+                                                  repository=None)
+
+
+class GitHubToken:
+    GITHUB_TOKEN_ENV_VAR = 'GITHUB_TOKEN'
 
+    def __init__(self,
+                 domain: str
+                 ) -> None:
+        self.__domain = domain
+        self.__value: Optional[str] = None
+        self.__provider: Optional[str] = None
+        for token_retrieval_method in [self.__get_token_from_hub, self.__get_token_from_gh, self.__get_token_from_env,
+                                       self.__get_token_from_file_in_home_directory]:
+            if not (self.value and self.provider):
+                token_retrieval_method()
+
+    def __bool__(self) -> bool:
+        return self.__value is not None and self.__provider is not None
+
+    def __get_token_from_hub(self) -> None:
+        home_path: str = str(Path.home())
+        config_hub_path: str = os.path.join(home_path, ".config", "hub")
+        if os.path.isfile(config_hub_path):
+            with open(config_hub_path) as config_hub:
+                # ~/.config/hub is a yaml file, with a structure similar to:
+                #
+                # {domain1}:
+                # - user: {username1}
+                #   oauth_token: *******************
+                #   protocol: {protocol}
+                #
+                # {domain2}:
+                # - user: {username2}
+                #   oauth_token: *******************
+                #   protocol: {protocol}
+                found_host = False
+                for line in config_hub.readlines():
+                    if line.rstrip() == self.__domain + ":":
+                        found_host = True
+                    elif found_host and line.lstrip().startswith("oauth_token:"):
+                        result = re.sub(' *oauth_token: *', '', line).rstrip().replace('"', '')
+                        self.__value = result
+                        self.__provider = f'auth token for {self.__domain} from `hub` GitHub CLI'
 
-def __get_github_token_and_provider(domain: str) -> Optional[GithubTokenAndTokenProvider]:
-    def get_token_from_gh() -> Optional[GithubTokenAndTokenProvider]:
+    def __get_token_from_gh(self) -> None:
         # Abort without error if `gh` isn't available
         gh = shutil.which('gh')
         if not gh:
-            return None
+            return
 
         # There is also `gh auth token`, but it's only been added in gh v2.17.0, in Oct 2022.
         # Let's stick to the older `gh auth status --show-token` for compatibility.
         proc = subprocess.run(
-            [gh, "auth", "status", "--hostname", domain, "--show-token"],
+            [gh, "auth", "status", "--hostname", self.__domain, "--show-token"],
             stdout=subprocess.PIPE,
             stderr=subprocess.PIPE
         )
         if proc.returncode != 0:
-            return None
+            return
 
         # `gh auth status --show-token` outputs to stderr in the form:
         #
@@ -104,282 +163,296 @@ def __get_github_token_and_provider(domain: str) -> Optional[GithubTokenAndToken
         stderr = proc.stderr.decode()
         match = re.search(r"Token: (\w+)", stderr)
         if match:
-            return GithubTokenAndTokenProvider(token=match.group(1),
-                                               token_provider=f'auth token for {domain} from `hub` GitHub CLI')
-        return None
+            self.__value = match.group(1)
+            self.__provider = f'auth token for {self.__domain} from `hub` GitHub CLI'
 
-    def get_token_from_hub() -> Optional[GithubTokenAndTokenProvider]:
-        home_path: str = str(Path.home())
-        config_hub_path: str = os.path.join(home_path, ".config", "hub")
-        if os.path.isfile(config_hub_path):
-            with open(config_hub_path) as config_hub:
-                # ~/.config/hub is a yaml file, with a structure similar to:
-                #
-                # {domain1}:
-                # - user: {username1}
-                #   oauth_token: *******************
-                #   protocol: {protocol}
-                #
-                # {domain2}:
-                # - user: {username2}
-                #   oauth_token: *******************
-                #   protocol: {protocol}
-                found_host = False
-                for line in config_hub.readlines():
-                    if line.rstrip() == domain + ":":
-                        found_host = True
-                    elif found_host and line.lstrip().startswith("oauth_token:"):
-                        result = re.sub(' *oauth_token: *', '', line).rstrip().replace('"', '')
-                        return GithubTokenAndTokenProvider(token=result,
-                                                           token_provider=f'auth token for {domain} from `hub` GitHub CLI')
-        return None
-
-    def get_token_from_env() -> Optional[GithubTokenAndTokenProvider]:
-        github_token = os.environ.get(GITHUB_TOKEN_ENV_VAR)
+    def __get_token_from_env(self) -> None:
+        github_token = os.environ.get(self.GITHUB_TOKEN_ENV_VAR)
         if github_token:
-            return GithubTokenAndTokenProvider(token=github_token,
-                                               token_provider=f'`{GITHUB_TOKEN_ENV_VAR}` environment variable')
-        return None
+            self.__value = github_token
+            self.__provider = f'`{self.GITHUB_TOKEN_ENV_VAR}` environment variable'
 
-    def get_token_from_file_in_home_directory() -> Optional[GithubTokenAndTokenProvider]:
+    def __get_token_from_file_in_home_directory(self) -> None:
         required_file_name = '.github-token'
-        token_provider = f'auth token for {domain} from `~/.github-token`'
+        self.__provider = f'auth token for {self.__domain} from `~/.github-token`'
         file_full_path = os.path.expanduser(f'~/{required_file_name}')
 
         if os.path.isfile(file_full_path):
             with open(file_full_path) as file:
                 for line in file.readlines():
-                    if line.endswith(" " + domain):
+                    if line.endswith(" " + self.__domain):
                         token = line.split(" ")[0]
-                        return GithubTokenAndTokenProvider(token=token, token_provider=token_provider)
-                    elif domain == DEFAULT_GITHUB_DOMAIN and " " not in line.rstrip():
-                        return GithubTokenAndTokenProvider(token=line.rstrip(), token_provider=token_provider)
-        return None
-
-    return (get_token_from_env() or
-            get_token_from_file_in_home_directory() or
-            get_token_from_gh() or
-            get_token_from_hub())
-
-
-def __get_github_token(domain: str) -> Optional[str]:
-    github_token_and_provider = __get_github_token_and_provider(domain)
-    return github_token_and_provider.token if github_token_and_provider else None
-
-
-def __get_github_token_provider(domain: str) -> Optional[str]:
-    github_token_and_provider = __get_github_token_and_provider(domain)
-    return github_token_and_provider.token_provider if github_token_and_provider else None
-
-
-def __extract_failure_info_from_422(response: Any) -> str:
-    if response['message'] != 'Validation Failed':
-        return str(response['message'])
-    ret: List[str] = []
-    if response.get('errors'):
-        for error in response['errors']:
-            if error.get('message'):
-                ret.append(error['message'])
-    if ret:
-        return '\n'.join(ret)
-    return str(response)
-
-
-def __fire_github_api_request(domain: str, method: str, path: str,
-                              token: Optional[str], request_body: Optional[Dict[str, Any]] = None) -> Any:
-    headers: Dict[str, str] = {
-        'Content-type': 'application/json',
-        'User-Agent': 'git-machete',
-        'Accept': 'application/vnd.github.v3+json'
-    }
-    if token:
-        headers['Authorization'] = 'Bearer ' + token
-
-    if domain == DEFAULT_GITHUB_DOMAIN:
-        url_prefix = 'https://api.' + domain
-    else:
-        url_prefix = 'https://' + domain + '/api/v3'
-
-    url = url_prefix + path
-    json_body: Optional[str] = json.dumps(request_body) if request_body else None
-    http_request = urllib.request.Request(url, headers=headers, data=json_body.encode() if json_body else None, method=method.upper())
-    debug(f'firing a {method} request to {url} with {"a" if token else "no"} bearer token and request body {json_body or "<none>"}')
-
-    try:
-        with urllib.request.urlopen(http_request) as response:
-            parsed_response_body: Any = json.loads(response.read().decode())
-            # https://docs.github.com/en/rest/guides/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers
-            link_header: str = response.info()["link"]
-            if link_header:
-                url_prefix_regex = re.escape(url_prefix)
-                match = re.search(f'<{url_prefix_regex}(/[^>]+)>; rel="next"', link_header)
-                if match:
-                    next_page_path = match.group(1)
-                    debug(f'there is more data to retrieve under {next_page_path}')
-                    return parsed_response_body + __fire_github_api_request(domain, method, next_page_path, token, request_body)
-            return parsed_response_body
-    except urllib.error.HTTPError as err:
-        if err.code == http.HTTPStatus.UNPROCESSABLE_ENTITY:
-            error_response = json.loads(err.read().decode())
-            error_reason: str = __extract_failure_info_from_422(error_response)
-            raise UnprocessableEntityHTTPError(error_reason)
-        elif err.code in (http.HTTPStatus.UNAUTHORIZED, http.HTTPStatus.FORBIDDEN):
-            first_line = f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`\n'
-            if token:
-                raise MacheteException(first_line + 'Make sure that the GitHub API token '
-                                                    f'provided by the {__get_github_token_provider(domain)} '
-                                                    f'is valid and allows for access to `{method.upper()}` `{url_prefix}{path}`.\n'
-                                                    'You can also use a different token provider, available providers can be found '
-                                                    'when running `git machete help github`.')
-            else:
+                        self.__value = token
+                    elif self.__domain == GitHubClient.DEFAULT_GITHUB_DOMAIN and " " not in line.rstrip():
+                        self.__value = line.rstrip()
+
+    @property
+    def value(self) -> Optional[str]:
+        return self.__value
+
+    @property
+    def provider(self) -> Optional[str]:
+        return self.__provider
+
+    @classmethod
+    def get_possible_providers(cls) -> str:
+        return (f'\n\t1. `{cls.GITHUB_TOKEN_ENV_VAR}` environment variable.\n'
+                '\t2. Content of the `~/.github-token` file.\n'
+                '\t3. Current auth token from the `gh` GitHub CLI.\n'
+                '\t4. Current auth token from the `hub` GitHub CLI.\n')
+
+
+class GitHubClient:
+    DEFAULT_GITHUB_DOMAIN = "github.com"
+
+    def __init__(self,
+                 domain: str,
+                 organization: str,
+                 repository: str
+                 ) -> None:
+        self.__domain: str = domain
+        self.__organization: str = organization
+        self.__repository: str = repository
+        self.__token: GitHubToken = GitHubToken(domain=domain)
+
+    @property
+    def organization(self) -> str:
+        return self.__organization
+
+    @property
+    def repository(self) -> str:
+        return self.__repository
+
+    def __fire_github_api_request(self,
+                                  method: str,
+                                  path: str,
+                                  request_body: Optional[Dict[str, Any]] = None
+                                  ) -> Any:
+        headers: Dict[str, str] = {
+            'Content-type': 'application/json',
+            'User-Agent': 'git-machete',
+            'Accept': 'application/vnd.github.v3+json'
+        }
+        if self.__token:
+            headers['Authorization'] = 'Bearer ' + self.__token.value
+
+        if self.__domain == self.DEFAULT_GITHUB_DOMAIN:
+            url_prefix = 'https://api.' + self.__domain
+        else:
+            url_prefix = 'https://' + self.__domain + '/api/v3'
+
+        url = url_prefix + path
+        json_body: Optional[str] = json.dumps(request_body) if request_body else None
+        http_request = urllib.request.Request(url, headers=headers, data=json_body.encode() if json_body else None, method=method.upper())
+        debug(f'firing a {method} request to {url} with {"a" if self.__token else "no"} '
+              f'bearer token and request body {json_body or "<none>"}')
+
+        try:
+            with urllib.request.urlopen(http_request) as response:
+                parsed_response_body: Any = json.loads(response.read().decode())
+                # https://docs.github.com/en/rest/guides/using-pagination-in-the-rest-api?apiVersion=2022-11-28#using-link-headers
+                link_header: str = response.info()["link"]
+                if link_header:
+                    url_prefix_regex = re.escape(url_prefix)
+                    match = re.search(f'<{url_prefix_regex}(/[^>]+)>; rel="next"', link_header)
+                    if match:
+                        next_page_path = match.group(1)
+                        debug(f'there is more data to retrieve under {next_page_path}')
+                        return parsed_response_body + self.__fire_github_api_request(method, next_page_path, request_body)
+                return parsed_response_body
+        except urllib.error.HTTPError as err:
+            if err.code == http.HTTPStatus.UNPROCESSABLE_ENTITY:
+                error_response = json.loads(err.read().decode())
+                error_reason: str = self.__extract_failure_info_from_422(error_response)
+                raise UnprocessableEntityHTTPError(error_reason)
+            elif err.code in (http.HTTPStatus.UNAUTHORIZED, http.HTTPStatus.FORBIDDEN):
+                first_line = f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`\n'
+                if self.__token:
+                    raise MacheteException(first_line + 'Make sure that the GitHub API token '
+                                                        f'provided by the {self.__token.provider} '
+                                                        f'is valid and allows for access to `{method.upper()}` `{url_prefix}{path}`.\n'
+                                                        'You can also use a different token provider, available providers can be found '
+                                                        'when running `git machete help github`.')
+                else:
+                    raise MacheteException(
+                        first_line + f'You might not have the required permissions for this repository.\n'
+                                     f'Provide a GitHub API token with `repo` access via {self.__token.provider}.\n'
+                                     f'Visit `https://{self.__domain}/settings/tokens` to generate a new one.\n'
+                                     'You can also use a different token provider, available providers can be found '
+                                     'when running `git machete help github`.')
+            elif err.code == http.HTTPStatus.NOT_FOUND:
                 raise MacheteException(
-                    first_line + f'You might not have the required permissions for this repository.\n'
-                                 f'Provide a GitHub API token with `repo` access via {__get_github_token_provider(domain)}.\n'
-                                 f'Visit `https://{domain}/settings/tokens` to generate a new one.\n'
-                                 'You can also use a different token provider, available providers can be found '
-                                 'when running `git machete help github`.')
-        elif err.code == http.HTTPStatus.NOT_FOUND:
-            raise MacheteException(
-                f'`{method} {url}` request ended up in 404 response from GitHub. A valid GitHub API token is required.\n'
-                f'Provide a GitHub API token with `repo` access via one of the: {get_github_token_possible_providers()} '
-                f'Visit `https://{domain}/settings/tokens` to generate a new one.')  # TODO (#164): make dedicated exception here
-        elif err.code == http.HTTPStatus.TEMPORARY_REDIRECT:
-            if err.headers['Location'] is not None:
-                if len(err.headers['Location'].split('/')) >= 5:
-                    current_repo_and_org = get_repo_and_org_names_by_id(domain, err.headers['Location'].split('/')[4])
+                    f'`{method} {url}` request ended up in 404 response from GitHub. A valid GitHub API token is required.\n'
+                    f'Provide a GitHub API token with `repo` access via one of the: {GitHubToken.get_possible_providers()} '
+                    f'Visit `https://{self.__domain}/settings/tokens` to generate a new one.')  # TODO (#164): make dedicated exception here
+            elif err.code == http.HTTPStatus.TEMPORARY_REDIRECT:
+                if err.headers['Location'] is not None:
+                    if len(err.headers['Location'].split('/')) >= 5:
+                        current_repo_and_org = self.get_repo_and_org_names_by_id(err.headers['Location'].split('/')[4])
+                else:
+                    first_line = fmt(f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`\n')
+                    raise MacheteException(
+                        first_line + 'It looks like the organization or repository name got changed recently and is outdated.\n'
+                        'Inferring current organization or repository... -> Cannot infer current organization or repository\n'
+                        'Update your remote repository manually via: `git remote set-url <remote_name> <new_repository_url>`.')
+                # err.headers is a case-insensitive dict of class Message with the `__getitem__` and `get` functions implemented in
+                # https://github.com/python/cpython/blob/3.10/Lib/email/message.py
+                pulls_or_issues_api_suffix = "/".join(path.split("/")[4:])
+                new_path = f'/repos/{current_repo_and_org}/{pulls_or_issues_api_suffix}'
+                # for example when creating a new PR, new_path='/repos/new_org_name/new_repo_name/pulls'
+                warn(f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`. \n'
+                     'It looks like the organization or repository name got changed recently and is outdated.\n'
+                     'Inferring current organization or repository... '
+                     f'New organization = {bold(current_repo_and_org.split("/")[0])}, '
+                     f'new repository = {bold(current_repo_and_org.split("/")[1])}.\n'
+                     'You can update your remote repository via: `git remote set-url <remote_name> <new_repository_url>`.',
+                     end='')
+                return self.__fire_github_api_request(method=method, path=new_path, request_body=request_body)
             else:
                 first_line = fmt(f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`\n')
-                raise MacheteException(
-                    first_line + 'It looks like the organization or repository name got changed recently and is outdated.\n'
-                    'Inferring current organization or repository... -> Cannot infer current organization or repository\n'
-                    'Update your remote repository manually via: `git remote set-url <remote_name> <new_repository_url>`.')
-            # err.headers is a case-insensitive dict of class Message with the `__getitem__` and `get` functions implemented in
-            # https://github.com/python/cpython/blob/3.10/Lib/email/message.py
-            pulls_or_issues_api_suffix = "/".join(path.split("/")[4:])
-            new_path = f'/repos/{current_repo_and_org}/{pulls_or_issues_api_suffix}'
-            # for example when creating a new PR, new_path='/repos/new_org_name/new_repo_name/pulls'
-            warn(f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`. \n'
-                 'It looks like the organization or repository name got changed recently and is outdated.\n'
-                 'Inferring current organization or repository... '
-                 f'New organization = {bold(current_repo_and_org.split("/")[0])}, '
-                 f'new repository = {bold(current_repo_and_org.split("/")[1])}.\n'
-                 'You can update your remote repository via: `git remote set-url <remote_name> <new_repository_url>`.',
-                 end='')
-            return __fire_github_api_request(domain=domain, method=method, path=new_path, token=token, request_body=request_body)
+                raise MacheteException(first_line + "Please open an issue regarding this topic under link: "
+                                                    "https://github.com/VirtusLab/git-machete/issues/new")
+        except OSError as e:
+            raise MacheteException(f'Could not connect to {url_prefix}: {e}')
+
+    @staticmethod
+    def __extract_failure_info_from_422(response: Any) -> str:
+        if response['message'] != 'Validation Failed':
+            return str(response['message'])
+        ret: List[str] = []
+        if response.get('errors'):
+            for error in response['errors']:
+                if error.get('message'):
+                    ret.append(error['message'])
+        if ret:
+            return '\n'.join(ret)
+        return str(response)
+
+    def create_pull_request(self,
+                            head: str,
+                            base: str,
+                            title: str,
+                            description: str,
+                            draft: bool
+                            ) -> GitHubPullRequest:
+        request_body: Dict[str, Any] = {
+            'head': head,
+            'base': base,
+            'title': title,
+            'body': description,
+            'draft': draft
+        }
+        pr = self.__fire_github_api_request(method='POST',
+                                            path=f'/repos/{self.__organization}/{self.__repository}/pulls',
+                                            request_body=request_body)
+        return GitHubPullRequest.from_json(pr)
+
+    def add_assignees_to_pull_request(self,
+                                      number: int,
+                                      assignees: List[str]
+                                      ) -> None:
+        request_body: Dict[str, List[str]] = {
+            'assignees': assignees
+        }
+        # Adding assignees is only available via the Issues API, not PRs API.
+        self.__fire_github_api_request(method='POST',
+                                       path=f'/repos/{self.__organization}/{self.__repository}/issues/{number}/assignees',
+                                       request_body=request_body)
+
+    def add_reviewers_to_pull_request(self,
+                                      number: int,
+                                      reviewers: List[str]
+                                      ) -> None:
+        request_body: Dict[str, List[str]] = {
+            'reviewers': reviewers
+        }
+        self.__fire_github_api_request(method='POST',
+                                       path=f'/repos/{self.__organization}/{self.__repository}/pulls/{number}/requested_reviewers',
+                                       request_body=request_body)
+
+    def set_base_of_pull_request(self,
+                                 number: int,
+                                 base: LocalBranchShortName
+                                 ) -> None:
+        request_body: Dict[str, str] = {'base': base}
+        self.__fire_github_api_request(method='PATCH',
+                                       path=f'/repos/{self.__organization}/{self.__repository}/pulls/{number}',
+                                       request_body=request_body)
+
+    def set_milestone_of_pull_request(self,
+                                      number: int,
+                                      milestone: str
+                                      ) -> None:
+        request_body: Dict[str, str] = {'milestone': milestone}
+        # Setting milestone is only available via the Issues API, not PRs API.
+        self.__fire_github_api_request(method='PATCH',
+                                       path=f'/repos/{self.__organization}/{self.__repository}/issues/{number}',
+                                       request_body=request_body)
+
+    def derive_pull_request_by_head(self,
+                                    head: LocalBranchShortName
+                                    ) -> Optional[GitHubPullRequest]:
+        path = f'/repos/{self.__organization}/{self.__repository}/pulls?head={self.__organization}:{head}'
+        prs = self.__fire_github_api_request(method='GET',
+                                             path=path)
+        if len(prs) >= 1:
+            return GitHubPullRequest.from_json(prs[0])
         else:
-            first_line = fmt(f'GitHub API returned `{err.code}` HTTP status with error message: `{err.reason}`\n')
-            raise MacheteException(
-                first_line + "Please open an issue regarding this topic under link: https://github.com/VirtusLab/git-machete/issues/new")
-    except OSError as e:
-        raise MacheteException(f'Could not connect to {url_prefix}: {e}')
-
-
-def create_pull_request(domain: str, org: str, repo: str,
-                        head: str, base: str, title: str, description: str, draft: bool) -> GitHubPullRequest:
-    token: Optional[str] = __get_github_token(domain)
-    request_body: Dict[str, Any] = {
-        'head': head,
-        'base': base,
-        'title': title,
-        'body': description,
-        'draft': draft
-    }
-    pr = __fire_github_api_request(domain, 'POST', f'/repos/{org}/{repo}/pulls', token, request_body)
-    return __parse_pr_json(pr)
-
-
-def add_assignees_to_pull_request(domain: str, org: str, repo: str, number: int, assignees: List[str]) -> None:
-    token: Optional[str] = __get_github_token(domain)
-    request_body: Dict[str, List[str]] = {
-        'assignees': assignees
-    }
-    # Adding assignees is only available via the Issues API, not PRs API.
-    __fire_github_api_request(domain, 'POST', f'/repos/{org}/{repo}/issues/{number}/assignees', token, request_body)
-
-
-def add_reviewers_to_pull_request(domain: str, org: str, repo: str, number: int, reviewers: List[str]) -> None:
-    token: Optional[str] = __get_github_token(domain)
-    request_body: Dict[str, List[str]] = {
-        'reviewers': reviewers
-    }
-    __fire_github_api_request(domain, 'POST', f'/repos/{org}/{repo}/pulls/{number}/requested_reviewers', token, request_body)
-
-
-def set_base_of_pull_request(domain: str, org: str, repo: str, number: int, base: LocalBranchShortName) -> None:
-    token: Optional[str] = __get_github_token(domain)
-    request_body: Dict[str, str] = {'base': base}
-    __fire_github_api_request(domain, 'PATCH', f'/repos/{org}/{repo}/pulls/{number}', token, request_body)
-
-
-def set_milestone_of_pull_request(domain: str, org: str, repo: str, number: int, milestone: str) -> None:
-    token: Optional[str] = __get_github_token(domain)
-    request_body: Dict[str, str] = {'milestone': milestone}
-    # Setting milestone is only available via the Issues API, not PRs API.
-    __fire_github_api_request(domain, 'PATCH', f'/repos/{org}/{repo}/issues/{number}', token, request_body)
-
-
-def derive_pull_request_by_head(domain: str, org: str, repo: str, head: LocalBranchShortName) -> Optional[GitHubPullRequest]:
-    token: Optional[str] = __get_github_token(domain)
-    prs = __fire_github_api_request(domain, 'GET', f'/repos/{org}/{repo}/pulls?head={org}:{head}', token)
-    if len(prs) >= 1:
-        return __parse_pr_json(prs[0])
-    else:
-        return None
-
-
-def derive_pull_requests(domain: str, org: str, repo: str) -> List[GitHubPullRequest]:
-    token: Optional[str] = __get_github_token(domain)
-    # As of Dec 2022, GitHub API never returns more than 100 PRs, even if per_page>100.
-    prs = __fire_github_api_request(domain, 'GET', f'/repos/{org}/{repo}/pulls?per_page=100', token)
-    return list(map(__parse_pr_json, prs))
-
-
-def derive_current_user_login(domain: str) -> Optional[str]:
-    token: Optional[str] = __get_github_token(domain)
-    if not token:
-        return None
-    user = __fire_github_api_request(domain, 'GET', '/user', token)
-    return str(user['login'])  # str() to satisfy mypy
-
-
-def is_github_remote_url(domain: str, url: str) -> bool:
-    return any((re.match(pattern, url) for pattern in github_remote_url_patterns(domain)))
-
-
-def get_parsed_github_remote_url(domain: str, url: str, remote: str) -> Optional[RemoteAndOrganizationAndRepository]:
-    for pattern in github_remote_url_patterns(domain):
-        match = re.match(pattern, url)
-        if match:
-            org = match.group(1)
-            repo = match.group(2)
-            return RemoteAndOrganizationAndRepository(remote=remote,
-                                                      organization=org,
-                                                      repository=repo if repo[-4:] != '.git' else repo[:-4])
-    return None
+            return None
 
+    def derive_pull_requests(self) -> List[GitHubPullRequest]:
+        # As of Dec 2022, GitHub API never returns more than 100 PRs, even if per_page>100.
+        prs = self.__fire_github_api_request(method='GET',
+                                             path=f'/repos/{self.__organization}/{self.__repository}/pulls?per_page=100')
+        return list(map(GitHubPullRequest.from_json, prs))
 
-def get_pull_request_by_number_or_none(domain: str, number: int, org: str, repo: str) -> Optional[GitHubPullRequest]:
-    token: Optional[str] = __get_github_token(domain)
-    try:
-        pr_json: Dict[str, Any] = __fire_github_api_request(domain, 'GET', f'/repos/{org}/{repo}/pulls/{number}', token)
-        return __parse_pr_json(pr_json)
-    except MacheteException:
-        return None
+    def derive_current_user_login(self) -> Optional[str]:
+        if not self.__token:
+            return None
+        user = self.__fire_github_api_request(method='GET',
+                                              path='/user')
+        return str(user['login'])  # str() to satisfy mypy
+
+    def get_pull_request_by_number_or_none(self,
+                                           number: int
+                                           ) -> Optional[GitHubPullRequest]:
+        try:
+            path = f'/repos/{self.__organization}/{self.__repository}/pulls/{number}'
+            pr_json: Dict[str, Any] = self.__fire_github_api_request(method='GET',
+                                                                     path=path)
+            return GitHubPullRequest.from_json(pr_json)
+        except MacheteException:
+            return None
 
+    def get_repo_and_org_names_by_id(self,
+                                     repo_id: str
+                                     ) -> str:
+        repo = self.__fire_github_api_request(path='GET',
+                                              method=f'/repositories/{repo_id}')
+        return str(repo['full_name'])
 
-def checkout_pr_refs(git: GitContext, remote: str, pr_number: int, branch: LocalBranchShortName) -> None:
-    git.fetch_ref(remote, f'pull/{pr_number}/head:{branch}')
-    git.checkout(branch)
+    @staticmethod
+    def checkout_pr_refs(git: GitContext,
+                         remote: str,
+                         pr_number: int,
+                         branch: LocalBranchShortName
+                         ) -> None:
+        git.fetch_ref(remote, f'pull/{pr_number}/head:{branch}')
+        git.checkout(branch)
 
 
-def get_repo_and_org_names_by_id(domain: str, repo_id: str) -> str:
-    token: Optional[str] = __get_github_token(domain)
-    repo = __fire_github_api_request(domain, 'GET', f'/repositories/{repo_id}', token)
-    return str(repo['full_name'])
+def github_remote_url_patterns(domain: str) -> List[str]:
+    # GitHub DOES NOT allow trailing `.git` suffix in the repository name (also applies to multiple repetitions e.g. `repo_name.git.git`)
+    domain_regex = re.escape(domain)
+    return [
+        f"^https://.*@{domain_regex}/(.*)/(.*)$",
+        f"^https://{domain_regex}/(.*)/(.*)$",
+        f"^git@{domain_regex}:(.*)/(.*)$",
+        f"^ssh://git@{domain_regex}/(.*)/(.*)$"
+    ]
 
 
-def get_github_token_possible_providers() -> str:
-    return (f'\n\t1. `{GITHUB_TOKEN_ENV_VAR}` environment variable.\n'
-            '\t2. Content of the `~/.github-token` file.\n'
-            '\t3. Current auth token from the `gh` GitHub CLI.\n'
-            '\t4. Current auth token from the `hub` GitHub CLI.\n')
+def is_github_remote_url(domain: str,
+                         url: str
+                         ) -> bool:
+    return any((re.match(pattern, url) for pattern in github_remote_url_patterns(domain)))


Test patch:
diff --git a/tests/test_github.py b/tests/test_github.py
index 97b98dd..ae638fc 100644
--- a/tests/test_github.py
+++ b/tests/test_github.py
@@ -9,7 +9,8 @@ import pytest
 
 from git_machete.exceptions import MacheteException
 from git_machete.git_operations import LocalBranchShortName
-from git_machete.github import get_parsed_github_remote_url
+from git_machete.github import (GitHubClient, GitHubToken,
+                                RemoteAndOrganizationAndRepository)
 from git_machete.options import CommandLineOptions
 
 from .mockers import (GitRepositorySandbox, MockContextManager,
@@ -27,6 +28,24 @@ class FakeCommandLineOptions(CommandLineOptions):
         self.opt_yes: bool = True
 
 
+class FakeGitHubToken(GitHubToken):
+    def __bool__(self) -> bool:
+        return True
+
+    @property
+    def value(self) -> Optional[str]:
+        return 'fake_token'
+
+    @property
+    def provider(self) -> Optional[str]:
+        return 'fake_provider'
+
+
+class EmptyGitHubToken(GitHubToken):
+    def __bool__(self) -> bool:
+        return False
+
+
 def mock_github_remote_url_patterns(domain: str) -> List[str]:
     return ['(.*)/(.*)']
 
@@ -40,14 +59,6 @@ def mock_derive_current_user_login(domain: str) -> str:
     return "very_complex_user_token"
 
 
-def mock__get_github_token_none(domain: str) -> Optional[str]:
-    return None
-
-
-def mock__get_github_token_fake(domain: str) -> Optional[str]:
-    return 'token'
-
-
 def mock_input(msg: str) -> str:
     print(msg)
     return '1'
@@ -324,7 +335,7 @@ class TestGithub:
     )
 
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
-    @mock.patch('git_machete.github.derive_current_user_login', mock_derive_current_user_login)
+    @mock.patch('git_machete.github.GitHubClient.derive_current_user_login', mock_derive_current_user_login)
     @mock.patch('urllib.request.urlopen', MockContextManager)
     @mock.patch('urllib.request.Request', git_api_state_for_test_anno_prs.new_request())
     def test_github_anno_prs(self) -> None:
@@ -628,7 +639,7 @@ class TestGithub:
 
     # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_github_create_pr` due to `git fetch` executed by `create-pr` subcommand.
     @mock.patch('git_machete.github.github_remote_url_patterns', mock_github_remote_url_patterns)
-    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token_none)
+    @mock.patch('git_machete.github.GitHubToken', EmptyGitHubToken)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
@@ -688,7 +699,7 @@ class TestGithub:
     @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
     # We need to mock GITHUB_REMOTE_PATTERNS in the tests for `test_github_create_pr` due to `git fetch` executed by `create-pr` subcommand.
     @mock.patch('git_machete.github.github_remote_url_patterns', mock_github_remote_url_patterns)
-    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token_none)
+    @mock.patch('git_machete.github.GitHubToken', EmptyGitHubToken)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('urllib.error.HTTPError', MockHTTPError)  # need to provide read() method, which does not actually reads error from url
@@ -951,7 +962,7 @@ class TestGithub:
     @mock.patch('git_machete.github.github_remote_url_patterns', mock_github_remote_url_patterns)
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
-    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token_none)
+    @mock.patch('git_machete.github.GitHubToken', EmptyGitHubToken)
     @mock.patch('urllib.request.Request', git_api_state_for_test_checkout_prs.new_request())
     @mock.patch('urllib.request.urlopen', MockContextManager)
     def test_github_checkout_prs(self, tmp_path: Any) -> None:
@@ -1139,10 +1150,10 @@ class TestGithub:
         )
 
         # check against wrong pr number
-        repo: str
-        org: str
-        _, org, repo = get_parsed_github_remote_url(domain='github.com', url=self.repo_sandbox.remote_path, remote='origin')
-        expected_error_message = f"PR #100 is not found in repository {org}/{repo}"
+        remote_org_repo = RemoteAndOrganizationAndRepository.from_url(domain=GitHubClient.DEFAULT_GITHUB_DOMAIN,
+                                                                      url=self.repo_sandbox.remote_path,
+                                                                      remote='origin')
+        expected_error_message = f"PR #100 is not found in repository {remote_org_repo.organization}/{remote_org_repo.repository}"
         with pytest.raises(MacheteException) as e:
             launch_command('github', 'checkout-prs', '100')
         if e:
@@ -1157,7 +1168,8 @@ class TestGithub:
 
         # check against user with no open pull requests
         expected_msg = ("Checking for open GitHub PRs... OK\n"
-                        f"Warn: User tester has no open pull request in repository {org}/{repo}\n")
+                        f"Warn: User tester has no open pull request in repository "
+                        f"{remote_org_repo.organization}/{remote_org_repo.repository}\n")
         assert_command(['github', 'checkout-prs', '--by', 'tester'], expected_msg, strip_indentation=False)
 
         # Check against closed pull request with head branch deleted from remote
@@ -1481,10 +1493,10 @@ class TestGithub:
     @mock.patch('git_machete.github.github_remote_url_patterns', mock_github_remote_url_patterns)
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
-    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token_none)
+    @mock.patch('git_machete.github.GitHubToken', EmptyGitHubToken)
     @mock.patch('urllib.request.Request', git_api_state_for_test_github_checkout_prs_of_current_user_and_other_users.new_request())
     @mock.patch('urllib.request.urlopen', MockContextManager)
-    @mock.patch('git_machete.github.derive_current_user_login', mock_derive_current_user_login)
+    @mock.patch('git_machete.github.GitHubClient.derive_current_user_login', mock_derive_current_user_login)
     def test_github_checkout_prs_of_current_user_and_other_users(self, tmp_path: Any) -> None:
         (
             self.repo_sandbox.new_branch("root")
@@ -1632,7 +1644,7 @@ class TestGithub:
     @mock.patch('git_machete.client.MacheteClient.ask_if', mock_ask_if)
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.github.github_remote_url_patterns', mock_github_remote_url_patterns)
-    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token_fake)
+    @mock.patch('git_machete.github.GitHubToken', FakeGitHubToken)
     @mock.patch('urllib.request.urlopen', MockContextManager)
     @mock.patch('urllib.request.Request', git_api_state_for_test_github_sync.new_request())
     def test_github_sync(self) -> None:
@@ -1698,7 +1710,9 @@ class TestGithub:
         urls = urls + [url + '.git' for url in urls]
 
         for url in urls:
-            remote_and_organization_and_repository = get_parsed_github_remote_url('github.com', url, 'origin')
+            remote_and_organization_and_repository = RemoteAndOrganizationAndRepository.from_url(domain=GitHubClient.DEFAULT_GITHUB_DOMAIN,
+                                                                                                 url=url,
+                                                                                                 remote='origin')
             assert remote_and_organization_and_repository.organization == organization
             assert remote_and_organization_and_repository.repository == repository
 
@@ -1708,9 +1722,9 @@ class TestGithub:
     @mock.patch('git_machete.github.github_remote_url_patterns', mock_github_remote_url_patterns)
     @mock.patch('git_machete.options.CommandLineOptions', FakeCommandLineOptions)
     @mock.patch('git_machete.utils.run_cmd', mock_run_cmd)  # to hide git outputs in tests
-    @mock.patch('git_machete.github.__get_github_token', mock__get_github_token_none)
+    @mock.patch('git_machete.github.GitHubToken', EmptyGitHubToken)
     @mock.patch('urllib.request.urlopen', MockContextManager)
-    @mock.patch('git_machete.github.derive_current_user_login', mock_derive_current_user_login)
+    @mock.patch('git_machete.github.GitHubClient.derive_current_user_login', mock_derive_current_user_login)
     @mock.patch('urllib.request.Request', MockGitHubAPIState([]).new_request())
     @mock.patch('tests.mockers.MockGitHubAPIResponse.info', mock_info)
     @mock.patch('tests.mockers.MockGitHubAPIResponse.read', mock_read)