← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~jugmac00/launchpad:create-lpcraft-jobs-on-push into launchpad:master

 

Jürgen Gmach has proposed merging ~jugmac00/launchpad:create-lpcraft-jobs-on-push into launchpad:master.

Commit message:
WIP: Create lpcraft jobs on push

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~jugmac00/launchpad/+git/launchpad/+merge/416223
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~jugmac00/launchpad:create-lpcraft-jobs-on-push into launchpad:master.
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
index 4537b1c..e1abcf8 100644
--- a/lib/lp/code/interfaces/cibuild.py
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -6,11 +6,16 @@
 __all__ = [
     "CannotFetchConfiguration",
     "CannotParseConfiguration",
+    "CIBuildAlreadyRequested",
+    "CIBuildDisallowedArchitecture",
     "ICIBuild",
     "ICIBuildSet",
     "MissingConfiguration",
     ]
 
+import http.client
+
+from lazr.restful.declarations import error_status
 from lazr.restful.fields import Reference
 from zope.schema import (
     Bool,
@@ -53,6 +58,26 @@ class CannotParseConfiguration(Exception):
     """Launchpad cannot parse this CI build's .launchpad.yaml."""
 
 
+@error_status(http.client.BAD_REQUEST)
+class CIBuildDisallowedArchitecture(Exception):
+    """A build was requested for a disallowed architecture."""
+
+    def __init__(self, das, pocket):
+        super().__init__(
+            "Builds for %s/%s are not allowed." % (
+                das.distroseries.getSuite(pocket), das.architecturetag)
+            )
+
+
+@error_status(http.client.BAD_REQUEST)
+class CIBuildAlreadyRequested(Exception):
+    """An identical build was requested more than once."""
+
+    def __init__(self):
+        super().__init__(
+            "An identical build for this commit was already requested.")
+
+
 class ICIBuildView(IPackageBuildView):
     """`ICIBuild` attributes that require launchpad.View."""
 
@@ -133,6 +158,37 @@ class ICIBuildSet(ISpecificBuildFarmJobSource):
             these Git commit IDs.
         """
 
+    def requestBuild(git_repository, commit_sha1, distro_arch_series):
+        """Request a CI build.
+
+        This checks that the architecture is allowed and that there isn't
+        already a matching pending build.
+
+        :param git_repository: The `IGitRepository` for the new build.
+        :param commit_sha1: The Git commit ID for the new build.
+        :param distro_arch_series: The `IDistroArchSeries` that the new
+            build should run on.
+        :raises CIBuildDisallowedArchitecture: if builds on
+            `distro_arch_series` are not allowed.
+        :raises CIBuildAlreadyRequested: if a matching build was already
+            requested.
+        :return: `ICIBuild`.
+        """
+
+    def requestBuildsForRefs(git_repository, ref_paths, logger=None):
+        """Request CI builds for a collection of refs.
+
+        This fetches `.launchpad.yaml` from the repository and parses it to
+        work out which series/architectures need builds.
+
+        :param git_repository: The `IGitRepository` for which to request
+            builds.
+        :param ref_paths: A collection of Git reference paths within
+            `git_repository`; builds will be requested for the commits that
+            each of them points to.
+        :param logger: An optional logger.
+        """
+
     def deleteByGitRepository(git_repository):
         """Delete all CI builds for the given Git repository.
 
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
index f9c9031..2407aef 100644
--- a/lib/lp/code/model/cibuild.py
+++ b/lib/lp/code/model/cibuild.py
@@ -9,6 +9,7 @@ __all__ = [
 
 from datetime import timedelta
 
+from lazr.lifecycle.event import ObjectCreatedEvent
 import pytz
 from storm.locals import (
     Bool,
@@ -21,8 +22,11 @@ from storm.locals import (
     )
 from storm.store import EmptyResultSet
 from zope.component import getUtility
+from zope.event import notify
 from zope.interface import implementer
 
+from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.buildmaster.enums import (
     BuildFarmJobType,
     BuildQueueStatus,
@@ -38,10 +42,14 @@ from lp.code.errors import (
 from lp.code.interfaces.cibuild import (
     CannotFetchConfiguration,
     CannotParseConfiguration,
+    CIBuildAlreadyRequested,
+    CIBuildDisallowedArchitecture,
     ICIBuild,
     ICIBuildSet,
     MissingConfiguration,
     )
+from lp.code.interfaces.githosting import IGitHostingClient
+from lp.code.model.gitref import GitRef
 from lp.code.model.lpcraft import load_configuration
 from lp.registry.interfaces.pocket import PackagePublishingPocket
 from lp.registry.interfaces.series import SeriesStatus
@@ -65,6 +73,53 @@ from lp.services.propertycache import cachedproperty
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
+def determine_DASes_to_build(configuration, logger=None):
+    """Generate distroarchseries to build for this configuration."""
+    architectures_by_series = {}
+    for stage in configuration.pipeline:
+        for job_name in stage:
+            if job_name not in configuration.jobs:
+                if logger is not None:
+                    logger.error("No job definition for %r", job_name)
+                continue
+            for job in configuration.jobs[job_name]:
+                for architecture in job["architectures"]:
+                    architectures_by_series.setdefault(
+                        job["series"], set()).add(architecture)
+    # XXX cjwatson 2022-01-21: We have to hardcode Ubuntu for now, since
+    # the .launchpad.yaml format doesn't currently support other
+    # distributions (although nor does the Launchpad build farm).
+    distribution = getUtility(ILaunchpadCelebrities).ubuntu
+    for series_name, architecture_names in architectures_by_series.items():
+        try:
+            series = distribution[series_name]
+        except NotFoundError:
+            if logger is not None:
+                logger.error("Unknown Ubuntu series name %s" % series_name)
+            continue
+        architectures = {
+            das.architecturetag: das
+            for das in series.buildable_architectures}
+        for architecture_name in architecture_names:
+            try:
+                das = architectures[architecture_name]
+            except KeyError:
+                if logger is not None:
+                    logger.error(
+                        "%s is not a buildable architecture name in "
+                        "Ubuntu %s" % (architecture_name, series_name))
+                continue
+            yield das
+
+
+def get_all_commits_for_paths(git_repository, paths):
+    return [
+        ref.commit_sha1
+        for ref in GitRef.findByReposAndPaths(
+            [(git_repository, ref_path)
+                for ref_path in paths]).values()]
+
+
 def parse_configuration(git_repository, blob):
     try:
         return load_configuration(blob)
@@ -329,6 +384,89 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
         store.flush()
         return cibuild
 
+    def findByGitRepository(self, git_repository, commit_sha1s=None):
+        """See `ICIBuildSet`."""
+        clauses = [CIBuild.git_repository == git_repository]
+        if commit_sha1s is not None:
+            clauses.append(CIBuild.commit_sha1.is_in(commit_sha1s))
+        return IStore(CIBuild).find(CIBuild, *clauses)
+
+    def _isBuildableArchitectureAllowed(self, das):
+        """Check whether we may build for a buildable `DistroArchSeries`.
+
+        The caller is assumed to have already checked that a suitable chroot
+        is available (either directly or via
+        `DistroSeries.buildable_architectures`).
+        """
+        return (
+            das.enabled
+            # We only support builds on virtualized builders at the moment.
+            and das.processor.supports_virtualized)
+
+    def _isArchitectureAllowed(self, das, pocket, snap_base=None):
+        return (
+            das.getChroot(pocket=pocket) is not None
+            and self._isBuildableArchitectureAllowed(das))
+
+    def requestBuild(self, git_repository, commit_sha1, distro_arch_series):
+        """See `ICIBuildSet`."""
+        pocket = PackagePublishingPocket.UPDATES
+        if not self._isArchitectureAllowed(distro_arch_series, pocket):
+            raise CIBuildDisallowedArchitecture(distro_arch_series, pocket)
+
+        result = IStore(CIBuild).find(
+            CIBuild,
+            CIBuild.git_repository == git_repository,
+            CIBuild.commit_sha1 == commit_sha1,
+            CIBuild.distro_arch_series == distro_arch_series)
+        if not result.is_empty():
+            raise CIBuildAlreadyRequested
+
+        build = self.new(git_repository, commit_sha1, distro_arch_series)
+        build.queueBuild()
+        notify(ObjectCreatedEvent(build))
+        return build
+
+    def requestBuildsForRefs(self, git_repository, ref_paths, logger=None):
+        """See `ICIBuildSet`."""
+        commit_sha1s = get_all_commits_for_paths(git_repository, ref_paths)
+        # getCommits performs a web request!
+        commits = getUtility(IGitHostingClient).getCommits(
+            git_repository.getInternalPath(), commit_sha1s,
+            # XXX cjwatson 2022-01-19: We should also fetch
+            # debian/.launchpad.yaml (or perhaps make the path a property of
+            # the repository) once lpcraft and launchpad-buildd support
+            # using alternative paths for builds.
+            filter_paths=[".launchpad.yaml"])
+        for commit in commits:
+            try:
+                configuration = parse_configuration(
+                    git_repository, commit["blobs"][".launchpad.yaml"])
+            except CannotParseConfiguration as e:
+                if logger is not None:
+                    logger.error(e)
+                continue
+            for das in determine_DASes_to_build(configuration):
+                self._tryToRequestBuild(
+                    git_repository, commit["sha1"], das,  logger)
+
+    def _tryToRequestBuild(self, git_repository, commit_sha1, das, logger):
+        try:
+            if logger is not None:
+                logger.info(
+                    "Requesting CI build for %s on %s/%s",
+                    commit_sha1, das.distroseries.name, das.architecturetag,
+                )
+            self.requestBuild(git_repository, commit_sha1, das)
+        except CIBuildAlreadyRequested:
+            pass
+        except Exception as e:
+            if logger is not None:
+                logger.error(
+                    "Failed to request CI build for %s on %s/%s: %s",
+                    commit_sha1, das.distroseries.name, das.architecturetag, e
+                )
+
     def getByID(self, build_id):
         """See `ISpecificBuildFarmJobSource`."""
         store = IMasterStore(CIBuild)
@@ -357,13 +495,6 @@ class CIBuildSet(SpecificBuildFarmJobSourceMixin):
                 bfj.id for bfj in build_farm_jobs))
         return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
 
-    def findByGitRepository(self, git_repository, commit_sha1s=None):
-        """See `ICIBuildSet`."""
-        clauses = [CIBuild.git_repository == git_repository]
-        if commit_sha1s is not None:
-            clauses.append(CIBuild.commit_sha1.is_in(commit_sha1s))
-        return IStore(CIBuild).find(CIBuild, *clauses)
-
     def deleteByGitRepository(self, git_repository):
         """See `ICIBuildSet`."""
         self.findByGitRepository(git_repository).remove()
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
index 133bed2..c8e9721 100644
--- a/lib/lp/code/model/tests/test_cibuild.py
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -7,9 +7,13 @@ from datetime import (
     datetime,
     timedelta,
     )
+import hashlib
 from textwrap import dedent
+from unittest.mock import Mock
 
+from fixtures import MockPatchObject
 import pytz
+from storm.locals import Store
 from testtools.matchers import (
     Equals,
     MatchesStructure,
@@ -18,9 +22,14 @@ from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.enums import InformationType
-from lp.buildmaster.enums import BuildStatus
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.buildmaster.enums import (
+    BuildQueueStatus,
+    BuildStatus,
+    )
 from lp.buildmaster.interfaces.buildqueue import IBuildQueue
 from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.model.buildqueue import BuildQueue
 from lp.code.errors import (
     GitRepositoryBlobNotFound,
     GitRepositoryScanFault,
@@ -28,12 +37,20 @@ from lp.code.errors import (
 from lp.code.interfaces.cibuild import (
     CannotFetchConfiguration,
     CannotParseConfiguration,
+    CIBuildAlreadyRequested,
+    CIBuildDisallowedArchitecture,
     ICIBuild,
     ICIBuildSet,
     MissingConfiguration,
     )
+from lp.code.model.cibuild import (
+    determine_DASes_to_build,
+    get_all_commits_for_paths,
+    )
+from lp.code.model.lpcraft import load_configuration
 from lp.code.tests.helpers import GitHostingFixture
 from lp.registry.interfaces.series import SeriesStatus
+from lp.services.log.logger import BufferLogger
 from lp.services.propertycache import clear_property_cache
 from lp.testing import (
     person_logged_in,
@@ -336,6 +353,336 @@ class TestCIBuildSet(TestCaseWithFactory):
         self.assertContentEqual(
             builds[2:], ci_build_set.findByGitRepository(repositories[1]))
 
+    def test_requestCIBuild(self):
+        # requestBuild creates a new CIBuild.
+        repository = self.factory.makeGitRepository()
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        das = self.factory.makeBuildableDistroArchSeries()
+
+        build = getUtility(ICIBuildSet).requestBuild(
+            repository, commit_sha1, das)
+
+        self.assertTrue(ICIBuild.providedBy(build))
+        self.assertThat(build, MatchesStructure.byEquality(
+            git_repository=repository,
+            commit_sha1=commit_sha1,
+            distro_arch_series=das,
+            status=BuildStatus.NEEDSBUILD,
+            ))
+        store = Store.of(build)
+        store.flush()
+        build_queue = store.find(
+            BuildQueue,
+            BuildQueue._build_farm_job_id ==
+                removeSecurityProxy(build).build_farm_job_id).one()
+        self.assertProvides(build_queue, IBuildQueue)
+        self.assertTrue(build_queue.virtualized)
+        self.assertEqual(BuildQueueStatus.WAITING, build_queue.status)
+
+    def test_requestBuild_score(self):
+        # CI builds have an initial queue score of 2600.
+        repository = self.factory.makeGitRepository()
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        das = self.factory.makeBuildableDistroArchSeries()
+        build = getUtility(ICIBuildSet).requestBuild(
+            repository, commit_sha1, das)
+        queue_record = build.buildqueue_record
+        queue_record.score()
+        self.assertEqual(2600, queue_record.lastscore)
+
+    def test_requestBuild_rejects_repeats(self):
+        # requestBuild refuses if an identical build was already requested.
+        repository = self.factory.makeGitRepository()
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        distro_series = self.factory.makeDistroSeries()
+        arches = [
+            self.factory.makeBuildableDistroArchSeries(
+                distroseries=distro_series)
+            for _ in range(2)]
+        old_build = getUtility(ICIBuildSet).requestBuild(
+            repository, commit_sha1, arches[0])
+        self.assertRaises(
+            CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild,
+            repository, commit_sha1, arches[0])
+        # We can build for a different distroarchseries.
+        getUtility(ICIBuildSet).requestBuild(
+            repository, commit_sha1, arches[1])
+        # Changing the status of the old build does not allow a new build.
+        old_build.updateStatus(BuildStatus.BUILDING)
+        old_build.updateStatus(BuildStatus.FULLYBUILT)
+        self.assertRaises(
+            CIBuildAlreadyRequested, getUtility(ICIBuildSet).requestBuild,
+            repository, commit_sha1, arches[0])
+
+    def test_requestBuild_virtualization(self):
+        # New builds are virtualized.
+        repository = self.factory.makeGitRepository()
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        distro_series = self.factory.makeDistroSeries()
+        for proc_nonvirt in True, False:
+            das = self.factory.makeBuildableDistroArchSeries(
+                distroseries=distro_series, supports_virtualized=True,
+                supports_nonvirtualized=proc_nonvirt)
+            build = getUtility(ICIBuildSet).requestBuild(
+                repository, commit_sha1, das)
+            self.assertTrue(build.virtualized)
+
+    def test_requestBuild_nonvirtualized(self):
+        # A non-virtualized processor cannot run a CI build.
+        repository = self.factory.makeGitRepository()
+        commit_sha1 = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        distro_series = self.factory.makeDistroSeries()
+        das = self.factory.makeBuildableDistroArchSeries(
+            distroseries=distro_series, supports_virtualized=False,
+            supports_nonvirtualized=True)
+        self.assertRaises(
+            CIBuildDisallowedArchitecture,
+            getUtility(ICIBuildSet).requestBuild, repository, commit_sha1, das)
+
+    def test_requestBuildsForRefs_triggers_builds(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        series = self.factory.makeDistroSeries(
+            distribution=ubuntu,
+            name="focal",
+        )
+        self.factory.makeBuildableDistroArchSeries(
+            distroseries=series,
+            architecturetag="amd64"
+        )
+        configuration = dedent("""\
+            pipeline:
+            - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: echo hello world >output
+            """).encode()
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        [ref] = self.factory.makeGitRefs(repository, ref_paths)
+        encoded_commit_json = {
+            "sha1": ref.commit_sha1,
+            "blobs": {".launchpad.yaml": configuration},
+        }
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[encoded_commit_json])
+        )
+
+        getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
+
+        self.assertEqual(
+            [((repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"]})],
+            hosting_fixture.getCommits.calls
+        )
+
+        build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
+
+        # check that a build was created
+        self.assertEqual(ref.commit_sha1, build.commit_sha1)
+        self.assertEqual("focal", build.distro_arch_series.distroseries.name)
+        self.assertEqual("amd64", build.distro_arch_series.architecturetag)
+
+    def test_requestBuildsForRefs_no_commits_at_all(self):
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        hosting_fixture = self.useFixture(GitHostingFixture(commits=[]))
+
+        getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
+
+        self.assertEqual(
+            [((repository.getInternalPath(), []),
+              {"filter_paths": [".launchpad.yaml"]})],
+            hosting_fixture.getCommits.calls
+        )
+
+        build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
+
+        self.assertIsNone(build)
+
+    def test_requestBuildsForRefs_no_matching_commits(self):
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        [ref] = self.factory.makeGitRefs(repository, ref_paths)
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[])
+        )
+
+        getUtility(ICIBuildSet).requestBuildsForRefs(repository, ref_paths)
+
+        self.assertEqual(
+            [((repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"]})],
+            hosting_fixture.getCommits.calls
+        )
+
+        build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
+
+        self.assertIsNone(build)
+
+    def test_requestBuildsForRefs_configuration_parse_error(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        series = self.factory.makeDistroSeries(
+            distribution=ubuntu,
+            name="focal",
+        )
+        self.factory.makeBuildableDistroArchSeries(
+            distroseries=series,
+            architecturetag="amd64"
+        )
+        configuration = dedent("""\
+            no - valid - configuration - file
+            """).encode()
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        [ref] = self.factory.makeGitRefs(repository, ref_paths)
+        encoded_commit_json = {
+            "sha1": ref.commit_sha1,
+            "blobs": {".launchpad.yaml": configuration},
+        }
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[encoded_commit_json])
+        )
+        logger = BufferLogger()
+
+        getUtility(ICIBuildSet).requestBuildsForRefs(
+            repository, ref_paths, logger)
+
+        self.assertEqual(
+            [((repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"]})],
+            hosting_fixture.getCommits.calls
+        )
+
+        build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
+
+        self.assertIsNone(build)
+
+        self.assertEqual(
+            "ERROR Cannot parse .launchpad.yaml from %s: "
+            "Configuration file does not declare 'pipeline'\n" % (
+                repository.unique_name,),
+            logger.getLogBuffer()
+        )
+
+    def test_requestBuildsForRefs_build_already_scheduled(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        series = self.factory.makeDistroSeries(
+            distribution=ubuntu,
+            name="focal",
+        )
+        self.factory.makeBuildableDistroArchSeries(
+            distroseries=series,
+            architecturetag="amd64"
+        )
+        configuration = dedent("""\
+            pipeline:
+            - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: echo hello world >output
+            """).encode()
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        [ref] = self.factory.makeGitRefs(repository, ref_paths)
+        encoded_commit_json = {
+            "sha1": ref.commit_sha1,
+            "blobs": {".launchpad.yaml": configuration},
+        }
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[encoded_commit_json])
+        )
+        build_set = removeSecurityProxy(getUtility(ICIBuildSet))
+        mock = Mock(side_effect=CIBuildAlreadyRequested)
+        self.useFixture(MockPatchObject(build_set, "requestBuild", mock))
+        logger = BufferLogger()
+
+        build_set.requestBuildsForRefs(repository, ref_paths, logger)
+
+        self.assertEqual(
+            [((repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"]})],
+            hosting_fixture.getCommits.calls
+        )
+
+        build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
+
+        self.assertIsNone(build)
+
+        # XXX jugmac00 2022-03-04
+        # for unknown reasons, the logger output starts with a backslash:
+        #
+        # actual    = '''\
+        # INFO Requesting CI build for  972c6d2dc6dd5efdad1377c0d224e03eb8f276f7 on focal/amd64  # noqa: E501
+        # '''
+        self.assertIn(
+            "INFO Requesting CI build for %s on focal/amd64" % ref.commit_sha1,
+            logger.getLogBuffer()
+        )
+
+    def test_requestBuildsForRefs_unexpected_exception(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        series = self.factory.makeDistroSeries(
+            distribution=ubuntu,
+            name="focal",
+        )
+        self.factory.makeBuildableDistroArchSeries(
+            distroseries=series,
+            architecturetag="amd64"
+        )
+        configuration = dedent("""\
+            pipeline:
+            - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: echo hello world >output
+            """).encode()
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        [ref] = self.factory.makeGitRefs(repository, ref_paths)
+        encoded_commit_json = {
+            "sha1": ref.commit_sha1,
+            "blobs": {".launchpad.yaml": configuration},
+        }
+        hosting_fixture = self.useFixture(
+            GitHostingFixture(commits=[encoded_commit_json])
+        )
+        build_set = removeSecurityProxy(getUtility(ICIBuildSet))
+        mock = Mock(side_effect=Exception("some unexpected error"))
+        self.useFixture(MockPatchObject(build_set, "requestBuild", mock))
+        logger = BufferLogger()
+
+        build_set.requestBuildsForRefs(repository, ref_paths, logger)
+
+        self.assertEqual(
+            [((repository.getInternalPath(), [ref.commit_sha1]),
+              {"filter_paths": [".launchpad.yaml"]})],
+            hosting_fixture.getCommits.calls
+        )
+
+        build = getUtility(ICIBuildSet).findByGitRepository(repository).one()
+
+        self.assertIsNone(build)
+
+        # last line is an empty string
+        log_line1, log_line2, _ = logger.getLogBuffer().split("\n")
+        self.assertEqual(
+            "INFO Requesting CI build for %s on focal/amd64" % ref.commit_sha1,
+            log_line1)
+        self.assertEqual(
+            "ERROR Failed to request CI build for %s on focal/amd64: "
+            "some unexpected error" % (ref.commit_sha1,),
+            log_line2
+        )
+
     def test_deleteByGitRepository(self):
         repositories = [self.factory.makeGitRepository() for _ in range(2)]
         builds = []
@@ -350,3 +697,157 @@ class TestCIBuildSet(TestCaseWithFactory):
             [], ci_build_set.findByGitRepository(repositories[0]))
         self.assertContentEqual(
             builds[2:], ci_build_set.findByGitRepository(repositories[1]))
+
+
+class TestCIBuildHelpers(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_determine_DASes_to_build(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distro_serieses = [
+            self.factory.makeDistroSeries(ubuntu) for _ in range(2)]
+        dases = []
+        for distro_series in distro_serieses:
+            for _ in range(2):
+                dases.append(self.factory.makeBuildableDistroArchSeries(
+                    distroseries=distro_series))
+        configuration = load_configuration(dedent("""\
+            pipeline:
+                - [build]
+                - [test]
+            jobs:
+                build:
+                    series: {distro_serieses[1].name}
+                    architectures:
+                        - {dases[2].architecturetag}
+                        - {dases[3].architecturetag}
+                test:
+                    series: {distro_serieses[1].name}
+                    architectures:
+                        - {dases[2].architecturetag}
+            """.format(distro_serieses=distro_serieses, dases=dases)))
+        logger = BufferLogger()
+
+        dases_to_build = list(
+            determine_DASes_to_build(configuration, logger=logger))
+
+        self.assertContentEqual(dases[2:], dases_to_build)
+        self.assertEqual("", logger.getLogBuffer())
+
+
+    def test_determine_DASes_to_build_logs_missing_job_definition(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distro_series = self.factory.makeDistroSeries(ubuntu)
+        das = self.factory.makeBuildableDistroArchSeries(
+            distroseries=distro_series)
+        configuration = load_configuration(dedent("""\
+            pipeline:
+                - [test]
+            jobs:
+                build:
+                    series: {distro_series.name}
+                    architectures:
+                        - {das.architecturetag}
+            """.format(distro_series=distro_series, das=das)))
+        logger = BufferLogger()
+
+        dases_to_build = list(
+            determine_DASes_to_build(configuration, logger=logger))
+
+        self.assertEqual(0, len(dases_to_build))
+        self.assertEqual(
+            "ERROR No job definition for 'test'\n", logger.getLogBuffer()
+        )
+
+
+    def test_determine_DASes_to_build_logs_missing_series(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distro_series = self.factory.makeDistroSeries(ubuntu)
+        das = self.factory.makeBuildableDistroArchSeries(
+            distroseries=distro_series)
+        configuration = load_configuration(dedent("""\
+            pipeline:
+                - [build]
+            jobs:
+                build:
+                    series: unknown-series
+                    architectures:
+                        - {das.architecturetag}
+            """.format(das=das)))
+        logger = BufferLogger()
+
+        dases_to_build = list(
+            determine_DASes_to_build(configuration, logger=logger))
+
+        self.assertEqual(0, len(dases_to_build))
+        self.assertEqual(
+            "ERROR Unknown Ubuntu series name unknown-series\n",
+            logger.getLogBuffer()
+        )
+
+
+    def test_determine_DASes_to_build_logs_non_buildable_architecture(self):
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distro_series = self.factory.makeDistroSeries(ubuntu)
+        configuration = load_configuration(dedent("""\
+            pipeline:
+                - [build]
+            jobs:
+                build:
+                    series: {distro_series.name}
+                    architectures:
+                        - non-buildable-architecture
+            """.format(distro_series=distro_series)))
+        logger = BufferLogger()
+
+        dases_to_build = list(
+            determine_DASes_to_build(configuration, logger=logger))
+
+        self.assertEqual(0, len(dases_to_build))
+        # XXX jugmac00 2022-03-08
+        # for unknown reasons, the logger output starts with a backslash:
+        # actual    = '''\
+        # ERROR non-buildable-architecture is not a buildable architecture name in Ubuntu distroseries-100005  # noqa: E501
+        # '''
+        self.assertIn(
+            "ERROR non-buildable-architecture is not a buildable architecture "
+            "name in Ubuntu %s" % distro_series.name,
+            logger.getLogBuffer()
+        )
+
+
+
+class TestGetAllCommitsForPaths(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_no_refs(self):
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+
+        rv = get_all_commits_for_paths(repository, ref_paths)
+
+        self.assertEqual([], rv)
+
+    def test_one_ref_one_path(self):
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master']
+        [ref] = self.factory.makeGitRefs(repository, ref_paths)
+
+        rv = get_all_commits_for_paths(repository, ref_paths)
+
+        self.assertEqual(1, len(rv))
+        self.assertEqual(ref.commit_sha1, rv[0])
+
+    def test_multiple_refs_and_paths(self):
+        # XXX jugmac00 2022-03-04
+        # this test possibly should have multiple commits per path
+        repository = self.factory.makeGitRepository()
+        ref_paths = ['refs/heads/master', "refs/heads/dev"]
+        refs = self.factory.makeGitRefs(repository, ref_paths)
+
+        rv = get_all_commits_for_paths(repository, ref_paths)
+
+        self.assertEqual(2, len(rv))
+        self.assertEqual({ref.commit_sha1 for ref in refs}, set(rv))
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 3272b3b..bc17444 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -11,6 +11,7 @@ import email
 from functools import partial
 import hashlib
 import json
+from textwrap import dedent
 
 from breezy import urlutils
 from fixtures import MockPatch
@@ -81,6 +82,10 @@ from lp.code.event.git import GitRefsUpdatedEvent
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES as FINAL_STATES,
     )
+from lp.code.interfaces.cibuild import (
+    ICIBuild,
+    ICIBuildSet,
+    )
 from lp.code.interfaces.codeimport import ICodeImportSet
 from lp.code.interfaces.defaultgit import ICanHasDefaultGitRepository
 from lp.code.interfaces.gitjob import (
@@ -161,6 +166,7 @@ from lp.services.identity.interfaces.account import AccountStatus
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.model.job import Job
 from lp.services.job.runner import JobRunner
+from lp.services.log.logger import BufferLogger
 from lp.services.macaroons.interfaces import IMacaroonIssuer
 from lp.services.macaroons.testing import (
     find_caveats_by_name,
@@ -1461,6 +1467,7 @@ class TestGitRepositoryModifications(TestCaseWithFactory):
             repository, "date_last_modified", UTC_NOW)
 
     def test_create_ref_sets_date_last_modified(self):
+        self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository(
             date_created=datetime(2015, 6, 1, tzinfo=pytz.UTC))
         [ref] = self.factory.makeGitRefs(repository=repository)
@@ -1869,6 +1876,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
             repository.refs, repository, ["refs/heads/master"])
 
     def test_update(self):
+        self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository()
         paths = ("refs/heads/master", "refs/tags/1.0")
         self.factory.makeGitRefs(repository=repository, paths=paths)
@@ -1900,6 +1908,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
         return [UpdatePreviewDiffJob(job) for job in jobs]
 
     def test_update_schedules_diff_update(self):
+        self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository()
         [ref] = self.factory.makeGitRefs(repository=repository)
         self.assertRefsMatch(repository.refs, repository, [ref.path])
@@ -2210,6 +2219,7 @@ class TestGitRepositoryRefs(TestCaseWithFactory):
     def test_synchroniseRefs(self):
         # synchroniseRefs copes with synchronising a repository where some
         # refs have been created, some deleted, and some changed.
+        self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository()
         paths = ("refs/heads/master", "refs/heads/foo", "refs/heads/bar")
         self.factory.makeGitRefs(repository=repository, paths=paths)
@@ -2958,6 +2968,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
 
     def test_base_repository_recipe(self):
         # On ref changes, recipes where this ref is the base become stale.
+        self.useFixture(GitHostingFixture())
         [ref] = self.factory.makeGitRefs()
         recipe = self.factory.makeSourcePackageRecipe(branches=[ref])
         removeSecurityProxy(recipe).is_stale = False
@@ -2968,6 +2979,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
     def test_base_repository_different_ref_recipe(self):
         # On ref changes, recipes where a different ref in the same
         # repository is the base are left alone.
+        self.useFixture(GitHostingFixture())
         ref1, ref2 = self.factory.makeGitRefs(
             paths=["refs/heads/a", "refs/heads/b"])
         recipe = self.factory.makeSourcePackageRecipe(branches=[ref1])
@@ -2979,6 +2991,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
     def test_base_repository_default_branch_recipe(self):
         # On ref changes to the default branch, recipes where this
         # repository is the base with no explicit revspec become stale.
+        self.useFixture(GitHostingFixture())
         repository = self.factory.makeGitRepository()
         ref1, ref2 = self.factory.makeGitRefs(
             repository=repository, paths=["refs/heads/a", "refs/heads/b"])
@@ -2994,6 +3007,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
 
     def test_instruction_repository_recipe(self):
         # On ref changes, recipes including this ref become stale.
+        self.useFixture(GitHostingFixture())
         [base_ref] = self.factory.makeGitRefs()
         [ref] = self.factory.makeGitRefs()
         recipe = self.factory.makeSourcePackageRecipe(branches=[base_ref, ref])
@@ -3005,6 +3019,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
     def test_instruction_repository_different_ref_recipe(self):
         # On ref changes, recipes including a different ref in the same
         # repository are left alone.
+        self.useFixture(GitHostingFixture())
         [base_ref] = self.factory.makeGitRefs()
         ref1, ref2 = self.factory.makeGitRefs(
             paths=["refs/heads/a", "refs/heads/b"])
@@ -3018,6 +3033,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
     def test_instruction_repository_default_branch_recipe(self):
         # On ref changes to the default branch, recipes including this
         # repository with no explicit revspec become stale.
+        self.useFixture(GitHostingFixture())
         [base_ref] = self.factory.makeGitRefs()
         repository = self.factory.makeGitRepository()
         ref1, ref2 = self.factory.makeGitRefs(
@@ -3035,6 +3051,7 @@ class TestGitRepositoryMarkRecipesStale(TestCaseWithFactory):
 
     def test_unrelated_repository_recipe(self):
         # On ref changes, unrelated recipes are left alone.
+        self.useFixture(GitHostingFixture())
         [ref] = self.factory.makeGitRefs()
         recipe = self.factory.makeSourcePackageRecipe(
             branches=self.factory.makeGitRefs())
@@ -3050,6 +3067,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
 
     def test_same_repository(self):
         # On ref changes, snap packages using this ref become stale.
+        self.useFixture(GitHostingFixture())
         [ref] = self.factory.makeGitRefs()
         snap = self.factory.makeSnap(git_ref=ref)
         removeSecurityProxy(snap).is_stale = False
@@ -3060,6 +3078,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
     def test_same_repository_different_ref(self):
         # On ref changes, snap packages using a different ref in the same
         # repository are left alone.
+        self.useFixture(GitHostingFixture())
         ref1, ref2 = self.factory.makeGitRefs(
             paths=["refs/heads/a", "refs/heads/b"])
         snap = self.factory.makeSnap(git_ref=ref1)
@@ -3070,6 +3089,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
 
     def test_different_repository(self):
         # On ref changes, unrelated snap packages are left alone.
+        self.useFixture(GitHostingFixture())
         [ref] = self.factory.makeGitRefs()
         snap = self.factory.makeSnap(git_ref=self.factory.makeGitRefs()[0])
         removeSecurityProxy(snap).is_stale = False
@@ -3079,6 +3099,7 @@ class TestGitRepositoryMarkSnapsStale(TestCaseWithFactory):
 
     def test_private_snap(self):
         # A private snap should be able to be marked stale
+        self.useFixture(GitHostingFixture())
         self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
         [ref] = self.factory.makeGitRefs()
         snap = self.factory.makeSnap(git_ref=ref, private=True)
@@ -3101,6 +3122,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory):
 
     def test_same_repository(self):
         # On ref changes, charm recipes using this ref become stale.
+        self.useFixture(GitHostingFixture())
         [ref] = self.factory.makeGitRefs()
         recipe = self.factory.makeCharmRecipe(git_ref=ref)
         removeSecurityProxy(recipe).is_stale = False
@@ -3111,6 +3133,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory):
     def test_same_repository_different_ref(self):
         # On ref changes, charm recipes using a different ref in the same
         # repository are left alone.
+        self.useFixture(GitHostingFixture())
         ref1, ref2 = self.factory.makeGitRefs(
             paths=["refs/heads/a", "refs/heads/b"])
         recipe = self.factory.makeCharmRecipe(git_ref=ref1)
@@ -3121,6 +3144,7 @@ class TestGitRepositoryMarkCharmRecipesStale(TestCaseWithFactory):
 
     def test_different_repository(self):
         # On ref changes, unrelated charm recipes are left alone.
+        self.useFixture(GitHostingFixture())
         [ref] = self.factory.makeGitRefs()
         recipe = self.factory.makeCharmRecipe(
             git_ref=self.factory.makeGitRefs()[0])
@@ -3321,6 +3345,81 @@ class TestGitRepositoryDetectMerges(TestCaseWithFactory):
                 for event in events[:2]})
 
 
+class TestGitRepositoryRequestCIBuilds(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_findByGitRepository_with_configuration(self):
+        # If a changed ref has CI configuration, we request CI builds.
+        logger = BufferLogger()
+        [ref] = self.factory.makeGitRefs()
+        ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+        distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
+        dases = [
+            self.factory.makeBuildableDistroArchSeries(
+                distroseries=distroseries)
+            for _ in range(2)]
+        configuration = dedent("""\
+            pipeline: [test]
+            jobs:
+                test:
+                    series: {series}
+                    architectures: [{architectures}]
+            """.format(
+                series=distroseries.name,
+                architectures=", ".join(
+                    das.architecturetag for das in dases))).encode()
+        new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        self.useFixture(GitHostingFixture(commits=[
+            {
+                "sha1": new_commit,
+                "blobs": {".launchpad.yaml": configuration},
+                },
+            ]))
+        with dbuser("branchscanner"):
+            ref.repository.createOrUpdateRefs(
+                {ref.path: {"sha1": new_commit, "type": GitObjectType.COMMIT}},
+                logger=logger)
+
+        results = getUtility(ICIBuildSet).findByGitRepository(ref.repository)
+        for result in results:
+            self.assertTrue(ICIBuild.providedBy(result))
+
+        self.assertThat(
+            results,
+            MatchesSetwise(*(
+                MatchesStructure.byEquality(
+                    git_repository=ref.repository,
+                    commit_sha1=new_commit,
+                    distro_arch_series=das)
+                for das in dases)))
+        self.assertContentEqual(
+            [
+                "INFO Requesting CI build for {commit} on "
+                "{series}/{arch}".format(
+                    commit=new_commit, series=distroseries.name,
+                    arch=das.architecturetag)
+                for das in dases],
+            logger.getLogBuffer().splitlines())
+
+    def test_findByGitRepository_without_configuration(self):
+        # If a changed ref has no CI configuration, we do not request CI
+        # builds.
+        logger = BufferLogger()
+        [ref] = self.factory.makeGitRefs()
+        new_commit = hashlib.sha1(self.factory.getUniqueBytes()).hexdigest()
+        self.useFixture(GitHostingFixture(commits=[]))
+        with dbuser("branchscanner"):
+            ref.repository.createOrUpdateRefs(
+                {ref.path: {"sha1": new_commit, "type": GitObjectType.COMMIT}},
+                logger=logger)
+        self.assertTrue(
+            getUtility(
+                ICIBuildSet).findByGitRepository(ref.repository).is_empty()
+        )
+        self.assertEqual("", logger.getLogBuffer())
+
+
 class TestGitRepositoryGetBlob(TestCaseWithFactory):
     """Tests for retrieving files from a Git repository."""
 
diff --git a/lib/lp/code/subscribers/git.py b/lib/lp/code/subscribers/git.py
index 30b432e..831ac49 100644
--- a/lib/lp/code/subscribers/git.py
+++ b/lib/lp/code/subscribers/git.py
@@ -1,8 +1,13 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2022 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Event subscribers for Git repositories."""
 
+from zope.component import getUtility
+
+from lp.code.interfaces.cibuild import ICIBuildSet
+
+
 def refs_updated(repository, event):
     """Some references in a Git repository have been updated."""
     repository.updateMergeCommitIDs(event.paths)
@@ -11,3 +16,5 @@ def refs_updated(repository, event):
     repository.markSnapsStale(event.paths)
     repository.markCharmRecipesStale(event.paths)
     repository.detectMerges(event.paths, logger=event.logger)
+    getUtility(ICIBuildSet).requestBuildsForRefs(
+        repository, event.paths, logger=event.logger)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 041cb89..83ad621 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -97,11 +97,15 @@ from lp.bugs.interfaces.cve import (
     )
 from lp.bugs.model.bug import FileBugData
 from lp.buildmaster.enums import (
+    BuildBaseImageType,
     BuilderResetProtocol,
     BuildStatus,
     )
 from lp.buildmaster.interfaces.builder import IBuilderSet
-from lp.buildmaster.interfaces.processor import IProcessorSet
+from lp.buildmaster.interfaces.processor import (
+    IProcessorSet,
+    ProcessorNotFound,
+    )
 from lp.charms.interfaces.charmbase import ICharmBaseSet
 from lp.charms.interfaces.charmrecipe import ICharmRecipeSet
 from lp.charms.interfaces.charmrecipebuild import ICharmRecipeBuildSet
@@ -2909,6 +2913,34 @@ class BareLaunchpadObjectFactory(ObjectFactory):
         return distroseries.newArch(
             architecturetag, processor, official, owner, enabled)
 
+    def makeBuildableDistroArchSeries(self, architecturetag=None,
+                                      processor=None,
+                                      supports_virtualized=True,
+                                      supports_nonvirtualized=True, **kwargs):
+        if architecturetag is None:
+            architecturetag = self.getUniqueUnicode("arch")
+        if processor is None:
+            try:
+                processor = getUtility(IProcessorSet).getByName(
+                    architecturetag)
+            except ProcessorNotFound:
+                processor = self.makeProcessor(
+                    name=architecturetag,
+                    supports_virtualized=supports_virtualized,
+                    supports_nonvirtualized=supports_nonvirtualized)
+        das = self.makeDistroArchSeries(
+            architecturetag=architecturetag, processor=processor, **kwargs)
+        # Add both a chroot and a LXD image to test that
+        # getAllowedArchitectures doesn't get confused by multiple
+        # PocketChroot rows for a single DistroArchSeries.
+        fake_chroot = self.makeLibraryFileAlias(
+            filename="fake_chroot.tar.gz", db_only=True)
+        das.addOrUpdateChroot(fake_chroot)
+        fake_lxd = self.makeLibraryFileAlias(
+            filename="fake_lxd.tar.gz", db_only=True)
+        das.addOrUpdateChroot(fake_lxd, image_type=BuildBaseImageType.LXD)
+        return das
+
     def makeComponent(self, name=None):
         """Make a new `IComponent`."""
         if name is None:

Follow ups