Add repository deletion and repository-aware navigation

46e86d51f709 · AtlantisPleb · · parent abda46ab664e

Add repository deletion and repository-aware navigation

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified config/test.exs
  • modified docs/openagents-cli/command-reference.md
  • added docs/openagents-cli/delete-repository.md
  • modified docs/openagents-cli/index.md
  • modified docs/repository-creation-and-openagents-cli-spec.md
  • modified docs/repository-creation-cli-implementation-roadmap.md
  • modified lib/openagents/accounts/user.ex
  • modified lib/openagents/forge/repos.ex
  • modified lib/openagents/forge/wal.ex
  • modified lib/openagents/forge/wal/gcs.ex
  • modified lib/openagents/forge/wal/local.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/controllers/repository_controller.ex
  • modified lib/openagents_web/docs_catalog.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/router.ex
  • modified lib/openagents_web/user_auth.ex
  • modified priv/api-contracts/repositories-v1.json
  • modified priv/docs/cli-command-reference.md
  • added priv/docs/delete-repository.md
  • modified priv/docs/openagents-cli.md
  • modified priv/docs/rest-api.md
  • modified test/openagents/forge/wal_test.exs
  • modified test/openagents_web/controllers/repository_controller_test.exs
  • modified test/openagents_web/home_controller_test.exs
  • modified test/openagents_web/live/code_live_test.exs
  • modified test/openagents_web/sidebar_state_test.exs

Diff

29 files changed, +737 -23

config/test.exs modified +2

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

1 1
import Config
2 2
3
config :openagents, :docs_source_dir, Path.expand("../priv/docs", __DIR__)
4
3 5
config :openagents, :repository_provisioner_enabled, false
4 6
5 7
config :openagents, :runtime_environment, :test
docs/openagents-cli/command-reference.md modified +12

@@ -118,6 +118,18 @@ openagents repo clone --repo OWNER/REPOSITORY [DIRECTORY]

118 118
119 119
The CLI retrieves the clone URL from the API and starts standard Git.
120 120
121
### `repo delete`
122
123
```text
124
openagents repo delete [OWNER/REPOSITORY] --yes
125
openagents repo delete --repo OWNER/REPOSITORY --yes
126
```
127
128
The command permanently deletes a repository you own, including its Git
129
history, issues, projects, and import records. You must pass `--yes`. When you
130
omit the repository, the CLI infers it from an exact OpenAgents `origin`
131
remote on the selected API origin.
132
121 133
## JSON and noninteractive use
122 134
123 135
With `--json`, stdout contains machine-readable output. Human progress and
docs/openagents-cli/delete-repository.md added +36

@@ -0,0 +1,36 @@

1
# Delete a repository
2
3
You can permanently delete a repository you own from the website, REST API,
4
or CLI. Maintainers, contributors, and viewers cannot delete a repository.
5
6
Deletion removes the Git history, issues, projects, labels, milestones,
7
memberships, import receipt, and provisioning records. You cannot undo it.
8
9
## Delete from the website
10
11
1. Open the repository.
12
2. Find **Delete repository** near the bottom of the **Code** page.
13
3. Type the full `OWNER/REPOSITORY` name shown in the confirmation field.
14
4. Select **Delete repository**.
15
16
## Delete with the CLI
17
18
```sh
19
openagents repo delete OWNER/REPOSITORY --yes
20
```
21
22
You can run the same command through `npx`:
23
24
```sh
25
npx --yes @openagentsinc/cli@latest repo delete OWNER/REPOSITORY --yes
26
```
27
28
## Delete with the REST API
29
30
```sh
31
curl --request DELETE \
32
  --header "Authorization: Bearer $OPENAGENTS_TOKEN" \
33
  https://openagents.com/api/v3/repos/OWNER/REPOSITORY
34
```
35
36
Success returns `204 No Content`.
docs/openagents-cli/index.md modified +6 -2

@@ -31,11 +31,12 @@ The first release lets you:

31 31
- Import a GitHub repository as a one-time copy.
32 32
- List and inspect repositories that you can access.
33 33
- Clone repositories and configure Git authentication.
34
- Delete a repository you own with explicit confirmation.
34 35
- Push, pull, and fetch through Git smart HTTP.
35 36
- Use JSON output and stable exit codes in scripts and agents.
36 37
37
Pull requests, repository deletion, continuous GitHub mirroring, SSH transport,
38
rulesets, and self-update are not part of this release.
38
Pull requests, continuous GitHub mirroring, SSH transport, rulesets, and
39
self-update are not part of this release.
39 40
40 41
## Namespaces and access
41 42

@@ -77,6 +78,8 @@ access.

77 78
- Select **Import from GitHub** to copy one GitHub repository.
78 79
- Open a repository to see its clone URL, lifecycle state, Issues, Projects,
79 80
  code, and import receipt when applicable.
81
- Open a repository's **Delete repository** section to permanently delete a
82
  repository you own.
80 83
81 84
## Next steps
82 85

@@ -84,4 +87,5 @@ access.

84 87
- [Create a repository](create-repository.md)
85 88
- [Import a GitHub repository](import-github.md)
86 89
- [Clone, push, and pull](git.md)
90
- [Delete a repository](delete-repository.md)
87 91
- [CLI command reference](command-reference.md)
docs/repository-creation-and-openagents-cli-spec.md modified +5 -1

@@ -828,6 +828,7 @@ openagents repo import <github-owner>/<github-repo>

828 828
openagents repo list
829 829
openagents repo view [<owner>/<name>]
830 830
openagents repo clone <owner>/<name> [<directory>]
831
openagents repo delete [<owner>/<name>] --yes
831 832
```
832 833
833 834
`repo create <name>` targets the authenticated user's GitHub user namespace.

@@ -888,6 +889,10 @@ Import rules:

888 889
`origin` remote when no argument is present. `-R, --repo <owner>/<name>`
889 890
overrides inference.
890 891
892
`repo delete` requires repository-owner authority and an explicit `--yes`.
893
Success permanently removes the repository row, its dependent issue and
894
project records, the durable WAL prefix, and connected-node bare caches.
895
891 896
Remote inference accepts only clone URLs returned by an admitted OpenAgents
892 897
endpoint. It must not treat an arbitrary path that resembles `owner/name` as an
893 898
authenticated OpenAgents repository.

@@ -897,7 +902,6 @@ authenticated OpenAgents repository.

897 902
Reserve these names without shipping placeholder commands:
898 903
899 904
```text
900
openagents repo delete
901 905
openagents repo mirror
902 906
openagents pr ...
903 907
openagents ruleset ...
docs/repository-creation-cli-implementation-roadmap.md modified +6 -4

@@ -397,12 +397,14 @@ headless behavior, token stdin, JSON output, signals, and secret tripwires.

397 397
   visibility, timeout, source head receipt, and no-sync notice.
398 398
3. Implement cursor-based `repo list` and `repo view`.
399 399
4. Implement `repo clone` using the server-provided clone URL.
400
5. Implement safe repository inference from an admitted remote and explicit
400
5. Implement owner-only `repo delete` with explicit confirmation and storage
401
   cleanup.
402
6. Implement safe repository inference from an admitted remote and explicit
401 403
   `-R` override.
402
6. Refuse to overwrite an existing unrelated remote.
403
7. Execute Git with argv arrays, bounded captured output, cancellation, and no
404
7. Refuse to overwrite an existing unrelated remote.
405
8. Execute Git with argv arrays, bounded captured output, cancellation, and no
404 406
   credential in the remote URL.
405
8. Keep human progress on stderr when JSON owns stdout.
407
9. Keep human progress on stderr when JSON owns stdout.
406 408
407 409
**Tests:** all command options, mutual exclusions, error and exit-code mapping,
408 410
idempotent retry, timeout without server cancellation, remote inference, remote
lib/openagents/accounts/user.ex modified +2

@@ -30,6 +30,7 @@ defmodule OpenAgents.Accounts.User do

30 30
    # be a query each time. Defaults to false, so a user loaded by any other
31 31
    # path is treated as new rather than accidentally grandfathered.
32 32
    field :agent_surfaces?, :boolean, virtual: true, default: false
33
    field :sidebar_repository_path, :string, virtual: true
33 34
34 35
    has_one :storage_owner, OpenAgents.Conversations.Visitor
35 36

@@ -54,6 +55,7 @@ defmodule OpenAgents.Accounts.User do

54 55
          public_leaderboard_opted_out: boolean(),
55 56
          browser_key_hash: binary() | nil,
56 57
          agent_surfaces?: boolean(),
58
          sidebar_repository_path: String.t() | nil,
57 59
          inserted_at: DateTime.t(),
58 60
          updated_at: DateTime.t()
59 61
        }
lib/openagents/forge/repos.ex modified +12

@@ -37,6 +37,18 @@ defmodule OpenAgents.Forge.Repos do

37 37
  @doc "Absolute path of the bare repository for `repo`."
38 38
  def bare_path(repo), do: Path.join([data_dir(), "repos", repo <> ".git"])
39 39
40
  @doc "Delete one repository's disposable local bare-repository cache."
41
  def delete_repo(storage_key) do
42
    if valid_storage_key?(storage_key) do
43
      case File.rm_rf(bare_path(storage_key)) do
44
        {:ok, _removed} -> :ok
45
        {:error, reason, _path} -> {:error, reason}
46
      end
47
    else
48
      {:error, :invalid_storage_key}
49
    end
50
  end
51
40 52
  @doc "Initialize the bare repository if absent. Returns the path."
41 53
  def ensure_repo!(repo, default_branch \\ "main") do
42 54
    path = bare_path(repo)
lib/openagents/forge/wal.ex modified +7

@@ -51,6 +51,7 @@ defmodule OpenAgents.Forge.WAL do

51 51
              :ok | {:error, term}
52 52
  @callback put_object(repo, object_key :: String.t(), payload :: binary()) ::
53 53
              {:ok, String.t()} | {:error, term}
54
  @callback delete_repo(repo) :: :ok | {:error, term}
54 55
55 56
  @repo_pattern ~r/^[a-z0-9](?:[a-z0-9_-]|\.(?=[a-z0-9]))*$/
56 57
  @entry_key_pattern ~r/^entries\/[0-9]{8}-[0-9a-f]{12}$/

@@ -135,6 +136,12 @@ defmodule OpenAgents.Forge.WAL do

135 136
    end
136 137
  end
137 138
139
  @doc "Delete every WAL object for one repository. This operation is idempotent."
140
  @spec delete_repo(repo) :: :ok | {:error, term}
141
  def delete_repo(repo) do
142
    with :ok <- validate_repo(repo), do: adapter().delete_repo(repo)
143
  end
144
138 145
  @doc """
139 146
  Store a named artifact blob alongside the WAL (P6, #123): built beam tars
140 147
  land here so a replaced node — whose local artifact cache is empty — can
lib/openagents/forge/wal/gcs.ex modified +61

@@ -145,6 +145,14 @@ defmodule OpenAgents.Forge.WAL.Gcs do

145 145
    end
146 146
  end
147 147
148
  @impl WAL
149
  def delete_repo(repo) do
150
    with {:ok, bucket} <- bucket(),
151
         {:ok, token} <- token() do
152
      delete_prefix(bucket, prefix(repo), token)
153
    end
154
  end
155
148 156
  ## Object naming (public so it is testable without a live bucket)
149 157
150 158
  @doc """

@@ -167,6 +175,59 @@ defmodule OpenAgents.Forge.WAL.Gcs do

167 175
168 176
  ## Internal
169 177
178
  defp delete_prefix(bucket, object_prefix, token) do
179
    with {:ok, names} <- list_objects(bucket, object_prefix, token),
180
         :ok <- delete_objects(bucket, names, token) do
181
      if names == [], do: :ok, else: delete_prefix(bucket, object_prefix, token)
182
    end
183
  end
184
185
  defp list_objects(bucket, object_prefix, token) do
186
    url =
187
      @storage_base <>
188
        "/storage/v1/b/#{URI.encode_www_form(bucket)}/o?" <>
189
        URI.encode_query(prefix: object_prefix, maxResults: 1_000, fields: "items(name)")
190
191
    case Req.get(url, headers: auth_headers(token)) do
192
      {:ok, %Req.Response{status: 200, body: %{"items" => items}}} when is_list(items) ->
193
        {:ok, Enum.map(items, &Map.fetch!(&1, "name"))}
194
195
      {:ok, %Req.Response{status: 200}} ->
196
        {:ok, []}
197
198
      {:ok, %Req.Response{status: status, body: body}} ->
199
        {:error, {:gcs_error, status, body}}
200
201
      {:error, reason} ->
202
        {:error, reason}
203
    end
204
  end
205
206
  defp delete_objects(_bucket, [], _token), do: :ok
207
208
  defp delete_objects(bucket, names, token) do
209
    names
210
    |> Task.async_stream(
211
      fn name -> delete_object(bucket, name, token) end,
212
      max_concurrency: 8,
213
      ordered: false,
214
      timeout: 60_000
215
    )
216
    |> Enum.reduce_while(:ok, fn
217
      {:ok, :ok}, :ok -> {:cont, :ok}
218
      {:ok, {:error, reason}}, :ok -> {:halt, {:error, reason}}
219
      {:exit, reason}, :ok -> {:halt, {:error, reason}}
220
    end)
221
  end
222
223
  defp delete_object(bucket, name, token) do
224
    case Req.delete(object_url(bucket, name), headers: auth_headers(token), retry: false) do
225
      {:ok, %Req.Response{status: status}} when status in [204, 404] -> :ok
226
      {:ok, %Req.Response{status: status, body: body}} -> {:error, {:gcs_error, status, body}}
227
      {:error, reason} -> {:error, reason}
228
    end
229
  end
230
170 231
  defp fetch_generation(bucket, name) do
171 232
    with {:ok, token} <- token() do
172 233
      url = object_url(bucket, name) <> "?" <> URI.encode_query(fields: "generation")
lib/openagents/forge/wal/local.ex modified +8

@@ -96,6 +96,14 @@ defmodule OpenAgents.Forge.WAL.Local do

96 96
    end
97 97
  end
98 98
99
  @impl WAL
100
  def delete_repo(repo) do
101
    case File.rm_rf(repo_dir(repo)) do
102
      {:ok, _removed} -> :ok
103
      {:error, reason, _path} -> {:error, reason}
104
    end
105
  end
106
99 107
  ## Internal
100 108
101 109
  defp do_cas(repo, :none, index) do
lib/openagents/repositories.ex modified +130 -1

@@ -4,7 +4,8 @@ defmodule OpenAgents.Repositories do

4 4
  import Ecto.Query, warn: false
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.{Audit, Repo}
7
  alias OpenAgents.Forge.{Repos, WAL}
8
  alias OpenAgents.{Analytics, Audit, Repo}
8 9
  alias OpenAgents.Machines.Machine
9 10
10 11
  alias OpenAgents.Repositories.{

@@ -261,6 +262,134 @@ defmodule OpenAgents.Repositories do

261 262
    )
262 263
  end
263 264
265
  @doc "Returns the first repository path in the user's workspace, or `nil`."
266
  def sidebar_repository_path(%User{id: user_id}) do
267
    case Repo.one(
268
           from repository in Repository,
269
             join: namespace in assoc(repository, :namespace),
270
             join: membership in Membership,
271
             on:
272
               membership.repository_id == repository.id and membership.user_id == ^user_id and
273
                 membership.role in ^~w(owner maintainer contributor viewer),
274
             order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
275
             limit: 1,
276
             select: {namespace.slug, repository.name}
277
         ) do
278
      {owner, name} -> "/#{owner}/#{name}"
279
      nil -> nil
280
    end
281
  end
282
283
  @doc "Delete a repository owned by `user`, including its durable and node-local Git data."
284
  def delete_owned_repository(owner, name, %User{} = user, options \\ [])
285
      when is_binary(owner) and is_binary(name) do
286
    with %Repository{} = repository <- owned_repository(owner, name, user) do
287
      result =
288
        :global.trans({{:forge_push, repository.storage_key}, self()}, fn ->
289
          delete_owned_repository_locked(repository, user)
290
        end)
291
292
      case result do
293
        {:ok, deleted} = success ->
294
          Analytics.capture("repository_deleted", Analytics.distinct_id(user), %{
295
            "repository_id" => deleted.id,
296
            "provisioning_kind" => deleted.provisioning_kind,
297
            "surface" => Keyword.get(options, :surface, "api")
298
          })
299
300
          success
301
302
        error ->
303
          error
304
      end
305
    else
306
      nil -> {:error, :not_found}
307
    end
308
  end
309
310
  defp owned_repository(owner, name, %User{id: user_id}) do
311
    owner_key = String.downcase(owner)
312
    name_key = String.downcase(name)
313
314
    Repo.one(
315
      from repository in repository_path_query(owner_key, name_key),
316
        join: membership in Membership,
317
        on:
318
          membership.repository_id == repository.id and membership.user_id == ^user_id and
319
            membership.role == "owner"
320
    )
321
  end
322
323
  defp delete_owned_repository_locked(repository, user) do
324
    Repo.transaction(fn ->
325
      locked_repository =
326
        Repo.one(
327
          from candidate in Repository,
328
            join: membership in Membership,
329
            on:
330
              membership.repository_id == candidate.id and membership.user_id == ^user.id and
331
                membership.role == "owner",
332
            where: candidate.id == ^repository.id,
333
            lock: "FOR UPDATE",
334
            select: candidate
335
        )
336
337
      if is_nil(locked_repository), do: Repo.rollback(:not_found)
338
339
      provisioning =
340
        Repo.one(
341
          from outbox in ProvisioningOutbox,
342
            where: outbox.repository_id == ^locked_repository.id,
343
            lock: "FOR UPDATE"
344
        )
345
346
      if provisioning && provisioning.state == "running", do: Repo.rollback(:repository_busy)
347
348
      with :ok <- WAL.delete_repo(locked_repository.storage_key),
349
           :ok <- delete_local_caches(locked_repository.storage_key) do
350
        Audit.record!(
351
          "repository.deleted",
352
          {:user, user.id},
353
          "repository",
354
          locked_repository.id,
355
          repository_id: locked_repository.id,
356
          metadata: %{
357
            "owner" => locked_repository.owner,
358
            "name" => locked_repository.name,
359
            "provisioning_kind" => locked_repository.provisioning_kind
360
          }
361
        )
362
363
        Repo.delete!(locked_repository)
364
      else
365
        {:error, reason} -> Repo.rollback({:storage_cleanup_failed, reason})
366
      end
367
    end)
368
  end
369
370
  defp delete_local_caches(storage_key) do
371
    [node() | Node.list()]
372
    |> Enum.uniq()
373
    |> Task.async_stream(
374
      fn target ->
375
        if target == node() do
376
          Repos.delete_repo(storage_key)
377
        else
378
          :erpc.call(target, Repos, :delete_repo, [storage_key], 30_000)
379
        end
380
      end,
381
      ordered: false,
382
      timeout: 31_000,
383
      on_timeout: :kill_task,
384
      max_concurrency: max(1, 1 + length(Node.list()))
385
    )
386
    |> Enum.reduce_while(:ok, fn
387
      {:ok, :ok}, :ok -> {:cont, :ok}
388
      {:ok, {:error, reason}}, :ok -> {:halt, {:error, reason}}
389
      {:exit, reason}, :ok -> {:halt, {:error, reason}}
390
    end)
391
  end
392
264 393
  def list_visible_repositories_page(
265 394
        %User{id: user_id},
266 395
        per_page,
lib/openagents_web/components/layouts.ex modified +5 -2

@@ -594,6 +594,7 @@ defmodule OpenAgentsWeb.Layouts do

594 594
    assigns =
595 595
      assigns
596 596
      |> assign(:agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
597
      |> assign(:repository_path, assigns.current_scope.sidebar_repository_path)
597 598
598 599
    ~H"""
599 600
    <aside id="sidebar" class="sidebar hidden lg:flex">

@@ -608,13 +609,15 @@ defmodule OpenAgentsWeb.Layouts do

608 609
          patchable={false}
609 610
        />
610 611
        <Layouts.sidebar_link
611
          path={~p"/OpenAgentsInc/openagents.com/issues"}
612
          :if={@repository_path}
613
          path={@repository_path <> "/issues"}
612 614
          label="Issues"
613 615
          icon="bug"
614 616
          patchable={false}
615 617
        />
616 618
        <Layouts.sidebar_link
617
          path={~p"/OpenAgentsInc/openagents.com/projects"}
619
          :if={@repository_path}
620
          path={@repository_path <> "/projects"}
618 621
          label="Projects"
619 622
          icon="folder"
620 623
          patchable={false}
lib/openagents_web/controllers/repository_controller.ex modified +27

@@ -67,6 +67,15 @@ defmodule OpenAgentsWeb.RepositoryController do

67 67
    Ecto.NoResultsError -> render_error(conn, :not_found)
68 68
  end
69 69
70
  def delete(conn, %{"owner" => owner, "repo" => name}) do
71
    case Repositories.delete_owned_repository(owner, name, conn.assigns.current_user,
72
           surface: "api"
73
         ) do
74
      {:ok, _repository} -> send_resp(conn, :no_content, "")
75
      {:error, reason} -> render_error(conn, reason)
76
    end
77
  end
78
70 79
  defp render_repository(conn, repository, user, replay_state) do
71 80
    status = if repository.lifecycle_state == "ready", do: :created, else: :accepted
72 81

@@ -177,6 +186,24 @@ defmodule OpenAgentsWeb.RepositoryController do

177 186
    do:
178 187
      error(conn, :conflict, "repository_name_conflict", "Repository name is unavailable", "name")
179 188
189
  defp render_error(conn, :repository_busy),
190
    do:
191
      error(
192
        conn,
193
        :conflict,
194
        "repository_busy",
195
        "Repository provisioning is still running. Try again after it finishes"
196
      )
197
198
  defp render_error(conn, {:storage_cleanup_failed, _reason}),
199
    do:
200
      error(
201
        conn,
202
        :service_unavailable,
203
        "repository_delete_failed",
204
        "Repository storage could not be deleted"
205
      )
206
180 207
  defp render_error(conn, :repository_quota_exceeded),
181 208
    do:
182 209
      error(
lib/openagents_web/docs_catalog.ex modified +13 -1

@@ -56,6 +56,12 @@ defmodule OpenAgentsWeb.DocsCatalog do

56 56
          icon: "code",
57 57
          route: "/repositories"
58 58
        },
59
        %{
60
          slug: "delete-repository",
61
          title: "Delete a repository",
62
          icon: "trash",
63
          route: "/repositories"
64
        },
59 65
        %{
60 66
          slug: "cli-command-reference",
61 67
          title: "CLI command reference",

@@ -154,7 +160,13 @@ defmodule OpenAgentsWeb.DocsCatalog do

154 160
  end
155 161
156 162
  @doc "Directory holding the Markdown sources."
157
  def source_dir, do: Application.app_dir(:openagents, "priv/docs")
163
  def source_dir do
164
    Application.get_env(
165
      :openagents,
166
      :docs_source_dir,
167
      Application.app_dir(:openagents, "priv/docs")
168
    )
169
  end
158 170
159 171
  @doc """
160 172
  Read and render one page.
lib/openagents_web/live/code_repo_live.ex modified +96 -1

@@ -50,6 +50,9 @@ defmodule OpenAgentsWeb.CodeRepoLive do

50 50
51 51
    refs = if head, do: Browse.refs(repository), else: []
52 52
53
    delete_allowed? =
54
      Repositories.membership_role(repository, socket.assigns.current_user) == "owner"
55
53 56
    # A repository that is still provisioning is the one state this page cannot
54 57
    # render usefully, and it is also the one state that ends on its own. The
55 58
    # provisioner and the importer announce each transition, so the page hears

@@ -75,7 +78,13 @@ defmodule OpenAgentsWeb.CodeRepoLive do

75 78
     |> assign(:branch_count, Enum.count(refs, &(&1.kind == :branch)))
76 79
     |> assign(:tag_count, Enum.count(refs, &(&1.kind == :tag)))
77 80
     |> assign(:open_issue_count, open_issue_count(repository))
78
     |> assign(:clone_url, RepositoryAccess.clone_url(repository))}
81
     |> assign(:clone_url, RepositoryAccess.clone_url(repository))
82
     |> assign(:delete_allowed?, delete_allowed?)
83
     |> assign(:delete_error, nil)
84
     |> assign(
85
       :delete_form,
86
       to_form(%{"confirmation" => ""}, as: :repository_delete)
87
     )}
79 88
  rescue
80 89
    Ecto.NoResultsError -> raise OpenAgentsWeb.PublicNotFoundError
81 90
  end

@@ -99,6 +108,47 @@ defmodule OpenAgentsWeb.CodeRepoLive do

99 108
    end
100 109
  end
101 110
111
  @impl true
112
  def handle_event(
113
        "delete_repository",
114
        %{"repository_delete" => %{"confirmation" => confirmation} = params},
115
        socket
116
      ) do
117
    expected = "#{socket.assigns.owner}/#{socket.assigns.repo}"
118
119
    if confirmation == expected do
120
      case Repositories.delete_owned_repository(
121
             socket.assigns.owner,
122
             socket.assigns.repo,
123
             socket.assigns.current_user,
124
             surface: "web"
125
           ) do
126
        {:ok, _repository} ->
127
          {:noreply,
128
           socket
129
           |> put_flash(:info, "Repository deleted.")
130
           |> push_navigate(to: ~p"/repositories")}
131
132
        {:error, :repository_busy} ->
133
          {:noreply,
134
           assign(
135
             socket,
136
             :delete_error,
137
             "Repository provisioning is still running. Try again after it finishes."
138
           )}
139
140
        {:error, _reason} ->
141
          {:noreply,
142
           assign(socket, :delete_error, "OpenAgents could not delete this repository.")}
143
      end
144
    else
145
      {:noreply,
146
       socket
147
       |> assign(:delete_form, to_form(params, as: :repository_delete))
148
       |> assign(:delete_error, "Type #{expected} exactly to confirm deletion.")}
149
    end
150
  end
151
102 152
  defp short(sha), do: String.slice(sha, 0, 12)
103 153
104 154
  # The tab carries a count only when there is something to count, the way the

@@ -300,6 +350,51 @@ defmodule OpenAgentsWeb.CodeRepoLive do

300 350
            </ol>
301 351
          </.card>
302 352
353
          <.card
354
            :if={@delete_allowed?}
355
            id="repository-danger-zone"
356
            variant={:danger}
357
            class="space-y-4"
358
          >
359
            <header>
360
              <h2>Delete repository</h2>
361
            </header>
362
            <p class="text-sm text-muted-foreground">
363
              This permanently deletes the repository, its Git history, issues, projects, and
364
              import records. You cannot undo this action.
365
            </p>
366
            <.alert
367
              :if={@delete_error}
368
              id="repository-delete-error"
369
              variant={:danger}
370
              title="Repository was not deleted"
371
            >
372
              {@delete_error}
373
            </.alert>
374
            <.form
375
              for={@delete_form}
376
              id="repository-delete-form"
377
              phx-submit="delete_repository"
378
              class="space-y-3"
379
            >
380
              <.input
381
                field={@delete_form[:confirmation]}
382
                type="text"
383
                label={"Type #{@owner}/#{@repo} to confirm"}
384
                autocomplete="off"
385
                required
386
              />
387
              <.button
388
                id="repository-delete-submit"
389
                type="submit"
390
                variant={:destructive}
391
                phx-disable-with="Deleting repository…"
392
              >
393
                Delete repository
394
              </.button>
395
            </.form>
396
          </.card>
397
303 398
          <:about>
304 399
            <.repo_about description={@repository.description}>
305 400
              <%!-- The file that is actually there, under the name it actually
lib/openagents_web/live/home_live.ex modified +1 -1

@@ -372,7 +372,7 @@ defmodule OpenAgentsWeb.HomeLive do

372 372
        >
373 373
          <:column title="Product">
374 374
            <.link navigate={~p"/docs"}>Documentation</.link>
375
            <.link navigate={~p"/OpenAgentsInc/openagents.com/issues"}>Issues</.link>
375
            <.link navigate={~p"/docs/issues"}>Issues</.link>
376 376
          </:column>
377 377
          <:column title="Transparency">
378 378
            <.link navigate={~p"/changelog"}>Changelog</.link>
lib/openagents_web/router.ex modified +1

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

239 239
    get "/user/repos", RepositoryController, :index
240 240
    post "/user/repos", RepositoryController, :create_user
241 241
    post "/orgs/:org/repos", RepositoryController, :create_organization
242
    delete "/repos/:owner/:repo", RepositoryController, :delete
242 243
    post "/user/repos/imports", RepositoryImportController, :create_user
243 244
    post "/orgs/:org/repos/imports", RepositoryImportController, :create_organization
244 245
    get "/repository-imports/:id", RepositoryImportController, :show
lib/openagents_web/user_auth.ex modified +21 -7

@@ -5,7 +5,7 @@ defmodule OpenAgentsWeb.UserAuth do

5 5
6 6
  import Plug.Conn
7 7
8
  alias OpenAgents.Accounts
8
  alias OpenAgents.{Accounts, Repositories}
9 9
10 10
  @session_key "user_id"
11 11

@@ -59,11 +59,25 @@ defmodule OpenAgentsWeb.UserAuth do

59 59
60 60
  # The scope is the user, plus the answers the layout needs on every render
61 61
  # and must not re-ask for. Resolved once here, at mount.
62
  defp scope(user) do
63
    %{user | agent_surfaces?: OpenAgents.Conversations.user_has_messages?(user)}
62
  defp scope(user, params) do
63
    repository_path =
64
      case params do
65
        %{"owner" => owner, "repo" => repository}
66
        when is_binary(owner) and is_binary(repository) ->
67
          "/#{owner}/#{repository}"
68
69
        _other ->
70
          Repositories.sidebar_repository_path(user)
71
      end
72
73
    %{
74
      user
75
      | agent_surfaces?: OpenAgents.Conversations.user_has_messages?(user),
76
        sidebar_repository_path: repository_path
77
    }
64 78
  end
65 79
66
  def on_mount(:mount_current_user, _params, session, socket) do
80
  def on_mount(:mount_current_user, params, session, socket) do
67 81
    socket = assign_sidebar_sections(socket, session)
68 82
69 83
    with user_id when is_binary(user_id) <- session[@session_key],

@@ -71,7 +85,7 @@ defmodule OpenAgentsWeb.UserAuth do

71 85
      {:cont,
72 86
       socket
73 87
       |> Phoenix.Component.assign(:current_user, user)
74
       |> Phoenix.Component.assign(:current_scope, scope(user))}
88
       |> Phoenix.Component.assign(:current_scope, scope(user, params))}
75 89
    else
76 90
      _missing_or_inactive ->
77 91
        {:cont,

@@ -81,7 +95,7 @@ defmodule OpenAgentsWeb.UserAuth do

81 95
    end
82 96
  end
83 97
84
  def on_mount(:ensure_authenticated, _params, session, socket) do
98
  def on_mount(:ensure_authenticated, params, session, socket) do
85 99
    socket = assign_sidebar_sections(socket, session)
86 100
87 101
    with user_id when is_binary(user_id) <- session[@session_key],

@@ -89,7 +103,7 @@ defmodule OpenAgentsWeb.UserAuth do

89 103
      {:cont,
90 104
       socket
91 105
       |> Phoenix.Component.assign(:current_user, user)
92
       |> Phoenix.Component.assign(:current_scope, scope(user))
106
       |> Phoenix.Component.assign(:current_scope, scope(user, params))
93 107
       |> Phoenix.LiveView.attach_hook(
94 108
         :active_user_guard,
95 109
         :handle_event,
priv/api-contracts/repositories-v1.json modified +3

@@ -23,6 +23,7 @@

23 23
    "import_organization_repository": "POST /api/v3/orgs/{org}/repos/imports",
24 24
    "list_repositories": "GET /api/v3/user/repos",
25 25
    "get_repository": "GET /api/v3/repos/{owner}/{repo}",
26
    "delete_repository": "DELETE /api/v3/repos/{owner}/{repo}",
26 27
    "get_repository_import": "GET /api/v3/repository-imports/{id}",
27 28
    "create_device_authorization": "POST /api/v3/device/authorizations",
28 29
    "poll_device_authorization": "POST /api/v3/device/authorizations/token"

@@ -88,6 +89,8 @@

88 89
      "not_found",
89 90
      "repository_quota_exceeded",
90 91
      "repository_name_conflict",
92
      "repository_busy",
93
      "repository_delete_failed",
91 94
      "slow_down",
92 95
      "source_namespace_mismatch",
93 96
      "source_repository_not_accessible"
priv/docs/cli-command-reference.md modified +12

@@ -118,6 +118,18 @@ openagents repo clone --repo OWNER/REPOSITORY [DIRECTORY]

118 118
119 119
The CLI retrieves the clone URL from the API and starts standard Git.
120 120
121
## Delete a repository
122
123
```text
124
openagents repo delete [OWNER/REPOSITORY] --yes
125
openagents repo delete --repo OWNER/REPOSITORY --yes
126
```
127
128
The command permanently deletes a repository you own, including its Git
129
history, issues, projects, and import records. You must pass `--yes`. When you
130
omit the repository, the CLI infers it from an exact OpenAgents `origin`
131
remote on the selected API origin.
132
121 133
## Use JSON in noninteractive processes
122 134
123 135
With `--json`, stdout contains machine-readable output. Human progress and
priv/docs/delete-repository.md added +47

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

1
# Delete a repository
2
3
You can permanently delete a repository you own from the website, REST API,
4
or CLI. Maintainers, contributors, and viewers cannot delete a repository.
5
6
Deletion removes the Git history, issues, projects, labels, milestones,
7
memberships, import receipt, and provisioning records. You cannot undo it.
8
9
## Delete from the website
10
11
1. Open the repository.
12
2. Find **Delete repository** near the bottom of the **Code** page.
13
3. Type the full `OWNER/REPOSITORY` name shown in the confirmation field.
14
4. Select **Delete repository**.
15
16
If provisioning is actively writing repository data, OpenAgents asks you to
17
try again after that operation finishes.
18
19
## Delete with the CLI
20
21
Pass `--yes` as explicit confirmation:
22
23
```sh
24
openagents repo delete OWNER/REPOSITORY --yes
25
```
26
27
You can run the same one-time command through `npx`:
28
29
```sh
30
npx --yes @openagentsinc/cli@latest repo delete OWNER/REPOSITORY --yes
31
```
32
33
The command also supports `--repo OWNER/REPOSITORY`. When you omit the name,
34
the CLI infers it from an exact OpenAgents `origin` remote.
35
36
## Delete with the REST API
37
38
Send an API token with the `forge:write` scope:
39
40
```sh
41
curl --request DELETE \
42
  --header "Authorization: Bearer $OPENAGENTS_TOKEN" \
43
  https://openagents.com/api/v3/repos/OWNER/REPOSITORY
44
```
45
46
Success returns `204 No Content`. OpenAgents returns `404 Not Found` when the
47
repository does not exist or you are not its owner.
priv/docs/openagents-cli.md modified +6 -2

@@ -13,11 +13,12 @@ The current release lets you:

13 13
- Import a GitHub repository as a one-time copy.
14 14
- List and inspect repositories that you can access.
15 15
- Clone repositories and configure Git authentication.
16
- Delete a repository you own with explicit confirmation.
16 17
- Push, pull, and fetch with standard Git.
17 18
- Use JSON output and stable exit codes in scripts and agents.
18 19
19
The current release does not provide pull requests, repository deletion,
20
continuous GitHub mirroring, SSH transport, rulesets, or a self-update command.
20
The current release does not provide pull requests, continuous GitHub
21
mirroring, SSH transport, rulesets, or a self-update command.
21 22
22 23
## Choose how to run the CLI
23 24

@@ -82,6 +83,8 @@ After you sign in, open [Repositories](/repositories).

82 83
- Select **Import from GitHub** to copy one GitHub repository.
83 84
- Open a repository to see its clone URL, lifecycle state, code, Issues,
84 85
  Projects, and import receipt when applicable.
86
- Open a repository's **Delete repository** section to permanently delete a
87
  repository you own.
85 88
86 89
## Follow a common terminal workflow
87 90

@@ -117,4 +120,5 @@ After you sign in, open [Repositories](/repositories).

117 120
- [Create a repository](/docs/create-repository)
118 121
- [Import from GitHub](/docs/import-github)
119 122
- [Clone, push, and pull](/docs/clone-push-pull)
123
- [Delete a repository](/docs/delete-repository)
120 124
- [CLI command reference](/docs/cli-command-reference)
priv/docs/rest-api.md modified +3

@@ -28,6 +28,7 @@ PATCH /api/v3/repos/:owner/:repo/issues/:issue_number

28 28
GET    /api/v3/user
29 29
GET    /api/v3/user/repos
30 30
GET    /api/v3/repos/:owner/:repo
31
DELETE /api/v3/repos/:owner/:repo
31 32
POST   /api/v3/user/repos
32 33
POST   /api/v3/orgs/:org/repos
33 34
POST   /api/v3/user/repos/imports

@@ -39,6 +40,8 @@ Repository writes require an `Idempotency-Key` header. The published

39 40
[`openagents.repositories.v1` contract](/api/contracts/repositories-v1.json)
40 41
defines request authority, lifecycle states, pagination, and stable error
41 42
codes. The [OpenAgents CLI](/docs/openagents-cli) implements this contract.
43
Only a repository owner can delete it. A successful deletion returns
44
`204 No Content`.
42 45
43 46
## Comments
44 47
test/openagents/forge/wal_test.exs modified +25

@@ -126,6 +126,29 @@ defmodule OpenAgents.Forge.WALTest do

126 126
    end
127 127
  end
128 128
129
  describe "delete_repo/1" do
130
    test "removes the index and every immutable object for one repository" do
131
      payload = "repository payload"
132
      assert {:ok, key} = WAL.put_entry(@repo, 0, payload)
133
134
      index =
135
        WAL.new_index()
136
        |> WAL.append_entry(%{
137
          "seq" => 0,
138
          "object" => key,
139
          "refs" => %{"refs/heads/main" => String.duplicate("a", 40)},
140
          "principal" => "test",
141
          "pushed_at" => DateTime.utc_now() |> DateTime.to_iso8601()
142
        })
143
144
      assert {:ok, _generation} = WAL.cas_index(@repo, :none, index)
145
      assert :ok = WAL.delete_repo(@repo)
146
      assert {:error, :not_found} = WAL.read_index(@repo)
147
      assert {:error, :not_found} = WAL.get_entry(@repo, key)
148
      assert :ok = WAL.delete_repo(@repo)
149
    end
150
  end
151
129 152
  describe "digest-addressed artifacts" do
130 153
    test "round trips only under the payload's full SHA-256" do
131 154
      payload = :crypto.strong_rand_bytes(512)

@@ -155,6 +178,7 @@ defmodule OpenAgents.Forge.WALTest do

155 178
        assert {:error, :invalid_repo} = WAL.put_entry(bad, 0, "x")
156 179
        assert {:error, :invalid_repo} = WAL.put_entry_file(bad, 0, "/tmp/entry")
157 180
        assert {:error, :invalid_repo} = WAL.get_entry(bad, "entries/00000000-0123456789ab")
181
        assert {:error, :invalid_repo} = WAL.delete_repo(bad)
158 182
159 183
        assert {:error, :invalid_repo} =
160 184
                 WAL.get_entry_file(bad, "entries/00000000-0123456789ab", "/tmp/entry")

@@ -260,6 +284,7 @@ defmodule OpenAgents.Forge.WALTest do

260 284
               OpenAgents.Forge.WAL.Gcs.cas_index(@repo, :none, WAL.new_index())
261 285
262 286
      assert {:error, :not_configured} = OpenAgents.Forge.WAL.Gcs.put_entry(@repo, 0, "x")
287
      assert {:error, :not_configured} = OpenAgents.Forge.WAL.Gcs.delete_repo(@repo)
263 288
264 289
      assert {:error, :not_configured} =
265 290
               OpenAgents.Forge.WAL.Gcs.put_entry_file(@repo, 0, "/tmp/entry")
test/openagents_web/controllers/repository_controller_test.exs modified +81

@@ -2,6 +2,7 @@ defmodule OpenAgentsWeb.RepositoryControllerTest do

2 2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  alias OpenAgents.ApiTokens
5
  alias OpenAgents.Forge.{Repos, WAL}
5 6
  alias OpenAgents.Repositories
6 7
7 8
  test "POST /api/v3/user/repos creates in the authenticated GitHub namespace", %{conn: conn} do

@@ -201,6 +202,63 @@ defmodule OpenAgentsWeb.RepositoryControllerTest do

201 202
             |> json_response(422)
202 203
  end
203 204
205
  test "DELETE /api/v3/repos/:owner/:repo removes an owned repository and its storage", %{
206
    conn: conn
207
  } do
208
    configure_repository_storage("repository-api-delete")
209
    owner = github_user("repository-api-delete-owner", "delete-owner")
210
211
    assert {:ok, repository, :created} =
212
             Repositories.create_user_repository(
213
               owner,
214
               %{name: "delete-me", visibility: "private"},
215
               "delete-repository"
216
             )
217
218
    mark_ready(repository)
219
    assert {:ok, _generation} = WAL.cas_index(repository.storage_key, :none, WAL.new_index())
220
    bare_path = Repos.ensure_repo!(repository.storage_key)
221
222
    response =
223
      conn
224
      |> authorize(owner)
225
      |> delete("/api/v3/repos/delete-owner/delete-me")
226
227
    assert response.status == 204
228
    assert response.resp_body == ""
229
230
    assert_raise Ecto.NoResultsError, fn ->
231
      Repositories.get_by_path!("delete-owner", "delete-me")
232
    end
233
234
    assert {:error, :not_found} = WAL.read_index(repository.storage_key)
235
    refute File.exists?(bare_path)
236
  end
237
238
  test "DELETE /api/v3/repos/:owner/:repo permits only repository owners", %{conn: conn} do
239
    owner = github_user("repository-api-delete-authorization-owner", "protected-owner")
240
    maintainer = github_user("repository-api-delete-authorization-maintainer")
241
242
    assert {:ok, repository, :created} =
243
             Repositories.create_user_repository(
244
               owner,
245
               %{name: "protected-repository", visibility: "private"},
246
               "protected-repository"
247
             )
248
249
    mark_ready(repository)
250
    assert {:ok, _membership} = Repositories.add_member(repository, maintainer, "maintainer")
251
252
    assert %{"code" => "not_found"} =
253
             conn
254
             |> authorize(maintainer)
255
             |> delete("/api/v3/repos/protected-owner/protected-repository")
256
             |> json_response(404)
257
258
    assert Repositories.get_by_path!("protected-owner", "protected-repository").id ==
259
             repository.id
260
  end
261
204 262
  defp authorize(conn, user) do
205 263
    {:ok, _credential, plaintext} =
206 264
      ApiTokens.create(user, %{name: "repository API test", scopes: ["forge:write"]})

@@ -216,4 +274,27 @@ defmodule OpenAgentsWeb.RepositoryControllerTest do

216 274
    )
217 275
    |> OpenAgents.Repo.update!()
218 276
  end
277
278
  defp configure_repository_storage(key) do
279
    root = Path.join(System.tmp_dir!(), "#{key}-#{System.unique_integer([:positive])}")
280
    previous_data = Application.get_env(:openagents, :forge_data_dir)
281
    previous_wal = Application.get_env(:openagents, :forge_wal_dir)
282
    previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)
283
284
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "data"))
285
    Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
286
    Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
287
288
    on_exit(fn ->
289
      restore_env(:forge_data_dir, previous_data)
290
      restore_env(:forge_wal_dir, previous_wal)
291
      restore_env(:forge_wal_adapter, previous_adapter)
292
      File.rm_rf!(root)
293
    end)
294
295
    root
296
  end
297
298
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
299
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
219 300
end
test/openagents_web/home_controller_test.exs modified +1 -1

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

1 1
defmodule OpenAgentsWeb.HomeControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  alias OpenAgents.{Repo, Repositories}
5 5
test/openagents_web/live/code_live_test.exs modified +61

@@ -318,6 +318,67 @@ defmodule OpenAgentsWeb.CodeLiveTest do

318 318
319 319
      refute has_element?(view, "#repo-import-provenance")
320 320
    end
321
322
    test "an owner can delete a repository after typing its full name", %{conn: conn} do
323
      owner = github_user("delete-repository-owner", "delete-repository-owner")
324
325
      assert {:ok, repository, :created} =
326
               OpenAgents.Repositories.create_user_repository(
327
                 owner,
328
                 %{name: "delete-repository", visibility: "private"},
329
                 "delete-repository-ui"
330
               )
331
332
      member_conn = Plug.Test.init_test_session(conn, %{"user_id" => owner.id})
333
      {:ok, view, _html} = live(member_conn, "/delete-repository-owner/delete-repository")
334
335
      assert has_element?(view, "#repository-danger-zone")
336
      assert has_element?(view, "#repository-delete-form")
337
338
      view
339
      |> form("#repository-delete-form", %{
340
        "repository_delete" => %{"confirmation" => "wrong-name"}
341
      })
342
      |> render_submit()
343
344
      assert has_element?(view, "#repository-delete-error")
345
346
      result =
347
        view
348
        |> form("#repository-delete-form", %{
349
          "repository_delete" => %{
350
            "confirmation" => "delete-repository-owner/delete-repository"
351
          }
352
        })
353
        |> render_submit()
354
355
      assert {:error, {:live_redirect, %{to: "/repositories"}}} = result
356
357
      assert_raise Ecto.NoResultsError, fn ->
358
        OpenAgents.Repositories.get_by_path!("delete-repository-owner", "delete-repository")
359
      end
360
361
      refute File.exists?(Repos.bare_path(repository.storage_key))
362
    end
363
364
    test "a non-owner never sees repository deletion controls", %{conn: conn} do
365
      owner = github_user("delete-repository-control-owner", "delete-control-owner")
366
      viewer = github_user("delete-repository-control-viewer", "delete-control-viewer")
367
368
      assert {:ok, repository, :created} =
369
               OpenAgents.Repositories.create_user_repository(
370
                 owner,
371
                 %{name: "protected-repository", visibility: "private"},
372
                 "delete-repository-controls"
373
               )
374
375
      assert {:ok, _membership} = OpenAgents.Repositories.add_member(repository, viewer, "viewer")
376
      viewer_conn = Plug.Test.init_test_session(conn, %{"user_id" => viewer.id})
377
      {:ok, view, _html} = live(viewer_conn, "/delete-control-owner/protected-repository")
378
379
      refute has_element?(view, "#repository-danger-zone")
380
      refute has_element?(view, "#repository-delete-form")
381
    end
321 382
  end
322 383
323 384
  describe "/code/:repo/blob/:ref/*path" do
test/openagents_web/sidebar_state_test.exs modified +47

@@ -18,8 +18,10 @@ defmodule OpenAgentsWeb.SidebarStateTest do

18 18
19 19
  use OpenAgentsWeb.ConnCase, async: true
20 20
21
  import Ecto.Query
21 22
  import Phoenix.LiveViewTest
22 23
24
  alias OpenAgents.Repositories.Membership
23 25
  alias OpenAgentsWeb.Plugs.SidebarSections
24 26
25 27
  describe "the admin row" do

@@ -90,6 +92,51 @@ defmodule OpenAgentsWeb.SidebarStateTest do

90 92
    end
91 93
  end
92 94
95
  describe "repository navigation" do
96
    test "repository pages keep Issues and Projects in the current repository", %{conn: conn} do
97
      user = github_user("sidebar-current-repository")
98
      conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
99
100
      {:ok, repository} =
101
        OpenAgents.Repositories.create_repository(%{
102
          owner: "navigation-owner",
103
          name: "navigation-repository",
104
          visibility: "private"
105
        })
106
107
      {:ok, _membership} = OpenAgents.Repositories.add_member(repository, user, "owner")
108
      {:ok, view, _html} = live(conn, ~p"/navigation-owner/navigation-repository/issues")
109
110
      assert has_element?(
111
               view,
112
               ~s(#sidebar a[href="/navigation-owner/navigation-repository/issues"])
113
             )
114
115
      assert has_element?(
116
               view,
117
               ~s(#sidebar a[href="/navigation-owner/navigation-repository/projects"])
118
             )
119
    end
120
121
    test "an account with no visible repository gets no dead Issues or Projects link", %{
122
      conn: conn
123
    } do
124
      user = github_user("sidebar-no-repository")
125
126
      OpenAgents.Repo.delete_all(
127
        from membership in Membership, where: membership.user_id == ^user.id
128
      )
129
130
      conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
131
132
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
133
134
      assert has_element?(view, ~s(#sidebar a[href="/repositories"]))
135
      refute has_element?(view, ~s(#sidebar a), "Issues")
136
      refute has_element?(view, ~s(#sidebar a), "Projects")
137
    end
138
  end
139
93 140
  describe "the cookie is reader-supplied, so it is bounded" do
94 141
    test "junk, wrong shapes, and unknown keys are dropped" do
95 142
      for value <- [

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