5.5 KiB · text 5af55d9
defmodule GitGud.Labels do
@moduledoc """
Labels attached polymorphically to issues and PRs.
Two scopes:
* **Repository labels** live on a single repo (`labels.repository_id`).
* **Organization labels** live at the org scope
(`labels.organization_id`) and are inherited by every repo the
org owns — unless that repo opts out via a row in
`repository_label_opt_outs`.
`list_labels/1` merges both sources for a repo, filters opt-outs,
and surfaces an `:inherited?` flag so the UI can distinguish.
Re-opting in is just deleting the opt-out row.
Opt-outs only hide a label from the *pickable* set — anything
already attached to a target stays attached and continues to render.
"""
import Ecto.Query, warn: false
alias GitGud.Labels.Label
alias GitGud.Labels.Labeling
alias GitGud.Labels.RepositoryOptOut
alias GitGud.Organizations.Organization
alias GitGud.Repo
alias GitGud.Repositories.Repository
@type target :: {String.t(), integer()}
def list_labels(%Repository{id: rid, organization_id: oid}) do
own =
Repo.all(from l in Label, where: l.repository_id == ^rid, order_by: [asc: l.name])
|> Enum.map(&Map.put(&1, :inherited?, false))
inherited =
case oid do
nil ->
[]
oid ->
opted_ids = opted_out_ids(rid)
Repo.all(
from l in Label,
where: l.organization_id == ^oid and l.id not in ^opted_ids,
order_by: [asc: l.name]
)
|> Enum.map(&Map.put(&1, :inherited?, true))
end
(own ++ inherited) |> Enum.sort_by(& &1.name)
end
@doc "Org labels only — no repo scope merge."
def list_organization_labels(%Organization{id: oid}) do
Repo.all(from l in Label, where: l.organization_id == ^oid, order_by: [asc: l.name])
end
defp opted_out_ids(repo_id) do
Repo.all(
from o in RepositoryOptOut, where: o.repository_id == ^repo_id, select: o.label_id
)
end
@doc """
Inherited org labels this repo has opted out of. Used to surface
the "hidden labels" expander on the repo labels page so the user
can re-enable.
"""
def list_opted_out_labels(%Repository{id: rid}) do
Repo.all(
from l in Label,
join: o in RepositoryOptOut,
on: o.label_id == l.id,
where: o.repository_id == ^rid,
order_by: [asc: l.name]
)
end
def get_label!(id), do: Repo.get!(Label, id)
def create_label(%Repository{id: rid}, attrs) do
%Label{repository_id: rid}
|> Label.changeset(attrs)
|> Repo.insert()
end
def create_organization_label(%Organization{id: oid}, attrs) do
%Label{organization_id: oid}
|> Label.changeset(attrs)
|> Repo.insert()
end
def update_label(%Label{} = label, attrs) do
label
|> Label.changeset(attrs)
|> Repo.update()
end
def delete_label(%Label{} = label), do: Repo.delete(label)
# ── opt-in / opt-out ────────────────────────────────────────────────
@doc "Opt this repo out of an inherited org label. Idempotent."
def opt_out(%Repository{id: rid}, %Label{id: lid, organization_id: oid})
when not is_nil(oid) do
%RepositoryOptOut{}
|> RepositoryOptOut.changeset(%{repository_id: rid, label_id: lid})
|> Repo.insert(
on_conflict: :nothing,
conflict_target: [:repository_id, :label_id]
)
end
def opt_out(_repo, _label), do: {:error, :not_an_org_label}
@doc "Drop an opt-out row, re-inheriting the org label."
def opt_in(%Repository{id: rid}, %Label{id: lid}) do
{count, _} =
Repo.delete_all(
from o in RepositoryOptOut,
where: o.repository_id == ^rid and o.label_id == ^lid
)
{:ok, count}
end
@doc "True if this repo currently hides this org label."
def opted_out?(%Repository{id: rid}, %Label{id: lid}) do
Repo.exists?(
from o in RepositoryOptOut,
where: o.repository_id == ^rid and o.label_id == ^lid
)
end
# ── labelings ───────────────────────────────────────────────────────
@doc "Attach a label to a target. Idempotent."
def attach(%Label{id: lid}, {type, id}) do
%Labeling{}
|> Labeling.changeset(%{
label_id: lid,
target_type: type,
target_id: id
})
|> Repo.insert(
on_conflict: :nothing,
conflict_target: [:label_id, :target_type, :target_id]
)
|> case do
{:ok, _} -> :ok
{:error, cs} -> {:error, cs}
end
end
def detach(%Label{id: lid}, {type, id}) do
Repo.delete_all(
from(lab in Labeling,
where: lab.label_id == ^lid and lab.target_type == ^type and lab.target_id == ^id
)
)
:ok
end
@doc "Labels attached to a target."
def labels_for({type, id}) do
Label
|> join(:inner, [l], lab in Labeling,
on: lab.label_id == l.id and lab.target_type == ^type and lab.target_id == ^id
)
|> order_by([l], asc: l.name)
|> Repo.all()
end
@doc """
Bulk lookup: returns a map of `target_id => [Label]` for the given
target type and ids. Used to avoid N+1 in list views.
"""
def labels_for_many(type, ids) when is_list(ids) do
from(l in Label,
join: lab in Labeling,
on: lab.label_id == l.id,
where: lab.target_type == ^type and lab.target_id in ^ids,
select: {lab.target_id, l}
)
|> Repo.all()
|> Enum.group_by(fn {id, _} -> id end, fn {_, l} -> l end)
end
end