diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 587cb4c73..a75200c4b 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -5,6 +5,7 @@ defmodule Algora.Bounties do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.Bounties.Attempt alias Algora.Bounties.Bounty alias Algora.Bounties.Claim alias Algora.Bounties.Jobs @@ -138,25 +139,53 @@ defmodule Algora.Bounties do @spec get_response_body( bounties :: list(Bounty.t()), - ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()} + ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()}, + attempts :: list(Attempt.t()) ) :: String.t() - def get_response_body(bounties, ticket_ref) do + def get_response_body(bounties, ticket_ref, attempts) 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 + "" + 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)} + """ + end + """ #{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 + 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"]}! + Thank you for contributing to #{ticket_ref[:owner]}/#{ticket_ref[:repo]}! + #{attempts_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) + + Workspace.refresh_command_response(%{ + token: token, + ticket_ref: ticket_ref, + ticket: ticket, + body: body, + command_type: :bounty + }) + end + @spec notify_bounty( %{ owner: User.t(), @@ -816,4 +845,42 @@ defmodule Algora.Bounties do {:ok, [debit, credit]} end end + + @spec create_attempt(%{ticket: Ticket.t(), user: User.t()}) :: + {:ok, Attempt.t()} | {:error, Ecto.Changeset.t()} + def create_attempt(%{ticket: ticket, user: user}) do + %Attempt{} + |> Attempt.changeset(%{ + ticket_id: ticket.id, + user_id: user.id + }) + |> Repo.insert() + end + + @spec get_or_create_attempt(%{ticket: Ticket.t(), user: User.t()}) :: + {:ok, Attempt.t()} | {:error, Ecto.Changeset.t()} + def get_or_create_attempt(%{ticket: ticket, user: user}) do + case Repo.fetch_by(Attempt, ticket_id: ticket.id, user_id: user.id) do + {:ok, attempt} -> {:ok, attempt} + {:error, _reason} -> create_attempt(%{ticket: ticket, user: user}) + end + end + + @spec list_attempts_for_ticket(String.t()) :: [Attempt.t()] + def list_attempts_for_ticket(ticket_id) do + Repo.all( + from(a in Attempt, + join: u in assoc(a, :user), + where: a.ticket_id == ^ticket_id, + order_by: [desc: a.inserted_at], + select_merge: %{ + user: u + } + ) + ) + end + + def get_attempt_emoji(%Attempt{status: :inactive}), do: "🔴" + def get_attempt_emoji(%Attempt{warnings_count: count}) when count > 0, do: "🟡" + def get_attempt_emoji(%Attempt{status: :active}), do: "🟢" end diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index 38f900d00..b27f4f156 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -66,10 +66,18 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do "command_source" => command_source } }) do + ticket_ref = %{ + owner: ticket_ref["owner"], + repo: ticket_ref["repo"], + number: ticket_ref["number"] + } + with {:ok, token} <- Github.get_installation_token(installation_id), - {:ok, ticket} <- Workspace.ensure_ticket(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"]), + {:ok, ticket} <- Workspace.ensure_ticket(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number), 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 + {:ok, _} <- Github.add_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, ["💎 Bounty"]) do + attempts = Bounties.list_attempts_for_ticket(ticket.id) + Workspace.ensure_command_response(%{ token: token, ticket_ref: ticket_ref, @@ -77,7 +85,7 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do command_type: :bounty, command_source: command_source, ticket: ticket, - body: Bounties.get_response_body(bounties, ticket_ref) + body: Bounties.get_response_body(bounties, ticket_ref, attempts) }) end end diff --git a/lib/algora/bounties/schemas/attempt.ex b/lib/algora/bounties/schemas/attempt.ex index d7d50e9ef..ece24c526 100644 --- a/lib/algora/bounties/schemas/attempt.ex +++ b/lib/algora/bounties/schemas/attempt.ex @@ -5,8 +5,11 @@ defmodule Algora.Bounties.Attempt do alias Algora.Activities.Activity typed_schema "attempts" do - belongs_to :bounty, Algora.Bounties.Bounty - belongs_to :user, Algora.Accounts.User + field :status, Ecto.Enum, values: [:active, :inactive], default: :active, null: false + field :warnings_count, :integer, default: 0, null: false + + belongs_to :ticket, Algora.Workspace.Ticket, null: false + belongs_to :user, Algora.Accounts.User, null: false has_many :activities, {"attempt_activities", Activity}, foreign_key: :assoc_id @@ -15,7 +18,11 @@ defmodule Algora.Bounties.Attempt do def changeset(attempt, attrs) do attempt - |> cast(attrs, [:bounty_id, :user_id]) - |> validate_required([:bounty_id, :user_id]) + |> cast(attrs, [:ticket_id, :user_id]) + |> generate_id() + |> validate_required([:ticket_id, :user_id]) + |> unique_constraint([:ticket_id, :user_id]) + |> foreign_key_constraint(:ticket_id) + |> foreign_key_constraint(:user_id) end end diff --git a/lib/algora/integrations/github/command.ex b/lib/algora/integrations/github/command.ex index 26245da4d..6724aa926 100644 --- a/lib/algora/integrations/github/command.ex +++ b/lib/algora/integrations/github/command.ex @@ -9,7 +9,8 @@ defmodule Algora.Github.Command do bounty: "/bounty ", tip: "/tip @username or /tip @username ", claim: "/claim (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)", - split: "/split @username" + split: "/split @username", + attempt: "/attempt (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)" } def commands do @@ -23,7 +24,8 @@ defmodule Algora.Github.Command do bounty_command(), tip_command(), claim_command(), - split_command() + split_command(), + attempt_command() ]), # Unknown command @@ -79,6 +81,16 @@ defmodule Algora.Github.Command do |> tag(:claim) |> label(@usage.claim) end + + def attempt_command do + "/attempt" + |> string() + |> ignore() + |> concat(ignore(whitespace())) + |> concat(ticket_ref()) + |> tag(:attempt) + |> label(@usage.attempt) + end end defparsec(:parse_raw, Helper.commands()) diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index db7dd0032..1c6a62088 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -15,6 +15,8 @@ defmodule Algora.Workspace do require Logger @type ticket_type :: :issue | :pull_request + @type command_type :: :bounty | :attempt | :claim + @type command_source :: :ticket | :comment def ensure_ticket(token, owner, repo, number) do ticket_query = @@ -170,8 +172,8 @@ defmodule Algora.Workspace do token: String.t(), ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, command_id: integer(), - command_type: :bounty | :attempt | :claim, - command_source: :ticket | :comment, + command_type: command_type(), + command_source: command_source(), ticket: Ticket.t(), body: String.t() }) :: {:ok, CommandResponse.t()} | {:error, any()} @@ -209,24 +211,24 @@ defmodule Algora.Workspace do @spec refresh_command_response(%{ token: String.t(), - command_type: :bounty | :attempt | :claim, + command_type: command_type(), ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}, ticket: Ticket.t(), body: String.t() }) :: {:ok, CommandResponse.t()} | {:error, any()} - defp refresh_command_response(%{ - token: token, - command_type: command_type, - ticket_ref: ticket_ref, - ticket: ticket, - body: body - }) do + def refresh_command_response(%{ + token: token, + command_type: command_type, + ticket_ref: ticket_ref, + ticket: ticket, + body: body + }) do case fetch_command_response(ticket.id, command_type) do {:ok, response} -> case Github.update_issue_comment( token, - ticket_ref["owner"], - ticket_ref["repo"], + ticket_ref[:owner], + ticket_ref[:repo], response.provider_response_id, body ) do @@ -235,7 +237,7 @@ defmodule Algora.Workspace do # TODO: don't rely on string matching {:error, "404 Not Found"} -> - Logger.error("Command response #{response.id} not found") + Logger.error("Github comment for command response #{response.id} not found") {:error, {:comment_not_found, response.id}} {:error, reason} -> @@ -250,7 +252,7 @@ defmodule Algora.Workspace do defp post_response(token, ticket_ref, command_id, command_source, ticket, body) do with {:ok, comment} <- - Github.create_issue_comment(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], body) do + Github.create_issue_comment(token, ticket_ref[:owner], ticket_ref[:repo], ticket_ref[:number], body) do create_command_response(%{ comment: comment, command_source: command_source, @@ -262,7 +264,7 @@ defmodule Algora.Workspace do @spec create_command_response(%{ comment: map(), - command_source: :ticket | :comment, + command_source: command_source(), command_id: integer(), ticket_id: integer() }) :: {:ok, CommandResponse.t()} | {:error, any()} diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 0ed347339..25e1d1283 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -140,6 +140,35 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end + defp execute_command(event_action, {:attempt, args}, author, params) + when event_action in ["issue_comment.created", "issue_comment.edited"] do + installation_id = params["installation"]["id"] + repo = params["repository"] + issue = params["issue"] + + source_ticket_ref = %{ + owner: repo["owner"]["login"], + repo: repo["name"], + number: issue["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 true <- source_ticket_ref == target_ticket_ref, + {:ok, token} <- Github.get_installation_token(installation_id), + {:ok, ticket} <- Workspace.ensure_ticket(token, repo["owner"]["login"], repo["name"], issue["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} + end + end + 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"] diff --git a/priv/repo/migrations/20250126122024_create_attempts.exs b/priv/repo/migrations/20250126122024_create_attempts.exs new file mode 100644 index 000000000..d9eaa403f --- /dev/null +++ b/priv/repo/migrations/20250126122024_create_attempts.exs @@ -0,0 +1,20 @@ +defmodule Algora.Repo.Migrations.CreateAttempts do + use Ecto.Migration + + def change do + create table(:attempts) do + add :status, :string, null: false, default: "active" + add :warnings_count, :integer, null: false, default: 0 + + add :ticket_id, references(:tickets, on_delete: :delete_all), null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:attempts, [:ticket_id]) + create index(:attempts, [:user_id]) + + create unique_index(:attempts, [:ticket_id, :user_id]) + end +end