Repository: VirtusLab/git-machete
Pull request #1061 (2023-09-24 15:06:53)

Add `git machete github restack-pr` subcommand

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1061?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) Report
All modified lines are covered by tests :white_check_mark:
> Comparison is base [(`1604d38`)](https://app.codecov.io/gh/VirtusLab/git-machete/commit/1604d38a4a452bcabdf3cb36b4e5f74a75aadd96?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) 98.75% compared to head [(`1604d38`)](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1061?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VirtusLab) 98.75%.

> :exclamation: Current head 1604d38 differs from pull request most recent head 216270c. Consider uploading reports for the commit 216270c to get more accurate results

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


```diff
@@                       Coverage Diff                        @@
##           feature/999-base-pr-description    #1061   +/-   ##
================================================================
  Coverage                            98.75%   98.75%           
================================================================
  Files                                   12       12           
  Lines                                 3204     3204           
  Branches                               722      722           
================================================================
  Hits                                  3164     3164           
  Misses                                  25       25           
  Partials                                15       15           
```




</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/VirtusLab/git-machete/pull/1061?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/RELEASE_NOTES.md b/RELEASE_NOTES.md
index ea6f1ce..b005a2e 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -3,6 +3,7 @@
 ## New in git-machete 3.20.0
 
 - added: `git machete github create-pr` adds a comment linking the PR to its base PR; `github retarget-pr` keeps that comment up to date (suggested by @guyboltonking)
+- added: new subcommand `git machete github restack-pr`, which first marks the PR as draft, then (force-)pushes branch, then retargets PR, then marks as ready for review again (suggested by @raylu)
 
 ## New in git-machete 3.19.1
 
diff --git a/completion/git-machete.completion.bash b/completion/git-machete.completion.bash
index 845079f..9d8b6f9 100644
--- a/completion/git-machete.completion.bash
+++ b/completion/git-machete.completion.bash
@@ -6,7 +6,7 @@ _git_machete() {
 
   local categories="addable childless managed slidable slidable-after unmanaged with-overridden-fork-point"
   local directions="down first last next prev root up"
-  local github_subcommands="anno-prs checkout-prs create-pr retarget-pr sync"
+  local github_subcommands="anno-prs checkout-prs create-pr restack-pr retarget-pr sync"
   local locations="current $directions"
   local opt_color_args="always auto never"
   local opt_return_to_args="here nearest-remaining stay"
diff --git a/completion/git-machete.completion.zsh b/completion/git-machete.completion.zsh
index 66efc4c..2481aac 100644
--- a/completion/git-machete.completion.zsh
+++ b/completion/git-machete.completion.zsh
@@ -252,6 +252,7 @@ __git_machete_github_subcommands() {
         'anno-prs:annotate the branches based on their corresponding GitHub PR numbers and authors'
         'checkout-prs:check out the given pull requests locally'
         'create-pr:create a PR for the current branch, using the upstream (parent) branch as the PR base'
+        'restack-pr:retarget and (force-)push the PR, without adding code owners as reviewers in the process'
         'retarget-pr:set the base of the current branch PR to upstream (parent) branch'
         'sync:synchronize with the remote repository: checkout open PRs for the current user associated with the GitHub token, delete unmanaged branches and also delete untracked managed branches with no downstream branch'
       )
diff --git a/completion/git-machete.fish b/completion/git-machete.fish
index 376491a..4563087 100644
--- a/completion/git-machete.fish
+++ b/completion/git-machete.fish
@@ -97,15 +97,16 @@ complete -c git-machete -n "__fish_seen_subcommand_from fork-point; and not __fi
 
 # git machete github
 complete -c git-machete -n "not __fish_seen_subcommand_from $__machete_commands"                                                                                          -f -a github                                               -d 'Create, check out and manage GitHub PRs while keeping them reflected in git machete'
-complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr retarget-pr sync"                     -f -a anno-prs                                             -d 'Annotates the branches based on their corresponding GitHub PR numbers and authors'
-complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr retarget-pr sync"                     -x -a checkout-prs                                         -d 'Check out the head branch of the given pull requests (specified by number), also traverse chain of pull requests upwards, adding branches one by one to git-machete and check them out locally'
-complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr retarget-pr sync"                     -f -a create-pr                                            -d 'Creates a PR for the current branch, using the upstream (parent) branch as the PR base'
-complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr retarget-pr sync"                     -f -a retarget-pr                                          -d 'Sets the base of PR for the current branch to upstream (parent) branch, as seen by git machete (see git machete show up)'
-complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr retarget-pr sync"                     -f -a sync                                                 -d 'Synchronize with the remote repository: checkout open PRs for the current user associated with the GitHub token, delete unmanaged branches and also delete untracked managed branches with no downstream branch'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr restack-pr retarget-pr sync"          -f -a anno-prs                                             -d 'Annotates the branches based on their corresponding GitHub PR numbers and authors'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr restack-pr retarget-pr sync"          -x -a checkout-prs                                         -d 'Check out the head branch of the given pull requests (specified by number), also traverse chain of pull requests upwards, adding branches one by one to git-machete and check them out locally'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr restack-pr retarget-pr sync"          -f -a create-pr                                            -d 'Creates a PR for the current branch, using the upstream (parent) branch as the PR base'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr restack-pr retarget-pr sync"          -f -a restack-pr                                           -d 'Retargets and (force-)pushes the PR, without adding code owners as reviewers in the process'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr restack-pr retarget-pr sync"          -f -a retarget-pr                                          -d 'Sets the base of PR for the current branch to upstream (parent) branch, as seen by git machete (see git machete show up)'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and not __fish_seen_subcommand_from anno-prs checkout-prs create-pr restack-pr retarget-pr sync"          -f -a sync                                                 -d 'Synchronize with the remote repository: checkout open PRs for the current user associated with the GitHub token, delete unmanaged branches and also delete untracked managed branches with no downstream branch'
 complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from checkout-prs; and not __fish_seen_subcommand_from --all"                  -f -l all                                                  -d 'Checkout all open PRs'
 complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from checkout-prs; and not __fish_seen_subcommand_from --by"                   -x -l by                                                   -d "Checkout someone's open PRs"
 complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from checkout-prs; and not __fish_seen_subcommand_from --mine"                 -x -l mine                                                 -d 'Checkout open PRs for the current user associated with the GitHub token'
-complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from create-pr; and not __fish_seen_subcommand_from --draft"                   -f -l draft                                                -d 'Creates the new PR as a draft'
+complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from create-pr;    and not __fish_seen_subcommand_from --draft"                -f -l draft                                                -d 'Creates the new PR as a draft'
 complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from retarget-pr; and not __fish_seen_subcommand_from --branch"                -x -l branch -s b       -a '(__machete_managed_branches)'  -d 'Specify the branch for which the associated PR base will be set to its upstream (parent) branch'
 complete -c git-machete -n "__fish_seen_subcommand_from github; and __fish_seen_subcommand_from retarget-pr; and not __fish_seen_subcommand_from --ignore-if-missing"     -f -l ignore-if-missing                                    -d 'Ignore errors and quietly terminate execution if there is no PR opened for current (or specified) branch'
 
diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1
index 8249620..04e6fa9 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" "Oct 19, 2023" "" "git-machete"
+.TH "GIT-MACHETE" "1" "Oct 20, 2023" "" "git-machete"
 .SH NAME
 git-machete \- git-machete 3.20.0
 .sp
@@ -1121,6 +1121,16 @@ If the newly\-created PR is stacked atop another PR, the actual PR description p
 Creates the new PR as a draft.
 .UNINDENT
 .TP
+.B \fBrestack\-pr\fP:
+Perform the following sequence of actions:
+1. If the PR for the current branch is ready for review, it gets converted to a draft.
+2. The branch is (force\-)pushed into remote.
+3. The PR is retargeted to its upstream (parent) branch, as in \fBretarget\-pr\fP\&.
+4. If the PR has been converted to draft in step 1, it\(aqs reverted to ready for review state.
+.sp
+The drafting/undrafting is useful in case the GitHub repository has set up \fI\%CODEOWNERS\fP\&.
+Draft PRs don\(aqt get code owners automatically added as reviewers.
+.TP
 .B \fBretarget\-pr [\-b|\-\-branch=<branch>] [\-\-ignore\-if\-missing]\fP:
 Sets the base of the current (or specified) branch\(aqs PR to upstream (parent) branch, as seen by git machete (see \fBgit machete show up\fP).
 .sp
diff --git a/docs/source/cli/github.rst b/docs/source/cli/github.rst
index baa716e..1e61a80 100644
--- a/docs/source/cli/github.rst
+++ b/docs/source/cli/github.rst
@@ -61,6 +61,16 @@ Creates, checks out and manages GitHub PRs while keeping them reflected in branc
 
     --draft    Creates the new PR as a draft.
 
+``restack-pr``:
+    Perform the following sequence of actions:
+    1. If the PR for the current branch is ready for review, it gets converted to a draft.
+    2. The branch is (force-)pushed into remote.
+    3. The PR is retargeted to its upstream (parent) branch, as in ``retarget-pr``.
+    4. If the PR has been converted to draft in step 1, it's reverted to ready for review state.
+
+    The drafting/undrafting is useful in case the GitHub repository has set up `CODEOWNERS <https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners>`_.
+    Draft PRs don't get code owners automatically added as reviewers.
+
 ``retarget-pr [-b|--branch=<branch>] [--ignore-if-missing]``:
     Sets the base of the current (or specified) branch's PR to upstream (parent) branch, as seen by git machete (see ``git machete show up``).
 
diff --git a/git_machete/cli.py b/git_machete/cli.py
index a3c1f96..f3809a2 100644
--- a/git_machete/cli.py
+++ b/git_machete/cli.py
@@ -241,7 +241,7 @@ def create_cli_parser() -> argparse.ArgumentParser:
         usage=argparse.SUPPRESS,
         add_help=False,
         parents=[common_args_parser])
-    github_parser.add_argument('subcommand', choices=['anno-prs', 'checkout-prs', 'create-pr', 'retarget-pr', 'sync'])
+    github_parser.add_argument('subcommand', choices=['anno-prs', 'checkout-prs', 'create-pr', 'restack-pr', 'retarget-pr', 'sync'])
     github_parser.add_argument('pr_no', nargs='*', type=int)
     github_parser.add_argument('-b', '--branch', default=argparse.SUPPRESS)
     github_parser.add_argument('--all', action='store_true')
@@ -701,6 +701,8 @@ def launch(orig_args: List[str]) -> None:
                     head=current_branch,
                     opt_draft=cli_opts.opt_draft,
                     opt_onto=cli_opts.opt_onto)
+            elif github_subcommand == "restack-pr":
+                machete_client.restack_github_pr()
             elif github_subcommand == "retarget-pr":
                 branch = parsed_cli.branch if 'branch' in parsed_cli else git.get_current_branch()
                 machete_client.expect_in_managed_branches(branch)
diff --git a/git_machete/client.py b/git_machete/client.py
index 7de2097..c3fd030 100644
--- a/git_machete/client.py
+++ b/git_machete/client.py
@@ -2179,6 +2179,90 @@ class MacheteClient:
                 return remote
         return None
 
+    def restack_github_pr(self) -> None:
+        domain = self.__derive_github_domain()
+        head = self.__git.get_current_branch()
+        org_repo_remote = self.__derive_org_repo_and_remote(domain=domain, branch_used_for_tracking_data=head)
+        github_client = GitHubClient(domain=domain, organization=org_repo_remote.organization, repository=org_repo_remote.repository)
+
+        prs: List[GitHubPullRequest] = github_client.get_open_pull_requests_by_head(head)
+        if not prs:
+            raise MacheteException(f"No PRs have `{head}` as its head")
+        if len(prs) > 1:
+            raise MacheteException(f"Multiple PRs have `{head}` as its head: " + ", ".join(f"#{_pr.number}" for _pr in prs))
+        pr = prs[0]
+
+        self.__git.fetch_remote(org_repo_remote.remote)
+
+        self.__empty_line_status = True
+        current_branch = self.__git.get_current_branch()
+        s, remote = self.__git.get_combined_remote_sync_status(current_branch)
+        statuses_to_push = (
+            SyncToRemoteStatuses.UNTRACKED,
+            SyncToRemoteStatuses.AHEAD_OF_REMOTE,
+            SyncToRemoteStatuses.DIVERGED_FROM_AND_NEWER_THAN_REMOTE)
+        if s in statuses_to_push:
+            if current_branch in self.annotations and not self.annotations[current_branch].qualifiers.push:
+                warn(f'Branch <b>{current_branch}</b> is marked as `push=no`; skipping the push.\n'
+                     'Did you want to just use `git machete github retarget-pr`?\n')
+
+                self.retarget_github_pr(head, ignore_if_missing=False)
+            else:
+                converted_to_draft = github_client.set_draft_status_of_pull_request(pr.number, target_draft_status=True)
+                if converted_to_draft:
+                    print(f'PR #{bold(str(pr.number))} has been temporarily marked as draft')
+
+                if s == SyncToRemoteStatuses.AHEAD_OF_REMOTE:
+                    assert remote is not None
+                    self.__handle_ahead_state(
+                        current_branch=current_branch,
+                        remote=remote,
+                        is_called_from_traverse=False,
+                        opt_push_tracked=True,
+                        opt_yes=True)
+                elif s == SyncToRemoteStatuses.UNTRACKED:
+                    self.__handle_untracked_state(
+                        branch=current_branch,
+                        is_called_from_traverse=False,
+                        is_called_from_github=True,
+                        opt_push_tracked=True,
+                        opt_push_untracked=True,
+                        opt_yes=True)
+                elif s == SyncToRemoteStatuses.DIVERGED_FROM_AND_NEWER_THAN_REMOTE:  # pragma: no branch
+                    assert remote is not None
+                    self.__handle_diverged_and_newer_state(
+                        current_branch=current_branch,
+                        remote=remote,
+                        is_called_from_traverse=False,
+                        opt_push_tracked=True,
+                        opt_yes=True)
+
+                self.__print_new_line(False)
+                self.status(
+                    warn_when_branch_in_sync_but_fork_point_off=True,
+                    opt_list_commits=False,
+                    opt_list_commits_with_hashes=False,
+                    opt_no_detect_squash_merges=False)
+                self.__print_new_line(False)
+
+                self.retarget_github_pr(head, ignore_if_missing=False)
+
+                if converted_to_draft:
+                    github_client.set_draft_status_of_pull_request(prs[0].number, target_draft_status=False)
+                    print(f'PR #{bold(str(pr.number))} has been marked as ready for review again')
+
+        else:
+            if s == SyncToRemoteStatuses.BEHIND_REMOTE:
+                warn(f"Branch {bold(current_branch)} is behind its remote counterpart. Consider using `git pull`.\n")
+            elif s == SyncToRemoteStatuses.DIVERGED_FROM_AND_OLDER_THAN_REMOTE:
+                warn(f"Branch {bold(current_branch)} is diverged from and older than its remote counterpart. "
+                     "Consider using `git reset --keep`.\n")
+            elif s == SyncToRemoteStatuses.NO_REMOTES:  # pragma: no cover; case handled elsewhere
+                raise MacheteException(
+                    "Could not retarget pull request - there are no remote repositories!")
+
+            self.retarget_github_pr(head, ignore_if_missing=False)
+
     @staticmethod
     def __get_updated_github_pr_description(old_description: Optional[str], pr_number_for_base_branch: Optional[int]) -> str:
         def skip_leading_empty(strs: List[str]) -> List[str]:
diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py
index bd8df88..86505ad 100644
--- a/git_machete/generated_docs.py
+++ b/git_machete/generated_docs.py
@@ -628,6 +628,17 @@ long_docs: Dict[str, str] = {
                  <b>--draft</b>
               Creates the new PR as a draft.
 
+           `restack-pr`:
+ 
+              Perform the following sequence of actions:
+              1. If the PR for the current branch is ready for review, it gets converted to a draft.
+              2. The branch is (force-)pushed into remote.
+              3. The PR is retargeted to its upstream (parent) branch, as in `retarget-pr`.
+              4. If the PR has been converted to draft in step 1, it's reverted to ready for review state.
+
+              The drafting/undrafting is useful in case the GitHub repository has set up CODEOWNERS.
+              Draft PRs don't get code owners automatically added as reviewers.
+
            `retarget-pr [-b|--branch=<branch>] [--ignore-if-missing]`:
  
               Sets the base of the current (or specified) branch's PR to upstream (parent) branch, as seen by git machete (see `git machete show up`).
diff --git a/git_machete/github.py b/git_machete/github.py
index 75abb2f..f86d635 100644
--- a/git_machete/github.py
+++ b/git_machete/github.py
@@ -11,7 +11,7 @@ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
 
 from .exceptions import MacheteException, UnprocessableEntityHTTPError
 from .git_operations import GitContext, LocalBranchShortName
-from .utils import bold, debug, fmt, popen_cmd, warn
+from .utils import bold, compact_dict, debug, fmt, popen_cmd, warn
 
 
 class GitHubPullRequest(NamedTuple):
@@ -228,6 +228,8 @@ class GitHubClient:
 
         if self.__domain == self.DEFAULT_GITHUB_DOMAIN:
             url_prefix = 'https://api.' + self.__domain
+        elif path == '/graphql':
+            url_prefix = 'https://' + self.__domain + '/api'
         else:
             url_prefix = 'https://' + self.__domain + '/api/v3'
 
@@ -235,7 +237,7 @@ class GitHubClient:
         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>"}')
+              f'bearer token and request body {compact_dict(request_body) if request_body else "<none>"}')
 
         try:
             with urllib.request.urlopen(http_request) as response:
@@ -308,6 +310,9 @@ class GitHubClient:
         except OSError as e:  # pragma: no cover
             raise MacheteException(f'Could not connect to {url_prefix}: {e}')
 
+    def __fire_github_graphql_api_request(self, query: str) -> Any:
+        return self.__fire_github_api_request(method='POST', path='/graphql', request_body={"query": query})
+
     @staticmethod
     def __extract_failure_info_from_422(response: Any) -> str:
         if response['message'] != 'Validation Failed':
@@ -371,6 +376,50 @@ class GitHubClient:
                                        path=f'/repos/{self.__organization}/{self.__repository}/issues/{number}',
                                        request_body=request_body)
 
+    # As of September 2023, REST (v3) GitHub API does **not** allow for setting PR draft status,
+    # only for creating a draft PR or retrieving draft status on an existing PR.
+    # See https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#update-a-pull-request
+    # and https://github.com/orgs/community/discussions/45174.
+    # GraphQL (v4) API mutation needs to be used for that purpose.
+    def set_draft_status_of_pull_request(self, number: int, target_draft_status: bool) -> bool:
+        """Returns true if PR had a different draft status, and draft status has been toggled.
+        Returns false if PR already had the desired draft status, and hence draft status has NOT been toggled."""
+
+        # This query is required to get the GraphQL-specific id of the PR
+        query = f"""query {{
+            repository(owner: "{self.organization}", name: "{self.repository}") {{
+                pullRequest(number: {number}) {{
+                    id
+                    isDraft
+                }}
+            }}
+        }}"""
+        response = self.__fire_github_graphql_api_request(query)
+        debug(f"query response is {response}")
+        is_draft = response["data"]["repository"]["pullRequest"]["isDraft"]
+        if is_draft and target_draft_status is True:
+            debug(f"PR #{number} is already a draft")
+            return False
+        if not is_draft and target_draft_status is False:
+            debug(f"PR #{number} is already ready for review")
+            return False
+
+        # Ids are of the form "PR_kwDOB1DpPc5bDwiF"
+        graphql_id = response["data"]["repository"]["pullRequest"]["id"]
+
+        mutation = "convertPullRequestToDraft" if target_draft_status else "markPullRequestReadyForReview"
+        query = f"""mutation {{
+            {mutation}(input: {{pullRequestId: "{graphql_id}"}}) {{
+                pullRequest {{
+                    id
+                    isDraft
+                }}
+            }}
+        }}"""
+        response = self.__fire_github_graphql_api_request(query)
+        debug(f"mutation response is {response}")
+        return True
+
     def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[GitHubPullRequest]:
         path = f'/repos/{self.__organization}/{self.__repository}/pulls?head={self.__organization}:{head}'
         prs = self.__fire_github_api_request(method='GET', path=path)
diff --git a/git_machete/utils.py b/git_machete/utils.py
index f2e3895..1ccf253 100644
--- a/git_machete/utils.py
+++ b/git_machete/utils.py
@@ -99,6 +99,10 @@ def find_executable(executable: str) -> Optional[str]:
     return None
 
 
+def compact_dict(d: Dict[str, Any]) -> Dict[str, str]:
+    return {k: re.sub('\n +', ' ', str(v), re.MULTILINE) for k, v in d.items()}
+
+
 def debug(msg: str) -> None:
     if debug_mode:
         function_name = bold(inspect.stack()[1].function)
@@ -108,10 +112,10 @@ def debug(msg: str) -> None:
         for arg, value in values.items():
             if arg in args_to_be_redacted or any(value_ in str(value) for value_ in GITHUB_TOKEN_PREFIXES):
                 values[arg] = '***'
+            if type(values[arg]) is dict:
+                values[arg] = compact_dict(values[arg])
 
-        excluded_args = {'self'}
-        allowed_args = excluding(args, excluded_args)
-        args_and_values_list = [arg + '=' + str(values[arg]) for arg in allowed_args]
+        args_and_values_list = [arg + '=' + str(values[arg]) for arg in excluding(args, {'self'})]
         args_and_values_str = ', '.join(args_and_values_list)
         args_and_values_bold_str = bold(f'({args_and_values_str})')
 


Test patch:
diff --git a/tests/completion_e2e/test_completion_e2e.py b/tests/completion_e2e/test_completion_e2e.py
index 070831f..11899f2 100644
--- a/tests/completion_e2e/test_completion_e2e.py
+++ b/tests/completion_e2e/test_completion_e2e.py
@@ -57,7 +57,7 @@ test_cases: Dict[str, str] = {
     "git machete fork-point --unset-override ":
         "develop",
     "git machete github ":
-        "anno-prs checkout-prs create-pr retarget-pr sync",
+        "anno-prs checkout-prs create-pr restack-pr retarget-pr sync",
     "git machete github checkout-prs -":
         "--all --by --debug -h --help --mine -v --verbose",
     "git machete github create-pr -":
diff --git a/tests/mockers_github.py b/tests/mockers_github.py
index e14b260..0e1860e 100644
--- a/tests/mockers_github.py
+++ b/tests/mockers_github.py
@@ -19,7 +19,8 @@ def mock_pr_json(head: str, base: str, number: int,
                  user: str = 'some_other_user',
                  html_url: str = 'www.github.com',
                  body: Optional[str] = '# Summary',
-                 state: str = 'open'
+                 state: str = 'open',
+                 draft: bool = False
                  ) -> Dict[str, Any]:
     return {
         'head': {'ref': head, 'repo': repo},
@@ -28,7 +29,8 @@ def mock_pr_json(head: str, base: str, number: int,
         'number': str(number),
         'html_url': html_url,
         'body': body,
-        'state': state
+        'state': state,
+        'draft': draft
     }
 
 
@@ -200,6 +202,30 @@ def __mock_urlopen_impl(github_api_state: MockGitHubAPIState, request: Request)
             else:
                 fill_pull_request_from_json_data(pull)
                 return MockGitHubAPIResponse(HTTPStatus.OK, pull)
+        elif parsed_url.path in ("/api/graphql", "/graphql"):  # /api/graphql for Enterprise domains
+            query_or_mutation = json_data['query']
+            if 'query {' in query_or_mutation:
+                match = re.search(r'pullRequest\(number: ([0-9]+)\)', query_or_mutation)
+                assert match is not None
+                pr_number = int(match.group(1))
+                pr = github_api_state.get_pull_by_number(pr_number)
+                assert pr is not None
+                pr_is_draft: bool = pr.get("draft") is True
+                return MockGitHubAPIResponse(HTTPStatus.OK, {
+                    # Let's just use PR number as PR GraphQL id, for simplicity
+                    'data': {'repository': {'pullRequest': {'id': str(pr_number), 'isDraft': pr_is_draft}}}
+                })
+            else:
+                match = re.search(r'([a-zA-Z]+)\(input: \{pullRequestId: "([0-9]+)"}\)', query_or_mutation)
+                assert match is not None
+                target_draft_state = match.group(1) == "convertPullRequestToDraft"
+                pr_number = int(match.group(2))
+                pr = github_api_state.get_pull_by_number(pr_number)
+                assert pr is not None
+                pr['draft'] = target_draft_state
+                return MockGitHubAPIResponse(HTTPStatus.OK, {
+                    'data': {'repository': {'pullRequest': {'id': str(pr_number), 'isDraft': target_draft_state}}}
+                })
         else:
             raise error_404()
 
diff --git a/tests/test_github_restack_pr.py b/tests/test_github_restack_pr.py
new file mode 100644
index 0000000..5677a5a
--- /dev/null
+++ b/tests/test_github_restack_pr.py
@@ -0,0 +1,296 @@
+
+from pytest_mock import MockerFixture
+
+from tests.base_test import BaseTest
+from tests.mockers import (assert_failure, assert_success,
+                           fixed_author_and_committer_date_in_past,
+                           rewrite_branch_layout_file)
+from tests.mockers_github import (MockGitHubAPIState, mock_from_url,
+                                  mock_github_token_for_domain_fake,
+                                  mock_pr_json, mock_urlopen)
+
+
+class TestGitHubRestackPR(BaseTest):
+
+    def setup_method(self) -> None:
+        super().setup_method()
+        self.github_api_state_for_test_restack_pr = MockGitHubAPIState(
+            mock_pr_json(head='feature_1', base='develop', number=14, draft=True),
+            mock_pr_json(head='feature', base='develop', number=15, body='# Based on PR #14\n# Summary'),
+            mock_pr_json(head='multiple-pr-branch', base='develop', number=16),
+            mock_pr_json(head='multiple-pr-branch', base='feature', number=17),
+        )
+
+    def test_github_restack_pr_no_prs_or_multiple_prs(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        self.repo_sandbox.new_branch("develop").commit()
+
+        assert_failure(
+            ['github', 'restack-pr'],
+            "No PRs have develop as its head"
+        )
+
+        self.repo_sandbox.new_branch("multiple-pr-branch").commit()
+        assert_failure(
+            ['github', 'restack-pr'],
+            "Multiple PRs have multiple-pr-branch as its head: #16, #17"
+        )
+
+    def test_github_restack_pr_branch_in_sync(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        (
+            self.repo_sandbox.new_branch("master")
+            .commit()
+            .new_branch("develop")
+            .commit()
+            .commit()
+            .push()
+            .new_branch('feature')
+            .commit()
+            .push()
+        )
+        body: str = \
+            """
+            master
+                develop
+                feature
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            Base branch of PR #15 has been switched to master
+            Base PR header has been removed from the description of PR #15
+            """
+        )
+        pr = self.github_api_state_for_test_restack_pr.get_pull_by_number(15)
+        assert pr is not None
+        assert pr['draft'] is False
+        assert pr['base']['ref'] == 'master'
+        assert pr['body'] == '# Summary'
+
+    def test_github_restack_pr_branch_untracked(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        (
+            self.repo_sandbox.new_branch("master")
+            .commit()
+            .new_branch('feature_1')
+            .commit()
+        )
+        body: str = \
+            """
+            master
+                feature_1
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            Pushing untracked branch feature_1 to origin...
+
+              master (untracked)
+              |
+              o-feature_1 *
+
+            Base branch of PR #14 has been switched to master
+            """
+        )
+        pr = self.github_api_state_for_test_restack_pr.get_pull_by_number(14)
+        assert pr is not None
+        assert pr['draft'] is True
+        assert pr['base']['ref'] == 'master'
+
+    def test_github_restack_pr_branch_diverged_and_newer(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        with fixed_author_and_committer_date_in_past():
+            (
+                self.repo_sandbox.new_branch("master")
+                .commit()
+                .new_branch('feature')
+                .commit()
+                .push()
+            )
+        self.repo_sandbox.amend_commit()
+        body: str = \
+            """
+            master
+                feature
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            PR #15 has been temporarily marked as draft
+            Branch feature diverged from (and has newer commits than) its remote counterpart origin/feature.
+            Pushing feature with force-with-lease to origin...
+
+              master (untracked)
+              |
+              o-feature *
+
+            Base branch of PR #15 has been switched to master
+            Base PR header has been removed from the description of PR #15
+            PR #15 has been marked as ready for review again
+            """
+        )
+        pr = self.github_api_state_for_test_restack_pr.get_pull_by_number(15)
+        assert pr is not None
+        assert pr['draft'] is False
+        assert pr['base']['ref'] == 'master'
+
+    def test_github_restack_pr_branch_ahead(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        (
+            self.repo_sandbox.new_branch("master")
+            .commit()
+            .new_branch('feature')
+            .commit()
+            .push()
+            .commit()
+            .set_git_config_key('machete.github.domain', 'git.example.org')
+        )
+        body: str = \
+            """
+            master
+                feature
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            PR #15 has been temporarily marked as draft
+            Pushing feature to origin...
+
+              master (untracked)
+              |
+              o-feature *
+
+            Base branch of PR #15 has been switched to master
+            Base PR header has been removed from the description of PR #15
+            PR #15 has been marked as ready for review again
+            """
+        )
+        pr = self.github_api_state_for_test_restack_pr.get_pull_by_number(15)
+        assert pr is not None
+        assert pr['draft'] is False
+        assert pr['base']['ref'] == 'master'
+
+    def test_github_restack_pr_branch_ahead_push_no(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        (
+            self.repo_sandbox.new_branch("master")
+            .commit()
+            .new_branch('feature')
+            .commit()
+            .push()
+            .commit()
+        )
+        body: str = \
+            """
+            master
+                feature push=no
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            Warn: Branch feature is marked as push=no; skipping the push.
+            Did you want to just use git machete github retarget-pr?
+
+            Base branch of PR #15 has been switched to master
+            Base PR header has been removed from the description of PR #15
+            """
+        )
+        pr = self.github_api_state_for_test_restack_pr.get_pull_by_number(15)
+        assert pr is not None
+        assert pr['draft'] is False
+        assert pr['base']['ref'] == 'master'
+
+    def test_github_restack_pr_branch_no_behind(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        (
+            self.repo_sandbox.new_branch("master")
+            .commit()
+            .new_branch('feature')
+            .commit()
+            .push()
+            .reset_to("HEAD~")
+        )
+        body: str = \
+            """
+            master
+                feature
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            Warn: Branch feature is behind its remote counterpart. Consider using git pull.
+
+            Base branch of PR #15 has been switched to master
+            Base PR header has been removed from the description of PR #15
+            """
+        )
+
+    def test_github_restack_pr_branch_diverged_and_older(self, mocker: MockerFixture) -> None:
+        self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake)
+        self.patch_symbol(mocker, 'git_machete.github.OrganizationAndRepository.from_url', mock_from_url)
+        self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_restack_pr))
+
+        (
+            self.repo_sandbox.new_branch("master")
+            .commit()
+            .new_branch('feature')
+            .commit()
+            .push()
+        )
+        with fixed_author_and_committer_date_in_past():
+            self.repo_sandbox.amend_commit()
+
+        body: str = \
+            """
+            master
+                feature
+            """
+        rewrite_branch_layout_file(body)
+
+        assert_success(
+            ['github', 'restack-pr'],
+            """
+            Warn: Branch feature is diverged from and older than its remote counterpart. Consider using git reset --keep.
+
+            Base branch of PR #15 has been switched to master
+            Base PR header has been removed from the description of PR #15
+            """
+        )
+        pr = self.github_api_state_for_test_restack_pr.get_pull_by_number(15)
+        assert pr is not None
+        assert pr['draft'] is False
+        assert pr['base']['ref'] == 'master'