diff --git a/config/config.exs b/config/config.exs index 679ce7cb3..6a6476c76 100644 --- a/config/config.exs +++ b/config/config.exs @@ -32,7 +32,8 @@ config :algora, Oban, comment_consumers: 1, github_og_image: 5, notify_bounty: 1, - notify_tip_intent: 1 + notify_tip_intent: 1, + notify_claim: 1 ] # Configures the mailer diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex index 08a8dd390..532ec12be 100644 --- a/lib/algora/accounts/accounts.ex +++ b/lib/algora/accounts/accounts.ex @@ -318,12 +318,15 @@ defmodule Algora.Accounts do end def get_random_access_tokens(n) when is_integer(n) and n > 0 do - Identity - |> where([i], i.provider == "github" and not is_nil(i.provider_token)) - |> order_by(fragment("RANDOM()")) - |> limit(^n) - |> select([i], i.provider_token) - |> Repo.all() + case Identity + |> where([i], i.provider == "github" and not is_nil(i.provider_token)) + |> order_by(fragment("RANDOM()")) + |> limit(^n) + |> select([i], i.provider_token) + |> Repo.all() do + [""] -> [] + tokens -> tokens + end end defp update_github_token(%User{} = user, new_token) do diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index db1584ee9..d8e940aba 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -8,10 +8,10 @@ defmodule Algora.Bounties do alias Algora.Bounties.Bounty alias Algora.Bounties.Claim alias Algora.Bounties.Jobs + alias Algora.Bounties.LineItem alias Algora.Bounties.Tip alias Algora.FeeTier alias Algora.Github - alias Algora.MoneyUtils alias Algora.Organizations.Member alias Algora.Payments alias Algora.Payments.Transaction @@ -56,8 +56,8 @@ defmodule Algora.Bounties do {:error, %{errors: [ticket_id: {_, [constraint: :unique, constraint_name: _]}]}} -> {:error, :already_exists} - {:error, _changeset} -> - {:error, :internal_server_error} + {:error, _changeset} = error -> + error end end @@ -121,6 +121,91 @@ defmodule Algora.Bounties do |> Oban.insert() end + @spec do_claim_bounty(%{ + user: User.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_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 + }) + + case Repo.insert(changeset) do + {:ok, claim} -> + {:ok, claim} + + {:error, %{errors: [target_id: {_, [constraint: :unique, constraint_name: _]}]}} -> + {:error, :already_exists} + + {:error, _changeset} = error -> + error + end + end + + @spec claim_bounty( + %{ + user: User.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, + type: :pull_request | :review | :video | :design | :article + }, + opts :: [installation_id: integer()] + ) :: + {:ok, Bounty.t()} | {:error, atom()} + def claim_bounty( + %{ + user: user, + 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, + type: type + }, + opts \\ [] + ) do + installation_id = opts[:installation_id] + + token_res = + if installation_id, + do: Github.get_installation_token(installation_id), + else: Accounts.get_access_token(user) + + Repo.transact(fn -> + 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, _job} <- notify_claim(%{claim: claim}, installation_id: installation_id) do + broadcast() + {:ok, claim} + else + {:error, _reason} = error -> error + end + end) + end + + @spec notify_claim( + %{claim: Claim.t()}, + opts :: [installation_id: integer()] + ) :: + {:ok, Oban.Job.t()} | {:error, atom()} + def notify_claim(%{claim: claim}, opts \\ []) do + %{claim_id: claim.id, installation_id: opts[:installation_id]} + |> Jobs.NotifyClaim.new() + |> Oban.insert() + end + @spec create_tip_intent( %{ recipient: String.t(), @@ -160,8 +245,6 @@ defmodule Algora.Bounties do ) :: {:ok, String.t()} | {:error, atom()} def create_tip(%{creator: creator, owner: owner, recipient: recipient, amount: amount}, opts \\ []) do - ticket_ref = opts[:ticket_ref] - changeset = Tip.changeset(%Tip{}, %{ amount: amount, @@ -170,94 +253,144 @@ defmodule Algora.Bounties do recipient_id: recipient.id }) - # Initialize transaction IDs - charge_id = Nanoid.generate() - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() - tx_group_id = Nanoid.generate() + Repo.transact(fn -> + with {:ok, tip} <- Repo.insert(changeset) do + create_payment_session( + %{owner: owner, amount: amount, description: "Tip payment for OSS contributions"}, + ticket_ref: opts[:ticket_ref], + tip_id: tip.id, + recipient: recipient + ) + end + end) + end - # Calculate fees - currency = to_string(amount.currency) - total_paid = Payments.get_total_paid(owner.id, recipient.id) - platform_fee_pct = FeeTier.calculate_fee_percentage(total_paid) + @spec reward_bounty( + %{ + owner: User.t(), + amount: Money.t(), + bounty_id: String.t(), + claims: [Claim.t()] + }, + opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}] + ) :: + {:ok, String.t()} | {:error, atom()} + def reward_bounty(%{owner: owner, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do + create_payment_session( + %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, + ticket_ref: opts[:ticket_ref], + bounty_id: bounty_id, + claims: claims + ) + end + + @spec generate_line_items( + %{amount: Money.t()}, + opts :: [ + ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, + claims: [Claim.t()], + recipient: User.t() + ] + ) :: + [LineItem.t()] + def generate_line_items(%{amount: amount}, opts \\ []) do + ticket_ref = opts[:ticket_ref] + recipient = opts[:recipient] + claims = opts[:claims] + + description = if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}") + + platform_fee_pct = FeeTier.calculate_fee_percentage(Money.zero(:USD)) transaction_fee_pct = Payments.get_transaction_fee_pct() - platform_fee = Money.mult!(amount, platform_fee_pct) - transaction_fee = Money.mult!(amount, transaction_fee_pct) - total_fee = Money.add!(platform_fee, transaction_fee) - gross_amount = Money.add!(amount, total_fee) - - line_items = [ - %{ - price_data: %{ - unit_amount: MoneyUtils.to_minor_units(amount), - currency: currency, - product_data: %{ - name: "Payment to @#{recipient.provider_login}", - # TODO: - description: - if(ticket_ref, - do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}", - else: "Tip to @#{recipient.provider_login}" - ), - images: [recipient.avatar_url] - } - }, - quantity: 1 - }, - %{ - price_data: %{ - unit_amount: MoneyUtils.to_minor_units(Money.mult!(amount, platform_fee_pct)), - currency: currency, - product_data: %{name: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})"} - }, - quantity: 1 - }, - %{ - price_data: %{ - unit_amount: MoneyUtils.to_minor_units(Money.mult!(amount, transaction_fee_pct)), - currency: currency, - product_data: %{name: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})"} + if recipient do + [ + %LineItem{ + amount: amount, + title: "Payment to @#{recipient.provider_login}", + description: description, + image: recipient.avatar_url, + type: :payout + } + ] + else + [] + end ++ + Enum.map(claims, fn claim -> + %LineItem{ + # TODO: ensure shares are normalized + amount: Money.mult!(amount, claim.group_share), + title: "Payment to @#{claim.user.provider_login}", + description: description, + image: claim.user.avatar_url, + type: :payout + } + end) ++ + [ + %LineItem{ + amount: Money.mult!(amount, platform_fee_pct), + title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})", + type: :fee }, - quantity: 1 - } - ] + %LineItem{ + amount: Money.mult!(amount, transaction_fee_pct), + title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})", + type: :fee + } + ] + end + + @spec create_payment_session( + %{owner: User.t(), amount: Money.t(), description: String.t()}, + opts :: [ + ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, + tip_id: String.t(), + bounty_id: String.t(), + claims: [Claim.t()], + recipient: User.t() + ] + ) :: + {:ok, String.t()} | {:error, atom()} + def create_payment_session(%{owner: owner, amount: amount, description: description}, opts \\ []) do + tx_group_id = Nanoid.generate() + + line_items = + generate_line_items(%{amount: amount}, + ticket_ref: opts[:ticket_ref], + recipient: opts[:recipient], + claims: opts[:claims] + ) + + gross_amount = LineItem.gross_amount(line_items) Repo.transact(fn -> - with {:ok, tip} <- Repo.insert(changeset), - {:ok, _charge} <- + with {:ok, _charge} <- initialize_charge(%{ - id: charge_id, - tip: tip, - user_id: creator.id, + id: Nanoid.generate(), + tip_id: opts[:tip_id], + bounty_id: opts[:bounty_id], + claim_id: nil, + user_id: owner.id, gross_amount: gross_amount, net_amount: amount, - total_fee: total_fee, + total_fee: Money.sub!(gross_amount, amount), line_items: line_items, group_id: tx_group_id }), - {:ok, _debit} <- - initialize_debit(%{ - id: debit_id, - tip: tip, - amount: amount, - user_id: creator.id, - linked_transaction_id: credit_id, - group_id: tx_group_id - }), - {:ok, _credit} <- - initialize_credit(%{ - id: credit_id, - tip: tip, + {:ok, _transactions} <- + create_transaction_pairs(%{ + claims: opts[:claims] || [], + tip_id: opts[:tip_id], + bounty_id: opts[:bounty_id], amount: amount, - user_id: recipient.id, - linked_transaction_id: debit_id, + creator_id: owner.id, group_id: tx_group_id }), {:ok, session} <- - Payments.create_stripe_session(line_items, %{ - # Mandatory for some countries like India - description: "Tip payment for OSS contributions", + line_items + |> Enum.map(&LineItem.to_stripe/1) + |> Payments.create_stripe_session(%{ + description: description, metadata: %{"version" => "2", "group_id" => tx_group_id} }) do {:ok, session.url} @@ -267,7 +400,8 @@ defmodule Algora.Bounties do defp initialize_charge(%{ id: id, - tip: tip, + tip_id: tip_id, + bounty_id: bounty_id, user_id: user_id, gross_amount: gross_amount, net_amount: net_amount, @@ -281,25 +415,30 @@ defmodule Algora.Bounties do provider: "stripe", type: :charge, status: :initialized, - tip_id: tip.id, + tip_id: tip_id, + bounty_id: bounty_id, user_id: user_id, gross_amount: gross_amount, net_amount: net_amount, total_fee: total_fee, - line_items: line_items, + line_items: Util.normalize_struct(line_items), group_id: group_id }) |> Algora.Validations.validate_positive(:gross_amount) |> Algora.Validations.validate_positive(:net_amount) |> Algora.Validations.validate_positive(:total_fee) - |> foreign_key_constraint(:tip_id) |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:tip_id) + |> foreign_key_constraint(:bounty_id) + |> foreign_key_constraint(:claim_id) |> Repo.insert() end defp initialize_debit(%{ id: id, - tip: tip, + tip_id: tip_id, + bounty_id: bounty_id, + claim_id: claim_id, amount: amount, user_id: user_id, linked_transaction_id: linked_transaction_id, @@ -311,7 +450,9 @@ defmodule Algora.Bounties do provider: "stripe", type: :debit, status: :initialized, - tip_id: tip.id, + tip_id: tip_id, + bounty_id: bounty_id, + claim_id: claim_id, user_id: user_id, gross_amount: amount, net_amount: amount, @@ -321,14 +462,18 @@ defmodule Algora.Bounties do }) |> Algora.Validations.validate_positive(:gross_amount) |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:tip_id) |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:tip_id) + |> foreign_key_constraint(:bounty_id) + |> foreign_key_constraint(:claim_id) |> Repo.insert() end defp initialize_credit(%{ id: id, - tip: tip, + tip_id: tip_id, + bounty_id: bounty_id, + claim_id: claim_id, amount: amount, user_id: user_id, linked_transaction_id: linked_transaction_id, @@ -340,7 +485,9 @@ defmodule Algora.Bounties do provider: "stripe", type: :credit, status: :initialized, - tip_id: tip.id, + tip_id: tip_id, + bounty_id: bounty_id, + claim_id: claim_id, user_id: user_id, gross_amount: amount, net_amount: amount, @@ -350,8 +497,10 @@ defmodule Algora.Bounties do }) |> Algora.Validations.validate_positive(:gross_amount) |> Algora.Validations.validate_positive(:net_amount) - |> foreign_key_constraint(:tip_id) |> foreign_key_constraint(:user_id) + |> foreign_key_constraint(:tip_id) + |> foreign_key_constraint(:bounty_id) + |> foreign_key_constraint(:claim_id) |> Repo.insert() end @@ -479,4 +628,52 @@ defmodule Algora.Bounties do reviews_count: 4 } end + + # Helper function to create transaction pairs + defp create_transaction_pairs(%{claims: claims} = params) when length(claims) > 0 do + Enum.reduce_while(claims, {:ok, []}, fn claim, {:ok, acc} -> + params + |> Map.put(:claim_id, claim.id) + |> Map.put(:recipient_id, claim.user.id) + |> create_single_transaction_pair() + |> case do + {:ok, transactions} -> {:cont, {:ok, transactions ++ acc}} + error -> {:halt, error} + end + end) + end + + defp create_transaction_pairs(params) do + create_single_transaction_pair(params) + end + + defp create_single_transaction_pair(params) do + debit_id = Nanoid.generate() + credit_id = Nanoid.generate() + + with {:ok, debit} <- + initialize_debit(%{ + id: debit_id, + tip_id: params.tip_id, + bounty_id: params.bounty_id, + claim_id: params.claim_id, + amount: params.amount, + user_id: params.creator_id, + linked_transaction_id: credit_id, + group_id: params.group_id + }), + {:ok, credit} <- + initialize_credit(%{ + id: credit_id, + tip_id: params.tip_id, + bounty_id: params.bounty_id, + claim_id: params.claim_id, + amount: params.amount, + user_id: params.recipient_id, + linked_transaction_id: debit_id, + group_id: params.group_id + }) do + {:ok, [debit, credit]} + end + end end diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index cebde955b..217b980dc 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -42,7 +42,8 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do with {:ok, token} <- Github.get_installation_token(installation_id), {:ok, installation} <- Workspace.fetch_installation_by(provider: "github", provider_id: to_string(installation_id)), - {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id) do + {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id), + {:ok, _} <- Github.add_labels(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], ["💎 Bounty"]) do body = """ ## 💎 #{amount} bounty [• #{owner.name}](#{User.url(owner)}) ### Steps to solve: diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex new file mode 100644 index 000000000..a73a8da3b --- /dev/null +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -0,0 +1,48 @@ +defmodule Algora.Bounties.Jobs.NotifyClaim do + @moduledoc false + use Oban.Worker, queue: :notify_claim + + alias Algora.Bounties.Claim + alias Algora.Github + alias Algora.Repo + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"claim_id" => _claim_id, "installation_id" => nil}}) do + :ok + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"claim_id" => claim_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 + :ok + end + end + + defp add_comment(token, claim) do + 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." + ) + 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"] + ) + end + + defp maybe_add_labels(_token, _claim), do: {:ok, nil} +end diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex index cb316db32..9cf85c1bd 100644 --- a/lib/algora/bounties/schemas/bounty.ex +++ b/lib/algora/bounties/schemas/bounty.ex @@ -14,7 +14,6 @@ defmodule Algora.Bounties.Bounty do belongs_to :owner, User belongs_to :creator, User has_many :attempts, Algora.Bounties.Attempt - has_many :claims, Algora.Bounties.Claim has_many :transactions, Algora.Payments.Transaction timestamps() diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index bebe17e79..7043ad0e8 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -3,44 +3,54 @@ defmodule Algora.Bounties.Claim do use Algora.Schema alias Algora.Bounties.Claim + alias Algora.Workspace.Ticket - @derive {Inspect, except: [:provider_meta]} typed_schema "claims" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map + field :status, Ecto.Enum, values: [:pending, :approved, :cancelled], null: false + field :type, Ecto.Enum, values: [:pull_request, :review, :video, :design, :article] + field :url, :string, null: false + field :group_id, :string, null: false + field :group_share, :decimal, null: false, default: 1.0 - field :type, Ecto.Enum, values: [:code, :video, :design, :article] - - field :status, Ecto.Enum, values: [:pending, :merged, :approved, :rejected, :charged, :paid] - - field :merged_at, :utc_datetime_usec - field :approved_at, :utc_datetime_usec - field :rejected_at, :utc_datetime_usec - field :charged_at, :utc_datetime_usec - field :paid_at, :utc_datetime_usec - - field :title, :string - field :description, :string - field :url, :string - field :group_id, :string - - belongs_to :bounty, Algora.Bounties.Bounty - belongs_to :user, Algora.Accounts.User - # has_one :transaction, Algora.Payments.Transaction + belongs_to :source, Ticket + belongs_to :target, Ticket, null: false + belongs_to :user, Algora.Accounts.User, null: false + has_many :transactions, Algora.Payments.Transaction timestamps() end def changeset(claim, attrs) do claim - |> cast(attrs, [:bounty_id, :user_id]) - |> validate_required([:bounty_id, :user_id]) + |> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id]) + |> validate_required([:target_id, :user_id, :status, :type, :url]) + |> generate_id() + |> put_group_id() + |> foreign_key_constraint(:source_id) + |> foreign_key_constraint(:target_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:target_id, :user_id]) end + def put_group_id(changeset) do + case get_field(changeset, :group_id) do + nil -> put_change(changeset, :group_id, get_field(changeset, :id)) + _existing -> changeset + end + end + + def type_label(:pull_request), do: "a pull request" + def type_label(:review), do: "a review" + def type_label(:video), do: "a video" + def type_label(:design), do: "a design" + 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 rewarded(query \\ Claim) do from c in query, - where: c.status == :approved and not is_nil(c.charged_at) + where: c.state == :approved and not is_nil(c.charged_at) end def filter_by_org_id(query, nil), do: query diff --git a/lib/algora/bounties/schemas/line_item.ex b/lib/algora/bounties/schemas/line_item.ex new file mode 100644 index 000000000..686fb3b96 --- /dev/null +++ b/lib/algora/bounties/schemas/line_item.ex @@ -0,0 +1,44 @@ +defmodule Algora.Bounties.LineItem do + @moduledoc false + use Algora.Schema + + alias Algora.MoneyUtils + + @primary_key false + typed_embedded_schema do + field :amount, Algora.Types.Money + field :title, :string + field :description, :string + field :image, :string + field :type, Ecto.Enum, values: [:payout, :fee] + end + + def to_stripe(line_item) do + %{ + price_data: %{ + unit_amount: MoneyUtils.to_minor_units(line_item.amount), + currency: to_string(line_item.amount.currency), + product_data: + Map.reject( + %{ + name: line_item.title, + description: line_item.description, + images: if(line_item.image, do: [line_item.image]) + }, + fn {_, v} -> is_nil(v) end + ) + }, + quantity: 1 + } + end + + def gross_amount(line_items) do + Enum.reduce(line_items, Money.zero(:USD), fn item, acc -> Money.add!(acc, item.amount) end) + end + + def total_fee(line_items) do + Enum.reduce(line_items, Money.zero(:USD), fn item, acc -> + if item.type == :fee, do: Money.add!(acc, item.amount), else: acc + end) + end +end diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index d7bf1592c..dae585cbe 100644 --- a/lib/algora/integrations/github/behaviour.ex +++ b/lib/algora/integrations/github/behaviour.ex @@ -1,5 +1,6 @@ defmodule Algora.Github.Behaviour do @moduledoc false + @type token :: String.t() @type response :: {:ok, map()} | {:error, any()} @@ -15,9 +16,9 @@ defmodule Algora.Github.Behaviour do @callback list_installations(token(), integer()) :: response @callback find_installation(token(), integer(), integer()) :: response @callback get_installation_token(integer()) :: response - @callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: - response - + @callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: response @callback list_repository_events(token(), String.t(), String.t(), keyword()) :: response @callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: response + @callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: response + @callback render_markdown(token(), String.t(), keyword()) :: response end diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex index 3703f72db..d86355267 100644 --- a/lib/algora/integrations/github/client.ex +++ b/lib/algora/integrations/github/client.ex @@ -7,11 +7,11 @@ defmodule Algora.Github.Client do @type token :: String.t() # TODO: move to a separate module and use only for data migration between databases - def http_cached(host, method, path, headers, body) do + def http_cached(host, method, path, headers, body, opts \\ []) do cache_path = ".local/github/#{path}.json" with :error <- read_from_cache(cache_path), - {:ok, response_body} <- do_http_request(host, method, path, headers, body) do + {:ok, response_body} <- do_http_request(host, method, path, headers, body, opts) do write_to_cache(cache_path, response_body) {:ok, response_body} else @@ -20,18 +20,18 @@ defmodule Algora.Github.Client do end end - def http(host, method, path, headers, body) do - do_http_request(host, method, path, headers, body) + def http(host, method, path, headers, body, opts \\ []) do + do_http_request(host, method, path, headers, body, opts) end - defp do_http_request(host, method, path, headers, body) do + defp do_http_request(host, method, path, headers, body, opts) do url = "https://#{host}#{path}" headers = [{"Content-Type", "application/json"} | headers] with {:ok, encoded_body} <- Jason.encode(body), request = Finch.build(method, url, headers, encoded_body), {:ok, response} <- Finch.request(request, Algora.Finch) do - handle_response(response) + if opts[:skip_decoding], do: {:ok, response.body}, else: handle_response(response) end end @@ -67,17 +67,19 @@ defmodule Algora.Github.Client do File.write!(cache_path, Jason.encode!(data)) end - def fetch(access_token, url, method \\ "GET", body \\ nil) + def fetch(access_token, url, method \\ "GET", body \\ nil, opts \\ []) - def fetch(access_token, "https://api.github.com" <> path, method, body), do: fetch(access_token, path, method, body) + def fetch(access_token, "https://api.github.com" <> path, method, body, opts), + do: fetch(access_token, path, method, body, opts) - def fetch(access_token, path, method, body) do + def fetch(access_token, path, method, body, opts) do http( "api.github.com", method, path, [{"accept", "application/vnd.github.v3+json"}, {"Authorization", "Bearer #{access_token}"}], - body + body, + opts ) end @@ -180,4 +182,16 @@ defmodule Algora.Github.Client do def list_repository_comments(access_token, owner, repo, opts \\ []) do fetch(access_token, "/repos/#{owner}/#{repo}/issues/comments#{build_query(opts)}") end + + @impl true + def add_labels(access_token, owner, repo, number, labels) do + fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}/labels", "POST", %{labels: labels}) + end + + @impl true + def render_markdown(access_token, text, opts \\ []) do + fetch(access_token, "/markdown", "POST", %{text: text, mode: opts[:mode] || "gfm", context: opts[:context]}, + skip_decoding: true + ) + end end diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index 23a41b8eb..180eca5f3 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -91,4 +91,10 @@ defmodule Algora.Github do @impl true def list_repository_comments(token, owner, repo, opts \\ []), do: client().list_repository_comments(token, owner, repo, opts) + + @impl true + def add_labels(token, owner, repo, number, labels), do: client().add_labels(token, owner, repo, number, labels) + + @impl true + def render_markdown(token, text, opts \\ []), do: client().render_markdown(token, text, opts) end diff --git a/lib/algora/integrations/github/token_pool.ex b/lib/algora/integrations/github/token_pool.ex index dfdb73307..f68445d21 100644 --- a/lib/algora/integrations/github/token_pool.ex +++ b/lib/algora/integrations/github/token_pool.ex @@ -3,6 +3,7 @@ defmodule Algora.Github.TokenPool do use GenServer alias Algora.Accounts + alias Algora.Github require Logger @@ -38,7 +39,7 @@ defmodule Algora.Github.TokenPool do token = Enum.at(tokens, index) if token == nil do - {:reply, nil, state} + {:reply, Github.pat(), state} else next_index = rem(index + 1, length(tokens)) if next_index == 0, do: refresh_tokens() diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex index 13509380d..4c0bb1c4e 100644 --- a/lib/algora/payments/schemas/transaction.ex +++ b/lib/algora/payments/schemas/transaction.ex @@ -35,7 +35,7 @@ defmodule Algora.Payments.Transaction do belongs_to :contract, Contract belongs_to :original_contract, Contract belongs_to :user, Algora.Accounts.User - # belongs_to :claim, Algora.Bounties.Claim + belongs_to :claim, Algora.Bounties.Claim belongs_to :bounty, Algora.Bounties.Bounty belongs_to :tip, Algora.Bounties.Tip belongs_to :linked_transaction, Algora.Payments.Transaction diff --git a/lib/algora/shared/util.ex b/lib/algora/shared/util.ex index 68bfafb1e..72e520da1 100644 --- a/lib/algora/shared/util.ex +++ b/lib/algora/shared/util.ex @@ -45,6 +45,15 @@ defmodule Algora.Util do date |> DateTime.shift_zone!(timezone) |> Calendar.strftime("%Y-%m-%d %I:%M %p") end + def to_date(nil), do: nil + + def to_date(date) do + case DateTime.from_iso8601(date) do + {:ok, datetime, _offset} -> datetime + {:error, _reason} = error -> error + end + end + def format_pct(percentage) do percentage |> Decimal.mult(100) diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index c8f613ca7..33f1cfeaf 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -13,6 +13,7 @@ defmodule AlgoraWeb.CoreComponents do use Gettext, backend: AlgoraWeb.Gettext alias AlgoraWeb.Components.UI.Accordion + alias AlgoraWeb.Components.UI.Alert alias AlgoraWeb.Components.UI.Avatar alias AlgoraWeb.Components.UI.Card alias AlgoraWeb.Components.UI.Dialog @@ -231,6 +232,7 @@ defmodule AlgoraWeb.CoreComponents do slot :link do attr :navigate, :string attr :href, :string + attr :patch, :string attr :method, :any end @@ -242,7 +244,7 @@ defmodule AlgoraWeb.CoreComponents do @@ -282,7 +284,8 @@ defmodule AlgoraWeb.CoreComponents do <.link tabindex="-1" role="menuitem" - class="block px-4 py-2 text-sm text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring" + class="block p-3 text-sm text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring" + phx-click={hide_dropdown("##{@id}-dropdown")} {link} > {render_slot(link)} @@ -571,7 +574,7 @@ defmodule AlgoraWeb.CoreComponents do phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} role="alert" class={[ - "fixed right-4 bottom-4 z-50 hidden w-80 rounded-lg p-3 pr-8 shadow-md ring-1 sm:w-96", + "fixed right-4 bottom-4 z-[1000] hidden w-80 rounded-lg p-3 pr-8 shadow-md ring-1 sm:w-96", @kind == :info && "bg-emerald-950 fill-success-foreground text-success-foreground ring ring-success/70", @kind == :warning && @@ -730,10 +733,7 @@ defmodule AlgoraWeb.CoreComponents do slot :inner_block def input(%{field: %FormField{} = field} = assigns) do - errors = - if Phoenix.Component.used_input?(field) and not assigns.hide_errors, - do: field.errors, - else: [] + errors = if assigns.hide_errors, do: [], else: field.errors value = with %Money{} <- field.value, @@ -857,8 +857,8 @@ defmodule AlgoraWeb.CoreComponents do value={Phoenix.HTML.Form.normalize_value(@type, @value)} class={[ "py-[7px] px-[11px] block w-full rounded-lg border-input bg-background", - "text-foreground focus:outline-none focus:ring-4 sm:text-sm sm:leading-6", - "border-input focus:border-ring focus:ring-ring/5", + "text-foreground focus:outline-none focus:ring-1 sm:text-sm sm:leading-6", + "border-input focus:border-ring focus:ring-ring", @errors != [] && "border-destructive placeholder-destructive-foreground/50 focus:border-destructive focus:ring-destructive/10", @icon && "pl-10", @@ -1258,7 +1258,9 @@ defmodule AlgoraWeb.CoreComponents do defdelegate accordion_item(assigns), to: Accordion defdelegate accordion_trigger(assigns), to: Accordion defdelegate accordion(assigns), to: Accordion - defdelegate alert(assigns), to: AlgoraWeb.Components.UI.Alert + defdelegate alert_description(assigns), to: Alert + defdelegate alert_title(assigns), to: Alert + defdelegate alert(assigns), to: Alert defdelegate avatar_fallback(assigns), to: Avatar defdelegate avatar_image(assigns), to: Avatar defdelegate avatar(assigns), to: Avatar @@ -1305,7 +1307,6 @@ defmodule AlgoraWeb.CoreComponents do defdelegate popover_content(assigns), to: Popover defdelegate popover_trigger(assigns), to: Popover defdelegate popover(assigns), to: Popover - defdelegate radio_group_item(assigns), to: RadioGroup defdelegate radio_group(assigns), to: RadioGroup defdelegate scroll_area(assigns), to: AlgoraWeb.Components.UI.ScrollArea defdelegate select_content(assigns), to: Select diff --git a/lib/algora_web/components/ui/drawer.ex b/lib/algora_web/components/ui/drawer.ex index 647b1471a..204608ade 100644 --- a/lib/algora_web/components/ui/drawer.ex +++ b/lib/algora_web/components/ui/drawer.ex @@ -114,7 +114,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do def drawer_content(assigns) do ~H""" -
+
{render_slot(@inner_block)}
""" diff --git a/lib/algora_web/components/ui/radio_group.ex b/lib/algora_web/components/ui/radio_group.ex index 1d4b681ff..16fd33892 100644 --- a/lib/algora_web/components/ui/radio_group.ex +++ b/lib/algora_web/components/ui/radio_group.ex @@ -2,93 +2,53 @@ defmodule AlgoraWeb.Components.UI.RadioGroup do @moduledoc false use AlgoraWeb.Component + import AlgoraWeb.CoreComponents + @doc """ - Radio input group component + Radio input group component styled with a modern card-like appearance. ## Examples: - <.radio_group name="question-1" value="option-2"> -
- <.radio_group_item builder={builder} value="option-one" id="option-one"> - <.label for="option-one"> - Option One - -
-
- <.radio_group_item builder={builder} value="option-two" id="option-two"> - <.label for="option-two"> - Option Two - -
- + <.radio_group + name="hiring" + options={[{"Yes", "true"}, {"No", "false"}]} + field={@form[:hiring]} + /> """ attr :name, :string, default: nil - attr :value, :any, default: nil - attr :"default-value", :any - - attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" - + attr :options, :list, default: [], doc: "List of {label, value} tuples" + attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form" attr :class, :string, default: nil - slot :inner_block, required: true def radio_group(assigns) do - assigns = prepare_assign(assigns) - assigns = assign(assigns, :builder, %{name: assigns.name, value: assigns.value}) - ~H""" -
- {render_slot(@inner_block, @builder)} +
+ <%= for {label, value} <- @options do %> + + <% end %>
- """ - end - - attr :builder, :map, required: true - attr :class, :string, default: nil - attr :checked, :any, default: false - attr :value, :string, default: nil - attr :rest, :global - - def radio_group_item(assigns) do - ~H""" - + <.error :for={msg <- @field.errors}>{translate_error(msg)} """ end end diff --git a/lib/algora_web/components/ui/stat_card.ex b/lib/algora_web/components/ui/stat_card.ex index 174c209ac..e071730cc 100644 --- a/lib/algora_web/components/ui/stat_card.ex +++ b/lib/algora_web/components/ui/stat_card.ex @@ -6,10 +6,12 @@ defmodule AlgoraWeb.Components.UI.StatCard do attr :href, :string, default: nil attr :title, :string - attr :value, :string + attr :value, :string, default: nil attr :subtext, :string, default: nil attr :icon, :string, default: nil + slot :inner_block + def stat_card(assigns) do ~H""" <%= if @href do %> @@ -24,13 +26,24 @@ defmodule AlgoraWeb.Components.UI.StatCard do defp stat_card_content(assigns) do ~H""" -
+

{@title}

<.icon :if={@icon} name={@icon} class="h-6 w-6 text-muted-foreground" />
-
{@value}
+
+ <%= if @value do %> + {@value} + <% else %> + {render_slot(@inner_block)} + <% end %> +

{@subtext}

diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 1963c7709..c58a63d82 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -13,24 +13,23 @@ defmodule AlgoraWeb.Webhooks.GithubController do # TODO: auto-retry failed deliveries with exponential backoff def new(conn, params) do - case Webhook.new(conn) do - {:ok, %Webhook{delivery: _delivery, event: event, installation_id: _installation_id}} -> - author = get_author(event, params) - body = get_body(event, params) - process_commands(body, author, params) - - conn |> put_status(:accepted) |> json(%{status: "ok"}) - + with {:ok, webhook} <- Webhook.new(conn), + {:ok, _} <- process_commands(webhook, params) do + conn |> put_status(:accepted) |> json(%{status: "ok"}) + else {:error, :missing_header} -> conn |> put_status(:bad_request) |> json(%{error: "Missing header"}) {:error, :signature_mismatch} -> conn |> put_status(:unauthorized) |> json(%{error: "Signature mismatch"}) + + {:error, reason} -> + Logger.error("Error processing webhook: #{inspect(reason)}") + conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"}) end rescue e -> Logger.error("Unexpected error: #{inspect(e)}") - conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"}) end @@ -51,7 +50,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do defp get_permissions(_author, _params), do: {:error, :invalid_params} - defp execute_command({:bounty, args}, author, params) do + defp execute_command(event_action, {:bounty, args}, author, params) + when event_action in ["issues.opened", "issues.edited", "issue_comment.created", "issue_comment.edited"] do amount = args[:amount] repo = params["repository"] issue = params["issue"] @@ -82,7 +82,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end - defp execute_command({:tip, args}, author, params) when not is_nil(args) do + defp execute_command(event_action, {:tip, args}, author, params) + when event_action in ["issue_comment.created", "issue_comment.edited"] do amount = args[:amount] recipient = args[:recipient] repo = params["repository"] @@ -113,27 +114,72 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end - defp execute_command({:claim, args}, _author, _params) when not is_nil(args) do - owner = Keyword.get(args, :owner) - repo = Keyword.get(args, :repo) - number = Keyword.get(args, :number) + defp execute_command(event_action, {:claim, args}, author, params) + when event_action in ["pull_request.opened", "pull_request.reopened", "pull_request.edited"] do + installation_id = params["installation"]["id"] + pull_request = params["pull_request"] + repo = params["repository"] - Logger.info("Claim #{owner}/#{repo}##{number}") + source_ticket_ref = %{ + owner: repo["owner"]["login"], + repo: repo["name"], + number: pull_request["number"] + } + + target_ticket_ref = + %{ + owner: args[:ticket_ref][:owner] || source_ticket_ref.owner, + repo: args[:ticket_ref][:repo] || source_ticket_ref.repo, + number: args[:ticket_ref][:number] + } + + with {:ok, token} <- Github.get_installation_token(installation_id), + {:ok, user} <- Workspace.ensure_user(token, author["login"]) do + Bounties.claim_bounty( + %{ + user: user, + target_ticket_ref: target_ticket_ref, + source_ticket_ref: source_ticket_ref, + status: if(pull_request["merged_at"], do: :approved, else: :pending), + type: :pull_request + }, + installation_id: installation_id + ) + end + end + + defp execute_command(_event_action, _command, _author, _params) do + {:ok, nil} end - defp execute_command({command, _} = args, _author, _params), - do: Logger.info("Unhandled command: #{command} #{inspect(args)}") + 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"] - def process_commands(body, author, params) when is_binary(body) do case Github.Command.parse(body) do - {:ok, commands} -> Enum.map(commands, &execute_command(&1, author, params)) - # TODO: handle errors - {:error, error} -> Logger.error("Error parsing commands: #{inspect(error)}") + {:ok, commands} -> + Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, results} -> + case execute_command(event_action, command, author, params) do + {:ok, result} -> + {:cont, {:ok, [result | results]}} + + error -> + Logger.error( + "Command execution failed for #{event_action}(#{hook_id}): #{inspect(command)}: #{inspect(error)}" + ) + + {:halt, error} + end + end) + + {:error, reason} = error -> + Logger.error("Error parsing commands: #{inspect(reason)}") + error end end - def process_commands(_body, _author, _params), do: nil - defp get_author("issues", params), do: params["issue"]["user"] defp get_author("issue_comment", params), do: params["comment"]["user"] defp get_author("pull_request", params), do: params["pull_request"]["user"] diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex new file mode 100644 index 000000000..77171a9e4 --- /dev/null +++ b/lib/algora_web/live/claim_live.ex @@ -0,0 +1,642 @@ +defmodule AlgoraWeb.ClaimLive do + @moduledoc false + use AlgoraWeb, :live_view + + import Ecto.Changeset + import Ecto.Query + + alias Algora.Bounties + alias Algora.Bounties.Claim + alias Algora.Bounties.LineItem + alias Algora.Github + alias Algora.Organizations + alias Algora.Repo + alias Algora.Util + + require Logger + + defp tip_options do + [ + {"None", 0}, + {"10%", 10}, + {"20%", 20}, + {"50%", 50} + ] + end + + defmodule RewardBountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :amount, :decimal + field :tip_percentage, :decimal + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:amount, :tip_percentage]) + |> validate_required([:amount, :tip_percentage]) + |> validate_number(:tip_percentage, greater_than_or_equal_to: 0) + |> validate_number(:amount, greater_than: 0) + end + end + + @impl true + def mount(%{"group_id" => group_id}, _session, socket) do + claims = + from(c in Claim, where: c.group_id == ^group_id) + |> order_by(desc: :group_share) + |> Repo.all() + |> Repo.preload([ + :user, + :transactions, + source: [repository: [:user]], + target: [repository: [:user], bounties: [:owner]] + ]) + + case claims do + [] -> + raise(AlgoraWeb.NotFoundError) + + [primary_claim | _] -> + prize_pool = + primary_claim.target.bounties + |> Enum.map(& &1.amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + + debits = + claims + |> Enum.flat_map(& &1.transactions) + |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded)) + + total_paid = + debits + |> Enum.map(& &1.net_amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + + context = + if repo = primary_claim.source.repository do + "#{repo.user.provider_login}/#{repo.name}" + end + + source_body_html = + with token when is_binary(token) <- Github.TokenPool.get_token(), + {:ok, source_body_html} <- + Github.render_markdown(token, primary_claim.source.description, context: context) do + source_body_html + else + _ -> primary_claim.source.description + end + + pledges = + primary_claim.target.bounties + |> Enum.group_by(& &1.owner.id) + |> Map.new(fn {owner_id, bounties} -> + {owner_id, + {hd(bounties).owner, + Enum.reduce(bounties, Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1.amount, &2))}} + end) + + payments = + debits + |> Enum.group_by(& &1.user_id) + |> Map.new(fn {user_id, debits} -> + {user_id, Enum.reduce(debits, Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1.net_amount, &2))} + end) + + sponsors = + pledges + |> Enum.map(fn {sponsor_id, {sponsor, pledged}} -> + paid = Map.get(payments, sponsor_id, Money.zero(:USD, no_fraction_if_integer: true)) + tipped = Money.sub!(paid, pledged) + + status = + cond do + Money.equal?(paid, pledged) -> :paid + Money.positive?(tipped) -> :overpaid + Money.positive?(paid) -> :partial + primary_claim.status == :approved -> :pending + true -> :none + end + + %{ + sponsor: sponsor, + status: status, + pledged: pledged, + paid: paid, + tipped: tipped + } + end) + |> Enum.sort_by(&{&1.pledged, &1.paid, &1.sponsor.name}, :desc) + + source_or_target = primary_claim.source || primary_claim.target + + contexts = + if socket.assigns.current_user do + Organizations.get_user_orgs(socket.assigns.current_user) ++ [socket.assigns.current_user] + else + [] + end + + context_ids = MapSet.new(contexts, & &1.id) + available_bounties = Enum.filter(primary_claim.target.bounties, &MapSet.member?(context_ids, &1.owner_id)) + + amount = + case available_bounties do + [] -> nil + [bounty | _] -> Money.to_decimal(bounty.amount) + end + + changeset = RewardBountyForm.changeset(%RewardBountyForm{}, %{tip_percentage: 0, amount: amount}) + + {:ok, + socket + |> assign(:page_title, source_or_target.title) + |> assign(:claims, claims) + |> assign(:primary_claim, primary_claim) + |> assign(:target, primary_claim.target) + |> assign(:source, primary_claim.source) + |> assign(:source_or_target, source_or_target) + |> assign(:bounties, primary_claim.target.bounties) + |> assign(:prize_pool, prize_pool) + |> assign(:total_paid, total_paid) + |> assign(:source_body_html, source_body_html) + |> assign(:sponsors, sponsors) + |> assign(:contexts, contexts) + |> assign(:show_reward_bounty_modal, false) + |> assign(:available_bounties, available_bounties) + |> assign(:reward_bounty_form, to_form(changeset))} + end + end + + @impl true + def handle_params(_params, _url, %{assigns: %{current_user: nil}} = socket) do + {:noreply, socket} + end + + def handle_params(%{"context" => context_id}, _url, socket) do + {:noreply, socket |> assign_selected_context(context_id) |> assign_line_items()} + end + + def handle_params(_params, _url, socket) do + {:noreply, socket |> assign_selected_context(default_context_id(socket)) |> assign_line_items()} + end + + @impl true + def handle_event("reward_bounty", _params, %{assigns: %{current_user: nil}} = socket) do + {:noreply, + redirect(socket, to: ~p"/auth/login?#{%{return_to: ~p"/claims/#{socket.assigns.primary_claim.group_id}"}}")} + end + + def handle_event("reward_bounty", _params, socket) do + {:noreply, assign(socket, :show_reward_bounty_modal, true)} + end + + def handle_event("close_drawer", _params, socket) do + {:noreply, assign(socket, :show_reward_bounty_modal, false)} + end + + def handle_event("validate_reward_bounty", %{"reward_bounty_form" => params}, socket) do + {:noreply, + socket + |> assign(:reward_bounty_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params))) + |> assign_line_items()} + end + + def handle_event("split_bounty", _params, socket) do + # TODO: Implement split bounty + Logger.error( + "Attempt to split bounty #{socket.assigns.target.repository.user.provider_login}/#{socket.assigns.target.repository.name}#{socket.assigns.target.number}" + ) + + {:noreply, socket} + end + + def handle_event("pay_with_stripe", %{"reward_bounty_form" => params}, socket) do + changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params) + + case apply_action(changeset, :save) do + {:ok, data} -> + with {:ok, bounty} <- get_or_create_bounty(socket, data), + {:ok, session_url} <- reward_bounty(socket, bounty, changeset) do + {:noreply, redirect(socket, external: session_url)} + else + {:error, reason} -> + Logger.error("Failed to create payment session: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} + end + + {:error, changeset} -> + {:noreply, assign(socket, :reward_bounty_form, to_form(changeset))} + end + end + + defp default_context_id(socket) do + case socket.assigns.available_bounties do + [] -> socket.assigns.current_user.id + [bounty | _] -> bounty.owner_id + end + end + + defp assign_selected_context(socket, context_id) do + case Enum.find(socket.assigns.contexts, &(&1.id == context_id)) do + nil -> + push_patch(socket, to: "/claims/#{socket.assigns.primary_claim.group_id}?context=#{default_context_id(socket)}") + + context -> + assign(socket, :selected_context, context) + end + end + + defp assign_line_items(socket) do + line_items = + Bounties.generate_line_items(%{amount: calculate_final_amount(socket.assigns.reward_bounty_form.source)}, + ticket_ref: %{ + owner: socket.assigns.target.repository.user.provider_login, + repo: socket.assigns.target.repository.name, + number: socket.assigns.target.number + }, + claims: socket.assigns.claims + ) + + assign(socket, :line_items, line_items) + end + + defp ticket_ref(socket) do + %{ + owner: socket.assigns.target.repository.user.provider_login, + repo: socket.assigns.target.repository.name, + number: socket.assigns.target.number + } + end + + defp get_or_create_bounty(socket, data) do + case Enum.find(socket.assigns.available_bounties, &(&1.owner_id == socket.assigns.selected_context.id)) do + nil -> + Bounties.create_bounty(%{ + creator: socket.assigns.current_user, + owner: socket.assigns.selected_context, + amount: Money.new!(:USD, data.amount), + ticket_ref: ticket_ref(socket) + }) + + bounty -> + {:ok, bounty} + end + end + + defp reward_bounty(socket, bounty, changeset) do + final_amount = calculate_final_amount(changeset) + + Bounties.reward_bounty( + %{ + owner: socket.assigns.selected_context, + amount: final_amount, + bounty_id: bounty.id, + claims: socket.assigns.claims + }, + ticket_ref: ticket_ref(socket) + ) + end + + defp calculate_final_amount(changeset) do + tip_percentage = get_field(changeset, :tip_percentage) || Decimal.new(0) + amount = get_field(changeset, :amount) || Decimal.new(0) + + multiplier = tip_percentage |> Decimal.div(100) |> Decimal.add(1) + amount |> Money.new!(:USD) |> Money.mult!(multiplier) + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+ <.card> + <.card_header> +
+ <.avatar class="h-12 w-12 rounded-full"> + <.avatar_image src={@source_or_target.repository.user.avatar_url} /> + <.avatar_fallback> + {String.first(@source_or_target.repository.user.provider_login)} + + +
+ <.link + href={@source_or_target.url} + class="text-xl font-semibold hover:underline" + target="_blank" + > + {@source_or_target.title} + +
+ {@source_or_target.repository.user.provider_login}/{@source_or_target.repository.name}#{@source_or_target.number} +
+
+
+ + <.card_content> +
+ {Phoenix.HTML.raw(@source_body_html)} +
+ + +
+ +
+ <.card> + <.card_header> +
+ <.card_title> + Claim + + <.button phx-click="reward_bounty"> + Reward bounty + +
+ + <.card_content> +
+
+ Total prize pool + + {Money.to_string!(@prize_pool)} + +
+
+ Total paid + + {Money.to_string!(@total_paid)} + +
+
+ Status + {@primary_claim.status |> to_string() |> String.capitalize()} +
+
+ Submitted + {Calendar.strftime(@primary_claim.inserted_at, "%B %d, %Y")} +
+
+ Last updated + {Calendar.strftime(@primary_claim.updated_at, "%B %d, %Y")} +
+
+ + + + <.card> + <.card_header> +
+ <.card_title> + Authors + + + <.button variant="secondary" phx-click="split_bounty"> + Split bounty + +
+ + <.card_content> +
+ <%= for claim <- @claims do %> +
+ +
+ <.avatar> + <.avatar_image src={claim.user.avatar_url} /> + <.avatar_fallback>{String.first(claim.user.name)} + +
+

{claim.user.name}

+

@{claim.user.handle}

+
+
+
+ + + {Util.format_pct(claim.group_share)} + + +
+ <% end %> +
+ + + + <.card> + <.card_header> + <.card_title> + Sponsors + + + <.card_content> +
+ <%= for sponsor <- @sponsors do %> +
+
+ <.avatar> + <.avatar_image src={sponsor.sponsor.avatar_url} /> + <.avatar_fallback> + {String.first(sponsor.sponsor.name)} + + +
+

{sponsor.sponsor.name}

+

@{sponsor.sponsor.handle}

+
+
+
+
+ <%= case sponsor.status do %> + <% :overpaid -> %> +
+ + {Money.to_string!(Money.sub!(sponsor.paid, sponsor.tipped))} + + paid +
+
+ + +{Money.to_string!(sponsor.tipped)} + + tip! +
+ <% :paid -> %> +
+ + {Money.to_string!(sponsor.paid)} + + paid +
+ <% :partial -> %> +
+ + {Money.to_string!(sponsor.paid)} + + paid +
+
+ + {Money.to_string!(Money.sub!(sponsor.pledged, sponsor.paid))} + + pending +
+ <% :pending -> %> +
+ + {Money.to_string!(sponsor.pledged)} + + pending +
+ <% :none -> %> +
+ + {Money.to_string!(sponsor.pledged)} + +
+ <% end %> +
+
+
+ <% end %> +
+ + +
+
+
+ <.drawer :if={@current_user} show={@show_reward_bounty_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Reward Bounty + <.drawer_description> + You can pay the full bounty now or start with a partial amount - it's up to you! + + + <.drawer_content class="mt-4"> + <.form + for={@reward_bounty_form} + phx-change="validate_reward_bounty" + phx-submit="pay_with_stripe" + > +
+
+ <.card> + <.card_header> + <.card_title>Payment Details + + <.card_content> +
+ <%= if Enum.empty?(@available_bounties) do %> + <.alert variant="destructive"> + <.alert_title>No bounties available + <.alert_description> + You didn't post a bounty for this issue. Would you like to create one now? + + + <% end %> + <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_bounty_form[:amount]} + /> + +
+ <.label>On behalf of + <.dropdown2 id="context-dropdown" class="mt-2"> + <:img src={@selected_context.avatar_url} /> + <:title>{@selected_context.name} + <:subtitle>@{@selected_context.handle} + + <:link + :for={context <- @contexts |> Enum.reject(&(&1.id == @selected_context.id))} + patch={"?context=#{context.id}"} + > +
+ {context.name} +
+
{context.name}
+
@{context.handle}
+
+
+ + +
+ +
+ <.label>Tip +
+ <.radio_group + class="grid grid-cols-4 gap-4" + field={@reward_bounty_form[:tip_percentage]} + options={tip_options()} + /> +
+
+
+ + + <.card> + <.card_header> + <.card_title>Payment Summary + + <.card_content> +
+ <%= for line_item <- @line_items do %> +
+
+ <%= if line_item.image do %> + <.avatar> + <.avatar_image src={line_item.image} /> + + <% else %> +
+ <% end %> +
+
{line_item.title}
+
{line_item.description}
+
+
+
+ {Money.to_string!(line_item.amount)} +
+
+ <% end %> +
+
+
+
+
Total due
+
+
+ {LineItem.gross_amount(@line_items)} +
+
+
+ + +
+
+ <.button variant="secondary" phx-click="close_drawer" type="button"> + Cancel + + <.button type="submit"> + Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" /> + +
+
+ + + + """ + end +end diff --git a/lib/algora_web/live/org/bounty_hook.ex b/lib/algora_web/live/org/bounty_hook.ex index 179db5d05..a9a9cedeb 100644 --- a/lib/algora_web/live/org/bounty_hook.ex +++ b/lib/algora_web/live/org/bounty_hook.ex @@ -27,9 +27,6 @@ defmodule AlgoraWeb.Org.BountyHook do {:error, :already_exists} -> {:halt, put_flash(socket, :warning, "You have already created a bounty for this ticket")} - {:error, :internal_server_error} -> - {:halt, put_flash(socket, :error, "Something went wrong")} - {:error, _reason} -> changeset = add_error(socket.assigns.new_bounty_form.changeset, :github_issue_url, "Invalid URL") {:halt, assign(socket, :new_bounty_form, to_form(changeset))} diff --git a/lib/algora_web/live/org/create_bounty_live.ex b/lib/algora_web/live/org/create_bounty_live.ex index 466d780f4..6a744503c 100644 --- a/lib/algora_web/live/org/create_bounty_live.ex +++ b/lib/algora_web/live/org/create_bounty_live.ex @@ -468,9 +468,6 @@ defmodule AlgoraWeb.Org.CreateBountyLive do {:error, :already_exists} -> {:noreply, put_flash(socket, :warning, "You have already created a bounty for this ticket")} - {:error, :internal_server_error} -> - {:noreply, put_flash(socket, :error, "Something went wrong")} - {:error, _reason} -> changeset = add_error(socket.assigns.new_bounty_form.changeset, :github_issue_url, "Invalid URL") {:noreply, assign(socket, :new_bounty_form, to_form(changeset))} diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 7d7589567..81819494b 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -114,6 +114,7 @@ defmodule AlgoraWeb.Router do live "/payment/success", Payment.SuccessLive, :index live "/payment/canceled", Payment.CanceledLive, :index live "/@/:handle", User.ProfileLive, :index + live "/claims/:group_id", ClaimLive end live "/orgs/new", Org.CreateLive diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs new file mode 100644 index 000000000..acf28265e --- /dev/null +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -0,0 +1,73 @@ +defmodule Algora.Repo.Migrations.RecreateClaims do + use Ecto.Migration + + def up do + drop index(:claims, [:bounty_id]) + drop index(:claims, [:user_id]) + drop table(:claims) + + create table(:claims) do + add :status, :string, null: false + add :type, :string, null: false + add :url, :string, null: false + add :group_id, :string, null: false + add :group_share, :decimal, null: false, default: 1.0 + + add :source_id, references(:tickets, on_delete: :nothing), null: false + add :target_id, references(:tickets, on_delete: :nothing), null: false + add :user_id, references(:users, on_delete: :nothing), null: false + + timestamps() + end + + alter table(:transactions) do + add :claim_id, references(:claims, on_delete: :nothing) + end + + create unique_index(:claims, [:group_id, :user_id]) + create index(:claims, [:source_id]) + create index(:claims, [:target_id]) + create index(:claims, [:user_id]) + end + + def down do + drop index(:claims, [:group_id, :user_id]) + drop index(:claims, [:source_id]) + drop index(:claims, [:target_id]) + drop index(:claims, [:user_id]) + drop table(:claims) + + alter table(:transactions) do + remove :claim_id + end + + create table(:claims) do + add :provider, :string + add :provider_id, :string + add :provider_meta, :map + + add :type, :string + + add :status, :string + + add :merged_at, :utc_datetime_usec + add :approved_at, :utc_datetime_usec + add :rejected_at, :utc_datetime_usec + add :charged_at, :utc_datetime_usec + add :paid_at, :utc_datetime_usec + + add :title, :string + add :description, :string + add :url, :string + add :group_id, :string + + add :bounty_id, references(:bounties, on_delete: :nothing) + add :user_id, references(:users, on_delete: :nothing) + + timestamps() + end + + create index(:claims, [:bounty_id]) + create index(:claims, [:user_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index ff4c9a103..f3eb2d30a 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -77,6 +77,7 @@ richard = email: "richard@example.com", display_name: "Richard Hendricks", handle: "richard", + provider_login: "richard", bio: "CEO of Pied Piper. Creator of the middle-out compression algorithm.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/richard.jpg", tech_stack: ["Python", "C++"], @@ -94,6 +95,7 @@ dinesh = email: "dinesh@example.com", display_name: "Dinesh Chugtai", handle: "dinesh", + provider_login: "dinesh", bio: "Lead Frontend Engineer at Pied Piper. Java bad, Python good.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/dinesh.png", tech_stack: ["Python", "JavaScript"], @@ -111,6 +113,7 @@ gilfoyle = email: "gilfoyle@example.com", display_name: "Bertram Gilfoyle", handle: "gilfoyle", + provider_login: "gilfoyle", bio: "Systems Architect. Security. DevOps. Satanist.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/gilfoyle.jpg", tech_stack: ["Python", "Rust", "Go", "Terraform"], @@ -128,6 +131,7 @@ jared = email: "jared@example.com", display_name: "Jared Dunn", handle: "jared", + provider_login: "jared", bio: "COO of Pied Piper. Former Hooli executive. Excel wizard.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/jared.png", tech_stack: ["Python", "SQL"], @@ -145,6 +149,7 @@ carver = email: "carver@example.com", display_name: "Kevin 'The Carver'", handle: "carver", + provider_login: "carver", bio: "Cloud architecture specialist. If your infrastructure needs a teardown, I'm your guy. Known for my 'insane' cloud architectures and occasional server incidents.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/carver.jpg", @@ -313,133 +318,6 @@ end Logger.info("Contract: #{AlgoraWeb.Endpoint.url()}/org/#{pied_piper.handle}/contracts/#{initial_contract.id}") -repos = [ - { - "middle-out", - [ - "Optimize algorithm performance", - "Add support for new file types", - "Improve error handling", - "Implement streaming compression", - "Add compression statistics API" - ] - }, - { - "pied-piper-web", - [ - "Fix memory leak in upload handler", - "Implement new dashboard UI", - "Add real-time compression stats", - "Integrate SSO authentication", - "Build file comparison view" - ] - }, - { - "infra", - [ - "Scale kubernetes cluster", - "Implement auto-scaling", - "Optimize cloud costs", - "Set up monitoring and alerts", - "Configure disaster recovery" - ] - } -] - -for {repo_name, issues} <- repos do - repo = - insert!(:repository, %{ - name: repo_name, - url: "https://github.com/piedpiper/#{repo_name}", - user_id: pied_piper.id - }) - - for {issue_title, index} <- Enum.with_index(issues, 1) do - ticket = - insert!(:ticket, %{ - repository_id: repo.id, - title: issue_title, - description: "We need help implementing this feature to improve our platform.", - number: index, - url: "https://github.com/piedpiper/#{repo_name}/issues/#{index}" - }) - - amount = Money.new!(Enum.random([500, 1000, 1500, 2000]), :USD) - - claimed = rem(index, 2) > 0 - paid = claimed and rem(index, 3) > 0 - - bounty = - insert!(:bounty, %{ - ticket_id: ticket.id, - owner_id: pied_piper.id, - creator_id: richard.id, - amount: amount, - status: if(paid, do: :paid, else: :open) - }) - - if not claimed do - pied_piper_members - |> Enum.take_random(Enum.random(0..(length(pied_piper_members) - 1))) - |> Enum.each(fn member -> - amount = Money.new!(Enum.random([500, 1000, 1500, 2000]), :USD) - - insert!(:bounty, %{ - ticket_id: ticket.id, - owner_id: member.id, - creator_id: member.id, - amount: amount, - status: :open - }) - end) - end - - if claimed do - claim = - insert!(:claim, %{ - bounty_id: bounty.id, - user_id: carver.id, - status: if(paid, do: :paid, else: :pending), - title: "Implementation for #{issue_title}", - description: "Here's my solution to this issue.", - url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" - }) - - # Create transaction pairs for paid claims - if paid do - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() - - Repo.transact(fn -> - insert!(:transaction, %{ - id: debit_id, - linked_transaction_id: credit_id, - bounty_id: bounty.id, - type: :debit, - status: :succeeded, - net_amount: amount, - user_id: pied_piper.id, - succeeded_at: claim.inserted_at - }) - - insert!(:transaction, %{ - id: credit_id, - linked_transaction_id: debit_id, - bounty_id: bounty.id, - type: :credit, - status: :succeeded, - net_amount: amount, - user_id: carver.id, - succeeded_at: claim.inserted_at - }) - - {:ok, :ok} - end) - end - end - end -end - big_head = upsert!( :user, @@ -448,6 +326,7 @@ big_head = email: "bighead@example.com", display_name: "Nelson Bighetti", handle: "bighead", + provider_login: "bighead", bio: "Former Hooli executive. Accidental tech success. Stanford President.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/bighead.jpg", tech_stack: ["Python", "JavaScript"], @@ -466,6 +345,7 @@ jian_yang = email: "jianyang@example.com", display_name: "Jian Yang", handle: "jianyang", + provider_login: "jianyang", bio: "App developer. Creator of SeeFood and Smokation.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/jianyang.jpg", tech_stack: ["Swift", "Python", "TensorFlow"], @@ -484,6 +364,7 @@ john = email: "john@example.com", display_name: "John Stafford", handle: "john", + provider_login: "john", bio: "Datacenter infrastructure expert. Rack space optimization specialist.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/john.png", tech_stack: ["Perl", "Terraform", "C++", "C"], @@ -502,6 +383,7 @@ aly = email: "aly@example.com", display_name: "Aly Dutta", handle: "aly", + provider_login: "aly", bio: "Former Hooli engineer. Expert in distributed systems and scalability.", avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/aly.png", tech_stack: ["Java", "Kotlin", "Go"], @@ -542,6 +424,180 @@ for user <- [aly, big_head, jian_yang, john] do end) end +repos = [ + { + "middle-out", + [ + "Optimize algorithm performance", + "Add support for new file types", + "Improve error handling", + "Implement streaming compression", + "Add compression statistics API" + ] + }, + { + "pied-piper-web", + [ + "Fix memory leak in upload handler", + "Implement new dashboard UI", + "Add real-time compression stats", + "Integrate SSO authentication", + "Build file comparison view" + ] + }, + { + "infra", + [ + "Scale kubernetes cluster", + "Implement auto-scaling", + "Optimize cloud costs", + "Set up monitoring and alerts", + "Configure disaster recovery" + ] + } +] + +for {repo_name, issues} <- repos do + repo = + insert!(:repository, %{ + name: repo_name, + url: "https://github.com/piedpiper/#{repo_name}", + user_id: pied_piper.id + }) + + for {issue_title, index} <- Enum.with_index(issues, 1) do + issue = + insert!(:ticket, %{ + type: :issue, + repository_id: repo.id, + title: issue_title, + description: "We need help implementing this feature to improve our platform.", + number: index, + url: "https://github.com/piedpiper/#{repo_name}/issues/#{index}" + }) + + claimed = rem(index, 2) > 0 + paid = claimed and rem(index, 3) > 0 + + bounties = + [2000, 500, 400, 300, 200, 100] + |> Enum.map(&Money.new!(&1, :USD)) + |> Enum.zip([pied_piper | pied_piper_members]) + |> Enum.map(fn {amount, sponsor} -> + insert!(:bounty, %{ + ticket_id: issue.id, + owner_id: sponsor.id, + creator_id: sponsor.id, + amount: amount, + status: if(paid, do: :paid, else: :open) + }) + end) + + if claimed do + pull_request = + insert!(:ticket, %{ + type: :pull_request, + repository_id: repo.id, + title: "Fix memory leak in upload handler and optimize buffer allocation", + description: """ + This PR addresses the memory leak in the file upload handler by: + - Implementing proper buffer cleanup in the streaming pipeline + - Adding automatic resource disposal using with-clauses + - Optimizing memory allocation for large file uploads + - Adding memory usage monitoring + + Testing shows a 60% reduction in memory usage during sustained uploads. + + Key changes: + ```python + def process_upload(file_stream): + try: + with MemoryManager.track() as memory: + for chunk in file_stream: + # Optimize buffer allocation + buffer = BytesIO(initial_size=chunk.size) + compressed = middle_out.compress(chunk, buffer) + yield compressed + + memory.log_usage("Upload complete") + finally: + buffer.close() + gc.collect() # Force cleanup + ``` + + Closes ##{index} + """, + number: index + length(issues), + url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" + }) + + group_id = Nanoid.generate() + + claimants = + [carver, aly, big_head] + |> Enum.zip(["0.5", "0.3", "0.2"]) + |> Enum.map(fn {user, share} -> {user, Decimal.new(share)} end) + + for {user, share} <- claimants do + claim = + insert!(:claim, %{ + group_id: group_id, + group_share: share, + user_id: user.id, + target_id: issue.id, + source_id: pull_request.id, + type: :pull_request, + status: if(paid, do: :approved, else: :pending), + url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" + }) + + if paid do + for {pct_paid, bounty} <- + ["1.25", "1.0", "1.0", "0.5", "0.0", "0.0"] + |> Enum.map(&Decimal.new/1) + |> Enum.zip(bounties) do + debit_id = Nanoid.generate() + credit_id = Nanoid.generate() + + net_paid = Money.mult!(bounty.amount, Decimal.mult(share, pct_paid)) + + # Create transaction pairs for paid claims + Repo.transact(fn -> + insert!(:transaction, %{ + id: debit_id, + linked_transaction_id: credit_id, + bounty_id: bounty.id, + claim_id: claim.id, + type: :debit, + status: :succeeded, + net_amount: net_paid, + user_id: bounty.owner_id, + succeeded_at: claim.inserted_at + }) + + insert!(:transaction, %{ + id: credit_id, + linked_transaction_id: debit_id, + bounty_id: bounty.id, + claim_id: claim.id, + type: :credit, + status: :succeeded, + net_amount: net_paid, + user_id: user.id, + succeeded_at: claim.inserted_at + }) + + {:ok, :ok} + end) + end + end + + Logger.info("Claim [#{claim.status}]: #{AlgoraWeb.Endpoint.url()}/claims/#{claim.group_id}") + end + end + end +end + reviews = [ {richard, carver, -1, "His cloud architecture is... unconventional, but it works. Like, really works. Our servers haven't crashed in weeks. Just wish he'd document things better."}, diff --git a/test/algora_web/controllers/webhooks/github_controller_test.exs b/test/algora_web/controllers/webhooks/github_controller_test.exs index 6b3070230..52cd98f7a 100644 --- a/test/algora_web/controllers/webhooks/github_controller_test.exs +++ b/test/algora_web/controllers/webhooks/github_controller_test.exs @@ -5,6 +5,7 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do import Money.Sigil import Mox + alias Algora.Github.Webhook alias AlgoraWeb.Webhooks.GithubController setup :verify_on_exit! @@ -15,7 +16,20 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do @repo_name "repo" @installation_id 123 + @webhook %Webhook{ + event: "issue_comment", + hook_id: "123456789", + delivery: "00000000-0000-0000-0000-000000000000", + signature: "sha1=0000000000000000000000000000000000000000", + signature_256: "sha256=0000000000000000000000000000000000000000000000000000000000000000", + user_agent: "GitHub-Hookshot/0000000", + installation_type: "integration", + installation_id: "123456" + } + @params %{ + "id" => 123, + "action" => "created", "repository" => %{ "owner" => %{"login" => @repo_owner}, "name" => @repo_name @@ -47,57 +61,66 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do @tag user: @unauthorized_user test "handles bounty command with unauthorized user", %{user: user} do - assert process_bounty_command("/bounty $100", user)[:ok] == nil - assert process_bounty_command("/bounty $100", user)[:error] == :unauthorized + assert {:error, :unauthorized} = process_bounty_command("/bounty $100", user) end test "handles bounty command without amount" do - assert process_bounty_command("/bounty")[:ok] == nil - assert process_bounty_command("/bounty")[:error] == nil + assert {:ok, []} = process_bounty_command("/bounty") end test "handles valid bounty command with $ prefix" do - assert process_bounty_command("/bounty $100")[:ok].amount == ~M[100]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty $100") + assert bounty.amount == ~M[100]usd end test "handles invalid bounty command with $ suffix" do - assert process_bounty_command("/bounty 100$")[:ok].amount == ~M[100]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 100$") + assert bounty.amount == ~M[100]usd end test "handles bounty command without $ symbol" do - assert process_bounty_command("/bounty 100")[:ok].amount == ~M[100]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 100") + assert bounty.amount == ~M[100]usd end test "handles bounty command with decimal amount" do - assert process_bounty_command("/bounty 100.50")[:ok].amount == ~M[100.50]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 100.50") + assert bounty.amount == ~M[100.50]usd end test "handles bounty command with partial decimal amount" do - assert process_bounty_command("/bounty 100.5")[:ok].amount == ~M[100.5]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 100.5") + assert bounty.amount == ~M[100.5]usd end test "handles bounty command with decimal amount and $ prefix" do - assert process_bounty_command("/bounty $100.50")[:ok].amount == ~M[100.50]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty $100.50") + assert bounty.amount == ~M[100.50]usd end test "handles bounty command with partial decimal amount and $ prefix" do - assert process_bounty_command("/bounty $100.5")[:ok].amount == ~M[100.5]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty $100.5") + assert bounty.amount == ~M[100.5]usd end test "handles bounty command with decimal amount and $ suffix" do - assert process_bounty_command("/bounty 100.50$")[:ok].amount == ~M[100.50]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 100.50$") + assert bounty.amount == ~M[100.50]usd end test "handles bounty command with partial decimal amount and $ suffix" do - assert process_bounty_command("/bounty 100.5$")[:ok].amount == ~M[100.5]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 100.5$") + assert bounty.amount == ~M[100.5]usd end test "handles bounty command with comma separator" do - assert process_bounty_command("/bounty 1,000")[:ok].amount == ~M[1000]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 1,000") + assert bounty.amount == ~M[1000]usd end test "handles bounty command with comma separator and decimal amount" do - assert process_bounty_command("/bounty 1,000.50")[:ok].amount == ~M[1000.50]usd + assert {:ok, [bounty]} = process_bounty_command("/bounty 1,000.50") + assert bounty.amount == ~M[1000.50]usd end end @@ -184,14 +207,17 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do end # Helper function to process bounty commands - defp process_bounty_command(body, author \\ @admin_user) do - full_body = """ + defp process_bounty_command(command, author \\ @admin_user) do + body = """ Lorem - ipsum #{body} dolor + ipsum #{command} dolor sit amet """ - GithubController.process_commands(full_body, %{"login" => author}, @params) + GithubController.process_commands( + @webhook, + Map.put(@params, "comment", %{"user" => %{"login" => author}, "body" => body}) + ) end end diff --git a/test/support/factory.ex b/test/support/factory.ex index 0a8dda7f6..a7de935cd 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -66,7 +66,10 @@ defmodule Algora.Factory do twitter_url: "https://twitter.com/piedpiper", github_url: "https://github.com/piedpiper", discord_url: "https://discord.gg/piedpiper", - slack_url: "https://piedpiper.slack.com" + slack_url: "https://piedpiper.slack.com", + provider: "github", + provider_login: "piedpiper", + provider_id: sequence(:provider_id, &"#{&1}") } end @@ -195,16 +198,13 @@ defmodule Algora.Factory do end def claim_factory do + id = Nanoid.generate() + %Algora.Bounties.Claim{ - id: Nanoid.generate(), - provider: "github", - provider_id: sequence(:provider_id, &"#{&1}"), - type: :code, - status: :pending, - title: "Implemented compression optimization", - description: "Added parallel processing for large files", - url: "https://github.com/piedpiper/middle-out/pull/2", - provider_meta: %{} + id: id, + group_id: id, + type: :pull_request, + status: :pending } end