From 22973fbc2e7c842360564301c9ac0ac9fc71ceab Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 12 Jan 2025 21:03:17 +0300 Subject: [PATCH 01/34] feat: bounty claims --- config/config.exs | 3 +- lib/algora/bounties/bounties.ex | 82 +++++++++++++++++++ lib/algora/bounties/jobs/notify_claim.ex | 28 +++++++ lib/algora/bounties/schemas/bounty.ex | 1 - lib/algora/bounties/schemas/claim.ex | 53 ++++++++---- lib/algora/shared/util.ex | 9 ++ lib/algora/workspace/schemas/ticket.ex | 1 + .../controllers/webhooks/github_controller.ex | 19 +++-- .../20250112164132_recreate_claims.exs | 72 ++++++++++++++++ priv/repo/seeds.exs | 1 - test/support/factory.ex | 2 - 11 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 lib/algora/bounties/jobs/notify_claim.ex create mode 100644 priv/repo/migrations/20250112164132_recreate_claims.exs 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/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index db1584ee9..66fc3966b 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -121,6 +121,88 @@ defmodule Algora.Bounties do |> Oban.insert() end + @spec do_claim_bounty(%{user: User.t(), ticket: Ticket.t(), pull_request: map()}) :: + {:ok, Claim.t()} | {:error, atom()} + defp do_claim_bounty(%{user: user, ticket: ticket, pull_request: pull_request}) do + # TODO: ensure user is pull request author + id = Nanoid.generate() + + changeset = + Claim.changeset(%Claim{}, %{ + ticket_id: ticket.id, + user_id: user.id, + provider: "github", + provider_id: pull_request["id"], + provider_meta: pull_request, + title: pull_request["title"], + url: pull_request["html_url"], + group_id: id, + merged_at: Util.to_date(pull_request["merged_at"]) + }) + + case Repo.insert(changeset) do + {:ok, claim} -> + {:ok, claim} + + {:error, %{errors: [ticket_id: {_, [constraint: :unique, constraint_name: _]}]}} -> + {:error, :already_exists} + + {:error, _changeset} -> + {:error, :internal_server_error} + end + end + + @spec claim_bounty( + %{ + user: User.t(), + ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, + pull_request: map() + }, + opts :: [installation_id: integer()] + ) :: + {:ok, Bounty.t()} | {:error, atom()} + def claim_bounty( + %{ + user: user, + ticket_ref: %{owner: repo_owner, repo: repo_name, number: number} = ticket_ref, + pull_request: pull_request + }, + 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, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number), + {:ok, claim} <- do_claim_bounty(%{user: user, ticket: ticket, pull_request: pull_request}), + {:ok, _job} <- notify_claim(%{ticket_ref: ticket_ref}, installation_id: installation_id) do + broadcast() + {:ok, claim} + else + {:error, _reason} = error -> error + end + end) + end + + @spec notify_claim( + %{ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}}, + opts :: [installation_id: integer()] + ) :: + {:ok, Oban.Job.t()} | {:error, atom()} + def notify_claim(%{ticket_ref: ticket_ref}, opts \\ []) do + %{ + ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}, + installation_id: opts[:installation_id] + } + |> Jobs.NotifyClaim.new() + |> Oban.insert() + end + @spec create_tip_intent( %{ recipient: String.t(), diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex new file mode 100644 index 000000000..da24f9956 --- /dev/null +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -0,0 +1,28 @@ +defmodule Algora.Bounties.Jobs.NotifyClaim do + @moduledoc false + use Oban.Worker, queue: :notify_claim + + alias Algora.Github + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"ticket_ref" => _ticket_ref, "installation_id" => nil}}) do + :ok + end + + @impl Oban.Worker + def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do + with {:ok, token} <- Github.get_installation_token(installation_id) do + body = "Claimed!" + + Github.create_issue_comment( + token, + ticket_ref["owner"], + ticket_ref["repo"], + ticket_ref["number"], + body + ) + end + end +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..00000c16c 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -6,13 +6,9 @@ defmodule Algora.Bounties.Claim do @derive {Inspect, except: [:provider_meta]} typed_schema "claims" do - field :provider, :string - field :provider_id, :string - field :provider_meta, :map - - field :type, Ecto.Enum, values: [:code, :video, :design, :article] - - field :status, Ecto.Enum, values: [:pending, :merged, :approved, :rejected, :charged, :paid] + field :provider, :string, null: false + field :provider_id, :string, null: false + field :provider_meta, :map, null: false field :merged_at, :utc_datetime_usec field :approved_at, :utc_datetime_usec @@ -20,13 +16,13 @@ defmodule Algora.Bounties.Claim do field :charged_at, :utc_datetime_usec field :paid_at, :utc_datetime_usec - field :title, :string + field :title, :string, null: false field :description, :string - field :url, :string - field :group_id, :string + field :url, :string, null: false + field :group_id, :string, null: false - belongs_to :bounty, Algora.Bounties.Bounty - belongs_to :user, Algora.Accounts.User + belongs_to :ticket, Algora.Workspace.Ticket, null: false + belongs_to :user, Algora.Accounts.User, null: false # has_one :transaction, Algora.Payments.Transaction timestamps() @@ -34,13 +30,40 @@ defmodule Algora.Bounties.Claim do def changeset(claim, attrs) do claim - |> cast(attrs, [:bounty_id, :user_id]) - |> validate_required([:bounty_id, :user_id]) + |> cast(attrs, [ + :ticket_id, + :user_id, + :provider, + :provider_id, + :provider_meta, + :merged_at, + :approved_at, + :rejected_at, + :charged_at, + :paid_at, + :title, + :description, + :url, + :group_id + ]) + |> validate_required([ + :ticket_id, + :user_id, + :provider, + :provider_id, + :provider_meta, + :title, + :url, + :group_id + ]) + |> foreign_key_constraint(:ticket_id) + |> foreign_key_constraint(:user_id) + |> unique_constraint([:ticket_id, :user_id]) end 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/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/workspace/schemas/ticket.ex b/lib/algora/workspace/schemas/ticket.ex index 851a53222..fed298d24 100644 --- a/lib/algora/workspace/schemas/ticket.ex +++ b/lib/algora/workspace/schemas/ticket.ex @@ -18,6 +18,7 @@ defmodule Algora.Workspace.Ticket do belongs_to :repository, Algora.Workspace.Repository has_many :bounties, Algora.Bounties.Bounty + has_many :claims, Algora.Bounties.Claim timestamps() end diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 1963c7709..359ee4966 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -113,12 +113,21 @@ 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({:claim, args}, author, params) when not is_nil(args) do + installation_id = params["installation"]["id"] + pull_request = params["pull_request"] - Logger.info("Claim #{owner}/#{repo}##{number}") + with {:ok, token} <- Github.get_installation_token(installation_id), + {:ok, user} <- Workspace.ensure_user(token, author["login"]) do + Bounties.claim_bounty( + %{ + user: user, + ticket_ref: %{owner: args[:owner], repo: args[:repo], number: args[:number]}, + pull_request: pull_request + }, + installation_id: installation_id + ) + end end defp execute_command({command, _} = args, _author, _params), diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs new file mode 100644 index 000000000..d9fc1c43d --- /dev/null +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -0,0 +1,72 @@ +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 :provider, :string, null: false + add :provider_id, :string, null: false + add :provider_meta, :map, null: false + + add :opened_at, :utc_datetime_usec + 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, null: false + add :description, :string + add :url, :string, null: false + add :group_id, :string, null: false + + add :ticket_id, references(:tickets, on_delete: :nothing), null: false + add :user_id, references(:users, on_delete: :nothing), null: false + + timestamps() + end + + create unique_index(:claims, [:ticket_id, :user_id]) + create index(:claims, [:ticket_id]) + create index(:claims, [:user_id]) + end + + def down do + drop unique_index(:claims, [:ticket_id, :user_id]) + drop index(:claims, [:ticket_id]) + drop index(:claims, [:user_id]) + drop table(:claims) + + 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..121021460 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -399,7 +399,6 @@ for {repo_name, issues} <- repos do 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}" diff --git a/test/support/factory.ex b/test/support/factory.ex index 0a8dda7f6..894bb5b10 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -199,8 +199,6 @@ defmodule Algora.Factory do 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", From e3589d2aa57f610c02093ebdb8de6a5b2963fa33 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 18:06:47 +0300 Subject: [PATCH 02/34] handle all kinds of ticket_refs --- lib/algora_web/controllers/webhooks/github_controller.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 359ee4966..692787e6b 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -116,13 +116,20 @@ defmodule AlgoraWeb.Webhooks.GithubController do defp execute_command({:claim, args}, author, params) when not is_nil(args) do installation_id = params["installation"]["id"] pull_request = params["pull_request"] + repo = params["repository"] + + ticket_ref = %{ + owner: args[:ticket_ref][:owner] || repo["owner"]["login"], + repo: args[:ticket_ref][:repo] || repo["name"], + 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, - ticket_ref: %{owner: args[:owner], repo: args[:repo], number: args[:number]}, + ticket_ref: ticket_ref, pull_request: pull_request }, installation_id: installation_id From dcc7921912fdf057566c84b21a61ecde5ea721c2 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 18:07:35 +0300 Subject: [PATCH 03/34] refactor controller for better control and o11y --- lib/algora/bounties/bounties.ex | 8 +-- .../controllers/webhooks/github_controller.ex | 47 ++++++++++------- lib/algora_web/live/org/bounty_hook.ex | 3 -- lib/algora_web/live/org/create_bounty_live.ex | 3 -- .../webhooks/github_controller_test.exs | 50 ++++++++++++------- 5 files changed, 65 insertions(+), 46 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 66fc3966b..260369c55 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -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 @@ -147,8 +147,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 diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 692787e6b..ad5d457e3 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{event: event}} <- Webhook.new(conn), + {:ok, _} <- process_commands(event, 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 @@ -137,19 +136,33 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end - defp execute_command({command, _} = args, _author, _params), - do: Logger.info("Unhandled command: #{command} #{inspect(args)}") + defp execute_command(_command, _author, _params) do + {:error, :unhandled_command} + end + + def process_commands(event, params) do + author = get_author(event, params) + body = get_body(event, params) - 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(command, author, params) do + {:ok, result} -> + {:cont, {:ok, [result | results]}} + + error -> + Logger.error("Command execution failed for #{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/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/test/algora_web/controllers/webhooks/github_controller_test.exs b/test/algora_web/controllers/webhooks/github_controller_test.exs index 6b3070230..f8399a451 100644 --- a/test/algora_web/controllers/webhooks/github_controller_test.exs +++ b/test/algora_web/controllers/webhooks/github_controller_test.exs @@ -47,57 +47,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 +193,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( + "issue_comment", + Map.put(@params, "comment", %{"user" => %{"login" => author}, "body" => body}) + ) end end From de8ccb6ef1e2ff93661b1a3a46f58226a0c5a145 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 18:15:42 +0300 Subject: [PATCH 04/34] properly set ids --- lib/algora/bounties/bounties.ex | 7 ++----- lib/algora/bounties/jobs/notify_claim.ex | 1 + lib/algora/bounties/schemas/claim.ex | 12 ++++++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 260369c55..2910f0064 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -125,18 +125,15 @@ defmodule Algora.Bounties do {:ok, Claim.t()} | {:error, atom()} defp do_claim_bounty(%{user: user, ticket: ticket, pull_request: pull_request}) do # TODO: ensure user is pull request author - id = Nanoid.generate() - changeset = Claim.changeset(%Claim{}, %{ ticket_id: ticket.id, user_id: user.id, provider: "github", - provider_id: pull_request["id"], - provider_meta: pull_request, + provider_id: to_string(pull_request["id"]), + provider_meta: Util.normalize_struct(pull_request), title: pull_request["title"], url: pull_request["html_url"], - group_id: id, merged_at: Util.to_date(pull_request["merged_at"]) }) diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex index da24f9956..1160ca912 100644 --- a/lib/algora/bounties/jobs/notify_claim.ex +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -14,6 +14,7 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do @impl Oban.Worker def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do with {:ok, token} <- Github.get_installation_token(installation_id) do + # TODO: update message body = "Claimed!" Github.create_issue_comment( diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index 00000c16c..f1720bd94 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -53,14 +53,22 @@ defmodule Algora.Bounties.Claim do :provider_id, :provider_meta, :title, - :url, - :group_id + :url ]) + |> generate_id() + |> put_group_id() |> foreign_key_constraint(:ticket_id) |> foreign_key_constraint(:user_id) |> unique_constraint([:ticket_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 rewarded(query \\ Claim) do from c in query, where: c.state == :approved and not is_nil(c.charged_at) From 18d3cd55f7d435ccbf56a812d143e5572f2d7d2d Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 19:00:38 +0300 Subject: [PATCH 05/34] update claim notif --- lib/algora/bounties/bounties.ex | 12 ++++------ lib/algora/bounties/jobs/notify_claim.ex | 23 +++++++++++-------- lib/algora/bounties/schemas/claim.ex | 10 ++++++++ .../20250112164132_recreate_claims.exs | 1 + 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 2910f0064..62049bebc 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -132,6 +132,7 @@ defmodule Algora.Bounties do provider: "github", provider_id: to_string(pull_request["id"]), provider_meta: Util.normalize_struct(pull_request), + type: :pull_request, title: pull_request["title"], url: pull_request["html_url"], merged_at: Util.to_date(pull_request["merged_at"]) @@ -177,7 +178,7 @@ defmodule Algora.Bounties do with {:ok, token} <- token_res, {:ok, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number), {:ok, claim} <- do_claim_bounty(%{user: user, ticket: ticket, pull_request: pull_request}), - {:ok, _job} <- notify_claim(%{ticket_ref: ticket_ref}, installation_id: installation_id) do + {:ok, _job} <- notify_claim(%{claim: claim}, installation_id: installation_id) do broadcast() {:ok, claim} else @@ -187,15 +188,12 @@ defmodule Algora.Bounties do end @spec notify_claim( - %{ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}}, + %{claim: Claim.t()}, opts :: [installation_id: integer()] ) :: {:ok, Oban.Job.t()} | {:error, atom()} - def notify_claim(%{ticket_ref: ticket_ref}, opts \\ []) do - %{ - ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}, - installation_id: opts[:installation_id] - } + def notify_claim(%{claim: claim}, opts \\ []) do + %{claim_id: claim.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 1160ca912..c466739ef 100644 --- a/lib/algora/bounties/jobs/notify_claim.ex +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -2,27 +2,32 @@ 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: %{"ticket_ref" => _ticket_ref, "installation_id" => nil}}) do + def perform(%Oban.Job{args: %{"claim_id" => _claim_id, "installation_id" => nil}}) do :ok end @impl Oban.Worker - def perform(%Oban.Job{args: %{"ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do - with {:ok, token} <- Github.get_installation_token(installation_id) do - # TODO: update message - body = "Claimed!" + 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) do + claim = Repo.preload(claim, ticket: [repository: [:user]], user: []) + + # TODO: implement + claim_reward_url = "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.id}" Github.create_issue_comment( token, - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body + claim.ticket.repository.user.provider_login, + claim.ticket.repository.name, + claim.ticket.number, + "💡 @#{claim.user.provider_login} submitted [#{Claim.type_label(claim.type)}](#{claim.url}) that claims the bounty. You can visit [Algora](#{claim_reward_url}) to reward." ) end end diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index f1720bd94..219230d37 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -10,6 +10,8 @@ defmodule Algora.Bounties.Claim do field :provider_id, :string, null: false field :provider_meta, :map, null: false + field :type, Ecto.Enum, values: [:pull_request, :review, :video, :design, :article] + field :merged_at, :utc_datetime_usec field :approved_at, :utc_datetime_usec field :rejected_at, :utc_datetime_usec @@ -41,6 +43,7 @@ defmodule Algora.Bounties.Claim do :rejected_at, :charged_at, :paid_at, + :type, :title, :description, :url, @@ -69,6 +72,13 @@ defmodule Algora.Bounties.Claim do 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 rewarded(query \\ Claim) do from c in query, where: c.state == :approved and not is_nil(c.charged_at) diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs index d9fc1c43d..1f19030bf 100644 --- a/priv/repo/migrations/20250112164132_recreate_claims.exs +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -18,6 +18,7 @@ defmodule Algora.Repo.Migrations.RecreateClaims do add :charged_at, :utc_datetime_usec add :paid_at, :utc_datetime_usec + add :type, :string, null: false add :title, :string, null: false add :description, :string add :url, :string, null: false From abc648e1ad4a7b19e6f8e716c4a9e769ee9cc408 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 19:22:41 +0300 Subject: [PATCH 06/34] improve notif handling --- lib/algora/bounties/jobs/notify_claim.ex | 38 ++++++++++++++------- lib/algora/bounties/schemas/claim.ex | 2 ++ lib/algora/integrations/github/behaviour.ex | 6 ++-- lib/algora/integrations/github/client.ex | 5 +++ lib/algora/integrations/github/github.ex | 3 ++ 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex index c466739ef..c6ce07e2d 100644 --- a/lib/algora/bounties/jobs/notify_claim.ex +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -16,19 +16,33 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do @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) do - claim = Repo.preload(claim, ticket: [repository: [:user]], user: []) + {:ok, claim} <- Repo.fetch(Claim, claim_id), + claim = Repo.preload(claim, ticket: [repository: [:user]], user: []), + {:ok, _} <- maybe_add_labels(token, claim), + {:ok, _} <- add_comment(token, claim) do + :ok + end + end - # TODO: implement - claim_reward_url = "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.id}" + defp add_comment(token, claim) do + Github.create_issue_comment( + token, + claim.ticket.repository.user.provider_login, + claim.ticket.repository.name, + claim.ticket.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 - Github.create_issue_comment( - token, - claim.ticket.repository.user.provider_login, - claim.ticket.repository.name, - claim.ticket.number, - "💡 @#{claim.user.provider_login} submitted [#{Claim.type_label(claim.type)}](#{claim.url}) that claims the bounty. You can visit [Algora](#{claim_reward_url}) to reward." - ) - end + defp maybe_add_labels(token, %Claim{type: :pull_request} = claim) do + Github.add_labels( + token, + claim.provider_meta["base"]["repo"]["owner"]["login"], + claim.provider_meta["base"]["repo"]["name"], + claim.provider_meta["number"], + ["🙋 Bounty claim"]) + end + + defp maybe_add_labels(_token, _claim), do: {:ok, nil} end diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index 219230d37..f079342e0 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -79,6 +79,8 @@ 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 rewarded(query \\ Claim) do from c in query, where: c.state == :approved and not is_nil(c.charged_at) diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index d7bf1592c..21b93e3dd 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,8 @@ 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 end diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex index 3703f72db..a7bbc9efc 100644 --- a/lib/algora/integrations/github/client.ex +++ b/lib/algora/integrations/github/client.ex @@ -180,4 +180,9 @@ 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 end diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index 23a41b8eb..9fef4ff62 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -91,4 +91,7 @@ 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) end From 0378891741bba88051a91ff34146158e191bc9c9 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 21:06:21 +0300 Subject: [PATCH 07/34] restructure claim schema --- lib/algora/bounties/bounties.ex | 42 +++++++++------ lib/algora/bounties/jobs/notify_claim.ex | 20 +++---- lib/algora/bounties/schemas/claim.ex | 52 ++++--------------- lib/algora/workspace/schemas/ticket.ex | 1 - .../controllers/webhooks/github_controller.ex | 21 +++++--- .../20250112164132_recreate_claims.exs | 27 ++++------ priv/repo/seeds.exs | 21 +++++--- test/support/factory.ex | 8 +-- 8 files changed, 85 insertions(+), 107 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 62049bebc..ae6149397 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -121,28 +121,31 @@ defmodule Algora.Bounties do |> Oban.insert() end - @spec do_claim_bounty(%{user: User.t(), ticket: Ticket.t(), pull_request: map()}) :: + @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, ticket: ticket, pull_request: pull_request}) do + 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{}, %{ - ticket_id: ticket.id, + target_id: target.id, + source_id: source.id, user_id: user.id, - provider: "github", - provider_id: to_string(pull_request["id"]), - provider_meta: Util.normalize_struct(pull_request), - type: :pull_request, - title: pull_request["title"], - url: pull_request["html_url"], - merged_at: Util.to_date(pull_request["merged_at"]) + type: type, + status: status, + url: source.url }) case Repo.insert(changeset) do {:ok, claim} -> {:ok, claim} - {:error, %{errors: [ticket_id: {_, [constraint: :unique, constraint_name: _]}]}} -> + {:error, %{errors: [target_id: {_, [constraint: :unique, constraint_name: _]}]}} -> {:error, :already_exists} {:error, _changeset} = error -> @@ -153,8 +156,10 @@ defmodule Algora.Bounties do @spec claim_bounty( %{ user: User.t(), - ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, - pull_request: map() + 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()] ) :: @@ -162,8 +167,10 @@ defmodule Algora.Bounties do def claim_bounty( %{ user: user, - ticket_ref: %{owner: repo_owner, repo: repo_name, number: number} = ticket_ref, - pull_request: pull_request + 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 @@ -176,8 +183,9 @@ defmodule Algora.Bounties do Repo.transact(fn -> with {:ok, token} <- token_res, - {:ok, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number), - {:ok, claim} <- do_claim_bounty(%{user: user, ticket: ticket, pull_request: pull_request}), + {: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} diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex index c6ce07e2d..a73a8da3b 100644 --- a/lib/algora/bounties/jobs/notify_claim.ex +++ b/lib/algora/bounties/jobs/notify_claim.ex @@ -17,7 +17,7 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do 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, ticket: [repository: [:user]], user: []), + claim = Repo.preload(claim, source: [repository: [:user]], target: [repository: [:user]], user: []), {:ok, _} <- maybe_add_labels(token, claim), {:ok, _} <- add_comment(token, claim) do :ok @@ -27,21 +27,21 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do defp add_comment(token, claim) do Github.create_issue_comment( token, - claim.ticket.repository.user.provider_login, - claim.ticket.repository.name, - claim.ticket.number, + 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{type: :pull_request} = claim) do + defp maybe_add_labels(token, %Claim{source: source} = claim) when not is_nil(source) do Github.add_labels( token, - claim.provider_meta["base"]["repo"]["owner"]["login"], - claim.provider_meta["base"]["repo"]["name"], - claim.provider_meta["number"], - ["🙋 Bounty claim"]) - + 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} diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index f079342e0..aae3b72ee 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -3,27 +3,16 @@ 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, null: false - field :provider_id, :string, null: false - field :provider_meta, :map, null: false - + field :status, Ecto.Enum, values: [:pending, :approved, :rejected, :paid], null: false field :type, Ecto.Enum, values: [:pull_request, :review, :video, :design, :article] - - 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, null: false - field :description, :string field :url, :string, null: false field :group_id, :string, null: false - belongs_to :ticket, Algora.Workspace.Ticket, null: false + belongs_to :source, Ticket + belongs_to :target, Ticket, null: false belongs_to :user, Algora.Accounts.User, null: false # has_one :transaction, Algora.Payments.Transaction @@ -32,37 +21,14 @@ defmodule Algora.Bounties.Claim do def changeset(claim, attrs) do claim - |> cast(attrs, [ - :ticket_id, - :user_id, - :provider, - :provider_id, - :provider_meta, - :merged_at, - :approved_at, - :rejected_at, - :charged_at, - :paid_at, - :type, - :title, - :description, - :url, - :group_id - ]) - |> validate_required([ - :ticket_id, - :user_id, - :provider, - :provider_id, - :provider_meta, - :title, - :url - ]) + |> 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(:ticket_id) + |> foreign_key_constraint(:source_id) + |> foreign_key_constraint(:target_id) |> foreign_key_constraint(:user_id) - |> unique_constraint([:ticket_id, :user_id]) + |> unique_constraint([:target_id, :user_id]) end def put_group_id(changeset) do diff --git a/lib/algora/workspace/schemas/ticket.ex b/lib/algora/workspace/schemas/ticket.ex index fed298d24..851a53222 100644 --- a/lib/algora/workspace/schemas/ticket.ex +++ b/lib/algora/workspace/schemas/ticket.ex @@ -18,7 +18,6 @@ defmodule Algora.Workspace.Ticket do belongs_to :repository, Algora.Workspace.Repository has_many :bounties, Algora.Bounties.Bounty - has_many :claims, Algora.Bounties.Claim timestamps() end diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index ad5d457e3..808e10f07 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -117,19 +117,28 @@ defmodule AlgoraWeb.Webhooks.GithubController do pull_request = params["pull_request"] repo = params["repository"] - ticket_ref = %{ - owner: args[:ticket_ref][:owner] || repo["owner"]["login"], - repo: args[:ticket_ref][:repo] || repo["name"], - number: args[:ticket_ref][: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, - ticket_ref: ticket_ref, - pull_request: pull_request + 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 ) diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs index 1f19030bf..90724bd43 100644 --- a/priv/repo/migrations/20250112164132_recreate_claims.exs +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -7,37 +7,28 @@ defmodule Algora.Repo.Migrations.RecreateClaims do drop table(:claims) create table(:claims) do - add :provider, :string, null: false - add :provider_id, :string, null: false - add :provider_meta, :map, null: false - - add :opened_at, :utc_datetime_usec - 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 :status, :string, null: false add :type, :string, null: false - add :title, :string, null: false - add :description, :string add :url, :string, null: false add :group_id, :string, null: false - add :ticket_id, references(:tickets, on_delete: :nothing), null: false + 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 - create unique_index(:claims, [:ticket_id, :user_id]) - create index(:claims, [:ticket_id]) + create unique_index(:claims, [:target_id, :user_id]) + create index(:claims, [:source_id]) + create index(:claims, [:target_id]) create index(:claims, [:user_id]) end def down do - drop unique_index(:claims, [:ticket_id, :user_id]) - drop index(:claims, [:ticket_id]) + drop unique_index(:claims, [:target_id, :user_id]) + drop index(:claims, [:source_id]) + drop index(:claims, [:target_id]) drop index(:claims, [:user_id]) drop table(:claims) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 121021460..64814a5b9 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -355,7 +355,7 @@ for {repo_name, issues} <- repos do }) for {issue_title, index} <- Enum.with_index(issues, 1) do - ticket = + issue = insert!(:ticket, %{ repository_id: repo.id, title: issue_title, @@ -371,7 +371,7 @@ for {repo_name, issues} <- repos do bounty = insert!(:bounty, %{ - ticket_id: ticket.id, + ticket_id: issue.id, owner_id: pied_piper.id, creator_id: richard.id, amount: amount, @@ -385,7 +385,7 @@ for {repo_name, issues} <- repos do amount = Money.new!(Enum.random([500, 1000, 1500, 2000]), :USD) insert!(:bounty, %{ - ticket_id: ticket.id, + ticket_id: issue.id, owner_id: member.id, creator_id: member.id, amount: amount, @@ -395,15 +395,24 @@ for {repo_name, issues} <- repos do end if claimed do - claim = - insert!(:claim, %{ - bounty_id: bounty.id, + pull_request = + insert!(:ticket, %{ user_id: carver.id, title: "Implementation for #{issue_title}", description: "Here's my solution to this issue.", url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" }) + claim = + insert!(:claim, %{ + user_id: carver.id, + target_id: pull_request.id, + source_id: issue.id, + type: :pull_request, + status: if(paid, do: :paid, else: :pending), + url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" + }) + # Create transaction pairs for paid claims if paid do debit_id = Nanoid.generate() diff --git a/test/support/factory.ex b/test/support/factory.ex index 894bb5b10..0490687d0 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -197,12 +197,8 @@ defmodule Algora.Factory do def claim_factory do %Algora.Bounties.Claim{ id: Nanoid.generate(), - provider: "github", - provider_id: sequence(:provider_id, &"#{&1}"), - title: "Implemented compression optimization", - description: "Added parallel processing for large files", - url: "https://github.com/piedpiper/middle-out/pull/2", - provider_meta: %{} + type: :pull_request, + status: :pending } end From fd16bef2c2be189de19415f0a42df34896da1d50 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 21:47:26 +0300 Subject: [PATCH 08/34] add /claims/:id --- lib/algora_web/live/claim_live.ex | 146 ++++++++++++++++++++++++++++++ lib/algora_web/router.ex | 2 + priv/repo/seeds.exs | 68 ++++++++++---- test/support/factory.ex | 10 +- 4 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 lib/algora_web/live/claim_live.ex diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex new file mode 100644 index 000000000..d3d3d0f13 --- /dev/null +++ b/lib/algora_web/live/claim_live.ex @@ -0,0 +1,146 @@ +defmodule AlgoraWeb.ClaimLive do + @moduledoc false + use AlgoraWeb, :live_view + + alias Algora.Bounties.Claim + alias Algora.Repo + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, claim} = Repo.fetch(Claim, id) + + claim = Repo.preload(claim, [:user, source: [repository: [:user]], target: [repository: [:user], bounties: [:owner]]]) + + dbg(claim) + {:ok, prize_pool} = claim.target.bounties |> Enum.map(& &1.amount) |> Money.sum() + + {:ok, + socket + |> assign(:page_title, "Claim Details") + |> assign(:claim, claim) + |> assign(:target, claim.target) + |> assign(:source, claim.source) + |> assign(:user, claim.user) + |> assign(:bounties, claim.target.bounties) + |> assign(:prize_pool, prize_pool)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+ <%!-- Header with target issue and prize pool --%> + <.header class="mb-8"> +
+ <.link href={@target.url} class="text-xl font-semibold hover:underline" target="_blank"> + {@target.title} + +
+ {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
+
+ <:subtitle> +
+ {Money.to_string!(@prize_pool)} +
+ + <:actions> + <.button variant="outline"> + <.icon name="tabler-clock" class="mr-2 h-4 w-4" /> + {@claim.status |> to_string() |> String.capitalize()} + + + + + <%!-- Claimant and Sponsors Cards --%> +
+ <%!-- Claimer Info --%> + <.card> + <.card_header> + <.card_title> +
+ <.icon name="tabler-user" class="h-5 w-5 text-muted-foreground" /> Claimed By +
+ + + <.card_content> +
+ <.avatar> + <.avatar_image src={@user.avatar_url} /> + <.avatar_fallback> + {String.first(@user.name)} + + +
+

{@user.name}

+

@{@user.handle}

+
+
+ + + + <%!-- Bounty Sponsors Card --%> + <.card> + <.card_header> + <.card_title> +
+ <.icon name="tabler-users" class="h-5 w-5 text-muted-foreground" /> Sponsors +
+ + + <.card_content> +
+ <%= for bounty <- @bounties do %> +
+
+ <.avatar> + <.avatar_image src={bounty.owner.avatar_url} /> + <.avatar_fallback> + {String.first(bounty.owner.name)} + + +
+

{bounty.owner.name}

+

@{bounty.owner.handle}

+
+
+ <.badge variant="secondary"> + {Money.to_string!(bounty.amount)} + +
+ <% end %> +
+ + +
+ + <%!-- Pull Request Details --%> + <.card> + <.card_header> + <.card_title> +
+ <.icon name="tabler-git-pull-request" class="h-5 w-5 text-muted-foreground" /> + Pull Request +
+ + + <.card_content> +
+ <.link href={@source.url} class="text-lg font-semibold hover:underline" target="_blank"> + {@source.title} + +
+ {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
+
+ {@source.description} +
+
+ + +
+
+ """ + end +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 7d7589567..933989ca9 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -140,6 +140,8 @@ defmodule AlgoraWeb.Router do live "/trotw", TROTWLive live "/open-source", OpenSourceLive, :index + + live "/claims/:id", ClaimLive end # Other scopes may use custom stacks. diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 64814a5b9..be76e5a4c 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -357,6 +357,7 @@ for {repo_name, issues} <- repos do 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.", @@ -378,36 +379,63 @@ for {repo_name, issues} <- repos do 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: issue.id, - owner_id: member.id, - creator_id: member.id, - amount: amount, - status: :open - }) - end) - end + 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: issue.id, + owner_id: member.id, + creator_id: member.id, + amount: amount, + status: :open + }) + end) if claimed do pull_request = insert!(:ticket, %{ - user_id: carver.id, - title: "Implementation for #{issue_title}", - description: "Here's my solution to this issue.", + 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}" }) claim = insert!(:claim, %{ user_id: carver.id, - target_id: pull_request.id, - source_id: issue.id, + target_id: issue.id, + source_id: pull_request.id, type: :pull_request, status: if(paid, do: :paid, else: :pending), url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" diff --git a/test/support/factory.ex b/test/support/factory.ex index 0490687d0..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,8 +198,11 @@ defmodule Algora.Factory do end def claim_factory do + id = Nanoid.generate() + %Algora.Bounties.Claim{ - id: Nanoid.generate(), + id: id, + group_id: id, type: :pull_request, status: :pending } From a31a8af90ca9eb5d69731c105294089f254385e3 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 22:02:04 +0300 Subject: [PATCH 09/34] render claim body --- lib/algora/accounts/accounts.ex | 15 ++++++----- lib/algora/integrations/github/behaviour.ex | 1 + lib/algora/integrations/github/client.ex | 27 ++++++++++++-------- lib/algora/integrations/github/github.ex | 3 +++ lib/algora/integrations/github/token_pool.ex | 3 ++- lib/algora_web/live/claim_live.ex | 18 +++++++++---- 6 files changed, 45 insertions(+), 22 deletions(-) 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/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index 21b93e3dd..2b0bc2f6e 100644 --- a/lib/algora/integrations/github/behaviour.ex +++ b/lib/algora/integrations/github/behaviour.ex @@ -20,4 +20,5 @@ defmodule Algora.Github.Behaviour do @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()) :: response end diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex index a7bbc9efc..161ffd7f8 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 @@ -185,4 +187,9 @@ defmodule Algora.Github.Client do 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, markdown) do + fetch(access_token, "/markdown", "POST", %{text: markdown}, skip_decoding: true) + end end diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index 9fef4ff62..91d4c8fba 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -94,4 +94,7 @@ defmodule Algora.Github do @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, markdown), do: client().render_markdown(token, markdown) 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_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index d3d3d0f13..c586d3fcd 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -3,16 +3,23 @@ defmodule AlgoraWeb.ClaimLive do use AlgoraWeb, :live_view alias Algora.Bounties.Claim + alias Algora.Github alias Algora.Repo @impl true def mount(%{"id" => id}, _session, socket) do {:ok, claim} = Repo.fetch(Claim, id) - claim = Repo.preload(claim, [:user, source: [repository: [:user]], target: [repository: [:user], bounties: [:owner]]]) + claim = + Repo.preload(claim, [ + :user, + source: [repository: [:user]], + target: [repository: [:user], bounties: [:owner]] + ]) - dbg(claim) {:ok, prize_pool} = claim.target.bounties |> Enum.map(& &1.amount) |> Money.sum() + token = Github.TokenPool.get_token() + {:ok, source_body_html} = Github.render_markdown(token, claim.source.description) {:ok, socket @@ -22,7 +29,8 @@ defmodule AlgoraWeb.ClaimLive do |> assign(:source, claim.source) |> assign(:user, claim.user) |> assign(:bounties, claim.target.bounties) - |> assign(:prize_pool, prize_pool)} + |> assign(:prize_pool, prize_pool) + |> assign(:source_body_html, source_body_html)} end @impl true @@ -133,8 +141,8 @@ defmodule AlgoraWeb.ClaimLive do
{@source.repository.user.provider_login}/{@source.repository.name}#{@source.number}
-
- {@source.description} +
+ {Phoenix.HTML.raw(@source_body_html)}
From b238a2226fb11734fac3d954433ce5a84b6488b8 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 13 Jan 2025 22:20:07 +0300 Subject: [PATCH 10/34] improve claim page --- lib/algora_web/live/claim_live.ex | 216 +++++++++++++++++------------- 1 file changed, 122 insertions(+), 94 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index c586d3fcd..ca975baec 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -18,8 +18,14 @@ defmodule AlgoraWeb.ClaimLive do ]) {:ok, prize_pool} = claim.target.bounties |> Enum.map(& &1.amount) |> Money.sum() - token = Github.TokenPool.get_token() - {:ok, source_body_html} = Github.render_markdown(token, claim.source.description) + + source_body_html = + with token when is_binary(token) <- Github.TokenPool.get_token(), + {:ok, source_body_html} <- Github.render_markdown(token, claim.source.description) do + source_body_html + else + _ -> claim.source.description + end {:ok, socket @@ -40,113 +46,135 @@ defmodule AlgoraWeb.ClaimLive do
<%!-- Header with target issue and prize pool --%> <.header class="mb-8"> -
- <.link href={@target.url} class="text-xl font-semibold hover:underline" target="_blank"> - {@target.title} - -
- {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
+ <.avatar class="h-16 w-16 rounded-full"> + <.avatar_image src={@source.repository.user.avatar_url} /> + <.avatar_fallback> + {String.first(@source.repository.user.provider_login)} + + +
+ <.link href={@target.url} class="text-xl font-semibold hover:underline" target="_blank"> + {@target.title} + +
+ {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
- <:subtitle> + <:actions>
{Money.to_string!(@prize_pool)}
- - <:actions> - <.button variant="outline"> - <.icon name="tabler-clock" class="mr-2 h-4 w-4" /> - {@claim.status |> to_string() |> String.capitalize()} - - <%!-- Claimant and Sponsors Cards --%> -
- <%!-- Claimer Info --%> - <.card> - <.card_header> - <.card_title> -
- <.icon name="tabler-user" class="h-5 w-5 text-muted-foreground" /> Claimed By + <%!-- New grid layout with different column widths --%> +
+ <%!-- Combined Claim Details Card --%> +
+ <%!-- Claimer Info --%> + <.card> + <.card_header> +
+ <.avatar> + <.avatar_image src={@user.avatar_url} /> + <.avatar_fallback>{String.first(@user.name)} + +
+

{@user.name}

+

@{@user.handle}

+
- - - <.card_content> -
- <.avatar> - <.avatar_image src={@user.avatar_url} /> - <.avatar_fallback> - {String.first(@user.name)} - - -
-

{@user.name}

-

@{@user.handle}

+ + <.card_content> +
+ <%!-- Pull Request Details --%> +
+ <.link + href={@source.url} + class="text-lg font-semibold hover:underline" + target="_blank" + > + {@source.title} + +
+ {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
+
+ {Phoenix.HTML.raw(@source_body_html)} +
+
-
- - + + +
- <%!-- Bounty Sponsors Card --%> - <.card> - <.card_header> - <.card_title> -
- <.icon name="tabler-users" class="h-5 w-5 text-muted-foreground" /> Sponsors + <%!-- Right Column: Claim Metadata + Sponsors --%> +
+ <%!-- Claim Metadata Card --%> + <.card> + <.card_header> + <.card_title> +
+ <.icon name="tabler-info-circle" class="h-5 w-5 text-muted-foreground" /> + Claim Info +
+ + + <.card_content> +
+
+ Status + {@claim.status |> to_string() |> String.capitalize()} +
+
+ Submitted + {Calendar.strftime(@claim.inserted_at, "%B %d, %Y")} +
+
+ Last Updated + {Calendar.strftime(@claim.updated_at, "%B %d, %Y")} +
- - - <.card_content> -
- <%= for bounty <- @bounties do %> -
-
- <.avatar> - <.avatar_image src={bounty.owner.avatar_url} /> - <.avatar_fallback> - {String.first(bounty.owner.name)} - - -
-

{bounty.owner.name}

-

@{bounty.owner.handle}

+ + + + <%!-- Sponsors Card --%> + <.card> + <.card_header> + <.card_title> +
+ <.icon name="tabler-users" class="h-5 w-5 text-muted-foreground" /> Sponsors +
+ + + <.card_content> +
+ <%= for bounty <- Enum.sort_by(@bounties, &{&1.amount, &1.inserted_at}, :desc) do %> +
+
+ <.avatar> + <.avatar_image src={bounty.owner.avatar_url} /> + <.avatar_fallback> + {String.first(bounty.owner.name)} + + +
+

{bounty.owner.name}

+

@{bounty.owner.handle}

+
+ <.badge variant="success" class="font-display"> + {Money.to_string!(bounty.amount)} +
- <.badge variant="secondary"> - {Money.to_string!(bounty.amount)} - -
- <% end %> -
- - + <% end %> +
+ + +
- - <%!-- Pull Request Details --%> - <.card> - <.card_header> - <.card_title> -
- <.icon name="tabler-git-pull-request" class="h-5 w-5 text-muted-foreground" /> - Pull Request -
- - - <.card_content> -
- <.link href={@source.url} class="text-lg font-semibold hover:underline" target="_blank"> - {@source.title} - -
- {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} -
-
- {Phoenix.HTML.raw(@source_body_html)} -
-
- -
""" From b0e5563882dcee8445b763b82f21027b96fef50b Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 16 Jan 2025 19:51:41 +0300 Subject: [PATCH 11/34] simplify claim status values --- lib/algora/bounties/schemas/claim.ex | 2 +- priv/repo/seeds.exs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index aae3b72ee..54251022a 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -6,7 +6,7 @@ defmodule Algora.Bounties.Claim do alias Algora.Workspace.Ticket typed_schema "claims" do - field :status, Ecto.Enum, values: [:pending, :approved, :rejected, :paid], null: false + 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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index be76e5a4c..26d0b5500 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -437,7 +437,7 @@ for {repo_name, issues} <- repos do target_id: issue.id, source_id: pull_request.id, type: :pull_request, - status: if(paid, do: :paid, else: :pending), + status: if(paid, do: :approved, else: :pending), url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" }) From 0df66241b10fbd68ea8f4819ce3a2c18afde96b0 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 16 Jan 2025 19:51:52 +0300 Subject: [PATCH 12/34] drop unique constraint on claims --- priv/repo/migrations/20250112164132_recreate_claims.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs index 90724bd43..4feffa613 100644 --- a/priv/repo/migrations/20250112164132_recreate_claims.exs +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -19,14 +19,12 @@ defmodule Algora.Repo.Migrations.RecreateClaims do timestamps() end - create unique_index(:claims, [:target_id, :user_id]) create index(:claims, [:source_id]) create index(:claims, [:target_id]) create index(:claims, [:user_id]) end def down do - drop unique_index(:claims, [:target_id, :user_id]) drop index(:claims, [:source_id]) drop index(:claims, [:target_id]) drop index(:claims, [:user_id]) From 6536c47ab6ef94df4cde4517e2f35a5dde769cef Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 16 Jan 2025 19:54:34 +0300 Subject: [PATCH 13/34] add group share on claims --- lib/algora/bounties/schemas/claim.ex | 1 + priv/repo/migrations/20250112164132_recreate_claims.exs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index 54251022a..ba012e2c4 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -10,6 +10,7 @@ defmodule Algora.Bounties.Claim do 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 belongs_to :source, Ticket belongs_to :target, Ticket, null: false diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs index 4feffa613..afca14709 100644 --- a/priv/repo/migrations/20250112164132_recreate_claims.exs +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -11,6 +11,7 @@ defmodule Algora.Repo.Migrations.RecreateClaims do 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 @@ -19,12 +20,14 @@ defmodule Algora.Repo.Migrations.RecreateClaims do timestamps() 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]) From 02e79ab1df3858f1ead9e73e64c1d6efa2b7e300 Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 16 Jan 2025 20:15:24 +0300 Subject: [PATCH 14/34] update seeds to add shared claims --- priv/repo/seeds.exs | 286 ++++++++++++++++++++++---------------------- 1 file changed, 146 insertions(+), 140 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 26d0b5500..2be5c1cc4 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -313,6 +313,108 @@ end Logger.info("Contract: #{AlgoraWeb.Endpoint.url()}/org/#{pied_piper.handle}/contracts/#{initial_contract.id}") +big_head = + upsert!( + :user, + [:email], + %{ + email: "bighead@example.com", + display_name: "Nelson Bighetti", + handle: "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"], + country: "IT", + hourly_rate_min: Money.new!(150, :USD), + hourly_rate_max: Money.new!(200, :USD), + hours_per_week: 25 + } + ) + +jian_yang = + upsert!( + :user, + [:email], + %{ + email: "jianyang@example.com", + display_name: "Jian Yang", + handle: "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"], + country: "HK", + hourly_rate_min: Money.new!(125, :USD), + hourly_rate_max: Money.new!(175, :USD), + hours_per_week: 35 + } + ) + +john = + upsert!( + :user, + [:email], + %{ + email: "john@example.com", + display_name: "John Stafford", + handle: "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"], + country: "GB", + hourly_rate_min: Money.new!(140, :USD), + hourly_rate_max: Money.new!(190, :USD), + hours_per_week: 40 + } + ) + +aly = + upsert!( + :user, + [:email], + %{ + email: "aly@example.com", + display_name: "Aly Dutta", + handle: "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"], + country: "IN", + hourly_rate_min: Money.new!(160, :USD), + hourly_rate_max: Money.new!(220, :USD), + hours_per_week: 35 + } + ) + +for user <- [aly, big_head, jian_yang, john] do + debit_id = Nanoid.generate() + credit_id = Nanoid.generate() + amount = Money.new!(Enum.random(1..10) * 10_000, :USD) + + Repo.transact(fn -> + insert!(:transaction, %{ + id: debit_id, + linked_transaction_id: credit_id, + type: :debit, + status: :succeeded, + net_amount: amount, + user_id: pied_piper.id, + succeeded_at: days_from_now(0) + }) + + insert!(:transaction, %{ + id: credit_id, + linked_transaction_id: debit_id, + type: :credit, + status: :succeeded, + net_amount: amount, + user_id: user.id, + succeeded_at: days_from_now(0) + }) + + {:ok, :ok} + end) +end + repos = [ { "middle-out", @@ -431,153 +533,57 @@ for {repo_name, issues} <- repos do url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}" }) - claim = - insert!(:claim, %{ - user_id: carver.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}" - }) - - # 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 + group_id = Nanoid.generate() + + for {user, share} <- [{carver, Decimal.new("0.5")}, {aly, Decimal.new("0.3")}, {big_head, Decimal.new("0.2")}] 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}" }) - {:ok, :ok} - end) + # 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: Money.mult!(amount, share), + 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: Money.mult!(amount, share), + user_id: user.id, + succeeded_at: claim.inserted_at + }) + + {:ok, :ok} + end) + end end end end end -big_head = - upsert!( - :user, - [:email], - %{ - email: "bighead@example.com", - display_name: "Nelson Bighetti", - handle: "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"], - country: "IT", - hourly_rate_min: Money.new!(150, :USD), - hourly_rate_max: Money.new!(200, :USD), - hours_per_week: 25 - } - ) - -jian_yang = - upsert!( - :user, - [:email], - %{ - email: "jianyang@example.com", - display_name: "Jian Yang", - handle: "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"], - country: "HK", - hourly_rate_min: Money.new!(125, :USD), - hourly_rate_max: Money.new!(175, :USD), - hours_per_week: 35 - } - ) - -john = - upsert!( - :user, - [:email], - %{ - email: "john@example.com", - display_name: "John Stafford", - handle: "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"], - country: "GB", - hourly_rate_min: Money.new!(140, :USD), - hourly_rate_max: Money.new!(190, :USD), - hours_per_week: 40 - } - ) - -aly = - upsert!( - :user, - [:email], - %{ - email: "aly@example.com", - display_name: "Aly Dutta", - handle: "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"], - country: "IN", - hourly_rate_min: Money.new!(160, :USD), - hourly_rate_max: Money.new!(220, :USD), - hours_per_week: 35 - } - ) - -for user <- [aly, big_head, jian_yang, john] do - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() - amount = Money.new!(Enum.random(1..10) * 10_000, :USD) - - Repo.transact(fn -> - insert!(:transaction, %{ - id: debit_id, - linked_transaction_id: credit_id, - type: :debit, - status: :succeeded, - net_amount: amount, - user_id: pied_piper.id, - succeeded_at: days_from_now(0) - }) - - insert!(:transaction, %{ - id: credit_id, - linked_transaction_id: debit_id, - type: :credit, - status: :succeeded, - net_amount: amount, - user_id: user.id, - succeeded_at: days_from_now(0) - }) - - {:ok, :ok} - 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."}, From 96243edd4e3474674a55fc675b373d820cc4162f Mon Sep 17 00:00:00 2001 From: zafer Date: Thu, 16 Jan 2025 20:15:39 +0300 Subject: [PATCH 15/34] update claim page to display co-authors --- lib/algora_web/live/claim_live.ex | 61 ++++++++++++++++++------------- lib/algora_web/router.ex | 2 +- 2 files changed, 36 insertions(+), 27 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index ca975baec..119282b9a 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -2,39 +2,44 @@ defmodule AlgoraWeb.ClaimLive do @moduledoc false use AlgoraWeb, :live_view + import Ecto.Query + alias Algora.Bounties.Claim alias Algora.Github alias Algora.Repo @impl true - def mount(%{"id" => id}, _session, socket) do - {:ok, claim} = Repo.fetch(Claim, id) - - claim = - Repo.preload(claim, [ + 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, source: [repository: [:user]], target: [repository: [:user], bounties: [:owner]] ]) - {:ok, prize_pool} = claim.target.bounties |> Enum.map(& &1.amount) |> Money.sum() + [primary_claim | _] = claims + + {:ok, prize_pool} = primary_claim.target.bounties |> Enum.map(& &1.amount) |> Money.sum() source_body_html = with token when is_binary(token) <- Github.TokenPool.get_token(), - {:ok, source_body_html} <- Github.render_markdown(token, claim.source.description) do + {:ok, source_body_html} <- Github.render_markdown(token, primary_claim.source.description) do source_body_html else - _ -> claim.source.description + _ -> primary_claim.source.description end {:ok, socket - |> assign(:page_title, "Claim Details") - |> assign(:claim, claim) - |> assign(:target, claim.target) - |> assign(:source, claim.source) - |> assign(:user, claim.user) - |> assign(:bounties, claim.target.bounties) + |> assign(:page_title, primary_claim.source.title) + |> assign(:claims, claims) + |> assign(:primary_claim, primary_claim) + |> assign(:target, primary_claim.target) + |> assign(:source, primary_claim.source) + |> assign(:bounties, primary_claim.target.bounties) |> assign(:prize_pool, prize_pool) |> assign(:source_body_html, source_body_html)} end @@ -76,15 +81,19 @@ defmodule AlgoraWeb.ClaimLive do <%!-- Claimer Info --%> <.card> <.card_header> -
- <.avatar> - <.avatar_image src={@user.avatar_url} /> - <.avatar_fallback>{String.first(@user.name)} - -
-

{@user.name}

-

@{@user.handle}

-
+
+ <%= 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}

+
+
+ <% end %>
<.card_content> @@ -126,15 +135,15 @@ defmodule AlgoraWeb.ClaimLive do
Status - {@claim.status |> to_string() |> String.capitalize()} + {@primary_claim.status |> to_string() |> String.capitalize()}
Submitted - {Calendar.strftime(@claim.inserted_at, "%B %d, %Y")} + {Calendar.strftime(@primary_claim.inserted_at, "%B %d, %Y")}
Last Updated - {Calendar.strftime(@claim.updated_at, "%B %d, %Y")} + {Calendar.strftime(@primary_claim.updated_at, "%B %d, %Y")}
diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 933989ca9..488e3462e 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -141,7 +141,7 @@ defmodule AlgoraWeb.Router do live "/open-source", OpenSourceLive, :index - live "/claims/:id", ClaimLive + live "/claims/:group_id", ClaimLive end # Other scopes may use custom stacks. From 0c8f1b07e5d816018f63b39d0001590216cce22e Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 13:42:02 +0300 Subject: [PATCH 16/34] display paid amount on claim page --- lib/algora/bounties/schemas/claim.ex | 2 +- lib/algora/payments/schemas/transaction.ex | 2 +- lib/algora_web/components/ui/stat_card.ex | 9 ++- lib/algora_web/live/claim_live.ex | 70 ++++++++++--------- .../20250112164132_recreate_claims.exs | 8 +++ priv/repo/seeds.exs | 2 + 6 files changed, 56 insertions(+), 37 deletions(-) diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex index ba012e2c4..7043ad0e8 100644 --- a/lib/algora/bounties/schemas/claim.ex +++ b/lib/algora/bounties/schemas/claim.ex @@ -15,7 +15,7 @@ defmodule Algora.Bounties.Claim do belongs_to :source, Ticket belongs_to :target, Ticket, null: false belongs_to :user, Algora.Accounts.User, null: false - # has_one :transaction, Algora.Payments.Transaction + has_many :transactions, Algora.Payments.Transaction timestamps() end 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_web/components/ui/stat_card.ex b/lib/algora_web/components/ui/stat_card.ex index 174c209ac..c94ccfa0d 100644 --- a/lib/algora_web/components/ui/stat_card.ex +++ b/lib/algora_web/components/ui/stat_card.ex @@ -24,13 +24,18 @@ 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}
+
{@value}

{@subtext}

diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 119282b9a..41b2cca42 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -16,13 +16,23 @@ defmodule AlgoraWeb.ClaimLive do |> Repo.all() |> Repo.preload([ :user, + :transactions, source: [repository: [:user]], target: [repository: [:user], bounties: [:owner]] ]) [primary_claim | _] = claims - {:ok, prize_pool} = primary_claim.target.bounties |> Enum.map(& &1.amount) |> Money.sum() + prize_pool = + primary_claim.target.bounties + |> Enum.map(& &1.amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + + total_paid = + primary_claim.transactions + |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded)) + |> Enum.map(& &1.net_amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) source_body_html = with token when is_binary(token) <- Github.TokenPool.get_token(), @@ -41,6 +51,7 @@ defmodule AlgoraWeb.ClaimLive do |> assign(:source, primary_claim.source) |> assign(:bounties, primary_claim.target.bounties) |> assign(:prize_pool, prize_pool) + |> assign(:total_paid, total_paid) |> assign(:source_body_html, source_body_html)} end @@ -49,36 +60,37 @@ defmodule AlgoraWeb.ClaimLive do ~H"""
- <%!-- Header with target issue and prize pool --%> <.header class="mb-8"> -
- <.avatar class="h-16 w-16 rounded-full"> - <.avatar_image src={@source.repository.user.avatar_url} /> - <.avatar_fallback> - {String.first(@source.repository.user.provider_login)} - - -
- <.link href={@target.url} class="text-xl font-semibold hover:underline" target="_blank"> - {@target.title} - -
- {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
+
+ <.avatar class="h-16 w-16 rounded-full"> + <.avatar_image src={@source.repository.user.avatar_url} /> + <.avatar_fallback> + {String.first(@source.repository.user.provider_login)} + + +
+ <.link + href={@target.url} + class="text-xl font-semibold hover:underline" + target="_blank" + > + {@target.title} + +
+ {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} +
-
- <:actions> -
- {Money.to_string!(@prize_pool)} +
+ <.stat_card title="Total Paid" value={Money.to_string!(@total_paid)} /> + <.stat_card title="Prize Pool" value={Money.to_string!(@prize_pool)} />
- +
- <%!-- New grid layout with different column widths --%>
- <%!-- Combined Claim Details Card --%>
- <%!-- Claimer Info --%> <.card> <.card_header>
@@ -119,16 +131,11 @@ defmodule AlgoraWeb.ClaimLive do
- <%!-- Right Column: Claim Metadata + Sponsors --%>
- <%!-- Claim Metadata Card --%> <.card> <.card_header> <.card_title> -
- <.icon name="tabler-info-circle" class="h-5 w-5 text-muted-foreground" /> - Claim Info -
+ Claim <.card_content> @@ -149,13 +156,10 @@ defmodule AlgoraWeb.ClaimLive do - <%!-- Sponsors Card --%> <.card> <.card_header> <.card_title> -
- <.icon name="tabler-users" class="h-5 w-5 text-muted-foreground" /> Sponsors -
+ Sponsors <.card_content> diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs index afca14709..acf28265e 100644 --- a/priv/repo/migrations/20250112164132_recreate_claims.exs +++ b/priv/repo/migrations/20250112164132_recreate_claims.exs @@ -20,6 +20,10 @@ defmodule Algora.Repo.Migrations.RecreateClaims do 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]) @@ -33,6 +37,10 @@ defmodule Algora.Repo.Migrations.RecreateClaims do 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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 2be5c1cc4..b344b3e10 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -558,6 +558,7 @@ for {repo_name, issues} <- repos do id: debit_id, linked_transaction_id: credit_id, bounty_id: bounty.id, + claim_id: claim.id, type: :debit, status: :succeeded, net_amount: Money.mult!(amount, share), @@ -569,6 +570,7 @@ for {repo_name, issues} <- repos do id: credit_id, linked_transaction_id: debit_id, bounty_id: bounty.id, + claim_id: claim.id, type: :credit, status: :succeeded, net_amount: Money.mult!(amount, share), From 744269ba7a83bcaa716c3b8d80437b0332a4f1f0 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 13:43:58 +0300 Subject: [PATCH 17/34] handle not found case --- lib/algora_web/live/claim_live.ex | 60 ++++++++++++++++--------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 41b2cca42..1c6b347e9 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -21,38 +21,42 @@ defmodule AlgoraWeb.ClaimLive do target: [repository: [:user], bounties: [:owner]] ]) - [primary_claim | _] = claims + case claims do + [] -> + raise(AlgoraWeb.NotFoundError) - prize_pool = - primary_claim.target.bounties - |> Enum.map(& &1.amount) - |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + [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)) - total_paid = - primary_claim.transactions - |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded)) - |> Enum.map(& &1.net_amount) - |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + total_paid = + primary_claim.transactions + |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded)) + |> Enum.map(& &1.net_amount) + |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) - 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) do - source_body_html - else - _ -> primary_claim.source.description - 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) do + source_body_html + else + _ -> primary_claim.source.description + end - {:ok, - socket - |> assign(:page_title, primary_claim.source.title) - |> assign(:claims, claims) - |> assign(:primary_claim, primary_claim) - |> assign(:target, primary_claim.target) - |> assign(:source, primary_claim.source) - |> assign(:bounties, primary_claim.target.bounties) - |> assign(:prize_pool, prize_pool) - |> assign(:total_paid, total_paid) - |> assign(:source_body_html, source_body_html)} + {:ok, + socket + |> assign(:page_title, primary_claim.source.title) + |> assign(:claims, claims) + |> assign(:primary_claim, primary_claim) + |> assign(:target, primary_claim.target) + |> assign(:source, primary_claim.source) + |> assign(:bounties, primary_claim.target.bounties) + |> assign(:prize_pool, prize_pool) + |> assign(:total_paid, total_paid) + |> assign(:source_body_html, source_body_html)} + end end @impl true From fd671f7f447d0391af3cade1f9239b9e873f3738 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 13:47:51 +0300 Subject: [PATCH 18/34] include all claims when calculating total paid --- lib/algora_web/live/claim_live.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 1c6b347e9..425b2bedd 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -31,9 +31,13 @@ defmodule AlgoraWeb.ClaimLive do |> Enum.map(& &1.amount) |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) + credits = + claims + |> Enum.flat_map(& &1.transactions) + |> Enum.filter(&(&1.type == :credit and &1.status == :succeeded)) + total_paid = - primary_claim.transactions - |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded)) + credits |> Enum.map(& &1.net_amount) |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) From a86dc57eabb77084e261309d7b762dbae2c5737c Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 22:08:51 +0300 Subject: [PATCH 19/34] handle all types of claim payment statuses --- lib/algora_web/components/ui/stat_card.ex | 12 +- lib/algora_web/live/claim_live.ex | 128 +++++++++++++++++++--- priv/repo/seeds.exs | 113 +++++++++---------- 3 files changed, 180 insertions(+), 73 deletions(-) diff --git a/lib/algora_web/components/ui/stat_card.ex b/lib/algora_web/components/ui/stat_card.ex index c94ccfa0d..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 %> @@ -35,7 +37,13 @@ defmodule AlgoraWeb.Components.UI.StatCard do <.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/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 425b2bedd..d531d27c9 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -31,13 +31,13 @@ defmodule AlgoraWeb.ClaimLive do |> Enum.map(& &1.amount) |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) - credits = + debits = claims |> Enum.flat_map(& &1.transactions) - |> Enum.filter(&(&1.type == :credit and &1.status == :succeeded)) + |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded)) total_paid = - credits + debits |> Enum.map(& &1.net_amount) |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2)) @@ -49,6 +49,47 @@ defmodule AlgoraWeb.ClaimLive do _ -> 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) + {:ok, socket |> assign(:page_title, primary_claim.source.title) @@ -59,7 +100,8 @@ defmodule AlgoraWeb.ClaimLive do |> assign(:bounties, primary_claim.target.bounties) |> assign(:prize_pool, prize_pool) |> assign(:total_paid, total_paid) - |> assign(:source_body_html, source_body_html)} + |> assign(:source_body_html, source_body_html) + |> assign(:sponsors, sponsors)} end end @@ -91,8 +133,16 @@ defmodule AlgoraWeb.ClaimLive do
- <.stat_card title="Total Paid" value={Money.to_string!(@total_paid)} /> - <.stat_card title="Prize Pool" value={Money.to_string!(@prize_pool)} /> + <.stat_card title="Total Paid"> +
+ {Money.to_string!(@total_paid)} +
+ + <.stat_card title="Prize Pool"> +
+ {Money.to_string!(@prize_pool)} +
+
@@ -118,7 +168,6 @@ defmodule AlgoraWeb.ClaimLive do <.card_content>
- <%!-- Pull Request Details --%>
<.link href={@source.url} @@ -172,23 +221,72 @@ defmodule AlgoraWeb.ClaimLive do <.card_content>
- <%= for bounty <- Enum.sort_by(@bounties, &{&1.amount, &1.inserted_at}, :desc) do %> + <%= for sponsor <- @sponsors do %>
<.avatar> - <.avatar_image src={bounty.owner.avatar_url} /> + <.avatar_image src={sponsor.sponsor.avatar_url} /> <.avatar_fallback> - {String.first(bounty.owner.name)} + {String.first(sponsor.sponsor.name)}
-

{bounty.owner.name}

-

@{bounty.owner.handle}

+

{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 %>
- <.badge variant="success" class="font-display"> - {Money.to_string!(bounty.amount)} -
<% end %>
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index b344b3e10..8c3a5e074 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -467,33 +467,22 @@ for {repo_name, issues} <- repos do 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: issue.id, - owner_id: pied_piper.id, - creator_id: richard.id, - amount: amount, - status: if(paid, do: :paid, else: :open) - }) - - 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: issue.id, - owner_id: member.id, - creator_id: member.id, - amount: amount, - status: :open - }) - end) + 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 = @@ -535,7 +524,12 @@ for {repo_name, issues} <- repos do group_id = Nanoid.generate() - for {user, share} <- [{carver, Decimal.new("0.5")}, {aly, Decimal.new("0.3")}, {big_head, Decimal.new("0.2")}] do + 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, @@ -548,38 +542,45 @@ for {repo_name, issues} <- repos do 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, - claim_id: claim.id, - type: :debit, - status: :succeeded, - net_amount: Money.mult!(amount, share), - user_id: pied_piper.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: Money.mult!(amount, share), - user_id: user.id, - succeeded_at: claim.inserted_at - }) - - {:ok, :ok} - end) + 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 end end From edc17cd61ad60f2abc1a88f5f50e1f07409c4e01 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 22:14:11 +0300 Subject: [PATCH 20/34] update context in rendered markdown --- lib/algora/integrations/github/behaviour.ex | 2 +- lib/algora/integrations/github/client.ex | 6 ++++-- lib/algora/integrations/github/github.ex | 2 +- lib/algora_web/live/claim_live.ex | 8 +++++++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index 2b0bc2f6e..dae585cbe 100644 --- a/lib/algora/integrations/github/behaviour.ex +++ b/lib/algora/integrations/github/behaviour.ex @@ -20,5 +20,5 @@ defmodule Algora.Github.Behaviour do @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()) :: 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 161ffd7f8..d86355267 100644 --- a/lib/algora/integrations/github/client.ex +++ b/lib/algora/integrations/github/client.ex @@ -189,7 +189,9 @@ defmodule Algora.Github.Client do end @impl true - def render_markdown(access_token, markdown) do - fetch(access_token, "/markdown", "POST", %{text: markdown}, skip_decoding: 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 91d4c8fba..180eca5f3 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -96,5 +96,5 @@ defmodule Algora.Github do def add_labels(token, owner, repo, number, labels), do: client().add_labels(token, owner, repo, number, labels) @impl true - def render_markdown(token, markdown), do: client().render_markdown(token, markdown) + def render_markdown(token, text, opts \\ []), do: client().render_markdown(token, text, opts) end diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index d531d27c9..3972146c9 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -41,9 +41,15 @@ defmodule AlgoraWeb.ClaimLive do |> 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) do + {:ok, source_body_html} <- + Github.render_markdown(token, primary_claim.source.description, context: context) do source_body_html else _ -> primary_claim.source.description From bb67b0bc631b5d3f8a57af6f6e061032431c9128 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 22:35:18 +0300 Subject: [PATCH 21/34] reorganize page --- lib/algora_web/live/claim_live.ex | 37 +++++++++++-------------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 3972146c9..5587b1f7c 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -96,13 +96,16 @@ defmodule AlgoraWeb.ClaimLive do end) |> Enum.sort_by(&{&1.pledged, &1.paid, &1.sponsor.name}, :desc) + source_or_target = primary_claim.source || primary_claim.target + {:ok, socket - |> assign(:page_title, primary_claim.source.title) + |> 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) @@ -119,22 +122,22 @@ defmodule AlgoraWeb.ClaimLive do <.header class="mb-8">
- <.avatar class="h-16 w-16 rounded-full"> - <.avatar_image src={@source.repository.user.avatar_url} /> + <.avatar class="h-12 w-12 rounded-full"> + <.avatar_image src={@source_or_target.repository.user.avatar_url} /> <.avatar_fallback> - {String.first(@source.repository.user.provider_login)} + {String.first(@source_or_target.repository.user.provider_login)} -
+
<.link - href={@target.url} + href={@source_or_target.url} class="text-xl font-semibold hover:underline" target="_blank" > - {@target.title} + {@source_or_target.title}
- {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} + {@source_or_target.repository.user.provider_login}/{@source_or_target.repository.name}#{@source_or_target.number}
@@ -173,22 +176,8 @@ defmodule AlgoraWeb.ClaimLive do
<.card_content> -
-
- <.link - href={@source.url} - class="text-lg font-semibold hover:underline" - target="_blank" - > - {@source.title} - -
- {@source.repository.user.provider_login}/{@source.repository.name}#{@source.number} -
-
- {Phoenix.HTML.raw(@source_body_html)} -
-
+
+ {Phoenix.HTML.raw(@source_body_html)}
From b5e013480e79472367ab5bd6ca7d493799e8ecee Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 22:55:20 +0300 Subject: [PATCH 22/34] reorganize more --- lib/algora_web/live/claim_live.ex | 327 ++++++++++++++++-------------- 1 file changed, 175 insertions(+), 152 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 5587b1f7c..aa70ef9c5 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -7,6 +7,7 @@ defmodule AlgoraWeb.ClaimLive do alias Algora.Bounties.Claim alias Algora.Github alias Algora.Repo + alias Algora.Util @impl true def mount(%{"group_id" => group_id}, _session, socket) do @@ -118,176 +119,198 @@ defmodule AlgoraWeb.ClaimLive do def render(assigns) do ~H"""
-
- <.header class="mb-8"> -
-
- <.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> + <.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} +
-
-
- <.stat_card title="Total Paid"> -
- {Money.to_string!(@total_paid)} -
- - <.stat_card title="Prize Pool"> -
- {Money.to_string!(@prize_pool)} -
- -
-
- - -
-
- <.card> - <.card_header> -
- <%= 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}

-
-
- <% end %> -
- - <.card_content> -
- {Phoenix.HTML.raw(@source_body_html)} -
- - -
+ + <.card_content> +
+ {Phoenix.HTML.raw(@source_body_html)} +
+ + +
-
- <.card> - <.card_header> +
+ <.card> + <.card_header> +
<.card_title> Claim - - <.card_content> -
-
- 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")} -
+ <.button> + 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> + <.card_header> +
<.card_title> - Sponsors + Authors - - <.card_content> -
- <%= for sponsor <- @sponsors do %> -
+ <.button variant="secondary"> + Split bounty + +
+ + <.card_content> +
+ <%= for claim <- @claims do %> +
+
<.avatar> - <.avatar_image src={sponsor.sponsor.avatar_url} /> - <.avatar_fallback> - {String.first(sponsor.sponsor.name)} - + <.avatar_image src={claim.user.avatar_url} /> + <.avatar_fallback>{String.first(claim.user.name)}
-

{sponsor.sponsor.name}

-

@{sponsor.sponsor.handle}

+

{claim.user.name}

+

@{claim.user.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 %> -
+ + + + {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}

- <% end %> -
- - -
+
+
+ <%= 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 %> +
+ +
From 1410b544764fea52f4a05e65ecf151fbb36cee41 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 17 Jan 2025 22:59:05 +0300 Subject: [PATCH 23/34] update styling --- lib/algora_web/live/claim_live.ex | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index aa70ef9c5..39d707c2c 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -255,51 +255,51 @@ defmodule AlgoraWeb.ClaimLive do
-
+
<%= 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)}
From e0efa61a7a8d5e3ab0d34c7a76aeed3d882e547a Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 18 Jan 2025 20:37:23 +0300 Subject: [PATCH 24/34] impl create_payment_session --- lib/algora/bounties/bounties.ex | 96 ++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 24 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index ae6149397..e7be048b2 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -245,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, @@ -255,6 +253,39 @@ defmodule Algora.Bounties do recipient_id: recipient.id }) + Repo.transact(fn -> + with {:ok, tip} <- Repo.insert(changeset) do + create_payment_session( + %{ + creator: creator, + owner: owner, + recipient: recipient, + amount: amount, + description: "Tip payment for OSS contributions" + }, + ticket_ref: opts[:ticket_ref], + tip_id: tip.id + ) + end + end) + end + + @spec create_payment_session( + %{creator: User.t(), owner: User.t(), recipient: 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(), + claim_id: String.t() + ] + ) :: + {:ok, String.t()} | {:error, atom()} + def create_payment_session( + %{creator: creator, owner: owner, recipient: recipient, amount: amount, description: description}, + opts \\ [] + ) do + ticket_ref = opts[:ticket_ref] + # Initialize transaction IDs charge_id = Nanoid.generate() debit_id = Nanoid.generate() @@ -279,12 +310,7 @@ defmodule Algora.Bounties do 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}" - ), + description: if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"), images: [recipient.avatar_url] } }, @@ -309,11 +335,12 @@ defmodule Algora.Bounties do ] Repo.transact(fn -> - with {:ok, tip} <- Repo.insert(changeset), - {:ok, _charge} <- + with {:ok, _charge} <- initialize_charge(%{ id: charge_id, - tip: tip, + tip_id: opts[:tip_id], + bounty_id: opts[:bounty_id], + claim_id: opts[:claim_id], user_id: creator.id, gross_amount: gross_amount, net_amount: amount, @@ -324,7 +351,9 @@ defmodule Algora.Bounties do {:ok, _debit} <- initialize_debit(%{ id: debit_id, - tip: tip, + tip_id: opts[:tip_id], + bounty_id: opts[:bounty_id], + claim_id: opts[:claim_id], amount: amount, user_id: creator.id, linked_transaction_id: credit_id, @@ -333,7 +362,9 @@ defmodule Algora.Bounties do {:ok, _credit} <- initialize_credit(%{ id: credit_id, - tip: tip, + tip_id: opts[:tip_id], + bounty_id: opts[:bounty_id], + claim_id: opts[:claim_id], amount: amount, user_id: recipient.id, linked_transaction_id: debit_id, @@ -341,8 +372,7 @@ defmodule Algora.Bounties do }), {:ok, session} <- Payments.create_stripe_session(line_items, %{ - # Mandatory for some countries like India - description: "Tip payment for OSS contributions", + description: description, metadata: %{"version" => "2", "group_id" => tx_group_id} }) do {:ok, session.url} @@ -352,7 +382,9 @@ defmodule Algora.Bounties do defp initialize_charge(%{ id: id, - tip: tip, + tip_id: tip_id, + bounty_id: bounty_id, + claim_id: claim_id, user_id: user_id, gross_amount: gross_amount, net_amount: net_amount, @@ -366,7 +398,9 @@ defmodule Algora.Bounties do provider: "stripe", type: :charge, status: :initialized, - tip_id: tip.id, + tip_id: tip_id, + bounty_id: bounty_id, + claim_id: claim_id, user_id: user_id, gross_amount: gross_amount, net_amount: net_amount, @@ -377,14 +411,18 @@ defmodule Algora.Bounties do |> 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, @@ -396,7 +434,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, @@ -406,14 +446,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, @@ -425,7 +469,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, @@ -435,8 +481,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 From ee945e28be3b6ef56f0de980637d8e6091b716a7 Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 18 Jan 2025 20:44:29 +0300 Subject: [PATCH 25/34] in midst of implementing reward_bounty --- lib/algora/bounties/bounties.ex | 31 +++++++++++++++++++++++++++++ lib/algora_web/live/claim_live.ex | 33 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index e7be048b2..34dc05a94 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -270,6 +270,37 @@ defmodule Algora.Bounties do end) end + @spec reward_bounty( + %{ + creator: User.t(), + owner: User.t(), + recipient: User.t(), + amount: Money.t(), + bounty_id: String.t(), + claim_id: String.t() + }, + opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}] + ) :: + {:ok, String.t()} | {:error, atom()} + def reward_bounty( + %{creator: creator, owner: owner, recipient: recipient, amount: amount, bounty_id: bounty_id, claim_id: claim_id}, + opts \\ [] + ) do + # TODO: handle bounty splits + create_payment_session( + %{ + creator: creator, + owner: owner, + recipient: recipient, + amount: amount, + description: "Bounty payment for OSS contributions" + }, + ticket_ref: opts[:ticket_ref], + bounty_id: bounty_id, + claim_id: claim_id + ) + end + @spec create_payment_session( %{creator: User.t(), owner: User.t(), recipient: User.t(), amount: Money.t(), description: String.t()}, opts :: [ diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 39d707c2c..d23dbbb8c 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -115,6 +115,37 @@ defmodule AlgoraWeb.ClaimLive do end end + @impl true + def handle_event("reward_bounty", _params, socket) do + claim = socket.assigns.primary_claim + + # TODO: use the correct bounty + bounty = hd(claim.target.bounties) + + case Algora.Bounties.reward_bounty( + %{ + # TODO: handle unauthenticated user + creator: socket.assigns.current_user, + owner: bounty.owner, + recipient: claim.user, + amount: bounty.amount, + bounty_id: bounty.id, + claim_id: claim.id + }, + ticket_ref: %{ + owner: claim.target.repository.user.provider_login, + repo: claim.target.repository.name, + number: claim.target.number + } + ) do + {:ok, session_url} -> + {:noreply, redirect(socket, external: session_url)} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Failed to create payment session. Please try again later.")} + end + end + @impl true def render(assigns) do ~H""" @@ -159,7 +190,7 @@ defmodule AlgoraWeb.ClaimLive do <.card_title> Claim - <.button> + <.button phx-click="reward_bounty"> Reward bounty
From 9b5200882976978ab91cd95a7df416396ea008ef Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 18 Jan 2025 21:52:28 +0300 Subject: [PATCH 26/34] handle splits --- lib/algora/bounties/bounties.ex | 205 +++++++++++++++++------------- lib/algora_web/live/claim_live.ex | 4 +- lib/algora_web/router.ex | 3 +- priv/repo/seeds.exs | 11 ++ 4 files changed, 132 insertions(+), 91 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 34dc05a94..d79249c59 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -256,15 +256,10 @@ defmodule Algora.Bounties do Repo.transact(fn -> with {:ok, tip} <- Repo.insert(changeset) do create_payment_session( - %{ - creator: creator, - owner: owner, - recipient: recipient, - amount: amount, - description: "Tip payment for OSS contributions" - }, + %{creator: creator, amount: amount, description: "Tip payment for OSS contributions"}, ticket_ref: opts[:ticket_ref], - tip_id: tip.id + tip_id: tip.id, + recipient: recipient ) end end) @@ -273,60 +268,43 @@ defmodule Algora.Bounties do @spec reward_bounty( %{ creator: User.t(), - owner: User.t(), - recipient: User.t(), amount: Money.t(), bounty_id: String.t(), - claim_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( - %{creator: creator, owner: owner, recipient: recipient, amount: amount, bounty_id: bounty_id, claim_id: claim_id}, - opts \\ [] - ) do - # TODO: handle bounty splits + def reward_bounty(%{creator: creator, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do create_payment_session( - %{ - creator: creator, - owner: owner, - recipient: recipient, - amount: amount, - description: "Bounty payment for OSS contributions" - }, + %{creator: creator, amount: amount, description: "Bounty payment for OSS contributions"}, ticket_ref: opts[:ticket_ref], bounty_id: bounty_id, - claim_id: claim_id + claims: claims ) end @spec create_payment_session( - %{creator: User.t(), owner: User.t(), recipient: User.t(), amount: Money.t(), description: String.t()}, + %{creator: 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(), - claim_id: String.t() + claims: [Claim.t()], + recipient: User.t() ] ) :: {:ok, String.t()} | {:error, atom()} - def create_payment_session( - %{creator: creator, owner: owner, recipient: recipient, amount: amount, description: description}, - opts \\ [] - ) do + def create_payment_session(%{creator: creator, amount: amount, description: description}, opts \\ []) do ticket_ref = opts[:ticket_ref] + recipient = opts[:recipient] + claims = opts[:claims] - # Initialize transaction IDs - charge_id = Nanoid.generate() - debit_id = Nanoid.generate() - credit_id = Nanoid.generate() tx_group_id = Nanoid.generate() # 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) + 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) @@ -334,44 +312,66 @@ defmodule Algora.Bounties do 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}", - description: if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"), - images: [recipient.avatar_url] + line_items = + if recipient do + [ + %{ + price_data: %{ + unit_amount: MoneyUtils.to_minor_units(amount), + currency: currency, + product_data: %{ + name: "Payment to @#{recipient.provider_login}", + description: if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"), + images: [recipient.avatar_url] + } + }, + quantity: 1 } - }, - 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)})"} - }, - quantity: 1 - } - ] + ] + else + [] + end ++ + Enum.map(claims, fn claim -> + %{ + price_data: %{ + # TODO: ensure shares are normalized + unit_amount: amount |> Money.mult!(claim.group_share) |> MoneyUtils.to_minor_units(), + currency: currency, + product_data: %{ + name: "Payment to @#{claim.user.provider_login}", + description: if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"), + images: [claim.user.avatar_url] + } + }, + quantity: 1 + } + end) ++ + [ + %{ + 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)})"} + }, + quantity: 1 + } + ] Repo.transact(fn -> with {:ok, _charge} <- initialize_charge(%{ - id: charge_id, + id: Nanoid.generate(), tip_id: opts[:tip_id], bounty_id: opts[:bounty_id], - claim_id: opts[:claim_id], + claim_id: nil, user_id: creator.id, gross_amount: gross_amount, net_amount: amount, @@ -379,26 +379,13 @@ defmodule Algora.Bounties do line_items: line_items, group_id: tx_group_id }), - {:ok, _debit} <- - initialize_debit(%{ - id: debit_id, - tip_id: opts[:tip_id], - bounty_id: opts[:bounty_id], - claim_id: opts[:claim_id], - amount: amount, - user_id: creator.id, - linked_transaction_id: credit_id, - group_id: tx_group_id - }), - {:ok, _credit} <- - initialize_credit(%{ - id: credit_id, + {:ok, _transactions} <- + create_transaction_pairs(%{ + claims: opts[:claims] || [], tip_id: opts[:tip_id], bounty_id: opts[:bounty_id], - claim_id: opts[:claim_id], amount: amount, - user_id: recipient.id, - linked_transaction_id: debit_id, + creator_id: creator.id, group_id: tx_group_id }), {:ok, session} <- @@ -415,7 +402,6 @@ defmodule Algora.Bounties do id: id, tip_id: tip_id, bounty_id: bounty_id, - claim_id: claim_id, user_id: user_id, gross_amount: gross_amount, net_amount: net_amount, @@ -431,7 +417,6 @@ defmodule Algora.Bounties do status: :initialized, tip_id: tip_id, bounty_id: bounty_id, - claim_id: claim_id, user_id: user_id, gross_amount: gross_amount, net_amount: net_amount, @@ -643,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_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index d23dbbb8c..5d65fa11a 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -126,11 +126,9 @@ defmodule AlgoraWeb.ClaimLive do %{ # TODO: handle unauthenticated user creator: socket.assigns.current_user, - owner: bounty.owner, - recipient: claim.user, amount: bounty.amount, bounty_id: bounty.id, - claim_id: claim.id + claims: socket.assigns.claims }, ticket_ref: %{ owner: claim.target.repository.user.provider_login, diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 488e3462e..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 @@ -140,8 +141,6 @@ defmodule AlgoraWeb.Router do live "/trotw", TROTWLive live "/open-source", OpenSourceLive, :index - - live "/claims/:group_id", ClaimLive end # Other scopes may use custom stacks. diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 8c3a5e074..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", @@ -321,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"], @@ -339,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"], @@ -357,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"], @@ -375,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"], @@ -582,6 +591,8 @@ for {repo_name, issues} <- repos do end) end end + + Logger.info("Claim [#{claim.status}]: #{AlgoraWeb.Endpoint.url()}/claims/#{claim.group_id}") end end end From e1c0c6024d8461fc555e61767a888f57d97f6faa Mon Sep 17 00:00:00 2001 From: zafer Date: Sat, 18 Jan 2025 22:15:28 +0300 Subject: [PATCH 27/34] miscellanea --- lib/algora_web/live/claim_live.ex | 54 ++++++++++++++++++------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 5d65fa11a..ba87595dc 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -116,31 +116,41 @@ defmodule AlgoraWeb.ClaimLive do end @impl true - def handle_event("reward_bounty", _params, socket) do - claim = socket.assigns.primary_claim + 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, %{assigns: %{current_user: current_user, target: target}} = socket) do + user_org_ids = MapSet.new([current_user | Algora.Organizations.get_user_orgs(current_user)], & &1.id) - # TODO: use the correct bounty - bounty = hd(claim.target.bounties) + # TODO: allow user to choose if multiple bounties are available + case Enum.find(target.bounties, &MapSet.member?(user_org_ids, &1.owner_id)) do + nil -> + # TODO: allow user to add a bounty + {:noreply, put_flash(socket, :error, "You are not authorized to reward this bounty")} - case Algora.Bounties.reward_bounty( - %{ - # TODO: handle unauthenticated user - creator: socket.assigns.current_user, - amount: bounty.amount, - bounty_id: bounty.id, - claims: socket.assigns.claims - }, - ticket_ref: %{ - owner: claim.target.repository.user.provider_login, - repo: claim.target.repository.name, - number: claim.target.number - } - ) do - {:ok, session_url} -> - {:noreply, redirect(socket, external: session_url)} + bounty -> + case Algora.Bounties.reward_bounty( + %{ + creator: current_user, + # TODO: allow user to choose amount + amount: bounty.amount, + bounty_id: bounty.id, + claims: socket.assigns.claims + }, + ticket_ref: %{ + owner: target.repository.user.provider_login, + repo: target.repository.name, + number: target.number + } + ) do + {:ok, session_url} -> + {:noreply, redirect(socket, external: session_url)} - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to create payment session. Please try again later.")} + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Failed to create payment session. Please try again later.")} + end end end From d0fbaabe302cb29dfc74e743dbff44a796328483 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 19 Jan 2025 21:03:00 +0300 Subject: [PATCH 28/34] add reward bounty drawer --- lib/algora/bounties/bounties.ex | 169 ++++++---- lib/algora_web/components/core_components.ex | 20 +- lib/algora_web/components/ui/drawer.ex | 2 +- lib/algora_web/components/ui/radio_group.ex | 103 ++----- lib/algora_web/live/claim_live.ex | 307 ++++++++++++++++++- 5 files changed, 435 insertions(+), 166 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index d79249c59..9f5544de9 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -284,86 +284,123 @@ defmodule Algora.Bounties do ) end - @spec create_payment_session( - %{creator: User.t(), amount: Money.t(), description: String.t()}, + # TODO: move to separate module + defmodule LineItem do + @moduledoc false + defstruct [:amount, :title, :description, :image, :type] + + @type t :: %__MODULE__{ + amount: Money.t(), + title: String.t(), + description: String.t() | nil, + image: String.t() | nil, + type: :payment | :fee + } + + 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: %{ + name: line_item.title, + description: line_item.description, + images: if(line_item.image, do: [line_item.image]) + } + }, + 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 + + @spec generate_line_items( + %{amount: Money.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(%{creator: creator, amount: amount, description: description}, opts \\ []) do + [LineItem.t()] + def generate_line_items(%{amount: amount}, opts \\ []) do ticket_ref = opts[:ticket_ref] recipient = opts[:recipient] claims = opts[:claims] - tx_group_id = Nanoid.generate() + description = if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}") - # Calculate fees - currency = to_string(amount.currency) 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) + if recipient do + [ + %LineItem{ + amount: amount, + title: "Payment to @#{recipient.provider_login}", + description: description, + image: recipient.avatar_url, + type: :payment + } + ] + 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: :payment + } + end) ++ + [ + %LineItem{ + amount: Money.mult!(amount, platform_fee_pct), + title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})", + type: :fee + }, + %LineItem{ + amount: Money.mult!(amount, transaction_fee_pct), + title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})", + type: :fee + } + ] + end + + @spec create_payment_session( + %{creator: 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(%{creator: creator, amount: amount, description: description}, opts \\ []) do + tx_group_id = Nanoid.generate() line_items = - if recipient do - [ - %{ - price_data: %{ - unit_amount: MoneyUtils.to_minor_units(amount), - currency: currency, - product_data: %{ - name: "Payment to @#{recipient.provider_login}", - description: if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"), - images: [recipient.avatar_url] - } - }, - quantity: 1 - } - ] - else - [] - end ++ - Enum.map(claims, fn claim -> - %{ - price_data: %{ - # TODO: ensure shares are normalized - unit_amount: amount |> Money.mult!(claim.group_share) |> MoneyUtils.to_minor_units(), - currency: currency, - product_data: %{ - name: "Payment to @#{claim.user.provider_login}", - description: if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"), - images: [claim.user.avatar_url] - } - }, - quantity: 1 - } - end) ++ - [ - %{ - 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)})"} - }, - quantity: 1 - } - ] + 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, _charge} <- @@ -375,7 +412,7 @@ defmodule Algora.Bounties do user_id: creator.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 }), @@ -389,7 +426,7 @@ defmodule Algora.Bounties do group_id: tx_group_id }), {:ok, session} <- - Payments.create_stripe_session(line_items, %{ + Payments.create_stripe_session(LineItem.to_stripe(line_items), %{ description: description, metadata: %{"version" => "2", "group_id" => tx_group_id} }) do diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index c8f613ca7..a42d34b10 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 @@ -238,11 +240,11 @@ defmodule AlgoraWeb.CoreComponents do ~H"""
-
+
@@ -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)} @@ -857,8 +860,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 +1261,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 +1310,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..884351914 100644 --- a/lib/algora_web/components/ui/radio_group.ex +++ b/lib/algora_web/components/ui/radio_group.ex @@ -2,93 +2,48 @@ 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""" - - """ - end end diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index ba87595dc..e89cddfbf 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -2,12 +2,47 @@ 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.Github alias Algora.Repo + alias Algora.Types.USD alias Algora.Util + alias Algora.Validations + + defp tip_options do + [ + {"10%", 10}, + {"20%", 20}, + {"50%", 50}, + {"None", 0} + ] + end + + defmodule RewardBountyForm do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @primary_key false + embedded_schema do + field :owner_id, :string + field :amount, USD + field :tip_percentage, :integer + end + + def changeset(form, attrs) do + form + |> cast(attrs, [:owner_id, :amount, :tip_percentage]) + |> validate_required([:owner_id, :amount, :tip_percentage]) + |> validate_number(:tip_percentage, greater_than_or_equal_to: 0) + |> Validations.validate_money_positive(:amount) + end + end @impl true def mount(%{"group_id" => group_id}, _session, socket) do @@ -99,6 +134,21 @@ defmodule AlgoraWeb.ClaimLive do source_or_target = primary_claim.source || primary_claim.target + contexts = + if socket.assigns.current_user do + Algora.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)) + + changeset = + %RewardBountyForm{} + |> RewardBountyForm.changeset(%{tip_percentage: 0}) + |> maybe_set_amount(available_bounties) + {:ok, socket |> assign(:page_title, source_or_target.title) @@ -111,49 +161,153 @@ defmodule AlgoraWeb.ClaimLive do |> assign(:prize_pool, prize_pool) |> assign(:total_paid, total_paid) |> assign(:source_body_html, source_body_html) - |> assign(:sponsors, sponsors)} + |> assign(:sponsors, sponsors) + |> assign(:contexts, contexts) + |> assign(:show_reward_bounty_modal, true) + |> assign(:available_bounties, available_bounties) + |> assign(:reward_bounty_form, to_form(changeset))} end end + @impl true + def handle_params(%{"context" => context_id}, _url, socket) do + line_items = + if amount = get_field(socket.assigns.reward_bounty_form.source, :amount) do + Bounties.generate_line_items(%{amount: amount}, + 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 + ) + else + [] + end + + {:noreply, socket |> assign_selected_context(context_id) |> assign(:line_items, line_items)} + end + + def handle_params(_params, _url, socket) do + {:noreply, assign_selected_context(socket)} + 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, %{assigns: %{current_user: current_user, target: target}} = socket) do - user_org_ids = MapSet.new([current_user | Algora.Organizations.get_user_orgs(current_user)], & &1.id) + 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 + case %RewardBountyForm{} + |> RewardBountyForm.changeset(params) + |> apply_action(:validate) do + {:ok, data} -> + dbg(data) + + line_items = + Bounties.generate_line_items(%{amount: data.amount}, + 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 + ) + + {:noreply, assign(socket, :line_items, line_items)} + + {:error, changeset} -> + {:noreply, assign(socket, :reward_bounty_form, to_form(changeset))} + end + end - # TODO: allow user to choose if multiple bounties are available - case Enum.find(target.bounties, &MapSet.member?(user_org_ids, &1.owner_id)) do - nil -> - # TODO: allow user to add a bounty - {:noreply, put_flash(socket, :error, "You are not authorized to reward this bounty")} + def handle_event("save_reward_bounty", params, socket) do + case %RewardBountyForm{} + |> RewardBountyForm.changeset(params) + |> apply_action(:save) do + {:ok, data} -> + bounty = get_or_create_bounty(socket, data) + final_amount = calculate_final_amount(data.amount || bounty.amount, data.tip_percentage) - bounty -> case Algora.Bounties.reward_bounty( %{ - creator: current_user, - # TODO: allow user to choose amount - amount: bounty.amount, + creator: socket.assigns.current_user, + amount: final_amount, bounty_id: bounty.id, claims: socket.assigns.claims }, ticket_ref: %{ - owner: target.repository.user.provider_login, - repo: target.repository.name, - number: target.number + owner: socket.assigns.target.repository.user.provider_login, + repo: socket.assigns.target.repository.name, + number: socket.assigns.target.number } ) do {:ok, session_url} -> {:noreply, redirect(socket, external: session_url)} - {:error, _reason} -> - {:noreply, put_flash(socket, :error, "Failed to create payment session. Please try again later.")} + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Failed to create payment session: #{inspect(reason)}")} end + + {:error, changeset} -> + {:noreply, assign(socket, :reward_bounty_form, to_form(changeset))} end end + defp assign_selected_context(socket, context_id \\ nil) + + defp assign_selected_context(socket, nil) do + context_id = + case List.first(socket.assigns.available_bounties) do + nil -> socket.assigns.current_user.id + bounty -> bounty.owner_id + end + + assign_selected_context(socket, context_id) + end + + defp assign_selected_context(socket, context_id) do + changeset = put_change(socket.assigns.reward_bounty_form.source, :owner_id, context_id) + + context = Enum.find(socket.assigns.contexts, &(&1.id == get_field(changeset, :owner_id))) + + socket + |> assign(:reward_bounty_form, to_form(changeset)) + |> assign(:selected_context, context) + end + + defp maybe_set_amount(changeset, [bounty | _]) do + put_change(changeset, :amount, bounty.amount) + end + + defp maybe_set_amount(changeset, _), do: changeset + + defp get_or_create_bounty(socket, %{owner_id: nil} = data) do + # TODO: Create new bounty logic here + Bounties.create_bounty(%{ + owner: socket.assigns.current_user, + amount: data.amount, + target: socket.assigns.target + }) + end + + defp get_or_create_bounty(socket, data) do + Enum.find(socket.assigns.available_bounties, &(&1.owner_id == data.owner_id)) + end + + defp calculate_final_amount(base_amount, tip_percentage) do + base_amount * (100 + tip_percentage) / 100 + end + @impl true def render(assigns) do ~H""" @@ -353,6 +507,125 @@ defmodule AlgoraWeb.ClaimLive do
+ <.drawer show={@show_reward_bounty_modal} on_cancel="close_drawer"> + <.drawer_header> + <.drawer_title>Reward Bounty + + <.drawer_content class="mt-4"> +
+
+ <.form for={@reward_bounty_form} phx-submit="validate_reward_bounty"> +
+ <%= if Enum.empty?(@available_bounties) do %> +
+ <.alert variant="destructive"> + <.alert_title>No bounties available + <.alert_description> + You don't have any bounties available. Would you like to create one? + + + +
+ <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_bounty_form[:amount]} + /> +
+
+ <% else %> +
+ <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_bounty_form[:amount]} + /> +
+ <% end %> + +
+ <.label>On behalf of + <.dropdown2 id="context-dropdown" class="mt-2"> + <:img src={@selected_context.avatar_url} /> + <:title>{@selected_context.name} + + <:link + :for={context <- @contexts |> Enum.reject(&(&1.id == @selected_context.id))} + patch={"?context=#{context.id}"} + > +
+ {context.name} +
+
{context.name}
+
@{context.handle}
+
+
+ + +
+ +
+ <.label>Tip Amount +
+ <.radio_group + class="grid grid-cols-4 gap-4" + field={@reward_bounty_form[:tip_percentage]} + options={tip_options()} + /> +
+
+
+ +
+
+ <.card class="mt-1"> + <.card_header> + <.card_title>Payment Summary + + <.card_content> +
+ <%= for line_item <- @line_items do %> +
+
+ <.avatar :if={line_item.image}> + <.avatar_image src={line_item.image} /> + +
+
{line_item.title}
+
{line_item.description}
+
+
+
+ {Money.to_string!(line_item.amount)} +
+
+ <% end %> +
+
+
Total Due
+
+ {Bounties.LineItem.gross_amount(@line_items)} +
+
+
+ + +
+ <.button variant="secondary" type="button"> + Cancel + + <.button type="submit"> + <.icon name="tabler-brand-stripe" class="-ml-1 mr-2 h-4 w-4" /> Pay with Stripe + +
+
+
+ + """ end end From c17705a4c587af479dbf952e25eeb5ec87d6e3f6 Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 20 Jan 2025 21:17:46 +0300 Subject: [PATCH 29/34] finalize reward bounty form --- lib/algora/bounties/bounties.ex | 51 +-- lib/algora/bounties/schemas/line_item.ex | 44 ++ lib/algora_web/components/core_components.ex | 7 +- lib/algora_web/components/ui/radio_group.ex | 9 +- lib/algora_web/live/claim_live.ex | 398 ++++++++++--------- 5 files changed, 262 insertions(+), 247 deletions(-) create mode 100644 lib/algora/bounties/schemas/line_item.ex diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 9f5544de9..d635f1cd1 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 @@ -284,45 +284,6 @@ defmodule Algora.Bounties do ) end - # TODO: move to separate module - defmodule LineItem do - @moduledoc false - defstruct [:amount, :title, :description, :image, :type] - - @type t :: %__MODULE__{ - amount: Money.t(), - title: String.t(), - description: String.t() | nil, - image: String.t() | nil, - type: :payment | :fee - } - - 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: %{ - name: line_item.title, - description: line_item.description, - images: if(line_item.image, do: [line_item.image]) - } - }, - 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 - @spec generate_line_items( %{amount: Money.t()}, opts :: [ @@ -349,7 +310,7 @@ defmodule Algora.Bounties do title: "Payment to @#{recipient.provider_login}", description: description, image: recipient.avatar_url, - type: :payment + type: :payout } ] else @@ -362,7 +323,7 @@ defmodule Algora.Bounties do title: "Payment to @#{claim.user.provider_login}", description: description, image: claim.user.avatar_url, - type: :payment + type: :payout } end) ++ [ @@ -426,7 +387,9 @@ defmodule Algora.Bounties do group_id: tx_group_id }), {:ok, session} <- - Payments.create_stripe_session(LineItem.to_stripe(line_items), %{ + line_items + |> Enum.map(&LineItem.to_stripe/1) + |> Payments.create_stripe_session(%{ description: description, metadata: %{"version" => "2", "group_id" => tx_group_id} }) do @@ -458,7 +421,7 @@ defmodule Algora.Bounties do 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) 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_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index a42d34b10..38cf60f0e 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -574,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 && @@ -733,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, diff --git a/lib/algora_web/components/ui/radio_group.ex b/lib/algora_web/components/ui/radio_group.ex index 884351914..16fd33892 100644 --- a/lib/algora_web/components/ui/radio_group.ex +++ b/lib/algora_web/components/ui/radio_group.ex @@ -17,7 +17,6 @@ defmodule AlgoraWeb.Components.UI.RadioGroup do """ attr :name, :string, default: nil - attr :value, :any, default: nil 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 @@ -32,7 +31,12 @@ defmodule AlgoraWeb.Components.UI.RadioGroup do "border-border has-[:checked]:border-primary has-[:checked]:bg-primary/10" ]}>
- <.input field={@field} type="radio" value={value} checked={to_string(@value) == value} /> + <.input + field={@field} + type="radio" + value={value} + checked={to_string(@field.value) == to_string(value)} + />
{label} @@ -44,6 +48,7 @@ defmodule AlgoraWeb.Components.UI.RadioGroup do <% end %>
+ <.error :for={msg <- @field.errors}>{translate_error(msg)} """ end end diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index e89cddfbf..581dc9fb6 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -7,18 +7,20 @@ defmodule AlgoraWeb.ClaimLive do alias Algora.Bounties alias Algora.Bounties.Claim + alias Algora.Bounties.LineItem alias Algora.Github + alias Algora.Organizations alias Algora.Repo - alias Algora.Types.USD alias Algora.Util - alias Algora.Validations + + require Logger defp tip_options do [ + {"None", 0}, {"10%", 10}, {"20%", 20}, - {"50%", 50}, - {"None", 0} + {"50%", 50} ] end @@ -30,17 +32,16 @@ defmodule AlgoraWeb.ClaimLive do @primary_key false embedded_schema do - field :owner_id, :string - field :amount, USD - field :tip_percentage, :integer + field :amount, :decimal + field :tip_percentage, :decimal end def changeset(form, attrs) do form - |> cast(attrs, [:owner_id, :amount, :tip_percentage]) - |> validate_required([:owner_id, :amount, :tip_percentage]) + |> cast(attrs, [:amount, :tip_percentage]) + |> validate_required([:amount, :tip_percentage]) |> validate_number(:tip_percentage, greater_than_or_equal_to: 0) - |> Validations.validate_money_positive(:amount) + |> validate_number(:amount, greater_than: 0) end end @@ -136,7 +137,7 @@ defmodule AlgoraWeb.ClaimLive do contexts = if socket.assigns.current_user do - Algora.Organizations.get_user_orgs(socket.assigns.current_user) ++ [socket.assigns.current_user] + Organizations.get_user_orgs(socket.assigns.current_user) ++ [socket.assigns.current_user] else [] end @@ -144,10 +145,13 @@ defmodule AlgoraWeb.ClaimLive do context_ids = MapSet.new(contexts, & &1.id) available_bounties = Enum.filter(primary_claim.target.bounties, &MapSet.member?(context_ids, &1.owner_id)) - changeset = - %RewardBountyForm{} - |> RewardBountyForm.changeset(%{tip_percentage: 0}) - |> maybe_set_amount(available_bounties) + amount = + case available_bounties do + [] -> nil + [bounty | _] -> Money.to_decimal(bounty.amount) + end + + changeset = RewardBountyForm.changeset(%RewardBountyForm{}, %{tip_percentage: 0, amount: amount}) {:ok, socket @@ -163,7 +167,7 @@ defmodule AlgoraWeb.ClaimLive do |> assign(:source_body_html, source_body_html) |> assign(:sponsors, sponsors) |> assign(:contexts, contexts) - |> assign(:show_reward_bounty_modal, true) + |> assign(:show_reward_bounty_modal, false) |> assign(:available_bounties, available_bounties) |> assign(:reward_bounty_form, to_form(changeset))} end @@ -171,25 +175,11 @@ defmodule AlgoraWeb.ClaimLive do @impl true def handle_params(%{"context" => context_id}, _url, socket) do - line_items = - if amount = get_field(socket.assigns.reward_bounty_form.source, :amount) do - Bounties.generate_line_items(%{amount: amount}, - 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 - ) - else - [] - end - - {:noreply, socket |> assign_selected_context(context_id) |> assign(:line_items, line_items)} + {:noreply, socket |> assign_selected_context(context_id) |> assign_line_items()} end def handle_params(_params, _url, socket) do - {:noreply, assign_selected_context(socket)} + {:noreply, socket |> assign_selected_context(default_context_id(socket)) |> assign_line_items()} end @impl true @@ -207,55 +197,24 @@ defmodule AlgoraWeb.ClaimLive do end def handle_event("validate_reward_bounty", %{"reward_bounty_form" => params}, socket) do - case %RewardBountyForm{} - |> RewardBountyForm.changeset(params) - |> apply_action(:validate) do - {:ok, data} -> - dbg(data) - - line_items = - Bounties.generate_line_items(%{amount: data.amount}, - 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 - ) - - {:noreply, assign(socket, :line_items, line_items)} - - {:error, changeset} -> - {:noreply, assign(socket, :reward_bounty_form, to_form(changeset))} - end + {:noreply, + socket + |> assign(:reward_bounty_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params))) + |> assign_line_items()} end - def handle_event("save_reward_bounty", params, socket) do - case %RewardBountyForm{} - |> RewardBountyForm.changeset(params) - |> apply_action(:save) do - {:ok, data} -> - bounty = get_or_create_bounty(socket, data) - final_amount = calculate_final_amount(data.amount || bounty.amount, data.tip_percentage) - - case Algora.Bounties.reward_bounty( - %{ - creator: socket.assigns.current_user, - amount: final_amount, - bounty_id: bounty.id, - claims: socket.assigns.claims - }, - ticket_ref: %{ - owner: socket.assigns.target.repository.user.provider_login, - repo: socket.assigns.target.repository.name, - number: socket.assigns.target.number - } - ) do - {:ok, session_url} -> - {:noreply, redirect(socket, external: session_url)} + 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} -> - {:noreply, put_flash(socket, :error, "Failed to create payment session: #{inspect(reason)}")} + Logger.error("Failed to create payment session: #{inspect(reason)}") + {:noreply, put_flash(socket, :error, "Something went wrong")} end {:error, changeset} -> @@ -263,49 +222,80 @@ defmodule AlgoraWeb.ClaimLive do end end - defp assign_selected_context(socket, context_id \\ nil) - - defp assign_selected_context(socket, nil) do - context_id = - case List.first(socket.assigns.available_bounties) do - nil -> socket.assigns.current_user.id - bounty -> bounty.owner_id - end - - assign_selected_context(socket, context_id) + 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 - changeset = put_change(socket.assigns.reward_bounty_form.source, :owner_id, context_id) - - context = Enum.find(socket.assigns.contexts, &(&1.id == get_field(changeset, :owner_id))) + 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)}") - socket - |> assign(:reward_bounty_form, to_form(changeset)) - |> assign(:selected_context, context) + context -> + assign(socket, :selected_context, context) + end end - defp maybe_set_amount(changeset, [bounty | _]) do - put_change(changeset, :amount, bounty.amount) + 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 maybe_set_amount(changeset, _), do: changeset - - defp get_or_create_bounty(socket, %{owner_id: nil} = data) do - # TODO: Create new bounty logic here - Bounties.create_bounty(%{ - owner: socket.assigns.current_user, - amount: data.amount, - target: socket.assigns.target - }) + 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 - Enum.find(socket.assigns.available_bounties, &(&1.owner_id == data.owner_id)) + 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: 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( + %{ + creator: socket.assigns.current_user, + amount: final_amount, + bounty_id: bounty.id, + claims: socket.assigns.claims + }, + ticket_ref: ticket_ref(socket) + ) end - defp calculate_final_amount(base_amount, tip_percentage) do - base_amount * (100 + tip_percentage) / 100 + 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 @@ -510,120 +500,136 @@ defmodule AlgoraWeb.ClaimLive do <.drawer 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-submit="validate_reward_bounty"> -
- <%= if Enum.empty?(@available_bounties) do %> -
- <.alert variant="destructive"> - <.alert_title>No bounties available - <.alert_description> - You don't have any bounties available. Would you like to create one? - - - -
+ <.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 don't have any bounties available. Would you like to create one? + + + + <.input + label="Amount" + icon="tabler-currency-dollar" + field={@reward_bounty_form[:amount]} + /> +
+ <% else %> <.input label="Amount" icon="tabler-currency-dollar" field={@reward_bounty_form[:amount]} /> + <% end %> + +
+ <.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}
+
+
+ +
-
- <% else %> -
- <.input - label="Amount" - icon="tabler-currency-dollar" - field={@reward_bounty_form[:amount]} - /> -
- <% end %> -
- <.label>On behalf of - <.dropdown2 id="context-dropdown" class="mt-2"> - <:img src={@selected_context.avatar_url} /> - <:title>{@selected_context.name} - - <:link - :for={context <- @contexts |> Enum.reject(&(&1.id == @selected_context.id))} - patch={"?context=#{context.id}"} - > -
- {context.name} + <.label>Tip +
+ <.radio_group + class="grid grid-cols-4 gap-4" + field={@reward_bounty_form[:tip_percentage]} + options={tip_options()} /> -
-
{context.name}
-
@{context.handle}
-
- - -
- -
- <.label>Tip Amount -
- <.radio_group - class="grid grid-cols-4 gap-4" - field={@reward_bounty_form[:tip_percentage]} - options={tip_options()} - /> +
-
-
- -
-
- <.card class="mt-1"> - <.card_header> - <.card_title>Payment Summary - - <.card_content> -
- <%= for line_item <- @line_items do %> + + + <.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 %> +
- <.avatar :if={line_item.image}> - <.avatar_image src={line_item.image} /> - -
-
{line_item.title}
-
{line_item.description}
-
+
+
Total due
- {Money.to_string!(line_item.amount)} + {LineItem.gross_amount(@line_items)}
- <% end %> -
-
-
Total Due
-
- {Bounties.LineItem.gross_amount(@line_items)} -
-
-
- - +
+ + +
- <.button variant="secondary" type="button"> + <.button variant="secondary" phx-click="close_drawer" type="button"> Cancel <.button type="submit"> - <.icon name="tabler-brand-stripe" class="-ml-1 mr-2 h-4 w-4" /> Pay with Stripe + Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" />
-
+ """ From 9c62bbe72b503f8c587844fa702cc6213c5a4b5c Mon Sep 17 00:00:00 2001 From: zafer Date: Mon, 20 Jan 2025 23:32:19 +0300 Subject: [PATCH 30/34] filter specific event actions --- .../controllers/webhooks/github_controller.ex | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 808e10f07..62944477d 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -50,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"] @@ -81,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"] @@ -112,7 +114,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end - defp execute_command({:claim, args}, author, params) when not is_nil(args) do + 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"] @@ -145,7 +148,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end - defp execute_command(_command, _author, _params) do + defp execute_command(_event_action, _command, _author, _params) do {:error, :unhandled_command} end @@ -153,15 +156,20 @@ defmodule AlgoraWeb.Webhooks.GithubController do author = get_author(event, params) body = get_body(event, params) + event_action = event <> "." <> params["action"] + case Github.Command.parse(body) do {:ok, commands} -> Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, results} -> - case execute_command(command, author, params) do + case execute_command(event_action, command, author, params) do {:ok, result} -> {:cont, {:ok, [result | results]}} error -> - Logger.error("Command execution failed for #{inspect(command)}: #{inspect(error)}") + Logger.error( + "Command execution failed for #{event_action}(#{event["id"]}): #{inspect(command)}: #{inspect(error)}" + ) + {:halt, error} end end) From 605b576a3879cf3b3a507d2bb0c6fa45c7d1a986 Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 21 Jan 2025 00:01:33 +0300 Subject: [PATCH 31/34] fix miscellanea --- lib/algora/bounties/bounties.ex | 16 +++++----- lib/algora_web/live/claim_live.ex | 53 +++++++++++++++++-------------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index d635f1cd1..d8e940aba 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -256,7 +256,7 @@ defmodule Algora.Bounties do Repo.transact(fn -> with {:ok, tip} <- Repo.insert(changeset) do create_payment_session( - %{creator: creator, amount: amount, description: "Tip payment for OSS contributions"}, + %{owner: owner, amount: amount, description: "Tip payment for OSS contributions"}, ticket_ref: opts[:ticket_ref], tip_id: tip.id, recipient: recipient @@ -267,7 +267,7 @@ defmodule Algora.Bounties do @spec reward_bounty( %{ - creator: User.t(), + owner: User.t(), amount: Money.t(), bounty_id: String.t(), claims: [Claim.t()] @@ -275,9 +275,9 @@ defmodule Algora.Bounties do opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}] ) :: {:ok, String.t()} | {:error, atom()} - def reward_bounty(%{creator: creator, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do + def reward_bounty(%{owner: owner, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do create_payment_session( - %{creator: creator, amount: amount, description: "Bounty payment for OSS contributions"}, + %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"}, ticket_ref: opts[:ticket_ref], bounty_id: bounty_id, claims: claims @@ -341,7 +341,7 @@ defmodule Algora.Bounties do end @spec create_payment_session( - %{creator: User.t(), amount: Money.t(), description: String.t()}, + %{owner: User.t(), amount: Money.t(), description: String.t()}, opts :: [ ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, tip_id: String.t(), @@ -351,7 +351,7 @@ defmodule Algora.Bounties do ] ) :: {:ok, String.t()} | {:error, atom()} - def create_payment_session(%{creator: creator, amount: amount, description: description}, opts \\ []) do + def create_payment_session(%{owner: owner, amount: amount, description: description}, opts \\ []) do tx_group_id = Nanoid.generate() line_items = @@ -370,7 +370,7 @@ defmodule Algora.Bounties do tip_id: opts[:tip_id], bounty_id: opts[:bounty_id], claim_id: nil, - user_id: creator.id, + user_id: owner.id, gross_amount: gross_amount, net_amount: amount, total_fee: Money.sub!(gross_amount, amount), @@ -383,7 +383,7 @@ defmodule Algora.Bounties do tip_id: opts[:tip_id], bounty_id: opts[:bounty_id], amount: amount, - creator_id: creator.id, + creator_id: owner.id, group_id: tx_group_id }), {:ok, session} <- diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex index 581dc9fb6..77171a9e4 100644 --- a/lib/algora_web/live/claim_live.ex +++ b/lib/algora_web/live/claim_live.ex @@ -174,6 +174,10 @@ defmodule AlgoraWeb.ClaimLive do 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 @@ -203,6 +207,15 @@ defmodule AlgoraWeb.ClaimLive do |> 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) @@ -267,7 +280,7 @@ defmodule AlgoraWeb.ClaimLive do Bounties.create_bounty(%{ creator: socket.assigns.current_user, owner: socket.assigns.selected_context, - amount: data.amount, + amount: Money.new!(:USD, data.amount), ticket_ref: ticket_ref(socket) }) @@ -281,7 +294,7 @@ defmodule AlgoraWeb.ClaimLive do Bounties.reward_bounty( %{ - creator: socket.assigns.current_user, + owner: socket.assigns.selected_context, amount: final_amount, bounty_id: bounty.id, claims: socket.assigns.claims @@ -383,7 +396,8 @@ defmodule AlgoraWeb.ClaimLive do <.card_title> Authors - <.button variant="secondary"> + + <.button variant="secondary" phx-click="split_bounty"> Split bounty
@@ -497,7 +511,7 @@ defmodule AlgoraWeb.ClaimLive do
- <.drawer show={@show_reward_bounty_modal} on_cancel="close_drawer"> + <.drawer :if={@current_user} show={@show_reward_bounty_modal} on_cancel="close_drawer"> <.drawer_header> <.drawer_title>Reward Bounty <.drawer_description> @@ -519,27 +533,18 @@ defmodule AlgoraWeb.ClaimLive do <.card_content>
<%= if Enum.empty?(@available_bounties) do %> -
- <.alert variant="destructive"> - <.alert_title>No bounties available - <.alert_description> - You don't have any bounties available. Would you like to create one? - - - - <.input - label="Amount" - icon="tabler-currency-dollar" - field={@reward_bounty_form[:amount]} - /> -
- <% else %> - <.input - label="Amount" - icon="tabler-currency-dollar" - field={@reward_bounty_form[:amount]} - /> + <.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 From d2c4a769a7391ab6e754091539b04cf75a5b26be Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 21 Jan 2025 00:17:26 +0300 Subject: [PATCH 32/34] update tests --- .../controllers/webhooks/github_controller.ex | 10 +++++----- .../webhooks/github_controller_test.exs | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 62944477d..c58a63d82 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -13,8 +13,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do # TODO: auto-retry failed deliveries with exponential backoff def new(conn, params) do - with {:ok, %Webhook{event: event}} <- Webhook.new(conn), - {:ok, _} <- process_commands(event, params) do + with {:ok, webhook} <- Webhook.new(conn), + {:ok, _} <- process_commands(webhook, params) do conn |> put_status(:accepted) |> json(%{status: "ok"}) else {:error, :missing_header} -> @@ -149,10 +149,10 @@ defmodule AlgoraWeb.Webhooks.GithubController do end defp execute_command(_event_action, _command, _author, _params) do - {:error, :unhandled_command} + {:ok, nil} end - def process_commands(event, params) do + def process_commands(%Webhook{event: event, hook_id: hook_id}, params) do author = get_author(event, params) body = get_body(event, params) @@ -167,7 +167,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do error -> Logger.error( - "Command execution failed for #{event_action}(#{event["id"]}): #{inspect(command)}: #{inspect(error)}" + "Command execution failed for #{event_action}(#{hook_id}): #{inspect(command)}: #{inspect(error)}" ) {:halt, error} diff --git a/test/algora_web/controllers/webhooks/github_controller_test.exs b/test/algora_web/controllers/webhooks/github_controller_test.exs index f8399a451..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 @@ -202,7 +216,7 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do """ GithubController.process_commands( - "issue_comment", + @webhook, Map.put(@params, "comment", %{"user" => %{"login" => author}, "body" => body}) ) end From dd270c53280b247ad25a7d199d21d032c567a2eb Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 21 Jan 2025 00:17:38 +0300 Subject: [PATCH 33/34] add label on bounty created --- lib/algora/bounties/jobs/notify_bounty.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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: From d44f886893fd3bb09c7f79203d84b2943a1b037c Mon Sep 17 00:00:00 2001 From: zafer Date: Tue, 21 Jan 2025 00:18:30 +0300 Subject: [PATCH 34/34] delete obsolete class --- lib/algora_web/components/core_components.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 38cf60f0e..33f1cfeaf 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -240,7 +240,7 @@ defmodule AlgoraWeb.CoreComponents do ~H"""
-
+