Add an org dashboard at /orgs/:handle/dashboard

8041c34 · Gabriel Morell · 2026-09-11 15:44

19 files +732 -111
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -30,6 +30,7 @@ reinvent them.
30 30 - Per-user editor theme (curated subset of Lumis themes — Dracula, Solarized, Monokai, Tokyo Night, Catppuccin, etc) — separate from the daisyUI page theme
31 31 - **Discovery**
32 32 - **Dashboard at `/`** for signed-in visitors — PRs waiting on your review (asked of you first, maintainer inference as a fallback), your open PRs and issues, CI, pinned and recently-pushed repos, org invitations. Logged-out visitors get the landing page
33 + - **Org dashboard at `/orgs/:handle/dashboard`** — the same panels narrowed to one organization, and reframed: the landing page is about *your* work, an org's is about the org's, so everyone's open PRs and issues show alongside yours. Scoped by the org's visibility rules, so a panel never surfaces a repo you couldn't have opened yourself
33 34 - **Topic tags** — up to seven per repo, plus one derived automatically from its primary language and kept current by the stats worker. Browse at `/tags`, filter the repo listing by them
34 35 - **Syms** — stars, but with a configurable set of symbols; instance-wide rankings at `/syms`
35 36 - Repository and organization listings as cards with filter, sort and scope, paginated and deep-linkable (`/repositories/2`, `/orgs/page/2`)
modified lib/git_gud/issues.ex
+27 −0
@@ -69,6 +69,33 @@ defmodule GitGud.Issues do
69 69 |> Repo.all()
70 70 end
71 71
72 + @doc """
73 + Every open issue in `repo_ids`, newest first.
74 +
75 + Unlike `list_open_authored_by/3` this does no visibility widening of
76 + its own — the caller has already decided which repositories the
77 + viewer may see, which is what makes it safe to show other people's
78 + work rather than only the viewer's.
79 +
80 + `:author_id` narrows to one author, for a "your work here" panel.
81 + """
82 + def list_open_in_repos(repo_ids, opts \\ []) when is_list(repo_ids) do
83 + limit = Keyword.get(opts, :limit, 10)
84 +
85 + from(i in Issue,
86 + join: r in assoc(i, :repository),
87 + where: i.repository_id in ^repo_ids and i.state == "open",
88 + order_by: [desc: i.inserted_at],
89 + limit: ^limit,
90 + preload: [:author, repository: {r, [:owner, :organization]}]
91 + )
92 + |> filter_author(Keyword.get(opts, :author_id))
93 + |> Repo.all()
94 + end
95 +
96 + defp filter_author(query, nil), do: query
97 + defp filter_author(query, author_id), do: where(query, [i], i.author_id == ^author_id)
98 +
72 99 defp filter_state(q, "all"), do: q
73 100
74 101 defp filter_state(q, state) when state in ["open", "closed"],
modified lib/git_gud/pull_requests.ex
+37 −0
@@ -1038,6 +1038,10 @@ defmodule GitGud.PullRequests do
1038 1038
1039 1039 Unlike `list_open_awaiting_review/3` this is not inferred from
1040 1040 maintainer status — someone actually asked.
1041 +
1042 + Pass `:repo_ids` to narrow to a set of repositories; the org
1043 + dashboard does, so "waiting on you" there means "waiting on you *in
1044 + this org*" rather than everywhere.
1041 1045 """
1042 1046 def list_open_review_requests(user_id, opts \\ []) when is_integer(user_id) do
1043 1047 limit = Keyword.get(opts, :limit, 10)
@@ -1051,9 +1055,42 @@ defmodule GitGud.PullRequests do
1051 1055 limit: ^limit,
1052 1056 preload: [:author, repository: {r, [:owner, :organization]}]
1053 1057 )
1058 + |> scope_to_repos(Keyword.get(opts, :repo_ids))
1054 1059 |> Repo.all()
1055 1060 end
1056 1061
1062 + @doc """
1063 + Every open PR in `repo_ids`, newest first.
1064 +
1065 + Unlike `list_open_authored_by/3` this does no visibility widening of
1066 + its own — the caller has already decided which repositories the
1067 + viewer may see, which is what makes it safe to show other people's
1068 + work rather than only the viewer's.
1069 +
1070 + `:author_id` narrows to one author, for a "your work here" panel.
1071 + """
1072 + def list_open_in_repos(repo_ids, opts \\ []) when is_list(repo_ids) do
1073 + limit = Keyword.get(opts, :limit, 10)
1074 +
1075 + from(p in PullRequest,
1076 + join: r in assoc(p, :repository),
1077 + where: p.repository_id in ^repo_ids and p.state == "open",
1078 + order_by: [desc: p.inserted_at],
1079 + limit: ^limit,
1080 + preload: [:author, repository: {r, [:owner, :organization]}]
1081 + )
1082 + |> filter_author(Keyword.get(opts, :author_id))
1083 + |> Repo.all()
1084 + end
1085 +
1086 + defp filter_author(query, nil), do: query
1087 + defp filter_author(query, author_id), do: where(query, [p], p.author_id == ^author_id)
1088 +
1089 + defp scope_to_repos(query, nil), do: query
1090 +
1091 + defp scope_to_repos(query, repo_ids) when is_list(repo_ids),
1092 + do: where(query, [p], p.repository_id in ^repo_ids)
1093 +
1057 1094 @doc """
1058 1095 Reviewers whose standing verdict is "changes requested".
1059 1096
added lib/git_gud_web/components/dashboard_components.ex
+161 −0
@@ -0,0 +1,161 @@
1 +defmodule GitGudWeb.DashboardComponents do
2 + @moduledoc """
3 + The panels a dashboard is made of, and the small display helpers that
4 + go with them.
5 +
6 + Two dashboards use these: `GitGudWeb.PageLive.Home` (everything the
7 + viewer can reach) and `GitGudWeb.OrgLive.Dashboard` (the same slice,
8 + narrowed to one organization). They differ only in what they query;
9 + they should never differ in how a row of work looks, which is why
10 + this lives here rather than being written twice.
11 + """
12 + use GitGudWeb, :html
13 +
14 + alias GitGud.Repositories.Storage
15 +
16 + @doc """
17 + A titled, counted list panel. Renders the empty-state copy instead of
18 + the list when `count` is zero, so callers don't each re-invent "or
19 + else say nothing's here".
20 + """
21 + attr :title, :string, required: true
22 + attr :hint, :string, default: nil
23 + attr :empty, :string, required: true
24 + attr :count, :integer, required: true
25 + attr :more_label, :string, default: nil
26 + attr :more_path, :string, default: nil
27 + slot :inner_block, required: true
28 +
29 + def panel(assigns) do
30 + ~H"""
31 + <section class="rounded-lg border border-base-300 p-4">
32 + <header class="flex items-baseline justify-between gap-2 mb-2">
33 + <div class="min-w-0">
34 + <h2 class="font-semibold">
35 + {@title}
36 + <span :if={@count > 0} class="badge badge-sm badge-ghost ml-1">{@count}</span>
37 + </h2>
38 + <p :if={@hint} class="text-xs opacity-60">{@hint}</p>
39 + </div>
40 + <.link
41 + :if={@more_path}
42 + navigate={@more_path}
43 + class="text-xs link link-hover opacity-60 shrink-0"
44 + >
45 + {@more_label} →
46 + </.link>
47 + </header>
48 +
49 + <p :if={@count == 0} class="py-4 text-sm opacity-50">{@empty}</p>
50 +
51 + <ul :if={@count > 0} class="divide-y divide-base-300">
52 + {render_slot(@inner_block)}
53 + </ul>
54 + </section>
55 + """
56 + end
57 +
58 + @doc "One issue or pull request, as a row inside a `panel/1`."
59 + attr :path, :string, required: true
60 + attr :repo, :string, required: true
61 + attr :number, :integer, required: true
62 + attr :title, :string, required: true
63 + attr :meta, :string, required: true
64 +
65 + def work_row(assigns) do
66 + ~H"""
67 + <li class="py-2 min-w-0">
68 + <.link navigate={@path} class="text-sm link link-hover line-clamp-1">{@title}</.link>
69 + <p class="text-xs opacity-60 mt-0.5 truncate">
70 + <span class="font-mono">{@repo}#{@number}</span> · {@meta}
71 + </p>
72 + </li>
73 + """
74 + end
75 +
76 + @doc "One workflow run, as a row inside a `panel/1`."
77 + attr :run, :map, required: true
78 +
79 + def ci_row(assigns) do
80 + ~H"""
81 + <li class="py-2 flex items-baseline gap-2 min-w-0">
82 + <span class={["badge badge-xs shrink-0", ci_badge(@run.status)]}>{@run.status}</span>
83 + <.link
84 + navigate={
85 + ~p"/r/#{repo_handle(@run.repository)}/#{@run.repository.name}/actions/#{@run.number}"
86 + }
87 + class="text-sm link link-hover truncate"
88 + >
89 + {@run.workflow_name || "run ##{@run.number}"}
90 + </.link>
91 + <span class="text-xs opacity-60 font-mono truncate">{repo_path(@run.repository)}</span>
92 + <span class="text-xs opacity-50 ml-auto shrink-0">{ago(@run.inserted_at)}</span>
93 + </li>
94 + """
95 + end
96 +
97 + @doc "One repository, as a row in a recently-pushed list."
98 + attr :repo, :map, required: true
99 +
100 + def repo_row(assigns) do
101 + ~H"""
102 + <li class="py-2 flex items-baseline gap-2 min-w-0">
103 + <.link
104 + navigate={~p"/r/#{repo_handle(@repo)}/#{@repo.name}"}
105 + class="font-mono text-sm link link-hover truncate"
106 + >
107 + {repo_path(@repo)}
108 + </.link>
109 + <span :if={@repo.description} class="text-xs opacity-60 truncate">
110 + {@repo.description}
111 + </span>
112 + <span class="text-xs opacity-50 ml-auto shrink-0">{ago(@repo.pushed_at)}</span>
113 + </li>
114 + """
115 + end
116 +
117 + # ── display helpers ──────────────────────────────────────────────────
118 +
119 + @doc "The namespace handle a repository lives under."
120 + def repo_handle(%{organization: %{handle: h}}) when is_binary(h), do: h
121 + def repo_handle(%{owner: user}), do: Storage.owner_handle(user)
122 +
123 + @doc "`owner/name`, for the monospace crumbs on every row."
124 + def repo_path(repo), do: "#{repo_handle(repo)}/#{repo.name}"
125 +
126 + @doc "daisyUI badge class for a workflow run status."
127 + def ci_badge("success"), do: "badge-success"
128 + def ci_badge("failure"), do: "badge-error"
129 + def ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning"
130 + def ci_badge(_), do: "badge-ghost"
131 +
132 + @doc """
133 + Who opened something. Federated rows carry a `source_actor` and no
134 + local author, so this deliberately doesn't assume one.
135 + """
136 + def author_name(%{author: %{handle: h}}) when is_binary(h), do: h
137 + def author_name(_), do: "someone"
138 +
139 + @doc "Coarse relative time. Precision past a day isn't worth a row of chrome."
140 + def ago(nil), do: ""
141 +
142 + def ago(%DateTime{} = dt) do
143 + case DateTime.diff(DateTime.utc_now(), dt) do
144 + s when s < 60 -> "just now"
145 + s when s < 3600 -> "#{div(s, 60)}m ago"
146 + s when s < 86_400 -> "#{div(s, 3600)}h ago"
147 + s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
148 + s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago"
149 + s -> "#{div(s, 31_536_000)}y ago"
150 + end
151 + end
152 +
153 + @doc """
154 + What to call the review panel's inference.
155 +
156 + Once someone has actually been asked, say so; otherwise the panel is
157 + still guessing from maintainer status and should admit it.
158 + """
159 + def review_hint([]), do: "Open in repos you maintain, opened by someone else"
160 + def review_hint(_requested), do: "Requested of you, plus repos you maintain"
161 +end
added lib/git_gud_web/live/org_live/dashboard.ex
+288 −0
@@ -0,0 +1,288 @@
1 +defmodule GitGudWeb.OrgLive.Dashboard do
2 + @moduledoc """
3 + `/orgs/:handle/dashboard` — the signed-in dashboard, narrowed to one
4 + organization.
5 +
6 + Same panels as `GitGudWeb.PageLive.Home`, same components, one
7 + difference in framing: the landing dashboard is about *you*, so it
8 + only ever shows work you opened. An org dashboard is about the
9 + organization, so it shows everyone's open work alongside yours —
10 + otherwise it would be the landing page with fewer rows on it, which
11 + is not worth a route.
12 +
13 + Scoped by `Repositories.visible_repo_ids_for_org/2`, so every panel
14 + inherits the org's visibility rules and a member of a private org
15 + sees what they may see and nothing else. Non-members can reach a
16 + public org's dashboard; everything on it is already public.
17 + """
18 +
19 + use GitGudWeb, :live_view
20 +
21 + import GitGudWeb.DashboardComponents
22 +
23 + alias GitGud.Issues
24 + alias GitGud.Organizations
25 + alias GitGud.Organizations.Organization
26 + alias GitGud.Profiles
27 + alias GitGud.PullRequests
28 + alias GitGud.Repositories
29 + alias GitGud.Workflows
30 +
31 + @panel_limit 6
32 +
33 + @impl true
34 + def mount(%{"handle" => handle}, _session, socket) do
35 + org = Organizations.get_organization_by_handle!(handle)
36 + user = socket.assigns.current_scope.user
37 +
38 + if can_view?(org, user) do
39 + if connected?(socket),
40 + do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
41 +
42 + {:ok,
43 + socket
44 + |> assign(:org, org)
45 + |> assign(:user, user)
46 + |> assign(:is_admin?, Organizations.org_admin?(org, user))
47 + |> assign(:repo_ids, Repositories.visible_repo_ids_for_org(org, user))
48 + |> assign(:pins, Profiles.list_pins(org))
49 + |> assign(:page_title, "Dashboard — @#{org.handle}")
50 + |> load_work()
51 + |> load_repos()
52 + |> load_ci()}
53 + else
54 + {:ok,
55 + socket
56 + |> put_flash(:error, "You're not authorized to view this organization.")
57 + |> push_navigate(to: ~p"/")}
58 + end
59 + end
60 +
61 + # Mirrors `GitGudWeb.OrgLive.Actions` — the two org-wide views should
62 + # never disagree about who may look.
63 + defp can_view?(%{visibility: "public"}, _user), do: true
64 + defp can_view?(%{visibility: "internal"}, %{}), do: true
65 + defp can_view?(%{visibility: "private"} = org, %{} = user), do: Organizations.member?(org, user)
66 + defp can_view?(_, _), do: false
67 +
68 + defp load_work(socket) do
69 + %{user: user, repo_ids: ids} = socket.assigns
70 +
71 + # `maintained ∩ org` rather than all maintained: the point of this
72 + # page is that everything on it is about this organization.
73 + maintained =
74 + user
75 + |> Repositories.maintained_repo_ids_for_user()
76 + |> intersect(ids)
77 +
78 + socket
79 + |> assign(
80 + :requested_reviews,
81 + PullRequests.list_open_review_requests(user.id, repo_ids: ids, limit: @panel_limit)
82 + )
83 + |> assign(
84 + :review_queue,
85 + PullRequests.list_open_awaiting_review(user.id, maintained, limit: @panel_limit)
86 + )
87 + |> assign(
88 + :my_prs,
89 + PullRequests.list_open_in_repos(ids, author_id: user.id, limit: @panel_limit)
90 + )
91 + |> assign(:my_issues, Issues.list_open_in_repos(ids, author_id: user.id, limit: @panel_limit))
92 + |> assign(:open_prs, PullRequests.list_open_in_repos(ids, limit: @panel_limit))
93 + |> assign(:open_issues, Issues.list_open_in_repos(ids, limit: @panel_limit))
94 + end
95 +
96 + defp intersect(a, b) do
97 + set = MapSet.new(b)
98 + Enum.filter(a, &MapSet.member?(set, &1))
99 + end
100 +
101 + defp load_repos(socket) do
102 + assign(
103 + socket,
104 + :recent_repos,
105 + Repositories.list_recently_pushed(socket.assigns.repo_ids, limit: @panel_limit)
106 + )
107 + end
108 +
109 + defp load_ci(socket) do
110 + ids = socket.assigns.repo_ids
111 +
112 + socket
113 + |> assign(
114 + :active_runs,
115 + Workflows.list_runs_for_repos(ids, statuses: Workflows.active_statuses())
116 + )
117 + |> assign(:recent_runs, Workflows.list_runs_for_repos(ids, limit: @panel_limit))
118 + end
119 +
120 + @impl true
121 + def handle_info({:ci_run_changed, _run_id}, socket), do: {:noreply, load_ci(socket)}
122 + def handle_info(_msg, socket), do: {:noreply, socket}
123 +
124 + @impl true
125 + def render(assigns) do
126 + ~H"""
127 + <Layouts.app flash={@flash} current_scope={@current_scope}>
128 + <div class="space-y-6 pt-4">
129 + <header class="flex items-baseline justify-between gap-4 flex-wrap">
130 + <div class="flex items-center gap-3 min-w-0">
131 + <.avatar name={@org.handle} size="md" alt={@org.handle} />
132 + <div class="min-w-0">
133 + <h1 class="text-2xl font-semibold truncate">
134 + {Organization.display(@org)}
135 + </h1>
136 + <nav class="text-xs flex gap-3 opacity-80 mt-1">
137 + <.link navigate={~p"/orgs/#{@org.handle}"} class="link link-hover">Overview</.link>
138 + <.link navigate={~p"/orgs/#{@org.handle}/actions"} class="link link-hover">CI</.link>
139 + <.link
140 + :if={@is_admin?}
141 + navigate={~p"/orgs/#{@org.handle}/settings/members"}
142 + class="link link-hover"
143 + >
144 + Members
145 + </.link>
146 + </nav>
147 + </div>
148 + </div>
149 + <div class="flex items-center gap-2 shrink-0">
150 + <.link
151 + :if={@is_admin?}
152 + id="org-new-repo"
153 + navigate={~p"/repositories/new"}
154 + class="btn btn-sm btn-primary"
155 + >
156 + <.icon name="hero-plus-micro" class="size-4" /> New repository
157 + </.link>
158 + <.link
159 + navigate={~p"/orgs/#{@org.handle}"}
160 + class="btn btn-sm btn-ghost border border-base-300"
161 + >
162 + Overview
163 + </.link>
164 + </div>
165 + </header>
166 +
167 + <div class="grid lg:grid-cols-2 gap-4">
168 + <.panel
169 + title="Needs your review"
170 + hint={review_hint(@requested_reviews)}
171 + empty="Nothing waiting on you here."
172 + count={length(@requested_reviews) + length(@review_queue)}
173 + >
174 + <.work_row
175 + :for={pr <- @requested_reviews}
176 + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
177 + repo={repo_path(pr.repository)}
178 + number={pr.number}
179 + title={pr.title}
180 + meta={"you were asked to review · #{ago(pr.inserted_at)}"}
181 + />
182 + <.work_row
183 + :for={pr <- @review_queue}
184 + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
185 + repo={repo_path(pr.repository)}
186 + number={pr.number}
187 + title={pr.title}
188 + meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"}
189 + />
190 + </.panel>
191 +
192 + <.panel
193 + title="Your work here"
194 + hint="Open PRs and issues you opened in this organization"
195 + empty="Nothing open from you here."
196 + count={length(@my_prs) + length(@my_issues)}
197 + >
198 + <.work_row
199 + :for={pr <- @my_prs}
200 + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
201 + repo={repo_path(pr.repository)}
202 + number={pr.number}
203 + title={pr.title}
204 + meta={"pull request · #{ago(pr.inserted_at)}"}
205 + />
206 + <.work_row
207 + :for={issue <- @my_issues}
208 + path={
209 + ~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}"
210 + }
211 + repo={repo_path(issue.repository)}
212 + number={issue.number}
213 + title={issue.title}
214 + meta={"issue · #{ago(issue.inserted_at)}"}
215 + />
216 + </.panel>
217 +
218 + <.panel
219 + title="Open pull requests"
220 + hint="Everyone's, across this organization"
221 + empty="No open pull requests."
222 + count={length(@open_prs)}
223 + >
224 + <.work_row
225 + :for={pr <- @open_prs}
226 + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"}
227 + repo={repo_path(pr.repository)}
228 + number={pr.number}
229 + title={pr.title}
230 + meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"}
231 + />
232 + </.panel>
233 +
234 + <.panel
235 + title="Open issues"
236 + hint="Everyone's, across this organization"
237 + empty="No open issues."
238 + count={length(@open_issues)}
239 + >
240 + <.work_row
241 + :for={issue <- @open_issues}
242 + path={
243 + ~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}"
244 + }
245 + repo={repo_path(issue.repository)}
246 + number={issue.number}
247 + title={issue.title}
248 + meta={"by #{author_name(issue)} · #{ago(issue.inserted_at)}"}
249 + />
250 + </.panel>
251 +
252 + <.panel
253 + title="CI"
254 + hint={
255 + if @active_runs == [],
256 + do: "Latest runs across this organization",
257 + else: "#{length(@active_runs)} running now"
258 + }
259 + empty="No runs yet."
260 + count={length(@recent_runs)}
261 + more_label="CI dashboard"
262 + more_path={~p"/orgs/#{@org.handle}/actions"}
263 + >
264 + <.ci_row :for={run <- @recent_runs} run={run} />
265 + </.panel>
266 + </div>
267 +
268 + <section :if={@pins != []}>
269 + <h2 class="text-lg font-semibold mb-2">Pinned</h2>
270 + <GitGudWeb.PinnedReposGrid.grid pins={@pins} />
271 + </section>
272 +
273 + <section :if={@recent_repos != []}>
274 + <header class="flex items-baseline justify-between mb-2">
275 + <h2 class="text-lg font-semibold">Recently pushed</h2>
276 + <.link navigate={~p"/orgs/#{@org.handle}"} class="text-xs link link-hover opacity-60">
277 + All repositories →
278 + </.link>
279 + </header>
280 + <ul class="divide-y divide-base-300">
281 + <.repo_row :for={repo <- @recent_repos} repo={repo} />
282 + </ul>
283 + </section>
284 + </div>
285 + </Layouts.app>
286 + """
287 + end
288 +end
modified lib/git_gud_web/live/org_live/show.ex
+7 −0
@@ -160,6 +160,13 @@ defmodule GitGudWeb.OrgLive.Show do
160 160 </.link>
161 161 </nav>
162 162 <nav class="text-xs flex gap-3 opacity-80 mt-2">
163 + <.link
164 + :if={@current_scope && @current_scope.user}
165 + navigate={~p"/orgs/#{@org.handle}/dashboard"}
166 + class="link link-hover"
167 + >
168 + Dashboard
169 + </.link>
163 170 <.link navigate={~p"/orgs/#{@org.handle}/actions"} class="link link-hover">
164 171 CI dashboard
165 172 </.link>
modified lib/git_gud_web/live/page_live/home.ex
+6 −111
@@ -14,6 +14,7 @@ defmodule GitGudWeb.PageLive.Home do
14 14
15 15 use GitGudWeb, :live_view
16 16
17 + import GitGudWeb.DashboardComponents
17 18 import GitGudWeb.LandingComponents
18 19
19 20 alias GitGud.Issues
@@ -22,7 +23,6 @@ defmodule GitGudWeb.PageLive.Home do
22 23 alias GitGud.Profiles
23 24 alias GitGud.PullRequests
24 25 alias GitGud.Repositories
25 − alias GitGud.Repositories.Storage
26 26 alias GitGud.Workflows
27 27
28 28 @panel_limit 6
@@ -138,11 +138,9 @@ defmodule GitGudWeb.PageLive.Home do
138 138 def handle_info(_msg, socket), do: {:noreply, socket}
139 139
140 140 # ── view helpers ─────────────────────────────────────────────────────
141 −
142 − # Once someone has actually been asked, say so; otherwise the panel is
143 − # still inferring from maintainer status and should admit it.
144 − defp review_hint([]), do: "Open in repos you maintain, opened by someone else"
145 − defp review_hint(_requested), do: "Requested of you, plus repos you maintain"
141 + #
142 + # The rest — panels, rows, `ago/1`, `ci_badge/1` — live in
143 + # `GitGudWeb.DashboardComponents`, shared with the org dashboard.
146 144
147 145 defp inviter_name(%{invited_by: %{handle: h}}) when is_binary(h), do: h
148 146 defp inviter_name(_), do: "An admin"
@@ -150,32 +148,6 @@ defmodule GitGudWeb.PageLive.Home do
150 148 defp signed_in?(%{user: %{}}), do: true
151 149 defp signed_in?(_), do: false
152 150
153 − defp repo_handle(%{organization: %{handle: h}}) when is_binary(h), do: h
154 − defp repo_handle(%{owner: user}), do: Storage.owner_handle(user)
155 −
156 − defp repo_path(repo), do: "#{repo_handle(repo)}/#{repo.name}"
157 −
158 − defp ci_badge("success"), do: "badge-success"
159 − defp ci_badge("failure"), do: "badge-error"
160 − defp ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning"
161 − defp ci_badge(_), do: "badge-ghost"
162 −
163 − defp author_name(%{author: %{handle: h}}) when is_binary(h), do: h
164 − defp author_name(_), do: "someone"
165 −
166 − defp ago(nil), do: ""
167 −
168 − defp ago(%DateTime{} = dt) do
169 − case DateTime.diff(DateTime.utc_now(), dt) do
170 − s when s < 60 -> "just now"
171 − s when s < 3600 -> "#{div(s, 60)}m ago"
172 − s when s < 86_400 -> "#{div(s, 3600)}h ago"
173 − s when s < 2_592_000 -> "#{div(s, 86_400)}d ago"
174 − s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago"
175 − s -> "#{div(s, 31_536_000)}y ago"
176 − end
177 − end
178 −
179 151 @impl true
180 152 def render(assigns) do
181 153 ~H"""
@@ -345,19 +317,7 @@ defmodule GitGudWeb.PageLive.Home do
345 317 more_label="My CI"
346 318 more_path={~p"/users/ci"}
347 319 >
348 − <li :for={run <- @recent_runs} class="py-2 flex items-baseline gap-2 min-w-0">
349 − <span class={["badge badge-xs shrink-0", ci_badge(run.status)]}>{run.status}</span>
350 − <.link
351 − navigate={
352 − ~p"/r/#{repo_handle(run.repository)}/#{run.repository.name}/actions/#{run.number}"
353 − }
354 − class="text-sm link link-hover truncate"
355 − >
356 − {run.workflow_name || "run ##{run.number}"}
357 − </.link>
358 − <span class="text-xs opacity-60 font-mono truncate">{repo_path(run.repository)}</span>
359 − <span class="text-xs opacity-50 ml-auto shrink-0">{ago(run.inserted_at)}</span>
360 − </li>
320 + <.ci_row :for={run <- @recent_runs} run={run} />
361 321 </.panel>
362 322 </div>
363 323
@@ -374,18 +334,7 @@ defmodule GitGudWeb.PageLive.Home do
374 334 </.link>
375 335 </header>
376 336 <ul class="divide-y divide-base-300">
377 − <li :for={repo <- @recent_repos} class="py-2 flex items-baseline gap-2 min-w-0">
378 − <.link
379 − navigate={~p"/r/#{repo_handle(repo)}/#{repo.name}"}
380 − class="font-mono text-sm link link-hover truncate"
381 − >
382 − {repo_path(repo)}
383 − </.link>
384 − <span :if={repo.description} class="text-xs opacity-60 truncate">
385 − {repo.description}
386 − </span>
387 − <span class="text-xs opacity-50 ml-auto shrink-0">{ago(repo.pushed_at)}</span>
388 − </li>
337 + <.repo_row :for={repo <- @recent_repos} repo={repo} />
389 338 </ul>
390 339 </section>
391 340
@@ -412,58 +361,4 @@ defmodule GitGudWeb.PageLive.Home do
412 361 </div>
413 362 """
414 363 end
415 −
416 − attr :title, :string, required: true
417 − attr :hint, :string, default: nil
418 − attr :empty, :string, required: true
419 − attr :count, :integer, required: true
420 − attr :more_label, :string, default: nil
421 − attr :more_path, :string, default: nil
422 − slot :inner_block, required: true
423 −
424 − defp panel(assigns) do
425 − ~H"""
426 − <section class="rounded-lg border border-base-300 p-4">
427 − <header class="flex items-baseline justify-between gap-2 mb-2">
428 − <div class="min-w-0">
429 − <h2 class="font-semibold">
430 − {@title}
431 − <span :if={@count > 0} class="badge badge-sm badge-ghost ml-1">{@count}</span>
432 − </h2>
433 − <p :if={@hint} class="text-xs opacity-60">{@hint}</p>
434 − </div>
435 − <.link
436 − :if={@more_path}
437 − navigate={@more_path}
438 − class="text-xs link link-hover opacity-60 shrink-0"
439 − >
440 − {@more_label} →
441 − </.link>
442 − </header>
443 −
444 − <p :if={@count == 0} class="py-4 text-sm opacity-50">{@empty}</p>
445 −
446 − <ul :if={@count > 0} class="divide-y divide-base-300">
447 − {render_slot(@inner_block)}
448 − </ul>
449 − </section>
450 − """
451 − end
452 −
453 − attr :path, :string, required: true
454 − attr :repo, :string, required: true
455 − attr :number, :integer, required: true
456 − attr :title, :string, required: true
457 − attr :meta, :string, required: true
458 −
459 − defp work_row(assigns) do
460 − ~H"""
461 − <li class="py-2 min-w-0">
462 − <.link navigate={@path} class="text-sm link link-hover line-clamp-1">{@title}</.link>
463 − <p class="text-xs opacity-60 mt-0.5 truncate">
464 − <span class="font-mono">{@repo}#{@number}</span> · {@meta}
465 − </p>
466 − </li>
467 − """
468 − end
469 364 end
modified lib/git_gud_web/router.ex
+1 −0
@@ -195,6 +195,7 @@ defmodule GitGudWeb.Router do
195 195 live "/r/:owner/:name/settings/runners", RepoLive.SettingsRunners, :index
196 196 live "/r/:owner/:name/settings/moderation", RepoLive.Moderation, :index
197 197 live "/r/:owner/:name/settings", RepoLive.Settings, :edit
198 + live "/orgs/:handle/dashboard", OrgLive.Dashboard, :index
198 199 live "/orgs/:handle/settings/profile", OrgLive.SettingsProfile, :index
199 200 live "/orgs/:handle/settings/members", OrgLive.Members, :index
200 201 live "/orgs/:handle/settings/secrets", SecretsLive.Org, :index
added test/git_gud_web/live/org_dashboard_test.exs
+204 −0
@@ -0,0 +1,204 @@
1 +defmodule GitGudWeb.OrgDashboardTest do
2 + @moduledoc """
3 + `/orgs/:handle/dashboard` — the landing dashboard, narrowed to one
4 + organization.
5 +
6 + The assertions that matter are the two ways it could be wrong: work
7 + from *outside* the org leaking in, and work inside the org the viewer
8 + isn't allowed to see leaking in.
9 + """
10 + use GitGudWeb.ConnCase, async: false
11 +
12 + import GitGud.AccountsFixtures
13 + import GitGud.ForgeFixtures
14 + import Phoenix.LiveViewTest
15 +
16 + alias GitGud.Organizations
17 + alias GitGud.Repositories
18 +
19 + setup do
20 + owner = user_fixture() |> set_password()
21 +
22 + {:ok, org} =
23 + Organizations.create_organization(owner, %{
24 + "handle" => "org#{System.unique_integer([:positive])}",
25 + "name" => "Test Org"
26 + })
27 +
28 + {:ok, repo} =
29 + Repositories.create_repository_for_org(org, owner, %{
30 + "name" => unique_repo_name(),
31 + "visibility" => "public"
32 + })
33 +
34 + {:ok, owner: owner, org: org, repo: GitGud.Repo.preload(repo, [:owner, :organization])}
35 + end
36 +
37 + defp sign_in(conn, user), do: GitGudWeb.ConnCase.log_in_user(conn, user)
38 +
39 + defp path(org), do: "/orgs/#{org.handle}/dashboard"
40 +
41 + describe "scoping" do
42 + test "shows the org's open work", %{conn: conn, owner: owner, org: org, repo: repo} do
43 + issue_fixture(repo, owner, %{"title" => "an org issue"})
44 +
45 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org))
46 +
47 + assert html =~ "an org issue"
48 + assert html =~ "Open issues"
49 + assert html =~ "Open pull requests"
50 + assert html =~ "Needs your review"
51 + end
52 +
53 + test "does not show work from outside the org", %{conn: conn, owner: owner, org: org} do
54 + {_other_user, personal} = repository_fixture(%{owner: owner, visibility: "public"})
55 + issue_fixture(personal, owner, %{"title" => "a personal issue"})
56 +
57 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org))
58 +
59 + refute html =~ "a personal issue"
60 + end
61 +
62 + test "someone else's work shows too — an org dashboard is not the landing page", %{
63 + conn: conn,
64 + owner: owner,
65 + org: org,
66 + repo: repo
67 + } do
68 + other = user_fixture()
69 + issue_fixture(repo, other, %{"title" => "opened by someone else"})
70 +
71 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org))
72 +
73 + assert html =~ "opened by someone else"
74 + end
75 +
76 + test "your own work is separated out", %{conn: conn, owner: owner, org: org, repo: repo} do
77 + issue_fixture(repo, owner, %{"title" => "mine here"})
78 +
79 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org))
80 +
81 + assert html =~ "Your work here"
82 + assert html =~ "mine here"
83 + end
84 +
85 + test "a brand-new org renders the panels with their own empty copy", %{
86 + conn: conn,
87 + owner: owner
88 + } do
89 + {:ok, fresh} =
90 + Organizations.create_organization(owner, %{
91 + "handle" => "fresh#{System.unique_integer([:positive])}",
92 + "name" => "Fresh"
93 + })
94 +
95 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(fresh))
96 +
97 + # Every org gets an auto-provisioned profile repo, so "no
98 + # repositories at all" isn't a state a viewer reaches; the panels
99 + # say what's empty, the same way the landing dashboard does.
100 + assert html =~ "No open pull requests."
101 + assert html =~ "No open issues."
102 + assert html =~ "Nothing waiting on you here."
103 + end
104 + end
105 +
106 + describe "visibility" do
107 + test "a private repo's issues are hidden from a non-member", %{
108 + conn: conn,
109 + owner: owner,
110 + org: org
111 + } do
112 + {:ok, private} =
113 + Repositories.create_repository_for_org(org, owner, %{
114 + "name" => unique_repo_name(),
115 + "visibility" => "private"
116 + })
117 +
118 + issue_fixture(private, owner, %{"title" => "a private issue"})
119 +
120 + outsider = user_fixture() |> set_password()
121 +
122 + {:ok, _view, html} = conn |> sign_in(outsider) |> live(path(org))
123 +
124 + refute html =~ "a private issue"
125 + end
126 +
127 + test "…and visible to a member", %{conn: conn, owner: owner, org: org} do
128 + {:ok, private} =
129 + Repositories.create_repository_for_org(org, owner, %{
130 + "name" => unique_repo_name(),
131 + "visibility" => "private"
132 + })
133 +
134 + issue_fixture(private, owner, %{"title" => "a private issue"})
135 +
136 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org))
137 +
138 + assert html =~ "a private issue"
139 + end
140 +
141 + test "a private org's dashboard redirects a non-member", %{conn: conn, owner: owner} do
142 + {:ok, secret} =
143 + Organizations.create_organization(owner, %{
144 + "handle" => "secret#{System.unique_integer([:positive])}",
145 + "name" => "Secret",
146 + "visibility" => "private"
147 + })
148 +
149 + outsider = user_fixture() |> set_password()
150 +
151 + assert {:error, {:live_redirect, %{to: "/"}}} =
152 + conn |> sign_in(outsider) |> live(path(secret))
153 + end
154 +
155 + test "signing in is required", %{conn: conn, org: org} do
156 + assert {:error, {:redirect, %{to: redirect}}} = live(conn, path(org))
157 + assert redirect =~ "/users/log-in"
158 + end
159 +
160 + test "404 for an org that doesn't exist", %{conn: conn, owner: owner} do
161 + assert_raise Ecto.NoResultsError, fn ->
162 + conn |> sign_in(owner) |> live("/orgs/nobody-at-all/dashboard")
163 + end
164 + end
165 + end
166 +
167 + describe "chrome" do
168 + test "links back to the org and its CI", %{conn: conn, owner: owner, org: org} do
169 + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org))
170 +
171 + assert html =~ ~s(href="/orgs/#{org.handle}")
172 + assert html =~ ~s(href="/orgs/#{org.handle}/actions")
173 + end
174 +
175 + test "an admin gets the New repository button; a plain member doesn't", %{
176 + conn: conn,
177 + owner: owner,
178 + org: org
179 + } do
180 + {:ok, admin_view, _html} = conn |> sign_in(owner) |> live(path(org))
181 + assert has_element?(admin_view, "#org-new-repo")
182 +
183 + member = user_fixture() |> set_password()
184 + {:ok, _} = Organizations.add_member(org, member, "member")
185 +
186 + {:ok, member_view, _html} = build_conn() |> sign_in(member) |> live(path(org))
187 + # Matched by id, not by text: the app layout has its own "New
188 + # repository" link for every signed-in visitor.
189 + refute has_element?(member_view, "#org-new-repo")
190 + end
191 +
192 + test "the org page offers the dashboard to signed-in visitors", %{
193 + conn: conn,
194 + owner: owner,
195 + org: org
196 + } do
197 + {:ok, _view, html} = conn |> sign_in(owner) |> live("/orgs/#{org.handle}")
198 + assert html =~ "/orgs/#{org.handle}/dashboard"
199 +
200 + {:ok, _view, anon} = live(build_conn(), "/orgs/#{org.handle}")
201 + refute anon =~ "/orgs/#{org.handle}/dashboard"
202 + end
203 + end
204 +end

Parents: 8e8d69e