diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 0bd87892a..35f3a97bc 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -138,30 +138,78 @@ 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 @@ -169,14 +217,15 @@ defmodule Algora.Bounties do 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, @@ -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(), diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index b27f4f156..80d6669d6 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -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, @@ -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 diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 9fbfc1602..43bc6f8fc 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -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 @@ -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 diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs index b81d5c21b..75698ba64 100644 --- a/test/algora/bounties_test.exs +++ b/test/algora/bounties_test.exs @@ -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 @@ -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 diff --git a/test/support/factory.ex b/test/support/factory.ex index 09526bef1..08a036193 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -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()