1
0
mirror of https://github.com/openSUSE/osc.git synced 2025-02-13 22:07:18 +01:00

Merge pull request #1694 from dmach/gitea_pr

Add 'git-obs pr' command
This commit is contained in:
Daniel Mach 2025-01-16 21:04:14 +01:00 committed by GitHub
commit 289bf02eaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1007 additions and 55 deletions

View File

@ -42,7 +42,10 @@ jobs:
- name: 'Install packages'
run: |
sudo apt-get -y update
sudo apt-get -y --no-install-recommends install pylint python3-rpm python3-ruamel.yaml
sudo apt-get -y --no-install-recommends install python3-rpm python3-ruamel.yaml
# we're using the latest pylint from pypi
sudo pip3 config set global.break-system-packages 1
sudo pip3 install pylint
- uses: actions/checkout@v3
@ -68,7 +71,10 @@ jobs:
- name: 'Install packages'
run: |
sudo apt-get -y update
sudo apt-get -y --no-install-recommends install diffutils pylint python3-pip python3-rpm python3-ruamel.yaml
sudo apt-get -y --no-install-recommends install diffutils python3-pip python3-rpm python3-ruamel.yaml
# we're using the latest pylint from pypi
sudo pip3 config set global.break-system-packages 1
sudo pip3 install pylint
- uses: actions/checkout@v3
with:

View File

@ -146,6 +146,11 @@ jobs:
run: |
podman pull ghcr.io/suse-autobuild/obs-server:latest
- name: "Configure git"
run: |
git config --global user.email "admin@example.com"
git config --global user.name "Admin"
- name: "Run tests"
run: |
cd behave

View File

@ -17,14 +17,14 @@ def after_step(context, step):
def before_scenario(context, scenario):
# truncate the logs before running any commands
proc = context.podman.container.exec(["bash", "-c", "find /srv/www/obs/api/log/ /srv/obs/log/ -name '*.log' -exec truncate --size=0 {} \\;"])
proc = context.podman.container.exec(["bash", "-c", "find /srv/www/obs/api/log/ /srv/obs/log/ /var/log/gitea/ -name '*.log' -exec truncate --size=0 {} \\;"])
def after_scenario(context, scenario):
if scenario.status == Status.failed:
# the scenario has failed, dump server logs
print("===== BEGIN: server logs ======")
proc = context.podman.container.exec(["bash", "-c", "tail -n +1 /srv/www/obs/api/log/*.log /srv/obs/log/*.log"])
proc = context.podman.container.exec(["bash", "-c", "tail -n +1 /srv/www/obs/api/log/*.log /srv/obs/log/*.log /var/log/gitea/*.log"])
print(proc.stdout)
print("===== END: server logs ======")

View File

@ -0,0 +1,115 @@
Feature: `git-obs pr` command
Background:
Given I set working directory to "{context.osc.temp}"
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 "git push"
And I execute git-obs with args "pr create --title 'Change version' --description='some text'"
@destructive
Scenario: List pull requests
When I execute git-obs with args "pr list pool/test-GitPkgA"
Then the exit code is 0
And stdout matches
"""
ID : pool/test-GitPkgA#1
URL : http://localhost:{context.podman.container.ports[gitea_http]}/pool/test-GitPkgA/pulls/1
Title : Change version
State : open
Draft : no
Merged : no
Author : Admin \(admin@example.com\)
Source : Admin/test-GitPkgA, branch: factory, commit: .*
Description : some text
"""
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
Total entries: 1
"""
@destructive
Scenario: Search pull requests
When I execute git-obs with args "pr search"
Then the exit code is 0
And stdout matches
"""
ID : pool/test-GitPkgA#1
URL : http://localhost:{context.podman.container.ports[gitea_http]}/pool/test-GitPkgA/pulls/1
Title : Change version
State : open
Author : Admin \(admin@example.com\)
Description : some text
"""
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
Total entries: 1
"""
@destructive
Scenario: Get a pull request
When I execute git-obs with args "pr get pool/test-GitPkgA#1"
Then the exit code is 0
And stdout matches
"""
ID : pool/test-GitPkgA#1
URL : http://localhost:{context.podman.container.ports[gitea_http]}/pool/test-GitPkgA/pulls/1
Title : Change version
State : open
Draft : no
Merged : no
Author : Admin \(admin@example.com\)
Source : Admin/test-GitPkgA, branch: factory, commit: .*
Description : some text
"""
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
Total entries: 1
"""
@destructive
Scenario: Get a pull request that doesn't exist
When I execute git-obs with args "pr get does-not/exist#1"
Then the exit code is 1
And stdout matches
"""
"""
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
Total entries: 0
ERROR: Couldn't retrieve the following pull requests: does-not/exist#1
"""

View File

@ -7,7 +7,7 @@ Background:
@destructive
Scenario: Clone a git repo
When I execute git-obs with args "repo clone pool test-GitPkgA --no-ssh-strict-host-key-checking"
When I execute git-obs with args "repo clone pool/test-GitPkgA --no-ssh-strict-host-key-checking"
Then the exit code is 0
And stdout is
"""
@ -20,5 +20,8 @@ Scenario: Clone a git repo
* URL: http://localhost:{context.podman.container.ports[gitea_http]}
* User: Admin
Cloning git repo pool/test-GitPkgA ...
Cloning into 'test-GitPkgA'...
Total cloned repos: 1
"""

View File

@ -7,7 +7,7 @@ Background:
@destructive
Scenario: Fork a git repo
When I execute git-obs with args "repo fork pool test-GitPkgA"
When I execute git-obs with args "repo fork pool/test-GitPkgA"
Then the exit code is 0
And stdout is
"""
@ -22,12 +22,14 @@ Scenario: Fork a git repo
Forking git repo pool/test-GitPkgA ...
* Fork created: Admin/test-GitPkgA
Total forked repos: 1
"""
@destructive
Scenario: Fork a git repo twice under different names
When I execute git-obs with args "repo fork pool test-GitPkgA"
When I execute git-obs with args "repo fork pool/test-GitPkgA"
Then the exit code is 0
And stdout is
"""
@ -42,8 +44,10 @@ Scenario: Fork a git repo twice under different names
Forking git repo pool/test-GitPkgA ...
* Fork created: Admin/test-GitPkgA
Total forked repos: 1
"""
When I execute git-obs with args "repo fork pool test-GitPkgA --new-repo-name=new-package"
When I execute git-obs with args "repo fork pool/test-GitPkgA --new-repo-name=new-package"
Then the exit code is 0
And stdout is
"""
@ -59,12 +63,14 @@ Scenario: Fork a git repo twice under different names
Forking git repo pool/test-GitPkgA ...
* Fork already exists: Admin/test-GitPkgA
* WARNING: Using an existing fork with a different name than requested
Total forked repos: 1
"""
@destructive
Scenario: Fork a git repo from pool and fork someone else's fork of the same repo
When I execute git-obs with args "repo fork pool test-GitPkgA"
When I execute git-obs with args "repo fork pool/test-GitPkgA"
Then the exit code is 0
And stdout is
"""
@ -79,8 +85,10 @@ Scenario: Fork a git repo from pool and fork someone else's fork of the same rep
Forking git repo pool/test-GitPkgA ...
* Fork created: Admin/test-GitPkgA
Total forked repos: 1
"""
When I execute git-obs with args "repo fork -G alice pool test-GitPkgA --new-repo-name=test-GitPkgA-alice"
When I execute git-obs with args "repo fork -G alice pool/test-GitPkgA --new-repo-name=test-GitPkgA-alice"
Then the exit code is 0
And stdout is
"""
@ -95,9 +103,11 @@ Scenario: Fork a git repo from pool and fork someone else's fork of the same rep
Forking git repo pool/test-GitPkgA ...
* Fork created: Alice/test-GitPkgA-alice
Total forked repos: 1
"""
# this succeeds with 202 and the requested fork is NOT created
When I execute git-obs with args "repo fork Alice test-GitPkgA-alice"
When I execute git-obs with args "repo fork Alice/test-GitPkgA-alice"
Then the exit code is 0
And stdout is
"""
@ -112,6 +122,8 @@ Scenario: Fork a git repo from pool and fork someone else's fork of the same rep
Forking git repo Alice/test-GitPkgA-alice ...
* Fork created: Admin/test-GitPkgA-alice
Total forked repos: 1
"""
When I execute git-obs with args "repo clone Admin test-GitPkgA-alice --no-ssh-strict-host-key-checking"
When I execute git-obs with args "repo clone Admin/test-GitPkgA-alice --no-ssh-strict-host-key-checking"
Then the exit code is 0

View File

@ -83,6 +83,12 @@ def run_in_context(context, cmd, can_fail=False, **run_args):
raise AssertionError('Running command "%s" failed: %s' % (cmd, context.cmd_exitcode))
@behave.step("I execute \"{command}\"")
def step_impl(context, command):
command = command.format(context=context)
run_in_context(context, command, can_fail=True)
@behave.step("stdout contains \"{text}\"")
def step_impl(context, text):
if re.search(text.format(context=context), context.cmd_stdout):

View File

@ -1,4 +1,6 @@
import argparse
import os
import subprocess
import sys
import osc.commandline_common
@ -7,6 +9,36 @@ from . import oscerr
from .output import print_msg
class OwnerRepoAction(argparse.Action):
def __call__(self, parser, namespace, value, option_string=None):
from . import gitea_api
try:
if isinstance(value, list):
namespace_value = [gitea_api.Repo.split_id(i) for i in value]
else:
namespace_value = gitea_api.Repo.split_id(value)
except ValueError as e:
raise argparse.ArgumentError(self, str(e))
setattr(namespace, self.dest, namespace_value)
class OwnerRepoPullAction(argparse.Action):
def __call__(self, parser, namespace, value, option_string=None):
from . import gitea_api
try:
if isinstance(value, list):
namespace_value = [gitea_api.PullRequest.split_id(i) for i in value]
else:
namespace_value = gitea_api.PullRequest.split_id(value)
except ValueError as e:
raise argparse.ArgumentError(self, str(e))
setattr(namespace, self.dest, namespace_value)
class GitObsCommand(osc.commandline_common.Command):
@property
def gitea_conf(self):
@ -28,16 +60,20 @@ class GitObsCommand(osc.commandline_common.Command):
print(f" * User: {self.gitea_login.user}", file=sys.stderr)
print("", file=sys.stderr)
def add_argument_owner(self):
def add_argument_owner_repo(self, **kwargs):
self.add_argument(
"owner",
help="Name of the repository owner (login, org)",
"owner_repo",
action=OwnerRepoAction,
help="Owner and repo: (format: <owner>/<repo>)",
**kwargs,
)
def add_argument_repo(self):
def add_argument_owner_repo_pull(self, **kwargs):
self.add_argument(
"repo",
help="Name of the repository",
"owner_repo_pull",
action=OwnerRepoPullAction,
help="Owner, repo and pull request number (format: <owner>/<repo>#<pull-request-number>)",
**kwargs,
)
def add_argument_new_repo_name(self):
@ -132,7 +168,9 @@ def main():
except oscerr.OscBaseError as e:
print_msg(str(e), print_to="error")
sys.exit(1)
except subprocess.CalledProcessError as e:
print_msg(str(e), print_to="error")
sys.exit(1)
if __name__ == "__main__":
main()

19
osc/commands_git/pr.py Normal file
View File

@ -0,0 +1,19 @@
import osc.commandline_git
# we decided not to use the command name 'pull' because that could be confused
# with the completely unrelated 'git pull' command
class PullRequestCommand(osc.commandline_git.GitObsCommand):
"""
Manage pull requests
"""
name = "pr"
def init_arguments(self):
pass
def run(self, args):
self.parser.print_help()

View File

@ -0,0 +1,212 @@
import os
import re
import subprocess
import sys
from typing import Optional
import osc.commandline_git
def get_editor() -> str:
import shutil
editor = os.getenv("EDITOR", None)
if editor:
candidates = [editor]
else:
candidates = ["vim", "vi"]
editor_path = None
for i in candidates:
editor_path = shutil.which(i)
if editor_path:
break
if not editor_path:
raise RuntimeError(f"Unable to start editor '{candidates[0]}'")
return editor_path
def run_editor(file_path: str):
cmd = [get_editor(), file_path]
subprocess.run(cmd)
def edit_message(template: Optional[str] = None) -> str:
import tempfile
with tempfile.NamedTemporaryFile(mode="w+", encoding="utf-8", prefix="git_obs_message_") as f:
if template:
f.write(template)
f.flush()
run_editor(f.name)
f.seek(0)
return f.read()
NEW_PULL_REQUEST_TEMPLATE = """
# title
{title}
# description
{description}
#
# Please enter pull request title and description in the following format:
# <title>
# <blank line>
# <description>
#
# Lines starting with '#' will be ignored, and an empty message aborts the operation.
#
# Creating {source_owner}/{source_repo}#{source_branch} -> {target_owner}/{target_repo}#{target_branch}
#
""".lstrip()
class PullRequestCreateCommand(osc.commandline_git.GitObsCommand):
"""
Create a pull request
"""
name = "create"
parent = "PullRequestCommand"
def init_arguments(self):
self.add_argument(
"--title",
metavar="TEXT",
help="Pull request title",
)
self.add_argument(
"--description",
metavar="TEXT",
help="Pull request description (body)",
)
self.add_argument(
"--source-owner",
metavar="OWNER",
help="Owner of the source repo (default: derived from remote URL in local git repo)",
)
self.add_argument(
"--source-repo",
metavar="REPO",
help="Name of the source repo (default: derived from remote URL in local git repo)",
)
self.add_argument(
"--source-branch",
metavar="BRANCH",
help="Source branch (default: the current branch in local git repo)",
)
self.add_argument(
"--target-branch",
metavar="BRANCH",
help="Target branch (default: derived from the current branch in local git repo)",
)
def run(self, args):
from osc import gitea_api
# the source args are optional, but if one of them is used, the others must be used too
source_args = (args.source_owner, args.source_repo, args.source_branch)
if sum((int(i is not None) for i in source_args)) not in (0, len(source_args)):
self.parser.error("All of the following options must be used together: --source-owner, --source-repo, --source-branch")
self.print_gitea_settings()
use_local_git = args.source_owner is None
if use_local_git:
# local git repo
git = gitea_api.Git(".")
local_owner, local_repo = git.get_owner_repo()
local_branch = git.current_branch
local_rev = git.get_branch_head(local_branch)
# remote git repo - source
if use_local_git:
source_owner = local_owner
source_repo = local_repo
source_branch = local_branch
else:
source_owner = args.source_owner
source_repo = args.source_repo
source_branch = args.source_branch
source_repo_data = gitea_api.Repo.get(self.gitea_conn, source_owner, source_repo).json()
source_branch_data = gitea_api.Branch.get(self.gitea_conn, source_owner, source_repo, source_branch).json()
source_rev = source_branch_data["commit"]["id"]
# remote git repo - target
target_owner, target_repo = source_repo_data["parent"]["full_name"].split("/")
if source_branch.startswith("for/"):
# source branch name format: for/<target-branch>/<what-the-branch-name-would-normally-be>
target_branch = source_branch.split("/")[1]
else:
target_branch = source_branch
target_branch_data = gitea_api.Branch.get(self.gitea_conn, target_owner, target_repo, target_branch).json()
target_rev = target_branch_data["commit"]["id"]
print("Creating a pull request ...", file=sys.stderr)
if use_local_git:
print(f" * Local git: branch: {local_branch}, rev: {local_rev}", file=sys.stderr)
print(f" * Source: {source_owner}/{source_repo}, branch: {source_branch}, rev: {source_rev}", file=sys.stderr)
print(f" * Target: {target_owner}/{target_repo}, branch: {target_branch}, rev: {target_rev}", file=sys.stderr)
if use_local_git and local_rev != source_rev:
from osc.output import tty
print(f"{tty.colorize('ERROR', 'red,bold')}: Local commit doesn't correspond with the latest commit in the remote source branch")
sys.exit(1)
if source_rev == target_rev:
from osc.output import tty
print(f"{tty.colorize('ERROR', 'red,bold')}: Source and target are identical, make and push changes to the remote source repo first")
sys.exit(1)
title = args.title or ""
description = args.description or ""
if not title or not description:
# TODO: add list of commits and list of changed files to the template; requires local git repo
message = edit_message(template=NEW_PULL_REQUEST_TEMPLATE.format(**locals()))
# remove comments
message = "\n".join([i for i in message.splitlines() if not i.startswith("#")])
# strip leading and trailing spaces
message = message.strip()
if not message:
raise RuntimeError("Aborting operation due to empty title and description.")
parts = re.split(r"\n\n", message, 1)
if len(parts) == 1:
# empty description
title = parts[0]
description = ""
else:
title = parts[0]
description = parts[1]
title = title.strip()
description = description.strip()
pull = gitea_api.PullRequest.create(
self.gitea_conn,
target_owner=target_owner,
target_repo=target_repo,
target_branch=target_branch,
source_owner=source_owner,
# source_repo is not required because the information lives in Gitea database
source_branch=source_branch,
title=title,
description=description,
).json()
print("", file=sys.stderr)
print("Pull request created:", file=sys.stderr)
print(gitea_api.PullRequest.to_human_readable_string(pull))

View File

@ -0,0 +1,59 @@
import sys
import osc.commandline_git
class PullRequestGetCommand(osc.commandline_git.GitObsCommand):
"""
Get details about the specified pull requests
"""
name = "get"
aliases = ["show"] # for compatibility with osc
parent = "PullRequestCommand"
def init_arguments(self):
self.add_argument_owner_repo_pull(nargs="+")
self.add_argument(
"-p",
"--patch",
action="store_true",
help="Show patches associated with the pull requests",
)
def run(self, args):
from osc import gitea_api
from osc.core import highlight_diff
from osc.output import tty
self.print_gitea_settings()
num_entries = 0
failed_entries = []
for owner, repo, pull in args.owner_repo_pull:
try:
pr = gitea_api.PullRequest.get(self.gitea_conn, owner, repo, pull).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))
if args.patch:
print("")
print(tty.colorize("Patch:", "bold"))
patch = gitea_api.PullRequest.get_patch(self.gitea_conn, owner, repo, pull).data
patch = highlight_diff(patch)
print(patch.decode("utf-8"))
print()
print(f"Total entries: {num_entries}", file=sys.stderr)
if failed_entries:
print(
f"{tty.colorize('ERROR', 'red,bold')}: Couldn't retrieve the following pull requests: {', '.join(failed_entries)}",
file=sys.stderr,
)
sys.exit(1)

View File

@ -0,0 +1,38 @@
import sys
import osc.commandline_git
class PullRequestListCommand(osc.commandline_git.GitObsCommand):
"""
List pull requests in a repository
"""
name = "list"
parent = "PullRequestCommand"
def init_arguments(self):
self.add_argument_owner_repo(nargs="+")
self.add_argument(
"--state",
choices=["open", "closed", "all"],
default="open",
help="State of the pull requests (default: open)",
)
def run(self, args):
from osc import gitea_api
self.print_gitea_settings()
total_entries = 0
for owner, repo in args.owner_repo:
data = gitea_api.PullRequest.list(self.gitea_conn, owner, repo, state=args.state).json()
total_entries += len(data)
text = gitea_api.PullRequest.list_to_human_readable_string(data, sort=True)
if text:
print(text)
print("", file=sys.stderr)
print(f"Total entries: {total_entries}", file=sys.stderr)

View File

@ -0,0 +1,79 @@
import sys
import osc.commandline_git
class PullRequestSearchCommand(osc.commandline_git.GitObsCommand):
"""
Search pull requests in the whole gitea instance
"""
name = "search"
parent = "PullRequestCommand"
def init_arguments(self):
self.add_argument(
"--state",
choices=["open", "closed"],
default="open",
help="Filter by state: open, closed (default: open)",
)
self.add_argument(
"--title",
help="Filter by substring in title",
)
self.add_argument(
"--owner",
help="Filter by owner of the repository associated with the pull requests",
)
self.add_argument(
"--label",
dest="labels",
metavar="LABEL",
action="append",
help="Filter by associated labels. Non existent labels are discarded. Can be specified multiple times.",
)
self.add_argument(
"--assigned",
action="store_true",
help="Filter pull requests assigned to you",
)
self.add_argument(
"--created",
action="store_true",
help="Filter pull requests created by you",
)
self.add_argument(
"--mentioned",
action="store_true",
help="Filter pull requests mentioning you",
)
self.add_argument(
"--review-requested",
action="store_true",
help="Filter pull requests requesting your review",
)
def run(self, args):
from osc import gitea_api
self.print_gitea_settings()
data = gitea_api.PullRequest.search(
self.gitea_conn,
state=args.state,
title=args.title,
owner=args.owner,
labels=args.labels,
assigned=args.assigned,
created=args.created,
mentioned=args.mentioned,
review_requested=args.review_requested,
).json()
text = gitea_api.PullRequest.list_to_human_readable_string(data, sort=True)
if text:
print(text)
print("", file=sys.stderr)
print(f"Total entries: {len(data)}", file=sys.stderr)

View File

@ -1,17 +1,22 @@
import subprocess
import sys
import osc.commandline_git
class RepoCloneCommand(osc.commandline_git.GitObsCommand):
"""
Clone a git repo
NOTE: Some of the options may result in setting "core.sshCommand"
config option in the git repository."
"""
name = "clone"
parent = "RepoCommand"
def init_arguments(self):
self.add_argument_owner()
self.add_argument_repo()
self.add_argument_owner_repo(nargs="+")
self.add_argument(
"-a",
@ -42,15 +47,42 @@ class RepoCloneCommand(osc.commandline_git.GitObsCommand):
def run(self, args):
from osc import gitea_api
from osc.output import tty
self.print_gitea_settings()
gitea_api.Repo.clone(
self.gitea_conn,
args.owner,
args.repo,
directory=args.directory,
anonymous=args.anonymous,
add_remotes=True,
ssh_private_key_path=self.gitea_login.ssh_key or args.ssh_key,
ssh_strict_host_key_checking=not(args.no_ssh_strict_host_key_checking),
)
if len(args.owner_repo) > 1 and args.directory:
self.parser.error("The --directory option cannot be used with multiple repos")
num_entries = 0
failed_entries = []
for owner, repo in args.owner_repo:
print(f"Cloning git repo {owner}/{repo} ...", file=sys.stderr)
try:
gitea_api.Repo.clone(
self.gitea_conn,
owner,
repo,
directory=args.directory,
anonymous=args.anonymous,
add_remotes=True,
ssh_private_key_path=args.ssh_key or self.gitea_login.ssh_key,
ssh_strict_host_key_checking=not(args.no_ssh_strict_host_key_checking),
)
num_entries += 1
except gitea_api.GiteaException as e:
if e.status == 404:
print(f" * {tty.colorize('ERROR', 'red,bold')}: Repo doesn't exist: {owner}/{repo}", file=sys.stderr)
failed_entries.append(f"{owner}/{repo}")
continue
raise
except subprocess.CalledProcessError as e:
print(f" * {tty.colorize('ERROR', 'red,bold')}: git clone failed", file=sys.stderr)
failed_entries.append(f"{owner}/{repo}")
continue
print("", file=sys.stderr)
print(f"Total cloned repos: {num_entries}", file=sys.stderr)
if failed_entries:
print(f"{tty.colorize('ERROR', 'red,bold')}: Couldn't clone the following repos: {', '.join(failed_entries)}", file=sys.stderr)
sys.exit(1)

View File

@ -12,8 +12,7 @@ class RepoForkCommand(osc.commandline_git.GitObsCommand):
parent = "RepoCommand"
def init_arguments(self):
self.add_argument_owner()
self.add_argument_repo()
self.add_argument_owner_repo(nargs="+")
self.add_argument_new_repo_name()
def run(self, args):
@ -22,15 +21,35 @@ class RepoForkCommand(osc.commandline_git.GitObsCommand):
self.print_gitea_settings()
print(f"Forking git repo {args.owner}/{args.repo} ...", file=sys.stderr)
try:
response = gitea_api.Fork.create(self.gitea_conn, args.owner, args.repo, new_repo_name=args.new_repo_name)
repo = response.json()
fork_owner = repo["owner"]["login"]
fork_repo = repo["name"]
print(f" * Fork created: {fork_owner}/{fork_repo}", file=sys.stderr)
except gitea_api.ForkExists as e:
fork_owner = e.fork_owner
fork_repo = e.fork_repo
print(f" * Fork already exists: {fork_owner}/{fork_repo}", file=sys.stderr)
print(f" * {tty.colorize('WARNING', 'yellow,bold')}: Using an existing fork with a different name than requested", file=sys.stderr)
if len(args.owner_repo) > 1 and args.new_repo_name:
self.parser.error("The --new-repo-name option cannot be used with multiple repos")
num_entries = 0
failed_entries = []
for owner, repo in args.owner_repo:
print(f"Forking git repo {owner}/{repo} ...", file=sys.stderr)
try:
response = gitea_api.Fork.create(self.gitea_conn, owner, repo, new_repo_name=args.new_repo_name)
repo = response.json()
fork_owner = repo["owner"]["login"]
fork_repo = repo["name"]
print(f" * Fork created: {fork_owner}/{fork_repo}", file=sys.stderr)
num_entries += 1
except gitea_api.ForkExists as e:
fork_owner = e.fork_owner
fork_repo = e.fork_repo
print(f" * Fork already exists: {fork_owner}/{fork_repo}", file=sys.stderr)
print(f" * {tty.colorize('WARNING', 'yellow,bold')}: Using an existing fork with a different name than requested", file=sys.stderr)
num_entries += 1
except gitea_api.GiteaException as e:
if e.status == 404:
print(f" * {tty.colorize('ERROR', 'red,bold')}: Repo doesn't exist: {owner}/{repo}", file=sys.stderr)
failed_entries.append(f"{owner}/{repo}")
continue
raise
print("", file=sys.stderr)
print(f"Total forked repos: {num_entries}", file=sys.stderr)
if failed_entries:
print(f"{tty.colorize('ERROR', 'red,bold')}: Couldn't fork the following repos: {', '.join(failed_entries)}", file=sys.stderr)
sys.exit(1)

View File

@ -1935,7 +1935,7 @@ def get_default_editor():
def format_diff_line(line):
if line.startswith(b"+++") or line.startswith(b"---") or line.startswith(b"Index:"):
if line.startswith(b"+++ ") or line.startswith(b"--- ") or line.startswith(b"Index:"):
line = b"\x1b[1m" + line + b"\x1b[0m"
elif line.startswith(b"+"):
line = b"\x1b[32m" + line + b"\x1b[0m"

View File

@ -7,6 +7,8 @@ from .branch import Branch
from .conf import Config
from .conf import Login
from .fork import Fork
from .git import Git
from .pr import PullRequest
from .ssh_key import SSHKey
from .repo import Repo
from .user import User

View File

@ -1,18 +1,17 @@
import copy
import http.client
import json
import time
import urllib.parse
from typing import Optional
import urllib3
import urllib3.exceptions
import urllib3.response
from .conf import Login
# TODO: retry, backoff, connection pool?
class GiteaHTTPResponse:
"""
A ``urllib3.response.HTTPResponse`` wrapper
@ -52,6 +51,16 @@ class Connection:
self.port = alternative_port if alternative_port else parsed_url.port
self.conn = ConnectionClass(host=self.host, port=self.port)
# retries; variables are named according to urllib3
self.retry_count = 3
self.retry_backoff_factor = 2
self.retry_status_forcelist = (
500, # Internal Server Error
502, # Bad Gateway
503, # Service Unavailable
504, # Gateway Timeout
)
if hasattr(self.conn, "set_cert"):
# needed to avoid: AttributeError: 'HTTPSConnection' object has no attribute 'assert_hostname'. Did you mean: 'server_hostname'?
self.conn.set_cert()
@ -95,8 +104,27 @@ class Connection:
body = json.dumps(json_data) if json_data else None
self.conn.request(method, url, body, headers)
response = self.conn.getresponse()
for retry in range(1 + self.retry_count):
# 1 regular request + ``self.retry_count`` retries
try:
self.conn.request(method, url, body, headers)
response = self.conn.getresponse()
if response.status not in self.retry_status_forcelist:
# we are happy with the response status -> use the response
break
if retry >= self.retry_count:
# we have reached maximum number of retries -> use the response
break
except (urllib3.exceptions.HTTPError, ConnectionResetError):
if retry >= self.retry_count:
raise
# {backoff factor} * (2 ** ({number of previous retries}))
time.sleep(self.retry_backoff_factor * (2 ** retry))
self.conn.close()
if isinstance(response, http.client.HTTPResponse):
result = GiteaHTTPResponse(urllib3.response.HTTPResponse.from_httplib(response))

View File

@ -56,8 +56,8 @@ class Fork:
return conn.request("POST", url, json_data=json_data)
except GiteaException as e:
# use ForkExists exception to parse fork_owner and fork_repo from the response
fork_exists_exception = ForkExists(e.response, owner, repo)
if e.status == 409:
fork_exists_exception = ForkExists(e.response, owner, repo)
if exist_ok:
from . import Repo
return Repo.get(conn, fork_exists_exception.fork_owner, fork_exists_exception.fork_repo)

31
osc/gitea_api/git.py Normal file
View File

@ -0,0 +1,31 @@
import os
import subprocess
import urllib
from typing import Tuple
class Git:
def __init__(self, workdir):
self.abspath = os.path.abspath(workdir)
def _run_git(self, args) -> str:
return subprocess.check_output(["git"] + args, encoding="utf-8", cwd=self.abspath).strip()
@property
def current_branch(self) -> str:
return self._run_git(["branch", "--show-current"])
def get_branch_head(self, branch: str) -> str:
return self._run_git(["rev-parse", branch])
def get_remote_url(self, name: str = "origin") -> str:
return self._run_git(["remote", "get-url", name])
def get_owner_repo(self, remote: str = "origin") -> Tuple[str, str]:
remote_url = self.get_remote_url(name=remote)
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]
return owner, repo

212
osc/gitea_api/pr.py Normal file
View File

@ -0,0 +1,212 @@
import re
from typing import List
from typing import Optional
from typing import Tuple
from .connection import Connection
from .connection import GiteaHTTPResponse
class PullRequest:
@classmethod
def cmp(cls, entry: dict):
if "base" in entry:
# a proper pull request
return entry["base"]["repo"]["full_name"], entry["number"]
else:
# an issue without pull request details
return entry["repository"]["full_name"], entry["number"]
@classmethod
def split_id(cls, pr_id: str) -> Tuple[str, str, str]:
"""
Split <owner>/<repo>#<number> into individual components and return them in a tuple.
"""
match = re.match(r"^([^/]+)/([^/]+)#([0-9]+)$", pr_id)
if not match:
raise ValueError(f"Invalid pull request id: {pr_id}")
return match.groups()
@classmethod
def to_human_readable_string(cls, entry: dict):
from osc.output import KeyValueTable
from . import User
def yes_no(value):
return "yes" if value else "no"
if "base" in entry:
# a proper pull request
entry_id = f"{entry['base']['repo']['full_name']}#{entry['number']}"
is_pull_request = True
else:
# an issue without pull request details
entry_id = f"{entry['repository']['full_name']}#{entry['number']}"
is_pull_request = False
# HACK: search API returns issues, the URL needs to be transformed to a pull request URL
entry_url = entry["url"]
entry_url = re.sub(r"^(.*)/api/v1/repos/(.+/.+)/issues/([0-9]+)$", r"\1/\2/pulls/\3", entry_url)
table = KeyValueTable()
table.add("ID", entry_id, color="bold")
table.add("URL", f"{entry_url}")
table.add("Title", f"{entry['title']}")
table.add("State", entry["state"])
if is_pull_request:
table.add("Draft", yes_no(entry["draft"]))
table.add("Merged", yes_no(entry["merged"]))
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']}")
table.add("Description", entry["body"])
return str(table)
@classmethod
def list_to_human_readable_string(cls, entries: List, sort: bool = False):
if sort:
entries = sorted(entries, key=cls.cmp)
result = []
for entry in entries:
result.append(cls.to_human_readable_string(entry))
return "\n\n".join(result)
@classmethod
def create(
cls,
conn: Connection,
*,
target_owner: str,
target_repo: str,
target_branch: str,
source_owner: str,
source_branch: str,
title: str,
description: Optional[str] = None,
) -> GiteaHTTPResponse:
"""
Create a pull request to ``owner``/``repo`` to the ``base`` branch.
The pull request comes from a fork. The fork repo name is determined from gitea database.
:param conn: Gitea ``Connection`` instance.
:param target_owner: Owner of the target repo.
:param target_repo: Name of the target repo.
:param target_branch: Name of the target branch in the target repo.
:param source_owner: Owner of the source (forked) repo.
:param source_branch: Name of the source branch in the source (forked) repo.
:param title: Pull request title.
:param description: Pull request description.
"""
url = conn.makeurl("repos", target_owner, target_repo, "pulls")
data = {
"base": target_branch,
"head": f"{source_owner}:{source_branch}",
"title": title,
"body": description,
}
return conn.request("POST", url, json_data=data)
@classmethod
def get(
cls,
conn: Connection,
owner: str,
repo: str,
number: str,
) -> GiteaHTTPResponse:
"""
Get 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.
"""
url = conn.makeurl("repos", owner, repo, "pulls", number)
return conn.request("GET", url)
@classmethod
def list(
cls,
conn: Connection,
owner: str,
repo: str,
*,
state: Optional[str] = "open",
) -> GiteaHTTPResponse:
"""
List pull requests in a repo.
:param conn: Gitea ``Connection`` instance.
:param owner: Owner of the repo.
:param repo: Name of the repo.
:param state: Filter by state: open, closed, all. Defaults to open.
"""
if state == "all":
state = None
q = {
"state": state,
}
url = conn.makeurl("repos", owner, repo, "pulls", query=q)
return conn.request("GET", url)
@classmethod
def search(
cls,
conn: Connection,
*,
state: str = "open",
title: Optional[str] = None,
owner: Optional[str] = None,
labels: Optional[List[str]] = None,
assigned: bool = False,
created: bool = False,
mentioned: bool = False,
review_requested: bool = False,
) -> GiteaHTTPResponse:
"""
Search pull requests.
:param conn: Gitea ``Connection`` instance.
:param state: Filter by state: open, closed. Defaults to open.
:param title: Filter by substring in title.
:param owner: Filter by owner of the repository associated with the pull requests.
:param labels: Filter by associated labels. Non existent labels are discarded.
:param assigned: Filter pull requests assigned to you.
:param created: Filter pull requests created by you.
:param mentioned: Filter pull requests mentioning you.
:param review_requested: Filter pull requests requesting your review.
"""
q = {
"type": "pulls",
"state": state,
"q": title,
"owner": owner,
"labels": ",".join(labels) if labels else None,
"assigned": assigned,
"created": created,
"mentioned": mentioned,
"review_requested": review_requested,
}
url = conn.makeurl("repos", "issues", "search", query=q)
return conn.request("GET", url)
@classmethod
def get_patch(
cls,
conn: Connection,
owner: str,
repo: str,
number: str,
) -> GiteaHTTPResponse:
"""
Get a patch associated with 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.
"""
url = conn.makeurl("repos", owner, repo, "pulls", f"{number}.patch")
return conn.request("GET", url)

View File

@ -1,6 +1,8 @@
import os
import re
import subprocess
from typing import Optional
from typing import Tuple
from .connection import Connection
from .connection import GiteaHTTPResponse
@ -8,6 +10,16 @@ from .user import User
class Repo:
@classmethod
def split_id(cls, repo_id: str) -> Tuple[str, str]:
"""
Split <owner>/<repo> into individual components and return them in a tuple.
"""
match = re.match(r"^([^/]+)/([^/]+)$", repo_id)
if not match:
raise ValueError(f"Invalid repo id: {repo_id}")
return match.groups()
@classmethod
def get(
cls,
@ -77,22 +89,28 @@ class Repo:
fork = forks[0]
remotes["fork"] = fork["clone_url"] if anonymous else fork["ssh_url"]
env = os.environ.copy()
ssh_args = []
env = os.environ.copy()
if ssh_private_key_path:
ssh_args += [f"-i {shlex.quote(ssh_private_key_path)}"]
ssh_args += [
# avoid guessing the ssh key, use the specified one
"-o IdentitiesOnly=yes",
f"-o IdentityFile={shlex.quote(ssh_private_key_path)}",
]
if not ssh_strict_host_key_checking:
ssh_args += [
"-o StrictHostKeyChecking=no",
"-o UserKnownHostsFile=/dev/null",
"-o LogLevel=ERROR",
]
if ssh_args:
env["GIT_SSH_COMMAND"] = f"ssh {' '.join(ssh_args)}"
# clone
cmd = ["git", "clone", clone_url, directory]
subprocess.run(cmd, cwd=cwd, env=env, check=True)
# setup remotes
@ -100,4 +118,10 @@ class Repo:
cmd = ["git", "-C", directory_abspath, "remote", "add", name, url]
subprocess.run(cmd, cwd=cwd, check=True)
# store used ssh args (GIT_SSH_COMMAND) in the local git config
# to allow seamlessly running ``git push`` and other commands
if ssh_args:
cmd = ["git", "-C", directory_abspath, "config", "core.sshCommand", f"echo 'Using core.sshCommand: {env['GIT_SSH_COMMAND']}' >&2; {env['GIT_SSH_COMMAND']}"]
subprocess.run(cmd, cwd=cwd, check=True)
return directory_abspath

View File

@ -3,6 +3,18 @@ from .connection import GiteaHTTPResponse
class User:
@classmethod
def to_full_name_email_string(cls, data):
full_name = data["full_name"]
email = data["email"]
if full_name:
return f"{full_name} <{email}>"
return email
@classmethod
def to_login_full_name_email_string(cls, data):
return f"{data['login']} ({cls.to_full_name_email_string(data)})"
@classmethod
def get(
cls,