Tell an issue which release carried it

04bd030ceedc · AtlantisPleb · · parent 3011a18599e3

Tell an issue which release carried it

The issue-to-receipt chain already existed and already joined, but it
joined on an exact revision. `Forge.receipts_for/2` matches a fleet target
by comparing shas, which is right for a build receipt and wrong for a
release: a release is promoted at the revision the fleet converges to, and
the commit that closed an issue is an ancestor of it, never it. So a reader
could see that an issue's commit was pushed and built, and could not see
that it shipped.

`OpenAgents.Issues.Releases` answers the missing half by asking git the
question a sha comparison cannot: is the issue's closing commit contained
in the release's revision. It stores nothing — containment is a property of
the commit graph the forge already holds, and a copy of it would be a
second authority that could disagree with git.

It reads only `issue_closing_references`, not an attempt's self-reported
terminal commit. Everything it returns is derived from commits on the
default branch of a repository the caller was already admitted to read, so
it needs no second disclosure ladder.

`GitPlane.containing/3` is the primitive underneath: `ancestor?/3` pays a
WAL freshness check per pair, and a matrix that is mostly zeroes should pay
one for the whole read. It is bounded at 64 pairs; `Releases` stays inside
that with at most four claiming commits against twelve recent targets, and
reports `truncated` so a caller can tell "nothing shipped it" from "the
window did not reach far enough".

The answer rides the issue activity endpoint, which already carries the
threads and receipts that name an issue and already reads the same closing
references, so the two halves cannot disagree about which commits the issue
claims. No new route, no new table, no per-row cost on the issue list.

Proved against a real bare forge repository with a real commit graph and
real promoted targets: a release at a descendant carries the commit, one
promoted before it does not, an unmerged side branch is carried by nothing,
a promoted-but-not-live target is listed but never `released_in`, and
`released_in` is the oldest live release containing every claiming commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 388 · 2026-08-25T14:52:42.920069Z

Changed files

  • modified lib/openagents/forge/git_plane.ex
  • modified lib/openagents/issues/activity.ex
  • added lib/openagents/issues/releases.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified test/openagents/forge/git_plane_test.exs
  • added test/openagents/issues/releases_test.exs

Diff

6 files changed, +674 -11

lib/openagents/forge/git_plane.ex modified +54

@@ -24,6 +24,11 @@ defmodule OpenAgents.Forge.GitPlane do

24 24
  @oid_pattern ~r/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
25 25
  @ref_pattern ~r|\Arefs/[A-Za-z0-9][A-Za-z0-9._/-]{0,200}\z|
26 26
  @segment_pattern ~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,63}\z/
27
  # The most ancestry pairs one `containing/3` read may ask git for. A read
28
  # path that spawns a subprocess per pair needs a ceiling, and this one is
29
  # generous for the question it answers: a handful of commits against a
30
  # window of recent release revisions.
31
  @ancestry_pair_limit 64
27 32
  @committer_name "OpenAgents Forge"
28 33
  @committer_email "forge@openagents.com"
29 34

@@ -99,6 +104,55 @@ defmodule OpenAgents.Forge.GitPlane do

99 104
    end
100 105
  end
101 106
107
  @doc """
108
  For each commit in `ancestors`, which of `candidates` contain it.
109
110
  `ancestor?/3` answers one pair and pays one WAL freshness check for it.
111
  A caller asking "which release carried this work" holds several commits and
112
  several release revisions at once, and paying a freshness check per pair
113
  would make the answer cost a network round trip per cell of a matrix that
114
  is mostly zeroes. This runs the check once and then one
115
  `merge-base --is-ancestor` per pair.
116
117
  The matrix is bounded at #{@ancestry_pair_limit} pairs. A caller that asks
118
  for more gets `{:error, :too_many_pairs}` rather than an unbounded scan,
119
  because the bound is what keeps a read path a read path.
120
121
  A candidate this repository does not have does not contain anything: git
122
  answers "not an ancestor" for a revision it cannot resolve, and so does
123
  this. The question is "did this ship", and a revision the forge never saw
124
  did not ship it.
125
  """
126
  @spec containing(String.t(), [String.t()], [String.t()]) ::
127
          {:ok, %{String.t() => [String.t()]}} | {:error, term()}
128
  def containing(repo, ancestors, candidates)
129
      when is_binary(repo) and is_list(ancestors) and is_list(candidates) do
130
    if length(ancestors) * length(candidates) > @ancestry_pair_limit do
131
      {:error, :too_many_pairs}
132
    else
133
      with :ok <- Sync.ensure_fresh(repo) do
134
        {:ok,
135
         Map.new(ancestors, fn ancestor ->
136
           {ancestor, Enum.filter(candidates, &contained_by?(repo, ancestor, &1))}
137
         end)}
138
      end
139
    end
140
  end
141
142
  def containing(_repo, _ancestors, _candidates), do: {:error, :not_found}
143
144
  defp contained_by?(repo, ancestor, candidate) do
145
    with :ok <- check_rev(ancestor),
146
         :ok <- check_rev(candidate) do
147
      match?(
148
        {_output, 0},
149
        git(repo, ["merge-base", "--is-ancestor", "--end-of-options", ancestor, candidate])
150
      )
151
    else
152
      _unreadable -> false
153
    end
154
  end
155
102 156
  @doc "The best common ancestor of two commits, or `{:error, :no_merge_base}`."
103 157
  def merge_base(repo, rev_a, rev_b) do
104 158
    with :ok <- check_rev(rev_a),
lib/openagents/issues/activity.ex modified +23 -10

@@ -10,25 +10,37 @@ defmodule OpenAgents.Issues.Activity do

10 10
  target, and deployment receipts. No new work record, no new linkage table, and
11 11
  no new repository authority: the repository is checked once and the thread
12 12
  authority is composed rather than restated.
13
14
  `receipts` answers for the exact commit and stops there, which leaves the
15
  last question a reader actually has unanswered: a fleet target is matched by
16
  comparing shas, and a release is almost never promoted at the commit that
17
  closed the issue. `releases` closes that gap through
18
  `OpenAgents.Issues.Releases`, which asks the commit graph whether the
19
  release's revision contains the issue's commits. It reads the same closing
20
  references this module already reads, so the two halves of the answer can
21
  never disagree about which commits the issue claims.
13 22
  """
14 23
15 24
  alias OpenAgents.Accounts.User
16 25
  alias OpenAgents.Forge
17
  alias OpenAgents.Issues.{ClosingReferences, Issue}
26
  alias OpenAgents.Issues.{ClosingReferences, Issue, Releases}
18 27
  alias OpenAgents.Repositories
19 28
  alias OpenAgents.Repositories.Repository
20 29
  alias OpenAgents.Threads
21 30
22 31
  @doc """
23
  The threads and receipts that name `issue` and that `reader` may read.
32
  The threads, receipts, and releases that name `issue` and that `reader` may
33
  read.
24 34
25
  Returns `%{threads: [...], receipts: [...]}`. An issue with no closing
26
  references or no matching receipts returns an empty `:receipts` list, and an
27
  issue with no readable threads returns an empty `:threads` list.
35
  Returns `%{threads: [...], receipts: [...], releases: %{...}}`. An issue with
36
  no closing references or no matching receipts returns an empty `:receipts`
37
  list, an issue with no readable threads returns an empty `:threads` list, and
38
  an issue no release carried returns `OpenAgents.Issues.Releases.empty/0`.
28 39
  """
29 40
  @spec for_issue(Issue.t(), User.t() | nil) :: %{
30 41
          threads: [Threads.Thread.t()],
31
          receipts: [map()]
42
          receipts: [map()],
43
          releases: Releases.t()
32 44
        }
33 45
  def for_issue(%Issue{} = issue, %User{} = reader), do: do_for_issue(issue, reader)
34 46
  def for_issue(%Issue{} = issue, _reader), do: do_for_issue(issue, nil)

@@ -38,14 +50,15 @@ defmodule OpenAgents.Issues.Activity do

38 50
    repository = visible_repository(issue, reader)
39 51
    threads = if reader, do: Threads.list_for_issue(issue, reader), else: []
40 52
41
    receipts =
53
    {receipts, releases} =
42 54
      if repository do
43
        issue |> ClosingReferences.for_issue() |> receipts_for_references(repository)
55
        {issue |> ClosingReferences.for_issue() |> receipts_for_references(repository),
56
         Releases.for_issue(repository, issue)}
44 57
      else
45
        []
58
        {[], Releases.empty()}
46 59
      end
47 60
48
    %{threads: threads, receipts: receipts}
61
    %{threads: threads, receipts: receipts, releases: releases}
49 62
  end
50 63
51 64
  defp visible_repository(%Issue{repository_id: repository_id}, reader) do
lib/openagents/issues/releases.ex added +249

@@ -0,0 +1,249 @@

1
defmodule OpenAgents.Issues.Releases do
2
  @moduledoc """
3
  Which release carried an issue.
4
5
  The chain from an issue to its receipts already existed and already joined,
6
  but it joined on an exact revision. `OpenAgents.Issues.Evidence` binds an
7
  issue to the receipts that evaluated the precise commit its work produced,
8
  and `OpenAgents.Forge.receipts_for/2` matches a fleet target by comparing
9
  shas. That is the right rule for a build receipt, which evaluated one tree
10
  and no other. It is the wrong rule for a release: a release is promoted at
11
  the revision the fleet should converge to, and the commit that closed an
12
  issue is almost never that revision. It is an ancestor of it.
13
14
  So a reader could see that an issue's commit was pushed and built, and
15
  could not see that it shipped. This module answers the missing half by
16
  asking git the question the sha comparison cannot: is the issue's commit
17
  contained in the release's revision.
18
19
  ## What it reads
20
21
  Two records, both of which already exist:
22
23
    * `issue_closing_references` — the commits that say they close the issue.
24
      `OpenAgents.Issues.ClosingReferences` verified the pusher could write
25
      the issue and required the commit to be reachable from the default
26
      branch before recording one, so a topic branch claims nothing here.
27
    * `forge_fleet_targets` — the operator-approved revisions the fleet was
28
      told to converge to, with the deploy-lane status each reached.
29
30
  It writes nothing. There is no issue-to-release table, because there is no
31
  fact to store: containment is a property of the commit graph the forge
32
  already holds, and a stored copy of it would be a second authority that
33
  could disagree with git.
34
35
  ## Why only closing references
36
37
  `forge_assignments.terminal_commit` is the other commit-to-issue source the
38
  evidence chain reads, and this module deliberately does not. An attempt's
39
  self-reported revision is the executor's claim rather than a merge, and the
40
  evidence chain gates it behind `OpenAgents.Transparency.WorkDisclosure`
41
  because a branch name and a revision an attempt produced can restate private
42
  repository content. Everything this module returns is derived from commits
43
  on the default branch of a repository the caller has already been admitted
44
  to read, so it needs no second disclosure ladder — and a module that needed
45
  one would be the wrong place to add it.
46
47
  ## What it bounds
48
49
  A read path that spawns a git subprocess per pair needs a ceiling in both
50
  directions: at most four claiming commits and at most twelve recent release
51
  targets, which is inside the pair limit
52
  `OpenAgents.Forge.GitPlane.containing/3` enforces. `truncated` says the
53
  window cut something off, so a caller can tell "nothing shipped it" from
54
  "the window did not reach far enough".
55
  """
56
57
  import Ecto.Query
58
59
  require Logger
60
61
  alias OpenAgents.Forge.{GitPlane, Target}
62
  alias OpenAgents.Issues.{ClosingReferences, Issue}
63
  alias OpenAgents.Repo
64
  alias OpenAgents.Repositories.Repository
65
66
  # The newest claiming commits and the newest release targets this read looks
67
  # at. Their product stays inside `GitPlane.containing/3`'s pair limit.
68
  @commit_limit 4
69
  @target_limit 12
70
71
  @typedoc "One release target that contains a commit, projected."
72
  @type release :: %{
73
          id: binary(),
74
          sha: String.t(),
75
          status: String.t(),
76
          promoted_at: DateTime.t(),
77
          settled_at: DateTime.t()
78
        }
79
80
  @typedoc """
81
  The release answer for one issue.
82
83
  `released_in` is the oldest release that reached `live` and contains every
84
  claiming commit in the window — the first release that shipped the whole of
85
  what the issue asked for. It is `nil` when no such release is in the window,
86
  including when only some of the commits shipped.
87
  """
88
  @type t :: %{
89
          commits: [
90
            %{
91
              sha: String.t(),
92
              verb: String.t() | nil,
93
              referenced_at: DateTime.t(),
94
              releases: [release()]
95
            }
96
          ],
97
          released_in: release() | nil,
98
          truncated: boolean()
99
        }
100
101
  @doc """
102
  The releases that carried `issue`, resolved through the commit graph.
103
104
  Never raises. A repository whose bare cache cannot be read, a WAL that is
105
  unreachable, and an issue no commit claims all answer the same way: an empty
106
  list, which is the honest reading of "nothing here says this shipped".
107
  """
108
  @spec for_issue(Issue.t()) :: t()
109
  def for_issue(%Issue{repository_id: repository_id} = issue) do
110
    case Repo.get(Repository, repository_id) do
111
      %Repository{} = repository -> for_issue(repository, issue)
112
      nil -> empty()
113
    end
114
  end
115
116
  @doc """
117
  The releases that carried `issue` in a repository the caller already
118
  resolved.
119
120
  `repository` must be the issue's own repository; a caller that passes
121
  another one gets `empty/0` rather than another repository's releases.
122
  """
123
  @spec for_issue(Repository.t(), Issue.t()) :: t()
124
  def for_issue(%Repository{id: repository_id} = repository, %Issue{} = issue) do
125
    if issue.repository_id == repository_id do
126
      resolve(repository, issue)
127
    else
128
      empty()
129
    end
130
  rescue
131
    error ->
132
      Logger.warning("issue_releases_failed code=#{OpenAgents.OperationalLog.code(error)}")
133
      empty()
134
  end
135
136
  @doc "The answer for an issue nothing claims: no commits, no release."
137
  @spec empty() :: t()
138
  def empty, do: %{commits: [], released_in: nil, truncated: false}
139
140
  # ── internals ────────────────────────────────────────────────────────────
141
142
  defp resolve(%Repository{} = repository, %Issue{} = issue) do
143
    references = issue |> ClosingReferences.for_issue() |> Enum.uniq_by(& &1.commit_sha)
144
    kept = Enum.take(references, @commit_limit)
145
    targets = targets(repository)
146
147
    truncated? = length(references) > @commit_limit or length(targets) == @target_limit
148
    containment = containment(repository, kept, targets)
149
150
    commits =
151
      Enum.map(kept, fn reference ->
152
        %{
153
          sha: reference.commit_sha,
154
          verb: reference.verb,
155
          referenced_at: reference.inserted_at,
156
          releases: containment |> Map.get(reference.commit_sha, []) |> Enum.map(&release/1)
157
        }
158
      end)
159
160
    %{
161
      commits: commits,
162
      released_in: released_in(kept, containment),
163
      truncated: truncated?
164
    }
165
  end
166
167
  # A target names its repository by string, and two strings reach the same
168
  # repository: the storage key the git plane reads and the repository name a
169
  # promotion may have been recorded under. Both are read, and neither is
170
  # allowed to reach a repository other than this one.
171
  defp targets(%Repository{} = repository) do
172
    keys =
173
      [repository.storage_key, repository.name]
174
      |> Enum.reject(&(is_nil(&1) or &1 == ""))
175
      |> Enum.uniq()
176
177
    Target
178
    |> where([target], target.repo in ^keys)
179
    |> order_by([target], desc: target.inserted_at, desc: target.id)
180
    |> limit(@target_limit)
181
    |> Repo.all()
182
  end
183
184
  defp containment(_repository, [], _targets), do: %{}
185
  defp containment(_repository, _references, []), do: %{}
186
187
  defp containment(%Repository{} = repository, references, targets) do
188
    commits = Enum.map(references, & &1.commit_sha)
189
    by_sha = Enum.group_by(targets, & &1.sha)
190
    shas = Map.keys(by_sha)
191
192
    case GitPlane.containing(repository.storage_key, commits, shas) do
193
      {:ok, matrix} ->
194
        Map.new(matrix, fn {commit, matched} ->
195
          {commit,
196
           matched
197
           |> Enum.flat_map(&Map.get(by_sha, &1, []))
198
           |> Enum.sort_by(& &1.inserted_at, DateTime)}
199
        end)
200
201
      # An unreachable WAL, a cache this node cannot read, and a matrix past
202
      # the pair bound are all "this read cannot answer", which is not the
203
      # same fact as "nothing shipped it" — but the caller sees the same empty
204
      # list either way, because an issue page that guessed would be worse
205
      # than one that says nothing.
206
      {:error, reason} ->
207
        Logger.debug(
208
          "issue_releases_containment_unavailable code=#{OpenAgents.OperationalLog.code(reason)}"
209
        )
210
211
        %{}
212
    end
213
  end
214
215
  # The first release that carried the whole issue: the oldest `live` target
216
  # that contains every claiming commit in the window. A target that carried
217
  # some of them is on each of those commits' own lists and is not this.
218
  defp released_in([], _containment), do: nil
219
220
  defp released_in([first | rest], containment) do
221
    carried = Map.get(containment, first.commit_sha, [])
222
223
    shared =
224
      Enum.reduce(rest, MapSet.new(carried, & &1.id), fn reference, acc ->
225
        MapSet.intersection(
226
          acc,
227
          containment |> Map.get(reference.commit_sha, []) |> MapSet.new(& &1.id)
228
        )
229
      end)
230
231
    carried
232
    |> Enum.filter(&(&1.status == "live" and MapSet.member?(shared, &1.id)))
233
    |> Enum.min_by(& &1.inserted_at, DateTime, fn -> nil end)
234
    |> case do
235
      %Target{} = target -> release(target)
236
      nil -> nil
237
    end
238
  end
239
240
  defp release(%Target{} = target) do
241
    %{
242
      id: target.id,
243
      sha: target.sha,
244
      status: target.status,
245
      promoted_at: target.inserted_at,
246
      settled_at: target.updated_at
247
    }
248
  end
249
end
lib/openagents_web/controllers/issue_json.ex modified +35 -1

@@ -47,7 +47,41 @@ defmodule OpenAgentsWeb.IssueJSON do

47 47
48 48
    %{
49 49
      threads: Enum.map(activity.threads, &thread_json(&1, url_base)),
50
      receipts: Enum.map(activity.receipts, &receipt_json/1)
50
      receipts: Enum.map(activity.receipts, &receipt_json/1),
51
      releases: releases_json(Map.get(activity, :releases))
52
    }
53
  end
54
55
  # The release half of the activity answer. `receipts` matches a receipt to
56
  # the exact commit; this says which release revision contains that commit,
57
  # which is the question "did this ship" actually asks.
58
  defp releases_json(nil), do: releases_json(OpenAgents.Issues.Releases.empty())
59
60
  defp releases_json(releases) do
61
    %{
62
      commits:
63
        Enum.map(releases.commits, fn commit ->
64
          %{
65
            sha: commit.sha,
66
            verb: commit.verb,
67
            referenced_at: commit.referenced_at,
68
            releases: Enum.map(commit.releases, &release_json/1)
69
          }
70
        end),
71
      released_in: release_json(releases.released_in),
72
      truncated: releases.truncated
73
    }
74
  end
75
76
  defp release_json(nil), do: nil
77
78
  defp release_json(release) do
79
    %{
80
      id: release.id,
81
      sha: release.sha,
82
      status: release.status,
83
      promoted_at: release.promoted_at,
84
      settled_at: release.settled_at
51 85
    }
52 86
  end
53 87
test/openagents/forge/git_plane_test.exs modified +41

@@ -215,6 +215,47 @@ defmodule OpenAgents.Forge.GitPlaneTest do

215 215
    end
216 216
  end
217 217
218
  describe "containing/3" do
219
    test "answers the whole matrix in one read", %{
220
      base_commit: base_commit,
221
      b1: b1,
222
      b2: b2,
223
      c1: c1,
224
      trunk_x: trunk_x
225
    } do
226
      assert {:ok, matrix} = GitPlane.containing(@repo, [b1, trunk_x], [b2, c1, trunk_x])
227
228
      assert matrix[b1] == [b2, c1]
229
      assert matrix[trunk_x] == [trunk_x]
230
231
      assert {:ok, %{^base_commit => carried}} =
232
               GitPlane.containing(@repo, [base_commit], [b2, trunk_x])
233
234
      assert carried == [b2, trunk_x]
235
    end
236
237
    test "a revision the repository does not have contains nothing", %{b2: b2} do
238
      absent = String.duplicate("a", 40)
239
240
      assert {:ok, matrix} = GitPlane.containing(@repo, [absent, b2], [b2, absent])
241
      assert matrix[absent] == []
242
      assert matrix[b2] == [b2]
243
    end
244
245
    test "refuses a matrix past the pair bound rather than scanning it", %{b2: b2} do
246
      candidates = List.duplicate(b2, 65)
247
248
      assert {:error, :too_many_pairs} = GitPlane.containing(@repo, [b2], candidates)
249
      assert {:ok, _matrix} = GitPlane.containing(@repo, [b2], Enum.take(candidates, 64))
250
    end
251
252
    test "an empty ask is an empty answer", %{b2: b2} do
253
      assert {:ok, %{}} = GitPlane.containing(@repo, [], [b2])
254
      assert {:ok, %{^b2 => []}} = GitPlane.containing(@repo, [b2], [])
255
      assert {:error, :not_found} = GitPlane.containing(@repo, b2, [b2])
256
    end
257
  end
258
218 259
  describe "merge_base/3" do
219 260
    test "finds the common ancestor of diverged branches", %{base_commit: base_commit} do
220 261
      assert {:ok, ^base_commit} = GitPlane.merge_base(@repo, "layer-1", "main")
test/openagents/issues/releases_test.exs added +272

@@ -0,0 +1,272 @@

1
defmodule OpenAgents.Issues.ReleasesTest do
2
  @moduledoc """
3
  #10, the half of the traceability chain a sha comparison cannot answer.
4
5
  An issue's closing commit is an ancestor of the revision a release was
6
  promoted at, never that revision itself, so `OpenAgents.Forge.receipts_for/2`
7
  — which matches a fleet target by comparing shas — finds nothing for the
8
  commit that actually shipped. These tests run against a real bare forge
9
  repository with a real commit graph and real promoted targets, because the
10
  only thing that makes the answer true is git's own containment relation.
11
12
  The graph every test reads:
13
14
      c0 ── c1 ── c2 ── c3      (refs/heads/main = c3)
15
        └── side                (refs/heads/side, never merged)
16
  """
17
18
  use OpenAgents.DataCase, async: false
19
20
  alias OpenAgents.Forge.Repos
21
  alias OpenAgents.Forge.Targets
22
  alias OpenAgents.Issues
23
  alias OpenAgents.Issues.{Activity, ClosingReference, Releases}
24
  alias OpenAgents.Repo
25
  alias OpenAgents.Repositories
26
27
  @repo "openagents.com"
28
29
  setup do
30
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
31
32
    base = Path.join(System.tmp_dir!(), "issue-releases-#{System.unique_integer([:positive])}")
33
    File.mkdir_p!(base)
34
35
    previous_data = Application.get_env(:openagents, :forge_data_dir)
36
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
37
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
38
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
39
40
    on_exit(fn ->
41
      restore(:forge_data_dir, previous_data)
42
      restore(:forge_wal_dir, previous_wal)
43
      File.rm_rf(base)
44
    end)
45
46
    repository = Repositories.get_by_path!("OpenAgentsInc", @repo)
47
    {:ok, issue} = Issues.create_issue(repository, %{title: "Ship the release link"})
48
49
    Map.merge(seed_graph(), %{repository: repository, issue: issue})
50
  end
51
52
  defp restore(key, nil), do: Application.delete_env(:openagents, key)
53
  defp restore(key, value), do: Application.put_env(:openagents, key, value)
54
55
  describe "for_issue/2" do
56
    test "an issue no commit claims has no commits and no release", context do
57
      assert Releases.for_issue(context.repository, context.issue) == Releases.empty()
58
    end
59
60
    test "a release promoted at a descendant of the issue's commit carried it", context do
61
      claim(context, context.c1)
62
      target = release(context.c2, "live")
63
64
      assert %{commits: [commit], released_in: released, truncated: false} =
65
               Releases.for_issue(context.repository, context.issue)
66
67
      assert commit.sha == context.c1
68
      assert [carried] = commit.releases
69
      assert carried.sha == context.c2
70
      assert carried.status == "live"
71
      assert released.id == target.id
72
      assert released.sha == context.c2
73
    end
74
75
    test "a release promoted before the commit existed did not carry it", context do
76
      claim(context, context.c2)
77
      release(context.c0, "live")
78
79
      assert %{commits: [commit], released_in: nil} =
80
               Releases.for_issue(context.repository, context.issue)
81
82
      assert commit.releases == []
83
    end
84
85
    test "a commit that never reached the mainline is carried by nothing", context do
86
      claim(context, context.side)
87
      release(context.c3, "live")
88
89
      assert %{commits: [commit], released_in: nil} =
90
               Releases.for_issue(context.repository, context.issue)
91
92
      assert commit.releases == []
93
    end
94
95
    test "every release that contains the commit is listed, oldest first", context do
96
      claim(context, context.c1)
97
      first = release(context.c2, "live")
98
      second = release(context.c3, "live")
99
100
      assert %{commits: [commit], released_in: released} =
101
               Releases.for_issue(context.repository, context.issue)
102
103
      assert Enum.map(commit.releases, & &1.id) == [first.id, second.id]
104
      assert released.id == first.id
105
    end
106
107
    test "released_in is the oldest live release that carried every commit", context do
108
      claim(context, context.c1)
109
      claim(context, context.c3)
110
      partial = release(context.c2, "live")
111
      whole = release(context.c3, "live")
112
113
      assert %{commits: commits, released_in: released} =
114
               Releases.for_issue(context.repository, context.issue)
115
116
      by_sha = Map.new(commits, &{&1.sha, &1})
117
118
      assert Enum.map(by_sha[context.c1].releases, & &1.id) == [partial.id, whole.id]
119
      assert Enum.map(by_sha[context.c3].releases, & &1.id) == [whole.id]
120
      assert released.id == whole.id
121
    end
122
123
    test "a promoted release that never went live is listed but never released_in", context do
124
      claim(context, context.c1)
125
      promoted = release(context.c3, "promoted")
126
127
      assert %{commits: [commit], released_in: nil} =
128
               Releases.for_issue(context.repository, context.issue)
129
130
      assert [carried] = commit.releases
131
      assert carried.id == promoted.id
132
      assert carried.status == "promoted"
133
    end
134
135
    test "the target window is bounded and says so when it cuts something off", context do
136
      claim(context, context.c1)
137
      for _each <- 1..13, do: release(context.c3, "live")
138
139
      assert %{commits: [commit], truncated: true} =
140
               Releases.for_issue(context.repository, context.issue)
141
142
      assert length(commit.releases) == 12
143
    end
144
145
    test "a repository that is not the issue's own answers with nothing", context do
146
      claim(context, context.c1)
147
      release(context.c2, "live")
148
149
      other = %{context.repository | id: Ecto.UUID.generate()}
150
151
      assert Releases.for_issue(other, context.issue) == Releases.empty()
152
    end
153
  end
154
155
  describe "the activity read and its JSON" do
156
    test "activity carries the release that shipped the issue", context do
157
      claim(context, context.c1)
158
      target = release(context.c2, "live")
159
160
      activity = Activity.for_issue(context.issue)
161
162
      assert activity.releases.released_in.id == target.id
163
164
      rendered = OpenAgentsWeb.IssueJSON.render("activity.json", %{activity: activity})
165
166
      assert %{released_in: %{sha: sha, status: "live"}, truncated: false} = rendered.releases
167
      assert sha == context.c2
168
      assert [%{sha: ^sha}] = hd(rendered.releases.commits).releases
169
    end
170
171
    test "an issue nothing shipped renders an empty release answer", context do
172
      rendered =
173
        OpenAgentsWeb.IssueJSON.render("activity.json", %{
174
          activity: Activity.for_issue(context.issue)
175
        })
176
177
      assert rendered.releases == %{commits: [], released_in: nil, truncated: false}
178
    end
179
  end
180
181
  # ── fixture ──────────────────────────────────────────────────────────────
182
183
  # One `Closes #N` reference, written the way `OpenAgents.Issues.ClosingReferences`
184
  # writes it. What the reference means is proved by `OpenAgents.Forge.PushClosesIssuesTest`;
185
  # what it is worth here is the commit it names.
186
  defp claim(%{repository: repository, issue: issue}, sha) do
187
    %ClosingReference{}
188
    |> ClosingReference.changeset(%{
189
      repository_id: repository.id,
190
      issue_id: issue.id,
191
      commit_sha: sha,
192
      repo: @repo,
193
      principal: "test:releases",
194
      verb: "closes",
195
      closed: true
196
    })
197
    |> Repo.insert!()
198
  end
199
200
  # One promotion through the real lane, so the sha precondition and the
201
  # transition table both run. `status` is where the target is left.
202
  defp release(sha, status) do
203
    {:ok, target} = Targets.promote(@repo, sha, "operator:releases-test")
204
205
    Enum.reduce_while(["building", "built", "deploying", "live"], target, fn step, current ->
206
      if current.status == status, do: {:halt, current}, else: {:cont, advance(current, step)}
207
    end)
208
  end
209
210
  defp advance(target, step) do
211
    {:ok, advanced} = Targets.advance(target.id, step)
212
    advanced
213
  end
214
215
  defp seed_graph do
216
    path = Repos.ensure_repo!(@repo)
217
218
    c0 = commit(path, [{"f.txt", "zero\n"}], [])
219
    c1 = commit(path, [{"f.txt", "zero\n"}, {"one.txt", "one\n"}], ["-p", c0])
220
    c2 = commit(path, [{"f.txt", "zero\n"}, {"two.txt", "two\n"}], ["-p", c1])
221
    c3 = commit(path, [{"f.txt", "zero\n"}, {"three.txt", "three\n"}], ["-p", c2])
222
    side = commit(path, [{"f.txt", "zero\n"}, {"side.txt", "side\n"}], ["-p", c0])
223
224
    {_output, 0} = Repos.git(path, ["update-ref", "refs/heads/main", c3])
225
    {_output, 0} = Repos.git(path, ["update-ref", "refs/heads/side", side])
226
227
    %{path: path, c0: c0, c1: c1, c2: c2, c3: c3, side: side}
228
  end
229
230
  defp commit(path, files, parents) do
231
    listing =
232
      files
233
      |> Enum.map(fn {name, content} -> "100644 blob #{blob(path, content)}\t#{name}\n" end)
234
      |> Enum.join()
235
236
    {tree, 0} = plumb(path, ["mktree"], listing)
237
238
    {sha, 0} =
239
      plumb(path, ["commit-tree", String.trim(tree)] ++ parents, "commit #{listing}",
240
        env: [
241
          {"GIT_AUTHOR_NAME", "Release Test"},
242
          {"GIT_AUTHOR_EMAIL", "release@example.test"},
243
          {"GIT_AUTHOR_DATE", "2026-01-01T00:00:00Z"},
244
          {"GIT_COMMITTER_NAME", "Release Test"},
245
          {"GIT_COMMITTER_EMAIL", "release@example.test"},
246
          {"GIT_COMMITTER_DATE", "2026-01-01T00:00:00Z"}
247
        ]
248
      )
249
250
    String.trim(sha)
251
  end
252
253
  defp blob(path, content) do
254
    {sha, 0} = plumb(path, ["hash-object", "-w", "--stdin"], content)
255
    String.trim(sha)
256
  end
257
258
  defp plumb(path, args, stdin, opts \\ []) do
259
    input = Path.join(System.tmp_dir!(), "plumb-#{System.unique_integer([:positive])}")
260
    File.write!(input, stdin)
261
262
    try do
263
      System.cmd(
264
        "sh",
265
        ["-c", ~s(exec git --git-dir "$GD" "$@" < "$IN"), "sh"] ++ args,
266
        env: [{"GD", path}, {"IN", input}] ++ Keyword.get(opts, :env, [])
267
      )
268
    after
269
      File.rm(input)
270
    end
271
  end
272
end

This page updates live while a promote is in flight · changelog