Add GitHub-backed repository creation

a12e6539a470 · AtlantisPleb · · parent cf3d9591cc31

Add GitHub-backed repository creation

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 INVARIANTS.md
  • modified config/config.exs
  • modified config/test.exs
  • modified docs/runtime-configuration.md
  • added lib/openagents/device_authorizations.ex
  • added lib/openagents/device_authorizations/device_authorization.ex
  • modified lib/openagents/forge/browse.ex
  • modified lib/openagents/forge/git_http.ex
  • modified lib/openagents/forge/janitor.ex
  • modified lib/openagents/forge/pushes.ex
  • modified lib/openagents/forge/repos.ex
  • modified lib/openagents/forge/supervisor.ex
  • modified lib/openagents/forge/sync.ex
  • modified lib/openagents/forge/targets.ex
  • modified lib/openagents/repositories.ex
  • added lib/openagents/repositories/github_projection.ex
  • added lib/openagents/repositories/idempotency_request.ex
  • added lib/openagents/repositories/importer.ex
  • added lib/openagents/repositories/namespace.ex
  • added lib/openagents/repositories/namespace_alias.ex
  • added lib/openagents/repositories/provisioner.ex
  • added lib/openagents/repositories/provisioning_outbox.ex
  • modified lib/openagents/repositories/repository.ex
  • added lib/openagents/repositories/repository_import.ex
  • modified lib/openagents/runtime_config.ex
  • added lib/openagents_web/controllers/api_contract_controller.ex
  • modified lib/openagents_web/controllers/auth_controller.ex
  • added lib/openagents_web/controllers/device_authorization_controller.ex
  • added lib/openagents_web/controllers/not_found_controller.ex
  • added lib/openagents_web/controllers/repository_controller.ex
  • added lib/openagents_web/controllers/repository_import_controller.ex
  • added lib/openagents_web/controllers/repository_import_json.ex
  • added lib/openagents_web/controllers/repository_json.ex
  • modified lib/openagents_web/live/code_blob_live.ex
  • modified lib/openagents_web/live/code_commit_live.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • added lib/openagents_web/live/device_authorization_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • added lib/openagents_web/live/repository_import_live.ex
  • added lib/openagents_web/live/repository_index_live.ex
  • added lib/openagents_web/live/repository_new_live.ex
  • modified lib/openagents_web/plugs/forge_git_auth.ex
  • added lib/openagents_web/plugs/optional_api_token_auth.ex
  • added lib/openagents_web/repository_access.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified ops/staging/gate-5-profile.sh
  • added priv/api-contracts/repositories-v1.json
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260820204317_add_repository_creation_lifecycle.exs
  • added priv/repo/migrations/20260820211218_add_cli_device_authorizations.exs
  • modified test/openagents/forge/git_http_test.exs
  • added test/openagents/forge/sync_test.exs
  • modified test/openagents/forge/targets_test.exs
  • added test/openagents/repositories/provisioner_test.exs
  • added test/openagents/repository_lifecycle_test.exs
  • modified test/openagents_web/auth_controller_test.exs
  • added test/openagents_web/controllers/api_contract_controller_test.exs
  • modified test/openagents_web/controllers/data_controller_test.exs
  • added test/openagents_web/controllers/device_authorization_controller_test.exs
  • added test/openagents_web/controllers/repository_controller_test.exs
  • added test/openagents_web/controllers/repository_import_controller_test.exs
  • modified test/openagents_web/live/code_live_test.exs
  • added test/openagents_web/live/device_authorization_live_test.exs
  • added test/openagents_web/live/repository_live_test.exs
  • modified test/openagents_web/route_authority_test.exs

Diff

66 files changed, +5465 -214

INVARIANTS.md modified +27

@@ -1765,6 +1765,32 @@ Evidence: `OpenAgents.Forge.Visibility`, `OpenAgents.Forge.Browse`, `OpenAgents.

1765 1765
`OpenAgentsWeb.CodeCommitLive`, `OpenAgentsWeb.CodeBlobLive`,
1766 1766
`OpenAgentsWeb.ChangelogController`, and their tests.
1767 1767
1768
### REPOSITORY-001 — GitHub identity names repositories; OpenAgents owns stored snapshots
1769
1770
Status: Current
1771
1772
Every hosted namespace retains one immutable GitHub user or organization ID.
1773
GitHub slugs are projections and old slugs remain aliases after a rename.
1774
Reserved product-route segments cannot become namespaces. A database repository
1775
row resolves every browser, API, and Git request to one opaque storage key; the
1776
initial `OpenAgentsInc/openagents.com` repository keeps the historical
1777
`openagents.com` key so existing WAL and object storage remain authoritative.
1778
1779
Repository creation atomically records the repository, owner membership,
1780
idempotency receipt, and durable provisioning work. A GitHub import freezes an
1781
authorized branch-and-tag ref map, stores no GitHub credential, persists the
1782
accepted objects through the forge WAL, and schedules no later synchronization.
1783
OpenAgents becomes the source of truth for the imported snapshot. Public ready
1784
repositories permit anonymous Git reads. Private reads and every write require
1785
an admitted repository principal, and read-only members cannot push.
1786
1787
Evidence: `OpenAgents.Repositories`, `OpenAgents.Repositories.Provisioner`,
1788
`OpenAgents.Repositories.Importer`, `OpenAgents.Forge.GitHTTP`,
1789
`test/openagents/repository_lifecycle_test.exs`,
1790
`test/openagents/repositories/provisioner_test.exs`,
1791
`test/openagents_web/controllers/repository_controller_test.exs`, and
1792
`test/openagents/forge/git_http_test.exs`.
1793
1768 1794
## Executable proof index
1769 1795
1770 1796
This index is part of the ledger. Every `Current` invariant has at least one

@@ -1847,3 +1873,4 @@ contract; the invariant prose above defines the assertion, not the filename.

1847 1873
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
1848 1874
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
1849 1875
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs` |
1876
| REPOSITORY-001 | `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/forge/git_http_test.exs` |
config/config.exs modified +2 -1

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

36 36
  staging_gate: 0,
37 37
  staging_cleanup_enabled: false,
38 38
  production_deploy_enabled: false,
39
  repository_provisioner_enabled: true,
39 40
  build_revision: "image",
40 41
  image_digest: nil,
41 42
  migrate_on_boot: false,

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

151 152
    base_url: "https://api.github.com",
152 153
    request_options: []
153 154
  ],
154
  github_oauth_scopes: ["repo"],
155
  github_oauth_scopes: ["repo", "read:org"],
155 156
  voice_recording: [
156 157
    enabled: false,
157 158
    timeslice_ms: 5_000,
config/test.exs modified +2

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

1 1
import Config
2 2
3
config :openagents, :repository_provisioner_enabled, false
4
3 5
config :openagents, :runtime_environment, :test
4 6
5 7
# Configure your database
docs/runtime-configuration.md modified +1 -1

@@ -130,7 +130,7 @@ URLs, receipts, or checked-in environment files.

130 130
| Database | `OPENAGENTS_MIGRATE_ON_BOOT` | `true` in staging and production |
131 131
| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Staging OAuth application credentials |
132 132
| GitHub | `GITHUB_REDIRECT_URI` | Exact HTTPS callback on `PHX_HOST` |
133
| GitHub | `GITHUB_OAUTH_SCOPES` | Exactly `repo`; profile identity needs no additional scope |
133
| GitHub | `GITHUB_OAUTH_SCOPES` | Exactly `repo,read:org`; repository import needs retained repository access and organization creation needs membership access |
134 134
| GitHub | `OPENAGENTS_ADMIN_GITHUB_IDS` | Comma-separated immutable numeric GitHub IDs allowed to use operator surfaces; never use logins |
135 135
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY` | Base64-encoded 32-byte staging key |
136 136
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY_ID` | Bounded active-key identifier prefixed with `development-`, `test-`, `staging-`, or `production-` to match the runtime |
lib/openagents/device_authorizations.ex added +209

@@ -0,0 +1,209 @@

1
defmodule OpenAgents.DeviceAuthorizations do
2
  @moduledoc "Short-lived, one-claim browser authorization for the OpenAgents CLI."
3
4
  import Ecto.Query
5
6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.ApiTokens
8
  alias OpenAgents.DeviceAuthorizations.DeviceAuthorization
9
  alias OpenAgents.Repo
10
11
  @ttl_seconds 600
12
  @interval_seconds 5
13
  @maximum_create_attempts 3
14
15
  def create, do: create(@maximum_create_attempts)
16
17
  def get_pending_by_user_code(user_code) when is_binary(user_code) do
18
    now = DateTime.utc_now()
19
20
    Repo.one(
21
      from authorization in DeviceAuthorization,
22
        where:
23
          authorization.user_code_digest == ^digest(normalize_user_code(user_code)) and
24
            authorization.state == "pending" and authorization.expires_at > ^now
25
    )
26
  end
27
28
  def get_pending_by_user_code(_user_code), do: nil
29
30
  def approve(user_code, %User{status: "active", id: user_id}) do
31
    transition(user_code, "approved", user_id)
32
  end
33
34
  def approve(_user_code, %User{}), do: {:error, :access_denied}
35
36
  def deny(user_code, %User{status: "active", id: user_id}) do
37
    transition(user_code, "denied", user_id)
38
  end
39
40
  def deny(_user_code, %User{}), do: {:error, :access_denied}
41
42
  def poll(device_code) when is_binary(device_code) and byte_size(device_code) < 256 do
43
    Repo.transaction(fn -> poll_locked(device_code, DateTime.utc_now()) end)
44
    |> case do
45
      {:ok, result} -> result
46
      {:error, reason} -> {:error, reason}
47
    end
48
  end
49
50
  def poll(_device_code), do: {:error, :access_denied}
51
52
  defp create(0), do: {:error, :authorization_unavailable}
53
54
  defp create(attempts_left) do
55
    device_code = random_url_token(32)
56
    user_code = random_user_code()
57
    expires_at = DateTime.add(DateTime.utc_now(), @ttl_seconds, :second)
58
59
    %DeviceAuthorization{}
60
    |> DeviceAuthorization.create_changeset(%{
61
      device_code_digest: digest(device_code),
62
      user_code_digest: digest(user_code),
63
      expires_at: expires_at,
64
      interval_seconds: @interval_seconds
65
    })
66
    |> Repo.insert()
67
    |> case do
68
      {:ok, authorization} ->
69
        {:ok, authorization, device_code, user_code}
70
71
      {:error, changeset} ->
72
        if Keyword.has_key?(changeset.errors, :device_code_digest) or
73
             Keyword.has_key?(changeset.errors, :user_code_digest),
74
           do: create(attempts_left - 1),
75
           else: {:error, changeset}
76
    end
77
  end
78
79
  defp transition(user_code, next_state, user_id) do
80
    now = DateTime.utc_now()
81
82
    result =
83
      Repo.transaction(fn ->
84
        authorization =
85
          Repo.one(
86
            from authorization in DeviceAuthorization,
87
              where:
88
                authorization.user_code_digest == ^digest(normalize_user_code(user_code)) and
89
                  authorization.state == "pending" and authorization.expires_at > ^now,
90
              lock: "FOR UPDATE"
91
          )
92
93
        case authorization do
94
          %DeviceAuthorization{} ->
95
            attrs =
96
              if next_state == "approved",
97
                do: [state: "approved", user_id: user_id, approved_at: now],
98
                else: [state: "denied", user_id: user_id, denied_at: now]
99
100
            authorization
101
            |> Ecto.Changeset.change(attrs)
102
            |> Repo.update!()
103
104
          nil ->
105
            Repo.rollback(:access_denied)
106
        end
107
      end)
108
109
    case result do
110
      {:ok, authorization} -> {:ok, authorization}
111
      {:error, _reason} -> {:error, :access_denied}
112
    end
113
  end
114
115
  defp poll_locked(device_code, now) do
116
    authorization =
117
      Repo.one(
118
        from authorization in DeviceAuthorization,
119
          where: authorization.device_code_digest == ^digest(device_code),
120
          lock: "FOR UPDATE"
121
      )
122
123
    case authorization do
124
      %DeviceAuthorization{expires_at: expires_at} = authorization ->
125
        if DateTime.compare(expires_at, now) != :gt,
126
          do: {:error, :access_denied},
127
          else: poll_by_state(authorization, now)
128
129
      _unavailable ->
130
        {:error, :access_denied}
131
    end
132
  end
133
134
  defp poll_by_state(%DeviceAuthorization{state: "pending"} = pending, now) do
135
    poll_pending(pending, now)
136
  end
137
138
  defp poll_by_state(%DeviceAuthorization{state: "approved", user_id: user_id} = approved, now) do
139
    claim(approved, Repo.get(User, user_id), now)
140
  end
141
142
  defp poll_by_state(_authorization, _now), do: {:error, :access_denied}
143
144
  defp poll_pending(%DeviceAuthorization{} = authorization, now) do
145
    elapsed =
146
      if authorization.last_polled_at,
147
        do: DateTime.diff(now, authorization.last_polled_at, :second),
148
        else: authorization.interval_seconds
149
150
    authorization
151
    |> Ecto.Changeset.change(
152
      last_polled_at: now,
153
      poll_count: authorization.poll_count + 1
154
    )
155
    |> Repo.update!()
156
157
    if elapsed < authorization.interval_seconds,
158
      do: {:error, :slow_down},
159
      else: {:error, :authorization_pending}
160
  end
161
162
  defp claim(%DeviceAuthorization{} = authorization, %User{status: "active"} = user, now) do
163
    case ApiTokens.create(user, %{
164
           name: "OpenAgents CLI",
165
           scopes: ["forge:write"],
166
           lifetime_days: 30
167
         }) do
168
      {:ok, api_token, plaintext} ->
169
        authorization
170
        |> Ecto.Changeset.change(
171
          state: "claimed",
172
          api_token_id: api_token.id,
173
          claimed_at: now
174
        )
175
        |> Repo.update!()
176
177
        {:ok, plaintext, api_token}
178
179
      {:error, _reason} ->
180
        Repo.rollback(:authorization_unavailable)
181
    end
182
  end
183
184
  defp claim(_authorization, _user, _now), do: {:error, :access_denied}
185
186
  defp normalize_user_code(code) do
187
    code
188
    |> String.trim()
189
    |> String.upcase()
190
  end
191
192
  defp random_user_code do
193
    alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
194
195
    8
196
    |> :crypto.strong_rand_bytes()
197
    |> :binary.bin_to_list()
198
    |> Enum.map_join(fn byte -> String.at(alphabet, rem(byte, byte_size(alphabet))) end)
199
    |> then(fn <<left::binary-size(4), right::binary-size(4)>> -> left <> "-" <> right end)
200
  end
201
202
  defp random_url_token(bytes) do
203
    bytes
204
    |> :crypto.strong_rand_bytes()
205
    |> Base.url_encode64(padding: false)
206
  end
207
208
  defp digest(value), do: :crypto.hash(:sha256, value)
209
end
lib/openagents/device_authorizations/device_authorization.ex added +54

@@ -0,0 +1,54 @@

1
defmodule OpenAgents.DeviceAuthorizations.DeviceAuthorization do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "device_authorizations" do
12
    field :device_code_digest, :binary
13
    field :user_code_digest, :binary
14
    field :state, :string, default: "pending"
15
    field :scopes, {:array, :string}, default: ["forge:write"]
16
    field :interval_seconds, :integer, default: 5
17
    field :poll_count, :integer, default: 0
18
    field :last_polled_at, :utc_datetime_usec
19
    field :expires_at, :utc_datetime_usec
20
    field :approved_at, :utc_datetime_usec
21
    field :denied_at, :utc_datetime_usec
22
    field :claimed_at, :utc_datetime_usec
23
24
    belongs_to :user, OpenAgents.Accounts.User
25
    belongs_to :api_token, OpenAgents.ApiTokens.ApiToken
26
27
    timestamps()
28
  end
29
30
  def create_changeset(authorization, attrs) do
31
    authorization
32
    |> cast(attrs, [
33
      :device_code_digest,
34
      :user_code_digest,
35
      :expires_at,
36
      :interval_seconds
37
    ])
38
    |> put_change(:state, "pending")
39
    |> put_change(:scopes, ["forge:write"])
40
    |> validate_required([
41
      :device_code_digest,
42
      :user_code_digest,
43
      :state,
44
      :scopes,
45
      :expires_at,
46
      :interval_seconds
47
    ])
48
    |> validate_number(:interval_seconds, greater_than_or_equal_to: 1, less_than_or_equal_to: 30)
49
    |> unique_constraint(:device_code_digest)
50
    |> unique_constraint(:user_code_digest)
51
    |> check_constraint(:state, name: :device_authorizations_state_check)
52
    |> check_constraint(:interval_seconds, name: :device_authorizations_interval_check)
53
  end
54
end
lib/openagents/forge/browse.ex modified +18 -8

@@ -14,6 +14,7 @@ defmodule OpenAgents.Forge.Browse do

14 14
  require Logger
15 15
16 16
  alias OpenAgents.Forge.{Repos, Sync}
17
  alias OpenAgents.Repositories.Repository
17 18
18 19
  @ref_pattern ~r|^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$|
19 20
  @path_pattern ~r|^[^\x00]{1,512}$|

@@ -54,18 +55,18 @@ defmodule OpenAgents.Forge.Browse do

54 55
  # node-local cache fault, not a reason to fail a public read: serve what
55 56
  # this node already has, and let the operator repair path fix the cache.
56 57
  defp freshen(repo) do
57
    Sync.ensure_fresh(repo)
58
    Sync.ensure_fresh(storage_key(repo), default_branch(repo))
58 59
  rescue
59 60
    error ->
60 61
      Logger.warning(
61
        "forge_browse_sync_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(error)}"
62
        "forge_browse_sync_failed repo=#{storage_key(repo)} code=#{OpenAgents.OperationalLog.code(error)}"
62 63
      )
63 64
64 65
      :ok
65 66
  catch
66 67
    :exit, reason ->
67 68
      Logger.warning(
68
        "forge_browse_sync_exited repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
69
        "forge_browse_sync_exited repo=#{storage_key(repo)} code=#{OpenAgents.OperationalLog.code(reason)}"
69 70
      )
70 71
71 72
      :ok

@@ -73,9 +74,11 @@ defmodule OpenAgents.Forge.Browse do

73 74
74 75
  @doc "The default branch head, if the repo has one."
75 76
  def head(repo) do
76
    case Repos.refs(repo) do
77
      %{"refs/heads/main" => sha} -> {:ok, sha}
78
      refs when map_size(refs) > 0 -> {:ok, refs |> Map.values() |> List.first()}
77
    refs = Repos.refs(storage_key(repo))
78
79
    case Map.fetch(refs, "refs/heads/#{default_branch(repo)}") do
80
      {:ok, sha} -> {:ok, sha}
81
      :error when map_size(refs) > 0 -> {:ok, refs |> Enum.sort() |> hd() |> elem(1)}
79 82
      _ -> {:error, :empty}
80 83
    end
81 84
  end

@@ -83,6 +86,7 @@ defmodule OpenAgents.Forge.Browse do

83 86
  @doc "Branches and tags as `[%{name, sha, kind}]`, bounded."
84 87
  def refs(repo) do
85 88
    repo
89
    |> storage_key()
86 90
    |> Repos.refs()
87 91
    |> Enum.flat_map(fn {name, sha} ->
88 92
      case name do

@@ -292,7 +296,7 @@ defmodule OpenAgents.Forge.Browse do

292 296
293 297
  defp check(repo, ref) do
294 298
    cond do
295
      not Repos.valid_name?(repo) -> {:error, :not_found}
299
      not Repos.valid_storage_key?(storage_key(repo)) -> {:error, :not_found}
296 300
      not valid_ref?(ref) -> {:error, :not_found}
297 301
      true -> :ok
298 302
    end

@@ -306,7 +310,13 @@ defmodule OpenAgents.Forge.Browse do

306 310
    end
307 311
  end
308 312
309
  defp git(repo, args), do: Repos.git(Repos.bare_path(repo), args)
313
  defp git(repo, args), do: Repos.git(Repos.bare_path(storage_key(repo)), args)
314
315
  defp storage_key(%Repository{storage_key: storage_key}), do: storage_key
316
  defp storage_key(storage_key) when is_binary(storage_key), do: storage_key
317
318
  defp default_branch(%Repository{default_branch: default_branch}), do: default_branch
319
  defp default_branch(_storage_key), do: "main"
310 320
311 321
  defp truncate(binary, cap) when byte_size(binary) > cap,
312 322
    do: binary |> binary_part(0, cap) |> trim_to_valid_utf8()
lib/openagents/forge/git_http.ex modified +157 -32

@@ -21,9 +21,11 @@ defmodule OpenAgents.Forge.GitHTTP do

21 21
22 22
  @behaviour Plug
23 23
24
  import Ecto.Query
24 25
  import Plug.Conn
25 26
26 27
  alias OpenAgents.Forge.{Pushes, Repos, Sync}
28
  alias OpenAgents.Repositories
27 29
28 30
  @max_body_bytes 512 * 1024 * 1024
29 31
  @read_chunk 8 * 1024 * 1024

@@ -34,14 +36,14 @@ defmodule OpenAgents.Forge.GitHTTP do

34 36
  @impl true
35 37
  def call(conn, _opts) do
36 38
    case {conn.method, split_repo(conn.path_info)} do
37
      {"GET", {:ok, repo, ["info", "refs"]}} ->
38
        advertise(conn, repo, first_query(conn, "service"))
39
      {"GET", {:ok, owner, name, ["info", "refs"]}} ->
40
        advertise(conn, owner, name, first_query(conn, "service"))
39 41
40
      {"POST", {:ok, repo, ["git-upload-pack"]}} ->
41
        upload_pack(conn, repo)
42
      {"POST", {:ok, owner, name, ["git-upload-pack"]}} ->
43
        upload_pack(conn, owner, name)
42 44
43
      {"POST", {:ok, repo, ["git-receive-pack"]}} ->
44
        receive_pack(conn, repo)
45
      {"POST", {:ok, owner, name, ["git-receive-pack"]}} ->
46
        receive_pack(conn, owner, name)
45 47
46 48
      _other ->
47 49
        send_resp(conn, 404, "not found") |> halt()

@@ -50,11 +52,15 @@ defmodule OpenAgents.Forge.GitHTTP do

50 52
51 53
  # ── routes ──────────────────────────────────────────────────────────────
52 54
53
  defp advertise(conn, repo, service) when service in ["git-upload-pack", "git-receive-pack"] do
54
    with :ok <- check_repo(repo) do
55
      Sync.ensure_fresh(repo)
55
  defp advertise(conn, owner, name, service)
56
       when service in ["git-upload-pack", "git-receive-pack"] do
57
    operation = if service == "git-upload-pack", do: :read, else: :write
58
59
    with {:ok, repository} <- resolve_repository(conn, owner, name),
60
         :ok <- authorize(conn, repository, operation) do
61
      Sync.ensure_fresh(repository.storage_key, repository.default_branch)
56 62
      command = String.trim_leading(service, "git-")
57
      path = Repos.ensure_repo!(repo)
63
      path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
58 64
59 65
      {output, 0} =
60 66
        run_git_service(command, ["--advertise-refs", path], "", git_protocol(conn))

@@ -67,18 +73,19 @@ defmodule OpenAgents.Forge.GitHTTP do

67 73
      |> send_resp(200, header <> output)
68 74
      |> halt()
69 75
    else
70
      {:error, status, message} -> send_resp(conn, status, message) |> halt()
76
      error -> send_git_error(conn, error)
71 77
    end
72 78
  end
73 79
74
  defp advertise(conn, _repo, _service),
80
  defp advertise(conn, _owner, _name, _service),
75 81
    do: send_resp(conn, 400, "dumb http protocol is not supported") |> halt()
76 82
77
  defp upload_pack(conn, repo) do
78
    with :ok <- check_repo(repo),
83
  defp upload_pack(conn, owner, name) do
84
    with {:ok, repository} <- resolve_repository(conn, owner, name),
85
         :ok <- authorize(conn, repository, :read),
79 86
         {:ok, body, conn} <- read_git_body(conn) do
80
      Sync.ensure_fresh(repo)
81
      path = Repos.ensure_repo!(repo)
87
      Sync.ensure_fresh(repository.storage_key, repository.default_branch)
88
      path = Repos.ensure_repo!(repository.storage_key, repository.default_branch)
82 89
      {output, _status} = run_git_service("upload-pack", [path], body, git_protocol(conn))
83 90
84 91
      conn

@@ -87,15 +94,20 @@ defmodule OpenAgents.Forge.GitHTTP do

87 94
      |> send_resp(200, output)
88 95
      |> halt()
89 96
    else
90
      {:error, status, message} -> send_resp(conn, status, message) |> halt()
97
      error -> send_git_error(conn, error)
91 98
    end
92 99
  end
93 100
94
  defp receive_pack(conn, repo) do
95
    with :ok <- check_repo(repo),
96
         :ok <- check_push_allowed(conn),
101
  defp receive_pack(conn, owner, name) do
102
    with {:ok, repository} <- resolve_repository(conn, owner, name),
103
         :ok <- authorize(conn, repository, :write),
97 104
         {:ok, body, conn} <- read_git_body(conn) do
98
      case Pushes.handle_receive_pack(repo, body, principal(conn), git_protocol(conn)) do
105
      case Pushes.handle_receive_pack(
106
             repository.storage_key,
107
             body,
108
             principal(conn),
109
             git_protocol(conn)
110
           ) do
99 111
        {:ok, output} ->
100 112
          conn
101 113
          |> put_resp_content_type("application/x-git-receive-pack-result")

@@ -110,34 +122,147 @@ defmodule OpenAgents.Forge.GitHTTP do

110 122
          send_resp(conn, 500, "push failed") |> halt()
111 123
      end
112 124
    else
113
      {:error, status, message} -> send_resp(conn, status, message) |> halt()
125
      error -> send_git_error(conn, error)
114 126
    end
115 127
  end
116 128
117 129
  # ── helpers ─────────────────────────────────────────────────────────────
118 130
119 131
  defp split_repo([segment | rest]) do
120
    case String.split(segment, ".git") do
121
      [name, ""] -> {:ok, name, rest}
122
      _ -> {:ok, segment, rest}
132
    case strip_git_suffix(segment) do
133
      {:ok, "openagents.com"} -> {:ok, "OpenAgentsInc", "openagents.com", rest}
134
      {:ok, name} -> {:ok, nil, name, rest}
135
      :error -> split_namespaced_repo(segment, rest)
123 136
    end
124 137
  end
125 138
126 139
  defp split_repo(_), do: :error
127 140
128
  defp check_repo(repo) do
129
    if Repos.valid_name?(repo), do: :ok, else: {:error, 404, "unknown repository"}
141
  defp split_namespaced_repo(owner, [segment | rest]) do
142
    case strip_git_suffix(segment) do
143
      {:ok, name} -> {:ok, owner, name, rest}
144
      :error -> :error
145
    end
146
  end
147
148
  defp split_namespaced_repo(_owner, _rest), do: :error
149
150
  defp strip_git_suffix(segment) do
151
    if String.ends_with?(segment, ".git") and byte_size(segment) > 4 do
152
      {:ok, String.trim_trailing(segment, ".git")}
153
    else
154
      :error
155
    end
156
  end
157
158
  defp resolve_repository(conn, owner, name) when is_binary(owner) do
159
    case OpenAgents.Repo.one(repository_query(owner, name)) do
160
      nil -> repository_not_found(conn)
161
      repository -> {:ok, repository}
162
    end
163
  end
164
165
  defp resolve_repository(conn, nil, name) do
166
    case conn.assigns[:forge_principal] do
167
      %{kind: kind} when kind in [:operator, :machine] ->
168
        if Repos.valid_name?(name) do
169
          {:ok,
170
           %OpenAgents.Repositories.Repository{
171
             owner: name,
172
             name: name,
173
             storage_key: name,
174
             default_branch: "main",
175
             visibility: "private",
176
             lifecycle_state: "ready"
177
           }}
178
        else
179
          repository_not_found(conn)
180
        end
181
182
      _principal ->
183
        repository_not_found(conn)
184
    end
185
  end
186
187
  defp repository_query(owner, name) do
188
    owner_key = String.downcase(owner)
189
    name_key = String.downcase(name)
190
191
    from repository in OpenAgents.Repositories.Repository,
192
      join: namespace in assoc(repository, :namespace),
193
      left_join: namespace_alias in OpenAgents.Repositories.NamespaceAlias,
194
      on: namespace_alias.namespace_id == namespace.id and namespace_alias.slug_key == ^owner_key,
195
      where:
196
        repository.name_key == ^name_key and repository.lifecycle_state == "ready" and
197
          namespace.state == "active" and
198
          (namespace.slug_key == ^owner_key or not is_nil(namespace_alias.id)),
199
      preload: [namespace: namespace]
200
  end
201
202
  defp authorize(_conn, %{visibility: "public"}, :read), do: :ok
203
204
  defp authorize(conn, repository, :read) do
205
    case conn.assigns[:forge_principal] do
206
      nil -> authentication_required()
207
      %{kind: :user, user: user} -> member_read(repository, user)
208
      %{kind: kind} when kind in [:operator, :machine] -> operational_access(repository)
209
    end
130 210
  end
131 211
132
  # Pushes require an authenticated principal (set by the router auth plug);
133
  # a missing principal here means a misconfigured pipeline — refuse.
134
  defp check_push_allowed(conn) do
212
  defp authorize(conn, repository, :write) do
135 213
    case conn.assigns[:forge_principal] do
136
      %{} -> :ok
137
      _ -> {:error, 401, "authentication required"}
214
      nil ->
215
        authentication_required()
216
217
      %{kind: :user, user: user} ->
218
        if Repositories.writable?(repository, user) do
219
          :ok
220
        else
221
          case Repositories.membership_role(repository, user) do
222
            nil -> {:error, 404, "unknown repository"}
223
            _read_only -> {:error, 403, "repository is read only"}
224
          end
225
        end
226
227
      %{kind: kind} when kind in [:operator, :machine] ->
228
        operational_access(repository)
138 229
    end
139 230
  end
140 231
232
  defp member_read(repository, user) do
233
    if Repositories.membership_role(repository, user),
234
      do: :ok,
235
      else: {:error, 404, "unknown repository"}
236
  end
237
238
  defp operational_access(repository) do
239
    if repository.storage_key in Repos.allowed_repos(),
240
      do: :ok,
241
      else: {:error, 404, "unknown repository"}
242
  end
243
244
  defp repository_not_found(conn) do
245
    if conn.assigns[:forge_principal],
246
      do: {:error, 404, "unknown repository"},
247
      else: authentication_required()
248
  end
249
250
  defp authentication_required,
251
    do:
252
      {:error, 401, "authentication required",
253
       [{"www-authenticate", ~s(Basic realm="openagents-forge")}]}
254
255
  defp send_git_error(conn, {:error, status, message}) do
256
    conn |> send_resp(status, message) |> halt()
257
  end
258
259
  defp send_git_error(conn, {:error, status, message, headers}) do
260
    conn =
261
      Enum.reduce(headers, conn, fn {name, value}, acc -> put_resp_header(acc, name, value) end)
262
263
    conn |> send_resp(status, message) |> halt()
264
  end
265
141 266
  defp principal(conn) do
142 267
    case conn.assigns[:forge_principal] do
143 268
      %{kind: kind, id: id} -> "#{kind}:#{id}"
lib/openagents/forge/janitor.ex modified +17 -1

@@ -18,6 +18,8 @@ defmodule OpenAgents.Forge.Janitor do

18 18
19 19
  use GenServer
20 20
21
  import Ecto.Query
22
21 23
  require Logger
22 24
23 25
  alias OpenAgents.Forge.Repos

@@ -49,7 +51,7 @@ defmodule OpenAgents.Forge.Janitor do

49 51
    # F1 (#124): the WAL→receipt replayer rides the same slow tick — any
50 52
    # push receipt lost to a crash or restore is re-derived, exactly once
51 53
    # by WAL index position.
52
    Enum.each(Repos.allowed_repos(), fn repo ->
54
    Enum.each(receipt_repository_keys(), fn repo ->
53 55
      safely(fn -> OpenAgents.Forge.Pushes.reconcile_receipts(repo) end)
54 56
    end)
55 57

@@ -117,6 +119,20 @@ defmodule OpenAgents.Forge.Janitor do

117 119
    _error -> nil
118 120
  end
119 121
122
  defp receipt_repository_keys do
123
    hosted =
124
      OpenAgents.Repositories.Repository
125
      |> where([repository], repository.lifecycle_state == "ready")
126
      |> order_by([repository], asc: repository.id)
127
      |> limit(1_000)
128
      |> select([repository], repository.storage_key)
129
      |> OpenAgents.Repo.all()
130
131
    Enum.uniq(Repos.allowed_repos() ++ hosted)
132
  rescue
133
    _error -> Repos.allowed_repos()
134
  end
135
120 136
  defp stale?(path, now_ms) do
121 137
    case File.stat(path, time: :posix) do
122 138
      {:ok, %{mtime: mtime}} -> now_ms - mtime * 1000 > @retention_ms
lib/openagents/forge/pushes.ex modified +8 -10

@@ -29,11 +29,11 @@ defmodule OpenAgents.Forge.Pushes do

29 29
  """
30 30
  def handle_receive_pack(repo, body, principal, git_protocol) do
31 31
    :global.trans({{:forge_push, repo}, self()}, fn ->
32
      do_handle(repo, body, principal, git_protocol)
32
      do_handle(repo, body, principal, git_protocol, false)
33 33
    end)
34 34
  end
35 35
36
  defp do_handle(repo, body, principal, git_protocol) do
36
  defp do_handle(repo, body, principal, git_protocol, retried?) do
37 37
    started_at = System.monotonic_time(:millisecond)
38 38
    Sync.ensure_fresh(repo)
39 39
    path = Repos.ensure_repo!(repo)

@@ -61,6 +61,10 @@ defmodule OpenAgents.Forge.Pushes do

61 61
            mirror_async(repo)
62 62
            {:ok, output}
63 63
64
          {:error, :cas_conflict} when not retried? ->
65
            Sync.ensure_fresh(repo)
66
            do_handle(repo, body, principal, git_protocol, true)
67
64 68
          {:error, reason} ->
65 69
            Logger.error(
66 70
              "forge_push_wal_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"

@@ -74,7 +78,7 @@ defmodule OpenAgents.Forge.Pushes do

74 78
75 79
  # ── WAL persist (ack barrier) ───────────────────────────────────────────
76 80
77
  defp persist(repo, body, refs_after, principal, retried? \\ false) do
81
  defp persist(repo, body, refs_after, principal) do
78 82
    {expected, index} =
79 83
      case WAL.read_index(repo) do
80 84
        {:ok, generation, index} -> {generation, index}

@@ -88,6 +92,7 @@ defmodule OpenAgents.Forge.Pushes do

88 92
         entry = %{
89 93
           "seq" => seq,
90 94
           "object" => object,
95
           "format" => "receive_pack",
91 96
           "refs" => refs_after,
92 97
           "principal" => principal,
93 98
           "pushed_at" => DateTime.utc_now() |> DateTime.to_iso8601()

@@ -95,13 +100,6 @@ defmodule OpenAgents.Forge.Pushes do

95 100
         {:ok, _generation} <- WAL.cas_index(repo, expected, WAL.append_entry(index, entry)) do
96 101
      {:ok, seq}
97 102
    else
98
      {:error, :cas_conflict} when not retried? ->
99
        # Someone else advanced the WAL. Re-sync the cache (their entries
100
        # replay on top of ours only if compatible; converge_refs settles it)
101
        # and try once more from the new head.
102
        Sync.ensure_fresh(repo)
103
        persist(repo, body, Repos.refs(repo), principal, true)
104
105 103
      {:error, reason} ->
106 104
        {:error, reason}
107 105
    end
lib/openagents/forge/repos.ex modified +17 -2

@@ -9,6 +9,7 @@ defmodule OpenAgents.Forge.Repos do

9 9
  """
10 10
11 11
  @name_pattern ~r/^[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9])){0,63}$/
12
  @storage_key_pattern ~r/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
12 13
13 14
  @doc "The forge data directory (bare repos + WAL cache + beam artifacts)."
14 15
  def data_dir do

@@ -27,21 +28,35 @@ defmodule OpenAgents.Forge.Repos do

27 28
28 29
  def valid_name?(_), do: false
29 30
31
  @doc "Whether an opaque repository storage key is safe as one path segment."
32
  def valid_storage_key?(storage_key) when is_binary(storage_key),
33
    do: Regex.match?(@storage_key_pattern, storage_key)
34
35
  def valid_storage_key?(_storage_key), do: false
36
30 37
  @doc "Absolute path of the bare repository for `repo`."
31 38
  def bare_path(repo), do: Path.join([data_dir(), "repos", repo <> ".git"])
32 39
33 40
  @doc "Initialize the bare repository if absent. Returns the path."
34
  def ensure_repo!(repo) do
41
  def ensure_repo!(repo, default_branch \\ "main") do
35 42
    path = bare_path(repo)
36 43
37 44
    unless File.exists?(Path.join(path, "HEAD")) do
38 45
      File.mkdir_p!(path)
39
      {_, 0} = git(path, ["init", "--bare", "--initial-branch=main", path])
46
      {_, 0} = git(path, ["init", "--bare", "--initial-branch=#{default_branch}", path])
40 47
    end
41 48
49
    set_default_branch!(repo, default_branch)
50
42 51
    path
43 52
  end
44 53
54
  def set_default_branch!(repo, default_branch) do
55
    path = bare_path(repo)
56
    {_, 0} = git(path, ["symbolic-ref", "HEAD", "refs/heads/#{default_branch}"])
57
    :ok
58
  end
59
45 60
  @doc "Current refs of the bare repo as a `%{name => sha}` map."
46 61
  def refs(repo) do
47 62
    path = bare_path(repo)
lib/openagents/forge/supervisor.ex modified +11 -1

@@ -10,11 +10,21 @@ defmodule OpenAgents.Forge.Supervisor do

10 10
  @impl true
11 11
  def init(_init_arg) do
12 12
    children =
13
      [{Task.Supervisor, name: OpenAgents.Forge.TaskSupervisor}] ++ deploy_lane_children()
13
      [
14
        {Task.Supervisor, name: OpenAgents.Forge.TaskSupervisor}
15
      ] ++ repository_children() ++ deploy_lane_children()
14 16
15 17
    Supervisor.init(children, strategy: :one_for_one)
16 18
  end
17 19
20
  defp repository_children do
21
    if Application.get_env(:openagents, :repository_provisioner_enabled, true) do
22
      [{OpenAgents.Repositories.Provisioner, []}]
23
    else
24
      []
25
    end
26
  end
27
18 28
  # The deploy lane (builder + hot loader) runs in the release; tests start
19 29
  # these under their own supervision with scripted executors.
20 30
  defp deploy_lane_children do
lib/openagents/forge/sync.ex modified +39 -7

@@ -16,13 +16,13 @@ defmodule OpenAgents.Forge.Sync do

16 16
  or nothing pushed yet) — read paths degrade to serving the local cache if
17 17
  the WAL is unreachable, logging honestly, rather than failing reads.
18 18
  """
19
  def ensure_fresh(repo) do
19
  def ensure_fresh(repo, default_branch \\ "main") do
20 20
    case WAL.read_index(repo) do
21 21
      {:error, :not_found} ->
22 22
        :ok
23 23
24 24
      {:ok, _generation, index} ->
25
        replay_missing(repo, index)
25
        replay_missing(repo, index, default_branch)
26 26
27 27
      {:error, reason} ->
28 28
        Logger.warning(

@@ -34,8 +34,8 @@ defmodule OpenAgents.Forge.Sync do

34 34
  end
35 35
36 36
  @doc "Replay WAL entries the local repo has not applied. Used by reads and boot."
37
  def replay_missing(repo, index) do
38
    Repos.ensure_repo!(repo)
37
  def replay_missing(repo, index, default_branch \\ "main") do
38
    Repos.ensure_repo!(repo, default_branch)
39 39
    applied = Repos.applied_seq(repo)
40 40
41 41
    index

@@ -44,13 +44,25 @@ defmodule OpenAgents.Forge.Sync do

44 44
    |> Enum.each(fn entry -> apply_entry!(repo, entry) end)
45 45
46 46
    converge_refs(repo, index)
47
    Repos.set_default_branch!(repo, default_branch)
47 48
    :ok
48 49
  end
49 50
50
  defp apply_entry!(repo, %{"seq" => seq, "object" => object}) do
51
  defp apply_entry!(repo, %{"seq" => seq, "object" => object} = entry) do
51 52
    {:ok, payload} = WAL.get_entry(repo, object)
52 53
    path = Repos.bare_path(repo)
53
    {_output, 0} = run_receive_pack(path, payload)
54
55
    case entry["format"] || "receive_pack" do
56
      "receive_pack" ->
57
        {_output, 0} = run_receive_pack(path, payload)
58
59
      "git_bundle" ->
60
        :ok = unbundle(path, payload)
61
62
      "empty_import" ->
63
        :ok
64
    end
65
54 66
    Repos.record_applied_seq!(repo, seq)
55 67
  end
56 68

@@ -60,11 +72,31 @@ defmodule OpenAgents.Forge.Sync do

60 72
  defp converge_refs(repo, index) do
61 73
    target = WAL.refs(index)
62 74
63
    if target != %{} and Repos.refs(repo) != target do
75
    if Repos.refs(repo) != target do
64 76
      Repos.set_refs!(repo, target)
65 77
    end
66 78
  end
67 79
80
  defp unbundle(path, payload) do
81
    temporary_path =
82
      Path.join(
83
        System.tmp_dir!(),
84
        "openagents-import-#{System.unique_integer([:positive, :monotonic])}.bundle"
85
      )
86
87
    try do
88
      File.write!(temporary_path, payload, [:binary, :exclusive])
89
      File.chmod!(temporary_path, 0o600)
90
91
      case Repos.git(path, ["bundle", "unbundle", temporary_path]) do
92
        {_output, 0} -> :ok
93
        {_output, _status} -> raise "repository bundle could not be materialized"
94
      end
95
    after
96
      File.rm(temporary_path)
97
    end
98
  end
99
68 100
  defp run_receive_pack(path, payload) do
69 101
    GitHTTP.run_git_service("receive-pack", [path], payload, nil)
70 102
  end
lib/openagents/forge/targets.ex modified +15 -1

@@ -41,7 +41,8 @@ defmodule OpenAgents.Forge.Targets do

41 41
    commit_store = Keyword.get(opts, :commit_store, &commit_exists_store/2)
42 42
    details = Keyword.get(opts, :details, %{}) || %{}
43 43
44
    with :ok <- validate_sha_format(sha),
44
    with :ok <- validate_deployable_repo(repo),
45
         :ok <- validate_sha_format(sha),
45 46
         :ok <- with_commit_store(repo, sha, commit_store) do
46 47
      %Target{}
47 48
      |> Target.changeset(%{

@@ -267,6 +268,19 @@ defmodule OpenAgents.Forge.Targets do

267 268
    if Regex.match?(~r/^[0-9a-f]{40}$/, sha), do: :ok, else: {:error, :invalid_sha}
268 269
  end
269 270
271
  defp validate_deployable_repo(repo) do
272
    owner = Application.get_env(:openagents, :forge_url_owner, "OpenAgentsInc")
273
274
    deployable? =
275
      Enum.any?(OpenAgents.Forge.Repos.allowed_repos(), fn allowed ->
276
        repo == allowed or repo == "#{owner}/#{allowed}"
277
      end)
278
279
    if deployable?,
280
      do: :ok,
281
      else: {:error, :repository_not_deployable}
282
  end
283
270 284
  # The promotable set is exactly what the WAL-backed repo contains.
271 285
  defp commit_exists_store(repo, sha) do
272 286
    if commit_exists?(repo, sha), do: :ok, else: {:error, :unknown_sha}
lib/openagents/repositories.ex modified +513 -16

@@ -5,7 +5,16 @@ defmodule OpenAgents.Repositories do

5 5
6 6
  alias OpenAgents.Accounts.User
7 7
  alias OpenAgents.Repo
8
  alias OpenAgents.Repositories.{Membership, Repository}
8
9
  alias OpenAgents.Repositories.{
10
    IdempotencyRequest,
11
    Membership,
12
    Namespace,
13
    NamespaceAlias,
14
    ProvisioningOutbox,
15
    Repository,
16
    RepositoryImport
17
  }
9 18
10 19
  @initial_owner "OpenAgentsInc"
11 20
  @initial_name "openagents.com"

@@ -18,42 +27,259 @@ defmodule OpenAgents.Repositories do

18 27
  end
19 28
20 29
  def get_by_path!(owner, name) when is_binary(owner) and is_binary(name) do
30
    owner_key = String.downcase(owner)
31
    name_key = String.downcase(name)
32
33
    Repo.one!(repository_path_query(owner_key, name_key))
34
  end
35
36
  def get_public_by_path!(owner, name) when is_binary(owner) and is_binary(name) do
37
    owner_key = String.downcase(owner)
38
    name_key = String.downcase(name)
39
21 40
    Repo.one!(
22
      from repository in Repository,
23
        where:
24
          repository.owner_key == ^String.downcase(owner) and
25
            repository.name_key == ^String.downcase(name)
41
      from repository in repository_path_query(owner_key, name_key),
42
        where: repository.visibility == "public" and repository.lifecycle_state == "ready"
26 43
    )
27 44
  end
28 45
29
  def get_public_by_path!(owner, name) when is_binary(owner) and is_binary(name) do
46
  def get_visible_by_path!(owner, name, nil), do: get_public_by_path!(owner, name)
47
48
  def get_visible_by_path!(owner, name, %User{id: user_id}) do
49
    owner_key = String.downcase(owner)
50
    name_key = String.downcase(name)
51
30 52
    Repo.one!(
31
      from repository in Repository,
53
      from repository in repository_path_query(owner_key, name_key),
54
        left_join: membership in Membership,
55
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
32 56
        where:
33
          repository.owner_key == ^String.downcase(owner) and
34
            repository.name_key == ^String.downcase(name) and repository.visibility == "public"
57
          (repository.visibility == "public" and repository.lifecycle_state == "ready") or
58
            (not is_nil(membership.user_id) and
59
               membership.role in ^~w(owner maintainer contributor viewer))
35 60
    )
36 61
  end
37 62
38 63
  def get_writable_by_path!(owner, name, %User{id: user_id}) do
64
    owner_key = String.downcase(owner)
65
    name_key = String.downcase(name)
66
39 67
    Repo.one!(
40
      from repository in Repository,
68
      from repository in repository_path_query(owner_key, name_key),
41 69
        join: membership in Membership,
42 70
        on:
43 71
          membership.repository_id == repository.id and membership.user_id == ^user_id and
44 72
            membership.role in ^@writable_roles,
45 73
        join: user in User,
46 74
        on: user.id == membership.user_id and user.status == "active",
47
        where:
48
          repository.owner_key == ^String.downcase(owner) and
49
            repository.name_key == ^String.downcase(name)
75
        where: repository.lifecycle_state == "ready"
50 76
    )
51 77
  end
52 78
53 79
  def create_repository(attrs) do
54
    %Repository{}
55
    |> Repository.changeset(attrs)
56
    |> Repo.insert()
80
    owner = fetch_attr!(attrs, :owner)
81
82
    Repo.transaction(fn ->
83
      namespace = ensure_legacy_namespace!(owner)
84
      now = DateTime.utc_now()
85
86
      attrs =
87
        attrs
88
        |> Map.new()
89
        |> Map.put(:namespace_id, namespace.id)
90
        |> Map.put_new(:storage_key, Ecto.UUID.generate())
91
        |> Map.put_new(:lifecycle_state, "ready")
92
        |> Map.put_new(:provisioning_kind, "empty")
93
        |> Map.put_new(:ready_at, now)
94
95
      %Repository{}
96
      |> Repository.changeset(attrs)
97
      |> Repo.insert!()
98
      |> Repo.preload(:namespace)
99
    end)
100
    |> case do
101
      {:ok, repository} -> {:ok, repository}
102
      {:error, reason} -> {:error, reason}
103
    end
104
  rescue
105
    error in Ecto.InvalidChangesetError -> {:error, error.changeset}
106
  end
107
108
  def ensure_user_namespace(%User{} = user) do
109
    upsert_github_namespace(%{
110
      provider_account_id: user.github_id,
111
      slug: user.github_login,
112
      kind: "user",
113
      owner_user_id: user.id
114
    })
115
  end
116
117
  def upsert_github_namespace(attrs) when is_map(attrs) do
118
    attrs = Map.put_new(attrs, :provider_refreshed_at, DateTime.utc_now())
119
120
    Repo.transaction(fn ->
121
      provider_account_id = fetch_attr!(attrs, :provider_account_id)
122
      kind = fetch_attr!(attrs, :kind)
123
124
      existing =
125
        Repo.one(
126
          from namespace in Namespace,
127
            where:
128
              namespace.provider == "github" and
129
                namespace.provider_account_id == ^provider_account_id and
130
                namespace.kind == ^kind,
131
            lock: "FOR UPDATE"
132
        )
133
134
      case existing do
135
        nil ->
136
          %Namespace{}
137
          |> Namespace.changeset(attrs)
138
          |> Repo.insert!()
139
140
        %Namespace{} = namespace ->
141
          maybe_update_namespace!(namespace, attrs)
142
      end
143
    end)
144
    |> unwrap_transaction()
145
  rescue
146
    error in Ecto.InvalidChangesetError -> {:error, error.changeset}
147
  end
148
149
  def get_namespace_by_slug!(slug) when is_binary(slug) do
150
    slug_key = String.downcase(slug)
151
152
    case Repo.get_by(Namespace, slug_key: slug_key, state: "active") do
153
      %Namespace{} = namespace ->
154
        namespace
155
156
      nil ->
157
        Repo.one!(
158
          from namespace_alias in NamespaceAlias,
159
            join: namespace in assoc(namespace_alias, :namespace),
160
            where: namespace_alias.slug_key == ^slug_key and namespace.state == "active",
161
            select: namespace
162
        )
163
    end
164
  end
165
166
  def create_user_repository(%User{} = user, attrs, idempotency_key)
167
      when is_map(attrs) and is_binary(idempotency_key) do
168
    with {:ok, namespace} <- ensure_user_namespace(user) do
169
      create_repository_transaction(
170
        user,
171
        namespace,
172
        attrs,
173
        nil,
174
        "create",
175
        "empty",
176
        idempotency_key
177
      )
178
    end
179
  end
180
181
  def create_organization_repository(
182
        %User{} = user,
183
        %Namespace{kind: "organization"} = namespace,
184
        attrs,
185
        idempotency_key
186
      )
187
      when is_map(attrs) and is_binary(idempotency_key) do
188
    create_repository_transaction(
189
      user,
190
      namespace,
191
      attrs,
192
      nil,
193
      "create",
194
      "empty",
195
      idempotency_key
196
    )
197
  end
198
199
  def create_user_import(%User{} = user, source, attrs, idempotency_key)
200
      when is_map(source) and is_map(attrs) and is_binary(idempotency_key) do
201
    with {:ok, namespace} <- ensure_user_namespace(user),
202
         true <-
203
           fetch_attr!(source, :source_owner_id) == user.github_id or
204
             {:error, :source_namespace_mismatch} do
205
      create_repository_transaction(
206
        user,
207
        namespace,
208
        attrs,
209
        source,
210
        "github_import",
211
        "github_import",
212
        idempotency_key
213
      )
214
    end
215
  end
216
217
  def create_organization_import(
218
        %User{} = user,
219
        %Namespace{kind: "organization"} = namespace,
220
        source,
221
        attrs,
222
        idempotency_key
223
      )
224
      when is_map(source) and is_map(attrs) and is_binary(idempotency_key) do
225
    if fetch_attr!(source, :source_owner_id) == namespace.provider_account_id do
226
      create_repository_transaction(
227
        user,
228
        namespace,
229
        attrs,
230
        source,
231
        "github_import",
232
        "github_import",
233
        idempotency_key
234
      )
235
    else
236
      {:error, :source_namespace_mismatch}
237
    end
238
  end
239
240
  def list_visible_repositories(%User{id: user_id}) do
241
    Repo.all(
242
      from repository in Repository,
243
        join: namespace in assoc(repository, :namespace),
244
        left_join: membership in Membership,
245
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
246
        where:
247
          repository.visibility == "public" or
248
            (not is_nil(membership.user_id) and
249
               membership.role in ^~w(owner maintainer contributor viewer)),
250
        order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
251
        preload: [namespace: namespace]
252
    )
253
  end
254
255
  def list_visible_repositories_page(%User{id: user_id}, per_page, after_cursor)
256
      when per_page in 1..100 do
257
    query =
258
      from repository in Repository,
259
        join: namespace in assoc(repository, :namespace),
260
        left_join: membership in Membership,
261
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
262
        where:
263
          (repository.visibility == "public" and repository.lifecycle_state == "ready") or
264
            (not is_nil(membership.user_id) and
265
               membership.role in ^~w(owner maintainer contributor viewer)),
266
        order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
267
        preload: [namespace: namespace]
268
269
    query = apply_repository_cursor(query, after_cursor)
270
    rows = Repo.all(from row in query, limit: ^(per_page + 1))
271
    {Enum.take(rows, per_page), length(rows) > per_page}
272
  end
273
274
  def get_import_for_user!(id, %User{id: user_id}) do
275
    Repo.one!(
276
      from repository_import in RepositoryImport,
277
        join: repository in assoc(repository_import, :repository),
278
        join: membership in Membership,
279
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
280
        where: repository_import.id == ^id,
281
        preload: [repository: {repository, [:namespace]}]
282
    )
57 283
  end
58 284
59 285
  def add_member(%Repository{} = repository, %User{} = user, role \\ "contributor") do

@@ -81,6 +307,16 @@ defmodule OpenAgents.Repositories do

81 307
    )
82 308
  end
83 309
310
  def membership_role(%Repository{id: repository_id}, %User{id: user_id}) do
311
    Repo.one(
312
      from membership in Membership,
313
        where: membership.repository_id == ^repository_id and membership.user_id == ^user_id,
314
        select: membership.role
315
    )
316
  end
317
318
  def membership_role(%Repository{}, nil), do: nil
319
84 320
  def list_assignable_users(%Repository{id: repository_id}) do
85 321
    Repo.all(
86 322
      from user in User,

@@ -105,4 +341,265 @@ defmodule OpenAgents.Repositories do

105 341
            fragment("lower(?)", user.github_login) == ^String.downcase(URI.decode(login))
106 342
    )
107 343
  end
344
345
  defp create_repository_transaction(
346
         user,
347
         namespace,
348
         attrs,
349
         source,
350
         operation,
351
         provisioning_kind,
352
         idempotency_key
353
       ) do
354
    normalized_request = %{
355
      namespace_id: namespace.id,
356
      repository: normalize_repository_attrs(attrs),
357
      source: normalize_source(source)
358
    }
359
360
    request_digest = digest(normalized_request)
361
362
    result =
363
      Repo.transaction(fn ->
364
        case get_idempotency_request(user.id, operation, idempotency_key) do
365
          %IdempotencyRequest{request_digest: ^request_digest} = request ->
366
            replay_result(request, operation)
367
368
          %IdempotencyRequest{} ->
369
            Repo.rollback(:idempotency_conflict)
370
371
          nil ->
372
            create_repository_rows!(
373
              user,
374
              namespace,
375
              normalized_request.repository,
376
              normalized_request.source,
377
              operation,
378
              provisioning_kind,
379
              idempotency_key,
380
              request_digest
381
            )
382
        end
383
      end)
384
385
    case result do
386
      {:ok, value} -> value
387
      {:error, reason} -> {:error, reason}
388
    end
389
  rescue
390
    error in Ecto.InvalidChangesetError -> {:error, error.changeset}
391
  end
392
393
  defp create_repository_rows!(
394
         user,
395
         namespace,
396
         attrs,
397
         source,
398
         operation,
399
         provisioning_kind,
400
         idempotency_key,
401
         request_digest
402
       ) do
403
    repository =
404
      %Repository{}
405
      |> Repository.creation_changeset(attrs, namespace, user.id, provisioning_kind)
406
      |> Repo.insert!()
407
408
    %Membership{}
409
    |> Membership.changeset(%{repository_id: repository.id, user_id: user.id, role: "owner"})
410
    |> Repo.insert!()
411
412
    repository_import =
413
      if source do
414
        %RepositoryImport{}
415
        |> RepositoryImport.changeset(repository.id, source)
416
        |> Repo.insert!()
417
      end
418
419
    %ProvisioningOutbox{}
420
    |> ProvisioningOutbox.changeset(
421
      repository.id,
422
      repository_import && repository_import.id,
423
      operation
424
    )
425
    |> Repo.insert!()
426
427
    %IdempotencyRequest{}
428
    |> IdempotencyRequest.changeset(
429
      user.id,
430
      operation,
431
      idempotency_key,
432
      request_digest,
433
      %{
434
        repository_id: repository.id,
435
        repository_import_id: repository_import && repository_import.id
436
      }
437
    )
438
    |> Repo.insert!()
439
440
    repository = Repo.preload(repository, [:namespace, :memberships, :repository_import])
441
442
    if repository_import do
443
      {:ok, repository, repository.repository_import, :created}
444
    else
445
      {:ok, repository, :created}
446
    end
447
  end
448
449
  defp replay_result(request, "github_import") do
450
    repository =
451
      Repository
452
      |> Repo.get!(request.repository_id)
453
      |> Repo.preload([:namespace, :memberships, :repository_import])
454
455
    {:ok, repository, repository.repository_import, :replayed}
456
  end
457
458
  defp replay_result(request, _operation) do
459
    repository =
460
      Repository
461
      |> Repo.get!(request.repository_id)
462
      |> Repo.preload([:namespace, :memberships, :repository_import])
463
464
    {:ok, repository, :replayed}
465
  end
466
467
  defp get_idempotency_request(user_id, operation, idempotency_key) do
468
    Repo.one(
469
      from request in IdempotencyRequest,
470
        where:
471
          request.user_id == ^user_id and request.operation == ^operation and
472
            request.idempotency_key == ^idempotency_key,
473
        lock: "FOR UPDATE"
474
    )
475
  end
476
477
  defp maybe_update_namespace!(namespace, attrs) do
478
    next_slug = fetch_attr!(attrs, :slug)
479
480
    if String.downcase(next_slug) != namespace.slug_key do
481
      assert_namespace_slug_available!(next_slug, namespace.id)
482
483
      %NamespaceAlias{}
484
      |> NamespaceAlias.changeset(namespace.id, namespace.slug)
485
      |> Repo.insert!(on_conflict: :nothing, conflict_target: [:slug_key])
486
    end
487
488
    namespace
489
    |> Namespace.changeset(attrs)
490
    |> Repo.update!()
491
  end
492
493
  # Legacy domain tests create already-ready repositories without a GitHub
494
  # principal. Keep that fixture seam separate from the public creation API,
495
  # which always projects an immutable GitHub account ID.
496
  defp ensure_legacy_namespace!(owner) do
497
    slug_key = String.downcase(owner)
498
499
    case Repo.get_by(Namespace, slug_key: slug_key, state: "active") do
500
      %Namespace{} = namespace ->
501
        namespace
502
503
      nil ->
504
        provider_account_id = 8_000_000_000 + :erlang.phash2(slug_key, 1_000_000_000)
505
506
        %Namespace{}
507
        |> Namespace.changeset(%{
508
          provider_account_id: provider_account_id,
509
          slug: owner,
510
          kind: "organization",
511
          provider_refreshed_at: DateTime.utc_now()
512
        })
513
        |> Repo.insert!()
514
    end
515
  end
516
517
  defp assert_namespace_slug_available!(slug, namespace_id) do
518
    slug_key = String.downcase(slug)
519
520
    collision? =
521
      Repo.exists?(
522
        from namespace in Namespace,
523
          where:
524
            namespace.slug_key == ^slug_key and namespace.state == "active" and
525
              namespace.id != ^namespace_id
526
      ) or
527
        Repo.exists?(
528
          from namespace_alias in NamespaceAlias,
529
            where:
530
              namespace_alias.slug_key == ^slug_key and
531
                namespace_alias.namespace_id != ^namespace_id
532
        )
533
534
    if collision?, do: Repo.rollback(:namespace_slug_conflict), else: :ok
535
  end
536
537
  defp repository_path_query(owner_key, name_key) do
538
    from repository in Repository,
539
      join: namespace in assoc(repository, :namespace),
540
      left_join: namespace_alias in NamespaceAlias,
541
      on: namespace_alias.namespace_id == namespace.id and namespace_alias.slug_key == ^owner_key,
542
      where:
543
        repository.name_key == ^name_key and namespace.state == "active" and
544
          (namespace.slug_key == ^owner_key or not is_nil(namespace_alias.id)),
545
      distinct: repository.id,
546
      preload: [namespace: namespace]
547
  end
548
549
  defp apply_repository_cursor(query, nil), do: query
550
551
  defp apply_repository_cursor(query, {owner_key, name_key, id}) do
552
    from [repository, namespace, _membership] in query,
553
      where:
554
        namespace.slug_key > ^owner_key or
555
          (namespace.slug_key == ^owner_key and repository.name_key > ^name_key) or
556
          (namespace.slug_key == ^owner_key and repository.name_key == ^name_key and
557
             repository.id > ^id)
558
  end
559
560
  defp normalize_repository_attrs(attrs) do
561
    %{
562
      name: fetch_attr!(attrs, :name),
563
      description: fetch_attr(attrs, :description),
564
      visibility: fetch_attr(attrs, :visibility) || "private",
565
      default_branch: fetch_attr(attrs, :default_branch) || "main"
566
    }
567
  end
568
569
  defp normalize_source(nil), do: nil
570
571
  defp normalize_source(source) do
572
    %{
573
      provider: fetch_attr(source, :provider) || "github",
574
      source_repository_id: fetch_attr!(source, :source_repository_id),
575
      source_owner_id: fetch_attr!(source, :source_owner_id),
576
      source_full_name: fetch_attr!(source, :source_full_name),
577
      source_default_branch: fetch_attr!(source, :source_default_branch),
578
      source_ref_digest: fetch_attr!(source, :source_ref_digest),
579
      source_head_sha: fetch_attr(source, :source_head_sha),
580
      source_refs: fetch_attr!(source, :source_refs),
581
      source_uses_lfs: fetch_attr(source, :source_uses_lfs) || false
582
    }
583
  end
584
585
  defp digest(value) do
586
    value
587
    |> :erlang.term_to_binary([:deterministic])
588
    |> then(&:crypto.hash(:sha256, &1))
589
    |> Base.encode16(case: :lower)
590
  end
591
592
  defp fetch_attr(attrs, key) do
593
    Map.get(attrs, key, Map.get(attrs, Atom.to_string(key)))
594
  end
595
596
  defp fetch_attr!(attrs, key) do
597
    case fetch_attr(attrs, key) do
598
      nil -> raise ArgumentError, "missing #{key}"
599
      value -> value
600
    end
601
  end
602
603
  defp unwrap_transaction({:ok, value}), do: {:ok, value}
604
  defp unwrap_transaction({:error, reason}), do: {:error, reason}
108 605
end
lib/openagents/repositories/github_projection.ex added +156

@@ -0,0 +1,156 @@

1
defmodule OpenAgents.Repositories.GitHubProjection do
2
  @moduledoc "Projects retained GitHub authority into hosted namespaces and import snapshots."
3
4
  alias OpenAgents.{Accounts, GitHub, GitHubOAuth, Repositories}
5
  alias OpenAgents.Accounts.User
6
7
  @maximum_organization_pages 10
8
9
  def available_namespaces(%User{} = user) do
10
    with {:ok, user_namespace} <- Repositories.ensure_user_namespace(user),
11
         {:ok, token} <- retained_token(user),
12
         {:ok, organizations} <- collect_admin_organizations(token, 1, []) do
13
      namespaces =
14
        Enum.map(organizations, fn organization ->
15
          {:ok, namespace} =
16
            Repositories.upsert_github_namespace(%{
17
              provider_account_id: organization["id"],
18
              provider_node_id: organization["node_id"],
19
              slug: organization["login"],
20
              kind: "organization"
21
            })
22
23
          namespace
24
        end)
25
26
      {:ok, [user_namespace | namespaces]}
27
    else
28
      {:error, :github_token_missing} -> {:error, :github_connection_required}
29
      {:error, reason} -> {:error, reason}
30
    end
31
  end
32
33
  def import_candidates(%User{} = user, page \\ 1) when page in 1..1_000 do
34
    with {:ok, namespaces} <- available_namespaces(user),
35
         {:ok, token} <- retained_token(user),
36
         {:ok, repositories} <- GitHub.list_repository_page(token, page: page, per_page: 50) do
37
      namespace_ids = MapSet.new(namespaces, & &1.provider_account_id)
38
39
      items =
40
        Enum.filter(repositories["items"], fn repository ->
41
          repository["readable"] and not repository["archived"] and
42
            MapSet.member?(namespace_ids, repository["owner"]["id"])
43
        end)
44
45
      {:ok, %{repositories | "items" => items}}
46
    else
47
      {:error, :github_token_missing} -> {:error, :github_connection_required}
48
      {:error, reason} -> {:error, reason}
49
    end
50
  end
51
52
  def authorized_organization(%User{} = user, requested_slug) when is_binary(requested_slug) do
53
    with {:ok, token} <- retained_token(user),
54
         {:ok, membership} <- find_organization_membership(token, requested_slug, 1),
55
         "admin" <- membership["role"] || {:error, :namespace_not_allowed},
56
         organization = membership["organization"],
57
         {:ok, namespace} <-
58
           Repositories.upsert_github_namespace(%{
59
             provider_account_id: organization["id"],
60
             provider_node_id: organization["node_id"],
61
             slug: organization["login"],
62
             kind: "organization"
63
           }) do
64
      {:ok, namespace}
65
    else
66
      {:error, reason} -> {:error, reason}
67
      _not_admin -> {:error, :namespace_not_allowed}
68
    end
69
  end
70
71
  def import_source(%User{} = user, full_name) when is_binary(full_name) do
72
    with {:ok, token} <- retained_token(user),
73
         {:ok, repository} <- GitHub.get_import_source(token, full_name),
74
         {:ok, references} <- GitHub.list_references(token, full_name),
75
         {:ok, lfs_warning?} <- lfs_warning(token, repository, references) do
76
      refs = Map.new(references["refs"], &{&1["name"], &1["sha"]})
77
      default_branch = repository["default_branch"]
78
79
      {:ok,
80
       %{
81
         provider: "github",
82
         source_repository_id: repository["id"],
83
         source_owner_id: repository["owner"]["id"],
84
         source_full_name: repository["full_name"],
85
         source_default_branch: default_branch,
86
         source_ref_digest: references["digest"],
87
         source_head_sha: refs["refs/heads/#{default_branch}"],
88
         source_refs: refs,
89
         source_uses_lfs: lfs_warning?
90
       }, repository}
91
    else
92
      {:error, :github_token_missing} -> {:error, :github_connection_required}
93
      {:error, :github_not_found} -> {:error, :source_repository_not_accessible}
94
      {:error, :github_permission_denied} -> {:error, :source_repository_not_accessible}
95
      {:error, reason} -> {:error, reason}
96
    end
97
  end
98
99
  defp retained_token(%User{} = user) do
100
    if user.github_token_scopes == GitHubOAuth.required_scopes() do
101
      Accounts.github_token(user)
102
    else
103
      {:error, :github_scope_required}
104
    end
105
  end
106
107
  defp find_organization_membership(_token, _requested_slug, page)
108
       when page > @maximum_organization_pages,
109
       do: {:error, :namespace_not_allowed}
110
111
  defp find_organization_membership(token, requested_slug, page) do
112
    requested_key = String.downcase(requested_slug)
113
114
    with {:ok, result} <- GitHub.list_active_organization_memberships(token, page: page) do
115
      case Enum.find(result["items"], fn membership ->
116
             String.downcase(membership["organization"]["login"]) == requested_key
117
           end) do
118
        nil ->
119
          if result["has_next_page"],
120
            do: find_organization_membership(token, requested_slug, page + 1),
121
            else: {:error, :namespace_not_allowed}
122
123
        membership ->
124
          {:ok, membership}
125
      end
126
    end
127
  end
128
129
  defp collect_admin_organizations(_token, page, _acc)
130
       when page > @maximum_organization_pages,
131
       do: {:error, :github_pagination_limit_exceeded}
132
133
  defp collect_admin_organizations(token, page, acc) do
134
    with {:ok, result} <- GitHub.list_active_organization_memberships(token, page: page) do
135
      current =
136
        result["items"]
137
        |> Enum.filter(&(&1["role"] == "admin"))
138
        |> Enum.map(& &1["organization"])
139
140
      next_acc = acc ++ current
141
142
      if result["has_next_page"],
143
        do: collect_admin_organizations(token, page + 1, next_acc),
144
        else: {:ok, next_acc}
145
    end
146
  end
147
148
  defp lfs_warning(_token, _repository, %{"refs" => []}), do: {:ok, false}
149
150
  defp lfs_warning(token, repository, _references) do
151
    case GitHub.lfs_warning_inputs(token, repository["full_name"], repository["default_branch"]) do
152
      {:ok, inputs} -> {:ok, inputs["warning_recommended"]}
153
      {:error, reason} -> {:error, reason}
154
    end
155
  end
156
end
lib/openagents/repositories/idempotency_request.ex added +47

@@ -0,0 +1,47 @@

1
defmodule OpenAgents.Repositories.IdempotencyRequest do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "repository_idempotency_requests" do
12
    field :operation, :string
13
    field :idempotency_key, :string
14
    field :request_digest, :string
15
16
    belongs_to :user, OpenAgents.Accounts.User
17
    belongs_to :repository, OpenAgents.Repositories.Repository
18
    belongs_to :repository_import, OpenAgents.Repositories.RepositoryImport
19
    timestamps()
20
  end
21
22
  def changeset(request, user_id, operation, idempotency_key, request_digest, result) do
23
    request
24
    |> change()
25
    |> put_change(:user_id, user_id)
26
    |> put_change(:operation, operation)
27
    |> put_change(:idempotency_key, idempotency_key)
28
    |> put_change(:request_digest, request_digest)
29
    |> put_change(:repository_id, result.repository_id)
30
    |> put_change(:repository_import_id, Map.get(result, :repository_import_id))
31
    |> validate_required([
32
      :user_id,
33
      :operation,
34
      :idempotency_key,
35
      :request_digest,
36
      :repository_id
37
    ])
38
    |> validate_length(:operation, min: 1, max: 40)
39
    |> validate_length(:idempotency_key, min: 1, max: 255)
40
    |> validate_format(:request_digest, ~r/\A[0-9a-f]{64}\z/)
41
    |> unique_constraint([:user_id, :operation, :idempotency_key])
42
    |> foreign_key_constraint(:user_id)
43
    |> foreign_key_constraint(:repository_id)
44
    |> foreign_key_constraint(:repository_import_id)
45
    |> check_constraint(:request_digest, name: :repository_idempotency_digest_check)
46
  end
47
end
lib/openagents/repositories/importer.ex added +328

@@ -0,0 +1,328 @@

1
defmodule OpenAgents.Repositories.Importer do
2
  @moduledoc "Copies one accepted GitHub ref snapshot into the durable forge WAL."
3
4
  alias OpenAgents.{Accounts, GitHubOAuth, Repo}
5
  alias OpenAgents.Forge.{Repos, Sync, WAL}
6
  alias OpenAgents.Repositories.{Repository, RepositoryImport}
7
8
  @maximum_append_attempts 3
9
10
  def import(%Repository{} = repository, options \\ []) do
11
    repository = Repo.preload(repository, [:created_by_user, :repository_import])
12
    repository_import = fresh_import!(repository.repository_import.id)
13
14
    if repository_import.state == "completed" do
15
      Sync.ensure_fresh(repository.storage_key, repository.default_branch)
16
    else
17
      run_import(repository, repository_import, options)
18
    end
19
  end
20
21
  defp run_import(repository, repository_import, options) do
22
    running_import = mark_running!(repository_import)
23
    temporary_directory = temporary_directory(running_import.id)
24
25
    result =
26
      try do
27
        with {:ok, source_url, credential} <- source_access(repository, running_import, options),
28
             :ok <- File.mkdir_p(temporary_directory),
29
             :ok <- File.chmod(temporary_directory, 0o700),
30
             {:ok, source_repository} <- initialize_source(temporary_directory),
31
             :ok <- fetch_source(source_repository, source_url, credential, temporary_directory),
32
             {:ok, refs} <- verify_snapshot(source_repository, running_import),
33
             {:ok, payload, format} <-
34
               create_payload(source_repository, refs, temporary_directory),
35
             :ok <-
36
               append_import(
37
                 repository,
38
                 running_import,
39
                 payload,
40
                 format,
41
                 refs,
42
                 0
43
               ),
44
             :ok <- Sync.ensure_fresh(repository.storage_key, repository.default_branch) do
45
          mark_completed!(running_import)
46
          :ok
47
        end
48
      rescue
49
        _error -> {:error, :import_failed}
50
      catch
51
        _kind, _reason -> {:error, :import_failed}
52
      after
53
        File.rm_rf(temporary_directory)
54
      end
55
56
    case result do
57
      :ok ->
58
        :ok
59
60
      {:error, reason} ->
61
        mark_failed!(running_import, error_code(reason))
62
        {:error, reason}
63
    end
64
  end
65
66
  defp source_access(repository, repository_import, options) do
67
    case Keyword.get(options, :source_url) do
68
      source_url when is_binary(source_url) ->
69
        if Path.type(source_url) == :absolute,
70
          do: {:ok, source_url, nil},
71
          else: {:error, :invalid_source_url}
72
73
      nil ->
74
        with true <-
75
               repository.created_by_user.github_token_scopes == GitHubOAuth.required_scopes() or
76
                 {:error, :github_scope_required},
77
             {:ok, credential} <- Accounts.github_token(repository.created_by_user) do
78
          {:ok, "https://github.com/#{repository_import.source_full_name}.git", credential}
79
        end
80
    end
81
  end
82
83
  defp initialize_source(temporary_directory) do
84
    path = Path.join(temporary_directory, "source.git")
85
86
    case System.cmd("git", ["init", "--bare", path], stderr_to_stdout: true) do
87
      {_output, 0} -> {:ok, path}
88
      {_output, _status} -> {:error, :git_initialization_failed}
89
    end
90
  end
91
92
  defp fetch_source(source_repository, source_url, credential, temporary_directory) do
93
    with {:ok, environment} <- credential_environment(credential, temporary_directory) do
94
      args = [
95
        "-c",
96
        "credential.helper=",
97
        "-c",
98
        "credential.interactive=never",
99
        "fetch",
100
        "--force",
101
        "--prune",
102
        "--no-recurse-submodules",
103
        source_url,
104
        "+refs/heads/*:refs/heads/*",
105
        "+refs/tags/*:refs/tags/*"
106
      ]
107
108
      case Repos.git(source_repository, args, env: environment) do
109
        {_output, 0} -> :ok
110
        {_output, _status} -> {:error, :source_fetch_failed}
111
      end
112
    end
113
  end
114
115
  defp credential_environment(nil, _temporary_directory),
116
    do: {:ok, [{"GIT_TERMINAL_PROMPT", "0"}]}
117
118
  defp credential_environment(credential, temporary_directory) do
119
    token_path = Path.join(temporary_directory, "credential")
120
    askpass_path = Path.join(temporary_directory, "askpass")
121
122
    askpass = """
123
    #!/bin/sh
124
    case "$1" in
125
      *sername*) printf '%s\n' 'x-access-token' ;;
126
      *) exec /bin/cat "$OPENAGENTS_GITHUB_TOKEN_FILE" ;;
127
    esac
128
    """
129
130
    with :ok <- File.write(token_path, credential, [:binary, :exclusive]),
131
         :ok <- File.chmod(token_path, 0o600),
132
         :ok <- File.write(askpass_path, askpass, [:binary, :exclusive]),
133
         :ok <- File.chmod(askpass_path, 0o700) do
134
      {:ok,
135
       [
136
         {"GIT_ASKPASS", askpass_path},
137
         {"GIT_TERMINAL_PROMPT", "0"},
138
         {"OPENAGENTS_GITHUB_TOKEN_FILE", token_path}
139
       ]}
140
    end
141
  end
142
143
  defp verify_snapshot(source_repository, repository_import) do
144
    refs = refs(source_repository)
145
146
    cond do
147
      refs != repository_import.source_refs ->
148
        {:error, :source_changed}
149
150
      ref_digest(source_repository, refs) != repository_import.source_ref_digest ->
151
        {:error, :source_changed}
152
153
      true ->
154
        {:ok, refs}
155
    end
156
  end
157
158
  defp refs(source_repository) do
159
    case Repos.git(source_repository, ["for-each-ref", "--format=%(objectname) %(refname)"]) do
160
      {output, 0} ->
161
        output
162
        |> String.split("\n", trim: true)
163
        |> Map.new(fn line ->
164
          [sha, name] = String.split(line, " ", parts: 2)
165
          {name, sha}
166
        end)
167
168
      {_output, _status} ->
169
        %{}
170
    end
171
  end
172
173
  defp ref_digest(source_repository, refs) do
174
    refs
175
    |> Enum.sort_by(&elem(&1, 0))
176
    |> Enum.map_join("\n", fn {name, sha} ->
177
      {object_type, 0} = Repos.git(source_repository, ["cat-file", "-t", sha])
178
      Enum.join([name, String.trim(object_type), sha], "\0")
179
    end)
180
    |> then(&:crypto.hash(:sha256, &1))
181
    |> Base.encode16(case: :lower)
182
  end
183
184
  defp create_payload(_source_repository, refs, _temporary_directory) when map_size(refs) == 0,
185
    do: {:ok, "", "empty_import"}
186
187
  defp create_payload(source_repository, _refs, temporary_directory) do
188
    bundle_path = Path.join(temporary_directory, "snapshot.bundle")
189
190
    case Repos.git(source_repository, ["bundle", "create", bundle_path, "--all"]) do
191
      {_output, 0} ->
192
        case File.read(bundle_path) do
193
          {:ok, payload} -> {:ok, payload, "git_bundle"}
194
          {:error, _reason} -> {:error, :bundle_unavailable}
195
        end
196
197
      {_output, _status} ->
198
        {:error, :bundle_creation_failed}
199
    end
200
  end
201
202
  defp append_import(
203
         _repository,
204
         _repository_import,
205
         _payload,
206
         _format,
207
         _refs,
208
         attempt
209
       )
210
       when attempt >= @maximum_append_attempts,
211
       do: {:error, :wal_cas_conflict}
212
213
  defp append_import(repository, repository_import, payload, format, refs, attempt) do
214
    result =
215
      :global.trans({{:repository_import, repository.storage_key}, self()}, fn ->
216
        append_import_once(repository, repository_import, payload, format, refs)
217
      end)
218
219
    case result do
220
      {:error, :cas_conflict} ->
221
        append_import(repository, repository_import, payload, format, refs, attempt + 1)
222
223
      other ->
224
        other
225
    end
226
  end
227
228
  defp append_import_once(repository, repository_import, payload, format, refs) do
229
    with {:ok, expected, index} <- read_or_create_index(repository.storage_key),
230
         :missing <- import_entry(index, repository_import.id),
231
         true <- WAL.refs(index) == %{} or {:error, :destination_not_empty},
232
         seq = WAL.next_seq(index),
233
         {:ok, object} <- WAL.put_entry(repository.storage_key, seq, payload),
234
         entry = %{
235
           "seq" => seq,
236
           "object" => object,
237
           "format" => format,
238
           "import_id" => repository_import.id,
239
           "refs" => refs,
240
           "principal" => "github-import:#{repository_import.id}",
241
           "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
242
         },
243
         {:ok, _generation} <-
244
           WAL.cas_index(repository.storage_key, expected, WAL.append_entry(index, entry)) do
245
      :ok
246
    else
247
      {:present, entry} ->
248
        if entry["refs"] == refs, do: :ok, else: {:error, :import_receipt_conflict}
249
250
      {:error, reason} ->
251
        {:error, reason}
252
    end
253
  end
254
255
  defp read_or_create_index(storage_key) do
256
    case WAL.read_index(storage_key) do
257
      {:ok, generation, index} ->
258
        {:ok, generation, index}
259
260
      {:error, :not_found} ->
261
        case WAL.cas_index(storage_key, :none, WAL.new_index()) do
262
          {:ok, generation} -> {:ok, generation, WAL.new_index()}
263
          {:error, :cas_conflict} -> read_or_create_index(storage_key)
264
          {:error, reason} -> {:error, reason}
265
        end
266
267
      {:error, reason} ->
268
        {:error, reason}
269
    end
270
  end
271
272
  defp import_entry(index, import_id) do
273
    case Enum.find(WAL.entries(index), &(&1["import_id"] == import_id)) do
274
      nil -> :missing
275
      entry -> {:present, entry}
276
    end
277
  end
278
279
  defp fresh_import!(id), do: Repo.get!(RepositoryImport, id)
280
281
  defp mark_running!(repository_import) do
282
    now = DateTime.utc_now()
283
284
    repository_import
285
    |> RepositoryImport.transition_changeset(%{
286
      state: "running",
287
      attempt_count: repository_import.attempt_count + 1,
288
      error_code: nil,
289
      started_at: repository_import.started_at || now,
290
      completed_at: nil
291
    })
292
    |> Repo.update!()
293
  end
294
295
  defp mark_completed!(repository_import) do
296
    repository_import
297
    |> RepositoryImport.transition_changeset(%{
298
      state: "completed",
299
      attempt_count: repository_import.attempt_count,
300
      error_code: nil,
301
      started_at: repository_import.started_at,
302
      completed_at: DateTime.utc_now()
303
    })
304
    |> Repo.update!()
305
  end
306
307
  defp mark_failed!(repository_import, error_code) do
308
    fresh_import!(repository_import.id)
309
    |> RepositoryImport.transition_changeset(%{
310
      state: "failed",
311
      attempt_count: repository_import.attempt_count,
312
      error_code: error_code,
313
      started_at: repository_import.started_at,
314
      completed_at: nil
315
    })
316
    |> Repo.update!()
317
  end
318
319
  defp error_code(:source_changed), do: "source_changed"
320
  defp error_code(:github_scope_required), do: "github_scope_required"
321
  defp error_code(:github_token_missing), do: "github_connection_required"
322
  defp error_code(_reason), do: "import_failed"
323
324
  defp temporary_directory(import_id) do
325
    root = Application.get_env(:openagents, :repository_import_temp_dir, System.tmp_dir!())
326
    Path.join(root, "openagents-import-#{import_id}-#{System.unique_integer([:positive])}")
327
  end
328
end
lib/openagents/repositories/namespace.ex added +97

@@ -0,0 +1,97 @@

1
defmodule OpenAgents.Repositories.Namespace do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
  @reserved_slugs ~w(
11
    admin api assets auth changelog chat components computers controller data dev device docs git
12
    health healthz leaderboard machines memory repositories settings status voice
13
  )
14
15
  def reserved_slugs, do: @reserved_slugs
16
17
  schema "namespaces" do
18
    field :provider, :string, default: "github"
19
    field :provider_account_id, :integer
20
    field :provider_node_id, :string
21
    field :slug, :string
22
    field :slug_key, :string
23
    field :kind, :string
24
    field :provider_refreshed_at, :utc_datetime_usec
25
    field :state, :string, default: "active"
26
27
    belongs_to :owner_user, OpenAgents.Accounts.User
28
    has_many :aliases, OpenAgents.Repositories.NamespaceAlias
29
    has_many :repositories, OpenAgents.Repositories.Repository
30
31
    timestamps()
32
  end
33
34
  def changeset(namespace, attrs) do
35
    namespace
36
    |> cast(attrs, [
37
      :provider_account_id,
38
      :provider_node_id,
39
      :slug,
40
      :kind,
41
      :owner_user_id,
42
      :provider_refreshed_at,
43
      :state
44
    ])
45
    |> put_change(:provider, "github")
46
    |> validate_required([
47
      :provider,
48
      :provider_account_id,
49
      :slug,
50
      :kind,
51
      :provider_refreshed_at,
52
      :state
53
    ])
54
    |> validate_number(:provider_account_id, greater_than: 0)
55
    |> validate_inclusion(:kind, ~w(user organization))
56
    |> validate_inclusion(:state, ~w(active suspended retired))
57
    |> validate_length(:slug, min: 1, max: 100)
58
    |> validate_length(:provider_node_id, max: 100)
59
    |> validate_format(:slug, ~r/\A[A-Za-z0-9][A-Za-z0-9-]*\z/)
60
    |> normalize_slug()
61
    |> validate_exclusion(:slug_key, @reserved_slugs)
62
    |> validate_owner_kind()
63
    |> unique_constraint([:provider, :provider_account_id, :kind])
64
    |> unique_constraint(:slug_key, name: :namespaces_active_slug_key_index)
65
    |> foreign_key_constraint(:owner_user_id)
66
    |> check_constraint(:kind, name: :namespaces_kind_check)
67
    |> check_constraint(:state, name: :namespaces_state_check)
68
    |> check_constraint(:slug_key, name: :namespaces_normalized_slug_check)
69
    |> check_constraint(:owner_user_id, name: :namespaces_owner_kind_check)
70
  end
71
72
  defp normalize_slug(changeset) do
73
    case get_field(changeset, :slug) do
74
      slug when is_binary(slug) -> put_change(changeset, :slug_key, String.downcase(slug))
75
      _missing -> changeset
76
    end
77
  end
78
79
  defp validate_owner_kind(changeset) do
80
    case {get_field(changeset, :kind), get_field(changeset, :owner_user_id)} do
81
      {"user", owner_user_id} when is_binary(owner_user_id) ->
82
        changeset
83
84
      {"organization", nil} ->
85
        changeset
86
87
      {"user", _missing} ->
88
        add_error(changeset, :owner_user_id, "is required for a user namespace")
89
90
      {"organization", _present} ->
91
        add_error(changeset, :owner_user_id, "must be empty for an organization namespace")
92
93
      _invalid ->
94
        changeset
95
    end
96
  end
97
end
lib/openagents/repositories/namespace_alias.ex added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgents.Repositories.NamespaceAlias do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "namespace_aliases" do
12
    field :slug, :string
13
    field :slug_key, :string
14
    belongs_to :namespace, OpenAgents.Repositories.Namespace
15
    timestamps()
16
  end
17
18
  def changeset(namespace_alias, namespace_id, slug) do
19
    namespace_alias
20
    |> change()
21
    |> put_change(:namespace_id, namespace_id)
22
    |> put_change(:slug, slug)
23
    |> put_change(:slug_key, String.downcase(slug))
24
    |> validate_required([:namespace_id, :slug, :slug_key])
25
    |> validate_length(:slug, min: 1, max: 100)
26
    |> unique_constraint(:slug_key)
27
    |> foreign_key_constraint(:namespace_id)
28
    |> check_constraint(:slug_key, name: :namespace_aliases_normalized_slug_check)
29
  end
30
end
lib/openagents/repositories/provisioner.ex added +235

@@ -0,0 +1,235 @@

1
defmodule OpenAgents.Repositories.Provisioner do
2
  @moduledoc "Claims repository outbox work and materializes durable Git storage."
3
4
  use GenServer
5
6
  import Ecto.Query
7
8
  require Logger
9
10
  alias OpenAgents.Forge.{Repos, WAL}
11
  alias OpenAgents.Repo
12
  alias OpenAgents.Repositories.{ProvisioningOutbox, Repository}
13
14
  @lease_seconds 120
15
  @maximum_drain 100
16
17
  def start_link(options) do
18
    name = Keyword.get(options, :name, __MODULE__)
19
    gen_server_options = if name, do: [name: name], else: []
20
    GenServer.start_link(__MODULE__, options, gen_server_options)
21
  end
22
23
  def drain(server \\ __MODULE__), do: GenServer.call(server, :drain, 30_000)
24
25
  def run_once(executor \\ &execute/1) when is_function(executor, 1) do
26
    case claim_next() do
27
      nil ->
28
        :idle
29
30
      %ProvisioningOutbox{} = work ->
31
        case safe_execute(executor, work) do
32
          :ok -> complete(work)
33
          {:error, _reason} -> fail(work)
34
        end
35
36
        :processed
37
    end
38
  end
39
40
  @impl true
41
  def init(options) do
42
    state = %{
43
      executor: Keyword.get(options, :executor, &execute/1),
44
      poll_interval_ms: Keyword.get(options, :poll_interval_ms, poll_interval_ms())
45
    }
46
47
    schedule(state.poll_interval_ms)
48
    {:ok, state}
49
  end
50
51
  @impl true
52
  def handle_call(:drain, _from, state) do
53
    {:reply, {:ok, drain_now(state.executor, 0)}, state}
54
  end
55
56
  @impl true
57
  def handle_info(:poll, state) do
58
    _result = run_once(state.executor)
59
    schedule(state.poll_interval_ms)
60
    {:noreply, state}
61
  end
62
63
  defp drain_now(_executor, count) when count >= @maximum_drain, do: count
64
65
  defp drain_now(executor, count) do
66
    case run_once(executor) do
67
      :processed -> drain_now(executor, count + 1)
68
      :idle -> count
69
    end
70
  end
71
72
  defp claim_next do
73
    now = DateTime.utc_now()
74
    stale_before = DateTime.add(now, -@lease_seconds, :second)
75
76
    {:ok, work} =
77
      Repo.transaction(fn ->
78
        work =
79
          Repo.one(
80
            from outbox in ProvisioningOutbox,
81
              where:
82
                (outbox.state in ["pending", "failed"] and outbox.retry_at <= ^now) or
83
                  (outbox.state == "running" and outbox.claimed_at < ^stale_before),
84
              order_by: [asc: outbox.retry_at, asc: outbox.inserted_at, asc: outbox.id],
85
              limit: 1,
86
              lock: "FOR UPDATE SKIP LOCKED"
87
          )
88
89
        case work do
90
          nil ->
91
            nil
92
93
          %ProvisioningOutbox{} = claimed ->
94
            claimed
95
            |> ProvisioningOutbox.transition_changeset(%{
96
              state: "running",
97
              attempt_count: claimed.attempt_count + 1,
98
              retry_at: now,
99
              claimed_at: now,
100
              completed_at: nil,
101
              error_code: nil
102
            })
103
            |> Repo.update!()
104
        end
105
      end)
106
107
    if work do
108
      Repo.preload(work, repository: [:created_by_user, :repository_import])
109
    end
110
  end
111
112
  defp execute(%ProvisioningOutbox{operation: "create", repository: repository}) do
113
    initialize_empty(repository)
114
  end
115
116
  defp execute(%ProvisioningOutbox{operation: "github_import", repository: repository}) do
117
    OpenAgents.Repositories.Importer.import(repository)
118
  end
119
120
  defp initialize_empty(repository) do
121
    with :ok <- ensure_wal_index(repository.storage_key) do
122
      Repos.ensure_repo!(repository.storage_key, repository.default_branch)
123
      :ok
124
    end
125
  rescue
126
    _error -> {:error, :storage_unavailable}
127
  end
128
129
  defp ensure_wal_index(storage_key) do
130
    case WAL.read_index(storage_key) do
131
      {:ok, _generation, _index} ->
132
        :ok
133
134
      {:error, :not_found} ->
135
        case WAL.cas_index(storage_key, :none, WAL.new_index()) do
136
          {:ok, _generation} -> :ok
137
          {:error, :cas_conflict} -> ensure_wal_index(storage_key)
138
          {:error, reason} -> {:error, reason}
139
        end
140
141
      {:error, reason} ->
142
        {:error, reason}
143
    end
144
  end
145
146
  defp safe_execute(executor, work) do
147
    case executor.(work) do
148
      :ok -> :ok
149
      {:error, reason} -> {:error, reason}
150
      _invalid -> {:error, :invalid_executor_result}
151
    end
152
  rescue
153
    _error -> {:error, :provisioning_exception}
154
  catch
155
    _kind, _reason -> {:error, :provisioning_exception}
156
  end
157
158
  defp complete(work) do
159
    now = DateTime.utc_now()
160
161
    Repo.transaction(fn ->
162
      repository = lock_repository!(work.repository_id)
163
      outbox = lock_outbox!(work.id)
164
165
      repository
166
      |> Ecto.Changeset.change(
167
        lifecycle_state: "ready",
168
        ready_at: now,
169
        provision_error_code: nil
170
      )
171
      |> Repo.update!()
172
173
      outbox
174
      |> ProvisioningOutbox.transition_changeset(%{
175
        state: "completed",
176
        attempt_count: outbox.attempt_count,
177
        retry_at: now,
178
        claimed_at: outbox.claimed_at,
179
        completed_at: now,
180
        error_code: nil
181
      })
182
      |> Repo.update!()
183
    end)
184
185
    :ok
186
  end
187
188
  defp fail(work) do
189
    now = DateTime.utc_now()
190
    retry_at = DateTime.add(now, retry_delay(work.attempt_count), :second)
191
192
    Repo.transaction(fn ->
193
      repository = lock_repository!(work.repository_id)
194
      outbox = lock_outbox!(work.id)
195
196
      repository
197
      |> Ecto.Changeset.change(
198
        lifecycle_state: "failed",
199
        ready_at: nil,
200
        provision_error_code: "provisioning_failed"
201
      )
202
      |> Repo.update!()
203
204
      outbox
205
      |> ProvisioningOutbox.transition_changeset(%{
206
        state: "failed",
207
        attempt_count: outbox.attempt_count,
208
        retry_at: retry_at,
209
        claimed_at: outbox.claimed_at,
210
        completed_at: nil,
211
        error_code: "provisioning_failed"
212
      })
213
      |> Repo.update!()
214
    end)
215
216
    Logger.warning("repository_provisioning_failed code=provisioning_failed")
217
    :ok
218
  end
219
220
  defp lock_repository!(id) do
221
    Repo.one!(from repository in Repository, where: repository.id == ^id, lock: "FOR UPDATE")
222
  end
223
224
  defp lock_outbox!(id) do
225
    Repo.one!(from outbox in ProvisioningOutbox, where: outbox.id == ^id, lock: "FOR UPDATE")
226
  end
227
228
  defp retry_delay(attempt_count), do: min(attempt_count * attempt_count * 5, 300)
229
230
  defp schedule(interval), do: Process.send_after(self(), :poll, interval)
231
232
  defp poll_interval_ms do
233
    Application.get_env(:openagents, :repository_provisioner_poll_interval_ms, 1_000)
234
  end
235
end
lib/openagents/repositories/provisioning_outbox.ex added +53

@@ -0,0 +1,53 @@

1
defmodule OpenAgents.Repositories.ProvisioningOutbox do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "repository_provisioning_outbox" do
12
    field :operation, :string
13
    field :state, :string, default: "pending"
14
    field :attempt_count, :integer, default: 0
15
    field :retry_at, :utc_datetime_usec
16
    field :claimed_at, :utc_datetime_usec
17
    field :completed_at, :utc_datetime_usec
18
    field :error_code, :string
19
20
    belongs_to :repository, OpenAgents.Repositories.Repository
21
    belongs_to :repository_import, OpenAgents.Repositories.RepositoryImport
22
    timestamps()
23
  end
24
25
  def changeset(outbox, repository_id, repository_import_id, operation) do
26
    outbox
27
    |> change()
28
    |> put_change(:repository_id, repository_id)
29
    |> put_change(:repository_import_id, repository_import_id)
30
    |> put_change(:operation, operation)
31
    |> put_change(:state, "pending")
32
    |> put_change(:attempt_count, 0)
33
    |> put_change(:retry_at, DateTime.utc_now())
34
    |> validate_required([:repository_id, :operation, :state, :attempt_count, :retry_at])
35
    |> validate_inclusion(:operation, ~w(create github_import))
36
    |> validate_inclusion(:state, ~w(pending running completed failed))
37
    |> unique_constraint(:repository_id)
38
    |> foreign_key_constraint(:repository_id)
39
    |> foreign_key_constraint(:repository_import_id)
40
    |> check_constraint(:operation, name: :repository_provisioning_operation_check)
41
    |> check_constraint(:state, name: :repository_provisioning_state_check)
42
  end
43
44
  def transition_changeset(outbox, attrs) do
45
    outbox
46
    |> cast(attrs, [:state, :attempt_count, :retry_at, :claimed_at, :completed_at, :error_code])
47
    |> validate_required([:state, :attempt_count, :retry_at])
48
    |> validate_inclusion(:state, ~w(pending running completed failed))
49
    |> validate_number(:attempt_count, greater_than_or_equal_to: 0)
50
    |> validate_length(:error_code, max: 80)
51
    |> check_constraint(:state, name: :repository_provisioning_state_check)
52
  end
53
end
lib/openagents/repositories/repository.ex modified +90 -4

@@ -15,24 +15,99 @@ defmodule OpenAgents.Repositories.Repository do

15 15
    field :name_key, :string
16 16
    field :visibility, :string, default: "private"
17 17
    field :default_branch, :string, default: "main"
18
    field :description, :string
19
    field :lifecycle_state, :string, default: "provisioning"
20
    field :provisioning_kind, :string, default: "empty"
21
    field :provision_error_code, :string
22
    field :storage_key, :string
23
    field :ready_at, :utc_datetime_usec
18 24
25
    belongs_to :namespace, OpenAgents.Repositories.Namespace
26
    belongs_to :created_by_user, OpenAgents.Accounts.User
19 27
    has_many :memberships, OpenAgents.Repositories.Membership
28
    has_one :repository_import, OpenAgents.Repositories.RepositoryImport
29
    has_one :provisioning_outbox, OpenAgents.Repositories.ProvisioningOutbox
20 30
21 31
    timestamps()
22 32
  end
23 33
24 34
  def changeset(repository, attrs) do
25 35
    repository
26
    |> cast(attrs, [:owner, :name, :visibility, :default_branch])
27
    |> validate_required([:owner, :name, :visibility, :default_branch])
36
    |> cast(attrs, [
37
      :namespace_id,
38
      :owner,
39
      :name,
40
      :visibility,
41
      :default_branch,
42
      :description,
43
      :lifecycle_state,
44
      :provisioning_kind,
45
      :provision_error_code,
46
      :storage_key,
47
      :ready_at
48
    ])
49
    |> validate_required([
50
      :namespace_id,
51
      :owner,
52
      :name,
53
      :visibility,
54
      :default_branch,
55
      :lifecycle_state,
56
      :provisioning_kind,
57
      :storage_key
58
    ])
28 59
    |> validate_inclusion(:visibility, ~w(public private))
60
    |> validate_inclusion(:lifecycle_state, ~w(provisioning ready failed))
61
    |> validate_inclusion(:provisioning_kind, ~w(empty github_import))
29 62
    |> validate_length(:owner, min: 1, max: 100)
30
    |> validate_length(:name, min: 1, max: 100)
63
    |> validate_length(:name, min: 1, max: 64)
64
    |> validate_format(:name, ~r/\A[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9])){0,63}\z/)
65
    |> validate_length(:description, max: 350)
31 66
    |> validate_length(:default_branch, min: 1, max: 255)
67
    |> validate_branch_name()
68
    |> validate_length(:provision_error_code, max: 80)
32 69
    |> normalize_path()
33
    |> unique_constraint([:owner_key, :name_key])
70
    |> unique_constraint([:namespace_id, :name_key])
71
    |> unique_constraint(:storage_key)
72
    |> foreign_key_constraint(:namespace_id)
73
    |> foreign_key_constraint(:created_by_user_id)
34 74
    |> check_constraint(:visibility, name: :repositories_visibility_check)
35 75
    |> check_constraint(:owner_key, name: :repositories_normalized_path_check)
76
    |> check_constraint(:lifecycle_state, name: :repositories_lifecycle_state_check)
77
    |> check_constraint(:provisioning_kind, name: :repositories_provisioning_kind_check)
78
    |> check_constraint(:ready_at, name: :repositories_ready_state_check)
79
  end
80
81
  def creation_changeset(repository, attrs, namespace, created_by_user_id, provisioning_kind) do
82
    storage_key = Ecto.UUID.generate()
83
84
    attrs =
85
      attrs
86
      |> Map.new()
87
      |> normalize_creation_name()
88
      |> Map.put(:namespace_id, namespace.id)
89
      |> Map.put(:owner, namespace.slug)
90
      |> Map.put_new(:visibility, "private")
91
      |> Map.put_new(:default_branch, "main")
92
      |> Map.put(:lifecycle_state, "provisioning")
93
      |> Map.put(:provisioning_kind, provisioning_kind)
94
      |> Map.put(:storage_key, storage_key)
95
96
    repository
97
    |> changeset(attrs)
98
    |> put_change(:created_by_user_id, created_by_user_id)
99
  end
100
101
  defp normalize_creation_name(attrs) do
102
    case Map.get(attrs, :name, Map.get(attrs, "name")) do
103
      name when is_binary(name) ->
104
        attrs
105
        |> Map.delete("name")
106
        |> Map.put(:name, String.downcase(name))
107
108
      _missing ->
109
        attrs
110
    end
36 111
  end
37 112
38 113
  defp normalize_path(changeset) do

@@ -49,4 +124,15 @@ defmodule OpenAgents.Repositories.Repository do

49 124
  defp maybe_put_key(changeset, field, value) do
50 125
    put_change(changeset, field, String.downcase(value))
51 126
  end
127
128
  defp validate_branch_name(changeset) do
129
    validate_change(changeset, :default_branch, fn :default_branch, branch ->
130
      invalid? =
131
        String.starts_with?(branch, ["-", "."]) or
132
          String.ends_with?(branch, [".", "/", ".lock"]) or
133
          String.contains?(branch, ["..", "@{", "\\", " ", "~", "^", ":", "?", "*", "["])
134
135
      if invalid?, do: [default_branch: "is not a valid Git ref name"], else: []
136
    end)
137
  end
52 138
end
lib/openagents/repositories/repository_import.ex added +100

@@ -0,0 +1,100 @@

1
defmodule OpenAgents.Repositories.RepositoryImport do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "repository_imports" do
12
    field :provider, :string, default: "github"
13
    field :source_repository_id, :integer
14
    field :source_owner_id, :integer
15
    field :source_full_name, :string
16
    field :source_default_branch, :string
17
    field :source_ref_digest, :string
18
    field :source_head_sha, :string
19
    field :source_refs, :map
20
    field :source_uses_lfs, :boolean, default: false
21
    field :state, :string, default: "pending"
22
    field :attempt_count, :integer, default: 0
23
    field :error_code, :string
24
    field :started_at, :utc_datetime_usec
25
    field :completed_at, :utc_datetime_usec
26
27
    belongs_to :repository, OpenAgents.Repositories.Repository
28
    timestamps()
29
  end
30
31
  def changeset(repository_import, repository_id, attrs) do
32
    repository_import
33
    |> cast(attrs, [
34
      :provider,
35
      :source_repository_id,
36
      :source_owner_id,
37
      :source_full_name,
38
      :source_default_branch,
39
      :source_ref_digest,
40
      :source_head_sha,
41
      :source_refs,
42
      :source_uses_lfs
43
    ])
44
    |> put_change(:repository_id, repository_id)
45
    |> put_change(:provider, "github")
46
    |> put_change(:state, "pending")
47
    |> put_change(:attempt_count, 0)
48
    |> validate_required([
49
      :repository_id,
50
      :provider,
51
      :source_repository_id,
52
      :source_owner_id,
53
      :source_full_name,
54
      :source_default_branch,
55
      :source_ref_digest,
56
      :source_refs,
57
      :state
58
    ])
59
    |> validate_number(:source_repository_id, greater_than: 0)
60
    |> validate_number(:source_owner_id, greater_than: 0)
61
    |> validate_length(:source_full_name, min: 3, max: 202)
62
    |> validate_length(:source_default_branch, min: 1, max: 255)
63
    |> validate_format(:source_ref_digest, ~r/\A[0-9a-f]{64}\z/)
64
    |> validate_format(:source_head_sha, ~r/\A[0-9a-f]{40,64}\z/)
65
    |> validate_ref_map()
66
    |> unique_constraint(:repository_id)
67
    |> foreign_key_constraint(:repository_id)
68
    |> check_constraint(:state, name: :repository_imports_state_check)
69
    |> check_constraint(:source_ref_digest, name: :repository_imports_digest_check)
70
  end
71
72
  def transition_changeset(repository_import, attrs) do
73
    repository_import
74
    |> cast(attrs, [:state, :attempt_count, :error_code, :started_at, :completed_at])
75
    |> validate_required([:state, :attempt_count])
76
    |> validate_inclusion(:state, ~w(pending running completed failed))
77
    |> validate_number(:attempt_count, greater_than_or_equal_to: 0)
78
    |> validate_length(:error_code, max: 80)
79
    |> check_constraint(:state, name: :repository_imports_state_check)
80
  end
81
82
  defp validate_ref_map(changeset) do
83
    validate_change(changeset, :source_refs, fn :source_refs, refs ->
84
      valid? =
85
        is_map(refs) and map_size(refs) <= 10_000 and
86
          Enum.all?(refs, fn
87
            {"refs/heads/" <> name, sha} -> valid_ref_part?(name) and valid_sha?(sha)
88
            {"refs/tags/" <> name, sha} -> valid_ref_part?(name) and valid_sha?(sha)
89
            _invalid -> false
90
          end)
91
92
      if valid?, do: [], else: [source_refs: "must contain bounded branch and tag refs"]
93
    end)
94
  end
95
96
  defp valid_ref_part?(name),
97
    do: name != "" and byte_size(name) <= 255 and not String.contains?(name, "..")
98
99
  defp valid_sha?(sha), do: is_binary(sha) and Regex.match?(~r/\A[0-9a-f]{40,64}\z/, sha)
100
end
lib/openagents/runtime_config.ex modified +2 -2

@@ -281,9 +281,9 @@ defmodule OpenAgents.RuntimeConfig do

281 281
         :ok <- validate_redirect(redirect_uri, environment),
282 282
         :ok <-
283 283
           ensure(
284
             scopes == ["repo"],
284
             scopes == ["repo", "read:org"],
285 285
             :github_oauth_scopes,
286
             "must match the retained-token tool model"
286
             "must include repository and organization membership access"
287 287
           ),
288 288
         :ok <-
289 289
           ensure(
lib/openagents_web/controllers/api_contract_controller.ex added +26

@@ -0,0 +1,26 @@

1
defmodule OpenAgentsWeb.ApiContractController do
2
  @moduledoc "Serves immutable, versioned public API contract artifacts."
3
4
  use OpenAgentsWeb, :controller
5
6
  @repository_contract Application.app_dir(
7
                         :openagents,
8
                         "priv/api-contracts/repositories-v1.json"
9
                       )
10
11
  def repositories_v1(conn, _params) do
12
    contract = File.read!(@repository_contract)
13
14
    conn
15
    |> put_resp_content_type("application/json")
16
    |> put_resp_header("cache-control", "public, max-age=300")
17
    |> put_resp_header("etag", ~s("#{sha256(contract)}"))
18
    |> send_resp(:ok, contract)
19
  end
20
21
  defp sha256(bytes) do
22
    bytes
23
    |> then(&:crypto.hash(:sha256, &1))
24
    |> Base.encode16(case: :lower)
25
  end
26
end
lib/openagents_web/controllers/auth_controller.ex modified +1

@@ -31,6 +31,7 @@ defmodule OpenAgentsWeb.AuthController do

31 31
         {:ok, user} <- Accounts.upsert_github_user(profile),
32 32
         {:ok, active_user} <- Accounts.get_active_user(user.id),
33 33
         {:ok, _membership} <- Repositories.ensure_initial_membership(active_user),
34
         {:ok, _namespace} <- Repositories.ensure_user_namespace(active_user),
34 35
         {:ok, _stored} <-
35 36
           Accounts.store_github_token(active_user, access_token, granted_scopes) do
36 37
      conn
lib/openagents_web/controllers/device_authorization_controller.ex added +67

@@ -0,0 +1,67 @@

1
defmodule OpenAgentsWeb.DeviceAuthorizationController do
2
  @moduledoc "Creates and polls short-lived CLI browser authorizations."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.DeviceAuthorizations
7
8
  def create(conn, _params) do
9
    case DeviceAuthorizations.create() do
10
      {:ok, authorization, device_code, user_code} ->
11
        verification_uri = OpenAgentsWeb.Endpoint.url() <> "/device"
12
13
        conn
14
        |> put_status(:created)
15
        |> no_store()
16
        |> json(%{
17
          "device_code" => device_code,
18
          "user_code" => user_code,
19
          "verification_uri" => verification_uri,
20
          "verification_uri_complete" =>
21
            verification_uri <> "?" <> URI.encode_query(%{"user_code" => user_code}),
22
          "expires_in" => DateTime.diff(authorization.expires_at, DateTime.utc_now(), :second),
23
          "interval" => authorization.interval_seconds
24
        })
25
26
      {:error, _reason} ->
27
        error(conn, :service_unavailable, "authorization_unavailable")
28
    end
29
  end
30
31
  def token(conn, %{"device_code" => device_code}) do
32
    case DeviceAuthorizations.poll(device_code) do
33
      {:ok, plaintext, api_token} ->
34
        conn
35
        |> no_store()
36
        |> json(%{
37
          "access_token" => plaintext,
38
          "token_type" => "Bearer",
39
          "scope" => Enum.join(api_token.scopes, " "),
40
          "expires_in" => DateTime.diff(api_token.expires_at, DateTime.utc_now(), :second)
41
        })
42
43
      {:error, :authorization_pending} ->
44
        error(conn, :precondition_required, "authorization_pending")
45
46
      {:error, :slow_down} ->
47
        error(conn, :too_many_requests, "slow_down")
48
49
      {:error, :authorization_unavailable} ->
50
        error(conn, :service_unavailable, "authorization_unavailable")
51
52
      {:error, _reason} ->
53
        error(conn, :bad_request, "access_denied")
54
    end
55
  end
56
57
  def token(conn, _params), do: error(conn, :bad_request, "access_denied")
58
59
  defp error(conn, status, code) do
60
    conn
61
    |> put_status(status)
62
    |> no_store()
63
    |> json(%{"code" => code})
64
  end
65
66
  defp no_store(conn), do: put_resp_header(conn, "cache-control", "no-store")
67
end
lib/openagents_web/controllers/not_found_controller.ex added +7

@@ -0,0 +1,7 @@

1
defmodule OpenAgentsWeb.NotFoundController do
2
  @moduledoc false
3
4
  use OpenAgentsWeb, :controller
5
6
  def show(conn, _params), do: send_resp(conn, :not_found, "Not found")
7
end
lib/openagents_web/controllers/repository_controller.ex added +200

@@ -0,0 +1,200 @@

1
defmodule OpenAgentsWeb.RepositoryController do
2
  @moduledoc "Creates and reads hosted repositories."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.Repositories
7
  alias OpenAgents.Repositories.GitHubProjection
8
  alias OpenAgentsWeb.RepositoryJSON
9
10
  def create_user(conn, params) do
11
    with {:ok, idempotency_key} <- idempotency_key(conn),
12
         {:ok, attrs} <- repository_attrs(params),
13
         {:ok, repository, replay_state} <-
14
           Repositories.create_user_repository(conn.assigns.current_user, attrs, idempotency_key) do
15
      render_repository(conn, repository, conn.assigns.current_user, replay_state)
16
    else
17
      {:error, reason} -> render_error(conn, reason)
18
    end
19
  end
20
21
  def create_organization(conn, %{"org" => org} = params) do
22
    with {:ok, idempotency_key} <- idempotency_key(conn),
23
         {:ok, attrs} <- repository_attrs(params),
24
         {:ok, namespace} <-
25
           GitHubProjection.authorized_organization(conn.assigns.current_user, org),
26
         {:ok, repository, replay_state} <-
27
           Repositories.create_organization_repository(
28
             conn.assigns.current_user,
29
             namespace,
30
             attrs,
31
             idempotency_key
32
           ) do
33
      render_repository(conn, repository, conn.assigns.current_user, replay_state)
34
    else
35
      {:error, reason} -> render_error(conn, reason)
36
    end
37
  end
38
39
  def index(conn, params) do
40
    with {:ok, per_page} <- per_page(params),
41
         {:ok, after_cursor} <- decode_cursor(params["after"]) do
42
      {repositories, more?} =
43
        Repositories.list_visible_repositories_page(
44
          conn.assigns.current_user,
45
          per_page,
46
          after_cursor
47
        )
48
49
      next_cursor = if more?, do: encode_cursor(List.last(repositories)), else: nil
50
51
      json(conn, %{
52
        "repositories" =>
53
          Enum.map(repositories, &projection(&1, conn.assigns.current_user, base_url(conn))),
54
        "next_cursor" => next_cursor
55
      })
56
    else
57
      {:error, reason} -> render_error(conn, reason)
58
    end
59
  end
60
61
  def show(conn, %{"owner" => owner, "repo" => name}) do
62
    repository = Repositories.get_visible_by_path!(owner, name, conn.assigns.current_user)
63
    json(conn, projection(repository, conn.assigns.current_user, base_url(conn)))
64
  rescue
65
    Ecto.NoResultsError -> render_error(conn, :not_found)
66
  end
67
68
  defp render_repository(conn, repository, user, replay_state) do
69
    status = if repository.lifecycle_state == "ready", do: :created, else: :accepted
70
71
    conn
72
    |> put_status(status)
73
    |> put_resp_header("cache-control", "no-store")
74
    |> json(
75
      projection(repository, user, base_url(conn))
76
      |> Map.put("replayed", replay_state == :replayed)
77
    )
78
  end
79
80
  defp projection(repository, user, base_url) do
81
    role = Repositories.membership_role(repository, user)
82
83
    RepositoryJSON.repository(repository, RepositoryJSON.permissions(repository, role), base_url)
84
  end
85
86
  defp repository_attrs(params) do
87
    private? = Map.get(params, "private", true)
88
89
    if is_boolean(private?) do
90
      {:ok,
91
       %{
92
         name: params["name"],
93
         description: params["description"],
94
         visibility: if(private?, do: "private", else: "public"),
95
         default_branch: params["default_branch"] || "main"
96
       }}
97
    else
98
      {:error, :invalid_repository}
99
    end
100
  end
101
102
  defp idempotency_key(conn) do
103
    case get_req_header(conn, "idempotency-key") do
104
      [key] when byte_size(key) in 1..200 ->
105
        if String.contains?(key, ["\r", "\n", "\0"]),
106
          do: {:error, :invalid_idempotency_key},
107
          else: {:ok, key}
108
109
      _invalid ->
110
        {:error, :invalid_idempotency_key}
111
    end
112
  end
113
114
  defp per_page(%{"per_page" => value}) do
115
    case Integer.parse(value) do
116
      {number, ""} when number in 1..100 -> {:ok, number}
117
      _invalid -> {:error, :invalid_pagination}
118
    end
119
  end
120
121
  defp per_page(_params), do: {:ok, 30}
122
123
  defp decode_cursor(nil), do: {:ok, nil}
124
125
  defp decode_cursor(cursor) when is_binary(cursor) do
126
    with {:ok, encoded} <- Base.url_decode64(cursor, padding: false),
127
         {:ok, [owner_key, name_key, id]} <- Jason.decode(encoded),
128
         true <- Enum.all?([owner_key, name_key, id], &is_binary/1),
129
         {:ok, id} <- Ecto.UUID.cast(id) do
130
      {:ok, {owner_key, name_key, id}}
131
    else
132
      _invalid -> {:error, :invalid_pagination}
133
    end
134
  end
135
136
  defp encode_cursor(repository) do
137
    Jason.encode!([
138
      repository.namespace.slug_key,
139
      repository.name_key,
140
      repository.id
141
    ])
142
    |> Base.url_encode64(padding: false)
143
  end
144
145
  defp render_error(conn, %Ecto.Changeset{} = changeset) do
146
    conflict? =
147
      Enum.any?(changeset.errors, fn
148
        {:name_key, {_message, options}} -> options[:constraint] == :unique
149
        {:storage_key, {_message, options}} -> options[:constraint] == :unique
150
        _error -> false
151
      end)
152
153
    if conflict?,
154
      do: render_error(conn, :repository_name_conflict),
155
      else: render_error(conn, :invalid_repository)
156
  end
157
158
  defp render_error(conn, :invalid_idempotency_key),
159
    do: error(conn, :bad_request, "invalid_idempotency_key", "Provide one Idempotency-Key header")
160
161
  defp render_error(conn, :idempotency_conflict),
162
    do: error(conn, :conflict, "idempotency_conflict", "The idempotency key is already in use")
163
164
  defp render_error(conn, :repository_name_conflict),
165
    do:
166
      error(conn, :conflict, "repository_name_conflict", "Repository name is unavailable", "name")
167
168
  defp render_error(conn, :not_found),
169
    do: error(conn, :not_found, "not_found", "Repository not found")
170
171
  defp render_error(conn, :invalid_pagination),
172
    do: error(conn, :unprocessable_entity, "invalid_pagination", "Pagination input is invalid")
173
174
  defp render_error(conn, :namespace_not_allowed),
175
    do: error(conn, :forbidden, "namespace_not_allowed", "Namespace is not eligible")
176
177
  defp render_error(conn, :github_scope_required),
178
    do: error(conn, :forbidden, "github_scope_required", "Reconnect GitHub with required access")
179
180
  defp render_error(conn, _reason),
181
    do: error(conn, :unprocessable_entity, "invalid_repository", "Repository input is invalid")
182
183
  defp error(conn, status, code, message, field \\ nil) do
184
    body = %{
185
      "code" => code,
186
      "message" => message,
187
      "request_id" => List.first(get_resp_header(conn, "x-request-id"))
188
    }
189
190
    body = if field, do: Map.put(body, "field", field), else: body
191
192
    conn
193
    |> put_status(status)
194
    |> put_resp_header("cache-control", "no-store")
195
    |> json(body)
196
  end
197
198
  defp base_url(conn),
199
    do: URI.to_string(%URI{scheme: Atom.to_string(conn.scheme), host: conn.host, port: conn.port})
200
end
lib/openagents_web/controllers/repository_import_controller.ex added +179

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

1
defmodule OpenAgentsWeb.RepositoryImportController do
2
  @moduledoc "Accepts and reports one-time GitHub repository imports."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.Repositories
7
  alias OpenAgents.Repositories.GitHubProjection
8
  alias OpenAgentsWeb.{RepositoryImportJSON, RepositoryJSON}
9
10
  def create_user(conn, params) do
11
    with {:ok, idempotency_key} <- idempotency_key(conn),
12
         {:ok, full_name} <- source_repository(params),
13
         {:ok, source, github_repository} <-
14
           GitHubProjection.import_source(conn.assigns.current_user, full_name),
15
         {:ok, attrs} <- import_attrs(params, github_repository),
16
         {:ok, repository, repository_import, replay_state} <-
17
           Repositories.create_user_import(
18
             conn.assigns.current_user,
19
             source,
20
             attrs,
21
             idempotency_key
22
           ) do
23
      render_import(conn, repository, repository_import, replay_state)
24
    else
25
      {:error, reason} -> render_error(conn, reason)
26
    end
27
  end
28
29
  def create_organization(conn, %{"org" => org} = params) do
30
    with {:ok, idempotency_key} <- idempotency_key(conn),
31
         {:ok, namespace} <-
32
           GitHubProjection.authorized_organization(conn.assigns.current_user, org),
33
         {:ok, full_name} <- source_repository(params),
34
         {:ok, source, github_repository} <-
35
           GitHubProjection.import_source(conn.assigns.current_user, full_name),
36
         {:ok, attrs} <- import_attrs(params, github_repository),
37
         {:ok, repository, repository_import, replay_state} <-
38
           Repositories.create_organization_import(
39
             conn.assigns.current_user,
40
             namespace,
41
             source,
42
             attrs,
43
             idempotency_key
44
           ) do
45
      render_import(conn, repository, repository_import, replay_state)
46
    else
47
      {:error, reason} -> render_error(conn, reason)
48
    end
49
  end
50
51
  def show(conn, %{"id" => id}) do
52
    repository_import = Repositories.get_import_for_user!(id, conn.assigns.current_user)
53
    repository = repository_import.repository
54
    role = Repositories.membership_role(repository, conn.assigns.current_user)
55
56
    json(conn, %{
57
      "repository" =>
58
        RepositoryJSON.repository(
59
          repository,
60
          RepositoryJSON.permissions(repository, role),
61
          base_url(conn)
62
        ),
63
      "import" => RepositoryImportJSON.import(repository_import)
64
    })
65
  rescue
66
    Ecto.NoResultsError -> render_error(conn, :not_found)
67
  end
68
69
  defp render_import(conn, repository, repository_import, replay_state) do
70
    role = Repositories.membership_role(repository, conn.assigns.current_user)
71
    status = if repository.lifecycle_state == "ready", do: :created, else: :accepted
72
73
    repository_body =
74
      RepositoryJSON.repository(
75
        repository,
76
        RepositoryJSON.permissions(repository, role),
77
        base_url(conn)
78
      )
79
80
    conn
81
    |> put_status(status)
82
    |> put_resp_header("cache-control", "no-store")
83
    |> json(
84
      repository_body
85
      |> Map.put("import", RepositoryImportJSON.import(repository_import))
86
      |> Map.put("replayed", replay_state == :replayed)
87
    )
88
  end
89
90
  defp source_repository(%{
91
         "source" => %{"provider" => "github", "repository" => full_name}
92
       })
93
       when is_binary(full_name),
94
       do: {:ok, full_name}
95
96
  defp source_repository(_params), do: {:error, :invalid_import}
97
98
  defp import_attrs(params, github_repository) do
99
    private? = Map.get(params, "private", true)
100
    name = params["name"] || github_repository["name"]
101
102
    if is_boolean(private?) do
103
      {:ok,
104
       %{
105
         name: name,
106
         description: github_repository["description"],
107
         visibility: if(private?, do: "private", else: "public"),
108
         default_branch: github_repository["default_branch"]
109
       }}
110
    else
111
      {:error, :invalid_import}
112
    end
113
  end
114
115
  defp idempotency_key(conn) do
116
    case get_req_header(conn, "idempotency-key") do
117
      [key] when byte_size(key) in 1..200 ->
118
        if String.contains?(key, ["\r", "\n", "\0"]),
119
          do: {:error, :invalid_idempotency_key},
120
          else: {:ok, key}
121
122
      _invalid ->
123
        {:error, :invalid_idempotency_key}
124
    end
125
  end
126
127
  defp render_error(conn, :not_found),
128
    do: error(conn, :not_found, "not_found", "Repository import not found")
129
130
  defp render_error(conn, :source_namespace_mismatch),
131
    do: error(conn, :forbidden, "source_namespace_mismatch", "Source namespace is not eligible")
132
133
  defp render_error(conn, :namespace_not_allowed),
134
    do: error(conn, :forbidden, "namespace_not_allowed", "Namespace is not eligible")
135
136
  defp render_error(conn, :github_connection_required),
137
    do: error(conn, :forbidden, "github_connection_required", "Connect GitHub before importing")
138
139
  defp render_error(conn, :github_scope_required),
140
    do: error(conn, :forbidden, "github_scope_required", "Reconnect GitHub with required access")
141
142
  defp render_error(conn, :source_repository_not_accessible),
143
    do: error(conn, :forbidden, "source_repository_not_accessible", "Source is not accessible")
144
145
  defp render_error(conn, :idempotency_conflict),
146
    do: error(conn, :conflict, "idempotency_conflict", "The idempotency key is already in use")
147
148
  defp render_error(conn, :invalid_idempotency_key),
149
    do: error(conn, :bad_request, "invalid_idempotency_key", "Provide one Idempotency-Key header")
150
151
  defp render_error(conn, %Ecto.Changeset{}),
152
    do: error(conn, :unprocessable_entity, "invalid_import", "Repository import is invalid")
153
154
  defp render_error(conn, reason)
155
       when reason in [
156
              :github_unavailable,
157
              :github_request_failed,
158
              :github_pagination_limit_exceeded
159
            ],
160
       do: error(conn, :service_unavailable, "github_unavailable", "GitHub is unavailable")
161
162
  defp render_error(conn, _reason),
163
    do: error(conn, :unprocessable_entity, "invalid_import", "Repository import is invalid")
164
165
  defp error(conn, status, code, message) do
166
    conn
167
    |> put_status(status)
168
    |> put_resp_header("cache-control", "no-store")
169
    |> json(%{
170
      "code" => code,
171
      "message" => message,
172
      "request_id" => List.first(get_resp_header(conn, "x-request-id"))
173
    })
174
  end
175
176
  defp base_url(conn) do
177
    URI.to_string(%URI{scheme: Atom.to_string(conn.scheme), host: conn.host, port: conn.port})
178
  end
179
end
lib/openagents_web/controllers/repository_import_json.ex added +27

@@ -0,0 +1,27 @@

1
defmodule OpenAgentsWeb.RepositoryImportJSON do
2
  @moduledoc "JSON projections for one-time repository imports."
3
4
  alias OpenAgents.Repositories.RepositoryImport
5
6
  def import(%RepositoryImport{} = repository_import) do
7
    %{
8
      "id" => repository_import.id,
9
      "provider" => repository_import.provider,
10
      "source_full_name" => repository_import.source_full_name,
11
      "source_default_branch" => repository_import.source_default_branch,
12
      "source_head_sha" => repository_import.source_head_sha,
13
      "source_ref_digest" => repository_import.source_ref_digest,
14
      "state" => repository_import.state,
15
      "attempt_count" => repository_import.attempt_count,
16
      "error_code" => repository_import.error_code,
17
      "lfs_warning" => repository_import.source_uses_lfs,
18
      "started_at" => iso8601(repository_import.started_at),
19
      "completed_at" => iso8601(repository_import.completed_at),
20
      "created_at" => iso8601(repository_import.inserted_at),
21
      "updated_at" => iso8601(repository_import.updated_at)
22
    }
23
  end
24
25
  defp iso8601(nil), do: nil
26
  defp iso8601(value), do: DateTime.to_iso8601(value)
27
end
lib/openagents_web/controllers/repository_json.ex added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgentsWeb.RepositoryJSON do
2
  @moduledoc "JSON projections for hosted repositories."
3
4
  alias OpenAgents.Repositories.Repository
5
6
  def repository(%Repository{} = repository, permissions, base_url) do
7
    owner = repository.namespace.slug
8
9
    %{
10
      "id" => repository.id,
11
      "name" => repository.name,
12
      "full_name" => owner <> "/" <> repository.name,
13
      "owner" => %{
14
        "id" => repository.namespace.provider_account_id,
15
        "login" => owner,
16
        "type" => if(repository.namespace.kind == "user", do: "User", else: "Organization")
17
      },
18
      "private" => repository.visibility == "private",
19
      "visibility" => repository.visibility,
20
      "description" => repository.description,
21
      "default_branch" => repository.default_branch,
22
      "lifecycle_state" => repository.lifecycle_state,
23
      "provision_error_code" => repository.provision_error_code,
24
      "clone_url" => base_url <> "/git/#{owner}/#{repository.name}.git",
25
      "html_url" => base_url <> "/#{owner}/#{repository.name}",
26
      "permissions" => permissions,
27
      "created_at" => DateTime.to_iso8601(repository.inserted_at),
28
      "updated_at" => DateTime.to_iso8601(repository.updated_at)
29
    }
30
  end
31
32
  def permissions(repository, role) do
33
    %{
34
      "admin" => role in ~w(owner maintainer),
35
      "push" => role in ~w(owner maintainer contributor),
36
      "pull" => repository.visibility == "public" or not is_nil(role)
37
    }
38
  end
39
end
lib/openagents_web/live/code_blob_live.ex modified +24 -13

@@ -15,19 +15,21 @@ defmodule OpenAgentsWeb.CodeBlobLive do

15 15
16 16
  use OpenAgentsWeb, :live_view
17 17
18
  alias OpenAgents.Forge.{Browse, Visibility}
18
  alias OpenAgents.Forge.Browse
19
  alias OpenAgentsWeb.RepositoryAccess
19 20
20 21
  @impl true
21
  def mount(%{"repo" => repo, "ref" => ref} = params, _session, socket) do
22
  def mount(%{"owner" => owner, "repo" => name, "ref" => ref} = params, _session, socket) do
22 23
    path = params |> Map.get("path", []) |> Enum.join("/")
23
    base = Visibility.repo_path(repo)
24
    repository = RepositoryAccess.get_visible!(owner, name, socket.assigns.current_user)
25
    base = RepositoryAccess.base(repository)
24 26
25
    unless OpenAgents.Forge.enabled?() and base do
27
    unless OpenAgents.Forge.enabled?() and repository.lifecycle_state == "ready" do
26 28
      raise OpenAgentsWeb.PublicNotFoundError
27 29
    end
28 30
29 31
    sha =
30
      case Browse.resolve_commit(repo, ref) do
32
      case Browse.resolve_commit(repository, ref) do
31 33
        {:ok, sha} -> sha
32 34
        _ -> raise OpenAgentsWeb.PublicNotFoundError
33 35
      end

@@ -35,14 +37,20 @@ defmodule OpenAgentsWeb.CodeBlobLive do

35 37
    # Either the whole repo is browsable (:l3), or this is a published
36 38
    # document at the current head. A published path at an older ref is a
37 39
    # 404: publishing one document must not publish its history.
38
    head = with {:ok, head} <- Browse.head(repo), do: head
39
40
    unless Visibility.allows_file?(repo, path, sha, head) do
40
    head = with {:ok, head} <- Browse.head(repository), do: head
41
42
    unless RepositoryAccess.allows_file?(
43
             repository,
44
             socket.assigns.current_user,
45
             path,
46
             sha,
47
             head
48
           ) do
41 49
      raise OpenAgentsWeb.PublicNotFoundError
42 50
    end
43 51
44 52
    blob =
45
      case Browse.blob(repo, sha, path) do
53
      case Browse.blob(repository, sha, path) do
46 54
        {:ok, blob} -> blob
47 55
        _ -> raise OpenAgentsWeb.PublicNotFoundError
48 56
      end

@@ -51,16 +59,19 @@ defmodule OpenAgentsWeb.CodeBlobLive do

51 59
52 60
    {:ok,
53 61
     socket
54
     |> assign(:page_title, "#{Path.basename(path)} · #{repo}")
55
     |> assign(:repo, repo)
56
     |> assign(:owner, Visibility.owner(repo))
62
     |> assign(:page_title, "#{Path.basename(path)} · #{repository.name}")
63
     |> assign(:repository, repository)
64
     |> assign(:repo, repository.name)
65
     |> assign(:owner, repository.namespace.slug)
57 66
     |> assign(:base, base)
58 67
     |> assign(:ref, ref)
59 68
     |> assign(:sha, sha)
60 69
     |> assign(:path, path)
61 70
     |> assign(:blob, blob)
62
     |> assign(:browsable, Visibility.allows?(repo, :files))
71
     |> assign(:browsable, RepositoryAccess.full_source?(repository, socket.assigns.current_user))
63 72
     |> assign(:markdown?, markdown?(path) and not plain and not blob.binary)}
73
  rescue
74
    Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
64 75
  end
65 76
66 77
  defp markdown?(path), do: String.ends_with?(String.downcase(path), [".md", ".markdown"])
lib/openagents_web/live/code_commit_live.ex modified +18 -12

@@ -15,31 +15,33 @@ defmodule OpenAgentsWeb.CodeCommitLive do

15 15
  use OpenAgentsWeb, :live_view
16 16
17 17
  alias OpenAgents.Forge
18
  alias OpenAgents.Forge.{Browse, Visibility}
18
  alias OpenAgents.Forge.Browse
19
  alias OpenAgentsWeb.RepositoryAccess
19 20
20 21
  @impl true
21
  def mount(%{"repo" => repo, "sha" => sha}, _session, socket) do
22
    base = Visibility.repo_path(repo)
22
  def mount(%{"owner" => owner, "repo" => name, "sha" => sha}, _session, socket) do
23
    repository = RepositoryAccess.get_visible!(owner, name, socket.assigns.current_user)
24
    base = RepositoryAccess.base(repository)
23 25
24
    unless Visibility.allows?(repo, :ledger) and Forge.enabled?() and base do
26
    unless RepositoryAccess.ledger?(repository, socket.assigns.current_user) and Forge.enabled?() do
25 27
      raise OpenAgentsWeb.PublicNotFoundError
26 28
    end
27 29
28 30
    commit =
29
      case Browse.commit(repo, sha) do
31
      case Browse.commit(repository, sha) do
30 32
        {:ok, commit} -> commit
31 33
        _ -> raise OpenAgentsWeb.PublicNotFoundError
32 34
      end
33 35
34 36
    files =
35
      case Browse.changed_files(repo, commit.sha) do
37
      case Browse.changed_files(repository, commit.sha) do
36 38
        {:ok, files} -> files
37 39
        _ -> []
38 40
      end
39 41
40 42
    {diff, diff_truncated} =
41
      if Visibility.allows?(repo, :diffs) do
42
        case Browse.diff(repo, commit.sha) do
43
      if RepositoryAccess.full_source?(repository, socket.assigns.current_user) do
44
        case Browse.diff(repository, commit.sha) do
43 45
          {:ok, diff, truncated} -> {diff, truncated}
44 46
          _ -> {nil, false}
45 47
        end

@@ -58,9 +60,11 @@ defmodule OpenAgentsWeb.CodeCommitLive do

58 60
59 61
    {:ok,
60 62
     socket
61
     |> assign(:page_title, "#{short(commit.sha)} · #{repo}")
62
     |> assign(:repo, repo)
63
     |> assign(:owner, Visibility.owner(repo))
63
     |> assign(:page_title, "#{short(commit.sha)} · #{repository.name}")
64
     |> assign(:repository, repository)
65
     |> assign(:repo, repository.storage_key)
66
     |> assign(:name, repository.name)
67
     |> assign(:owner, repository.namespace.slug)
64 68
     |> assign(:base, base)
65 69
     |> assign(:commit, commit)
66 70
     |> assign(:files, files)

@@ -68,7 +72,9 @@ defmodule OpenAgentsWeb.CodeCommitLive do

68 72
     |> assign(:diff_truncated, diff_truncated)
69 73
     |> assign(:diff_files, diff_files)
70 74
     |> assign(:diff_totals, OpenAgents.Diff.totals(diff_files))
71
     |> assign(:receipts, Forge.receipts_for(repo, commit.sha))}
75
     |> assign(:receipts, Forge.receipts_for(repository.storage_key, commit.sha))}
76
  rescue
77
    Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
72 78
  end
73 79
74 80
  @impl true
lib/openagents_web/live/code_repo_live.ex modified +64 -17

@@ -10,44 +10,51 @@ defmodule OpenAgentsWeb.CodeRepoLive do

10 10
  use OpenAgentsWeb, :live_view
11 11
12 12
  alias OpenAgents.Forge
13
  alias OpenAgents.Forge.{Browse, Visibility}
13
  alias OpenAgents.Forge.Browse
14
  alias OpenAgentsWeb.RepositoryAccess
14 15
15 16
  @impl true
16
  def mount(%{"repo" => repo}, _session, socket) do
17
    base = Visibility.repo_path(repo)
17
  def mount(%{"owner" => owner, "repo" => name}, _session, socket) do
18
    repository = RepositoryAccess.get_visible!(owner, name, socket.assigns.current_user)
19
    base = RepositoryAccess.base(repository)
20
    source? = RepositoryAccess.full_source?(repository, socket.assigns.current_user)
18 21
19
    unless Visibility.allows?(repo, :files) and Forge.enabled?() and base do
22
    unless Forge.enabled?() and (source? or repository.lifecycle_state != "ready") do
20 23
      raise OpenAgentsWeb.PublicNotFoundError
21 24
    end
22 25
23 26
    head =
24
      case Browse.head(repo) do
27
      case Browse.head(repository) do
25 28
        {:ok, sha} -> sha
26
        _ -> raise OpenAgentsWeb.PublicNotFoundError
29
        _ -> nil
27 30
      end
28 31
29 32
    readme =
30
      case Browse.readme(repo, head) do
33
      case head && Browse.readme(repository, head) do
31 34
        {:ok, name, blob} -> %{name: name, blob: blob}
32 35
        _ -> nil
33 36
      end
34 37
35 38
    commits =
36
      case Browse.log(repo, head, 20) do
39
      case head && Browse.log(repository, head, 20) do
37 40
        {:ok, commits} -> commits
38 41
        _ -> []
39 42
      end
40 43
41 44
    {:ok,
42 45
     socket
43
     |> assign(:page_title, "#{repo} · code")
44
     |> assign(:repo, repo)
45
     |> assign(:owner, Visibility.owner(repo))
46
     |> assign(:page_title, "#{repository.name} · code")
47
     |> assign(:repository, repository)
48
     |> assign(:repo, repository.name)
49
     |> assign(:owner, repository.namespace.slug)
46 50
     |> assign(:base, base)
47 51
     |> assign(:head, head)
48 52
     |> assign(:readme, readme)
49 53
     |> assign(:commits, commits)
50
     |> assign(:refs, Browse.refs(repo))}
54
     |> assign(:refs, if(head, do: Browse.refs(repository), else: []))
55
     |> assign(:clone_url, RepositoryAccess.clone_url(repository))}
56
  rescue
57
    Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
51 58
  end
52 59
53 60
  defp short(sha), do: String.slice(sha, 0, 12)

@@ -62,14 +69,54 @@ defmodule OpenAgentsWeb.CodeRepoLive do

62 69
            <div>
63 70
              <h1>{@owner}/{@repo}</h1>
64 71
              <p class="code-meta">
65
                head <code>{short(@head)}</code>
66
                · <.text_button navigate="/changelog">changelog</.text_button>
67
                · read-only mirror for anonymous clones: <code>github.com/OpenAgentsInc/{@repo}</code>
72
                <.badge variant={if(@repository.visibility == "public", do: :success, else: :dim)}>
73
                  {@repository.visibility}
74
                </.badge>
75
                <span :if={@head}>head <code>{short(@head)}</code></span>
76
                <span :if={@repository.description}>{@repository.description}</span>
68 77
              </p>
69 78
            </div>
79
            <div class="code-actions">
80
              <.button navigate={"#{@base}/issues"} variant={:secondary} size={:sm}>Issues</.button>
81
              <.button navigate={"#{@base}/projects"} variant={:secondary} size={:sm}>
82
                Projects
83
              </.button>
84
            </div>
70 85
          </header>
71 86
72
          <.card id="repo-commits">
87
          <.alert
88
            :if={@repository.lifecycle_state == "provisioning"}
89
            id="repo-provisioning"
90
            variant={:info}
91
            title="Repository provisioning is in progress"
92
          >
93
            Refresh this page after the repository becomes ready for Git operations.
94
          </.alert>
95
96
          <.alert
97
            :if={@repository.lifecycle_state == "failed"}
98
            id="repo-provisioning-failed"
99
            variant={:danger}
100
            title="Repository provisioning failed"
101
          >
102
            Error code: <code>{@repository.provision_error_code || "provisioning_failed"}</code>
103
          </.alert>
104
105
          <.card :if={@repository.lifecycle_state == "ready"} id="repo-clone">
106
            <h2>Clone</h2>
107
            <code class="block break-all">git clone {@clone_url}</code>
108
          </.card>
109
110
          <.empty
111
            :if={@repository.lifecycle_state == "ready" and is_nil(@head)}
112
            id="repo-empty"
113
            title="This repository is empty"
114
          >
115
            Push the first commit to <code>{@repository.default_branch}</code>
116
            with the clone URL above.
117
          </.empty>
118
119
          <.card :if={@head} id="repo-commits">
73 120
            <h2>Recent commits</h2>
74 121
            <ol class="code-commits">
75 122
              <li :for={commit <- @commits}>

@@ -82,7 +129,7 @@ defmodule OpenAgentsWeb.CodeRepoLive do

82 129
            </ol>
83 130
          </.card>
84 131
85
          <.card id="repo-refs">
132
          <.card :if={@head} id="repo-refs">
86 133
            <h2>Refs</h2>
87 134
            <ul class="code-refs">
88 135
              <li :for={ref <- @refs}>
lib/openagents_web/live/device_authorization_live.ex added +133

@@ -0,0 +1,133 @@

1
defmodule OpenAgentsWeb.DeviceAuthorizationLive do
2
  @moduledoc "Authenticated review and approval for one CLI device code."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.DeviceAuthorizations
7
8
  @impl true
9
  def mount(params, _session, socket) do
10
    user_code = params |> Map.get("user_code", "") |> normalize_code()
11
12
    {:ok,
13
     socket
14
     |> assign(:page_title, "Authorize OpenAgents CLI")
15
     |> assign(:authorization, DeviceAuthorizations.get_pending_by_user_code(user_code))
16
     |> assign(:decided, nil)
17
     |> assign(:form, code_form(user_code))}
18
  end
19
20
  @impl true
21
  def handle_event("lookup", %{"device" => %{"user_code" => user_code}}, socket) do
22
    user_code = normalize_code(user_code)
23
24
    {:noreply,
25
     socket
26
     |> assign(:authorization, DeviceAuthorizations.get_pending_by_user_code(user_code))
27
     |> assign(:decided, nil)
28
     |> assign(:form, code_form(user_code))}
29
  end
30
31
  def handle_event("approve", _params, socket) do
32
    decide(socket, &DeviceAuthorizations.approve/2, :approved)
33
  end
34
35
  def handle_event("deny", _params, socket) do
36
    decide(socket, &DeviceAuthorizations.deny/2, :denied)
37
  end
38
39
  @impl true
40
  def render(assigns) do
41
    ~H"""
42
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Authorize CLI">
43
      <main id="device-authorization" class="mx-auto w-full max-w-xl space-y-8 px-4 py-12">
44
        <.header>
45
          Authorize OpenAgents CLI
46
          <:subtitle>
47
            Confirm that the code in your browser matches the code shown in your terminal.
48
          </:subtitle>
49
        </.header>
50
51
        <.alert :if={@decided == :approved} id="device-approved" variant={:success}>
52
          Authorization approved. Return to your terminal to finish signing in.
53
        </.alert>
54
55
        <.alert :if={@decided == :denied} id="device-denied" variant={:warning}>
56
          Authorization denied. You can close this page.
57
        </.alert>
58
59
        <.card :if={is_nil(@decided)}>
60
          <.form for={@form} id="device-code-form" phx-submit="lookup" class="space-y-4">
61
            <.input
62
              field={@form[:user_code]}
63
              label="Code from your terminal"
64
              placeholder="ABCD-EFGH"
65
              maxlength="9"
66
              autocomplete="one-time-code"
67
              required
68
            />
69
            <.button id="review-device-code" type="submit" variant={:secondary}>
70
              Review code
71
            </.button>
72
          </.form>
73
        </.card>
74
75
        <.alert
76
          :if={is_nil(@decided) and @form[:user_code].value != "" and is_nil(@authorization)}
77
          id="device-code-invalid"
78
          variant={:danger}
79
        >
80
          This code is invalid or expired. Start a new login from the CLI.
81
        </.alert>
82
83
        <.card :if={@authorization && is_nil(@decided)} id="device-authorization-review">
84
          <div class="space-y-5">
85
            <div>
86
              <p class="text-sm text-muted-foreground">Terminal code</p>
87
              <code class="text-2xl font-semibold tracking-widest">{@form[:user_code].value}</code>
88
            </div>
89
            <div>
90
              <p class="font-medium">Requested access</p>
91
              <p class="text-sm text-muted-foreground">
92
                Create and manage repositories as you through <code>forge:write</code>. The CLI never receives your GitHub token.
93
              </p>
94
            </div>
95
            <div class="flex flex-wrap justify-end gap-3">
96
              <.button id="deny-device" phx-click="deny" variant={:secondary}>Deny</.button>
97
              <.button id="approve-device" phx-click="approve">Authorize CLI</.button>
98
            </div>
99
          </div>
100
        </.card>
101
      </main>
102
    </Layouts.app>
103
    """
104
  end
105
106
  defp decide(%{assigns: %{authorization: nil}} = socket, _transition, _decision),
107
    do: {:noreply, put_flash(socket, :error, "This code is invalid or expired.")}
108
109
  defp decide(socket, transition, decision) do
110
    user_code = socket.assigns.form[:user_code].value
111
112
    case transition.(user_code, socket.assigns.current_user) do
113
      {:ok, _authorization} ->
114
        {:noreply,
115
         socket
116
         |> assign(:authorization, nil)
117
         |> assign(:decided, decision)}
118
119
      {:error, _reason} ->
120
        {:noreply,
121
         socket
122
         |> assign(:authorization, nil)
123
         |> put_flash(:error, "This code is invalid or expired.")}
124
    end
125
  end
126
127
  defp code_form(user_code), do: to_form(%{"user_code" => user_code}, as: :device)
128
129
  defp normalize_code(code) when is_binary(code),
130
    do: code |> String.trim() |> String.upcase()
131
132
  defp normalize_code(_code), do: ""
133
end
lib/openagents_web/live/home_live.ex modified +24 -9

@@ -41,6 +41,7 @@ defmodule OpenAgentsWeb.HomeLive do

41 41
  defp assign_dashboard(socket) do
42 42
    repository = Repositories.initial_repository!()
43 43
    {owner, name} = Repositories.initial_path()
44
    repositories = Repositories.list_visible_repositories(socket.assigns.current_user)
44 45
45 46
    open_issues = Issues.list_issues(repository, state: "open")
46 47
    closed_issues = Issues.list_issues(repository, state: "closed")

@@ -55,6 +56,7 @@ defmodule OpenAgentsWeb.HomeLive do

55 56
    |> assign(:issues, Enum.take(open_issues, @feed_limit))
56 57
    |> assign(:projects, Enum.take(projects, 6))
57 58
    |> assign(:changelog, changelog_entries())
59
    |> stream(:repositories, repositories)
58 60
  end
59 61
60 62
  # The ledger is a bounded public projection and can legitimately refuse. An

@@ -133,18 +135,31 @@ defmodule OpenAgentsWeb.HomeLive do

133 135
        </div>
134 136
135 137
        <aside class="dashboard__rail">
136
          <section class="panel" aria-labelledby="dashboard-repo">
138
          <section class="panel" aria-labelledby="dashboard-repositories">
137 139
            <header class="panel__header">
138
              <h2 id="dashboard-repo" class="panel__title">Repository</h2>
140
              <h2 id="dashboard-repositories" class="panel__title">Repositories</h2>
141
              <.link navigate={~p"/repositories"} class="panel__more">
142
                View all <.icon name="arrow-right" />
143
              </.link>
139 144
            </header>
140 145
141
            <%!-- The code browser's owner segment is a literal in the router
142
            (a wildcard first segment would shadow every two-segment path), so
143
            the route is written the way the router declares it. --%>
144
            <.link navigate={~p"/OpenAgentsInc/#{@name}"} class="repo-card">
145
              <.icon name="folder" />
146
              <span class="repo-card__name">{@owner}/{@name}</span>
147
            </.link>
146
            <div id="dashboard-repository-list" phx-update="stream" class="space-y-2">
147
              <p class="panel__empty hidden only:block">No repositories yet.</p>
148
              <.link
149
                :for={{id, repository} <- @streams.repositories}
150
                id={id}
151
                navigate={~p"/#{repository.namespace.slug}/#{repository.name}"}
152
                class="repo-card"
153
              >
154
                <.icon name="folder" />
155
                <span class="repo-card__name">
156
                  {repository.namespace.slug}/{repository.name}
157
                </span>
158
                <.badge variant={if(repository.lifecycle_state == "ready", do: :success, else: :info)}>
159
                  {repository.lifecycle_state}
160
                </.badge>
161
              </.link>
162
            </div>
148 163
149 164
            <dl class="stat-pairs">
150 165
              <div>
lib/openagents_web/live/repository_import_live.ex added +204

@@ -0,0 +1,204 @@

1
defmodule OpenAgentsWeb.RepositoryImportLive do
2
  @moduledoc "Imports one accepted GitHub repository snapshot."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.Repositories
7
  alias OpenAgents.Repositories.GitHubProjection
8
9
  @impl true
10
  def mount(_params, _session, socket) do
11
    {repositories, next_page, error} = candidates(socket.assigns.current_user, 1)
12
13
    {:ok,
14
     socket
15
     |> assign(:page_title, "Import from GitHub")
16
     |> assign(:source_repositories, repositories)
17
     |> assign(:next_page, next_page)
18
     |> assign(:source_error, error)
19
     |> assign(:form, import_form(repositories))}
20
  end
21
22
  @impl true
23
  def handle_event("validate", %{"repository_import" => params}, socket) do
24
    repository =
25
      Enum.find(socket.assigns.source_repositories, &(&1["full_name"] == params["source"]))
26
27
    params = if repository, do: Map.put(params, "name", repository["name"]), else: params
28
    {:noreply, assign(socket, :form, to_form(params, as: :repository_import))}
29
  end
30
31
  def handle_event("load-more", _params, %{assigns: %{next_page: nil}} = socket),
32
    do: {:noreply, socket}
33
34
  def handle_event("load-more", _params, socket) do
35
    {repositories, next_page, error} =
36
      candidates(socket.assigns.current_user, socket.assigns.next_page)
37
38
    {:noreply,
39
     socket
40
     |> update(:source_repositories, &(&1 ++ repositories))
41
     |> assign(:next_page, next_page)
42
     |> assign(:source_error, error)}
43
  end
44
45
  def handle_event("save", %{"repository_import" => params}, socket) do
46
    with {:ok, source, _github_repository} <-
47
           GitHubProjection.import_source(socket.assigns.current_user, params["source"]),
48
         {:ok, result} <- create_import(socket.assigns.current_user, source, params) do
49
      _result = result
50
51
      {:noreply,
52
       socket
53
       |> put_flash(:info, "GitHub import accepted as a one-time copy.")
54
       |> push_navigate(to: ~p"/repositories")}
55
    else
56
      {:error, reason} -> {:noreply, put_flash(socket, :error, error_message(reason))}
57
    end
58
  end
59
60
  @impl true
61
  def render(assigns) do
62
    ~H"""
63
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Import from GitHub">
64
      <main id="repository-import" class="mx-auto w-full max-w-3xl space-y-8 px-4 py-10">
65
        <.header>
66
          Import from GitHub
67
          <:subtitle>
68
            Copy one accepted snapshot of branches, tags, and Git history into OpenAgents.
69
          </:subtitle>
70
        </.header>
71
72
        <.alert id="one-time-import" variant={:info} title="This import does not sync">
73
          GitHub remains unchanged. Later GitHub commits do not update the OpenAgents repository.
74
        </.alert>
75
76
        <.alert :if={@source_error} id="github-import-error" variant={:warning}>
77
          {@source_error}
78
        </.alert>
79
80
        <.empty
81
          :if={@source_repositories == [] and is_nil(@source_error)}
82
          id="github-repositories-empty"
83
          title="No eligible GitHub repositories"
84
        >
85
          You can import repositories from your GitHub user or an organization where you are an administrator.
86
        </.empty>
87
88
        <.card :if={@source_repositories != []}>
89
          <.form
90
            for={@form}
91
            id="repository-import-form"
92
            phx-change="validate"
93
            phx-submit="save"
94
            class="space-y-5"
95
          >
96
            <.input
97
              field={@form[:source]}
98
              type="select"
99
              label="GitHub repository"
100
              options={Enum.map(@source_repositories, &{&1["full_name"], &1["full_name"]})}
101
              required
102
            />
103
            <.input field={@form[:name]} label="Destination name" maxlength="64" required />
104
            <.input field={@form[:private]} type="checkbox" label="Make this repository private" />
105
106
            <.alert id="lfs-import-warning" variant={:warning}>
107
              Git LFS pointer files are copied, but Git LFS objects are not included in this release.
108
            </.alert>
109
110
            <div class="flex flex-wrap justify-between gap-3">
111
              <.button
112
                :if={@next_page}
113
                id="load-more-github-repositories"
114
                type="button"
115
                phx-click="load-more"
116
                variant={:secondary}
117
              >
118
                Load more
119
              </.button>
120
              <div class="ml-auto flex gap-3">
121
                <.button navigate={~p"/repositories"} variant={:secondary}>Cancel</.button>
122
                <.button id="import-github-repository" type="submit">Import repository</.button>
123
              </div>
124
            </div>
125
          </.form>
126
        </.card>
127
      </main>
128
    </Layouts.app>
129
    """
130
  end
131
132
  defp candidates(user, page) do
133
    case GitHubProjection.import_candidates(user, page) do
134
      {:ok, result} ->
135
        next_page = if result["has_next_page"], do: result["next_page"], else: nil
136
        {result["items"], next_page, nil}
137
138
      {:error, :github_connection_required} ->
139
        {[], nil, "Connect GitHub before importing a repository."}
140
141
      {:error, :github_scope_required} ->
142
        {[], nil, "Reconnect GitHub with repository and organization access before importing."}
143
144
      {:error, _reason} ->
145
        {[], nil, "GitHub repositories are unavailable right now."}
146
    end
147
  end
148
149
  defp create_import(user, source, params) do
150
    attrs = %{
151
      name: params["name"],
152
      visibility: if(params["private"] == "true", do: "private", else: "public"),
153
      default_branch: source.source_default_branch
154
    }
155
156
    result =
157
      if source.source_owner_id == user.github_id do
158
        Repositories.create_user_import(user, source, attrs, Ecto.UUID.generate())
159
      else
160
        [owner | _name] = String.split(source.source_full_name, "/", parts: 2)
161
162
        with {:ok, namespace} <- GitHubProjection.authorized_organization(user, owner) do
163
          Repositories.create_organization_import(
164
            user,
165
            namespace,
166
            source,
167
            attrs,
168
            Ecto.UUID.generate()
169
          )
170
        end
171
      end
172
173
    case result do
174
      {:ok, repository, repository_import, state} ->
175
        {:ok, {repository, repository_import, state}}
176
177
      {:error, reason} ->
178
        {:error, reason}
179
    end
180
  end
181
182
  defp import_form([]),
183
    do: to_form(%{"source" => "", "name" => "", "private" => "true"}, as: :repository_import)
184
185
  defp import_form([repository | _rest]) do
186
    to_form(
187
      %{
188
        "source" => repository["full_name"],
189
        "name" => repository["name"],
190
        "private" => "true"
191
      },
192
      as: :repository_import
193
    )
194
  end
195
196
  defp error_message(:source_repository_not_accessible),
197
    do: "OpenAgents cannot read that GitHub repository."
198
199
  defp error_message(:github_scope_required),
200
    do: "Reconnect GitHub with repository and organization access."
201
202
  defp error_message(%Ecto.Changeset{}), do: "Check the destination name, then try again."
203
  defp error_message(_reason), do: "OpenAgents could not start this GitHub import."
204
end
lib/openagents_web/live/repository_index_live.ex added +79

@@ -0,0 +1,79 @@

1
defmodule OpenAgentsWeb.RepositoryIndexLive do
2
  @moduledoc "Lists repositories visible to the signed-in user."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.Repositories
7
8
  @impl true
9
  def mount(_params, _session, socket) do
10
    repositories = Repositories.list_visible_repositories(socket.assigns.current_user)
11
12
    {:ok,
13
     socket
14
     |> assign(:page_title, "Repositories")
15
     |> stream(:repositories, repositories)}
16
  end
17
18
  @impl true
19
  def render(assigns) do
20
    ~H"""
21
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Repositories" wide>
22
      <main id="repository-index" class="mx-auto w-full max-w-6xl space-y-8 px-4 py-10">
23
        <.header>
24
          Repositories
25
          <:subtitle>Create an OpenAgents repository or copy one from GitHub once.</:subtitle>
26
          <:actions>
27
            <.button
28
              id="import-repository"
29
              navigate={~p"/repositories/import/github"}
30
              variant={:secondary}
31
            >
32
              Import from GitHub
33
            </.button>
34
            <.button id="new-repository" navigate={~p"/repositories/new"}>New repository</.button>
35
          </:actions>
36
        </.header>
37
38
        <div id="repositories" phx-update="stream" class="grid gap-4 md:grid-cols-2">
39
          <.empty
40
            id="repositories-empty"
41
            class="hidden only:block md:col-span-2"
42
            title="No repositories yet"
43
          >
44
            Create an empty repository or import a GitHub repository as a one-time copy.
45
          </.empty>
46
47
          <.card :for={{id, repository} <- @streams.repositories} id={id}>
48
            <div class="flex items-start justify-between gap-4">
49
              <div class="min-w-0 space-y-2">
50
                <.link
51
                  navigate={~p"/#{repository.namespace.slug}/#{repository.name}"}
52
                  class="font-semibold text-foreground hover:underline"
53
                >
54
                  {repository.namespace.slug}/{repository.name}
55
                </.link>
56
                <p :if={repository.description} class="text-sm text-muted-foreground">
57
                  {repository.description}
58
                </p>
59
              </div>
60
              <.badge variant={status_variant(repository.lifecycle_state)}>
61
                {repository.lifecycle_state}
62
              </.badge>
63
            </div>
64
            <div class="mt-5 flex flex-wrap gap-2 text-sm text-muted-foreground">
65
              <span>{repository.visibility}</span>
66
              <span aria-hidden="true">·</span>
67
              <span>default branch <code>{repository.default_branch}</code></span>
68
            </div>
69
          </.card>
70
        </div>
71
      </main>
72
    </Layouts.app>
73
    """
74
  end
75
76
  defp status_variant("ready"), do: :success
77
  defp status_variant("failed"), do: :danger
78
  defp status_variant(_state), do: :info
79
end
lib/openagents_web/live/repository_new_live.ex added +159

@@ -0,0 +1,159 @@

1
defmodule OpenAgentsWeb.RepositoryNewLive do
2
  @moduledoc "Creates an empty repository in an eligible GitHub namespace."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.Repositories
7
  alias OpenAgents.Repositories.GitHubProjection
8
9
  @impl true
10
  def mount(_params, _session, socket) do
11
    {namespaces, namespace_notice} = namespaces(socket.assigns.current_user)
12
13
    {:ok,
14
     socket
15
     |> assign(:page_title, "New repository")
16
     |> assign(:namespaces, namespaces)
17
     |> assign(:namespace_notice, namespace_notice)
18
     |> assign(:form, repository_form(hd(namespaces).slug))}
19
  end
20
21
  @impl true
22
  def handle_event("validate", %{"repository" => params}, socket) do
23
    {:noreply, assign(socket, :form, to_form(params, as: :repository))}
24
  end
25
26
  def handle_event("save", %{"repository" => params}, socket) do
27
    namespace = Enum.find(socket.assigns.namespaces, &(&1.slug == params["namespace"]))
28
29
    attrs = %{
30
      name: params["name"],
31
      description: params["description"],
32
      visibility: if(params["private"] == "true", do: "private", else: "public"),
33
      default_branch: params["default_branch"]
34
    }
35
36
    result = create(socket.assigns.current_user, namespace, attrs)
37
38
    case result do
39
      {:ok, _repository, _state} ->
40
        {:noreply,
41
         socket
42
         |> put_flash(:info, "Repository provisioning started.")
43
         |> push_navigate(to: ~p"/repositories")}
44
45
      {:error, reason} ->
46
        {:noreply, put_flash(socket, :error, error_message(reason))}
47
    end
48
  end
49
50
  @impl true
51
  def render(assigns) do
52
    ~H"""
53
    <Layouts.app flash={@flash} current_scope={@current_scope} title="New repository">
54
      <main id="repository-new" class="mx-auto w-full max-w-3xl space-y-8 px-4 py-10">
55
        <.header>
56
          New repository
57
          <:subtitle>
58
            Create an empty Git repository in a namespace you control through GitHub.
59
          </:subtitle>
60
        </.header>
61
62
        <.alert :if={@namespace_notice} id="namespace-notice" variant={:warning}>
63
          {@namespace_notice}
64
        </.alert>
65
66
        <.card>
67
          <.form
68
            for={@form}
69
            id="repository-form"
70
            phx-change="validate"
71
            phx-submit="save"
72
            class="space-y-5"
73
          >
74
            <.input
75
              field={@form[:namespace]}
76
              type="select"
77
              label="Owner namespace"
78
              options={Enum.map(@namespaces, &{&1.slug, &1.slug})}
79
              required
80
            />
81
            <.input
82
              field={@form[:name]}
83
              label="Repository name"
84
              pattern="[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9])){0,63}"
85
              maxlength="64"
86
              placeholder="my-project"
87
              required
88
            />
89
            <.input
90
              field={@form[:description]}
91
              type="textarea"
92
              label="Description"
93
              maxlength="350"
94
              rows="3"
95
            />
96
            <.input
97
              field={@form[:default_branch]}
98
              label="Default branch"
99
              maxlength="255"
100
              required
101
            />
102
            <.input field={@form[:private]} type="checkbox" label="Make this repository private" />
103
104
            <div class="flex flex-wrap justify-end gap-3">
105
              <.button navigate={~p"/repositories"} variant={:secondary}>Cancel</.button>
106
              <.button id="create-repository" type="submit">Create repository</.button>
107
            </div>
108
          </.form>
109
        </.card>
110
      </main>
111
    </Layouts.app>
112
    """
113
  end
114
115
  defp namespaces(user) do
116
    case GitHubProjection.available_namespaces(user) do
117
      {:ok, namespaces} ->
118
        {namespaces, nil}
119
120
      {:error, _reason} ->
121
        {:ok, user_namespace} = Repositories.ensure_user_namespace(user)
122
123
        {[user_namespace],
124
         "Reconnect GitHub with repository and organization access to use organization namespaces."}
125
    end
126
  end
127
128
  defp create(user, %{kind: "user", owner_user_id: user_id}, attrs) when user_id == user.id,
129
    do: Repositories.create_user_repository(user, attrs, Ecto.UUID.generate())
130
131
  defp create(user, %{kind: "organization", slug: slug}, attrs) do
132
    with {:ok, namespace} <- GitHubProjection.authorized_organization(user, slug) do
133
      Repositories.create_organization_repository(user, namespace, attrs, Ecto.UUID.generate())
134
    end
135
  end
136
137
  defp create(_user, _namespace, _attrs), do: {:error, :namespace_not_allowed}
138
139
  defp repository_form(namespace) do
140
    to_form(
141
      %{
142
        "namespace" => namespace,
143
        "name" => "",
144
        "description" => "",
145
        "default_branch" => "main",
146
        "private" => "true"
147
      },
148
      as: :repository
149
    )
150
  end
151
152
  defp error_message(%Ecto.Changeset{}),
153
    do: "Check the repository name and branch, then try again."
154
155
  defp error_message(:idempotency_conflict),
156
    do: "This create request conflicts with an earlier request."
157
158
  defp error_message(_reason), do: "OpenAgents could not create this repository."
159
end
lib/openagents_web/plugs/forge_git_auth.ex modified +33 -14

@@ -1,9 +1,7 @@

1 1
defmodule OpenAgentsWeb.Plugs.ForgeGitAuth do
2 2
  @moduledoc """
3
  HTTP basic auth for the forge's git endpoints. The password is the
4
  credential (the username is ignored, as git clients vary): a paired
5
  machine token (`smct_…`, verified through `OpenAgents.Machines`) or the
6
  operator forge token from runtime configuration. No anonymous access.
3
  Optional HTTP Basic authentication for Git endpoints. Repository policy is
4
  evaluated after path resolution by `OpenAgents.Forge.GitHTTP`.
7 5
  """
8 6
9 7
  import Plug.Conn

@@ -19,15 +17,18 @@ defmodule OpenAgentsWeb.Plugs.ForgeGitAuth do

19 17
  end
20 18
21 19
  defp authenticate(conn) do
22
    with {_user, password} <- basic_credentials(conn),
23
         {:ok, principal} <- principal_for(password) do
24
      assign(conn, :forge_principal, principal)
25
    else
26
      _ ->
27
        conn
28
        |> put_resp_header("www-authenticate", ~s(Basic realm="openagents-forge"))
29
        |> send_resp(401, "authentication required")
30
        |> halt()
20
    case basic_credentials(conn) do
21
      :missing ->
22
        assign(conn, :forge_principal, nil)
23
24
      {:ok, {_user, password}} ->
25
        case principal_for(password) do
26
          {:ok, principal} -> assign(conn, :forge_principal, principal)
27
          :error -> refuse(conn)
28
        end
29
30
      :error ->
31
        refuse(conn)
31 32
    end
32 33
  end
33 34

@@ -35,12 +36,23 @@ defmodule OpenAgentsWeb.Plugs.ForgeGitAuth do

35 36
    with ["Basic " <> encoded | _] <- get_req_header(conn, "authorization"),
36 37
         {:ok, decoded} <- Base.decode64(encoded),
37 38
         [user, password] <- String.split(decoded, ":", parts: 2) do
38
      {user, password}
39
      {:ok, {user, password}}
39 40
    else
41
      [] -> :missing
40 42
      _ -> :error
41 43
    end
42 44
  end
43 45
46
  defp principal_for("oa_pat_" <> _ = token) do
47
    case OpenAgents.ApiTokens.authenticate(token, "forge:write") do
48
      {:ok, user, api_token} ->
49
        {:ok, %{kind: :user, id: user.id, user: user, api_token_id: api_token.id}}
50
51
      {:error, :invalid_api_token} ->
52
        :error
53
    end
54
  end
55
44 56
  defp principal_for("smct_" <> _ = token) do
45 57
    case OpenAgents.Machines.authenticate_token(token) do
46 58
      {:ok, machine} -> {:ok, %{kind: :machine, id: machine.id}}

@@ -60,4 +72,11 @@ defmodule OpenAgentsWeb.Plugs.ForgeGitAuth do

60 72
  end
61 73
62 74
  defp principal_for(_), do: :error
75
76
  defp refuse(conn) do
77
    conn
78
    |> put_resp_header("www-authenticate", ~s(Basic realm="openagents-forge"))
79
    |> send_resp(401, "authentication required")
80
    |> halt()
81
  end
63 82
end
lib/openagents_web/plugs/optional_api_token_auth.ex added +44

@@ -0,0 +1,44 @@

1
defmodule OpenAgentsWeb.Plugs.OptionalApiTokenAuth do
2
  @moduledoc "Authenticates a supplied first-party bearer credential and permits anonymous requests."
3
4
  import Plug.Conn
5
6
  alias OpenAgents.ApiTokens
7
8
  def init(options), do: Keyword.fetch!(options, :scope)
9
10
  def call(conn, required_scope) do
11
    case get_req_header(conn, "authorization") do
12
      [] ->
13
        assign(conn, :current_user, nil)
14
15
      ["Bearer " <> token] when token != "" ->
16
        authenticate(conn, token, required_scope)
17
18
      _invalid ->
19
        refuse(conn)
20
    end
21
  end
22
23
  defp authenticate(conn, token, required_scope) do
24
    case ApiTokens.authenticate(token, required_scope) do
25
      {:ok, user, api_token} ->
26
        conn
27
        |> put_resp_header("cache-control", "no-store")
28
        |> assign(:current_user, user)
29
        |> assign(:api_token, api_token)
30
        |> assign(:api_scope, required_scope)
31
32
      {:error, :invalid_api_token} ->
33
        refuse(conn)
34
    end
35
  end
36
37
  defp refuse(conn) do
38
    conn
39
    |> put_status(:unauthorized)
40
    |> put_resp_header("cache-control", "no-store")
41
    |> Phoenix.Controller.json(%{"error" => "invalid_api_token"})
42
    |> halt()
43
  end
44
end
lib/openagents_web/repository_access.ex added +49

@@ -0,0 +1,49 @@

1
defmodule OpenAgentsWeb.RepositoryAccess do
2
  @moduledoc "Repository-aware policy for browser code and repository pages."
3
4
  alias OpenAgents.Forge.Visibility
5
  alias OpenAgents.Repositories
6
  alias OpenAgents.Repositories.Repository
7
8
  @member_roles ~w(owner maintainer contributor viewer)
9
10
  def get_visible!(owner, name, user) do
11
    owner
12
    |> Repositories.get_visible_by_path!(name, user)
13
    |> OpenAgents.Repo.preload(:repository_import)
14
  end
15
16
  def base(%Repository{namespace: namespace, name: name}), do: "/#{namespace.slug}/#{name}"
17
18
  def full_source?(%Repository{} = repository, user) do
19
    ready?(repository) and
20
      (member?(repository, user) or ordinary_public?(repository) or
21
         Visibility.allows?(repository.storage_key, :files))
22
  end
23
24
  def ledger?(%Repository{} = repository, user) do
25
    full_source?(repository, user) or
26
      (ready?(repository) and Visibility.allows?(repository.storage_key, :ledger))
27
  end
28
29
  def allows_file?(%Repository{} = repository, user, path, ref_sha, head_sha) do
30
    full_source?(repository, user) or
31
      (ready?(repository) and
32
         Visibility.allows_file?(repository.storage_key, path, ref_sha, head_sha))
33
  end
34
35
  def member?(%Repository{} = repository, user),
36
    do: Repositories.membership_role(repository, user) in @member_roles
37
38
  def clone_url(%Repository{} = repository) do
39
    OpenAgentsWeb.Endpoint.url() <>
40
      "/git/#{repository.namespace.slug}/#{repository.name}.git"
41
  end
42
43
  defp ordinary_public?(%Repository{visibility: "public", storage_key: storage_key}),
44
    do: storage_key not in OpenAgents.Forge.Repos.allowed_repos()
45
46
  defp ordinary_public?(_repository), do: false
47
  defp ready?(%Repository{lifecycle_state: "ready"}), do: true
48
  defp ready?(_repository), do: false
49
end
lib/openagents_web/route_authority.ex modified +45 -3

@@ -40,6 +40,8 @@ defmodule OpenAgentsWeb.RouteAuthority do

40 40
    # No trailing slash: the memory page is "/memory" itself, and its export
41 41
    # lives under it.
42 42
    "/memory",
43
    "/device",
44
    "/repositories",
43 45
    "/settings/api-tokens",
44 46
    "/github/connection",
45 47
    "/api/tokens",

@@ -101,9 +103,6 @@ defmodule OpenAgentsWeb.RouteAuthority do

101 103
       when path in @public_browser_paths and verb in [:get, :head],
102 104
       do: declaration(:public_read, "anonymous", "published:web", false)
103 105
104
  defp policy(%{path: "/OpenAgentsInc/" <> _path, verb: verb}) when verb in [:get, :head],
105
    do: declaration(:public_read, "anonymous", "published:source", false)
106
107 106
  defp policy(%{path: "/auth/github", verb: :post}),
108 107
    do: declaration(:authenticated_browser, "explicit OAuth applicant", "identity:connect", true)
109 108

@@ -143,6 +142,10 @@ defmodule OpenAgentsWeb.RouteAuthority do

143 142
  defp policy(%{path: "/api/changelog", verb: verb}) when verb in [:get, :head],
144 143
    do: declaration(:public_read, "anonymous", "published:changelog", false)
145 144
145
  defp policy(%{path: "/api/contracts/repositories-v1.json", verb: verb})
146
       when verb in [:get, :head],
147
       do: declaration(:public_read, "anonymous", "published:api-contract", false)
148
146 149
  defp policy(%{path: "/controller/pairings", verb: :post}),
147 150
    do: declaration(:machine, "unpaired machine", "machine:pairing:create", true)
148 151

@@ -152,6 +155,29 @@ defmodule OpenAgentsWeb.RouteAuthority do

152 155
  defp policy(%{path: "/api/inference/proxy"}),
153 156
    do: declaration(:internal_service, "scoped inference grant", "inference:invoke", true)
154 157
158
  defp policy(%{path: "/api/v3/device/authorizations" <> _path, verb: :post}),
159
    do:
160
      declaration(
161
        :authenticated_api,
162
        "expiring one-time device secret",
163
        "device:authorize",
164
        true
165
      )
166
167
  defp policy(%{path: path, verb: verb})
168
       when path in ["/api/v3/user/repos", "/api/v3/repository-imports/:id"] and
169
              verb in [:get, :head],
170
       do: declaration(:authenticated_api, "first-party bearer token", "forge:read", false)
171
172
  defp policy(%{path: "/api/v3/repos/:owner/:repo", verb: verb}) when verb in [:get, :head],
173
    do:
174
      declaration(
175
        :public_read,
176
        "anonymous or first-party bearer token",
177
        "forge:repository:read",
178
        false
179
      )
180
155 181
  defp policy(%{path: "/api/v3/" <> _path, verb: verb}) when verb in [:get, :head],
156 182
    do: declaration(:public_read, "anonymous", "published:forge", false)
157 183

@@ -162,6 +188,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

162 188
    do:
163 189
      declaration(:internal_service, "development-only browser", "development:diagnostics", false)
164 190
191
  defp policy(%{plug: OpenAgentsWeb.NotFoundController, verb: verb}) when verb in [:get, :head],
192
    do: declaration(:public_read, "anonymous", "published:not-found", false)
193
165 194
  defp policy(%{path: path, verb: verb}) do
166 195
    cond do
167 196
      Enum.any?(@authenticated_browser_prefixes, &String.starts_with?(path, &1)) ->

@@ -175,6 +204,14 @@ defmodule OpenAgentsWeb.RouteAuthority do

175 204
      tracker_browser_path?(path) ->
176 205
        declaration(:authenticated_browser, "active encrypted browser session", "forge:web", true)
177 206
207
      repository_browser_path?(path) and verb in [:get, :head] ->
208
        declaration(
209
          :public_read,
210
          "anonymous or active repository member session",
211
          "forge:repository:web",
212
          false
213
        )
214
178 215
      true ->
179 216
        %{class: :unclassified, principal: nil, scope: nil, mutation: mutation_verb?(path)}
180 217
    end

@@ -203,5 +240,10 @@ defmodule OpenAgentsWeb.RouteAuthority do

203 240
    String.match?(path, ~r{\A/:owner/:repo/(issues|labels|milestones|assignees|projects)})
204 241
  end
205 242
243
  defp repository_browser_path?(path) do
244
    path == "/:owner/:repo" or
245
      String.starts_with?(path, ["/:owner/:repo/commit/", "/:owner/:repo/blob/"])
246
  end
247
206 248
  defp mutation_verb?(_path), do: true
207 249
end
lib/openagents_web/router.ex modified +48 -22

@@ -3,9 +3,6 @@ defmodule OpenAgentsWeb.Router do

3 3
4 4
  import OpenAgentsWeb.UserAuth
5 5
6
  # The account segment public forge URLs live under, mirroring GitHub.
7
  @forge_url_owner Application.compile_env(:openagents, :forge_url_owner, "OpenAgentsInc")
8
9 6
  pipeline :browser do
10 7
    plug :accepts, ["html"]
11 8
    plug :fetch_session

@@ -40,6 +37,11 @@ defmodule OpenAgentsWeb.Router do

40 37
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
41 38
  end
42 39
40
  pipeline :optional_forge_api do
41
    plug :accepts, ["json"]
42
    plug OpenAgentsWeb.Plugs.OptionalApiTokenAuth, scope: "forge:write"
43
  end
44
43 45
  pipeline :forge_git do
44 46
    plug OpenAgentsWeb.Plugs.ForgeGitAuth
45 47
  end

@@ -90,25 +92,6 @@ defmodule OpenAgentsWeb.Router do

90 92
    delete "/logout", AuthController, :logout
91 93
  end
92 94
93
  # The public forge web UI (TRANSPARENCY-001), addressed exactly the way the
94
  # GitHub URL it replaces is:
95
  # openagents.com/OpenAgentsInc/openagents.com/blob/main/README.md.
96
  #
97
  # The owner is a LITERAL scope segment, not a `:owner` pattern: a wildcard
98
  # first segment would match every two-segment path on the domain (/dev/ui,
99
  # a future /foo/bar) and shadow it. Scoping to the owning account keeps the
100
  # GitHub-identical URL while touching nothing else.
101
  scope "/#{@forge_url_owner}", OpenAgentsWeb do
102
    pipe_through :browser
103
104
    live_session :public_code,
105
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
106
      live "/:repo", CodeRepoLive, :index
107
      live "/:repo/commit/:sha", CodeCommitLive, :index
108
      live "/:repo/blob/:ref/*path", CodeBlobLive, :index
109
    end
110
  end
111
112 95
  scope "/", OpenAgentsWeb do
113 96
    pipe_through [:browser, :authenticated]
114 97

@@ -118,6 +101,10 @@ defmodule OpenAgentsWeb.Router do

118 101
      live "/memory", MemoryLive, :index
119 102
      live "/computers", ComputersLive, :index
120 103
      live "/settings/api-tokens", ApiTokensLive, :index
104
      live "/device", DeviceAuthorizationLive, :show
105
      live "/repositories", RepositoryIndexLive, :index
106
      live "/repositories/new", RepositoryNewLive, :new
107
      live "/repositories/import/github", RepositoryImportLive, :new
121 108
      live "/:owner/:repo/issues/new", IssueNewLive, :new
122 109
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
123 110
      live "/:owner/:repo/issues", IssueIndexLive, :index

@@ -195,6 +182,7 @@ defmodule OpenAgentsWeb.Router do

195 182
    get "/healthz", HealthController, :show
196 183
    get "/api/status", NetworkStatusController, :show
197 184
    get "/api/changelog", ChangelogController, :show
185
    get "/api/contracts/repositories-v1.json", ApiContractController, :repositories_v1
198 186
199 187
    post "/controller/pairings", ControllerPairingController, :create
200 188
    get "/controller/pairings/:id", ControllerPairingController, :show

@@ -204,6 +192,9 @@ defmodule OpenAgentsWeb.Router do

204 192
  scope "/api/v3", OpenAgentsWeb do
205 193
    pipe_through :api
206 194
195
    post "/device/authorizations", DeviceAuthorizationController, :create
196
    post "/device/authorizations/token", DeviceAuthorizationController, :token
197
207 198
    get "/repos/:owner/:repo/issues", IssueController, :index
208 199
    get "/repos/:owner/:repo/issues/:issue_number", IssueController, :show
209 200
    get "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :index

@@ -222,9 +213,22 @@ defmodule OpenAgentsWeb.Router do

222 213
    get "/users/:username/projectsV2/:project_number/fields", ProjectController, :fields
223 214
  end
224 215
216
  scope "/api/v3", OpenAgentsWeb do
217
    pipe_through :optional_forge_api
218
219
    get "/repos/:owner/:repo", RepositoryController, :show
220
  end
221
225 222
  scope "/api/v3", OpenAgentsWeb do
226 223
    pipe_through :forge_write_api
227 224
225
    get "/user/repos", RepositoryController, :index
226
    post "/user/repos", RepositoryController, :create_user
227
    post "/orgs/:org/repos", RepositoryController, :create_organization
228
    post "/user/repos/imports", RepositoryImportController, :create_user
229
    post "/orgs/:org/repos/imports", RepositoryImportController, :create_organization
230
    get "/repository-imports/:id", RepositoryImportController, :show
231
228 232
    post "/repos/:owner/:repo/issues", IssueController, :create
229 233
    put "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
230 234
    patch "/repos/:owner/:repo/issues/:issue_number", IssueController, :update

@@ -268,4 +272,26 @@ defmodule OpenAgentsWeb.Router do

268 272
      forward "/mailbox", Plug.Swoosh.MailboxPreview
269 273
    end
270 274
  end
275
276
  # Keep repository-shaped routes last. Every fixed product, API, operator,
277
  # Git, and development route above wins before a GitHub-backed namespace can
278
  # be interpreted from the first path segment.
279
  scope "/", OpenAgentsWeb do
280
    pipe_through :browser
281
282
    for reserved <- OpenAgents.Repositories.Namespace.reserved_slugs() do
283
      get "/#{reserved}/*path", NotFoundController, :show
284
    end
285
  end
286
287
  scope "/", OpenAgentsWeb do
288
    pipe_through :browser
289
290
    live_session :repository_code,
291
      on_mount: [{OpenAgentsWeb.UserAuth, :mount_current_user}] do
292
      live "/:owner/:repo", CodeRepoLive, :index
293
      live "/:owner/:repo/commit/:sha", CodeCommitLive, :index
294
      live "/:owner/:repo/blob/:ref/*path", CodeBlobLive, :index
295
    end
296
  end
271 297
end
ops/staging/gate-5-profile.sh modified +1 -1

@@ -11,7 +11,7 @@ set -eu

11 11
: "${OPENAI_API_KEY:?OPENAI_API_KEY is required}"
12 12
: "${SECRET_KEY_BASE:?SECRET_KEY_BASE is required}"
13 13
14
export GITHUB_OAUTH_SCOPES="repo"
14
export GITHUB_OAUTH_SCOPES="repo,read:org"
15 15
export GITHUB_REDIRECT_URI="https://staging.openagents.com/auth/github/callback"
16 16
export GITHUB_TOKEN_DECRYPTION_KEYS_JSON="${GITHUB_TOKEN_DECRYPTION_KEYS_JSON:-{}}"
17 17
export OPENAGENTS_ALLOWED_ORIGINS="https://staging.openagents.com"
priv/api-contracts/repositories-v1.json added +89

@@ -0,0 +1,89 @@

1
{
2
  "contract": "openagents.repositories.v1",
3
  "version": 1,
4
  "authentication": {
5
    "type": "bearer",
6
    "token_prefix": "oa_pat_",
7
    "scope": "forge:write"
8
  },
9
  "idempotency": {
10
    "header": "Idempotency-Key",
11
    "required_for": [
12
      "POST /api/v3/user/repos",
13
      "POST /api/v3/orgs/{org}/repos",
14
      "POST /api/v3/user/repos/imports",
15
      "POST /api/v3/orgs/{org}/repos/imports"
16
    ]
17
  },
18
  "endpoints": {
19
    "create_user_repository": "POST /api/v3/user/repos",
20
    "create_organization_repository": "POST /api/v3/orgs/{org}/repos",
21
    "import_user_repository": "POST /api/v3/user/repos/imports",
22
    "import_organization_repository": "POST /api/v3/orgs/{org}/repos/imports",
23
    "list_repositories": "GET /api/v3/user/repos",
24
    "get_repository": "GET /api/v3/repos/{owner}/{repo}",
25
    "get_repository_import": "GET /api/v3/repository-imports/{id}",
26
    "create_device_authorization": "POST /api/v3/device/authorizations",
27
    "poll_device_authorization": "POST /api/v3/device/authorizations/token"
28
  },
29
  "repository": {
30
    "required": [
31
      "id",
32
      "name",
33
      "full_name",
34
      "owner",
35
      "private",
36
      "visibility",
37
      "default_branch",
38
      "lifecycle_state",
39
      "clone_url",
40
      "html_url",
41
      "permissions",
42
      "created_at",
43
      "updated_at"
44
    ],
45
    "lifecycle_states": ["provisioning", "ready", "failed"],
46
    "visibilities": ["private", "public"]
47
  },
48
  "repository_import": {
49
    "required": [
50
      "id",
51
      "provider",
52
      "source_full_name",
53
      "source_default_branch",
54
      "source_ref_digest",
55
      "source_head_sha",
56
      "state",
57
      "lfs_warning",
58
      "attempt_count"
59
    ],
60
    "states": ["pending", "running", "completed", "failed"]
61
  },
62
  "pagination": {
63
    "request_cursor": "after",
64
    "response_cursor": "next_cursor",
65
    "maximum_page_size": 100
66
  },
67
  "errors": {
68
    "shape": ["code", "message", "request_id"],
69
    "stable_codes": [
70
      "access_denied",
71
      "authorization_pending",
72
      "authorization_unavailable",
73
      "github_connection_required",
74
      "github_scope_required",
75
      "github_unavailable",
76
      "idempotency_conflict",
77
      "invalid_idempotency_key",
78
      "invalid_import",
79
      "invalid_pagination",
80
      "invalid_repository",
81
      "namespace_not_allowed",
82
      "not_found",
83
      "repository_name_conflict",
84
      "slow_down",
85
      "source_namespace_mismatch",
86
      "source_repository_not_accessible"
87
    ]
88
  }
89
}
priv/migration_lineages/prior-2026-08-19.json modified +3 -1

@@ -187,7 +187,9 @@

187 187
    20260820130000,
188 188
    20260820140000,
189 189
    20260820150000,
190
    20260820161342
190
    20260820161342,
191
    20260820204317,
192
    20260820211218
191 193
  ],
192 194
  "required_tables": [
193 195
    "users",
priv/repo/migrations/20260820204317_add_repository_creation_lifecycle.exs added +239

@@ -0,0 +1,239 @@

1
defmodule OpenAgents.Repo.Migrations.AddRepositoryCreationLifecycle do
2
  use Ecto.Migration
3
4
  @initial_repository_id "00000000-0000-4000-8000-000000000001"
5
  @initial_namespace_id "00000000-0000-4000-8000-000000000002"
6
  @openagents_github_id 115_798_681
7
8
  def up do
9
    create table(:namespaces, primary_key: false) do
10
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
11
      add :provider, :string, null: false, default: "github"
12
      add :provider_account_id, :bigint, null: false
13
      add :provider_node_id, :string
14
      add :slug, :string, null: false
15
      add :slug_key, :string, null: false
16
      add :kind, :string, null: false
17
      add :owner_user_id, references(:users, type: :binary_id, on_delete: :delete_all)
18
      add :provider_refreshed_at, :utc_datetime_usec, null: false
19
      add :state, :string, null: false, default: "active"
20
21
      timestamps(type: :utc_datetime_usec)
22
    end
23
24
    create unique_index(:namespaces, [:provider, :provider_account_id, :kind])
25
26
    create unique_index(:namespaces, [:slug_key],
27
             where: "state = 'active'",
28
             name: :namespaces_active_slug_key_index
29
           )
30
31
    create constraint(:namespaces, :namespaces_kind_check,
32
             check: "kind IN ('user', 'organization')"
33
           )
34
35
    create constraint(:namespaces, :namespaces_state_check,
36
             check: "state IN ('active', 'suspended', 'retired')"
37
           )
38
39
    create constraint(:namespaces, :namespaces_normalized_slug_check,
40
             check: "slug_key = lower(slug)"
41
           )
42
43
    create constraint(:namespaces, :namespaces_owner_kind_check,
44
             check:
45
               "(kind = 'user' AND owner_user_id IS NOT NULL) OR " <>
46
                 "(kind = 'organization' AND owner_user_id IS NULL)"
47
           )
48
49
    create table(:namespace_aliases, primary_key: false) do
50
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
51
52
      add :namespace_id,
53
          references(:namespaces, type: :binary_id, on_delete: :delete_all),
54
          null: false
55
56
      add :slug, :string, null: false
57
      add :slug_key, :string, null: false
58
      timestamps(type: :utc_datetime_usec)
59
    end
60
61
    create unique_index(:namespace_aliases, [:slug_key])
62
63
    create constraint(:namespace_aliases, :namespace_aliases_normalized_slug_check,
64
             check: "slug_key = lower(slug)"
65
           )
66
67
    execute("""
68
    INSERT INTO namespaces
69
      (id, provider, provider_account_id, provider_node_id, slug, slug_key, kind,
70
       provider_refreshed_at, state, inserted_at, updated_at)
71
    VALUES
72
      ('#{@initial_namespace_id}', 'github', #{@openagents_github_id}, 'O_kgDOBubymQ',
73
       'OpenAgentsInc', 'openagentsinc', 'organization', now(), 'active', now(), now())
74
    """)
75
76
    alter table(:repositories) do
77
      add :namespace_id, references(:namespaces, type: :binary_id, on_delete: :restrict)
78
      add :description, :string
79
      add :lifecycle_state, :string, null: false, default: "ready"
80
      add :provisioning_kind, :string, null: false, default: "empty"
81
      add :provision_error_code, :string
82
      add :storage_key, :string
83
      add :created_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
84
      add :ready_at, :utc_datetime_usec
85
    end
86
87
    execute("""
88
    UPDATE repositories
89
    SET namespace_id = '#{@initial_namespace_id}',
90
        storage_key = 'openagents.com',
91
        ready_at = COALESCE(updated_at, inserted_at)
92
    WHERE id = '#{@initial_repository_id}'
93
    """)
94
95
    execute("ALTER TABLE repositories ALTER COLUMN namespace_id SET NOT NULL")
96
    execute("ALTER TABLE repositories ALTER COLUMN storage_key SET NOT NULL")
97
98
    drop index(:repositories, [:owner_key, :name_key])
99
    create unique_index(:repositories, [:namespace_id, :name_key])
100
    create unique_index(:repositories, [:storage_key])
101
102
    create constraint(:repositories, :repositories_lifecycle_state_check,
103
             check: "lifecycle_state IN ('provisioning', 'ready', 'failed')"
104
           )
105
106
    create constraint(:repositories, :repositories_provisioning_kind_check,
107
             check: "provisioning_kind IN ('empty', 'github_import')"
108
           )
109
110
    create constraint(:repositories, :repositories_ready_state_check,
111
             check:
112
               "(lifecycle_state = 'ready' AND ready_at IS NOT NULL) OR " <>
113
                 "(lifecycle_state <> 'ready')"
114
           )
115
116
    create table(:repository_imports, primary_key: false) do
117
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
118
119
      add :repository_id,
120
          references(:repositories, type: :binary_id, on_delete: :delete_all),
121
          null: false
122
123
      add :provider, :string, null: false, default: "github"
124
      add :source_repository_id, :bigint, null: false
125
      add :source_owner_id, :bigint, null: false
126
      add :source_full_name, :string, null: false
127
      add :source_default_branch, :string, null: false
128
      add :source_ref_digest, :string, null: false
129
      add :source_head_sha, :string
130
      add :source_refs, :map, null: false
131
      add :source_uses_lfs, :boolean, null: false, default: false
132
      add :state, :string, null: false, default: "pending"
133
      add :attempt_count, :integer, null: false, default: 0
134
      add :error_code, :string
135
      add :started_at, :utc_datetime_usec
136
      add :completed_at, :utc_datetime_usec
137
138
      timestamps(type: :utc_datetime_usec)
139
    end
140
141
    create unique_index(:repository_imports, [:repository_id])
142
143
    create constraint(:repository_imports, :repository_imports_state_check,
144
             check: "state IN ('pending', 'running', 'completed', 'failed')"
145
           )
146
147
    create constraint(:repository_imports, :repository_imports_digest_check,
148
             check: "source_ref_digest ~ '^[0-9a-f]{64}$'"
149
           )
150
151
    create table(:repository_provisioning_outbox, primary_key: false) do
152
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
153
154
      add :repository_id,
155
          references(:repositories, type: :binary_id, on_delete: :delete_all),
156
          null: false
157
158
      add :repository_import_id,
159
          references(:repository_imports, type: :binary_id, on_delete: :delete_all)
160
161
      add :operation, :string, null: false
162
      add :state, :string, null: false, default: "pending"
163
      add :attempt_count, :integer, null: false, default: 0
164
      add :retry_at, :utc_datetime_usec, null: false
165
      add :claimed_at, :utc_datetime_usec
166
      add :completed_at, :utc_datetime_usec
167
      add :error_code, :string
168
169
      timestamps(type: :utc_datetime_usec)
170
    end
171
172
    create unique_index(:repository_provisioning_outbox, [:repository_id])
173
    create index(:repository_provisioning_outbox, [:state, :retry_at])
174
175
    create constraint(:repository_provisioning_outbox, :repository_provisioning_operation_check,
176
             check: "operation IN ('create', 'github_import')"
177
           )
178
179
    create constraint(:repository_provisioning_outbox, :repository_provisioning_state_check,
180
             check: "state IN ('pending', 'running', 'completed', 'failed')"
181
           )
182
183
    create table(:repository_idempotency_requests, primary_key: false) do
184
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
185
186
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
187
188
      add :operation, :string, null: false
189
      add :idempotency_key, :string, null: false
190
      add :request_digest, :string, null: false
191
192
      add :repository_id,
193
          references(:repositories, type: :binary_id, on_delete: :delete_all),
194
          null: false
195
196
      add :repository_import_id,
197
          references(:repository_imports, type: :binary_id, on_delete: :delete_all)
198
199
      timestamps(type: :utc_datetime_usec)
200
    end
201
202
    create unique_index(:repository_idempotency_requests, [
203
             :user_id,
204
             :operation,
205
             :idempotency_key
206
           ])
207
208
    create constraint(:repository_idempotency_requests, :repository_idempotency_digest_check,
209
             check: "request_digest ~ '^[0-9a-f]{64}$'"
210
           )
211
  end
212
213
  def down do
214
    drop table(:repository_idempotency_requests)
215
    drop table(:repository_provisioning_outbox)
216
    drop table(:repository_imports)
217
218
    drop constraint(:repositories, :repositories_ready_state_check)
219
    drop constraint(:repositories, :repositories_provisioning_kind_check)
220
    drop constraint(:repositories, :repositories_lifecycle_state_check)
221
    drop index(:repositories, [:storage_key])
222
    drop index(:repositories, [:namespace_id, :name_key])
223
    create unique_index(:repositories, [:owner_key, :name_key])
224
225
    alter table(:repositories) do
226
      remove :ready_at
227
      remove :created_by_user_id
228
      remove :storage_key
229
      remove :provision_error_code
230
      remove :provisioning_kind
231
      remove :lifecycle_state
232
      remove :description
233
      remove :namespace_id
234
    end
235
236
    drop table(:namespace_aliases)
237
    drop table(:namespaces)
238
  end
239
end
priv/repo/migrations/20260820211218_add_cli_device_authorizations.exs added +43

@@ -0,0 +1,43 @@

1
defmodule OpenAgents.Repo.Migrations.AddCliDeviceAuthorizations do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:device_authorizations, primary_key: false) do
6
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
7
      add :device_code_digest, :binary, null: false
8
      add :user_code_digest, :binary, null: false
9
      add :state, :string, null: false, default: "pending"
10
      add :scopes, {:array, :string}, null: false, default: ["forge:write"]
11
      add :interval_seconds, :integer, null: false, default: 5
12
      add :poll_count, :integer, null: false, default: 0
13
      add :last_polled_at, :utc_datetime_usec
14
      add :expires_at, :utc_datetime_usec, null: false
15
      add :approved_at, :utc_datetime_usec
16
      add :denied_at, :utc_datetime_usec
17
      add :claimed_at, :utc_datetime_usec
18
19
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
20
21
      add :api_token_id,
22
          references(:api_tokens, type: :binary_id, on_delete: :nilify_all)
23
24
      timestamps(type: :utc_datetime_usec)
25
    end
26
27
    create unique_index(:device_authorizations, [:device_code_digest])
28
    create unique_index(:device_authorizations, [:user_code_digest])
29
    create index(:device_authorizations, [:expires_at])
30
31
    create constraint(:device_authorizations, :device_authorizations_state_check,
32
             check: "state IN ('pending', 'approved', 'denied', 'claimed')"
33
           )
34
35
    create constraint(:device_authorizations, :device_authorizations_interval_check,
36
             check: "interval_seconds BETWEEN 1 AND 30"
37
           )
38
39
    create constraint(:device_authorizations, :device_authorizations_poll_count_check,
40
             check: "poll_count >= 0"
41
           )
42
  end
43
end
test/openagents/forge/git_http_test.exs modified +151 -28

@@ -7,6 +7,9 @@ defmodule OpenAgents.Forge.GitHTTPTest do

7 7
  """
8 8
9 9
  use OpenAgents.DataCase, async: false
10
11
  import OpenAgents.AccountsFixtures
12
10 13
  alias OpenAgents.Forge
11 14
  alias OpenAgents.Forge.{Repos, WAL}
12 15

@@ -18,8 +21,6 @@ defmodule OpenAgents.Forge.GitHTTPTest do

18 21
    plug OpenAgents.Forge.GitHTTP
19 22
  end
20 23
21
  @operator_token "forge_test_operator_token_0123456789"
22
23 24
  setup do
24 25
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
25 26

@@ -30,6 +31,23 @@ defmodule OpenAgents.Forge.GitHTTPTest do

30 31
    Application.put_env(:openagents, :forge_data_dir, Path.join(base, "data"))
31 32
    Application.put_env(:openagents, :forge_wal_dir, Path.join(base, "wal"))
32 33
34
    user = repository_user_fixture("git-http-owner")
35
36
    {:ok, repository, :created} =
37
      OpenAgents.Repositories.create_user_repository(user, %{name: "demo"}, "git-http-demo")
38
39
    repository =
40
      repository
41
      |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
42
      |> OpenAgents.Repo.update!()
43
44
    {:ok, _api_token, plaintext} =
45
      OpenAgents.ApiTokens.create(user, %{
46
        name: "Git HTTP test",
47
        scopes: ["forge:write"],
48
        lifetime_days: 1
49
      })
50
33 51
    port = free_port()
34 52
    start_supervised!({Bandit, plug: TestPipeline, port: port, ip: {127, 0, 0, 1}})
35 53

@@ -39,7 +57,14 @@ defmodule OpenAgents.Forge.GitHTTPTest do

39 57
      File.rm_rf(base)
40 58
    end)
41 59
42
    %{base: base, port: port, url: "http://x:#{@operator_token}@127.0.0.1:#{port}/demo.git"}
60
    %{
61
      base: base,
62
      port: port,
63
      repository: repository,
64
      token: plaintext,
65
      url: "http://x:#{plaintext}@127.0.0.1:#{port}/git-http-owner/demo.git",
66
      user: user
67
    }
43 68
  end
44 69
45 70
  defp free_port do

@@ -75,68 +100,87 @@ defmodule OpenAgents.Forge.GitHTTPTest do

75 100
    sh!(work, "git", ["push", "origin", "HEAD:main"])
76 101
  end
77 102
78
  test "clone, push, WAL persist, receipt, and PubSub — the ack chain", %{base: base, url: url} do
103
  test "clone, push, WAL persist, receipt, and PubSub — the ack chain", %{
104
    base: base,
105
    repository: repository,
106
    url: url,
107
    user: user
108
  } do
79 109
    Phoenix.PubSub.subscribe(OpenAgents.PubSub, "forge:pushes")
80 110
81 111
    work = seed_clone!(base, url)
82 112
    commit_and_push!(work, "hello.txt", "hello forge\n", "first commit")
83 113
84 114
    # WAL is the authority: index has the entry and the ref.
85
    assert {:ok, _generation, index} = WAL.read_index("demo")
115
    assert {:ok, _generation, index} = WAL.read_index(repository.storage_key)
86 116
    assert [entry] = WAL.entries(index)
87 117
    assert entry["seq"] == 0
88
    assert entry["principal"] == "operator:forge-token"
118
    assert entry["principal"] == "user:#{user.id}"
89 119
    assert %{"refs/heads/main" => sha} = WAL.refs(index)
90 120
    assert byte_size(sha) == 40
91 121
92 122
    # Derived receipt exists, idempotent by (repo, wal_seq).
93
    assert [receipt] = Forge.recent_pushes("demo")
123
    assert [receipt] = Forge.recent_pushes(repository.storage_key)
94 124
    assert receipt.wal_seq == 0
95 125
    assert receipt.refs["refs/heads/main"]["new"] == sha
96 126
    assert receipt.refs["refs/heads/main"]["old"] == nil
97 127
98 128
    # Deploy signal fired.
99
    assert_receive {:forge_push, %{repo: "demo", wal_seq: 0}}, 2_000
129
    assert_receive {:forge_push, %{repo: storage_key, wal_seq: 0}}, 2_000
130
    assert storage_key == repository.storage_key
100 131
101 132
    # A second clone sees the commit.
102 133
    verify = seed_clone!(base, url)
103 134
    assert File.read!(Path.join(verify, "hello.txt")) == "hello forge\n"
104 135
  end
105 136
106
  test "a second push appends to the WAL and receipts stay ordered", %{base: base, url: url} do
137
  test "a second push appends to the WAL and receipts stay ordered", %{
138
    base: base,
139
    repository: repository,
140
    url: url
141
  } do
107 142
    work = seed_clone!(base, url)
108 143
    commit_and_push!(work, "a.txt", "one\n", "one")
109 144
    commit_and_push!(work, "b.txt", "two\n", "two")
110 145
111
    assert {:ok, _generation, index} = WAL.read_index("demo")
146
    assert {:ok, _generation, index} = WAL.read_index(repository.storage_key)
112 147
    assert length(WAL.entries(index)) == 2
113
    assert [%{wal_seq: 1}, %{wal_seq: 0}] = Forge.recent_pushes("demo")
148
    assert [%{wal_seq: 1}, %{wal_seq: 0}] = Forge.recent_pushes(repository.storage_key)
114 149
  end
115 150
116
  test "cache loss: the bare repo re-materializes from the WAL", %{base: base, url: url} do
151
  test "cache loss: the bare repo re-materializes from the WAL", %{
152
    base: base,
153
    repository: repository,
154
    url: url
155
  } do
117 156
    work = seed_clone!(base, url)
118 157
    commit_and_push!(work, "keep.txt", "durable\n", "durable commit")
119
    refs_before = Repos.refs("demo")
158
    refs_before = Repos.refs(repository.storage_key)
120 159
121 160
    # Kill the cache entirely.
122
    File.rm_rf!(Repos.bare_path("demo"))
123
    refute File.exists?(Repos.bare_path("demo"))
161
    File.rm_rf!(Repos.bare_path(repository.storage_key))
162
    refute File.exists?(Repos.bare_path(repository.storage_key))
124 163
125 164
    # A fresh clone triggers materialization and sees identical state.
126 165
    verify = seed_clone!(base, url)
127 166
    assert File.read!(Path.join(verify, "keep.txt")) == "durable\n"
128
    assert Repos.refs("demo") == refs_before
167
    assert Repos.refs(repository.storage_key) == refs_before
129 168
  end
130 169
131
  test "unauthenticated and wrong-token pushes are refused", %{base: base, port: port, url: url} do
170
  test "unauthenticated and wrong-token pushes are refused", %{
171
    base: base,
172
    port: port,
173
    repository: repository,
174
    url: url
175
  } do
132 176
    work = seed_clone!(base, url)
133 177
    File.write!(Path.join(work, "no.txt"), "no\n")
134 178
    sh!(work, "git", ["add", "."])
135 179
    sh!(work, "git", ["commit", "-m", "unauthorized"])
136 180
137 181
    for bad_url <- [
138
          "http://127.0.0.1:#{port}/demo.git",
139
          "http://x:wrong-token@127.0.0.1:#{port}/demo.git"
182
          "http://127.0.0.1:#{port}/git-http-owner/demo.git",
183
          "http://x:wrong-token@127.0.0.1:#{port}/git-http-owner/demo.git"
140 184
        ] do
141 185
      {output, status} =
142 186
        System.cmd("git", ["-c", "credential.helper=", "push", bad_url, "HEAD:main"],

@@ -152,13 +196,13 @@ defmodule OpenAgents.Forge.GitHTTPTest do

152 196
    end
153 197
154 198
    # Nothing leaked into the WAL.
155
    case WAL.read_index("demo") do
199
    case WAL.read_index(repository.storage_key) do
156 200
      {:error, :not_found} -> :ok
157 201
      {:ok, _generation, index} -> assert WAL.entries(index) == []
158 202
    end
159 203
  end
160 204
161
  test "unknown repositories are refused", %{port: port} do
205
  test "unknown repositories are refused", %{port: port, token: token} do
162 206
    {output, status} =
163 207
      System.cmd(
164 208
        "git",

@@ -166,7 +210,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

166 210
          "-c",
167 211
          "credential.helper=",
168 212
          "clone",
169
          "http://x:#{@operator_token}@127.0.0.1:#{port}/not-allowed.git"
213
          "http://x:#{token}@127.0.0.1:#{port}/git-http-owner/not-allowed.git"
170 214
        ],
171 215
        cd: System.tmp_dir!(),
172 216
        stderr_to_stdout: true

@@ -176,14 +220,93 @@ defmodule OpenAgents.Forge.GitHTTPTest do

176 220
    assert output =~ "404" or output =~ "not found" or output =~ "unknown"
177 221
  end
178 222
179
  test "a failed WAL persist rolls refs back and the push is not acked", %{base: base, url: url} do
223
  test "public repositories clone anonymously and private repositories issue a challenge", %{
224
    base: base,
225
    port: port,
226
    repository: repository
227
  } do
228
    anonymous_url = "http://127.0.0.1:#{port}/git-http-owner/demo.git"
229
230
    {private_output, private_status} =
231
      System.cmd(
232
        "git",
233
        ["-c", "credential.helper=", "clone", anonymous_url, Path.join(base, "private")],
234
        stderr_to_stdout: true,
235
        env: [{"GIT_TERMINAL_PROMPT", "0"}]
236
      )
237
238
    assert private_status != 0
239
    assert private_output =~ "Authentication" or private_output =~ "terminal prompts disabled"
240
241
    repository
242
    |> Ecto.Changeset.change(visibility: "public")
243
    |> OpenAgents.Repo.update!()
244
245
    public_clone = Path.join(base, "public")
246
    sh!(base, "git", ["clone", anonymous_url, public_clone])
247
    assert File.dir?(Path.join(public_clone, ".git"))
248
  end
249
250
  test "a viewer can clone a private repository but cannot push", %{
251
    base: base,
252
    port: port,
253
    repository: repository,
254
    url: owner_url
255
  } do
256
    owner_clone = seed_clone!(base, owner_url)
257
    commit_and_push!(owner_clone, "owner.txt", "owner\n", "Owner commit")
258
259
    viewer = repository_user_fixture("git-http-viewer")
260
    {:ok, _membership} = OpenAgents.Repositories.add_member(repository, viewer, "viewer")
261
262
    {:ok, _api_token, viewer_token} =
263
      OpenAgents.ApiTokens.create(viewer, %{
264
        name: "Viewer Git HTTP test",
265
        scopes: ["forge:write"],
266
        lifetime_days: 1
267
      })
268
269
    viewer_url =
270
      "http://x:#{viewer_token}@127.0.0.1:#{port}/git-http-owner/demo.git"
271
272
    viewer_clone = seed_clone!(base, viewer_url)
273
    File.write!(Path.join(viewer_clone, "viewer.txt"), "viewer\n")
274
    sh!(viewer_clone, "git", ["add", "viewer.txt"])
275
    sh!(viewer_clone, "git", ["commit", "-m", "Viewer commit"])
276
277
    {output, status} =
278
      System.cmd("git", ["-c", "credential.helper=", "push", "origin", "HEAD:main"],
279
        cd: viewer_clone,
280
        stderr_to_stdout: true
281
      )
282
283
    assert status != 0
284
    assert output =~ "403" or output =~ "read only" or output =~ "unable to access"
285
  end
286
287
  test "the legacy initial repository path remains available", %{
288
    base: base,
289
    port: port,
290
    token: token
291
  } do
292
    legacy_url = "http://x:#{token}@127.0.0.1:#{port}/openagents.com.git"
293
    legacy_clone = Path.join(base, "legacy")
294
    sh!(base, "git", ["clone", legacy_url, legacy_clone])
295
    assert File.dir?(Path.join(legacy_clone, ".git"))
296
  end
297
298
  test "a failed WAL persist rolls refs back and the push is not acked", %{
299
    base: base,
300
    repository: repository,
301
    url: url
302
  } do
180 303
    work = seed_clone!(base, url)
181 304
    commit_and_push!(work, "ok.txt", "fine\n", "fine")
182
    refs_before = Repos.refs("demo")
305
    refs_before = Repos.refs(repository.storage_key)
183 306
184 307
    # Break the WAL (unwritable dir) — the next push must not ack.
185 308
    wal_dir = Application.get_env(:openagents, :forge_wal_dir)
186
    File.chmod!(Path.join(wal_dir, "demo"), 0o500)
309
    File.chmod!(Path.join(wal_dir, repository.storage_key), 0o500)
187 310
188 311
    File.write!(Path.join(work, "lost.txt"), "must not land\n")
189 312
    sh!(work, "git", ["add", "."])

@@ -195,12 +318,12 @@ defmodule OpenAgents.Forge.GitHTTPTest do

195 318
        stderr_to_stdout: true
196 319
      )
197 320
198
    File.chmod!(Path.join(wal_dir, "demo"), 0o700)
321
    File.chmod!(Path.join(wal_dir, repository.storage_key), 0o700)
199 322
200 323
    assert status != 0, "push must fail when the WAL cannot persist: #{output}"
201 324
    # Local refs rolled back — cache never ahead of authority.
202
    assert Repos.refs("demo") == refs_before
203
    assert {:ok, _generation, index} = WAL.read_index("demo")
325
    assert Repos.refs(repository.storage_key) == refs_before
326
    assert {:ok, _generation, index} = WAL.read_index(repository.storage_key)
204 327
    assert length(WAL.entries(index)) == 1
205 328
  end
206 329
end
test/openagents/forge/sync_test.exs added +110

@@ -0,0 +1,110 @@

1
defmodule OpenAgents.Forge.SyncTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Forge.{Repos, Sync, WAL}
5
6
  setup do
7
    root =
8
      Path.join(System.tmp_dir!(), "forge-sync-#{System.unique_integer([:positive, :monotonic])}")
9
10
    previous_data = Application.get_env(:openagents, :forge_data_dir)
11
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
12
    previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)
13
14
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "data"))
15
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
16
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
17
18
    on_exit(fn ->
19
      restore_env(:forge_data_dir, previous_data)
20
      restore_env(:forge_wal_dir, previous_wal)
21
      restore_env(:forge_wal_adapter, previous_adapter)
22
      File.rm_rf!(root)
23
    end)
24
25
    %{root: root}
26
  end
27
28
  test "a Git bundle WAL entry survives deletion of the bare cache", %{root: root} do
29
    source = Path.join(root, "source")
30
    File.mkdir_p!(source)
31
    git!(source, ["init", "--initial-branch=trunk"])
32
    git!(source, ["config", "user.email", "test@example.com"])
33
    git!(source, ["config", "user.name", "Forge test"])
34
    File.write!(Path.join(source, "README.md"), "durable import\n")
35
    git!(source, ["add", "README.md"])
36
    git!(source, ["commit", "-m", "Imported commit"])
37
    git!(source, ["tag", "v1"])
38
39
    sha = source |> git!(["rev-parse", "HEAD"]) |> String.trim()
40
    bundle = Path.join(root, "source.bundle")
41
    git!(source, ["bundle", "create", bundle, "--all"])
42
    payload = File.read!(bundle)
43
    refs = %{"refs/heads/trunk" => sha, "refs/tags/v1" => sha}
44
45
    index = WAL.new_index()
46
    {:ok, object} = WAL.put_entry("storage-key", 0, payload)
47
48
    entry = %{
49
      "seq" => 0,
50
      "object" => object,
51
      "format" => "git_bundle",
52
      "refs" => refs,
53
      "principal" => "github-import:test",
54
      "pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
55
    }
56
57
    {:ok, _generation} = WAL.cas_index("storage-key", :none, WAL.append_entry(index, entry))
58
59
    assert :ok = Sync.ensure_fresh("storage-key", "trunk")
60
    assert Repos.refs("storage-key") == refs
61
62
    assert String.trim(git_bare!(Repos.bare_path("storage-key"), ["symbolic-ref", "HEAD"])) ==
63
             "refs/heads/trunk"
64
65
    File.rm_rf!(Repos.bare_path("storage-key"))
66
    assert :ok = Sync.ensure_fresh("storage-key", "trunk")
67
    assert Repos.refs("storage-key") == refs
68
69
    assert String.trim(git_bare!(Repos.bare_path("storage-key"), ["show", "trunk:README.md"])) ==
70
             "durable import"
71
  end
72
73
  test "an authoritative empty ref map deletes stale local refs", %{root: root} do
74
    source = Path.join(root, "stale-source")
75
    File.mkdir_p!(source)
76
    git!(source, ["init", "--initial-branch=main"])
77
    git!(source, ["config", "user.email", "test@example.com"])
78
    git!(source, ["config", "user.name", "Forge test"])
79
    File.write!(Path.join(source, "stale.txt"), "stale\n")
80
    git!(source, ["add", "stale.txt"])
81
    git!(source, ["commit", "-m", "Stale commit"])
82
83
    Repos.ensure_repo!("empty-authority")
84
85
    {_output, 0} =
86
      Repos.git(Repos.bare_path("empty-authority"), [
87
        "fetch",
88
        source,
89
        "refs/heads/main:refs/heads/main"
90
      ])
91
92
    refute Repos.refs("empty-authority") == %{}
93
94
    assert :ok = Sync.replay_missing("empty-authority", WAL.new_index())
95
    assert Repos.refs("empty-authority") == %{}
96
  end
97
98
  defp git!(directory, args) do
99
    {output, 0} = System.cmd("git", args, cd: directory, stderr_to_stdout: true)
100
    output
101
  end
102
103
  defp git_bare!(directory, args) do
104
    {output, 0} = Repos.git(directory, args)
105
    output
106
  end
107
108
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
109
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
110
end
test/openagents/forge/targets_test.exs modified +10

@@ -17,6 +17,16 @@ defmodule OpenAgents.Forge.TargetsTest do

17 17
  alias OpenAgents.Forge.Target
18 18
  alias OpenAgents.Forge.Targets
19 19
20
  test "a hosted user repository is not deployable" do
21
    assert {:error, :repository_not_deployable} =
22
             Targets.promote(
23
               Ecto.UUID.generate(),
24
               String.duplicate("a", 40),
25
               "operator:test",
26
               commit_store: fn _repo, _sha -> :ok end
27
             )
28
  end
29
20 30
  @repo "OpenAgentsInc/openagents.com"
21 31
22 32
  # A SHA that is well-formed but not in any repo, so only the injected
test/openagents/repositories/provisioner_test.exs added +190

@@ -0,0 +1,190 @@

1
defmodule OpenAgents.Repositories.ProvisionerTest do
2
  use OpenAgents.DataCase, async: false
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.Forge.{Repos, WAL}
7
  alias OpenAgents.Repositories
8
  alias OpenAgents.Repositories.{Importer, Provisioner, ProvisioningOutbox, Repository}
9
10
  setup do
11
    root =
12
      Path.join(
13
        System.tmp_dir!(),
14
        "repository-provisioner-#{System.unique_integer([:positive, :monotonic])}"
15
      )
16
17
    previous_data = Application.get_env(:openagents, :forge_data_dir)
18
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
19
    previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)
20
21
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "data"))
22
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
23
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
24
25
    on_exit(fn ->
26
      restore_env(:forge_data_dir, previous_data)
27
      restore_env(:forge_wal_dir, previous_wal)
28
      restore_env(:forge_wal_adapter, previous_adapter)
29
      File.rm_rf!(root)
30
    end)
31
32
    :ok
33
  end
34
35
  test "drain provisions an empty repository once and restores its symbolic default branch" do
36
    user = repository_user_fixture("provisioner-owner")
37
38
    assert {:ok, repository, :created} =
39
             Repositories.create_user_repository(
40
               user,
41
               %{name: "durable-empty", default_branch: "trunk"},
42
               "provision-empty"
43
             )
44
45
    provisioner = start_supervised!({Provisioner, name: nil, poll_interval_ms: 60_000})
46
    assert {:ok, 1} = Provisioner.drain(provisioner)
47
    assert {:ok, 0} = Provisioner.drain(provisioner)
48
49
    ready = OpenAgents.Repo.get!(Repository, repository.id)
50
    outbox = OpenAgents.Repo.get_by!(ProvisioningOutbox, repository_id: repository.id)
51
52
    assert ready.lifecycle_state == "ready"
53
    assert ready.ready_at
54
    assert outbox.state == "completed"
55
    assert outbox.attempt_count == 1
56
57
    assert {:ok, _generation, %{"entries" => [], "refs" => %{}}} =
58
             WAL.read_index(repository.storage_key)
59
60
    assert String.trim(bare_git!(repository.storage_key, ["symbolic-ref", "HEAD"])) ==
61
             "refs/heads/trunk"
62
63
    File.rm_rf!(Repos.bare_path(repository.storage_key))
64
    assert :ok = OpenAgents.Forge.Sync.ensure_fresh(repository.storage_key, "trunk")
65
66
    assert String.trim(bare_git!(repository.storage_key, ["symbolic-ref", "HEAD"])) ==
67
             "refs/heads/trunk"
68
  end
69
70
  test "a stale running lease is reclaimed and an injected failure stays bounded" do
71
    user = repository_user_fixture("provisioner-recovery-owner")
72
73
    assert {:ok, repository, :created} =
74
             Repositories.create_user_repository(user, %{name: "recover-me"}, "recover-key")
75
76
    stale_claim = DateTime.add(DateTime.utc_now(), -600, :second)
77
78
    OpenAgents.Repo.get_by!(ProvisioningOutbox, repository_id: repository.id)
79
    |> Ecto.Changeset.change(state: "running", claimed_at: stale_claim, attempt_count: 1)
80
    |> OpenAgents.Repo.update!()
81
82
    assert :processed = Provisioner.run_once(fn _work -> {:error, :fixture_secret_failure} end)
83
84
    failed_outbox = OpenAgents.Repo.get_by!(ProvisioningOutbox, repository_id: repository.id)
85
    failed_repository = OpenAgents.Repo.get!(Repository, repository.id)
86
87
    assert failed_outbox.state == "failed"
88
    assert failed_outbox.attempt_count == 2
89
    assert failed_outbox.error_code == "provisioning_failed"
90
    assert failed_repository.lifecycle_state == "failed"
91
    assert failed_repository.provision_error_code == "provisioning_failed"
92
    refute inspect(failed_outbox) =~ "fixture_secret_failure"
93
  end
94
95
  test "a one-time import persists a bundle that reconstructs after cache loss", %{test: _test} do
96
    root = Application.fetch_env!(:openagents, :forge_data_dir) |> Path.dirname()
97
    source = Path.join(root, "github-source")
98
    File.mkdir_p!(source)
99
    git!(source, ["init", "--initial-branch=main"])
100
    git!(source, ["config", "user.email", "test@example.com"])
101
    git!(source, ["config", "user.name", "Import test"])
102
    File.write!(Path.join(source, "README.md"), "accepted snapshot\n")
103
    git!(source, ["add", "README.md"])
104
    git!(source, ["commit", "-m", "Accepted snapshot"])
105
    git!(source, ["branch", "release"])
106
    git!(source, ["tag", "v1"])
107
108
    sha = source |> git!(["rev-parse", "HEAD"]) |> String.trim()
109
110
    refs = %{
111
      "refs/heads/main" => sha,
112
      "refs/heads/release" => sha,
113
      "refs/tags/v1" => sha
114
    }
115
116
    user = repository_user_fixture("import-provisioner-owner")
117
118
    source_record = %{
119
      source_repository_id: 501,
120
      source_owner_id: user.github_id,
121
      source_full_name: "import-provisioner-owner/source",
122
      source_default_branch: "main",
123
      source_ref_digest: ref_digest(source, refs),
124
      source_head_sha: sha,
125
      source_refs: refs,
126
      source_uses_lfs: false
127
    }
128
129
    assert {:ok, repository, repository_import, :created} =
130
             Repositories.create_user_import(
131
               user,
132
               source_record,
133
               %{name: "imported-repository", default_branch: "main"},
134
               "import-provision-key"
135
             )
136
137
    assert :processed =
138
             Provisioner.run_once(fn work ->
139
               Importer.import(work.repository, source_url: source)
140
             end)
141
142
    completed_import =
143
      OpenAgents.Repo.get!(OpenAgents.Repositories.RepositoryImport, repository_import.id)
144
145
    assert completed_import.state == "completed"
146
    assert completed_import.completed_at
147
    assert {:ok, _generation, index} = WAL.read_index(repository.storage_key)
148
    assert [%{"format" => "git_bundle", "import_id" => import_id}] = WAL.entries(index)
149
    assert import_id == repository_import.id
150
    assert WAL.refs(index) == refs
151
152
    File.rm_rf!(Repos.bare_path(repository.storage_key))
153
    assert :ok = OpenAgents.Forge.Sync.ensure_fresh(repository.storage_key, "main")
154
    assert Repos.refs(repository.storage_key) == refs
155
156
    assert String.trim(bare_git!(repository.storage_key, ["show", "main:README.md"])) ==
157
             "accepted snapshot"
158
159
    File.write!(Path.join(source, "README.md"), "later source change\n")
160
    git!(source, ["commit", "-am", "Later source change"])
161
    assert :ok = OpenAgents.Forge.Sync.ensure_fresh(repository.storage_key, "main")
162
163
    assert String.trim(bare_git!(repository.storage_key, ["show", "main:README.md"])) ==
164
             "accepted snapshot"
165
  end
166
167
  defp bare_git!(storage_key, args) do
168
    {output, 0} = Repos.git(Repos.bare_path(storage_key), args)
169
    output
170
  end
171
172
  defp git!(directory, args) do
173
    {output, 0} = System.cmd("git", args, cd: directory, stderr_to_stdout: true)
174
    output
175
  end
176
177
  defp ref_digest(source, refs) do
178
    refs
179
    |> Enum.sort_by(&elem(&1, 0))
180
    |> Enum.map_join("\n", fn {name, sha} ->
181
      object_type = source |> git!(["cat-file", "-t", sha]) |> String.trim()
182
      Enum.join([name, object_type, sha], "\0")
183
    end)
184
    |> then(&:crypto.hash(:sha256, &1))
185
    |> Base.encode16(case: :lower)
186
  end
187
188
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
189
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
190
end
test/openagents/repository_lifecycle_test.exs added +191

@@ -0,0 +1,191 @@

1
defmodule OpenAgents.RepositoryLifecycleTest do
2
  use OpenAgents.DataCase
3
4
  import OpenAgents.AccountsFixtures
5
6
  alias OpenAgents.Repo
7
  alias OpenAgents.Repositories
8
  alias OpenAgents.Repositories.{Namespace, NamespaceAlias, ProvisioningOutbox, RepositoryImport}
9
10
  test "projects a GitHub user namespace by numeric account ID and retains a renamed slug alias" do
11
    user = repository_user_fixture("before-rename")
12
13
    assert {:ok, namespace} = Repositories.ensure_user_namespace(user)
14
    assert namespace.provider == "github"
15
    assert namespace.provider_account_id == user.github_id
16
    assert namespace.slug == "before-rename"
17
    assert namespace.kind == "user"
18
    assert namespace.owner_user_id == user.id
19
20
    assert {:ok, renamed} =
21
             Repositories.upsert_github_namespace(%{
22
               provider_account_id: user.github_id,
23
               slug: "after-rename",
24
               kind: "user",
25
               owner_user_id: user.id
26
             })
27
28
    assert renamed.id == namespace.id
29
    assert renamed.slug == "after-rename"
30
    assert Repositories.get_namespace_by_slug!("after-rename").id == namespace.id
31
    assert Repositories.get_namespace_by_slug!("before-rename").id == namespace.id
32
33
    assert Repo.get_by!(NamespaceAlias,
34
             namespace_id: namespace.id,
35
             slug_key: "before-rename"
36
           )
37
  end
38
39
  test "creates a ready-to-provision repository, owner membership, and outbox atomically" do
40
    user = repository_user_fixture("repo-owner")
41
42
    assert {:ok, repository, :created} =
43
             Repositories.create_user_repository(
44
               user,
45
               %{
46
                 name: "new-project",
47
                 description: "Created through the public repository contract",
48
                 visibility: "private",
49
                 default_branch: "main"
50
               },
51
               "create-key-1"
52
             )
53
54
    assert repository.owner == user.github_login
55
    assert repository.namespace.provider_account_id == user.github_id
56
    assert repository.lifecycle_state == "provisioning"
57
    assert repository.provisioning_kind == "empty"
58
    assert is_binary(repository.storage_key)
59
    assert Repositories.writable?(repository, user)
60
61
    repository_id = repository.id
62
63
    assert %ProvisioningOutbox{
64
             repository_id: ^repository_id,
65
             operation: "create",
66
             state: "pending"
67
           } = Repo.get_by!(ProvisioningOutbox, repository_id: repository.id)
68
  end
69
70
  test "replays the same idempotent create and rejects a different request" do
71
    user = repository_user_fixture("idempotent-owner")
72
    attrs = %{name: "same-project", visibility: "private", default_branch: "main"}
73
74
    assert {:ok, first, :created} =
75
             Repositories.create_user_repository(user, attrs, "create-key-2")
76
77
    assert {:ok, replayed, :replayed} =
78
             Repositories.create_user_repository(user, attrs, "create-key-2")
79
80
    assert replayed.id == first.id
81
82
    assert {:error, :idempotency_conflict} =
83
             Repositories.create_user_repository(
84
               user,
85
               %{attrs | name: "different-project"},
86
               "create-key-2"
87
             )
88
  end
89
90
  test "records a one-time GitHub import without a credential" do
91
    user = repository_user_fixture("import-owner")
92
93
    source = %{
94
      provider: "github",
95
      source_repository_id: 4_242,
96
      source_owner_id: user.github_id,
97
      source_full_name: "import-owner/source-project",
98
      source_default_branch: "main",
99
      source_ref_digest: String.duplicate("a", 64),
100
      source_head_sha: String.duplicate("b", 40),
101
      source_refs: %{
102
        "refs/heads/main" => String.duplicate("b", 40),
103
        "refs/tags/v1" => String.duplicate("c", 40)
104
      }
105
    }
106
107
    assert {:ok, repository, repository_import, :created} =
108
             Repositories.create_user_import(
109
               user,
110
               source,
111
               %{name: "source-project", visibility: "private"},
112
               "import-key-1"
113
             )
114
115
    assert repository.provisioning_kind == "github_import"
116
    assert repository_import.repository_id == repository.id
117
    assert repository_import.source_repository_id == 4_242
118
    assert repository_import.source_owner_id == user.github_id
119
    assert repository_import.source_ref_digest == String.duplicate("a", 64)
120
    assert repository_import.source_refs == source.source_refs
121
    assert repository_import.state == "pending"
122
123
    fields = RepositoryImport.__schema__(:fields)
124
    refute :token in fields
125
    refute :clone_url in fields
126
    refute :credential in fields
127
128
    assert %ProvisioningOutbox{operation: "github_import"} =
129
             Repo.get_by!(ProvisioningOutbox, repository_id: repository.id)
130
  end
131
132
  test "lists only repositories visible to the active user" do
133
    owner = repository_user_fixture("list-owner")
134
    viewer = repository_user_fixture("list-viewer")
135
    outsider = repository_user_fixture("list-outsider")
136
137
    assert {:ok, public_repository, :created} =
138
             Repositories.create_user_repository(
139
               owner,
140
               %{name: "public-project", visibility: "public"},
141
               "public-key"
142
             )
143
144
    assert {:ok, private_repository, :created} =
145
             Repositories.create_user_repository(
146
               owner,
147
               %{name: "private-project", visibility: "private"},
148
               "private-key"
149
             )
150
151
    assert {:ok, _membership} = Repositories.add_member(private_repository, viewer, "viewer")
152
153
    viewer_repository_ids = Enum.map(Repositories.list_visible_repositories(viewer), & &1.id)
154
    outsider_repository_ids = Enum.map(Repositories.list_visible_repositories(outsider), & &1.id)
155
156
    assert public_repository.id in viewer_repository_ids
157
    assert private_repository.id in viewer_repository_ids
158
    assert public_repository.id in outsider_repository_ids
159
    refute private_repository.id in outsider_repository_ids
160
  end
161
162
  test "an organization namespace retains GitHub identity separately from its slug" do
163
    assert {:ok, namespace} =
164
             Repositories.upsert_github_namespace(%{
165
               provider_account_id: 115_798_681,
166
               provider_node_id: "O_kgDOBubymQ",
167
               slug: "OpenAgentsInc",
168
               kind: "organization"
169
             })
170
171
    assert %Namespace{
172
             provider: "github",
173
             provider_account_id: 115_798_681,
174
             provider_node_id: "O_kgDOBubymQ",
175
             slug_key: "openagentsinc",
176
             kind: "organization",
177
             owner_user_id: nil
178
           } = namespace
179
  end
180
181
  test "reserved product paths cannot become repository namespaces" do
182
    assert {:error, changeset} =
183
             Repositories.upsert_github_namespace(%{
184
               provider_account_id: 9_999_991,
185
               slug: "repositories",
186
               kind: "organization"
187
             })
188
189
    assert {"is reserved", _metadata} = changeset.errors[:slug_key]
190
  end
191
end
test/openagents_web/auth_controller_test.exs modified +2 -2

@@ -43,7 +43,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

43 43
    refute user.github_token_ciphertext =~ "ephemeral-github-token"
44 44
    assert {:ok, "ephemeral-github-token"} = Accounts.github_token(user)
45 45
    assert user.github_token_key_id == "test-2026-08"
46
    assert user.github_token_scopes == ["repo"]
46
    assert user.github_token_scopes == ["repo", "read:org"]
47 47
    assert user.github_token_connected_at
48 48
49 49
    cookie = authenticated |> get_resp_header("set-cookie") |> Enum.join(";")

@@ -203,7 +203,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

203 203
    Req.Test.expect(__MODULE__, fn conn ->
204 204
      Req.Test.json(conn, %{
205 205
        "access_token" => "ephemeral-github-token",
206
        "scope" => "repo"
206
        "scope" => "repo,read:org"
207 207
      })
208 208
    end)
209 209
test/openagents_web/controllers/api_contract_controller_test.exs added +21

@@ -0,0 +1,21 @@

1
defmodule OpenAgentsWeb.ApiContractControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  test "repository contract bytes are public, versioned, and stable", %{conn: conn} do
5
    response = get(conn, ~p"/api/contracts/repositories-v1.json")
6
7
    assert response.status == 200
8
    assert get_resp_header(response, "content-type") == ["application/json; charset=utf-8"]
9
    assert get_resp_header(response, "cache-control") == ["public, max-age=300"]
10
11
    digest =
12
      response.resp_body
13
      |> then(&:crypto.hash(:sha256, &1))
14
      |> Base.encode16(case: :lower)
15
16
    assert get_resp_header(response, "etag") == [~s("#{digest}")]
17
    contract = Jason.decode!(response.resp_body)
18
    assert contract["contract"] == "openagents.repositories.v1"
19
    assert contract["version"] == 1
20
  end
21
end
test/openagents_web/controllers/data_controller_test.exs modified +1 -1

@@ -94,7 +94,7 @@ defmodule OpenAgentsWeb.DataControllerTest do

94 94
             "credential_exported" => false,
95 95
             "product_data_deletion" => "retained_until_explicit_disconnect",
96 96
             "rotated_at" => nil,
97
             "scopes" => ["repo"]
97
             "scopes" => ["repo", "read:org"]
98 98
           }
99 99
100 100
    refute inspect(export) =~ "gho_export_sentinel"
test/openagents_web/controllers/device_authorization_controller_test.exs added +120

@@ -0,0 +1,120 @@

1
defmodule OpenAgentsWeb.DeviceAuthorizationControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.DeviceAuthorizations.DeviceAuthorization
5
  alias OpenAgents.Repo
6
7
  test "a pending device authorization is digested and polling is paced", %{conn: conn} do
8
    created = post(conn, ~p"/api/v3/device/authorizations", %{})
9
10
    assert %{
11
             "device_code" => device_code,
12
             "user_code" => user_code,
13
             "verification_uri" => verification_uri,
14
             "verification_uri_complete" => verification_uri_complete,
15
             "expires_in" => expires_in,
16
             "interval" => interval
17
           } = json_response(created, 201)
18
19
    assert expires_in in 590..600
20
    assert interval == 5
21
    assert String.ends_with?(verification_uri, "/device")
22
    assert String.contains?(verification_uri_complete, URI.encode_www_form(user_code))
23
    assert get_resp_header(created, "cache-control") == ["no-store"]
24
25
    authorization = Repo.one!(DeviceAuthorization)
26
    refute authorization.device_code_digest == device_code
27
    refute authorization.user_code_digest == user_code
28
    refute inspect(authorization) =~ device_code
29
    refute inspect(authorization) =~ user_code
30
31
    pending =
32
      post(recycle(conn), ~p"/api/v3/device/authorizations/token", %{device_code: device_code})
33
34
    assert json_response(pending, 428) == %{"code" => "authorization_pending"}
35
36
    paced =
37
      post(recycle(conn), ~p"/api/v3/device/authorizations/token", %{device_code: device_code})
38
39
    assert json_response(paced, 429) == %{"code" => "slow_down"}
40
    assert get_resp_header(paced, "cache-control") == ["no-store"]
41
  end
42
43
  test "approval returns one PAT exactly once", %{conn: conn} do
44
    %{"device_code" => device_code, "user_code" => user_code} =
45
      conn
46
      |> post(~p"/api/v3/device/authorizations", %{})
47
      |> json_response(201)
48
49
    user = github_user("device-approval", "device-owner")
50
    assert {:ok, _authorization} = OpenAgents.DeviceAuthorizations.approve(user_code, user)
51
52
    claimed =
53
      conn
54
      |> recycle()
55
      |> post(~p"/api/v3/device/authorizations/token", %{device_code: device_code})
56
57
    assert %{
58
             "access_token" => "oa_pat_" <> _secret,
59
             "token_type" => "Bearer",
60
             "scope" => "forge:write",
61
             "expires_in" => expires_in
62
           } = json_response(claimed, 200)
63
64
    assert expires_in > 2_500_000
65
66
    repeated =
67
      conn
68
      |> recycle()
69
      |> post(~p"/api/v3/device/authorizations/token", %{device_code: device_code})
70
71
    assert json_response(repeated, 400) == %{"code" => "access_denied"}
72
73
    authorization = Repo.one!(DeviceAuthorization)
74
    assert authorization.state == "claimed"
75
    assert authorization.claimed_at
76
    assert authorization.api_token_id
77
  end
78
79
  test "unknown, denied, expired, and claimed codes share one refusal", %{conn: conn} do
80
    unknown =
81
      post(conn, ~p"/api/v3/device/authorizations/token", %{device_code: "unknown-device"})
82
83
    assert json_response(unknown, 400) == %{"code" => "access_denied"}
84
85
    %{"device_code" => denied_code, "user_code" => user_code} =
86
      conn
87
      |> recycle()
88
      |> post(~p"/api/v3/device/authorizations", %{})
89
      |> json_response(201)
90
91
    user = github_user("device-denial")
92
    assert {:ok, _authorization} = OpenAgents.DeviceAuthorizations.deny(user_code, user)
93
94
    denied =
95
      conn
96
      |> recycle()
97
      |> post(~p"/api/v3/device/authorizations/token", %{device_code: denied_code})
98
99
    assert json_response(denied, 400) == %{"code" => "access_denied"}
100
101
    %{"device_code" => expired_code} =
102
      conn
103
      |> recycle()
104
      |> post(~p"/api/v3/device/authorizations", %{})
105
      |> json_response(201)
106
107
    DeviceAuthorization
108
    |> Repo.all()
109
    |> Enum.find(&(&1.state == "pending"))
110
    |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
111
    |> Repo.update!()
112
113
    expired =
114
      conn
115
      |> recycle()
116
      |> post(~p"/api/v3/device/authorizations/token", %{device_code: expired_code})
117
118
    assert json_response(expired, 400) == %{"code" => "access_denied"}
119
  end
120
end
test/openagents_web/controllers/repository_controller_test.exs added +166

@@ -0,0 +1,166 @@

1
defmodule OpenAgentsWeb.RepositoryControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  alias OpenAgents.ApiTokens
5
  alias OpenAgents.Repositories
6
7
  test "POST /api/v3/user/repos creates in the authenticated GitHub namespace", %{conn: conn} do
8
    user = github_user("repository-api-create", "octavia")
9
10
    response =
11
      conn
12
      |> authorize(user)
13
      |> put_req_header("idempotency-key", "repo-create-1")
14
      |> post(~p"/api/v3/user/repos", %{
15
        name: "My-Project",
16
        description: "An API-created repository",
17
        private: true,
18
        default_branch: "trunk"
19
      })
20
21
    assert %{
22
             "id" => id,
23
             "name" => "my-project",
24
             "full_name" => "octavia/my-project",
25
             "private" => true,
26
             "visibility" => "private",
27
             "default_branch" => "trunk",
28
             "lifecycle_state" => "provisioning",
29
             "clone_url" => clone_url,
30
             "html_url" => html_url,
31
             "permissions" => %{"admin" => true, "pull" => true, "push" => true}
32
           } = json_response(response, 202)
33
34
    assert String.ends_with?(clone_url, "/git/octavia/my-project.git")
35
    assert String.ends_with?(html_url, "/octavia/my-project")
36
    assert Repositories.get_by_path!("octavia", "my-project").id == id
37
  end
38
39
  test "create requires a bounded idempotency key and rejects conflicting replay", %{conn: conn} do
40
    user = github_user("repository-api-idempotency")
41
42
    assert %{"code" => "invalid_idempotency_key"} =
43
             conn
44
             |> authorize(user)
45
             |> post(~p"/api/v3/user/repos", %{name: "missing-key"})
46
             |> json_response(400)
47
48
    first =
49
      conn
50
      |> authorize(user)
51
      |> put_req_header("idempotency-key", "same-key")
52
      |> post(~p"/api/v3/user/repos", %{name: "same-request"})
53
54
    replayed =
55
      conn
56
      |> authorize(user)
57
      |> put_req_header("idempotency-key", "same-key")
58
      |> post(~p"/api/v3/user/repos", %{name: "same-request"})
59
60
    assert json_response(first, 202)["id"] == json_response(replayed, 202)["id"]
61
62
    assert %{"code" => "idempotency_conflict"} =
63
             conn
64
             |> authorize(user)
65
             |> put_req_header("idempotency-key", "same-key")
66
             |> post(~p"/api/v3/user/repos", %{name: "different-request"})
67
             |> json_response(409)
68
  end
69
70
  test "GET repository permits public reads and conceals private repositories", %{conn: conn} do
71
    owner = github_user("repository-api-visibility-owner", "visible-owner")
72
    viewer = github_user("repository-api-visibility-viewer")
73
74
    {:ok, public_repository, :created} =
75
      Repositories.create_user_repository(
76
        owner,
77
        %{name: "public-repo", visibility: "public"},
78
        "public-create"
79
      )
80
81
    {:ok, private_repository, :created} =
82
      Repositories.create_user_repository(
83
        owner,
84
        %{name: "private-repo", visibility: "private"},
85
        "private-create"
86
      )
87
88
    mark_ready(public_repository)
89
    mark_ready(private_repository)
90
91
    assert %{"id" => public_id, "permissions" => %{"pull" => true, "push" => false}} =
92
             conn
93
             |> get(~p"/api/v3/repos/visible-owner/public-repo")
94
             |> json_response(200)
95
96
    assert public_id == public_repository.id
97
98
    assert conn
99
           |> get(~p"/api/v3/repos/visible-owner/private-repo")
100
           |> json_response(404)
101
102
    assert conn
103
           |> authorize(viewer)
104
           |> get(~p"/api/v3/repos/visible-owner/private-repo")
105
           |> json_response(404)
106
107
    {:ok, _membership} = Repositories.add_member(private_repository, viewer, "viewer")
108
109
    assert %{"id" => private_id, "permissions" => %{"pull" => true, "push" => false}} =
110
             conn
111
             |> authorize(viewer)
112
             |> get(~p"/api/v3/repos/visible-owner/private-repo")
113
             |> json_response(200)
114
115
    assert private_id == private_repository.id
116
  end
117
118
  test "GET /api/v3/user/repos returns a bounded visible list", %{conn: conn} do
119
    user = github_user("repository-api-list", "repo-list-owner")
120
121
    Enum.each(1..3, fn number ->
122
      assert {:ok, _repository, :created} =
123
               Repositories.create_user_repository(
124
                 user,
125
                 %{name: "project-#{number}"},
126
                 "list-key-#{number}"
127
               )
128
    end)
129
130
    first =
131
      conn
132
      |> authorize(user)
133
      |> get(~p"/api/v3/user/repos?per_page=2")
134
      |> json_response(200)
135
136
    assert length(first["repositories"]) == 2
137
    assert is_binary(first["next_cursor"])
138
139
    second =
140
      conn
141
      |> authorize(user)
142
      |> get(~p"/api/v3/user/repos?per_page=2&after=#{first["next_cursor"]}")
143
      |> json_response(200)
144
145
    assert MapSet.disjoint?(
146
             MapSet.new(Enum.map(first["repositories"], & &1["id"])),
147
             MapSet.new(Enum.map(second["repositories"], & &1["id"]))
148
           )
149
  end
150
151
  defp authorize(conn, user) do
152
    {:ok, _credential, plaintext} =
153
      ApiTokens.create(user, %{name: "repository API test", scopes: ["forge:write"]})
154
155
    put_req_header(conn, "authorization", "Bearer " <> plaintext)
156
  end
157
158
  defp mark_ready(repository) do
159
    repository
160
    |> Ecto.Changeset.change(
161
      lifecycle_state: "ready",
162
      ready_at: DateTime.utc_now()
163
    )
164
    |> OpenAgents.Repo.update!()
165
  end
166
end
test/openagents_web/controllers/repository_import_controller_test.exs added +165

@@ -0,0 +1,165 @@

1
defmodule OpenAgentsWeb.RepositoryImportControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.{Accounts, ApiTokens, Repo}
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 "POST /api/v3/user/repos/imports accepts one frozen GitHub snapshot", %{conn: conn} do
21
    user = github_user("repository-import-api", "octavia")
22
    assert {:ok, user} = Accounts.store_github_token(user, "gho_import_fixture")
23
    main_sha = String.duplicate("a", 40)
24
    tag_sha = String.duplicate("b", 40)
25
26
    expect_import_source(user, main_sha, tag_sha)
27
28
    response =
29
      conn
30
      |> authorize(user)
31
      |> put_req_header("idempotency-key", "import-key-1")
32
      |> post(~p"/api/v3/user/repos/imports", %{
33
        source: %{provider: "github", repository: "octavia/source-project"},
34
        name: "copied-project",
35
        private: true
36
      })
37
38
    assert %{
39
             "name" => "copied-project",
40
             "lifecycle_state" => "provisioning",
41
             "import" => %{
42
               "id" => import_id,
43
               "provider" => "github",
44
               "source_full_name" => "octavia/source-project",
45
               "source_head_sha" => ^main_sha,
46
               "state" => "pending",
47
               "lfs_warning" => true
48
             }
49
           } = json_response(response, 202)
50
51
    repository_import = Repo.get!(OpenAgents.Repositories.RepositoryImport, import_id)
52
53
    assert repository_import.source_refs == %{
54
             "refs/heads/main" => main_sha,
55
             "refs/tags/v1.0.0" => tag_sha
56
           }
57
58
    refute Map.has_key?(repository_import.source_refs, "credential")
59
60
    status =
61
      conn
62
      |> authorize(user)
63
      |> get(~p"/api/v3/repository-imports/#{import_id}")
64
65
    assert json_response(status, 200)["import"]["id"] == import_id
66
67
    other_user = github_user("repository-import-other")
68
69
    assert conn
70
           |> authorize(other_user)
71
           |> get(~p"/api/v3/repository-imports/#{import_id}")
72
           |> json_response(404)
73
  end
74
75
  test "organization creation requires an active GitHub administrator membership", %{conn: conn} do
76
    user = github_user("repository-org-api")
77
    assert {:ok, user} = Accounts.store_github_token(user, "gho_org_fixture")
78
79
    Req.Test.expect(__MODULE__, fn github_conn ->
80
      assert github_conn.request_path == "/user/memberships/orgs"
81
82
      Req.Test.json(github_conn, [
83
        %{
84
          "state" => "active",
85
          "role" => "admin",
86
          "organization" => %{
87
            "id" => 42,
88
            "node_id" => "O_42",
89
            "login" => "acme",
90
            "avatar_url" => "https://avatars.githubusercontent.com/u/42?v=4"
91
          }
92
        }
93
      ])
94
    end)
95
96
    created =
97
      conn
98
      |> authorize(user)
99
      |> put_req_header("idempotency-key", "org-create-key")
100
      |> post(~p"/api/v3/orgs/acme/repos", %{name: "org-project", private: false})
101
102
    assert %{
103
             "full_name" => "acme/org-project",
104
             "private" => false,
105
             "owner" => %{"login" => "acme", "type" => "Organization"}
106
           } = json_response(created, 202)
107
  end
108
109
  defp expect_import_source(user, main_sha, tag_sha) do
110
    Req.Test.expect(__MODULE__, fn github_conn ->
111
      assert github_conn.request_path == "/repos/octavia/source-project"
112
113
      Req.Test.json(github_conn, %{
114
        "id" => 501,
115
        "node_id" => "R_501",
116
        "name" => "source-project",
117
        "full_name" => "octavia/source-project",
118
        "private" => true,
119
        "default_branch" => "main",
120
        "owner" => %{
121
          "id" => user.github_id,
122
          "node_id" => "U_#{user.github_id}",
123
          "login" => "octavia",
124
          "avatar_url" => "https://avatars.githubusercontent.com/u/#{user.github_id}?v=4",
125
          "type" => "User"
126
        },
127
        "permissions" => %{"pull" => true, "push" => false, "admin" => false}
128
      })
129
    end)
130
131
    Req.Test.expect(__MODULE__, fn github_conn ->
132
      assert github_conn.request_path ==
133
               "/repos/octavia/source-project/git/matching-refs/heads/"
134
135
      Req.Test.json(github_conn, [
136
        %{"ref" => "refs/heads/main", "object" => %{"type" => "commit", "sha" => main_sha}}
137
      ])
138
    end)
139
140
    Req.Test.expect(__MODULE__, fn github_conn ->
141
      assert github_conn.request_path ==
142
               "/repos/octavia/source-project/git/matching-refs/tags/"
143
144
      Req.Test.json(github_conn, [
145
        %{"ref" => "refs/tags/v1.0.0", "object" => %{"type" => "tag", "sha" => tag_sha}}
146
      ])
147
    end)
148
149
    Req.Test.expect(__MODULE__, fn github_conn ->
150
      assert github_conn.request_path == "/repos/octavia/source-project/git/trees/main"
151
152
      Req.Test.json(github_conn, %{
153
        "truncated" => false,
154
        "tree" => [%{"path" => ".gitattributes", "type" => "blob", "size" => 200}]
155
      })
156
    end)
157
  end
158
159
  defp authorize(conn, user) do
160
    {:ok, _credential, plaintext} =
161
      ApiTokens.create(user, %{name: "repository import API test", scopes: ["forge:write"]})
162
163
    put_req_header(conn, "authorization", "Bearer " <> plaintext)
164
  end
165
end
test/openagents_web/live/code_live_test.exs modified +57 -5

@@ -187,11 +187,14 @@ defmodule OpenAgentsWeb.CodeLiveTest do

187 187
      browsable()
188 188
      # The GitHub-identical URL works...
189 189
      {:ok, _view, _html} = live(conn, "/OpenAgentsInc/openagents.com")
190
      # ...and no other account segment is even routed (no wildcard owner), so
191
      # a two-segment path that is not ours 404s without reaching a LiveView.
192
      assert conn |> get("/someoneelse/demo") |> Map.fetch!(:status) == 404
193
      # The old /code/* shape is gone.
194
      assert conn |> get("/code/demo") |> Map.fetch!(:status) == 404
190
      # ...and a different namespace reaches the database-backed resolver but
191
      # remains concealed as the same public not-found result.
192
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
193
        live(conn, "/someoneelse/demo")
194
      end
195
196
      # The old /code/* shape resolves as an unknown namespace and stays hidden.
197
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn -> live(conn, "/code/demo") end
195 198
    end
196 199
197 200
    test "a dark repo and an unknown repo 404 indistinguishably", %{conn: conn} do

@@ -200,6 +203,55 @@ defmodule OpenAgentsWeb.CodeLiveTest do

200 203
    end
201 204
  end
202 205
206
  describe "database-backed hosted repository pages" do
207
    test "an empty ready public repository renders clone instructions", %{conn: conn} do
208
      user = github_user("empty-hosted-repository", "hosted-owner")
209
210
      assert {:ok, repository, :created} =
211
               OpenAgents.Repositories.create_user_repository(
212
                 user,
213
                 %{name: "empty-repository", visibility: "public", default_branch: "trunk"},
214
                 "empty-hosted-repository"
215
               )
216
217
      repository
218
      |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
219
      |> Repo.update!()
220
221
      Repos.ensure_repo!(repository.storage_key, "trunk")
222
223
      {:ok, view, _html} = live(conn, "/hosted-owner/empty-repository")
224
225
      assert has_element?(view, "#repo-empty")
226
      assert has_element?(view, "#repo-clone")
227
      refute has_element?(view, "#repo-commits")
228
    end
229
230
    test "a private repository is visible only to a member session", %{conn: conn} do
231
      owner = github_user("private-hosted-owner", "private-hosted-owner")
232
      outsider = github_user("private-hosted-outsider", "private-hosted-outsider")
233
234
      assert {:ok, repository, :created} =
235
               OpenAgents.Repositories.create_user_repository(
236
                 owner,
237
                 %{name: "private-repository", visibility: "private"},
238
                 "private-hosted-repository"
239
               )
240
241
      member_conn = Plug.Test.init_test_session(conn, %{"user_id" => owner.id})
242
      {:ok, view, _html} = live(member_conn, "/private-hosted-owner/private-repository")
243
      assert has_element?(view, "#repo-provisioning")
244
245
      outsider_conn = Plug.Test.init_test_session(recycle(conn), %{"user_id" => outsider.id})
246
247
      assert_raise OpenAgentsWeb.PublicNotFoundError, fn ->
248
        live(outsider_conn, "/private-hosted-owner/private-repository")
249
      end
250
251
      assert repository.lifecycle_state == "provisioning"
252
    end
253
  end
254
203 255
  describe "/code/:repo/blob/:ref/*path" do
204 256
    test "renders markdown as HTML, not source", %{conn: conn} do
205 257
      {:ok, _view, html} = live(conn, "/OpenAgentsInc/openagents.com/blob/main/docs/audit.md")
test/openagents_web/live/device_authorization_live_test.exs added +28

@@ -0,0 +1,28 @@

1
defmodule OpenAgentsWeb.DeviceAuthorizationLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
6
  test "an authenticated user reviews and approves a matching terminal code", %{conn: conn} do
7
    user = github_user("device-live-approval", "device-live-owner")
8
    {:ok, _authorization, _device_code, user_code} = OpenAgents.DeviceAuthorizations.create()
9
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
10
11
    {:ok, view, _html} = live(conn, ~p"/device?user_code=#{user_code}")
12
13
    assert has_element?(view, "#device-authorization-review")
14
    assert has_element?(view, "#approve-device")
15
    refute has_element?(view, "#device-code-invalid")
16
17
    view |> element("#approve-device") |> render_click()
18
19
    assert has_element?(view, "#device-approved")
20
    refute has_element?(view, "#device-authorization-review")
21
  end
22
23
  test "a signed-out browser cannot approve a device code", %{conn: conn} do
24
    {:ok, _authorization, _device_code, user_code} = OpenAgents.DeviceAuthorizations.create()
25
26
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/device?user_code=#{user_code}")
27
  end
28
end
test/openagents_web/live/repository_live_test.exs added +153

@@ -0,0 +1,153 @@

1
defmodule OpenAgentsWeb.RepositoryLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.{Accounts, Repo, Repositories}
7
  alias OpenAgents.Repositories.RepositoryImport
8
9
  setup {Req.Test, :verify_on_exit!}
10
11
  setup do
12
    original = Application.get_env(:openagents, :github_api)
13
14
    Application.put_env(:openagents, :github_api,
15
      base_url: "https://github-api.internal",
16
      request_options: [plug: {Req.Test, __MODULE__}]
17
    )
18
19
    on_exit(fn -> Application.put_env(:openagents, :github_api, original) end)
20
    :ok
21
  end
22
23
  test "repository index uses a scoped stream and shows lifecycle state", %{conn: conn} do
24
    user = github_user("repository-live-index", "index-owner")
25
26
    assert {:ok, repository, :created} =
27
             Repositories.create_user_repository(
28
               user,
29
               %{name: "streamed-repository", visibility: "private"},
30
               "streamed-repository"
31
             )
32
33
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
34
35
    assert has_element?(view, "#repository-index")
36
    assert has_element?(view, "#repositories-#{repository.id}")
37
    assert has_element?(view, "#new-repository")
38
    assert has_element?(view, "#import-repository")
39
  end
40
41
  test "new repository defaults private and normalizes its name", %{conn: conn} do
42
    user = github_user("repository-live-create", "create-owner")
43
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories/new")
44
45
    assert has_element?(view, "#repository-form")
46
    assert has_element?(view, "#repository_private[type=checkbox]")
47
48
    view
49
    |> form("#repository-form", %{
50
      "repository" => %{
51
        "namespace" => "create-owner",
52
        "name" => "My.Project",
53
        "description" => "Created in the browser",
54
        "default_branch" => "trunk",
55
        "private" => "true"
56
      }
57
    })
58
    |> render_submit()
59
60
    assert_redirect(view, ~p"/repositories")
61
62
    repository = Repositories.get_by_path!("create-owner", "my.project")
63
    assert repository.name == "my.project"
64
    assert repository.visibility == "private"
65
    assert repository.default_branch == "trunk"
66
    assert repository.lifecycle_state == "provisioning"
67
  end
68
69
  test "one-time import picker creates an immutable GitHub import receipt", %{conn: conn} do
70
    user = github_user("repository-live-import", "import-owner")
71
    assert {:ok, user} = Accounts.store_github_token(user, "gho_live_import")
72
    main_sha = String.duplicate("a", 40)
73
74
    Req.Test.stub(__MODULE__, &github_import_response(&1, user, main_sha))
75
76
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories/import/github")
77
78
    assert has_element?(view, "#one-time-import")
79
    assert has_element?(view, "#repository-import-form")
80
    assert has_element?(view, "#lfs-import-warning")
81
82
    view
83
    |> form("#repository-import-form", %{
84
      "repository_import" => %{
85
        "source" => "import-owner/source-project",
86
        "name" => "copied-project",
87
        "private" => "true"
88
      }
89
    })
90
    |> render_submit()
91
92
    assert_redirect(view, ~p"/repositories")
93
    repository = Repositories.get_by_path!("import-owner", "copied-project")
94
    repository_import = Repo.get_by!(RepositoryImport, repository_id: repository.id)
95
96
    assert repository.provisioning_kind == "github_import"
97
    assert repository_import.source_full_name == "import-owner/source-project"
98
    assert repository_import.source_head_sha == main_sha
99
    assert repository_import.source_refs == %{"refs/heads/main" => main_sha}
100
  end
101
102
  defp log_in(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
103
104
  defp repository_payload(user, main_sha) do
105
    %{
106
      "id" => 901,
107
      "node_id" => "R_901",
108
      "name" => "source-project",
109
      "full_name" => "import-owner/source-project",
110
      "private" => true,
111
      "fork" => false,
112
      "archived" => false,
113
      "default_branch" => "main",
114
      "description" => "Source repository",
115
      "language" => "Elixir",
116
      "pushed_at" => "2026-08-20T12:00:00Z",
117
      "size" => 20,
118
      "owner" => %{
119
        "id" => user.github_id,
120
        "node_id" => "U_#{user.github_id}",
121
        "login" => "import-owner",
122
        "avatar_url" => "https://avatars.githubusercontent.com/u/#{user.github_id}?v=4",
123
        "type" => "User"
124
      },
125
      "permissions" => %{"pull" => true, "push" => true, "admin" => true},
126
      "sha" => main_sha
127
    }
128
  end
129
130
  defp github_import_response(github_conn, user, main_sha) do
131
    case github_conn.request_path do
132
      "/user/memberships/orgs" ->
133
        Req.Test.json(github_conn, [])
134
135
      "/user/repos" ->
136
        Req.Test.json(github_conn, [repository_payload(user, main_sha)])
137
138
      "/repos/import-owner/source-project" ->
139
        Req.Test.json(github_conn, repository_payload(user, main_sha))
140
141
      "/repos/import-owner/source-project/git/matching-refs/heads/" ->
142
        Req.Test.json(github_conn, [
143
          %{"ref" => "refs/heads/main", "object" => %{"type" => "commit", "sha" => main_sha}}
144
        ])
145
146
      "/repos/import-owner/source-project/git/matching-refs/tags/" ->
147
        Req.Test.json(github_conn, [])
148
149
      "/repos/import-owner/source-project/git/trees/main" ->
150
        Req.Test.json(github_conn, %{"truncated" => false, "tree" => []})
151
    end
152
  end
153
end
test/openagents_web/route_authority_test.exs modified +27

@@ -49,6 +49,33 @@ 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
53
    repository_list = route!(:get, "/api/v3/user/repos")
54
    import_status = route!(:get, "/api/v3/repository-imports/:id")
55
    repository_view = route!(:get, "/api/v3/repos/:owner/:repo")
56
57
    assert repository_list.class == :authenticated_api
58
    assert repository_list.scope == "forge:read"
59
    assert import_status.class == :authenticated_api
60
    assert import_status.scope == "forge:read"
61
    assert repository_view.class == :public_read
62
    assert repository_view.principal == "anonymous or first-party bearer token"
63
64
    assert Phoenix.Router.route_info(
65
             OpenAgentsWeb.Router,
66
             "GET",
67
             "/api/v3/user/repos",
68
             "stage.openagents.com"
69
           ).pipe_through == [:forge_write_api]
70
71
    assert Phoenix.Router.route_info(
72
             OpenAgentsWeb.Router,
73
             "GET",
74
             "/api/v3/repos/octavia/project",
75
             "stage.openagents.com"
76
           ).pipe_through == [:optional_forge_api]
77
  end
78
52 79
  test "operator and machine surfaces cannot drift into browser or public classes" do
53 80
    assert route!(:get, "/admin").class == :operator
54 81
    assert route!(:get, "/admin/forge").scope == "forge:promote"

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