neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
Log in to mark this repository.
Replace the landing page's ad grid with a dashboard for signed-in users
8a2b95a · Gabriel Morell · 2026-09-09 21:38
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/issues.ex
+22
−0
| 47 | 47 | Enum.map(issues, &Map.put(&1, :labels, Map.get(labels_by_id, &1.id, []))) |
| 48 | 48 | end |
| 49 | 49 | |
| 50 | + @doc """ | |
| 51 | + Open issues authored by `user_id`, across every repo they can still | |
| 52 | + see: public and internal repos, plus anything in | |
| 53 | + `accessible_repo_ids` (pass | |
| 54 | + `Repositories.relevant_repo_ids_for_user/1`). Newest first. Powers | |
| 55 | + the signed-in dashboard. | |
| 56 | + """ | |
| 57 | + def list_open_authored_by(user_id, accessible_repo_ids, opts \\ []) | |
| 58 | + when is_integer(user_id) and is_list(accessible_repo_ids) do | |
| 59 | + limit = Keyword.get(opts, :limit, 10) | |
| 60 | + | |
| 61 | + from(i in Issue, | |
| 62 | + join: r in assoc(i, :repository), | |
| 63 | + where: i.author_id == ^user_id and i.state == "open", | |
| 64 | + where: r.visibility in ["public", "internal"] or r.id in ^accessible_repo_ids, | |
| 65 | + order_by: [desc: i.inserted_at], | |
| 66 | + limit: ^limit, | |
| 67 | + preload: [repository: {r, [:owner, :organization]}] | |
| 68 | + ) | |
| 69 | + |> Repo.all() | |
| 70 | + end | |
| 71 | + | |
| 50 | 72 | defp filter_state(q, "all"), do: q |
| 51 | 73 | |
| 52 | 74 | defp filter_state(q, state) when state in ["open", "closed"], |
modified
lib/git_gud/pull_requests.ex
+42
−0
| 51 | 51 | Enum.map(prs, &Map.put(&1, :labels, Map.get(labels_by_id, &1.id, []))) |
| 52 | 52 | end |
| 53 | 53 | |
| 54 | + @doc """ | |
| 55 | + Open PRs authored by `user_id`, across every repo they can still see: | |
| 56 | + public and internal repos, plus anything in `accessible_repo_ids` | |
| 57 | + (pass `Repositories.relevant_repo_ids_for_user/1`). Ordered newest | |
| 58 | + first. Powers the signed-in dashboard. | |
| 59 | + """ | |
| 60 | + def list_open_authored_by(user_id, accessible_repo_ids, opts \\ []) | |
| 61 | + when is_integer(user_id) and is_list(accessible_repo_ids) do | |
| 62 | + limit = Keyword.get(opts, :limit, 10) | |
| 63 | + | |
| 64 | + from(p in PullRequest, | |
| 65 | + join: r in assoc(p, :repository), | |
| 66 | + where: p.author_id == ^user_id and p.state == "open", | |
| 67 | + where: r.visibility in ["public", "internal"] or r.id in ^accessible_repo_ids, | |
| 68 | + order_by: [desc: p.inserted_at], | |
| 69 | + limit: ^limit, | |
| 70 | + preload: [repository: {r, [:owner, :organization]}] | |
| 71 | + ) | |
| 72 | + |> Repo.all() | |
| 73 | + end | |
| 74 | + | |
| 75 | + @doc """ | |
| 76 | + Open PRs sitting in repos the user maintains that someone else | |
| 77 | + opened — the closest thing to a review queue, since PRs carry an | |
| 78 | + author but no review requests. Pass | |
| 79 | + `Repositories.maintained_repo_ids_for_user/1`. | |
| 80 | + """ | |
| 81 | + def list_open_awaiting_review(user_id, maintained_repo_ids, opts \\ []) | |
| 82 | + when is_integer(user_id) and is_list(maintained_repo_ids) do | |
| 83 | + limit = Keyword.get(opts, :limit, 10) | |
| 84 | + | |
| 85 | + from(p in PullRequest, | |
| 86 | + join: r in assoc(p, :repository), | |
| 87 | + where: p.repository_id in ^maintained_repo_ids and p.state == "open", | |
| 88 | + where: is_nil(p.author_id) or p.author_id != ^user_id, | |
| 89 | + order_by: [desc: p.inserted_at], | |
| 90 | + limit: ^limit, | |
| 91 | + preload: [:author, repository: {r, [:owner, :organization]}] | |
| 92 | + ) | |
| 93 | + |> Repo.all() | |
| 94 | + end | |
| 95 | + | |
| 54 | 96 | @doc "How many pull requests are open against this repo." |
| 55 | 97 | def count_open(%Repository{id: rid}) do |
| 56 | 98 | PullRequest |
modified
lib/git_gud/repositories.ex
+35
−0
| 154 | 154 | (owned_and_member ++ team_granted) |> Enum.uniq() |
| 155 | 155 | end |
| 156 | 156 | |
| 157 | + @doc """ | |
| 158 | + The most recently pushed repos out of `repo_ids`, newest first. | |
| 159 | + Repos that have never been pushed to are left out entirely rather | |
| 160 | + than sorted to the end — this feeds a "recently pushed" panel, and an | |
| 161 | + empty repo has nothing to show there. | |
| 162 | + """ | |
| 163 | + def list_recently_pushed(repo_ids, opts \\ []) when is_list(repo_ids) do | |
| 164 | + limit = Keyword.get(opts, :limit, 10) | |
| 165 | + | |
| 166 | + from(r in Repository, | |
| 167 | + where: r.id in ^repo_ids and not is_nil(r.pushed_at), | |
| 168 | + order_by: [desc: r.pushed_at], | |
| 169 | + limit: ^limit, | |
| 170 | + preload: [:owner, :organization] | |
| 171 | + ) | |
| 172 | + |> Repo.all() | |
| 173 | + end | |
| 174 | + | |
| 175 | + @doc """ | |
| 176 | + Ids of repos the user maintains: ones they own outright, plus every | |
| 177 | + repo owned by an org where they're an admin. Narrower than | |
| 178 | + `relevant_repo_ids_for_user/1`, which also counts plain org | |
| 179 | + membership and team grants — this is the set whose pull requests are | |
| 180 | + the user's to review. | |
| 181 | + """ | |
| 182 | + def maintained_repo_ids_for_user(%User{id: uid}) do | |
| 183 | + from(r in Repository, | |
| 184 | + left_join: m in GitGud.Organizations.Membership, | |
| 185 | + on: m.organization_id == r.organization_id and m.user_id == ^uid and m.role == "admin", | |
| 186 | + where: (r.owner_id == ^uid and is_nil(r.organization_id)) or not is_nil(m.id), | |
| 187 | + select: r.id | |
| 188 | + ) | |
| 189 | + |> Repo.all() | |
| 190 | + end | |
| 191 | + | |
| 157 | 192 | @doc """ |
| 158 | 193 | Repos a user can pin to their personal profile: their own + any repo |
| 159 | 194 | in an organization they're a member of. Returns rows with `:owner` + |
added
lib/git_gud_web/components/landing_components.ex
+208
−0
| 1 | +defmodule GitGudWeb.LandingComponents do | |
| 2 | + @moduledoc """ | |
| 3 | + The logged-out face of `/`: hero, feature grid, quick links, footer. | |
| 4 | + | |
| 5 | + Lifted out of the old `PageHTML` when `/` became a LiveView so the | |
| 6 | + anonymous landing could stay exactly as it was while signed-in | |
| 7 | + visitors get `GitGudWeb.PageLive.Home`'s dashboard instead. | |
| 8 | + """ | |
| 9 | + use GitGudWeb, :html | |
| 10 | + | |
| 11 | + @doc "Hero, calls to action, and the feature grid — the logged-out pitch." | |
| 12 | + attr :current_scope, :map, default: nil | |
| 13 | + | |
| 14 | + def landing(assigns) do | |
| 15 | + ~H""" | |
| 16 | + <section class="space-y-3 pt-6 pb-10"> | |
| 17 | + <p class="font-mono text-xs uppercase tracking-[0.18em] text-accent"> | |
| 18 | + Self-hosted code forge | |
| 19 | + </p> | |
| 20 | + <h1 class="text-5xl sm:text-6xl font-bold tracking-tight"> | |
| 21 | + git gud<span class="text-primary">.</span> | |
| 22 | + </h1> | |
| 23 | + <p class="text-lg opacity-80 max-w-2xl"> | |
| 24 | + Repos, issues, pull requests, CI, wiki, federation. Phoenix on the front, | |
| 25 | + <span class="font-mono">gitoxide</span> | |
| 26 | + on the back, ForgeFed by default. Built so you can host the small | |
| 27 | + project that becomes a movement. | |
| 28 | + </p> | |
| 29 | + | |
| 30 | + <div class="flex flex-wrap gap-2 pt-2"> | |
| 31 | + <.link navigate={~p"/repositories"} class="btn btn-primary" id="cta-browse"> | |
| 32 | + Browse repositories <.icon name="hero-arrow-right-micro" class="size-4" /> | |
| 33 | + </.link> | |
| 34 | + <.link | |
| 35 | + navigate={~p"/users/register"} | |
| 36 | + class="btn btn-ghost border border-base-300" | |
| 37 | + id="cta-register" | |
| 38 | + > | |
| 39 | + Get an account | |
| 40 | + </.link> | |
| 41 | + </div> | |
| 42 | + </section> | |
| 43 | + | |
| 44 | + <section class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 pt-2"> | |
| 45 | + <.feature | |
| 46 | + icon="hero-folder" | |
| 47 | + title="Repos, smart-HTTP + SSH" | |
| 48 | + body="Bare git on disk with a Postgres cache in front. Embedded SSH daemon — DB-backed key auth, no system git user. LFS too." | |
| 49 | + /> | |
| 50 | + <.feature | |
| 51 | + icon="hero-arrow-trending-up" | |
| 52 | + title="Forks, PRs, line-level diffs" | |
| 53 | + body="3-way merge, fork sync, compare any two refs. Diffs render hunk-by-hunk with syntax-highlighted lines; large diffs keep their hunks out of the DOM until you open the file." | |
| 54 | + /> | |
| 55 | + <.feature | |
| 56 | + icon="hero-bolt" | |
| 57 | + title="Forgejo Actions CI" | |
| 58 | + body="Talk protobuf to forgejo-runner. Per-task JWTs, encrypted secrets, artifacts + cache with hit matching, re-run + cancel from the UI." | |
| 59 | + /> | |
| 60 | + <.feature | |
| 61 | + icon="hero-globe-alt" | |
| 62 | + title="Federation that works" | |
| 63 | + body="ForgeFed over ActivityPub: federated PRs, federated reviews (Like/Dislike), federated comments. HTTP-signed; allowlist by default." | |
| 64 | + /> | |
| 65 | + <.feature | |
| 66 | + icon="hero-shield-check" | |
| 67 | + title="Moderation up front" | |
| 68 | + body="Per-repo interaction policy, quarantine queue, rate limits, peer-block suggestions, and a reversible 'replace with mod message' that keeps the original visible to admins + author." | |
| 69 | + /> | |
| 70 | + <.feature | |
| 71 | + icon="hero-finger-print" | |
| 72 | + title="MFA + WebAuthn" | |
| 73 | + body="TOTP authenticator apps, FIDO2 security keys (YubiKey, passkeys, Touch ID), recovery codes. Optional instance-wide enforcement with a grace window." | |
| 74 | + /> | |
| 75 | + <.feature | |
| 76 | + icon="hero-paint-brush" | |
| 77 | + title="Markdown that links itself" | |
| 78 | + body="GFM with cross-references — `#42` links to whichever issue or PR that number actually is, `#x2a` works too, `owner/repo#42` crosses repos. Quote-reply on every comment." | |
| 79 | + /> | |
| 80 | + <.feature | |
| 81 | + icon="hero-tag" | |
| 82 | + title="Org-level labels" | |
| 83 | + body="Define labels once at the org; every repo inherits unless it opts out. Color picker with a 16-swatch palette and a live preview badge." | |
| 84 | + /> | |
| 85 | + <.feature | |
| 86 | + icon="hero-book-open" | |
| 87 | + title="Wiki + container registry" | |
| 88 | + body="Sister .wiki.git you edit through the UI. Container packages mirrored from zot via webhook, browsable per-repo without leaving the forge." | |
| 89 | + /> | |
| 90 | + <.feature | |
| 91 | + icon="hero-user-circle" | |
| 92 | + title="Profiles + identicon avatars" | |
| 93 | + body="User + org profile pages at /u/:handle and /orgs/:handle. Bio, location, socials, pinned repos (drag-to-reorder, up to 7), and an auto-created alice/alice README repo that renders inline." | |
| 94 | + /> | |
| 95 | + <.feature | |
| 96 | + icon="hero-chart-bar" | |
| 97 | + title="Language + contributor stats" | |
| 98 | + body="0.3em GitHub-style bar above the file tree, click for /stats/languages or /stats/committers. Computed lazily by Oban from cached blob + commit metadata — no extra git walks on the request path." | |
| 99 | + /> | |
| 100 | + <.feature | |
| 101 | + icon="hero-ticket" | |
| 102 | + title="Invite-gated signups" | |
| 103 | + body="Three registration modes — open, invite-only (default), closed. Single-use invite links generated from /users/settings/invites, revocable anytime. Org members manageable from settings with a last-admin guard." | |
| 104 | + /> | |
| 105 | + </section> | |
| 106 | + | |
| 107 | + <section class="mt-12 grid sm:grid-cols-2 gap-3 text-sm"> | |
| 108 | + <.quick_link | |
| 109 | + icon="hero-list-bullet" | |
| 110 | + label="All visible repositories" | |
| 111 | + navigate={~p"/repositories"} | |
| 112 | + /> | |
| 113 | + </section> | |
| 114 | + """ | |
| 115 | + end | |
| 116 | + | |
| 117 | + @doc "Settings and admin shortcuts for a signed-in visitor." | |
| 118 | + attr :user, :map, required: true | |
| 119 | + | |
| 120 | + def quick_links(assigns) do | |
| 121 | + ~H""" | |
| 122 | + <section class="grid sm:grid-cols-2 gap-3 text-sm"> | |
| 123 | + <.quick_link | |
| 124 | + icon="hero-user-circle" | |
| 125 | + label="Your profile" | |
| 126 | + navigate={~p"/u/#{@user.handle}"} | |
| 127 | + /> | |
| 128 | + <.quick_link icon="hero-key" label="SSH keys" navigate={~p"/users/settings/ssh-keys"} /> | |
| 129 | + <.quick_link | |
| 130 | + icon="hero-finger-print" | |
| 131 | + label="Two-factor auth" | |
| 132 | + navigate={~p"/users/settings/two-factor"} | |
| 133 | + /> | |
| 134 | + <.quick_link icon="hero-ticket" label="Invites" navigate={~p"/users/settings/invites"} /> | |
| 135 | + <.quick_link | |
| 136 | + icon="hero-cog-6-tooth" | |
| 137 | + label="Account settings" | |
| 138 | + navigate={~p"/users/settings"} | |
| 139 | + /> | |
| 140 | + <.quick_link | |
| 141 | + :if={@user.is_admin} | |
| 142 | + icon="hero-wrench-screwdriver" | |
| 143 | + label="Federation admin" | |
| 144 | + navigate={~p"/admin/federation"} | |
| 145 | + /> | |
| 146 | + </section> | |
| 147 | + """ | |
| 148 | + end | |
| 149 | + | |
| 150 | + @doc "Instance footer, shown to everyone." | |
| 151 | + def site_footer(assigns) do | |
| 152 | + ~H""" | |
| 153 | + <footer class="py-10 sm:py-12 mt-8 border-t border-base-300 text-center"> | |
| 154 | + <p class="font-mono text-xs uppercase tracking-[0.25em] text-base-content/70 flex items-center justify-center gap-2"> | |
| 155 | + <span class="font-bold tracking-[0.16em]">GIT GUD</span> | |
| 156 | + <span class="opacity-50">·</span> | |
| 157 | + <span>a</span> | |
| 158 | + <%!-- | |
| 159 | + Hardcoded oklch from the neiam-co accent (the value every | |
| 160 | + ported theme assigns to --color-accent). Inline so the | |
| 161 | + NEIAM mark stays yellow regardless of which theme the | |
| 162 | + viewer has picked. | |
| 163 | + --%> | |
| 164 | + <span class="font-bold tracking-[0.16em]" style="color: oklch(96% 0.058 96);"> | |
| 165 | + <a href="https://neiam.org"> NEIAM </a> | |
| 166 | + </span> | |
| 167 | + <span>production</span> | |
| 168 | + </p> | |
| 169 | + </footer> | |
| 170 | + """ | |
| 171 | + end | |
| 172 | + | |
| 173 | + @doc "Card used in the landing page feature grid." | |
| 174 | + attr :icon, :string, required: true | |
| 175 | + attr :title, :string, required: true | |
| 176 | + attr :body, :string, required: true | |
| 177 | + | |
| 178 | + def feature(assigns) do | |
| 179 | + ~H""" | |
| 180 | + <div class="rounded-box border border-base-300 bg-base-200 p-4 space-y-2"> | |
| 181 | + <div class="flex items-center gap-2"> | |
| 182 | + <.icon name={@icon} class="size-5 text-primary" /> | |
| 183 | + <h3 class="font-semibold">{@title}</h3> | |
| 184 | + </div> | |
| 185 | + <p class="text-sm opacity-75">{@body}</p> | |
| 186 | + </div> | |
| 187 | + """ | |
| 188 | + end | |
| 189 | + | |
| 190 | + @doc "Inline link tile with leading icon and trailing chevron." | |
| 191 | + attr :icon, :string, required: true | |
| 192 | + attr :label, :string, required: true | |
| 193 | + attr :navigate, :string, required: true | |
| 194 | + | |
| 195 | + def quick_link(assigns) do | |
| 196 | + ~H""" | |
| 197 | + <.link | |
| 198 | + navigate={@navigate} | |
| 199 | + class="flex items-center justify-between rounded-box px-4 py-3 bg-base-200 hover:bg-base-300" | |
| 200 | + > | |
| 201 | + <span class="flex items-center gap-2"> | |
| 202 | + <.icon name={@icon} class="size-4 text-primary" /> {@label} | |
| 203 | + </span> | |
| 204 | + <.icon name="hero-arrow-right-micro" class="size-4 opacity-60" /> | |
| 205 | + </.link> | |
| 206 | + """ | |
| 207 | + end | |
| 208 | +end |
deleted
lib/git_gud_web/controllers/page_controller.ex
+0
−7
| 1 | −defmodule GitGudWeb.PageController do | |
| 2 | − use GitGudWeb, :controller | |
| 3 | − | |
| 4 | − def home(conn, _params) do | |
| 5 | − render(conn, :home) | |
| 6 | − end | |
| 7 | −end |
deleted
lib/git_gud_web/controllers/page_html.ex
+0
−45
Click to load diff…
deleted
lib/git_gud_web/controllers/page_html/home.html.heex
+0
−170
Click to load diff…
added
lib/git_gud_web/live/page_live/home.ex
+371
−0
Click to load diff…
modified
lib/git_gud_web/router.ex
+1
−2
Click to load diff…
deleted
test/git_gud_web/controllers/page_controller_test.exs
+0
−16
Click to load diff…
added
test/git_gud_web/live/page_live/home_test.exs
+240
−0
Click to load diff…
Parents: bff9e3f