1
0
mirror of https://github.com/openSUSE/osc.git synced 2025-02-05 02:56:17 +01:00

Add 'git-obs pr checkout' command

This commit is contained in:
Daniel Mach 2025-01-23 09:17:33 +01:00
parent 0b5f9a0a8b
commit 9598f8070a
7 changed files with 347 additions and 7 deletions

View File

@ -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
"""

View File

@ -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):

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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

View File

@ -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,