mirror of
https://github.com/openSUSE/osc.git
synced 2025-08-24 15:18:54 +02:00
Merge pull request #1760 from dmach/osc-build-project-from-_ObsPrj
Extend 'osc build' and 'osc fork' to use project.build file from Gitea, 'osc fork' also follows the devel project now
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
@@ -44,27 +45,58 @@ class ForkCommand(osc.commandline.OscCommand):
|
|||||||
|
|
||||||
self.add_argument_new_repo_name()
|
self.add_argument_new_repo_name()
|
||||||
|
|
||||||
|
self.add_argument(
|
||||||
|
"--no-devel-project",
|
||||||
|
action="store_true",
|
||||||
|
help="Fork the specified package instead the package from the devel project (which is the place where the package is developed)",
|
||||||
|
)
|
||||||
|
|
||||||
def run(self, args):
|
def run(self, args):
|
||||||
from osc import conf as osc_conf
|
from osc import conf as osc_conf
|
||||||
from osc import gitea_api
|
from osc import gitea_api
|
||||||
from osc import obs_api
|
from osc import obs_api
|
||||||
|
from osc.git_scm import GitStore
|
||||||
from osc.output import tty
|
from osc.output import tty
|
||||||
|
|
||||||
is_package = args.package is not None
|
# make a copy of project, package; if we change them, the original values remain in args
|
||||||
|
project = args.project
|
||||||
|
package = args.package
|
||||||
|
|
||||||
|
is_package = package is not None
|
||||||
|
use_devel_project = False
|
||||||
|
|
||||||
if not is_package and args.target_package:
|
if not is_package and args.target_package:
|
||||||
self.parser.error("The '--target-package' option requires the 'package' argument to be set")
|
self.parser.error("The '--target-package' option requires the 'package' argument to be set")
|
||||||
|
|
||||||
|
if not is_package and args.no_devel_project:
|
||||||
|
self.parser.error("The '--no-devel-project' option can be used only when forking a package")
|
||||||
|
|
||||||
if is_package:
|
if is_package:
|
||||||
# get the package meta from the OBS API first
|
# get the package meta from the OBS API first
|
||||||
package = obs_api.Package.from_api(args.apiurl, args.project, args.package)
|
pkg = obs_api.Package.from_api(args.apiurl, project, package)
|
||||||
if not package.scmsync:
|
|
||||||
raise RuntimeError(
|
if not args.no_devel_project:
|
||||||
"Forking is possible only with packages managed in Git (the <scmsync> element must be set in the package meta)"
|
# devel project is not set in package meta as usual but we parse it from "OBS:RejectBranch" attribute
|
||||||
)
|
attributes = obs_api.Attributes.from_api(args.apiurl, project, package, attr="OBS:RejectBranch").attribute_list
|
||||||
|
if attributes:
|
||||||
|
attribute = attributes[0].value
|
||||||
|
# the pattern starts with a non-greedy match so we capture the first url
|
||||||
|
match = re.match(r".*?(https://[^ ]+).*", attribute)
|
||||||
|
if match:
|
||||||
|
devel_project_url = match.group(1)
|
||||||
|
build_project = GitStore.get_build_project(devel_project_url)
|
||||||
|
# override the package we're cloning with the one from the devel project
|
||||||
|
use_devel_project = True
|
||||||
|
project = build_project
|
||||||
|
pkg = obs_api.Package.from_api(args.apiurl, project, package)
|
||||||
|
|
||||||
|
if not pkg.scmsync:
|
||||||
|
print(f"{tty.colorize('ERROR', 'red,bold')}: Forking is possible only with packages managed in Git (the <scmsync> element must be set in the package meta)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# get the project meta from the OBS API first
|
# get the project meta from the OBS API first
|
||||||
project = obs_api.Project.from_api(args.apiurl, args.project)
|
project = obs_api.Project.from_api(args.apiurl, project)
|
||||||
if not project.scmsync:
|
if not project.scmsync:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Forking is possible only with projects managed in Git (the <scmsync> element must be set in the project meta)"
|
"Forking is possible only with projects managed in Git (the <scmsync> element must be set in the project meta)"
|
||||||
@@ -72,11 +104,16 @@ class ForkCommand(osc.commandline.OscCommand):
|
|||||||
|
|
||||||
# parse gitea url, owner, repo and branch from the scmsync url
|
# parse gitea url, owner, repo and branch from the scmsync url
|
||||||
if is_package:
|
if is_package:
|
||||||
parsed_scmsync_url = urllib.parse.urlparse(package.scmsync, scheme="https")
|
parsed_scmsync_url = urllib.parse.urlparse(pkg.scmsync, scheme="https")
|
||||||
else:
|
else:
|
||||||
parsed_scmsync_url = urllib.parse.urlparse(project.scmsync, scheme="https")
|
parsed_scmsync_url = urllib.parse.urlparse(project.scmsync, scheme="https")
|
||||||
url = urllib.parse.urlunparse((parsed_scmsync_url.scheme, parsed_scmsync_url.netloc, "", "", "", ""))
|
url = urllib.parse.urlunparse((parsed_scmsync_url.scheme, parsed_scmsync_url.netloc, "", "", "", ""))
|
||||||
owner, repo = parsed_scmsync_url.path.strip("/").split("/")
|
owner, repo = parsed_scmsync_url.path.strip("/").split("/")
|
||||||
|
|
||||||
|
# remove trailing ".git" from repo
|
||||||
|
if repo.endswith(".git"):
|
||||||
|
repo = repo[:-4]
|
||||||
|
|
||||||
# temporary hack to allow people using fork atm at all, when packages
|
# temporary hack to allow people using fork atm at all, when packages
|
||||||
# are managed via git project.
|
# are managed via git project.
|
||||||
# fallback always to default branch for now, but we actually need to
|
# fallback always to default branch for now, but we actually need to
|
||||||
@@ -125,15 +162,17 @@ class ForkCommand(osc.commandline.OscCommand):
|
|||||||
|
|
||||||
print()
|
print()
|
||||||
if is_package:
|
if is_package:
|
||||||
print(f"Forking OBS package {args.project}/{args.package} ...")
|
print(f"Forking OBS package {project}/{package} ...")
|
||||||
|
if use_devel_project:
|
||||||
|
print(f" * {tty.colorize('NOTE', 'bold')}: Forking from the devel project instead of the specified {args.project}/{args.package}")
|
||||||
else:
|
else:
|
||||||
print(f"Forking OBS project {args.project} ...")
|
print(f"Forking OBS project {project} ...")
|
||||||
print(f" * OBS apiurl: {args.apiurl}")
|
print(f" * OBS apiurl: {args.apiurl}")
|
||||||
# we use a single API endpoint for forking both projects and packages (project requires setting package to "_project")
|
# we use a single API endpoint for forking both projects and packages (project requires setting package to "_project")
|
||||||
status = obs_api.Package.cmd_fork(
|
status = obs_api.Package.cmd_fork(
|
||||||
args.apiurl,
|
args.apiurl,
|
||||||
args.project,
|
project,
|
||||||
args.package if is_package else "_project",
|
package if is_package else "_project",
|
||||||
scmsync=fork_scmsync,
|
scmsync=fork_scmsync,
|
||||||
target_project=args.target_project,
|
target_project=args.target_project,
|
||||||
target_package=args.target_package if is_package else None,
|
target_package=args.target_package if is_package else None,
|
||||||
|
@@ -28,6 +28,66 @@ class GitStore:
|
|||||||
return False
|
return False
|
||||||
return store.is_package
|
return store.is_package
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_build_project(git_repo_url: str):
|
||||||
|
"""
|
||||||
|
Get the project we use for building from _ObsPrj git repo.
|
||||||
|
The _ObsPrj is located under the same owner as the repo with the package.
|
||||||
|
They share the same branch.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from osc import gitea_api
|
||||||
|
|
||||||
|
# parse the git_repo_url (which usually corresponds with the url of the 'origin' remote of the local git repo)
|
||||||
|
scheme, netloc, path, params, query, fragment = gitea_api.Git.urlparse(git_repo_url)
|
||||||
|
|
||||||
|
# scheme + host
|
||||||
|
gitea_host = urllib.parse.urlunparse((scheme, netloc, "", None, None, None))
|
||||||
|
|
||||||
|
# OBS and Gitea usernames are identical
|
||||||
|
# XXX: we're using the configured apiurl; it would be great to have a mapping from -G/--gitea-login to -A/--apiurl so we don't have to provide -A on the command-line
|
||||||
|
apiurl = osc_conf.config["apiurl"]
|
||||||
|
gitea_user = osc_conf.get_apiurl_usr(apiurl)
|
||||||
|
|
||||||
|
# remove trailing ".git" from path
|
||||||
|
if path.endswith(".git"):
|
||||||
|
path = path[:-4]
|
||||||
|
|
||||||
|
gitea_owner, gitea_repo = path.strip("/").split("/")[-2:]
|
||||||
|
|
||||||
|
# replace gitea_repo with _ObsPrj
|
||||||
|
gitea_repo = "_ObsPrj"
|
||||||
|
|
||||||
|
# XXX: we assume that the _ObsPrj project has the same branch as the package
|
||||||
|
gitea_branch = fragment
|
||||||
|
|
||||||
|
gitea_conf = gitea_api.Config()
|
||||||
|
try:
|
||||||
|
gitea_login = gitea_conf.get_login_by_url_user(url=gitea_host, user=gitea_user)
|
||||||
|
except gitea_api.Login.DoesNotExist:
|
||||||
|
# matching login entry doesn't exist in git-obs config
|
||||||
|
return None
|
||||||
|
|
||||||
|
gitea_conn = gitea_api.Connection(gitea_login)
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="osc_devel_project_git") as tmp_dir:
|
||||||
|
try:
|
||||||
|
gitea_api.Repo.clone(gitea_conn, gitea_owner, gitea_repo, branch=gitea_branch, quiet=True, directory=tmp_dir)
|
||||||
|
project_build_path = os.path.join(tmp_dir, "project.build")
|
||||||
|
with open(project_build_path, "r", encoding="utf-8") as f:
|
||||||
|
project_build = f.readline().strip()
|
||||||
|
return project_build
|
||||||
|
except gitea_api.GiteaException:
|
||||||
|
# "_ObsPrj" repo doesn't exist
|
||||||
|
return None
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
# branch doesn't exist
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
# "project.build" file doesn't exist
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def git_project_dir(self):
|
def git_project_dir(self):
|
||||||
if not hasattr(self, "_git_project_dir"):
|
if not hasattr(self, "_git_project_dir"):
|
||||||
@@ -135,6 +195,10 @@ class GitStore:
|
|||||||
if self.is_package and self.project_obs_scm_store:
|
if self.is_package and self.project_obs_scm_store:
|
||||||
# read project from parent directory that contains a project with .osc metadata
|
# read project from parent directory that contains a project with .osc metadata
|
||||||
self._project = self.project_obs_scm_store.project
|
self._project = self.project_obs_scm_store.project
|
||||||
|
if not self._project:
|
||||||
|
# read project from Gitea (identical owner, repo: _ObsPrj, file: project.build)
|
||||||
|
origin = self._run_git(["remote", "get-url", "origin"])
|
||||||
|
self._project = self.get_build_project(origin)
|
||||||
if not self._project:
|
if not self._project:
|
||||||
# HACK: assume openSUSE:Factory project if project metadata is missing
|
# HACK: assume openSUSE:Factory project if project metadata is missing
|
||||||
self._project = "openSUSE:Factory"
|
self._project = "openSUSE:Factory"
|
||||||
|
@@ -121,9 +121,31 @@ class Config:
|
|||||||
"""
|
"""
|
||||||
Return ``Login`` object for the given ``url`` and ``user``.
|
Return ``Login`` object for the given ``url`` and ``user``.
|
||||||
"""
|
"""
|
||||||
|
from .git import Git
|
||||||
|
|
||||||
|
# exact match
|
||||||
for login in self.list_logins():
|
for login in self.list_logins():
|
||||||
if (login.url, login.user) == (url, user):
|
if (login.url, login.user) == (url, user):
|
||||||
return login
|
return login
|
||||||
|
|
||||||
|
def url_to_hostname(value):
|
||||||
|
netloc = Git.urlparse(value).netloc
|
||||||
|
|
||||||
|
# remove user from hostname
|
||||||
|
if "@" in netloc:
|
||||||
|
netloc = netloc.split("@")[-1]
|
||||||
|
|
||||||
|
# remove port from hostname
|
||||||
|
if ":" in netloc:
|
||||||
|
netloc = netloc.split(":")[0]
|
||||||
|
|
||||||
|
return netloc
|
||||||
|
|
||||||
|
# match only hostname (netloc without 'user@' and ':port') + user
|
||||||
|
for login in self.list_logins():
|
||||||
|
if (url_to_hostname(login.url), login.user) == (url_to_hostname(url), user):
|
||||||
|
return login
|
||||||
|
|
||||||
raise Login.DoesNotExist(url=url, user=user)
|
raise Login.DoesNotExist(url=url, user=user)
|
||||||
|
|
||||||
def add_login(self, login: Login):
|
def add_login(self, login: Login):
|
||||||
|
@@ -9,6 +9,38 @@ from typing import Tuple
|
|||||||
|
|
||||||
|
|
||||||
class Git:
|
class Git:
|
||||||
|
@staticmethod
|
||||||
|
def urlparse(url) -> urllib.parse.ParseResult:
|
||||||
|
"""
|
||||||
|
Parse git url.
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
- https://example.com/owner/repo.git
|
||||||
|
- https://example.com:1234/owner/repo.git
|
||||||
|
- example.com/owner/repo.git
|
||||||
|
- user@example.com:owner/repo.git
|
||||||
|
- user@example.com:1234:owner/repo.git"
|
||||||
|
"""
|
||||||
|
# try ssh clone url first
|
||||||
|
pattern = r"(?P<netloc>[^@:]+@[^@:]+(:[0-9]+)?):(?P<path>.+)"
|
||||||
|
match = re.match(pattern, url)
|
||||||
|
if match:
|
||||||
|
scheme = ""
|
||||||
|
netloc = match.groupdict()["netloc"]
|
||||||
|
path = match.groupdict()["path"]
|
||||||
|
params = ''
|
||||||
|
query = ''
|
||||||
|
fragment = ''
|
||||||
|
result = urllib.parse.ParseResult(scheme, netloc, path, params, query, fragment)
|
||||||
|
return result
|
||||||
|
|
||||||
|
result = urllib.parse.urlparse(url)
|
||||||
|
if not result.netloc:
|
||||||
|
# empty netloc is most likely an error, prepend and then discard scheme to trick urlparse()
|
||||||
|
result = urllib.parse.urlparse("https://" + url)
|
||||||
|
result = urllib.parse.ParseResult("", *list(result)[1:])
|
||||||
|
return result
|
||||||
|
|
||||||
def __init__(self, workdir):
|
def __init__(self, workdir):
|
||||||
self.abspath = os.path.abspath(workdir)
|
self.abspath = os.path.abspath(workdir)
|
||||||
|
|
||||||
|
@@ -44,6 +44,8 @@ class Repo:
|
|||||||
owner: str,
|
owner: str,
|
||||||
repo: str,
|
repo: str,
|
||||||
*,
|
*,
|
||||||
|
branch: Optional[str] = None,
|
||||||
|
quiet: bool = False,
|
||||||
directory: Optional[str] = None,
|
directory: Optional[str] = None,
|
||||||
cwd: Optional[str] = None,
|
cwd: Optional[str] = None,
|
||||||
anonymous: bool = False,
|
anonymous: bool = False,
|
||||||
@@ -112,6 +114,13 @@ class Repo:
|
|||||||
|
|
||||||
# clone
|
# clone
|
||||||
cmd = ["git", "clone", clone_url, directory]
|
cmd = ["git", "clone", clone_url, directory]
|
||||||
|
|
||||||
|
if branch:
|
||||||
|
cmd += ["--branch", branch]
|
||||||
|
|
||||||
|
if quiet:
|
||||||
|
cmd += ["--quiet"]
|
||||||
|
|
||||||
subprocess.run(cmd, cwd=cwd, env=env, check=True)
|
subprocess.run(cmd, cwd=cwd, env=env, check=True)
|
||||||
|
|
||||||
# setup remotes
|
# setup remotes
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
from .attributes import Attributes
|
||||||
from .keyinfo import Keyinfo
|
from .keyinfo import Keyinfo
|
||||||
from .package import Package
|
from .package import Package
|
||||||
from .package_sources import PackageSources
|
from .package_sources import PackageSources
|
||||||
|
46
osc/obs_api/attributes.py
Normal file
46
osc/obs_api/attributes.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..util.models import * # pylint: disable=wildcard-import,unused-wildcard-import
|
||||||
|
|
||||||
|
|
||||||
|
class Attribute(XmlModel):
|
||||||
|
name: str = Field(
|
||||||
|
xml_attribute=True,
|
||||||
|
)
|
||||||
|
namespace: str = Field(
|
||||||
|
xml_attribute=True,
|
||||||
|
)
|
||||||
|
value: str = Field(
|
||||||
|
xml_set_text=True,
|
||||||
|
xml_wrapped=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Attributes(XmlModel):
|
||||||
|
XML_TAG = "attributes"
|
||||||
|
|
||||||
|
attribute_list: List[Attribute] = Field(
|
||||||
|
xml_name="attribute",
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api(
|
||||||
|
cls, apiurl: str, project: str, package: Optional[str] = None, *, attr: Optional[str] = None
|
||||||
|
) -> "Attributes":
|
||||||
|
import urllib.error
|
||||||
|
from .. import oscerr
|
||||||
|
from ..connection import http_request
|
||||||
|
from ..core import makeurl
|
||||||
|
|
||||||
|
if package:
|
||||||
|
url_path = ["source", project, package, "_attribute"]
|
||||||
|
else:
|
||||||
|
url_path = ["source", project, "_attribute"]
|
||||||
|
|
||||||
|
if attr:
|
||||||
|
url_path.append(attr)
|
||||||
|
|
||||||
|
url_query: Dict[str, Any] = {}
|
||||||
|
url = makeurl(apiurl, url_path, url_query)
|
||||||
|
response = http_request("GET", url)
|
||||||
|
return cls.from_file(response)
|
36
tests/test_gitea_api_git.py
Normal file
36
tests/test_gitea_api_git.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from osc.gitea_api import Git
|
||||||
|
|
||||||
|
|
||||||
|
class TestGiteaApiGit(unittest.TestCase):
|
||||||
|
def test_urlparse(self):
|
||||||
|
# https url without port
|
||||||
|
url = "https://example.com/owner/repo.git"
|
||||||
|
result = Git.urlparse(url)
|
||||||
|
self.assertEqual(list(result), ['https', 'example.com', '/owner/repo.git', '', '', ''])
|
||||||
|
|
||||||
|
# https url with port
|
||||||
|
url = "https://example.com:1234/owner/repo.git"
|
||||||
|
result = Git.urlparse(url)
|
||||||
|
self.assertEqual(list(result), ['https', 'example.com:1234', '/owner/repo.git', '', '', ''])
|
||||||
|
|
||||||
|
# url without scheme
|
||||||
|
# urllib.parse.urlparse() would normally return ['', '', 'example.com/owner/repo.git', '', '', '']
|
||||||
|
url = "example.com/owner/repo.git"
|
||||||
|
result = Git.urlparse(url)
|
||||||
|
self.assertEqual(list(result), ['', 'example.com', '/owner/repo.git', '', '', ''])
|
||||||
|
|
||||||
|
# ssh url
|
||||||
|
url = "user@example.com:owner/repo.git"
|
||||||
|
result = Git.urlparse(url)
|
||||||
|
self.assertEqual(list(result), ['', 'user@example.com', 'owner/repo.git', '', '', ''])
|
||||||
|
|
||||||
|
# ssh url with port
|
||||||
|
url = "user@example.com:1234:owner/repo.git"
|
||||||
|
result = Git.urlparse(url)
|
||||||
|
self.assertEqual(list(result), ['', 'user@example.com:1234', 'owner/repo.git', '', '', ''])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
Reference in New Issue
Block a user