Repository: VirtusLab/git-machete

Issue:
`github create-pr`: check if base branch for PR exists in remote
Attempt to create a pull request with base branch being already deleted from remote ends up with `Unprocessable Entity` error. (example in #332).

Proposed solution:
Perform `git fetch <remote>` at the beginning of `create-pr`. if base branch is not present in remote branches, perform `handle_untracked_branch` with relevant remote for missing base branch.

Discussion:

Patch:
diff --git a/git_machete/client.py b/git_machete/client.py
index 556fa3d..d1d9146 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -1631,6 +1631,7 @@ class MacheteClient:
             *,
             branch: LocalBranchShortName,
             is_called_from_traverse: bool,
+            is_called_from_create_pr: bool,
             opt_push_untracked: bool,
             opt_push_tracked: bool,
             opt_yes: bool
@@ -1654,6 +1655,7 @@ class MacheteClient:
                 new_remote=rems[index],
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=is_called_from_create_pr,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -1667,12 +1669,13 @@ class MacheteClient:
             new_remote: str,
             branch: LocalBranchShortName,
             is_called_from_traverse: bool,
+            is_called_from_create_pr: bool,
             opt_push_untracked: bool,
             opt_push_tracked: bool,
             opt_yes: bool
     ) -> None:
         rems: List[str] = self.__git.get_remotes()
-        can_pick_other_remote = len(rems) > 1
+        can_pick_other_remote = len(rems) > 1 and not is_called_from_create_pr
         other_remote_choice = "o[ther-remote]" if can_pick_other_remote else ""
         remote_branch = RemoteBranchShortName.of(f"{new_remote}/{branch}")
         if not self.__git.get_commit_sha_by_revision(remote_branch):
@@ -1694,6 +1697,7 @@ class MacheteClient:
                     self.__pick_remote(
                         branch=branch,
                         is_called_from_traverse=is_called_from_traverse,
+                        is_called_from_create_pr=is_called_from_create_pr,
                         opt_push_untracked=opt_push_untracked,
                         opt_push_tracked=opt_push_tracked,
                         opt_yes=opt_yes)
@@ -1708,6 +1712,7 @@ class MacheteClient:
                     self.__pick_remote(
                         branch=branch,
                         is_called_from_traverse=is_called_from_traverse,
+                        is_called_from_create_pr=is_called_from_create_pr,
                         opt_push_untracked=opt_push_untracked,
                         opt_push_tracked=opt_push_tracked,
                         opt_yes=opt_yes)
@@ -1783,6 +1788,7 @@ class MacheteClient:
             self.__pick_remote(
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=is_called_from_create_pr,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -1994,9 +2000,24 @@ class MacheteClient:
         self.flush_caches()
 
         base: Optional[LocalBranchShortName] = self.up_branch.get(LocalBranchShortName.of(head))
+        if not base:
+            raise MacheteException(f'Could not determine base branch for PR. Branch `{head}` is a root branch.')
         org: str
         repo: str
-        _, (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():
+            warn(f'Base branch for this PR (`{base}`) is not found on remote, pushing...')
+            self.handle_untracked_branch(
+                branch=base,
+                new_remote=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()
         debug(f'create_github_pr({head})', f'organization is {org}, repository is {repo}')
         debug(f'create_github_pr({head})', 'current GitHub user is ' + (current_user or '<none>'))
@@ -2079,13 +2100,16 @@ class MacheteClient:
                 new_remote=rmt,
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
         elif len(rems) == 1:
             self.handle_untracked_branch(
-                new_remote=rems[0], branch=branch,
+                new_remote=rems[0],
+                branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -2094,6 +2118,7 @@ class MacheteClient:
                 new_remote="origin",
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)
@@ -2103,6 +2128,7 @@ class MacheteClient:
             self.__pick_remote(
                 branch=branch,
                 is_called_from_traverse=is_called_from_traverse,
+                is_called_from_create_pr=False,
                 opt_push_untracked=opt_push_untracked,
                 opt_push_tracked=opt_push_tracked,
                 opt_yes=opt_yes)


Test patch:
diff --git a/git_machete/tests/functional/test_machete.py b/git_machete/tests/functional/test_machete.py
index 36165d7..55749e7 100644
--- a/git_machete/tests/functional/test_machete.py
+++ b/git_machete/tests/functional/test_machete.py
@@ -1927,6 +1927,8 @@ class MacheteTester(unittest.TestCase):
     git_api_state_for_test_create_pr = MockGithubAPIState([{'head': {'ref': 'ignore-trailing', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'hotfix/add-trigger'}, 'number': '3', 'html_url': 'www.github.com', 'state': 'open'}],
                                                           issues=[{'number': '4'}, {'number': '5'}, {'number': '6'}])
 
+    # 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_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
     @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)
@@ -2093,6 +2095,47 @@ class MacheteTester(unittest.TestCase):
             self.assertEqual(e.exception.parameter, expected_error_message,
                              'Verify that expected error message has appeared when creating PR from root branch.')
 
+    git_api_state_for_test_create_pr_missing_base_branch_on_remote = MockGithubAPIState([{'head': {'ref': 'chore/redundant_checks', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'restrict_access'}, 'number': '18', 'html_url': 'www.github.com', 'state': 'open'}])
+
+    # 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_PATTERNS', FAKE_GITHUB_REMOTE_PATTERNS)
+    @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)
+    @mock.patch('urllib.request.urlopen', MockContextManager)
+    @mock.patch('urllib.request.Request', git_api_state_for_test_create_pr_missing_base_branch_on_remote.new_request())
+    def test_github_create_pr_missing_base_branch_on_remote(self) -> None:
+        (
+            self.repo_sandbox.new_branch("root")
+                .commit("initial commit")
+                .new_branch("develop")
+                .commit("first commit on develop")
+                .push()
+                .new_branch("feature/api_handling")
+                .commit("Introduce GET and POST methods on API")
+                .new_branch("feature/api_exception_handling")
+                .commit("catch exceptions coming from API")
+                .push()
+                .delete_branch("root")
+        )
+
+        self.launch_command('discover')
+
+        expected_msg = ("Fetching origin...\n"
+                        "Warn: Base branch for this PR (`feature/api_handling`) is not found on remote, pushing...\n"
+                        "Creating a PR from `feature/api_exception_handling` to `feature/api_handling`... OK, see www.github.com\n")
+        self.assert_command(['github', 'create-pr'], expected_msg, strip_indentation=False)
+        self.assert_command(
+            ['status'],
+            """
+            develop
+            |
+            o-feature/api_handling
+              |
+              o-feature/api_exception_handling *  PR #19
+            """,
+        )
+
     git_api_state_for_test_checkout_prs = MockGithubAPIState([
         {'head': {'ref': 'chore/redundant_checks', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'restrict_access'}, 'number': '18', 'html_url': 'www.github.com', 'state': 'open'},
         {'head': {'ref': 'restrict_access', 'repo': mock_repository_info}, 'user': {'login': 'github_user'}, 'base': {'ref': 'allow-ownership-link'}, 'number': '17', 'html_url': 'www.github.com', 'state': 'open'},