Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package osc for openSUSE:Factory checked in at 2025-09-16 18:19:35 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/osc (Old) and /work/SRC/openSUSE:Factory/.osc.new.1977 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "osc" Tue Sep 16 18:19:35 2025 rev:217 rq:1305159 version:1.20.0 Changes: -------- --- /work/SRC/openSUSE:Factory/osc/osc.changes 2025-08-06 14:36:16.103533038 +0200 +++ /work/SRC/openSUSE:Factory/.osc.new.1977/osc.changes 2025-09-16 18:20:32.204497391 +0200 @@ -1,0 +2,22 @@ +Tue Sep 16 11:59:09 UTC 2025 - Daniel Mach <[email protected]> + +- 1.20.0 + - Command-line: + - Fix 'osc fork' command to use the right tracking branch + - Fix 'osc blt' command by checking if the working copy is a package + - Make 'osc buildlog' work outside of osc package directory + - Add 'git-obs pr close' and 'git-obs pr reopen' commands + - Add 'close' option to 'git-obs pr review interactive' + - Change 'git-obs pr review interactive' to work with all archives, not only those in Git LFS + - Fix checkout of the base branch in 'git-obs pr review interactive' command + - Library: + - Support _manifest file in git store + - Allow pull request IDs in '<owner>/<repo>!<number>' format + - Properly handle deleted users and teams in the git-obs timeline + - Handle situations when there's 'None' among timeline entries + - Skip binary files in gitea_api.PullRequest.get_patch() + - Change get_user_input(), add support for vertically printed list of answers + - Spec: + - Provide git-obs + +------------------------------------------------------------------- Old: ---- osc-1.19.1.tar.gz New: ---- osc-1.20.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ osc.spec ++++++ --- /var/tmp/diff_new_pack.NXBJ3y/_old 2025-09-16 18:20:33.432549109 +0200 +++ /var/tmp/diff_new_pack.NXBJ3y/_new 2025-09-16 18:20:33.436549277 +0200 @@ -80,7 +80,7 @@ %endif Name: osc -Version: 1.19.1 +Version: 1.20.0 Release: 0 Summary: Command-line client for the Open Build Service License: GPL-2.0-or-later @@ -176,6 +176,7 @@ Provides: python3-osc = %{version}-%{release} %endif %endif +Provides: git-obs = %{version}-%{release} %description openSUSE Commander is a command-line client for the Open Build Service. ++++++ PKGBUILD ++++++ --- /var/tmp/diff_new_pack.NXBJ3y/_old 2025-09-16 18:20:33.480551130 +0200 +++ /var/tmp/diff_new_pack.NXBJ3y/_new 2025-09-16 18:20:33.488551467 +0200 @@ -1,5 +1,5 @@ pkgname=osc -pkgver=1.19.1 +pkgver=1.20.0 pkgrel=0 pkgdesc="Command-line client for the Open Build Service" arch=('x86_64') ++++++ debian.changelog ++++++ --- /var/tmp/diff_new_pack.NXBJ3y/_old 2025-09-16 18:20:33.532553320 +0200 +++ /var/tmp/diff_new_pack.NXBJ3y/_new 2025-09-16 18:20:33.540553657 +0200 @@ -1,4 +1,4 @@ -osc (1.19.1-0) unstable; urgency=low +osc (1.20.0-0) unstable; urgency=low * Placeholder ++++++ osc-1.19.1.tar.gz -> osc-1.20.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/NEWS new/osc-1.20.0/NEWS --- old/osc-1.19.1/NEWS 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/NEWS 2025-09-16 13:52:28.000000000 +0200 @@ -1,3 +1,22 @@ +- 1.20.0 + - Command-line: + - Fix 'osc fork' command to use the right tracking branch + - Fix 'osc blt' command by checking if the working copy is a package + - Make 'osc buildlog' work outside of osc package directory + - Add 'git-obs pr close' and 'git-obs pr reopen' commands + - Add 'close' option to 'git-obs pr review interactive' + - Change 'git-obs pr review interactive' to work with all archives, not only those in Git LFS + - Fix checkout of the base branch in 'git-obs pr review interactive' command + - Library: + - Support _manifest file in git store + - Allow pull request IDs in '<owner>/<repo>!<number>' format + - Properly handle deleted users and teams in the git-obs timeline + - Handle situations when there's 'None' among timeline entries + - Skip binary files in gitea_api.PullRequest.get_patch() + - Change get_user_input(), add support for vertically printed list of answers + - Spec: + - Provide git-obs + - 1.19.1 - Command-line: - Use OSC_PACKAGE_CACHE_DIR env var instead of deprecated OSC_PACKAGECACHEDIR diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/__init__.py new/osc-1.20.0/osc/__init__.py --- old/osc-1.19.1/osc/__init__.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/__init__.py 2025-09-16 13:52:28.000000000 +0200 @@ -13,7 +13,7 @@ from .util import git_version -__version__ = git_version.get_version('1.19.1') +__version__ = git_version.get_version('1.20.0') # vim: sw=4 et diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/commandline.py new/osc-1.20.0/osc/commandline.py --- old/osc-1.19.1/osc/commandline.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/commandline.py 2025-09-16 13:52:28.000000000 +0200 @@ -6590,18 +6590,17 @@ results' output. If the buildlog url is used buildlog command has the same behavior as remotebuildlog. - buildlog [REPOSITORY ARCH | BUILDLOGURL] + buildlog [PROJECT PACKAGE REPOSITORY [ARCH] | REPOSITORY ARCH | BUILDLOGURL] """ from . import build as osc_build from . import conf + from . import store as osc_store from .core import ET from .core import http_GET from .core import makeurl from .core import parse_buildlogurl from .core import print_buildlog - from .core import store_read_package - from .core import store_read_project project = package = repository = arch = None @@ -6609,9 +6608,22 @@ if len(args) == 1 and args[0].startswith('http'): apiurl, project, package, repository, arch = parse_buildlogurl(args[0]) + elif len(args) > 2: + project = args[0] + project = self._process_project_name(project) + package = args[1] + repository = args[2] + if len(args) == 3: + arch = osc_build.hostarch + elif len(args) > 4: + raise oscerr.WrongArgs('Too many arguments.') + else: + arch = args[1] else: - project = store_read_project(Path.cwd()) - package = store_read_package(Path.cwd()) + store = osc_store.get_store(Path.cwd()) + store.assert_is_package() + project = store.project + package = store.package if len(args) == 1: repository, arch = self._find_last_repo_arch(args[0], fatal=False) if repository is None: @@ -6621,8 +6633,6 @@ arch = osc_build.hostarch elif len(args) < 2: self.print_repos() - elif len(args) > 2: - raise oscerr.WrongArgs('Too many arguments.') else: repository = args[0] arch = args[1] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/commands/fork.py new/osc-1.20.0/osc/commands/fork.py --- old/osc-1.19.1/osc/commands/fork.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/commands/fork.py 2025-09-16 13:52:28.000000000 +0200 @@ -104,9 +104,10 @@ # parse gitea url, owner, repo and branch from the scmsync url if is_package: - parsed_scmsync_url = urllib.parse.urlparse(pkg.scmsync, scheme="https") + url_scmsync = pkg.scmsync else: - parsed_scmsync_url = urllib.parse.urlparse(project.scmsync, scheme="https") + url_scmsync = project.scmsync + parsed_scmsync_url = urllib.parse.urlparse(url_scmsync, scheme="https") url = urllib.parse.urlunparse((parsed_scmsync_url.scheme, parsed_scmsync_url.netloc, "", "", "", "")) owner, repo = parsed_scmsync_url.path.strip("/").split("/") @@ -120,6 +121,9 @@ # parse the right branch instead from .gitmodules #branch = parsed_scmsync_url.fragment or None branch = None + parsed_scmsync_url_query = urllib.parse.parse_qs(parsed_scmsync_url.query) + if "trackingbranch" in parsed_scmsync_url_query: + branch = parsed_scmsync_url_query["trackingbranch"][0] # find a credentials entry for url and OBS user (there can be multiple users configured for a single URL in the config file) gitea_conf = gitea_api.Config(args.gitea_config) @@ -135,12 +139,10 @@ print(f"Forking git repo {owner}/{repo} ...", file=sys.stderr) # the branch was not specified, fetch the default branch from the repo - if branch: - fork_branch = branch - else: + if not branch: repo_obj = gitea_api.Repo.get(gitea_conn, owner, repo) branch = repo_obj.default_branch - fork_branch = branch + fork_branch = branch # check if the scmsync branch exists in the source repo parent_branch_obj = gitea_api.Branch.get(gitea_conn, owner, repo, fork_branch) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/commands_git/pr_close.py new/osc-1.20.0/osc/commands_git/pr_close.py --- old/osc-1.19.1/osc/commands_git/pr_close.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-1.20.0/osc/commands_git/pr_close.py 2025-09-16 13:52:28.000000000 +0200 @@ -0,0 +1,49 @@ +import osc.commandline_git + + +class PullRequestCloseCommand(osc.commandline_git.GitObsCommand): + """ + Close pull requests + """ + + name = "close" + parent = "PullRequestCommand" + + def init_arguments(self): + self.add_argument( + "id", + nargs="+", + help="Pull request ID in <owner>/<repo>#<number> format", + ) + self.add_argument( + "-m", + "--message", + help="Text of the comment", + ) + + def run(self, args): + from osc import gitea_api + + self.print_gitea_settings() + + pull_request_ids = args.id + + for pr_index, pr_id in enumerate(pull_request_ids): + print(f"Closing {pr_id} ...") + owner, repo, number = gitea_api.PullRequest.split_id(pr_id) + + gitea_api.PullRequest.close( + self.gitea_conn, + owner, + repo, + number, + ) + + if args.message: + gitea_api.PullRequest.add_comment( + self.gitea_conn, + owner, + repo, + number, + msg=args.message, + ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/commands_git/pr_get.py new/osc-1.20.0/osc/commands_git/pr_get.py --- old/osc-1.19.1/osc/commands_git/pr_get.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/commands_git/pr_get.py 2025-09-16 13:52:28.000000000 +0200 @@ -53,6 +53,9 @@ print(tty.colorize("Timeline:", "bold")) timeline = gitea_api.IssueTimelineEntry.list(self.gitea_conn, owner, repo, pull) for entry in timeline: + if entry._data is None: + print(f"{tty.colorize('ERROR', 'red,bold,blink')}: Gitea returned ``None`` instead of a timeline entry") + continue text, body = entry.format() if text is None: continue diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/commands_git/pr_reopen.py new/osc-1.20.0/osc/commands_git/pr_reopen.py --- old/osc-1.19.1/osc/commands_git/pr_reopen.py 1970-01-01 01:00:00.000000000 +0100 +++ new/osc-1.20.0/osc/commands_git/pr_reopen.py 2025-09-16 13:52:28.000000000 +0200 @@ -0,0 +1,49 @@ +import osc.commandline_git + + +class PullRequestReopenCommand(osc.commandline_git.GitObsCommand): + """ + Reopen pull requests + """ + + name = "reopen" + parent = "PullRequestCommand" + + def init_arguments(self): + self.add_argument( + "id", + nargs="+", + help="Pull request ID in <owner>/<repo>#<number> format", + ) + self.add_argument( + "-m", + "--message", + help="Text of the comment", + ) + + def run(self, args): + from osc import gitea_api + + self.print_gitea_settings() + + pull_request_ids = args.id + + for pr_index, pr_id in enumerate(pull_request_ids): + print(f"Reopening {pr_id} ...") + owner, repo, number = gitea_api.PullRequest.split_id(pr_id) + + gitea_api.PullRequest.reopen( + self.gitea_conn, + owner, + repo, + number, + ) + + if args.message: + gitea_api.PullRequest.add_comment( + self.gitea_conn, + owner, + repo, + number, + msg=args.message, + ) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/commands_git/pr_review_interactive.py new/osc-1.20.0/osc/commands_git/pr_review_interactive.py --- old/osc-1.19.1/osc/commands_git/pr_review_interactive.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/commands_git/pr_review_interactive.py 2025-09-16 13:52:28.000000000 +0200 @@ -27,6 +27,16 @@ """ +CLOSE_TEMPLATE = """ + +# +# Lines starting with '#' will be ignored. +# +# Closing pull request {owner}/{repo}#{number} +# +""" + + class PullRequestReviewInteractiveCommand(osc.commandline_git.GitObsCommand): """ Interactive review of pull requests @@ -90,7 +100,7 @@ skipped_drafts += 1 continue - self.clone_git(owner, repo, number) + self.clone_git(owner, repo, number, subdir="base") self.view(owner, repo, number, pr_index=pr_index, pr_count=len(pull_request_ids), pr_obj=pr_obj) while True: @@ -101,12 +111,14 @@ "a": "approve", "A": "approve and schedule for merging", "d": "decline", + "C": "close", "m": "comment", "v": "view again", "s": "skip", "x": "exit", }, default_answer="s", + vertical=True, ) if reply == "a": self.approve(owner, repo, number, commit=pr_obj.head_commit) @@ -118,6 +130,9 @@ elif reply == "d": self.decline(owner, repo, number) break + elif reply == "C": + self.close(owner, repo, number) + break elif reply == "m": self.comment(owner, repo, number) break @@ -156,6 +171,20 @@ gitea_api.PullRequest.decline_review(self.gitea_conn, owner, repo, number, msg=message, reviewer=reviewer) + def close(self, owner: str, repo: str, number: int): + from osc import gitea_api + + message = gitea_api.edit_message(template=CLOSE_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() + + gitea_api.PullRequest.add_comment(self.gitea_conn, owner, repo, number, msg=message) + gitea_api.PullRequest.close(self.gitea_conn, owner, repo, number) + def comment(self, owner: str, repo: str, number: int): from osc import gitea_api @@ -169,19 +198,22 @@ gitea_api.PullRequest.add_comment(self.gitea_conn, owner, repo, number, msg=message) - def get_git_repo_path(self, owner: str, repo: str, number: int): + def get_git_repo_path(self, owner: str, repo: str, number: int, *, subdir: Optional[str] = None): path = os.path.join("~", ".cache", "git-obs", "reviews", self.gitea_login.name, f"{owner}_{repo}_{number}") + if subdir: + # we don't check if the subdir points inside the ``path`` because this is not a library and we provide the values only in this command + path = os.path.join(path, subdir) path = os.path.expanduser(path) return path - def clone_git(self, owner: str, repo: str, number: int): + def clone_git(self, owner: str, repo: str, number: int, *, subdir: Optional[str] = None): from osc import gitea_api repo_obj = gitea_api.Repo.get(self.gitea_conn, owner, repo) clone_url = repo_obj.ssh_url # TODO: it might be good to have a central cache for the git repos to speed cloning up - path = self.get_git_repo_path(owner, repo, number) + path = self.get_git_repo_path(owner, repo, number, subdir=subdir) git = gitea_api.Git(path) if os.path.isdir(path): git.fetch() @@ -228,6 +260,9 @@ timeline_lines = [] timeline_lines.append(tty.colorize("Timeline:", "bold")) for entry in timeline: + if entry._data is None: + timeline_lines.append(f"{tty.colorize('ERROR', 'red,bold,blink')}: Gitea returned ``None`` instead of a timeline entry") + continue text, body = entry.format() if text is None: continue @@ -268,39 +303,64 @@ def tardiff(self, owner: str, repo: str, number: int, *, pr_obj: ".PullRequest") -> Generator[bytes, None, None]: from osc import gitea_api - path = self.get_git_repo_path(owner, repo, number) - git = gitea_api.Git(path) + base_path = self.get_git_repo_path(owner, repo, number, subdir="base") + base_git = gitea_api.Git(base_path) # the repo might be outdated, make sure the commits are available - git.fetch() - - src_archives = git.lfs_ls_files(ref=pr_obj.head_commit) - dst_archives = git.lfs_ls_files(ref=pr_obj.base_commit) + base_git.fetch() + base_git.switch(pr_obj.base_branch) + base_git.reset(pr_obj.base_commit, hard=True) + + head_path = self.get_git_repo_path(owner, repo, number, subdir="head") + if os.path.exists(head_path): + # update the 'base' and 'head' worktrees to the latest revisions from the pull request + pr_branch = base_git.fetch_pull_request(number, commit=pr_obj.head_commit, force=True) + else: + # IMPORTANT: git lfs is extremly difficult to use to query files from random branches and commits. + # The easiest we can do is to work with a checkout that contains the exact state we want to work with, + # that's why we're creating the 'head' worktree that contains the contents of the pull request. + # + # typical git lfs issues are: + # - ``git cat-file --format <commit>:<path>`` returns lfs metadata instead of the actual file while switched to another branch + # - ``git cat-file blob <oid> | git lfs smudge`` prints errors when a file is not part of lfs: Pointer file error: Unable to parse pointer at: "<unknown file>" + pr_branch = f"pull/{number}" + base_git._run_git(["worktree", "add", "--force", head_path, pr_branch]) + + head_git = gitea_api.Git(head_path) + + head_archives = head_git.ls_files(ref=pr_obj.head_commit, suffixes=gitea_api.TarDiff.SUFFIXES) + base_archives = base_git.ls_files(ref=pr_obj.base_commit, suffixes=gitea_api.TarDiff.SUFFIXES) + + # we need to override oids with lfs oids that match the actual file checksums; that is crucial for creating correct branch names in the cache + head_archives.update(head_git.lfs_ls_files(ref=pr_obj.head_commit, suffixes=gitea_api.TarDiff.SUFFIXES)) + base_archives.update(base_git.lfs_ls_files(ref=pr_obj.base_commit, suffixes=gitea_api.TarDiff.SUFFIXES)) def map_archives_by_name(archives: list): result = {} - for fn, sha in archives: - name = fn.rsplit("-", 1)[0] + for path, sha in archives.items(): + dirname = os.path.dirname(path) + basename = os.path.basename(path) + name = os.path.join(dirname, basename.rsplit("-", 1)[0]) assert name not in result - result[name] = (fn, sha) + result[name] = (path, sha) return result - src_archives_by_name = map_archives_by_name(src_archives) - dst_archives_by_name = map_archives_by_name(dst_archives) - all_names = sorted(set(src_archives_by_name) | set(dst_archives_by_name)) + head_archives_by_name = map_archives_by_name(head_archives) + base_archives_by_name = map_archives_by_name(base_archives) + all_names = sorted(set(head_archives_by_name) | set(base_archives_by_name)) path = self.get_tardiff_path() td = gitea_api.TarDiff(path) for name in all_names: - src_archive = src_archives_by_name.get(name, (None, None)) - dst_archive = dst_archives_by_name.get(name, (None, None)) + head_archive = head_archives_by_name.get(name, (None, None)) + base_archive = base_archives_by_name.get(name, (None, None)) - if src_archive[0]: - td.add_archive(src_archive[0], src_archive[1], git.lfs_cat_file(src_archive[0], ref=pr_obj.head_commit)) + if head_archive[0]: + td.add_archive(head_archive[0], head_archive[1], head_git.lfs_cat_file(head_archive[0], ref=pr_obj.head_commit)) - if dst_archive[0]: - td.add_archive(dst_archive[0], dst_archive[1], git.lfs_cat_file(dst_archive[0], ref=pr_obj.base_commit)) + if base_archive[0]: + td.add_archive(base_archive[0], base_archive[1], base_git.lfs_cat_file(base_archive[0], ref=pr_obj.base_commit)) # TODO: max output length / max lines; in such case, it would be great to list all the changed files at least - yield from td.diff_archives(*dst_archive, *src_archive) + yield from td.diff_archives(*base_archive, *head_archive) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/git_scm/store.py new/osc-1.20.0/osc/git_scm/store.py --- old/osc-1.19.1/osc/git_scm/store.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/git_scm/store.py 2025-09-16 13:52:28.000000000 +0200 @@ -1,6 +1,7 @@ import json import os import subprocess +import sys import urllib.parse from pathlib import Path @@ -112,13 +113,47 @@ config_path = os.path.join(path, "_config") pbuild_path = os.path.join(path, "_pbuild") subdirs_path = os.path.join(path, "_subdirs") + manifest_path = os.path.join(path, "_manifest") # we always stop at the top-most directory that contains .git subdir if not (os.path.isfile(config_path) or os.path.isfile(pbuild_path)): # it's not a project, stop traversing and return return None - if os.path.isfile(subdirs_path): + if os.path.isfile(manifest_path): + if os.path.isfile(subdirs_path): + print("WARNING: Ignoring '_subdirs' file, using data from '_manifest'", file=sys.stderr) + + # the _manifest file contains a list of project subdirs that contain packages and list of dirs which are packages + with open(manifest_path, "r") as f: + data = osc_yaml.yaml_load(f) + + # ``packages`` is a list of directories which are packages + packages = data.get("packages", []) + packages_abspath = [os.path.abspath(os.path.join(path, i)) for i in packages] + + # ``subdirectories`` is a list of directories, which have subdirectories which are packages + subdirs = data.get("subdirectories", []) + subdirs_abspath = [os.path.abspath(os.path.join(path, i)) for i in subdirs] + + common_paths = set(packages) & set(subdirs) + if common_paths: + print(f"WARNING: _manifest contains conflicting entries between 'packages' and 'subdirectories': {sorted(common_paths)}", file=sys.stderr) + + if self.abspath in packages_abspath: + # we're in a path defined in 'packages' -> it's a package + pass + elif self.abspath in subdirs_abspath: + # paths listed in ``subdirectories`` are never packages, their subdirs are + return None + elif os.path.abspath(os.path.join(self.abspath, "..")) not in subdirs_abspath: + # we're outside paths specified in 'subdirectories' -> not a package + return None + else: + # we're in a subdir of a directory listed in 'subdirectories' -> it's a package + pass + + elif os.path.isfile(subdirs_path): # the _subdirs file contains a list of project subdirs that contain packages with open(subdirs_path, "r") as f: data = osc_yaml.yaml_load(f) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/gitea_api/common.py new/osc-1.20.0/osc/gitea_api/common.py --- old/osc-1.19.1/osc/gitea_api/common.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/gitea_api/common.py 2025-09-16 13:52:28.000000000 +0200 @@ -12,7 +12,18 @@ class GiteaModel: - def __init__(self, data, *, response: Optional[GiteaHTTPResponse] = None, conn: Optional[Connection] = None): + def __init__( + self, + data, + *, + check_data: bool = True, + response: Optional[GiteaHTTPResponse] = None, + conn: Optional[Connection] = None, + ): + if check_data and not isinstance(data, dict): + # Gitea sometimes fails to serialize an object and returns ``None`` instead + raise ValueError(f"Unable to instantiate model {self.__class__.__name__} from the following data: {data}") + self._data = data self._response = response self._conn = conn diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/gitea_api/git.py new/osc-1.20.0/osc/gitea_api/git.py --- old/osc-1.19.1/osc/gitea_api/git.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/gitea_api/git.py 2025-09-16 13:52:28.000000000 +0200 @@ -2,6 +2,7 @@ import re import subprocess import urllib +from typing import Dict from typing import Iterator from typing import List from typing import Optional @@ -89,6 +90,12 @@ except subprocess.CalledProcessError: return -1 + def reset(self, commit: str, hard: bool = False): + cmd = ["reset", commit] + if hard: + cmd += ["--hard"] + self._run_git(cmd) + def switch(self, branch: str, orphan: bool = False): cmd = ["switch"] if orphan: @@ -101,12 +108,18 @@ pull_number: int, *, remote: str = "origin", + commit: Optional[str] = None, force: bool = False, ): """ Fetch pull/$pull_number/head to pull/$pull_number branch """ target_branch = f"pull/{pull_number}" + + # if the branch exists and the head matches the expected commit, skip running 'git fetch' + if commit and self.branch_exists(target_branch) and self.get_branch_head(target_branch) == commit: + return target_branch + cmd = ["fetch", remote, f"pull/{pull_number}/head:{target_branch}"] if force: cmd += [ @@ -151,16 +164,29 @@ # LFS - def lfs_ls_files(self, ref: str = "HEAD") -> List[Tuple[str, str]]: + def lfs_ls_files(self, ref: str = "HEAD", suffixes: Optional[List[str]] = None) -> Dict[str, str]: # TODO: --size; returns human readable string; can we somehow get the exact value in bytes instead? out = self._run_git(["lfs", "ls-files", "--long", ref]) - regex = re.compile(r"^(?P<checksum>[0-9a-f]+) [\*\-] (?P<filename>.*)$") - result = [] + regex = re.compile(r"^(?P<checksum>[0-9a-f]+) [\*\-] (?P<path>.*)$") + result = {} for line in out.splitlines(): match = regex.match(line) if not match: continue - result.append((match.group(2), match.group(1))) + + checksum = match.groupdict()["checksum"] + path = match.groupdict()["path"] + + if suffixes: + found = False + for suffix in suffixes: + if path.endswith(suffix): + found = True + break + if not found: + continue + + result[path] = checksum return result def lfs_cat_file(self, filename: str, ref: str = "HEAD"): @@ -187,6 +213,30 @@ cmd += ["--allow-empty"] self._run_git(cmd) + def ls_files(self, ref: str = "HEAD", suffixes: Optional[List[str]] = None) -> Dict[str, str]: + out = self._run_git(["ls-tree", "-r", "--format=%(objectname) %(path)", ref]) + regex = re.compile(r"^(?P<checksum>[0-9a-f]+) (?P<path>.*)$") + result = {} + for line in out.splitlines(): + match = regex.match(line) + if not match: + continue + + checksum = match.groupdict()["checksum"] + path = match.groupdict()["path"] + + if suffixes: + found = False + for suffix in suffixes: + if path.endswith(suffix): + found = True + break + if not found: + continue + + result[path] = checksum + return result + def diff(self, ref_old: str, ref_new: str, src_prefix: Optional[str] = None, dst_prefix: Optional[str] = None) -> Iterator[bytes]: cmd = ["git", "diff", ref_old, ref_new] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/gitea_api/issue_timeline_entry.py new/osc-1.20.0/osc/gitea_api/issue_timeline_entry.py --- old/osc-1.19.1/osc/gitea_api/issue_timeline_entry.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/gitea_api/issue_timeline_entry.py 2025-09-16 13:52:28.000000000 +0200 @@ -158,8 +158,18 @@ return f"{msg} the review", self.body def _format_review_request(self): - reviewer = self._data["assignee"]["login"] if self._data["assignee"] else self._data["assignee_team"]["name"] - return f"requested review from {reviewer}", self.body + action = "removed" if self._data["removed_assignee"] else "requested" + + if self._data["assignee"]: + reviewer = self._data["assignee"]["login"] + if self._data["assignee"]["id"] == -1: + reviewer += " (DELETED)" + elif self._data["assignee_team"]: + reviewer = self._data["assignee_team"]["name"] + else: + reviewer = "Ghost Team (DELETED)" + + return f"{action} review from {reviewer}", self.body # unused; we are not interested in these types of entries @@ -235,6 +245,7 @@ ) -> List["IssueTimelineEntry"]: """ List issue timeline entries (applicable to issues and pull request). + HACK: the resulting list may contain instances wrapping ``None`` instead of dictionary with data! :param conn: Gitea ``Connection`` instance. :param owner: Owner of the repo. @@ -246,5 +257,5 @@ } url = conn.makeurl("repos", owner, repo, "issues", str(number), "timeline", query=q) response = conn.request("GET", url) - obj_list = [cls(i, response=response, conn=conn) for i in response.json() or []] + obj_list = [cls(i, response=response, conn=conn, check_data=False) for i in response.json() or []] return obj_list diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/gitea_api/pr.py new/osc-1.20.0/osc/gitea_api/pr.py --- old/osc-1.19.1/osc/gitea_api/pr.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/gitea_api/pr.py 2025-09-16 13:52:28.000000000 +0200 @@ -4,17 +4,14 @@ from typing import Optional from typing import Tuple +from .common import GiteaModel from .connection import Connection from .connection import GiteaHTTPResponse from .user import User @functools.total_ordering -class PullRequest: - def __init__(self, data, *, response: Optional[GiteaHTTPResponse] = None): - self._data = data - self._response = response - +class PullRequest(GiteaModel): def __eq__(self, other): (self.base_owner, self.base_repo, self.number) == (other.base_owner, other.base_repo, other.number) @@ -24,9 +21,9 @@ @classmethod def split_id(cls, pr_id: str) -> Tuple[str, str, int]: """ - Split <owner>/<repo>#<number> into individual components and return them in a tuple. + Split <owner>/<repo>#<number> or <owner>/<repo>!<number> into individual components and return them in a tuple. """ - match = re.match(r"^([^/]+)/([^/]+)#([0-9]+)$", pr_id) + match = re.match(r"^([^/]+)/([^/]+)[#!]([0-9]+)$", pr_id) if not match: raise ValueError(f"Invalid pull request id: {pr_id}") return match.group(1), match.group(2), int(match.group(3)) @@ -409,7 +406,11 @@ :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") + q = { + "binary": 0, + } + # XXX: .patch suffix doesn't work with binary=0 + url = conn.makeurl("repos", owner, repo, "pulls", f"{number}.diff", query=q) response = conn.request("GET", url) return response.data @@ -554,3 +555,41 @@ # the error message is the same and it's not possible to distinguish between the two cases. if e.status != 404: raise + + @classmethod + def close( + cls, + conn: Connection, + owner: str, + repo: str, + number: int, + ) -> "PullRequest": + """ + Close a pull request. + """ + url = conn.makeurl("repos", owner, repo, "pulls", str(number)) + json_data = { + "state": "closed", + } + response = conn.request("PATCH", url, json_data=json_data, context={"owner": owner, "repo": repo}) + obj = cls(response.json(), response=response, conn=conn) + return obj + + @classmethod + def reopen( + cls, + conn: Connection, + owner: str, + repo: str, + number: int, + ) -> "PullRequest": + """ + Reopen a pull request. + """ + url = conn.makeurl("repos", owner, repo, "pulls", str(number)) + json_data = { + "state": "open", + } + response = conn.request("PATCH", url, json_data=json_data, context={"owner": owner, "repo": repo}) + obj = cls(response.json(), response=response, conn=conn) + return obj diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/gitea_api/tardiff.py new/osc-1.20.0/osc/gitea_api/tardiff.py --- old/osc-1.19.1/osc/gitea_api/tardiff.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/gitea_api/tardiff.py 2025-09-16 13:52:28.000000000 +0200 @@ -10,6 +10,23 @@ class TarDiff: + SUFFIXES = [ + ".7z", + ".bz2", + ".gz", + ".jar", + ".lz", + ".lzma", + ".obscpio", + ".tbz", + ".tbz2", + ".tgz", + ".txz", + ".xz", + ".zip", + ".zst", + ] + def __init__(self, path): self.git = git.Git(path) os.makedirs(self.path, exist_ok=True) @@ -25,14 +42,14 @@ filename = os.path.basename(filename) return f"{filename}-{checksum}" - def add_archive(self, filename: str, checksum: str, data: Iterator[bytes]) -> str: + def add_archive(self, path: str, checksum: str, data: Iterator[bytes]) -> str: """ Create a branch with expanded archive. The easiest way of obtaining the `checksum` is via running `git lfs ls-files --long`. """ # make sure we don't use the path anywhere - filename = os.path.basename(filename) + filename = os.path.basename(path) branch = self._get_branch_name(filename, checksum) @@ -63,7 +80,8 @@ for chunk in data: proc.stdin.write(chunk) proc.communicate() - assert proc.returncode == 0 + if proc.returncode != 0: + raise RuntimeError(f"bsdtar returned {proc.returncode} while extracting {path}") # add files and commit self.git.add(["--all"]) @@ -75,21 +93,21 @@ return branch - def diff_archives(self, src_filename, src_checksum, dst_filename, dst_checksum) -> Iterator[bytes]: - if src_filename: - src_filename = os.path.basename(src_filename) + def diff_archives(self, src_path, src_checksum, dst_path, dst_checksum) -> Iterator[bytes]: + if src_path: + src_filename = os.path.basename(src_path) src_branch = self._get_branch_name(src_filename, src_checksum) src_branch = f"refs/heads/{src_branch}" else: src_filename = "/dev/null" src_branch = GIT_EMPTY_COMMIT - if dst_filename: - dst_filename = os.path.basename(dst_filename) + if dst_path: + dst_filename = os.path.basename(dst_path) dst_branch = self._get_branch_name(dst_filename, dst_checksum) dst_branch = f"refs/heads/{dst_branch}" else: dst_filename = "/dev/null" dst_branch = GIT_EMPTY_COMMIT - yield from self.git.diff(src_branch, dst_branch, src_prefix=src_filename, dst_prefix=dst_filename) + yield from self.git.diff(src_branch, dst_branch, src_prefix=src_path, dst_prefix=dst_path) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/output/input.py new/osc-1.20.0/osc/output/input.py --- old/osc-1.19.1/osc/output/input.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/output/input.py 2025-09-16 13:52:28.000000000 +0200 @@ -7,7 +7,7 @@ from .tty import colorize -def get_user_input(question: str, answers: Dict[str, str], default_answer: Optional[str] = None) -> str: +def get_user_input(question: str, answers: Dict[str, str], default_answer: Optional[str] = None, vertical: bool = False) -> str: """ Ask user a question and wait for reply. @@ -27,12 +27,18 @@ value = f"{colorize(key, 'bold')}){value}" prompt.append(value) - prompt_str = " / ".join(prompt) - if default_answer: - prompt_str += f" (default={colorize(default_answer, 'bold')})" - prompt_str += ": " - - print(question, file=sys.stderr) + if vertical: + prompt_str = "\n".join(prompt) + if default_answer: + prompt_str += f"\n(default={colorize(default_answer, 'bold')})" + prompt_str += "\n" + prompt_str += question + " " + else: + prompt_str = " / ".join(prompt) + if default_answer: + prompt_str += f" (default={colorize(default_answer, 'bold')})" + prompt_str += "\n" + prompt_str += question + " " while True: try: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/osc/util/git_version.py new/osc-1.20.0/osc/util/git_version.py --- old/osc-1.19.1/osc/util/git_version.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/osc/util/git_version.py 2025-09-16 13:52:28.000000000 +0200 @@ -9,7 +9,7 @@ """ # the `version` variable contents get substituted during `git archive` # it requires adding this to .gitattributes: <path to this file> export-subst - version = "1.19.1" + version = "1.20.0" if version.startswith(("$", "%")): # "$": version hasn't been substituted during `git archive` # "%": "Format:" and "$" characters get removed from the version string (a GitHub bug?) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/osc-1.19.1/tests/test_git_scm_store.py new/osc-1.20.0/tests/test_git_scm_store.py --- old/osc-1.19.1/tests/test_git_scm_store.py 2025-08-06 07:02:14.000000000 +0200 +++ new/osc-1.20.0/tests/test_git_scm_store.py 2025-09-16 13:52:28.000000000 +0200 @@ -1,3 +1,5 @@ +import contextlib +import io import os import shutil import subprocess @@ -148,6 +150,71 @@ pkg_path = os.path.join(prj_path, "group/package") os.makedirs(pkg_path, exist_ok=True) + + store = GitStore(pkg_path) + self.assertEqual(store.project, "PROJ") + self.assertEqual(store.package, "my-package") + + def test_nested_pkg_osc_project_from_git_both_subdirs_and_manifest(self): + # project .git and .osc are next to each other + prj_path = os.path.join(self.tmpdir, "project") + self._git_init(prj_path) + self._write(os.path.join(prj_path, "_config")) + self._osc_init(prj_path, project="PROJ") + + # the nested package must be under a subdirectory tracked in _subdirs file + # otherwise it's not recognized as a package + # IMPORTANT: in this case, _manifest prevails over _subdirs + subdirs = {"subdirs": ["does-not-exist"]} + self._write(os.path.join(prj_path, "_subdirs"), osc_yaml.yaml_dumps(subdirs)) + + # the nested package must be under a subdirectory tracked in _manifest file + # otherwise it's not recognized as a package + subdirs = {"subdirectories": ["group"]} + self._write(os.path.join(prj_path, "_manifest"), osc_yaml.yaml_dumps(subdirs)) + + pkg_path = os.path.join(prj_path, "group/package") + os.makedirs(pkg_path, exist_ok=True) + + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + store = GitStore(pkg_path) + self.assertEqual("", stdout.getvalue()) + self.assertEqual("WARNING: Ignoring '_subdirs' file, using data from '_manifest'\n", stderr.getvalue()) + + self.assertEqual(store.project, "PROJ") + self.assertEqual(store.package, "my-package") + + def test_manifest_packages(self): + # project .git and .osc are next to each other + prj_path = os.path.join(self.tmpdir, "project") + self._git_init(prj_path) + self._write(os.path.join(prj_path, "_config")) + self._osc_init(prj_path, project="PROJ") + + subdirs = {"packages": ["group/package"]} + self._write(os.path.join(prj_path, "_manifest"), osc_yaml.yaml_dumps(subdirs)) + + pkg_path = os.path.join(prj_path, "group/package") + os.makedirs(pkg_path, exist_ok=True) + + store = GitStore(pkg_path) + self.assertEqual(store.project, "PROJ") + self.assertEqual(store.package, "my-package") + + def test_manifest_subdirectories(self): + # project .git and .osc are next to each other + prj_path = os.path.join(self.tmpdir, "project") + self._git_init(prj_path) + self._write(os.path.join(prj_path, "_config")) + self._osc_init(prj_path, project="PROJ") + + subdirs = {"subdirectories": ["group"]} + self._write(os.path.join(prj_path, "_manifest"), osc_yaml.yaml_dumps(subdirs)) + + pkg_path = os.path.join(prj_path, "group/package") + os.makedirs(pkg_path, exist_ok=True) store = GitStore(pkg_path) self.assertEqual(store.project, "PROJ") ++++++ osc.dsc ++++++ --- /var/tmp/diff_new_pack.NXBJ3y/_old 2025-09-16 18:20:33.964571513 +0200 +++ /var/tmp/diff_new_pack.NXBJ3y/_new 2025-09-16 18:20:33.968571682 +0200 @@ -1,6 +1,6 @@ Format: 1.0 Source: osc -Version: 1.19.1-0 +Version: 1.20.0-0 Binary: osc Maintainer: Adrian Schroeter <[email protected]> Architecture: any
