From fbba9798fcf330bf72bbb00879427a7758272b54 Mon Sep 17 00:00:00 2001 From: Pawel Lipski Date: Wed, 9 Sep 2026 16:01:44 +0200 Subject: [PATCH 1/5] Add machete.{github,gitlab}.retrieveByAuthor git config keys When set, commands that list open PRs/MRs download those authored by a given user (via GitHub GraphQL search / GitLab author_username filter) instead of every open PR/MR in the repository. By default (and with `--mine`) that author is the current user; `--by=` selects a different author, and chain reconstruction walks that same author's PRs. This makes annotating and traversing one's own PRs feasible in repositories with hundreds or thousands of open PRs, at the cost of not discovering PRs opened by other users when traversing mixed-author chains. The `--all` flag still downloads every open PR/MR regardless of the key. --- RELEASE_NOTES.md | 6 + docs/man/git-machete.1 | 74 +++++++- docs/source/cli/config.rst | 6 + docs/source/cli/github.rst | 3 + docs/source/cli/gitlab.rst | 3 + .../github.retrieveByAuthor.rst | 16 ++ .../gitlab.retrieveByAuthor.rst | 16 ++ git_machete/__init__.py | 2 +- git_machete/client/traverse.py | 4 +- git_machete/client/with_code_hosting.py | 81 ++++++-- git_machete/code_hosting.py | 9 +- git_machete/config.py | 3 + git_machete/generated_docs.py | 72 +++++++ git_machete/github.py | 75 +++++++- git_machete/gitlab.py | 15 +- tests/mockers_github.py | 31 ++- tests/mockers_gitlab.py | 3 + tests/test_github_anno_prs.py | 82 +++++++- tests/test_github_checkout_prs.py | 114 ++++++++++++ tests/test_github_update_pr_descriptions.py | 176 ++++++++++++++++++ tests/test_gitlab_anno_mrs.py | 56 ++++++ tests/test_gitlab_checkout_mrs.py | 106 +++++++++++ tests/test_gitlab_update_mr_descriptions.py | 167 +++++++++++++++++ 23 files changed, 1086 insertions(+), 34 deletions(-) create mode 100644 docs/source/git-config-keys/github.retrieveByAuthor.rst create mode 100644 docs/source/git-config-keys/gitlab.retrieveByAuthor.rst 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..d7e4b17c7 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,24 @@ 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. +.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 +505,24 @@ 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. +.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 +1335,24 @@ 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. +.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 +1661,24 @@ 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. +.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..a52e8a202 --- /dev/null +++ b/docs/source/git-config-keys/github.retrieveByAuthor.rst @@ -0,0 +1,16 @@ +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. + +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..332b47ea0 --- /dev/null +++ b/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst @@ -0,0 +1,16 @@ +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. + +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..746c2e263 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`/`--mine` selection when one was given; 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,7 +263,7 @@ 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() + all_open_prs = self._get_relevant_open_prs() self.__sync_annotations_to_branch_layout_file(all_open_prs, current_user, include_urls=include_urls, verbose=True) def create_pull_request( @@ -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) @@ -894,7 +930,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 +959,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 +974,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 +989,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,12 +1004,12 @@ 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: + relevant_open_prs = self._get_relevant_open_prs() for pr_number in pr_numbers: - pr: Optional[PullRequest] = find_or_none(lambda x: x.number == pr_number, all_open_prs) + pr: Optional[PullRequest] = find_or_none(lambda x: x.number == pr_number, relevant_open_prs) if pr: result.append(pr) else: @@ -986,12 +1022,21 @@ def _get_applicable_pull_requests( 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 +1044,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..f91402a30 100644 --- a/git_machete/generated_docs.py +++ b/git_machete/generated_docs.py @@ -316,6 +316,24 @@ * `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. + + 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 +400,24 @@ * `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. + + 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 +951,24 @@ * `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. + + 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 +1200,24 @@ * `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. + + 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..db50e0e12 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..85789d848 100644 --- a/tests/test_github_checkout_prs.py +++ b/tests/test_github_checkout_prs.py @@ -422,6 +422,120 @@ 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 + """ + ) + # Checking out another user's PR by explicit number still works (it falls back to a by-number fetch) + # even though that PR is absent from the current user's PR list. + assert_success( + ['github', 'checkout-prs', '2'], + """ + Checking for open GitHub PRs by github_user... OK + PR #2 checked out at local branch feature/theirs + """ + ) + assert_success( + ["status"], + """ + develop + | + o-feature/mine PR #1 + | + o-feature/theirs * PR #2 (some_other_user) rebase=no push=no + """ + ) + 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..6cc0dd9f1 100644 --- a/tests/test_gitlab_checkout_mrs.py +++ b/tests/test_gitlab_checkout_mrs.py @@ -597,3 +597,109 @@ 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( + ['gitlab', 'checkout-mrs', '2'], + """ + Checking for open GitLab MRs by gitlab_user... OK + MR !2 checked out at local branch feature/theirs + """ + ) + assert_success( + ["status"], + """ + develop + | + o-feature/mine MR !1 + | + o-feature/theirs * MR !2 (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 + """ + ) From b47ab07cac63f151ccc63330af55c35b4054bd92 Mon Sep 17 00:00:00 2001 From: Pawel Lipski Date: Wed, 9 Sep 2026 16:25:50 +0200 Subject: [PATCH 2/5] Infer retrieveByAuthor from the first PR/MR number when checking out by number. Checkout of specific PR/MR numbers now downloads that first PR's author's open PRs and reconstructs the stack from them, rather than from the current user's. --- docs/man/git-machete.1 | 4 +++ .../github.retrieveByAuthor.rst | 1 + .../gitlab.retrieveByAuthor.rst | 1 + git_machete/client/with_code_hosting.py | 24 +++++++++++++---- git_machete/generated_docs.py | 4 +++ tests/test_github_checkout_prs.py | 27 ++++++++++++++----- tests/test_gitlab_checkout_mrs.py | 23 ++++++++++++---- 7 files changed, 67 insertions(+), 17 deletions(-) diff --git a/docs/man/git-machete.1 b/docs/man/git-machete.1 index d7e4b17c7..98160860b 100644 --- a/docs/man/git-machete.1 +++ b/docs/man/git-machete.1 @@ -416,6 +416,7 @@ download open PRs by author rather than every open PR in the repository. 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 @@ -513,6 +514,7 @@ download open MRs by author rather than every open MR in the project. 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 @@ -1344,6 +1346,7 @@ download open PRs by author rather than every open PR in the repository. 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 @@ -1670,6 +1673,7 @@ download open MRs by author rather than every open MR in the project. 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 diff --git a/docs/source/git-config-keys/github.retrieveByAuthor.rst b/docs/source/git-config-keys/github.retrieveByAuthor.rst index a52e8a202..0d37d2d12 100644 --- a/docs/source/git-config-keys/github.retrieveByAuthor.rst +++ b/docs/source/git-config-keys/github.retrieveByAuthor.rst @@ -5,6 +5,7 @@ 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 diff --git a/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst b/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst index 332b47ea0..ba64c9002 100644 --- a/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst +++ b/docs/source/git-config-keys/gitlab.retrieveByAuthor.rst @@ -5,6 +5,7 @@ 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 diff --git a/git_machete/client/with_code_hosting.py b/git_machete/client/with_code_hosting.py index 746c2e263..16950bb9e 100644 --- a/git_machete/client/with_code_hosting.py +++ b/git_machete/client/with_code_hosting.py @@ -263,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_relevant_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, @@ -894,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) @@ -1007,17 +1009,29 @@ def _get_applicable_pull_requests( repo_pretty = ( f"{spec.repository_name} {self.code_hosting_client.organization}/{self.code_hosting_client.repository}") if pr_numbers: - relevant_open_prs = self._get_relevant_open_prs() - for pr_number in pr_numbers: + 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 diff --git a/git_machete/generated_docs.py b/git_machete/generated_docs.py index f91402a30..344c47790 100644 --- a/git_machete/generated_docs.py +++ b/git_machete/generated_docs.py @@ -324,6 +324,7 @@ 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 @@ -408,6 +409,7 @@ 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 @@ -959,6 +961,7 @@ 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 @@ -1208,6 +1211,7 @@ 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 diff --git a/tests/test_github_checkout_prs.py b/tests/test_github_checkout_prs.py index 85789d848..4c08d8336 100644 --- a/tests/test_github_checkout_prs.py +++ b/tests/test_github_checkout_prs.py @@ -516,13 +516,26 @@ def test_github_checkout_prs_retrieve_by_author_mine(self, mocker: MockerFixture PR #1 checked out at local branch feature/mine """ ) - # Checking out another user's PR by explicit number still works (it falls back to a by-number fetch) - # even though that PR is absent from the current user's PR list. assert_success( - ['github', 'checkout-prs', '2'], + ["status"], """ - Checking for open GitHub PRs by github_user... OK + 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( @@ -530,9 +543,9 @@ def test_github_checkout_prs_retrieve_by_author_mine(self, mocker: MockerFixture """ develop | - o-feature/mine PR #1 - | - o-feature/theirs * PR #2 (some_other_user) rebase=no push=no + 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 """ ) diff --git a/tests/test_gitlab_checkout_mrs.py b/tests/test_gitlab_checkout_mrs.py index 6cc0dd9f1..e084f3b13 100644 --- a/tests/test_gitlab_checkout_mrs.py +++ b/tests/test_gitlab_checkout_mrs.py @@ -687,10 +687,23 @@ def test_gitlab_checkout_mrs_retrieve_by_author_mine(self, mocker: MockerFixture """ ) assert_success( - ['gitlab', 'checkout-mrs', '2'], + ["status"], """ - Checking for open GitLab MRs by gitlab_user... OK + 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( @@ -698,8 +711,8 @@ def test_gitlab_checkout_mrs_retrieve_by_author_mine(self, mocker: MockerFixture """ develop | - o-feature/mine MR !1 - | - o-feature/theirs * MR !2 (some_other_user) rebase=no push=no + 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 """ ) From 54a43a9cfd746b8f9e1e426a205157ade39eb504 Mon Sep 17 00:00:00 2001 From: Pawel Lipski Date: Wed, 9 Sep 2026 22:02:55 +0200 Subject: [PATCH 3/5] Cover retrieveByAuthor checkout-by-number miss and closed-PR fallback. --- tests/test_github_checkout_prs.py | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_github_checkout_prs.py b/tests/test_github_checkout_prs.py index 4c08d8336..07c0fef00 100644 --- a/tests/test_github_checkout_prs.py +++ b/tests/test_github_checkout_prs.py @@ -549,6 +549,41 @@ def test_github_checkout_prs_retrieve_by_author_by_number(self, mocker: MockerFi """ ) + 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) From e011c44463420a9f56721bab18337706c5d050e0 Mon Sep 17 00:00:00 2001 From: Pawel Lipski Date: Thu, 17 Sep 2026 16:17:47 +0200 Subject: [PATCH 4/5] Make retrieveByAuthor test setup helpers public. --- tests/test_github_anno_prs.py | 6 +++--- tests/test_github_checkout_prs.py | 12 ++++++------ tests/test_gitlab_checkout_mrs.py | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_github_anno_prs.py b/tests/test_github_anno_prs.py index db50e0e12..ccc96832c 100644 --- a/tests/test_github_anno_prs.py +++ b/tests/test_github_anno_prs.py @@ -215,7 +215,7 @@ def github_api_state_for_test_anno_prs_retrieve_by_author() -> MockGitHubAPIStat mock_pr_json(number=37, user='github_user', head='develop', base='master') ) - def __setup_repo_for_retrieve_by_author(self) -> None: + def setup_repo_for_retrieve_by_author(self) -> None: create_repo_with_remote() new_branch("master") commit("master commit") @@ -247,7 +247,7 @@ 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() + 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; @@ -271,7 +271,7 @@ def test_github_anno_prs_retrieve_by_author_no_current_user(self, mocker: Mocker # (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() + self.setup_repo_for_retrieve_by_author() set_git_config_key('machete.github.retrieveByAuthor', 'true') assert_failure( diff --git a/tests/test_github_checkout_prs.py b/tests/test_github_checkout_prs.py index 07c0fef00..9631b47d3 100644 --- a/tests/test_github_checkout_prs.py +++ b/tests/test_github_checkout_prs.py @@ -430,7 +430,7 @@ def github_api_state_for_test_checkout_prs_retrieve_by_author() -> MockGitHubAPI 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: + 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', @@ -456,7 +456,7 @@ def __setup_repo_for_checkout_prs_retrieve_by_author(self, mocker: MockerFixture 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) + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) assert_success( ['github', 'checkout-prs', '--all'], @@ -484,7 +484,7 @@ def test_github_checkout_prs_retrieve_by_author_by_other_user(self, mocker: Mock # `--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) + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) assert_success( ['github', 'checkout-prs', '--by', 'some_other_user'], @@ -506,7 +506,7 @@ def test_github_checkout_prs_retrieve_by_author_by_other_user(self, mocker: Mock ) def test_github_checkout_prs_retrieve_by_author_mine(self, mocker: MockerFixture) -> None: - self.__setup_repo_for_checkout_prs_retrieve_by_author(mocker) + 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( @@ -528,7 +528,7 @@ def test_github_checkout_prs_retrieve_by_author_mine(self, mocker: MockerFixture 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) + self.setup_repo_for_checkout_prs_retrieve_by_author(mocker) assert_success( ['github', 'checkout-prs', '3'], @@ -550,7 +550,7 @@ def test_github_checkout_prs_retrieve_by_author_by_number(self, mocker: MockerFi ) 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) + 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' diff --git a/tests/test_gitlab_checkout_mrs.py b/tests/test_gitlab_checkout_mrs.py index e084f3b13..a237ca27e 100644 --- a/tests/test_gitlab_checkout_mrs.py +++ b/tests/test_gitlab_checkout_mrs.py @@ -606,7 +606,7 @@ def gitlab_api_state_for_test_checkout_mrs_retrieve_by_author() -> MockGitLabAPI 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: + 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', @@ -630,7 +630,7 @@ def __setup_repo_for_checkout_mrs_retrieve_by_author(self, mocker: MockerFixture 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) + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) assert_success( ['gitlab', 'checkout-mrs', '--all'], @@ -655,7 +655,7 @@ def test_gitlab_checkout_mrs_retrieve_by_author_all(self, mocker: MockerFixture) ) 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) + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) assert_success( ['gitlab', 'checkout-mrs', '--by', 'some_other_user'], @@ -677,7 +677,7 @@ def test_gitlab_checkout_mrs_retrieve_by_author_by_other_user(self, mocker: Mock ) def test_gitlab_checkout_mrs_retrieve_by_author_mine(self, mocker: MockerFixture) -> None: - self.__setup_repo_for_checkout_mrs_retrieve_by_author(mocker) + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) assert_success( ['gitlab', 'checkout-mrs', '--mine'], @@ -696,7 +696,7 @@ def test_gitlab_checkout_mrs_retrieve_by_author_mine(self, mocker: MockerFixture ) def test_gitlab_checkout_mrs_retrieve_by_author_by_number(self, mocker: MockerFixture) -> None: - self.__setup_repo_for_checkout_mrs_retrieve_by_author(mocker) + self.setup_repo_for_checkout_mrs_retrieve_by_author(mocker) assert_success( ['gitlab', 'checkout-mrs', '3'], From ff7d0ca85e8f0c13d711df68f241bcce1b74e3fa Mon Sep 17 00:00:00 2001 From: Pawel Lipski Date: Thu, 17 Sep 2026 16:44:30 +0200 Subject: [PATCH 5/5] Clarify that retrieveByAuthor's author is --by or a PR number, not --mine. --- git_machete/client/with_code_hosting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git_machete/client/with_code_hosting.py b/git_machete/client/with_code_hosting.py index 16950bb9e..decbc44a2 100644 --- a/git_machete/client/with_code_hosting.py +++ b/git_machete/client/with_code_hosting.py @@ -46,7 +46,7 @@ def code_hosting_client(self, value: CodeHostingApi) -> None: 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`/`--mine` selection when one was given; otherwise the current user. + # `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.