Repository: VirtusLab/git-machete
Pull request #1423 (2025-04-14 15:22:25)

Create PRs/MRs during traverse
- [x] ~traverse should skip branches removed from remote~ let's do as a part of #1242
- [x] ~test for the case with a branch removed from remote~ let's do as a part of #1242
- [x] test for the case when if PR exists (even if branch untracked)


codecov-commenter: ## [Codecov](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1423?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) Report
Attention: Patch coverage is `51.61290%` with `15 lines` in your changes missing coverage. Please review.
> Project coverage is 98.21%. Comparing base [(`f248e5a`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/f248e5aecaf899dd7605ecf86f10d9469fc9aaea?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) to head [(`30d8e56`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/30d8e5661e58831bb81add558ad118fe8fd0ff5e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab).

| [Files with missing lines](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1423?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) | Patch % | Lines |
|---|---|---|
| [git\_machete/client/traverse.py](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1423?src=pr&el=tree&filepath=git_machete%2Fclient%2Ftraverse.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab#diff-Z2l0X21hY2hldGUvY2xpZW50L3RyYXZlcnNlLnB5) | 34.78% | [13 Missing and 2 partials :warning: ](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1423?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) |

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


```diff
@@             Coverage Diff             @@
##           develop    #1423      +/-   ##
===========================================
- Coverage    98.56%   98.21%   -0.36%     
===========================================
  Files           26       26              
  Lines         4174     4194      +20     
  Branches       788      795       +7     
===========================================
+ Hits          4114     4119       +5     
- Misses          38       51      +13     
- Partials        22       24       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1423?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).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9e3e53f..b818eac 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,9 +36,14 @@ Install `fish` and `shellcheck` for `run-all-checks.sh` to pass successfully.
 tox -e py
 ```
 
+To display rich diff when tests fail, use
+```shell
+tox -e py -- -k github -vv  # also, use -k for tests whose names contain a specific string
+```
+
 To display full operands in failed assertions (rather than just diffs), use
 ```shell
-tox -e py -- -k github --full-operands
+tox -e py -- -k "hub or lab" --full-operands  # logical expressions are also allowed in -k
 ```
 
 ## Install locally for development purposes
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index b185e2c..5ccbbfe 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,8 @@
 # Release notes
 
-## New in git-machete 3.34.2
+## New in git-machete 3.35.0
+
+- added: `git machete traverse --sync-github-prs`/`--sync-gitlab-mrs` suggests creating PRs/MRs for untracked branches (suggested by @bradneuman and @tir38)
 
 ## New in git-machete 3.34.1
 
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index 89d1ff2..fab6fe4 100644
--- a/docs/man/git-machete.1
+++ b/docs/man/git-machete.1
@@ -29,7 +29,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
 ..
 .TH "GIT-MACHETE" "1" "May 13, 2025" "" "git-machete"
 .SH NAME
-git-machete \- git-machete 3.34.2
+git-machete \- git-machete 3.35.0
 .sp
 git machete is a robust tool that \fBsimplifies your git workflows\fP\&.
 .sp
@@ -2105,6 +2105,8 @@ asks the user whether to \fBpull\fP the branch;
 if \fB\-H\fP/\fB\-\-sync\-github\-prs\fP or \fB\-L\fP/\fB\-\-sync\-gitlab\-mrs\fP option is present:
 .INDENT 2.0
 .IP \(bu 2
+asks the user whether to \fBcreate\fP a PR/MR for the given branch if it\(aqs untracked,
+.IP \(bu 2
 asks the user whether to \fBretarget\fP the PR/MR if it exists for the given branch,
 and its base/target branch in GitHub/GitLab is different than the upstream in machete file
 (just as \fBgit machete github retarget\-pr\fP and \fBgit machete gitlab retarget\-mr\fP would do);
diff --git a/docs/source/cli/traverse.rst b/docs/source/cli/traverse.rst
index c504ced..108b809 100644
--- a/docs/source/cli/traverse.rst
+++ b/docs/source/cli/traverse.rst
@@ -55,6 +55,8 @@ For each branch, the command:
 
 * if ``-H``/``--sync-github-prs`` or ``-L``/``--sync-gitlab-mrs`` option is present:
 
+  - asks the user whether to **create** a PR/MR for the given branch if it's untracked,
+
   - asks the user whether to **retarget** the PR/MR if it exists for the given branch,
     and its base/target branch in GitHub/GitLab is different than the upstream in machete file
     (just as ``git machete github retarget-pr`` and ``git machete gitlab retarget-mr`` would do);
diff --git a/git_machete/__init__.py b/git_machete/__init__.py
index 5bec2c2..7637d8d 100644
--- a/git_machete/__init__.py
+++ b/git_machete/__init__.py
@@ -1,1 +1,1 @@
-__version__ = '3.34.2'
+__version__ = '3.35.0'
diff --git a/git_machete/cli.py b/git_machete/cli.py
index f3ecdf3..8f49e00 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -799,10 +799,13 @@ def launch(orig_args: List[str]) -> None:
                     fail_on_missing_current_user_for_my_open_prs=True)
             elif subcommand == f"create-{pr_or_mr}":
                 current_branch = git.get_current_branch()
+                try:
+                    github_or_gitlab_client.sync_before_creating_pull_request(opt_yes=cli_opts.opt_yes)
+                except InteractionStopped:
+                    return
                 github_or_gitlab_client.create_pull_request(
                     head=current_branch,
                     opt_draft=cli_opts.opt_draft,
-                    opt_onto=cli_opts.opt_onto,
                     opt_title=cli_opts.opt_title,
                     opt_update_related_descriptions=cli_opts.opt_update_related_descriptions,
                     opt_yes=cli_opts.opt_yes)
@@ -1001,9 +1004,9 @@ def launch(orig_args: List[str]) -> None:
 def main() -> None:
     try:
         launch(sys.argv[1:])
-    except EOFError:
+    except EOFError:  # pragma: no cover
         sys.exit(ExitCode.END_OF_FILE_SIGNAL)
-    except KeyboardInterrupt:
+    except KeyboardInterrupt:  # pragma: no cover
         sys.exit(ExitCode.KEYBOARD_INTERRUPT)
     except (MacheteException, UnderlyingGitException) as e:
         print(e, file=sys.stderr)
diff --git a/git_machete/client/traverse.py b/git_machete/client/traverse.py
index 5f0ffeb..1efef77 100644
--- a/git_machete/client/traverse.py
+++ b/git_machete/client/traverse.py
@@ -94,6 +94,7 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
             else:
                 needs_remote_sync = False
 
+            needs_retarget_pr = False
             if opt_sync_github_prs or opt_sync_gitlab_mrs:
                 prs = list(filter(lambda pr: pr.head == branch, self._get_all_open_prs()))
                 if len(prs) > 1:
@@ -102,9 +103,14 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
                         f"Multiple {spec.pr_short_name}s have <b>{branch}</b> as its {spec.head_branch_name} branch: " +
                         ", ".join(_pr.short_display_text() for _pr in prs))
                 pr = prs[0] if prs else None
-                needs_retarget_pr = pr and upstream and pr.base != upstream
-            else:
-                needs_retarget_pr = False
+                needs_retarget_pr = pr is not None and upstream is not None and pr.base != upstream
+
+            needs_create_pr = False
+            if opt_sync_github_prs or opt_sync_gitlab_mrs:
+                if upstream:
+                    prs = [_pr for _pr in self._get_all_open_prs() if _pr.head == branch]
+                    if not prs:
+                        needs_create_pr = True
 
             use_merge = opt_merge or (branch in self.annotations and self.annotations[branch].qualifiers.update_with_merge)
 
@@ -261,15 +267,15 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
                 assert upstream is not None
                 spec = self.code_hosting_spec
                 self._print_new_line(False)
-                ans_intro = f"Branch {bold(str(branch))} has a different {spec.pr_short_name} {spec.base_branch_name} ({bold(pr.base)}) " \
-                    f"in {spec.display_name} than in machete file ({bold(str(upstream))}).\n"
+                ans_intro = f"Branch {bold(branch)} has a different {spec.pr_short_name} {spec.base_branch_name} ({bold(pr.base)}) " \
+                    f"in {spec.display_name} than in machete file ({bold(upstream)}).\n"
                 ans = self.ask_if(
-                    ans_intro + f"Retarget {pr.display_text()} to {bold(str(upstream))}?" + get_pretty_choices('y', 'N', 'q', 'yq'),
-                    ans_intro + f"Retargeting {pr.display_text()} to {bold(str(upstream))}...",
+                    ans_intro + f"Retarget {pr.display_text()} to {bold(upstream)}?" + get_pretty_choices('y', 'N', 'q', 'yq'),
+                    ans_intro + f"Retargeting {pr.display_text()} to {bold(upstream)}...",
                     opt_yes=opt_yes)
                 if ans in ('y', 'yes', 'yq'):
                     self.code_hosting_client.set_base_of_pull_request(pr.number, base=upstream)
-                    print(f'{spec.base_branch_name.capitalize()} branch of {pr.display_text()} has been switched to {bold(str(upstream))}')
+                    print(f'{spec.base_branch_name.capitalize()} branch of {pr.display_text()} has been switched to {bold(upstream)}')
                     pr.base = upstream
 
                     anno = self._state.annotations.get(branch)
@@ -333,6 +339,31 @@ class TraverseMacheteClient(MacheteClientWithCodeHosting):
                 except InteractionStopped:
                     return
 
+            if needs_create_pr:
+                any_action_suggested = True
+                assert upstream is not None
+                spec = self.code_hosting_spec
+                self._print_new_line(False)
+                ans_intro = f"Branch {bold(branch)} does not have {spec.pr_short_name_article} {spec.pr_short_name}" \
+                    f" in {spec.display_name}.\n"
+                ans = self.ask_if(
+                    ans_intro + f"Create {spec.pr_short_name_article} {spec.pr_short_name} "
+                    f"from {bold(branch)} to {bold(upstream)}?" + get_pretty_choices('y', 'd[raft]', 'N', 'q', 'yq'),
+                    ans_intro + f"Creating {spec.pr_short_name_article} {spec.pr_short_name} "
+                    f"from {bold(branch)} to {bold(upstream)}...",
+                    opt_yes=opt_yes)
+                if ans in ('y', 'yes', 'yq', 'd', 'draft'):
+                    self.create_pull_request(
+                        head=current_branch,
+                        opt_draft=(ans in ('d', 'draft')),
+                        opt_title=None,
+                        opt_update_related_descriptions=True,
+                        opt_yes=opt_yes)
+                    if ans == 'yq':
+                        return
+                elif ans in ('q', 'quit'):
+                    return
+
         if opt_return_to == TraverseReturnTo.HERE:
             self._git.checkout(initial_branch)
         elif opt_return_to == TraverseReturnTo.NEAREST_REMAINING:
diff --git a/git_machete/client/with_code_hosting.py b/git_machete/client/with_code_hosting.py
index 1208028..b02d4ef 100644
--- a/git_machete/client/with_code_hosting.py
+++ b/git_machete/client/with_code_hosting.py
@@ -11,8 +11,7 @@ from git_machete.code_hosting import (CodeHostingClient, CodeHostingSpec,
                                       OrganizationAndRepository,
                                       OrganizationAndRepositoryAndRemote,
                                       PullRequest, is_matching_remote_url)
-from git_machete.exceptions import (InteractionStopped, MacheteException,
-                                    UnexpectedMacheteException)
+from git_machete.exceptions import MacheteException, UnexpectedMacheteException
 from git_machete.git_operations import (GitContext, GitFormatPatterns,
                                         GitLogEntry, LocalBranchShortName,
                                         RemoteBranchShortName,
@@ -120,7 +119,7 @@ class MacheteClientWithCodeHosting(MacheteClient):
                 return remote
         return None
 
-    def __sync_before_creating_pull_request(self, *, opt_onto: Optional[LocalBranchShortName], opt_yes: bool) -> None:
+    def sync_before_creating_pull_request(self, *, opt_yes: bool) -> None:
         spec = self.code_hosting_spec
         self.expect_at_least_one_managed_branch()
         self._set_empty_line_status()
@@ -128,7 +127,7 @@ class MacheteClientWithCodeHosting(MacheteClient):
         current_branch = self._git.get_current_branch()
         if current_branch not in self.managed_branches:
             self.add(branch=current_branch,
-                     opt_onto=opt_onto,
+                     opt_onto=None,
                      opt_as_first_child=False,
                      opt_as_root=False,
                      opt_yes=opt_yes,
@@ -231,17 +230,11 @@ class MacheteClientWithCodeHosting(MacheteClient):
             *,
             head: LocalBranchShortName,
             opt_draft: bool,
-            opt_onto: Optional[LocalBranchShortName],
             opt_title: Optional[str],
             opt_update_related_descriptions: bool,
             opt_yes: bool
     ) -> None:
         # first make sure that head branch is synced with remote
-        try:
-            self.__sync_before_creating_pull_request(opt_onto=opt_onto, opt_yes=opt_yes)
-        except InteractionStopped:
-            return
-
         base: Optional[LocalBranchShortName] = self.up_branch_for(LocalBranchShortName.of(head))
         spec = self.code_hosting_spec
         if not base:
@@ -745,8 +738,13 @@ class MacheteClientWithCodeHosting(MacheteClient):
         prepend += f'{self.END_GIT_MACHETE_GENERATED_COMMENT}\n'
         return prepend
 
-    def update_pull_request_descriptions(self, *, all: bool = False, by: Optional[str] = None, mine: bool = False, related: bool = False
-                                         ) -> None:
+    def update_pull_request_descriptions(
+        self, *,
+            all: bool = False,
+            by: Optional[str] = None,
+            mine: bool = False,
+            related: bool = False
+    ) -> None:
         spec = self.code_hosting_spec
         if self.__code_hosting_client is None:
             self._init_code_hosting_client()
@@ -772,14 +770,15 @@ class MacheteClientWithCodeHosting(MacheteClient):
                 pr.description = new_description
                 print(fmt(f'Description of {pr.display_text()} (<b>{pr.head} {get_right_arrow()} {pr.base}</b>) has been updated'))
 
-    def checkout_pull_requests(self,
-                               pr_numbers: Optional[List[int]],
-                               *,
-                               all: bool = False,
-                               mine: bool = False,
-                               by: Optional[str] = None,
-                               fail_on_missing_current_user_for_my_open_prs: bool = False
-                               ) -> None:
+    def checkout_pull_requests(
+        self,
+            pr_numbers: Optional[List[int]],
+            *,
+            all: bool = False,
+            by: Optional[str] = None,
+            mine: bool = False,
+            fail_on_missing_current_user_for_my_open_prs: bool = False
+    ) -> None:
         spec = self.code_hosting_spec
         domain, org_repo_remote = self._init_code_hosting_client()
 
diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py
index aa93b6e..e86f501 100644
--- a/git_machete/generated_docs.py
+++ b/git_machete/generated_docs.py
@@ -1483,6 +1483,7 @@ long_docs: Dict[str, str] = {
            * otherwise, if the branch is behind its remote counterpart:
               - asks the user whether to <b>pull</b> the branch;
            * if `-H`/`--sync-github-prs` or `-L`/`--sync-gitlab-mrs` option is present:
+              - asks the user whether to <b>create</b> a PR/MR for the given branch if it's untracked,
               - asks the user whether to <b>retarget</b> the PR/MR if it exists for the given branch,
                 and its base/target branch in GitHub/GitLab is different than the upstream in machete file
                 (just as `git machete github retarget-pr` and `git machete gitlab retarget-mr` would do);
diff --git a/git_machete/utils.py b/git_machete/utils.py
index 3a3da71..71049f5 100644
--- a/git_machete/utils.py
+++ b/git_machete/utils.py
@@ -379,7 +379,7 @@ def get_pretty_choices(*choices: str) -> str:
         else:
             return colored(c, AnsiEscapeCodes.ORANGE)
 
-    return f" ({', '.join(map_truthy_only(format_choice, choices))}) "
+    return " (" + (", ".join(map_truthy_only(format_choice, choices))) + ") "
 
 
 def get_current_date() -> str:
diff --git a/tox.ini b/tox.ini
index 348800b..7522592 100644
--- a/tox.ini
+++ b/tox.ini
@@ -119,9 +119,7 @@ relative_files = True
 
 [coverage:report]
 exclude_lines =
-  except EOFError:
   except InterruptedError:
-  except KeyboardInterrupt:
   if __name__ == .__main__.:
   if sys.platform == .win32.:
   pragma: no cover


Test patch:
diff --git a/tests/pytest_full_operands.py b/tests/pytest_full_operands.py
index 6de3672..b70a00c 100644
--- a/tests/pytest_full_operands.py
+++ b/tests/pytest_full_operands.py
@@ -20,12 +20,13 @@ def pytest_addoption(parser):
 def pytest_assertrepr_compare(config, op: str, left, right):
     if op == "==" and config.getoption(OPTION):
         def lines_for(arg):
-            return [''] + ['  ' + x for x in arg.splitlines()] + ['']
+            return [''] + ['    ' + x for x in arg.splitlines()] + ['']
         return [
             'Comparing values:',
-            'left (typically meaning actual):', *lines_for(left),
-            'right (typically meaning expected):', *lines_for(right)
+            'LEFT (typically means actual):', *lines_for(left),
+            'RIGHT (typically means expected):', *lines_for(right)
         ]
+    return None
 
 
 def pytest_configure(config):
diff --git a/tests/test_traverse_github.py b/tests/test_traverse_github.py
index 61dc6bc..c73f2e4 100644
--- a/tests/test_traverse_github.py
+++ b/tests/test_traverse_github.py
@@ -55,7 +55,7 @@ class TestTraverseGitHub(BaseTest):
             mock_pr_json(head='call-ws', base='build-chain', number=3),
         )
 
-    def test_traverse_sync_github_prs(self, mocker: MockerFixture) -> None:
+    def test_traverse_sync_retarget_github_prs(self, mocker: MockerFixture) -> None:
         self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url)
         self.patch_symbol(mocker, "git_machete.github.GitHubToken.for_domain", mock_github_token_for_domain_fake)
         self.patch_symbol(mocker, 'git_machete.utils.get_current_date', lambda: '2023-12-31')
@@ -230,3 +230,129 @@ class TestTraverseGitHub(BaseTest):
             No successor of build-chain needs to be slid out or synced with upstream branch or remote; nothing left to update
             Returned to the initial branch build-chain
             """)
+
+    def test_traverse_sync_create_github_prs(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        github_api_state = MockGitHubAPIState.with_prs(mock_pr_json(head='call-ws', base='build-chain', number=1))
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(github_api_state))
+
+        create_repo_with_remote()
+        new_branch("develop")
+        commit()
+        push()
+        new_branch("allow-ownership-link")
+        commit()
+        new_branch("build-chain")
+        commit()
+        new_branch("call-ws")
+        commit()
+        push()
+        new_branch("drop-constraint")
+
+        body: str = \
+            """
+            develop
+                allow-ownership-link
+                    build-chain
+                        call-ws  PR #1
+                            drop-constraint
+            """
+        rewrite_branch_layout_file(body)
+        check_out("develop")
+
+        self.patch_symbol(mocker, 'builtins.input', mock_input_returning("y", "q"))
+        assert_success(
+            ["traverse", "--sync-github-prs"],
+            """
+            Checking for open GitHub PRs... OK
+            Checking out allow-ownership-link
+
+              develop
+              |
+              o-allow-ownership-link * (untracked)
+                |
+                o-build-chain (untracked)
+                  |
+                  o-call-ws  PR #1
+                    |
+                    o-drop-constraint (untracked)
+
+            Push untracked branch allow-ownership-link to origin? (y, N, q, yq)
+
+            Branch allow-ownership-link does not have a PR in GitHub.
+            Create a PR from allow-ownership-link to develop? (y, d[raft], N, q, yq)
+            """
+        )
+
+        self.patch_symbol(mocker, 'builtins.input', mock_input_returning("yq"))
+        assert_success(
+            ["traverse", "--sync-github-prs"],
+            """
+            Checking for open GitHub PRs... OK
+            Branch allow-ownership-link does not have a PR in GitHub.
+            Create a PR from allow-ownership-link to develop? (y, d[raft], N, q, yq)
+            Checking if base branch develop exists in origin remote... YES
+            Creating a PR from allow-ownership-link to develop... OK, see www.github.com
+            Adding github_user as assignee to PR #2... OK
+            Updating descriptions of other PRs...
+            """
+        )
+
+        self.patch_symbol(mocker, 'builtins.input', mock_input_returning("y", "", "y", "draft"))
+        assert_success(
+            ["traverse", "--sync-github-prs"],
+            """
+            Checking for open GitHub PRs... OK
+            Checking out build-chain
+
+              develop
+              |
+              o-allow-ownership-link  PR #2 (some_other_user)
+                |
+                o-build-chain * (untracked)
+                  |
+                  o-call-ws  PR #1
+                    |
+                    o-drop-constraint (untracked)
+
+            Push untracked branch build-chain to origin? (y, N, q, yq)
+
+            Branch build-chain does not have a PR in GitHub.
+            Create a PR from build-chain to allow-ownership-link? (y, d[raft], N, q, yq)
+
+            Checking out drop-constraint
+
+              develop
+              |
+              o-allow-ownership-link  PR #2 (some_other_user)
+                |
+                o-build-chain
+                  |
+                  o-call-ws  PR #1
+                    |
+                    o-drop-constraint * (untracked)
+
+            Push untracked branch drop-constraint to origin? (y, N, q, yq)
+
+            Branch drop-constraint does not have a PR in GitHub.
+            Create a PR from drop-constraint to call-ws? (y, d[raft], N, q, yq)
+            Checking if base branch call-ws exists in origin remote... YES
+            Creating a draft PR from drop-constraint to call-ws... OK, see www.github.com
+            Updating description of PR #3 to include the chain of PRs... OK
+            Adding github_user as assignee to PR #3... OK
+            Updating descriptions of other PRs...
+
+              develop
+              |
+              o-allow-ownership-link  PR #2 (some_other_user)
+                |
+                o-build-chain
+                  |
+                  o-call-ws  PR #1
+                    |
+                    o-drop-constraint *  PR #3 (some_other_user)
+
+            Reached branch drop-constraint which has no successor; nothing left to update
+            """
+        )