diff --git a/config/config.exs b/config/config.exs
index 679ce7cb3..6a6476c76 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -32,7 +32,8 @@ config :algora, Oban,
comment_consumers: 1,
github_og_image: 5,
notify_bounty: 1,
- notify_tip_intent: 1
+ notify_tip_intent: 1,
+ notify_claim: 1
]
# Configures the mailer
diff --git a/lib/algora/accounts/accounts.ex b/lib/algora/accounts/accounts.ex
index 08a8dd390..532ec12be 100644
--- a/lib/algora/accounts/accounts.ex
+++ b/lib/algora/accounts/accounts.ex
@@ -318,12 +318,15 @@ defmodule Algora.Accounts do
end
def get_random_access_tokens(n) when is_integer(n) and n > 0 do
- Identity
- |> where([i], i.provider == "github" and not is_nil(i.provider_token))
- |> order_by(fragment("RANDOM()"))
- |> limit(^n)
- |> select([i], i.provider_token)
- |> Repo.all()
+ case Identity
+ |> where([i], i.provider == "github" and not is_nil(i.provider_token))
+ |> order_by(fragment("RANDOM()"))
+ |> limit(^n)
+ |> select([i], i.provider_token)
+ |> Repo.all() do
+ [""] -> []
+ tokens -> tokens
+ end
end
defp update_github_token(%User{} = user, new_token) do
diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex
index db1584ee9..d8e940aba 100644
--- a/lib/algora/bounties/bounties.ex
+++ b/lib/algora/bounties/bounties.ex
@@ -8,10 +8,10 @@ defmodule Algora.Bounties do
alias Algora.Bounties.Bounty
alias Algora.Bounties.Claim
alias Algora.Bounties.Jobs
+ alias Algora.Bounties.LineItem
alias Algora.Bounties.Tip
alias Algora.FeeTier
alias Algora.Github
- alias Algora.MoneyUtils
alias Algora.Organizations.Member
alias Algora.Payments
alias Algora.Payments.Transaction
@@ -56,8 +56,8 @@ defmodule Algora.Bounties do
{:error, %{errors: [ticket_id: {_, [constraint: :unique, constraint_name: _]}]}} ->
{:error, :already_exists}
- {:error, _changeset} ->
- {:error, :internal_server_error}
+ {:error, _changeset} = error ->
+ error
end
end
@@ -121,6 +121,91 @@ defmodule Algora.Bounties do
|> Oban.insert()
end
+ @spec do_claim_bounty(%{
+ user: User.t(),
+ target: Ticket.t(),
+ source: Ticket.t(),
+ status: :pending | :approved | :rejected | :paid,
+ type: :pull_request | :review | :video | :design | :article
+ }) ::
+ {:ok, Claim.t()} | {:error, atom()}
+ defp do_claim_bounty(%{user: user, target: target, source: source, status: status, type: type}) do
+ # TODO: ensure user is pull request author
+ changeset =
+ Claim.changeset(%Claim{}, %{
+ target_id: target.id,
+ source_id: source.id,
+ user_id: user.id,
+ type: type,
+ status: status,
+ url: source.url
+ })
+
+ case Repo.insert(changeset) do
+ {:ok, claim} ->
+ {:ok, claim}
+
+ {:error, %{errors: [target_id: {_, [constraint: :unique, constraint_name: _]}]}} ->
+ {:error, :already_exists}
+
+ {:error, _changeset} = error ->
+ error
+ end
+ end
+
+ @spec claim_bounty(
+ %{
+ user: User.t(),
+ target_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
+ source_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
+ status: :pending | :approved | :rejected | :paid,
+ type: :pull_request | :review | :video | :design | :article
+ },
+ opts :: [installation_id: integer()]
+ ) ::
+ {:ok, Bounty.t()} | {:error, atom()}
+ def claim_bounty(
+ %{
+ user: user,
+ target_ticket_ref: %{owner: target_repo_owner, repo: target_repo_name, number: target_number},
+ source_ticket_ref: %{owner: source_repo_owner, repo: source_repo_name, number: source_number},
+ status: status,
+ type: type
+ },
+ opts \\ []
+ ) do
+ installation_id = opts[:installation_id]
+
+ token_res =
+ if installation_id,
+ do: Github.get_installation_token(installation_id),
+ else: Accounts.get_access_token(user)
+
+ Repo.transact(fn ->
+ with {:ok, token} <- token_res,
+ {:ok, target} <- Workspace.ensure_ticket(token, target_repo_owner, target_repo_name, target_number),
+ {:ok, source} <- Workspace.ensure_ticket(token, source_repo_owner, source_repo_name, source_number),
+ {:ok, claim} <- do_claim_bounty(%{user: user, target: target, source: source, status: status, type: type}),
+ {:ok, _job} <- notify_claim(%{claim: claim}, installation_id: installation_id) do
+ broadcast()
+ {:ok, claim}
+ else
+ {:error, _reason} = error -> error
+ end
+ end)
+ end
+
+ @spec notify_claim(
+ %{claim: Claim.t()},
+ opts :: [installation_id: integer()]
+ ) ::
+ {:ok, Oban.Job.t()} | {:error, atom()}
+ def notify_claim(%{claim: claim}, opts \\ []) do
+ %{claim_id: claim.id, installation_id: opts[:installation_id]}
+ |> Jobs.NotifyClaim.new()
+ |> Oban.insert()
+ end
+
@spec create_tip_intent(
%{
recipient: String.t(),
@@ -160,8 +245,6 @@ defmodule Algora.Bounties do
) ::
{:ok, String.t()} | {:error, atom()}
def create_tip(%{creator: creator, owner: owner, recipient: recipient, amount: amount}, opts \\ []) do
- ticket_ref = opts[:ticket_ref]
-
changeset =
Tip.changeset(%Tip{}, %{
amount: amount,
@@ -170,94 +253,144 @@ defmodule Algora.Bounties do
recipient_id: recipient.id
})
- # Initialize transaction IDs
- charge_id = Nanoid.generate()
- debit_id = Nanoid.generate()
- credit_id = Nanoid.generate()
- tx_group_id = Nanoid.generate()
+ Repo.transact(fn ->
+ with {:ok, tip} <- Repo.insert(changeset) do
+ create_payment_session(
+ %{owner: owner, amount: amount, description: "Tip payment for OSS contributions"},
+ ticket_ref: opts[:ticket_ref],
+ tip_id: tip.id,
+ recipient: recipient
+ )
+ end
+ end)
+ end
- # Calculate fees
- currency = to_string(amount.currency)
- total_paid = Payments.get_total_paid(owner.id, recipient.id)
- platform_fee_pct = FeeTier.calculate_fee_percentage(total_paid)
+ @spec reward_bounty(
+ %{
+ owner: User.t(),
+ amount: Money.t(),
+ bounty_id: String.t(),
+ claims: [Claim.t()]
+ },
+ opts :: [ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}]
+ ) ::
+ {:ok, String.t()} | {:error, atom()}
+ def reward_bounty(%{owner: owner, amount: amount, bounty_id: bounty_id, claims: claims}, opts \\ []) do
+ create_payment_session(
+ %{owner: owner, amount: amount, description: "Bounty payment for OSS contributions"},
+ ticket_ref: opts[:ticket_ref],
+ bounty_id: bounty_id,
+ claims: claims
+ )
+ end
+
+ @spec generate_line_items(
+ %{amount: Money.t()},
+ opts :: [
+ ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
+ claims: [Claim.t()],
+ recipient: User.t()
+ ]
+ ) ::
+ [LineItem.t()]
+ def generate_line_items(%{amount: amount}, opts \\ []) do
+ ticket_ref = opts[:ticket_ref]
+ recipient = opts[:recipient]
+ claims = opts[:claims]
+
+ description = if(ticket_ref, do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}")
+
+ platform_fee_pct = FeeTier.calculate_fee_percentage(Money.zero(:USD))
transaction_fee_pct = Payments.get_transaction_fee_pct()
- platform_fee = Money.mult!(amount, platform_fee_pct)
- transaction_fee = Money.mult!(amount, transaction_fee_pct)
- total_fee = Money.add!(platform_fee, transaction_fee)
- gross_amount = Money.add!(amount, total_fee)
-
- line_items = [
- %{
- price_data: %{
- unit_amount: MoneyUtils.to_minor_units(amount),
- currency: currency,
- product_data: %{
- name: "Payment to @#{recipient.provider_login}",
- # TODO:
- description:
- if(ticket_ref,
- do: "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}",
- else: "Tip to @#{recipient.provider_login}"
- ),
- images: [recipient.avatar_url]
- }
- },
- quantity: 1
- },
- %{
- price_data: %{
- unit_amount: MoneyUtils.to_minor_units(Money.mult!(amount, platform_fee_pct)),
- currency: currency,
- product_data: %{name: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})"}
- },
- quantity: 1
- },
- %{
- price_data: %{
- unit_amount: MoneyUtils.to_minor_units(Money.mult!(amount, transaction_fee_pct)),
- currency: currency,
- product_data: %{name: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})"}
+ if recipient do
+ [
+ %LineItem{
+ amount: amount,
+ title: "Payment to @#{recipient.provider_login}",
+ description: description,
+ image: recipient.avatar_url,
+ type: :payout
+ }
+ ]
+ else
+ []
+ end ++
+ Enum.map(claims, fn claim ->
+ %LineItem{
+ # TODO: ensure shares are normalized
+ amount: Money.mult!(amount, claim.group_share),
+ title: "Payment to @#{claim.user.provider_login}",
+ description: description,
+ image: claim.user.avatar_url,
+ type: :payout
+ }
+ end) ++
+ [
+ %LineItem{
+ amount: Money.mult!(amount, platform_fee_pct),
+ title: "Algora platform fee (#{Util.format_pct(platform_fee_pct)})",
+ type: :fee
},
- quantity: 1
- }
- ]
+ %LineItem{
+ amount: Money.mult!(amount, transaction_fee_pct),
+ title: "Transaction fee (#{Util.format_pct(transaction_fee_pct)})",
+ type: :fee
+ }
+ ]
+ end
+
+ @spec create_payment_session(
+ %{owner: User.t(), amount: Money.t(), description: String.t()},
+ opts :: [
+ ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
+ tip_id: String.t(),
+ bounty_id: String.t(),
+ claims: [Claim.t()],
+ recipient: User.t()
+ ]
+ ) ::
+ {:ok, String.t()} | {:error, atom()}
+ def create_payment_session(%{owner: owner, amount: amount, description: description}, opts \\ []) do
+ tx_group_id = Nanoid.generate()
+
+ line_items =
+ generate_line_items(%{amount: amount},
+ ticket_ref: opts[:ticket_ref],
+ recipient: opts[:recipient],
+ claims: opts[:claims]
+ )
+
+ gross_amount = LineItem.gross_amount(line_items)
Repo.transact(fn ->
- with {:ok, tip} <- Repo.insert(changeset),
- {:ok, _charge} <-
+ with {:ok, _charge} <-
initialize_charge(%{
- id: charge_id,
- tip: tip,
- user_id: creator.id,
+ id: Nanoid.generate(),
+ tip_id: opts[:tip_id],
+ bounty_id: opts[:bounty_id],
+ claim_id: nil,
+ user_id: owner.id,
gross_amount: gross_amount,
net_amount: amount,
- total_fee: total_fee,
+ total_fee: Money.sub!(gross_amount, amount),
line_items: line_items,
group_id: tx_group_id
}),
- {:ok, _debit} <-
- initialize_debit(%{
- id: debit_id,
- tip: tip,
- amount: amount,
- user_id: creator.id,
- linked_transaction_id: credit_id,
- group_id: tx_group_id
- }),
- {:ok, _credit} <-
- initialize_credit(%{
- id: credit_id,
- tip: tip,
+ {:ok, _transactions} <-
+ create_transaction_pairs(%{
+ claims: opts[:claims] || [],
+ tip_id: opts[:tip_id],
+ bounty_id: opts[:bounty_id],
amount: amount,
- user_id: recipient.id,
- linked_transaction_id: debit_id,
+ creator_id: owner.id,
group_id: tx_group_id
}),
{:ok, session} <-
- Payments.create_stripe_session(line_items, %{
- # Mandatory for some countries like India
- description: "Tip payment for OSS contributions",
+ line_items
+ |> Enum.map(&LineItem.to_stripe/1)
+ |> Payments.create_stripe_session(%{
+ description: description,
metadata: %{"version" => "2", "group_id" => tx_group_id}
}) do
{:ok, session.url}
@@ -267,7 +400,8 @@ defmodule Algora.Bounties do
defp initialize_charge(%{
id: id,
- tip: tip,
+ tip_id: tip_id,
+ bounty_id: bounty_id,
user_id: user_id,
gross_amount: gross_amount,
net_amount: net_amount,
@@ -281,25 +415,30 @@ defmodule Algora.Bounties do
provider: "stripe",
type: :charge,
status: :initialized,
- tip_id: tip.id,
+ tip_id: tip_id,
+ bounty_id: bounty_id,
user_id: user_id,
gross_amount: gross_amount,
net_amount: net_amount,
total_fee: total_fee,
- line_items: line_items,
+ line_items: Util.normalize_struct(line_items),
group_id: group_id
})
|> Algora.Validations.validate_positive(:gross_amount)
|> Algora.Validations.validate_positive(:net_amount)
|> Algora.Validations.validate_positive(:total_fee)
- |> foreign_key_constraint(:tip_id)
|> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:tip_id)
+ |> foreign_key_constraint(:bounty_id)
+ |> foreign_key_constraint(:claim_id)
|> Repo.insert()
end
defp initialize_debit(%{
id: id,
- tip: tip,
+ tip_id: tip_id,
+ bounty_id: bounty_id,
+ claim_id: claim_id,
amount: amount,
user_id: user_id,
linked_transaction_id: linked_transaction_id,
@@ -311,7 +450,9 @@ defmodule Algora.Bounties do
provider: "stripe",
type: :debit,
status: :initialized,
- tip_id: tip.id,
+ tip_id: tip_id,
+ bounty_id: bounty_id,
+ claim_id: claim_id,
user_id: user_id,
gross_amount: amount,
net_amount: amount,
@@ -321,14 +462,18 @@ defmodule Algora.Bounties do
})
|> Algora.Validations.validate_positive(:gross_amount)
|> Algora.Validations.validate_positive(:net_amount)
- |> foreign_key_constraint(:tip_id)
|> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:tip_id)
+ |> foreign_key_constraint(:bounty_id)
+ |> foreign_key_constraint(:claim_id)
|> Repo.insert()
end
defp initialize_credit(%{
id: id,
- tip: tip,
+ tip_id: tip_id,
+ bounty_id: bounty_id,
+ claim_id: claim_id,
amount: amount,
user_id: user_id,
linked_transaction_id: linked_transaction_id,
@@ -340,7 +485,9 @@ defmodule Algora.Bounties do
provider: "stripe",
type: :credit,
status: :initialized,
- tip_id: tip.id,
+ tip_id: tip_id,
+ bounty_id: bounty_id,
+ claim_id: claim_id,
user_id: user_id,
gross_amount: amount,
net_amount: amount,
@@ -350,8 +497,10 @@ defmodule Algora.Bounties do
})
|> Algora.Validations.validate_positive(:gross_amount)
|> Algora.Validations.validate_positive(:net_amount)
- |> foreign_key_constraint(:tip_id)
|> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:tip_id)
+ |> foreign_key_constraint(:bounty_id)
+ |> foreign_key_constraint(:claim_id)
|> Repo.insert()
end
@@ -479,4 +628,52 @@ defmodule Algora.Bounties do
reviews_count: 4
}
end
+
+ # Helper function to create transaction pairs
+ defp create_transaction_pairs(%{claims: claims} = params) when length(claims) > 0 do
+ Enum.reduce_while(claims, {:ok, []}, fn claim, {:ok, acc} ->
+ params
+ |> Map.put(:claim_id, claim.id)
+ |> Map.put(:recipient_id, claim.user.id)
+ |> create_single_transaction_pair()
+ |> case do
+ {:ok, transactions} -> {:cont, {:ok, transactions ++ acc}}
+ error -> {:halt, error}
+ end
+ end)
+ end
+
+ defp create_transaction_pairs(params) do
+ create_single_transaction_pair(params)
+ end
+
+ defp create_single_transaction_pair(params) do
+ debit_id = Nanoid.generate()
+ credit_id = Nanoid.generate()
+
+ with {:ok, debit} <-
+ initialize_debit(%{
+ id: debit_id,
+ tip_id: params.tip_id,
+ bounty_id: params.bounty_id,
+ claim_id: params.claim_id,
+ amount: params.amount,
+ user_id: params.creator_id,
+ linked_transaction_id: credit_id,
+ group_id: params.group_id
+ }),
+ {:ok, credit} <-
+ initialize_credit(%{
+ id: credit_id,
+ tip_id: params.tip_id,
+ bounty_id: params.bounty_id,
+ claim_id: params.claim_id,
+ amount: params.amount,
+ user_id: params.recipient_id,
+ linked_transaction_id: debit_id,
+ group_id: params.group_id
+ }) do
+ {:ok, [debit, credit]}
+ end
+ end
end
diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex
index cebde955b..217b980dc 100644
--- a/lib/algora/bounties/jobs/notify_bounty.ex
+++ b/lib/algora/bounties/jobs/notify_bounty.ex
@@ -42,7 +42,8 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
with {:ok, token} <- Github.get_installation_token(installation_id),
{:ok, installation} <-
Workspace.fetch_installation_by(provider: "github", provider_id: to_string(installation_id)),
- {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id) do
+ {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id),
+ {:ok, _} <- Github.add_labels(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], ["💎 Bounty"]) do
body = """
## 💎 #{amount} bounty [• #{owner.name}](#{User.url(owner)})
### Steps to solve:
diff --git a/lib/algora/bounties/jobs/notify_claim.ex b/lib/algora/bounties/jobs/notify_claim.ex
new file mode 100644
index 000000000..a73a8da3b
--- /dev/null
+++ b/lib/algora/bounties/jobs/notify_claim.ex
@@ -0,0 +1,48 @@
+defmodule Algora.Bounties.Jobs.NotifyClaim do
+ @moduledoc false
+ use Oban.Worker, queue: :notify_claim
+
+ alias Algora.Bounties.Claim
+ alias Algora.Github
+ alias Algora.Repo
+
+ require Logger
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"claim_id" => _claim_id, "installation_id" => nil}}) do
+ :ok
+ end
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"claim_id" => claim_id, "installation_id" => installation_id}}) do
+ with {:ok, token} <- Github.get_installation_token(installation_id),
+ {:ok, claim} <- Repo.fetch(Claim, claim_id),
+ claim = Repo.preload(claim, source: [repository: [:user]], target: [repository: [:user]], user: []),
+ {:ok, _} <- maybe_add_labels(token, claim),
+ {:ok, _} <- add_comment(token, claim) do
+ :ok
+ end
+ end
+
+ defp add_comment(token, claim) do
+ Github.create_issue_comment(
+ token,
+ claim.target.repository.user.provider_login,
+ claim.target.repository.name,
+ claim.target.number,
+ "💡 @#{claim.user.provider_login} submitted [#{Claim.type_label(claim.type)}](#{claim.url}) that claims the bounty. You can visit [Algora](#{Claim.reward_url(claim)}) to reward."
+ )
+ end
+
+ defp maybe_add_labels(token, %Claim{source: source} = claim) when not is_nil(source) do
+ Github.add_labels(
+ token,
+ claim.source.repository.user.provider_login,
+ claim.source.repository.name,
+ claim.source.number,
+ ["🙋 Bounty claim"]
+ )
+ end
+
+ defp maybe_add_labels(_token, _claim), do: {:ok, nil}
+end
diff --git a/lib/algora/bounties/schemas/bounty.ex b/lib/algora/bounties/schemas/bounty.ex
index cb316db32..9cf85c1bd 100644
--- a/lib/algora/bounties/schemas/bounty.ex
+++ b/lib/algora/bounties/schemas/bounty.ex
@@ -14,7 +14,6 @@ defmodule Algora.Bounties.Bounty do
belongs_to :owner, User
belongs_to :creator, User
has_many :attempts, Algora.Bounties.Attempt
- has_many :claims, Algora.Bounties.Claim
has_many :transactions, Algora.Payments.Transaction
timestamps()
diff --git a/lib/algora/bounties/schemas/claim.ex b/lib/algora/bounties/schemas/claim.ex
index bebe17e79..7043ad0e8 100644
--- a/lib/algora/bounties/schemas/claim.ex
+++ b/lib/algora/bounties/schemas/claim.ex
@@ -3,44 +3,54 @@ defmodule Algora.Bounties.Claim do
use Algora.Schema
alias Algora.Bounties.Claim
+ alias Algora.Workspace.Ticket
- @derive {Inspect, except: [:provider_meta]}
typed_schema "claims" do
- field :provider, :string
- field :provider_id, :string
- field :provider_meta, :map
+ field :status, Ecto.Enum, values: [:pending, :approved, :cancelled], null: false
+ field :type, Ecto.Enum, values: [:pull_request, :review, :video, :design, :article]
+ field :url, :string, null: false
+ field :group_id, :string, null: false
+ field :group_share, :decimal, null: false, default: 1.0
- field :type, Ecto.Enum, values: [:code, :video, :design, :article]
-
- field :status, Ecto.Enum, values: [:pending, :merged, :approved, :rejected, :charged, :paid]
-
- field :merged_at, :utc_datetime_usec
- field :approved_at, :utc_datetime_usec
- field :rejected_at, :utc_datetime_usec
- field :charged_at, :utc_datetime_usec
- field :paid_at, :utc_datetime_usec
-
- field :title, :string
- field :description, :string
- field :url, :string
- field :group_id, :string
-
- belongs_to :bounty, Algora.Bounties.Bounty
- belongs_to :user, Algora.Accounts.User
- # has_one :transaction, Algora.Payments.Transaction
+ belongs_to :source, Ticket
+ belongs_to :target, Ticket, null: false
+ belongs_to :user, Algora.Accounts.User, null: false
+ has_many :transactions, Algora.Payments.Transaction
timestamps()
end
def changeset(claim, attrs) do
claim
- |> cast(attrs, [:bounty_id, :user_id])
- |> validate_required([:bounty_id, :user_id])
+ |> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id])
+ |> validate_required([:target_id, :user_id, :status, :type, :url])
+ |> generate_id()
+ |> put_group_id()
+ |> foreign_key_constraint(:source_id)
+ |> foreign_key_constraint(:target_id)
+ |> foreign_key_constraint(:user_id)
+ |> unique_constraint([:target_id, :user_id])
end
+ def put_group_id(changeset) do
+ case get_field(changeset, :group_id) do
+ nil -> put_change(changeset, :group_id, get_field(changeset, :id))
+ _existing -> changeset
+ end
+ end
+
+ def type_label(:pull_request), do: "a pull request"
+ def type_label(:review), do: "a review"
+ def type_label(:video), do: "a video"
+ def type_label(:design), do: "a design"
+ def type_label(:article), do: "an article"
+ def type_label(nil), do: "a URL"
+
+ def reward_url(claim), do: "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.id}"
+
def rewarded(query \\ Claim) do
from c in query,
- where: c.status == :approved and not is_nil(c.charged_at)
+ where: c.state == :approved and not is_nil(c.charged_at)
end
def filter_by_org_id(query, nil), do: query
diff --git a/lib/algora/bounties/schemas/line_item.ex b/lib/algora/bounties/schemas/line_item.ex
new file mode 100644
index 000000000..686fb3b96
--- /dev/null
+++ b/lib/algora/bounties/schemas/line_item.ex
@@ -0,0 +1,44 @@
+defmodule Algora.Bounties.LineItem do
+ @moduledoc false
+ use Algora.Schema
+
+ alias Algora.MoneyUtils
+
+ @primary_key false
+ typed_embedded_schema do
+ field :amount, Algora.Types.Money
+ field :title, :string
+ field :description, :string
+ field :image, :string
+ field :type, Ecto.Enum, values: [:payout, :fee]
+ end
+
+ def to_stripe(line_item) do
+ %{
+ price_data: %{
+ unit_amount: MoneyUtils.to_minor_units(line_item.amount),
+ currency: to_string(line_item.amount.currency),
+ product_data:
+ Map.reject(
+ %{
+ name: line_item.title,
+ description: line_item.description,
+ images: if(line_item.image, do: [line_item.image])
+ },
+ fn {_, v} -> is_nil(v) end
+ )
+ },
+ quantity: 1
+ }
+ end
+
+ def gross_amount(line_items) do
+ Enum.reduce(line_items, Money.zero(:USD), fn item, acc -> Money.add!(acc, item.amount) end)
+ end
+
+ def total_fee(line_items) do
+ Enum.reduce(line_items, Money.zero(:USD), fn item, acc ->
+ if item.type == :fee, do: Money.add!(acc, item.amount), else: acc
+ end)
+ end
+end
diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex
index d7bf1592c..dae585cbe 100644
--- a/lib/algora/integrations/github/behaviour.ex
+++ b/lib/algora/integrations/github/behaviour.ex
@@ -1,5 +1,6 @@
defmodule Algora.Github.Behaviour do
@moduledoc false
+
@type token :: String.t()
@type response :: {:ok, map()} | {:error, any()}
@@ -15,9 +16,9 @@ defmodule Algora.Github.Behaviour do
@callback list_installations(token(), integer()) :: response
@callback find_installation(token(), integer(), integer()) :: response
@callback get_installation_token(integer()) :: response
- @callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) ::
- response
-
+ @callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: response
@callback list_repository_events(token(), String.t(), String.t(), keyword()) :: response
@callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: response
+ @callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: response
+ @callback render_markdown(token(), String.t(), keyword()) :: response
end
diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex
index 3703f72db..d86355267 100644
--- a/lib/algora/integrations/github/client.ex
+++ b/lib/algora/integrations/github/client.ex
@@ -7,11 +7,11 @@ defmodule Algora.Github.Client do
@type token :: String.t()
# TODO: move to a separate module and use only for data migration between databases
- def http_cached(host, method, path, headers, body) do
+ def http_cached(host, method, path, headers, body, opts \\ []) do
cache_path = ".local/github/#{path}.json"
with :error <- read_from_cache(cache_path),
- {:ok, response_body} <- do_http_request(host, method, path, headers, body) do
+ {:ok, response_body} <- do_http_request(host, method, path, headers, body, opts) do
write_to_cache(cache_path, response_body)
{:ok, response_body}
else
@@ -20,18 +20,18 @@ defmodule Algora.Github.Client do
end
end
- def http(host, method, path, headers, body) do
- do_http_request(host, method, path, headers, body)
+ def http(host, method, path, headers, body, opts \\ []) do
+ do_http_request(host, method, path, headers, body, opts)
end
- defp do_http_request(host, method, path, headers, body) do
+ defp do_http_request(host, method, path, headers, body, opts) do
url = "https://#{host}#{path}"
headers = [{"Content-Type", "application/json"} | headers]
with {:ok, encoded_body} <- Jason.encode(body),
request = Finch.build(method, url, headers, encoded_body),
{:ok, response} <- Finch.request(request, Algora.Finch) do
- handle_response(response)
+ if opts[:skip_decoding], do: {:ok, response.body}, else: handle_response(response)
end
end
@@ -67,17 +67,19 @@ defmodule Algora.Github.Client do
File.write!(cache_path, Jason.encode!(data))
end
- def fetch(access_token, url, method \\ "GET", body \\ nil)
+ def fetch(access_token, url, method \\ "GET", body \\ nil, opts \\ [])
- def fetch(access_token, "https://api.github.com" <> path, method, body), do: fetch(access_token, path, method, body)
+ def fetch(access_token, "https://api.github.com" <> path, method, body, opts),
+ do: fetch(access_token, path, method, body, opts)
- def fetch(access_token, path, method, body) do
+ def fetch(access_token, path, method, body, opts) do
http(
"api.github.com",
method,
path,
[{"accept", "application/vnd.github.v3+json"}, {"Authorization", "Bearer #{access_token}"}],
- body
+ body,
+ opts
)
end
@@ -180,4 +182,16 @@ defmodule Algora.Github.Client do
def list_repository_comments(access_token, owner, repo, opts \\ []) do
fetch(access_token, "/repos/#{owner}/#{repo}/issues/comments#{build_query(opts)}")
end
+
+ @impl true
+ def add_labels(access_token, owner, repo, number, labels) do
+ fetch(access_token, "/repos/#{owner}/#{repo}/issues/#{number}/labels", "POST", %{labels: labels})
+ end
+
+ @impl true
+ def render_markdown(access_token, text, opts \\ []) do
+ fetch(access_token, "/markdown", "POST", %{text: text, mode: opts[:mode] || "gfm", context: opts[:context]},
+ skip_decoding: true
+ )
+ end
end
diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex
index 23a41b8eb..180eca5f3 100644
--- a/lib/algora/integrations/github/github.ex
+++ b/lib/algora/integrations/github/github.ex
@@ -91,4 +91,10 @@ defmodule Algora.Github do
@impl true
def list_repository_comments(token, owner, repo, opts \\ []),
do: client().list_repository_comments(token, owner, repo, opts)
+
+ @impl true
+ def add_labels(token, owner, repo, number, labels), do: client().add_labels(token, owner, repo, number, labels)
+
+ @impl true
+ def render_markdown(token, text, opts \\ []), do: client().render_markdown(token, text, opts)
end
diff --git a/lib/algora/integrations/github/token_pool.ex b/lib/algora/integrations/github/token_pool.ex
index dfdb73307..f68445d21 100644
--- a/lib/algora/integrations/github/token_pool.ex
+++ b/lib/algora/integrations/github/token_pool.ex
@@ -3,6 +3,7 @@ defmodule Algora.Github.TokenPool do
use GenServer
alias Algora.Accounts
+ alias Algora.Github
require Logger
@@ -38,7 +39,7 @@ defmodule Algora.Github.TokenPool do
token = Enum.at(tokens, index)
if token == nil do
- {:reply, nil, state}
+ {:reply, Github.pat(), state}
else
next_index = rem(index + 1, length(tokens))
if next_index == 0, do: refresh_tokens()
diff --git a/lib/algora/payments/schemas/transaction.ex b/lib/algora/payments/schemas/transaction.ex
index 13509380d..4c0bb1c4e 100644
--- a/lib/algora/payments/schemas/transaction.ex
+++ b/lib/algora/payments/schemas/transaction.ex
@@ -35,7 +35,7 @@ defmodule Algora.Payments.Transaction do
belongs_to :contract, Contract
belongs_to :original_contract, Contract
belongs_to :user, Algora.Accounts.User
- # belongs_to :claim, Algora.Bounties.Claim
+ belongs_to :claim, Algora.Bounties.Claim
belongs_to :bounty, Algora.Bounties.Bounty
belongs_to :tip, Algora.Bounties.Tip
belongs_to :linked_transaction, Algora.Payments.Transaction
diff --git a/lib/algora/shared/util.ex b/lib/algora/shared/util.ex
index 68bfafb1e..72e520da1 100644
--- a/lib/algora/shared/util.ex
+++ b/lib/algora/shared/util.ex
@@ -45,6 +45,15 @@ defmodule Algora.Util do
date |> DateTime.shift_zone!(timezone) |> Calendar.strftime("%Y-%m-%d %I:%M %p")
end
+ def to_date(nil), do: nil
+
+ def to_date(date) do
+ case DateTime.from_iso8601(date) do
+ {:ok, datetime, _offset} -> datetime
+ {:error, _reason} = error -> error
+ end
+ end
+
def format_pct(percentage) do
percentage
|> Decimal.mult(100)
diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex
index c8f613ca7..33f1cfeaf 100644
--- a/lib/algora_web/components/core_components.ex
+++ b/lib/algora_web/components/core_components.ex
@@ -13,6 +13,7 @@ defmodule AlgoraWeb.CoreComponents do
use Gettext, backend: AlgoraWeb.Gettext
alias AlgoraWeb.Components.UI.Accordion
+ alias AlgoraWeb.Components.UI.Alert
alias AlgoraWeb.Components.UI.Avatar
alias AlgoraWeb.Components.UI.Card
alias AlgoraWeb.Components.UI.Dialog
@@ -231,6 +232,7 @@ defmodule AlgoraWeb.CoreComponents do
slot :link do
attr :navigate, :string
attr :href, :string
+ attr :patch, :string
attr :method, :any
end
@@ -242,7 +244,7 @@ defmodule AlgoraWeb.CoreComponents do
<.icon
name="tabler-selector"
- class="ml-2 h-5 w-5 flex-shrink-0 text-gray-500 group-hover:text-gray-400"
+ class="ml-2 h-6 w-6 flex-shrink-0 text-gray-500 group-hover:text-gray-400"
/>
@@ -282,7 +284,8 @@ defmodule AlgoraWeb.CoreComponents do
<.link
tabindex="-1"
role="menuitem"
- class="block px-4 py-2 text-sm text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring"
+ class="block p-3 text-sm text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring"
+ phx-click={hide_dropdown("##{@id}-dropdown")}
{link}
>
{render_slot(link)}
@@ -571,7 +574,7 @@ defmodule AlgoraWeb.CoreComponents do
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class={[
- "fixed right-4 bottom-4 z-50 hidden w-80 rounded-lg p-3 pr-8 shadow-md ring-1 sm:w-96",
+ "fixed right-4 bottom-4 z-[1000] hidden w-80 rounded-lg p-3 pr-8 shadow-md ring-1 sm:w-96",
@kind == :info &&
"bg-emerald-950 fill-success-foreground text-success-foreground ring ring-success/70",
@kind == :warning &&
@@ -730,10 +733,7 @@ defmodule AlgoraWeb.CoreComponents do
slot :inner_block
def input(%{field: %FormField{} = field} = assigns) do
- errors =
- if Phoenix.Component.used_input?(field) and not assigns.hide_errors,
- do: field.errors,
- else: []
+ errors = if assigns.hide_errors, do: [], else: field.errors
value =
with %Money{} <- field.value,
@@ -857,8 +857,8 @@ defmodule AlgoraWeb.CoreComponents do
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"py-[7px] px-[11px] block w-full rounded-lg border-input bg-background",
- "text-foreground focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
- "border-input focus:border-ring focus:ring-ring/5",
+ "text-foreground focus:outline-none focus:ring-1 sm:text-sm sm:leading-6",
+ "border-input focus:border-ring focus:ring-ring",
@errors != [] &&
"border-destructive placeholder-destructive-foreground/50 focus:border-destructive focus:ring-destructive/10",
@icon && "pl-10",
@@ -1258,7 +1258,9 @@ defmodule AlgoraWeb.CoreComponents do
defdelegate accordion_item(assigns), to: Accordion
defdelegate accordion_trigger(assigns), to: Accordion
defdelegate accordion(assigns), to: Accordion
- defdelegate alert(assigns), to: AlgoraWeb.Components.UI.Alert
+ defdelegate alert_description(assigns), to: Alert
+ defdelegate alert_title(assigns), to: Alert
+ defdelegate alert(assigns), to: Alert
defdelegate avatar_fallback(assigns), to: Avatar
defdelegate avatar_image(assigns), to: Avatar
defdelegate avatar(assigns), to: Avatar
@@ -1305,7 +1307,6 @@ defmodule AlgoraWeb.CoreComponents do
defdelegate popover_content(assigns), to: Popover
defdelegate popover_trigger(assigns), to: Popover
defdelegate popover(assigns), to: Popover
- defdelegate radio_group_item(assigns), to: RadioGroup
defdelegate radio_group(assigns), to: RadioGroup
defdelegate scroll_area(assigns), to: AlgoraWeb.Components.UI.ScrollArea
defdelegate select_content(assigns), to: Select
diff --git a/lib/algora_web/components/ui/drawer.ex b/lib/algora_web/components/ui/drawer.ex
index 647b1471a..204608ade 100644
--- a/lib/algora_web/components/ui/drawer.ex
+++ b/lib/algora_web/components/ui/drawer.ex
@@ -114,7 +114,7 @@ defmodule AlgoraWeb.Components.UI.Drawer do
def drawer_content(assigns) do
~H"""
-
+
{render_slot(@inner_block)}
"""
diff --git a/lib/algora_web/components/ui/radio_group.ex b/lib/algora_web/components/ui/radio_group.ex
index 1d4b681ff..16fd33892 100644
--- a/lib/algora_web/components/ui/radio_group.ex
+++ b/lib/algora_web/components/ui/radio_group.ex
@@ -2,93 +2,53 @@ defmodule AlgoraWeb.Components.UI.RadioGroup do
@moduledoc false
use AlgoraWeb.Component
+ import AlgoraWeb.CoreComponents
+
@doc """
- Radio input group component
+ Radio input group component styled with a modern card-like appearance.
## Examples:
- <.radio_group name="question-1" value="option-2">
-
- <.radio_group_item builder={builder} value="option-one" id="option-one">
- <.label for="option-one">
- Option One
-
-
-
- <.radio_group_item builder={builder} value="option-two" id="option-two">
- <.label for="option-two">
- Option Two
-
-
-
+ <.radio_group
+ name="hiring"
+ options={[{"Yes", "true"}, {"No", "false"}]}
+ field={@form[:hiring]}
+ />
"""
attr :name, :string, default: nil
- attr :value, :any, default: nil
- attr :"default-value", :any
-
- attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]"
-
+ attr :options, :list, default: [], doc: "List of {label, value} tuples"
+ attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form"
attr :class, :string, default: nil
- slot :inner_block, required: true
def radio_group(assigns) do
- assigns = prepare_assign(assigns)
- assigns = assign(assigns, :builder, %{name: assigns.name, value: assigns.value})
-
~H"""
-
- {render_slot(@inner_block, @builder)}
+
+ <%= for {label, value} <- @options do %>
+
+
+ <.input
+ field={@field}
+ type="radio"
+ value={value}
+ checked={to_string(@field.value) == to_string(value)}
+ />
+
+
+ {label}
+ <.icon
+ name="tabler-check"
+ class="invisible size-5 text-primary group-has-[:checked]:visible"
+ />
+
+
+ <% end %>
- """
- end
-
- attr :builder, :map, required: true
- attr :class, :string, default: nil
- attr :checked, :any, default: false
- attr :value, :string, default: nil
- attr :rest, :global
-
- def radio_group_item(assigns) do
- ~H"""
-
-
-
-
-
-
-
-
+ <.error :for={msg <- @field.errors}>{translate_error(msg)}
"""
end
end
diff --git a/lib/algora_web/components/ui/stat_card.ex b/lib/algora_web/components/ui/stat_card.ex
index 174c209ac..e071730cc 100644
--- a/lib/algora_web/components/ui/stat_card.ex
+++ b/lib/algora_web/components/ui/stat_card.ex
@@ -6,10 +6,12 @@ defmodule AlgoraWeb.Components.UI.StatCard do
attr :href, :string, default: nil
attr :title, :string
- attr :value, :string
+ attr :value, :string, default: nil
attr :subtext, :string, default: nil
attr :icon, :string, default: nil
+ slot :inner_block
+
def stat_card(assigns) do
~H"""
<%= if @href do %>
@@ -24,13 +26,24 @@ defmodule AlgoraWeb.Components.UI.StatCard do
defp stat_card_content(assigns) do
~H"""
-
+
{@title}
<.icon :if={@icon} name={@icon} class="h-6 w-6 text-muted-foreground" />
-
{@value}
+
+ <%= if @value do %>
+ {@value}
+ <% else %>
+ {render_slot(@inner_block)}
+ <% end %>
+
{@subtext}
diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex
index 1963c7709..c58a63d82 100644
--- a/lib/algora_web/controllers/webhooks/github_controller.ex
+++ b/lib/algora_web/controllers/webhooks/github_controller.ex
@@ -13,24 +13,23 @@ defmodule AlgoraWeb.Webhooks.GithubController do
# TODO: auto-retry failed deliveries with exponential backoff
def new(conn, params) do
- case Webhook.new(conn) do
- {:ok, %Webhook{delivery: _delivery, event: event, installation_id: _installation_id}} ->
- author = get_author(event, params)
- body = get_body(event, params)
- process_commands(body, author, params)
-
- conn |> put_status(:accepted) |> json(%{status: "ok"})
-
+ with {:ok, webhook} <- Webhook.new(conn),
+ {:ok, _} <- process_commands(webhook, params) do
+ conn |> put_status(:accepted) |> json(%{status: "ok"})
+ else
{:error, :missing_header} ->
conn |> put_status(:bad_request) |> json(%{error: "Missing header"})
{:error, :signature_mismatch} ->
conn |> put_status(:unauthorized) |> json(%{error: "Signature mismatch"})
+
+ {:error, reason} ->
+ Logger.error("Error processing webhook: #{inspect(reason)}")
+ conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"})
end
rescue
e ->
Logger.error("Unexpected error: #{inspect(e)}")
-
conn |> put_status(:internal_server_error) |> json(%{error: "Internal server error"})
end
@@ -51,7 +50,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do
defp get_permissions(_author, _params), do: {:error, :invalid_params}
- defp execute_command({:bounty, args}, author, params) do
+ defp execute_command(event_action, {:bounty, args}, author, params)
+ when event_action in ["issues.opened", "issues.edited", "issue_comment.created", "issue_comment.edited"] do
amount = args[:amount]
repo = params["repository"]
issue = params["issue"]
@@ -82,7 +82,8 @@ defmodule AlgoraWeb.Webhooks.GithubController do
end
end
- defp execute_command({:tip, args}, author, params) when not is_nil(args) do
+ defp execute_command(event_action, {:tip, args}, author, params)
+ when event_action in ["issue_comment.created", "issue_comment.edited"] do
amount = args[:amount]
recipient = args[:recipient]
repo = params["repository"]
@@ -113,27 +114,72 @@ defmodule AlgoraWeb.Webhooks.GithubController do
end
end
- defp execute_command({:claim, args}, _author, _params) when not is_nil(args) do
- owner = Keyword.get(args, :owner)
- repo = Keyword.get(args, :repo)
- number = Keyword.get(args, :number)
+ defp execute_command(event_action, {:claim, args}, author, params)
+ when event_action in ["pull_request.opened", "pull_request.reopened", "pull_request.edited"] do
+ installation_id = params["installation"]["id"]
+ pull_request = params["pull_request"]
+ repo = params["repository"]
- Logger.info("Claim #{owner}/#{repo}##{number}")
+ source_ticket_ref = %{
+ owner: repo["owner"]["login"],
+ repo: repo["name"],
+ number: pull_request["number"]
+ }
+
+ target_ticket_ref =
+ %{
+ owner: args[:ticket_ref][:owner] || source_ticket_ref.owner,
+ repo: args[:ticket_ref][:repo] || source_ticket_ref.repo,
+ number: args[:ticket_ref][:number]
+ }
+
+ with {:ok, token} <- Github.get_installation_token(installation_id),
+ {:ok, user} <- Workspace.ensure_user(token, author["login"]) do
+ Bounties.claim_bounty(
+ %{
+ user: user,
+ target_ticket_ref: target_ticket_ref,
+ source_ticket_ref: source_ticket_ref,
+ status: if(pull_request["merged_at"], do: :approved, else: :pending),
+ type: :pull_request
+ },
+ installation_id: installation_id
+ )
+ end
+ end
+
+ defp execute_command(_event_action, _command, _author, _params) do
+ {:ok, nil}
end
- defp execute_command({command, _} = args, _author, _params),
- do: Logger.info("Unhandled command: #{command} #{inspect(args)}")
+ def process_commands(%Webhook{event: event, hook_id: hook_id}, params) do
+ author = get_author(event, params)
+ body = get_body(event, params)
+
+ event_action = event <> "." <> params["action"]
- def process_commands(body, author, params) when is_binary(body) do
case Github.Command.parse(body) do
- {:ok, commands} -> Enum.map(commands, &execute_command(&1, author, params))
- # TODO: handle errors
- {:error, error} -> Logger.error("Error parsing commands: #{inspect(error)}")
+ {:ok, commands} ->
+ Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, results} ->
+ case execute_command(event_action, command, author, params) do
+ {:ok, result} ->
+ {:cont, {:ok, [result | results]}}
+
+ error ->
+ Logger.error(
+ "Command execution failed for #{event_action}(#{hook_id}): #{inspect(command)}: #{inspect(error)}"
+ )
+
+ {:halt, error}
+ end
+ end)
+
+ {:error, reason} = error ->
+ Logger.error("Error parsing commands: #{inspect(reason)}")
+ error
end
end
- def process_commands(_body, _author, _params), do: nil
-
defp get_author("issues", params), do: params["issue"]["user"]
defp get_author("issue_comment", params), do: params["comment"]["user"]
defp get_author("pull_request", params), do: params["pull_request"]["user"]
diff --git a/lib/algora_web/live/claim_live.ex b/lib/algora_web/live/claim_live.ex
new file mode 100644
index 000000000..77171a9e4
--- /dev/null
+++ b/lib/algora_web/live/claim_live.ex
@@ -0,0 +1,642 @@
+defmodule AlgoraWeb.ClaimLive do
+ @moduledoc false
+ use AlgoraWeb, :live_view
+
+ import Ecto.Changeset
+ import Ecto.Query
+
+ alias Algora.Bounties
+ alias Algora.Bounties.Claim
+ alias Algora.Bounties.LineItem
+ alias Algora.Github
+ alias Algora.Organizations
+ alias Algora.Repo
+ alias Algora.Util
+
+ require Logger
+
+ defp tip_options do
+ [
+ {"None", 0},
+ {"10%", 10},
+ {"20%", 20},
+ {"50%", 50}
+ ]
+ end
+
+ defmodule RewardBountyForm do
+ @moduledoc false
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key false
+ embedded_schema do
+ field :amount, :decimal
+ field :tip_percentage, :decimal
+ end
+
+ def changeset(form, attrs) do
+ form
+ |> cast(attrs, [:amount, :tip_percentage])
+ |> validate_required([:amount, :tip_percentage])
+ |> validate_number(:tip_percentage, greater_than_or_equal_to: 0)
+ |> validate_number(:amount, greater_than: 0)
+ end
+ end
+
+ @impl true
+ def mount(%{"group_id" => group_id}, _session, socket) do
+ claims =
+ from(c in Claim, where: c.group_id == ^group_id)
+ |> order_by(desc: :group_share)
+ |> Repo.all()
+ |> Repo.preload([
+ :user,
+ :transactions,
+ source: [repository: [:user]],
+ target: [repository: [:user], bounties: [:owner]]
+ ])
+
+ case claims do
+ [] ->
+ raise(AlgoraWeb.NotFoundError)
+
+ [primary_claim | _] ->
+ prize_pool =
+ primary_claim.target.bounties
+ |> Enum.map(& &1.amount)
+ |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2))
+
+ debits =
+ claims
+ |> Enum.flat_map(& &1.transactions)
+ |> Enum.filter(&(&1.type == :debit and &1.status == :succeeded))
+
+ total_paid =
+ debits
+ |> Enum.map(& &1.net_amount)
+ |> Enum.reduce(Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1, &2))
+
+ context =
+ if repo = primary_claim.source.repository do
+ "#{repo.user.provider_login}/#{repo.name}"
+ end
+
+ source_body_html =
+ with token when is_binary(token) <- Github.TokenPool.get_token(),
+ {:ok, source_body_html} <-
+ Github.render_markdown(token, primary_claim.source.description, context: context) do
+ source_body_html
+ else
+ _ -> primary_claim.source.description
+ end
+
+ pledges =
+ primary_claim.target.bounties
+ |> Enum.group_by(& &1.owner.id)
+ |> Map.new(fn {owner_id, bounties} ->
+ {owner_id,
+ {hd(bounties).owner,
+ Enum.reduce(bounties, Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1.amount, &2))}}
+ end)
+
+ payments =
+ debits
+ |> Enum.group_by(& &1.user_id)
+ |> Map.new(fn {user_id, debits} ->
+ {user_id, Enum.reduce(debits, Money.zero(:USD, no_fraction_if_integer: true), &Money.add!(&1.net_amount, &2))}
+ end)
+
+ sponsors =
+ pledges
+ |> Enum.map(fn {sponsor_id, {sponsor, pledged}} ->
+ paid = Map.get(payments, sponsor_id, Money.zero(:USD, no_fraction_if_integer: true))
+ tipped = Money.sub!(paid, pledged)
+
+ status =
+ cond do
+ Money.equal?(paid, pledged) -> :paid
+ Money.positive?(tipped) -> :overpaid
+ Money.positive?(paid) -> :partial
+ primary_claim.status == :approved -> :pending
+ true -> :none
+ end
+
+ %{
+ sponsor: sponsor,
+ status: status,
+ pledged: pledged,
+ paid: paid,
+ tipped: tipped
+ }
+ end)
+ |> Enum.sort_by(&{&1.pledged, &1.paid, &1.sponsor.name}, :desc)
+
+ source_or_target = primary_claim.source || primary_claim.target
+
+ contexts =
+ if socket.assigns.current_user do
+ Organizations.get_user_orgs(socket.assigns.current_user) ++ [socket.assigns.current_user]
+ else
+ []
+ end
+
+ context_ids = MapSet.new(contexts, & &1.id)
+ available_bounties = Enum.filter(primary_claim.target.bounties, &MapSet.member?(context_ids, &1.owner_id))
+
+ amount =
+ case available_bounties do
+ [] -> nil
+ [bounty | _] -> Money.to_decimal(bounty.amount)
+ end
+
+ changeset = RewardBountyForm.changeset(%RewardBountyForm{}, %{tip_percentage: 0, amount: amount})
+
+ {:ok,
+ socket
+ |> assign(:page_title, source_or_target.title)
+ |> assign(:claims, claims)
+ |> assign(:primary_claim, primary_claim)
+ |> assign(:target, primary_claim.target)
+ |> assign(:source, primary_claim.source)
+ |> assign(:source_or_target, source_or_target)
+ |> assign(:bounties, primary_claim.target.bounties)
+ |> assign(:prize_pool, prize_pool)
+ |> assign(:total_paid, total_paid)
+ |> assign(:source_body_html, source_body_html)
+ |> assign(:sponsors, sponsors)
+ |> assign(:contexts, contexts)
+ |> assign(:show_reward_bounty_modal, false)
+ |> assign(:available_bounties, available_bounties)
+ |> assign(:reward_bounty_form, to_form(changeset))}
+ end
+ end
+
+ @impl true
+ def handle_params(_params, _url, %{assigns: %{current_user: nil}} = socket) do
+ {:noreply, socket}
+ end
+
+ def handle_params(%{"context" => context_id}, _url, socket) do
+ {:noreply, socket |> assign_selected_context(context_id) |> assign_line_items()}
+ end
+
+ def handle_params(_params, _url, socket) do
+ {:noreply, socket |> assign_selected_context(default_context_id(socket)) |> assign_line_items()}
+ end
+
+ @impl true
+ def handle_event("reward_bounty", _params, %{assigns: %{current_user: nil}} = socket) do
+ {:noreply,
+ redirect(socket, to: ~p"/auth/login?#{%{return_to: ~p"/claims/#{socket.assigns.primary_claim.group_id}"}}")}
+ end
+
+ def handle_event("reward_bounty", _params, socket) do
+ {:noreply, assign(socket, :show_reward_bounty_modal, true)}
+ end
+
+ def handle_event("close_drawer", _params, socket) do
+ {:noreply, assign(socket, :show_reward_bounty_modal, false)}
+ end
+
+ def handle_event("validate_reward_bounty", %{"reward_bounty_form" => params}, socket) do
+ {:noreply,
+ socket
+ |> assign(:reward_bounty_form, to_form(RewardBountyForm.changeset(%RewardBountyForm{}, params)))
+ |> assign_line_items()}
+ end
+
+ def handle_event("split_bounty", _params, socket) do
+ # TODO: Implement split bounty
+ Logger.error(
+ "Attempt to split bounty #{socket.assigns.target.repository.user.provider_login}/#{socket.assigns.target.repository.name}#{socket.assigns.target.number}"
+ )
+
+ {:noreply, socket}
+ end
+
+ def handle_event("pay_with_stripe", %{"reward_bounty_form" => params}, socket) do
+ changeset = RewardBountyForm.changeset(%RewardBountyForm{}, params)
+
+ case apply_action(changeset, :save) do
+ {:ok, data} ->
+ with {:ok, bounty} <- get_or_create_bounty(socket, data),
+ {:ok, session_url} <- reward_bounty(socket, bounty, changeset) do
+ {:noreply, redirect(socket, external: session_url)}
+ else
+ {:error, reason} ->
+ Logger.error("Failed to create payment session: #{inspect(reason)}")
+ {:noreply, put_flash(socket, :error, "Something went wrong")}
+ end
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :reward_bounty_form, to_form(changeset))}
+ end
+ end
+
+ defp default_context_id(socket) do
+ case socket.assigns.available_bounties do
+ [] -> socket.assigns.current_user.id
+ [bounty | _] -> bounty.owner_id
+ end
+ end
+
+ defp assign_selected_context(socket, context_id) do
+ case Enum.find(socket.assigns.contexts, &(&1.id == context_id)) do
+ nil ->
+ push_patch(socket, to: "/claims/#{socket.assigns.primary_claim.group_id}?context=#{default_context_id(socket)}")
+
+ context ->
+ assign(socket, :selected_context, context)
+ end
+ end
+
+ defp assign_line_items(socket) do
+ line_items =
+ Bounties.generate_line_items(%{amount: calculate_final_amount(socket.assigns.reward_bounty_form.source)},
+ ticket_ref: %{
+ owner: socket.assigns.target.repository.user.provider_login,
+ repo: socket.assigns.target.repository.name,
+ number: socket.assigns.target.number
+ },
+ claims: socket.assigns.claims
+ )
+
+ assign(socket, :line_items, line_items)
+ end
+
+ defp ticket_ref(socket) do
+ %{
+ owner: socket.assigns.target.repository.user.provider_login,
+ repo: socket.assigns.target.repository.name,
+ number: socket.assigns.target.number
+ }
+ end
+
+ defp get_or_create_bounty(socket, data) do
+ case Enum.find(socket.assigns.available_bounties, &(&1.owner_id == socket.assigns.selected_context.id)) do
+ nil ->
+ Bounties.create_bounty(%{
+ creator: socket.assigns.current_user,
+ owner: socket.assigns.selected_context,
+ amount: Money.new!(:USD, data.amount),
+ ticket_ref: ticket_ref(socket)
+ })
+
+ bounty ->
+ {:ok, bounty}
+ end
+ end
+
+ defp reward_bounty(socket, bounty, changeset) do
+ final_amount = calculate_final_amount(changeset)
+
+ Bounties.reward_bounty(
+ %{
+ owner: socket.assigns.selected_context,
+ amount: final_amount,
+ bounty_id: bounty.id,
+ claims: socket.assigns.claims
+ },
+ ticket_ref: ticket_ref(socket)
+ )
+ end
+
+ defp calculate_final_amount(changeset) do
+ tip_percentage = get_field(changeset, :tip_percentage) || Decimal.new(0)
+ amount = get_field(changeset, :amount) || Decimal.new(0)
+
+ multiplier = tip_percentage |> Decimal.div(100) |> Decimal.add(1)
+ amount |> Money.new!(:USD) |> Money.mult!(multiplier)
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <.card>
+ <.card_header>
+
+ <.avatar class="h-12 w-12 rounded-full">
+ <.avatar_image src={@source_or_target.repository.user.avatar_url} />
+ <.avatar_fallback>
+ {String.first(@source_or_target.repository.user.provider_login)}
+
+
+
+ <.link
+ href={@source_or_target.url}
+ class="text-xl font-semibold hover:underline"
+ target="_blank"
+ >
+ {@source_or_target.title}
+
+
+ {@source_or_target.repository.user.provider_login}/{@source_or_target.repository.name}#{@source_or_target.number}
+
+
+
+
+ <.card_content>
+
+ {Phoenix.HTML.raw(@source_body_html)}
+
+
+
+
+
+
+ <.card>
+ <.card_header>
+
+ <.card_title>
+ Claim
+
+ <.button phx-click="reward_bounty">
+ Reward bounty
+
+
+
+ <.card_content>
+
+
+ Total prize pool
+
+ {Money.to_string!(@prize_pool)}
+
+
+
+ Total paid
+
+ {Money.to_string!(@total_paid)}
+
+
+
+ Status
+ {@primary_claim.status |> to_string() |> String.capitalize()}
+
+
+ Submitted
+ {Calendar.strftime(@primary_claim.inserted_at, "%B %d, %Y")}
+
+
+ Last updated
+ {Calendar.strftime(@primary_claim.updated_at, "%B %d, %Y")}
+
+
+
+
+
+ <.card>
+ <.card_header>
+
+ <.card_title>
+ Authors
+
+
+ <.button variant="secondary" phx-click="split_bounty">
+ Split bounty
+
+
+
+ <.card_content>
+
+ <%= for claim <- @claims do %>
+
+
+
+ <.avatar>
+ <.avatar_image src={claim.user.avatar_url} />
+ <.avatar_fallback>{String.first(claim.user.name)}
+
+
+
{claim.user.name}
+
@{claim.user.handle}
+
+
+
+
+
+ {Util.format_pct(claim.group_share)}
+
+
+
+ <% end %>
+
+
+
+
+ <.card>
+ <.card_header>
+ <.card_title>
+ Sponsors
+
+
+ <.card_content>
+
+ <%= for sponsor <- @sponsors do %>
+
+
+ <.avatar>
+ <.avatar_image src={sponsor.sponsor.avatar_url} />
+ <.avatar_fallback>
+ {String.first(sponsor.sponsor.name)}
+
+
+
+
{sponsor.sponsor.name}
+
@{sponsor.sponsor.handle}
+
+
+
+
+ <%= case sponsor.status do %>
+ <% :overpaid -> %>
+
+
+ {Money.to_string!(Money.sub!(sponsor.paid, sponsor.tipped))}
+
+ paid
+
+
+
+ +{Money.to_string!(sponsor.tipped)}
+
+ tip!
+
+ <% :paid -> %>
+
+
+ {Money.to_string!(sponsor.paid)}
+
+ paid
+
+ <% :partial -> %>
+
+
+ {Money.to_string!(sponsor.paid)}
+
+ paid
+
+
+
+ {Money.to_string!(Money.sub!(sponsor.pledged, sponsor.paid))}
+
+ pending
+
+ <% :pending -> %>
+
+
+ {Money.to_string!(sponsor.pledged)}
+
+ pending
+
+ <% :none -> %>
+
+
+ {Money.to_string!(sponsor.pledged)}
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
+
+
+ <.drawer :if={@current_user} show={@show_reward_bounty_modal} on_cancel="close_drawer">
+ <.drawer_header>
+ <.drawer_title>Reward Bounty
+ <.drawer_description>
+ You can pay the full bounty now or start with a partial amount - it's up to you!
+
+
+ <.drawer_content class="mt-4">
+ <.form
+ for={@reward_bounty_form}
+ phx-change="validate_reward_bounty"
+ phx-submit="pay_with_stripe"
+ >
+
+
+ <.card>
+ <.card_header>
+ <.card_title>Payment Details
+
+ <.card_content>
+
+ <%= if Enum.empty?(@available_bounties) do %>
+ <.alert variant="destructive">
+ <.alert_title>No bounties available
+ <.alert_description>
+ You didn't post a bounty for this issue. Would you like to create one now?
+
+
+ <% end %>
+ <.input
+ label="Amount"
+ icon="tabler-currency-dollar"
+ field={@reward_bounty_form[:amount]}
+ />
+
+
+ <.label>On behalf of
+ <.dropdown2 id="context-dropdown" class="mt-2">
+ <:img src={@selected_context.avatar_url} />
+ <:title>{@selected_context.name}
+ <:subtitle>@{@selected_context.handle}
+
+ <:link
+ :for={context <- @contexts |> Enum.reject(&(&1.id == @selected_context.id))}
+ patch={"?context=#{context.id}"}
+ >
+
+
+
+
{context.name}
+
@{context.handle}
+
+
+
+
+
+
+
+ <.label>Tip
+
+ <.radio_group
+ class="grid grid-cols-4 gap-4"
+ field={@reward_bounty_form[:tip_percentage]}
+ options={tip_options()}
+ />
+
+
+
+
+
+ <.card>
+ <.card_header>
+ <.card_title>Payment Summary
+
+ <.card_content>
+
+ <%= for line_item <- @line_items do %>
+
+
+ <%= if line_item.image do %>
+ <.avatar>
+ <.avatar_image src={line_item.image} />
+
+ <% else %>
+
+ <% end %>
+
+
{line_item.title}
+
{line_item.description}
+
+
+
+ {Money.to_string!(line_item.amount)}
+
+
+ <% end %>
+
+
+
+
+ Total due
+
+
+ {LineItem.gross_amount(@line_items)}
+
+
+
+
+
+
+
+ <.button variant="secondary" phx-click="close_drawer" type="button">
+ Cancel
+
+ <.button type="submit">
+ Pay with Stripe <.icon name="tabler-arrow-right" class="-mr-1 ml-2 h-4 w-4" />
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/algora_web/live/org/bounty_hook.ex b/lib/algora_web/live/org/bounty_hook.ex
index 179db5d05..a9a9cedeb 100644
--- a/lib/algora_web/live/org/bounty_hook.ex
+++ b/lib/algora_web/live/org/bounty_hook.ex
@@ -27,9 +27,6 @@ defmodule AlgoraWeb.Org.BountyHook do
{:error, :already_exists} ->
{:halt, put_flash(socket, :warning, "You have already created a bounty for this ticket")}
- {:error, :internal_server_error} ->
- {:halt, put_flash(socket, :error, "Something went wrong")}
-
{:error, _reason} ->
changeset = add_error(socket.assigns.new_bounty_form.changeset, :github_issue_url, "Invalid URL")
{:halt, assign(socket, :new_bounty_form, to_form(changeset))}
diff --git a/lib/algora_web/live/org/create_bounty_live.ex b/lib/algora_web/live/org/create_bounty_live.ex
index 466d780f4..6a744503c 100644
--- a/lib/algora_web/live/org/create_bounty_live.ex
+++ b/lib/algora_web/live/org/create_bounty_live.ex
@@ -468,9 +468,6 @@ defmodule AlgoraWeb.Org.CreateBountyLive do
{:error, :already_exists} ->
{:noreply, put_flash(socket, :warning, "You have already created a bounty for this ticket")}
- {:error, :internal_server_error} ->
- {:noreply, put_flash(socket, :error, "Something went wrong")}
-
{:error, _reason} ->
changeset = add_error(socket.assigns.new_bounty_form.changeset, :github_issue_url, "Invalid URL")
{:noreply, assign(socket, :new_bounty_form, to_form(changeset))}
diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex
index 7d7589567..81819494b 100644
--- a/lib/algora_web/router.ex
+++ b/lib/algora_web/router.ex
@@ -114,6 +114,7 @@ defmodule AlgoraWeb.Router do
live "/payment/success", Payment.SuccessLive, :index
live "/payment/canceled", Payment.CanceledLive, :index
live "/@/:handle", User.ProfileLive, :index
+ live "/claims/:group_id", ClaimLive
end
live "/orgs/new", Org.CreateLive
diff --git a/priv/repo/migrations/20250112164132_recreate_claims.exs b/priv/repo/migrations/20250112164132_recreate_claims.exs
new file mode 100644
index 000000000..acf28265e
--- /dev/null
+++ b/priv/repo/migrations/20250112164132_recreate_claims.exs
@@ -0,0 +1,73 @@
+defmodule Algora.Repo.Migrations.RecreateClaims do
+ use Ecto.Migration
+
+ def up do
+ drop index(:claims, [:bounty_id])
+ drop index(:claims, [:user_id])
+ drop table(:claims)
+
+ create table(:claims) do
+ add :status, :string, null: false
+ add :type, :string, null: false
+ add :url, :string, null: false
+ add :group_id, :string, null: false
+ add :group_share, :decimal, null: false, default: 1.0
+
+ add :source_id, references(:tickets, on_delete: :nothing), null: false
+ add :target_id, references(:tickets, on_delete: :nothing), null: false
+ add :user_id, references(:users, on_delete: :nothing), null: false
+
+ timestamps()
+ end
+
+ alter table(:transactions) do
+ add :claim_id, references(:claims, on_delete: :nothing)
+ end
+
+ create unique_index(:claims, [:group_id, :user_id])
+ create index(:claims, [:source_id])
+ create index(:claims, [:target_id])
+ create index(:claims, [:user_id])
+ end
+
+ def down do
+ drop index(:claims, [:group_id, :user_id])
+ drop index(:claims, [:source_id])
+ drop index(:claims, [:target_id])
+ drop index(:claims, [:user_id])
+ drop table(:claims)
+
+ alter table(:transactions) do
+ remove :claim_id
+ end
+
+ create table(:claims) do
+ add :provider, :string
+ add :provider_id, :string
+ add :provider_meta, :map
+
+ add :type, :string
+
+ add :status, :string
+
+ add :merged_at, :utc_datetime_usec
+ add :approved_at, :utc_datetime_usec
+ add :rejected_at, :utc_datetime_usec
+ add :charged_at, :utc_datetime_usec
+ add :paid_at, :utc_datetime_usec
+
+ add :title, :string
+ add :description, :string
+ add :url, :string
+ add :group_id, :string
+
+ add :bounty_id, references(:bounties, on_delete: :nothing)
+ add :user_id, references(:users, on_delete: :nothing)
+
+ timestamps()
+ end
+
+ create index(:claims, [:bounty_id])
+ create index(:claims, [:user_id])
+ end
+end
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index ff4c9a103..f3eb2d30a 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -77,6 +77,7 @@ richard =
email: "richard@example.com",
display_name: "Richard Hendricks",
handle: "richard",
+ provider_login: "richard",
bio: "CEO of Pied Piper. Creator of the middle-out compression algorithm.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/richard.jpg",
tech_stack: ["Python", "C++"],
@@ -94,6 +95,7 @@ dinesh =
email: "dinesh@example.com",
display_name: "Dinesh Chugtai",
handle: "dinesh",
+ provider_login: "dinesh",
bio: "Lead Frontend Engineer at Pied Piper. Java bad, Python good.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/dinesh.png",
tech_stack: ["Python", "JavaScript"],
@@ -111,6 +113,7 @@ gilfoyle =
email: "gilfoyle@example.com",
display_name: "Bertram Gilfoyle",
handle: "gilfoyle",
+ provider_login: "gilfoyle",
bio: "Systems Architect. Security. DevOps. Satanist.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/gilfoyle.jpg",
tech_stack: ["Python", "Rust", "Go", "Terraform"],
@@ -128,6 +131,7 @@ jared =
email: "jared@example.com",
display_name: "Jared Dunn",
handle: "jared",
+ provider_login: "jared",
bio: "COO of Pied Piper. Former Hooli executive. Excel wizard.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/jared.png",
tech_stack: ["Python", "SQL"],
@@ -145,6 +149,7 @@ carver =
email: "carver@example.com",
display_name: "Kevin 'The Carver'",
handle: "carver",
+ provider_login: "carver",
bio:
"Cloud architecture specialist. If your infrastructure needs a teardown, I'm your guy. Known for my 'insane' cloud architectures and occasional server incidents.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/carver.jpg",
@@ -313,133 +318,6 @@ end
Logger.info("Contract: #{AlgoraWeb.Endpoint.url()}/org/#{pied_piper.handle}/contracts/#{initial_contract.id}")
-repos = [
- {
- "middle-out",
- [
- "Optimize algorithm performance",
- "Add support for new file types",
- "Improve error handling",
- "Implement streaming compression",
- "Add compression statistics API"
- ]
- },
- {
- "pied-piper-web",
- [
- "Fix memory leak in upload handler",
- "Implement new dashboard UI",
- "Add real-time compression stats",
- "Integrate SSO authentication",
- "Build file comparison view"
- ]
- },
- {
- "infra",
- [
- "Scale kubernetes cluster",
- "Implement auto-scaling",
- "Optimize cloud costs",
- "Set up monitoring and alerts",
- "Configure disaster recovery"
- ]
- }
-]
-
-for {repo_name, issues} <- repos do
- repo =
- insert!(:repository, %{
- name: repo_name,
- url: "https://github.com/piedpiper/#{repo_name}",
- user_id: pied_piper.id
- })
-
- for {issue_title, index} <- Enum.with_index(issues, 1) do
- ticket =
- insert!(:ticket, %{
- repository_id: repo.id,
- title: issue_title,
- description: "We need help implementing this feature to improve our platform.",
- number: index,
- url: "https://github.com/piedpiper/#{repo_name}/issues/#{index}"
- })
-
- amount = Money.new!(Enum.random([500, 1000, 1500, 2000]), :USD)
-
- claimed = rem(index, 2) > 0
- paid = claimed and rem(index, 3) > 0
-
- bounty =
- insert!(:bounty, %{
- ticket_id: ticket.id,
- owner_id: pied_piper.id,
- creator_id: richard.id,
- amount: amount,
- status: if(paid, do: :paid, else: :open)
- })
-
- if not claimed do
- pied_piper_members
- |> Enum.take_random(Enum.random(0..(length(pied_piper_members) - 1)))
- |> Enum.each(fn member ->
- amount = Money.new!(Enum.random([500, 1000, 1500, 2000]), :USD)
-
- insert!(:bounty, %{
- ticket_id: ticket.id,
- owner_id: member.id,
- creator_id: member.id,
- amount: amount,
- status: :open
- })
- end)
- end
-
- if claimed do
- claim =
- insert!(:claim, %{
- bounty_id: bounty.id,
- user_id: carver.id,
- status: if(paid, do: :paid, else: :pending),
- title: "Implementation for #{issue_title}",
- description: "Here's my solution to this issue.",
- url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}"
- })
-
- # Create transaction pairs for paid claims
- if paid do
- debit_id = Nanoid.generate()
- credit_id = Nanoid.generate()
-
- Repo.transact(fn ->
- insert!(:transaction, %{
- id: debit_id,
- linked_transaction_id: credit_id,
- bounty_id: bounty.id,
- type: :debit,
- status: :succeeded,
- net_amount: amount,
- user_id: pied_piper.id,
- succeeded_at: claim.inserted_at
- })
-
- insert!(:transaction, %{
- id: credit_id,
- linked_transaction_id: debit_id,
- bounty_id: bounty.id,
- type: :credit,
- status: :succeeded,
- net_amount: amount,
- user_id: carver.id,
- succeeded_at: claim.inserted_at
- })
-
- {:ok, :ok}
- end)
- end
- end
- end
-end
-
big_head =
upsert!(
:user,
@@ -448,6 +326,7 @@ big_head =
email: "bighead@example.com",
display_name: "Nelson Bighetti",
handle: "bighead",
+ provider_login: "bighead",
bio: "Former Hooli executive. Accidental tech success. Stanford President.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/bighead.jpg",
tech_stack: ["Python", "JavaScript"],
@@ -466,6 +345,7 @@ jian_yang =
email: "jianyang@example.com",
display_name: "Jian Yang",
handle: "jianyang",
+ provider_login: "jianyang",
bio: "App developer. Creator of SeeFood and Smokation.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/jianyang.jpg",
tech_stack: ["Swift", "Python", "TensorFlow"],
@@ -484,6 +364,7 @@ john =
email: "john@example.com",
display_name: "John Stafford",
handle: "john",
+ provider_login: "john",
bio: "Datacenter infrastructure expert. Rack space optimization specialist.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/john.png",
tech_stack: ["Perl", "Terraform", "C++", "C"],
@@ -502,6 +383,7 @@ aly =
email: "aly@example.com",
display_name: "Aly Dutta",
handle: "aly",
+ provider_login: "aly",
bio: "Former Hooli engineer. Expert in distributed systems and scalability.",
avatar_url: "https://algora.io/asset/storage/v1/object/public/mock/aly.png",
tech_stack: ["Java", "Kotlin", "Go"],
@@ -542,6 +424,180 @@ for user <- [aly, big_head, jian_yang, john] do
end)
end
+repos = [
+ {
+ "middle-out",
+ [
+ "Optimize algorithm performance",
+ "Add support for new file types",
+ "Improve error handling",
+ "Implement streaming compression",
+ "Add compression statistics API"
+ ]
+ },
+ {
+ "pied-piper-web",
+ [
+ "Fix memory leak in upload handler",
+ "Implement new dashboard UI",
+ "Add real-time compression stats",
+ "Integrate SSO authentication",
+ "Build file comparison view"
+ ]
+ },
+ {
+ "infra",
+ [
+ "Scale kubernetes cluster",
+ "Implement auto-scaling",
+ "Optimize cloud costs",
+ "Set up monitoring and alerts",
+ "Configure disaster recovery"
+ ]
+ }
+]
+
+for {repo_name, issues} <- repos do
+ repo =
+ insert!(:repository, %{
+ name: repo_name,
+ url: "https://github.com/piedpiper/#{repo_name}",
+ user_id: pied_piper.id
+ })
+
+ for {issue_title, index} <- Enum.with_index(issues, 1) do
+ issue =
+ insert!(:ticket, %{
+ type: :issue,
+ repository_id: repo.id,
+ title: issue_title,
+ description: "We need help implementing this feature to improve our platform.",
+ number: index,
+ url: "https://github.com/piedpiper/#{repo_name}/issues/#{index}"
+ })
+
+ claimed = rem(index, 2) > 0
+ paid = claimed and rem(index, 3) > 0
+
+ bounties =
+ [2000, 500, 400, 300, 200, 100]
+ |> Enum.map(&Money.new!(&1, :USD))
+ |> Enum.zip([pied_piper | pied_piper_members])
+ |> Enum.map(fn {amount, sponsor} ->
+ insert!(:bounty, %{
+ ticket_id: issue.id,
+ owner_id: sponsor.id,
+ creator_id: sponsor.id,
+ amount: amount,
+ status: if(paid, do: :paid, else: :open)
+ })
+ end)
+
+ if claimed do
+ pull_request =
+ insert!(:ticket, %{
+ type: :pull_request,
+ repository_id: repo.id,
+ title: "Fix memory leak in upload handler and optimize buffer allocation",
+ description: """
+ This PR addresses the memory leak in the file upload handler by:
+ - Implementing proper buffer cleanup in the streaming pipeline
+ - Adding automatic resource disposal using with-clauses
+ - Optimizing memory allocation for large file uploads
+ - Adding memory usage monitoring
+
+ Testing shows a 60% reduction in memory usage during sustained uploads.
+
+ Key changes:
+ ```python
+ def process_upload(file_stream):
+ try:
+ with MemoryManager.track() as memory:
+ for chunk in file_stream:
+ # Optimize buffer allocation
+ buffer = BytesIO(initial_size=chunk.size)
+ compressed = middle_out.compress(chunk, buffer)
+ yield compressed
+
+ memory.log_usage("Upload complete")
+ finally:
+ buffer.close()
+ gc.collect() # Force cleanup
+ ```
+
+ Closes ##{index}
+ """,
+ number: index + length(issues),
+ url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}"
+ })
+
+ group_id = Nanoid.generate()
+
+ claimants =
+ [carver, aly, big_head]
+ |> Enum.zip(["0.5", "0.3", "0.2"])
+ |> Enum.map(fn {user, share} -> {user, Decimal.new(share)} end)
+
+ for {user, share} <- claimants do
+ claim =
+ insert!(:claim, %{
+ group_id: group_id,
+ group_share: share,
+ user_id: user.id,
+ target_id: issue.id,
+ source_id: pull_request.id,
+ type: :pull_request,
+ status: if(paid, do: :approved, else: :pending),
+ url: "https://github.com/piedpiper/#{repo_name}/pull/#{index}"
+ })
+
+ if paid do
+ for {pct_paid, bounty} <-
+ ["1.25", "1.0", "1.0", "0.5", "0.0", "0.0"]
+ |> Enum.map(&Decimal.new/1)
+ |> Enum.zip(bounties) do
+ debit_id = Nanoid.generate()
+ credit_id = Nanoid.generate()
+
+ net_paid = Money.mult!(bounty.amount, Decimal.mult(share, pct_paid))
+
+ # Create transaction pairs for paid claims
+ Repo.transact(fn ->
+ insert!(:transaction, %{
+ id: debit_id,
+ linked_transaction_id: credit_id,
+ bounty_id: bounty.id,
+ claim_id: claim.id,
+ type: :debit,
+ status: :succeeded,
+ net_amount: net_paid,
+ user_id: bounty.owner_id,
+ succeeded_at: claim.inserted_at
+ })
+
+ insert!(:transaction, %{
+ id: credit_id,
+ linked_transaction_id: debit_id,
+ bounty_id: bounty.id,
+ claim_id: claim.id,
+ type: :credit,
+ status: :succeeded,
+ net_amount: net_paid,
+ user_id: user.id,
+ succeeded_at: claim.inserted_at
+ })
+
+ {:ok, :ok}
+ end)
+ end
+ end
+
+ Logger.info("Claim [#{claim.status}]: #{AlgoraWeb.Endpoint.url()}/claims/#{claim.group_id}")
+ end
+ end
+ end
+end
+
reviews = [
{richard, carver, -1,
"His cloud architecture is... unconventional, but it works. Like, really works. Our servers haven't crashed in weeks. Just wish he'd document things better."},
diff --git a/test/algora_web/controllers/webhooks/github_controller_test.exs b/test/algora_web/controllers/webhooks/github_controller_test.exs
index 6b3070230..52cd98f7a 100644
--- a/test/algora_web/controllers/webhooks/github_controller_test.exs
+++ b/test/algora_web/controllers/webhooks/github_controller_test.exs
@@ -5,6 +5,7 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do
import Money.Sigil
import Mox
+ alias Algora.Github.Webhook
alias AlgoraWeb.Webhooks.GithubController
setup :verify_on_exit!
@@ -15,7 +16,20 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do
@repo_name "repo"
@installation_id 123
+ @webhook %Webhook{
+ event: "issue_comment",
+ hook_id: "123456789",
+ delivery: "00000000-0000-0000-0000-000000000000",
+ signature: "sha1=0000000000000000000000000000000000000000",
+ signature_256: "sha256=0000000000000000000000000000000000000000000000000000000000000000",
+ user_agent: "GitHub-Hookshot/0000000",
+ installation_type: "integration",
+ installation_id: "123456"
+ }
+
@params %{
+ "id" => 123,
+ "action" => "created",
"repository" => %{
"owner" => %{"login" => @repo_owner},
"name" => @repo_name
@@ -47,57 +61,66 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do
@tag user: @unauthorized_user
test "handles bounty command with unauthorized user", %{user: user} do
- assert process_bounty_command("/bounty $100", user)[:ok] == nil
- assert process_bounty_command("/bounty $100", user)[:error] == :unauthorized
+ assert {:error, :unauthorized} = process_bounty_command("/bounty $100", user)
end
test "handles bounty command without amount" do
- assert process_bounty_command("/bounty")[:ok] == nil
- assert process_bounty_command("/bounty")[:error] == nil
+ assert {:ok, []} = process_bounty_command("/bounty")
end
test "handles valid bounty command with $ prefix" do
- assert process_bounty_command("/bounty $100")[:ok].amount == ~M[100]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty $100")
+ assert bounty.amount == ~M[100]usd
end
test "handles invalid bounty command with $ suffix" do
- assert process_bounty_command("/bounty 100$")[:ok].amount == ~M[100]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 100$")
+ assert bounty.amount == ~M[100]usd
end
test "handles bounty command without $ symbol" do
- assert process_bounty_command("/bounty 100")[:ok].amount == ~M[100]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 100")
+ assert bounty.amount == ~M[100]usd
end
test "handles bounty command with decimal amount" do
- assert process_bounty_command("/bounty 100.50")[:ok].amount == ~M[100.50]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 100.50")
+ assert bounty.amount == ~M[100.50]usd
end
test "handles bounty command with partial decimal amount" do
- assert process_bounty_command("/bounty 100.5")[:ok].amount == ~M[100.5]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 100.5")
+ assert bounty.amount == ~M[100.5]usd
end
test "handles bounty command with decimal amount and $ prefix" do
- assert process_bounty_command("/bounty $100.50")[:ok].amount == ~M[100.50]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty $100.50")
+ assert bounty.amount == ~M[100.50]usd
end
test "handles bounty command with partial decimal amount and $ prefix" do
- assert process_bounty_command("/bounty $100.5")[:ok].amount == ~M[100.5]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty $100.5")
+ assert bounty.amount == ~M[100.5]usd
end
test "handles bounty command with decimal amount and $ suffix" do
- assert process_bounty_command("/bounty 100.50$")[:ok].amount == ~M[100.50]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 100.50$")
+ assert bounty.amount == ~M[100.50]usd
end
test "handles bounty command with partial decimal amount and $ suffix" do
- assert process_bounty_command("/bounty 100.5$")[:ok].amount == ~M[100.5]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 100.5$")
+ assert bounty.amount == ~M[100.5]usd
end
test "handles bounty command with comma separator" do
- assert process_bounty_command("/bounty 1,000")[:ok].amount == ~M[1000]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 1,000")
+ assert bounty.amount == ~M[1000]usd
end
test "handles bounty command with comma separator and decimal amount" do
- assert process_bounty_command("/bounty 1,000.50")[:ok].amount == ~M[1000.50]usd
+ assert {:ok, [bounty]} = process_bounty_command("/bounty 1,000.50")
+ assert bounty.amount == ~M[1000.50]usd
end
end
@@ -184,14 +207,17 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do
end
# Helper function to process bounty commands
- defp process_bounty_command(body, author \\ @admin_user) do
- full_body = """
+ defp process_bounty_command(command, author \\ @admin_user) do
+ body = """
Lorem
- ipsum #{body} dolor
+ ipsum #{command} dolor
sit
amet
"""
- GithubController.process_commands(full_body, %{"login" => author}, @params)
+ GithubController.process_commands(
+ @webhook,
+ Map.put(@params, "comment", %{"user" => %{"login" => author}, "body" => body})
+ )
end
end
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 0a8dda7f6..a7de935cd 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -66,7 +66,10 @@ defmodule Algora.Factory do
twitter_url: "https://twitter.com/piedpiper",
github_url: "https://github.com/piedpiper",
discord_url: "https://discord.gg/piedpiper",
- slack_url: "https://piedpiper.slack.com"
+ slack_url: "https://piedpiper.slack.com",
+ provider: "github",
+ provider_login: "piedpiper",
+ provider_id: sequence(:provider_id, &"#{&1}")
}
end
@@ -195,16 +198,13 @@ defmodule Algora.Factory do
end
def claim_factory do
+ id = Nanoid.generate()
+
%Algora.Bounties.Claim{
- id: Nanoid.generate(),
- provider: "github",
- provider_id: sequence(:provider_id, &"#{&1}"),
- type: :code,
- status: :pending,
- title: "Implemented compression optimization",
- description: "Added parallel processing for large files",
- url: "https://github.com/piedpiper/middle-out/pull/2",
- provider_meta: %{}
+ id: id,
+ group_id: id,
+ type: :pull_request,
+ status: :pending
}
end