diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index cbb71c47e..e5e3bc655 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -12,6 +12,7 @@ defmodule Algora.Accounts.User do alias Algora.Organizations.Member alias Algora.Types.Money alias Algora.Workspace.Installation + alias AlgoraWeb.Endpoint @derive {Inspect, except: [:provider_meta]} typed_schema "users" do @@ -322,8 +323,9 @@ defmodule Algora.Accounts.User do def handle(%{handle: handle}) when is_binary(handle), do: handle def handle(%{provider_login: handle}), do: handle - def url(%{handle: handle, type: :individual}) when is_binary(handle), do: "/@/#{handle}" - def url(%{handle: handle, type: :organization}), do: "/org/#{handle}" + def url(%{handle: handle, type: :individual}) when is_binary(handle), do: "#{Endpoint.url()}/@/#{handle}" + def url(%{handle: handle, type: :organization}), do: "#{Endpoint.url()}/org/#{handle}" + def url(%{handle: handle}) when is_binary(handle), do: "#{Endpoint.url()}/org/#{handle}" def url(%{provider_login: handle}), do: "https://github.com/#{handle}" def last_context(%{last_context: last_context}), do: last_context || default_context() diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 10b45c372..7797e0611 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -26,7 +26,8 @@ defmodule Algora.Bounties do @type criterion :: {:limit, non_neg_integer()} - | {:owner_id, integer()} + | {:ticket_id, String.t()} + | {:owner_id, String.t()} | {:status, :open | :paid} | {:tech_stack, [String.t()]} @@ -66,6 +67,18 @@ defmodule Algora.Bounties do end end + @type strategy :: :create | :set | :increase + + @spec strategy_to_action(Bounty.t() | nil, strategy() | nil) :: {:ok, strategy()} | {:error, atom()} + defp strategy_to_action(bounty, strategy) do + case {bounty, strategy} do + {_, nil} -> strategy_to_action(bounty, :increase) + {nil, _} -> {:ok, :create} + {_existing, :create} -> {:error, :already_exists} + {_existing, strategy} -> {:ok, strategy} + end + end + @spec create_bounty( %{ creator: User.t(), @@ -73,7 +86,12 @@ defmodule Algora.Bounties do amount: Money.t(), ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()} }, - opts :: [installation_id: integer()] + opts :: [ + strategy: strategy(), + installation_id: integer(), + command_id: integer(), + command_source: :ticket | :comment + ] ) :: {:ok, Bounty.t()} | {:error, atom()} def create_bounty( @@ -86,6 +104,7 @@ defmodule Algora.Bounties do opts \\ [] ) do installation_id = opts[:installation_id] + command_id = opts[:command_id] token_res = if installation_id, @@ -95,9 +114,20 @@ defmodule Algora.Bounties do Repo.transact(fn -> with {:ok, token} <- token_res, {:ok, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number), - {:ok, bounty} <- do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}), + existing = Repo.get_by(Bounty, owner_id: owner.id, ticket_id: ticket.id), + {:ok, strategy} <- strategy_to_action(existing, opts[:strategy]), + {:ok, bounty} <- + (case strategy do + :create -> do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}) + :set -> existing |> Bounty.changeset(%{amount: amount}) |> Repo.update() + :increase -> existing |> Bounty.changeset(%{amount: Money.add!(existing.amount, amount)}) |> Repo.update() + end), {:ok, _job} <- - notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, installation_id: installation_id) do + notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, + installation_id: installation_id, + command_id: command_id, + command_source: opts[:command_source] + ) do broadcast() {:ok, bounty} else @@ -112,7 +142,7 @@ defmodule Algora.Bounties do bounty: Bounty.t(), ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()} }, - opts :: [installation_id: integer()] + opts :: [installation_id: integer(), command_id: integer(), command_source: :ticket | :comment] ) :: {:ok, Oban.Job.t()} | {:error, atom()} def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, opts \\ []) do @@ -120,7 +150,9 @@ defmodule Algora.Bounties do owner_login: owner.provider_login, amount: Money.to_string!(bounty.amount, no_fraction_if_integer: true), ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}, - installation_id: opts[:installation_id] + installation_id: opts[:installation_id], + command_id: opts[:command_id], + command_source: opts[:command_source] } |> Jobs.NotifyBounty.new() |> Oban.insert() @@ -593,6 +625,9 @@ defmodule Algora.Bounties do {:limit, limit}, query -> from([b] in query, limit: ^limit) + {:ticket_id, ticket_id}, query -> + from([b] in query, where: b.ticket_id == ^ticket_id) + {:owner_id, owner_id}, query -> from([b] in query, where: b.owner_id == ^owner_id) diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index 217b980dc..8318b6914 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -1,17 +1,29 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do @moduledoc false - use Oban.Worker, queue: :notify_bounty + use Oban.Worker, + queue: :notify_bounty, + max_attempts: 1 - alias Algora.Accounts alias Algora.Accounts.User + alias Algora.Bounties alias Algora.Github + alias Algora.Repo + alias Algora.Util alias Algora.Workspace + alias Algora.Workspace.CommandResponse require Logger @impl Oban.Worker def perform(%Oban.Job{ - args: %{"owner_login" => owner_login, "amount" => amount, "ticket_ref" => ticket_ref, "installation_id" => nil} + args: %{ + "owner_login" => owner_login, + "amount" => amount, + "ticket_ref" => ticket_ref, + "installation_id" => nil, + "command_id" => command_id, + "command_source" => command_source + } }) do body = """ 💎 **#{owner_login}** is offering a **#{amount}** bounty for this issue @@ -20,13 +32,19 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do """ if Github.pat_enabled() do - Github.create_issue_comment( - Github.pat(), - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body - ) + with {:ok, response} <- + Github.create_issue_comment( + Github.pat(), + ticket_ref["owner"], + ticket_ref["repo"], + ticket_ref["number"], + body + ), + {:ok, ticket} <- + Workspace.ensure_ticket(Github.pat(), ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"]) do + # TODO: update existing command response if it exists + create_command_response(response, command_source, command_id, ticket.id) + end else Logger.info(""" Github.create_issue_comment(Github.pat(), "#{ticket_ref["owner"]}", "#{ticket_ref["repo"]}", #{ticket_ref["number"]}, @@ -38,14 +56,26 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do end @impl Oban.Worker - def perform(%Oban.Job{args: %{"amount" => amount, "ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do + def perform(%Oban.Job{ + args: %{ + "amount" => _amount, + "ticket_ref" => ticket_ref, + "installation_id" => installation_id, + "command_id" => command_id, + "command_source" => command_source + } + }) do with {:ok, token} <- Github.get_installation_token(installation_id), - {:ok, installation} <- - Workspace.fetch_installation_by(provider: "github", provider_id: to_string(installation_id)), - {:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id), + {: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 + header = + Enum.map_join(bounties, "\n", fn bounty -> + "## 💎 #{bounty.amount} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})" + end) + body = """ - ## 💎 #{amount} bounty [• #{owner.name}](#{User.url(owner)}) + #{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 @@ -54,13 +84,69 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do Thank you for contributing to #{ticket_ref["owner"]}/#{ticket_ref["repo"]}! """ - Github.create_issue_comment( - token, - ticket_ref["owner"], - ticket_ref["repo"], - ticket_ref["number"], - body - ) + ensure_command_response(token, ticket_ref, command_id, command_source, ticket, body) + end + end + + defp ensure_command_response(token, ticket_ref, command_id, command_source, ticket, body) do + case Workspace.fetch_command_response(ticket.id, :bounty) do + {:ok, response} -> + case Github.update_issue_comment( + token, + ticket_ref["owner"], + ticket_ref["repo"], + response.provider_response_id, + body + ) do + {:ok, comment} -> + try_update_command_response(response, comment) + + {:error, "404 Not Found"} -> + with {:ok, _} <- Workspace.delete_command_response(response.id) do + post_response(token, ticket_ref, command_id, command_source, ticket, body) + end + + {:error, reason} -> + Logger.error("Failed to update command response #{response.id}: #{inspect(reason)}") + {:error, reason} + end + + {:error, _reason} -> + post_response(token, ticket_ref, command_id, command_source, ticket, body) + end + end + + 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 + create_command_response(comment, command_source, command_id, ticket.id) + end + end + + defp create_command_response(comment, command_source, command_id, ticket_id) do + %CommandResponse{} + |> CommandResponse.changeset(%{ + provider: "github", + provider_meta: Util.normalize_struct(comment), + provider_command_id: to_string(command_id), + provider_response_id: to_string(comment["id"]), + command_source: command_source, + command_type: :bounty, + ticket_id: ticket_id + }) + |> Repo.insert() + end + + defp try_update_command_response(command_response, body) do + case command_response + |> CommandResponse.changeset(%{provider_meta: Util.normalize_struct(body)}) + |> Repo.update() do + {:ok, command_response} -> + {:ok, command_response} + + {:error, reason} -> + Logger.error("Failed to update command response #{command_response.id}: #{inspect(reason)}") + {:ok, command_response} end end end diff --git a/lib/algora/integrations/github/behaviour.ex b/lib/algora/integrations/github/behaviour.ex index dae585cbe..0e8a2c8ad 100644 --- a/lib/algora/integrations/github/behaviour.ex +++ b/lib/algora/integrations/github/behaviour.ex @@ -17,6 +17,7 @@ defmodule Algora.Github.Behaviour do @callback find_installation(token(), integer(), integer()) :: response @callback get_installation_token(integer()) :: response @callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: response + @callback update_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: response @callback list_repository_events(token(), String.t(), String.t(), keyword()) :: response @callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: response @callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: response diff --git a/lib/algora/integrations/github/client.ex b/lib/algora/integrations/github/client.ex index d6d842fd3..c4703f9d1 100644 --- a/lib/algora/integrations/github/client.ex +++ b/lib/algora/integrations/github/client.ex @@ -176,6 +176,16 @@ defmodule Algora.Github.Client do ) end + @impl true + def update_issue_comment(access_token, owner, repo, comment_id, body) do + fetch( + access_token, + "/repos/#{owner}/#{repo}/issues/comments/#{comment_id}", + "PATCH", + %{body: body} + ) + end + @impl true def list_repository_events(access_token, owner, repo, opts \\ []) do fetch(access_token, "/repos/#{owner}/#{repo}/events#{build_query(opts)}") diff --git a/lib/algora/integrations/github/github.ex b/lib/algora/integrations/github/github.ex index 180eca5f3..4566f9296 100644 --- a/lib/algora/integrations/github/github.ex +++ b/lib/algora/integrations/github/github.ex @@ -84,6 +84,10 @@ defmodule Algora.Github do def create_issue_comment(token, owner, repo, number, body), do: client().create_issue_comment(token, owner, repo, number, body) + @impl true + def update_issue_comment(token, owner, repo, comment_id, body), + do: client().update_issue_comment(token, owner, repo, comment_id, body) + @impl true def list_repository_events(token, owner, repo, opts \\ []), do: client().list_repository_events(token, owner, repo, opts) diff --git a/lib/algora/integrations/github/poller/comment_consumer.ex b/lib/algora/integrations/github/poller/comment_consumer.ex index 89b6eab3a..31dfe7a72 100644 --- a/lib/algora/integrations/github/poller/comment_consumer.ex +++ b/lib/algora/integrations/github/poller/comment_consumer.ex @@ -34,12 +34,16 @@ defmodule Algora.Github.Poller.CommentConsumer do defp run_command({:bounty, args}, ticket_ref, comment) do case Accounts.fetch_user_by(provider_id: to_string(comment["user"]["id"])) do {:ok, user} -> - Bounties.create_bounty(%{ - creator: user, - owner: user, - amount: args[:amount], - ticket_ref: %{owner: ticket_ref[:owner], repo: ticket_ref[:repo], number: ticket_ref[:number]} - }) + Bounties.create_bounty( + %{ + creator: user, + owner: user, + amount: args[:amount], + ticket_ref: %{owner: ticket_ref[:owner], repo: ticket_ref[:repo], number: ticket_ref[:number]} + }, + command_id: comment["id"], + command_source: :comment + ) {:error, _reason} = error -> Logger.error("Failed to create bounty: #{inspect(error)}") diff --git a/lib/algora/workspace/schemas/command_response.ex b/lib/algora/workspace/schemas/command_response.ex new file mode 100644 index 000000000..d4f4b173d --- /dev/null +++ b/lib/algora/workspace/schemas/command_response.ex @@ -0,0 +1,44 @@ +defmodule Algora.Workspace.CommandResponse do + @moduledoc """ + Schema for tracking command comments and their corresponding bot responses. + This allows updating existing bot responses instead of creating new ones. + """ + use Algora.Schema + + typed_schema "command_responses" do + field :provider, :string, null: false + field :provider_meta, :map, null: false + field :provider_command_id, :string + field :provider_response_id, :string, null: false + field :command_source, Ecto.Enum, values: [:ticket, :comment], null: false + field :command_type, Ecto.Enum, values: [:bounty, :attempt, :claim], null: false + + belongs_to :ticket, Algora.Workspace.Ticket, null: false + + timestamps() + end + + def changeset(command_response, attrs) do + command_response + |> cast(attrs, [ + :provider, + :provider_meta, + :provider_command_id, + :provider_response_id, + :command_source, + :command_type, + :ticket_id + ]) + |> validate_required([ + :provider, + :provider_meta, + :provider_response_id, + :command_source, + :command_type, + :ticket_id + ]) + |> generate_id() + |> foreign_key_constraint(:ticket_id) + |> unique_constraint([:provider, :provider_command_id]) + end +end diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index 134476c60..edebe167b 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -5,6 +5,7 @@ defmodule Algora.Workspace do alias Algora.Accounts.User alias Algora.Github alias Algora.Repo + alias Algora.Workspace.CommandResponse alias Algora.Workspace.Installation alias Algora.Workspace.Jobs alias Algora.Workspace.Repository @@ -153,4 +154,14 @@ defmodule Algora.Workspace do def list_user_installations(user_id) do Repo.all(from(i in Installation, where: i.owner_id == ^user_id, preload: [:connected_user])) end + + def fetch_command_response(ticket_id, command_type) do + Repo.fetch_one( + from cr in CommandResponse, + where: cr.ticket_id == ^ticket_id, + where: cr.command_type == ^command_type + ) + end + + def delete_command_response(id), do: Repo.delete(Repo.get(CommandResponse, id)) end diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 5ec098150..0ed347339 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -5,7 +5,9 @@ defmodule AlgoraWeb.Webhooks.GithubController do alias Algora.Bounties alias Algora.Github alias Algora.Github.Webhook + alias Algora.Repo alias Algora.Workspace + alias Algora.Workspace.CommandResponse require Logger @@ -52,11 +54,32 @@ defmodule AlgoraWeb.Webhooks.GithubController do defp execute_command(event_action, {:bounty, args}, author, params) when event_action in ["issues.opened", "issues.edited", "issue_comment.created", "issue_comment.edited"] do + [event, _action] = String.split(event_action, ".") + amount = args[:amount] repo = params["repository"] issue = params["issue"] installation_id = params["installation"]["id"] + {command_source, command_id} = + case event do + "issue_comment" -> {:comment, params["comment"]["id"]} + _ -> {:ticket, issue["id"]} + end + + # TODO: perform compensating action if needed + # ❌ comment1.created (:set) -> comment2.created (:increase) -> comment2.edited (:increase) + # ✅ comment1.created (:set) -> comment2.created (:increase) -> comment2.edited (:decrease + :increase) + strategy = + case Repo.get_by(CommandResponse, + provider: "github", + provider_command_id: to_string(command_id), + command_source: command_source + ) do + nil -> :increase + _ -> :set + end + # TODO: community bounties? with {:ok, "admin"} <- get_permissions(author, params), {:ok, token} <- Github.get_installation_token(installation_id), @@ -71,7 +94,10 @@ defmodule AlgoraWeb.Webhooks.GithubController do amount: amount, ticket_ref: %{owner: repo["owner"]["login"], repo: repo["name"], number: issue["number"]} }, - installation_id: installation_id + strategy: strategy, + installation_id: installation_id, + command_id: command_id, + command_source: command_source ) else {:ok, _permission} -> diff --git a/priv/repo/migrations/20250125160156_create_command_responses.exs b/priv/repo/migrations/20250125160156_create_command_responses.exs new file mode 100644 index 000000000..4545a8e65 --- /dev/null +++ b/priv/repo/migrations/20250125160156_create_command_responses.exs @@ -0,0 +1,19 @@ +defmodule Algora.Repo.Migrations.CreateCommandResponses do + use Ecto.Migration + + def change do + create table(:command_responses) do + add :provider, :string, null: false + add :provider_meta, :map, null: false + add :provider_command_id, :string + add :provider_response_id, :string, null: false + add :command_source, :string, null: false + add :command_type, :string, null: false + add :ticket_id, references(:tickets), null: false + + timestamps() + end + + create unique_index(:command_responses, [:ticket_id, :command_type]) + end +end