← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:ci-build into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:ci-build into launchpad:master.

Commit message:
Add basic CIBuild model

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/414474

This doesn't have an associated recipe object, but rather is linked directly to its containing Git repository.  It will soon also be linked to revision status reports, although the machinery to create and make use of those links isn't in place yet.

DB MP: https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/414172
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:ci-build into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 2ddb85c..7c99b77 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -59,6 +59,7 @@ from lp.code.interfaces.branch import (
     )
 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
 from lp.code.interfaces.branchsubscription import IBranchSubscription
+from lp.code.interfaces.cibuild import ICIBuild
 from lp.code.interfaces.codeimport import ICodeImport
 from lp.code.interfaces.codereviewcomment import ICodeReviewComment
 from lp.code.interfaces.codereviewvote import ICodeReviewVoteReference
@@ -534,6 +535,7 @@ patch_list_parameter_type(
 # IRevisionStatusReport
 patch_reference_property(
     IRevisionStatusReport, 'git_repository', IGitRepository)
+patch_reference_property(IRevisionStatusReport, 'ci_build', ICIBuild)
 
 # ILiveFSFile
 patch_reference_property(ILiveFSFile, 'livefsbuild', ILiveFSBuild)
diff --git a/lib/lp/buildmaster/enums.py b/lib/lp/buildmaster/enums.py
index 8801fdd..86a2348 100644
--- a/lib/lp/buildmaster/enums.py
+++ b/lib/lp/buildmaster/enums.py
@@ -174,6 +174,12 @@ class BuildFarmJobType(DBEnumeratedType):
         Build a charm from a recipe.
         """)
 
+    CIBUILD = DBItem(9, """
+        CI build
+
+        Run a continuous integration job on a code revision.
+        """)
+
 
 class BuildQueueStatus(DBEnumeratedType):
     """Build queue status.
diff --git a/lib/lp/code/browser/configure.zcml b/lib/lp/code/browser/configure.zcml
index 33d0368..71d04dd 100644
--- a/lib/lp/code/browser/configure.zcml
+++ b/lib/lp/code/browser/configure.zcml
@@ -1408,6 +1408,11 @@
             factory="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeBreadcrumb"
             permission="zope.Public"/>
 
+        <browser:url
+            for="lp.code.interfaces.cibuild.ICIBuild"
+            path_expression="string:+build/${id}"
+            attribute_to_parent="git_repository"/>
+
     </facet>
 
 </configure>
diff --git a/lib/lp/code/configure.zcml b/lib/lp/code/configure.zcml
index 8ba4643..a73d8a1 100644
--- a/lib/lp/code/configure.zcml
+++ b/lib/lp/code/configure.zcml
@@ -1272,6 +1272,33 @@
     for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe zope.lifecycleevent.interfaces.IObjectModifiedEvent"
     handler="lp.code.model.sourcepackagerecipe.recipe_modified"/>
 
+  <!-- CIBuild -->
+
+  <class class="lp.code.model.cibuild.CIBuild">
+    <require
+        permission="launchpad.View"
+        interface="lp.code.interfaces.cibuild.ICIBuildView" />
+    <require
+        permission="launchpad.Edit"
+        interface="lp.code.interfaces.cibuild.ICIBuildEdit" />
+    <require
+        permission="launchpad.Admin"
+        interface="lp.code.interfaces.cibuild.ICIBuildAdmin" />
+  </class>
+
+  <!-- CIBuildSet -->
+  <securedutility
+      class="lp.code.model.cibuild.CIBuildSet"
+      provides="lp.code.interfaces.cibuild.ICIBuildSet">
+    <allow interface="lp.code.interfaces.cibuild.ICIBuildSet" />
+  </securedutility>
+  <securedutility
+      class="lp.code.model.cibuild.CIBuildSet"
+      provides="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource"
+      name="CIBUILD">
+    <allow interface="lp.buildmaster.interfaces.buildfarmjob.ISpecificBuildFarmJobSource" />
+  </securedutility>
+
   <webservice:register module="lp.code.interfaces.webservice" />
 
   <adapter
diff --git a/lib/lp/code/interfaces/cibuild.py b/lib/lp/code/interfaces/cibuild.py
new file mode 100644
index 0000000..11032c2
--- /dev/null
+++ b/lib/lp/code/interfaces/cibuild.py
@@ -0,0 +1,95 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Interfaces for CI builds."""
+
+__all__ = [
+    "ICIBuild",
+    "ICIBuildSet",
+    ]
+
+from lazr.restful.fields import Reference
+from zope.schema import (
+    Bool,
+    Datetime,
+    Int,
+    TextLine,
+    )
+
+from lp import _
+from lp.buildmaster.interfaces.buildfarmjob import (
+    IBuildFarmJobAdmin,
+    IBuildFarmJobEdit,
+    ISpecificBuildFarmJobSource,
+    )
+from lp.buildmaster.interfaces.packagebuild import (
+    IPackageBuild,
+    IPackageBuildView,
+    )
+from lp.code.interfaces.gitrepository import IGitRepository
+from lp.services.database.constants import DEFAULT
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
+
+
+class ICIBuildView(IPackageBuildView):
+    """`ICIBuild` attributes that require launchpad.View."""
+
+    git_repository = Reference(
+        IGitRepository,
+        title=_("The Git repository for this CI build."),
+        required=False, readonly=True)
+
+    commit_sha1 = TextLine(
+        title=_("The Git commit ID for this CI build."),
+        required=True, readonly=True)
+
+    distro_arch_series = Reference(
+        IDistroArchSeries,
+        title=_(
+            "The series and architecture that this CI build should run on."),
+        required=True, readonly=True)
+
+    arch_tag = TextLine(
+        title=_("Architecture tag"), required=True, readonly=True)
+
+    score = Int(
+        title=_("Score of the related build farm job (if any)."),
+        required=False, readonly=True)
+
+    eta = Datetime(
+        title=_("The datetime when the build job is estimated to complete."),
+        readonly=True)
+
+    estimate = Bool(
+        title=_("If true, the date value is an estimate."), readonly=True)
+
+    date = Datetime(
+        title=_(
+            "The date when the build completed or is estimated to complete."),
+        readonly=True)
+
+
+class ICIBuildEdit(IBuildFarmJobEdit):
+    """`ICIBuild` methods that require launchpad.Edit."""
+
+
+class ICIBuildAdmin(IBuildFarmJobAdmin):
+    """`ICIBuild` methods that require launchpad.Admin."""
+
+
+class ICIBuild(ICIBuildView, ICIBuildEdit, ICIBuildAdmin, IPackageBuild):
+    """A build record for a pipeline of CI jobs."""
+
+
+class ICIBuildSet(ISpecificBuildFarmJobSource):
+    """Utility to create and access `ICIBuild`s."""
+
+    def new(git_repository, commit_sha1, distro_arch_series,
+            date_created=DEFAULT):
+        """Create an `ICIBuild`."""
+
+    def deleteByGitRepository(repository):
+        """Delete all CI builds for the given Git repository.
+
+        :param repository: An `IGitRepository`.
+        """
diff --git a/lib/lp/code/interfaces/revisionstatus.py b/lib/lp/code/interfaces/revisionstatus.py
index 20c6a5a..53b5085 100644
--- a/lib/lp/code/interfaces/revisionstatus.py
+++ b/lib/lp/code/interfaces/revisionstatus.py
@@ -109,6 +109,11 @@ class IRevisionStatusReportEditableAttributes(Interface):
         title=_('Result of the report'),  readonly=True,
         required=False, vocabulary=RevisionStatusResult))
 
+    ci_build = Reference(
+        title=_("The CI build that produced this report."),
+        # Really ICIBuild, patched in _schema_circular_imports.py.
+        schema=Interface, required=False, readonly=True)
+
     @mutator_for(result)
     @operation_parameters(result=copy_field(result))
     @export_write_operation()
diff --git a/lib/lp/code/model/cibuild.py b/lib/lp/code/model/cibuild.py
new file mode 100644
index 0000000..509be36
--- /dev/null
+++ b/lib/lp/code/model/cibuild.py
@@ -0,0 +1,317 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""CI builds."""
+
+__all__ = [
+    "CIBuild",
+    ]
+
+from datetime import timedelta
+
+import pytz
+from storm.locals import (
+    Bool,
+    DateTime,
+    Desc,
+    Int,
+    Reference,
+    Store,
+    Unicode,
+    )
+from storm.store import EmptyResultSet
+from zope.component import getUtility
+from zope.interface import implementer
+
+from lp.buildmaster.enums import (
+    BuildFarmJobType,
+    BuildQueueStatus,
+    BuildStatus,
+    )
+from lp.buildmaster.interfaces.buildfarmjob import IBuildFarmJobSource
+from lp.buildmaster.model.buildfarmjob import SpecificBuildFarmJobSourceMixin
+from lp.buildmaster.model.packagebuild import PackageBuildMixin
+from lp.code.interfaces.cibuild import (
+    ICIBuild,
+    ICIBuildSet,
+    )
+from lp.registry.interfaces.pocket import PackagePublishingPocket
+from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distroseries import DistroSeries
+from lp.services.database.bulk import load_related
+from lp.services.database.constants import DEFAULT
+from lp.services.database.decoratedresultset import DecoratedResultSet
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import (
+    IMasterStore,
+    IStore,
+    )
+from lp.services.database.stormbase import StormBase
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
+from lp.services.librarian.model import (
+    LibraryFileAlias,
+    LibraryFileContent,
+    )
+from lp.services.propertycache import cachedproperty
+from lp.soyuz.model.distroarchseries import DistroArchSeries
+
+
+@implementer(ICIBuild)
+class CIBuild(PackageBuildMixin, StormBase):
+    """See `ICIBuild`."""
+
+    __storm_table__ = "CIBuild"
+
+    job_type = BuildFarmJobType.CIBUILD
+
+    id = Int(name="id", primary=True)
+
+    git_repository_id = Int(name="git_repository", allow_none=False)
+    git_repository = Reference(git_repository_id, "GitRepository.id")
+
+    commit_sha1 = Unicode(name="commit_sha1", allow_none=False)
+
+    distro_arch_series_id = Int(name="distro_arch_series", allow_none=False)
+    distro_arch_series = Reference(
+        distro_arch_series_id, "DistroArchSeries.id")
+
+    processor_id = Int(name="processor", allow_none=False)
+    processor = Reference(processor_id, "Processor.id")
+
+    virtualized = Bool(name="virtualized", allow_none=False)
+
+    date_created = DateTime(
+        name="date_created", tzinfo=pytz.UTC, allow_none=False)
+    date_started = DateTime(
+        name="date_started", tzinfo=pytz.UTC, allow_none=True)
+    date_finished = DateTime(
+        name="date_finished", tzinfo=pytz.UTC, allow_none=True)
+    date_first_dispatched = DateTime(
+        name="date_first_dispatched", tzinfo=pytz.UTC, allow_none=True)
+
+    builder_id = Int(name="builder", allow_none=True)
+    builder = Reference(builder_id, "Builder.id")
+
+    status = DBEnum(name="status", enum=BuildStatus, allow_none=False)
+
+    log_id = Int(name="log", allow_none=True)
+    log = Reference(log_id, "LibraryFileAlias.id")
+
+    upload_log_id = Int(name="upload_log", allow_none=True)
+    upload_log = Reference(upload_log_id, "LibraryFileAlias.id")
+
+    dependencies = Unicode(name="dependencies", allow_none=True)
+
+    failure_count = Int(name="failure_count", allow_none=False)
+
+    build_farm_job_id = Int(name="build_farm_job", allow_none=False)
+    build_farm_job = Reference(build_farm_job_id, "BuildFarmJob.id")
+
+    def __init__(self, build_farm_job, git_repository, commit_sha1,
+                 distro_arch_series, processor, virtualized,
+                 date_created=DEFAULT):
+        """Construct a `CIBuild`."""
+        super().__init__()
+        self.build_farm_job = build_farm_job
+        self.git_repository = git_repository
+        self.commit_sha1 = commit_sha1
+        self.distro_arch_series = distro_arch_series
+        self.processor = processor
+        self.virtualized = virtualized
+        self.date_created = date_created
+        self.status = BuildStatus.NEEDSBUILD
+
+    @property
+    def is_private(self):
+        """See `IBuildFarmJob`."""
+        return self.git_repository.private
+
+    def __repr__(self):
+        return "<CIBuild %s/+build/%s>" % (
+            self.git_repository.unique_name, self.id)
+
+    @property
+    def title(self):
+        """See `IBuildFarmJob`."""
+        return "%s CI build of %s:%s" % (
+            self.distro_arch_series.architecturetag,
+            self.git_repository.unique_name, self.commit_sha1)
+
+    @property
+    def distribution(self):
+        """See `IPackageBuild`."""
+        return self.distro_arch_series.distroseries.distribution
+
+    @property
+    def distro_series(self):
+        """See `IPackageBuild`."""
+        return self.distro_arch_series.distroseries
+
+    @property
+    def pocket(self):
+        """See `IPackageBuild`."""
+        return PackagePublishingPocket.UPDATES
+
+    @property
+    def arch_tag(self):
+        """See `ICIBuild`."""
+        return self.distro_arch_series.architecturetag
+
+    @property
+    def archive(self):
+        """See `IPackageBuild`."""
+        return self.distribution.main_archive
+
+    @property
+    def score(self):
+        """See `ICIBuild`."""
+        if self.buildqueue_record is None:
+            return None
+        else:
+            return self.buildqueue_record.lastscore
+
+    @property
+    def can_be_retried(self):
+        """See `IBuildFarmJob`."""
+        # First check that the behaviour would accept the build if it
+        # succeeded.
+        if self.distro_series.status == SeriesStatus.OBSOLETE:
+            return False
+        return super().can_be_retried
+
+    def calculateScore(self):
+        # Low latency is especially useful for CI builds, so score these
+        # above bulky things like live filesystem builds, but below
+        # important things like builds of proposed Ubuntu stable updates.
+        # See https://help.launchpad.net/Packaging/BuildScores.
+        return 2600
+
+    def getMedianBuildDuration(self):
+        """Return the median duration of our recent successful builds."""
+        store = IStore(self)
+        result = store.find(
+            (CIBuild.date_started, CIBuild.date_finished),
+            CIBuild.git_repository == self.git_repository_id,
+            CIBuild.distro_arch_series == self.distro_arch_series_id,
+            CIBuild.status == BuildStatus.FULLYBUILT)
+        result.order_by(Desc(CIBuild.date_finished))
+        durations = [row[1] - row[0] for row in result[:9]]
+        if len(durations) == 0:
+            return None
+        durations.sort()
+        return durations[len(durations) // 2]
+
+    def estimateDuration(self):
+        """See `IBuildFarmJob`."""
+        median = self.getMedianBuildDuration()
+        if median is not None:
+            return median
+        return timedelta(minutes=10)
+
+    def lfaUrl(self, lfa):
+        """Return the URL for a LibraryFileAlias in this context."""
+        if lfa is None:
+            return None
+        return ProxiedLibraryFileAlias(lfa, self).http_url
+
+    @property
+    def log_url(self):
+        """See `IBuildFarmJob`."""
+        return self.lfaUrl(self.log)
+
+    @property
+    def upload_log_url(self):
+        """See `IPackageBuild`."""
+        return self.lfaUrl(self.upload_log)
+
+    @cachedproperty
+    def eta(self):
+        """The datetime when the build job is estimated to complete.
+
+        This is the BuildQueue.estimated_duration plus the
+        Job.date_started or BuildQueue.getEstimatedJobStartTime.
+        """
+        if self.buildqueue_record is None:
+            return None
+        queue_record = self.buildqueue_record
+        if queue_record.status == BuildQueueStatus.WAITING:
+            start_time = queue_record.getEstimatedJobStartTime()
+        else:
+            start_time = queue_record.date_started
+        if start_time is None:
+            return None
+        duration = queue_record.estimated_duration
+        return start_time + duration
+
+    @property
+    def estimate(self):
+        """If true, the date value is an estimate."""
+        if self.date_finished is not None:
+            return False
+        return self.eta is not None
+
+    @property
+    def date(self):
+        """The date when the build completed or is estimated to complete."""
+        if self.estimate:
+            return self.eta
+        return self.date_finished
+
+    def verifySuccessfulUpload(self):
+        """See `IPackageBuild`."""
+        # We have no interesting checks to perform here.
+
+    def notify(self, extra_info=None):
+        """See `IPackageBuild`."""
+        # We don't currently send any notifications.
+
+
+@implementer(ICIBuildSet)
+class CIBuildSet(SpecificBuildFarmJobSourceMixin):
+
+    def new(self, git_repository, commit_sha1, distro_arch_series,
+            date_created=DEFAULT):
+        """See `ICIBuildSet`."""
+        store = IMasterStore(CIBuild)
+        build_farm_job = getUtility(IBuildFarmJobSource).new(
+            CIBuild.job_type, BuildStatus.NEEDSBUILD, date_created)
+        cibuild = CIBuild(
+            build_farm_job, git_repository, commit_sha1, distro_arch_series,
+            distro_arch_series.processor, virtualized=True,
+            date_created=date_created)
+        store.add(cibuild)
+        store.flush()
+        return cibuild
+
+    def getByID(self, build_id):
+        """See `ISpecificBuildFarmJobSource`."""
+        store = IMasterStore(CIBuild)
+        return store.get(CIBuild, build_id)
+
+    def getByBuildFarmJob(self, build_farm_job):
+        """See `ISpecificBuildFarmJobSource`."""
+        return Store.of(build_farm_job).find(
+            CIBuild, build_farm_job_id=build_farm_job.id).one()
+
+    def preloadBuildsData(self, builds):
+        lfas = load_related(LibraryFileAlias, builds, ["log_id"])
+        load_related(LibraryFileContent, lfas, ["contentID"])
+        distroarchseries = load_related(
+            DistroArchSeries, builds, ["distro_arch_series_id"])
+        distroseries = load_related(
+            DistroSeries, distroarchseries, ["distroseriesID"])
+        load_related(Distribution, distroseries, ["distributionID"])
+
+    def getByBuildFarmJobs(self, build_farm_jobs):
+        """See `ISpecificBuildFarmJobSource`."""
+        if len(build_farm_jobs) == 0:
+            return EmptyResultSet()
+        rows = Store.of(build_farm_jobs[0]).find(
+            CIBuild, CIBuild.build_farm_job_id.is_in(
+                bfj.id for bfj in build_farm_jobs))
+        return DecoratedResultSet(rows, pre_iter_hook=self.preloadBuildsData)
+
+    def deleteByGitRepository(self, repository):
+        """See `ICIBuildSet`."""
+        IMasterStore(CIBuild).find(CIBuild, git_repository=repository).remove()
diff --git a/lib/lp/code/model/gitrepository.py b/lib/lp/code/model/gitrepository.py
index 8a3b08c..a3d8c2e 100644
--- a/lib/lp/code/model/gitrepository.py
+++ b/lib/lp/code/model/gitrepository.py
@@ -114,6 +114,7 @@ from lp.code.event.git import GitRefsUpdatedEvent
 from lp.code.interfaces.branchmergeproposal import (
     BRANCH_MERGE_PROPOSAL_FINAL_STATES,
     )
+from lp.code.interfaces.cibuild import ICIBuildSet
 from lp.code.interfaces.codeimport import ICodeImportSet
 from lp.code.interfaces.gitactivity import IGitActivitySet
 from lp.code.interfaces.gitcollection import (
@@ -1820,6 +1821,7 @@ class GitRepository(StormBase, WebhookTargetMixin, AccessTokenTargetMixin,
         self.grants.remove()
         self.rules.remove()
         getUtility(IRevisionStatusReportSet).deleteForRepository(self)
+        getUtility(ICIBuildSet).deleteByGitRepository(self)
 
         # Now destroy the repository.
         repository_name = self.unique_name
diff --git a/lib/lp/code/model/revisionstatus.py b/lib/lp/code/model/revisionstatus.py
index de9bfff..56469a9 100644
--- a/lib/lp/code/model/revisionstatus.py
+++ b/lib/lp/code/model/revisionstatus.py
@@ -58,6 +58,9 @@ class RevisionStatusReport(StormBase):
 
     result = DBEnum(name='result', allow_none=True, enum=RevisionStatusResult)
 
+    ci_build_id = Int(name="ci_build", allow_none=True)
+    ci_build = Reference(ci_build_id, "CIBuild.id")
+
     date_created = DateTime(
         name='date_created', tzinfo=pytz.UTC, allow_none=False)
 
@@ -67,7 +70,7 @@ class RevisionStatusReport(StormBase):
                              allow_none=True)
 
     def __init__(self, git_repository, user, title, commit_sha1,
-                 url, result_summary, result):
+                 url, result_summary, result, ci_build=None):
         super().__init__()
         self.creator = user
         self.git_repository = git_repository
@@ -75,6 +78,7 @@ class RevisionStatusReport(StormBase):
         self.commit_sha1 = commit_sha1
         self.url = url
         self.result_summary = result_summary
+        self.ci_build = ci_build
         self.date_created = UTC_NOW
         self.transitionToNewResult(result)
 
@@ -123,12 +127,12 @@ class RevisionStatusReportSet:
 
     def new(self, creator, title, git_repository, commit_sha1,
             url=None, result_summary=None, result=None,
-            date_started=None, date_finished=None, log=None):
+            date_started=None, date_finished=None, log=None, ci_build=None):
         """See `IRevisionStatusReportSet`."""
         store = IStore(RevisionStatusReport)
         report = RevisionStatusReport(git_repository, creator, title,
                                       commit_sha1, url, result_summary,
-                                      result)
+                                      result, ci_build=ci_build)
         store.add(report)
         return report
 
diff --git a/lib/lp/code/model/tests/test_cibuild.py b/lib/lp/code/model/tests/test_cibuild.py
new file mode 100644
index 0000000..aaa7897
--- /dev/null
+++ b/lib/lp/code/model/tests/test_cibuild.py
@@ -0,0 +1,264 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test CI builds."""
+
+from datetime import (
+    datetime,
+    timedelta,
+    )
+
+import pytz
+from testtools.matchers import Equals
+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.buildmaster.interfaces.buildqueue import IBuildQueue
+from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.code.interfaces.cibuild import (
+    ICIBuild,
+    ICIBuildSet,
+    )
+from lp.registry.interfaces.series import SeriesStatus
+from lp.services.propertycache import clear_property_cache
+from lp.testing import (
+    person_logged_in,
+    StormStatementRecorder,
+    TestCaseWithFactory,
+    )
+from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.matchers import HasQueryCount
+
+
+class TestCIBuild(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_implements_interfaces(self):
+        # CIBuild implements IPackageBuild and ICIBuild.
+        build = self.factory.makeCIBuild()
+        self.assertProvides(build, IPackageBuild)
+        self.assertProvides(build, ICIBuild)
+
+    def test___repr__(self):
+        # CIBuild has an informative __repr__.
+        build = self.factory.makeCIBuild()
+        self.assertEqual(
+            "<CIBuild %s/+build/%s>" % (
+                build.git_repository.unique_name, build.id),
+            repr(build))
+
+    def test_title(self):
+        # CIBuild has an informative title.
+        build = self.factory.makeCIBuild()
+        self.assertEqual(
+            "%s CI build of %s:%s" % (
+                build.distro_arch_series.architecturetag,
+                build.git_repository.unique_name, build.commit_sha1),
+            build.title)
+
+    def test_queueBuild(self):
+        # CIBuild can create the queue entry for itself.
+        build = self.factory.makeCIBuild()
+        bq = build.queueBuild()
+        self.assertProvides(bq, IBuildQueue)
+        self.assertEqual(
+            build.build_farm_job, removeSecurityProxy(bq)._build_farm_job)
+        self.assertEqual(build, bq.specific_build)
+        self.assertEqual(build.virtualized, bq.virtualized)
+        self.assertIsNotNone(bq.processor)
+        self.assertEqual(bq, build.buildqueue_record)
+
+    def test_is_private(self):
+        # A CIBuild is private iff its repository is.
+        build = self.factory.makeCIBuild()
+        self.assertFalse(build.is_private)
+        with person_logged_in(self.factory.makePerson()) as owner:
+            build = self.factory.makeCIBuild(
+                git_repository=self.factory.makeGitRepository(
+                    owner=owner, information_type=InformationType.USERDATA))
+            self.assertTrue(build.is_private)
+
+    def test_can_be_retried(self):
+        ok_cases = [
+            BuildStatus.FAILEDTOBUILD,
+            BuildStatus.MANUALDEPWAIT,
+            BuildStatus.CHROOTWAIT,
+            BuildStatus.FAILEDTOUPLOAD,
+            BuildStatus.CANCELLED,
+            BuildStatus.SUPERSEDED,
+            ]
+        for status in BuildStatus.items:
+            build = self.factory.makeCIBuild(status=status)
+            if status in ok_cases:
+                self.assertTrue(build.can_be_retried)
+            else:
+                self.assertFalse(build.can_be_retried)
+
+    def test_can_be_retried_obsolete_series(self):
+        # Builds for obsolete series cannot be retried.
+        distroseries = self.factory.makeDistroSeries(
+            status=SeriesStatus.OBSOLETE)
+        das = self.factory.makeDistroArchSeries(distroseries=distroseries)
+        build = self.factory.makeCIBuild(distro_arch_series=das)
+        self.assertFalse(build.can_be_retried)
+
+    def test_can_be_cancelled(self):
+        # For all states that can be cancelled, can_be_cancelled returns True.
+        ok_cases = [
+            BuildStatus.BUILDING,
+            BuildStatus.NEEDSBUILD,
+            ]
+        for status in BuildStatus.items:
+            build = self.factory.makeCIBuild()
+            build.queueBuild()
+            build.updateStatus(status)
+            if status in ok_cases:
+                self.assertTrue(build.can_be_cancelled)
+            else:
+                self.assertFalse(build.can_be_cancelled)
+
+    def test_retry_resets_state(self):
+        # Retrying a build resets most of the state attributes, but does
+        # not modify the first dispatch time.
+        now = datetime.now(pytz.UTC)
+        build = self.factory.makeCIBuild()
+        build.updateStatus(BuildStatus.BUILDING, date_started=now)
+        build.updateStatus(BuildStatus.FAILEDTOBUILD)
+        build.gotFailure()
+        with person_logged_in(build.git_repository.owner):
+            build.retry()
+        self.assertEqual(BuildStatus.NEEDSBUILD, build.status)
+        self.assertEqual(now, build.date_first_dispatched)
+        self.assertIsNone(build.log)
+        self.assertIsNone(build.upload_log)
+        self.assertEqual(0, build.failure_count)
+
+    def test_cancel_not_in_progress(self):
+        # The cancel() method for a pending build leaves it in the CANCELLED
+        # state.
+        build = self.factory.makeCIBuild()
+        build.queueBuild()
+        build.cancel()
+        self.assertEqual(BuildStatus.CANCELLED, build.status)
+        self.assertIsNone(build.buildqueue_record)
+
+    def test_cancel_in_progress(self):
+        # The cancel() method for a building build leaves it in the
+        # CANCELLING state.
+        build = self.factory.makeCIBuild()
+        bq = build.queueBuild()
+        bq.markAsBuilding(self.factory.makeBuilder())
+        build.cancel()
+        self.assertEqual(BuildStatus.CANCELLING, build.status)
+        self.assertEqual(bq, build.buildqueue_record)
+
+    def test_estimateDuration(self):
+        # Without previous builds, the default time estimate is 10m.
+        build = self.factory.makeCIBuild()
+        self.assertEqual(600, build.estimateDuration().seconds)
+
+    def test_estimateDuration_with_history(self):
+        # Previous successful builds of the same repository are used for
+        # estimates.
+        build = self.factory.makeCIBuild()
+        self.factory.makeCIBuild(
+            git_repository=build.git_repository,
+            distro_arch_series=build.distro_arch_series,
+            status=BuildStatus.FULLYBUILT, duration=timedelta(seconds=335))
+        for i in range(3):
+            self.factory.makeCIBuild(
+                git_repository=build.git_repository,
+                distro_arch_series=build.distro_arch_series,
+                status=BuildStatus.FAILEDTOBUILD,
+                duration=timedelta(seconds=20))
+        self.assertEqual(335, build.estimateDuration().seconds)
+
+    def test_build_cookie(self):
+        build = self.factory.makeCIBuild()
+        self.assertEqual('CIBUILD-%d' % build.id, build.build_cookie)
+
+    def addFakeBuildLog(self, build):
+        build.setLog(self.factory.makeLibraryFileAlias("mybuildlog.txt"))
+
+    def test_log_url(self):
+        # The log URL for a CI build will use the repository context.
+        build = self.factory.makeCIBuild()
+        self.addFakeBuildLog(build)
+        self.assertEqual(
+            "http://launchpad.test/%s/+build/%d/+files/mybuildlog.txt"; % (
+                build.git_repository.unique_name, build.id),
+            build.log_url)
+
+    def test_eta(self):
+        # CIBuild.eta returns a non-None value when it should, or None when
+        # there's no start time.
+        build = self.factory.makeCIBuild()
+        build.queueBuild()
+        self.assertIsNone(build.eta)
+        self.factory.makeBuilder(processors=[build.processor])
+        clear_property_cache(build)
+        self.assertIsNotNone(build.eta)
+
+    def test_eta_cached(self):
+        # The expensive completion time estimate is cached.
+        build = self.factory.makeCIBuild()
+        build.queueBuild()
+        build.eta
+        with StormStatementRecorder() as recorder:
+            build.eta
+        self.assertThat(recorder, HasQueryCount(Equals(0)))
+
+    def test_estimate(self):
+        # CIBuild.estimate returns True until the job is completed.
+        build = self.factory.makeCIBuild()
+        build.queueBuild()
+        self.factory.makeBuilder(processors=[build.processor])
+        build.updateStatus(BuildStatus.BUILDING)
+        self.assertTrue(build.estimate)
+        build.updateStatus(BuildStatus.FULLYBUILT)
+        clear_property_cache(build)
+        self.assertFalse(build.estimate)
+
+
+class TestCIBuildSet(TestCaseWithFactory):
+
+    layer = LaunchpadZopelessLayer
+
+    def test_getByBuildFarmJob_works(self):
+        build = self.factory.makeCIBuild()
+        self.assertEqual(
+            build,
+            getUtility(ICIBuildSet).getByBuildFarmJob(build.build_farm_job))
+
+    def test_getByBuildFarmJob_returns_None_when_missing(self):
+        bpb = self.factory.makeBinaryPackageBuild()
+        self.assertIsNone(
+            getUtility(ICIBuildSet).getByBuildFarmJob(bpb.build_farm_job))
+
+    def test_getByBuildFarmJobs_works(self):
+        builds = [self.factory.makeCIBuild() for i in range(10)]
+        self.assertContentEqual(
+            builds,
+            getUtility(ICIBuildSet).getByBuildFarmJobs(
+                [build.build_farm_job for build in builds]))
+
+    def test_getByBuildFarmJobs_works_empty(self):
+        self.assertContentEqual(
+            [], getUtility(ICIBuildSet).getByBuildFarmJobs([]))
+
+    def test_virtualized_processor_requires(self):
+        distro_arch_series = self.factory.makeDistroArchSeries()
+        distro_arch_series.processor.supports_nonvirtualized = False
+        target = self.factory.makeCIBuild(
+            distro_arch_series=distro_arch_series)
+        self.assertTrue(target.virtualized)
+
+    def test_virtualized_processor_does_not_require(self):
+        distro_arch_series = self.factory.makeDistroArchSeries()
+        distro_arch_series.processor.supports_nonvirtualized = True
+        target = self.factory.makeCIBuild(
+            distro_arch_series=distro_arch_series)
+        self.assertTrue(target.virtualized)
diff --git a/lib/lp/code/model/tests/test_gitrepository.py b/lib/lp/code/model/tests/test_gitrepository.py
index 39a46d5..fe86ea1 100644
--- a/lib/lp/code/model/tests/test_gitrepository.py
+++ b/lib/lp/code/model/tests/test_gitrepository.py
@@ -1170,6 +1170,15 @@ class TestGitRepositoryDeletion(TestCaseWithFactory):
             GitActivity, GitActivity.repository_id == repository_id)
         self.assertEqual([], list(activities))
 
+    def test_related_ci_builds_deleted(self):
+        # A repository that has a CI build can be deleted.
+        build = self.factory.makeCIBuild(git_repository=self.repository)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
+        self.repository.destroySelf()
+        transaction.commit()
+        self.assertRaises(LostObjectError, getattr, report, 'ci_build')
+        self.assertRaises(LostObjectError, getattr, build, 'git_repository')
+
 
 class TestGitRepositoryDeletionConsequences(TestCaseWithFactory):
     """Test determination and application of repository deletion
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 37d52d3..d70a99b 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -82,6 +82,7 @@ from lp.code.interfaces.branchcollection import (
     IBranchCollection,
     )
 from lp.code.interfaces.branchmergeproposal import IBranchMergeProposal
+from lp.code.interfaces.cibuild import ICIBuild
 from lp.code.interfaces.codeimport import ICodeImport
 from lp.code.interfaces.codeimportjob import (
     ICodeImportJobSet,
@@ -3735,3 +3736,27 @@ class EditCharmBase(EditByRegistryExpertsOrAdmins):
 
 class EditCharmBaseSet(EditByRegistryExpertsOrAdmins):
     usedfor = ICharmBaseSet
+
+
+class ViewCIBuild(DelegatedAuthorization):
+    permission = "launchpad.View"
+    usedfor = ICIBuild
+
+    def iter_objects(self):
+        yield self.obj.git_repository
+
+
+class EditCIBuild(AdminByBuilddAdmin):
+    permission = "launchpad.Edit"
+    usedfor = ICIBuild
+
+    def checkAuthenticated(self, user):
+        """Check edit access for CI builds.
+
+        Allow admins, buildd admins, and people who can edit the originating
+        Git repository.
+        """
+        auth_repository = EditGitRepository(self.obj.git_repository)
+        if auth_repository.checkAuthenticated(user):
+            return True
+        return super().checkAuthenticated(user)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index 1588aad..47e9290 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -124,6 +124,7 @@ from lp.code.enums import (
 from lp.code.errors import UnknownBranchTypeError
 from lp.code.interfaces.branch import IBranch
 from lp.code.interfaces.branchnamespace import get_branch_namespace
+from lp.code.interfaces.cibuild import ICIBuildSet
 from lp.code.interfaces.codeimport import ICodeImportSet
 from lp.code.interfaces.codeimportevent import ICodeImportEventSet
 from lp.code.interfaces.codeimportmachine import ICodeImportMachineSet
@@ -1852,23 +1853,30 @@ class BareLaunchpadObjectFactory(ObjectFactory):
 
     def makeRevisionStatusReport(self, user=None, title=None,
                                  git_repository=None, commit_sha1=None,
-                                 result_summary=None, url=None, result=None):
+                                 result_summary=None, url=None, result=None,
+                                 ci_build=None):
         """Create a new RevisionStatusReport."""
         if title is None:
             title = self.getUniqueUnicode()
         if git_repository is None:
-            git_repository = self.makeGitRepository()
+            if ci_build is not None:
+                git_repository = ci_build.git_repository
+            else:
+                git_repository = self.makeGitRepository()
         if user is None:
             user = git_repository.owner
         if commit_sha1 is None:
-            commit_sha1 = hashlib.sha1(self.getUniqueBytes()).hexdigest()
+            if ci_build is not None:
+                commit_sha1 = ci_build.commit_sha1
+            else:
+                commit_sha1 = hashlib.sha1(self.getUniqueBytes()).hexdigest()
         if result_summary is None:
             result_summary = self.getUniqueUnicode()
         if result is None:
             result = RevisionStatusResult.RUNNING
         return getUtility(IRevisionStatusReportSet).new(
             user, title, git_repository, commit_sha1, url,
-            result_summary, result)
+            result_summary, result, ci_build=ci_build)
 
     def makeRevisionStatusArtifact(
             self, lfa=None, report=None,
@@ -5268,6 +5276,32 @@ class BareLaunchpadObjectFactory(ObjectFactory):
             registrant, distro_series, build_snap_channels,
             processors=processors, date_created=date_created)
 
+    def makeCIBuild(self, git_repository=None, commit_sha1=None,
+                    distro_arch_series=None, date_created=DEFAULT,
+                    status=BuildStatus.NEEDSBUILD, builder=None,
+                    duration=None):
+        """Make a new `CIBuild`."""
+        if git_repository is None:
+            git_repository = self.makeGitRepository()
+        if commit_sha1 is None:
+            commit_sha1 = hashlib.sha1(self.getUniqueBytes()).hexdigest()
+        if distro_arch_series is None:
+            distro_arch_series = self.makeDistroArchSeries()
+        build = getUtility(ICIBuildSet).new(
+            git_repository, commit_sha1, distro_arch_series,
+            date_created=date_created)
+        if duration is not None:
+            removeSecurityProxy(build).updateStatus(
+                BuildStatus.BUILDING, builder=builder,
+                date_started=build.date_created)
+            removeSecurityProxy(build).updateStatus(
+                status, builder=builder,
+                date_finished=build.date_started + duration)
+        else:
+            removeSecurityProxy(build).updateStatus(status, builder=builder)
+        IStore(build).flush()
+        return build
+
 
 # Some factory methods return simple Python types. We don't add
 # security wrappers for them, as well as for objects created by