Add approved pull request chat tool

c7b05741ed38 · AtlantisPleb · · parent 968690372498

Add approved pull request chat tool

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified config/config.exs
  • modified config/test.exs
  • modified docs/pull-requests.md
  • modified lib/openagents/pull_requests.ex
  • modified lib/openagents/pull_requests/pull_request.ex
  • modified lib/openagents/tools/conversation_execution_context.ex
  • added lib/openagents/tools/open_pull_request.ex
  • modified lib/openagents/tools/runner.ex
  • modified lib/openagents_web/controllers/pull_request_json.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823021021_add_workspace_publication_to_pull_requests.exs
  • added test/openagents/tools/open_pull_request_test.exs
  • modified test/openagents_web/controllers/pull_request_controller_test.exs

Diff

13 files changed, +727 -9

config/config.exs modified +1

@@ -152,6 +152,7 @@ config :openagents,

152 152
    OpenAgents.Tools.WorkspaceWrite,
153 153
    OpenAgents.Tools.WorkspaceEdit,
154 154
    OpenAgents.Tools.PublishChanges,
155
    OpenAgents.Tools.OpenPullRequest,
155 156
    OpenAgents.Tools.ConversationSearch,
156 157
    OpenAgents.Tools.ConversationRead,
157 158
    OpenAgents.Tools.MemoryList,
config/test.exs modified +1

@@ -116,6 +116,7 @@ config :openagents, :tools, [

116 116
  OpenAgents.Tools.WorkspaceWrite,
117 117
  OpenAgents.Tools.WorkspaceEdit,
118 118
  OpenAgents.Tools.PublishChanges,
119
  OpenAgents.Tools.OpenPullRequest,
119 120
  OpenAgents.Tools.ConversationSearch,
120 121
  OpenAgents.Tools.ConversationRead,
121 122
  OpenAgents.Tools.MemoryList,
docs/pull-requests.md modified +16

@@ -43,6 +43,22 @@ The source repository and both refs must exist on the forge. The caller must be

43 43
44 44
Merging remains a separate publication operation. Creating or closing a pull request does not update a forge ref.
45 45
46
## Chat tool
47
48
The `open_pull_request` chat tool opens a pull request from an accepted repository publication receipt. The tool uses the same shared registry for text, voice, and account API turns.
49
50
The tool requires a separate, explicit person approval for opening the pull request. Approval for `publish_changes` does not approve `open_pull_request`. The server also verifies all of the following conditions before it creates or updates a pull request:
51
52
- The publication belongs to the same account, conversation, repository workspace, and workspace reference as the tool call.
53
- The publication state is `accepted`.
54
- The current forge WAL entry still maps the published branch to the exact published commit.
55
- The branch uses the `openagents/chat/` namespace and differs from the default branch.
56
- The repository allows pull requests, and the account can write to the repository.
57
58
The tool creates a draft pull request by default. Repeating the tool call for the same open source and base branches returns the existing pull request. If a later accepted publication advances the same source branch, the tool updates the existing pull request with the new publication receipt and commit.
59
60
The result includes the pull request number, state, draft state, source and base refs, commit IDs, and receipt references. It does not include access tokens, workspace host paths, or other secrets.
61
46 62
## Browser views
47 63
48 64
Open `/{owner}/{repo}/pulls` to list a repository's pull requests. Select a pull request to open `/{owner}/{repo}/pulls/{pull_number}` and review its source and target refs, description, and state.
lib/openagents/pull_requests.ex modified +178 -4

@@ -4,11 +4,13 @@ defmodule OpenAgents.PullRequests do

4 4
5 5
  alias OpenAgents.Accounts.User
6 6
  alias OpenAgents.Forge.Browse
7
  alias OpenAgents.Forge.WAL
7 8
  alias OpenAgents.Issues
8 9
  alias OpenAgents.PullRequests.PullRequest
9 10
  alias OpenAgents.Repo
10 11
  alias OpenAgents.Repositories
11 12
  alias OpenAgents.Repositories.Repository
13
  alias OpenAgents.Repositories.RepositoryPublication
12 14
13 15
  def list(%Repository{id: id}) do
14 16
    Repo.all(

@@ -40,7 +42,8 @@ defmodule OpenAgents.PullRequests do

40 42
           {:ok, head_sha} <- resolve(source, head_ref),
41 43
           {:ok, base_sha} <- resolve(target, base_ref),
42 44
           {:ok, issue} <- Issues.create_issue(target, attrs, actor),
43
           {:ok, pr} <- insert(target, source, issue, head_ref, head_sha, base_ref, base_sha) do
45
           {:ok, pr} <-
46
             insert(target, source, issue, head_ref, head_sha, base_ref, base_sha, attrs) do
44 47
        Repo.preload(pr, [:issue, :head_repository])
45 48
      else
46 49
        false -> Repo.rollback(:forbidden)

@@ -62,7 +65,9 @@ defmodule OpenAgents.PullRequests do

62 65
                 actor
63 66
               ),
64 67
             {:ok, updated} <-
65
               pr |> PullRequest.changeset(%{state: issue.state}) |> Repo.update() do
68
               pr
69
               |> PullRequest.changeset(pull_request_update_attrs(attrs, issue.state))
70
               |> Repo.update() do
66 71
          %{updated | issue: issue}
67 72
        else
68 73
          {:error, reason} -> Repo.rollback(reason)

@@ -73,6 +78,174 @@ defmodule OpenAgents.PullRequests do

73 78
    end
74 79
  end
75 80
81
  @doc "Opens or refreshes the draft pull request for an accepted Forge publication."
82
  def open_from_publication(%RepositoryPublication{} = publication, attrs, %User{} = actor) do
83
    publication = Repo.preload(publication, :repository)
84
    repository = publication.repository
85
86
    with :ok <- validate_publication(publication, actor),
87
         :ok <- validate_pull_request_policy(repository, actor),
88
         {:ok, head_sha, base_sha} <- validate_wal_authority(publication) do
89
      Repo.transaction(fn ->
90
        lock_open_head(repository.id, publication.branch, repository.default_branch)
91
92
        case open_for_head(repository.id, publication.branch, repository.default_branch) do
93
          nil ->
94
            create_from_publication(publication, attrs, actor, head_sha, base_sha)
95
96
          %PullRequest{repository_publication_id: id} = pull_request
97
          when id == publication.id ->
98
            Repo.preload(pull_request, [:issue, :head_repository, :repository_publication])
99
100
          %PullRequest{} = pull_request ->
101
            refresh_from_publication(pull_request, publication, attrs, actor, head_sha, base_sha)
102
        end
103
      end)
104
    end
105
  end
106
107
  defp validate_publication(%RepositoryPublication{} = publication, %User{} = actor) do
108
    cond do
109
      publication.owner_user_id != actor.id ->
110
        {:error, :publication_scope_mismatch}
111
112
      publication.state != "accepted" ->
113
        {:error, :publication_not_accepted}
114
115
      not is_binary(publication.published_oid) ->
116
        {:error, :publication_not_accepted}
117
118
      not is_integer(publication.wal_seq) or publication.wal_seq < 0 ->
119
        {:error, :publication_receipt_invalid}
120
121
      publication.branch == publication.repository.default_branch ->
122
        {:error, :publication_branch_refused}
123
124
      not String.starts_with?(publication.branch || "", "openagents/chat/") ->
125
        {:error, :publication_branch_refused}
126
127
      true ->
128
        :ok
129
    end
130
  end
131
132
  defp validate_pull_request_policy(%Repository{pull_requests_enabled: false}, _actor),
133
    do: {:error, :pull_requests_disabled}
134
135
  defp validate_pull_request_policy(repository, actor) do
136
    if Repositories.writable?(repository, actor), do: :ok, else: {:error, :forbidden}
137
  end
138
139
  defp validate_wal_authority(publication) do
140
    repository = publication.repository
141
    published_oid = publication.published_oid
142
    head_ref = "refs/heads/#{publication.branch}"
143
    base_ref = "refs/heads/#{repository.default_branch}"
144
145
    with {:ok, _generation, index} <- WAL.read_index(repository.storage_key),
146
         ^published_oid <- WAL.refs(index)[head_ref],
147
         base_sha when is_binary(base_sha) <- WAL.refs(index)[base_ref],
148
         %{"refs" => receipt_refs} <- Enum.at(WAL.entries(index), publication.wal_seq),
149
         ^published_oid <- receipt_refs[head_ref] do
150
      {:ok, published_oid, base_sha}
151
    else
152
      {:error, _reason} -> {:error, :forge_authority_unavailable}
153
      _ -> {:error, :publication_receipt_stale}
154
    end
155
  end
156
157
  defp open_for_head(repository_id, head_ref, base_ref) do
158
    Repo.one(
159
      from pr in PullRequest,
160
        where:
161
          pr.repository_id == ^repository_id and pr.head_repository_id == ^repository_id and
162
            pr.head_ref == ^head_ref and pr.base_ref == ^base_ref and pr.state == "open",
163
        lock: "FOR UPDATE"
164
    )
165
  end
166
167
  defp lock_open_head(repository_id, head_ref, base_ref) do
168
    key = Enum.join([repository_id, head_ref, base_ref], ":")
169
    Repo.query!("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key])
170
    :ok
171
  end
172
173
  defp create_from_publication(publication, attrs, actor, head_sha, base_sha) do
174
    repository = publication.repository
175
176
    with {:ok, issue} <- Issues.create_issue(repository, issue_attrs(attrs), actor),
177
         {:ok, pull_request} <-
178
           %PullRequest{}
179
           |> PullRequest.changeset(%{
180
             repository_id: repository.id,
181
             issue_id: issue.id,
182
             head_repository_id: repository.id,
183
             repository_publication_id: publication.id,
184
             opened_by_user_id: actor.id,
185
             conversation_id: publication.conversation_id,
186
             head_ref: publication.branch,
187
             head_sha: head_sha,
188
             base_ref: repository.default_branch,
189
             base_sha: base_sha,
190
             draft: Map.get(attrs, "draft", true)
191
           })
192
           |> Repo.insert() do
193
      Repo.preload(pull_request, [:issue, :head_repository, :repository_publication])
194
    else
195
      {:error, reason} -> Repo.rollback(reason)
196
    end
197
  end
198
199
  defp refresh_from_publication(pull_request, publication, attrs, actor, head_sha, base_sha) do
200
    pull_request = Repo.preload(pull_request, [:issue, :repository_publication])
201
202
    with :ok <- validate_existing_publication_scope(pull_request, publication, actor),
203
         {:ok, issue} <- Issues.update_issue(pull_request.issue, issue_attrs(attrs), actor),
204
         {:ok, updated} <-
205
           pull_request
206
           |> PullRequest.changeset(%{
207
             repository_publication_id: publication.id,
208
             head_sha: head_sha,
209
             base_sha: base_sha,
210
             draft: Map.get(attrs, "draft", true),
211
             conversation_id: publication.conversation_id
212
           })
213
           |> Repo.update() do
214
      %{Repo.preload(updated, [:head_repository, :repository_publication]) | issue: issue}
215
    else
216
      {:error, reason} -> Repo.rollback(reason)
217
    end
218
  end
219
220
  defp validate_existing_publication_scope(pull_request, publication, actor) do
221
    previous_publication = pull_request.repository_publication
222
223
    cond do
224
      pull_request.opened_by_user_id != actor.id ->
225
        {:error, :publication_scope_mismatch}
226
227
      pull_request.conversation_id != publication.conversation_id ->
228
        {:error, :publication_scope_mismatch}
229
230
      is_nil(previous_publication) or
231
          previous_publication.workspace_ref != publication.workspace_ref ->
232
        {:error, :publication_workspace_mismatch}
233
234
      true ->
235
        :ok
236
    end
237
  end
238
239
  defp issue_attrs(attrs), do: Map.take(attrs, ["title", "body"])
240
241
  defp pull_request_update_attrs(attrs, state) do
242
    %{state: state}
243
    |> maybe_put(:draft, Map.fetch(attrs, "draft"))
244
  end
245
246
  defp maybe_put(attrs, _key, :error), do: attrs
247
  defp maybe_put(attrs, key, {:ok, value}), do: Map.put(attrs, key, value)
248
76 249
  defp source_repository(attrs, actor) do
77 250
    case Map.get(attrs, "head_repository") do
78 251
      value when is_binary(value) ->

@@ -104,7 +277,7 @@ defmodule OpenAgents.PullRequests do

104 277
    end
105 278
  end
106 279
107
  defp insert(target, source, issue, head_ref, head_sha, base_ref, base_sha) do
280
  defp insert(target, source, issue, head_ref, head_sha, base_ref, base_sha, attrs) do
108 281
    %PullRequest{}
109 282
    |> PullRequest.changeset(%{
110 283
      repository_id: target.id,

@@ -113,7 +286,8 @@ defmodule OpenAgents.PullRequests do

113 286
      head_ref: head_ref,
114 287
      head_sha: head_sha,
115 288
      base_ref: base_ref,
116
      base_sha: base_sha
289
      base_sha: base_sha,
290
      draft: Map.get(attrs, "draft", true)
117 291
    })
118 292
    |> Repo.insert()
119 293
  end
lib/openagents/pull_requests/pull_request.ex modified +10 -1

@@ -15,6 +15,10 @@ defmodule OpenAgents.PullRequests.PullRequest do

15 15
    field :base_ref, :string
16 16
    field :base_sha, :string
17 17
    field :state, :string, default: "open"
18
    field :draft, :boolean, default: true
19
    belongs_to :repository_publication, OpenAgents.Repositories.RepositoryPublication
20
    belongs_to :opened_by_user, OpenAgents.Accounts.User
21
    field :conversation_id, :binary_id
18 22
    field :merged_at, :utc_datetime_usec
19 23
    belongs_to :merged_by_user, OpenAgents.Accounts.User
20 24
    field :merge_commit_sha, :string

@@ -28,11 +32,15 @@ defmodule OpenAgents.PullRequests.PullRequest do

28 32
      :head_sha,
29 33
      :base_ref,
30 34
      :base_sha,
31
      :state
35
      :state,
36
      :draft,
37
      :conversation_id
32 38
    ])
33 39
    |> put_programmatic_change(attrs, :repository_id)
34 40
    |> put_programmatic_change(attrs, :issue_id)
35 41
    |> put_programmatic_change(attrs, :head_repository_id)
42
    |> put_programmatic_change(attrs, :repository_publication_id)
43
    |> put_programmatic_change(attrs, :opened_by_user_id)
36 44
    |> validate_required([
37 45
      :repository_id,
38 46
      :issue_id,

@@ -46,6 +54,7 @@ defmodule OpenAgents.PullRequests.PullRequest do

46 54
    |> validate_length(:base_ref, min: 1, max: 255)
47 55
    |> validate_inclusion(:state, ~w(open closed))
48 56
    |> unique_constraint(:issue_id)
57
    |> unique_constraint(:repository_publication_id)
49 58
    |> unique_constraint([:repository_id, :head_repository_id, :head_ref, :base_ref],
50 59
      name: :pull_requests_one_open_head_base_index
51 60
    )
lib/openagents/tools/conversation_execution_context.ex modified +1

@@ -23,6 +23,7 @@ defmodule OpenAgents.Tools.ConversationExecutionContext do

23 23
                 "memory.write",
24 24
                 "module.discover",
25 25
                 "repository.read",
26
                 "repository.write",
26 27
                 "scv.deploy",
27 28
                 "work.delegate"
28 29
               ])
lib/openagents/tools/open_pull_request.ex added +155

@@ -0,0 +1,155 @@

1
defmodule OpenAgents.Tools.OpenPullRequest do
2
  @moduledoc "Opens an approved draft pull request from a Forge publication receipt."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Modules.Metadata
8
  alias OpenAgents.PullRequests
9
  alias OpenAgents.Repositories.RepositoryPublication
10
  alias OpenAgents.Repo
11
  alias OpenAgents.Tools.{ExecutionResult, Tool}
12
13
  @impl true
14
  def specification do
15
    %Tool{
16
      module_id: "openagents.tool.open_pull_request.v1",
17
      name: "open_pull_request",
18
      version: 1,
19
      description:
20
        "Open or refresh a draft pull request from an accepted repository publication receipt. " <>
21
          "The server loads and validates the receipt, repository policy, branch, commit, and Forge WAL authority.",
22
      input_schema: %{
23
        "type" => "object",
24
        "properties" => %{
25
          "publication_receipt_ref" => %{
26
            "type" => "string",
27
            "pattern" => "^repository-publication:[0-9a-f-]{36}$"
28
          },
29
          "title" => %{"type" => "string", "minLength" => 1, "maxLength" => 256},
30
          "body" => %{"type" => "string", "maxLength" => 65_536},
31
          "draft" => %{"type" => "boolean", "default" => true}
32
        },
33
        "required" => ["publication_receipt_ref", "title", "body"],
34
        "additionalProperties" => false
35
      },
36
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
37
      side_effect: :external_effect,
38
      required_scope: "browser_conversation",
39
      required_authority: "repository.write",
40
      executor: %{
41
        id: "openagents.forge.pull_requests",
42
        disclosure: "the OpenAgents pull request service, using an accepted Forge publication"
43
      },
44
      maintainer: "OpenAgents",
45
      attribution: ["OpenAgentsInc/openagents.com"],
46
      policy_facets: %{
47
        "privacy" => "browser_conversation",
48
        "residency" => "host",
49
        "consent" => "approved_publication_pull_request"
50
      },
51
      module_metadata:
52
        Metadata.first_party("repository.write", "browser_conversation",
53
          effect: :external_effect,
54
          privacy: "browser_conversation",
55
          residency: "host",
56
          surfaces: ["text", "voice"],
57
          approval_class: "external_confirmation",
58
          approval_enforcement: "host_receipt"
59
        ),
60
      timeout_ms: 30_000,
61
      maximum_input_bytes: 72_000,
62
      maximum_output_bytes: 16_384,
63
      implementation: __MODULE__
64
    }
65
  end
66
67
  @doc "Builds the separate person approval required to open a pull request."
68
  def approval_receipt(scope_ref, approval_ref)
69
      when is_binary(scope_ref) and is_binary(approval_ref) do
70
    %{
71
      "schema" => "sarah.module_approval.v1",
72
      "approval_class" => "external_confirmation",
73
      "module_id" => specification().module_id,
74
      "version" => specification().version,
75
      "scope_ref" => scope_ref,
76
      "explicit" => true,
77
      "actor_type" => "person",
78
      "receipt_ref" => approval_ref
79
    }
80
  end
81
82
  @impl true
83
  def execute(%{"publication_receipt_ref" => receipt_ref} = arguments, context) do
84
    with {:ok, publication_id} <- parse_receipt_ref(receipt_ref),
85
         %RepositoryPublication{} = publication <- Repo.get(RepositoryPublication, publication_id),
86
         :ok <- validate_context(publication, context),
87
         %User{} = actor <- Repo.get(User, context.owner_user_id),
88
         {:ok, pull_request} <- PullRequests.open_from_publication(publication, arguments, actor) do
89
      result = result(pull_request)
90
91
      {:ok,
92
       %ExecutionResult{
93
         result: result,
94
         target_receipt_refs: [
95
           receipt_ref,
96
           "pull-request:#{pull_request.id}",
97
           "issue:#{pull_request.repository_id}:#{pull_request.issue.number}"
98
         ]
99
       }}
100
    else
101
      nil -> {:error, :publication_receipt_not_found}
102
      {:error, reason} -> {:error, reason}
103
      _ -> {:error, :publication_scope_mismatch}
104
    end
105
  end
106
107
  defp parse_receipt_ref("repository-publication:" <> publication_id) do
108
    case Ecto.UUID.cast(publication_id) do
109
      {:ok, id} -> {:ok, id}
110
      :error -> {:error, :publication_receipt_invalid}
111
    end
112
  end
113
114
  defp parse_receipt_ref(_receipt_ref), do: {:error, :publication_receipt_invalid}
115
116
  defp validate_context(publication, context) do
117
    workspace = context.workspace || %{}
118
119
    cond do
120
      publication.owner_user_id != context.owner_user_id ->
121
        {:error, :publication_scope_mismatch}
122
123
      publication.conversation_id != context.conversation_id ->
124
        {:error, :publication_scope_mismatch}
125
126
      workspace["repository_id"] != publication.repository_id ->
127
        {:error, :publication_workspace_mismatch}
128
129
      workspace["workspace_ref"] != publication.workspace_ref ->
130
        {:error, :publication_workspace_mismatch}
131
132
      true ->
133
        :ok
134
    end
135
  end
136
137
  defp result(pull_request) do
138
    %{
139
      "schema" => "openagents.pull_request_opened.v1",
140
      "id" => pull_request.id,
141
      "number" => pull_request.issue.number,
142
      "state" => pull_request.state,
143
      "draft" => pull_request.draft,
144
      "title" => pull_request.issue.title,
145
      "head" => %{"ref" => pull_request.head_ref, "oid" => pull_request.head_sha},
146
      "base" => %{"ref" => pull_request.base_ref, "oid" => pull_request.base_sha},
147
      "publication_receipt_ref" =>
148
        "repository-publication:#{pull_request.repository_publication_id}",
149
      "receipt" => %{
150
        "schema" => "openagents.pull_request_receipt.v1",
151
        "receipt_ref" => "pull-request:#{pull_request.id}"
152
      }
153
    }
154
  end
155
end
lib/openagents/tools/runner.ex modified +39 -1

@@ -301,7 +301,15 @@ defmodule OpenAgents.Tools.Runner do

301 301
              :module_approval_required,
302 302
              :memory_consent_required,
303 303
              :memory_consent_mismatch,
304
              :memory_policy_refused
304
              :memory_policy_refused,
305
              :publication_scope_mismatch,
306
              :publication_workspace_mismatch,
307
              :publication_not_accepted,
308
              :publication_receipt_invalid,
309
              :publication_receipt_stale,
310
              :publication_branch_refused,
311
              :pull_requests_disabled,
312
              :forbidden
305 313
            ],
306 314
       do: "refused"
307 315

@@ -454,6 +462,36 @@ defmodule OpenAgents.Tools.Runner do

454 462
  defp error_message(:forge_push_unconfigured),
455 463
    do: "This deployment has no forge push endpoint configured."
456 464
465
  defp error_message(:publication_receipt_invalid),
466
    do: "The repository publication receipt is invalid."
467
468
  defp error_message(:publication_receipt_not_found),
469
    do: "The repository publication receipt does not exist."
470
471
  defp error_message(:publication_scope_mismatch),
472
    do: "The repository publication does not belong to this account and conversation."
473
474
  defp error_message(:publication_workspace_mismatch),
475
    do: "The repository publication does not belong to this conversation workspace."
476
477
  defp error_message(:publication_not_accepted),
478
    do: "The repository publication has not been accepted by Forge."
479
480
  defp error_message(:publication_receipt_stale),
481
    do: "The published branch no longer matches the exact Forge publication receipt."
482
483
  defp error_message(:publication_branch_refused),
484
    do: "A pull request must use an OpenAgents chat publication branch."
485
486
  defp error_message(:forge_authority_unavailable),
487
    do: "Forge could not verify the authoritative branch state."
488
489
  defp error_message(:pull_requests_disabled),
490
    do: "Pull requests are disabled for this repository."
491
492
  defp error_message(:forbidden),
493
    do: "Your account cannot open a pull request in this repository."
494
457 495
  defp error_message({:workspace_clone_failed, _detail}),
458 496
    do: "Cloning the job workspace from the forge failed."
459 497
lib/openagents_web/controllers/pull_request_json.ex modified +1

@@ -18,6 +18,7 @@ defmodule OpenAgentsWeb.PullRequestJSON do

18 18
      title: pr.issue.title,
19 19
      body: pr.issue.body,
20 20
      state: pr.issue.state,
21
      draft: pr.draft,
21 22
      user: pr.issue.user,
22 23
      merged: not is_nil(pr.merged_at),
23 24
      head: %{
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -233,7 +233,8 @@

233 233
    20260822234211,
234 234
    20260823000143,
235 235
    20260823010819,
236
    20260823013135
236
    20260823013135,
237
    20260823021021
237 238
  ],
238 239
  "required_tables": [
239 240
    "users",
priv/repo/migrations/20260823021021_add_workspace_publication_to_pull_requests.exs added +19

@@ -0,0 +1,19 @@

1
defmodule OpenAgents.Repo.Migrations.AddWorkspacePublicationToPullRequests do
2
  use Ecto.Migration
3
4
  def change do
5
    alter table(:pull_requests) do
6
      add :draft, :boolean, null: false, default: true
7
8
      add :repository_publication_id,
9
          references(:repository_publications, type: :binary_id, on_delete: :restrict)
10
11
      add :opened_by_user_id, references(:users, type: :binary_id, on_delete: :restrict)
12
      add :conversation_id, :binary_id
13
    end
14
15
    create unique_index(:pull_requests, [:repository_publication_id],
16
             where: "repository_publication_id IS NOT NULL"
17
           )
18
  end
19
end
test/openagents/tools/open_pull_request_test.exs added +299

@@ -0,0 +1,299 @@

1
defmodule OpenAgents.Tools.OpenPullRequestTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.Forge.WAL
5
  alias OpenAgents.PullRequests.PullRequest
6
  alias OpenAgents.Repositories.RepositoryPublication
7
  alias OpenAgents.Tools.{ExecutionContext, OpenPullRequest, Registry, Runner}
8
9
  setup do
10
    wal_dir =
11
      Path.join(System.tmp_dir!(), "open-pull-request-wal-#{System.unique_integer([:positive])}")
12
13
    previous_wal_dir = Application.get_env(:openagents, :forge_wal_dir)
14
    Application.put_env(:openagents, :forge_wal_dir, wal_dir)
15
16
    user = repository_user_fixture("pull-request-tool-owner")
17
    repository = repository_with_member_fixture(user)
18
    conversation_id = Ecto.UUID.generate()
19
    workspace_ref = "workspace:#{Ecto.UUID.generate()}"
20
    branch = "openagents/chat/#{conversation_id}"
21
    base_oid = String.duplicate("a", 40)
22
    head_oid = String.duplicate("b", 40)
23
24
    write_wal(repository.storage_key, [
25
      %{"refs/heads/main" => base_oid, "refs/heads/#{branch}" => head_oid}
26
    ])
27
28
    publication =
29
      publication_fixture(repository, user, conversation_id, workspace_ref, branch, head_oid, 0)
30
31
    context = %ExecutionContext{
32
      scope: "browser_conversation",
33
      scope_ref: "conversation:#{conversation_id}",
34
      authorities: MapSet.new(["repository.write"]),
35
      surface: "text",
36
      owner_user_id: user.id,
37
      owner_visitor_id: user.id,
38
      conversation_id: conversation_id,
39
      workspace: %{
40
        "type" => "repository_workspace",
41
        "repository_id" => repository.id,
42
        "workspace_ref" => workspace_ref
43
      }
44
    }
45
46
    on_exit(fn ->
47
      if previous_wal_dir,
48
        do: Application.put_env(:openagents, :forge_wal_dir, previous_wal_dir),
49
        else: Application.delete_env(:openagents, :forge_wal_dir)
50
51
      File.rm_rf!(wal_dir)
52
    end)
53
54
    %{
55
      branch: branch,
56
      context: context,
57
      head_oid: head_oid,
58
      publication: publication,
59
      repository: repository,
60
      user: user
61
    }
62
  end
63
64
  test "requires a separate exact approval and opens one draft pull request", %{context: context} do
65
    {:ok, snapshot} = Registry.build([OpenPullRequest])
66
    call = call("open-1", context)
67
68
    assert {:ok, refused} = Runner.run(snapshot, call, context)
69
    assert refused["status"] == "refused"
70
    assert refused["error"]["code"] == "module_approval_required"
71
72
    publication_approval = %{
73
      "schema" => "sarah.module_approval.v1",
74
      "approval_class" => "external_confirmation",
75
      "module_id" => "openagents.tool.publish_changes.v1",
76
      "version" => 1,
77
      "scope_ref" => context.scope_ref,
78
      "explicit" => true,
79
      "actor_type" => "person",
80
      "receipt_ref" => "approval:publication"
81
    }
82
83
    assert {:ok, still_refused} =
84
             Runner.run(snapshot, call, %{context | approval_receipts: [publication_approval]})
85
86
    assert still_refused["error"]["code"] == "module_approval_required"
87
88
    context = approve(context)
89
    assert {:ok, opened} = Runner.run(snapshot, call, context)
90
    assert opened["status"] == "succeeded"
91
    assert opened["result"]["state"] == "open"
92
    assert opened["result"]["draft"]
93
    assert opened["result"]["head"]["ref"] =~ "openagents/chat/"
94
    assert length(Repo.all(PullRequest)) == 1
95
96
    retry_call = %{call | call_id: "open-2"}
97
    assert {:ok, retried} = Runner.run(snapshot, retry_call, context)
98
    assert retried["result"]["id"] == opened["result"]["id"]
99
    assert length(Repo.all(PullRequest)) == 1
100
  end
101
102
  test "refreshes the existing open pull request from a later exact WAL publication", %{
103
    branch: branch,
104
    context: context,
105
    publication: publication,
106
    repository: repository,
107
    user: user
108
  } do
109
    {:ok, snapshot} = Registry.build([OpenPullRequest])
110
    context = approve(context)
111
    assert {:ok, first} = Runner.run(snapshot, call("open-first", context), context)
112
113
    next_oid = String.duplicate("c", 40)
114
    base_oid = String.duplicate("a", 40)
115
116
    write_wal(repository.storage_key, [
117
      %{"refs/heads/main" => base_oid, "refs/heads/#{branch}" => publication.published_oid},
118
      %{"refs/heads/main" => base_oid, "refs/heads/#{branch}" => next_oid}
119
    ])
120
121
    later =
122
      publication_fixture(
123
        repository,
124
        user,
125
        context.conversation_id,
126
        publication.workspace_ref,
127
        branch,
128
        next_oid,
129
        1
130
      )
131
132
    second_call =
133
      call("open-later", context, later)
134
      |> put_in(
135
        [:raw_arguments],
136
        Jason.encode!(%{
137
          "publication_receipt_ref" => "repository-publication:#{later.id}",
138
          "title" => "Updated pull request",
139
          "body" => "Updated body",
140
          "draft" => false
141
        })
142
      )
143
144
    assert {:ok, refreshed} = Runner.run(snapshot, second_call, context)
145
    assert refreshed["result"]["id"] == first["result"]["id"]
146
    assert refreshed["result"]["head"]["oid"] == next_oid
147
    refute refreshed["result"]["draft"]
148
149
    stored = Repo.one!(PullRequest) |> Repo.preload(:issue)
150
    assert stored.head_sha == next_oid
151
    assert stored.repository_publication_id == later.id
152
    assert stored.issue.title == "Updated pull request"
153
  end
154
155
  test "refuses another account, conversation, or workspace", %{
156
    context: context
157
  } do
158
    {:ok, snapshot} = Registry.build([OpenPullRequest])
159
    call = call("open-wrong-scope", context)
160
161
    another_user = repository_user_fixture("pull-request-tool-other-account")
162
163
    wrong_account =
164
      context
165
      |> Map.put(:owner_user_id, another_user.id)
166
      |> approve()
167
168
    assert {:ok, account_refused} = Runner.run(snapshot, call, wrong_account)
169
    assert account_refused["status"] == "refused"
170
    assert account_refused["error"]["code"] == "publication_scope_mismatch"
171
172
    conversation_id = Ecto.UUID.generate()
173
174
    wrong_conversation =
175
      context
176
      |> Map.put(:conversation_id, conversation_id)
177
      |> Map.put(:scope_ref, "conversation:#{conversation_id}")
178
      |> approve()
179
180
    assert {:ok, conversation_refused} = Runner.run(snapshot, call, wrong_conversation)
181
    assert conversation_refused["status"] == "refused"
182
    assert conversation_refused["error"]["code"] == "publication_scope_mismatch"
183
184
    wrong_workspace =
185
      context
186
      |> put_in([Access.key(:workspace), "workspace_ref"], "workspace:other")
187
      |> approve()
188
189
    assert {:ok, workspace_refused} = Runner.run(snapshot, call, wrong_workspace)
190
    assert workspace_refused["status"] == "refused"
191
    assert workspace_refused["error"]["code"] == "publication_workspace_mismatch"
192
  end
193
194
  test "refuses disabled policy and stale WAL receipts", %{
195
    context: context,
196
    publication: publication,
197
    repository: repository
198
  } do
199
    {:ok, snapshot} = Registry.build([OpenPullRequest])
200
    context = approve(context)
201
    call = call("open-refused", context)
202
203
    repository
204
    |> Ecto.Changeset.change(pull_requests_enabled: false)
205
    |> Repo.update!()
206
207
    assert {:ok, disabled} = Runner.run(snapshot, call, context)
208
    assert disabled["status"] == "refused"
209
    assert disabled["error"]["code"] == "pull_requests_disabled"
210
211
    repository
212
    |> then(&Repo.get!(OpenAgents.Repositories.Repository, &1.id))
213
    |> Ecto.Changeset.change(pull_requests_enabled: true)
214
    |> Repo.update!()
215
216
    stale_oid = String.duplicate("d", 40)
217
218
    write_wal(repository.storage_key, [
219
      %{
220
        "refs/heads/main" => String.duplicate("a", 40),
221
        "refs/heads/#{publication.branch}" => stale_oid
222
      }
223
    ])
224
225
    assert {:ok, stale} = Runner.run(snapshot, call, context)
226
    assert stale["status"] == "refused"
227
    assert stale["error"]["code"] == "publication_receipt_stale"
228
  end
229
230
  defp approve(context) do
231
    receipt = OpenPullRequest.approval_receipt(context.scope_ref, "approval:open-pull-request")
232
    %{context | approval_receipts: [receipt]}
233
  end
234
235
  defp call(call_id, context, publication \\ nil) do
236
    publication = publication || publication_for(context)
237
238
    %{
239
      call_id: call_id,
240
      name: "open_pull_request",
241
      version: 1,
242
      raw_arguments:
243
        Jason.encode!(%{
244
          "publication_receipt_ref" => "repository-publication:#{publication.id}",
245
          "title" => "Open a draft pull request",
246
          "body" => "Review the published chat workspace."
247
        })
248
    }
249
  end
250
251
  defp publication_for(context) do
252
    Repo.one!(
253
      from publication in RepositoryPublication,
254
        where: publication.conversation_id == ^context.conversation_id,
255
        order_by: [asc: publication.inserted_at],
256
        limit: 1
257
    )
258
  end
259
260
  defp publication_fixture(repository, user, conversation_id, workspace_ref, branch, oid, wal_seq) do
261
    digest = :crypto.hash(:sha256, "#{conversation_id}:#{wal_seq}") |> Base.encode16(case: :lower)
262
263
    %RepositoryPublication{}
264
    |> RepositoryPublication.changeset(%{
265
      repository_id: repository.id,
266
      owner_user_id: user.id,
267
      conversation_id: conversation_id,
268
      workspace_ref: workspace_ref,
269
      idempotency_key: digest,
270
      argument_digest: digest,
271
      message: "Publish chat workspace",
272
      branch: branch,
273
      published_oid: oid,
274
      state: "accepted",
275
      wal_seq: wal_seq,
276
      result: %{"receipt" => %{"wal_seq" => wal_seq, "oid" => oid}}
277
    })
278
    |> Repo.insert!()
279
  end
280
281
  defp write_wal(storage_key, refs_by_sequence) do
282
    WAL.delete_repo(storage_key)
283
284
    index =
285
      Enum.with_index(refs_by_sequence)
286
      |> Enum.reduce(WAL.new_index(), fn {refs, sequence}, index ->
287
        WAL.append_entry(index, %{
288
          "seq" => sequence,
289
          "object" =>
290
            "entries/#{String.pad_leading(Integer.to_string(sequence), 8, "0")}-000000000000",
291
          "refs" => refs,
292
          "principal" => "test",
293
          "pushed_at" => "2026-08-23T00:00:00Z"
294
        })
295
      end)
296
297
    assert {:ok, _generation} = WAL.cas_index(storage_key, :none, index)
298
  end
299
end
test/openagents_web/controllers/pull_request_controller_test.exs modified +5 -2

@@ -50,6 +50,7 @@ defmodule OpenAgentsWeb.PullRequestControllerTest do

50 50
             "number" => number,
51 51
             "title" => "Add pull request support",
52 52
             "state" => "open",
53
             "draft" => true,
53 54
             "head" => %{"ref" => "main"},
54 55
             "base" => %{"ref" => "main"}
55 56
           } = json_response(create_conn, 201)

@@ -62,13 +63,15 @@ defmodule OpenAgentsWeb.PullRequestControllerTest do

62 63
    update_conn =
63 64
      patch(conn, "/api/v3/repos/#{target.owner}/#{target.name}/pulls/#{number}", %{
64 65
        title: "Ship pull request support",
65
        state: "closed"
66
        state: "closed",
67
        draft: false
66 68
      })
67 69
68 70
    assert %{
69 71
             "number" => ^number,
70 72
             "title" => "Ship pull request support",
71
             "state" => "closed"
73
             "state" => "closed",
74
             "draft" => false
72 75
           } = json_response(update_conn, 200)
73 76
  end
74 77

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