uprev_lib: Add function to uprev cros-workon package to specific version

Add a new function `uprev_workon_ebuild_to_version` for use in PUPr
endpoints to uprev a cros-workon package while being able to control the
version of the stable ebuild that gets emitted.

This function differs from `uprev_ebuild_from_pin` in two significant ways:
1. It emits a proper stable cros-workon ebuild with derived CROS_WORKON
variables written out.
2. It doesn't require a stable ebuild to exist yet.

BUG=chromium:1139412, chromium:1071391
TEST=`run_pytest`

Change-Id: I31c7400bf81a060d513813d242cfa7e222b2c2f4
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/2481502
Tested-by: Chris McDonald <cjmcdonald@chromium.org>
Reviewed-by: Michael Mortensen <mmortensen@google.com>
Commit-Queue: Chris McDonald <cjmcdonald@chromium.org>
diff --git a/conftest.py b/conftest.py
index 2a426e9..b1281ab 100644
--- a/conftest.py
+++ b/conftest.py
@@ -76,6 +76,10 @@
   # pylint: disable=protected-access
   retry_stats._STATS_COLLECTION = None
 
+@pytest.fixture(autouse=True)
+def set_testing_environment_variable(monkeypatch):
+  """Set an environment marker to relax certain strict checks for test code."""
+  monkeypatch.setenv('CHROMITE_INSIDE_PYTEST', '1')
 
 @pytest.fixture
 def singleton_manager(monkeypatch):
diff --git a/lib/portage_util.py b/lib/portage_util.py
index fcdb335..ad8a2b0 100644
--- a/lib/portage_util.py
+++ b/lib/portage_util.py
@@ -835,8 +835,15 @@
 
     # See what git repo the ebuild lives in to make sure the ebuild isn't
     # tracking the same repo.  https://crbug.com/1050663
+    # We set strict=False if we're running under pytest because it's valid for
+    # ebuilds created in tests to not live in a git tree.
+    under_test = os.environ.get('CHROMITE_INSIDE_PYTEST') == '1'
     ebuild_git_tree = manifest.FindCheckoutFromPath(
-        self.ebuild_path).get('local_path')
+        self.ebuild_path, strict=not under_test)
+    if ebuild_git_tree:
+      ebuild_git_tree_path = ebuild_git_tree.get('local_path')
+    else:
+      ebuild_git_tree_path = None
 
     subdir_paths = []
     subtree_paths = []
@@ -871,7 +878,7 @@
                                                    real_project,
                                                    project))
 
-      if subdir_path == ebuild_git_tree:
+      if subdir_path == ebuild_git_tree_path:
         msg = ('%s: ebuilds may not live in & track the same source '
                'repository (%s); use the empty-project instead' %
                (self.ebuild_path, subdir_path))
diff --git a/lib/portage_util_unittest.py b/lib/portage_util_unittest.py
index 287db37..fd707f9 100644
--- a/lib/portage_util_unittest.py
+++ b/lib/portage_util_unittest.py
@@ -363,7 +363,7 @@
 
       raise Exception('unhandled path: %s' % path)
 
-    def _FindCheckoutFromPath(path):
+    def _FindCheckoutFromPath(path, strict=True): # pylint: disable=unused-argument
       """Mock function for manifest.FindCheckoutFromPath"""
       for project, localname in zip(fake_projects, fake_localnames):
         if path == os.path.join(self.tempdir, 'platform', localname):
diff --git a/lib/uprev_lib.py b/lib/uprev_lib.py
index 7ac6f62..5fb2868 100644
--- a/lib/uprev_lib.py
+++ b/lib/uprev_lib.py
@@ -20,6 +20,7 @@
 from chromite.lib import constants
 from chromite.lib import cros_build_lib
 from chromite.lib import cros_logging as logging
+from chromite.lib import git
 from chromite.lib import osutils
 from chromite.lib import parallel
 from chromite.lib import portage_util
@@ -718,3 +719,114 @@
                      stable_ebuild.ebuild_path,
                      manifest_src_path])
   return result
+
+
+def uprev_workon_ebuild_to_version(
+    package_path: Union[str, 'pathlib.Path'],
+    target_version: str,
+    chroot: Optional['chromite.lib.chroot_lib.Chroot'] = None,
+    *,
+    src_root: str = constants.SOURCE_ROOT,
+    chroot_src_root: str = constants.CHROOT_SOURCE_ROOT) -> UprevResult:
+  """Uprev a cros-workon ebuild to a specified version.
+
+  Args:
+    package_path: The path of the package relative to the src root. This path
+      should contain an unstable 9999 ebuild that inherits from cros-workon.
+    target_version: The version to use for the stable ebuild to be generated.
+      Should not contain a revision number.
+    chroot: The path to the chroot to enter, if not the default.
+    srcroot: Path to the root of the source checkout. Only override for testing.
+    chroot_src_root: Path to the root of the source checkout when inside the
+      chroot. Only override for testing.
+  """
+
+  package_path = str(package_path)
+  package = os.path.basename(package_path)
+
+  package_src_path = os.path.join(src_root, package_path)
+  ebuild_paths = list(portage_util.EBuild.List(package_src_path))
+  stable_ebuild = None
+  unstable_ebuild = None
+  for path in ebuild_paths:
+    ebuild = portage_util.EBuild(path)
+    if ebuild.is_stable:
+      stable_ebuild = ebuild
+    else:
+      unstable_ebuild = ebuild
+
+  outcome = None
+
+  if stable_ebuild is None:
+    outcome = outcome or Outcome.NEW_EBUILD_CREATED
+  if unstable_ebuild is None:
+    raise EbuildUprevError(f'No unstable ebuild found for {package}')
+  if len(ebuild_paths) > 2:
+    raise EbuildUprevError(f'Found too many ebuilds for {package}: '
+                           'expected one stable and one unstable')
+
+  if not unstable_ebuild.is_workon:
+    raise EbuildUprevError('A workon ebuild was expected '
+                           f'but {unstable_ebuild.ebuild_path} is not workon.')
+  # If the new version is the same as the old version, bump the revision number,
+  # otherwise reset it to 1
+  if stable_ebuild and target_version == stable_ebuild.version_no_rev:
+    output_version = f'{target_version}-r{stable_ebuild.current_revision + 1}'
+    outcome = outcome or Outcome.REVISION_BUMP
+  else:
+    output_version = f'{target_version}-r1'
+    outcome = outcome or Outcome.VERSION_BUMP
+
+  new_ebuild_path = os.path.join(package_path,
+                                 f'{package}-{output_version}.ebuild')
+  new_ebuild_src_path = os.path.join(src_root, new_ebuild_path)
+  manifest_src_path = os.path.join(package_src_path, 'Manifest')
+
+  # Go through the normal uprev process for a cros-workon ebuild, by calculating
+  # and writing out the commit & tree IDs for the projects and subtrees
+  # specified in the unstable ebuild.
+  manifest = git.ManifestCheckout.Cached(constants.SOURCE_ROOT)
+  info = unstable_ebuild.GetSourceInfo(
+      os.path.join(constants.SOURCE_ROOT, 'src'), manifest)
+  commit_ids = [unstable_ebuild.GetCommitId(x) for x in info.srcdirs]
+  if not commit_ids:
+    raise EbuildUprevError('No commit_ids found for %s' % info.srcdirs)
+
+  tree_ids = [unstable_ebuild.GetTreeId(x) for x in info.subtrees]
+  tree_ids = [tree_id for tree_id in tree_ids if tree_id]
+  if not tree_ids:
+    raise EbuildUprevError('No tree_ids found for %s' % info.subtrees)
+
+  variables = dict(
+      CROS_WORKON_COMMIT=unstable_ebuild.FormatBashArray(commit_ids),
+      CROS_WORKON_TREE=unstable_ebuild.FormatBashArray(tree_ids))
+
+  portage_util.EBuild.MarkAsStable(unstable_ebuild.ebuild_path,
+                                   new_ebuild_src_path, variables)
+
+  # If the newly generated stable ebuild is identical to the previous one,
+  # early return without incrementing the revision number.
+  if stable_ebuild and target_version == stable_ebuild.version_no_rev and \
+    filecmp.cmp(
+        new_ebuild_src_path, stable_ebuild.ebuild_path, shallow=False):
+    return UprevResult(outcome=Outcome.SAME_VERSION_EXISTS)
+
+  if stable_ebuild is not None:
+    osutils.SafeUnlink(stable_ebuild.ebuild_path)
+
+  try:
+    # UpdateEbuildManifest runs inside the chroot and therefore needs a
+    # chroot-relative path.
+    new_ebuild_chroot_path = os.path.join(chroot_src_root, new_ebuild_path)
+    portage_util.UpdateEbuildManifest(new_ebuild_chroot_path, chroot=chroot)
+  except cros_build_lib.RunCommandError as e:
+    raise EbuildManifestError(
+        f'Unable to update manifest for {package}: {e.stderr}')
+
+  result = UprevResult(
+      outcome=outcome, changed_files=[new_ebuild_src_path, manifest_src_path])
+
+  if stable_ebuild is not None:
+    result.changed_files.append(stable_ebuild.ebuild_path)
+
+  return result
diff --git a/lib/uprev_lib_unittest.py b/lib/uprev_lib_unittest.py
index 4d0687b..8a52549 100644
--- a/lib/uprev_lib_unittest.py
+++ b/lib/uprev_lib_unittest.py
@@ -9,9 +9,11 @@
 from __future__ import print_function
 
 import os
+import pathlib
 import sys
 
 import mock
+import pytest
 
 import chromite as cr
 from chromite.lib import constants
@@ -21,6 +23,7 @@
 from chromite.lib import uprev_lib
 from chromite.lib.build_target_lib import BuildTarget
 from chromite.lib.chroot_lib import Chroot
+from chromite.lib.parser import package_info
 
 assert sys.version_info >= (3, 6), 'This module requires Python 3.6+'
 
@@ -461,3 +464,68 @@
       'chromeos-base', 'chromeos-chrome', version=f'{NEW_CHROME_VERSION}_rc-r1')
 
   assert stable_chrome.cpv in overlay
+
+
+@pytest.mark.inside_only
+def test_non_workon_fails_uprev_workon_ebuild_to_version(overlay_stack):
+  overlay, = overlay_stack(1)
+  unstable_package = cr.test.Package(
+      'chromeos-base',
+      'test-package',
+      version='9999',
+      keywords='~*',
+  )
+
+  overlay.add_package(unstable_package)
+
+  with pytest.raises(uprev_lib.EbuildUprevError):
+    uprev_lib.uprev_workon_ebuild_to_version(
+        pathlib.Path(unstable_package.category) / unstable_package.package,
+        target_version='1',
+        chroot=None,
+        src_root=overlay.path,
+        chroot_src_root=overlay.path,
+    )
+
+  stable_package = package_info.PackageInfo(
+      'chromeos-base',
+      'test-package',
+      version='1',
+      revision='1',
+  )
+
+  assert not stable_package in overlay
+
+
+@pytest.mark.inside_only
+def test_simple_uprev_workon_ebuild_to_version(overlay_stack):
+  overlay, = overlay_stack(1)
+  unstable_package = cr.test.Package(
+      'chromeos-base',
+      'test-package',
+      version='9999',
+      keywords='~*',
+      inherit='cros-workon',
+      CROS_WORKON_PROJECT='chromiumos/infra/build/empty-project',
+      CROS_WORKON_LOCALNAME='empty-project')
+
+  overlay.add_package(unstable_package)
+
+  res = uprev_lib.uprev_workon_ebuild_to_version(
+      pathlib.Path(unstable_package.category) / unstable_package.package,
+      target_version='1',
+      chroot=None,
+      src_root=overlay.path,
+      chroot_src_root=overlay.path,
+  )
+
+  assert res.outcome is uprev_lib.Outcome.NEW_EBUILD_CREATED
+
+  stable_package = package_info.PackageInfo(
+      'chromeos-base',
+      'test-package',
+      version='1',
+      revision='1',
+  )
+
+  assert stable_package in overlay