Point the sidebar's Issues and Projects at everything you can read

9ba43cf77453 · AtlantisPleb · · parent 8e01231d1bdc

Point the sidebar's Issues and Projects at everything you can read

The two rows were repository-scoped and did not say so. `UserAuth.scope/2`
read `:owner`/`:repo` off the current page's params, so the rows retargeted
themselves at whichever repository you happened to be looking at, and on a
page that named none they fell back to
`Repositories.sidebar_repository_path/1` — the first repository in your
workspace, alphabetically. The same row therefore led somewhere different
depending on where it was clicked from, pointed at an arbitrary repository the
rest of the time, and disappeared entirely for an account with no membership.

A row in the app's own navigation has to mean one thing everywhere, so the
rows are global now: `/issues` and `/projects` list every issue and project
across the repositories you can read. The repository-scoped lists are
unchanged and keep their place in the repository's own Code / Issues /
Projects tabs, which is where a repository-scoped nav belongs.
`sidebar_repository_path/1`, its query, and the virtual field it filled have
no callers left and are gone rather than left behind as a dead concept.

Both lists open on open items, newest first. GitHub's global list opens on
"assigned to you", and that is right there because an assignee is how work is
handed over on GitHub. Issues here arrive mostly by import and through the
API and mostly carry no assignee, so that default would hand nearly every
account an empty page and hide what they came for. Assigned-to-you and
opened-by-you are one control away, alongside the open/closed tabs and a
search box.

Authorization is one predicate rather than a second copy. `readable_by/2`
holds it — public and `ready`, or a membership in a reading role — and the
five places that had each written their own join now compose it, including
`list_visible_repositories/1`, whose copy had lost the `ready` half and so
disagreed with the paged read about the same repository. The workspace-wide
queries join their tables to that predicate as a subquery, so no filter,
search term, or page number they accept can widen the set: a filter narrows an
already-authorized query. Both lists are read-only, because whether you may
close an issue is a different question for every row on a cross-repository
page; triage stays where the answer is settled once.

Each page is 25 rows with the page number clamped, and both empty states
explain themselves: an account that can read no repository is offered the two
ways to get one, and a filter that matched nothing says which filter.

The row itself is extracted rather than copied. `IssuePresentation` owns the
translation from an `%Issue{}` to `Circle.issue_row/1` — the state category, the
close-reason wording, which of several assignees the face shows, an author who
no longer has an account — and both lists render through it, so they cannot
drift. `issue_row/1` grew one attribute, `repository`, drawn in the scan column
at a fixed width beside the identifier: on a cross-repository list, which
repository a row belongs to is as load-bearing as its number, and the trailing
edge drops by width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

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

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 assets/css/openagents.css
  • modified lib/openagents/accounts/user.ex
  • modified lib/openagents/issues.ex
  • modified lib/openagents/projects.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents_web/components/circle.ex
  • added lib/openagents_web/components/issue_presentation.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • added lib/openagents_web/live/issue_workspace_live.ex
  • added lib/openagents_web/live/project_workspace_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified lib/openagents_web/user_auth.ex
  • added test/openagents/issues_workspace_test.exs
  • modified test/openagents/repository_lifecycle_test.exs
  • added test/openagents_web/live/issue_workspace_live_test.exs
  • added test/openagents_web/live/project_workspace_live_test.exs
  • modified test/openagents_web/sidebar_state_test.exs

Diff

20 files changed, +1561 -204

INVARIANTS.md modified +16 -2

@@ -1873,11 +1873,25 @@ OpenAgents becomes the source of truth for the imported snapshot. Public ready

1873 1873
repositories permit anonymous Git reads. Private reads and every write require
1874 1874
an admitted repository principal, and read-only members cannot push.
1875 1875
1876
Amended 2026-08-21 (workspace-wide issue and project lists): who may read a
1877
repository is one composable predicate, `OpenAgents.Repositories.readable_by/2`
1878
— public and `ready`, or a membership in a reading role — and every surface
1879
that lists or resolves a repository composes it rather than restating the
1880
join. Reading across repositories obeys the same rule as reading one: the
1881
workspace-wide lists at `/issues` and `/projects` join their tables to that
1882
predicate, so no filter, search term, or page number they accept can surface a
1883
row from a repository the reader could not open directly. The duplicate that
1884
`list_visible_repositories/1` held had already lost the `ready` half and
1885
disagreed with the paged read about the same repository; there is one copy now.
1886
1876 1887
Evidence: `OpenAgents.Repositories`, `OpenAgents.Repositories.Provisioner`,
1877 1888
`OpenAgents.Repositories.Importer`, `OpenAgents.Forge.GitHTTP`,
1878 1889
`test/openagents/repository_lifecycle_test.exs`,
1879 1890
`test/openagents/repositories/provisioner_test.exs`,
1880
`test/openagents_web/controllers/repository_controller_test.exs`, and
1891
`test/openagents_web/controllers/repository_controller_test.exs`,
1892
`test/openagents/issues_workspace_test.exs`,
1893
`test/openagents_web/live/issue_workspace_live_test.exs`,
1894
`test/openagents_web/live/project_workspace_live_test.exs`, and
1881 1895
`test/openagents/forge/git_http_test.exs`.
1882 1896
1883 1897
### REPOSITORY-002 — Development pushes go to the forge, never to the mirror

@@ -2005,5 +2019,5 @@ contract; the invariant prose above defines the assertion, not the filename.

2005 2019
| RELEASE-005 | `test/openagents/forge/relup_deployment_test.exs`, `test/openagents/forge/relup_node_test.exs`, `test/openagents/release/appup_test.exs`, `test/openagents/cluster/code_change_test.exs`, `test/openagents/forge/rolling_replacement_test.exs` |
2006 2020
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
2007 2021
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs` |
2008
| 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` |
2022
| REPOSITORY-001 | `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
2009 2023
| REPOSITORY-002 | `ops/ci/push-remote-check.sh`, `ops/dev/install-push-guard.sh`, `test/openagents/push_remote_contract_test.exs` |
assets/css/openagents.css modified +18

@@ -5966,6 +5966,20 @@

5966 5966
    text-overflow: ellipsis;
5967 5967
  }
5968 5968
5969
  /* Only a cross-repository list draws this. There, which repository a row is
5970
     in is as load-bearing as its number, so it takes a fixed width inside the
5971
     scan column and forms a column of its own rather than sitting at the
5972
     trailing edge, which drops. The full path is on the element's title, for
5973
     the long name that clips. */
5974
  .issue-row__repository {
5975
    width: 6.5rem;
5976
    overflow: hidden;
5977
    color: var(--text-dim);
5978
    font-size: 0.75rem;
5979
    white-space: nowrap;
5980
    text-overflow: ellipsis;
5981
  }
5982
5969 5983
  .issue-row__title {
5970 5984
    min-width: 0;
5971 5985
    flex: 1;

@@ -6022,6 +6036,10 @@

6022 6036
    .issue-row__chips {
6023 6037
      display: inline-flex;
6024 6038
    }
6039
6040
    .issue-row__repository {
6041
      width: 10rem;
6042
    }
6025 6043
  }
6026 6044
6027 6045
  .issue-card {
lib/openagents/accounts/user.ex modified -2

@@ -30,7 +30,6 @@ 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
34 33
35 34
    has_one :storage_owner, OpenAgents.Conversations.Visitor
36 35

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

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

@@ -68,11 +68,67 @@ defmodule OpenAgents.Issues do

68 68
69 69
  def parse_page(_page), do: 1
70 70
71
  @doc """
72
  One page of the issues `user` can read, across every repository, with the
73
  unpaginated total.
74
75
  The workspace-wide counterpart to `list_issues_page/2`. Where that one is
76
  handed a repository the caller has already authorized, this one authorizes
77
  as it reads: the issue table is joined to `Repositories.readable_by/2`, the
78
  single predicate every repository surface composes, so an issue in a private
79
  repository the reader has no membership in cannot reach the query's result
80
  no matter what the options say.
81
82
  Supported options: `:state`, `:assignee`, `:author`, `:q`, and `:page`.
83
  Rows come back with their repository preloaded, because a cross-repository
84
  list has to say which repository each row belongs to.
85
  """
86
  def list_visible_issues_page(%User{} = user, opts \\ []) when is_list(opts) do
87
    page = max(parse_page(opts[:page]), 1)
88
    query = visible_issue_query(user, opts)
89
90
    total = Repo.aggregate(query, :count)
91
92
    issues =
93
      query
94
      |> order_by([issue], desc: issue.inserted_at, desc: issue.id)
95
      |> limit(@issues_per_page)
96
      |> offset(^((page - 1) * @issues_per_page))
97
      |> Repo.all()
98
      |> Repo.preload(repository: :namespace)
99
100
    {issues, total}
101
  end
102
103
  @doc "How many issues `user` can read across every repository, filtered."
104
  def count_visible_issues(%User{} = user, opts \\ []) when is_list(opts),
105
    do: user |> visible_issue_query(opts) |> Repo.aggregate(:count)
106
107
  # The readable-repository set arrives as a subquery rather than as extra
108
  # joins on this query, so the membership left join keeps its own bindings and
109
  # the filter chain below still sees the issue as binding zero.
110
  defp visible_issue_query(user, opts) do
111
    readable = from(repository in Repositories.readable_by(Repository, user), select: repository)
112
113
    from(issue in Issue,
114
      as: :issue,
115
      join: repository in subquery(readable),
116
      on: repository.id == issue.repository_id
117
    )
118
    |> apply_issue_filters(opts)
119
    |> maybe_filter_author(Keyword.get(opts, :author))
120
  end
121
71 122
  # Every list surface shares one filter chain so a page, a count, and an
72 123
  # unpaginated read can never disagree about what matches.
73 124
  defp issue_query(repository_id, opts) do
74 125
    from(issue in Issue, as: :issue)
75 126
    |> where([issue], issue.repository_id == ^repository_id)
127
    |> apply_issue_filters(opts)
128
  end
129
130
  defp apply_issue_filters(query, opts) do
131
    query
76 132
    |> maybe_filter_state(Keyword.get(opts, :state, "open"))
77 133
    |> maybe_filter_label(Keyword.get(opts, :label))
78 134
    |> maybe_filter_assignee(Keyword.get(opts, :assignee))

@@ -624,6 +680,23 @@ defmodule OpenAgents.Issues do

624 680
    )
625 681
  end
626 682
683
  # "Opened by me" has two honest answers. An issue filed here carries a
684
  # durable author link; an issue imported from GitHub carries only the login
685
  # in its user snapshot, because the account that opened it may not exist
686
  # here. Either one is the reader having opened it, so the filter takes both.
687
  defp maybe_filter_author(query, nil), do: query
688
689
  defp maybe_filter_author(query, %User{id: user_id, github_login: login}) do
690
    login_key = String.downcase(login)
691
692
    where(
693
      query,
694
      [issue],
695
      issue.author_user_id == ^user_id or
696
        fragment("lower(? ->> 'login')", issue.user) == ^login_key
697
    )
698
  end
699
627 700
  defp maybe_filter_milestone(query, nil), do: query
628 701
  defp maybe_filter_milestone(query, ""), do: query
629 702
lib/openagents/projects.ex modified +69

@@ -34,6 +34,75 @@ defmodule OpenAgents.Projects do

34 34
    )
35 35
  end
36 36
37
  @projects_per_page 25
38
39
  @doc "How many projects one workspace-wide page shows."
40
  def per_page, do: @projects_per_page
41
42
  @doc """
43
  One page of the projects `user` can read, across every repository, with the
44
  unpaginated total.
45
46
  Authorization is the same predicate the repository surfaces compose,
47
  `Repositories.readable_by/2`, joined in as a subquery rather than restated
48
  here, so a project in a repository the reader has no membership in cannot
49
  appear.
50
51
  Supported options: `:state`, `:owner`, and `:page`. Rows come back with their
52
  repository preloaded, because a project's board lives at a repository path.
53
  """
54
  def list_visible_projects_page(%User{} = user, opts \\ []) when is_list(opts) do
55
    page = max(parse_page(opts[:page]), 1)
56
    query = visible_project_query(user, opts)
57
58
    total = Repo.aggregate(query, :count)
59
60
    projects =
61
      query
62
      |> order_by([project], desc: project.inserted_at, desc: project.id)
63
      |> limit(@projects_per_page)
64
      |> offset(^((page - 1) * @projects_per_page))
65
      |> Repo.all()
66
      |> Repo.preload(repository: :namespace)
67
68
    {projects, total}
69
  end
70
71
  @doc "How many projects `user` can read across every repository, filtered."
72
  def count_visible_projects(%User{} = user, opts \\ []) when is_list(opts),
73
    do: user |> visible_project_query(opts) |> Repo.aggregate(:count)
74
75
  @doc "Clamps a reader-supplied page number into the bounded range."
76
  def parse_page(page), do: OpenAgents.Issues.parse_page(page)
77
78
  defp visible_project_query(user, opts) do
79
    readable = from(repository in Repositories.readable_by(Repository, user), select: repository)
80
81
    from(project in Project,
82
      join: repository in subquery(readable),
83
      on: repository.id == project.repository_id
84
    )
85
    |> maybe_filter_project_state(Keyword.get(opts, :state, "open"))
86
    |> maybe_filter_project_owner(Keyword.get(opts, :owner))
87
  end
88
89
  defp maybe_filter_project_state(query, "all"), do: query
90
  defp maybe_filter_project_state(query, state), do: where(query, state: ^state)
91
92
  # As with issues, a project created here carries a durable owner link while
93
  # one that arrived with only a login carries the login.
94
  defp maybe_filter_project_owner(query, nil), do: query
95
96
  defp maybe_filter_project_owner(query, %User{id: user_id, github_login: login}) do
97
    login_key = String.downcase(login)
98
99
    where(
100
      query,
101
      [project],
102
      project.owner_user_id == ^user_id or fragment("lower(?)", project.owner) == ^login_key
103
    )
104
  end
105
37 106
  def get_project!(id), do: get_project!(Repositories.initial_repository!(), id)
38 107
39 108
  def get_project!(%Repository{id: repository_id}, id) do
lib/openagents/repositories.ex modified +58 -73

@@ -69,21 +69,43 @@ defmodule OpenAgents.Repositories do

69 69
    )
70 70
  end
71 71
72
  def get_visible_by_path!(owner, name, nil), do: get_public_by_path!(owner, name)
73
74
  def get_visible_by_path!(owner, name, %User{id: user_id}) do
72
  def get_visible_by_path!(owner, name, user) do
75 73
    owner_key = String.downcase(owner)
76 74
    name_key = String.downcase(name)
77 75
78
    Repo.one!(
79
      from repository in repository_path_query(owner_key, name_key),
80
        left_join: membership in Membership,
81
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
82
        where:
83
          (repository.visibility == "public" and repository.lifecycle_state == "ready") or
84
            (not is_nil(membership.user_id) and
85
               membership.role in ^~w(owner maintainer contributor viewer))
86
    )
76
    owner_key
77
    |> repository_path_query(name_key)
78
    |> readable_by(user)
79
    |> Repo.one!()
80
  end
81
82
  @doc """
83
  Narrows a repository query to the repositories `user` may read.
84
85
  One predicate, composed by every surface that lists or resolves a repository,
86
  so a new surface cannot arrive at a looser rule by rewriting the join from
87
  memory. Reading is public on a repository that is both public and
88
  provisioned; anything else needs a membership in a reading role. `nil` is an
89
  anonymous visitor, who sees only the public half.
90
91
  It composes into a query that already carries joins and preloads: the
92
  membership join is appended, so the caller's own bindings keep their
93
  positions.
94
  """
95
  def readable_by(query, user)
96
97
  def readable_by(query, nil) do
98
    from repository in query,
99
      where: repository.visibility == "public" and repository.lifecycle_state == "ready"
100
  end
101
102
  def readable_by(query, %User{id: user_id}) do
103
    from repository in query,
104
      left_join: reader in Membership,
105
      on: reader.repository_id == repository.id and reader.user_id == ^user_id,
106
      where:
107
        (repository.visibility == "public" and repository.lifecycle_state == "ready") or
108
          (not is_nil(reader.user_id) and reader.role in ^@all_roles)
87 109
  end
88 110
89 111
  def get_writable_by_path!(owner, name, %User{id: user_id}) do

@@ -263,38 +285,23 @@ defmodule OpenAgents.Repositories do

263 285
    end
264 286
  end
265 287
266
  def list_visible_repositories(%User{id: user_id}) do
288
  def list_visible_repositories(%User{} = user) do
267 289
    Repo.all(
268
      from repository in Repository,
290
      from repository in readable_by(Repository, user),
269 291
        join: namespace in assoc(repository, :namespace),
270
        left_join: membership in Membership,
271
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
272
        where:
273
          repository.visibility == "public" or
274
            (not is_nil(membership.user_id) and
275
               membership.role in ^~w(owner maintainer contributor viewer)),
276 292
        order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
277 293
        preload: [namespace: namespace]
278 294
    )
279 295
  end
280 296
281
  @doc "Returns the first repository path in the user's workspace, or `nil`."
282
  def sidebar_repository_path(%User{id: user_id}) do
283
    case Repo.one(
284
           from repository in Repository,
285
             join: namespace in assoc(repository, :namespace),
286
             join: membership in Membership,
287
             on:
288
               membership.repository_id == repository.id and membership.user_id == ^user_id and
289
                 membership.role in ^~w(owner maintainer contributor viewer),
290
             order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
291
             limit: 1,
292
             select: {namespace.slug, repository.name}
293
         ) do
294
      {owner, name} -> "/#{owner}/#{name}"
295
      nil -> nil
296
    end
297
  end
297
  @doc """
298
  Whether `user` can read any repository at all.
299
300
  What a workspace-wide list asks to tell an empty page from an empty
301
  workspace: "no open issues" and "no repositories yet" want different words
302
  and different next steps, and only this distinguishes them.
303
  """
304
  def any_visible_repository?(user), do: Repo.exists?(readable_by(Repository, user))
298 305
299 306
  @doc "Delete a repository owned by `user`, including its durable and node-local Git data."
300 307
  def delete_owned_repository(owner, name, %User{} = user, options \\ [])

@@ -409,21 +416,16 @@ defmodule OpenAgents.Repositories do

409 416
  end
410 417
411 418
  def list_visible_repositories_page(
412
        %User{id: user_id},
419
        %User{} = user,
413 420
        per_page,
414 421
        after_cursor,
415 422
        namespace_key \\ nil
416 423
      )
417 424
      when per_page in 1..100 do
418 425
    query =
419
      from repository in Repository,
426
      from repository in readable_by(Repository, user),
420 427
        join: namespace in assoc(repository, :namespace),
421
        left_join: membership in Membership,
422
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
423
        where:
424
          (repository.visibility == "public" and repository.lifecycle_state == "ready") or
425
            (not is_nil(membership.user_id) and
426
               membership.role in ^~w(owner maintainer contributor viewer)),
428
        as: :namespace,
427 429
        order_by: [asc: namespace.slug_key, asc: repository.name_key, asc: repository.id],
428 430
        preload: [namespace: namespace]
429 431

@@ -448,29 +450,11 @@ defmodule OpenAgents.Repositories do

448 450
  than the whole page. Returns `nil` rather than raising, because a repository
449 451
  can stop being visible between the broadcast and the read.
450 452
  """
451
  def get_visible_repository(id, user)
452
453
  def get_visible_repository(id, nil) when is_binary(id) do
454
    visible_repository(
455
      from repository in Repository,
456
        join: namespace in assoc(repository, :namespace),
457
        where: repository.id == ^id,
458
        where: repository.visibility == "public" and repository.lifecycle_state == "ready",
459
        preload: [namespace: namespace]
460
    )
461
  end
462
463
  def get_visible_repository(id, %User{id: user_id}) when is_binary(id) do
453
  def get_visible_repository(id, user) when is_binary(id) do
464 454
    visible_repository(
465
      from repository in Repository,
455
      from repository in readable_by(Repository, user),
466 456
        join: namespace in assoc(repository, :namespace),
467
        left_join: membership in Membership,
468
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
469 457
        where: repository.id == ^id,
470
        where:
471
          (repository.visibility == "public" and repository.lifecycle_state == "ready") or
472
            (not is_nil(membership.user_id) and
473
               membership.role in ^~w(owner maintainer contributor viewer)),
474 458
        preload: [namespace: namespace]
475 459
    )
476 460
  end

@@ -530,8 +514,7 @@ defmodule OpenAgents.Repositories do

530 514
  defp apply_namespace_filter(query, nil), do: query
531 515
532 516
  defp apply_namespace_filter(query, namespace_key) when is_binary(namespace_key) do
533
    from [repository, namespace, membership] in query,
534
      where: namespace.slug_key == ^namespace_key
517
    where(query, [namespace: namespace], namespace.slug_key == ^namespace_key)
535 518
  end
536 519
537 520
  def get_import_for_user!(id, %User{id: user_id}) do

@@ -1212,12 +1195,14 @@ defmodule OpenAgents.Repositories do

1212 1195
  defp apply_repository_cursor(query, nil), do: query
1213 1196
1214 1197
  defp apply_repository_cursor(query, {owner_key, name_key, id}) do
1215
    from [repository, namespace, _membership] in query,
1216
      where:
1217
        namespace.slug_key > ^owner_key or
1218
          (namespace.slug_key == ^owner_key and repository.name_key > ^name_key) or
1219
          (namespace.slug_key == ^owner_key and repository.name_key == ^name_key and
1220
             repository.id > ^id)
1198
    where(
1199
      query,
1200
      [repository, namespace: namespace],
1201
      namespace.slug_key > ^owner_key or
1202
        (namespace.slug_key == ^owner_key and repository.name_key > ^name_key) or
1203
        (namespace.slug_key == ^owner_key and repository.name_key == ^name_key and
1204
           repository.id > ^id)
1205
    )
1221 1206
  end
1222 1207
1223 1208
  defp normalize_repository_attrs(attrs) do
lib/openagents_web/components/circle.ex modified +9

@@ -269,6 +269,12 @@ defmodule OpenAgentsWeb.UI.Circle do

269 269
  attr :status_label, :string, required: true
270 270
  attr :progress, :integer, default: nil
271 271
  attr :priority, :atom, values: @priorities, default: :none
272
273
  # Which repository the issue is in. A list drawn from one repository already
274
  # knows, and leaves this `nil`; a list drawn across several is unreadable
275
  # without it, so it joins the scan column rather than the trailing edge,
276
  # which drops by width.
277
  attr :repository, :string, default: nil, doc: "`owner/name`, for a cross-repository list"
272 278
  attr :labels, :list, default: [], doc: "`[%{name: String.t(), tone: atom()}]`"
273 279
  attr :project, :string, default: nil
274 280
  attr :due, :string, default: nil, doc: "already formatted; overdue is the caller's judgement"

@@ -299,6 +305,9 @@ defmodule OpenAgentsWeb.UI.Circle do

299 305
    <div class={["issue-row", @class]} data-selected={@selected} {@rest}>
300 306
      <span class="issue-row__scan">
301 307
        <.issue_priority level={@priority} />
308
        <span :if={@repository} class="issue-row__repository" title={@repository}>
309
          {@repository}
310
        </span>
302 311
        <span class="issue-row__identifier">{@identifier}</span>
303 312
        {render_slot(@state)}
304 313
        <.issue_status
lib/openagents_web/components/issue_presentation.ex added +149

@@ -0,0 +1,149 @@

1
defmodule OpenAgentsWeb.Components.IssuePresentation do
2
  @moduledoc """
3
  How an `%OpenAgents.Issues.Issue{}` is drawn as a row, in one place.
4
5
  `OpenAgentsWeb.UI.Circle.issue_row/1` takes plain maps and atoms on purpose,
6
  so it can be rendered from a schema, an API payload, or a literal in a test.
7
  That leaves a translation layer — which state category a close reason maps
8
  to, which assignee of several the face shows, how an author with no account
9
  is named — and there is exactly one right answer to each. This module owns
10
  those answers so the repository list and the workspace-wide list cannot
11
  drift apart, and so a third list would inherit them rather than restate
12
  them.
13
14
  It renders the row and nothing else. Who may change an issue's state or its
15
  assignees is the caller's question: a caller with the authority passes
16
  controls through the `:state` and `:people` slots, and a caller without it
17
  passes none and gets the static glyphs.
18
  """
19
  use Phoenix.Component
20
21
  alias OpenAgents.Issues.Issue
22
  alias OpenAgentsWeb.UI.Circle
23
24
  attr :id, :string, required: true
25
  attr :issue, Issue, required: true
26
  attr :navigate, :any, required: true, doc: "where the title goes"
27
28
  attr :repository, :string,
29
    default: nil,
30
    doc: "`owner/name`; set it on a cross-repository list, leave it off within one"
31
32
  slot :state, doc: "a control that changes the issue's state, for a caller who may"
33
  slot :people, doc: "a control that changes the issue's assignees, for a caller who may"
34
35
  def issue_row(assigns) do
36
    ~H"""
37
    <Circle.issue_row
38
      id={@id}
39
      identifier={"##{@issue.number}"}
40
      repository={@repository}
41
      title={@issue.title}
42
      navigate={@navigate}
43
      status_category={category(@issue)}
44
      status_label={status_label(@issue)}
45
      labels={labels(@issue)}
46
      assignee={assignee(@issue)}
47
      created={"opened #{relative(@issue.inserted_at)} ago"}
48
      author={author(@issue)}
49
      comments={@issue.comments}
50
    >
51
      <:state :if={@state != []}>{render_slot(@state)}</:state>
52
      <:people :if={@people != []}>{render_slot(@people)}</:people>
53
    </Circle.issue_row>
54
    """
55
  end
56
57
  @doc """
58
  GitHub's two states, and nothing invented on top of them.
59
60
  `not_planned` is the one close reason with a distinct reading, so it takes
61
  the cancelled glyph; every other close is a completion.
62
  """
63
  def category(%{state: "closed", state_reason: "not_planned"}), do: :canceled
64
  def category(%{state: "closed"}), do: :completed
65
  def category(_issue), do: :unstarted
66
67
  @doc "The word beside the glyph, for assistive technology and for a label."
68
  def status_label(%{state: "closed", state_reason: "not_planned"}), do: "Closed as not planned"
69
  def status_label(%{state: "closed"}), do: "Closed"
70
  def status_label(_issue), do: "Open"
71
72
  @doc """
73
  The states a triaging member may choose between.
74
75
  `duplicate` is rendered when it arrives from the API but is not offered: the
76
  menu has nowhere to record which issue it duplicates, and a duplicate that
77
  does not say of what is worse than a plain close.
78
  """
79
  def state_options do
80
    [
81
      {"Open", "open", nil},
82
      {"Closed as completed", "closed", "completed"},
83
      {"Closed as not planned", "closed", "not_planned"}
84
    ]
85
  end
86
87
  @doc "The close reason a menu should show as selected, defaulted like GitHub."
88
  def close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
89
  def close_reason(_issue), do: nil
90
91
  @doc "Whether `login` is among the issue's assignees."
92
  def assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))
93
94
  @doc """
95
  The issue's labels, as the row wants them.
96
97
  A label carries a colour on GitHub; the row takes a tone from our ladder
98
  rather than that hex, so the list stays in one palette.
99
  """
100
  def labels(%{labels: labels}) when is_list(labels) do
101
    Enum.map(labels, fn label ->
102
      %{name: label["name"] || label[:name] || "label", tone: :neutral}
103
    end)
104
  end
105
106
  def labels(_issue), do: []
107
108
  @doc """
109
  The assignee the row's face shows.
110
111
  GitHub issues carry many assignees; the row shows the first, which is the one
112
  GitHub itself treats as `assignee`.
113
  """
114
  def assignee(%{assignees: [first | _rest]}) when is_map(first) do
115
    %{
116
      name: first["login"] || first[:login],
117
      src: first["avatar_url"] || first[:avatar_url],
118
      presence: :none
119
    }
120
  end
121
122
  def assignee(_issue), do: nil
123
124
  @doc """
125
  Who opened the issue.
126
127
  GitHub prints this beside when. An issue whose author is gone still has a
128
  history, so it says so rather than showing a blank.
129
  """
130
  def author(%{user: %{} = user}), do: user["login"] || user[:login] || "anonymous"
131
  def author(_issue), do: "anonymous"
132
133
  @doc "How long ago, coarsely: minutes, then hours, then days."
134
  def relative(nil), do: nil
135
136
  def relative(at) do
137
    at = if is_struct(at, NaiveDateTime), do: DateTime.from_naive!(at, "Etc/UTC"), else: at
138
139
    case DateTime.diff(DateTime.utc_now(), at, :second) do
140
      s when s < 3_600 -> "#{max(div(s, 60), 1)}m"
141
      s when s < 86_400 -> "#{div(s, 3_600)}h"
142
      s -> "#{div(s, 86_400)}d"
143
    end
144
  end
145
146
  @doc "The `owner/name` a cross-repository row shows, from a preloaded issue."
147
  def repository_path(%{repository: %{owner: owner, name: name}}), do: "#{owner}/#{name}"
148
  def repository_path(_issue), do: nil
149
end
lib/openagents_web/components/layouts.ex modified +12 -13

@@ -702,10 +702,7 @@ defmodule OpenAgentsWeb.Layouts do

702 702
  slot :extra, doc: "rows contributed by the current page"
703 703
704 704
  defp sidebar(assigns) do
705
    assigns =
706
      assigns
707
      |> assign(:agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
708
      |> assign(:repository_path, assigns.current_scope.sidebar_repository_path)
705
    assigns = assign(assigns, :agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
709 706
710 707
    ~H"""
711 708
    <aside id="sidebar" class="sidebar" aria-hidden="true">

@@ -719,16 +716,18 @@ defmodule OpenAgentsWeb.Layouts do

719 716
          icon="branch"
720 717
          patchable={false}
721 718
        />
719
        <%!-- Global, and unconditional. These rows used to address whichever
720
        repository the current page named, falling back to the first repository
721
        in the reader's workspace alphabetically, so the same row led somewhere
722
        different depending on where it was clicked from — and vanished
723
        entirely for an account with no membership. A row in the app's own
724
        navigation has to mean one thing everywhere, so it addresses everything
725
        the reader can see. The repository-scoped lists keep their place in the
726
        repository's own tabs, which is where a repository-scoped nav
727
        belongs. --%>
728
        <Layouts.sidebar_link path={~p"/issues"} label="Issues" icon="bug" patchable={false} />
722 729
        <Layouts.sidebar_link
723
          :if={@repository_path}
724
          path={@repository_path <> "/issues"}
725
          label="Issues"
726
          icon="bug"
727
          patchable={false}
728
        />
729
        <Layouts.sidebar_link
730
          :if={@repository_path}
731
          path={@repository_path <> "/projects"}
730
          path={~p"/projects"}
732 731
          label="Projects"
733 732
          icon="folder"
734 733
          patchable={false}
lib/openagents_web/live/issue_index_live.ex modified +9 -82

@@ -14,6 +14,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

14 14
  alias OpenAgents.Labels
15 15
  alias OpenAgents.Milestones
16 16
  alias OpenAgents.Repositories
17
  alias OpenAgentsWeb.Components.IssuePresentation
17 18
  alias OpenAgentsWeb.OG
18 19
  alias OpenAgentsWeb.UI.Circle
19 20

@@ -303,19 +304,11 @@ defmodule OpenAgentsWeb.IssueIndexLive do

303 304
      </.empty>
304 305
305 306
      <div :if={@issues_count > 0} id="issues" phx-update="stream" class="issue-list">
306
        <Circle.issue_row
307
        <IssuePresentation.issue_row
307 308
          :for={{id, issue} <- @streams.issues}
308 309
          id={id}
309
          identifier={"##{issue.number}"}
310
          title={issue.title}
310
          issue={issue}
311 311
          navigate={~p"/#{@owner}/#{@repo}/issues/#{issue.number}"}
312
          status_category={category(issue)}
313
          status_label={status_label(issue)}
314
          labels={labels(issue)}
315
          assignee={assignee(issue)}
316
          created={"opened #{relative(issue.inserted_at)} ago"}
317
          author={author(issue)}
318
          comments={issue.comments}
319 312
        >
320 313
          <:state :if={@can_write}>
321 314
            <Circle.field_menu

@@ -326,10 +319,10 @@ defmodule OpenAgentsWeb.IssueIndexLive do

326 319
                <Circle.issue_state state={issue.state} reason={issue.state_reason} />
327 320
              </:trigger>
328 321
              <Circle.field_menu_item
329
                :for={{label, state, reason} <- state_options()}
322
                :for={{label, state, reason} <- IssuePresentation.state_options()}
330 323
                label={label}
331 324
                mode={:choice}
332
                selected={issue.state == state and close_reason(issue) == reason}
325
                selected={issue.state == state and IssuePresentation.close_reason(issue) == reason}
333 326
                closes={"row-state-#{issue.id}"}
334 327
                on_select={JS.push("set_state", value: %{id: issue.id, state: state, reason: reason})}
335 328
              >

@@ -345,14 +338,14 @@ defmodule OpenAgentsWeb.IssueIndexLive do

345 338
            >
346 339
              <:trigger>
347 340
                <Circle.assignee
348
                  name={assignee(issue) && assignee(issue)[:name]}
349
                  src={assignee(issue) && assignee(issue)[:src]}
341
                  name={IssuePresentation.assignee(issue)[:name]}
342
                  src={IssuePresentation.assignee(issue)[:src]}
350 343
                />
351 344
              </:trigger>
352 345
              <Circle.field_menu_item
353 346
                :for={user <- @assignable}
354 347
                label={user.github_login}
355
                selected={assigned?(issue, user.github_login)}
348
                selected={IssuePresentation.assigned?(issue, user.github_login)}
356 349
                on_select={
357 350
                  JS.push("toggle_assignee", value: %{id: issue.id, login: user.github_login})
358 351
                }

@@ -361,7 +354,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

361 354
              </Circle.field_menu_item>
362 355
            </Circle.field_menu>
363 356
          </:people>
364
        </Circle.issue_row>
357
        </IssuePresentation.issue_row>
365 358
      </div>
366 359
367 360
      <nav :if={@total_count > Issues.per_page()} class="issue-pagination" aria-label="Pages">

@@ -392,70 +385,4 @@ defmodule OpenAgentsWeb.IssueIndexLive do

392 385
    </Layouts.app>
393 386
    """
394 387
  end
395
396
  # GitHub's two states, and nothing invented on top of them. `not_planned` is
397
  # the one close reason with a distinct reading, so it takes the cancelled
398
  # glyph; every other close is a completion.
399
  defp category(%{state: "closed", state_reason: "not_planned"}), do: :canceled
400
  defp category(%{state: "closed"}), do: :completed
401
  defp category(_issue), do: :unstarted
402
403
  defp status_label(%{state: "closed", state_reason: "not_planned"}), do: "Closed as not planned"
404
  defp status_label(%{state: "closed"}), do: "Closed"
405
  defp status_label(_issue), do: "Open"
406
407
  # `duplicate` is rendered when it arrives from the API but is not offered:
408
  # the menu has nowhere to record which issue it duplicates, and a duplicate
409
  # that does not say of what is worse than a plain close.
410
  defp state_options do
411
    [
412
      {"Open", "open", nil},
413
      {"Closed as completed", "closed", "completed"},
414
      {"Closed as not planned", "closed", "not_planned"}
415
    ]
416
  end
417
418
  defp close_reason(%{state: "closed", state_reason: reason}), do: reason || "completed"
419
  defp close_reason(_issue), do: nil
420
421
  defp assigned?(issue, login), do: Enum.any?(issue.assignees || [], &(&1["login"] == login))
422
423
  # A label carries a colour on GitHub; the row takes a tone from our ladder
424
  # rather than that hex, so the list stays in one palette.
425
  defp labels(%{labels: labels}) when is_list(labels) do
426
    Enum.map(labels, fn label ->
427
      %{name: label["name"] || label[:name] || "label", tone: :neutral}
428
    end)
429
  end
430
431
  defp labels(_issue), do: []
432
433
  # GitHub issues carry many assignees; the row shows the first, which is the
434
  # one GitHub itself treats as `assignee`.
435
  defp assignee(%{assignees: [first | _rest]}) when is_map(first) do
436
    %{
437
      name: first["login"] || first[:login],
438
      src: first["avatar_url"] || first[:avatar_url],
439
      presence: :none
440
    }
441
  end
442
443
  defp assignee(_issue), do: nil
444
445
  # GitHub prints who opened an issue beside when. An issue whose author is
446
  # gone still has a history, so it says so rather than showing a blank.
447
  defp author(%{user: %{} = user}), do: user["login"] || user[:login] || "anonymous"
448
  defp author(_issue), do: "anonymous"
449
450
  defp relative(nil), do: nil
451
452
  defp relative(at) do
453
    at = if is_struct(at, NaiveDateTime), do: DateTime.from_naive!(at, "Etc/UTC"), else: at
454
455
    case DateTime.diff(DateTime.utc_now(), at, :second) do
456
      s when s < 3_600 -> "#{max(div(s, 60), 1)}m"
457
      s when s < 86_400 -> "#{div(s, 3_600)}h"
458
      s -> "#{div(s, 86_400)}d"
459
    end
460
  end
461 388
end
lib/openagents_web/live/issue_workspace_live.ex added +286

@@ -0,0 +1,286 @@

1
defmodule OpenAgentsWeb.IssueWorkspaceLive do
2
  @moduledoc """
3
  Every issue you can read, across every repository.
4
5
  The sidebar's **Issues** row points here. It used to point at a repository —
6
  whichever one the page you were on named, or, on a page that named none, the
7
  first repository in your workspace alphabetically — so the same row led
8
  somewhere different depending on where you clicked it from, and disappeared
9
  for an account with no membership. A row in the app's own navigation has to
10
  mean one thing everywhere. `/:owner/:repo/issues` still exists and is
11
  unchanged; it is reached from the repository's own tabs, which is where a
12
  repository-scoped list belongs.
13
14
  ## What it opens on
15
16
  Open issues in every repository you can read, newest first.
17
18
  GitHub's global issue list opens on **assigned to you**, and that is the
19
  right default there because on GitHub an assignee is how work is handed
20
  over. It is the wrong default here. Issues on this forge arrive mostly by
21
  import and through the API, and most of them carry no assignee at all, so
22
  opening on "assigned to you" would show almost every account an empty page
23
  and hide the thing they came to see. A default that hides the answer is
24
  worse than one that shows more than you asked for. Assigned and opened-by
25
  are one control away, and both are honest once the data supports them.
26
27
  ## Authorization
28
29
  Every read goes through `OpenAgents.Issues.list_visible_issues_page/2`,
30
  which joins the issue table to `OpenAgents.Repositories.readable_by/2` — the
31
  one predicate every repository surface composes. There is no second rule
32
  here to fall out of step with the first, and no filter this view offers can
33
  widen the set: filters narrow an already-authorized query.
34
35
  This view is read-only. Changing an issue's state or its assignees needs a
36
  writable membership in that issue's repository, which is a different
37
  question for every row on the page, so triage stays on the repository's own
38
  list where the answer is settled once.
39
40
  ## Bound
41
42
  One page is `OpenAgents.Issues.per_page/0` rows — 25 — and the page number
43
  is clamped to 10,000 by `OpenAgents.Issues.parse_page/1`, so the deepest
44
  reachable offset is fixed no matter what the query string says. Nothing here
45
  loads an unbounded list.
46
  """
47
  use OpenAgentsWeb, :live_view
48
49
  alias OpenAgents.Issues
50
  alias OpenAgents.Repositories
51
  alias OpenAgentsWeb.Components.IssuePresentation
52
  alias OpenAgentsWeb.UI.Circle
53
54
  @involvements [
55
    {"Everyone", "all"},
56
    {"Assigned to you", "assigned"},
57
    {"Opened by you", "created"}
58
  ]
59
60
  def mount(_params, _session, socket) do
61
    {:ok,
62
     socket
63
     |> assign(:current_scope, socket.assigns[:current_scope])
64
     |> assign(:involvements, @involvements)
65
     |> assign(
66
       :any_repository?,
67
       Repositories.any_visible_repository?(socket.assigns.current_user)
68
     )}
69
  end
70
71
  def handle_params(params, _url, socket) do
72
    filters = read_filters(params)
73
74
    {:noreply,
75
     socket
76
     |> assign(:state, normalize_state(params["state"]))
77
     |> assign(:page, Issues.parse_page(params["page"]))
78
     |> assign(:filters, filters)
79
     |> assign(:filter_form, to_form(filters, as: :filter))
80
     |> load()}
81
  end
82
83
  # One form drives every filter, so one change event carries the complete
84
  # desired set and patching replaces it wholesale.
85
  def handle_event("filter", params, socket) do
86
    filters = %{
87
      "involvement" => normalize_involvement(params["involvement"]),
88
      "q" => blank_to_nil(params["q"])
89
    }
90
91
    {:noreply, push_patch(socket, to: issues_path(filters, %{"state" => socket.assigns.state}))}
92
  end
93
94
  def handle_event(_unsupported_event, _params, socket) do
95
    {:noreply, put_flash(socket, :error, "That action is not available here.")}
96
  end
97
98
  defp read_filters(params) do
99
    %{
100
      "involvement" => normalize_involvement(params["involvement"]),
101
      "q" => blank_to_nil(params["q"])
102
    }
103
  end
104
105
  # A hand-edited query string cannot smuggle an option into the context call:
106
  # anything unrecognized becomes the default.
107
  defp normalize_involvement(involvement) when involvement in ~w(assigned created),
108
    do: involvement
109
110
  defp normalize_involvement(_involvement), do: "all"
111
112
  defp normalize_state("closed"), do: "closed"
113
  defp normalize_state("all"), do: "all"
114
  defp normalize_state(_state), do: "open"
115
116
  defp blank_to_nil(""), do: nil
117
  defp blank_to_nil(value), do: value
118
119
  defp issues_path(filters, extra) do
120
    query =
121
      filters
122
      |> Map.merge(extra)
123
      |> Enum.reject(fn {_key, value} -> value in [nil, "", "all"] end)
124
      |> Map.new()
125
126
    ~p"/issues?#{query}"
127
  end
128
129
  defp load(socket) do
130
    user = socket.assigns.current_user
131
    %{filters: filters, state: state, page: page} = socket.assigns
132
133
    opts = involvement_opts(user, filters["involvement"]) ++ [q: filters["q"]]
134
    count_opts = Keyword.put(opts, :page, 1)
135
136
    {issues, total} =
137
      Issues.list_visible_issues_page(user, opts ++ [state: state, page: page])
138
139
    socket
140
    |> assign(:open_count, Issues.count_visible_issues(user, count_opts ++ [state: "open"]))
141
    |> assign(:closed_count, Issues.count_visible_issues(user, count_opts ++ [state: "closed"]))
142
    |> assign(:total_count, total)
143
    |> assign(:issues_count, length(issues))
144
    |> stream(:issues, issues, reset: true)
145
  end
146
147
  # The view's involvement words become the context's own options. "Assigned"
148
  # reads the assignee snapshot by login; "opened by" matches either the
149
  # durable author link or an imported login.
150
  defp involvement_opts(user, "assigned"), do: [assignee: user.github_login]
151
  defp involvement_opts(user, "created"), do: [author: user]
152
  defp involvement_opts(_user, _all), do: []
153
154
  def render(assigns) do
155
    ~H"""
156
    <Layouts.app
157
      flash={@flash}
158
      sidebar_sections={assigns[:sidebar_sections]}
159
      current_scope={@current_scope}
160
      title="Issues"
161
      subtitle="Across every repository you can read"
162
      wide
163
    >
164
      <Circle.issue_toolbar>
165
        <:leading>
166
          <Circle.view_tabs>
167
            <:tab
168
              label={"#{@open_count} Open"}
169
              patch={issues_path(@filters, %{"state" => "open"})}
170
              selected={@state == "open"}
171
            />
172
            <:tab
173
              label={"#{@closed_count} Closed"}
174
              patch={issues_path(@filters, %{"state" => "closed"})}
175
              selected={@state == "closed"}
176
            />
177
          </Circle.view_tabs>
178
        </:leading>
179
180
        <:actions>
181
          <.link navigate={~p"/repositories"} class="btn" data-variant="ghost" data-size="sm">
182
            <.icon name="branch" /> Repositories
183
          </.link>
184
        </:actions>
185
      </Circle.issue_toolbar>
186
187
      <div class="issue-filters">
188
        <.form for={@filter_form} phx-change="filter" id="workspace-issue-filter-form">
189
          <.input
190
            type="search"
191
            name="q"
192
            value={@filters["q"]}
193
            placeholder="Search issues"
194
            aria-label="Search issues"
195
            class="!w-56"
196
          />
197
          <.input
198
            type="select"
199
            name="involvement"
200
            value={@filters["involvement"]}
201
            options={@involvements}
202
            aria-label="Filter by your involvement"
203
          />
204
        </.form>
205
      </div>
206
207
      <%!-- Two different emptinesses, two different next steps. An account
208
      with nowhere to read is not looking at a filter that matched nothing. --%>
209
      <.empty
210
        :if={@issues_count == 0 and not @any_repository?}
211
        id="workspace-issues-no-repositories"
212
        title="No repositories yet"
213
      >
214
        Issues appear here once you can read a repository.
215
        <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
216
          Create one
217
        </.link>
218
        or
219
        <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
220
          import one from GitHub
221
        </.link>
222
        , and its issues arrive with it.
223
      </.empty>
224
225
      <.empty
226
        :if={@issues_count == 0 and @any_repository?}
227
        id="workspace-issues-empty"
228
        title={empty_title(@state, @filters)}
229
      >
230
        {empty_body(@filters)}
231
      </.empty>
232
233
      <div :if={@issues_count > 0} id="workspace-issues" phx-update="stream" class="issue-list">
234
        <IssuePresentation.issue_row
235
          :for={{id, issue} <- @streams.issues}
236
          id={id}
237
          issue={issue}
238
          repository={IssuePresentation.repository_path(issue)}
239
          navigate={~p"/#{issue.repository.owner}/#{issue.repository.name}/issues/#{issue.number}"}
240
        />
241
      </div>
242
243
      <nav :if={@total_count > Issues.per_page()} class="issue-pagination" aria-label="Pages">
244
        <span class="issue-pagination__status">
245
          Showing {@issues_count} of {@total_count}
246
        </span>
247
        <span class="issue-pagination__controls">
248
          <.link
249
            :if={@page > 1}
250
            patch={issues_path(@filters, %{"state" => @state, "page" => @page - 1})}
251
            class="btn"
252
            data-variant="ghost"
253
            data-size="sm"
254
          >
255
            Previous
256
          </.link>
257
          <.link
258
            :if={@page * Issues.per_page() < @total_count}
259
            patch={issues_path(@filters, %{"state" => @state, "page" => @page + 1})}
260
            class="btn"
261
            data-variant="ghost"
262
            data-size="sm"
263
          >
264
            Next
265
          </.link>
266
        </span>
267
      </nav>
268
    </Layouts.app>
269
    """
270
  end
271
272
  defp empty_title(state, %{"involvement" => "assigned"}),
273
    do: "No #{state} issues assigned to you"
274
275
  defp empty_title(state, %{"involvement" => "created"}), do: "No #{state} issues you opened"
276
  defp empty_title(state, _filters), do: "No #{state} issues"
277
278
  defp empty_body(%{"q" => q}) when is_binary(q),
279
    do: "Nothing matches that search. Clear it to see the rest."
280
281
  defp empty_body(%{"involvement" => involvement}) when involvement != "all",
282
    do: "Choose Everyone to see the rest of the issues you can read."
283
284
  defp empty_body(_filters),
285
    do: "Issues from every repository you can read arrive here as they are opened."
286
end
lib/openagents_web/live/project_workspace_live.ex added +230

@@ -0,0 +1,230 @@

1
defmodule OpenAgentsWeb.ProjectWorkspaceLive do
2
  @moduledoc """
3
  Every project you can read, across every repository.
4
5
  The sidebar's **Projects** row points here, for the same reason
6
  `OpenAgentsWeb.IssueWorkspaceLive` exists: the row used to address whichever
7
  repository the current page named, so it led somewhere different depending
8
  on where it was clicked from. `/:owner/:repo/projects` is unchanged and
9
  still owns creating, closing, and deleting a project, because each of those
10
  needs a writable membership in one named repository.
11
12
  ## What it opens on
13
14
  Open projects in every repository you can read, newest first, with the
15
  repository each belongs to beside it. A project's own board is one click
16
  away.
17
18
  ## Authorization
19
20
  `OpenAgents.Projects.list_visible_projects_page/2` joins the project table
21
  to `OpenAgents.Repositories.readable_by/2`, the same predicate every
22
  repository surface composes, so a project in a repository you have no
23
  membership in cannot appear. This view reads and does not write.
24
25
  ## Bound
26
27
  One page is `OpenAgents.Projects.per_page/0` rows — 25 — with the page
28
  number clamped the same way the issue lists clamp theirs.
29
  """
30
  use OpenAgentsWeb, :live_view
31
32
  alias OpenAgents.Projects
33
  alias OpenAgents.Repositories
34
  alias OpenAgentsWeb.UI.Circle
35
36
  @involvements [{"Everyone", "all"}, {"Created by you", "created"}]
37
38
  def mount(_params, _session, socket) do
39
    {:ok,
40
     socket
41
     |> assign(:current_scope, socket.assigns[:current_scope])
42
     |> assign(:involvements, @involvements)
43
     |> assign(
44
       :any_repository?,
45
       Repositories.any_visible_repository?(socket.assigns.current_user)
46
     )}
47
  end
48
49
  def handle_params(params, _url, socket) do
50
    filters = %{"involvement" => normalize_involvement(params["involvement"])}
51
52
    {:noreply,
53
     socket
54
     |> assign(:state, normalize_state(params["state"]))
55
     |> assign(:page, Projects.parse_page(params["page"]))
56
     |> assign(:filters, filters)
57
     |> assign(:filter_form, to_form(filters, as: :filter))
58
     |> load()}
59
  end
60
61
  def handle_event("filter", params, socket) do
62
    filters = %{"involvement" => normalize_involvement(params["involvement"])}
63
64
    {:noreply, push_patch(socket, to: projects_path(filters, %{"state" => socket.assigns.state}))}
65
  end
66
67
  def handle_event(_unsupported_event, _params, socket) do
68
    {:noreply, put_flash(socket, :error, "That action is not available here.")}
69
  end
70
71
  defp normalize_involvement("created"), do: "created"
72
  defp normalize_involvement(_involvement), do: "all"
73
74
  defp normalize_state("closed"), do: "closed"
75
  defp normalize_state("all"), do: "all"
76
  defp normalize_state(_state), do: "open"
77
78
  defp projects_path(filters, extra) do
79
    query =
80
      filters
81
      |> Map.merge(extra)
82
      |> Enum.reject(fn {_key, value} -> value in [nil, "", "all"] end)
83
      |> Map.new()
84
85
    ~p"/projects?#{query}"
86
  end
87
88
  defp load(socket) do
89
    user = socket.assigns.current_user
90
    %{filters: filters, state: state, page: page} = socket.assigns
91
92
    opts = involvement_opts(user, filters["involvement"])
93
94
    {projects, total} =
95
      Projects.list_visible_projects_page(user, opts ++ [state: state, page: page])
96
97
    socket
98
    |> assign(:open_count, Projects.count_visible_projects(user, opts ++ [state: "open"]))
99
    |> assign(:closed_count, Projects.count_visible_projects(user, opts ++ [state: "closed"]))
100
    |> assign(:total_count, total)
101
    |> assign(:projects_count, length(projects))
102
    |> assign(:projects, projects)
103
  end
104
105
  defp involvement_opts(user, "created"), do: [owner: user]
106
  defp involvement_opts(_user, _all), do: []
107
108
  def render(assigns) do
109
    ~H"""
110
    <Layouts.app
111
      flash={@flash}
112
      sidebar_sections={assigns[:sidebar_sections]}
113
      current_scope={@current_scope}
114
      title="Projects"
115
      subtitle="Across every repository you can read"
116
      wide
117
    >
118
      <Circle.issue_toolbar>
119
        <:leading>
120
          <Circle.view_tabs>
121
            <:tab
122
              label={"#{@open_count} Open"}
123
              patch={projects_path(@filters, %{"state" => "open"})}
124
              selected={@state == "open"}
125
            />
126
            <:tab
127
              label={"#{@closed_count} Closed"}
128
              patch={projects_path(@filters, %{"state" => "closed"})}
129
              selected={@state == "closed"}
130
            />
131
          </Circle.view_tabs>
132
        </:leading>
133
134
        <:actions>
135
          <.link navigate={~p"/repositories"} class="btn" data-variant="ghost" data-size="sm">
136
            <.icon name="branch" /> Repositories
137
          </.link>
138
        </:actions>
139
      </Circle.issue_toolbar>
140
141
      <div class="issue-filters">
142
        <.form for={@filter_form} phx-change="filter" id="workspace-project-filter-form">
143
          <.input
144
            type="select"
145
            name="involvement"
146
            value={@filters["involvement"]}
147
            options={@involvements}
148
            aria-label="Filter by your involvement"
149
          />
150
        </.form>
151
      </div>
152
153
      <.empty
154
        :if={@projects_count == 0 and not @any_repository?}
155
        id="workspace-projects-no-repositories"
156
        title="No repositories yet"
157
      >
158
        Projects belong to a repository.
159
        <.link navigate={~p"/repositories/new"} data-variant="link" class="btn px-0">
160
          Create one
161
        </.link>
162
        or
163
        <.link navigate={~p"/repositories/import/github"} data-variant="link" class="btn px-0">
164
          import one from GitHub
165
        </.link>
166
        to start a board.
167
      </.empty>
168
169
      <.empty
170
        :if={@projects_count == 0 and @any_repository?}
171
        id="workspace-projects-empty"
172
        title={empty_title(@state, @filters)}
173
      >
174
        {empty_body(@filters)}
175
      </.empty>
176
177
      <div :if={@projects_count > 0} id="workspace-projects" class="project-index">
178
        <div :for={project <- @projects} class="project-index__row">
179
          <Circle.project_row
180
            name={project.title}
181
            navigate={
182
              ~p"/#{project.repository.owner}/#{project.repository.name}/projects/#{project.number}"
183
            }
184
            status_category={if project.state == "closed", do: :completed, else: :unstarted}
185
            status_label={String.capitalize(project.state)}
186
            labels={[%{name: repository_path(project), tone: :neutral}]}
187
          />
188
        </div>
189
      </div>
190
191
      <nav :if={@total_count > Projects.per_page()} class="issue-pagination" aria-label="Pages">
192
        <span class="issue-pagination__status">
193
          Showing {@projects_count} of {@total_count}
194
        </span>
195
        <span class="issue-pagination__controls">
196
          <.link
197
            :if={@page > 1}
198
            patch={projects_path(@filters, %{"state" => @state, "page" => @page - 1})}
199
            class="btn"
200
            data-variant="ghost"
201
            data-size="sm"
202
          >
203
            Previous
204
          </.link>
205
          <.link
206
            :if={@page * Projects.per_page() < @total_count}
207
            patch={projects_path(@filters, %{"state" => @state, "page" => @page + 1})}
208
            class="btn"
209
            data-variant="ghost"
210
            data-size="sm"
211
          >
212
            Next
213
          </.link>
214
        </span>
215
      </nav>
216
    </Layouts.app>
217
    """
218
  end
219
220
  defp repository_path(%{repository: %{owner: owner, name: name}}), do: "#{owner}/#{name}"
221
222
  defp empty_title(state, %{"involvement" => "created"}), do: "No #{state} projects you created"
223
  defp empty_title(state, _filters), do: "No #{state} projects"
224
225
  defp empty_body(%{"involvement" => "created"}),
226
    do: "Choose Everyone to see the rest of the projects you can read."
227
228
  defp empty_body(_filters),
229
    do: "A project you can read in any repository appears here once it is created."
230
end
lib/openagents_web/route_authority.ex modified +7

@@ -42,6 +42,11 @@ defmodule OpenAgentsWeb.RouteAuthority do

42 42
    "/memory",
43 43
    "/device",
44 44
    "/repositories",
45
    # The workspace-wide issue and project lists. Repository-scoped reading is
46
    # public; reading across every repository is not, because the set being
47
    # read is one person's.
48
    "/issues",
49
    "/projects",
45 50
    "/settings/api-tokens",
46 51
    "/github/connection",
47 52
    "/api/tokens",

@@ -253,6 +258,8 @@ defmodule OpenAgentsWeb.RouteAuthority do

253 258
  defp browser_scope("/data" <> _path), do: "data:self"
254 259
  defp browser_scope("/memory/" <> _path), do: "memory:self"
255 260
  defp browser_scope("/github/connection"), do: "github-tools:self"
261
  defp browser_scope("/issues"), do: "forge:issues:self"
262
  defp browser_scope("/projects"), do: "forge:projects:self"
256 263
  defp browser_scope("/settings/api-tokens"), do: "api-token:self"
257 264
  defp browser_scope(_path), do: "product:self"
258 265
lib/openagents_web/router.ex modified +8

@@ -108,6 +108,14 @@ defmodule OpenAgentsWeb.Router do

108 108
      live "/repositories", RepositoryIndexLive, :index
109 109
      live "/repositories/new", RepositoryNewLive, :new
110 110
      live "/repositories/import/github", RepositoryImportLive, :new
111
112
      # The sidebar's global surfaces. They are workspace-wide by construction:
113
      # each reads through the repository visibility predicate rather than
114
      # through a repository the URL names, so what they show never depends on
115
      # which page you arrived from.
116
      live "/issues", IssueWorkspaceLive, :index
117
      live "/projects", ProjectWorkspaceLive, :index
118
111 119
      live "/:owner/:repo/issues/new", IssueNewLive, :new
112 120
113 121
      live "/:owner/:repo/members", MemberIndexLive, :index
lib/openagents_web/user_auth.ex modified +13 -21

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

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

@@ -59,25 +59,17 @@ 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, 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
    }
62
  #
63
  # It deliberately does not depend on the page's params. It used to carry the
64
  # repository the sidebar's Issues and Projects rows pointed at, read from
65
  # `:owner`/`:repo` when the route had them, so a global nav row silently
66
  # retargeted as you browsed. Those rows now address `/issues` and
67
  # `/projects`, which mean the same thing on every page.
68
  defp scope(user) do
69
    %{user | agent_surfaces?: OpenAgents.Conversations.user_has_messages?(user)}
78 70
  end
79 71
80
  def on_mount(:mount_current_user, params, session, socket) do
72
  def on_mount(:mount_current_user, _params, session, socket) do
81 73
    socket = assign_sidebar_sections(socket, session)
82 74
83 75
    with user_id when is_binary(user_id) <- session[@session_key],

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

85 77
      {:cont,
86 78
       socket
87 79
       |> Phoenix.Component.assign(:current_user, user)
88
       |> Phoenix.Component.assign(:current_scope, scope(user, params))}
80
       |> Phoenix.Component.assign(:current_scope, scope(user))}
89 81
    else
90 82
      _missing_or_inactive ->
91 83
        {:cont,

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

95 87
    end
96 88
  end
97 89
98
  def on_mount(:ensure_authenticated, params, session, socket) do
90
  def on_mount(:ensure_authenticated, _params, session, socket) do
99 91
    socket = assign_sidebar_sections(socket, session)
100 92
101 93
    with user_id when is_binary(user_id) <- session[@session_key],

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

103 95
      {:cont,
104 96
       socket
105 97
       |> Phoenix.Component.assign(:current_user, user)
106
       |> Phoenix.Component.assign(:current_scope, scope(user, params))
98
       |> Phoenix.Component.assign(:current_scope, scope(user))
107 99
       |> Phoenix.LiveView.attach_hook(
108 100
         :active_user_guard,
109 101
         :handle_event,
test/openagents/issues_workspace_test.exs added +162

@@ -0,0 +1,162 @@

1
defmodule OpenAgents.IssuesWorkspaceTest do
2
  @moduledoc """
3
  What a workspace-wide issue read may return, and how much of it.
4
5
  The repository-scoped list is handed a repository the caller has already
6
  authorized. This one authorizes as it reads, so the interesting questions
7
  are the ones a per-repository list never has to ask: whether a private
8
  repository can leak into someone else's result, whether a filter can widen
9
  the set rather than narrow it, and whether the query stays bounded.
10
  """
11
  use OpenAgents.DataCase
12
13
  import OpenAgents.AccountsFixtures
14
15
  alias OpenAgents.Issues
16
  alias OpenAgents.Repo
17
  alias OpenAgents.Repositories
18
19
  setup do
20
    owner = repository_user_fixture("workspace-owner")
21
    member = repository_user_fixture("workspace-member")
22
    outsider = repository_user_fixture("workspace-outsider")
23
24
    private = ready_repository!(owner, "workspace-private", "private")
25
    {:ok, _membership} = Repositories.add_member(private, member, "viewer")
26
27
    {:ok, secret} =
28
      Issues.create_issue(private, %{"title" => "Rotate the signing key"}, owner)
29
30
    %{owner: owner, member: member, outsider: outsider, private: private, secret: secret}
31
  end
32
33
  test "a private repository's issue reaches a member", context do
34
    assert context.secret.id in visible_ids(context.member)
35
    assert context.secret.id in visible_ids(context.owner)
36
  end
37
38
  test "a private repository's issue does not reach a stranger", context do
39
    refute context.secret.id in visible_ids(context.outsider)
40
    assert Issues.count_visible_issues(context.outsider, state: "open") >= 0
41
  end
42
43
  test "a stranger cannot reach it by filtering for it either", context do
44
    for opts <- [
45
          [q: "Rotate the signing key"],
46
          [assignee: context.owner.github_login],
47
          [author: context.owner],
48
          [state: "all"]
49
        ] do
50
      {issues, _total} = Issues.list_visible_issues_page(context.outsider, opts)
51
52
      refute context.secret.id in Enum.map(issues, & &1.id),
53
             "leaked through #{inspect(opts)}"
54
    end
55
  end
56
57
  test "losing membership loses the issue", context do
58
    assert context.secret.id in visible_ids(context.member)
59
60
    Repo.delete_all(
61
      from membership in Repositories.Membership,
62
        where:
63
          membership.repository_id == ^context.private.id and
64
            membership.user_id == ^context.member.id
65
    )
66
67
    refute context.secret.id in visible_ids(context.member)
68
  end
69
70
  test "a public repository's issue reaches everyone", context do
71
    public = ready_repository!(context.owner, "workspace-public", "public")
72
    {:ok, issue} = Issues.create_issue(public, %{"title" => "Public business"}, context.owner)
73
74
    assert issue.id in visible_ids(context.outsider)
75
  end
76
77
  test "assignee and author narrow rather than widen", context do
78
    {:ok, mine} =
79
      Issues.create_issue(
80
        context.private,
81
        %{"title" => "Mine", "assignees" => [context.owner.github_login]},
82
        context.member
83
      )
84
85
    assigned = visible_ids(context.member, assignee: context.owner.github_login)
86
    authored = visible_ids(context.member, author: context.member)
87
88
    assert mine.id in assigned
89
    refute context.secret.id in assigned
90
91
    assert mine.id in authored
92
    refute context.secret.id in authored
93
  end
94
95
  test "an imported issue counts as opened by the login that opened it", context do
96
    {:ok, imported} =
97
      Issues.create_issue(context.private, %{
98
        "title" => "Filed on GitHub",
99
        "user" => %{"login" => context.member.github_login}
100
      })
101
102
    assert imported.author_user_id == nil
103
    assert imported.id in visible_ids(context.member, author: context.member)
104
  end
105
106
  test "the closed state is a filter, not a second query", context do
107
    {:ok, _closed} =
108
      Issues.update_issue(context.secret, %{"state" => "closed"}, context.owner)
109
110
    refute context.secret.id in visible_ids(context.member, state: "open")
111
    assert context.secret.id in visible_ids(context.member, state: "closed")
112
  end
113
114
  test "one page is bounded and the page number is clamped", context do
115
    for index <- 1..(Issues.per_page() + 5) do
116
      {:ok, _issue} =
117
        Issues.create_issue(context.private, %{"title" => "Bulk #{index}"}, context.owner)
118
    end
119
120
    {first, total} = Issues.list_visible_issues_page(context.member, state: "open")
121
122
    assert length(first) == Issues.per_page()
123
    assert total > Issues.per_page()
124
125
    {second, ^total} = Issues.list_visible_issues_page(context.member, state: "open", page: 2)
126
127
    assert second != []
128
    assert Enum.all?(second, &(&1.id not in Enum.map(first, fn issue -> issue.id end)))
129
130
    # A hand-typed page number cannot walk past the ceiling, and a page beyond
131
    # the results is empty rather than an error.
132
    {beyond, ^total} =
133
      Issues.list_visible_issues_page(context.member, state: "open", page: "999999999")
134
135
    assert beyond == []
136
  end
137
138
  test "rows arrive with their repository, because the list has to name it", context do
139
    {[issue | _rest], _total} = Issues.list_visible_issues_page(context.member, state: "open")
140
141
    assert issue.repository.owner == context.private.owner
142
    assert issue.repository.name == context.private.name
143
  end
144
145
  defp visible_ids(user, opts \\ []) do
146
    {issues, _total} = Issues.list_visible_issues_page(user, Keyword.put_new(opts, :state, "all"))
147
    Enum.map(issues, & &1.id)
148
  end
149
150
  defp ready_repository!(owner, name, visibility) do
151
    {:ok, repository, :created} =
152
      Repositories.create_user_repository(
153
        owner,
154
        %{name: name, visibility: visibility},
155
        "#{name}-key"
156
      )
157
158
    repository
159
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
160
    |> Repo.update!()
161
  end
162
end
test/openagents/repository_lifecycle_test.exs modified +20 -2

@@ -153,10 +153,28 @@ defmodule OpenAgents.RepositoryLifecycleTest do

153 153
    viewer_repository_ids = Enum.map(Repositories.list_visible_repositories(viewer), & &1.id)
154 154
    outsider_repository_ids = Enum.map(Repositories.list_visible_repositories(outsider), & &1.id)
155 155
156
    assert public_repository.id in viewer_repository_ids
156
    # A repository is readable through membership at any point in its
157
    # lifecycle, and readable to everyone else only once it is both public and
158
    # provisioned. Both of these are still `provisioning`, so the viewer sees
159
    # only the private one it belongs to, and the stranger sees neither.
160
    #
161
    # `list_visible_repositories/1` used to hold its own copy of the predicate,
162
    # and that copy had lost the lifecycle half, so it disagreed with
163
    # `list_visible_repositories_page/4` and with `get_visible_by_path!/3`
164
    # about the same repository. All of them compose `readable_by/2` now.
165
    refute public_repository.id in viewer_repository_ids
157 166
    assert private_repository.id in viewer_repository_ids
158
    assert public_repository.id in outsider_repository_ids
167
    refute public_repository.id in outsider_repository_ids
159 168
    refute private_repository.id in outsider_repository_ids
169
170
    public_repository
171
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
172
    |> Repo.update!()
173
174
    ready_outsider_ids = Enum.map(Repositories.list_visible_repositories(outsider), & &1.id)
175
176
    assert public_repository.id in ready_outsider_ids
177
    refute private_repository.id in ready_outsider_ids
160 178
  end
161 179
162 180
  test "an organization namespace retains GitHub identity separately from its slug" do
test/openagents_web/live/issue_workspace_live_test.exs added +210

@@ -0,0 +1,210 @@

1
defmodule OpenAgentsWeb.IssueWorkspaceLiveTest do
2
  @moduledoc """
3
  The global issue list at `/issues`.
4
5
  What matters here is that the page means the same thing wherever you reach
6
  it from, that it names the repository each row belongs to, that a private
7
  repository stays private in both directions, and that both kinds of
8
  emptiness explain themselves.
9
  """
10
  use OpenAgentsWeb.ConnCase, async: false
11
12
  import Ecto.Query
13
  import Phoenix.LiveViewTest
14
15
  alias OpenAgents.Issues
16
  alias OpenAgents.Repo
17
  alias OpenAgents.Repositories
18
  alias OpenAgents.Repositories.Membership
19
  alias OpenAgents.Repositories.Repository
20
21
  setup %{conn: conn} do
22
    member = github_user("workspace-issues-member")
23
    owner = github_user("workspace-issues-owner")
24
25
    private = ready_repository!(owner, "member-only", "private")
26
    {:ok, _membership} = Repositories.add_member(private, member, "contributor")
27
28
    {:ok, secret} = Issues.create_issue(private, %{"title" => "Rotate the signing key"}, owner)
29
30
    %{
31
      conn: Plug.Test.init_test_session(conn, %{"user_id" => member.id}),
32
      member: member,
33
      owner: owner,
34
      private: private,
35
      secret: secret
36
    }
37
  end
38
39
  test "lists issues from every repository you can read, naming each one", context do
40
    {:ok, view, html} = live(context.conn, ~p"/issues")
41
42
    assert html =~ "Rotate the signing key"
43
    assert has_element?(view, "#workspace-issues")
44
45
    assert has_element?(
46
             view,
47
             ~s{a[href="/#{context.private.owner}/#{context.private.name}/issues/#{context.secret.number}"]},
48
             "Rotate the signing key"
49
           )
50
51
    # A cross-repository list is unreadable without the repository, so the row
52
    # carries it in the scan column rather than at the trailing edge.
53
    assert has_element?(
54
             view,
55
             ".issue-row__repository",
56
             "#{context.private.owner}/#{context.private.name}"
57
           )
58
  end
59
60
  test "a stranger to a private repository never sees its issues", context do
61
    stranger = github_user("workspace-issues-stranger")
62
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
63
64
    {:ok, view, html} = live(conn, ~p"/issues")
65
66
    refute html =~ "Rotate the signing key"
67
    refute issue_linked?(view, context.private, context.secret)
68
  end
69
70
  test "a stranger cannot reach it through the search filter either", context do
71
    stranger = github_user("workspace-issues-searcher")
72
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
73
74
    {:ok, view, html} = live(conn, ~p"/issues?q=Rotate+the+signing+key")
75
76
    # The search box echoes the needle, so the title alone is not the test:
77
    # what matters is that no row links to the issue.
78
    refute issue_linked?(view, context.private, context.secret)
79
    assert html =~ "Nothing matches that search"
80
  end
81
82
  test "the open and closed tabs each carry their own count", context do
83
    {:ok, closable} = Issues.create_issue(context.private, %{"title" => "Already done"})
84
    {:ok, _closed} = Issues.update_issue(closable, %{"state" => "closed"}, context.owner)
85
86
    {:ok, view, _html} = live(context.conn, ~p"/issues")
87
88
    assert has_element?(view, ~s{a[href="/issues?state=open"][aria-current]})
89
    assert render(view) =~ "Rotate the signing key"
90
    refute render(view) =~ "Already done"
91
92
    closed = render_patch(view, ~p"/issues?state=closed")
93
94
    assert closed =~ "Already done"
95
    refute closed =~ "Rotate the signing key"
96
  end
97
98
  test "the assigned filter shows only what is assigned to you", context do
99
    {:ok, _mine} =
100
      Issues.create_issue(
101
        context.private,
102
        %{"title" => "Mine to do", "assignees" => [context.member.github_login]}
103
      )
104
105
    {:ok, view, _html} = live(context.conn, ~p"/issues")
106
107
    assigned =
108
      view
109
      |> form("#workspace-issue-filter-form", %{"involvement" => "assigned", "q" => ""})
110
      |> render_change()
111
112
    assert assigned =~ "Mine to do"
113
    refute assigned =~ "Rotate the signing key"
114
  end
115
116
  test "the opened-by filter shows only what you opened", context do
117
    {:ok, _mine} =
118
      Issues.create_issue(context.private, %{"title" => "I filed this"}, context.member)
119
120
    {:ok, view, _html} = live(context.conn, ~p"/issues")
121
122
    created =
123
      view
124
      |> form("#workspace-issue-filter-form", %{"involvement" => "created", "q" => ""})
125
      |> render_change()
126
127
    assert created =~ "I filed this"
128
    refute created =~ "Rotate the signing key"
129
  end
130
131
  test "a filter value the URL invents falls back to everyone", context do
132
    {:ok, _view, html} = live(context.conn, ~p"/issues?involvement=everything")
133
134
    assert html =~ "Rotate the signing key"
135
  end
136
137
  test "repositories with no matching issues get an empty state that says so", %{conn: conn} do
138
    {:ok, view, html} = live(conn, ~p"/issues?q=nothing-matches-this")
139
140
    assert has_element?(view, "#workspace-issues-empty")
141
    assert html =~ "No open issues"
142
    assert html =~ "Nothing matches that search"
143
    refute has_element?(view, "#workspace-issues-no-repositories")
144
  end
145
146
  test "an account that can read no repository is told that instead", %{} do
147
    stranger = github_user("workspace-issues-repoless")
148
149
    Repo.delete_all(from membership in Membership, where: membership.user_id == ^stranger.id)
150
    Repo.update_all(Repository, set: [visibility: "private"])
151
152
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
153
    {:ok, view, html} = live(conn, ~p"/issues")
154
155
    assert has_element?(view, "#workspace-issues-no-repositories")
156
    assert html =~ "No repositories yet"
157
    assert has_element?(view, ~s{a[href="/repositories/new"]})
158
    refute has_element?(view, "#workspace-issues-empty")
159
  end
160
161
  test "the list is bounded and pages rather than loading everything", context do
162
    # Zero-padded, so "Bulk 001" is never a substring of "Bulk 010".
163
    bulk = for index <- 1..(Issues.per_page() + 3), do: bulk_title(index)
164
165
    for title <- bulk do
166
      {:ok, _issue} = Issues.create_issue(context.private, %{"title" => title})
167
    end
168
169
    newest = List.last(bulk)
170
    oldest = List.first(bulk)
171
172
    {:ok, view, html} = live(context.conn, ~p"/issues")
173
174
    assert html =~ "Showing #{Issues.per_page()} of #{length(bulk) + 1}"
175
    assert html =~ newest
176
    refute html =~ oldest
177
    assert has_element?(view, ~s{a[href="/issues?page=2&state=open"]}, "Next")
178
179
    second = render_patch(view, ~p"/issues?state=open&page=2")
180
181
    assert second =~ oldest
182
    refute second =~ newest
183
  end
184
185
  test "a signed-out visitor is sent home rather than shown the list", %{} do
186
    assert {:error, {:redirect, %{to: "/"}}} = live(build_conn(), ~p"/issues")
187
  end
188
189
  defp bulk_title(index), do: "Bulk #{String.pad_leading(to_string(index), 3, "0")}"
190
191
  defp issue_linked?(view, repository, issue) do
192
    has_element?(
193
      view,
194
      ~s{a[href="/#{repository.owner}/#{repository.name}/issues/#{issue.number}"]}
195
    )
196
  end
197
198
  defp ready_repository!(owner, name, visibility) do
199
    {:ok, repository, :created} =
200
      Repositories.create_user_repository(
201
        owner,
202
        %{name: name, visibility: visibility},
203
        "#{name}-key"
204
      )
205
206
    repository
207
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
208
    |> Repo.update!()
209
  end
210
end
test/openagents_web/live/project_workspace_live_test.exs added +165

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

1
defmodule OpenAgentsWeb.ProjectWorkspaceLiveTest do
2
  @moduledoc """
3
  The global project list at `/projects`, held to the same rules as `/issues`:
4
  one meaning wherever you reach it from, the repository named on every row,
5
  no leakage across a private repository in either direction, and a bound.
6
  """
7
  use OpenAgentsWeb.ConnCase, async: false
8
9
  import Ecto.Query
10
  import Phoenix.LiveViewTest
11
12
  alias OpenAgents.Projects
13
  alias OpenAgents.Repo
14
  alias OpenAgents.Repositories
15
  alias OpenAgents.Repositories.Membership
16
  alias OpenAgents.Repositories.Repository
17
18
  setup %{conn: conn} do
19
    member = github_user("workspace-projects-member")
20
    owner = github_user("workspace-projects-owner")
21
22
    private = ready_repository!(owner, "board-only", "private")
23
    {:ok, _membership} = Repositories.add_member(private, member, "contributor")
24
25
    {:ok, secret} = Projects.create_project(private, %{"title" => "Key rotation"}, owner)
26
27
    %{
28
      conn: Plug.Test.init_test_session(conn, %{"user_id" => member.id}),
29
      member: member,
30
      owner: owner,
31
      private: private,
32
      secret: secret
33
    }
34
  end
35
36
  test "lists projects from every repository you can read, naming each one", context do
37
    {:ok, view, html} = live(context.conn, ~p"/projects")
38
39
    assert html =~ "Key rotation"
40
    assert has_element?(view, "#workspace-projects")
41
    assert project_linked?(view, context.private, context.secret)
42
    assert html =~ "#{context.private.owner}/#{context.private.name}"
43
  end
44
45
  test "a stranger to a private repository never sees its projects", context do
46
    stranger = github_user("workspace-projects-stranger")
47
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
48
49
    {:ok, view, html} = live(conn, ~p"/projects")
50
51
    refute html =~ "Key rotation"
52
    refute project_linked?(view, context.private, context.secret)
53
  end
54
55
  test "the open and closed tabs each carry their own count", context do
56
    {:ok, closable} =
57
      Projects.create_project(context.private, %{"title" => "Shipped"}, context.owner)
58
59
    {:ok, _closed} = Projects.update_project(closable, %{"state" => "closed"})
60
61
    {:ok, view, _html} = live(context.conn, ~p"/projects")
62
63
    assert has_element?(view, ~s{a[href="/projects?state=open"][aria-current]})
64
    refute render(view) =~ "Shipped"
65
66
    closed = render_patch(view, ~p"/projects?state=closed")
67
68
    assert closed =~ "Shipped"
69
    refute closed =~ "Key rotation"
70
  end
71
72
  test "the created-by filter shows only what you created", context do
73
    {:ok, _mine} =
74
      Projects.create_project(context.private, %{"title" => "Mine to run"}, context.member)
75
76
    {:ok, view, _html} = live(context.conn, ~p"/projects")
77
78
    created =
79
      view
80
      |> form("#workspace-project-filter-form", %{"involvement" => "created"})
81
      |> render_change()
82
83
    assert created =~ "Mine to run"
84
    refute created =~ "Key rotation"
85
  end
86
87
  test "a filter value the URL invents falls back to everyone", context do
88
    {:ok, _view, html} = live(context.conn, ~p"/projects?involvement=everything")
89
90
    assert html =~ "Key rotation"
91
  end
92
93
  test "repositories with no matching projects get an empty state that says so", %{conn: conn} do
94
    {:ok, view, html} = live(conn, ~p"/projects?state=closed")
95
96
    assert has_element?(view, "#workspace-projects-empty")
97
    assert html =~ "No closed projects"
98
    refute has_element?(view, "#workspace-projects-no-repositories")
99
  end
100
101
  test "an account that can read no repository is told that instead", %{} do
102
    stranger = github_user("workspace-projects-repoless")
103
104
    Repo.delete_all(from membership in Membership, where: membership.user_id == ^stranger.id)
105
    Repo.update_all(Repository, set: [visibility: "private"])
106
107
    conn = Plug.Test.init_test_session(build_conn(), %{"user_id" => stranger.id})
108
    {:ok, view, html} = live(conn, ~p"/projects")
109
110
    assert has_element?(view, "#workspace-projects-no-repositories")
111
    assert html =~ "No repositories yet"
112
    assert has_element?(view, ~s{a[href="/repositories/import/github"]})
113
    refute has_element?(view, "#workspace-projects-empty")
114
  end
115
116
  test "the list is bounded and pages rather than loading everything", context do
117
    bulk = for index <- 1..(Projects.per_page() + 3), do: bulk_title(index)
118
119
    for title <- bulk do
120
      {:ok, _project} =
121
        Projects.create_project(context.private, %{"title" => title}, context.owner)
122
    end
123
124
    newest = List.last(bulk)
125
    oldest = List.first(bulk)
126
127
    {:ok, view, html} = live(context.conn, ~p"/projects")
128
129
    assert html =~ "Showing #{Projects.per_page()} of #{length(bulk) + 1}"
130
    assert html =~ newest
131
    refute html =~ oldest
132
    assert has_element?(view, ~s{a[href="/projects?page=2&state=open"]}, "Next")
133
134
    second = render_patch(view, ~p"/projects?state=open&page=2")
135
136
    assert second =~ oldest
137
    refute second =~ newest
138
  end
139
140
  test "a signed-out visitor is sent home rather than shown the list", %{} do
141
    assert {:error, {:redirect, %{to: "/"}}} = live(build_conn(), ~p"/projects")
142
  end
143
144
  defp bulk_title(index), do: "Board #{String.pad_leading(to_string(index), 3, "0")}"
145
146
  defp project_linked?(view, repository, project) do
147
    has_element?(
148
      view,
149
      ~s{a[href="/#{repository.owner}/#{repository.name}/projects/#{project.number}"]}
150
    )
151
  end
152
153
  defp ready_repository!(owner, name, visibility) do
154
    {:ok, repository, :created} =
155
      Repositories.create_user_repository(
156
        owner,
157
        %{name: name, visibility: visibility},
158
        "#{name}-key"
159
      )
160
161
    repository
162
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
163
    |> Repo.update!()
164
  end
165
end
test/openagents_web/sidebar_state_test.exs modified +47 -9

@@ -92,8 +92,16 @@ defmodule OpenAgentsWeb.SidebarStateTest do

92 92
    end
93 93
  end
94 94
95
  describe "repository navigation" do
96
    test "repository pages keep Issues and Projects in the current repository", %{conn: conn} do
95
  # The Issues and Projects rows used to be repository-scoped: they took the
96
  # `:owner`/`:repo` of whatever page you were on, and fell back to the first
97
  # repository in your workspace alphabetically. The same row therefore led
98
  # somewhere different depending on where you clicked it from, and vanished
99
  # for an account with no membership. They are global now, and these tests
100
  # say so from both directions.
101
  describe "global navigation" do
102
    test "a repository page does not retarget Issues and Projects at that repository", %{
103
      conn: conn
104
    } do
97 105
      user = github_user("sidebar-current-repository")
98 106
      conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
99 107

@@ -107,20 +115,21 @@ defmodule OpenAgentsWeb.SidebarStateTest do

107 115
      {:ok, _membership} = OpenAgents.Repositories.add_member(repository, user, "owner")
108 116
      {:ok, view, _html} = live(conn, ~p"/navigation-owner/navigation-repository/issues")
109 117
110
      assert has_element?(
118
      assert has_element?(view, ~s(#sidebar a[href="/issues"]))
119
      assert has_element?(view, ~s(#sidebar a[href="/projects"]))
120
121
      refute has_element?(
111 122
               view,
112 123
               ~s(#sidebar a[href="/navigation-owner/navigation-repository/issues"])
113 124
             )
114 125
115
      assert has_element?(
126
      refute has_element?(
116 127
               view,
117 128
               ~s(#sidebar a[href="/navigation-owner/navigation-repository/projects"])
118 129
             )
119 130
    end
120 131
121
    test "an account with no visible repository gets no dead Issues or Projects link", %{
122
      conn: conn
123
    } do
132
    test "an account with no repository still gets Issues and Projects", %{conn: conn} do
124 133
      user = github_user("sidebar-no-repository")
125 134
126 135
      OpenAgents.Repo.delete_all(

@@ -132,8 +141,37 @@ defmodule OpenAgentsWeb.SidebarStateTest do

132 141
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
133 142
134 143
      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")
144
      assert has_element?(view, ~s(#sidebar a[href="/issues"][aria-label="Issues"]))
145
      assert has_element?(view, ~s(#sidebar a[href="/projects"][aria-label="Projects"]))
146
    end
147
148
    test "the rows read the same from a page in a repository and a page outside one", %{
149
      conn: conn
150
    } do
151
      user = github_user("sidebar-stable-rows")
152
      conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
153
154
      for {owner, name} <- [
155
            {"alpha-owner", "alpha-repository"},
156
            {"zulu-owner", "zulu-repository"}
157
          ] do
158
        {:ok, repository} =
159
          OpenAgents.Repositories.create_repository(%{
160
            owner: owner,
161
            name: name,
162
            visibility: "private"
163
          })
164
165
        {:ok, _membership} = OpenAgents.Repositories.add_member(repository, user, "owner")
166
      end
167
168
      {:ok, from_repository, _html} = live(conn, ~p"/zulu-owner/zulu-repository/issues")
169
      {:ok, from_elsewhere, _html} = live(conn, ~p"/leaderboard")
170
171
      for view <- [from_repository, from_elsewhere] do
172
        assert has_element?(view, ~s(#sidebar a[href="/issues"]))
173
        assert has_element?(view, ~s(#sidebar a[href="/projects"]))
174
      end
137 175
    end
138 176
  end
139 177

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