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
6 changes: 4 additions & 2 deletions lib/algora/accounts/schemas/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
47 changes: 41 additions & 6 deletions lib/algora/bounties/bounties.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()]}

Expand Down Expand Up @@ -66,14 +67,31 @@ 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(),
owner: User.t(),
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(
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -112,15 +142,17 @@ 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
%{
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()
Expand Down Expand Up @@ -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)

Expand Down
130 changes: 108 additions & 22 deletions lib/algora/bounties/jobs/notify_bounty.ex
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"]},
Expand All @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions lib/algora/integrations/github/behaviour.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions lib/algora/integrations/github/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}")
Expand Down
4 changes: 4 additions & 0 deletions lib/algora/integrations/github/github.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 10 additions & 6 deletions lib/algora/integrations/github/poller/comment_consumer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)}")
Expand Down
44 changes: 44 additions & 0 deletions lib/algora/workspace/schemas/command_response.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading