5.9 KiB · text 5af55d9
defmodule GitGud.Issues do
@moduledoc """
Issues context. Per-repo issue numbers are allocated via a
serializable subquery: `max(number) + 1` inside the same insert
transaction.
Label assignment goes through `GitGud.Labels` against the
`"issue"` target type.
"""
import Ecto.Query, warn: false
alias GitGud.Accounts.User
alias GitGud.Issues.Issue
alias GitGud.Issues.IssueComment
alias GitGud.Labels
alias GitGud.Repo
alias GitGud.Repositories.Numbering
alias GitGud.Repositories.Repository
@target_type "issue"
# ── queries ──────────────────────────────────────────────────────────
def list_issues(%Repository{id: rid}, opts \\ []) do
state = Keyword.get(opts, :state, "open")
label_ids = Keyword.get(opts, :label_ids, [])
search = Keyword.get(opts, :search)
limit = Keyword.get(opts, :limit, 50)
offset = Keyword.get(opts, :offset, 0)
base =
Issue
|> where([i], i.repository_id == ^rid)
|> filter_state(state)
|> filter_labels(label_ids)
|> filter_search(search)
|> order_by([i], desc: i.inserted_at)
|> limit(^limit)
|> offset(^offset)
|> preload([:author])
issues = Repo.all(base)
labels_by_id = Labels.labels_for_many(@target_type, Enum.map(issues, & &1.id))
Enum.map(issues, &Map.put(&1, :labels, Map.get(labels_by_id, &1.id, [])))
end
defp filter_state(q, "all"), do: q
defp filter_state(q, state) when state in ["open", "closed"],
do: where(q, [i], i.state == ^state)
defp filter_state(q, _), do: q
defp filter_labels(q, []), do: q
defp filter_labels(q, label_ids) do
from(i in q,
join: lab in "labelings",
on:
lab.target_type == ^@target_type and lab.target_id == i.id and lab.label_id in ^label_ids
)
|> distinct(true)
end
defp filter_search(q, nil), do: q
defp filter_search(q, ""), do: q
defp filter_search(q, term) when is_binary(term) do
websearch = String.trim(term)
from i in q,
where: fragment("? @@ websearch_to_tsquery('english', ?)", i.search_vector, ^websearch)
end
def get_issue!(%Repository{id: rid}, number) do
comments_q = from c in IssueComment, order_by: [asc: c.inserted_at, asc: c.id]
Issue
|> where([i], i.repository_id == ^rid and i.number == ^number)
|> preload([:author, comments: ^{comments_q, [:author]}])
|> Repo.one!()
|> attach_labels()
end
@doc """
Resolve `#<number>` references in a repo's context.
Issue numbers and PR numbers share a sequence per repo (via
`Repositories.Numbering`), so a given number is unambiguous —
either an Issue, a PullRequest, or nothing. Returns:
* `{:issue, %Issue{}}`
* `{:pull_request, %PullRequest{}}`
* `nil`
Only the minimal fields needed for a link preview are loaded.
"""
@spec lookup_referent(Repository.t() | nil, integer()) ::
{:issue, Issue.t()} | {:pull_request, GitGud.PullRequests.PullRequest.t()} | nil
def lookup_referent(nil, _number), do: nil
def lookup_referent(_repo, number) when not is_integer(number) or number <= 0, do: nil
def lookup_referent(%Repository{id: rid}, number) do
case Repo.one(
from i in Issue,
where: i.repository_id == ^rid and i.number == ^number,
select: %{id: i.id, number: i.number, title: i.title, state: i.state}
) do
nil ->
case Repo.one(
from p in GitGud.PullRequests.PullRequest,
where: p.repository_id == ^rid and p.number == ^number,
select: %{id: p.id, number: p.number, title: p.title, state: p.state}
) do
nil -> nil
pr -> {:pull_request, pr}
end
issue ->
{:issue, issue}
end
end
defp attach_labels(%Issue{id: id} = issue),
do: Map.put(issue, :labels, Labels.labels_for({@target_type, id}))
# ── mutations ────────────────────────────────────────────────────────
@doc """
Open a new issue. Allocates the next per-repo number atomically.
"""
def create_issue(%Repository{id: rid} = repo, %User{id: uid} = author, attrs) do
result =
Repo.transaction(fn ->
next = Numbering.next_for(rid)
changeset =
%Issue{repository_id: rid, author_id: uid, number: next, state: "open"}
|> Issue.create_changeset(attrs)
case Repo.insert(changeset) do
{:ok, issue} -> attach_labels(issue)
{:error, cs} -> Repo.rollback(cs)
end
end)
with {:ok, issue} <- result do
_ = GitGud.Federation.Publishing.publish_issue_opened(repo, issue, author)
{:ok, issue}
end
end
def update_issue(%Issue{} = issue, attrs) do
issue
|> Issue.update_changeset(attrs)
|> Repo.update()
end
def set_state(%Issue{} = issue, state) when state in ["open", "closed"] do
issue
|> Issue.state_changeset(state)
|> Repo.update()
end
def add_comment(%Issue{} = issue, %User{id: uid} = author, attrs) do
case %IssueComment{issue_id: issue.id, author_id: uid}
|> IssueComment.changeset(attrs)
|> Repo.insert() do
{:ok, comment} ->
repo = GitGud.Repositories.get_repository!(issue.repository_id)
_ = GitGud.Federation.Publishing.publish_issue_comment(repo, issue, comment, author)
{:ok, comment}
err ->
err
end
end
# ── labels ───────────────────────────────────────────────────────────
def attach_label(%Issue{id: id}, label), do: Labels.attach(label, {@target_type, id})
def detach_label(%Issue{id: id}, label), do: Labels.detach(label, {@target_type, id})
def target_type, do: @target_type
end