Answer what evidence an issue has

a44961d6a54f · AtlantisPleb · · parent f3b7314d8df1

Answer what evidence an issue has

An issue could name work and could be closed by a commit, and nothing
put those together — so "what happened for this issue" had no answer
even though every piece of it was already recorded.

OpenAgents.Issues.Activity assembles the answer from records that
already exist: the threads that named the issue, read through
Threads.list_for_issue/2 so their own visibility stays in force, and
the push, build, target, and deployment receipts reachable from the
commit references the issue already claims.

Nothing new is stored. No second work record, no linkage table, no new
migration, and no new repository authority — the repository is checked
once and the thread rule is composed rather than restated. ISSUE-001
is untouched: a commit still closes an issue only from the default
branch, and this reads that link rather than adding another.

An issue with no evidence answers with none rather than erroring, and
a caller who cannot see a repository does not see its receipts.

Built by a Devin child through the openagents coder's delegate tool;
731 issue, thread, forge, and route-authority tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <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 364 · 2026-08-25T11:45:34.584260Z

Changed files

  • modified lib/openagents/forge.ex
  • modified lib/openagents/forge/gate_receipt.ex
  • added lib/openagents/issues/activity.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/controllers/issue_json.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified test/openagents/forge/gate_receipt_test.exs
  • added test/openagents/issues/activity_test.exs

Diff

10 files changed, +357 -2

lib/openagents/forge.ex modified +9

@@ -87,6 +87,15 @@ defmodule OpenAgents.Forge do

87 87
    }
88 88
  end
89 89
90
  def gate_receipts_for(repo, sha, opts \\ []) when is_binary(repo) and is_binary(sha) do
91
    git_common_dir = Keyword.get(opts, :git_common_dir, OpenAgents.Forge.Repos.bare_path(repo))
92
93
    case OpenAgents.Forge.GateReceipt.verify(sha, git_common_dir: git_common_dir) do
94
      {:ok, receipt} -> %{gates: [receipt]}
95
      _ -> %{gates: []}
96
    end
97
  end
98
90 99
  @doc "Prefix-match two shas in either direction (short vs full), ≥7 chars."
91 100
  def sha_match?(a, b) when is_binary(a) and is_binary(b) do
92 101
    min(byte_size(a), byte_size(b)) >= 7 and
lib/openagents/forge/gate_receipt.ex modified +8 -2

@@ -51,8 +51,14 @@ defmodule OpenAgents.Forge.GateReceipt do

51 51
52 52
  @doc "Return the default receipt path for an exact Git SHA."
53 53
  def path(sha, opts \\ []) do
54
    root = Keyword.get_lazy(opts, :repo_root, &repo_root!/0)
55
    Path.join([root, ".git", "openagents", "release-gate-receipts", "#{sha}.json"])
54
    case Keyword.fetch(opts, :git_common_dir) do
55
      {:ok, git_common_dir} ->
56
        Path.join([git_common_dir, "openagents", "release-gate-receipts", "#{sha}.json"])
57
58
      :error ->
59
        root = Keyword.get_lazy(opts, :repo_root, &repo_root!/0)
60
        Path.join([root, ".git", "openagents", "release-gate-receipts", "#{sha}.json"])
61
    end
56 62
  end
57 63
58 64
  defp verify_receipt(sha, opts) do
lib/openagents/issues/activity.ex added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgents.Issues.Activity do
2
  @moduledoc """
3
  The agent work and forge receipts that name an issue, scoped to a reader.
4
5
  The read is assembled from records that already exist. Threads that named the
6
  issue are read through `OpenAgents.Threads.list_for_issue/2`, so a thread's
7
  own visibility rules stay in force. Receipts are reached from the commit
8
  references an issue already claims through `OpenAgents.Issues.ClosingReferences`,
9
  using `OpenAgents.Forge.receipts_for/2` to scan the commit's push, build,
10
  target, and deployment receipts. No new work record, no new linkage table, and
11
  no new repository authority: the repository is checked once and the thread
12
  authority is composed rather than restated.
13
  """
14
15
  alias OpenAgents.Accounts.User
16
  alias OpenAgents.Forge
17
  alias OpenAgents.Issues.{ClosingReferences, Issue}
18
  alias OpenAgents.Repositories
19
  alias OpenAgents.Repositories.Repository
20
  alias OpenAgents.Threads
21
22
  @doc """
23
  The threads and receipts that name `issue` and that `reader` may read.
24
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.
28
  """
29
  @spec for_issue(Issue.t(), User.t() | nil) :: %{
30
          threads: [Threads.Thread.t()],
31
          receipts: [map()]
32
        }
33
  def for_issue(%Issue{} = issue, %User{} = reader), do: do_for_issue(issue, reader)
34
  def for_issue(%Issue{} = issue, _reader), do: do_for_issue(issue, nil)
35
  def for_issue(%Issue{} = issue), do: do_for_issue(issue, nil)
36
37
  defp do_for_issue(%Issue{} = issue, reader) do
38
    repository = visible_repository(issue, reader)
39
    threads = if reader, do: Threads.list_for_issue(issue, reader), else: []
40
41
    receipts =
42
      if repository do
43
        issue |> ClosingReferences.for_issue() |> receipts_for_references(repository)
44
      else
45
        []
46
      end
47
48
    %{threads: threads, receipts: receipts}
49
  end
50
51
  defp visible_repository(%Issue{repository_id: repository_id}, reader) do
52
    case Repositories.get_visible_repository(repository_id, reader) do
53
      %Repository{} = repository -> repository
54
      _not_visible -> nil
55
    end
56
  end
57
58
  defp receipts_for_references(references, %Repository{} = repository) do
59
    Enum.flat_map(references, fn reference ->
60
      sha = reference.commit_sha
61
62
      db_receipts =
63
        repository.storage_key
64
        |> Forge.receipts_for(sha)
65
        |> flatten_receipts(sha)
66
67
      gate_receipts =
68
        repository.storage_key
69
        |> Forge.gate_receipts_for(sha)
70
        |> flatten_receipts(sha)
71
72
      db_receipts ++ gate_receipts
73
    end)
74
  end
75
76
  defp flatten_receipts(receipts_by_family, sha) do
77
    for {family, receipts} <- receipts_by_family,
78
        receipt <- receipts,
79
        do: %{family: to_string(family), sha: sha, receipt: receipt}
80
  end
81
end
lib/openagents_web/api_route_authority.ex modified +2

@@ -156,6 +156,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

156 156
      "get /api/v1/repos/:owner/:repo/issues" => {:optional_bearer, :issue, :envelope},
157 157
      "get /api/v1/repos/:owner/:repo/issues/:issue_number" =>
158 158
        {:optional_bearer, :issue, :envelope},
159
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/activity" =>
160
        {:optional_bearer, :issue_activity, :envelope},
159 161
      "get /api/v1/repos/:owner/:repo/issues/:issue_number/dependencies" =>
160 162
        {:optional_bearer, :issue_dependency, :envelope},
161 163
      "get /api/v1/repos/:owner/:repo/pulls" => {:optional_bearer, :pull_request, :legacy},
lib/openagents_web/controllers/issue_controller.ex modified +28

@@ -4,6 +4,7 @@ defmodule OpenAgentsWeb.IssueController do

4 4
  alias OpenAgents.Accounts.User
5 5
  alias OpenAgents.Forge.Assignments
6 6
  alias OpenAgents.Issues
7
  alias OpenAgents.Issues.Activity
7 8
  alias OpenAgents.Issues.Capture
8 9
  alias OpenAgents.Issues.CompletionClaims
9 10
  alias OpenAgents.Issues.Evidence

@@ -386,6 +387,33 @@ defmodule OpenAgentsWeb.IssueController do

386 387
387 388
  defp threads_by_issue(_issues, _reader), do: %{}
388 389
390
  def activity(conn, %{
391
        "owner" => owner,
392
        "repo" => repo,
393
        "issue_number" => issue_number
394
      }) do
395
    reader = conn.assigns[:current_user]
396
397
    with {:ok, repository} <-
398
           lookup(fn -> Repositories.get_visible_by_path!(owner, repo, reader) end),
399
         {:ok, issue} <-
400
           lookup(fn ->
401
             Issues.get_issue_by_number!(repository, integer_param!(issue_number))
402
           end) do
403
      conn
404
      |> put_status(:ok)
405
      |> put_extensions_header()
406
      |> render(:activity,
407
        owner: owner,
408
        repo: repo,
409
        issue: issue,
410
        activity: Activity.for_issue(issue, reader)
411
      )
412
    else
413
      {:error, :not_found} -> not_found(conn)
414
    end
415
  end
416
389 417
  # The extension namespace is discoverable from the response itself, so a
390 418
  # client never has to infer which OpenAgents fields this deployment sends.
391 419
  defp put_extensions_header(conn),
lib/openagents_web/controllers/issue_json.ex modified +24

@@ -42,6 +42,15 @@ defmodule OpenAgentsWeb.IssueJSON do

42 42
    issue_json(issue, assigns)
43 43
  end
44 44
45
  def render("activity.json", %{activity: activity} = assigns) do
46
    url_base = url_base(assigns)
47
48
    %{
49
      threads: Enum.map(activity.threads, &thread_json(&1, url_base)),
50
      receipts: Enum.map(activity.receipts, &receipt_json/1)
51
    }
52
  end
53
45 54
  defp issue_json(issue, assigns) do
46 55
    owner = Map.get(assigns, :owner, "OpenAgents")
47 56
    repo = Map.get(assigns, :repo, "openagents")

@@ -201,6 +210,21 @@ defmodule OpenAgentsWeb.IssueJSON do

201 210
202 211
  defp evidence_json(entry), do: entry
203 212
213
  defp receipt_json(%{family: family, sha: sha, receipt: receipt}) do
214
    base =
215
      if is_struct(receipt) do
216
        receipt
217
        |> Map.from_struct()
218
        |> Map.delete(:__meta__)
219
      else
220
        receipt
221
      end
222
223
    base
224
    |> Map.put(:family, family)
225
    |> Map.put(:sha, sha)
226
  end
227
204 228
  defp attempt_json(attempt) do
205 229
    attempt
206 230
    |> Map.drop([:work_job])
lib/openagents_web/route_authority.ex modified +1

@@ -72,6 +72,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

72 72
  @optional_forge_read_paths [
73 73
    "/api/v1/repos/:owner/:repo/issues",
74 74
    "/api/v1/repos/:owner/:repo/issues/:issue_number",
75
    "/api/v1/repos/:owner/:repo/issues/:issue_number/activity",
75 76
    "/api/v1/repos/:owner/:repo/issues/:issue_number/dependencies",
76 77
    "/api/v1/repos/:owner/:repo/issues/:issue_number/comments",
77 78
    "/api/v1/repos/:owner/:repo/issues/comments/:id",
lib/openagents_web/router.ex modified +4

@@ -672,6 +672,10 @@ defmodule OpenAgentsWeb.Router do

672 672
    get "/repos/:owner/:repo/issues", IssueController, :index
673 673
    get "/repos/:owner/:repo/issues/:issue_number", IssueController, :show
674 674
675
    get "/repos/:owner/:repo/issues/:issue_number/activity",
676
        IssueController,
677
        :activity
678
675 679
    get "/repos/:owner/:repo/issues/:issue_number/dependencies",
676 680
        IssueDependencyController,
677 681
        :index
test/openagents/forge/gate_receipt_test.exs modified +21

@@ -40,6 +40,27 @@ defmodule OpenAgents.Forge.GateReceiptTest do

40 40
             GateReceipt.verify(String.duplicate("b", 40), repo_root: root)
41 41
  end
42 42
43
  test "accepts a git common dir override" do
44
    root = temporary_root()
45
    common_dir = Path.join(root, "repo.git")
46
    File.mkdir_p!(common_dir)
47
    path = GateReceipt.path(@sha, git_common_dir: common_dir)
48
    File.mkdir_p!(Path.dirname(path))
49
50
    receipt = %{
51
      "schema" => "openagents.release-gate.v1",
52
      "git_sha" => @sha,
53
      "status" => "passed",
54
      "stages" => Map.new(@stages, &{&1, %{"status" => "passed"}})
55
    }
56
57
    File.write!(path, Jason.encode!(receipt))
58
    assert {:ok, ^receipt} = GateReceipt.verify(@sha, git_common_dir: common_dir)
59
60
    assert {:error, :missing_gate_receipt} =
61
             GateReceipt.verify(String.duplicate("b", 40), git_common_dir: common_dir)
62
  end
63
43 64
  test "rejects an incomplete receipt" do
44 65
    root = temporary_root()
45 66
    path = GateReceipt.path(@sha, repo_root: root)
test/openagents/issues/activity_test.exs added +179

@@ -0,0 +1,179 @@

1
defmodule OpenAgents.Issues.ActivityTest do
2
  @moduledoc """
3
  Acceptance for the issue activity read: threads an issue names that a reader
4
  may read, and receipts reachable from the commits that reference the issue.
5
  """
6
7
  use OpenAgents.DataCase, async: true
8
9
  import OpenAgents.AccountsFixtures
10
11
  alias OpenAgents.Forge.BuildReceipt
12
  alias OpenAgents.Issues
13
  alias OpenAgents.Issues.Activity
14
  alias OpenAgents.Issues.ClosingReference
15
  alias OpenAgents.Repo
16
  alias OpenAgents.Threads
17
18
  @sha String.duplicate("ab", 20)
19
  @stages ~w(
20
    compile
21
    production_compile
22
    precommit
23
    cluster
24
    javascript
25
    direct_transaction
26
    relup_topology
27
    relup
28
    version_chain
29
    interrupted_install
30
    rolling_replacement
31
    contracts
32
    staging_infra
33
    release_smoke
34
  )
35
36
  setup do
37
    user = repository_user_fixture("activity-reader")
38
    repository = repository_with_member_fixture(user, %{visibility: "private"}, "owner")
39
    {:ok, issue} = Issues.create_issue(repository, %{title: "Activity test issue"})
40
    %{user: user, repository: repository, issue: issue}
41
  end
42
43
  describe "issue activity" do
44
    test "an issue with no references lists empty threads and receipts", %{
45
      issue: issue
46
    } do
47
      activity = Activity.for_issue(issue)
48
49
      assert activity.threads == []
50
      assert activity.receipts == []
51
    end
52
53
    test "an issue with a referencing commit that has a gate receipt lists it", %{
54
      repository: repository,
55
      issue: issue,
56
      user: user
57
    } do
58
      closing_reference(%{repository: repository, issue: issue, user: user}, @sha)
59
60
      forge_data =
61
        Path.join(
62
          System.tmp_dir!(),
63
          "openagents-activity-gate-#{System.unique_integer([:positive])}"
64
        )
65
66
      previous = Application.get_env(:openagents, :forge_data_dir)
67
      Application.put_env(:openagents, :forge_data_dir, forge_data)
68
69
      on_exit(fn ->
70
        if previous,
71
          do: Application.put_env(:openagents, :forge_data_dir, previous),
72
          else: Application.delete_env(:openagents, :forge_data_dir)
73
74
        File.rm_rf!(forge_data)
75
      end)
76
77
      _receipt = gate_receipt(repository, @sha)
78
79
      activity = Activity.for_issue(issue, user)
80
81
      assert [entry] = Enum.filter(activity.receipts, &(&1.family == "gates"))
82
      assert entry.sha == @sha
83
      assert entry.receipt["schema"] == "openagents.release-gate.v1"
84
      assert entry.receipt["git_sha"] == @sha
85
    end
86
87
    test "an issue with a referencing commit that has a receipt lists that receipt", %{
88
      repository: repository,
89
      issue: issue,
90
      user: user
91
    } do
92
      closing_reference(%{repository: repository, issue: issue, user: user}, @sha)
93
      build = build_receipt(repository, @sha)
94
95
      activity = Activity.for_issue(issue, user)
96
97
      assert [entry] = activity.receipts
98
      assert entry.family == "builds"
99
      assert entry.sha == @sha
100
      assert entry.receipt.id == build.id
101
      assert activity.threads == []
102
    end
103
104
    test "a caller who cannot see the repository does not see its receipts", %{
105
      repository: repository,
106
      issue: issue,
107
      user: user
108
    } do
109
      other = repository_user_fixture("activity-stranger")
110
      closing_reference(%{repository: repository, issue: issue, user: user}, @sha)
111
      _build = build_receipt(repository, @sha)
112
113
      activity = Activity.for_issue(issue, other)
114
115
      assert activity.receipts == []
116
      assert activity.threads == []
117
    end
118
119
    test "issue agent activity includes readable threads and excludes unreadable ones", %{
120
      issue: issue,
121
      user: user
122
    } do
123
      {:ok, readable} =
124
        Threads.open(user, "work for this issue", issue_id: issue.id)
125
126
      other = repository_user_fixture("other-thread-owner")
127
128
      {:ok, _unreadable} =
129
        Threads.open(other, "someone else's work", issue_id: issue.id)
130
131
      activity = Activity.for_issue(issue, user)
132
133
      assert [thread] = activity.threads
134
      assert thread.id == readable.id
135
      assert activity.receipts == []
136
    end
137
  end
138
139
  defp closing_reference(%{repository: repository, issue: issue, user: user}, sha) do
140
    %ClosingReference{}
141
    |> ClosingReference.changeset(%{
142
      repository_id: repository.id,
143
      issue_id: issue.id,
144
      commit_sha: sha,
145
      principal: "user:#{user.id}",
146
      verb: "closes",
147
      closed: true,
148
      closed_by_user_id: user.id
149
    })
150
    |> Repo.insert!()
151
  end
152
153
  defp build_receipt(repository, sha) do
154
    %BuildReceipt{}
155
    |> BuildReceipt.start_changeset(%{
156
      repo: repository.storage_key,
157
      sha: sha,
158
      target_id: Ecto.UUID.generate()
159
    })
160
    |> Ecto.Changeset.put_change(:status, "complete")
161
    |> Repo.insert!()
162
  end
163
164
  defp gate_receipt(repository, sha) do
165
    bare = OpenAgents.Forge.Repos.bare_path(repository.storage_key)
166
    path = Path.join([bare, "openagents", "release-gate-receipts", "#{sha}.json"])
167
    File.mkdir_p!(Path.dirname(path))
168
169
    receipt = %{
170
      "schema" => "openagents.release-gate.v1",
171
      "git_sha" => sha,
172
      "status" => "passed",
173
      "stages" => Map.new(@stages, &{&1, %{"status" => "passed"}})
174
    }
175
176
    File.write!(path, Jason.encode!(receipt))
177
    receipt
178
  end
179
end

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