diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 47f9e9399..10b45c372 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -127,42 +127,96 @@ defmodule Algora.Bounties do end @spec do_claim_bounty(%{ - user: User.t(), + provider_login: String.t(), + token: String.t(), target: Ticket.t(), source: Ticket.t(), + group_id: String.t() | nil, + group_share: Decimal.t(), status: :pending | :approved | :rejected | :paid, type: :pull_request | :review | :video | :design | :article }) :: {:ok, Claim.t()} | {:error, atom()} - defp do_claim_bounty(%{user: user, target: target, source: source, status: status, type: type}) do - # TODO: ensure user is pull request author - changeset = - Claim.changeset(%Claim{}, %{ - target_id: target.id, - source_id: source.id, - user_id: user.id, - type: type, - status: status, - url: source.url - }) - - activity_attrs = %{type: :claim_submitted, notify_users: [user.id]} - - case Repo.insert_with_activity(changeset, activity_attrs) do - {:ok, claim} -> - {:ok, claim} - + defp do_claim_bounty(%{ + provider_login: provider_login, + token: token, + target: target, + source: source, + group_id: group_id, + group_share: group_share, + status: status, + type: type + }) do + with {:ok, user} <- Workspace.ensure_user(token, provider_login), + activity_attrs = %{type: :claim_submitted, notify_users: [user.id]}, + {:ok, claim} <- + Repo.insert_with_activity( + Claim.changeset(%Claim{}, %{ + target_id: target.id, + source_id: source.id, + user_id: user.id, + type: type, + status: status, + url: source.url, + group_id: group_id, + group_share: group_share + }), + activity_attrs + ) do + {:ok, claim} + else {:error, %{errors: [target_id: {_, [constraint: :unique, constraint_name: _]}]}} -> {:error, :already_exists} - {:error, _changeset} = error -> + {:error, _reason} = error -> error end end + @spec do_claim_bounties(%{ + provider_logins: [String.t()], + token: String.t(), + target: Ticket.t(), + source: Ticket.t(), + status: :pending | :approved | :rejected | :paid, + type: :pull_request | :review | :video | :design | :article + }) :: + {:ok, [Claim.t()]} | {:error, atom()} + defp do_claim_bounties(%{ + provider_logins: provider_logins, + token: token, + target: target, + source: source, + status: status, + type: type + }) do + Enum.reduce_while(provider_logins, {:ok, []}, fn provider_login, {:ok, acc} -> + group_id = + case List.last(acc) do + nil -> nil + primary_claim -> primary_claim.group_id + end + + case do_claim_bounty(%{ + provider_login: provider_login, + token: token, + target: target, + source: source, + status: status, + type: type, + group_id: group_id, + group_share: Decimal.div(1, length(provider_logins)) + }) do + {:ok, claim} -> {:cont, {:ok, [claim | acc]}} + error -> {:halt, error} + end + end) + end + @spec claim_bounty( %{ user: User.t(), + coauthor_provider_logins: [String.t()], target_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, source_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, status: :pending | :approved | :rejected | :paid, @@ -174,6 +228,7 @@ defmodule Algora.Bounties do def claim_bounty( %{ user: user, + coauthor_provider_logins: coauthor_provider_logins, target_ticket_ref: %{owner: target_repo_owner, repo: target_repo_name, number: target_number}, source_ticket_ref: %{owner: source_repo_owner, repo: source_repo_name, number: source_number}, status: status, @@ -192,7 +247,15 @@ defmodule Algora.Bounties do with {:ok, token} <- token_res, {:ok, target} <- Workspace.ensure_ticket(token, target_repo_owner, target_repo_name, target_number), {:ok, source} <- Workspace.ensure_ticket(token, source_repo_owner, source_repo_name, source_number), - {:ok, claim} <- do_claim_bounty(%{user: user, target: target, source: source, status: status, type: type}), + {:ok, [claim | _]} <- + do_claim_bounties(%{ + provider_logins: [user.provider_login | coauthor_provider_logins], + token: token, + target: target, + source: source, + status: status, + type: type + }), {:ok, _job} <- notify_claim(%{claim: claim}, installation_id: installation_id) do broadcast() {:ok, claim} @@ -208,7 +271,7 @@ defmodule Algora.Bounties do ) :: {:ok, Oban.Job.t()} | {:error, atom()} def notify_claim(%{claim: claim}, opts \\ []) do - %{claim_id: claim.id, installation_id: opts[:installation_id]} + %{claim_group_id: claim.group_id, installation_id: opts[:installation_id]} |> Jobs.NotifyClaim.new() |> Oban.insert() end diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex index a73a8da3b..d0e170f67 100644 --- a/lib/algora/bounties/jobs/notify_claim.ex +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -2,6 +2,8 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do @moduledoc false use Oban.Worker, queue: :notify_claim + import Ecto.Query + alias Algora.Bounties.Claim alias Algora.Github alias Algora.Repo @@ -9,40 +11,67 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do require Logger @impl Oban.Worker - def perform(%Oban.Job{args: %{"claim_id" => _claim_id, "installation_id" => nil}}) do + def perform(%Oban.Job{args: %{"claim_group_id" => _claim_group_id, "installation_id" => nil}}) do :ok end @impl Oban.Worker - def perform(%Oban.Job{args: %{"claim_id" => claim_id, "installation_id" => installation_id}}) do + def perform(%Oban.Job{args: %{"claim_group_id" => claim_group_id, "installation_id" => installation_id}}) do with {:ok, token} <- Github.get_installation_token(installation_id), - {:ok, claim} <- Repo.fetch(Claim, claim_id), - claim = Repo.preload(claim, source: [repository: [:user]], target: [repository: [:user]], user: []), - {:ok, _} <- maybe_add_labels(token, claim), - {:ok, _} <- add_comment(token, claim) do + claims = + from(c in Claim, + where: c.group_id == ^claim_group_id, + order_by: [asc: c.inserted_at] + ) + |> Repo.all() + |> Repo.preload([:user, source: [repository: [:user]], target: [repository: [:user]]]), + {:ok, _} <- maybe_add_labels(token, claims), + {:ok, _} <- add_comment(token, claims) do :ok end end - defp add_comment(token, claim) do + defp add_comment(token, claims) do + primary_claim = List.first(claims) + Github.create_issue_comment( token, - claim.target.repository.user.provider_login, - claim.target.repository.name, - claim.target.number, - "💡 @#{claim.user.provider_login} submitted [#{Claim.type_label(claim.type)}](#{claim.url}) that claims the bounty. You can visit [Algora](#{Claim.reward_url(claim)}) to reward." + primary_claim.target.repository.user.provider_login, + primary_claim.target.repository.name, + primary_claim.target.number, + "💡 #{names(claims)} submitted [#{Claim.type_label(primary_claim.type)}](#{primary_claim.url}) that claims the bounty. You can visit [Algora](#{Claim.reward_url(primary_claim)}) to reward." ) end - defp maybe_add_labels(token, %Claim{source: source} = claim) when not is_nil(source) do - Github.add_labels( - token, - claim.source.repository.user.provider_login, - claim.source.repository.name, - claim.source.number, - ["🙋 Bounty claim"] - ) + defp maybe_add_labels(token, claims) do + primary_claim = List.first(claims) + + if primary_claim.source do + Github.add_labels( + token, + primary_claim.source.repository.user.provider_login, + primary_claim.source.repository.name, + primary_claim.source.number, + ["🙋 Bounty claim"] + ) + else + {:ok, nil} + end end - defp maybe_add_labels(_token, _claim), do: {:ok, nil} + defp names([claim]) do + "@#{claim.user.provider_login}" + end + + defp names([c1, c2]) do + "@#{c1.user.provider_login} and @#{c2.user.provider_login}" + end + + defp names([c1, c2, c3]) do + "@#{c1.user.provider_login}, @#{c2.user.provider_login} and @#{c3.user.provider_login}" + end + + defp names([c1, c2 | claims]) do + "@#{c1.user.provider_login}, @#{c2.user.provider_login} and #{length(claims)} others" + end end diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index 2031a3da8..3f6536ba6 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -24,7 +24,7 @@ defmodule Algora.Bounties.Claim do def changeset(claim, attrs) do claim - |> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id]) + |> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id, :group_share]) |> validate_required([:target_id, :user_id, :status, :type, :url]) |> generate_id() |> put_group_id() @@ -54,7 +54,7 @@ defmodule Algora.Bounties.Claim do def type_label(:article), do: "an article" def type_label(nil), do: "a URL" - def reward_url(claim), do: "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.id}" + def reward_url(claim), do: "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.group_id}" def rewarded(query \\ Claim) do from c in query, diff --git a/lib/algora/integrations/github/command.ex b/lib/algora/integrations/github/command.ex index 514c6876d..26245da4d 100644 --- a/lib/algora/integrations/github/command.ex +++ b/lib/algora/integrations/github/command.ex @@ -8,7 +8,8 @@ defmodule Algora.Github.Command do @usage %{ bounty: "/bounty ", tip: "/tip @username or /tip @username ", - claim: "/claim (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)" + claim: "/claim (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)", + split: "/split @username" } def commands do @@ -21,7 +22,8 @@ defmodule Algora.Github.Command do choice([ bounty_command(), tip_command(), - claim_command() + claim_command(), + split_command() ]), # Unknown command @@ -58,6 +60,16 @@ defmodule Algora.Github.Command do |> label(@usage.tip) end + def split_command do + "/split" + |> string() + |> ignore() + |> concat(ignore(whitespace())) + |> concat(recipient()) + |> tag(:split) + |> label(@usage.split) + end + def claim_command do "/claim" |> string() diff --git a/lib/algora/shared/parser.ex b/lib/algora/shared/parser.ex index 983dee239..aa05a8854 100644 --- a/lib/algora/shared/parser.ex +++ b/lib/algora/shared/parser.ex @@ -6,8 +6,8 @@ defmodule Algora.Parser do @moduledoc false def whitespace, do: ascii_string([?\s, ?\t], min: 1) def digits, do: ascii_string([?0..?9], min: 1) - def word_chars, do: ascii_string([not: ?\s, not: ?\t], min: 1) - def non_separator_chars, do: ascii_string([not: ?#, not: ?/, not: ?\s, not: ?\t], min: 1) + def word_chars, do: ascii_string([not: ?\s, not: ?\t, not: ?\n], min: 1) + def non_separator_chars, do: ascii_string([not: ?#, not: ?/, not: ?\s, not: ?\t, not: ?\n], min: 1) def integer, do: reduce(digits(), {__MODULE__, :to_integer, []}) def amount do diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index c58a63d82..5ec098150 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -138,6 +138,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do Bounties.claim_bounty( %{ user: user, + coauthor_provider_logins: (args[:splits] || []) |> Enum.map(& &1[:recipient]) |> Enum.uniq(), target_ticket_ref: target_ticket_ref, source_ticket_ref: source_ticket_ref, status: if(pull_request["merged_at"], do: :approved, else: :pending), @@ -152,13 +153,36 @@ defmodule AlgoraWeb.Webhooks.GithubController do {:ok, nil} end + def build_command({:claim, args}, commands) do + splits = Keyword.get_values(commands, :split) + {:claim, Keyword.put(args, :splits, splits)} + end + + def build_command({:split, _args}, _commands), do: nil + + def build_command(command, _commands), do: command + + def build_commands(body) do + case Github.Command.parse(body) do + {:ok, commands} -> + {:ok, + commands + |> Enum.map(&build_command(&1, commands)) + |> Enum.reject(&is_nil/1)} + + {:error, reason} = error -> + Logger.error("Error parsing commands: #{inspect(reason)}") + error + end + end + def process_commands(%Webhook{event: event, hook_id: hook_id}, params) do author = get_author(event, params) body = get_body(event, params) event_action = event <> "." <> params["action"] - case Github.Command.parse(body) do + case build_commands(body) do {:ok, commands} -> Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, results} -> case execute_command(event_action, command, author, params) do diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index c45e9aa6c..2cd8b6657 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -397,10 +397,6 @@ defmodule AlgoraWeb.ClaimLive do <.card_title> Authors - - <.button variant="secondary" phx-click="split_bounty"> - Split bounty - <.card_content> diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs index d00f704ef..7faff16c2 100644 --- a/test/algora/bounties_test.exs +++ b/test/algora/bounties_test.exs @@ -63,6 +63,7 @@ defmodule Algora.BountiesTest do Algora.Bounties.claim_bounty( %{ user: recipient, + coauthor_provider_logins: [], target_ticket_ref: ticket_ref, source_ticket_ref: ticket_ref, status: :approved, diff --git a/test/support/factory.ex b/test/support/factory.ex index e25deae72..4a6b71844 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -37,7 +37,8 @@ defmodule Algora.Factory do twitter_url: "https://twitter.com/erich", github_url: "https://github.com/erich", linkedin_url: "https://linkedin.com/in/erich", - provider: "github" + provider: "github", + provider_login: sequence(:provider_login, &"erlich#{&1}") } end