Repository: VirtusLab/git-machete
Pull request #1246 (2024-05-08 13:20:46)

Support opening non-stacked PRs from forks

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1246?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.48%. Comparing base [(`03ad774`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/03ad7743b532ad6a3517ef7dc96c866c71212283?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) to head [(`03ad774`)](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1246?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).

> :exclamation: Current head 03ad774 differs from pull request most recent head 909de1a. Consider uploading reports for the commit 909de1a to get more accurate results


<details><summary>Additional details and impacted files</summary>


```diff
@@                         Coverage Diff                         @@
##           fix/code-hosting-unexpected-errors    #1246   +/-   ##
===================================================================
  Coverage                               98.48%   98.48%           
===================================================================
  Files                                      14       14           
  Lines                                    3706     3706           
  Branches                                  814      814           
===================================================================
  Hits                                     3650     3650           
  Misses                                     37       37           
  Partials                                   19       19           
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1246?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).


Patch:
diff --git a/PACKAGES.md b/PACKAGES.md
index e301982..3f56259 100644
--- a/PACKAGES.md
+++ b/PACKAGES.md
@@ -45,7 +45,7 @@ the **edge-most** channel that corresponds to the **released** versions of git-m
 
 ## [Repology](https://repology.org/project/git-machete/versions)
 
-May duplicate badges already included in the above table,
+Duplicates some badges already included in the above table,
 but may also include newly-added packages that we're not aware of yet.
 
 [![Packaging status](https://repology.org/badge/vertical-allrepos/git-machete.svg)](https://repology.org/project/git-machete/versions)
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 598a6b1..6d49d25 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -3,6 +3,7 @@
 ## New in git-machete 3.26.4
 
 - fixed: avoid detecting a cycle when there's a PR/MR from `main` or `master` in a fork to the original repo (reported by @Joibel)
+- fixed: `git machete git{hub,lab} create-{pr,mr}` no longer fails when creating a PR/MR across forks; instead, a forked PR is created (reported by @cspotcode)
 - improved: `git machete git{hub,lab} checkout-{prs,mrs}` only adds annotations to affected branches (and not to every branch)
 
 ## New in git-machete 3.26.3
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index 18195d2..c3a22ab 100644
--- a/docs/man/git-machete.1
+++ b/docs/man/git-machete.1
@@ -27,7 +27,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
 .in \\n[rst2man-indent\\n[rst2man-indent-level]]u
 ..
-.TH "GIT-MACHETE" "1" "Aug 05, 2024" "" "git-machete"
+.TH "GIT-MACHETE" "1" "Aug 07, 2024" "" "git-machete"
 .SH NAME
 git-machete \- git-machete 3.26.4
 .sp
@@ -873,7 +873,7 @@ git machete github <subcommand>
 .UNINDENT
 .UNINDENT
 .sp
-where \fB<subcommand>\fP is one of: \fBanno\-prs\fP, \fBcheckout\-prs\fP, \fBcreate\-pr\fP, \fBretarget\-pr\fP\&.
+where \fB<subcommand>\fP is one of: \fBanno\-prs\fP, \fBcheckout\-prs\fP, \fBcreate\-pr\fP, \fBretarget\-pr\fP or \fBrestack\-pr\fP\&.
 .sp
 Creates, checks out and manages GitHub PRs while keeping them reflected in branch layout file.
 .sp
@@ -1097,7 +1097,7 @@ git machete gitlab <subcommand>
 .UNINDENT
 .UNINDENT
 .sp
-where \fB<subcommand>\fP is one of: \fBanno\-mrs\fP, \fBcheckout\-mrs\fP, \fBcreate\-mr\fP, \fBretarget\-mr\fP\&.
+where \fB<subcommand>\fP is one of: \fBanno\-mrs\fP, \fBcheckout\-mrs\fP, \fBcreate\-mr\fP, \fBretarget\-mr\fP or \fBrestack\-mr\fP\&.
 .sp
 Creates, checks out and manages GitLab MRs while keeping them reflected in branch layout file.
 .sp
diff --git a/docs/source/cli/github.rst b/docs/source/cli/github.rst
index 1276346..f765937 100644
--- a/docs/source/cli/github.rst
+++ b/docs/source/cli/github.rst
@@ -8,7 +8,7 @@ github
 
     git machete github <subcommand>
 
-where ``<subcommand>`` is one of: ``anno-prs``, ``checkout-prs``, ``create-pr``, ``retarget-pr``.
+where ``<subcommand>`` is one of: ``anno-prs``, ``checkout-prs``, ``create-pr``, ``retarget-pr`` or ``restack-pr``.
 
 Creates, checks out and manages GitHub PRs while keeping them reflected in branch layout file.
 
diff --git a/docs/source/cli/gitlab.rst b/docs/source/cli/gitlab.rst
index c6ef38c..8bb8df0 100644
--- a/docs/source/cli/gitlab.rst
+++ b/docs/source/cli/gitlab.rst
@@ -8,7 +8,7 @@ gitlab
 
     git machete gitlab <subcommand>
 
-where ``<subcommand>`` is one of: ``anno-mrs``, ``checkout-mrs``, ``create-mr``, ``retarget-mr``.
+where ``<subcommand>`` is one of: ``anno-mrs``, ``checkout-mrs``, ``create-mr``, ``retarget-mr`` or ``restack-mr``.
 
 Creates, checks out and manages GitLab MRs while keeping them reflected in branch layout file.
 
diff --git a/git_machete/client.py b/git_machete/client.py
index fd31b35..e5786af 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -528,7 +528,7 @@ class MacheteClient:
             self.expect_in_managed_branches(branch)
             anno = self.annotations.get(branch)
             if anno and not anno.qualifiers.slide_out:
-                raise MacheteException(f"Branch `{branch}` is annotated with `slide-out=no` qualifier, aborting.\n"
+                raise MacheteException(f"Branch {bold(branch)} is annotated with `slide-out=no` qualifier, aborting.\n"
                                        f"Remove the qualifier using `git machete anno` or edit branch layout file directly.")
             new_upstream = self.__up_branch.get(branch)
             if not new_upstream:
@@ -1200,7 +1200,7 @@ class MacheteClient:
     def rebase(self, onto: AnyRevision, from_exclusive: AnyRevision, branch: LocalBranchShortName, opt_no_interactive_rebase: bool) -> None:
         anno = self.annotations.get(branch)
         if anno and not anno.qualifiers.rebase:
-            raise MacheteException(f"Branch `{branch}` is annotated with `rebase=no` qualifier, aborting.\n"
+            raise MacheteException(f"Branch {bold(branch)} is annotated with `rebase=no` qualifier, aborting.\n"
                                    f"Remove the qualifier using `git machete anno` or edit branch layout file directly.")
         # Let's use `OPTS` suffix for consistency with git's built-in env var `GIT_DIFF_OPTS`
         extra_rebase_opts = os.environ.get('GIT_MACHETE_REBASE_OPTS', '').split()
@@ -2287,9 +2287,11 @@ class MacheteClient:
 
         prs: List[PullRequest] = code_hosting_client.get_open_pull_requests_by_head(head)
         if not prs:
-            raise MacheteException(f"No {spec.pr_short_name}s have `{head}` as its {spec.head_branch_name} branch")
+            raise MacheteException(f"No {spec.pr_short_name}s in <b>{org_repo_remote.extract_org_and_repo()}</b> "
+                                   f"have <b>{head}</b> as its {spec.head_branch_name} branch")
         if len(prs) > 1:
-            raise MacheteException(f"Multiple {spec.pr_short_name}s have `{head}` as its {spec.head_branch_name} branch: " +
+            raise MacheteException(f"Multiple {spec.pr_short_name}s in <b>{org_repo_remote.extract_org_and_repo()}</b> "
+                                   f"have <b>{head}</b> as its {spec.head_branch_name} branch: " +
                                    ", ".join(_pr.short_display_text() for _pr in prs))
         pr = prs[0]
 
@@ -2398,12 +2400,15 @@ class MacheteClient:
         prs: List[PullRequest] = code_hosting_client.get_open_pull_requests_by_head(head)
         if not prs:
             if ignore_if_missing:
-                warn(f"no {spec.pr_short_name}s have `{head}` as its {spec.head_branch_name} branch")
+                warn(f"no {spec.pr_short_name}s in <b>{org_repo_remote.extract_org_and_repo()}</b> "
+                     f"have <b>{head}</b> as its {spec.head_branch_name} branch")
                 return
             else:
-                raise MacheteException(f"No {spec.pr_short_name}s have `{head}` as its {spec.head_branch_name} branch")
+                raise MacheteException(f"No {spec.pr_short_name}s in <b>{org_repo_remote.extract_org_and_repo()}</b> "
+                                       f"have <b>{head}</b> as its {spec.head_branch_name} branch")
         if len(prs) > 1:
-            raise MacheteException(f"Multiple {spec.pr_short_name}s have `{head}` as its {spec.head_branch_name} branch: " +
+            raise MacheteException(f"Multiple {spec.pr_short_name}s in <b>{org_repo_remote.extract_org_and_repo()}</b> "
+                                   f"have <b>{head}</b> as its {spec.head_branch_name} branch: " +
                                    ", ".join(_pr.short_display_text() for _pr in prs))
         pr = prs[0]
         debug(f'found {pr}')
@@ -2498,6 +2503,7 @@ class MacheteClient:
                 (remote, OrganizationAndRepository.from_url(domain, url)) for remote, url in url_for_remote.items()
             ) if oar
         }
+        debug(f"remote_and_organization_and_repository_from_urls = {remote_and_organization_and_repository_from_urls}")
 
         if not remote_and_organization_and_repository_from_urls:
             raise MacheteException(
@@ -2511,9 +2517,6 @@ class MacheteClient:
         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_organization_and_repository_from_urls:
-            return remote_and_organization_and_repository_from_urls['origin']
-
         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,
@@ -2521,6 +2524,9 @@ class MacheteClient:
             if remote_for_fetching_of_branch is not None:
                 return remote_and_organization_and_repository_from_urls[remote_for_fetching_of_branch]
 
+        if 'origin' in remote_and_organization_and_repository_from_urls:
+            return remote_and_organization_and_repository_from_urls['origin']
+
         raise MacheteException(
             f'Multiple non-origin remotes correspond to {spec.display_name} in this repository: '
             f'{", ".join(remote_and_organization_and_repository_from_urls.keys())} -> aborting. \n'
@@ -2580,31 +2586,50 @@ class MacheteClient:
                                              f'Branch {bold(head)} is a root branch.')
 
         domain = self.__derive_code_hosting_domain(spec)
-        org_repo_remote = self.__derive_org_repo_and_remote(spec, domain=domain, branch_used_for_tracking_data=head)
-        code_hosting_client = spec.create_client(domain=domain, organization=org_repo_remote.organization,
-                                                 repository=org_repo_remote.repository)
-
-        remote_branch = RemoteBranchShortName(f"{org_repo_remote.remote}/{base}")
-        remote_base_branch_exists_locally = remote_branch in self.__git.get_remote_branches()
+        head_org_repo_remote = self.__derive_org_repo_and_remote(spec, domain=domain, branch_used_for_tracking_data=head)
+        base_org_repo_remote = self.__derive_org_repo_and_remote(spec, domain=domain, branch_used_for_tracking_data=base)
+        debug(f"head_org_repo_remote={head_org_repo_remote}, base_org_repo_remote={base_org_repo_remote}")
+
+        base_org_repo = base_org_repo_remote.extract_org_and_repo()
+        head_org_repo = head_org_repo_remote.extract_org_and_repo()
+        if base_org_repo != head_org_repo:
+            arrow = utils.get_right_arrow()
+            warn(f"{spec.base_branch_name.capitalize()} branch <b>{base}</b> lives in <b>{base_org_repo}</b> {spec.repository_name},\n"
+                 f"while {spec.head_branch_name} branch <b>{head}</b> lives in <b>{head_org_repo}</b> {spec.repository_name}.\n"
+                 f"git-machete will now attempt to create {spec.pr_short_name_article} {spec.pr_short_name} in <b>{base_org_repo}</b>.\n"
+                 "\n"
+                 f"Note that due to the limitations of {spec.display_name}'s {spec.pr_short_name} model, "
+                 f"it is <b>not</b> possible to cleanly create stacked {spec.pr_short_name}s from forks.\n"
+                 f"For example, in a hypothetical chain <b>some-other-branch</b> {arrow} <b>{head}</b> {arrow} <b>{base}</b>, "
+                 f"{spec.pr_short_name_article} {spec.pr_short_name} from <b>some-other-branch</b> to <b>{head}</b>\n"
+                 f"could <b>not</b> be created in <b>{base_org_repo}</b>, "
+                 f"since its {spec.head_branch_name} branch <b>{head}</b> lives in <b>{head_org_repo}</b>.\n"
+                 f"Generally, {spec.pr_short_name}s need to be created "
+                 f"in whatever {spec.repository_name} the {spec.base_branch_name} branch lives.\n")
+
+        base_remote_branch = RemoteBranchShortName(f"{base_org_repo_remote.remote}/{base}")
+        remote_base_branch_exists_locally = base_remote_branch in self.__git.get_remote_branches()
         print(f"Checking if {spec.base_branch_name} branch {bold(base)} "
-              f"exists in {bold(org_repo_remote.remote)} remote... ", end='', flush=True)
-        base_branch_found_on_remote = self.__git.does_remote_branch_exist(org_repo_remote.remote, base)
+              f"exists in {bold(base_org_repo_remote.remote)} remote... ", end='', flush=True)
+        base_branch_found_on_remote = self.__git.does_remote_branch_exist(base_org_repo_remote.remote, base)
         print(fmt('<green><b>YES</b></green>' if base_branch_found_on_remote else '<red><b>NO</b></red>'))
         if not base_branch_found_on_remote and remote_base_branch_exists_locally:
-            self.__git.delete_remote_branch(remote_branch)
+            self.__git.delete_remote_branch(base_remote_branch)
 
         if not base_branch_found_on_remote:
             self.__handle_untracked_branch(
                 branch=base,
-                new_remote=org_repo_remote.remote,
+                new_remote=base_org_repo_remote.remote,
                 is_called_from_traverse=False,
                 is_called_from_code_hosting=True,
                 opt_push_tracked=False,
                 opt_push_untracked=True,
                 opt_yes=opt_yes)
 
+        code_hosting_client = spec.create_client(domain=domain, organization=base_org_repo_remote.organization,
+                                                 repository=base_org_repo_remote.repository)
         current_user: Optional[str] = code_hosting_client.get_current_user_login()
-        debug(f'organization is {org_repo_remote.organization}, repository is {org_repo_remote.repository}')
+        debug(f'organization is {base_org_repo_remote.organization}, repository is {base_org_repo_remote.repository}')
         debug(f'current {spec.display_name} user is ' + (current_user or '<none>'))
 
         fork_point = self.fork_point(head, use_overrides=True)
@@ -2635,8 +2660,9 @@ class MacheteClient:
         ok_str = '<green><b>OK</b></green>'
         print(f'Creating a {"draft " if opt_draft else ""}{spec.pr_short_name} from {bold(head)} to {bold(base)}... ', end='', flush=True)
 
-        pr: PullRequest = code_hosting_client.create_pull_request(head=head, base=base, title=title,
-                                                                  description=description, draft=opt_draft)
+        pr: PullRequest = code_hosting_client.create_pull_request(
+            head=head, head_org_repo=head_org_repo, base=base,
+            title=title, description=description, draft=opt_draft)
         print(fmt(f'{ok_str}, see `{pr.html_url}`'))
 
         # If base branch has NOT originally been found on the remote,
diff --git a/git_machete/code_hosting.py b/git_machete/code_hosting.py
index e75f5e4..55a2fe4 100644
--- a/git_machete/code_hosting.py
+++ b/git_machete/code_hosting.py
@@ -33,6 +33,9 @@ class OrganizationAndRepository(NamedTuple):
     organization: str
     repository: str
 
+    def __str__(self) -> str:
+        return f"{self.organization}/{self.repository}"
+
     @classmethod
     def from_url(cls, domain: str, url: str) -> Optional["OrganizationAndRepository"]:
         url = url if url.endswith('.git') else url + '.git'
@@ -50,6 +53,9 @@ class OrganizationAndRepositoryAndRemote(NamedTuple):
     repository: str
     remote: str
 
+    def extract_org_and_repo(self) -> OrganizationAndRepository:
+        return OrganizationAndRepository(organization=self.organization, repository=self.repository)
+
 
 class OrganizationAndRepositoryAndGitUrl(NamedTuple):
     organization: str
@@ -98,6 +104,7 @@ class CodeHostingSpec(NamedTuple):
     pr_description_path: List[str]
     pr_full_name: str
     pr_ordinal_char: str
+    pr_short_name_article: str
     pr_short_name: str
     repository_name: str
     token_providers_message: str
@@ -121,7 +128,8 @@ class CodeHostingClient(metaclass=ABCMeta):  # pragma: no cover
         self.__org_repo_and_git_url_by_repo_id: Dict[int, Optional[OrganizationAndRepositoryAndGitUrl]] = {}
 
     @abstractmethod
-    def create_pull_request(self, head: str, base: str, title: str, description: str, draft: bool) -> PullRequest:
+    def create_pull_request(self, head: str, head_org_repo: OrganizationAndRepository,
+                            base: str, title: str, description: str, draft: bool) -> PullRequest:
         pass
 
     @abstractmethod
diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py
index 8a986bc..ea1a285 100644
--- a/git_machete/generated_docs.py
+++ b/git_machete/generated_docs.py
@@ -543,7 +543,7 @@ long_docs: Dict[str, str] = {
         <b>Usage:</b><b>
            git machete github <subcommand></b>
 
-        where `<subcommand>` is one of: `anno-prs`, `checkout-prs`, `create-pr`, `retarget-pr`.
+        where `<subcommand>` is one of: `anno-prs`, `checkout-prs`, `create-pr`, `retarget-pr` or `restack-pr`.
 
         Creates, checks out and manages GitHub PRs while keeping them reflected in branch layout file.
 
@@ -723,7 +723,7 @@ long_docs: Dict[str, str] = {
         <b>Usage:</b><b>
            git machete gitlab <subcommand></b>
 
-        where `<subcommand>` is one of: `anno-mrs`, `checkout-mrs`, `create-mr`, `retarget-mr`.
+        where `<subcommand>` is one of: `anno-mrs`, `checkout-mrs`, `create-mr`, `retarget-mr` or `restack-mr`.
 
         Creates, checks out and manages GitLab MRs while keeping them reflected in branch layout file.
 
diff --git a/git_machete/github.py b/git_machete/github.py
index aa1ccb3..35f6641 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -10,8 +10,8 @@ from pathlib import Path
 from typing import Any, Dict, List, NamedTuple, Optional, Tuple
 
 from .code_hosting import (CodeHostingClient, CodeHostingGitConfigKeys,
-                           CodeHostingSpec, OrganizationAndRepositoryAndGitUrl,
-                           PullRequest)
+                           CodeHostingSpec, OrganizationAndRepository,
+                           OrganizationAndRepositoryAndGitUrl, PullRequest)
 from .exceptions import MacheteException, UnexpectedMacheteException
 from .git_operations import LocalBranchShortName
 from .utils import bold, compact_dict, debug, popen_cmd, warn
@@ -164,6 +164,7 @@ class GitHubClient(CodeHostingClient):
             pr_ordinal_char='#',
             pr_full_name='pull request',
             pr_short_name='PR',
+            pr_short_name_article='a',
             repository_name='repository',
             token_providers_message=(
                 f'\n\t1. `{GITHUB_TOKEN_ENV_VAR}` environment variable\n'
@@ -326,9 +327,11 @@ class GitHubClient(CodeHostingClient):
         else:
             return str(response)
 
-    def create_pull_request(self, head: str, base: str, title: str, description: str, draft: bool) -> PullRequest:
+    def create_pull_request(self, head: str, head_org_repo: OrganizationAndRepository,
+                            base: str, title: str, description: str, draft: bool) -> PullRequest:
         request_body: Dict[str, Any] = {
             'head': head,
+            'head_repo': f'{head_org_repo.organization}/{head_org_repo.repository}',
             'base': base,
             'title': title,
             'body': description,
diff --git a/git_machete/gitlab.py b/git_machete/gitlab.py
index bc59d6f..f3100d9 100644
--- a/git_machete/gitlab.py
+++ b/git_machete/gitlab.py
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, NamedTuple, Optional
 from git_machete.code_hosting import (CodeHostingClient,
                                       CodeHostingGitConfigKeys,
                                       CodeHostingSpec,
+                                      OrganizationAndRepository,
                                       OrganizationAndRepositoryAndGitUrl,
                                       PullRequest)
 from git_machete.exceptions import MacheteException, UnexpectedMacheteException
@@ -119,6 +120,7 @@ class GitLabClient(CodeHostingClient):
             pr_full_name='merge request',
             pr_ordinal_char='!',
             pr_short_name='MR',
+            pr_short_name_article='an',
             repository_name='project',
             token_providers_message=(
                 f'\n\t1. `{GITLAB_TOKEN_ENV_VAR}` environment variable\n'
@@ -135,7 +137,8 @@ class GitLabClient(CodeHostingClient):
             )
         )
 
-    def __get_merge_request_from_json(self, mr_json: Dict[str, Any]) -> PullRequest:
+    @staticmethod
+    def __get_merge_request_from_json(mr_json: Dict[str, Any]) -> PullRequest:
         return PullRequest(
             number=int(mr_json['iid']),
             display_prefix='MR !',
@@ -225,8 +228,12 @@ class GitLabClient(CodeHostingClient):
         except OSError as e:  # pragma: no cover
             raise MacheteException(f'Could not connect to {url_prefix}: {e}')
 
+    @staticmethod
+    def __url_encode_project_name(organization: str, repository: str) -> str:
+        return urllib.parse.quote(f"{organization}/{repository}", safe='')  # `safe` empty, so that `/` is encoded as well
+
     def __fire_gitlab_api_project_request(self, method: str, path_suffix: str, request_body: Optional[Dict[str, Any]] = None) -> Any:
-        project = urllib.parse.quote(f"{self.organization}/{self.repository}", safe='')  # `safe` empty, so that `/` is encoded as well
+        project = self.__url_encode_project_name(self.organization, self.repository)
         path = f'/projects/{project}{path_suffix}'
         return self.__fire_gitlab_api_request(method=method, path=path, request_body=request_body)
 
@@ -240,14 +247,25 @@ class GitLabClient(CodeHostingClient):
         else:
             return str(response)
 
-    def create_pull_request(self, head: str, base: str, title: str, description: str, draft: bool) -> PullRequest:
+    def __get_repo_id_by_org_repo(self) -> int:
+        project = self.__url_encode_project_name(self.organization, self.repository)
+        return int(self.__fire_gitlab_api_request(method='GET', path=f'/projects/{project}')['id'])  # cast to int to satisfy mypy
+
+    def create_pull_request(self, head: str, head_org_repo: OrganizationAndRepository,
+                            base: str, title: str, description: str, draft: bool) -> PullRequest:
+        # The GitLab API for creating MRs across projects is somewhat confusing:
+        # URL path needs to point to the source project, but the MR is still created in the target project,
+        # which needs to pointed to by `target_project_id` JSON property.
         request_body: Dict[str, Any] = {
             'source_branch': head,
             'target_branch': base,
+            'target_project_id': self.__get_repo_id_by_org_repo(),
             'title': ('Draft: ' if draft else '') + title,
             'description': description,
         }
-        mr = self.__fire_gitlab_api_project_request(method='POST', path_suffix='/merge_requests', request_body=request_body)
+        head_project = self.__url_encode_project_name(head_org_repo.organization, head_org_repo.repository)
+        path = f'/projects/{head_project}/merge_requests'
+        mr = self.__fire_gitlab_api_request(method='POST', path=path, request_body=request_body)
         return self.__get_merge_request_from_json(mr)
 
     def __get_user_id_by_username(self, username: str) -> Optional[int]:


Test patch:
diff --git a/tests/mockers_code_hosting.py b/tests/mockers_code_hosting.py
index f08111c..7e169b5 100644
--- a/tests/mockers_code_hosting.py
+++ b/tests/mockers_code_hosting.py
@@ -1,4 +1,5 @@
 import json
+import os
 from collections import defaultdict
 from typing import Any, Callable, Dict, List, Optional, Union
 from urllib.error import HTTPError
@@ -34,7 +35,16 @@ class MockAPIResponse:
 
 
 def mock_from_url(domain: str, url: str) -> "OrganizationAndRepository":  # noqa: U100
-    return OrganizationAndRepository("example-org", "example-repo")
+    if url[:8] == "https://":
+        return OrganizationAndRepository(url.split("/")[3], url.split("/")[4].replace(".git", ""))
+    dir_to_repo = {
+        "remote": "example-repo",
+        "remote-1": "example-repo-1",
+        "remote-2": "example-repo-2",
+        "second-remote": "second-example-repo",
+    }
+    dir = url.split(os.path.sep)[-1]
+    return OrganizationAndRepository("example-org", dir_to_repo[dir])
 
 
 def mock_shutil_which(path: Optional[str]) -> Callable[[Any], Optional[str]]:
diff --git a/tests/mockers_gitlab.py b/tests/mockers_gitlab.py
index b50bbe0..ae4cf12 100644
--- a/tests/mockers_gitlab.py
+++ b/tests/mockers_gitlab.py
@@ -1,5 +1,6 @@
 import json
 import re
+import urllib
 from contextlib import AbstractContextManager, contextmanager
 from http import HTTPStatus
 from typing import Any, Callable, Dict, Iterator, List, Optional
@@ -55,10 +56,14 @@ class MockGitLabAPIState:
     @staticmethod
     def with_mrs(*mrs: Dict[str, Any]) -> "MockGitLabAPIState":
         projects = {
-            1: {'namespace': {'full_path': 'tester/tester'}, 'name': 'repo_sandbox',
+            1: {'id': 1, 'namespace': {'full_path': 'tester/tester/repo_sandbox'}, 'name': 'repo_sandbox',
                 'http_url_to_repo': 'https://gitlab.com/tester/tester/repo_sandbox.git'},
-            2: {'namespace': {'full_path': 'example-org'}, 'name': 'example-repo',
-                'http_url_to_repo': 'https://github.com/example-org/example-repo.git'},
+            2: {'id': 2, 'namespace': {'full_path': 'example-org/example-repo'}, 'name': 'example-repo',
+                'http_url_to_repo': 'https://gitlab.com/example-org/example-repo.git'},
+            3: {'id': 3, 'namespace': {'full_path': 'example-org/example-repo-1'}, 'name': 'example-repo-1',
+                'http_url_to_repo': 'https://gitlab.com/example-org/example-repo-1.git'},
+            4: {'id': 4, 'namespace': {'full_path': 'example-org/example-repo-2'}, 'name': 'example-repo-2',
+                'http_url_to_repo': 'https://gitlab.com/example-org/example-repo-2.git'},
         }
         return MockGitLabAPIState(projects, *mrs)
 
@@ -126,6 +131,12 @@ def __mock_urlopen_impl(gitlab_api_state: MockGitLabAPIState, request: Request)
             if repo_no in gitlab_api_state.projects:
                 return MockAPIResponse(HTTPStatus.OK, gitlab_api_state.projects[repo_no])
             raise error_404()
+        elif url_path_matches('/projects/*'):
+            repo_name = urllib.parse.unquote(url_segments[-1])
+            for project in gitlab_api_state.projects.values():
+                if project['namespace']['full_path'] == repo_name:
+                    return MockAPIResponse(HTTPStatus.OK, project)
+            raise error_404()
         elif url_path_matches('/projects/*/merge_requests'):
             head: Optional[str] = query_params.get('source_branch')
             if head:
diff --git a/tests/test_github_create_pr.py b/tests/test_github_create_pr.py
index 858c2c7..332e579 100644
--- a/tests/test_github_create_pr.py
+++ b/tests/test_github_create_pr.py
@@ -570,7 +570,16 @@ class TestGitHubCreatePR(BaseTest):
             |
             o-feature *
 
-        Checking if base branch branch-1 exists in origin_1 remote... YES
+        Warn: Base branch branch-1 lives in example-org/example-repo-2 repository,
+        while head branch feature lives in example-org/example-repo-1 repository.
+        git-machete will now attempt to create a PR in example-org/example-repo-2.
+
+        Note that due to the limitations of GitHub's PR model, it is not possible to cleanly create stacked PRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature -> branch-1, a PR from some-other-branch to feature
+        could not be created in example-org/example-repo-2, since its head branch feature lives in example-org/example-repo-1.
+        Generally, PRs need to be created in whatever repository the base branch lives.
+
+        Checking if base branch branch-1 exists in origin_2 remote... YES
         Creating a PR from feature to branch-1... OK, see www.github.com
         Checking for open GitHub PRs (to determine PR chain)... OK
         Updating description of PR #16 to include the chain of PRs... OK
@@ -623,7 +632,16 @@ class TestGitHubCreatePR(BaseTest):
         expected_result = """
         Add feature_1 onto the inferred upstream (parent) branch feature? (y, N)
         Added branch feature_1 onto feature
-        Checking if base branch feature exists in origin_2 remote... YES
+        Warn: Base branch feature lives in example-org/example-repo-1 repository,
+        while head branch feature_1 lives in example-org/example-repo-2 repository.
+        git-machete will now attempt to create a PR in example-org/example-repo-1.
+
+        Note that due to the limitations of GitHub's PR model, it is not possible to cleanly create stacked PRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature_1 -> feature, a PR from some-other-branch to feature_1
+        could not be created in example-org/example-repo-1, since its head branch feature_1 lives in example-org/example-repo-2.
+        Generally, PRs need to be created in whatever repository the base branch lives.
+
+        Checking if base branch feature exists in origin_1 remote... YES
         Creating a PR from feature_1 to feature... OK, see www.github.com
         Checking for open GitHub PRs (to determine PR chain)... OK
         Updating description of PR #17 to include the chain of PRs... OK
@@ -705,9 +723,19 @@ class TestGitHubCreatePR(BaseTest):
         expected_result = """
         Add feature_4 onto the inferred upstream (parent) branch feature_3? (y, N)
         Added branch feature_4 onto feature_3
-        Checking if base branch feature_3 exists in origin_2 remote... NO
-        Push untracked branch feature_3 to origin_2? (y, Q)
+        Warn: Base branch feature_3 lives in example-org/example-repo-1 repository,
+        while head branch feature_4 lives in example-org/example-repo-2 repository.
+        git-machete will now attempt to create a PR in example-org/example-repo-1.
+
+        Note that due to the limitations of GitHub's PR model, it is not possible to cleanly create stacked PRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature_4 -> feature_3, a PR from some-other-branch to feature_4
+        could not be created in example-org/example-repo-1, since its head branch feature_4 lives in example-org/example-repo-2.
+        Generally, PRs need to be created in whatever repository the base branch lives.
+
+        Checking if base branch feature_3 exists in origin_1 remote... YES
         Creating a PR from feature_4 to feature_3... OK, see www.github.com
+        Checking for open GitHub PRs (to determine PR chain)... OK
+        Updating description of PR #20 to include the chain of PRs... OK
         """
         assert_success(
             ['github', 'create-pr'],
@@ -727,9 +755,19 @@ class TestGitHubCreatePR(BaseTest):
         expected_result = """
         Add feature_5 onto the inferred upstream (parent) branch feature_3? (y, N)
         Added branch feature_5 onto feature_3
-        Checking if base branch feature_3 exists in origin remote... NO
-        Push untracked branch feature_3 to origin? (y, Q)
+        Warn: Base branch feature_3 lives in example-org/example-repo-1 repository,
+        while head branch feature_5 lives in example-org/example-repo-2 repository.
+        git-machete will now attempt to create a PR in example-org/example-repo-1.
+
+        Note that due to the limitations of GitHub's PR model, it is not possible to cleanly create stacked PRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature_5 -> feature_3, a PR from some-other-branch to feature_5
+        could not be created in example-org/example-repo-1, since its head branch feature_5 lives in example-org/example-repo-2.
+        Generally, PRs need to be created in whatever repository the base branch lives.
+
+        Checking if base branch feature_3 exists in origin_1 remote... YES
         Creating a PR from feature_5 to feature_3... OK, see www.github.com
+        Checking for open GitHub PRs (to determine PR chain)... OK
+        Updating description of PR #21 to include the chain of PRs... OK
         """
         assert_success(
             ['github', 'create-pr'],
diff --git a/tests/test_github_restack_pr.py b/tests/test_github_restack_pr.py
index 8f40f25..be0bc93 100644
--- a/tests/test_github_restack_pr.py
+++ b/tests/test_github_restack_pr.py
@@ -39,13 +39,13 @@ class TestGitHubRestackPR(BaseTest):
 
         assert_failure(
             ['github', 'restack-pr'],
-            "No PRs have develop as its head branch"
+            "No PRs in example-org/example-repo have develop as its head branch"
         )
 
         self.repo_sandbox.new_branch("multiple-pr-branch").commit()
         assert_failure(
             ['github', 'restack-pr'],
-            "Multiple PRs have multiple-pr-branch as its head branch: #16, #17"
+            "Multiple PRs in example-org/example-repo have multiple-pr-branch as its head branch: #16, #17"
         )
 
     def test_github_restack_pr_branch_in_sync(self, mocker: MockerFixture) -> None:
diff --git a/tests/test_github_retarget_pr.py b/tests/test_github_retarget_pr.py
index 099122a..5b1a1bd 100644
--- a/tests/test_github_retarget_pr.py
+++ b/tests/test_github_retarget_pr.py
@@ -102,7 +102,7 @@ class TestGitHubRetargetPR(BaseTest):
 
         assert_failure(
             ['github', 'retarget-pr'],
-            'Multiple PRs have feature_4 as its head branch: #35, #40'
+            'Multiple PRs in example-org/old-example-repo have feature_4 as its head branch: #35, #40'
         )
 
     @staticmethod
@@ -180,11 +180,11 @@ class TestGitHubRetargetPR(BaseTest):
 
         assert_failure(
             ["github", "retarget-pr", "--branch", "branch-without-pr"],
-            "No PRs have branch-without-pr as its head branch")
+            "No PRs in user/repo have branch-without-pr as its head branch")
 
         assert_success(
             ['github', 'retarget-pr', '--branch', 'branch-without-pr', '--ignore-if-missing'],
-            "Warn: no PRs have branch-without-pr as its head branch\n")
+            "Warn: no PRs in user/repo have branch-without-pr as its head branch\n")
 
     def test_github_retarget_pr_multiple_non_origin_remotes(self, mocker: MockerFixture) -> None:
         self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url)
diff --git a/tests/test_gitlab_checkout_mrs.py b/tests/test_gitlab_checkout_mrs.py
index 2742a16..09480aa 100644
--- a/tests/test_gitlab_checkout_mrs.py
+++ b/tests/test_gitlab_checkout_mrs.py
@@ -580,17 +580,7 @@ class TestGitLabCheckoutMRs(BaseTest):
         )
         assert_failure(
             ["gitlab", "checkout-mrs", "--all"],
-            "Could not check out MR !18 because branch develop is already deleted from origin-1."
-        )
-
-        (
-            self.repo_sandbox
-            .remove_remote("origin-1")
-            .add_remote("tester", 'https://gitlab.com/tester/lolxd.git')
-        )
-        assert_failure(
-            ["gitlab", "checkout-mrs", "--all"],
-            "Could not check out MR !18 because branch develop is already deleted from tester/tester."
+            "Could not check out MR !18 because branch develop is already deleted from tester/tester/repo_sandbox."
         )
 
     def test_gitlab_checkout_mrs_org_and_repo_from_config(self, mocker: MockerFixture) -> None:
diff --git a/tests/test_gitlab_create_mr.py b/tests/test_gitlab_create_mr.py
index f46b38e..63e51f0 100644
--- a/tests/test_gitlab_create_mr.py
+++ b/tests/test_gitlab_create_mr.py
@@ -565,7 +565,16 @@ class TestGitLabCreateMR(BaseTest):
             |
             o-feature *
 
-        Checking if target branch branch-1 exists in origin_1 remote... YES
+        Warn: Target branch branch-1 lives in example-org/example-repo-2 project,
+        while source branch feature lives in example-org/example-repo-1 project.
+        git-machete will now attempt to create an MR in example-org/example-repo-2.
+
+        Note that due to the limitations of GitLab's MR model, it is not possible to cleanly create stacked MRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature -> branch-1, an MR from some-other-branch to feature
+        could not be created in example-org/example-repo-2, since its source branch feature lives in example-org/example-repo-1.
+        Generally, MRs need to be created in whatever project the target branch lives.
+
+        Checking if target branch branch-1 exists in origin_2 remote... YES
         Creating a MR from feature to branch-1... OK, see www.gitlab.com
         Checking for open GitLab MRs (to determine MR chain)... OK
         Updating description of MR !16 to include the chain of MRs... OK
@@ -618,7 +627,16 @@ class TestGitLabCreateMR(BaseTest):
         expected_result = """
         Add feature_1 onto the inferred upstream (parent) branch feature? (y, N)
         Added branch feature_1 onto feature
-        Checking if target branch feature exists in origin_2 remote... YES
+        Warn: Target branch feature lives in example-org/example-repo-1 project,
+        while source branch feature_1 lives in example-org/example-repo-2 project.
+        git-machete will now attempt to create an MR in example-org/example-repo-1.
+
+        Note that due to the limitations of GitLab's MR model, it is not possible to cleanly create stacked MRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature_1 -> feature, an MR from some-other-branch to feature_1
+        could not be created in example-org/example-repo-1, since its source branch feature_1 lives in example-org/example-repo-2.
+        Generally, MRs need to be created in whatever project the target branch lives.
+
+        Checking if target branch feature exists in origin_1 remote... YES
         Creating a MR from feature_1 to feature... OK, see www.gitlab.com
         Checking for open GitLab MRs (to determine MR chain)... OK
         Updating description of MR !17 to include the chain of MRs... OK
@@ -700,9 +718,19 @@ class TestGitLabCreateMR(BaseTest):
         expected_result = """
         Add feature_4 onto the inferred upstream (parent) branch feature_3? (y, N)
         Added branch feature_4 onto feature_3
-        Checking if target branch feature_3 exists in origin_2 remote... NO
-        Push untracked branch feature_3 to origin_2? (y, Q)
+        Warn: Target branch feature_3 lives in example-org/example-repo-1 project,
+        while source branch feature_4 lives in example-org/example-repo-2 project.
+        git-machete will now attempt to create an MR in example-org/example-repo-1.
+
+        Note that due to the limitations of GitLab's MR model, it is not possible to cleanly create stacked MRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature_4 -> feature_3, an MR from some-other-branch to feature_4
+        could not be created in example-org/example-repo-1, since its source branch feature_4 lives in example-org/example-repo-2.
+        Generally, MRs need to be created in whatever project the target branch lives.
+
+        Checking if target branch feature_3 exists in origin_1 remote... YES
         Creating a MR from feature_4 to feature_3... OK, see www.gitlab.com
+        Checking for open GitLab MRs (to determine MR chain)... OK
+        Updating description of MR !20 to include the chain of MRs... OK
         """
         assert_success(
             ['gitlab', 'create-mr'],
@@ -722,9 +750,19 @@ class TestGitLabCreateMR(BaseTest):
         expected_result = """
         Add feature_5 onto the inferred upstream (parent) branch feature_3? (y, N)
         Added branch feature_5 onto feature_3
-        Checking if target branch feature_3 exists in origin remote... NO
-        Push untracked branch feature_3 to origin? (y, Q)
+        Warn: Target branch feature_3 lives in example-org/example-repo-1 project,
+        while source branch feature_5 lives in example-org/example-repo-2 project.
+        git-machete will now attempt to create an MR in example-org/example-repo-1.
+
+        Note that due to the limitations of GitLab's MR model, it is not possible to cleanly create stacked MRs from forks.
+        For example, in a hypothetical chain some-other-branch -> feature_5 -> feature_3, an MR from some-other-branch to feature_5
+        could not be created in example-org/example-repo-1, since its source branch feature_5 lives in example-org/example-repo-2.
+        Generally, MRs need to be created in whatever project the target branch lives.
+
+        Checking if target branch feature_3 exists in origin_1 remote... YES
         Creating a MR from feature_5 to feature_3... OK, see www.gitlab.com
+        Checking for open GitLab MRs (to determine MR chain)... OK
+        Updating description of MR !21 to include the chain of MRs... OK
         """
         assert_success(
             ['gitlab', 'create-mr'],
diff --git a/tests/test_gitlab_restack_mr.py b/tests/test_gitlab_restack_mr.py
index 210cf84..c6ab92d 100644
--- a/tests/test_gitlab_restack_mr.py
+++ b/tests/test_gitlab_restack_mr.py
@@ -39,13 +39,13 @@ class TestGitLabRestackMR(BaseTest):
 
         assert_failure(
             ['gitlab', 'restack-mr'],
-            "No MRs have develop as its source branch"
+            "No MRs in example-org/example-repo have develop as its source branch"
         )
 
         self.repo_sandbox.new_branch("multiple-mr-branch").commit()
         assert_failure(
             ['gitlab', 'restack-mr'],
-            "Multiple MRs have multiple-mr-branch as its source branch: !16, !17"
+            "Multiple MRs in example-org/example-repo have multiple-mr-branch as its source branch: !16, !17"
         )
 
     def test_gitlab_restack_mr_branch_in_sync(self, mocker: MockerFixture) -> None:
diff --git a/tests/test_gitlab_retarget_mr.py b/tests/test_gitlab_retarget_mr.py
index a8bb248..61c43d8 100644
--- a/tests/test_gitlab_retarget_mr.py
+++ b/tests/test_gitlab_retarget_mr.py
@@ -108,7 +108,7 @@ class TestGitLabRetargetMR(BaseTest):
 
         assert_failure(
             ['gitlab', 'retarget-mr'],
-            'Multiple MRs have feature_4 as its source branch: !35, !40'
+            'Multiple MRs in example-org/example-repo have feature_4 as its source branch: !35, !40'
         )
 
     @staticmethod
@@ -186,11 +186,11 @@ class TestGitLabRetargetMR(BaseTest):
 
         assert_failure(
             ["gitlab", "retarget-mr", "--branch", "branch-without-mr"],
-            "No MRs have branch-without-mr as its source branch")
+            "No MRs in user/repo have branch-without-mr as its source branch")
 
         assert_success(
             ['gitlab', 'retarget-mr', '--branch', 'branch-without-mr', '--ignore-if-missing'],
-            "Warn: no MRs have branch-without-mr as its source branch\n")
+            "Warn: no MRs in user/repo have branch-without-mr as its source branch\n")
 
     def test_gitlab_retarget_mr_multiple_non_origin_remotes(self, mocker: MockerFixture) -> None:
         self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url)