Skip to content

Commit 1c4ce7f

Browse files
feat: add config versioning with parent tracking and version history timeline
Adds bundle lineage tracking via parent_bundle_id and a dedicated version history page showing config evolution with inline diff summaries between consecutive versions.
1 parent 1db940d commit 1c4ce7f

10 files changed

Lines changed: 662 additions & 14 deletions

File tree

lib/sentinel_cp/bundles.ex

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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}

lib/sentinel_cp/bundles/bundle.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ defmodule SentinelCp.Bundles.Bundle do
3434
field :sbom_format, :string
3535

3636
belongs_to :project, SentinelCp.Projects.Project
37+
belongs_to :parent_bundle, SentinelCp.Bundles.Bundle
3738

3839
timestamps(type: :utc_datetime)
3940
end
@@ -49,7 +50,8 @@ defmodule SentinelCp.Bundles.Bundle do
4950
:source_type,
5051
:source_ref,
5152
:source_branch,
52-
:source_repo
53+
:source_repo,
54+
:parent_bundle_id
5355
])
5456
|> validate_required([:version, :config_source, :project_id])
5557
|> validate_length(:version, min: 1, max: 100)
@@ -58,6 +60,7 @@ defmodule SentinelCp.Bundles.Bundle do
5860
|> put_change(:status, "pending")
5961
|> unique_constraint([:project_id, :version])
6062
|> foreign_key_constraint(:project_id)
63+
|> foreign_key_constraint(:parent_bundle_id)
6164
end
6265

6366
def compilation_changeset(bundle, attrs) do

lib/sentinel_cp_web/graphql/types/bundle.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ defmodule SentinelCpWeb.GraphQL.Types.Bundle do
1414
end
1515

1616
field :size_bytes, :integer
17+
field :risk_level, :string
18+
field :parent_bundle_id, :id
1719
field :inserted_at, non_null(:datetime)
1820
end
1921

0 commit comments

Comments
 (0)