neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Rebuild /repositories as cards with filter, scope and sort
9ac35f7 · Gabriel Morell · 2026-09-09 21:17
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/repositories.ex
+25
−0
@@ -1593,6 +1593,31 @@ defmodule GitGud.Repositories do
| 1593 | 1593 | end |
| 1594 | 1594 | end |
| 1595 | 1595 | |
| 1596 | + @doc """ | |
| 1597 | + Top language per repository, keyed by repo id, for listings. | |
| 1598 | + | |
| 1599 | + Takes the most recently computed stats row for each repo rather than | |
| 1600 | + resolving each default branch to a sha — one query for the whole | |
| 1601 | + page, against N ref reads. The tradeoff is that a repo pushed since | |
| 1602 | + its last stats run shows the previous commit's top language, which is | |
| 1603 | + fine for a listing badge. | |
| 1604 | + """ | |
| 1605 | + def top_languages_for(repo_ids) when is_list(repo_ids) do | |
| 1606 | + from(l in GitCommitLanguageStats, | |
| 1607 | + where: l.repository_id in ^repo_ids, | |
| 1608 | + order_by: [asc: l.repository_id, desc: l.computed_at], | |
| 1609 | + distinct: l.repository_id, | |
| 1610 | + select: {l.repository_id, l.languages} | |
| 1611 | + ) | |
| 1612 | + |> Repo.all() | |
| 1613 | + |> Enum.reduce(%{}, fn {rid, languages}, acc -> | |
| 1614 | + case languages do | |
| 1615 | + %{"items" => [%{"language" => lang} | _]} when is_binary(lang) -> Map.put(acc, rid, lang) | |
| 1616 | + _ -> acc | |
| 1617 | + end | |
| 1618 | + end) | |
| 1619 | + end | |
| 1620 | + | |
| 1596 | 1621 | @doc """ |
| 1597 | 1622 | Walk the tree at `sha` and bucket blob bytes by `detected_language`. |
| 1598 | 1623 | Writes the result into `git_commit_language_stats`. Synchronous — |
modified
lib/git_gud_web/live/repo_live/index.ex
+236
−42
@@ -1,77 +1,271 @@
| 1 | 1 | defmodule GitGudWeb.RepoLive.Index do |
| 2 | 2 | use GitGudWeb, :live_view |
| 3 | 3 | |
| 4 | + alias GitGud.LanguageColors | |
| 4 | 5 | alias GitGud.Repositories |
| 5 | − | |
| 6 | + alias GitGud.Repositories.Repository | |
| 6 | 7 | alias GitGud.Repositories.Storage |
| 8 | + alias GitGud.Workflows | |
| 9 | + | |
| 10 | + @sorts ~w(pushed name size) | |
| 7 | 11 | |
| 8 | 12 | @impl true |
| 9 | 13 | def mount(_params, _session, socket) do |
| 10 | 14 | repos = |
| 11 | 15 | socket.assigns.current_scope |
| 12 | 16 | |> Repositories.list_repositories() |
| 13 | − |> GitGud.Repo.preload([:owner, :organization]) | |
| 17 | + |> GitGud.Repo.preload([:owner, :organization, parent_repository: [:owner, :organization]]) | |
| 14 | 18 | |> Enum.map(fn r -> Map.put(r, :handle, namespace_handle(r)) end) |
| 15 | 19 | |
| 20 | + ids = Enum.map(repos, & &1.id) | |
| 21 | + | |
| 22 | + # Live-update the CI badges as runs change anywhere. | |
| 23 | + if connected?(socket) do | |
| 24 | + Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic()) | |
| 25 | + end | |
| 26 | + | |
| 16 | 27 | {:ok, |
| 17 | 28 | socket |
| 18 | 29 | |> assign(:page_title, "Repositories") |
| 19 | − |> stream(:repos, repos)} | |
| 30 | + |> assign(:repos, repos) | |
| 31 | + |> assign(:languages, Repositories.top_languages_for(ids)) | |
| 32 | + |> assign(:ci_status, Workflows.latest_run_status_by_repo(ids)) | |
| 33 | + |> assign(:query, "") | |
| 34 | + |> assign(:scope_filter, "all") | |
| 35 | + |> assign(:sort, "pushed")} | |
| 20 | 36 | end |
| 21 | 37 | |
| 22 | 38 | defp namespace_handle(%{organization: %{handle: h}}) when is_binary(h), do: h |
| 23 | 39 | defp namespace_handle(%{owner: user}), do: Storage.owner_handle(user) |
| 24 | 40 | |
| 41 | + @impl true | |
| 42 | + def handle_info({:ci_run_changed, _run_id}, socket) do | |
| 43 | + ids = Enum.map(socket.assigns.repos, & &1.id) | |
| 44 | + {:noreply, assign(socket, :ci_status, Workflows.latest_run_status_by_repo(ids))} | |
| 45 | + end | |
| 46 | + | |
| 47 | + def handle_info(_msg, socket), do: {:noreply, socket} | |
| 48 | + | |
| 49 | + @impl true | |
| 50 | + def handle_event("filter", params, socket) do | |
| 51 | + {:noreply, | |
| 52 | + socket | |
| 53 | + |> assign(:query, params["query"] || socket.assigns.query) | |
| 54 | + |> assign(:sort, sort_param(params["sort"], socket.assigns.sort))} | |
| 55 | + end | |
| 56 | + | |
| 57 | + def handle_event("scope", %{"scope" => scope}, socket) | |
| 58 | + when scope in ~w(all mine) do | |
| 59 | + {:noreply, assign(socket, :scope_filter, scope)} | |
| 60 | + end | |
| 61 | + | |
| 62 | + defp sort_param(sort, _fallback) when sort in @sorts, do: sort | |
| 63 | + defp sort_param(_sort, fallback), do: fallback | |
| 64 | + | |
| 65 | + # Filtering and sorting run over the already-loaded list — the query | |
| 66 | + # is unbounded either way, and re-querying per keystroke would only | |
| 67 | + # add round-trips. | |
| 68 | + defp visible(assigns) do | |
| 69 | + assigns.repos | |
| 70 | + |> Enum.filter(&matches?(&1, assigns.query)) | |
| 71 | + |> Enum.filter(&in_scope?(&1, assigns.scope_filter, assigns.current_scope)) | |
| 72 | + |> sort_by(assigns.sort) | |
| 73 | + end | |
| 74 | + | |
| 75 | + defp matches?(_repo, ""), do: true | |
| 76 | + | |
| 77 | + defp matches?(repo, query) do | |
| 78 | + needle = String.downcase(query) | |
| 79 | + | |
| 80 | + String.contains?(String.downcase("#{repo.handle}/#{repo.name}"), needle) or | |
| 81 | + String.contains?(String.downcase(repo.description || ""), needle) | |
| 82 | + end | |
| 83 | + | |
| 84 | + defp in_scope?(_repo, "all", _scope), do: true | |
| 85 | + defp in_scope?(repo, "mine", %{user: %{id: uid}}), do: repo.owner_id == uid | |
| 86 | + defp in_scope?(_repo, "mine", _scope), do: false | |
| 87 | + | |
| 88 | + defp sort_by(repos, "name"), do: Enum.sort_by(repos, &"#{&1.handle}/#{&1.name}") | |
| 89 | + defp sort_by(repos, "size"), do: Enum.sort_by(repos, &(&1.size_kb || 0), :desc) | |
| 90 | + | |
| 91 | + defp sort_by(repos, _pushed) do | |
| 92 | + # `nil` pushed_at (never pushed) sorts last rather than first. | |
| 93 | + Enum.sort_by(repos, &{&1.pushed_at != nil, &1.pushed_at}, :desc) | |
| 94 | + end | |
| 95 | + | |
| 96 | + defp signed_in?(%{user: %{}}), do: true | |
| 97 | + defp signed_in?(_), do: false | |
| 98 | + | |
| 99 | + # daisyUI badge class for a repo's latest CI status. | |
| 100 | + defp ci_badge("success"), do: "badge-success" | |
| 101 | + defp ci_badge("failure"), do: "badge-error" | |
| 102 | + defp ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning" | |
| 103 | + defp ci_badge(_), do: "badge-ghost" | |
| 104 | + | |
| 105 | + defp format_size(kb) when is_integer(kb) and kb >= 1_048_576, | |
| 106 | + do: "#{Float.round(kb / 1_048_576, 1)} GB" | |
| 107 | + | |
| 108 | + defp format_size(kb) when is_integer(kb) and kb >= 1024, | |
| 109 | + do: "#{Float.round(kb / 1024, 1)} MB" | |
| 110 | + | |
| 111 | + defp format_size(kb) when is_integer(kb) and kb > 0, do: "#{kb} KB" | |
| 112 | + defp format_size(_), do: nil | |
| 113 | + | |
| 114 | + defp pushed_ago(nil), do: "never pushed" | |
| 115 | + | |
| 116 | + defp pushed_ago(%DateTime{} = dt) do | |
| 117 | + case DateTime.diff(DateTime.utc_now(), dt) do | |
| 118 | + s when s < 60 -> "pushed just now" | |
| 119 | + s when s < 3600 -> "pushed #{div(s, 60)}m ago" | |
| 120 | + s when s < 86_400 -> "pushed #{div(s, 3600)}h ago" | |
| 121 | + s when s < 2_592_000 -> "pushed #{div(s, 86_400)}d ago" | |
| 122 | + s when s < 31_536_000 -> "pushed #{div(s, 2_592_000)}mo ago" | |
| 123 | + s -> "pushed #{div(s, 31_536_000)}y ago" | |
| 124 | + end | |
| 125 | + end | |
| 126 | + | |
| 127 | + defp exact_time(nil), do: "no pushes yet" | |
| 128 | + defp exact_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M UTC") | |
| 129 | + | |
| 25 | 130 | @impl true |
| 26 | 131 | def render(assigns) do |
| 132 | + assigns = assign(assigns, :visible, visible(assigns)) | |
| 133 | + | |
| 27 | 134 | ~H""" |
| 28 | 135 | <Layouts.app flash={@flash} current_scope={@current_scope}> |
| 29 | 136 | <div class="space-y-4"> |
| 30 | − <div class="flex items-center justify-between"> | |
| 31 | − <h1 class="text-2xl font-semibold">Repositories</h1> | |
| 32 | − <%= if @current_scope && @current_scope.user do %> | |
| 33 | − <div class="flex items-center gap-2"> | |
| 34 | − <.link | |
| 35 | − navigate={~p"/repositories/import"} | |
| 36 | − class="btn btn-outline" | |
| 37 | − id="import-repo-link" | |
| 38 | − > | |
| 39 | − <.icon name="hero-arrow-down-tray" class="size-4" /> Import | |
| 40 | − </.link> | |
| 41 | − <.link | |
| 42 | − navigate={~p"/repositories/new"} | |
| 43 | − class="btn btn-primary" | |
| 44 | − id="new-repo-link" | |
| 45 | − > | |
| 46 | − <.icon name="hero-plus" class="size-4" /> New | |
| 47 | − </.link> | |
| 48 | − </div> | |
| 49 | − <% end %> | |
| 50 | − </div> | |
| 137 | + <header class="flex items-center justify-between gap-4"> | |
| 138 | + <div> | |
| 139 | + <h1 class="text-2xl font-semibold">Repositories</h1> | |
| 140 | + <p class="text-sm opacity-70 mt-1"> | |
| 141 | + {length(@visible)} of {length(@repos)} shown | |
| 142 | + </p> | |
| 143 | + </div> | |
| 144 | + <div :if={signed_in?(@current_scope)} class="flex items-center gap-2 shrink-0"> | |
| 145 | + <.link navigate={~p"/repositories/import"} class="btn btn-outline" id="import-repo-link"> | |
| 146 | + <.icon name="hero-arrow-down-tray" class="size-4" /> Import | |
| 147 | + </.link> | |
| 148 | + <.link navigate={~p"/repositories/new"} class="btn btn-primary" id="new-repo-link"> | |
| 149 | + <.icon name="hero-plus" class="size-4" /> New | |
| 150 | + </.link> | |
| 151 | + </div> | |
| 152 | + </header> | |
| 51 | 153 | |
| 52 | − <ul id="repos" phx-update="stream" class="divide-y divide-base-300"> | |
| 53 | − <li id="repos-empty" class="py-6 hidden only:block text-sm opacity-60"> | |
| 54 | − No repositories yet. | |
| 55 | − </li> | |
| 56 | − <li :for={{id, repo} <- @streams.repos} id={id} class="py-4"> | |
| 57 | − <.link | |
| 58 | − navigate={~p"/r/#{repo.handle}/#{repo.name}"} | |
| 59 | − class="font-mono text-primary hover:underline" | |
| 154 | + <div class="flex flex-wrap items-center gap-2"> | |
| 155 | + <form phx-change="filter" class="contents"> | |
| 156 | + <input | |
| 157 | + type="search" | |
| 158 | + name="query" | |
| 159 | + value={@query} | |
| 160 | + placeholder="Filter by name or description…" | |
| 161 | + class="input input-bordered input-sm flex-1 min-w-48" | |
| 162 | + /> | |
| 163 | + <select name="sort" class="select select-bordered select-sm"> | |
| 164 | + <option value="pushed" selected={@sort == "pushed"}>Recently pushed</option> | |
| 165 | + <option value="name" selected={@sort == "name"}>Name</option> | |
| 166 | + <option value="size" selected={@sort == "size"}>Size</option> | |
| 167 | + </select> | |
| 168 | + </form> | |
| 169 | + | |
| 170 | + <div :if={signed_in?(@current_scope)} class="join"> | |
| 171 | + <button | |
| 172 | + type="button" | |
| 173 | + phx-click="scope" | |
| 174 | + phx-value-scope="all" | |
| 175 | + class={["btn btn-sm join-item", @scope_filter == "all" && "btn-active"]} | |
| 60 | 176 | > |
| 61 | − {repo.handle}/{repo.name} | |
| 62 | − </.link> | |
| 63 | − <p :if={repo.description} class="text-sm opacity-70">{repo.description}</p> | |
| 64 | − <p class="text-xs opacity-50 mt-1"> | |
| 65 | − {repo.visibility} · | |
| 66 | − <span :if={repo.pushed_at}>pushed {format_time(repo.pushed_at)}</span> | |
| 67 | − <span :if={!repo.pushed_at}>no pushes yet</span> | |
| 68 | − </p> | |
| 177 | + All | |
| 178 | + </button> | |
| 179 | + <button | |
| 180 | + type="button" | |
| 181 | + phx-click="scope" | |
| 182 | + phx-value-scope="mine" | |
| 183 | + class={["btn btn-sm join-item", @scope_filter == "mine" && "btn-active"]} | |
| 184 | + > | |
| 185 | + Yours | |
| 186 | + </button> | |
| 187 | + </div> | |
| 188 | + </div> | |
| 189 | + | |
| 190 | + <p :if={@repos == []} class="py-12 text-center text-sm opacity-60"> | |
| 191 | + No repositories yet. | |
| 192 | + </p> | |
| 193 | + | |
| 194 | + <p :if={@repos != [] and @visible == []} class="py-12 text-center text-sm opacity-60"> | |
| 195 | + Nothing matches that filter. | |
| 196 | + </p> | |
| 197 | + | |
| 198 | + <ul class="space-y-3"> | |
| 199 | + <li | |
| 200 | + :for={repo <- @visible} | |
| 201 | + id={"repo-#{repo.id}"} | |
| 202 | + class={[ | |
| 203 | + "rounded-lg border border-base-300 p-4 transition-colors hover:border-primary/50", | |
| 204 | + repo.archived && "opacity-60" | |
| 205 | + ]} | |
| 206 | + > | |
| 207 | + <div class="flex items-start gap-3 min-w-0"> | |
| 208 | + <.avatar name={repo.handle} size="md" alt={repo.handle} /> | |
| 209 | + | |
| 210 | + <div class="min-w-0 flex-1"> | |
| 211 | + <div class="flex items-center gap-2 flex-wrap"> | |
| 212 | + <.link | |
| 213 | + navigate={~p"/r/#{repo.handle}/#{repo.name}"} | |
| 214 | + class="font-mono text-primary hover:underline truncate" | |
| 215 | + > | |
| 216 | + {repo.handle}/{repo.name} | |
| 217 | + </.link> | |
| 218 | + <span :if={repo.visibility != "public"} class="badge badge-xs badge-ghost"> | |
| 219 | + {repo.visibility} | |
| 220 | + </span> | |
| 221 | + <span :if={repo.archived} class="badge badge-xs badge-warning">archived</span> | |
| 222 | + <span :if={repo.upstream_url} class="badge badge-xs badge-ghost">imported</span> | |
| 223 | + <.link | |
| 224 | + :if={@ci_status[repo.id]} | |
| 225 | + navigate={~p"/r/#{repo.handle}/#{repo.name}/actions"} | |
| 226 | + class={["badge badge-xs", ci_badge(@ci_status[repo.id])]} | |
| 227 | + title={"latest CI run: #{@ci_status[repo.id]}"} | |
| 228 | + > | |
| 229 | + {@ci_status[repo.id]} | |
| 230 | + </.link> | |
| 231 | + </div> | |
| 232 | + | |
| 233 | + <p | |
| 234 | + :if={Repository.fork?(repo) and repo.parent_repository} | |
| 235 | + class="text-xs opacity-60 mt-0.5" | |
| 236 | + > | |
| 237 | + forked from | |
| 238 | + <.link | |
| 239 | + navigate={ | |
| 240 | + ~p"/r/#{namespace_handle(repo.parent_repository)}/#{repo.parent_repository.name}" | |
| 241 | + } | |
| 242 | + class="link link-hover font-mono" | |
| 243 | + > | |
| 244 | + {namespace_handle(repo.parent_repository)}/{repo.parent_repository.name} | |
| 245 | + </.link> | |
| 246 | + </p> | |
| 247 | + | |
| 248 | + <p :if={repo.description} class="text-sm opacity-70 mt-1 line-clamp-2"> | |
| 249 | + {repo.description} | |
| 250 | + </p> | |
| 251 | + | |
| 252 | + <div class="text-xs opacity-60 mt-2 flex items-center gap-3 flex-wrap"> | |
| 253 | + <span :if={@languages[repo.id]} class="flex items-center gap-1.5"> | |
| 254 | + <span | |
| 255 | + class="inline-block size-2.5 rounded-full" | |
| 256 | + style={"background-color: " <> LanguageColors.for(@languages[repo.id]) <> ";"} | |
| 257 | + /> | |
| 258 | + <span class="font-mono">{@languages[repo.id]}</span> | |
| 259 | + </span> | |
| 260 | + <span :if={format_size(repo.size_kb)}>{format_size(repo.size_kb)}</span> | |
| 261 | + <span title={exact_time(repo.pushed_at)}>{pushed_ago(repo.pushed_at)}</span> | |
| 262 | + </div> | |
| 263 | + </div> | |
| 264 | + </div> | |
| 69 | 265 | </li> |
| 70 | 266 | </ul> |
| 71 | 267 | </div> |
| 72 | 268 | </Layouts.app> |
| 73 | 269 | """ |
| 74 | 270 | end |
| 75 | − | |
| 76 | − defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") | |
| 77 | 271 | end |
added
test/git_gud_web/live/repo_live/index_test.exs
+131
−0
@@ -0,0 +1,131 @@
| 1 | +defmodule GitGudWeb.RepoLive.IndexTest do | |
| 2 | + use GitGudWeb.ConnCase, async: false | |
| 3 | + | |
| 4 | + import Phoenix.LiveViewTest | |
| 5 | + import GitGud.AccountsFixtures | |
| 6 | + import GitGud.ForgeFixtures | |
| 7 | + | |
| 8 | + alias GitGud.Repositories | |
| 9 | + | |
| 10 | + setup :register_and_log_in_user | |
| 11 | + | |
| 12 | + test "cards carry description, visibility and push recency", %{conn: conn, user: user} do | |
| 13 | + {_user, repo} = | |
| 14 | + repository_fixture(%{owner: user, name: "index-card", visibility: "private"}) | |
| 15 | + | |
| 16 | + {:ok, repo} = Repositories.update_repository(repo, %{"description" => "A described repo"}) | |
| 17 | + | |
| 18 | + {:ok, _lv, html} = live(conn, ~p"/repositories") | |
| 19 | + | |
| 20 | + assert html =~ "#{user.handle}/index-card" | |
| 21 | + assert html =~ "A described repo" | |
| 22 | + assert html =~ "private" | |
| 23 | + # Never pushed, so the footer says so rather than showing a date. | |
| 24 | + assert html =~ "never pushed" | |
| 25 | + refute repo.pushed_at | |
| 26 | + end | |
| 27 | + | |
| 28 | + test "public repos are visible without an account", %{user: user} do | |
| 29 | + {_user, _repo} = | |
| 30 | + repository_fixture(%{owner: user, name: "index-public", visibility: "public"}) | |
| 31 | + | |
| 32 | + {:ok, _lv, html} = live(build_conn(), ~p"/repositories") | |
| 33 | + | |
| 34 | + assert html =~ "#{user.handle}/index-public" | |
| 35 | + end | |
| 36 | + | |
| 37 | + test "the filter box narrows the list", %{conn: conn, user: user} do | |
| 38 | + {_user, _} = repository_fixture(%{owner: user, name: "alpha-widget"}) | |
| 39 | + {_user, _} = repository_fixture(%{owner: user, name: "beta-gadget"}) | |
| 40 | + | |
| 41 | + {:ok, lv, html} = live(conn, ~p"/repositories") | |
| 42 | + assert html =~ "alpha-widget" | |
| 43 | + assert html =~ "beta-gadget" | |
| 44 | + | |
| 45 | + html = lv |> form("form[phx-change=filter]", %{query: "alpha"}) |> render_change() | |
| 46 | + | |
| 47 | + assert html =~ "alpha-widget" | |
| 48 | + refute html =~ "beta-gadget" | |
| 49 | + end | |
| 50 | + | |
| 51 | + test "a filter matching nothing says so", %{conn: conn, user: user} do | |
| 52 | + {_user, _} = repository_fixture(%{owner: user, name: "only-one"}) | |
| 53 | + | |
| 54 | + {:ok, lv, _html} = live(conn, ~p"/repositories") | |
| 55 | + | |
| 56 | + html = lv |> form("form[phx-change=filter]", %{query: "zzz-no-match"}) |> render_change() | |
| 57 | + | |
| 58 | + assert html =~ "Nothing matches that filter." | |
| 59 | + end | |
| 60 | + | |
| 61 | + test "the Yours scope hides other people's repos", %{conn: conn, user: user} do | |
| 62 | + {_user, _} = repository_fixture(%{owner: user, name: "mine-repo", visibility: "public"}) | |
| 63 | + | |
| 64 | + other = user_fixture() | |
| 65 | + {_other, _} = repository_fixture(%{owner: other, name: "theirs-repo", visibility: "public"}) | |
| 66 | + | |
| 67 | + {:ok, lv, html} = live(conn, ~p"/repositories") | |
| 68 | + assert html =~ "theirs-repo" | |
| 69 | + | |
| 70 | + html = lv |> element("button[phx-value-scope=mine]") |> render_click() | |
| 71 | + | |
| 72 | + assert html =~ "mine-repo" | |
| 73 | + refute html =~ "theirs-repo" | |
| 74 | + end | |
| 75 | + | |
| 76 | + test "sorting by name reorders the list", %{conn: conn, user: user} do | |
| 77 | + {_user, _} = repository_fixture(%{owner: user, name: "zzz-last"}) | |
| 78 | + {_user, _} = repository_fixture(%{owner: user, name: "aaa-first"}) | |
| 79 | + | |
| 80 | + {:ok, lv, _html} = live(conn, ~p"/repositories") | |
| 81 | + | |
| 82 | + html = lv |> form("form[phx-change=filter]", %{sort: "name"}) |> render_change() | |
| 83 | + | |
| 84 | + assert :binary.match(html, "aaa-first") < :binary.match(html, "zzz-last") | |
| 85 | + end | |
| 86 | + | |
| 87 | + test "the anonymous view offers no scope toggle", %{user: user} do | |
| 88 | + {_user, _} = repository_fixture(%{owner: user, name: "anon-visible", visibility: "public"}) | |
| 89 | + | |
| 90 | + {:ok, lv, _html} = live(build_conn(), ~p"/repositories") | |
| 91 | + | |
| 92 | + refute has_element?(lv, "button[phx-value-scope=mine]") | |
| 93 | + end | |
| 94 | + | |
| 95 | + test "a fork names its parent", %{conn: conn, user: user} do | |
| 96 | + {_user, parent} = | |
| 97 | + repository_fixture(%{owner: user, name: "upstream-repo", visibility: "public"}) | |
| 98 | + | |
| 99 | + {_user, fork} = repository_fixture(%{owner: user, name: "downstream-repo"}) | |
| 100 | + | |
| 101 | + {:ok, _fork} = | |
| 102 | + fork | |
| 103 | + |> Ecto.Changeset.change(parent_repository_id: parent.id) | |
| 104 | + |> GitGud.Repo.update() | |
| 105 | + | |
| 106 | + {:ok, _lv, html} = live(conn, ~p"/repositories") | |
| 107 | + | |
| 108 | + assert html =~ "forked from" | |
| 109 | + assert html =~ "#{user.handle}/upstream-repo" | |
| 110 | + end | |
| 111 | + | |
| 112 | + test "archived repos are badged", %{conn: conn, user: user} do | |
| 113 | + {_user, repo} = repository_fixture(%{owner: user, name: "old-repo"}) | |
| 114 | + {:ok, _repo} = Repositories.update_repository(repo, %{"archived" => true}) | |
| 115 | + | |
| 116 | + {:ok, _lv, html} = live(conn, ~p"/repositories") | |
| 117 | + | |
| 118 | + assert html =~ "archived" | |
| 119 | + end | |
| 120 | + | |
| 121 | + test "the header counts shown against total", %{conn: conn, user: user} do | |
| 122 | + {_user, _} = repository_fixture(%{owner: user, name: "counted-one"}) | |
| 123 | + {_user, _} = repository_fixture(%{owner: user, name: "counted-two"}) | |
| 124 | + | |
| 125 | + {:ok, lv, _html} = live(conn, ~p"/repositories") | |
| 126 | + | |
| 127 | + html = lv |> form("form[phx-change=filter]", %{query: "counted-one"}) |> render_change() | |
| 128 | + | |
| 129 | + assert html =~ "1 of 2 shown" | |
| 130 | + end | |
| 131 | +end |
Parents: 7eeae4a