Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 74 additions & 11 deletions lib/algora/bounties/bounties.ex
Original file line number Diff line number Diff line change
Expand Up @@ -138,45 +138,94 @@ defmodule Algora.Bounties do
end)
end

defp claim_to_solution(claim) do
%{
type: :claim,
started_at: claim.inserted_at,
user: claim.user,
group_id: "claim-#{claim.group_id}",
indicator: "🟢",
solution: "##{claim.source.number}"
}
end

defp attempt_to_solution(attempt) do
%{
type: :attempt,
started_at: attempt.inserted_at,
user: attempt.user,
group_id: "attempt-#{attempt.id}",
indicator: get_attempt_emoji(attempt),
solution: "WIP"
}
end

@spec get_response_body(
bounties :: list(Bounty.t()),
ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()},
attempts :: list(Attempt.t())
attempts :: list(Attempt.t()),
claims :: list(Claim.t())
) :: String.t()
def get_response_body(bounties, ticket_ref, attempts) do
def get_response_body(bounties, ticket_ref, attempts, claims) do
header =
Enum.map_join(bounties, "\n", fn bounty ->
"## 💎 #{bounty.amount} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})"
end)

attempts_table =
if Enum.empty?(attempts) do
solutions =
[]
|> Enum.concat(Enum.map(claims, &claim_to_solution/1))
|> Enum.concat(Enum.map(attempts, &attempt_to_solution/1))
|> Enum.group_by(& &1.user.id)
|> Enum.map(fn {_user_id, solutions} ->
started_at = Enum.min_by(solutions, & &1.started_at).started_at
solution = Enum.find(solutions, &(&1.type == :claim)) || List.first(solutions)
%{solution | started_at: started_at}
end)
|> Enum.group_by(& &1.group_id)
|> Enum.sort_by(fn {_group_id, solutions} -> Enum.min_by(solutions, & &1.started_at).started_at end)
|> Enum.map(fn {_group_id, solutions} ->
primary_solution = Enum.min_by(solutions, & &1.started_at)
timestamp = Calendar.strftime(primary_solution.started_at, "%b %d, %Y, %I:%M:%S %p")

users =
solutions
|> Enum.sort_by(& &1.started_at)
|> Enum.map(&"@#{&1.user.provider_login}")
|> Util.format_name_list()

"| #{primary_solution.indicator} #{users} | #{timestamp} | #{primary_solution.solution} |"
end)

solutions_table =
if solutions == [] do
""
else
"""

| Attempt | Started (UTC) |
| --- | --- |
#{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)}
| Attempt | Started (UTC) | Solution |
| --- | --- | --- |
#{Enum.join(solutions, "\n")}
"""
end

"""
String.trim("""
#{header}
### Steps to solve:
1. **Start working**: Comment `/attempt ##{ticket_ref[:number]}` with your implementation plan
2. **Submit work**: Create a pull request including `/claim ##{ticket_ref[:number]}` in the PR body to claim the bounty
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)

Thank you for contributing to #{ticket_ref[:owner]}/#{ticket_ref[:repo]}!
#{attempts_table}
"""
#{solutions_table}
""")
end

def refresh_bounty_response(token, ticket_ref, ticket) do
bounties = list_bounties(ticket_id: ticket.id)
attempts = list_attempts_for_ticket(ticket.id)
body = get_response_body(bounties, ticket_ref, attempts)
claims = list_claims([ticket.id])
body = get_response_body(bounties, ticket_ref, attempts, claims)

Workspace.refresh_command_response(%{
token: token,
Expand All @@ -187,6 +236,20 @@ defmodule Algora.Bounties do
})
end

def try_refresh_bounty_response(token, ticket_ref, ticket) do
case refresh_bounty_response(token, ticket_ref, ticket) do
{:ok, response} ->
{:ok, response}

{:error, _} ->
Logger.error(
"Failed to refresh bounty response for #{ticket_ref[:owner]}/#{ticket_ref[:repo]}##{ticket_ref[:number]}"
)

{:ok, nil}
end
end

@spec notify_bounty(
%{
owner: User.t(),
Expand Down
3 changes: 2 additions & 1 deletion lib/algora/bounties/jobs/notify_bounty.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
bounties when bounties != [] <- Bounties.list_bounties(ticket_id: ticket.id),
{:ok, _} <- Github.add_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, ["💎 Bounty"]) do
attempts = Bounties.list_attempts_for_ticket(ticket.id)
claims = Bounties.list_claims([ticket.id])

Workspace.ensure_command_response(%{
token: token,
Expand All @@ -85,7 +86,7 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
command_type: :bounty,
command_source: command_source,
ticket: ticket,
body: Bounties.get_response_body(bounties, ticket_ref, attempts)
body: Bounties.get_response_body(bounties, ticket_ref, attempts, claims)
})
end
end
Expand Down
44 changes: 27 additions & 17 deletions lib/algora_web/controllers/webhooks/github_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -344,13 +344,13 @@ defmodule AlgoraWeb.Webhooks.GithubController do
{:ok, ticket} <-
Workspace.ensure_ticket(
token,
payload["repository"]["owner"]["login"],
payload["repository"]["name"],
payload["issue"]["number"]
source_ticket_ref.owner,
source_ticket_ref.repo,
source_ticket_ref.number
),
{:ok, user} <- Workspace.ensure_user(token, author["login"]),
{:ok, attempt} <- Bounties.get_or_create_attempt(%{ticket: ticket, user: user}),
{:ok, _} <- Bounties.refresh_bounty_response(token, target_ticket_ref, ticket) do
{:ok, attempt} <- Bounties.get_or_create_attempt(%{ticket: ticket, user: user}) do
Bounties.try_refresh_bounty_response(token, target_ticket_ref, ticket)
{:ok, attempt}
end
end
Expand All @@ -371,18 +371,28 @@ defmodule AlgoraWeb.Webhooks.GithubController do
}

with {:ok, token} <- Github.get_installation_token(payload["installation"]["id"]),
{:ok, user} <- Workspace.ensure_user(token, author["login"]) do
Bounties.claim_bounty(
%{
user: user,
coauthor_provider_logins: (args[:splits] || []) |> Enum.map(& &1[:recipient]) |> Enum.uniq(),
target_ticket_ref: target_ticket_ref,
source_ticket_ref: source_ticket_ref,
status: if(payload["pull_request"]["merged_at"], do: :approved, else: :pending),
type: :pull_request
},
installation_id: payload["installation"]["id"]
)
{:ok, user} <- Workspace.ensure_user(token, author["login"]),
{:ok, target_ticket} <-
Workspace.ensure_ticket(
token,
target_ticket_ref.owner,
target_ticket_ref.repo,
target_ticket_ref.number
),
{:ok, claims} <-
Bounties.claim_bounty(
%{
user: user,
coauthor_provider_logins: (args[:splits] || []) |> Enum.map(& &1[:recipient]) |> Enum.uniq(),
target_ticket_ref: target_ticket_ref,
source_ticket_ref: source_ticket_ref,
status: if(payload["pull_request"]["merged_at"], do: :approved, else: :pending),
type: :pull_request
},
installation_id: payload["installation"]["id"]
) do
Bounties.try_refresh_bounty_response(token, target_ticket_ref, target_ticket)
{:ok, claims}
end
end

Expand Down
151 changes: 151 additions & 0 deletions test/algora/bounties_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ defmodule Algora.BountiesTest do
import Algora.Factory
import Money.Sigil

alias Algora.Accounts.User
alias Algora.Activities.Notifier
alias Algora.Activities.SendEmail
alias Algora.Bounties
alias Algora.Bounties.Bounty
alias Algora.Payments.Transaction
alias Algora.PSP
alias Bounties.Tip
Expand Down Expand Up @@ -218,4 +220,153 @@ defmodule Algora.BountiesTest do
assert is_nil(transfer)
end
end

describe "get_response_body/4" do
test "generates correct response body with bounties and attempts" do
repo_owner = insert!(:user, provider_login: "repo_owner")
bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner")
bounty_owner = Repo.get!(User, bounty_owner.id)
repository = insert!(:repository, user: repo_owner, name: "test_repo")

bounties = [
%Bounty{
amount: Money.new(1000, :USD),
owner: bounty_owner
}
]

ticket = insert!(:ticket, number: 100, repository: repository)

ticket_ref = %{
owner: repo_owner.provider_login,
repo: ticket.repository.name,
number: ticket.number
}

solver1 = insert!(:user, provider_login: "solver1")
solver2 = insert!(:user, provider_login: "solver2")
solver3 = insert!(:user, provider_login: "solver3")
solver4 = insert!(:user, provider_login: "solver4")
solver5 = insert!(:user, provider_login: "solver5")
solver6 = insert!(:user, provider_login: "solver6")

attempts = [
insert!(:attempt,
user: solver1,
ticket: ticket,
status: :active,
warnings_count: 0,
inserted_at: ~U[2024-01-01 12:00:00Z]
),
insert!(:attempt,
user: solver3,
ticket: ticket,
status: :inactive,
warnings_count: 0,
inserted_at: ~U[2024-01-03 12:00:00Z]
),
insert!(:attempt,
user: solver4,
ticket: ticket,
status: :active,
warnings_count: 1,
inserted_at: ~U[2024-01-04 12:00:00Z]
),
insert!(:attempt,
user: solver5,
ticket: ticket,
status: :active,
warnings_count: 0,
inserted_at: ~U[2024-01-05 12:00:00Z]
)
]

claims = [
insert!(:claim,
user: solver1,
target: ticket,
source: insert!(:ticket, number: 101, repository: repository),
inserted_at: ~U[2024-01-01 12:30:00Z]
),
insert!(:claim,
user: solver2,
target: ticket,
source: insert!(:ticket, number: 102, repository: repository),
inserted_at: ~U[2024-01-02 12:30:00Z]
),
insert!(:claim,
user: solver5,
target: ticket,
source: insert!(:ticket, number: 105, repository: repository),
inserted_at: ~U[2024-01-05 12:30:00Z],
group_id: "group-105"
),
insert!(:claim,
user: solver6,
target: ticket,
source: insert!(:ticket, number: 105, repository: repository),
inserted_at: ~U[2024-01-05 12:30:00Z],
group_id: "group-105"
)
]

response = Algora.Bounties.get_response_body(bounties, ticket_ref, attempts, claims)

expected_response = """
## 💎 $1,000.00 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner)
### Steps to solve:
1. **Start working**: Comment `/attempt #100` with your implementation plan
2. **Submit work**: Create a pull request including `/claim #100` in the PR body to claim the bounty
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)

Thank you for contributing to repo_owner/test_repo!

| Attempt | Started (UTC) | Solution |
| --- | --- | --- |
| 🟢 @solver1 | Jan 01, 2024, 12:00:00 PM | #101 |
| 🟢 @solver2 | Jan 02, 2024, 12:30:00 PM | #102 |
| 🔴 @solver3 | Jan 03, 2024, 12:00:00 PM | WIP |
| 🟡 @solver4 | Jan 04, 2024, 12:00:00 PM | WIP |
| 🟢 @solver5 and @solver6 | Jan 05, 2024, 12:00:00 PM | #105 |
"""

assert response == String.trim(expected_response)
end

test "generates response body without attempts table when no attempts exist" do
repo_owner = insert!(:user, provider_login: "repo_owner")
bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner")
bounty_owner = Repo.get!(User, bounty_owner.id)
repository = insert!(:repository, user: repo_owner, name: "test_repo")

bounties = [
%Bounty{
amount: Money.new(1000, :USD),
owner: bounty_owner
}
]

ticket = insert!(:ticket, number: 100, repository: repository)

ticket_ref = %{
owner: repo_owner.provider_login,
repo: ticket.repository.name,
number: ticket.number
}

response = Algora.Bounties.get_response_body(bounties, ticket_ref, [], [])

expected_response = """
## 💎 $1,000.00 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner)
### Steps to solve:
1. **Start working**: Comment `/attempt #100` with your implementation plan
2. **Submit work**: Create a pull request including `/claim #100` in the PR body to claim the bounty
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)

Thank you for contributing to repo_owner/test_repo!
"""

assert response == String.trim(expected_response)
end
end
end
8 changes: 8 additions & 0 deletions test/support/factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,14 @@ defmodule Algora.Factory do
}
end

def attempt_factory do
%Algora.Bounties.Attempt{
id: Nanoid.generate(),
status: :active,
warnings_count: 0
}
end

def claim_factory do
id = Nanoid.generate()

Expand Down