Default to blocking self-approval, and add repo topic tags

5c4496e · Gabriel Morell · 2026-09-11 01:39

15 files +678 -15
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/repositories/repository.ex
+2 −1
@@ -24,7 +24,7 @@ defmodule GitGud.Repositories.Repository do
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,6 +38,7 @@ defmodule GitGud.Repositories.Repository do
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
@@ -0,0 +1,148 @@
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 timethe 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 pagea 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
@@ -0,0 +1,25 @@
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,9 +231,12 @@ defmodule GitGudWeb.Layouts do
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,6 +25,7 @@ defmodule GitGudWeb.RepoLive.Header do
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,6 +61,16 @@ defmodule GitGudWeb.RepoLive.Header do
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,8 +167,11 @@ defmodule GitGudWeb.RepoLive.Header do
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 inthey 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,6 +182,7 @@ defmodule GitGudWeb.RepoLive.Header 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