1
0
mirror of https://github.com/openSUSE/osc.git synced 2025-02-19 00:32:12 +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' - name: 'Install packages'
run: | run: |
sudo apt-get -y update 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 - uses: actions/checkout@v3
@ -68,7 +71,10 @@ jobs:
- name: 'Install packages' - name: 'Install packages'
run: | run: |
sudo apt-get -y update 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 - uses: actions/checkout@v3
with: with:

View File

@ -146,6 +146,11 @@ jobs:
run: | run: |
podman pull ghcr.io/suse-autobuild/obs-server:latest 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" - name: "Run tests"
run: | run: |
cd behave cd behave

View File

@ -17,14 +17,14 @@ def after_step(context, step):
def before_scenario(context, scenario): def before_scenario(context, scenario):
# truncate the logs before running any commands # 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): def after_scenario(context, scenario):
if scenario.status == Status.failed: if scenario.status == Status.failed:
# the scenario has failed, dump server logs # the scenario has failed, dump server logs
print("===== BEGIN: 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(proc.stdout)
print("===== END: server logs ======") 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 @destructive
Scenario: Clone a git repo 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 Then the exit code is 0
And stdout is And stdout is
""" """
@ -20,5 +20,8 @@ Scenario: Clone a git repo
* URL: http://localhost:{context.podman.container.ports[gitea_http]} * URL: http://localhost:{context.podman.container.ports[gitea_http]}
* User: Admin * User: Admin
Cloning git repo pool/test-GitPkgA ...
Cloning into 'test-GitPkgA'... Cloning into 'test-GitPkgA'...
Total cloned repos: 1
""" """

View File

@ -7,7 +7,7 @@ Background:
@destructive @destructive
Scenario: Fork a git repo 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 Then the exit code is 0
And stdout is And stdout is
""" """
@ -22,12 +22,14 @@ Scenario: Fork a git repo
Forking git repo pool/test-GitPkgA ... Forking git repo pool/test-GitPkgA ...
* Fork created: Admin/test-GitPkgA * Fork created: Admin/test-GitPkgA
Total forked repos: 1
""" """
@destructive @destructive
Scenario: Fork a git repo twice under different names 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 Then the exit code is 0
And stdout is And stdout is
""" """
@ -42,8 +44,10 @@ Scenario: Fork a git repo twice under different names
Forking git repo pool/test-GitPkgA ... Forking git repo pool/test-GitPkgA ...
* Fork created: Admin/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 Then the exit code is 0
And stdout is And stdout is
""" """
@ -59,12 +63,14 @@ Scenario: Fork a git repo twice under different names
Forking git repo pool/test-GitPkgA ... Forking git repo pool/test-GitPkgA ...
* Fork already exists: Admin/test-GitPkgA * Fork already exists: Admin/test-GitPkgA
* WARNING: Using an existing fork with a different name than requested * WARNING: Using an existing fork with a different name than requested
Total forked repos: 1
""" """
@destructive @destructive
Scenario: Fork a git repo from pool and fork someone else's fork of the same repo 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 Then the exit code is 0
And stdout is 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 ... Forking git repo pool/test-GitPkgA ...
* Fork created: Admin/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 Then the exit code is 0
And stdout is 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 ... Forking git repo pool/test-GitPkgA ...
* Fork created: Alice/test-GitPkgA-alice * Fork created: Alice/test-GitPkgA-alice
Total forked repos: 1
""" """
# this succeeds with 202 and the requested fork is NOT created # 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 Then the exit code is 0
And stdout is 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 ... Forking git repo Alice/test-GitPkgA-alice ...
* Fork created: Admin/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 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)) 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}\"") @behave.step("stdout contains \"{text}\"")
def step_impl(context, text): def step_impl(context, text):
if re.search(text.format(context=context), context.cmd_stdout): if re.search(text.format(context=context), context.cmd_stdout):

View File

@ -1,4 +1,6 @@
import argparse
import os import os
import subprocess
import sys import sys
import osc.commandline_common import osc.commandline_common
@ -7,6 +9,36 @@ from . import oscerr
from .output import print_msg 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): class GitObsCommand(osc.commandline_common.Command):
@property @property
def gitea_conf(self): 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(f" * User: {self.gitea_login.user}", file=sys.stderr)
print("", file=sys.stderr) print("", file=sys.stderr)
def add_argument_owner(self): def add_argument_owner_repo(self, **kwargs):
self.add_argument( self.add_argument(
"owner", "owner_repo",
help="Name of the repository owner (login, org)", 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( self.add_argument(
"repo", "owner_repo_pull",
help="Name of the repository", action=OwnerRepoPullAction,
help="Owner, repo and pull request number (format: <owner>/<repo>#<pull-request-number>)",
**kwargs,
) )
def add_argument_new_repo_name(self): def add_argument_new_repo_name(self):
@ -132,7 +168,9 @@ def main():
except oscerr.OscBaseError as e: except oscerr.OscBaseError as e:
print_msg(str(e), print_to="error") print_msg(str(e), print_to="error")
sys.exit(1) sys.exit(1)
except subprocess.CalledProcessError as e:
print_msg(str(e), print_to="error")
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
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 import osc.commandline_git
class RepoCloneCommand(osc.commandline_git.GitObsCommand): class RepoCloneCommand(osc.commandline_git.GitObsCommand):
""" """
Clone a git repo Clone a git repo
NOTE: Some of the options may result in setting "core.sshCommand"
config option in the git repository."
""" """
name = "clone" name = "clone"
parent = "RepoCommand" parent = "RepoCommand"
def init_arguments(self): def init_arguments(self):
self.add_argument_owner() self.add_argument_owner_repo(nargs="+")
self.add_argument_repo()
self.add_argument( self.add_argument(
"-a", "-a",
@ -42,15 +47,42 @@ class RepoCloneCommand(osc.commandline_git.GitObsCommand):
def run(self, args): def run(self, args):
from osc import gitea_api from osc import gitea_api
from osc.output import tty
self.print_gitea_settings() self.print_gitea_settings()
gitea_api.Repo.clone( if len(args.owner_repo) > 1 and args.directory:
self.gitea_conn, self.parser.error("The --directory option cannot be used with multiple repos")
args.owner,
args.repo, num_entries = 0
directory=args.directory, failed_entries = []
anonymous=args.anonymous, for owner, repo in args.owner_repo:
add_remotes=True, print(f"Cloning git repo {owner}/{repo} ...", file=sys.stderr)
ssh_private_key_path=self.gitea_login.ssh_key or args.ssh_key, try:
ssh_strict_host_key_checking=not(args.no_ssh_strict_host_key_checking), 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" parent = "RepoCommand"
def init_arguments(self): def init_arguments(self):
self.add_argument_owner() self.add_argument_owner_repo(nargs="+")
self.add_argument_repo()
self.add_argument_new_repo_name() self.add_argument_new_repo_name()
def run(self, args): def run(self, args):
@ -22,15 +21,35 @@ class RepoForkCommand(osc.commandline_git.GitObsCommand):
self.print_gitea_settings() self.print_gitea_settings()
print(f"Forking git repo {args.owner}/{args.repo} ...", file=sys.stderr) if len(args.owner_repo) > 1 and args.new_repo_name:
try: self.parser.error("The --new-repo-name option cannot be used with multiple repos")
response = gitea_api.Fork.create(self.gitea_conn, args.owner, args.repo, new_repo_name=args.new_repo_name)
repo = response.json() num_entries = 0
fork_owner = repo["owner"]["login"] failed_entries = []
fork_repo = repo["name"] for owner, repo in args.owner_repo:
print(f" * Fork created: {fork_owner}/{fork_repo}", file=sys.stderr) print(f"Forking git repo {owner}/{repo} ...", file=sys.stderr)
except gitea_api.ForkExists as e: try:
fork_owner = e.fork_owner response = gitea_api.Fork.create(self.gitea_conn, owner, repo, new_repo_name=args.new_repo_name)
fork_repo = e.fork_repo repo = response.json()
print(f" * Fork already exists: {fork_owner}/{fork_repo}", file=sys.stderr) fork_owner = repo["owner"]["login"]
print(f" * {tty.colorize('WARNING', 'yellow,bold')}: Using an existing fork with a different name than requested", file=sys.stderr) 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): 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" line = b"\x1b[1m" + line + b"\x1b[0m"
elif line.startswith(b"+"): elif line.startswith(b"+"):
line = b"\x1b[32m" + line + b"\x1b[0m" 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 Config
from .conf import Login from .conf import Login
from .fork import Fork from .fork import Fork
from .git import Git
from .pr import PullRequest
from .ssh_key import SSHKey from .ssh_key import SSHKey
from .repo import Repo from .repo import Repo
from .user import User from .user import User

View File

@ -1,18 +1,17 @@
import copy import copy
import http.client import http.client
import json import json
import time
import urllib.parse import urllib.parse
from typing import Optional from typing import Optional
import urllib3 import urllib3
import urllib3.exceptions
import urllib3.response import urllib3.response
from .conf import Login from .conf import Login
# TODO: retry, backoff, connection pool?
class GiteaHTTPResponse: class GiteaHTTPResponse:
""" """
A ``urllib3.response.HTTPResponse`` wrapper A ``urllib3.response.HTTPResponse`` wrapper
@ -52,6 +51,16 @@ class Connection:
self.port = alternative_port if alternative_port else parsed_url.port self.port = alternative_port if alternative_port else parsed_url.port
self.conn = ConnectionClass(host=self.host, port=self.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"): if hasattr(self.conn, "set_cert"):
# needed to avoid: AttributeError: 'HTTPSConnection' object has no attribute 'assert_hostname'. Did you mean: 'server_hostname'? # needed to avoid: AttributeError: 'HTTPSConnection' object has no attribute 'assert_hostname'. Did you mean: 'server_hostname'?
self.conn.set_cert() self.conn.set_cert()
@ -95,8 +104,27 @@ class Connection:
body = json.dumps(json_data) if json_data else None body = json.dumps(json_data) if json_data else None
self.conn.request(method, url, body, headers) for retry in range(1 + self.retry_count):
response = self.conn.getresponse() # 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): if isinstance(response, http.client.HTTPResponse):
result = GiteaHTTPResponse(urllib3.response.HTTPResponse.from_httplib(response)) 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) return conn.request("POST", url, json_data=json_data)
except GiteaException as e: except GiteaException as e:
# use ForkExists exception to parse fork_owner and fork_repo from the response # 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: if e.status == 409:
fork_exists_exception = ForkExists(e.response, owner, repo)
if exist_ok: if exist_ok:
from . import Repo from . import Repo
return Repo.get(conn, fork_exists_exception.fork_owner, fork_exists_exception.fork_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 os
import re
import subprocess import subprocess
from typing import Optional from typing import Optional
from typing import Tuple
from .connection import Connection from .connection import Connection
from .connection import GiteaHTTPResponse from .connection import GiteaHTTPResponse
@ -8,6 +10,16 @@ from .user import User
class Repo: 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 @classmethod
def get( def get(
cls, cls,
@ -77,22 +89,28 @@ class Repo:
fork = forks[0] fork = forks[0]
remotes["fork"] = fork["clone_url"] if anonymous else fork["ssh_url"] remotes["fork"] = fork["clone_url"] if anonymous else fork["ssh_url"]
env = os.environ.copy()
ssh_args = [] ssh_args = []
env = os.environ.copy()
if ssh_private_key_path: 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: if not ssh_strict_host_key_checking:
ssh_args += [ ssh_args += [
"-o StrictHostKeyChecking=no", "-o StrictHostKeyChecking=no",
"-o UserKnownHostsFile=/dev/null", "-o UserKnownHostsFile=/dev/null",
"-o LogLevel=ERROR", "-o LogLevel=ERROR",
] ]
if ssh_args: if ssh_args:
env["GIT_SSH_COMMAND"] = f"ssh {' '.join(ssh_args)}" env["GIT_SSH_COMMAND"] = f"ssh {' '.join(ssh_args)}"
# clone # clone
cmd = ["git", "clone", clone_url, directory] cmd = ["git", "clone", clone_url, directory]
subprocess.run(cmd, cwd=cwd, env=env, check=True) subprocess.run(cmd, cwd=cwd, env=env, check=True)
# setup remotes # setup remotes
@ -100,4 +118,10 @@ class Repo:
cmd = ["git", "-C", directory_abspath, "remote", "add", name, url] cmd = ["git", "-C", directory_abspath, "remote", "add", name, url]
subprocess.run(cmd, cwd=cwd, check=True) 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 return directory_abspath

View File

@ -3,6 +3,18 @@ from .connection import GiteaHTTPResponse
class User: 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 @classmethod
def get( def get(
cls, cls,