← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~enriqueesanchz/launchpad:add-bugpresence into launchpad:master

 

Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-bugpresence into launchpad:master.

Commit message:
Add SVT `BugPresence` model

Add `Bug` and `BugPresence` integration
Add SVT scripts `BugPresence` integration

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/485525
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-bugpresence into launchpad:master.
diff --git a/lib/lp/bugs/adapters/bugchange.py b/lib/lp/bugs/adapters/bugchange.py
index 1532bfc..80afe09 100644
--- a/lib/lp/bugs/adapters/bugchange.py
+++ b/lib/lp/bugs/adapters/bugchange.py
@@ -19,6 +19,8 @@ __all__ = [
     "BugInformationTypeChange",
     "BugLocked",
     "BugLockReasonSet",
+    "BugPresenceAdded",
+    "BugPresenceRemoved",
     "BugTagsChange",
     "BugTaskAdded",
     "BugTaskAssigneeChange",
@@ -72,6 +74,8 @@ ATTACHMENT_ADDED = "attachment added"
 ATTACHMENT_REMOVED = "attachment removed"
 BRANCH_LINKED = "branch linked"
 BRANCH_UNLINKED = "branch unlinked"
+BUG_PRESENCE_ADDED = "bug presence added"
+BUG_PRESENCE_REMOVED = "bug presence removed"
 BUG_WATCH_ADDED = "bug watch added"
 BUG_WATCH_REMOVED = "bug watch removed"
 CHANGED_DUPLICATE_MARKER = "changed duplicate marker"
@@ -328,6 +332,58 @@ class SeriesNominated(BugChangeBase):
         return None
 
 
+class BugPresenceAdded(BugChangeBase):
+    """A bug presence was added to the bug."""
+
+    def __init__(self, when, person, bug_presence):
+        super().__init__(when, person)
+        self.bug_presence = bug_presence
+
+    def getBugActivity(self):
+        """See `IBugChange`."""
+        return dict(
+            whatchanged=BUG_PRESENCE_ADDED,
+            newvalue=str(self.bug_presence.break_fix_data),
+        )
+
+    def getBugNotification(self):
+        """See `IBugChange`."""
+        return {
+            "text": "** Bug presence added: "
+            f"project: {self.bug_presence.project}"
+            f"distribution: {self.bug_presence.distribution}"
+            f"source_package_name: {self.bug_presence.source_package_name}"
+            f"gitrepository: {self.bug_presence.git_repository}"
+            f"break_fix_data: {self.bug_presence.break_fix_data}\n"
+        }
+
+
+class BugPresenceRemoved(BugChangeBase):
+    """A bug presence was removed from the bug."""
+
+    def __init__(self, when, person, bug_presence):
+        super().__init__(when, person)
+        self.bug_presence = bug_presence
+
+    def getBugActivity(self):
+        """See `IBugChange`."""
+        return dict(
+            whatchanged=BUG_PRESENCE_REMOVED,
+            newvalue=str(self.bug_presence.break_fix_data),
+        )
+
+    def getBugNotification(self):
+        """See `IBugChange`."""
+        return {
+            "text": "** Bug presence removed: "
+            f"project: {self.bug_presence.project}"
+            f"distribution: {self.bug_presence.distribution}"
+            f"source_package_name: {self.bug_presence.source_package_name}"
+            f"gitrepository: {self.bug_presence.git_repository}"
+            f"break_fix_data: {self.bug_presence.break_fix_data}\n"
+        }
+
+
 class BugWatchAdded(BugChangeBase):
     """A bug watch was added to the bug."""
 
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index 8ac1fcd..057cfdf 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -807,6 +807,7 @@
                 permission="launchpad.Append"
                 interface="lp.bugs.interfaces.bug.IBugAppend"
                 attributes="
+                    presences
                     linkBranch
                     unlinkBranch"/>
             <require
@@ -933,6 +934,42 @@
         for="lp.bugs.interfaces.bugsubscription.IBugSubscription                     lazr.lifecycle.interfaces.IObjectModifiedEvent"
         handler="lp.bugs.subscribers.bugactivity.record_bugsubscription_edited"/>
 
+        <!-- BugPresence -->
+
+        <class
+            class="lp.bugs.model.bugpresence.BugPresence">
+            <implements
+                interface="lp.bugs.interfaces.bugpresence.IBugPresence"/>
+            <require
+                permission="launchpad.View"
+                set_schema="lp.bugs.interfaces.bugpresence.IBugPresence"
+                attributes="
+                    id
+                    bug
+                    project
+                    distribution
+                    source_package_name
+                    git_repository
+                    break_fix_data"/>
+            <require
+                permission="launchpad.Edit"
+                attributes="destroySelf"/>
+        </class>
+
+        <!-- BugPresenceSet -->
+
+        <class
+            class="lp.bugs.model.bugpresence.BugPresenceSet">
+            <allow
+                interface="lp.bugs.interfaces.bugpresence.IBugPresenceSet"/>
+        </class>
+        <lp:securedutility
+            class="lp.bugs.model.bugpresence.BugPresenceSet"
+            provides="lp.bugs.interfaces.bugpresence.IBugPresenceSet">
+            <allow
+                interface="lp.bugs.interfaces.bugpresence.IBugPresenceSet"/>
+        </lp:securedutility>
+
         <!-- BugWatch -->
 
         <class
diff --git a/lib/lp/bugs/interfaces/bug.py b/lib/lp/bugs/interfaces/bug.py
index 5135dee..390dd0a 100644
--- a/lib/lp/bugs/interfaces/bug.py
+++ b/lib/lp/bugs/interfaces/bug.py
@@ -912,6 +912,34 @@ class IBugAppend(Interface):
         :is_patch: A boolean.
         """
 
+    def addPresence(
+        project,
+        distribution,
+        source_package_name,
+        git_repository,
+        break_fix_data,
+        user,
+    ):
+        """Add a bug presence to this bug.
+
+        :project: The project where this BugPresence is related to.
+        :distribution: The distribution where this BugPresence is related to.
+        :source_package_name: The source_package_name where this BugPresence is
+            related to.
+        :git_repository: The git_repository where this BugPresence is related
+            to.
+        :break_fix_data: The dict of commits that caused the issue (break) and
+            commits that solved it (fix).
+        :user: The user that adds this bug presence.
+        """
+
+    def removePresence(bug_presence, user):
+        """Remove a bug presence from the bug.
+
+        :bug_presence: The bug presence to be removed.
+        :user: The user that removes the bug presence.
+        """
+
     def addCommentNotification(
         message,
         recipients=None,
diff --git a/lib/lp/bugs/interfaces/bugpresence.py b/lib/lp/bugs/interfaces/bugpresence.py
new file mode 100644
index 0000000..bbba90d
--- /dev/null
+++ b/lib/lp/bugs/interfaces/bugpresence.py
@@ -0,0 +1,48 @@
+# Copyright 2009-2025 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""BugPresence interfaces"""
+
+__all__ = [
+    "IBugPresence",
+    "IBugPresenceSet",
+]
+
+from zope.interface import Interface
+from zope.schema import Dict, Int
+
+from lp import _
+from lp.services.fields import BugField
+
+
+class IBugPresence(Interface):
+    """A single `BugPresence` database entry."""
+
+    id = Int(title=_("ID"), required=True, readonly=True)
+    bug = BugField(title=_("Bug"), readonly=True)
+    project = Int(title=_("Project"))
+    distribution = Int(title=_("Distribution"))
+    source_package_name = Int(title=_("Source Package Name"))
+    git_repository = Int(title=_("Git Repository"))
+    break_fix_data = Dict(title=_("Break-Fix"))
+
+    def destroySelf(self):
+        """Destroy this `IBugPresence` object."""
+
+
+class IBugPresenceSet(Interface):
+    """The set of `IBugPresence` objects."""
+
+    def __getitem__(id):
+        """Get a `IBugPresence` by id."""
+
+    def create(
+        id,
+        bug,
+        project,
+        distribution,
+        source_package_name,
+        git_repository,
+        break_fix_data,
+    ):
+        """Create a new `IBugPresence`."""
diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py
index 989abe8..d5af37d 100644
--- a/lib/lp/bugs/model/bug.py
+++ b/lib/lp/bugs/model/bug.py
@@ -86,6 +86,8 @@ from lp.bugs.adapters.bugchange import (
     BugDuplicateChange,
     BugLocked,
     BugLockReasonSet,
+    BugPresenceAdded,
+    BugPresenceRemoved,
     BugUnlocked,
     BugWatchAdded,
     BugWatchRemoved,
@@ -114,6 +116,7 @@ from lp.bugs.interfaces.bugnomination import (
     NominationSeriesObsoleteError,
 )
 from lp.bugs.interfaces.bugnotification import IBugNotificationSet
+from lp.bugs.interfaces.bugpresence import IBugPresenceSet
 from lp.bugs.interfaces.bugtarget import ISeriesBugTarget
 from lp.bugs.interfaces.bugtask import (
     UNRESOLVED_BUGTASK_STATUSES,
@@ -135,6 +138,7 @@ from lp.bugs.model.buglinktarget import ObjectLinkedEvent, ObjectUnlinkedEvent
 from lp.bugs.model.bugmessage import BugMessage
 from lp.bugs.model.bugnomination import BugNomination
 from lp.bugs.model.bugnotification import BugNotification
+from lp.bugs.model.bugpresence import BugPresence
 from lp.bugs.model.bugsubscription import BugSubscription
 from lp.bugs.model.bugtarget import OfficialBugTag
 from lp.bugs.model.bugtask import BugTask, bugtask_sort_key
@@ -836,6 +840,15 @@ class Bug(StormBase, InformationTypeMixin):
         return sorted(tasks, key=bugtask_sort_key)
 
     @property
+    def presences(self):
+        """See `IBug`."""
+        store = Store.of(self)
+        presences = list(
+            store.find(BugPresence, BugPresence.bug_id == self.id)
+        )
+        return presences
+
+    @property
     def default_bugtask(self):
         """See `IBug`."""
         from lp.registry.model.product import Product
@@ -1719,6 +1732,32 @@ class Bug(StormBase, InformationTypeMixin):
             send_notifications=send_notifications,
         )
 
+    def addPresence(
+        self,
+        project,
+        distribution,
+        source_package_name,
+        git_repository,
+        break_fix_data,
+        user,
+    ):
+        """See `IBug`."""
+        bug_presence = getUtility(IBugPresenceSet).create(
+            bug=self,
+            project=project,
+            distribution=distribution,
+            source_package_name=source_package_name,
+            git_repository=git_repository,
+            break_fix_data=break_fix_data,
+        )
+        self.addChange(BugPresenceAdded(UTC_NOW, user, bug_presence))
+        return bug_presence
+
+    def removePresence(self, bug_presence, user):
+        """See `IBug`."""
+        self.addChange(BugPresenceRemoved(UTC_NOW, user, bug_presence))
+        bug_presence.destroySelf()
+
     def linkBranch(self, branch, registrant):
         """See `IBug`."""
         if branch in self.linked_branches:
diff --git a/lib/lp/bugs/model/bugpresence.py b/lib/lp/bugs/model/bugpresence.py
new file mode 100644
index 0000000..32f87d3
--- /dev/null
+++ b/lib/lp/bugs/model/bugpresence.py
@@ -0,0 +1,114 @@
+# Copyright 2009-2025 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "BugPresence",
+    "BugPresenceSet",
+]
+
+from storm.databases.postgres import JSON
+from storm.locals import Int
+from storm.references import Reference
+from storm.store import Store
+from zope.interface import implementer
+
+from lp.bugs.interfaces.bugpresence import IBugPresence, IBugPresenceSet
+from lp.services.database.interfaces import IStore
+from lp.services.database.stormbase import StormBase
+
+
+@implementer(IBugPresence)
+class BugPresence(StormBase):
+    """
+    Points in the code history of various entities like a project, a
+    distribution, or a distribution source package when something was broken
+    and/or when it was fixed.
+    """
+
+    __storm_table__ = "bugpresence"
+
+    id = Int(primary=True)
+
+    bug_id = Int(name="bug", allow_none=False)
+    bug = Reference(bug_id, "Bug.id")
+
+    project_id = Int(name="project", allow_none=True)
+    # Is this product or project: dbmodels says project but we dont have the
+    # project entity
+    project = Reference(project_id, "Product.id")
+
+    distribution_id = Int(name="distribution", allow_none=True)
+    distribution = Reference(distribution_id, "Distribution.id")
+
+    source_package_name_id = Int(name="source_package_name", allow_none=True)
+    source_package_name = Reference(
+        source_package_name_id, "SourcePackageName.id"
+    )
+
+    git_repository_id = Int(name="git_repository", allow_none=True)
+    git_repository = Reference(git_repository_id, "GitRepository.id")
+
+    _break_fix_data = JSON(name="break_fix_data", allow_none=False)
+
+    def __init__(
+        self,
+        bug,
+        project,
+        distribution,
+        source_package_name,
+        git_repository,
+        break_fix_data,
+    ):
+        super().__init__()
+        self.bug = bug
+        self.project = project
+        self.distribution = distribution
+        self.source_package_name = source_package_name
+        self.git_repository = git_repository
+        self._break_fix_data = break_fix_data
+
+    @property
+    def break_fix_data(self):
+        """See `IBugPresence`."""
+        return self._break_fix_data or {}
+
+    @break_fix_data.setter
+    def break_fix_data(self, value):
+        """See `IBugPresence`."""
+        assert value is None or isinstance(value, list)
+        self._break_fix_data = value
+
+    def destroySelf(self):
+        """See `IBugPresence`."""
+        Store.of(self).remove(self)
+
+
+@implementer(IBugPresenceSet)
+class BugPresenceSet:
+    """The set of `IBugPresence` objects."""
+
+    def __getitem__(id):
+        """See IBugPresenceSet."""
+        return IStore(BugPresence).find(BugPresence, id=id).one()
+
+    def create(
+        id,
+        bug,
+        project,
+        distribution,
+        source_package_name,
+        git_repository,
+        break_fix_data,
+    ):
+        """See IBugPresenceSet."""
+        bug_presence = BugPresence(
+            bug=bug,
+            project=project,
+            distribution=distribution,
+            source_package_name=source_package_name,
+            git_repository=git_repository,
+            break_fix_data=break_fix_data,
+        )
+
+        IStore(BugPresence).add(bug_presence)
+        return bug_presence
diff --git a/lib/lp/bugs/scripts/tests/test_uct.py b/lib/lp/bugs/scripts/tests/test_uct.py
index a3b35dd..bf43d4d 100644
--- a/lib/lp/bugs/scripts/tests/test_uct.py
+++ b/lib/lp/bugs/scripts/tests/test_uct.py
@@ -373,6 +373,14 @@ class TestCVE(TestCaseWithFactory):
                                 "commit/456"
                             ),
                         ),
+                        UCTRecord.Patch(
+                            patch_type="break-fix",
+                            entry=(
+                                "457f44363a8894135c85b7a9afd2bd8196db24ab "
+                                "c25b2ae136039ffa820c26138ed4a5e5f3ab3841|"
+                                "local-CVE-2022-23222-fix"
+                            ),
+                        ),
                     ],
                 ),
                 UCTRecord.Package(
@@ -546,6 +554,16 @@ class TestCVE(TestCaseWithFactory):
                     notes=None,
                 ),
             ],
+            break_fix_data=[
+                CVE.BreakFix(
+                    package_name=dsp1.sourcepackagename,
+                    break_="457f44363a8894135c85b7a9afd2bd8196db24ab",
+                    fix=(
+                        "c25b2ae136039ffa820c26138ed4a5e5f3ab3841|"
+                        "local-CVE-2022-23222-fix"
+                    ),
+                ),
+            ],
             global_tags={"cisa-kev"},
         )
 
@@ -603,6 +621,43 @@ class TestCVE(TestCaseWithFactory):
             ),
         )
 
+    def test_get_break_fix(self):
+        spn = self.factory.makeSourcePackageName()
+        self.assertListEqual(
+            [
+                CVE.BreakFix(
+                    package_name=spn,
+                    break_="d2406291483775ecddaee929231a39c70c08fda2",
+                    fix="f64e67e5d3a45a4a04286c47afade4b518acd47b",
+                ),
+                CVE.BreakFix(
+                    package_name=spn,
+                    break_="-",
+                    fix="f2ef6f7539c68c6bd6c32323d8845ee102b7c450",
+                ),
+            ],
+            list(
+                CVE.get_break_fix(
+                    spn,
+                    [
+                        UCTRecord.Patch(
+                            "break-fix",
+                            "d2406291483775ecddaee929231a39c70c08fda2 "
+                            "f64e67e5d3a45a4a04286c47afade4b518acd47b",
+                        ),
+                        UCTRecord.Patch(
+                            "break-fix",
+                            "- f2ef6f7539c68c6bd6c32323d8845ee102b7c450",
+                        ),
+                        UCTRecord.Patch(
+                            "upstream", "https://github.com/repo/2 (1.2.3)"
+                        ),
+                        UCTRecord.Patch("other", "foo"),
+                    ],
+                )
+            ),
+        )
+
 
 class TestUCTImporterExporter(TestCaseWithFactory):
     maxDiff = None
@@ -791,6 +846,16 @@ class TestUCTImporterExporter(TestCaseWithFactory):
                     notes=None,
                 ),
             ],
+            break_fix_data=[
+                CVE.BreakFix(
+                    package_name=self.ubuntu_package.sourcepackagename,
+                    break_="457f44363a8894135c85b7a9afd2bd8196db24ab",
+                    fix=(
+                        "c25b2ae136039ffa820c26138ed4a5e5f3ab3841|"
+                        "local-CVE-2022-23222-fix"
+                    ),
+                ),
+            ],
             global_tags={"cisa-kev"},
         )
         self.importer = UCTImporter()
@@ -814,6 +879,7 @@ class TestUCTImporterExporter(TestCaseWithFactory):
 
         self.checkBugTags(bug, cve)
         self.checkBugAttachments(bug, cve)
+        self.checkBugPresences(bug, cve)
 
     def checkBugTags(self, bug: Bug, cve: CVE):
         tags = cve.global_tags.copy()
@@ -893,6 +959,51 @@ class TestUCTImporterExporter(TestCaseWithFactory):
         for t in bug_tasks:
             self.assertEqual(cve.assignee, t.assignee)
 
+    def checkBugPresences(self, bug: Bug, cve: CVE):
+        presences_by_pkg = {
+            presence.source_package_name: presence
+            for presence in bug.presences
+        }
+        break_fix_by_pkg = defaultdict(list)
+        for break_fix in cve.break_fix_data:
+            break_fix_by_pkg[break_fix.package_name].append(break_fix)
+
+        self.assertEqual(
+            len(bug.presences),
+            len(break_fix_by_pkg),
+            "Mismatch in presences count and break_fix count",
+        )
+
+        for package, break_fix_data in break_fix_by_pkg.items():
+            presence = presences_by_pkg.get(package)
+
+            self.assertIsNotNone(
+                presence, f"Presence for package '{package}' not found"
+            )
+
+            self.assertEqual(package, presence.source_package_name)
+            self.assertEqual(
+                len(break_fix_data),
+                len(presence.break_fix_data),
+                "Number of break_fix_data don't match for package "
+                f"'{package}'",
+            )
+
+            # Check content and its order
+            for break_fix, presence_break_fix in zip(
+                break_fix_data, presence.break_fix_data
+            ):
+                self.assertEqual(
+                    break_fix.break_,
+                    presence_break_fix["break"],
+                    f"Break mismatch for patch in package '{package}'",
+                )
+                self.assertEqual(
+                    break_fix.fix,
+                    presence_break_fix["fix"],
+                    f"Fix mismatch for patch in package '{package}'",
+                )
+
     def checkBugAttachments(self, bug: Bug, cve: CVE):
         attachments_by_url = {a.url: a for a in bug.attachments if a.url}
         for patch_url in cve.patch_urls:
@@ -981,6 +1092,7 @@ class TestUCTImporterExporter(TestCaseWithFactory):
         self.assertEqual(expected.mitigation, actual.mitigation)
         self.assertListEqual(expected.cvss, actual.cvss)
         self.assertListEqual(expected.patch_urls, actual.patch_urls)
+        self.assertListEqual(expected.break_fix_data, actual.break_fix_data)
         self.assertEqual(expected.global_tags, actual.global_tags)
 
     def test_create_bug(self):
@@ -993,7 +1105,8 @@ class TestUCTImporterExporter(TestCaseWithFactory):
         self.assertEqual([self.lp_cve], bug.cves)
 
         activities = list(bug.activity)
-        self.assertEqual(6, len(activities))
+        # We are adding a bug presence so we add 1 activity
+        self.assertEqual(7, len(activities))
         import_bug_activity = activities[-1]
         self.assertEqual(self.bug_importer, import_bug_activity.person)
         self.assertEqual("bug", import_bug_activity.whatchanged)
@@ -1079,6 +1192,7 @@ class TestUCTImporterExporter(TestCaseWithFactory):
                 ),
             ],
             patch_urls=[],
+            break_fix_data=[],
             global_tags={"cisa-kev"},
         )
         lp_cve = self.factory.makeCVE(sequence="2022-1234")
@@ -1341,6 +1455,37 @@ class TestUCTImporterExporter(TestCaseWithFactory):
         self.importer.update_bug(bug, cve, self.lp_cve)
         self.checkBug(bug, cve)
 
+    def test_update_break_fix(self):
+        bug = self.importer.create_bug(self.cve, self.lp_cve)
+        cve = self.cve
+
+        # Add new patch URL
+        cve.break_fix_data.append(
+            CVE.BreakFix(
+                package_name=cve.distro_packages[0].package_name,
+                break_="d2406291483775ecddaee929231a39c70c08fda2",
+                fix=(
+                    "f64e67e5d3a45a4a04286c47afade4b518acd47b"
+                    "|cc8c837cf1b2f714dda723541c04acd1b8922d92"
+                ),
+            ),
+        )
+        cve.break_fix_data.append(
+            CVE.BreakFix(
+                package_name=cve.distro_packages[1].package_name,
+                break_="-",
+                fix="cffe487026be13eaf37ea28b783d9638ab147204",
+            ),
+        )
+        self.importer.update_bug(bug, cve, self.lp_cve)
+        self.checkBug(bug, cve)
+
+        # Remove break_fix and check if it removes from bug
+        cve.break_fix_data.pop()
+        cve.break_fix_data.pop()
+        self.importer.update_bug(bug, cve, self.lp_cve)
+        self.checkBug(bug, cve)
+
     def test_update_tags(self):
         bug = self.importer.create_bug(self.cve, self.lp_cve)
         cve = self.cve
diff --git a/lib/lp/bugs/scripts/uct/models.py b/lib/lp/bugs/scripts/uct/models.py
index daceec2..209d44e 100644
--- a/lib/lp/bugs/scripts/uct/models.py
+++ b/lib/lp/bugs/scripts/uct/models.py
@@ -466,6 +466,11 @@ class CVE:
         url: str
         notes: Optional[str]
 
+    class BreakFix(NamedTuple):
+        package_name: SourcePackageName
+        break_: str
+        fix: str
+
     # Example:
     # https://github.com/389ds/389-ds-base/commit/123 (1.4.4)
     # https://github.com/389ds/389-ds-base/commit/345
@@ -525,6 +530,7 @@ class CVE:
         mitigation: str,
         cvss: List[CVSS],
         global_tags: Set[str],
+        break_fix_data: List[BreakFix],
         patch_urls: Optional[List[PatchURL]] = None,
         importance_explanation: str = "",
     ):
@@ -549,6 +555,7 @@ class CVE:
         self.cvss = cvss
         self.global_tags = global_tags
         self.patch_urls: List[CVE.PatchURL] = patch_urls or []
+        self.break_fix_data: List[CVE.BreakFix] = break_fix_data or []
 
     @classmethod
     def make_from_uct_record(cls, uct_record: UCTRecord) -> "CVE":
@@ -561,6 +568,7 @@ class CVE:
         distro_packages = []
         series_packages = []
         patch_urls = []
+        break_fix_data = []
 
         spn_set = getUtility(ISourcePackageNameSet)
 
@@ -575,6 +583,10 @@ class CVE:
                 cls.get_patch_urls(source_package_name, uct_package.patches)
             )
 
+            break_fix_data.extend(
+                cls.get_break_fix(source_package_name, uct_package.patches)
+            )
+
             package_importance = (
                 cls.PRIORITY_MAP[uct_package.priority]
                 if uct_package.priority
@@ -697,6 +709,7 @@ class CVE:
             cvss=uct_record.cvss,
             global_tags=uct_record.global_tags,
             patch_urls=patch_urls,
+            break_fix_data=break_fix_data,
         )
 
     def to_uct_record(self) -> UCTRecord:
@@ -797,6 +810,14 @@ class CVE:
                 )
             )
 
+        for break_fix in self.break_fix_data:
+            packages_by_name[patch_url.package_name.name].patches.append(
+                UCTRecord.Patch(
+                    patch_type="break-fix",
+                    entry=f"{break_fix.break_} {break_fix.fix}",
+                )
+            )
+
         return UCTRecord(
             parent_dir=self.VULNERABILITY_STATUS_MAP_REVERSE.get(
                 self.status, ""
@@ -911,3 +932,27 @@ class CVE:
                 url=url,
                 notes=notes,
             )
+
+    @classmethod
+    def get_break_fix(
+        cls,
+        source_package_name: SourcePackageName,
+        patches: List[UCTRecord.Patch],
+    ) -> Iterable[PatchURL]:
+        for patch in patches:
+            if patch.patch_type != "break-fix":
+                continue
+
+            if " " not in patch.entry:
+                logger.warning(
+                    "Could not parse the break-fix patch entry: %s",
+                    patch.entry,
+                )
+                continue
+
+            break_, fix = patch.entry.split(maxsplit=1)
+            yield cls.BreakFix(
+                package_name=source_package_name,
+                break_=break_,
+                fix=fix,
+            )
diff --git a/lib/lp/bugs/scripts/uct/uctexport.py b/lib/lp/bugs/scripts/uct/uctexport.py
index fd7df39..80462e4 100644
--- a/lib/lp/bugs/scripts/uct/uctexport.py
+++ b/lib/lp/bugs/scripts/uct/uctexport.py
@@ -239,6 +239,17 @@ class UCTExporter:
                 )
             )
 
+        break_fix_data = []
+        for bugpresence in bug.presences:
+            for break_fix in bugpresence.break_fix_data:
+                break_fix_data.append(
+                    CVE.BreakFix(
+                        package_name=bugpresence.source_package_name,
+                        break_=break_fix.get("break"),
+                        fix=break_fix.get("fix"),
+                    )
+                )
+
         return CVE(
             sequence=f"CVE-{lp_cve.sequence}",
             date_made_public=vulnerability.date_made_public,
@@ -268,6 +279,7 @@ class UCTExporter:
             ],
             global_tags=global_tags,
             patch_urls=patch_urls,
+            break_fix_data=break_fix_data,
         )
 
     def _parse_bug_description(
diff --git a/lib/lp/bugs/scripts/uct/uctimport.py b/lib/lp/bugs/scripts/uct/uctimport.py
index a4abb5e..a6172fd 100644
--- a/lib/lp/bugs/scripts/uct/uctimport.py
+++ b/lib/lp/bugs/scripts/uct/uctimport.py
@@ -25,6 +25,7 @@ Three types of bug tasks are created:
    status of the package in upstream.
 """
 import logging
+from collections import defaultdict
 from datetime import timezone
 from itertools import chain
 from pathlib import Path
@@ -178,6 +179,7 @@ class UCTImporter:
 
         self._update_external_bug_urls(bug, cve.bug_urls)
         self._update_patches(bug, cve.patch_urls)
+        self._update_break_fix(bug, cve.break_fix_data)
         self._update_tags(bug, cve.global_tags, cve.distro_packages)
 
         self._create_bug_tasks(
@@ -236,6 +238,7 @@ class UCTImporter:
         self._assign_bug_tasks(bug, cve.assignee)
         self._update_external_bug_urls(bug, cve.bug_urls)
         self._update_patches(bug, cve.patch_urls)
+        self._update_break_fix(bug, cve.break_fix_data)
         self._update_tags(bug, cve.global_tags, cve.distro_packages)
 
         # Update or add new Vulnerabilities
@@ -482,6 +485,35 @@ class UCTImporter:
                     description=title,
                 )
 
+    def _update_break_fix(
+        self, bug: BugModel, break_fix_data: List[CVE.BreakFix]
+    ):
+        break_fix_by_pkg = defaultdict(list)
+        for break_fix in break_fix_data:
+            break_fix_by_pkg[break_fix.package_name].append(
+                {"break": break_fix.break_, "fix": break_fix.fix}
+            )
+
+        for presence in bug.presences:
+            if break_fix := break_fix_by_pkg.pop(
+                presence.source_package_name, None
+            ):
+                presence.break_fix_data = break_fix
+            else:
+                # Remove non existing
+                bug.removePresence(presence, self.bug_importer)
+
+        for package in break_fix_by_pkg:
+            # Create new presence
+            bug.addPresence(
+                project=None,
+                distribution=None,
+                source_package_name=package,
+                git_repository=None,
+                break_fix_data=break_fix_by_pkg[package],
+                user=self.bug_importer,
+            )
+
     def _make_bug_description(self, cve: CVE) -> str:
         """
         Some `CVE` fields can't be mapped to Launchpad models.