From 9598f8070abf10be76ff0f278bd6ab39ca9cdea8 Mon Sep 17 00:00:00 2001 From: Daniel Mach Date: Thu, 23 Jan 2025 09:17:33 +0100 Subject: [PATCH] Add 'git-obs pr checkout' command --- behave/features/git-pr.feature | 113 +++++++++++++++++++++++++++++++- osc/commandline_git.py | 12 ++++ osc/commands_git/pr_checkout.py | 63 ++++++++++++++++++ osc/commands_git/pr_get.py | 2 +- osc/commands_git/pr_set.py | 71 ++++++++++++++++++++ osc/gitea_api/git.py | 57 +++++++++++++++- osc/gitea_api/pr.py | 36 +++++++++- 7 files changed, 347 insertions(+), 7 deletions(-) create mode 100644 osc/commands_git/pr_checkout.py create mode 100644 osc/commands_git/pr_set.py diff --git a/behave/features/git-pr.feature b/behave/features/git-pr.feature index e5793811..4fa1ddd6 100644 --- a/behave/features/git-pr.feature +++ b/behave/features/git-pr.feature @@ -6,8 +6,10 @@ Background: And I execute git-obs with args "repo fork pool/test-GitPkgA" And I execute git-obs with args "repo clone Admin/test-GitPkgA --no-ssh-strict-host-key-checking" And I set working directory to "{context.osc.temp}/test-GitPkgA" - And I execute "sed -i 's@^\(Version.*\)@\1.1@' *.spec" - And I execute "git commit -m 'Change version' -a" + And I execute "sed -i 's@^\(Version: *\) .*@\1 v1.1@' *.spec" + And I execute "git commit -m 'v1.1' -a" + And I execute "sed -i 's@^\(Version: *\) .*@\1 v1.2@' *.spec" + And I execute "git commit -m 'v1.2' -a" And I execute "git push" And I execute git-obs with args "pr create --title 'Change version' --description='some text'" @@ -24,6 +26,7 @@ Scenario: List pull requests State : open Draft : no Merged : no + Allow edit : no Author : Admin \(admin@example.com\) Source : Admin/test-GitPkgA, branch: factory, commit: .* Description : some text @@ -79,6 +82,7 @@ Scenario: Get a pull request State : open Draft : no Merged : no + Allow edit : no Author : Admin \(admin@example.com\) Source : Admin/test-GitPkgA, branch: factory, commit: .* Description : some text @@ -113,3 +117,108 @@ Scenario: Get a pull request that doesn't exist Total entries: 0 ERROR: Couldn't retrieve the following pull requests: does-not/exist#1 """ + + +@destructive +Scenario: Checkout a pull request + Given I set working directory to "{context.osc.temp}" + And I execute git-obs with args "repo clone pool/test-GitPkgA --no-ssh-strict-host-key-checking --directory=pool-test-GitPkgA" + And I set working directory to "{context.osc.temp}/pool-test-GitPkgA" + When I execute git-obs with args "pr checkout 1" + Then the exit code is 0 + And stdout is + """ + """ + And stderr is + """ + Using the following Gitea settings: + * Config path: {context.git_obs.config} + * Login (name of the entry in the config file): admin + * URL: http://localhost:{context.podman.container.ports[gitea_http]} + * User: Admin + + Using core.sshCommand: ssh -o IdentitiesOnly=yes -o IdentityFile={context.fixtures}/ssh-keys/admin -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR + From ssh://localhost:{context.podman.container.ports[gitea_ssh]}/Admin/test-GitPkgA + * [new branch] factory -> Admin/factory + Using core.sshCommand: ssh -o IdentitiesOnly=yes -o IdentityFile={context.fixtures}/ssh-keys/admin -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR + From ssh://localhost:{context.podman.container.ports[gitea_ssh]}/pool/test-GitPkgA + * [new ref] refs/pull/1/head -> pull/1 + Switched to branch 'pull/1' + """ + +@destructive +Scenario: Checkout a pull request as a different user, make changes, commit, push + Given I set working directory to "{context.osc.temp}" + And I execute git-obs with args "repo clone pool/test-GitPkgA --no-ssh-strict-host-key-checking --directory=pool-test-GitPkgA -G alice" + And I set working directory to "{context.osc.temp}/pool-test-GitPkgA" + And I execute git-obs with args "pr checkout 1" + And I execute git-obs with args "api -X PUT teams/1/members/alice" + And I execute git-obs with args "pr set --allow-maintainer-edit=1 pool/test-GitPkgA#1" + When I execute "sed -i 's@^\(Version: *\) .*@\1 v1.3@' *.spec" + And I execute "git commit -m 'v1.3' -a" + And I execute "git push" + Then the exit code is 0 + + +@destructive +Scenario: Rebase a pull request checkout to fast-forwardable changes + # Alice makes a pull request checkout + Given I set working directory to "{context.osc.temp}" + And I execute git-obs with args "repo clone pool/test-GitPkgA --no-ssh-strict-host-key-checking --directory=alice-test-GitPkgA -G alice" + And I set working directory to "{context.osc.temp}/alice-test-GitPkgA" + And I execute git-obs with args "pr checkout 1" + + # Admin pushes additional changes + Given I set working directory to "{context.osc.temp}/test-GitPkgA" + And I execute "sed -i 's@^\(Version: *\) .*@\1 v2@' *.spec" + And I execute "git commit -m 'v2' -a" + And I execute "git push" + + # rebase Alice's checkout to the latest Admin's changes + When I set working directory to "{context.osc.temp}/alice-test-GitPkgA" + # `git fetch` is required to fetch all new changes before the rebase + And I execute "git fetch Admin" + And I execute "git rebase" + And I execute "git log --pretty=format:%s HEAD^^..HEAD" + Then the exit code is 0 + And stdout is + """ + v2 + v1.2 + """ + + +@destructive +Scenario: Rebase a pull request checkout to non fast-forwardable changes + # Alice makes a pull request checkout + Given I set working directory to "{context.osc.temp}" + And I execute git-obs with args "repo clone pool/test-GitPkgA --no-ssh-strict-host-key-checking --directory=alice-test-GitPkgA -G alice" + And I set working directory to "{context.osc.temp}/alice-test-GitPkgA" + And I execute git-obs with args "pr checkout 1" + And I execute "sed -i 's@^\(Version: *\) .*@\1 v123@' *.spec" + And I execute "git commit -m 'v123' -a" + + # Admin pushes a non fast-forwardable change + Given I set working directory to "{context.osc.temp}/test-GitPkgA" + And I execute "git reset --hard HEAD^" + And I execute "git push --force" + + # rebase Alice's checkout to the latest Admin's changes + When I set working directory to "{context.osc.temp}/alice-test-GitPkgA" + # `git fetch` is required to fetch all new changes before the rebase + And I execute "git fetch Admin" + And I execute "git rebase" + # error due to a conflicting file + And the exit code is 1 + # --theirs refers to Alice's version of the file + And I execute "git checkout --theirs test-GitPkgA.spec" + And I execute "git add test-GitPkgA.spec" + # avoid opening an editor by setting the GIT_EDITOR env variable + And I execute "GIT_EDITOR=true git rebase --continue" + And I execute "git log --pretty=format:%s HEAD^^..HEAD" + Then the exit code is 0 + And stdout is + """ + v123 + v1.1 + """ diff --git a/osc/commandline_git.py b/osc/commandline_git.py index 4dcb10c7..b36663a5 100644 --- a/osc/commandline_git.py +++ b/osc/commandline_git.py @@ -39,6 +39,18 @@ class OwnerRepoPullAction(argparse.Action): setattr(namespace, self.dest, namespace_value) +class BooleanAction(argparse.Action): + def __call__(self, parser, namespace, value, option_string=None): + if value is None: + setattr(namespace, self.dest, None) + elif value.lower() in ["0", "no", "false", "off"]: + setattr(namespace, self.dest, False) + elif value.lower() in ["1", "yes", "true", "on"]: + setattr(namespace, self.dest, True) + else: + raise argparse.ArgumentError(self, f"Invalid boolean value: {value}") + + class GitObsCommand(osc.commandline_common.Command): @property def gitea_conf(self): diff --git a/osc/commands_git/pr_checkout.py b/osc/commands_git/pr_checkout.py new file mode 100644 index 00000000..88261076 --- /dev/null +++ b/osc/commands_git/pr_checkout.py @@ -0,0 +1,63 @@ +import subprocess + +import osc.commandline_git + + +class PullRequestCheckoutCommand(osc.commandline_git.GitObsCommand): + """ + Check out a pull request + """ + + name = "checkout" + parent = "PullRequestCommand" + + def init_arguments(self): + self.add_argument( + "pull", + type=int, + help="Number of the pull request", + ) + self.add_argument( + "-f", + "--force", + action="store_true", + help="Reset the existing local branch to the latest state of the pull request", + ) + + def run(self, args): + from osc import gitea_api + + self.print_gitea_settings() + + git = gitea_api.Git(".") + owner, repo = git.get_owner_repo() + + pr = gitea_api.PullRequest.get(self.gitea_conn, owner, repo, args.pull).json() + + head_ssh_url = pr["head"]["repo"]["ssh_url"] + head_owner = pr["head"]["repo"]["owner"]["login"] + head_branch = pr["head"]["ref"] + + try: + git.add_remote(head_owner, head_ssh_url) + except subprocess.CalledProcessError as e: + # TODO: check if the remote url matches + if e.returncode != 3: + # returncode 3 means that the remote exists; see `man git-remote` + raise + git.fetch(head_owner) + + local_branch = git.fetch_pull_request(args.pull, force=args.force) + + # LFS data may not be accessible in the "origin" remote, we need to allow searching in all remotes + git.set_config("lfs.remote.searchall", "1") + + # configure branch for `git push` + git.set_config(f"branch.{local_branch}.remote", head_owner) + git.set_config(f"branch.{local_branch}.pushRemote", head_owner) + git.set_config(f"branch.{local_branch}.merge", f"refs/heads/{head_branch}") + + # allow `git push` with no arguments to push to a remote branch that is named differently than the local branch + git.set_config("push.default", "upstream") + + git.switch(local_branch) diff --git a/osc/commands_git/pr_get.py b/osc/commands_git/pr_get.py index 1db77abd..37254082 100644 --- a/osc/commands_git/pr_get.py +++ b/osc/commands_git/pr_get.py @@ -32,7 +32,7 @@ class PullRequestGetCommand(osc.commandline_git.GitObsCommand): failed_entries = [] for owner, repo, pull in args.owner_repo_pull: try: - pr = gitea_api.PullRequest.get(self.gitea_conn, owner, repo, pull).json() + pr = gitea_api.PullRequest.get(self.gitea_conn, owner, repo, int(pull)).json() num_entries += 1 except gitea_api.GiteaException as e: if e.status == 404: diff --git a/osc/commands_git/pr_set.py b/osc/commands_git/pr_set.py new file mode 100644 index 00000000..13279f2d --- /dev/null +++ b/osc/commands_git/pr_set.py @@ -0,0 +1,71 @@ +import sys + +import osc.commandline_git + + +def b(value: str): + if value is not None: + return value.lower() in ["1", "yes", "true", "on"] + return None + + +class PullRequestSetCommand(osc.commandline_git.GitObsCommand): + """ + Change a pull request + """ + + name = "set" + parent = "PullRequestCommand" + + def init_arguments(self): + self.add_argument_owner_repo_pull(nargs="+") + self.add_argument( + "--title", + ) + self.add_argument( + "--description", + ) + self.add_argument( + "--allow-maintainer-edit", + action=osc.commandline_git.BooleanAction, + help="Users with write access to the base branch can also push to the pull request's head branch", + ) + + def run(self, args): + from osc import gitea_api + from osc.core import highlight_diff + from osc.output import tty + + self.print_gitea_settings() + print(args) + + num_entries = 0 + failed_entries = [] + for owner, repo, pull in args.owner_repo_pull: + try: + pr = gitea_api.PullRequest.set( + self.gitea_conn, + owner, + repo, + int(pull), + title=args.title, + description=args.description, + allow_maintainer_edit=args.allow_maintainer_edit, + ).json() + num_entries += 1 + except gitea_api.GiteaException as e: + if e.status == 404: + failed_entries.append(f"{owner}/{repo}#{pull}") + continue + raise + + print(gitea_api.PullRequest.to_human_readable_string(pr)) + print() + + print(f"Total modified entries: {num_entries}", file=sys.stderr) + if failed_entries: + print( + f"{tty.colorize('ERROR', 'red,bold')}: Couldn't change the following pull requests: {', '.join(failed_entries)}", + file=sys.stderr, + ) + sys.exit(1) diff --git a/osc/gitea_api/git.py b/osc/gitea_api/git.py index 9b17ec09..6c60adf5 100644 --- a/osc/gitea_api/git.py +++ b/osc/gitea_api/git.py @@ -1,6 +1,7 @@ import os import subprocess import urllib +from typing import Optional from typing import Tuple @@ -8,9 +9,17 @@ class Git: def __init__(self, workdir): self.abspath = os.path.abspath(workdir) - def _run_git(self, args) -> str: + def _run_git(self, args: list) -> str: return subprocess.check_output(["git"] + args, encoding="utf-8", cwd=self.abspath).strip() + def init(self, *, quiet=True): + cmd = ["init"] + if quiet: + cmd += ["-q"] + self._run_git(cmd) + + # BRANCHES + @property def current_branch(self) -> str: return self._run_git(["branch", "--show-current"]) @@ -18,14 +27,58 @@ class Git: def get_branch_head(self, branch: str) -> str: return self._run_git(["rev-parse", branch]) + def switch(self, branch: str): + self._run_git(["switch", branch]) + + def fetch_pull_request( + self, + pull_number: int, + *, + remote: str = "origin", + force: bool = False, + ): + """ + Fetch pull/$pull_number/head to pull/$pull_number branch + """ + target_branch = f"pull/{pull_number}" + cmd = ["fetch", remote, f"pull/{pull_number}/head:{target_branch}"] + if force: + cmd += [ + "--force", + "--update-head-ok", + ] + self._run_git(cmd) + return target_branch + + # CONFIG + + def set_config(self, key: str, value: str): + self._run_git(["config", key, value]) + + # REMOTES + def get_remote_url(self, name: str = "origin") -> str: return self._run_git(["remote", "get-url", name]) + def add_remote(self, name: str, url: str): + self._run_git(["remote", "add", name, url]) + + def fetch(self, name: Optional[str] = None): + if name: + cmd = ["fetch", name] + else: + cmd = ["fetch", "--all"] + self._run_git(cmd) + def get_owner_repo(self, remote: str = "origin") -> Tuple[str, str]: remote_url = self.get_remote_url(name=remote) + if "@" in remote_url: + # ssh://gitea@example.com:owner/repo.git + # ssh://gitea@example.com:22/owner/repo.git + remote_url = remote_url.rsplit("@", 1)[-1] parsed_remote_url = urllib.parse.urlparse(remote_url) path = parsed_remote_url.path if path.endswith(".git"): path = path[:-4] - owner, repo = path.strip("/").split("/")[:2] + owner, repo = path.strip("/").split("/")[-2:] return owner, repo diff --git a/osc/gitea_api/pr.py b/osc/gitea_api/pr.py index 4b91fc15..9d5c9bfb 100644 --- a/osc/gitea_api/pr.py +++ b/osc/gitea_api/pr.py @@ -56,6 +56,7 @@ class PullRequest: if is_pull_request: table.add("Draft", yes_no(entry["draft"])) table.add("Merged", yes_no(entry["merged"])) + table.add("Allow edit", yes_no(entry["allow_maintainer_edit"])) table.add("Author", f"{User.to_login_full_name_email_string(entry['user'])}") if is_pull_request: table.add("Source", f"{entry['head']['repo']['full_name']}, branch: {entry['head']['ref']}, commit: {entry['head']['sha']}") @@ -113,7 +114,7 @@ class PullRequest: conn: Connection, owner: str, repo: str, - number: str, + number: int, ) -> GiteaHTTPResponse: """ Get a pull request. @@ -123,9 +124,40 @@ class PullRequest: :param repo: Name of the repo. :param number: Number of the pull request in the repo. """ - url = conn.makeurl("repos", owner, repo, "pulls", number) + url = conn.makeurl("repos", owner, repo, "pulls", str(number)) return conn.request("GET", url) + @classmethod + def set( + cls, + conn: Connection, + owner: str, + repo: str, + number: int, + *, + title: Optional[str] = None, + description: Optional[str] = None, + allow_maintainer_edit: Optional[bool] = None, + ) -> GiteaHTTPResponse: + """ + Change a pull request. + + :param conn: Gitea ``Connection`` instance. + :param owner: Owner of the repo. + :param repo: Name of the repo. + :param number: Number of the pull request in the repo. + :param title: Change pull request title. + :param description: Change pull request description. + :param allow_maintainer_edit: Change whether users with write access to the base branch can also push to the pull request's head branch. + """ + json_data = { + "title": title, + "description": description, + "allow_maintainer_edit": allow_maintainer_edit, + } + url = conn.makeurl("repos", owner, repo, "pulls", str(number)) + return conn.request("PATCH", url, json_data=json_data) + @classmethod def list( cls,