Complete CLI repository integration

cf04c0005dfb · AtlantisPleb · · parent 62aed84fc1ec

Complete CLI repository integration

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 docs/repository-creation-and-openagents-cli-spec.md
  • modified docs/repository-creation-cli-implementation-roadmap.md
  • modified lib/openagents/repositories.ex
  • added lib/openagents_web/controllers/forge_user_controller.ex
  • modified lib/openagents_web/controllers/repository_controller.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/api-contracts/repositories-v1.json
  • added test/openagents/repositories/cli_local_e2e_test.exs
  • added test/openagents_web/controllers/forge_user_controller_test.exs
  • modified test/openagents_web/controllers/repository_controller_test.exs
  • modified test/openagents_web/route_authority_test.exs
  • added test/support/repository_cli_github_fake.ex
  • modified test/test_helper.exs

Diff

14 files changed, +717 -13

docs/repository-creation-and-openagents-cli-spec.md modified +1 -1

@@ -942,7 +942,7 @@ when the server supplies one.

942 942
Create the CLI in the `openagents` monorepo at
943 943
`packages/openagents-cli/` with:
944 944
945
- Package name `@openagentsinc/openagents-cli`.
945
- Package name `@openagentsinc/cli`.
946 946
- Binary name `openagents`.
947 947
- Node 24, pnpm, and Vite Plus, matching the monorepo contract.
948 948
- Effect and Effect Schema for services, configuration, response decoding,
docs/repository-creation-cli-implementation-roadmap.md modified +5 -5

@@ -2,8 +2,8 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Implementation complete; local end-to-end and staging verification
6
remain
5
Status: Implementation complete; the cross-repository loopback suite passes.
6
Exact `localhost:4000` and staging receipts remain gated
7 7
8 8
## Outcome
9 9

@@ -465,9 +465,9 @@ result. Do not store tokens, local absolute paths, or repository content.

465 465
| 7. CLI device authorization | Complete | One-time device-code context, API, browser approval, and polling tests |
466 466
| 8. Repository browser interface | Complete | List, create, import, empty, failed, private, and code-route LiveView tests |
467 467
| 9. CLI package scaffold | Complete | `@openagentsinc/cli`, Effect 4 command graph, build, and package inspection |
468
| 10. CLI configuration and authentication | Complete | Profiles, custom origins, OS credential store, and Git helper tests |
469
| 11. CLI repository commands | Complete | Create, import, list, view, clone, source remote, inference, and refusal tests |
470
| 12. Local cross-repository verification | In progress | Contract digests and focused suites pass; disposable CLI-to-server create, push, clone, and import proof remains |
468
| 10. CLI configuration and authentication | Complete | Profiles, persisted configuration precedence, custom origins, OS credential store, headless refusal, stable errors, and origin-isolated Git helper tests |
469
| 11. CLI repository commands | Complete | Create, matching-namespace import, cursor list, view, clone, source remote, inference, and refusal tests |
470
| 12. Local cross-repository verification | In progress | The default-`4000` disposable suite passes on an alternate loopback port for create, push, clone, anonymous read, private concealment, viewer push refusal, cache reconstruction, exact import refs, and no later sync. Record the exact `localhost:4000` receipt when that port is available. |
471 471
| 13. Staging and release | Not started | — |
472 472
473 473
## Completion audit
lib/openagents/repositories.ex modified +18 -2

@@ -252,7 +252,12 @@ defmodule OpenAgents.Repositories do

252 252
    )
253 253
  end
254 254
255
  def list_visible_repositories_page(%User{id: user_id}, per_page, after_cursor)
255
  def list_visible_repositories_page(
256
        %User{id: user_id},
257
        per_page,
258
        after_cursor,
259
        namespace_key \\ nil
260
      )
256 261
      when per_page in 1..100 do
257 262
    query =
258 263
      from repository in Repository,

@@ -266,11 +271,22 @@ defmodule OpenAgents.Repositories do

266 271
        order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
267 272
        preload: [namespace: namespace]
268 273
269
    query = apply_repository_cursor(query, after_cursor)
274
    query =
275
      query
276
      |> apply_namespace_filter(namespace_key)
277
      |> apply_repository_cursor(after_cursor)
278
270 279
    rows = Repo.all(from row in query, limit: ^(per_page + 1))
271 280
    {Enum.take(rows, per_page), length(rows) > per_page}
272 281
  end
273 282
283
  defp apply_namespace_filter(query, nil), do: query
284
285
  defp apply_namespace_filter(query, namespace_key) when is_binary(namespace_key) do
286
    from [repository, namespace, membership] in query,
287
      where: namespace.slug_key == ^namespace_key
288
  end
289
274 290
  def get_import_for_user!(id, %User{id: user_id}) do
275 291
    Repo.one!(
276 292
      from repository_import in RepositoryImport,
lib/openagents_web/controllers/forge_user_controller.ex added +51

@@ -0,0 +1,51 @@

1
defmodule OpenAgentsWeb.ForgeUserController do
2
  @moduledoc "Reports the authenticated GitHub identity and eligible repository namespaces."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.Repositories.GitHubProjection
7
8
  def show(conn, _params) do
9
    user = conn.assigns.current_user
10
11
    case GitHubProjection.available_namespaces(user) do
12
      {:ok, namespaces} ->
13
        json(conn, %{
14
          "id" => user.github_id,
15
          "login" => user.github_login,
16
          "token_expires_at" => DateTime.to_iso8601(conn.assigns.api_token.expires_at),
17
          "namespaces" =>
18
            Enum.map(namespaces, fn namespace ->
19
              %{
20
                "id" => namespace.provider_account_id,
21
                "login" => namespace.slug,
22
                "type" => namespace.kind
23
              }
24
            end)
25
        })
26
27
      {:error, reason} ->
28
        render_error(conn, reason)
29
    end
30
  end
31
32
  defp render_error(conn, :github_connection_required),
33
    do: error(conn, :forbidden, "github_connection_required", "Connect GitHub to continue")
34
35
  defp render_error(conn, :github_scope_required),
36
    do: error(conn, :forbidden, "github_scope_required", "Reconnect GitHub with required access")
37
38
  defp render_error(conn, _reason),
39
    do: error(conn, :service_unavailable, "github_unavailable", "GitHub is unavailable")
40
41
  defp error(conn, status, code, message) do
42
    conn
43
    |> put_status(status)
44
    |> put_resp_header("cache-control", "no-store")
45
    |> json(%{
46
      "code" => code,
47
      "message" => message,
48
      "request_id" => List.first(get_resp_header(conn, "x-request-id"))
49
    })
50
  end
51
end
lib/openagents_web/controllers/repository_controller.ex modified +14 -2

@@ -38,12 +38,14 @@ defmodule OpenAgentsWeb.RepositoryController do

38 38
39 39
  def index(conn, params) do
40 40
    with {:ok, per_page} <- per_page(params),
41
         {:ok, after_cursor} <- decode_cursor(params["after"]) do
41
         {:ok, after_cursor} <- decode_cursor(params["after"]),
42
         {:ok, namespace_key} <- namespace_key(params["namespace"]) do
42 43
      {repositories, more?} =
43 44
        Repositories.list_visible_repositories_page(
44 45
          conn.assigns.current_user,
45 46
          per_page,
46
          after_cursor
47
          after_cursor,
48
          namespace_key
47 49
        )
48 50
49 51
      next_cursor = if more?, do: encode_cursor(List.last(repositories)), else: nil

@@ -120,6 +122,16 @@ defmodule OpenAgentsWeb.RepositoryController do

120 122
121 123
  defp per_page(_params), do: {:ok, 30}
122 124
125
  defp namespace_key(nil), do: {:ok, nil}
126
127
  defp namespace_key(namespace) when is_binary(namespace) do
128
    if Regex.match?(~r/\A[A-Za-z0-9][A-Za-z0-9-]{0,38}\z/, namespace),
129
      do: {:ok, String.downcase(namespace)},
130
      else: {:error, :invalid_pagination}
131
  end
132
133
  defp namespace_key(_namespace), do: {:error, :invalid_pagination}
134
123 135
  defp decode_cursor(nil), do: {:ok, nil}
124 136
125 137
  defp decode_cursor(cursor) when is_binary(cursor) do
lib/openagents_web/route_authority.ex modified +1 -1

@@ -165,7 +165,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

165 165
      )
166 166
167 167
  defp policy(%{path: path, verb: verb})
168
       when path in ["/api/v3/user/repos", "/api/v3/repository-imports/:id"] and
168
       when path in ["/api/v3/user", "/api/v3/user/repos", "/api/v3/repository-imports/:id"] and
169 169
              verb in [:get, :head],
170 170
       do: declaration(:authenticated_api, "first-party bearer token", "forge:read", false)
171 171
lib/openagents_web/router.ex modified +1

@@ -222,6 +222,7 @@ defmodule OpenAgentsWeb.Router do

222 222
  scope "/api/v3", OpenAgentsWeb do
223 223
    pipe_through :forge_write_api
224 224
225
    get "/user", ForgeUserController, :show
225 226
    get "/user/repos", RepositoryController, :index
226 227
    post "/user/repos", RepositoryController, :create_user
227 228
    post "/orgs/:org/repos", RepositoryController, :create_organization
priv/api-contracts/repositories-v1.json modified +6

@@ -16,6 +16,7 @@

16 16
    ]
17 17
  },
18 18
  "endpoints": {
19
    "get_authenticated_user": "GET /api/v3/user",
19 20
    "create_user_repository": "POST /api/v3/user/repos",
20 21
    "create_organization_repository": "POST /api/v3/orgs/{org}/repos",
21 22
    "import_user_repository": "POST /api/v3/user/repos/imports",

@@ -26,6 +27,10 @@

26 27
    "create_device_authorization": "POST /api/v3/device/authorizations",
27 28
    "poll_device_authorization": "POST /api/v3/device/authorizations/token"
28 29
  },
30
  "authenticated_user": {
31
    "required": ["id", "login", "namespaces", "token_expires_at"],
32
    "namespace_required": ["id", "login", "type"]
33
  },
29 34
  "repository": {
30 35
    "required": [
31 36
      "id",

@@ -61,6 +66,7 @@

61 66
  },
62 67
  "pagination": {
63 68
    "request_cursor": "after",
69
    "namespace_filter": "namespace",
64 70
    "response_cursor": "next_cursor",
65 71
    "maximum_page_size": 100
66 72
  },
test/openagents/repositories/cli_local_e2e_test.exs added +446

@@ -0,0 +1,446 @@

1
defmodule OpenAgents.Repositories.CliLocalE2ETest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.{Accounts, ApiTokens, Repositories}
7
  alias OpenAgents.Forge.{Repos, WAL}
8
  alias OpenAgents.Repositories.{Importer, Provisioner}
9
10
  @moduletag :cross_repo
11
12
  setup do
13
    cli_entry = System.fetch_env!("OPENAGENTS_CLI_ENTRY")
14
15
    node =
16
      System.find_executable("node") || raise "node is required for the CLI cross-repository test"
17
18
    true = Path.type(cli_entry) == :absolute
19
    true = File.regular?(cli_entry)
20
21
    root =
22
      Path.join(
23
        System.tmp_dir!(),
24
        "openagents-cli-local-e2e-#{Ecto.UUID.generate()}"
25
      )
26
27
    File.mkdir_p!(root)
28
    previous = save_environment([:forge_data_dir, :forge_wal_dir, :github_api])
29
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "forge"))
30
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
31
32
    user = repository_user_fixture("cli-e2e-owner")
33
    {:ok, user} = Accounts.store_github_token(user, "github-cli-e2e-token")
34
35
    {:ok, _api_token, plaintext} =
36
      ApiTokens.create(user, %{name: "CLI local E2E", scopes: ["forge:write"], lifetime_days: 1})
37
38
    source = fixture_repository!(root)
39
    refs = refs(source)
40
41
    github_port = free_port()
42
    endpoint_port = System.get_env("OPENAGENTS_E2E_PORT", "4000") |> String.to_integer()
43
    api_origin = "http://localhost:#{endpoint_port}"
44
45
    github_options = [
46
      source_full_name: "#{user.github_login}/source-project",
47
      repository_id: 9_001,
48
      owner_id: user.github_id,
49
      default_branch: "main",
50
      refs:
51
        Map.new(refs, fn {name, sha} ->
52
          {type, 0} = System.cmd("git", ["-C", source, "cat-file", "-t", sha])
53
          {name, %{sha: sha, type: String.trim(type)}}
54
        end)
55
    ]
56
57
    start_supervised!(
58
      Supervisor.child_spec(
59
        {Bandit,
60
         plug: {OpenAgents.Test.RepositoryCliGitHubFake, github_options},
61
         port: github_port,
62
         ip: {127, 0, 0, 1}},
63
        id: :repository_cli_github_fake
64
      )
65
    )
66
67
    Application.put_env(:openagents, :github_api,
68
      base_url: "http://127.0.0.1:#{github_port}",
69
      request_options: []
70
    )
71
72
    start_supervised!(
73
      Supervisor.child_spec(
74
        {Bandit, plug: OpenAgentsWeb.Endpoint, port: endpoint_port, ip: {127, 0, 0, 1}},
75
        id: :repository_cli_local_endpoint
76
      )
77
    )
78
79
    bin = Path.join(root, "bin")
80
    File.mkdir_p!(bin)
81
    wrapper = Path.join(bin, "openagents")
82
83
    File.write!(
84
      wrapper,
85
      "#!/bin/sh\nexec node \"$OPENAGENTS_CLI_ENTRY\" \"$@\"\n",
86
      [:exclusive]
87
    )
88
89
    File.chmod!(wrapper, 0o700)
90
91
    on_exit(fn ->
92
      restore_environment(previous)
93
      File.rm_rf!(root)
94
    end)
95
96
    %{
97
      cli_entry: cli_entry,
98
      node: node,
99
      environment: [
100
        {"OPENAGENTS_API_URL", api_origin},
101
        {"OPENAGENTS_TOKEN", plaintext},
102
        {"OPENAGENTS_CLI_ENTRY", cli_entry},
103
        {"PATH", bin <> ":" <> System.fetch_env!("PATH")},
104
        {"NO_COLOR", "1"}
105
      ],
106
      api_origin: api_origin,
107
      root: root,
108
      secret: plaintext,
109
      source: source,
110
      source_refs: refs,
111
      user: user
112
    }
113
  end
114
115
  test "CLI creates, pushes, clones, reconstructs, and imports once", context do
116
    checkout = local_checkout!(context.root, "created-source")
117
118
    create_task =
119
      cli_task(context, checkout, [
120
        "--json",
121
        "repo",
122
        "create",
123
        "created-project",
124
        "--public",
125
        "--source",
126
        checkout,
127
        "--wait-timeout",
128
        "20"
129
      ])
130
131
    assert :processed = await_outbox(&Provisioner.run_once/0)
132
    create_output = await_cli!(create_task, context.secret)
133
    assert create_output =~ "\"full_name\":\"#{context.user.github_login}/created-project\""
134
135
    setup_git_output =
136
      cli!(context, checkout, ["--json", "auth", "setup-git", "--local"])
137
138
    refute setup_git_output =~ context.secret
139
    git!(checkout, ["push", "-u", "origin", "HEAD:main"], context.environment)
140
    expected_sha = git!(checkout, ["rev-parse", "HEAD"], context.environment) |> String.trim()
141
142
    clone = Path.join(context.root, "cli-clone")
143
144
    clone_output =
145
      cli!(context, context.root, [
146
        "--json",
147
        "repo",
148
        "clone",
149
        "#{context.user.github_login}/created-project",
150
        clone
151
      ])
152
153
    refute clone_output =~ context.secret
154
155
    assert git!(clone, ["rev-parse", "HEAD"], context.environment) |> String.trim() ==
156
             expected_sha
157
158
    repository = Repositories.get_by_path!(context.user.github_login, "created-project")
159
    File.rm_rf!(Repos.bare_path(repository.storage_key))
160
161
    reconstructed = Path.join(context.root, "reconstructed-clone")
162
163
    _output =
164
      cli!(context, context.root, [
165
        "--json",
166
        "repo",
167
        "clone",
168
        "#{context.user.github_login}/created-project",
169
        reconstructed
170
      ])
171
172
    assert git!(reconstructed, ["rev-parse", "HEAD"], context.environment) |> String.trim() ==
173
             expected_sha
174
175
    anonymous = Path.join(context.root, "anonymous-clone")
176
177
    git!(
178
      context.root,
179
      [
180
        "-c",
181
        "credential.helper=",
182
        "clone",
183
        "#{context.api_origin}/git/#{context.user.github_login}/created-project.git",
184
        anonymous
185
      ],
186
      [{"GIT_TERMINAL_PROMPT", "0"}]
187
    )
188
189
    assert git!(anonymous, ["rev-parse", "HEAD"], []) |> String.trim() == expected_sha
190
191
    private_task =
192
      cli_task(context, context.root, [
193
        "--json",
194
        "repo",
195
        "create",
196
        "private-project",
197
        "--wait-timeout",
198
        "20"
199
      ])
200
201
    assert :processed = await_outbox(&Provisioner.run_once/0)
202
    _private_output = await_cli!(private_task, context.secret)
203
    private_repository = Repositories.get_by_path!(context.user.github_login, "private-project")
204
205
    private_url =
206
      "#{context.api_origin}/git/#{context.user.github_login}/private-project.git"
207
208
    {anonymous_private_output, anonymous_private_status} =
209
      git_raw(
210
        context.root,
211
        ["-c", "credential.helper=", "clone", private_url, Path.join(context.root, "concealed")],
212
        [{"GIT_TERMINAL_PROMPT", "0"}]
213
      )
214
215
    assert anonymous_private_status != 0
216
    refute anonymous_private_output =~ context.secret
217
218
    viewer = repository_user_fixture("cli-e2e-viewer")
219
    {:ok, _membership} = Repositories.add_member(private_repository, viewer, "viewer")
220
221
    {:ok, _viewer_token, viewer_plaintext} =
222
      ApiTokens.create(viewer, %{
223
        name: "CLI E2E viewer",
224
        scopes: ["forge:write"],
225
        lifetime_days: 1
226
      })
227
228
    viewer_context = %{
229
      context
230
      | secret: viewer_plaintext,
231
        environment:
232
          replace_environment(context.environment, "OPENAGENTS_TOKEN", viewer_plaintext)
233
    }
234
235
    viewer_clone = Path.join(context.root, "viewer-clone")
236
237
    _viewer_output =
238
      cli!(viewer_context, context.root, [
239
        "--json",
240
        "repo",
241
        "clone",
242
        "#{context.user.github_login}/private-project",
243
        viewer_clone
244
      ])
245
246
    cli!(viewer_context, viewer_clone, ["--json", "auth", "setup-git", "--local"])
247
    git!(viewer_clone, ["config", "user.email", "viewer@example.com"], [])
248
    git!(viewer_clone, ["config", "user.name", "Read-only viewer"], [])
249
    File.write!(Path.join(viewer_clone, "refused.txt"), "must not land\n")
250
    git!(viewer_clone, ["add", "refused.txt"], [])
251
    git!(viewer_clone, ["commit", "-m", "Refused viewer push"], [])
252
253
    {viewer_push_output, viewer_push_status} =
254
      git_raw(viewer_clone, ["push", "origin", "HEAD:main"], viewer_context.environment)
255
256
    assert viewer_push_status != 0
257
    refute viewer_push_output =~ viewer_plaintext
258
    assert Repos.refs(private_repository.storage_key) == %{}
259
260
    import_task =
261
      cli_task(context, context.root, [
262
        "--json",
263
        "repo",
264
        "import",
265
        "#{context.user.github_login}/source-project",
266
        "--name",
267
        "imported-project",
268
        "--wait-timeout",
269
        "20"
270
      ])
271
272
    executor = fn work -> Importer.import(work.repository, source_url: context.source) end
273
    assert :processed = await_outbox(fn -> Provisioner.run_once(executor) end)
274
    import_output = await_cli!(import_task, context.secret)
275
276
    assert import_output =~ "This is a one-time import" or
277
             import_output =~ "\"state\":\"completed\""
278
279
    imported = Repositories.get_by_path!(context.user.github_login, "imported-project")
280
    assert {:ok, _generation, imported_index} = WAL.read_index(imported.storage_key)
281
    assert WAL.refs(imported_index) == context.source_refs
282
283
    File.write!(Path.join(context.source, "later.txt"), "later GitHub change\n")
284
    git!(context.source, ["add", "later.txt"], [])
285
    git!(context.source, ["commit", "-m", "Later source change"], [])
286
    assert WAL.refs(imported_index) == context.source_refs
287
    assert Repos.refs(imported.storage_key) == context.source_refs
288
289
    remote_config = git!(checkout, ["config", "--get", "remote.origin.url"], [])
290
    refute remote_config =~ context.secret
291
    refute remote_config =~ "@localhost"
292
293
    write_receipt(context, expected_sha, imported)
294
  end
295
296
  defp cli_task(context, directory, arguments) do
297
    Task.async(fn -> cli_raw(context, directory, arguments) end)
298
  end
299
300
  defp cli!(context, directory, arguments) do
301
    context
302
    |> cli_raw(directory, arguments)
303
    |> assert_cli_success!(context.secret)
304
  end
305
306
  defp cli_raw(context, directory, arguments) do
307
    System.cmd(context.node, [context.cli_entry | arguments],
308
      cd: directory,
309
      env: context.environment,
310
      stderr_to_stdout: true
311
    )
312
  end
313
314
  defp await_cli!(task, secret) do
315
    task
316
    |> Task.await(30_000)
317
    |> assert_cli_success!(secret)
318
  end
319
320
  defp assert_cli_success!({output, 0}, secret) do
321
    refute output =~ secret
322
    output
323
  end
324
325
  defp assert_cli_success!({output, status}, _secret) do
326
    flunk("CLI exited with #{status}:\n#{output}")
327
  end
328
329
  defp await_outbox(run, remaining \\ 500)
330
  defp await_outbox(_run, 0), do: flunk("repository outbox work was not accepted")
331
332
  defp await_outbox(run, remaining) do
333
    case run.() do
334
      :processed ->
335
        :processed
336
337
      :idle ->
338
        receive do
339
        after
340
          10 -> await_outbox(run, remaining - 1)
341
        end
342
    end
343
  end
344
345
  defp local_checkout!(root, name) do
346
    path = Path.join(root, name)
347
    File.mkdir_p!(path)
348
    git!(path, ["init", "--initial-branch=main"], [])
349
    git!(path, ["config", "user.email", "cli-e2e@example.com"], [])
350
    git!(path, ["config", "user.name", "CLI E2E"], [])
351
    File.write!(Path.join(path, "README.md"), "CLI local E2E\n")
352
    git!(path, ["add", "README.md"], [])
353
    git!(path, ["commit", "-m", "Initial commit"], [])
354
    path
355
  end
356
357
  defp fixture_repository!(root) do
358
    path = local_checkout!(root, "github-source")
359
    git!(path, ["branch", "release"], [])
360
    git!(path, ["tag", "v1"], [])
361
    path
362
  end
363
364
  defp refs(path) do
365
    path
366
    |> git!(["for-each-ref", "--format=%(objectname) %(refname)"], [])
367
    |> String.split("\n", trim: true)
368
    |> Map.new(fn line ->
369
      [sha, name] = String.split(line, " ", parts: 2)
370
      {name, sha}
371
    end)
372
  end
373
374
  defp git!(directory, arguments, environment) do
375
    case git_raw(directory, arguments, environment) do
376
      {output, 0} -> output
377
      {output, status} -> flunk("git exited with #{status}:\n#{output}")
378
    end
379
  end
380
381
  defp git_raw(directory, arguments, environment) do
382
    System.cmd("git", arguments,
383
      cd: directory,
384
      env: environment,
385
      stderr_to_stdout: true
386
    )
387
  end
388
389
  defp replace_environment(environment, key, value) do
390
    Enum.map(environment, fn
391
      {^key, _old_value} -> {key, value}
392
      pair -> pair
393
    end)
394
  end
395
396
  defp write_receipt(context, pushed_sha, imported) do
397
    case System.get_env("OPENAGENTS_E2E_RECEIPT_PATH") do
398
      nil ->
399
        :ok
400
401
      path ->
402
        repository_import =
403
          imported
404
          |> OpenAgents.Repo.preload(:repository_import)
405
          |> Map.fetch!(:repository_import)
406
407
        contract_path =
408
          Application.app_dir(:openagents, "priv/api-contracts/repositories-v1.json")
409
410
        contract_digest =
411
          contract_path
412
          |> File.read!()
413
          |> then(&:crypto.hash(:sha256, &1))
414
          |> Base.encode16(case: :lower)
415
416
        body = %{
417
          "api_origin" => context.api_origin,
418
          "cli_revision" => System.get_env("OPENAGENTS_CLI_REVISION", "working-tree"),
419
          "contract_sha256" => contract_digest,
420
          "created_commit_sha" => pushed_sha,
421
          "import_ref_digest" => repository_import.source_ref_digest,
422
          "server_revision" => System.get_env("OPENAGENTS_SERVER_REVISION", "working-tree"),
423
          "secret_scan" => "passed",
424
          "test_count" => 1
425
        }
426
427
        File.write!(path, Jason.encode_to_iodata!(body, pretty: true))
428
    end
429
  end
430
431
  defp free_port do
432
    {:ok, socket} = :gen_tcp.listen(0, [])
433
    {:ok, port} = :inet.port(socket)
434
    :ok = :gen_tcp.close(socket)
435
    port
436
  end
437
438
  defp save_environment(keys), do: Map.new(keys, &{&1, Application.get_env(:openagents, &1)})
439
440
  defp restore_environment(environment) do
441
    Enum.each(environment, fn
442
      {key, nil} -> Application.delete_env(:openagents, key)
443
      {key, value} -> Application.put_env(:openagents, key, value)
444
    end)
445
  end
446
end
test/openagents_web/controllers/forge_user_controller_test.exs added +65

@@ -0,0 +1,65 @@

1
defmodule OpenAgentsWeb.ForgeUserControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.{Accounts, ApiTokens, GitHubOAuth}
5
6
  setup {Req.Test, :verify_on_exit!}
7
8
  setup do
9
    original = Application.get_env(:openagents, :github_api)
10
11
    Application.put_env(:openagents, :github_api,
12
      base_url: "https://github-api.internal",
13
      request_options: [plug: {Req.Test, __MODULE__}]
14
    )
15
16
    on_exit(fn -> Application.put_env(:openagents, :github_api, original) end)
17
    :ok
18
  end
19
20
  test "GET /api/v3/user returns the GitHub identity and eligible namespaces", %{conn: conn} do
21
    user = github_user("forge-user", "octavia")
22
23
    {:ok, user} =
24
      Accounts.store_github_token(user, "github-token", GitHubOAuth.required_scopes())
25
26
    {:ok, _token, plaintext} =
27
      ApiTokens.create(user, %{name: "CLI status", scopes: ["forge:write"]})
28
29
    Req.Test.expect(__MODULE__, fn github_conn ->
30
      case github_conn.request_path do
31
        "/user/memberships/orgs" ->
32
          Req.Test.json(github_conn, [])
33
34
        path ->
35
          raise "unexpected GitHub path: #{path}"
36
      end
37
    end)
38
39
    response =
40
      conn
41
      |> put_req_header("authorization", "Bearer " <> plaintext)
42
      |> get(~p"/api/v3/user")
43
44
    assert %{
45
             "id" => github_id,
46
             "login" => "octavia",
47
             "namespaces" => [
48
               %{"id" => namespace_id, "login" => "octavia", "type" => "user"}
49
             ],
50
             "token_expires_at" => token_expires_at
51
           } = json_response(response, 200)
52
53
    assert github_id == user.github_id
54
    assert namespace_id == user.github_id
55
    assert {:ok, _date_time, 0} = DateTime.from_iso8601(token_expires_at)
56
    assert get_resp_header(response, "cache-control") == ["no-store"]
57
  end
58
59
  test "GET /api/v3/user requires a bearer token", %{conn: conn} do
60
    assert %{"error" => "invalid_api_token"} =
61
             conn
62
             |> get(~p"/api/v3/user")
63
             |> json_response(401)
64
  end
65
end
test/openagents_web/controllers/repository_controller_test.exs modified +27

@@ -148,6 +148,33 @@ defmodule OpenAgentsWeb.RepositoryControllerTest do

148 148
           )
149 149
  end
150 150
151
  test "GET /api/v3/user/repos filters by GitHub namespace", %{conn: conn} do
152
    user = github_user("repository-api-list-namespace", "repo-list-filter")
153
154
    assert {:ok, _repository, :created} =
155
             Repositories.create_user_repository(
156
               user,
157
               %{name: "matching-project"},
158
               "list-namespace-key"
159
             )
160
161
    response =
162
      conn
163
      |> authorize(user)
164
      |> get(~p"/api/v3/user/repos?namespace=repo-list-filter&per_page=10")
165
      |> json_response(200)
166
167
    assert Enum.map(response["repositories"], & &1["full_name"]) == [
168
             "repo-list-filter/matching-project"
169
           ]
170
171
    assert %{"repositories" => []} =
172
             conn
173
             |> authorize(user)
174
             |> get(~p"/api/v3/user/repos?namespace=another-owner&per_page=10")
175
             |> json_response(200)
176
  end
177
151 178
  defp authorize(conn, user) do
152 179
    {:ok, _credential, plaintext} =
153 180
      ApiTokens.create(user, %{name: "repository API test", scopes: ["forge:write"]})
test/openagents_web/route_authority_test.exs modified +11 -1

@@ -49,11 +49,14 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

49 49
           ).pipe_through == [:forge_write_api]
50 50
  end
51 51
52
  test "repository list and import status reads require bearer authentication" do
52
  test "repository identity, list, and import status reads require bearer authentication" do
53
    forge_user = route!(:get, "/api/v3/user")
53 54
    repository_list = route!(:get, "/api/v3/user/repos")
54 55
    import_status = route!(:get, "/api/v3/repository-imports/:id")
55 56
    repository_view = route!(:get, "/api/v3/repos/:owner/:repo")
56 57
58
    assert forge_user.class == :authenticated_api
59
    assert forge_user.scope == "forge:read"
57 60
    assert repository_list.class == :authenticated_api
58 61
    assert repository_list.scope == "forge:read"
59 62
    assert import_status.class == :authenticated_api

@@ -61,6 +64,13 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

61 64
    assert repository_view.class == :public_read
62 65
    assert repository_view.principal == "anonymous or first-party bearer token"
63 66
67
    assert Phoenix.Router.route_info(
68
             OpenAgentsWeb.Router,
69
             "GET",
70
             "/api/v3/user",
71
             "stage.openagents.com"
72
           ).pipe_through == [:forge_write_api]
73
64 74
    assert Phoenix.Router.route_info(
65 75
             OpenAgentsWeb.Router,
66 76
             "GET",
test/support/repository_cli_github_fake.ex added +70

@@ -0,0 +1,70 @@

1
defmodule OpenAgents.Test.RepositoryCliGitHubFake do
2
  @moduledoc false
3
4
  import Plug.Conn
5
6
  def init(options), do: Map.new(options)
7
8
  def call(conn, options) do
9
    case {conn.method, conn.request_path} do
10
      {"GET", "/user/memberships/orgs"} ->
11
        json(conn, [])
12
13
      {"GET", "/repos/" <> full_name} when full_name == options.source_full_name ->
14
        json(conn, %{
15
          "id" => options.repository_id,
16
          "node_id" => "R_#{options.repository_id}",
17
          "name" => full_name |> String.split("/") |> List.last(),
18
          "full_name" => full_name,
19
          "private" => true,
20
          "description" => "Disposable CLI import fixture",
21
          "default_branch" => options.default_branch,
22
          "owner" => %{
23
            "id" => options.owner_id,
24
            "node_id" => "U_#{options.owner_id}",
25
            "login" => full_name |> String.split("/") |> List.first(),
26
            "avatar_url" => "https://avatars.githubusercontent.com/u/#{options.owner_id}?v=4",
27
            "type" => "User"
28
          },
29
          "permissions" => %{"pull" => true, "push" => true, "admin" => true}
30
        })
31
32
      {"GET", "/repos/" <> rest} ->
33
        reference_response(conn, rest, options)
34
35
      _unmatched ->
36
        send_resp(conn, 404, "not found")
37
    end
38
  end
39
40
  defp reference_response(conn, rest, options) do
41
    prefix = options.source_full_name <> "/git/matching-refs/"
42
43
    cond do
44
      rest == options.source_full_name <> "/git/trees/" <> options.default_branch ->
45
        json(conn, %{"truncated" => false, "tree" => []})
46
47
      String.starts_with?(rest, prefix) ->
48
        kind = rest |> String.replace_prefix(prefix, "") |> String.trim_trailing("/")
49
        expected_prefix = "refs/#{kind}/"
50
51
        refs =
52
          options.refs
53
          |> Enum.filter(fn {name, _value} -> String.starts_with?(name, expected_prefix) end)
54
          |> Enum.map(fn {name, value} ->
55
            %{"ref" => name, "object" => %{"type" => value.type, "sha" => value.sha}}
56
          end)
57
58
        json(conn, refs)
59
60
      true ->
61
        send_resp(conn, 404, "not found")
62
    end
63
  end
64
65
  defp json(conn, body) do
66
    conn
67
    |> put_resp_content_type("application/json")
68
    |> send_resp(200, Jason.encode!(body))
69
  end
70
end
test/test_helper.exs modified +1 -1

@@ -5,7 +5,7 @@

5 5
# (`mix test --only cluster`); this excludes them here for the same reason.
6 6
# The :skip tag is gone entirely: every lifted test now runs by default, so the
7 7
# suite is green because it passes, not because it is filtered.
8
ExUnit.start(exclude: [:cluster])
8
ExUnit.start(exclude: [:cluster, :cross_repo])
9 9
10 10
# The cluster stage needs the Erlang port mapper up before its first peer node.
11 11
# Leaving that to whichever module happened to run first made the stage

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