@@ -7,13 +7,17 @@ defmodule SentinelCp.Bundles do
77 """
88
99 import Ecto.Query , warn: false
10+ alias SentinelCp.Bundles . { Bundle , Diff }
1011 alias SentinelCp.Repo
11- alias SentinelCp.Bundles.Bundle
1212
1313 @ doc """
1414 Creates a bundle and enqueues compilation.
15+
16+ Automatically links the new bundle to the latest compiled bundle
17+ for the same project as its parent.
1518 """
1619 def create_bundle ( attrs ) do
20+ attrs = maybe_link_parent ( attrs )
1721 changeset = Bundle . create_changeset ( % Bundle { } , attrs )
1822
1923 case Repo . insert ( changeset ) do
@@ -26,6 +30,19 @@ defmodule SentinelCp.Bundles do
2630 end
2731 end
2832
33+ defp maybe_link_parent ( attrs ) do
34+ project_id = attrs [ :project_id ] || attrs [ "project_id" ]
35+
36+ if project_id do
37+ case get_latest_bundle ( project_id ) do
38+ % Bundle { id: parent_id } -> Map . put ( attrs , :parent_bundle_id , parent_id )
39+ nil -> attrs
40+ end
41+ else
42+ attrs
43+ end
44+ end
45+
2946 @ doc """
3047 Lists bundles for a project, ordered by most recent first.
3148 """
@@ -167,6 +184,70 @@ defmodule SentinelCp.Bundles do
167184 { :error , :cannot_delete_active_bundle }
168185 end
169186
187+ @ doc """
188+ Gets a bundle by ID with its parent bundle preloaded.
189+ """
190+ def get_bundle_with_parent ( id ) do
191+ Bundle
192+ |> Repo . get ( id )
193+ |> Repo . preload ( :parent_bundle )
194+ end
195+
196+ @ doc """
197+ Returns the chronologically previous compiled bundle for the same project.
198+ """
199+ def get_previous_bundle ( bundle , project_id ) do
200+ from ( b in Bundle ,
201+ where: b . project_id == ^ project_id ,
202+ where: b . status == "compiled" ,
203+ where: b . id != ^ bundle . id ,
204+ where:
205+ b . inserted_at < ^ bundle . inserted_at or
206+ ( b . inserted_at == ^ bundle . inserted_at and b . id < ^ bundle . id ) ,
207+ order_by: [ desc: b . inserted_at , desc: b . id ] ,
208+ limit: 1
209+ )
210+ |> Repo . one ( )
211+ end
212+
213+ @ doc """
214+ Returns bundle version history for a project with diff summaries.
215+
216+ Lists compiled and failed bundles ordered by insertion time (newest first).
217+ For each consecutive pair, precomputes diff stats and semantic summaries.
218+
219+ Returns `{bundles, diff_summaries}` where `diff_summaries` is a map of
220+ `bundle_id => %{stats: ..., semantic: ...}`.
221+ """
222+ def list_bundle_history ( project_id , opts \\ [ ] ) do
223+ limit = Keyword . get ( opts , :limit , 100 )
224+
225+ bundles =
226+ from ( b in Bundle ,
227+ where: b . project_id == ^ project_id ,
228+ where: b . status in [ "compiled" , "failed" , "superseded" , "revoked" ] ,
229+ order_by: [ desc: b . inserted_at , desc: b . id ] ,
230+ limit: ^ limit
231+ )
232+ |> Repo . all ( )
233+
234+ diff_summaries = compute_diff_summaries ( bundles )
235+
236+ { bundles , diff_summaries }
237+ end
238+
239+ defp compute_diff_summaries ( bundles ) do
240+ bundles
241+ |> Enum . chunk_every ( 2 , 1 , :discard )
242+ |> Enum . reduce ( % { } , fn [ newer , older ] , acc ->
243+ config_diff = Diff . config_diff ( older , newer )
244+ stats = Diff . diff_stats ( config_diff )
245+ semantic = Diff . semantic_diff ( older , newer )
246+
247+ Map . put ( acc , newer . id , % { stats: stats , semantic: semantic } )
248+ end )
249+ end
250+
170251 # Enqueue compilation via Oban
171252 defp enqueue_compilation ( bundle ) do
172253 % { bundle_id: bundle . id }
0 commit comments