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

Reply via email to