Skip to content

Commit d6695b3

Browse files
authored
feat: add CommandResponse schema to track and update bot comments (#42)
1 parent 6fb95e0 commit d6695b3

File tree

11 files changed

+279
-37
lines changed

11 files changed

+279
-37
lines changed

lib/algora/accounts/schemas/user.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule Algora.Accounts.User do
1212
alias Algora.Organizations.Member
1313
alias Algora.Types.Money
1414
alias Algora.Workspace.Installation
15+
alias AlgoraWeb.Endpoint
1516

1617
@derive {Inspect, except: [:provider_meta]}
1718
typed_schema "users" do
@@ -322,8 +323,9 @@ defmodule Algora.Accounts.User do
322323
def handle(%{handle: handle}) when is_binary(handle), do: handle
323324
def handle(%{provider_login: handle}), do: handle
324325

325-
def url(%{handle: handle, type: :individual}) when is_binary(handle), do: "/@/#{handle}"
326-
def url(%{handle: handle, type: :organization}), do: "/org/#{handle}"
326+
def url(%{handle: handle, type: :individual}) when is_binary(handle), do: "#{Endpoint.url()}/@/#{handle}"
327+
def url(%{handle: handle, type: :organization}), do: "#{Endpoint.url()}/org/#{handle}"
328+
def url(%{handle: handle}) when is_binary(handle), do: "#{Endpoint.url()}/org/#{handle}"
327329
def url(%{provider_login: handle}), do: "https://github.com/#{handle}"
328330

329331
def last_context(%{last_context: last_context}), do: last_context || default_context()

lib/algora/bounties/bounties.ex

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ defmodule Algora.Bounties do
2626

2727
@type criterion ::
2828
{:limit, non_neg_integer()}
29-
| {:owner_id, integer()}
29+
| {:ticket_id, String.t()}
30+
| {:owner_id, String.t()}
3031
| {:status, :open | :paid}
3132
| {:tech_stack, [String.t()]}
3233

@@ -66,14 +67,31 @@ defmodule Algora.Bounties do
6667
end
6768
end
6869

70+
@type strategy :: :create | :set | :increase
71+
72+
@spec strategy_to_action(Bounty.t() | nil, strategy() | nil) :: {:ok, strategy()} | {:error, atom()}
73+
defp strategy_to_action(bounty, strategy) do
74+
case {bounty, strategy} do
75+
{_, nil} -> strategy_to_action(bounty, :increase)
76+
{nil, _} -> {:ok, :create}
77+
{_existing, :create} -> {:error, :already_exists}
78+
{_existing, strategy} -> {:ok, strategy}
79+
end
80+
end
81+
6982
@spec create_bounty(
7083
%{
7184
creator: User.t(),
7285
owner: User.t(),
7386
amount: Money.t(),
7487
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}
7588
},
76-
opts :: [installation_id: integer()]
89+
opts :: [
90+
strategy: strategy(),
91+
installation_id: integer(),
92+
command_id: integer(),
93+
command_source: :ticket | :comment
94+
]
7795
) ::
7896
{:ok, Bounty.t()} | {:error, atom()}
7997
def create_bounty(
@@ -86,6 +104,7 @@ defmodule Algora.Bounties do
86104
opts \\ []
87105
) do
88106
installation_id = opts[:installation_id]
107+
command_id = opts[:command_id]
89108

90109
token_res =
91110
if installation_id,
@@ -95,9 +114,20 @@ defmodule Algora.Bounties do
95114
Repo.transact(fn ->
96115
with {:ok, token} <- token_res,
97116
{:ok, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number),
98-
{:ok, bounty} <- do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}),
117+
existing = Repo.get_by(Bounty, owner_id: owner.id, ticket_id: ticket.id),
118+
{:ok, strategy} <- strategy_to_action(existing, opts[:strategy]),
119+
{:ok, bounty} <-
120+
(case strategy do
121+
:create -> do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket})
122+
:set -> existing |> Bounty.changeset(%{amount: amount}) |> Repo.update()
123+
:increase -> existing |> Bounty.changeset(%{amount: Money.add!(existing.amount, amount)}) |> Repo.update()
124+
end),
99125
{:ok, _job} <-
100-
notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, installation_id: installation_id) do
126+
notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref},
127+
installation_id: installation_id,
128+
command_id: command_id,
129+
command_source: opts[:command_source]
130+
) do
101131
broadcast()
102132
{:ok, bounty}
103133
else
@@ -112,15 +142,17 @@ defmodule Algora.Bounties do
112142
bounty: Bounty.t(),
113143
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}
114144
},
115-
opts :: [installation_id: integer()]
145+
opts :: [installation_id: integer(), command_id: integer(), command_source: :ticket | :comment]
116146
) ::
117147
{:ok, Oban.Job.t()} | {:error, atom()}
118148
def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, opts \\ []) do
119149
%{
120150
owner_login: owner.provider_login,
121151
amount: Money.to_string!(bounty.amount, no_fraction_if_integer: true),
122152
ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number},
123-
installation_id: opts[:installation_id]
153+
installation_id: opts[:installation_id],
154+
command_id: opts[:command_id],
155+
command_source: opts[:command_source]
124156
}
125157
|> Jobs.NotifyBounty.new()
126158
|> Oban.insert()
@@ -593,6 +625,9 @@ defmodule Algora.Bounties do
593625
{:limit, limit}, query ->
594626
from([b] in query, limit: ^limit)
595627

628+
{:ticket_id, ticket_id}, query ->
629+
from([b] in query, where: b.ticket_id == ^ticket_id)
630+
596631
{:owner_id, owner_id}, query ->
597632
from([b] in query, where: b.owner_id == ^owner_id)
598633

Lines changed: 108 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
defmodule Algora.Bounties.Jobs.NotifyBounty do
22
@moduledoc false
3-
use Oban.Worker, queue: :notify_bounty
3+
use Oban.Worker,
4+
queue: :notify_bounty,
5+
max_attempts: 1
46

5-
alias Algora.Accounts
67
alias Algora.Accounts.User
8+
alias Algora.Bounties
79
alias Algora.Github
10+
alias Algora.Repo
11+
alias Algora.Util
812
alias Algora.Workspace
13+
alias Algora.Workspace.CommandResponse
914

1015
require Logger
1116

1217
@impl Oban.Worker
1318
def perform(%Oban.Job{
14-
args: %{"owner_login" => owner_login, "amount" => amount, "ticket_ref" => ticket_ref, "installation_id" => nil}
19+
args: %{
20+
"owner_login" => owner_login,
21+
"amount" => amount,
22+
"ticket_ref" => ticket_ref,
23+
"installation_id" => nil,
24+
"command_id" => command_id,
25+
"command_source" => command_source
26+
}
1527
}) do
1628
body = """
1729
💎 **#{owner_login}** is offering a **#{amount}** bounty for this issue
@@ -20,13 +32,19 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
2032
"""
2133

2234
if Github.pat_enabled() do
23-
Github.create_issue_comment(
24-
Github.pat(),
25-
ticket_ref["owner"],
26-
ticket_ref["repo"],
27-
ticket_ref["number"],
28-
body
29-
)
35+
with {:ok, response} <-
36+
Github.create_issue_comment(
37+
Github.pat(),
38+
ticket_ref["owner"],
39+
ticket_ref["repo"],
40+
ticket_ref["number"],
41+
body
42+
),
43+
{:ok, ticket} <-
44+
Workspace.ensure_ticket(Github.pat(), ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"]) do
45+
# TODO: update existing command response if it exists
46+
create_command_response(response, command_source, command_id, ticket.id)
47+
end
3048
else
3149
Logger.info("""
3250
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
3856
end
3957

4058
@impl Oban.Worker
41-
def perform(%Oban.Job{args: %{"amount" => amount, "ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do
59+
def perform(%Oban.Job{
60+
args: %{
61+
"amount" => _amount,
62+
"ticket_ref" => ticket_ref,
63+
"installation_id" => installation_id,
64+
"command_id" => command_id,
65+
"command_source" => command_source
66+
}
67+
}) do
4268
with {:ok, token} <- Github.get_installation_token(installation_id),
43-
{:ok, installation} <-
44-
Workspace.fetch_installation_by(provider: "github", provider_id: to_string(installation_id)),
45-
{:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id),
69+
{:ok, ticket} <- Workspace.ensure_ticket(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"]),
70+
bounties when bounties != [] <- Bounties.list_bounties(ticket_id: ticket.id),
4671
{:ok, _} <- Github.add_labels(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], ["💎 Bounty"]) do
72+
header =
73+
Enum.map_join(bounties, "\n", fn bounty ->
74+
"## 💎 #{bounty.amount} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})"
75+
end)
76+
4777
body = """
48-
## 💎 #{amount} bounty [• #{owner.name}](#{User.url(owner)})
78+
#{header}
4979
### Steps to solve:
5080
1. **Start working**: Comment `/attempt ##{ticket_ref["number"]}` with your implementation plan
5181
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
5484
Thank you for contributing to #{ticket_ref["owner"]}/#{ticket_ref["repo"]}!
5585
"""
5686

57-
Github.create_issue_comment(
58-
token,
59-
ticket_ref["owner"],
60-
ticket_ref["repo"],
61-
ticket_ref["number"],
62-
body
63-
)
87+
ensure_command_response(token, ticket_ref, command_id, command_source, ticket, body)
88+
end
89+
end
90+
91+
defp ensure_command_response(token, ticket_ref, command_id, command_source, ticket, body) do
92+
case Workspace.fetch_command_response(ticket.id, :bounty) do
93+
{:ok, response} ->
94+
case Github.update_issue_comment(
95+
token,
96+
ticket_ref["owner"],
97+
ticket_ref["repo"],
98+
response.provider_response_id,
99+
body
100+
) do
101+
{:ok, comment} ->
102+
try_update_command_response(response, comment)
103+
104+
{:error, "404 Not Found"} ->
105+
with {:ok, _} <- Workspace.delete_command_response(response.id) do
106+
post_response(token, ticket_ref, command_id, command_source, ticket, body)
107+
end
108+
109+
{:error, reason} ->
110+
Logger.error("Failed to update command response #{response.id}: #{inspect(reason)}")
111+
{:error, reason}
112+
end
113+
114+
{:error, _reason} ->
115+
post_response(token, ticket_ref, command_id, command_source, ticket, body)
116+
end
117+
end
118+
119+
defp post_response(token, ticket_ref, command_id, command_source, ticket, body) do
120+
with {:ok, comment} <-
121+
Github.create_issue_comment(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], body) do
122+
create_command_response(comment, command_source, command_id, ticket.id)
123+
end
124+
end
125+
126+
defp create_command_response(comment, command_source, command_id, ticket_id) do
127+
%CommandResponse{}
128+
|> CommandResponse.changeset(%{
129+
provider: "github",
130+
provider_meta: Util.normalize_struct(comment),
131+
provider_command_id: to_string(command_id),
132+
provider_response_id: to_string(comment["id"]),
133+
command_source: command_source,
134+
command_type: :bounty,
135+
ticket_id: ticket_id
136+
})
137+
|> Repo.insert()
138+
end
139+
140+
defp try_update_command_response(command_response, body) do
141+
case command_response
142+
|> CommandResponse.changeset(%{provider_meta: Util.normalize_struct(body)})
143+
|> Repo.update() do
144+
{:ok, command_response} ->
145+
{:ok, command_response}
146+
147+
{:error, reason} ->
148+
Logger.error("Failed to update command response #{command_response.id}: #{inspect(reason)}")
149+
{:ok, command_response}
64150
end
65151
end
66152
end

lib/algora/integrations/github/behaviour.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ defmodule Algora.Github.Behaviour do
1717
@callback find_installation(token(), integer(), integer()) :: response
1818
@callback get_installation_token(integer()) :: response
1919
@callback create_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: response
20+
@callback update_issue_comment(token(), String.t(), String.t(), integer(), String.t()) :: response
2021
@callback list_repository_events(token(), String.t(), String.t(), keyword()) :: response
2122
@callback list_repository_comments(token(), String.t(), String.t(), keyword()) :: response
2223
@callback add_labels(token(), String.t(), String.t(), integer(), [String.t()]) :: response

lib/algora/integrations/github/client.ex

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,16 @@ defmodule Algora.Github.Client do
176176
)
177177
end
178178

179+
@impl true
180+
def update_issue_comment(access_token, owner, repo, comment_id, body) do
181+
fetch(
182+
access_token,
183+
"/repos/#{owner}/#{repo}/issues/comments/#{comment_id}",
184+
"PATCH",
185+
%{body: body}
186+
)
187+
end
188+
179189
@impl true
180190
def list_repository_events(access_token, owner, repo, opts \\ []) do
181191
fetch(access_token, "/repos/#{owner}/#{repo}/events#{build_query(opts)}")

lib/algora/integrations/github/github.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ defmodule Algora.Github do
8484
def create_issue_comment(token, owner, repo, number, body),
8585
do: client().create_issue_comment(token, owner, repo, number, body)
8686

87+
@impl true
88+
def update_issue_comment(token, owner, repo, comment_id, body),
89+
do: client().update_issue_comment(token, owner, repo, comment_id, body)
90+
8791
@impl true
8892
def list_repository_events(token, owner, repo, opts \\ []),
8993
do: client().list_repository_events(token, owner, repo, opts)

lib/algora/integrations/github/poller/comment_consumer.ex

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ defmodule Algora.Github.Poller.CommentConsumer do
3434
defp run_command({:bounty, args}, ticket_ref, comment) do
3535
case Accounts.fetch_user_by(provider_id: to_string(comment["user"]["id"])) do
3636
{:ok, user} ->
37-
Bounties.create_bounty(%{
38-
creator: user,
39-
owner: user,
40-
amount: args[:amount],
41-
ticket_ref: %{owner: ticket_ref[:owner], repo: ticket_ref[:repo], number: ticket_ref[:number]}
42-
})
37+
Bounties.create_bounty(
38+
%{
39+
creator: user,
40+
owner: user,
41+
amount: args[:amount],
42+
ticket_ref: %{owner: ticket_ref[:owner], repo: ticket_ref[:repo], number: ticket_ref[:number]}
43+
},
44+
command_id: comment["id"],
45+
command_source: :comment
46+
)
4347

4448
{:error, _reason} = error ->
4549
Logger.error("Failed to create bounty: #{inspect(error)}")
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
defmodule Algora.Workspace.CommandResponse do
2+
@moduledoc """
3+
Schema for tracking command comments and their corresponding bot responses.
4+
This allows updating existing bot responses instead of creating new ones.
5+
"""
6+
use Algora.Schema
7+
8+
typed_schema "command_responses" do
9+
field :provider, :string, null: false
10+
field :provider_meta, :map, null: false
11+
field :provider_command_id, :string
12+
field :provider_response_id, :string, null: false
13+
field :command_source, Ecto.Enum, values: [:ticket, :comment], null: false
14+
field :command_type, Ecto.Enum, values: [:bounty, :attempt, :claim], null: false
15+
16+
belongs_to :ticket, Algora.Workspace.Ticket, null: false
17+
18+
timestamps()
19+
end
20+
21+
def changeset(command_response, attrs) do
22+
command_response
23+
|> cast(attrs, [
24+
:provider,
25+
:provider_meta,
26+
:provider_command_id,
27+
:provider_response_id,
28+
:command_source,
29+
:command_type,
30+
:ticket_id
31+
])
32+
|> validate_required([
33+
:provider,
34+
:provider_meta,
35+
:provider_response_id,
36+
:command_source,
37+
:command_type,
38+
:ticket_id
39+
])
40+
|> generate_id()
41+
|> foreign_key_constraint(:ticket_id)
42+
|> unique_constraint([:provider, :provider_command_id])
43+
end
44+
end

0 commit comments

Comments
 (0)