diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 98eba4801..098295838 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,11 @@ # Release notes +## New in git-machete 3.46.0 + +- added: `machete.github.retrieveByAuthor` and `machete.gitlab.retrieveByAuthor` git config keys + that make PR/MR-listing commands download open PRs/MRs by author (the current user by default, or `--by=` when given) instead of all of them, + which can speed up operations considerably in repositories/projects with many open PRs/MRs + ## New in git-machete 3.45.1 - added: `advice.macheteCreateFromFork` git config key suppresses the fork warning in `github create-pr` and `gitlab create-mr` when set to `false` (contributed by @be-student) diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1 index 96bd7dde1..98160860b 100644 --- a/docs/man/git-machete.1 +++ b/docs/man/git-machete.1 @@ -30,7 +30,7 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] .. .TH "GIT-MACHETE" "1" "" "" "git-machete" .SH NAME -git-machete \- git-machete 3.45.1 +git-machete \- git-machete 3.46.0 .sp git machete is a robust tool that \fBsimplifies your git workflows\fP\&. .sp @@ -408,6 +408,25 @@ from the message body of the first unique commit of the branch, even if \fB\&.gi .UNINDENT .UNINDENT .TP +.B \fBmachete.github.retrieveByAuthor\fP +When set to \fBtrue\fP, commands that need to list open pull requests in the repository +(such as \fBgithub anno\-prs\fP, \fBgithub checkout\-prs\fP and \fBtraverse\fP with GitHub integration) +download open PRs by author rather than every open PR in the repository. +.sp +By default (and with \fB\-\-mine\fP), that author is the current user as determined from the GitHub API token. +The \fB\-\-by=\fP flag selects a different author instead; chain reconstruction (walking upstream/downstream PRs) +uses that same author\(aqs PRs, not the current user\(aqs. +When checking out specific PR numbers, the author of the first given PR is used instead. +.sp +This can speed up operations considerably in repositories with hundreds or thousands of open PRs, +at the cost of not being able to discover PRs opened by other users when traversing PR chains +(for example, when checking out an entire stack that includes PRs from multiple authors). +.sp +A valid GitHub API token is required when this key is set. +.sp +The \fB\-\-all\fP flag to \fBgithub checkout\-prs\fP and \fBgithub update\-pr\-descriptions\fP +still downloads all open PRs in the repository, regardless of this setting. +.TP .B \fBmachete.gitlab.{domain,remote,namespace,project,baseRemote,baseNamespace,baseProject}\fP .INDENT 7.0 .TP @@ -487,6 +506,25 @@ from the message body of the first unique commit of the branch, even if \fB\&.gi .UNINDENT .UNINDENT .TP +.B \fBmachete.gitlab.retrieveByAuthor\fP +When set to \fBtrue\fP, commands that need to list open merge requests in the project +(such as \fBgitlab anno\-mrs\fP, \fBgitlab checkout\-mrs\fP and \fBtraverse\fP with GitLab integration) +download open MRs by author rather than every open MR in the project. +.sp +By default (and with \fB\-\-mine\fP), that author is the current user as determined from the GitLab API token. +The \fB\-\-by=\fP flag selects a different author instead; chain reconstruction (walking upstream/downstream MRs) +uses that same author\(aqs MRs, not the current user\(aqs. +When checking out specific MR numbers, the author of the first given MR is used instead. +.sp +This can speed up operations considerably in projects with hundreds or thousands of open MRs, +at the cost of not being able to discover MRs opened by other users when traversing MR chains +(for example, when checking out an entire stack that includes MRs from multiple authors). +.sp +A valid GitLab API token is required when this key is set. +.sp +The \fB\-\-all\fP flag to \fBgitlab checkout\-mrs\fP and \fBgitlab update\-mr\-descriptions\fP +still downloads all open MRs in the project, regardless of this setting. +.TP .B \fBmachete.overrideForkPoint..to\fP Executing \fBgit machete fork\-point \-\-override\-to[\-parent|\-inferred|=] []\fP sets up a fork point override for \fB\fP\&. .sp @@ -1299,6 +1337,25 @@ from the message body of the first unique commit of the branch, even if \fB\&.gi \fBnone\fP \-\-\- prepend no intro to the PR description at all .UNINDENT .UNINDENT +.TP +.B \fBmachete.github.retrieveByAuthor\fP (\fBanno\-prs\fP, \fBcheckout\-prs\fP and \fBupdate\-pr\-descriptions\fP) +When set to \fBtrue\fP, commands that need to list open pull requests in the repository +(such as \fBgithub anno\-prs\fP, \fBgithub checkout\-prs\fP and \fBtraverse\fP with GitHub integration) +download open PRs by author rather than every open PR in the repository. +.sp +By default (and with \fB\-\-mine\fP), that author is the current user as determined from the GitHub API token. +The \fB\-\-by=\fP flag selects a different author instead; chain reconstruction (walking upstream/downstream PRs) +uses that same author\(aqs PRs, not the current user\(aqs. +When checking out specific PR numbers, the author of the first given PR is used instead. +.sp +This can speed up operations considerably in repositories with hundreds or thousands of open PRs, +at the cost of not being able to discover PRs opened by other users when traversing PR chains +(for example, when checking out an entire stack that includes PRs from multiple authors). +.sp +A valid GitHub API token is required when this key is set. +.sp +The \fB\-\-all\fP flag to \fBgithub checkout\-prs\fP and \fBgithub update\-pr\-descriptions\fP +still downloads all open PRs in the repository, regardless of this setting. .UNINDENT .sp \fBEnvironment variables (all subcommands)\fP @@ -1607,6 +1664,25 @@ from the message body of the first unique commit of the branch, even if \fB\&.gi \fBnone\fP \-\-\- prepend no intro to the MR description at all .UNINDENT .UNINDENT +.TP +.B \fBmachete.gitlab.retrieveByAuthor\fP (\fBanno\-mrs\fP, \fBcheckout\-mrs\fP and \fBupdate\-mr\-descriptions\fP) +When set to \fBtrue\fP, commands that need to list open merge requests in the project +(such as \fBgitlab anno\-mrs\fP, \fBgitlab checkout\-mrs\fP and \fBtraverse\fP with GitLab integration) +download open MRs by author rather than every open MR in the project. +.sp +By default (and with \fB\-\-mine\fP), that author is the current user as determined from the GitLab API token. +The \fB\-\-by=\fP flag selects a different author instead; chain reconstruction (walking upstream/downstream MRs) +uses that same author\(aqs MRs, not the current user\(aqs. +When checking out specific MR numbers, the author of the first given MR is used instead. +.sp +This can speed up operations considerably in projects with hundreds or thousands of open MRs, +at the cost of not being able to discover MRs opened by other users when traversing MR chains +(for example, when checking out an entire stack that includes MRs from multiple authors). +.sp +A valid GitLab API token is required when this key is set. +.sp +The \fB\-\-all\fP flag to \fBgitlab checkout\-mrs\fP and \fBgitlab update\-mr\-descriptions\fP +still downloads all open MRs in the project, regardless of this setting. .UNINDENT .sp \fBEnvironment variables (all subcommands)\fP diff --git a/docs/source/cli/config.rst b/docs/source/cli/config.rst index 522f45474..8c5195df1 100644 --- a/docs/source/cli/config.rst +++ b/docs/source/cli/config.rst @@ -20,6 +20,9 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git ``machete.github.prDescriptionIntroStyle`` .. include:: git-config-keys/github.prDescriptionIntroStyle.rst +``machete.github.retrieveByAuthor`` + .. include:: git-config-keys/github.retrieveByAuthor.rst + ``machete.gitlab.{domain,remote,namespace,project,baseRemote,baseNamespace,baseProject}`` .. include:: git-config-keys/gitlab.access.rst @@ -32,6 +35,9 @@ Note: ``config`` is not a command as such, just a help topic (there is no ``git ``machete.gitlab.mrDescriptionIntroStyle`` .. include:: git-config-keys/gitlab.mrDescriptionIntroStyle.rst +``machete.gitlab.retrieveByAuthor`` + .. include:: git-config-keys/gitlab.retrieveByAuthor.rst + ``machete.overrideForkPoint..to`` Executing ``git machete fork-point --override-to[-parent|-inferred|=] []`` sets up a fork point override for ````. diff --git a/docs/source/cli/github.rst b/docs/source/cli/github.rst index 3fa6cf96b..f9fba0622 100644 --- a/docs/source/cli/github.rst +++ b/docs/source/cli/github.rst @@ -180,6 +180,9 @@ Create, check out and manage GitHub PRs while keeping them reflected in branch l ``machete.github.prDescriptionIntroStyle`` (``create-pr``, ``restack-pr`` and ``retarget-pr``) .. include:: git-config-keys/github.prDescriptionIntroStyle.rst +``machete.github.retrieveByAuthor`` (``anno-prs``, ``checkout-prs`` and ``update-pr-descriptions``) + .. include:: git-config-keys/github.retrieveByAuthor.rst + **Environment variables (all subcommands)** ``GITHUB_TOKEN`` diff --git a/docs/source/cli/gitlab.rst b/docs/source/cli/gitlab.rst index 45ddcce72..2700feab0 100644 --- a/docs/source/cli/gitlab.rst +++ b/docs/source/cli/gitlab.rst @@ -171,6 +171,9 @@ Create, check out and manage GitLab MRs while keeping them reflected in branch l ``machete.gitlab.mrDescriptionIntroStyle`` (``create-mr``, ``restack-mr`` and ``retarget-mr``) .. include:: git-config-keys/gitlab.mrDescriptionIntroStyle.rst +``machete.gitlab.retrieveByAuthor`` (``anno-mrs``, ``checkout-mrs`` and ``update-mr-descriptions``) + .. include:: git-config-keys/gitlab.retrieveByAuthor.rst + **Environment variables (all subcommands)** ``GITLAB_TOKEN`` diff --git a/docs/source/git-config-keys/github.retrieveByAuthor.rst b/docs/source/git-config-keys/github.retrieveByAuthor.rst new file mode 100644 index 000000000..0d37d2d12 --- /dev/null +++ b/docs/source/git-config-keys/github.retrieveByAuthor.rst @@ -0,0 +1,17 @@ +When set to ``true``, commands that need to list open pull requests in the repository +(such as ``github anno-prs``, ``github checkout-prs`` and ``traverse`` with GitHub integration) +download open PRs by author rather than every open PR in the repository. + +By default (and with ``--mine``), that author is the current user as determined from the GitHub API token. +The ``--by=`` flag selects a different author instead; chain reconstruction (walking upstream/downstream PRs) +uses that same author's PRs, not the current user's. +When checking out specific PR numbers, the author of the first given PR is used instead. + +This can speed up operations considerably in repositories with hundreds or thousands of open PRs, +at the cost of not being able to discover PRs opened by other users when traversing PR chains +(for example, when checking out an entire stack that includes PRs from multiple authors). + +A valid GitHub API token is required when this key is set. + +The ``--all`` flag to ``github checkout-prs`` and ``github update-pr-descriptions`` +still downloads all open PRs in the repository, regardless of this setting. diff --git a/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst b/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst new file mode 100644 index 000000000..ba64c9002 --- /dev/null +++ b/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst @@ -0,0 +1,17 @@ +When set to ``true``, commands that need to list open merge requests in the project +(such as ``gitlab anno-mrs``, ``gitlab checkout-mrs`` and ``traverse`` with GitLab integration) +download open MRs by author rather than every open MR in the project. + +By default (and with ``--mine``), that author is the current user as determined from the GitLab API token. +The ``--by=`` flag selects a different author instead; chain reconstruction (walking upstream/downstream MRs) +uses that same author's MRs, not the current user's. +When checking out specific MR numbers, the author of the first given MR is used instead. + +This can speed up operations considerably in projects with hundreds or thousands of open MRs, +at the cost of not being able to discover MRs opened by other users when traversing MR chains +(for example, when checking out an entire stack that includes MRs from multiple authors). + +A valid GitLab API token is required when this key is set. + +The ``--all`` flag to ``gitlab checkout-mrs`` and ``gitlab update-mr-descriptions`` +still downloads all open MRs in the project, regardless of this setting. diff --git a/git_machete/__init__.py b/git_machete/__init__.py index 693fd21ff..fda5b92cf 100644 --- a/git_machete/__init__.py +++ b/git_machete/__init__.py @@ -1 +1 @@ -__version__ = '3.45.1' +__version__ = '3.46.0' diff --git a/git_machete/client/traverse.py b/git_machete/client/traverse.py index 10ff13124..fd5c78b02 100644 --- a/git_machete/client/traverse.py +++ b/git_machete/client/traverse.py @@ -262,7 +262,7 @@ def traverse( 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())) + prs = list(filter(lambda pr: pr.head == branch, self._get_relevant_open_prs())) if len(prs) > 1: spec = self.code_hosting_spec raise MacheteException( @@ -274,7 +274,7 @@ def traverse( needs_create_pr = False if opt_sync_github_prs or opt_sync_gitlab_mrs: if parent: - prs = [_pr for _pr in self._get_all_open_prs() if _pr.head == branch] + prs = [_pr for _pr in self._get_relevant_open_prs() if _pr.head == branch] if not prs: if branch_anno is None or branch_anno.qualifiers.push: needs_create_pr = True diff --git a/git_machete/client/with_code_hosting.py b/git_machete/client/with_code_hosting.py index bf02e180b..decbc44a2 100644 --- a/git_machete/client/with_code_hosting.py +++ b/git_machete/client/with_code_hosting.py @@ -28,6 +28,7 @@ def __init__(self, spec: CodeHostingSpec, *, read_layout_file: bool = True, self.__code_hosting_spec: CodeHostingSpec = spec self.__code_hosting_client: Optional[CodeHostingApi] = None self.__all_open_prs: Optional[List[PullRequest]] = None + self.__open_prs_by_author: Dict[str, List[PullRequest]] = {} @property def code_hosting_spec(self) -> CodeHostingSpec: @@ -43,6 +44,21 @@ def code_hosting_client(self) -> CodeHostingApi: def code_hosting_client(self, value: CodeHostingApi) -> None: self.__code_hosting_client = value + def _get_relevant_open_prs(self, author: Optional[str] = None) -> List[PullRequest]: + # Honor `machete.{github,gitlab}.retrieveByAuthor`: download PRs by a single author rather than every open PR. + # `author` is the `--by` login or the author of an explicit PR number; otherwise the current user. + # Callers that need every open PR regardless of this key (e.g. the `--all` flag) call `_get_all_open_prs` directly instead; + # once that has happened, reuse the already-downloaded full list here too (e.g. for reconstructing PR chains) + # rather than issuing a second, narrower query. + if self.__all_open_prs is not None: + return self.__all_open_prs + keys = self.code_hosting_spec.git_config_keys + if self._config.code_hosting_retrieve_by_author(keys): + if author is not None: + return self._get_open_prs_by_author(author) + return self._get_open_prs_of_current_user() + return self._get_all_open_prs() + def _get_all_open_prs(self) -> List[PullRequest]: if self.__all_open_prs is None: spec = self.code_hosting_spec @@ -52,6 +68,25 @@ def _get_all_open_prs(self) -> List[PullRequest]: print_fmt(green_ok()) return self.__all_open_prs + def _get_open_prs_of_current_user(self) -> List[PullRequest]: + spec = self.code_hosting_spec + keys = spec.git_config_keys + author = self.code_hosting_client.get_current_user_login() + if author is None: + raise MacheteException( + f'`{keys.retrieve_by_author}` git config key is set, ' + f'but the current {spec.display_name} user could not be determined.\n' + f'Provide a {spec.display_name} API token via one of the:{spec.token_providers_message}') + return self._get_open_prs_by_author(author) + + def _get_open_prs_by_author(self, author: str) -> List[PullRequest]: + if author not in self.__open_prs_by_author: + spec = self.code_hosting_spec + print_fmt(f'Checking for open {spec.display_name} {spec.pr_short_name}s by {author}... ', newline=False) + self.__open_prs_by_author[author] = self.code_hosting_client.get_open_pull_requests_by_author(author) + print_fmt(green_ok()) + return self.__open_prs_by_author[author] + def _pull_request_annotation(self, pr: PullRequest, current_user: Optional[str], *, include_url: bool = False) -> str: anno = pr.display_text(fmt=False) if current_user != pr.user: @@ -228,8 +263,8 @@ def sync_annotations_to_prs(self, *, include_urls: bool) -> None: self._init_code_hosting_client() current_user: Optional[str] = self.code_hosting_client.get_current_user_login() debug(f'Current {self.code_hosting_spec.display_name} user is {current_user or ""}') - all_open_prs = self._get_all_open_prs() - self.__sync_annotations_to_branch_layout_file(all_open_prs, current_user, include_urls=include_urls, verbose=True) + relevant_open_prs = self._get_relevant_open_prs() + self.__sync_annotations_to_branch_layout_file(relevant_open_prs, current_user, include_urls=include_urls, verbose=True) def create_pull_request( self, @@ -512,14 +547,14 @@ def restack_pull_request(self, *, opt_update_related_descriptions: bool) -> None self.retarget_pull_request(opt_branch=head, opt_ignore_if_missing=False, opt_update_related_descriptions=opt_update_related_descriptions) - def _get_updated_pull_request_description(self, pr: PullRequest) -> str: + def _get_updated_pull_request_description(self, pr: PullRequest, *, author: Optional[str] = None) -> str: def skip_leading_empty(strs: List[str]) -> List[str]: return list(itertools.dropwhile(lambda line: line.strip() == '', strs)) original_trailing_newlines = ''.join(itertools.takewhile(lambda c: c == '\n', reversed(pr.description or ''))) lines = pr.description.strip().splitlines() if pr.description else [] style = self._config.code_hosting_pr_description_intro_style(self.code_hosting_spec.git_config_keys) - text_to_prepend = self.__generate_pr_description_intro(pr, style) + text_to_prepend = self.__generate_pr_description_intro(pr, style, author=author) lines_to_prepend = text_to_prepend.splitlines() if text_to_prepend else [] if self.START_GIT_MACHETE_GENERATED_COMMENT in lines and self.END_GIT_MACHETE_GENERATED_COMMENT in lines: start_index = lines.index(self.START_GIT_MACHETE_GENERATED_COMMENT) @@ -730,7 +765,7 @@ def __get_sole_pull_request_for_head( debug(f'found {pr}') return pr - def __generate_pr_description_intro(self, pr: PullRequest, style: PRDescriptionIntroStyle) -> str: + def __generate_pr_description_intro(self, pr: PullRequest, style: PRDescriptionIntroStyle, *, author: Optional[str] = None) -> str: if style == PRDescriptionIntroStyle.NONE: return '' @@ -749,9 +784,9 @@ def __generate_pr_description_intro(self, pr: PullRequest, style: PRDescriptionI pr_short_name = spec.pr_short_name br_before_branches = '
' if spec.pr_intro_br_before_branches else '' - pr_up_path = list(reversed(self.__get_upwards_path_including_pr(pr))) + pr_up_path = list(reversed(self.__get_upwards_path_including_pr(pr, author=author))) if style in (PRDescriptionIntroStyle.FULL, PRDescriptionIntroStyle.FULL_NO_BRANCHES): - pr_down_tree = self.__get_downwards_tree_excluding_pr(pr) + pr_down_tree = self.__get_downwards_tree_excluding_pr(pr, author=author) else: pr_down_tree = [] if len(pr_up_path) == 1 and pr_down_tree == []: @@ -820,12 +855,13 @@ def update_pull_request_descriptions( related_to = self.__get_sole_pull_request_for_head(head, ignore_if_missing=False) else: related_to = None + author = current_user if mine else by applicable_prs: List[PullRequest] = self._get_applicable_pull_requests( - all=all, by=current_user if mine else by, related_to=related_to) + all=all, by=author, related_to=related_to) debug("applicable PRs: " + ", ".join(pr.display_text() for pr in applicable_prs)) for pr in applicable_prs: - new_description = self._get_updated_pull_request_description(pr) + new_description = self._get_updated_pull_request_description(pr, author=author) if pr.description != new_description: print_fmt(f'Updating description of {pr.display_text()} ({pr.head} {pr.base})... ', newline=False) self.code_hosting_client.set_description_of_pull_request(pr.number, description=new_description) @@ -858,6 +894,8 @@ def checkout_pull_requests( applicable_prs: List[PullRequest] = self._get_applicable_pull_requests( pr_numbers=pr_numbers, all=all, by=by) + if by is None and pr_numbers and applicable_prs: + by = applicable_prs[0].user debug(f'organization is {org_repo_remote.organization}, repository is {org_repo_remote.repository}') self._git.fetch_remote(org_repo_remote.remote) @@ -894,7 +932,7 @@ def checkout_pull_requests( warn(f'{pr.display_text()} is already closed.') debug(f'found {pr}') - pr_path: List[PullRequest] = self.__get_upwards_path_including_pr(pr) + pr_path: List[PullRequest] = self.__get_upwards_path_including_pr(pr, author=by) prs_to_annotate.update(pr_path) reversed_pr_path: List[PullRequest] = pr_path[::-1] # need to add from root downwards if reversed_pr_path[0].base not in self.managed_branches: @@ -923,14 +961,14 @@ def checkout_pull_requests( if len(applicable_prs) == 1: self._git.checkout_in_current_worktree(LocalBranchShortName.of(applicable_prs[0].head)) - def __get_downwards_tree_excluding_pr(self, original_pr: PullRequest) -> List[Tuple[PullRequest, int]]: + def __get_downwards_tree_excluding_pr(self, original_pr: PullRequest, *, author: Optional[str] = None) -> List[Tuple[PullRequest, int]]: """Returns pairs of (PR, depth below the given PR)""" visited_head_branches: Set[str] = set([]) def reverse_pr_dfs(pr: PullRequest, depth: int) -> Iterator[Tuple[PullRequest, int]]: visited_head_branches.add(pr.head) - down_prs = filter(lambda x: x.base == pr.head, self._get_all_open_prs()) + down_prs = filter(lambda x: x.base == pr.head, self._get_relevant_open_prs(author=author)) for down_pr in sorted(down_prs, key=lambda x: x.number): if down_pr.head not in visited_head_branches: yield (down_pr, depth + 1) @@ -938,7 +976,7 @@ def reverse_pr_dfs(pr: PullRequest, depth: int) -> Iterator[Tuple[PullRequest, i return list(reverse_pr_dfs(original_pr, 0)) - def __get_upwards_path_including_pr(self, original_pr: PullRequest) -> List[PullRequest]: + def __get_upwards_path_including_pr(self, original_pr: PullRequest, *, author: Optional[str] = None) -> List[PullRequest]: visited_head_branches: List[str] = [original_pr.head] path: List[PullRequest] = [original_pr] pr_base: Optional[str] = original_pr.base @@ -953,7 +991,7 @@ def __get_upwards_path_including_pr(self, original_pr: PullRequest) -> List[Pull raise MacheteException(f"There is a cycle between {spec.display_name} {spec.pr_short_name}s: " + " -> ".join(visited_head_branches + [pr_base])) visited_head_branches += [pr_base] - pr = find_or_none(lambda x: x.head == pr_base, self._get_all_open_prs()) + pr = find_or_none(lambda x: x.head == pr_base, self._get_relevant_open_prs(author=author)) path = (path + [pr]) if pr else path pr_base = pr.base if pr else None return path @@ -968,30 +1006,51 @@ def _get_applicable_pull_requests( ) -> List[PullRequest]: result: List[PullRequest] = [] spec = self.code_hosting_spec - all_open_prs = self._get_all_open_prs() repo_pretty = ( f"{spec.repository_name} {self.code_hosting_client.organization}/{self.code_hosting_client.repository}") if pr_numbers: - for pr_number in pr_numbers: - pr: Optional[PullRequest] = find_or_none(lambda x: x.number == pr_number, all_open_prs) + keys = spec.git_config_keys + first_pr: Optional[PullRequest] = None + if self._config.code_hosting_retrieve_by_author(keys): + # Infer the author from the first given PR so chain reconstruction walks that user's PRs + # (same as `--by`), rather than the current user's. + first_pr = self.code_hosting_client.get_pull_request_by_number_or_none(pr_numbers[0]) + if first_pr is None: + raise MacheteException( + f"{spec.pr_short_name} {spec.pr_ordinal_char}{pr_numbers[0]} is not found in {repo_pretty}") + relevant_open_prs = self._get_open_prs_by_author(first_pr.user) + else: + relevant_open_prs = self._get_relevant_open_prs() + for i, pr_number in enumerate(pr_numbers): + pr: Optional[PullRequest] = find_or_none(lambda x: x.number == pr_number, relevant_open_prs) if pr: result.append(pr) + elif i == 0 and first_pr is not None: + result.append(first_pr) else: pr = self.code_hosting_client.get_pull_request_by_number_or_none(pr_number) if pr: result.append(pr) else: - raise MacheteException( f"{spec.pr_short_name} {spec.pr_ordinal_char}{pr_number} is not found in {repo_pretty}") return result if all: + # The `--all` flag deliberately overrides `retrieveByAuthor` and downloads every open PR. + all_open_prs = self._get_all_open_prs() if not all_open_prs: warn(f"currently there are no {spec.pr_full_name}s opened in {repo_pretty}") return [] return all_open_prs elif by: - result = [pr for pr in all_open_prs if pr.user == by] + keys = spec.git_config_keys + if self._config.code_hosting_retrieve_by_author(keys): + # With this key set we avoid downloading every open PR just to filter locally; + # ask the API for this author's PRs directly. `by` may be a user other than the current one + # (e.g. `--by=`), which is intentionally supported here. + result = self._get_open_prs_by_author(by) + else: + result = [pr for pr in self._get_all_open_prs() if pr.user == by] if not result: warn(f"user {by} has no open {spec.pr_full_name} in {repo_pretty}") return [] @@ -999,8 +1058,8 @@ def _get_applicable_pull_requests( elif related_to: # Always update the entire stack (both parents and children) when --related is used, # regardless of prDescriptionIntroStyle setting. - result = list(reversed(self.__get_upwards_path_including_pr(related_to))) - result += [downstream_pr for downstream_pr, _ in self.__get_downwards_tree_excluding_pr(related_to)] + result = list(reversed(self.__get_upwards_path_including_pr(related_to, author=by))) + result += [downstream_pr for downstream_pr, _ in self.__get_downwards_tree_excluding_pr(related_to, author=by)] return result raise UnexpectedMacheteException("All params passed to __get_applicable_pull_requests are empty.") diff --git a/git_machete/code_hosting.py b/git_machete/code_hosting.py index b02ba793f..77550992e 100644 --- a/git_machete/code_hosting.py +++ b/git_machete/code_hosting.py @@ -160,6 +160,7 @@ class CodeHostingGitConfigKeys(NamedTuple): annotate_with_urls: str force_description_from_commit_message: str pr_description_intro_style: str + retrieve_by_author: str def for_locating_repo_message(self) -> str: return f"`{self.domain}`, `{self.organization}`, `{self.repository}`, `{self.remote}`" @@ -244,11 +245,15 @@ def set_draft_status_of_pull_request(self, number: int, *, target_draft_status: Returns false if PR already had the desired draft status, and hence draft status has NOT been toggled.""" @abstractmethod - def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[PullRequest]: + def get_all_open_pull_requests(self) -> List[PullRequest]: pass @abstractmethod - def get_all_open_pull_requests(self) -> List[PullRequest]: + def get_open_pull_requests_by_author(self, author: str) -> List[PullRequest]: + pass + + @abstractmethod + def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[PullRequest]: pass @abstractmethod diff --git a/git_machete/config.py b/git_machete/config.py index 107b75caf..1386e7cb5 100644 --- a/git_machete/config.py +++ b/git_machete/config.py @@ -152,3 +152,6 @@ def code_hosting_pr_description_intro_style(self, keys: CodeHostingGitConfigKeys return PRDescriptionIntroStyle.from_string( value=value, from_where=f"`{keys.pr_description_intro_style}` git config key") + + def code_hosting_retrieve_by_author(self, keys: CodeHostingGitConfigKeys) -> bool: + return self._git.get_boolean_config_attr(key=keys.retrieve_by_author, default_value=False) diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py index 2e40b39bd..344c47790 100644 --- a/git_machete/generated_docs.py +++ b/git_machete/generated_docs.py @@ -316,6 +316,25 @@ * `up-only-no-branches` — same as `up-only`, but no branch names are included (only PR numbers & titles) * `none` — prepend no intro to the PR description at all + `machete.github.retrieveByAuthor` + When set to `true`, commands that need to list open pull requests in the repository + (such as `github anno-prs`, `github checkout-prs` and `traverse` with GitHub integration) + download open PRs by author rather than every open PR in the repository. + + By default (and with `--mine`), that author is the current user as determined from the GitHub API token. + The `--by=` flag selects a different author instead; chain reconstruction (walking upstream/downstream PRs) + uses that same author's PRs, not the current user's. + When checking out specific PR numbers, the author of the first given PR is used instead. + + This can speed up operations considerably in repositories with hundreds or thousands of open PRs, + at the cost of not being able to discover PRs opened by other users when traversing PR chains + (for example, when checking out an entire stack that includes PRs from multiple authors). + + A valid GitHub API token is required when this key is set. + + The `--all` flag to `github checkout-prs` and `github update-pr-descriptions` + still downloads all open PRs in the repository, regardless of this setting. + `machete.gitlab.{domain,remote,namespace,project,baseRemote,baseNamespace,baseProject}` `machete.gitlab.domain` The domain of the GitLab API server, for use with a GitLab self-managed instance; otherwise inferred from the remote URL. @@ -382,6 +401,25 @@ * `up-only-no-branches` — same as `up-only`, but no branch names are included (only MR numbers & titles) * `none` — prepend no intro to the MR description at all + `machete.gitlab.retrieveByAuthor` + When set to `true`, commands that need to list open merge requests in the project + (such as `gitlab anno-mrs`, `gitlab checkout-mrs` and `traverse` with GitLab integration) + download open MRs by author rather than every open MR in the project. + + By default (and with `--mine`), that author is the current user as determined from the GitLab API token. + The `--by=` flag selects a different author instead; chain reconstruction (walking upstream/downstream MRs) + uses that same author's MRs, not the current user's. + When checking out specific MR numbers, the author of the first given MR is used instead. + + This can speed up operations considerably in projects with hundreds or thousands of open MRs, + at the cost of not being able to discover MRs opened by other users when traversing MR chains + (for example, when checking out an entire stack that includes MRs from multiple authors). + + A valid GitLab API token is required when this key is set. + + The `--all` flag to `gitlab checkout-mrs` and `gitlab update-mr-descriptions` + still downloads all open MRs in the project, regardless of this setting. + `machete.overrideForkPoint..to` Executing `git machete fork-point --override-to[-parent|-inferred|=] []` sets up a fork point override for ``. @@ -915,6 +953,25 @@ * `up-only-no-branches` — same as `up-only`, but no branch names are included (only PR numbers & titles) * `none` — prepend no intro to the PR description at all + `machete.github.retrieveByAuthor` (`anno-prs`, `checkout-prs` and `update-pr-descriptions`) + When set to `true`, commands that need to list open pull requests in the repository + (such as `github anno-prs`, `github checkout-prs` and `traverse` with GitHub integration) + download open PRs by author rather than every open PR in the repository. + + By default (and with `--mine`), that author is the current user as determined from the GitHub API token. + The `--by=` flag selects a different author instead; chain reconstruction (walking upstream/downstream PRs) + uses that same author's PRs, not the current user's. + When checking out specific PR numbers, the author of the first given PR is used instead. + + This can speed up operations considerably in repositories with hundreds or thousands of open PRs, + at the cost of not being able to discover PRs opened by other users when traversing PR chains + (for example, when checking out an entire stack that includes PRs from multiple authors). + + A valid GitHub API token is required when this key is set. + + The `--all` flag to `github checkout-prs` and `github update-pr-descriptions` + still downloads all open PRs in the repository, regardless of this setting. + Environment variables (all subcommands) `GITHUB_TOKEN` @@ -1146,6 +1203,25 @@ * `up-only-no-branches` — same as `up-only`, but no branch names are included (only MR numbers & titles) * `none` — prepend no intro to the MR description at all + `machete.gitlab.retrieveByAuthor` (`anno-mrs`, `checkout-mrs` and `update-mr-descriptions`) + When set to `true`, commands that need to list open merge requests in the project + (such as `gitlab anno-mrs`, `gitlab checkout-mrs` and `traverse` with GitLab integration) + download open MRs by author rather than every open MR in the project. + + By default (and with `--mine`), that author is the current user as determined from the GitLab API token. + The `--by=` flag selects a different author instead; chain reconstruction (walking upstream/downstream MRs) + uses that same author's MRs, not the current user's. + When checking out specific MR numbers, the author of the first given MR is used instead. + + This can speed up operations considerably in projects with hundreds or thousands of open MRs, + at the cost of not being able to discover MRs opened by other users when traversing MR chains + (for example, when checking out an entire stack that includes MRs from multiple authors). + + A valid GitLab API token is required when this key is set. + + The `--all` flag to `gitlab checkout-mrs` and `gitlab update-mr-descriptions` + still downloads all open MRs in the project, regardless of this setting. + Environment variables (all subcommands) `GITLAB_TOKEN` diff --git a/git_machete/github.py b/git_machete/github.py index b2656dfff..ec4f4eadf 100644 --- a/git_machete/github.py +++ b/git_machete/github.py @@ -312,6 +312,69 @@ def __extract_failure_info_from_422(response: Any) -> str: else: return str(response) + def __get_pull_request_from_graphql_search_node(self, pr_node: Dict[str, Any]) -> PullRequest: + head_repository = pr_node.get('headRepository') or {} + return PullRequest( + number=int(pr_node['number']), + display_prefix='PR #', + user=pr_node['author']['login'], + base=pr_node['baseRefName'], + head=pr_node['headRefName'], + head_repo_id=int(head_repository.get('databaseId') or 0), + html_url=pr_node['url'], + state=str(pr_node['state']).lower(), + title=pr_node['title'], + description=pr_node.get('body')) + + # GraphQL (rather than REST) is used here on purpose - there is no REST endpoint that can filter PRs by author server-side + # while still returning the PR-specific fields we need: + # - `/repos/{owner}/{repo}/pulls` returns head/base branches (and head repo id), but supports no author/user filter at all + # (only state/head/base/sort/direction), so it would force downloading every open PR and filtering client-side - + # exactly the pagination cost this whole feature exists to avoid. + # - `/search/issues?q=is:pr+author:...` does filter by author, but returns only the fields common to issues and PRs; + # it omits head/base branch names and head repo id, which we can't do without (and there is no `/search/pulls`). + # The GraphQL `search(type: ISSUE)` connection is the only option that filters by author *and* exposes + # headRefName/baseRefName/headRepository.databaseId in a single query. See https://github.com/VirtusLab/git-machete/issues/1040. + def __get_open_pull_requests_by_author_via_graphql(self, author: str) -> List[PullRequest]: + search_query = f'is:pr is:open author:{author} repo:{self.organization}/{self.repository}' + result: List[PullRequest] = [] + cursor: Optional[str] = None + while True: + after_clause = f', after: "{cursor}"' if cursor else '' + query = f"""{{ + search(query: "{search_query}", type: ISSUE, first: {self.MAX_PULLS_PER_PAGE_COUNT}{after_clause}) {{ + edges {{ + node {{ + ... on PullRequest {{ + number + title + body + state + url + author {{ login }} + baseRefName + headRefName + headRepository {{ databaseId }} + }} + }} + }} + pageInfo {{ + endCursor + hasNextPage + }} + }} + }}""" + response = self.__fire_github_graphql_api_request(query) + search_result = response['data']['search'] + for edge in search_result['edges']: + result.append(self.__get_pull_request_from_graphql_search_node(edge['node'])) + page_info = search_result['pageInfo'] + if page_info['hasNextPage']: + cursor = page_info['endCursor'] + else: + break + return result + 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] = { @@ -397,14 +460,17 @@ def set_draft_status_of_pull_request(self, number: int, *, target_draft_status: debug(f"mutation response is {response}") return True - def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[PullRequest]: - prs = self.__fire_github_api_repo_request(method='GET', path_suffix=f'/pulls?head={self.organization}:{head}') - return [self.__get_pull_request_from_json(pr) for pr in prs] - def get_all_open_pull_requests(self) -> List[PullRequest]: prs = self.__fire_github_api_repo_request(method='GET', path_suffix=f'/pulls?per_page={self.MAX_PULLS_PER_PAGE_COUNT}') return [self.__get_pull_request_from_json(pr) for pr in prs] + def get_open_pull_requests_by_author(self, author: str) -> List[PullRequest]: + return self.__get_open_pull_requests_by_author_via_graphql(author) + + def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[PullRequest]: + prs = self.__fire_github_api_repo_request(method='GET', path_suffix=f'/pulls?head={self.organization}:{head}') + return [self.__get_pull_request_from_json(pr) for pr in prs] + def get_current_user_login(self) -> Optional[str]: if not self.__token: return None @@ -476,5 +542,6 @@ def get_ref_name_for_pull_request(self, pr_number: int) -> str: annotate_with_urls='machete.github.annotateWithUrls', force_description_from_commit_message='machete.github.forceDescriptionFromCommitMessage', pr_description_intro_style='machete.github.prDescriptionIntroStyle', + retrieve_by_author='machete.github.retrieveByAuthor', ) ) diff --git a/git_machete/gitlab.py b/git_machete/gitlab.py index 395a4eca8..2d45f6ecd 100644 --- a/git_machete/gitlab.py +++ b/git_machete/gitlab.py @@ -292,15 +292,21 @@ def set_draft_status_of_pull_request(self, number: int, *, target_draft_status: self.__fire_gitlab_api_project_request(method='PUT', path_suffix=f'/merge_requests/{number}', request_body=request_body) return True - def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[PullRequest]: - mrs = self.__fire_gitlab_api_project_request(method='GET', path_suffix=f'/merge_requests?state=opened&source_branch={head}') - return [self.__get_merge_request_from_json(mr) for mr in mrs] - def get_all_open_pull_requests(self) -> List[PullRequest]: mrs = self.__fire_gitlab_api_project_request(method='GET', path_suffix=f'/merge_requests?state=opened&per_page={self.MAX_PULLS_PER_PAGE_COUNT}') return [self.__get_merge_request_from_json(mr) for mr in mrs] + def get_open_pull_requests_by_author(self, author: str) -> List[PullRequest]: + path_suffix = (f'/merge_requests?state=opened&per_page={self.MAX_PULLS_PER_PAGE_COUNT}' + f'&author_username={urllib.parse.quote(author, safe="")}') + mrs = self.__fire_gitlab_api_project_request(method='GET', path_suffix=path_suffix) + return [self.__get_merge_request_from_json(mr) for mr in mrs] + + def get_open_pull_requests_by_head(self, head: LocalBranchShortName) -> List[PullRequest]: + mrs = self.__fire_gitlab_api_project_request(method='GET', path_suffix=f'/merge_requests?state=opened&source_branch={head}') + return [self.__get_merge_request_from_json(mr) for mr in mrs] + def get_current_user_login(self) -> Optional[str]: if not self.__token: return None @@ -368,5 +374,6 @@ def get_ref_name_for_pull_request(self, mr_number: int) -> str: annotate_with_urls='machete.gitlab.annotateWithUrls', force_description_from_commit_message='machete.gitlab.forceDescriptionFromCommitMessage', pr_description_intro_style='machete.gitlab.mrDescriptionIntroStyle', + retrieve_by_author='machete.gitlab.retrieveByAuthor', ) ) diff --git a/tests/mockers_github.py b/tests/mockers_github.py index a161f948a..e73fffa71 100644 --- a/tests/mockers_github.py +++ b/tests/mockers_github.py @@ -187,7 +187,9 @@ def handle_post() -> "MockAPIResponse": return MockAPIResponse(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: + if 'search(' in query_or_mutation: + return search_pull_requests_by_author() + elif '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)) @@ -238,6 +240,33 @@ def fill_pull_request_from_json_data(pull: Dict[str, Any]) -> None: else: pull[key] = value + def search_pull_requests_by_author() -> "MockAPIResponse": + query = json_data['query'] + author_match = re.search(r'author:(\S+) repo:', query) + assert author_match is not None + author = author_match.group(1) + matching = [pull for pull in github_api_state.get_open_pulls() if pull['user']['login'] == author] + # Deliberately paginate one PR at a time (ignoring the requested `first:`) so that even a couple of PRs + # exercise the cursor-following loop in `GitHubApi.get_open_pull_requests_by_author`. + after_match = re.search(r'after: "([0-9]+)"', query) + start = int(after_match.group(1)) if after_match else 0 + end = start + 1 + edges = [{'node': { + 'number': int(pull['number']), + 'title': pull['title'], + 'body': pull['body'], + 'state': str(pull['state']).upper(), + 'url': pull['html_url'], + 'author': {'login': pull['user']['login']}, + 'baseRefName': pull['base']['ref'], + 'headRefName': pull['head']['ref'], + 'headRepository': ({'databaseId': pull['head']['repo']['id']} if pull['head']['repo'] is not None else None), + }} for pull in matching[start:end]] + return MockAPIResponse(HTTPStatus.OK, {'data': {'search': { + 'edges': edges, + 'pageInfo': {'endCursor': str(end), 'hasNextPage': end < len(matching)} + }}}) + def redirect_307(location: str) -> HTTPError: return HTTPError(parsed_url.hostname, 307, 'Temporary redirect', {'Location': location}, None) # type: ignore[arg-type] diff --git a/tests/mockers_gitlab.py b/tests/mockers_gitlab.py index 07c0a83c9..76c77c3ed 100644 --- a/tests/mockers_gitlab.py +++ b/tests/mockers_gitlab.py @@ -147,6 +147,9 @@ def handle_get() -> "MockAPIResponse": return MockAPIResponse(HTTPStatus.OK, mrs) else: mrs = gitlab_api_state.get_open_mrs() + author_username: Optional[str] = query_params.get('author_username') + if author_username: + mrs = [mr for mr in mrs if mr['author']['username'] == author_username] page_str = query_params.get('page') page = int(page_str) if page_str else 1 per_page_str = query_params.get('per_page') diff --git a/tests/test_github_anno_prs.py b/tests/test_github_anno_prs.py index 87883ba6a..ccc96832c 100644 --- a/tests/test_github_anno_prs.py +++ b/tests/test_github_anno_prs.py @@ -4,7 +4,8 @@ from tests.cli_runner import assert_failure, assert_success, launch_command, rewrite_branch_layout_file from tests.git_repository import (add_remote, amend_commit, check_out, commit, create_repo, create_repo_with_remote, delete_branch, new_branch, push, remove_remote, reset_to, set_git_config_key, wait_to_bump_commit_timestamp) -from tests.mockers_github import MockGitHubAPIState, mock_github_token_for_domain_fake, mock_pr_json, mock_urlopen +from tests.mockers_github import (MockGitHubAPIState, mock_github_token_for_domain_fake, mock_github_token_for_domain_none, mock_pr_json, + mock_urlopen) class TestGitHubAnnoPRs(BaseTest): @@ -205,3 +206,82 @@ def test_github_anno_prs_multiple_non_origin_github_remotes(self) -> None: machete.github.domain, machete.github.organization, machete.github.repository, machete.github.remote """ ) + + @staticmethod + def github_api_state_for_test_anno_prs_retrieve_by_author() -> MockGitHubAPIState: + return MockGitHubAPIState.with_prs( + mock_pr_json(number=7, user='some_other_user', head='allow-ownership-link', base='develop'), + mock_pr_json(number=31, user='github_user', head='call-ws', base='develop'), + mock_pr_json(number=37, user='github_user', head='develop', base='master') + ) + + def setup_repo_for_retrieve_by_author(self) -> None: + create_repo_with_remote() + new_branch("master") + commit("master commit") + push() + new_branch("develop") + commit("develop commit") + push() + new_branch("allow-ownership-link") + commit("Allow ownership links") + push() + check_out("develop") + new_branch("call-ws") + commit("Call web service") + push() + check_out("master") + set_git_config_key('machete.github.remote', 'origin') + set_git_config_key('machete.github.organization', 'tester') + set_git_config_key('machete.github.repository', 'repo_sandbox') + body: str = \ + """ + master + develop + allow-ownership-link + call-ws + """ + rewrite_branch_layout_file(body) + + def test_github_anno_prs_retrieve_by_author(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_fake) + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.github_api_state_for_test_anno_prs_retrieve_by_author())) + + self.setup_repo_for_retrieve_by_author() + set_git_config_key('machete.github.retrieveByAuthor', 'true') + + # With `retrieveByAuthor` set, only PRs authored by the current user (github_user) are downloaded and annotated; + # allow-ownership-link (PR #7 by some_other_user) is deliberately left unannotated. + launch_command('github', 'anno-prs') + assert_success( + ["status"], + """ + master * + | + o-develop PR #37 + | + o-allow-ownership-link + | + o-call-ws PR #31 + """ + ) + + def test_github_anno_prs_retrieve_by_author_no_current_user(self, mocker: MockerFixture) -> None: + # No token -> the current user cannot be determined, so the fetch fails before any HTTP request is made + # (hence `urllib.request.urlopen` is deliberately left unpatched). + self.patch_symbol(mocker, 'git_machete.github.GitHubToken.for_domain', mock_github_token_for_domain_none) + + self.setup_repo_for_retrieve_by_author() + set_git_config_key('machete.github.retrieveByAuthor', 'true') + + assert_failure( + ["github", "anno-prs"], + """ + machete.github.retrieveByAuthor git config key is set, but the current GitHub user could not be determined. + Provide a GitHub API token via one of the: + 1. GITHUB_TOKEN environment variable + 2. Content of the ~/.github-token file + 3. Current auth token from the gh GitHub CLI + 4. Current auth token from the hub GitHub CLI + """ + ) diff --git a/tests/test_github_checkout_prs.py b/tests/test_github_checkout_prs.py index d36b03e58..9631b47d3 100644 --- a/tests/test_github_checkout_prs.py +++ b/tests/test_github_checkout_prs.py @@ -422,6 +422,168 @@ def test_github_checkout_prs_of_current_user_and_other_users(self, mocker: Mocke """ ) + @staticmethod + def github_api_state_for_test_checkout_prs_retrieve_by_author() -> MockGitHubAPIState: + return MockGitHubAPIState.with_prs( + mock_pr_json(head='feature/mine', base='develop', number=1, user='github_user'), + mock_pr_json(head='feature/theirs', base='develop', number=2, user='some_other_user'), + mock_pr_json(head='feature/theirs-child', base='feature/theirs', number=3, user='some_other_user'), + ) + + def setup_repo_for_checkout_prs_retrieve_by_author(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, 'urllib.request.urlopen', + mock_urlopen(self.github_api_state_for_test_checkout_prs_retrieve_by_author())) + create_repo_with_remote() + new_branch("develop") + commit("develop commit") + push() + new_branch("feature/mine") + commit("mine commit") + push() + check_out("develop") + new_branch("feature/theirs") + commit("theirs commit") + push() + new_branch("feature/theirs-child") + commit("theirs child commit") + push() + check_out("develop") + rewrite_branch_layout_file("develop") + set_git_config_key('machete.github.retrieveByAuthor', 'true') + + def test_github_checkout_prs_retrieve_by_author_all(self, mocker: MockerFixture) -> None: + # `--all` overrides `retrieveByAuthor`: every open PR is downloaded and checked out, + # including feature/theirs (PR #2) which belongs to another user. + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) + + assert_success( + ['github', 'checkout-prs', '--all'], + """ + Checking for open GitHub PRs... OK + PR #1 checked out at local branch feature/mine + PR #2 checked out at local branch feature/theirs + PR #3 checked out at local branch feature/theirs-child + """ + ) + assert_success( + ["status"], + """ + develop * + | + o-feature/mine PR #1 + | + o-feature/theirs PR #2 (some_other_user) rebase=no push=no + | + o-feature/theirs-child PR #3 (some_other_user) rebase=no push=no + """ + ) + + def test_github_checkout_prs_retrieve_by_author_by_other_user(self, mocker: MockerFixture) -> None: + # `--by=` asks the API for that user's PRs directly, so feature/theirs (PR #2) is still + # reachable even though `retrieveByAuthor` is set and PR #2 is not authored by the current user. + # Chain reconstruction walks that same author's PRs (not the current user's), so the stacked child is reattached. + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) + + assert_success( + ['github', 'checkout-prs', '--by', 'some_other_user'], + """ + Checking for open GitHub PRs by some_other_user... OK + PR #2 checked out at local branch feature/theirs + PR #3 checked out at local branch feature/theirs-child + """ + ) + assert_success( + ["status"], + """ + develop * + | + o-feature/theirs PR #2 (some_other_user) rebase=no push=no + | + o-feature/theirs-child PR #3 (some_other_user) rebase=no push=no + """ + ) + + def test_github_checkout_prs_retrieve_by_author_mine(self, mocker: MockerFixture) -> None: + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) + + # `--mine` downloads only the current user's PRs, so only feature/mine (PR #1) is checked out. + assert_success( + ['github', 'checkout-prs', '--mine'], + """ + Checking for open GitHub PRs by github_user... OK + PR #1 checked out at local branch feature/mine + """ + ) + assert_success( + ["status"], + """ + develop + | + o-feature/mine * PR #1 + """ + ) + + def test_github_checkout_prs_retrieve_by_author_by_number(self, mocker: MockerFixture) -> None: + # A PR number uses the author of that PR (not the current user) for the by-author download + # and for walking the stack, so checking out the child reattaches the parent. + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) + + assert_success( + ['github', 'checkout-prs', '3'], + """ + Checking for open GitHub PRs by some_other_user... OK + PR #2 checked out at local branch feature/theirs + PR #3 checked out at local branch feature/theirs-child + """ + ) + assert_success( + ["status"], + """ + develop + | + o-feature/theirs PR #2 (some_other_user) rebase=no push=no + | + o-feature/theirs-child * PR #3 (some_other_user) rebase=no push=no + """ + ) + + def test_github_checkout_prs_retrieve_by_author_by_number_missing(self, mocker: MockerFixture) -> None: + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) + assert_failure( + ['github', 'checkout-prs', '100'], + 'PR #100 is not found in repository example-org/example-repo' + ) + + def test_github_checkout_prs_retrieve_by_author_by_number_closed(self, mocker: MockerFixture) -> None: + # A closed PR is returned by number but absent from the author's open-PR list; + # we still keep that by-number result rather than querying the current user. + 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, 'urllib.request.urlopen', mock_urlopen(MockGitHubAPIState.with_prs( + mock_pr_json(head='feature/closed', base='develop', number=4, user='some_other_user', state='closed'), + ))) + create_repo_with_remote() + new_branch("develop") + commit("develop commit") + push() + new_branch("feature/closed") + commit("closed commit") + push() + check_out("develop") + rewrite_branch_layout_file("develop") + set_git_config_key('machete.github.retrieveByAuthor', 'true') + + assert_success( + ['github', 'checkout-prs', '4'], + """ + Checking for open GitHub PRs by some_other_user... OK + Warn: PR #4 is already closed. + PR #4 checked out at local branch feature/closed + """ + ) + def test_github_checkout_prs_misc_failures_and_warns(self, mocker: MockerFixture) -> None: create_repo_with_remote() self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url) diff --git a/tests/test_github_update_pr_descriptions.py b/tests/test_github_update_pr_descriptions.py index 01579538e..50dd8710d 100644 --- a/tests/test_github_update_pr_descriptions.py +++ b/tests/test_github_update_pr_descriptions.py @@ -84,6 +84,182 @@ def test_github_update_pr_descriptions_related_updates_entire_stack(self, mocker """ ) + def test_github_update_pr_descriptions_related_retrieve_by_author(self, mocker: MockerFixture) -> None: + """ + With `machete.github.retrieveByAuthor` set, the stack traversal underlying `--related` + downloads only the current user's PRs (repeatedly, exercising the per-author PR cache) rather than every open PR. + """ + 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) + prs = [ + mock_pr_json(head='branch1', base='root', number=1, user='github_user', body='# Summary\n'), + mock_pr_json(head='branch2', base='branch1', number=2, user='github_user', body='# Summary\n'), + mock_pr_json(head='branch3', base='branch2', number=3, user='github_user', body='# Summary\n') + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitHubAPIState.with_prs(*prs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + new_branch("branch3") + commit("branch3 commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + branch3 + """) + check_out('branch2') + set_git_config_key('machete.github.retrieveByAuthor', 'true') + + assert_success( + ['github', 'update-pr-descriptions', '--related'], + """ + Checking for open GitHub PRs by github_user... OK + Updating description of PR #2 (branch2 -> branch1)... OK + Updating description of PR #3 (branch3 -> branch2)... OK + """ + ) + + def test_github_update_pr_descriptions_retrieve_by_author_by_other_user(self, mocker: MockerFixture) -> None: + """ + `--by=` combined with `machete.github.retrieveByAuthor` asks the API for that user's PRs directly + (rather than filtering the current user's PRs), so PRs authored by someone else are still reachable when the key is set. + Chain reconstruction walks that same author's PRs, not the current user's. + """ + 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) + prs = [ + mock_pr_json(head='branch1', base='root', number=1, user='some_other_user', body='# Summary\n'), + mock_pr_json(head='branch2', base='branch1', number=2, user='some_other_user', body='# Summary\n'), + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitHubAPIState.with_prs(*prs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + """) + set_git_config_key('machete.github.retrieveByAuthor', 'true') + + assert_success( + ['github', 'update-pr-descriptions', '--by', 'some_other_user'], + """ + Checking for open GitHub PRs by some_other_user... OK + Updating description of PR #2 (branch2 -> branch1)... OK + """ + ) + + def test_github_update_pr_descriptions_retrieve_by_author_all(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) + prs = [ + mock_pr_json(head='branch1', base='root', number=1, user='github_user', body='# Summary\n'), + mock_pr_json(head='branch2', base='branch1', number=2, user='github_user', body='# Summary\n'), + mock_pr_json(head='theirs', base='root', number=3, user='some_other_user', body='# Summary\n'), + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitHubAPIState.with_prs(*prs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + check_out("root") + new_branch("theirs") + commit("theirs commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + theirs + """) + set_git_config_key('machete.github.retrieveByAuthor', 'true') + set_git_config_key('machete.github.prDescriptionIntroStyle', 'full') + + assert_success( + ['github', 'update-pr-descriptions', '--all'], + """ + Checking for open GitHub PRs... OK + Updating description of PR #1 (branch1 -> root)... OK + Updating description of PR #2 (branch2 -> branch1)... OK + """ + ) + + def test_github_update_pr_descriptions_retrieve_by_author_mine(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) + prs = [ + mock_pr_json(head='branch1', base='root', number=1, user='github_user', body='# Summary\n'), + mock_pr_json(head='branch2', base='branch1', number=2, user='github_user', body='# Summary\n'), + mock_pr_json(head='theirs', base='root', number=3, user='some_other_user', body='# Summary\n'), + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitHubAPIState.with_prs(*prs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + check_out("root") + new_branch("theirs") + commit("theirs commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + theirs + """) + set_git_config_key('machete.github.retrieveByAuthor', 'true') + set_git_config_key('machete.github.prDescriptionIntroStyle', 'full') + + assert_success( + ['github', 'update-pr-descriptions', '--mine'], + """ + Checking for open GitHub PRs by github_user... OK + Updating description of PR #1 (branch1 -> root)... OK + Updating description of PR #2 (branch2 -> branch1)... OK + """ + ) + def test_github_update_pr_descriptions_no_flag_defaults_to_related(self, mocker: MockerFixture) -> None: """ With no `--all`/`--by`/`--mine`/`--related` flag, `update-pr-descriptions` defaults to `--related`: diff --git a/tests/test_gitlab_anno_mrs.py b/tests/test_gitlab_anno_mrs.py index a128ca872..53226ae8d 100644 --- a/tests/test_gitlab_anno_mrs.py +++ b/tests/test_gitlab_anno_mrs.py @@ -206,3 +206,59 @@ def test_gitlab_anno_mrs_multiple_non_origin_gitlab_remotes(self) -> None: machete.gitlab.domain, machete.gitlab.namespace, machete.gitlab.project, machete.gitlab.remote """ # noqa: E501 ) + + @staticmethod + def gitlab_api_state_for_test_anno_mrs_retrieve_by_author() -> MockGitLabAPIState: + return MockGitLabAPIState.with_mrs( + mock_mr_json(number=7, user='some_other_user', head='allow-ownership-link', base='develop'), + mock_mr_json(number=31, user='gitlab_user', head='call-ws', base='develop'), + mock_mr_json(number=37, user='gitlab_user', head='develop', base='master') + ) + + def test_gitlab_anno_mrs_retrieve_by_author(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.gitlab.GitLabToken.for_domain', mock_gitlab_token_for_domain_fake) + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(self.gitlab_api_state_for_test_anno_mrs_retrieve_by_author())) + + create_repo_with_remote() + new_branch("master") + commit("master commit") + push() + new_branch("develop") + commit("develop commit") + push() + new_branch("allow-ownership-link") + commit("Allow ownership links") + push() + check_out("develop") + new_branch("call-ws") + commit("Call web service") + push() + check_out("master") + set_git_config_key('machete.gitlab.remote', 'origin') + set_git_config_key('machete.gitlab.namespace', 'tester') + set_git_config_key('machete.gitlab.project', 'repo_sandbox') + set_git_config_key('machete.gitlab.retrieveByAuthor', 'true') + body: str = \ + """ + master + develop + allow-ownership-link + call-ws + """ + rewrite_branch_layout_file(body) + + # With `retrieveByAuthor` set, only MRs authored by the current user (gitlab_user) are downloaded and annotated; + # allow-ownership-link (MR !7 by some_other_user) is deliberately left unannotated. + launch_command('gitlab', 'anno-mrs') + assert_success( + ["status"], + """ + master * + | + o-develop MR !37 + | + o-allow-ownership-link + | + o-call-ws MR !31 + """ + ) diff --git a/tests/test_gitlab_checkout_mrs.py b/tests/test_gitlab_checkout_mrs.py index 8183cb3f3..a237ca27e 100644 --- a/tests/test_gitlab_checkout_mrs.py +++ b/tests/test_gitlab_checkout_mrs.py @@ -597,3 +597,122 @@ def test_gitlab_checkout_mrs_main_to_main_mr(self, mocker: MockerFixture) -> Non 'Checking for open GitLab MRs... OK\n' 'MR !2 checked out at local branch fix-10341\n' ) + + @staticmethod + def gitlab_api_state_for_test_checkout_mrs_retrieve_by_author() -> MockGitLabAPIState: + return MockGitLabAPIState.with_mrs( + mock_mr_json(head='feature/mine', base='develop', number=1, user='gitlab_user'), + mock_mr_json(head='feature/theirs', base='develop', number=2, user='some_other_user'), + mock_mr_json(head='feature/theirs-child', base='feature/theirs', number=3, user='some_other_user'), + ) + + def setup_repo_for_checkout_mrs_retrieve_by_author(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url) + self.patch_symbol(mocker, 'git_machete.gitlab.GitLabToken.for_domain', mock_gitlab_token_for_domain_fake) + self.patch_symbol(mocker, 'urllib.request.urlopen', + mock_urlopen(self.gitlab_api_state_for_test_checkout_mrs_retrieve_by_author())) + create_repo_with_remote() + new_branch("develop") + commit("develop commit") + push() + new_branch("feature/mine") + commit("mine commit") + push() + check_out("develop") + new_branch("feature/theirs") + commit("theirs commit") + push() + new_branch("feature/theirs-child") + commit("theirs child commit") + push() + check_out("develop") + rewrite_branch_layout_file("develop") + set_git_config_key('machete.gitlab.retrieveByAuthor', 'true') + + def test_gitlab_checkout_mrs_retrieve_by_author_all(self, mocker: MockerFixture) -> None: + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) + + assert_success( + ['gitlab', 'checkout-mrs', '--all'], + """ + Checking for open GitLab MRs... OK + MR !1 checked out at local branch feature/mine + MR !2 checked out at local branch feature/theirs + MR !3 checked out at local branch feature/theirs-child + """ + ) + assert_success( + ["status"], + """ + develop * + | + o-feature/mine MR !1 + | + o-feature/theirs MR !2 (some_other_user) rebase=no push=no + | + o-feature/theirs-child MR !3 (some_other_user) rebase=no push=no + """ + ) + + def test_gitlab_checkout_mrs_retrieve_by_author_by_other_user(self, mocker: MockerFixture) -> None: + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) + + assert_success( + ['gitlab', 'checkout-mrs', '--by', 'some_other_user'], + """ + Checking for open GitLab MRs by some_other_user... OK + MR !2 checked out at local branch feature/theirs + MR !3 checked out at local branch feature/theirs-child + """ + ) + assert_success( + ["status"], + """ + develop * + | + o-feature/theirs MR !2 (some_other_user) rebase=no push=no + | + o-feature/theirs-child MR !3 (some_other_user) rebase=no push=no + """ + ) + + def test_gitlab_checkout_mrs_retrieve_by_author_mine(self, mocker: MockerFixture) -> None: + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) + + assert_success( + ['gitlab', 'checkout-mrs', '--mine'], + """ + Checking for open GitLab MRs by gitlab_user... OK + MR !1 checked out at local branch feature/mine + """ + ) + assert_success( + ["status"], + """ + develop + | + o-feature/mine * MR !1 + """ + ) + + def test_gitlab_checkout_mrs_retrieve_by_author_by_number(self, mocker: MockerFixture) -> None: + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) + + assert_success( + ['gitlab', 'checkout-mrs', '3'], + """ + Checking for open GitLab MRs by some_other_user... OK + MR !2 checked out at local branch feature/theirs + MR !3 checked out at local branch feature/theirs-child + """ + ) + assert_success( + ["status"], + """ + develop + | + o-feature/theirs MR !2 (some_other_user) rebase=no push=no + | + o-feature/theirs-child * MR !3 (some_other_user) rebase=no push=no + """ + ) diff --git a/tests/test_gitlab_update_mr_descriptions.py b/tests/test_gitlab_update_mr_descriptions.py index 0ad413110..301737df1 100644 --- a/tests/test_gitlab_update_mr_descriptions.py +++ b/tests/test_gitlab_update_mr_descriptions.py @@ -398,3 +398,170 @@ def test_gitlab_update_mr_descriptions_misc_failures_and_warns(self, mocker: Moc Warn: user gitlab_user has no open merge request in project example-org/example-repo """ ) + + def test_gitlab_update_mr_descriptions_related_retrieve_by_author(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url) + self.patch_symbol(mocker, 'git_machete.gitlab.GitLabToken.for_domain', mock_gitlab_token_for_domain_fake) + mrs = [ + mock_mr_json(head='branch1', base='root', number=1, user='gitlab_user', body='# Summary\n'), + mock_mr_json(head='branch2', base='branch1', number=2, user='gitlab_user', body='# Summary\n'), + mock_mr_json(head='branch3', base='branch2', number=3, user='gitlab_user', body='# Summary\n') + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitLabAPIState.with_mrs(*mrs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + new_branch("branch3") + commit("branch3 commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + branch3 + """) + check_out('branch2') + set_git_config_key('machete.gitlab.retrieveByAuthor', 'true') + + assert_success( + ['gitlab', 'update-mr-descriptions', '--related'], + """ + Checking for open GitLab MRs by gitlab_user... OK + Updating description of MR !2 (branch2 -> branch1)... OK + Updating description of MR !3 (branch3 -> branch2)... OK + """ + ) + + def test_gitlab_update_mr_descriptions_retrieve_by_author_by_other_user(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url) + self.patch_symbol(mocker, 'git_machete.gitlab.GitLabToken.for_domain', mock_gitlab_token_for_domain_fake) + mrs = [ + mock_mr_json(head='branch1', base='root', number=1, user='some_other_user', body='# Summary\n'), + mock_mr_json(head='branch2', base='branch1', number=2, user='some_other_user', body='# Summary\n'), + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitLabAPIState.with_mrs(*mrs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + """) + set_git_config_key('machete.gitlab.retrieveByAuthor', 'true') + + assert_success( + ['gitlab', 'update-mr-descriptions', '--by', 'some_other_user'], + """ + Checking for open GitLab MRs by some_other_user... OK + Updating description of MR !2 (branch2 -> branch1)... OK + """ + ) + + def test_gitlab_update_mr_descriptions_retrieve_by_author_all(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url) + self.patch_symbol(mocker, 'git_machete.gitlab.GitLabToken.for_domain', mock_gitlab_token_for_domain_fake) + mrs = [ + mock_mr_json(head='branch1', base='root', number=1, user='gitlab_user', body='# Summary\n'), + mock_mr_json(head='branch2', base='branch1', number=2, user='gitlab_user', body='# Summary\n'), + mock_mr_json(head='theirs', base='root', number=3, user='some_other_user', body='# Summary\n'), + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitLabAPIState.with_mrs(*mrs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + check_out("root") + new_branch("theirs") + commit("theirs commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + theirs + """) + set_git_config_key('machete.gitlab.retrieveByAuthor', 'true') + set_git_config_key('machete.gitlab.mrDescriptionIntroStyle', 'full') + + assert_success( + ['gitlab', 'update-mr-descriptions', '--all'], + """ + Checking for open GitLab MRs... OK + Updating description of MR !1 (branch1 -> root)... OK + Updating description of MR !2 (branch2 -> branch1)... OK + """ + ) + + def test_gitlab_update_mr_descriptions_retrieve_by_author_mine(self, mocker: MockerFixture) -> None: + self.patch_symbol(mocker, 'git_machete.code_hosting.OrganizationAndRepository.from_url', mock_from_url) + self.patch_symbol(mocker, 'git_machete.gitlab.GitLabToken.for_domain', mock_gitlab_token_for_domain_fake) + mrs = [ + mock_mr_json(head='branch1', base='root', number=1, user='gitlab_user', body='# Summary\n'), + mock_mr_json(head='branch2', base='branch1', number=2, user='gitlab_user', body='# Summary\n'), + mock_mr_json(head='theirs', base='root', number=3, user='some_other_user', body='# Summary\n'), + ] + self.patch_symbol(mocker, 'urllib.request.urlopen', mock_urlopen(MockGitLabAPIState.with_mrs(*mrs))) + self.patch_symbol(mocker, 'git_machete.utils.date.get_current_date', lambda: '2023-12-31') + + create_repo_with_remote() + new_branch("root") + commit("initial commit") + push() + new_branch("branch1") + commit("branch1 commit") + push() + new_branch("branch2") + commit("branch2 commit") + push() + check_out("root") + new_branch("theirs") + commit("theirs commit") + push() + + rewrite_branch_layout_file(""" + root + branch1 + branch2 + theirs + """) + set_git_config_key('machete.gitlab.retrieveByAuthor', 'true') + set_git_config_key('machete.gitlab.mrDescriptionIntroStyle', 'full') + + assert_success( + ['gitlab', 'update-mr-descriptions', '--mine'], + """ + Checking for open GitLab MRs by gitlab_user... OK + Updating description of MR !1 (branch1 -> root)... OK + Updating description of MR !2 (branch2 -> branch1)... OK + """ + )