neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
Log in to mark this repository.
Default to blocking self-approval, and add repo topic tags
5c4496e · Gabriel Morell · 2026-09-11 01:39
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/repositories/repository.ex
+2
−1
| 24 | 24 | # Approving reviews needed before merging. 0 = no requirement. |
| 25 | 25 | field :required_approvals, :integer, default: 0 |
| 26 | 26 | # When true, the PR's own author can't approve it. |
| 27 | − field :block_self_approval, :boolean, default: false | |
| 27 | + field :block_self_approval, :boolean, default: true | |
| 28 | 28 | # SHA-256 of the current runner registration token, if any. |
| 29 | 29 | # Regenerating the token replaces the hash, invalidating the old |
| 30 | 30 | # token immediately. |
| 38 | 38 | belongs_to :owner, User |
| 39 | 39 | belongs_to :organization, Organization |
| 40 | 40 | belongs_to :parent_repository, __MODULE__ |
| 41 | + has_many :tags, GitGud.Tags.RepositoryTag | |
| 41 | 42 | |
| 42 | 43 | timestamps(type: :utc_datetime) |
| 43 | 44 | end |
added
lib/git_gud/tags.ex
+148
−0
| 1 | +defmodule GitGud.Tags do | |
| 2 | + @moduledoc """ | |
| 3 | + Topic tags on repositories — "elixir", "git", "self-hosted". | |
| 4 | + | |
| 5 | + Up to seven per repo, normalised to lowercase so a tag means the same | |
| 6 | + thing however it was typed. Searching is exact on the normalised | |
| 7 | + name; these are labels to browse by, not free text. | |
| 8 | + """ | |
| 9 | + | |
| 10 | + import Ecto.Query, warn: false | |
| 11 | + | |
| 12 | + alias GitGud.Repo | |
| 13 | + alias GitGud.Repositories.Repository | |
| 14 | + alias GitGud.Tags.RepositoryTag | |
| 15 | + | |
| 16 | + @max_tags 7 | |
| 17 | + | |
| 18 | + @doc "How many tags one repository may carry." | |
| 19 | + def max_tags, do: @max_tags | |
| 20 | + | |
| 21 | + @doc """ | |
| 22 | + Normalise a tag as typed: trimmed, lowercased, inner whitespace | |
| 23 | + collapsed to single dashes. | |
| 24 | + | |
| 25 | + Returns nil for anything that normalises to nothing, so a stray comma | |
| 26 | + doesn't become a tag. | |
| 27 | + """ | |
| 28 | + def normalize(name) when is_binary(name) do | |
| 29 | + normalized = | |
| 30 | + name | |
| 31 | + |> String.trim() | |
| 32 | + |> String.downcase() | |
| 33 | + |> String.replace(~r/\s+/u, "-") | |
| 34 | + |> String.replace(~r/[^a-z0-9._+-]/u, "") | |
| 35 | + |> String.trim("-") | |
| 36 | + | |
| 37 | + if normalized == "", do: nil, else: String.slice(normalized, 0, 40) | |
| 38 | + end | |
| 39 | + | |
| 40 | + def normalize(_), do: nil | |
| 41 | + | |
| 42 | + @doc "Tags on a repository, alphabetical." | |
| 43 | + def list_tags(%Repository{id: rid}) do | |
| 44 | + from(t in RepositoryTag, where: t.repository_id == ^rid, order_by: [asc: t.name]) | |
| 45 | + |> Repo.all() | |
| 46 | + end | |
| 47 | + | |
| 48 | + @doc "Just the names, for rendering." | |
| 49 | + def tag_names(%Repository{} = repo), do: repo |> list_tags() |> Enum.map(& &1.name) | |
| 50 | + | |
| 51 | + @doc """ | |
| 52 | + Replace a repository's tags with `names`. | |
| 53 | + | |
| 54 | + Takes the whole set rather than adding one at a time — the settings | |
| 55 | + form submits a list, and diffing here keeps the caller from having to | |
| 56 | + work out what changed. Anything past the limit is refused rather than | |
| 57 | + silently truncated. | |
| 58 | + """ | |
| 59 | + def set_tags(%Repository{} = repo, names) when is_list(names) do | |
| 60 | + wanted = | |
| 61 | + names | |
| 62 | + |> Enum.map(&normalize/1) | |
| 63 | + |> Enum.reject(&is_nil/1) | |
| 64 | + |> Enum.uniq() | |
| 65 | + | |
| 66 | + if length(wanted) > @max_tags do | |
| 67 | + {:error, :too_many} | |
| 68 | + else | |
| 69 | + existing = repo |> list_tags() |> Enum.map(& &1.name) | |
| 70 | + | |
| 71 | + Repo.transaction(fn -> | |
| 72 | + for name <- existing -- wanted do | |
| 73 | + from(t in RepositoryTag, where: t.repository_id == ^repo.id and t.name == ^name) | |
| 74 | + |> Repo.delete_all() | |
| 75 | + end | |
| 76 | + | |
| 77 | + for name <- wanted -- existing do | |
| 78 | + %RepositoryTag{} | |
| 79 | + |> RepositoryTag.changeset(%{repository_id: repo.id, name: name}) | |
| 80 | + |> Repo.insert!() | |
| 81 | + end | |
| 82 | + | |
| 83 | + wanted | |
| 84 | + end) | |
| 85 | + end | |
| 86 | + end | |
| 87 | + | |
| 88 | + def set_tags(%Repository{} = repo, names) when is_binary(names), | |
| 89 | + do: set_tags(repo, String.split(names, ~r/[,\s]+/u)) | |
| 90 | + | |
| 91 | + @doc """ | |
| 92 | + Every tag in use, with how many repos carry it. | |
| 93 | + | |
| 94 | + Counts only repos the viewer may see, so a private repo's tags don't | |
| 95 | + leak through the browse page — a tag with one private user would | |
| 96 | + otherwise show a count nobody can account for. | |
| 97 | + """ | |
| 98 | + def list_all(scope, opts \\ []) do | |
| 99 | + limit = Keyword.get(opts, :limit, 200) | |
| 100 | + | |
| 101 | + from(t in RepositoryTag, | |
| 102 | + join: r in assoc(t, :repository), | |
| 103 | + as: :repo, | |
| 104 | + where: ^visible_where(scope), | |
| 105 | + group_by: t.name, | |
| 106 | + order_by: [desc: count(t.id), asc: t.name], | |
| 107 | + limit: ^limit, | |
| 108 | + select: {t.name, count(t.id)} | |
| 109 | + ) | |
| 110 | + |> Repo.all() | |
| 111 | + end | |
| 112 | + | |
| 113 | + @doc "Repos carrying `name` that the viewer may see, most recently pushed first." | |
| 114 | + def list_repositories(name, scope, opts \\ []) do | |
| 115 | + limit = Keyword.get(opts, :limit, 100) | |
| 116 | + | |
| 117 | + case normalize(name) do | |
| 118 | + nil -> | |
| 119 | + [] | |
| 120 | + | |
| 121 | + tag -> | |
| 122 | + from(r in Repository, | |
| 123 | + as: :repo, | |
| 124 | + join: t in assoc(r, :tags), | |
| 125 | + where: t.name == ^tag, | |
| 126 | + where: ^visible_where(scope), | |
| 127 | + order_by: [desc_nulls_last: r.pushed_at, asc: r.name], | |
| 128 | + limit: ^limit, | |
| 129 | + preload: [:owner, :organization] | |
| 130 | + ) | |
| 131 | + |> Repo.all() | |
| 132 | + end | |
| 133 | + end | |
| 134 | + | |
| 135 | + # Mirrors `Repositories.list_repositories/1`: your own repos plus | |
| 136 | + # anything public or internal, or public alone when logged out. | |
| 137 | + # | |
| 138 | + # Bound by name — the repository sits at a different position in the | |
| 139 | + # two queries that use this, and a positional binding silently | |
| 140 | + # matched the wrong schema. | |
| 141 | + defp visible_where(%GitGud.Accounts.Scope{user: %GitGud.Accounts.User{id: uid}}) do | |
| 142 | + dynamic([repo: r], r.owner_id == ^uid or r.visibility in ["public", "internal"]) | |
| 143 | + end | |
| 144 | + | |
| 145 | + defp visible_where(_scope) do | |
| 146 | + dynamic([repo: r], r.visibility == "public") | |
| 147 | + end | |
| 148 | +end |
added
lib/git_gud/tags/repository_tag.ex
+25
−0
| 1 | +defmodule GitGud.Tags.RepositoryTag do | |
| 2 | + use Ecto.Schema | |
| 3 | + import Ecto.Changeset | |
| 4 | + | |
| 5 | + alias GitGud.Repositories.Repository | |
| 6 | + | |
| 7 | + schema "repository_tags" do | |
| 8 | + field :name, :string | |
| 9 | + | |
| 10 | + belongs_to :repository, Repository | |
| 11 | + | |
| 12 | + timestamps(type: :utc_datetime, updated_at: false) | |
| 13 | + end | |
| 14 | + | |
| 15 | + def changeset(tag, attrs) do | |
| 16 | + tag | |
| 17 | + |> cast(attrs, [:repository_id, :name]) | |
| 18 | + |> validate_required([:repository_id, :name]) | |
| 19 | + |> validate_length(:name, min: 1, max: 40) | |
| 20 | + # Names arrive already normalised by `Tags.normalize/1`; this is the | |
| 21 | + # backstop for anything inserted directly. | |
| 22 | + |> validate_format(:name, ~r/\A[a-z0-9._+-]+\z/) | |
| 23 | + |> unique_constraint([:repository_id, :name]) | |
| 24 | + end | |
| 25 | +end |
modified
lib/git_gud_web/components/layouts.ex
+6
−3
| 231 | 231 | # Instance-wide browse facets, as {label, path, icon}. Add future ones |
| 232 | 232 | # here; the dropdown hides itself when the list comes back empty. |
| 233 | 233 | defp facets do |
| 234 | − if GitGud.Syms.enabled?(), | |
| 235 | − do: [{"Syms", ~p"/syms", "hero-sparkles-micro"}], | |
| 236 | − else: [] | |
| 234 | + syms = | |
| 235 | + if GitGud.Syms.enabled?(), | |
| 236 | + do: [{"Syms", ~p"/syms", "hero-sparkles-micro"}], | |
| 237 | + else: [] | |
| 238 | + | |
| 239 | + syms ++ [{"Tags", ~p"/tags", "hero-tag-micro"}] | |
| 237 | 240 | end |
| 238 | 241 | |
| 239 | 242 | defp signed_in?(%{user: %{}}), do: true |
modified
lib/git_gud_web/live/repo_live/header.ex
+17
−2
| 25 | 25 | alias GitGud.Workflows |
| 26 | 26 | |
| 27 | 27 | attr :repo, :map, required: true |
| 28 | + attr :tags, :list, default: [] | |
| 28 | 29 | attr :handle, :string, required: true |
| 29 | 30 | attr :current_scope, :map, default: nil |
| 30 | 31 | attr :has_packages?, :boolean, default: false |
| 60 | 61 | </span> |
| 61 | 62 | </h1> |
| 62 | 63 | <p :if={@repo.description} class="opacity-70">{@repo.description}</p> |
| 64 | + | |
| 65 | + <div :if={@tags != []} class="flex flex-wrap gap-1 mt-1"> | |
| 66 | + <.link | |
| 67 | + :for={tag <- @tags} | |
| 68 | + navigate={~p"/tags/#{tag}"} | |
| 69 | + class="badge badge-sm badge-outline hover:badge-primary" | |
| 70 | + > | |
| 71 | + {tag} | |
| 72 | + </.link> | |
| 73 | + </div> | |
| 63 | 74 | <p class="text-xs opacity-60"> |
| 64 | 75 | {@repo.visibility} · |
| 65 | 76 | <.link navigate={~p"/r/#{@handle}/#{@repo.name}/issues"} class="link link-hover"> |
| 156 | 167 | |
| 157 | 168 | @doc """ |
| 158 | 169 | Loads the cheap supporting assigns the header reads: `:has_packages?`, |
| 159 | − `:can_admin?`, `:latest_run`. Call from `mount/3` after the `:repo` | |
| 160 | − and `:current_scope` are set. | |
| 170 | + `:can_admin?`, `:latest_run`, `:repo_tags`. Call from `mount/3` after | |
| 171 | + the `:repo` and `:current_scope` are set. | |
| 172 | + | |
| 173 | + Tags are assigned for every page but rendered only where one is | |
| 174 | + passed in — they belong on the repo landing, not above a blob. | |
| 161 | 175 | """ |
| 162 | 176 | @spec assign_chrome(Phoenix.LiveView.Socket.t(), map()) :: Phoenix.LiveView.Socket.t() |
| 163 | 177 | def assign_chrome(socket, repo) do |
| 168 | 182 | |> Phoenix.Component.assign(:can_admin?, user && admin?(user, repo)) |
| 169 | 183 | |> Phoenix.Component.assign(:latest_run, Workflows.latest_run(repo)) |
| 170 | 184 | |> Phoenix.Component.assign(:open_pulls, PullRequests.count_open(repo)) |
| 185 | + |> Phoenix.Component.assign(:repo_tags, GitGud.Tags.tag_names(repo)) | |
| 171 | 186 | end |
| 172 | 187 | |
| 173 | 188 | defp admin?(user, %{owner_id: uid, organization_id: nil}), do: user.id == uid |
modified
lib/git_gud_web/live/repo_live/settings.ex
+48
−2
Click to load diff…
modified
lib/git_gud_web/live/repo_live/show.ex
+1
−0
Click to load diff…
added
lib/git_gud_web/live/tags_live/index.ex
+120
−0
Click to load diff…
modified
lib/git_gud_web/router.ex
+2
−0
Click to load diff…
added
priv/repo/migrations/20260910080000_default_block_self_approval_on.exs
+20
−0
Click to load diff…
added
priv/repo/migrations/20260910090000_create_repository_tags.exs
+24
−0
Click to load diff…
modified
test/git_gud_web/live/pr_live/review_request_test.exs
+3
−2
Click to load diff…
modified
test/git_gud_web/live/pr_live/self_approval_test.exs
+6
−2
Click to load diff…
modified
test/git_gud_web/live/syms_live/top_test.exs
+8
−3
Click to load diff…
added
test/git_gud_web/live/tags_test.exs
+248
−0
Click to load diff…
Parents: 407ee5c