Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package osc for openSUSE:Factory checked in 
at 2026-10-12 22:07:24
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/osc (Old)
 and      /work/SRC/openSUSE:Factory/.osc.new.3698736 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "osc"

Mon Oct 12 22:07:24 2026 rev:229 rq:1384264 version:1.28.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/osc/osc.changes  2026-08-04 21:37:22.694347402 
+0200
+++ /work/SRC/openSUSE:Factory/.osc.new.3698736/osc.changes     2026-10-12 
22:07:32.979132159 +0200
@@ -1,0 +2,36 @@
+Mon Oct 12 09:01:57 UTC 2026 - Daniel Mach <[email protected]>
+
+- 1.28.0
+  - Command-line:
+    - Added support for the 'VISUAL' environment variable
+    - Allow 'osc fork project/package' as a single argument
+    - Add 'osc log' --limit/-l option to limit the number of returned revisions
+    - Prefill interactive acceptance comments from the request message option
+    - Honor XDG_CONFIG_HOME in git-obs configuration lookup
+    - Expand user paths in XDG configuration overrides
+    - Fix interactive request review test compatibility
+    - Fix 'git-obs pr dump' to error out cleanly on changed submodule metadata
+    - Fix do_downloadassets missing help text
+    - Error out on deprecated apisrv option is set
+  - Library:
+    - Use any avaiable -p/--prefer-pkgs packages in containers builds
+    - Fix 'osc copypac' to error out when copying a package with an empty name
+    - Change XmlModel to check if the top-level element matches the expected 
XML_TAG
+    - Validate if we're working with a package in core.copy_pac()
+    - Fix 'osc branch' crash on packages with an empty <scmsync/> element
+    - Include multibuild flavors in interactive request review
+    - Fix stale tardiff cache by rebuilding it
+    - Set tardiff cache commit git username and email
+    - Fix pylint error by accessing Action members via getattr rather than 
directly
+    - Fix lint errors in request review branch
+    - Fix NoWorkingCopy exception silencing in get_store()
+  - Docs:
+    - Add AGENTS.md as a guideline for AI assisted coding
+    - Document git-obs pr forward workflow and merge modes
+    - Highlight 'sync' mode and clarify traditional OBS alignment in pr forward
+  - Spec:
+    - Drop no-compile
+    - Adapt use_python macro generation to support the legacy primary_python 
fallback introduced in python-rpm-macros
+    - Fix the regexp generating the use_python definition so that 'python3' 
doesn't turn into 'python3.'
+
+-------------------------------------------------------------------

Old:
----
  osc-1.27.3.tar.gz

New:
----
  osc-1.28.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ osc.spec ++++++
--- /var/tmp/diff_new_pack.dfyYjT/_old  2026-10-12 22:07:34.052176453 +0200
+++ /var/tmp/diff_new_pack.dfyYjT/_new  2026-10-12 22:07:34.053176494 +0200
@@ -17,7 +17,11 @@
 
 
 %if %{defined primary_python}
-%define use_python     %(echo %{primary_python} | sed -e 
's|python3|python3.|g')
+%if "%{primary_python}" == "python3"
+%define use_python python3
+%else
+%define use_python     %(echo %{primary_python} | sed -E 
's|python3([0-9]+)|python3.\\\1|g')
+%endif
 %define use_python_pkg %{primary_python}
 %else
 %define use_python python3
@@ -84,7 +88,7 @@
 %endif
 
 Name:           osc
-Version:        1.27.3
+Version:        1.28.0
 Release:        0
 Summary:        Command-line client for the Open Build Service
 License:        GPL-2.0-or-later
@@ -247,7 +251,6 @@
 %{use_python} -mpip install \
   --verbose --progress-bar off --disable-pip-version-check \
   --root %{buildroot} \
-  --no-compile \
   --ignore-installed --no-deps \
   --no-index --find-links ./build osc
 

++++++ PKGBUILD ++++++
--- /var/tmp/diff_new_pack.dfyYjT/_old  2026-10-12 22:07:34.087177897 +0200
+++ /var/tmp/diff_new_pack.dfyYjT/_new  2026-10-12 22:07:34.090178021 +0200
@@ -1,5 +1,5 @@
 pkgname=osc
-pkgver=1.27.3
+pkgver=1.28.0
 pkgrel=0
 pkgdesc="Command-line client for the Open Build Service"
 arch=('x86_64')

++++++ debian.changelog ++++++
--- /var/tmp/diff_new_pack.dfyYjT/_old  2026-10-12 22:07:34.140180085 +0200
+++ /var/tmp/diff_new_pack.dfyYjT/_new  2026-10-12 22:07:34.145180291 +0200
@@ -1,4 +1,4 @@
-osc (1.27.3-0) unstable; urgency=low
+osc (1.28.0-0) unstable; urgency=low
 
   * Placeholder
 

++++++ osc-1.27.3.tar.gz -> osc-1.28.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/AGENTS.md new/osc-1.28.0/AGENTS.md
--- old/osc-1.27.3/AGENTS.md    1970-01-01 01:00:00.000000000 +0100
+++ new/osc-1.28.0/AGENTS.md    2026-10-12 10:58:19.000000000 +0200
@@ -0,0 +1,62 @@
+# AGENTS.md — osc
+
+`osc` is the command-line client for the Open Build Service (OBS).
+`git-obs` is the command-line client for the Git Packaging Workflow on top of 
OBS.
+
+## Project structure - osc
+
+- `osc/commands/` - osc CLI commands.
+- `osc/commandline.py` - CLI setup and legacy `do_*` commands. Do not add new 
ones.
+- `osc/obs_api/` - typed OBS API objects.
+- `osc/conf.py` - configuration and `oscrc` handling.
+- `osc/core.py` - legacy API and implementation. Use and extend `obs_api` 
instead if applicable.
+- `osc/credentials.py` - credential handling.
+- `osc/oscerr.py` - osc-specific exception hierarchy.
+- `osc/output/` - user-facing output, prompts and TTY handling.
+- `osc/util/` - shared utilities.
+- `osc/cmdln.py` - vendored framework. Do not modify.
+- `osc/babysitter.py` - entry point; maps `oscerr.*` to messages and exit 
codes.
+- `osc/_private/` - new internals. Prefer over the legacy `osc/core.py`.
+
+## Project structure - git-obs
+
+- `osc/commands_git/` - git-obs CLI commands.
+- `osc/commandline_git.py` - CLI setup.
+- `osc/gitea_api/` - typed Gitea API objects.
+- `osc/gitea_api/conf.py` - configuration and `~/.config/tea/config.yml` 
handling.
+
+## Development rules
+
+- Minimal changes leading to the requested behavior.
+- Python 3.6 is the floor. f-strings yes; no walrus, no `match`, no `X | Y`.
+- Preferred line length <= 120.
+- Use `oscerr.OscBaseError` subclasses for new osc-specific error conditions.
+- Configuration descriptions are used to generate documentation.
+- Use attributes of the configuration objects, avoid legacy dict interface.
+- Keep imports lazy where practical; startup time matters.
+- Preserve backwards compatibility, especially the public and plugin API.
+
+## Adding a command
+
+- Add module in `osc/commands/<name>.py` with a `Command` subclass.
+- Modules are auto-discovered, no registration needed.
+- Docstring is user-facing help: `help` (1 line), empty line, description.
+
+## Handling input
+
+- Use `osc.util.helper.raw_input()` for normal stdin input. EOF raises 
`oscerr.UserAbort`.
+- Use `osc.output.input.get_user_input()` for choice prompts.
+
+## Tests
+
+- `tests` - unit tests
+  - Run `python3 -m unittest` after `pip install -e .`.
+  - Simple, fast, offline tests. Avoid mocking unless necessary.
+  - `tests/common.py` - shared harness.
+- `behave` - integration tests
+  - Require an OBS/podman environment.
+- Linters
+  - Code is old and has many issues, focus on diffs to produce new clean code.
+  - `darker --check --line-length=120 --formatter ruff .`
+  - `ruff check .`
+  - `pylint --errors-only osc`
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/NEWS new/osc-1.28.0/NEWS
--- old/osc-1.27.3/NEWS 2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/NEWS 2026-10-12 10:58:19.000000000 +0200
@@ -1,3 +1,39 @@
+- 1.28.0
+  - Command-line:
+    - Added support for the 'VISUAL' environment variable
+    - Allow 'osc fork project/package' as a single argument
+    - Add 'osc log' --limit/-l option to limit the number of returned revisions
+    - Prefill interactive acceptance comments from the request message option
+    - Honor XDG_CONFIG_HOME in git-obs configuration lookup
+    - Expand user paths in XDG configuration overrides
+    - Fix interactive request review test compatibility
+    - Fix 'git-obs pr dump' to error out cleanly on changed submodule metadata
+    - Fix do_downloadassets missing help text
+    - Error out on deprecated apisrv option is set
+  - Library:
+    - Use any avaiable -p/--prefer-pkgs packages in containers builds
+    - Fix 'osc copypac' to error out when copying a package with an empty name
+    - Change XmlModel to check if the top-level element matches the expected 
XML_TAG
+    - Validate if we're working with a package in core.copy_pac()
+    - Fix 'osc branch' crash on packages with an empty <scmsync/> element
+    - Include multibuild flavors in interactive request review
+    - Fix stale tardiff cache by rebuilding it
+    - Set tardiff cache commit git username and email
+    - Fix pylint error by accessing Action members via getattr rather than 
directly
+    - Fix lint errors in request review branch
+    - Fix NoWorkingCopy exception silencing in get_store()
+  - Docs:
+    - Add AGENTS.md as a guideline for AI assisted coding
+    - Document git-obs pr forward workflow and merge modes
+    - Highlight 'sync' mode and clarify traditional OBS alignment in pr forward
+  - Spec:
+    - Drop no-compile
+    - Adapt use_python macro generation to support the legacy primary_python 
fallback introduced in python-rpm-macros
+    - Fix the regexp generating the use_python definition so that 'python3' 
doesn't turn into 'python3.'
+
+- 1.27.4
+  - Command-line:
+    - Add 'osc log' --limit/-l option to limit the number of revisions 
returned by the server
 - 1.27.3
   - Command-line:
     - Fix 'osc lbl' to work with git based packages
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/doc/git-obs-quickstart.rst 
new/osc-1.28.0/doc/git-obs-quickstart.rst
--- old/osc-1.27.3/doc/git-obs-quickstart.rst   2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/doc/git-obs-quickstart.rst   2026-10-12 10:58:19.000000000 
+0200
@@ -14,7 +14,9 @@
 
 Before executing any ``git-obs`` commands, you need to configure your
 credentials, also known as logins.  These logins are shared with the ``tea``
-client and are stored in ``~/.config/tea/config.yml``.
+client and are stored in ``$XDG_CONFIG_HOME/tea/config.yml``. If
+``XDG_CONFIG_HOME`` is unset or empty, ``~/.config/tea/config.yml``
+is used instead. ``--gitea-config`` and ``GIT_OBS_CONFIG`` override this 
location.
 
 .. note::
     See ``git-obs login --help`` for all commands related to managing login 
entries.
@@ -200,6 +202,107 @@
     ...
 
 
+Workflow: Forwarding sources between branches (``git-obs pr forward``)
+----------------------------------------------------------------------
+
+The ``git-obs pr forward`` command is the Git-native counterpart to ``osc sr``
+across branches or code streams (for example, promoting changes from 
``factory``
+to a downstream release branch such as ``slfo-main`` or ``16.0``).
+
+How it works:
+~~~~~~~~~~~~~
+
+1. **Reuses your existing fork:**
+   Checks whether you already have a fork of the target repository under your
+   Gitea account. If so, it reuses your existing fork without creating 
redundant
+   repositories or affecting other remotes.
+
+2. **Guarantees safety of your existing work:**
+   The command never modifies your default branch (``main`` / ``master``) or
+   any ongoing work in your other branches. Instead, it creates a dedicated,
+   isolated branch specifically for the forward operation:
+
+   .. code::
+
+       for/<target_branch>/forward-<source_commit_sha>
+
+   *(e.g., ``for/slfo-main/forward-7f9a2b...``)*
+
+   Only this namespaced branch is pushed to your remote fork.
+
+3. **Performs clean, automated synchronization:**
+   The operation executes inside an isolated temporary directory. It integrates
+   the changes, verifies file additions and deletions, and synchronizes any
+   associated Git LFS assets.
+
+4. **Leaves no litter on your filesystem:**
+   The temporary directory is deleted automatically as soon as the command
+   finishes. No residual files are left behind unless you explicitly specify
+   ``--no-cleanup`` or designate a custom ``--workdir``.
+
+5. **Submits the Pull Request:**
+   Once the branch is pushed to your fork, a standard Gitea Pull Request is
+   opened against the upstream target branch.
+
+
+Understanding the merge modes (``--mode``):
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When forwarding sources between branches that may have evolved separately,
+you can select how changes should be reconciled:
+
+- **``auto`` (recommended):**
+  The best choice for routine forwarding. If the target branch has not diverged
+  from the source branch, it simply fast-forwards without adding unnecessary 
merge
+  commits. If the branches have diverged, it merges the source changes and
+  ensures the resulting file tree matches the source state.
+
+- **``sync``:**
+  Closest to traditional OBS behavior (``osc sr``). Rather than reconciling
+  disparate Git commit histories, this mode creates a single synchronization
+  commit that brings the destination package contents into exact alignment with
+  the source branch state. This is particularly useful when forwarding across
+  distribution releases or between branches that have diverged significantly,
+  ensuring the target cleanly reflects the source package without history 
conflicts.
+
+- **``fast-forward``:**
+  Applies changes only if the target branch can be cleanly fast-forwarded to
+  the source branch. If the target branch contains independent commits not 
present
+  in the source, the command halts without making changes. Use this when you
+  strictly require a linear commit history.
+
+- **``merge``:**
+  Integrates changes by creating a merge commit on the target branch. If 
conflicts
+  arise, incoming changes from the source take precedence, and files deleted in
+  the source are cleanly removed from the target.
+
+- **``merge-unrelated``:**
+  Behaves like ``merge``, but permits combining branches that do not share a 
common
+  historical ancestor (such as branches initialized independently in different 
repositories).
+
+
+Usage examples:
+~~~~~~~~~~~~~~~
+
+Forward package changes from ``factory`` to ``slfo-main``:
+
+.. code::
+
+    git-obs -G opensuse pr forward products/slfo factory slfo-main --mode auto
+
+Preview all planned actions without modifying remotes or creating a PR 
(dry-run):
+
+.. code::
+
+    git-obs -G opensuse pr forward products/slfo factory slfo-main --mode auto 
--dry-run
+
+Provide a custom title and interactively edit the PR message:
+
+.. code::
+
+    git-obs -G opensuse pr forward products/slfo factory slfo-main --mode auto 
--title "Sync slfo-main with Factory" --edit
+
+
 Workflow: Querying pull requests
 --------------------------------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/__init__.py 
new/osc-1.28.0/osc/__init__.py
--- old/osc-1.27.3/osc/__init__.py      2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/__init__.py      2026-10-12 10:58:19.000000000 +0200
@@ -13,7 +13,7 @@
 
 
 from .util import git_version
-__version__ = git_version.get_version('1.27.3')
+__version__ = git_version.get_version('1.28.0')
 
 
 # vim: sw=4 et
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/build.py new/osc-1.28.0/osc/build.py
--- old/osc-1.27.3/osc/build.py 2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/build.py 2026-10-12 10:58:19.000000000 +0200
@@ -614,6 +614,14 @@
 
 
 def get_prefer_pkgs(dirs, wanted_arch, type, cpio):
+    """
+    Gather all packages from directories specified via -p/--prefer-pkgs
+    to make them available in the build environment.
+
+    We're gathering all known package types, because they can be used
+    for building both packages and containers and we don't know
+    what's going to be used in the build recipe in the end.
+    """
     paths = []
     repositories = []
 
@@ -623,11 +631,7 @@
         if repository:
             repositories.append(repository)
         else:
-            # we don't know binary type as we haven't received buildconfig yet
-            # that's why we add all files for any matching build types
             for build_type in BUILD_TYPES:
-                if build_type.name != type:
-                    continue
                 for pattern in build_type.prefer_packages_paths:
                     for pkg_path in 
glob.glob(os.path.join(os.path.abspath(pkgs_dir), pattern), recursive=True):
                         use_package = True
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/commandline.py 
new/osc-1.28.0/osc/commandline.py
--- old/osc-1.27.3/osc/commandline.py   2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/commandline.py   2026-10-12 10:58:19.000000000 +0200
@@ -192,6 +192,9 @@
         if conf.config["show_download_progress"]:
             self.download_progress = create_text_meter()
 
+        if conf.config["apisrv"]:
+            raise oscerr.ConfigError("apisrv in config is deprecated, use 
apiurl instead", "")
+
         if not args.apiurl:
             self.parser.error("Could not determine apiurl, use -A/--apiurl to 
specify one")
 
@@ -1359,11 +1362,12 @@
 
     @cmdln.alias('da')
     def do_downloadassets(self, subcmd, opts, *args):
-        from .core import download_assets
-
         """
         Download all assets referenced in the build descriptions
         """
+
+        from .core import download_assets
+
         download_assets(".")
 
     @cmdln.alias('sdp')
@@ -1590,7 +1594,8 @@
         will be the pattern file to view or edit.
 
         With the --edit switch, the metadata can be edited. Per default, osc
-        opens the program specified by the environmental variable EDITOR with a
+        opens the program specified by the VISUAL environment variable,
+        or if not set the EDITOR environment variable, with a
         temporary file. Alternatively, content to be saved can be supplied via
         the --file switch. If the argument is '-', input is taken from stdin:
         osc meta prjconf home:user | sed ... | osc meta prjconf home:user -F -
@@ -2974,7 +2979,7 @@
     @cmdln.option('--no-devel', action='store_true',
                   help='Do not attempt to forward to devel project')
     @cmdln.option('-m', '--message', metavar='TEXT',
-                  help='specify message TEXT')
+                  help='specify message TEXT; prefill the acceptance comment 
editor in interactive mode')
     @cmdln.option('-t', '--type', metavar='TYPE',
                   help='limit to requests which contain a given action type 
(submit/delete/change_devel/add_role/set_bugowner/maintenance_incident/maintenance_release)')
     @cmdln.option('-a', '--all', action='store_true',
@@ -3373,7 +3378,8 @@
                             ignore_reviews = subcmd != 'review'
                             request_interactive_review(apiurl, result, 
group=opts.group,
                                                        
ignore_reviews=ignore_reviews,
-                                                       
source_buildstatus=source_buildstatus)
+                                                       
source_buildstatus=source_buildstatus,
+                                                       message=opts.message)
                         else:
                             print(result.list_view(), '\n')
                     else:
@@ -3409,12 +3415,15 @@
                 if not r.get_actions('submit'):
                     raise oscerr.WrongOptions('\'--edit\' not possible '
                                               '(request has no \'submit\' 
action)')
-                return request_interactive_review(apiurl, r, 'e')
+                request_interactive_review(apiurl, r, 'e', 
message=opts.message)
+                return
             elif (opts.interactive or conf.config['request_show_interactive']) 
and not opts.non_interactive:
                 ignore_reviews = subcmd != 'review'
-                return request_interactive_review(apiurl, r, group=opts.group,
-                                                  
ignore_reviews=ignore_reviews,
-                                                  
source_buildstatus=source_buildstatus)
+                request_interactive_review(apiurl, r, group=opts.group,
+                                           ignore_reviews=ignore_reviews,
+                                           
source_buildstatus=source_buildstatus,
+                                           message=opts.message)
+                return
             else:
                 print(r)
                 print_comments(apiurl, 'request', reqid)
@@ -5914,15 +5923,15 @@
            osc ci file1 file2 ...
         """
 
-        from .core import get_default_editor
+        from .core import _editor_command
 
         try:
             self._commit(subcmd, opts, args)
         except oscerr.ExtRuntimeError as e:
             pattern = re.compile("No such file")
             if "No such file" in e.msg:
-                editor = os.getenv('EDITOR', default=get_default_editor())
-                print(f"Editor {editor} not found")
+                editor = _editor_command()
+                print(f"Editor {editor[0]} not found")
                 return 1
             print("ERROR: service run failed", e, file=sys.stderr)
             return 1
@@ -8267,6 +8276,8 @@
                         help='work on deleted package')
     @cmdln.option('-M', '--meta', action='store_true', default=None,
                         help='checkout out meta data instead of sources')
+    @cmdln.option('-l', '--limit', metavar='N', type=int, default=None,
+                        help='limit the number of revisions returned by the 
server')
     def do_log(self, subcmd, opts, *args):
         """
         Shows the commit log of a package
@@ -8305,7 +8316,7 @@
         if opts.xml:
             format = 'xml'
 
-        lines = get_commitlog(apiurl, project, package, rev, format, 
opts.meta, opts.deleted, rev_upper, patch=opts.patch)
+        lines = get_commitlog(apiurl, project, package, rev, format, 
opts.meta, opts.deleted, rev_upper, patch=opts.patch, limit=opts.limit)
         pipe_to_pager(lines, add_newlines=True)
 
     @cmdln.option('-v', '--verbose', action='store_true',
@@ -10815,8 +10826,9 @@
 
         or can be specified via mailaddr environment variable.
 
-        By default, osc vc opens the program specified by the EDITOR
-        environment variable (and it uses Vim if that variable is not set) with
+        By default, osc vc opens the program specified by the VISUAL 
environment variable
+        or if not set the EDITOR environment variable
+        (and it uses Vim if that variable is not set), with
         a temporary file that should replace the *.changes file when saved by
         the editor, or discarded otherwise.
         """
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/commandline_git.py 
new/osc-1.28.0/osc/commandline_git.py
--- old/osc-1.27.3/osc/commandline_git.py       2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/commandline_git.py       2026-10-12 10:58:19.000000000 
+0200
@@ -250,7 +250,10 @@
 
         self.add_argument(
             "--gitea-config",
-            help="Path to gitea config. Default: $GIT_OBS_CONFIG or 
~/.config/tea/config.yml.",
+            help=(
+                "Path to gitea config. Default: $GIT_OBS_CONFIG or 
$XDG_CONFIG_HOME/tea/config.yml "
+                "(~/.config/tea/config.yml when XDG_CONFIG_HOME is unset or 
empty)."
+            ),
         )
 
         self.add_argument(
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/commands/fork.py 
new/osc-1.28.0/osc/commands/fork.py
--- old/osc-1.27.3/osc/commands/fork.py 2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/commands/fork.py 2026-10-12 10:58:19.000000000 +0200
@@ -91,6 +91,10 @@
         package = args.package
         target_project = self._process_project_name(args.target_project)
 
+        if package is None and project and "/" in project:
+            # allow a single "project/package" argument, matching commands 
like 'co' and 'branch'
+            project, package = project.split("/", 1)
+
         if project and "/" in project:
             self.parser.error(f"Invalid project: {project}")
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/commands_git/pr_dump.py 
new/osc-1.28.0/osc/commands_git/pr_dump.py
--- old/osc-1.27.3/osc/commands_git/pr_dump.py  2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/commands_git/pr_dump.py  2026-10-12 10:58:19.000000000 
+0200
@@ -325,7 +325,9 @@
                     elif is_project:
                         base_value = base_submodules[i].get(key, None)
                         head_value = head_submodules[i].get(key, None)
-                        assert base_value == head_value, f"Submodule metadata 
has changed: submodule='{i}', key='{key}', base_value='{base_value}', 
head_value='{head_value}'"
+                        if base_value != head_value:
+                            msg = f"Submodule metadata has changed: 
submodule='{i}', key='{key}', base_value='{base_value}', 
head_value='{head_value}'"
+                            raise gitea_api.GitObsRuntimeError(msg)
 
                 base_commit = base_submodules[i].get("commit","")
                 head_commit = head_submodules[i].get("commit","")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/conf.py new/osc-1.28.0/osc/conf.py
--- old/osc-1.27.3/osc/conf.py  2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/conf.py  2026-10-12 10:58:19.000000000 +0200
@@ -511,6 +511,15 @@
         ),
     )  # type: ignore[assignment]
 
+    apisrv: Optional[str] = Field(
+            default=None,
+            description=textwrap.dedent(
+                """
+                Deprecated option. Use apiurl instead.
+                """
+            ),
+        )  # type: ignore[assignment]
+
     section_auth: str = Field(
         default="Authentication options",
         exclude=True,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/core.py new/osc-1.28.0/osc/core.py
--- old/osc-1.27.3/osc/core.py  2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/core.py  2026-10-12 10:58:19.000000000 +0200
@@ -597,12 +597,10 @@
                 src_package_meta_releasename = 
self.src_pkg_object.get_meta_value("releasename")
                 tgt_package = src_package_meta_releasename.split(".")[0]
             else:
-                if not getattr(self, "tgt_project", None) or not getattr(self, 
"tgt_package", None):
+                tgt_project = getattr(self, "tgt_project", None)
+                tgt_package = getattr(self, "tgt_package", None)
+                if not tgt_project or not tgt_package:
                     return None
-                # tgt_project and tgt_package are checked above
-                # pylint: disable=no-member
-                tgt_project = self.tgt_project
-                tgt_package = self.tgt_package
             self._tgt_pkg_object = _private.ApiPackage(self.apiurl, 
tgt_project, tgt_package)
         return self._tgt_pkg_object
 
@@ -2051,8 +2049,9 @@
 
 
 def _editor_command():
+    visual = os.getenv("VISUAL", default="").strip()
     editor = os.getenv("EDITOR", default="").strip()
-    editor = editor or get_default_editor()
+    editor = visual or editor or get_default_editor()
     try:
         cmd = shlex.split(editor)
     except SyntaxError:
@@ -3810,11 +3809,12 @@
             root = xml_fromstring(m)
 
     # error out if we're branching a scmsync package (we'd end up with garbage 
anyway)
-    if root is not None and root.find("scmsync") is not None:
+    scmsync_node = root.find("scmsync") if root is not None else None
+    if scmsync_node is not None and scmsync_node.text:
         msg = ("osc cannot branch packages with <scmsync>, i.e. externally "
               "managed sources. Often, the URL for cloning is also the URL "
               "for a collaborative web interface where you can fork (branch). "
-              "The scmsync URL was: " + root.find("scmsync").text)
+              "The scmsync URL was: " + scmsync_node.text)
         if devel_project:
             raise oscerr.PackageError(devel_project, devel_package, msg)
         raise oscerr.PackageError(src_project, src_package, msg)
@@ -3918,6 +3918,8 @@
     them into the other by uploading them (client-side copy) --
     or by the server, in a single api call.
     """
+    from . import obs_api
+
     if (src_apiurl, src_project, src_package) == (dst_apiurl, dst_project, 
dst_package):
         # special cases when source and target can be the same:
         # * expanding sources
@@ -3925,10 +3927,15 @@
         if not any([expand, revision]):
             raise oscerr.OscValueError("Cannot copy package. Source and target 
are the same.")
 
+    if not src_package:
+        raise oscerr.OscValueError("Cannot copy a package without a name.")
+
     meta = new_meta = src_meta = None
     if not (src_apiurl == dst_apiurl and src_project == dst_project
             and src_package == dst_package):
         src_meta = show_package_meta(src_apiurl, src_project, src_package)
+        # initialize Package to validate the XML; we're not replacing the 
existing code with an object for now
+        obs_api.Package.from_string(b''.join(src_meta))
         dst_userid = conf.get_apiurl_usr(dst_apiurl)
         meta = replace_pkg_meta(src_meta, dst_package, dst_project, 
keep_maintainers,
                                 dst_userid, keep_develproject, 
keep_scmsync=(not client_side_copy))
@@ -4933,12 +4940,13 @@
     deleted: Optional[bool] = None,
     revision_upper: Optional[str] = None,
     patch: Optional[bool] = None,
+    limit: Optional[int] = None,
 ):
     if package is None:
         package = "_project"
 
     from . import obs_api
-    revision_list = obs_api.Package.get_revision_list(apiurl, prj, package, 
deleted=deleted, meta=meta)
+    revision_list = obs_api.Package.get_revision_list(apiurl, prj, package, 
deleted=deleted, meta=meta, limit=limit)
 
     # TODO: consider moving the following block to Package.get_revision_list()
     # keep only entries matching the specified revision
@@ -5910,7 +5918,7 @@
 
 
 def request_interactive_review(apiurl, request, initial_cmd='', group=None,
-                               ignore_reviews=False, source_buildstatus=False):
+                               ignore_reviews=False, source_buildstatus=False, 
message=None):
     """review the request interactively"""
     tmpfile = None
 
@@ -5936,16 +5944,19 @@
         """
         result = []
         for action in src_actions:
-            disabled = show_package_disabled_repos(apiurl, action.src_project, 
action.src_package)
-            for repo in get_repos_of_project(apiurl, action.src_project):
-                if (disabled is None) or (repo.name not in [d["repo"] for d in 
disabled]):
-                    entry = {
-                        "proj": action.src_project,
-                        "pkg": action.src_package,
-                        "repo": repo.name,
-                        "arch": repo.arch
-                    }
-                    result.append(entry)
+            for results in get_package_results(apiurl, action.src_project, 
action.src_package, multibuild=True):
+                root = xml_fromstring(results)
+                for repo in root.findall('result'):
+                    for status in repo.findall('status'):
+                        if status.get('code') in ('disabled', 'excluded'):
+                            continue
+                        entry = {
+                            "proj": action.src_project,
+                            "pkg": status.get('package'),
+                            "repo": repo.get('repository'),
+                            "arch": repo.get('arch')
+                        }
+                        result.append(entry)
         return result
 
     def select_repo(src_actions):
@@ -6007,7 +6018,7 @@
         for action in src_actions:
             print(f'{action.src_project}/{action.src_package}:')
             try:
-                print('\n'.join(get_results(apiurl, action.src_project, 
action.src_package)))
+                print('\n'.join(get_results(apiurl, action.src_project, 
action.src_package, multibuild=True)))
             except HTTPError as e:
                 if e.code != 404:
                     raise
@@ -6133,6 +6144,8 @@
                     footer = 'changing request from state \'%s\' to 
\'%s\'\n\n' \
                         % (request.state.name, state)
                     msg_template = change_request_state_template(request, 
state)
+                if state == 'accepted' and message is not None:
+                    msg_template = message
                 if tmpfile is None:
                     footer += str(request)
                 if tmpfile is not None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/gitea_api/common.py 
new/osc-1.28.0/osc/gitea_api/common.py
--- old/osc-1.27.3/osc/gitea_api/common.py      2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/gitea_api/common.py      2026-10-12 10:58:19.000000000 
+0200
@@ -63,8 +63,11 @@
     import shlex
     from .exceptions import GitObsRuntimeError
 
+    visual = os.getenv("VISUAL")
     editor = os.getenv("EDITOR", None)
-    if editor:
+    if visual:
+        candidates = [visual]
+    elif editor:
         candidates = [editor]
     else:
         candidates = ["vim", "vi"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/gitea_api/conf.py 
new/osc-1.28.0/osc/gitea_api/conf.py
--- old/osc-1.27.3/osc/gitea_api/conf.py        2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/gitea_api/conf.py        2026-10-12 10:58:19.000000000 
+0200
@@ -102,7 +102,8 @@
 
     def __init__(self, path: Optional[str] = None):
         if not path:
-            path = "~/.config/tea/config.yml"
+            config_home = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
+            path = os.path.join(config_home, "tea", "config.yml")
         self.path = os.path.abspath(os.path.expanduser(path))
 
         self.logins: List[Login] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/gitea_api/tardiff.py 
new/osc-1.28.0/osc/gitea_api/tardiff.py
--- old/osc-1.27.3/osc/gitea_api/tardiff.py     2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/gitea_api/tardiff.py     2026-10-12 10:58:19.000000000 
+0200
@@ -30,10 +30,30 @@
     def __init__(self, path):
         self.git = git.Git(path)
         os.makedirs(self.path, exist_ok=True)
+        if self._has_incompatible_git_cache():
+            shutil.rmtree(self.path)
+            os.makedirs(self.path, exist_ok=True)
+            self.git = git.Git(path)
         self.git.init(initial_branch="empty", quiet=True, mute_stderr=True)
+        self.git.set_config("user.name", "osc tardiff")
+        self.git.set_config("user.email", "[email protected]")
         # the git repo is switched to this branch by default to hide the files 
from disk
         self.git.commit("empty branch", allow_empty=True)
 
+    def _has_incompatible_git_cache(self) -> bool:
+        if not os.path.isdir(os.path.join(self.path, ".git")):
+            return False
+
+        proc = subprocess.run(
+            ["git", "rev-parse", "--show-object-format"],
+            stdout=subprocess.PIPE,
+            stderr=subprocess.DEVNULL,
+            encoding="utf-8",
+            cwd=self.path,
+            check=False,
+        )
+        return proc.returncode == 0 and proc.stdout.strip() != "sha256"
+
     @property
     def path(self):
         return self.git.abspath
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/obs_api/package.py 
new/osc-1.28.0/osc/obs_api/package.py
--- old/osc-1.27.3/osc/obs_api/package.py       2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/obs_api/package.py       2026-10-12 10:58:19.000000000 
+0200
@@ -164,13 +164,22 @@
         return Status.from_file(response, apiurl=apiurl)
 
     @classmethod
-    def get_revision_list(cls, apiurl: str, project: str, package: str, 
deleted: Optional[bool] = None, meta: Optional[bool] = None):
+    def get_revision_list(
+        cls,
+        apiurl: str,
+        project: str,
+        package: str,
+        deleted: Optional[bool] = None,
+        meta: Optional[bool] = None,
+        limit: Optional[int] = None,
+    ):
         from ..util.xml import xml_parse
 
         url_path = ["source", project, package, "_history"]
         url_query = {
             "meta": meta,
             "deleted": deleted,
+            "limit": limit,
         }
         response = cls.xml_request("GET", apiurl, url_path, url_query)
         root = xml_parse(response).getroot()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/store.py new/osc-1.28.0/osc/store.py
--- old/osc-1.27.3/osc/store.py 2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/store.py 2026-10-12 10:58:19.000000000 +0200
@@ -34,7 +34,12 @@
             store = git_scm.GitStore(path, check=check)
             if print_warnings:
                 git_scm.warn_experimental()
-        except oscerr.NoWorkingCopy:
+        except oscerr.NoWorkingCopy as e:
+            # GitStore raises "is not a Git SCM working copy" if the path is 
not a git repo.
+            # Any other NoWorkingCopy error indicates a valid git repo with a 
specific issue
+            # (e.g. detached HEAD, missing metadata), which we want to 
propagate to the user.
+            if "is not a Git SCM working copy" not in str(e):
+                raise
             pass
 
     if not store:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/osc/util/git_version.py 
new/osc-1.28.0/osc/util/git_version.py
--- old/osc-1.27.3/osc/util/git_version.py      2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/osc/util/git_version.py      2026-10-12 10:58:19.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.27.3"
+    version = "1.28.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.27.3/osc/util/models.py 
new/osc-1.28.0/osc/util/models.py
--- old/osc-1.27.3/osc/util/models.py   2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/osc/util/models.py   2026-10-12 10:58:19.000000000 +0200
@@ -721,6 +721,9 @@
         """
         Instantiate model from a XML root.
         """
+        if cls.XML_TAG and cls.XML_TAG != root.tag:
+            print(cls.XML_TAG, root.tag)
+            raise ValueError(f"{cls.__name__}: Expected '{cls.XML_TAG}' 
element, found '{root.tag}'")
 
         # We need to make sure we parse all data
         # and that's why we remove processed elements and attributes and check 
that nothing remains.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/tests/test_git_editor.py 
new/osc-1.28.0/tests/test_git_editor.py
--- old/osc-1.27.3/tests/test_git_editor.py     2026-08-03 16:54:23.000000000 
+0200
+++ new/osc-1.28.0/tests/test_git_editor.py     2026-10-12 10:58:19.000000000 
+0200
@@ -6,17 +6,23 @@
 from osc.gitea_api.common import get_editor_command
 
 
+vim_executable = shutil.which("vim")
+nano_executable = shutil.which("nano")
+
+
 class TestGitEditor(unittest.TestCase):
 
+    @unittest.skipIf(vim_executable is None, "vim executable not found")
     def test_default(self):
         with unittest.mock.patch.dict(os.environ, {"EDITOR": ""}):
             c = get_editor_command("test")
-        self.assertEqual(c, ["/usr/bin/vim", "test"])
+        self.assertEqual(c, [vim_executable, "test"])
 
+    @unittest.skipIf(nano_executable is None, "nano executable not found")
     def test_custom(self):
         with unittest.mock.patch.dict(os.environ, {"EDITOR": "nano"}):
             c = get_editor_command("test")
-        self.assertEqual(c, ["/usr/bin/nano", "test"])
+        self.assertEqual(c, [nano_executable, "test"])
 
     def test_custom_with_param(self):
         with unittest.mock.patch.dict(os.environ, {"EDITOR": "/usr/bin/emacs 
-nv"}):
@@ -33,11 +39,11 @@
             c = get_editor_command("test")
         self.assertEqual(c, ["/usr/bin/mycmd", "-A", "my fancy  parameter ", 
"test"])
 
+    def test_visual(self):
+        with unittest.mock.patch.dict(os.environ, { "VISUAL": 
"/usr/bin/emacs", "EDITOR": "/usr/bin/mycmd" }):
+            c = get_editor_command("test")
+        self.assertEqual(c, ["/usr/bin/emacs", "test"])
 
-if shutil.which("nano") != "/usr/bin/nano":
-    TestGitEditor = unittest.skip("nano is not /usr/bin/nano")(TestGitEditor)
-elif shutil.which("vim") != "/usr/bin/vim":
-    TestGitEditor = unittest.skip("vim is not /usr/bin/vim")(TestGitEditor)
 
 if __name__ == "__main__":
     unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/tests/test_gitea_api_conf.py 
new/osc-1.28.0/tests/test_gitea_api_conf.py
--- old/osc-1.27.3/tests/test_gitea_api_conf.py 1970-01-01 01:00:00.000000000 
+0100
+++ new/osc-1.28.0/tests/test_gitea_api_conf.py 2026-10-12 10:58:19.000000000 
+0200
@@ -0,0 +1,32 @@
+import os
+import unittest
+from unittest.mock import patch
+
+from osc.gitea_api.conf import Config
+
+
+class TestGiteaApiConfig(unittest.TestCase):
+    def test_xdg_config_home(self):
+        with patch.dict(os.environ, {"XDG_CONFIG_HOME": "/custom/config"}, 
clear=True):
+            self.assertEqual(Config().path, "/custom/config/tea/config.yml")
+
+    def test_default_config_home(self):
+        with patch.dict(os.environ, {}, clear=True):
+            self.assertEqual(Config().path, 
os.path.expanduser("~/.config/tea/config.yml"))
+
+    def test_empty_config_home(self):
+        with patch.dict(os.environ, {"XDG_CONFIG_HOME": ""}, clear=True):
+            self.assertEqual(Config().path, 
os.path.expanduser("~/.config/tea/config.yml"))
+
+    def test_relative_config_home(self):
+        with patch.dict(os.environ, {"XDG_CONFIG_HOME": "relative/config"}, 
clear=True):
+            with patch("os.getcwd", return_value="/worktree"):
+                self.assertEqual(Config().path, 
"/worktree/relative/config/tea/config.yml")
+
+    def test_tilde_config_home(self):
+        with patch.dict(os.environ, {"XDG_CONFIG_HOME": "~/foo"}, clear=True):
+            self.assertEqual(Config().path, 
os.path.expanduser("~/foo/tea/config.yml"))
+
+    def test_explicit_path_takes_precedence(self):
+        with patch.dict(os.environ, {"XDG_CONFIG_HOME": "/custom/config"}, 
clear=True):
+            self.assertEqual(Config("~/custom-tea.yml").path, 
os.path.expanduser("~/custom-tea.yml"))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/tests/test_gitea_api_tardiff.py 
new/osc-1.28.0/tests/test_gitea_api_tardiff.py
--- old/osc-1.27.3/tests/test_gitea_api_tardiff.py      1970-01-01 
01:00:00.000000000 +0100
+++ new/osc-1.28.0/tests/test_gitea_api_tardiff.py      2026-10-12 
10:58:19.000000000 +0200
@@ -0,0 +1,38 @@
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+from osc.gitea_api import TarDiff
+
+
[email protected](not shutil.which("git"), "The 'git' executable is not 
available")
+class TestGiteaApiTarDiff(unittest.TestCase):
+    def setUp(self):
+        self.tmpdir = tempfile.mkdtemp(prefix="osc_test_tardiff_")
+
+    def tearDown(self):
+        try:
+            shutil.rmtree(self.tmpdir)
+        except OSError:
+            pass
+
+    def test_init_recreates_stale_sha1_cache(self):
+        subprocess.check_call(["git", "init", "-q"], cwd=self.tmpdir)
+        with open(os.path.join(self.tmpdir, "stale.txt"), "w", 
encoding="utf-8") as f:
+            f.write("stale cache contents")
+
+        TarDiff(self.tmpdir)
+
+        object_format = subprocess.check_output(
+            ["git", "rev-parse", "--show-object-format"],
+            cwd=self.tmpdir,
+            encoding="utf-8",
+        ).strip()
+        self.assertEqual(object_format, "sha256")
+        self.assertFalse(os.path.exists(os.path.join(self.tmpdir, 
"stale.txt")))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/tests/test_request_interactive_review.py 
new/osc-1.28.0/tests/test_request_interactive_review.py
--- old/osc-1.27.3/tests/test_request_interactive_review.py     1970-01-01 
01:00:00.000000000 +0100
+++ new/osc-1.28.0/tests/test_request_interactive_review.py     2026-10-12 
10:58:19.000000000 +0200
@@ -0,0 +1,195 @@
+import io
+import os
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from unittest.mock import patch
+
+from osc import core
+
+from .common import OscTestCase
+
+
+class TestRequestInteractiveReview(unittest.TestCase):
+    apiurl = 'https://api.example.com'
+    results = b'''<resultlist>
+      <result project="source" repository="standard" arch="x86_64">
+        <status package="foo" code="succeeded"/>
+        <status package="foo:test" code="failed"/>
+        <status package="foo:disabled" code="disabled"/>
+        <status package="foo:excluded" code="excluded"/>
+      </result>
+      <result project="source" repository="standard" arch="aarch64">
+        <status package="foo:test" code="building"/>
+      </result>
+      <result project="source" repository="broken" arch="x86_64" 
code="broken"/>
+    </resultlist>'''
+
+    def setUp(self):
+        self.request = core.Request()
+        self.request.reqid = '123'
+        self.request.add_action('submit', src_project='source', 
src_package='foo',
+                                tgt_project='target', tgt_package='foo')
+        # Request rendering and comments are unrelated to log selection.
+        self.patchers = [
+            patch.object(core.Request, '__str__', return_value='request 123'),
+            patch.object(core, 'print_comments'),
+            patch.object(core, 'run_pager'),
+        ]
+        for patcher in self.patchers:
+            patcher.start()
+            self.addCleanup(patcher.stop)
+
+    def review(self, replies, **kwargs):
+        output = io.StringIO()
+        with patch.object(core, 'raw_input', side_effect=replies), \
+                redirect_stdout(output), redirect_stderr(io.StringIO()):
+            core.request_interactive_review(self.apiurl, self.request, 
**kwargs)
+        return output.getvalue()
+
+    def test_accept_message_template(self):
+        self.request.state = core.RequestState(core.xml_fromstring('<state 
name="new"/>'))
+        for message, expected in [('ok', 'ok'), (None, 'configured approval'), 
('', '')]:
+            with self.subTest(message=message), \
+                    patch.object(core, 'change_request_state_template', 
return_value='configured approval'), \
+                    patch.object(core, 'edit_message', return_value='edited 
approval') as editor, \
+                    patch.object(core, 'change_request_state', 
return_value=True) as change_state, \
+                    patch('osc._private.forward_request'):
+                self.review(['a'], message=message)
+                self.assertEqual(editor.call_args[1]['template'], expected)
+                self.assertEqual(change_state.call_args[0],
+                                 (self.apiurl, '123', 'accepted', 'edited 
approval'))
+
+    def test_inline_accept_message_overrides_default(self):
+        with patch.object(core, 'edit_message') as editor, \
+                patch.object(core, 'change_request_state', return_value=True) 
as change_state, \
+                patch('osc._private.forward_request'):
+            self.review(['a -m explicit approval'], message='ok')
+        editor.assert_not_called()
+        self.assertEqual(change_state.call_args[0],
+                         (self.apiurl, '123', 'accepted', 'explicit approval'))
+
+    def test_accept_message_does_not_override_decline_template(self):
+        self.request.state = core.RequestState(core.xml_fromstring('<state 
name="new"/>'))
+        with patch.object(core, 'change_request_state_template', 
return_value='decline reason'), \
+                patch.object(core, 'edit_message', return_value='edited 
reason') as editor, \
+                patch.object(core, 'change_request_state', return_value=True):
+            self.review(['d'], message='ok')
+        self.assertEqual(editor.call_args[1]['template'], 'decline reason')
+
+    def test_buildlog_flavor(self):
+        with patch.object(core, 'get_package_results', 
return_value=iter([self.results])) as results, \
+                patch.object(core, 'print_buildlog') as buildlog:
+            output = self.review(['bl', '1', 's'])
+        results.assert_called_once_with(self.apiurl, 'source', 'foo', 
multibuild=True)
+        self.assertEqual(buildlog.call_args[0],
+                         (self.apiurl, 'source', 'foo:test', 'standard', 
'x86_64'))
+        self.assertIn('(0) source/foo/standard/x86_64', output)
+        self.assertIn('(1) source/foo:test/standard/x86_64', output)
+        self.assertIn('(2) source/foo:test/standard/aarch64', output)
+        self.assertNotIn('foo:disabled', output)
+        self.assertNotIn('foo:excluded', output)
+        self.assertNotIn('/broken/', output)
+
+    def test_buildlog_base_package(self):
+        results_xml = b'''<resultlist><result repository="standard" 
arch="x86_64">
+          <status package="foo" code="succeeded"/>
+        </result></resultlist>'''
+        with patch.object(core, 'get_package_results', 
return_value=iter([results_xml])), \
+                patch.object(core, 'print_buildlog') as buildlog:
+            self.review(['bl', '0', 's'])
+        self.assertEqual(buildlog.call_args[0],
+                         (self.apiurl, 'source', 'foo', 'standard', 'x86_64'))
+
+    def test_rpmlint_flavor(self):
+        with patch.object(core, 'get_package_results', 
return_value=iter([self.results])), \
+                patch.object(core, 'get_rpmlint_log', return_value='lint log') 
as lintlog:
+            self.review(['li', '1', 's'])
+        lintlog.assert_called_once_with(self.apiurl, proj='source', 
pkg='foo:test',
+                                        repo='standard', arch='x86_64')
+
+    def test_empty_results(self):
+        with patch.object(core, 'get_package_results', 
return_value=iter([b'<resultlist/>'])), \
+                patch.object(core, 'print_buildlog') as buildlog:
+            output = self.review(['bl', 's'])
+        self.assertIn('No repos', output)
+        buildlog.assert_not_called()
+
+    def test_multiple_actions(self):
+        self.request.add_action('submit', src_project='other', 
src_package='bar',
+                                tgt_project='target', tgt_package='bar')
+        other_results = b'''<resultlist><result repository="ports" 
arch="aarch64">
+          <status package="bar:test" code="failed"/>
+        </result></resultlist>'''
+        with patch.object(core, 'get_package_results',
+                          side_effect=[iter([self.results]), 
iter([other_results])]), \
+                patch.object(core, 'print_buildlog') as buildlog:
+            self.review(['bl', '3', 's'])
+        self.assertEqual(buildlog.call_args[0],
+                         (self.apiurl, 'other', 'bar:test', 'ports', 
'aarch64'))
+
+    def test_missing_buildlog(self):
+        error = core.HTTPError('https://api.example.com', 404, 'Not Found', 
{}, None)
+        with patch.object(core, 'get_package_results', 
return_value=iter([self.results])), \
+                patch.object(core, 'print_buildlog', side_effect=error):
+            output = self.review(['bl', '1', 's'])
+        self.assertIn('No build log for standard/x86_64', output)
+
+    def test_buildstatus_includes_multibuild(self):
+        with patch.object(core, 'get_results', return_value=['foo:test 
failed']) as results:
+            output = self.review(['b', 's'])
+        results.assert_called_once_with(self.apiurl, 'source', 'foo', 
multibuild=True)
+        self.assertIn('foo:test failed', output)
+
+    def test_initial_buildstatus_includes_multibuild(self):
+        with patch.object(core, 'get_results', return_value=['foo:test 
failed']) as results:
+            output = self.review(['s'], source_buildstatus=True)
+        results.assert_called_once_with(self.apiurl, 'source', 'foo', 
multibuild=True)
+        self.assertIn('foo:test failed', output)
+
+
+class TestRequestInteractiveReviewCommandline(OscTestCase):
+    def _get_fixtures_dir(self):
+        return os.path.join(os.path.dirname(__file__), 'request_fixtures')
+
+    def setUp(self):
+        super().setUp(copytree=False)
+        self.request = core.Request()
+        self.request.reqid = '123'
+        self.request.state = core.RequestState(core.xml_fromstring('<state 
name="new"/>'))
+        self.request.add_action('submit', src_project='source', 
src_package='foo',
+                                tgt_project='target', tgt_package='foo')
+
+    def test_list_forwards_message(self):
+        for subcmd in ('request', 'review'):
+            for message in ('ok', '', None):
+                with self.subTest(subcmd=subcmd, message=message), \
+                        patch.object(core, 'get_request_collection', 
return_value=[self.request]), \
+                        patch.object(core, 'get_review_list', 
return_value=[self.request]), \
+                        patch.object(core, 'request_interactive_review') as 
review:
+                    args = [subcmd, 'list', '-i', '-U', 'Admin', '-D', '0']
+                    if message is not None:
+                        args.extend(['-m', message])
+                    self._run_osc(*args)
+                    review.assert_called_once_with('http://localhost', 
self.request, group=None,
+                                                   ignore_reviews=subcmd != 
'review',
+                                                   source_buildstatus=False, 
message=message)
+
+    def test_show_forwards_message(self):
+        for subcmd in ('request', 'review'):
+            with self.subTest(subcmd=subcmd), \
+                    patch.object(core, 'get_request', 
return_value=self.request), \
+                    patch.object(core, 'request_interactive_review') as review:
+                self._run_osc(subcmd, 'show', '-i', '--source-buildstatus', 
'-m', 'ok', '123')
+                review.assert_called_once_with('http://localhost', 
self.request, group=None,
+                                               ignore_reviews=subcmd != 
'review',
+                                               source_buildstatus=True, 
message='ok')
+
+    def test_edit_forwards_message(self):
+        with patch.object(core, 'get_request', return_value=self.request), \
+                patch.object(core, 'request_interactive_review') as review:
+            self._run_osc('request', 'show', '--edit', '-m', 'ok', '123')
+        review.assert_called_once_with('http://localhost', self.request, 'e', 
message='ok')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/osc-1.27.3/tests/test_store.py 
new/osc-1.28.0/tests/test_store.py
--- old/osc-1.27.3/tests/test_store.py  2026-08-03 16:54:23.000000000 +0200
+++ new/osc-1.28.0/tests/test_store.py  2026-10-12 10:58:19.000000000 +0200
@@ -1,4 +1,5 @@
 import os
+import shutil
 import sys
 import tempfile
 import unittest
@@ -18,10 +19,7 @@
         self.store.package = "package name"
 
     def tearDown(self):
-        try:
-            shutil.rmtree(self.tmpdir)
-        except:
-            pass
+        shutil.rmtree(self.tmpdir, ignore_errors=True)
 
     def fileEquals(self, fn, expected_value):
         path = os.path.join(self.tmpdir, ".osc", fn)
@@ -252,5 +250,58 @@
         self.assertTrue(os.path.exists(os.path.join(self.tmpdir, ".osc", 
"sources", "sources")))
 
 
+class TestGetStore(unittest.TestCase):
+    def setUp(self):
+        import shutil
+        self.tmpdir = tempfile.mkdtemp(prefix='osc_test')
+
+    def tearDown(self):
+        import shutil
+        shutil.rmtree(self.tmpdir, ignore_errors=True)
+
+    def _git_init(self, path, *, branch="master"):
+        import subprocess
+        os.makedirs(path, exist_ok=True)
+        cmd = ["git", "init"]
+        if branch:
+            cmd += ["-b", branch]
+        subprocess.check_call(cmd, cwd=path)
+        subprocess.check_call(["git", "config", "user.name", "Test User"], 
cwd=path)
+        subprocess.check_call(["git", "config", "user.email", 
"[email protected]"], cwd=path)
+
+    def test_get_store_generic_dir(self):
+        from osc.store import get_store
+        from osc import oscerr
+        with self.assertRaises(oscerr.NoWorkingCopy) as ctx:
+            get_store(self.tmpdir, check=True)
+        self.assertIn("is not a working copy", str(ctx.exception))
+
+    def test_get_store_git_branch(self):
+        from osc.store import get_store
+        from osc import oscerr
+        self._git_init(self.tmpdir, branch="master")
+        with self.assertRaises(oscerr.NoWorkingCopy) as ctx:
+            get_store(self.tmpdir, check=True)
+        # Should raise detailed metadata error because it's inside a git repo
+        self.assertIn("Git SCM package working copy doesn't have the following 
metadata set", str(ctx.exception))
+
+    def test_get_store_git_detached_head(self):
+        from osc.store import get_store
+        from osc import oscerr
+        import subprocess
+        self._git_init(self.tmpdir, branch="master")
+        # Create an initial commit to allow checkout
+        with open(os.path.join(self.tmpdir, "file.txt"), "w") as f:
+            f.write("test")
+        subprocess.check_call(["git", "add", "file.txt"], cwd=self.tmpdir)
+        subprocess.check_call(["git", "commit", "-m", "initial"], 
cwd=self.tmpdir)
+        # Detach HEAD
+        subprocess.check_call(["git", "checkout", "HEAD~0"], cwd=self.tmpdir)
+
+        with self.assertRaises(oscerr.NoWorkingCopy) as ctx:
+            get_store(self.tmpdir, check=True)
+        self.assertIn("detached HEAD state", str(ctx.exception))
+
+
 if __name__ == "__main__":
     unittest.main()

++++++ osc.dsc ++++++
--- /var/tmp/diff_new_pack.dfyYjT/_old  2026-10-12 22:07:34.737204729 +0200
+++ /var/tmp/diff_new_pack.dfyYjT/_new  2026-10-12 22:07:34.742204936 +0200
@@ -1,6 +1,6 @@
 Format: 1.0
 Source: osc
-Version: 1.27.3-0
+Version: 1.28.0-0
 Binary: osc
 Maintainer: Adrian Schroeter <[email protected]>
 Architecture: any

Reply via email to