Skip to content

Commit 7c4320e

Browse files
committed
feat: bounty splits (#34)
1 parent 6718067 commit 7c4320e

File tree

9 files changed

+180
-54
lines changed

9 files changed

+180
-54
lines changed

lib/algora/bounties/bounties.ex

Lines changed: 85 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -127,42 +127,96 @@ defmodule Algora.Bounties do
127127
end
128128

129129
@spec do_claim_bounty(%{
130-
user: User.t(),
130+
provider_login: String.t(),
131+
token: String.t(),
131132
target: Ticket.t(),
132133
source: Ticket.t(),
134+
group_id: String.t() | nil,
135+
group_share: Decimal.t(),
133136
status: :pending | :approved | :rejected | :paid,
134137
type: :pull_request | :review | :video | :design | :article
135138
}) ::
136139
{:ok, Claim.t()} | {:error, atom()}
137-
defp do_claim_bounty(%{user: user, target: target, source: source, status: status, type: type}) do
138-
# TODO: ensure user is pull request author
139-
changeset =
140-
Claim.changeset(%Claim{}, %{
141-
target_id: target.id,
142-
source_id: source.id,
143-
user_id: user.id,
144-
type: type,
145-
status: status,
146-
url: source.url
147-
})
148-
149-
activity_attrs = %{type: :claim_submitted, notify_users: [user.id]}
150-
151-
case Repo.insert_with_activity(changeset, activity_attrs) do
152-
{:ok, claim} ->
153-
{:ok, claim}
154-
140+
defp do_claim_bounty(%{
141+
provider_login: provider_login,
142+
token: token,
143+
target: target,
144+
source: source,
145+
group_id: group_id,
146+
group_share: group_share,
147+
status: status,
148+
type: type
149+
}) do
150+
with {:ok, user} <- Workspace.ensure_user(token, provider_login),
151+
activity_attrs = %{type: :claim_submitted, notify_users: [user.id]},
152+
{:ok, claim} <-
153+
Repo.insert_with_activity(
154+
Claim.changeset(%Claim{}, %{
155+
target_id: target.id,
156+
source_id: source.id,
157+
user_id: user.id,
158+
type: type,
159+
status: status,
160+
url: source.url,
161+
group_id: group_id,
162+
group_share: group_share
163+
}),
164+
activity_attrs
165+
) do
166+
{:ok, claim}
167+
else
155168
{:error, %{errors: [target_id: {_, [constraint: :unique, constraint_name: _]}]}} ->
156169
{:error, :already_exists}
157170

158-
{:error, _changeset} = error ->
171+
{:error, _reason} = error ->
159172
error
160173
end
161174
end
162175

176+
@spec do_claim_bounties(%{
177+
provider_logins: [String.t()],
178+
token: String.t(),
179+
target: Ticket.t(),
180+
source: Ticket.t(),
181+
status: :pending | :approved | :rejected | :paid,
182+
type: :pull_request | :review | :video | :design | :article
183+
}) ::
184+
{:ok, [Claim.t()]} | {:error, atom()}
185+
defp do_claim_bounties(%{
186+
provider_logins: provider_logins,
187+
token: token,
188+
target: target,
189+
source: source,
190+
status: status,
191+
type: type
192+
}) do
193+
Enum.reduce_while(provider_logins, {:ok, []}, fn provider_login, {:ok, acc} ->
194+
group_id =
195+
case List.last(acc) do
196+
nil -> nil
197+
primary_claim -> primary_claim.group_id
198+
end
199+
200+
case do_claim_bounty(%{
201+
provider_login: provider_login,
202+
token: token,
203+
target: target,
204+
source: source,
205+
status: status,
206+
type: type,
207+
group_id: group_id,
208+
group_share: Decimal.div(1, length(provider_logins))
209+
}) do
210+
{:ok, claim} -> {:cont, {:ok, [claim | acc]}}
211+
error -> {:halt, error}
212+
end
213+
end)
214+
end
215+
163216
@spec claim_bounty(
164217
%{
165218
user: User.t(),
219+
coauthor_provider_logins: [String.t()],
166220
target_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
167221
source_ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
168222
status: :pending | :approved | :rejected | :paid,
@@ -174,6 +228,7 @@ defmodule Algora.Bounties do
174228
def claim_bounty(
175229
%{
176230
user: user,
231+
coauthor_provider_logins: coauthor_provider_logins,
177232
target_ticket_ref: %{owner: target_repo_owner, repo: target_repo_name, number: target_number},
178233
source_ticket_ref: %{owner: source_repo_owner, repo: source_repo_name, number: source_number},
179234
status: status,
@@ -192,7 +247,15 @@ defmodule Algora.Bounties do
192247
with {:ok, token} <- token_res,
193248
{:ok, target} <- Workspace.ensure_ticket(token, target_repo_owner, target_repo_name, target_number),
194249
{:ok, source} <- Workspace.ensure_ticket(token, source_repo_owner, source_repo_name, source_number),
195-
{:ok, claim} <- do_claim_bounty(%{user: user, target: target, source: source, status: status, type: type}),
250+
{:ok, [claim | _]} <-
251+
do_claim_bounties(%{
252+
provider_logins: [user.provider_login | coauthor_provider_logins],
253+
token: token,
254+
target: target,
255+
source: source,
256+
status: status,
257+
type: type
258+
}),
196259
{:ok, _job} <- notify_claim(%{claim: claim}, installation_id: installation_id) do
197260
broadcast()
198261
{:ok, claim}
@@ -208,7 +271,7 @@ defmodule Algora.Bounties do
208271
) ::
209272
{:ok, Oban.Job.t()} | {:error, atom()}
210273
def notify_claim(%{claim: claim}, opts \\ []) do
211-
%{claim_id: claim.id, installation_id: opts[:installation_id]}
274+
%{claim_group_id: claim.group_id, installation_id: opts[:installation_id]}
212275
|> Jobs.NotifyClaim.new()
213276
|> Oban.insert()
214277
end

lib/algora/bounties/jobs/notify_claim.ex

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,76 @@ defmodule Algora.Bounties.Jobs.NotifyClaim do
22
@moduledoc false
33
use Oban.Worker, queue: :notify_claim
44

5+
import Ecto.Query
6+
57
alias Algora.Bounties.Claim
68
alias Algora.Github
79
alias Algora.Repo
810

911
require Logger
1012

1113
@impl Oban.Worker
12-
def perform(%Oban.Job{args: %{"claim_id" => _claim_id, "installation_id" => nil}}) do
14+
def perform(%Oban.Job{args: %{"claim_group_id" => _claim_group_id, "installation_id" => nil}}) do
1315
:ok
1416
end
1517

1618
@impl Oban.Worker
17-
def perform(%Oban.Job{args: %{"claim_id" => claim_id, "installation_id" => installation_id}}) do
19+
def perform(%Oban.Job{args: %{"claim_group_id" => claim_group_id, "installation_id" => installation_id}}) do
1820
with {:ok, token} <- Github.get_installation_token(installation_id),
19-
{:ok, claim} <- Repo.fetch(Claim, claim_id),
20-
claim = Repo.preload(claim, source: [repository: [:user]], target: [repository: [:user]], user: []),
21-
{:ok, _} <- maybe_add_labels(token, claim),
22-
{:ok, _} <- add_comment(token, claim) do
21+
claims =
22+
from(c in Claim,
23+
where: c.group_id == ^claim_group_id,
24+
order_by: [asc: c.inserted_at]
25+
)
26+
|> Repo.all()
27+
|> Repo.preload([:user, source: [repository: [:user]], target: [repository: [:user]]]),
28+
{:ok, _} <- maybe_add_labels(token, claims),
29+
{:ok, _} <- add_comment(token, claims) do
2330
:ok
2431
end
2532
end
2633

27-
defp add_comment(token, claim) do
34+
defp add_comment(token, claims) do
35+
primary_claim = List.first(claims)
36+
2837
Github.create_issue_comment(
2938
token,
30-
claim.target.repository.user.provider_login,
31-
claim.target.repository.name,
32-
claim.target.number,
33-
"💡 @#{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."
39+
primary_claim.target.repository.user.provider_login,
40+
primary_claim.target.repository.name,
41+
primary_claim.target.number,
42+
"💡 #{names(claims)} submitted [#{Claim.type_label(primary_claim.type)}](#{primary_claim.url}) that claims the bounty. You can visit [Algora](#{Claim.reward_url(primary_claim)}) to reward."
3443
)
3544
end
3645

37-
defp maybe_add_labels(token, %Claim{source: source} = claim) when not is_nil(source) do
38-
Github.add_labels(
39-
token,
40-
claim.source.repository.user.provider_login,
41-
claim.source.repository.name,
42-
claim.source.number,
43-
["🙋 Bounty claim"]
44-
)
46+
defp maybe_add_labels(token, claims) do
47+
primary_claim = List.first(claims)
48+
49+
if primary_claim.source do
50+
Github.add_labels(
51+
token,
52+
primary_claim.source.repository.user.provider_login,
53+
primary_claim.source.repository.name,
54+
primary_claim.source.number,
55+
["🙋 Bounty claim"]
56+
)
57+
else
58+
{:ok, nil}
59+
end
4560
end
4661

47-
defp maybe_add_labels(_token, _claim), do: {:ok, nil}
62+
defp names([claim]) do
63+
"@#{claim.user.provider_login}"
64+
end
65+
66+
defp names([c1, c2]) do
67+
"@#{c1.user.provider_login} and @#{c2.user.provider_login}"
68+
end
69+
70+
defp names([c1, c2, c3]) do
71+
"@#{c1.user.provider_login}, @#{c2.user.provider_login} and @#{c3.user.provider_login}"
72+
end
73+
74+
defp names([c1, c2 | claims]) do
75+
"@#{c1.user.provider_login}, @#{c2.user.provider_login} and #{length(claims)} others"
76+
end
4877
end

lib/algora/bounties/schemas/claim.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ defmodule Algora.Bounties.Claim do
2424

2525
def changeset(claim, attrs) do
2626
claim
27-
|> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id])
27+
|> cast(attrs, [:source_id, :target_id, :user_id, :status, :type, :url, :group_id, :group_share])
2828
|> validate_required([:target_id, :user_id, :status, :type, :url])
2929
|> generate_id()
3030
|> put_group_id()
@@ -54,7 +54,7 @@ defmodule Algora.Bounties.Claim do
5454
def type_label(:article), do: "an article"
5555
def type_label(nil), do: "a URL"
5656

57-
def reward_url(claim), do: "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.id}"
57+
def reward_url(claim), do: "#{AlgoraWeb.Endpoint.url()}/claims/#{claim.group_id}"
5858

5959
def rewarded(query \\ Claim) do
6060
from c in query,

lib/algora/integrations/github/command.ex

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ defmodule Algora.Github.Command do
88
@usage %{
99
bounty: "/bounty <amount>",
1010
tip: "/tip <amount> @username or /tip @username <amount>",
11-
claim: "/claim <issue-ref> (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)"
11+
claim: "/claim <issue-ref> (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)",
12+
split: "/split @username"
1213
}
1314

1415
def commands do
@@ -21,7 +22,8 @@ defmodule Algora.Github.Command do
2122
choice([
2223
bounty_command(),
2324
tip_command(),
24-
claim_command()
25+
claim_command(),
26+
split_command()
2527
]),
2628

2729
# Unknown command
@@ -58,6 +60,16 @@ defmodule Algora.Github.Command do
5860
|> label(@usage.tip)
5961
end
6062

63+
def split_command do
64+
"/split"
65+
|> string()
66+
|> ignore()
67+
|> concat(ignore(whitespace()))
68+
|> concat(recipient())
69+
|> tag(:split)
70+
|> label(@usage.split)
71+
end
72+
6173
def claim_command do
6274
"/claim"
6375
|> string()

lib/algora/shared/parser.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ defmodule Algora.Parser do
66
@moduledoc false
77
def whitespace, do: ascii_string([?\s, ?\t], min: 1)
88
def digits, do: ascii_string([?0..?9], min: 1)
9-
def word_chars, do: ascii_string([not: ?\s, not: ?\t], min: 1)
10-
def non_separator_chars, do: ascii_string([not: ?#, not: ?/, not: ?\s, not: ?\t], min: 1)
9+
def word_chars, do: ascii_string([not: ?\s, not: ?\t, not: ?\n], min: 1)
10+
def non_separator_chars, do: ascii_string([not: ?#, not: ?/, not: ?\s, not: ?\t, not: ?\n], min: 1)
1111
def integer, do: reduce(digits(), {__MODULE__, :to_integer, []})
1212

1313
def amount do

lib/algora_web/controllers/webhooks/github_controller.ex

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do
138138
Bounties.claim_bounty(
139139
%{
140140
user: user,
141+
coauthor_provider_logins: (args[:splits] || []) |> Enum.map(& &1[:recipient]) |> Enum.uniq(),
141142
target_ticket_ref: target_ticket_ref,
142143
source_ticket_ref: source_ticket_ref,
143144
status: if(pull_request["merged_at"], do: :approved, else: :pending),
@@ -152,13 +153,36 @@ defmodule AlgoraWeb.Webhooks.GithubController do
152153
{:ok, nil}
153154
end
154155

156+
def build_command({:claim, args}, commands) do
157+
splits = Keyword.get_values(commands, :split)
158+
{:claim, Keyword.put(args, :splits, splits)}
159+
end
160+
161+
def build_command({:split, _args}, _commands), do: nil
162+
163+
def build_command(command, _commands), do: command
164+
165+
def build_commands(body) do
166+
case Github.Command.parse(body) do
167+
{:ok, commands} ->
168+
{:ok,
169+
commands
170+
|> Enum.map(&build_command(&1, commands))
171+
|> Enum.reject(&is_nil/1)}
172+
173+
{:error, reason} = error ->
174+
Logger.error("Error parsing commands: #{inspect(reason)}")
175+
error
176+
end
177+
end
178+
155179
def process_commands(%Webhook{event: event, hook_id: hook_id}, params) do
156180
author = get_author(event, params)
157181
body = get_body(event, params)
158182

159183
event_action = event <> "." <> params["action"]
160184

161-
case Github.Command.parse(body) do
185+
case build_commands(body) do
162186
{:ok, commands} ->
163187
Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, results} ->
164188
case execute_command(event_action, command, author, params) do

lib/algora_web/live/claim_live.ex

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,6 @@ defmodule AlgoraWeb.ClaimLive do
397397
<.card_title>
398398
Authors
399399
</.card_title>
400-
<!-- TODO: hide if user is not an admin -->
401-
<.button variant="secondary" phx-click="split_bounty">
402-
Split bounty
403-
</.button>
404400
</div>
405401
</.card_header>
406402
<.card_content>

test/algora/bounties_test.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ defmodule Algora.BountiesTest do
6363
Algora.Bounties.claim_bounty(
6464
%{
6565
user: recipient,
66+
coauthor_provider_logins: [],
6667
target_ticket_ref: ticket_ref,
6768
source_ticket_ref: ticket_ref,
6869
status: :approved,

test/support/factory.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ defmodule Algora.Factory do
3737
twitter_url: "https://twitter.com/erich",
3838
github_url: "https://github.com/erich",
3939
linkedin_url: "https://linkedin.com/in/erich",
40-
provider: "github"
40+
provider: "github",
41+
provider_login: sequence(:provider_login, &"erlich#{&1}")
4142
}
4243
end
4344

0 commit comments

Comments
 (0)