defmodule OpenAgents.Projects do
@moduledoc "Repository-scoped projects and project items."
import Ecto.Query, warn: false
alias OpenAgents.Accounts.User
alias OpenAgents.Analytics
alias OpenAgents.Issues.Issue
alias OpenAgents.ProjectFields.ProjectField
alias OpenAgents.ProjectItems.ProjectItem
alias OpenAgents.Projects.Project
alias OpenAgents.Projects.ProjectNote
alias OpenAgents.Projects.ProjectItemEvent
alias OpenAgents.Projects.PromiseRegistry
alias OpenAgents.Repo
alias OpenAgents.Repositories
alias OpenAgents.Repositories.Repository
@doc """
The projects of one repository, in board order.
Archived projects are out of the working set, so they are left out unless
`archived: true` asks for them. Nothing else about them changes: an archived
board reads, and comes back, exactly as it was.
"""
def list_projects(repository, opts \\ [])
def list_projects(%Repository{id: repository_id}, opts) when is_list(opts) do
Project
|> where(repository_id: ^repository_id)
|> filter_archived(Keyword.get(opts, :archived, false))
|> order_by(asc: :number)
|> Repo.all()
end
defp filter_archived(query, true), do: query
defp filter_archived(query, _excluded), do: where(query, [project], is_nil(project.archived_at))
@projects_per_page 25
@doc "How many projects one workspace-wide page shows."
def per_page, do: @projects_per_page
@doc """
One page of the projects `user` can read, across every repository, with the
unpaginated total.
Authorization is the same predicate the repository surfaces compose,
`Repositories.readable_by/2`, joined in as a subquery rather than restated
here, so a project in a repository the reader has no membership in cannot
appear.
Supported options: `:state`, `:owner`, and `:page`. Rows come back with their
repository preloaded, because a project's board lives at a repository path.
"""
def list_visible_projects_page(user, opts \\ [])
when (is_nil(user) or is_struct(user, User)) and is_list(opts) do
page = max(parse_page(opts[:page]), 1)
query = visible_project_query(user, opts)
total = Repo.aggregate(query, :count)
projects =
query
|> order_by([project], desc: project.inserted_at, desc: project.id)
|> limit(@projects_per_page)
|> offset(^((page - 1) * @projects_per_page))
|> Repo.all()
|> Repo.preload(repository: :namespace)
{projects, total}
end
@doc "How many projects `user` can read across every repository, filtered."
def count_visible_projects(user, opts \\ [])
when (is_nil(user) or is_struct(user, User)) and is_list(opts),
do: user |> visible_project_query(opts) |> Repo.aggregate(:count)
@doc "Clamps a reader-supplied page number into the bounded range."
def parse_page(page), do: OpenAgents.Issues.parse_page(page)
defp visible_project_query(user, opts) do
readable = from(repository in Repositories.readable_by(Repository, user), select: repository)
from(project in Project,
join: repository in subquery(readable),
on: repository.id == project.repository_id
)
|> maybe_filter_project_state(Keyword.get(opts, :state, "open"))
|> maybe_filter_project_owner(Keyword.get(opts, :owner))
|> filter_archived(Keyword.get(opts, :archived, false))
end
defp maybe_filter_project_state(query, "all"), do: query
defp maybe_filter_project_state(query, state), do: where(query, state: ^state)
# As with issues, a project created here carries a durable owner link while
# one that arrived with only a login carries the login.
defp maybe_filter_project_owner(query, nil), do: query
defp maybe_filter_project_owner(query, %User{id: user_id, github_login: login}) do
login_key = String.downcase(login)
where(
query,
[project],
project.owner_user_id == ^user_id or fragment("lower(?)", project.owner) == ^login_key
)
end
def get_project!(%Repository{id: repository_id}, id) do
Repo.get_by!(Project, id: id, repository_id: repository_id)
end
def get_project_by_number!(%Repository{id: repository_id}, number) when is_integer(number),
do: Repo.get_by!(Project, repository_id: repository_id, number: number)
def get_project_by_path!(owner, repository_name, number) when is_integer(number) do
# Resolved through the one read predicate rather than a restated join, for
# the same reason as `OpenAgents.Issues.get_issue_by_path!/3`.
repository = Repositories.get_public_by_path!(owner, repository_name)
Repo.get_by!(Project, repository_id: repository.id, number: number)
end
def create_project(%Repository{} = repository, attrs),
do: create_project(repository, attrs, nil)
def create_project(%Repository{} = repository, attrs, owner_user)
when is_nil(owner_user) or is_struct(owner_user, User) do
normalized = to_string_map(attrs)
explicit_number? = Map.has_key?(normalized, "number")
normalized =
normalized
|> Map.put("repository_id", repository.id)
|> put_owner(owner_user)
create_project_with_number(repository, normalized, explicit_number?, owner_user, 20)
end
defp create_project_with_number(
repository,
normalized,
explicit_number?,
owner_user,
attempts_remaining
) do
normalized = Map.put_new(normalized, "number", next_project_number(repository.id))
%Project{}
|> Project.changeset(normalized)
|> Repo.insert()
|> case do
{:error, changeset} when not explicit_number? and attempts_remaining > 1 ->
if number_conflict?(changeset) do
normalized = Map.delete(normalized, "number")
create_project_with_number(
repository,
normalized,
false,
owner_user,
attempts_remaining - 1
)
else
{:error, changeset}
end
{:ok, project} ->
Analytics.capture("project_created", actor_distinct_id(owner_user), %{
"owner" => repository.owner,
"repo" => repository.name
})
Repositories.broadcast_projects(repository.id)
{:ok, project}
result ->
result
end
end
defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
def update_project(%Project{} = project, attrs), do: update_project(project, attrs, nil)
@doc """
Moves `project` into the archive, attributed to `actor`.
Archiving is orthogonal to closing. A closed project says the work it tracked
reached an end; an archived project says the board is out of the working set,
whatever became of the work. Archiving is reversible through
`restore_project/2`, and it is the precondition the API puts in front of a
project delete.
"""
def archive_project(%Project{} = project, actor \\ nil),
do: update_project(project, %{"archived" => true}, actor)
@doc "Brings `project` back out of the archive, attributed to `actor`."
def restore_project(%Project{} = project, actor \\ nil),
do: update_project(project, %{"archived" => false}, actor)
@doc """
Updates `project` and records what changed in its activity log.
Every accepted change to the title, description, or state appends one
immutable `"activity"` note, so a board carries the decision record even
when nobody wrote discussion around it. The note is written in the same
transaction as the update: an activity entry for a change that did not
commit would be a false record.
"""
def update_project(%Project{} = project, attrs, actor)
when is_nil(actor) or is_struct(actor, User) do
attrs = attrs |> to_string_map() |> Map.drop(["repository_id", "owner_user_id"])
attrs =
if project.owner_user_id do
Map.drop(attrs, ["owner"])
else
attrs
end
{attrs, archived_error} = put_archived_at(project, attrs)
changeset =
case archived_error do
nil -> Project.changeset(project, attrs)
message -> Ecto.Changeset.add_error(Project.changeset(project, attrs), :archived, message)
end
if changeset.valid? do
Repo.transaction(fn ->
case Repo.update(changeset) do
{:ok, updated} ->
Enum.each(activity_bodies(changeset), fn body ->
case insert_note(updated, %{"body" => body, "kind" => "activity"}, actor) do
{:ok, _note} -> :ok
{:error, note_changeset} -> Repo.rollback(note_changeset)
end
end)
updated
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
|> case do
{:ok, project} ->
Repositories.broadcast_projects(project.repository_id)
{:ok, project}
result ->
result
end
else
{:error, changeset}
end
end
# `archived` is a boolean on the wire because that is the question a caller
# is asking. The column is a timestamp because the answer is worth dating.
# Re-archiving an archived project keeps the original timestamp: the board
# left the working set once.
defp put_archived_at(project, attrs) do
case Map.pop(attrs, "archived") do
{nil, attrs} ->
{attrs, nil}
{true, attrs} ->
at = project.archived_at || DateTime.truncate(DateTime.utc_now(), :second)
{Map.put(attrs, "archived_at", at), nil}
{false, attrs} ->
{Map.put(attrs, "archived_at", nil), nil}
{_other, attrs} ->
{attrs, "is invalid"}
end
end
# One line per changed property, in a fixed order so a reader of the log sees
# the same shape every time. Only these three are worth a record: the rest of
# a project's columns are its identity, not its operating state.
defp activity_bodies(changeset) do
Enum.flat_map(
[
{:state, &"Changed the state to `#{&1}`."},
{:title, &"Changed the title to #{inspect(&1)}."},
{:description, &describe_description_change/1},
{:archived_at, &describe_archive_change/1}
],
fn {field, describe} ->
case Ecto.Changeset.fetch_change(changeset, field) do
{:ok, value} -> [describe.(value)]
:error -> []
end
end
)
end
defp describe_description_change(nil), do: "Removed the description."
defp describe_description_change(""), do: "Removed the description."
defp describe_description_change(_value), do: "Updated the description."
defp describe_archive_change(nil), do: "Restored the project from the archive."
defp describe_archive_change(_at), do: "Archived the project."
@doc """
Deletes `project` along with its fields and items.
The items go, the issues stay. A project item is a reference to a canonical
issue, so deleting the board that referenced it must never delete the work.
Notes and activity cascade from the database. Project item events do not:
they are append-only, and the record of what a promise did outlives the board
that carried it.
"""
def delete_project(%Project{} = project) do
Repo.transaction(fn ->
Repo.delete_all(from field in ProjectField, where: field.project_id == ^project.id)
Repo.delete_all(from item in ProjectItem, where: item.project_id == ^project.id)
Repo.delete!(project)
end)
|> case do
{:ok, project} ->
Repositories.broadcast_projects(project.repository_id)
{:ok, project}
result ->
result
end
end
def change_project(%Repository{id: repository_id}, %Project{} = project, attrs) do
attrs = attrs |> to_string_map() |> Map.put("repository_id", repository_id)
Project.changeset(project, attrs)
end
def change_project(%Project{repository_id: repository_id} = project, attrs \\ %{})
when not is_nil(repository_id) do
Project.changeset(project, attrs)
end
@notes_per_page 20
@doc "How many project notes one page carries."
def notes_per_page, do: @notes_per_page
@doc """
One page of `project`'s notes, newest first, with the unpaginated total.
A project object never embeds its timeline: a long-lived board accumulates
decisions without bound, so the notes are a separate paginated read. Page 1
is the most recent `notes_per_page/0` entries, which is what an operator
opening a board wants first.
Supported options: `:page` and `:kind`. `:kind` takes `"note"` for
discussion, `"activity"` for the immutable change record, or `"all"`, the
default.
Authority is the project's repository, which the caller has already resolved
through `OpenAgents.Repositories.get_visible_by_path!/3` or its writable
counterpart. Notes carry no separate visibility of their own.
"""
def list_project_notes_page(%Project{} = project, opts \\ []) when is_list(opts) do
query = project_notes_query(project, opts)
page = max(parse_page(opts[:page]), 1)
notes =
query
|> order_by([note], desc: note.inserted_at, desc: note.id)
|> limit(@notes_per_page)
|> offset(^((page - 1) * @notes_per_page))
|> Repo.all()
|> Repo.preload(:author_user)
{notes, Repo.aggregate(query, :count)}
end
@doc "How many notes `project` carries, with the same filters."
def count_project_notes(%Project{} = project, opts \\ []) when is_list(opts),
do: project |> project_notes_query(opts) |> Repo.aggregate(:count)
@doc "One note of `project`, by id."
def get_project_note!(%Project{id: project_id, repository_id: repository_id}, id) do
ProjectNote
|> Repo.get_by!(id: id, project_id: project_id, repository_id: repository_id)
|> Repo.preload(:author_user)
end
@doc """
Writes one discussion note on `project`, authored by `author`.
The caller establishes write authority on the project's repository first.
`kind` is not accepted from outside: activity entries are written only by the
context that made the change they record.
"""
def create_project_note(%Project{} = project, attrs, author \\ nil)
when is_nil(author) or is_struct(author, User) do
attrs =
attrs
|> to_string_map()
|> Map.take(["body"])
|> Map.put("kind", "note")
case insert_note(project, attrs, author) do
{:ok, note} ->
Analytics.capture("project_note_created", actor_distinct_id(author), %{
"project_number" => project.number
})
Repositories.broadcast_projects(project.repository_id)
{:ok, Repo.preload(note, :author_user)}
result ->
result
end
end
@doc """
Edits the body of one discussion note.
An activity entry is the record of a change that happened, so it is
immutable: this returns `{:error, :immutable}` for one. Authority to call
this is the note's author, which the caller checks with
`authored_by?/2`.
"""
def update_project_note(%ProjectNote{kind: "activity"}, _attrs), do: {:error, :immutable}
def update_project_note(%ProjectNote{} = note, attrs) do
attrs = attrs |> to_string_map() |> Map.take(["body"])
note
|> ProjectNote.changeset(attrs)
|> Repo.update()
|> case do
{:ok, note} ->
Repositories.broadcast_projects(note.repository_id)
{:ok, Repo.preload(note, :author_user)}
result ->
result
end
end
@doc "Deletes one discussion note. Activity entries never delete."
def delete_project_note(%ProjectNote{kind: "activity"}), do: {:error, :immutable}
def delete_project_note(%ProjectNote{} = note) do
case Repo.delete(note) do
{:ok, note} ->
Repositories.broadcast_projects(note.repository_id)
{:ok, note}
result ->
result
end
end
@doc """
Whether `user` wrote `note`.
Edit and delete authority for a discussion note is its author, and nobody
else: repository write access adds a note of your own rather than rewriting
somebody else's words. A note written without an authenticated author, by an
import or a token with no user behind it, has no author to match, so it is
not editable through this predicate.
"""
def authored_by?(%ProjectNote{author_user_id: nil}, _user), do: false
def authored_by?(%ProjectNote{}, nil), do: false
def authored_by?(%ProjectNote{author_user_id: author_user_id}, %User{id: user_id}),
do: author_user_id == user_id
@doc "A blank or seeded changeset for the note form."
def change_project_note(%ProjectNote{} = note \\ %ProjectNote{}, attrs \\ %{}),
do: ProjectNote.changeset(note, to_string_map(attrs))
defp project_notes_query(%Project{id: project_id, repository_id: repository_id}, opts) do
from(note in ProjectNote,
where: note.project_id == ^project_id and note.repository_id == ^repository_id
)
|> maybe_filter_note_kind(Keyword.get(opts, :kind, "all"))
end
defp maybe_filter_note_kind(query, kind) when kind in ["note", "activity"],
do: where(query, kind: ^kind)
defp maybe_filter_note_kind(query, _all), do: query
defp insert_note(%Project{} = project, attrs, author) do
attrs
|> Map.put("project_id", project.id)
|> Map.put("repository_id", project.repository_id)
|> put_note_author(author)
|> then(&ProjectNote.changeset(%ProjectNote{}, &1))
|> Repo.insert()
end
defp put_note_author(attrs, nil), do: attrs
defp put_note_author(attrs, %User{} = author) do
attrs
|> Map.put("author_user_id", author.id)
|> Map.put("author", %{"login" => author.github_login})
end
def list_project_items(%Project{id: project_id, repository_id: repository_id}) do
project_items_query(project_id, repository_id)
|> order_by(asc: :position, asc: :id)
|> preload(issue: :repository)
|> Repo.all()
end
def list_visible_project_items(
%Project{id: project_id, repository_id: repository_id} = project,
user,
opts \\ []
) do
promise_context =
Keyword.get(opts, :promise_context, PromiseRegistry.context(project))
readable =
from(repository in Repositories.readable_by(Repository, user), select: repository.id)
items =
project_items_query(project_id, repository_id)
|> where([item], item.issue_repository_id in subquery(readable))
|> order_by(asc: :position, asc: :id)
|> preload(issue: :repository)
|> Repo.all()
Enum.filter(items, fn item ->
state_matches? =
case opts[:promise_state] do
nil -> true
state -> PromiseRegistry.state(promise_context, item.values) == state
end
bounty_matches? =
case opts[:bounty_candidate] do
nil -> true
value -> PromiseRegistry.bounty_candidate?(promise_context, item.values) == value
end
state_matches? and bounty_matches?
end)
end
def list_visible_project_items_with_promises(%Project{} = project, user, opts \\ []) do
promise_context = PromiseRegistry.context(project)
items =
list_visible_project_items(
project,
user,
Keyword.put(opts, :promise_context, promise_context)
)
{items, project_item_projections(items, promise_context, user)}
end
def project_item_projections(items, promise_context, reader) do
Map.new(items, fn item ->
values = PromiseRegistry.redact_values(item.values, reader)
{item.id,
%{
values: values,
promise: PromiseRegistry.projection_from_redacted(promise_context, values)
}}
end)
end
def project_item_events(events, reader) do
Enum.map(events, fn event ->
changes =
case event.changes do
%{"values" => values} ->
%{"values" => PromiseRegistry.redact_values(values, reader)}
changes ->
changes
end
%{event | changes: changes}
end)
end
def get_project_item!(%Project{id: project_id, repository_id: repository_id}, id) do
ProjectItem
|> Repo.get_by!(
id: id,
project_id: project_id,
repository_id: repository_id
)
|> Repo.preload(issue: :repository)
end
def get_visible_project_item!(
%Project{id: project_id, repository_id: repository_id},
id,
user
) do
readable =
from(repository in Repositories.readable_by(Repository, user), select: repository.id)
project_items_query(project_id, repository_id)
|> where([item], item.id == ^id and item.issue_repository_id in subquery(readable))
|> preload(issue: :repository)
|> Repo.one!()
end
def create_project_item(attrs, project, actor \\ nil)
def create_project_item(attrs, %Project{} = project, actor)
when is_nil(actor) or is_struct(actor, User) do
attrs = to_string_map(attrs)
values = Map.get(attrs, "values", %{})
issue_repository_id = Map.get(attrs, "issue_repository_id", project.repository_id)
result =
case Map.get(attrs, "issue_number") do
nil ->
changeset =
ProjectItem.changeset(%ProjectItem{}, %{
"project_id" => project.id,
"repository_id" => project.repository_id,
"issue_repository_id" => issue_repository_id,
"values" => values
})
if PromiseRegistry.registry?(project) do
Ecto.Changeset.add_error(
changeset,
:issue_id,
"is required for promise registry items"
)
else
Ecto.Changeset.apply_action(changeset, :insert)
end
issue_number ->
issue =
Repo.get_by!(Issue,
repository_id: issue_repository_id,
number: issue_number
)
case project_item_for_issue(project, issue.id) do
%ProjectItem{} = existing ->
{:existing, existing}
nil ->
item_attrs = %{
"project_id" => project.id,
"issue_id" => issue.id,
"repository_id" => project.repository_id,
"issue_repository_id" => issue_repository_id,
"position" => next_item_position(project.id),
"values" => values
}
case prepare_promise_values(project, values, actor) do
{:ok, values} ->
insert_project_item(Map.put(item_attrs, "values", values), project, actor)
{:error, errors} ->
item_attrs
|> then(&ProjectItem.changeset(%ProjectItem{}, &1))
|> add_errors(errors)
|> then(&{:error, &1})
end
end
end
case result do
{:existing, item} ->
{:ok, Repo.preload(item, issue: :repository)}
{:ok, item} ->
Analytics.capture("project_item_added", actor_distinct_id(actor), %{
"project_number" => project.number,
"has_issue" => match?(%ProjectItem{issue_id: id} when id != nil, item)
})
Repositories.broadcast_projects(project.repository_id)
{:ok, Repo.preload(item, issue: :repository)}
result ->
result
end
end
@doc """
The item `project` already carries for `issue_id`, or `nil`.
Membership is a set, not a list: one issue is on a board once. Two boards can
each hold the same issue, because an item is a board's view of canonical work
rather than the work itself.
"""
def project_item_for_issue(%Project{id: project_id}, issue_id) do
Repo.get_by(ProjectItem, project_id: project_id, issue_id: issue_id)
end
defp next_item_position(project_id) do
case Repo.aggregate(
from(item in ProjectItem, where: item.project_id == ^project_id),
:max,
:position
) do
nil -> 1
position -> position + 1
end
end
@doc """
Removes one item from its board.
The item goes, the issue stays: an item points at canonical work rather than
owning it, so a board that drops a card has said nothing about the work. The
removal is appended to the item's event log before the row goes, and to the
project's activity feed as well, because the item log stops being reachable
through the item once the item is gone.
"""
def delete_project_item(%ProjectItem{} = item, actor \\ nil)
when is_nil(actor) or is_struct(actor, User) do
project = Repo.get!(Project, item.project_id)
item = Repo.preload(item, issue: :repository)
Repo.transaction(fn ->
record_item_event(item, project, actor, item.values || %{}, %{}, "remove")
record_field_activity(project.id, removal_body(item), actor)
Repo.delete!(item)
end)
|> case do
{:ok, removed} ->
Analytics.capture("project_item_removed", actor_distinct_id(actor), %{
"project_number" => project.number
})
Repositories.broadcast_projects(project.repository_id)
{:ok, removed}
result ->
result
end
end
defp removal_body(%ProjectItem{issue: %Issue{} = issue}) when not is_nil(issue),
do:
"Removed #{issue.repository.owner}/#{issue.repository.name}##{issue.number} from the board."
defp removal_body(%ProjectItem{}), do: "Removed an item from the board."
@doc """
Moves one item to another column, another rank, or both.
`attrs` carries `values`, merged onto the item the same way `PATCH` merges
them, and `position`, a one-based rank **within the destination column**,
because that is what a move on a board means. A move with only `position` is
a reorder; a move with only `values` lands at the end of the destination
column; a move with neither is a no-op that still reads back the item.
Two concurrent moves settle to one defined order and lose nothing. The write
takes a row lock on the project, reads the whole board inside it, splices the
item into the order it asks for, and renumbers the result densely, so the
second move sees the first one's result rather than racing it.
A move that changes nothing appends no event and announces nothing, so a
client that retries is indistinguishable from one that did not.
"""
def move_project_item(%ProjectItem{} = item, attrs, actor \\ nil)
when is_nil(actor) or is_struct(actor, User) do
attrs = to_string_map(attrs)
project = Repo.get!(Project, item.project_id)
incoming = Map.get(attrs, "values", %{})
case cast_position(Map.get(attrs, "position")) do
{:error, message} ->
{:error, Ecto.Changeset.add_error(ProjectItem.changeset(item, %{}), :position, message)}
{:ok, position} ->
item
|> move_within_project(project, incoming, position, actor)
|> case do
{:ok, {:unchanged, moved}} ->
{:ok, Repo.preload(moved, [issue: :repository], force: true)}
{:ok, {:moved, moved}} ->
Repositories.broadcast_projects(project.repository_id)
{:ok, Repo.preload(moved, [issue: :repository], force: true)}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, changeset}
{:error, reason} ->
{:error, reason}
end
end
end
defp move_within_project(item, project, incoming, position, actor) do
Repo.transaction(fn ->
_lock =
Repo.one!(
from project_row in Project,
where: project_row.id == ^project.id,
lock: "FOR UPDATE",
select: project_row.id
)
current = Repo.get!(ProjectItem, item.id)
previous = current.values || %{}
values =
case incoming do
incoming when is_map(incoming) -> Map.merge(previous, incoming)
incoming -> incoming
end
case prepare_promise_values(project, values, actor, current.id) do
{:error, errors} ->
Repo.rollback(add_errors(ProjectItem.changeset(current, %{}), errors))
{:ok, values} ->
ordered =
ProjectItem
|> where(project_id: ^project.id)
|> order_by(asc: :position, asc: :id)
|> Repo.all()
grouping = board_grouping(project)
target = %{current | values: values}
arranged = splice(ordered, target, position, grouping)
renumbered = renumber_items(arranged)
moved =
if values == previous do
current
else
Repo.update!(ProjectItem.changeset(current, %{"values" => values}))
end
if values == previous and renumbered == [] do
{:unchanged, Repo.get!(ProjectItem, current.id)}
else
record_item_event(moved, project, actor, previous, values, "move")
{:moved, Repo.get!(ProjectItem, current.id)}
end
end
end)
end
# A `nil` position means "wherever the destination column puts you"; anything
# that is not a positive integer is a caller mistake rather than a default.
defp cast_position(nil), do: {:ok, nil}
defp cast_position(position) when is_integer(position) and position > 0, do: {:ok, position}
defp cast_position(position) when is_binary(position) do
case Integer.parse(position) do
{parsed, ""} -> cast_position(parsed)
_other -> {:error, "must be a positive integer"}
end
end
defp cast_position(_position), do: {:error, "must be a positive integer"}
# Splices `target` into `ordered` so that it becomes the `position`-th member
# of its destination column. A `nil` position leaves a card that stayed in its
# column where it was and appends one that changed columns.
defp splice(ordered, target, position, grouping) do
previous_index = Enum.find_index(ordered, &(&1.id == target.id))
before = Enum.at(ordered, previous_index)
rest = List.delete_at(ordered, previous_index)
column = board_column_id(grouping, target.values)
members =
for {item, index} <- Enum.with_index(rest),
board_column_id(grouping, item.values) == column,
do: index
index =
cond do
is_nil(position) and board_column_id(grouping, before.values) == column ->
previous_index
members == [] ->
length(rest)
true ->
rank = position || length(members) + 1
cond do
rank <= 1 -> hd(members)
rank > length(members) -> List.last(members) + 1
true -> Enum.at(members, rank - 1)
end
end
List.insert_at(rest, index, target)
end
# Positions are dense and one-based after every move, so a reader never has
# to interpret a gap and a later move never runs out of room between two
# cards.
defp renumber_items(arranged) do
arranged
|> Enum.with_index(1)
|> Enum.reduce([], fn {item, rank}, changed ->
if item.position == rank do
changed
else
{1, _} =
ProjectItem
|> where(id: ^item.id)
|> Repo.update_all(set: [position: rank])
[item.id | changed]
end
end)
end
@default_board_field "Status"
@default_board_columns ["To Do", "In Progress", "Done"]
@doc """
The field a board groups its cards by, and the columns that field offers.
A board renders the project's stored fields rather than a fixed set of
headings: the `promise_state` field if the project declares one, otherwise a
`single_select` field named **Status**, otherwise the three default columns a
project has before it declares any field at all.
A column carries the option's `id` and its `name` separately, because an item
stores the identifier. Relabelling an option moves the heading and leaves
every card where it is.
"""
def board_grouping(%Project{} = project) do
fields = list_project_fields(project)
field =
Enum.find(fields, &(&1.data_type == "promise_state")) ||
Enum.find(
fields,
&(&1.data_type == "single_select" and
String.downcase(&1.name) == String.downcase(@default_board_field))
)
case field do
%ProjectField{} = field ->
%{field_name: field.name, columns: ProjectField.options(field), source: :field}
nil ->
%{
field_name: @default_board_field,
columns: Enum.map(@default_board_columns, &%{id: &1, name: &1}),
source: :default
}
end
end
@doc """
The column `values` belongs in, as an option identifier, or `nil`.
A stored value the field no longer offers is stale, and a board that dropped
the card would lose it silently, so a stale value sorts into no column and
the board renders it apart. The default grouping has no declared options to
be stale against, so an unrecognized value falls into the first column, which
is what a board did before any field existed.
"""
def board_column_id(grouping, values) do
value = Map.get(values || %{}, grouping.field_name)
ids = Enum.map(grouping.columns, & &1.id)
cond do
value in ids -> value
grouping.source == :default -> List.first(ids)
true -> nil
end
end
@doc """
Records new field values on one project item.
A committed change announces itself on the repository's project topic, the
same way every other project write does. A card's `Status` is a stored field
value, so a board that did not hear about this write would keep rendering the
card in the column it left — the write path, not the caller, owns the
announcement, so a change made over `/api/v1` and a change made from the
board produce the same event.
"""
def update_project_item(%ProjectItem{} = item, attrs, actor \\ nil) do
attrs = to_string_map(attrs)
values =
case Map.get(attrs, "values", %{}) do
incoming when is_map(incoming) -> Map.merge(item.values || %{}, incoming)
incoming -> incoming
end
project = Repo.get!(Project, item.project_id)
case prepare_promise_values(project, values, actor, item.id) do
{:ok, values} ->
changeset = ProjectItem.changeset(item, %{"values" => values})
Repo.transaction(fn ->
updated = Repo.update!(changeset)
record_item_event(updated, project, actor, item.values || %{}, values, "update")
updated
end)
|> case do
{:ok, updated} ->
Repositories.broadcast_projects(project.repository_id)
{:ok, Repo.preload(updated, issue: :repository)}
result ->
result
end
{:error, errors} ->
{:error, add_errors(ProjectItem.changeset(item, %{"values" => values}), errors)}
end
end
def list_project_fields(%Project{id: project_id}) do
ProjectField
|> where(project_id: ^project_id)
|> order_by(asc: :id)
|> Repo.all()
end
@doc "One field of `project`, by id."
def get_project_field!(%Project{id: project_id}, id),
do: Repo.get_by!(ProjectField, id: id, project_id: project_id)
def create_project_field(attrs), do: create_project_field(attrs, nil)
@doc """
Declares one field on a project, attributed to `actor`.
A project carries at most one `promise_state` field, because the promise
registry reads a single stored state per item.
"""
def create_project_field(attrs, actor) when is_nil(actor) or is_struct(actor, User) do
changeset =
%ProjectField{}
|> ProjectField.changeset(attrs)
|> PromiseRegistry.validate_field()
|> validate_promise_field_available()
if changeset.valid? do
Repo.transaction(fn ->
case Repo.insert(changeset) do
{:ok, field} ->
record_field_activity(field.project_id, "Added the field `#{field.name}`.", actor)
field
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
else
{:error, changeset}
end
end
@doc """
Updates one field of `project`, attributed to `actor`.
A field's name is the key its values are stored under on every item, so a
rename rewrites that key across the project's items in the same transaction:
a rename that left the values behind would silently empty the column.
The data type never changes. Values already stored were written against the
old type, and reinterpreting them is a destructive change wearing an edit's
clothes — declare a new field instead.
Options grow freely, and an option written as an object keeps its identifier
while its label changes. Removing an option that items still carry is
refused, so a removal cannot strand the values that chose it.
"""
def update_project_field(project, field, attrs, actor \\ nil)
def update_project_field(
%Project{id: project_id} = project,
%ProjectField{project_id: project_id} = field,
attrs,
actor
)
when is_nil(actor) or is_struct(actor, User) do
attrs = attrs |> to_string_map() |> Map.take(["name", "data_type", "options"])
changeset =
field
|> ProjectField.changeset(Map.put(attrs, "project_id", field.project_id))
|> refuse_data_type_change()
|> PromiseRegistry.validate_field()
|> refuse_stranded_options(project, field)
if changeset.valid? do
Repo.transaction(fn ->
case Repo.update(changeset) do
{:ok, updated} ->
rename_item_values(project, field.name, updated.name)
Enum.each(field_activity_bodies(field, updated), fn body ->
record_field_activity(project.id, body, actor)
end)
updated
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
|> case do
{:ok, updated} ->
Repositories.broadcast_projects(project.repository_id)
{:ok, updated}
result ->
result
end
else
{:error, changeset}
end
end
@doc """
Removes one field of `project`, attributed to `actor`.
A field whose values items still carry is preserved instead: deleting it
would leave those values keyed to a column nothing declares, which is data
loss reported as success.
"""
def delete_project_field(project, field, actor \\ nil)
def delete_project_field(
%Project{id: project_id} = project,
%ProjectField{project_id: project_id} = field,
actor
)
when is_nil(actor) or is_struct(actor, User) do
case count_items_carrying(project, field.name) do
0 ->
Repo.transaction(fn ->
Repo.delete!(field)
record_field_activity(project.id, "Removed the field `#{field.name}`.", actor)
field
end)
|> case do
{:ok, deleted} ->
Repositories.broadcast_projects(project.repository_id)
{:ok, deleted}
result ->
result
end
count ->
{:error,
field
|> ProjectField.changeset(%{})
|> Ecto.Changeset.add_error(
:name,
"is still carried by #{count} #{pluralize(count, "item")}"
)}
end
end
defp pluralize(1, word), do: word
defp pluralize(_count, word), do: word <> "s"
# A rename is a key rewrite on every item of the project. Postgres does it in
# one statement: drop the old key, then write the value back under the new
# one.
defp rename_item_values(_project, name, name), do: :ok
defp rename_item_values(%Project{id: project_id}, old_name, new_name) do
from(item in ProjectItem,
where:
item.project_id == ^project_id and
fragment("jsonb_exists(?, ?::text)", item.values, ^old_name),
update: [
set: [
values:
fragment(
"(? - ?::text) || jsonb_build_object(?::text, ? -> ?::text)",
item.values,
^old_name,
^new_name,
item.values,
^old_name
)
]
]
)
|> Repo.update_all([])
:ok
end
defp count_items_carrying(%Project{id: project_id}, name) do
Repo.aggregate(
from(item in ProjectItem,
where:
item.project_id == ^project_id and
fragment("jsonb_exists(?, ?::text)", item.values, ^name)
),
:count
)
end
defp refuse_data_type_change(changeset) do
case Ecto.Changeset.fetch_change(changeset, :data_type) do
{:ok, _data_type} ->
Ecto.Changeset.add_error(
changeset,
:data_type,
"cannot change once the field exists; declare a new field instead"
)
:error ->
changeset
end
end
defp refuse_stranded_options(changeset, %Project{} = project, %ProjectField{} = field) do
case Ecto.Changeset.fetch_change(changeset, :options) do
:error ->
changeset
{:ok, _options} ->
kept = ProjectField.option_ids(Ecto.Changeset.apply_changes(changeset))
removed = ProjectField.option_ids(field) -- kept
case options_in_use(project, field.name, removed) do
[] ->
changeset
in_use ->
Ecto.Changeset.add_error(
changeset,
:options,
"cannot drop #{Enum.map_join(in_use, ", ", &"`#{&1}`")} while items still carry them"
)
end
end
end
defp options_in_use(_project, _name, []), do: []
defp options_in_use(%Project{id: project_id}, name, removed) do
Repo.all(
from item in ProjectItem,
where:
item.project_id == ^project_id and
fragment("?->>?::text", item.values, ^name) in ^removed,
select: fragment("?->>?::text", item.values, ^name),
distinct: true
)
end
defp field_activity_bodies(%ProjectField{} = before, %ProjectField{} = now) do
rename =
if before.name != now.name,
do: ["Renamed the field `#{before.name}` to `#{now.name}`."],
else: []
options =
if before.options != now.options,
do: ["Updated the options of the field `#{now.name}`."],
else: []
rename ++ options
end
defp record_field_activity(project_id, body, actor) do
case Repo.get(Project, project_id) do
nil ->
:ok
%Project{} = project ->
case insert_note(project, %{"body" => body, "kind" => "activity"}, actor) do
{:ok, _note} -> :ok
{:error, changeset} -> Repo.rollback(changeset)
end
end
end
defp validate_promise_field_available(changeset) do
if promise_field_available?(changeset) do
changeset
else
Ecto.Changeset.add_error(
changeset,
:data_type,
"already exists on this project"
)
end
end
# Only values a declared field claims are checked. A value under a key no
# field declares passes through untouched: a board can carry notes the
# schema has not caught up with, and rejecting them would break every client
# that stored a value before its field existed.
defp validate_field_values(%Project{} = project, values) when is_map(values) do
errors =
project
|> list_project_fields()
|> Enum.flat_map(fn field ->
case Map.fetch(values, field.name) do
:error -> []
{:ok, nil} -> []
{:ok, value} -> field_value_errors(field, value)
end
end)
case errors do
[] -> :ok
errors -> {:error, %{values: errors}}
end
end
defp validate_field_values(_project, _values), do: :ok
defp field_value_errors(%ProjectField{data_type: "text", name: name}, value)
when not is_binary(value),
do: ["#{name} must be text"]
defp field_value_errors(%ProjectField{data_type: "number", name: name}, value)
when not is_number(value),
do: ["#{name} must be a number"]
defp field_value_errors(%ProjectField{data_type: "date", name: name}, value) do
case is_binary(value) and Date.from_iso8601(value) do
{:ok, _date} -> []
_invalid -> ["#{name} must be an ISO 8601 date"]
end
end
defp field_value_errors(%ProjectField{data_type: "single_select"} = field, value) do
if value in ProjectField.option_ids(field) do
[]
else
["#{field.name} must be one of the field's options"]
end
end
# A promise state carries its own gate checks in `PromiseRegistry`, which
# reports richer reasons than "not one of the options" would.
defp field_value_errors(%ProjectField{}, _value), do: []
def list_project_item_events(%ProjectItem{id: item_id}, opts \\ []) do
page = max(parse_page(opts[:page]), 1)
per_page = 25
query = from event in ProjectItemEvent, where: event.project_item_id == ^item_id
total = Repo.aggregate(query, :count)
events =
query
|> order_by([event], desc: event.occurred_at, desc: event.id)
|> limit(^per_page)
|> offset(^((page - 1) * per_page))
|> Repo.all()
{events, total, page, per_page}
end
defp next_project_number(repository_id) do
case Repo.aggregate(
from(project in Project, where: project.repository_id == ^repository_id),
:max,
:number
) do
nil -> 1
number -> number + 1
end
end
defp project_items_query(project_id, repository_id) do
from(item in ProjectItem,
where: item.project_id == ^project_id and item.repository_id == ^repository_id
)
end
defp prepare_promise_values(project, values, actor, exclude_id \\ nil)
defp prepare_promise_values(_project, values, _actor, _exclude_id) when not is_map(values) do
{:error, %{values: ["must be a map"]}}
end
defp prepare_promise_values(project, values, actor, exclude_id) do
with :ok <- validate_field_values(project, values) do
prepare_promise_values!(project, values, actor, exclude_id)
end
end
defp prepare_promise_values!(project, values, actor, exclude_id) do
case PromiseRegistry.validate_values(project, values, actor) do
{:ok, values} ->
if PromiseRegistry.registry?(project) do
promise_id = get_in(values, ["promise", "id"])
if promise_id && promise_id_taken?(project.id, promise_id, exclude_id) do
{:error, %{values: ["promise.id must be unique within this project"]}}
else
{:ok, values}
end
else
{:ok, values}
end
{:error, errors} ->
{:error, errors}
end
end
defp insert_project_item(attrs, project, actor) do
Repo.transaction(fn ->
case Repo.insert(ProjectItem.changeset(%ProjectItem{}, attrs)) do
{:ok, item} ->
record_item_event(item, project, actor, %{}, item.values || %{}, "create")
item
{:error, changeset} ->
Repo.rollback(changeset)
end
end)
|> case do
{:ok, item} -> {:ok, Repo.preload(item, issue: :repository)}
{:error, reason} -> {:error, reason}
end
end
# Every item mutation appends one actor-attributed entry. The states it
# records are the board's own grouping values, which for a promise registry
# are the promise states and for any other board are its stored Status
# values, so one log answers "what moved, who moved it, and from where" for
# every kind of board.
defp record_item_event(item, project, actor, previous, values, kind) do
grouping = board_grouping(project)
from_state = stored_column_value(grouping, previous)
to_state = stored_column_value(grouping, values)
kind =
if (kind in ["create", "update"] and from_state) && from_state != to_state,
do: "state_change",
else: kind
%ProjectItemEvent{}
|> ProjectItemEvent.changeset(%{
project_item_id: item.id,
project_id: project.id,
repository_id: project.repository_id,
actor_user_id: actor && actor.id,
actor_login: actor_login(actor),
kind: kind,
from_state: from_state,
to_state: to_state,
changes: %{"values" => values},
occurred_at: DateTime.utc_now()
})
|> Repo.insert!()
end
defp stored_column_value(grouping, values) when is_map(values),
do: Map.get(values, grouping.field_name)
defp stored_column_value(_grouping, _values), do: nil
defp actor_login(nil), do: "system"
defp actor_login(%User{github_login: login}) when is_binary(login), do: login
defp actor_login(_actor), do: "system"
defp promise_field_available?(changeset) do
project_id = Ecto.Changeset.get_field(changeset, :project_id)
data_type = Ecto.Changeset.get_field(changeset, :data_type)
data_type != "promise_state" or
not Repo.exists?(
from field in ProjectField,
where: field.project_id == ^project_id and field.data_type == "promise_state"
)
end
defp promise_id_taken?(project_id, promise_id, exclude_id) do
query =
from item in ProjectItem,
where:
item.project_id == ^project_id and
fragment("?->'promise'->>'id' = ?", item.values, ^promise_id)
query =
if exclude_id do
where(query, [item], item.id != ^exclude_id)
else
query
end
Repo.exists?(query)
end
defp add_errors(changeset, errors) do
Enum.reduce(errors, changeset, fn {field, messages}, changeset ->
Enum.reduce(messages, changeset, &Ecto.Changeset.add_error(&2, field, &1))
end)
end
defp number_conflict?(changeset) do
Enum.any?(changeset.errors, fn {_field, {_message, options}} ->
options[:constraint_name] == "projects_repository_id_number_index"
end)
end
defp put_owner(attrs, nil), do: attrs
defp put_owner(attrs, %User{} = owner) do
attrs
|> Map.put("owner", owner.github_login)
|> Map.put("owner_user_id", owner.id)
end
defp to_string_map(attrs) do
for {key, value} <- attrs, into: %{}, do: {to_string(key), value}
end
end