Skip to content

Commit ea168f6

Browse files
authored
feat: enhance bounty response with claims and grouped solutions (#58)
1 parent 1da7e3a commit ea168f6

File tree

5 files changed

+262
-29
lines changed

5 files changed

+262
-29
lines changed

lib/algora/bounties/bounties.ex

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,45 +138,94 @@ defmodule Algora.Bounties do
138138
end)
139139
end
140140

141+
defp claim_to_solution(claim) do
142+
%{
143+
type: :claim,
144+
started_at: claim.inserted_at,
145+
user: claim.user,
146+
group_id: "claim-#{claim.group_id}",
147+
indicator: "🟢",
148+
solution: "##{claim.source.number}"
149+
}
150+
end
151+
152+
defp attempt_to_solution(attempt) do
153+
%{
154+
type: :attempt,
155+
started_at: attempt.inserted_at,
156+
user: attempt.user,
157+
group_id: "attempt-#{attempt.id}",
158+
indicator: get_attempt_emoji(attempt),
159+
solution: "WIP"
160+
}
161+
end
162+
141163
@spec get_response_body(
142164
bounties :: list(Bounty.t()),
143165
ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()},
144-
attempts :: list(Attempt.t())
166+
attempts :: list(Attempt.t()),
167+
claims :: list(Claim.t())
145168
) :: String.t()
146-
def get_response_body(bounties, ticket_ref, attempts) do
169+
def get_response_body(bounties, ticket_ref, attempts, claims) do
147170
header =
148171
Enum.map_join(bounties, "\n", fn bounty ->
149172
"## 💎 #{bounty.amount} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})"
150173
end)
151174

152-
attempts_table =
153-
if Enum.empty?(attempts) do
175+
solutions =
176+
[]
177+
|> Enum.concat(Enum.map(claims, &claim_to_solution/1))
178+
|> Enum.concat(Enum.map(attempts, &attempt_to_solution/1))
179+
|> Enum.group_by(& &1.user.id)
180+
|> Enum.map(fn {_user_id, solutions} ->
181+
started_at = Enum.min_by(solutions, & &1.started_at).started_at
182+
solution = Enum.find(solutions, &(&1.type == :claim)) || List.first(solutions)
183+
%{solution | started_at: started_at}
184+
end)
185+
|> Enum.group_by(& &1.group_id)
186+
|> Enum.sort_by(fn {_group_id, solutions} -> Enum.min_by(solutions, & &1.started_at).started_at end)
187+
|> Enum.map(fn {_group_id, solutions} ->
188+
primary_solution = Enum.min_by(solutions, & &1.started_at)
189+
timestamp = Calendar.strftime(primary_solution.started_at, "%b %d, %Y, %I:%M:%S %p")
190+
191+
users =
192+
solutions
193+
|> Enum.sort_by(& &1.started_at)
194+
|> Enum.map(&"@#{&1.user.provider_login}")
195+
|> Util.format_name_list()
196+
197+
"| #{primary_solution.indicator} #{users} | #{timestamp} | #{primary_solution.solution} |"
198+
end)
199+
200+
solutions_table =
201+
if solutions == [] do
154202
""
155203
else
156204
"""
157205
158-
| Attempt | Started (UTC) |
159-
| --- | --- |
160-
#{Enum.map_join(attempts, "\n", fn attempt -> "| #{get_attempt_emoji(attempt)} @#{attempt.user.provider_login} | #{Calendar.strftime(attempt.inserted_at, "%b %d, %Y, %I:%M:%S %p")} |" end)}
206+
| Attempt | Started (UTC) | Solution |
207+
| --- | --- | --- |
208+
#{Enum.join(solutions, "\n")}
161209
"""
162210
end
163211

164-
"""
212+
String.trim("""
165213
#{header}
166214
### Steps to solve:
167215
1. **Start working**: Comment `/attempt ##{ticket_ref[:number]}` with your implementation plan
168216
2. **Submit work**: Create a pull request including `/claim ##{ticket_ref[:number]}` in the PR body to claim the bounty
169217
3. **Receive payment**: 100% of the bounty is received 2-5 days post-reward. [Make sure you are eligible for payouts](https://docs.algora.io/bounties/payments#supported-countries-regions)
170218
171219
Thank you for contributing to #{ticket_ref[:owner]}/#{ticket_ref[:repo]}!
172-
#{attempts_table}
173-
"""
220+
#{solutions_table}
221+
""")
174222
end
175223

176224
def refresh_bounty_response(token, ticket_ref, ticket) do
177225
bounties = list_bounties(ticket_id: ticket.id)
178226
attempts = list_attempts_for_ticket(ticket.id)
179-
body = get_response_body(bounties, ticket_ref, attempts)
227+
claims = list_claims([ticket.id])
228+
body = get_response_body(bounties, ticket_ref, attempts, claims)
180229

181230
Workspace.refresh_command_response(%{
182231
token: token,
@@ -187,6 +236,20 @@ defmodule Algora.Bounties do
187236
})
188237
end
189238

239+
def try_refresh_bounty_response(token, ticket_ref, ticket) do
240+
case refresh_bounty_response(token, ticket_ref, ticket) do
241+
{:ok, response} ->
242+
{:ok, response}
243+
244+
{:error, _} ->
245+
Logger.error(
246+
"Failed to refresh bounty response for #{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"
247+
)
248+
249+
{:ok, nil}
250+
end
251+
end
252+
190253
@spec notify_bounty(
191254
%{
192255
owner: User.t(),

lib/algora/bounties/jobs/notify_bounty.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
7777
bounties when bounties != [] <- Bounties.list_bounties(ticket_id: ticket.id),
7878
{:ok, _} <- Github.add_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, ["💎 Bounty"]) do
7979
attempts = Bounties.list_attempts_for_ticket(ticket.id)
80+
claims = Bounties.list_claims([ticket.id])
8081

8182
Workspace.ensure_command_response(%{
8283
token: token,
@@ -85,7 +86,7 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
8586
command_type: :bounty,
8687
command_source: command_source,
8788
ticket: ticket,
88-
body: Bounties.get_response_body(bounties, ticket_ref, attempts)
89+
body: Bounties.get_response_body(bounties, ticket_ref, attempts, claims)
8990
})
9091
end
9192
end

lib/algora_web/controllers/webhooks/github_controller.ex

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -344,13 +344,13 @@ defmodule AlgoraWeb.Webhooks.GithubController do
344344
{:ok, ticket} <-
345345
Workspace.ensure_ticket(
346346
token,
347-
payload["repository"]["owner"]["login"],
348-
payload["repository"]["name"],
349-
payload["issue"]["number"]
347+
source_ticket_ref.owner,
348+
source_ticket_ref.repo,
349+
source_ticket_ref.number
350350
),
351351
{:ok, user} <- Workspace.ensure_user(token, author["login"]),
352-
{:ok, attempt} <- Bounties.get_or_create_attempt(%{ticket: ticket, user: user}),
353-
{:ok, _} <- Bounties.refresh_bounty_response(token, target_ticket_ref, ticket) do
352+
{:ok, attempt} <- Bounties.get_or_create_attempt(%{ticket: ticket, user: user}) do
353+
Bounties.try_refresh_bounty_response(token, target_ticket_ref, ticket)
354354
{:ok, attempt}
355355
end
356356
end
@@ -371,18 +371,28 @@ defmodule AlgoraWeb.Webhooks.GithubController do
371371
}
372372

373373
with {:ok, token} <- Github.get_installation_token(payload["installation"]["id"]),
374-
{:ok, user} <- Workspace.ensure_user(token, author["login"]) do
375-
Bounties.claim_bounty(
376-
%{
377-
user: user,
378-
coauthor_provider_logins: (args[:splits] || []) |> Enum.map(& &1[:recipient]) |> Enum.uniq(),
379-
target_ticket_ref: target_ticket_ref,
380-
source_ticket_ref: source_ticket_ref,
381-
status: if(payload["pull_request"]["merged_at"], do: :approved, else: :pending),
382-
type: :pull_request
383-
},
384-
installation_id: payload["installation"]["id"]
385-
)
374+
{:ok, user} <- Workspace.ensure_user(token, author["login"]),
375+
{:ok, target_ticket} <-
376+
Workspace.ensure_ticket(
377+
token,
378+
target_ticket_ref.owner,
379+
target_ticket_ref.repo,
380+
target_ticket_ref.number
381+
),
382+
{:ok, claims} <-
383+
Bounties.claim_bounty(
384+
%{
385+
user: user,
386+
coauthor_provider_logins: (args[:splits] || []) |> Enum.map(& &1[:recipient]) |> Enum.uniq(),
387+
target_ticket_ref: target_ticket_ref,
388+
source_ticket_ref: source_ticket_ref,
389+
status: if(payload["pull_request"]["merged_at"], do: :approved, else: :pending),
390+
type: :pull_request
391+
},
392+
installation_id: payload["installation"]["id"]
393+
) do
394+
Bounties.try_refresh_bounty_response(token, target_ticket_ref, target_ticket)
395+
{:ok, claims}
386396
end
387397
end
388398

test/algora/bounties_test.exs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ defmodule Algora.BountiesTest do
55
import Algora.Factory
66
import Money.Sigil
77

8+
alias Algora.Accounts.User
89
alias Algora.Activities.Notifier
910
alias Algora.Activities.SendEmail
1011
alias Algora.Bounties
12+
alias Algora.Bounties.Bounty
1113
alias Algora.Payments.Transaction
1214
alias Algora.PSP
1315
alias Bounties.Tip
@@ -218,4 +220,153 @@ defmodule Algora.BountiesTest do
218220
assert is_nil(transfer)
219221
end
220222
end
223+
224+
describe "get_response_body/4" do
225+
test "generates correct response body with bounties and attempts" do
226+
repo_owner = insert!(:user, provider_login: "repo_owner")
227+
bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner")
228+
bounty_owner = Repo.get!(User, bounty_owner.id)
229+
repository = insert!(:repository, user: repo_owner, name: "test_repo")
230+
231+
bounties = [
232+
%Bounty{
233+
amount: Money.new(1000, :USD),
234+
owner: bounty_owner
235+
}
236+
]
237+
238+
ticket = insert!(:ticket, number: 100, repository: repository)
239+
240+
ticket_ref = %{
241+
owner: repo_owner.provider_login,
242+
repo: ticket.repository.name,
243+
number: ticket.number
244+
}
245+
246+
solver1 = insert!(:user, provider_login: "solver1")
247+
solver2 = insert!(:user, provider_login: "solver2")
248+
solver3 = insert!(:user, provider_login: "solver3")
249+
solver4 = insert!(:user, provider_login: "solver4")
250+
solver5 = insert!(:user, provider_login: "solver5")
251+
solver6 = insert!(:user, provider_login: "solver6")
252+
253+
attempts = [
254+
insert!(:attempt,
255+
user: solver1,
256+
ticket: ticket,
257+
status: :active,
258+
warnings_count: 0,
259+
inserted_at: ~U[2024-01-01 12:00:00Z]
260+
),
261+
insert!(:attempt,
262+
user: solver3,
263+
ticket: ticket,
264+
status: :inactive,
265+
warnings_count: 0,
266+
inserted_at: ~U[2024-01-03 12:00:00Z]
267+
),
268+
insert!(:attempt,
269+
user: solver4,
270+
ticket: ticket,
271+
status: :active,
272+
warnings_count: 1,
273+
inserted_at: ~U[2024-01-04 12:00:00Z]
274+
),
275+
insert!(:attempt,
276+
user: solver5,
277+
ticket: ticket,
278+
status: :active,
279+
warnings_count: 0,
280+
inserted_at: ~U[2024-01-05 12:00:00Z]
281+
)
282+
]
283+
284+
claims = [
285+
insert!(:claim,
286+
user: solver1,
287+
target: ticket,
288+
source: insert!(:ticket, number: 101, repository: repository),
289+
inserted_at: ~U[2024-01-01 12:30:00Z]
290+
),
291+
insert!(:claim,
292+
user: solver2,
293+
target: ticket,
294+
source: insert!(:ticket, number: 102, repository: repository),
295+
inserted_at: ~U[2024-01-02 12:30:00Z]
296+
),
297+
insert!(:claim,
298+
user: solver5,
299+
target: ticket,
300+
source: insert!(:ticket, number: 105, repository: repository),
301+
inserted_at: ~U[2024-01-05 12:30:00Z],
302+
group_id: "group-105"
303+
),
304+
insert!(:claim,
305+
user: solver6,
306+
target: ticket,
307+
source: insert!(:ticket, number: 105, repository: repository),
308+
inserted_at: ~U[2024-01-05 12:30:00Z],
309+
group_id: "group-105"
310+
)
311+
]
312+
313+
response = Algora.Bounties.get_response_body(bounties, ticket_ref, attempts, claims)
314+
315+
expected_response = """
316+
## 💎 $1,000.00 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner)
317+
### Steps to solve:
318+
1. **Start working**: Comment `/attempt #100` with your implementation plan
319+
2. **Submit work**: Create a pull request including `/claim #100` in the PR body to claim the bounty
320+
3. **Receive payment**: 100% of the bounty is received 2-5 days post-reward. [Make sure you are eligible for payouts](https://docs.algora.io/bounties/payments#supported-countries-regions)
321+
322+
Thank you for contributing to repo_owner/test_repo!
323+
324+
| Attempt | Started (UTC) | Solution |
325+
| --- | --- | --- |
326+
| 🟢 @solver1 | Jan 01, 2024, 12:00:00 PM | #101 |
327+
| 🟢 @solver2 | Jan 02, 2024, 12:30:00 PM | #102 |
328+
| 🔴 @solver3 | Jan 03, 2024, 12:00:00 PM | WIP |
329+
| 🟡 @solver4 | Jan 04, 2024, 12:00:00 PM | WIP |
330+
| 🟢 @solver5 and @solver6 | Jan 05, 2024, 12:00:00 PM | #105 |
331+
"""
332+
333+
assert response == String.trim(expected_response)
334+
end
335+
336+
test "generates response body without attempts table when no attempts exist" do
337+
repo_owner = insert!(:user, provider_login: "repo_owner")
338+
bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner")
339+
bounty_owner = Repo.get!(User, bounty_owner.id)
340+
repository = insert!(:repository, user: repo_owner, name: "test_repo")
341+
342+
bounties = [
343+
%Bounty{
344+
amount: Money.new(1000, :USD),
345+
owner: bounty_owner
346+
}
347+
]
348+
349+
ticket = insert!(:ticket, number: 100, repository: repository)
350+
351+
ticket_ref = %{
352+
owner: repo_owner.provider_login,
353+
repo: ticket.repository.name,
354+
number: ticket.number
355+
}
356+
357+
response = Algora.Bounties.get_response_body(bounties, ticket_ref, [], [])
358+
359+
expected_response = """
360+
## 💎 $1,000.00 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner)
361+
### Steps to solve:
362+
1. **Start working**: Comment `/attempt #100` with your implementation plan
363+
2. **Submit work**: Create a pull request including `/claim #100` in the PR body to claim the bounty
364+
3. **Receive payment**: 100% of the bounty is received 2-5 days post-reward. [Make sure you are eligible for payouts](https://docs.algora.io/bounties/payments#supported-countries-regions)
365+
366+
Thank you for contributing to repo_owner/test_repo!
367+
"""
368+
369+
assert response == String.trim(expected_response)
370+
end
371+
end
221372
end

test/support/factory.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,14 @@ defmodule Algora.Factory do
206206
}
207207
end
208208

209+
def attempt_factory do
210+
%Algora.Bounties.Attempt{
211+
id: Nanoid.generate(),
212+
status: :active,
213+
warnings_count: 0
214+
}
215+
end
216+
209217
def claim_factory do
210218
id = Nanoid.generate()
211219

0 commit comments

Comments
 (0)