Skip to content

Commit 61b95d9

Browse files
committed
feat: attempts (#43)
1 parent d70578e commit 61b95d9

File tree

7 files changed

+174
-29
lines changed

7 files changed

+174
-29
lines changed

lib/algora/bounties/bounties.ex

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule Algora.Bounties do
55

66
alias Algora.Accounts
77
alias Algora.Accounts.User
8+
alias Algora.Bounties.Attempt
89
alias Algora.Bounties.Bounty
910
alias Algora.Bounties.Claim
1011
alias Algora.Bounties.Jobs
@@ -138,25 +139,53 @@ defmodule Algora.Bounties do
138139

139140
@spec get_response_body(
140141
bounties :: list(Bounty.t()),
141-
ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()}
142+
ticket_ref :: %{owner: String.t(), repo: String.t(), number: integer()},
143+
attempts :: list(Attempt.t())
142144
) :: String.t()
143-
def get_response_body(bounties, ticket_ref) do
145+
def get_response_body(bounties, ticket_ref, attempts) do
144146
header =
145147
Enum.map_join(bounties, "\n", fn bounty ->
146148
"## 💎 #{bounty.amount} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})"
147149
end)
148150

151+
attempts_table =
152+
if Enum.empty?(attempts) do
153+
""
154+
else
155+
"""
156+
157+
| Attempt | Started (UTC) |
158+
| --- | --- |
159+
#{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)}
160+
"""
161+
end
162+
149163
"""
150164
#{header}
151165
### Steps to solve:
152-
1. **Start working**: Comment `/attempt ##{ticket_ref["number"]}` with your implementation plan
153-
2. **Submit work**: Create a pull request including `/claim ##{ticket_ref["number"]}` in the PR body to claim the bounty
166+
1. **Start working**: Comment `/attempt ##{ticket_ref[:number]}` with your implementation plan
167+
2. **Submit work**: Create a pull request including `/claim ##{ticket_ref[:number]}` in the PR body to claim the bounty
154168
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)
155169
156-
Thank you for contributing to #{ticket_ref["owner"]}/#{ticket_ref["repo"]}!
170+
Thank you for contributing to #{ticket_ref[:owner]}/#{ticket_ref[:repo]}!
171+
#{attempts_table}
157172
"""
158173
end
159174

175+
def refresh_bounty_response(token, ticket_ref, ticket) do
176+
bounties = list_bounties(ticket_id: ticket.id)
177+
attempts = list_attempts_for_ticket(ticket.id)
178+
body = get_response_body(bounties, ticket_ref, attempts)
179+
180+
Workspace.refresh_command_response(%{
181+
token: token,
182+
ticket_ref: ticket_ref,
183+
ticket: ticket,
184+
body: body,
185+
command_type: :bounty
186+
})
187+
end
188+
160189
@spec notify_bounty(
161190
%{
162191
owner: User.t(),
@@ -816,4 +845,42 @@ defmodule Algora.Bounties do
816845
{:ok, [debit, credit]}
817846
end
818847
end
848+
849+
@spec create_attempt(%{ticket: Ticket.t(), user: User.t()}) ::
850+
{:ok, Attempt.t()} | {:error, Ecto.Changeset.t()}
851+
def create_attempt(%{ticket: ticket, user: user}) do
852+
%Attempt{}
853+
|> Attempt.changeset(%{
854+
ticket_id: ticket.id,
855+
user_id: user.id
856+
})
857+
|> Repo.insert()
858+
end
859+
860+
@spec get_or_create_attempt(%{ticket: Ticket.t(), user: User.t()}) ::
861+
{:ok, Attempt.t()} | {:error, Ecto.Changeset.t()}
862+
def get_or_create_attempt(%{ticket: ticket, user: user}) do
863+
case Repo.fetch_by(Attempt, ticket_id: ticket.id, user_id: user.id) do
864+
{:ok, attempt} -> {:ok, attempt}
865+
{:error, _reason} -> create_attempt(%{ticket: ticket, user: user})
866+
end
867+
end
868+
869+
@spec list_attempts_for_ticket(String.t()) :: [Attempt.t()]
870+
def list_attempts_for_ticket(ticket_id) do
871+
Repo.all(
872+
from(a in Attempt,
873+
join: u in assoc(a, :user),
874+
where: a.ticket_id == ^ticket_id,
875+
order_by: [desc: a.inserted_at],
876+
select_merge: %{
877+
user: u
878+
}
879+
)
880+
)
881+
end
882+
883+
def get_attempt_emoji(%Attempt{status: :inactive}), do: "🔴"
884+
def get_attempt_emoji(%Attempt{warnings_count: count}) when count > 0, do: "🟡"
885+
def get_attempt_emoji(%Attempt{status: :active}), do: "🟢"
819886
end

lib/algora/bounties/jobs/notify_bounty.ex

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,26 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
6666
"command_source" => command_source
6767
}
6868
}) do
69+
ticket_ref = %{
70+
owner: ticket_ref["owner"],
71+
repo: ticket_ref["repo"],
72+
number: ticket_ref["number"]
73+
}
74+
6975
with {:ok, token} <- Github.get_installation_token(installation_id),
70-
{:ok, ticket} <- Workspace.ensure_ticket(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"]),
76+
{:ok, ticket} <- Workspace.ensure_ticket(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number),
7177
bounties when bounties != [] <- Bounties.list_bounties(ticket_id: ticket.id),
72-
{:ok, _} <- Github.add_labels(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], ["💎 Bounty"]) do
78+
{:ok, _} <- Github.add_labels(token, ticket_ref.owner, ticket_ref.repo, ticket_ref.number, ["💎 Bounty"]) do
79+
attempts = Bounties.list_attempts_for_ticket(ticket.id)
80+
7381
Workspace.ensure_command_response(%{
7482
token: token,
7583
ticket_ref: ticket_ref,
7684
command_id: command_id,
7785
command_type: :bounty,
7886
command_source: command_source,
7987
ticket: ticket,
80-
body: Bounties.get_response_body(bounties, ticket_ref)
88+
body: Bounties.get_response_body(bounties, ticket_ref, attempts)
8189
})
8290
end
8391
end

lib/algora/bounties/schemas/attempt.ex

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ defmodule Algora.Bounties.Attempt do
55
alias Algora.Activities.Activity
66

77
typed_schema "attempts" do
8-
belongs_to :bounty, Algora.Bounties.Bounty
9-
belongs_to :user, Algora.Accounts.User
8+
field :status, Ecto.Enum, values: [:active, :inactive], default: :active, null: false
9+
field :warnings_count, :integer, default: 0, null: false
10+
11+
belongs_to :ticket, Algora.Workspace.Ticket, null: false
12+
belongs_to :user, Algora.Accounts.User, null: false
1013

1114
has_many :activities, {"attempt_activities", Activity}, foreign_key: :assoc_id
1215

@@ -15,7 +18,11 @@ defmodule Algora.Bounties.Attempt do
1518

1619
def changeset(attempt, attrs) do
1720
attempt
18-
|> cast(attrs, [:bounty_id, :user_id])
19-
|> validate_required([:bounty_id, :user_id])
21+
|> cast(attrs, [:ticket_id, :user_id])
22+
|> generate_id()
23+
|> validate_required([:ticket_id, :user_id])
24+
|> unique_constraint([:ticket_id, :user_id])
25+
|> foreign_key_constraint(:ticket_id)
26+
|> foreign_key_constraint(:user_id)
2027
end
2128
end

lib/algora/integrations/github/command.ex

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ defmodule Algora.Github.Command do
99
bounty: "/bounty <amount>",
1010
tip: "/tip <amount> @username or /tip @username <amount>",
1111
claim: "/claim <issue-ref> (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)",
12-
split: "/split @username"
12+
split: "/split @username",
13+
attempt: "/attempt <issue-ref> (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)"
1314
}
1415

1516
def commands do
@@ -23,7 +24,8 @@ defmodule Algora.Github.Command do
2324
bounty_command(),
2425
tip_command(),
2526
claim_command(),
26-
split_command()
27+
split_command(),
28+
attempt_command()
2729
]),
2830

2931
# Unknown command
@@ -79,6 +81,16 @@ defmodule Algora.Github.Command do
7981
|> tag(:claim)
8082
|> label(@usage.claim)
8183
end
84+
85+
def attempt_command do
86+
"/attempt"
87+
|> string()
88+
|> ignore()
89+
|> concat(ignore(whitespace()))
90+
|> concat(ticket_ref())
91+
|> tag(:attempt)
92+
|> label(@usage.attempt)
93+
end
8294
end
8395

8496
defparsec(:parse_raw, Helper.commands())

lib/algora/workspace/workspace.ex

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ defmodule Algora.Workspace do
1515
require Logger
1616

1717
@type ticket_type :: :issue | :pull_request
18+
@type command_type :: :bounty | :attempt | :claim
19+
@type command_source :: :ticket | :comment
1820

1921
def ensure_ticket(token, owner, repo, number) do
2022
ticket_query =
@@ -170,8 +172,8 @@ defmodule Algora.Workspace do
170172
token: String.t(),
171173
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
172174
command_id: integer(),
173-
command_type: :bounty | :attempt | :claim,
174-
command_source: :ticket | :comment,
175+
command_type: command_type(),
176+
command_source: command_source(),
175177
ticket: Ticket.t(),
176178
body: String.t()
177179
}) :: {:ok, CommandResponse.t()} | {:error, any()}
@@ -209,24 +211,24 @@ defmodule Algora.Workspace do
209211

210212
@spec refresh_command_response(%{
211213
token: String.t(),
212-
command_type: :bounty | :attempt | :claim,
214+
command_type: command_type(),
213215
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()},
214216
ticket: Ticket.t(),
215217
body: String.t()
216218
}) :: {:ok, CommandResponse.t()} | {:error, any()}
217-
defp refresh_command_response(%{
218-
token: token,
219-
command_type: command_type,
220-
ticket_ref: ticket_ref,
221-
ticket: ticket,
222-
body: body
223-
}) do
219+
def refresh_command_response(%{
220+
token: token,
221+
command_type: command_type,
222+
ticket_ref: ticket_ref,
223+
ticket: ticket,
224+
body: body
225+
}) do
224226
case fetch_command_response(ticket.id, command_type) do
225227
{:ok, response} ->
226228
case Github.update_issue_comment(
227229
token,
228-
ticket_ref["owner"],
229-
ticket_ref["repo"],
230+
ticket_ref[:owner],
231+
ticket_ref[:repo],
230232
response.provider_response_id,
231233
body
232234
) do
@@ -235,7 +237,7 @@ defmodule Algora.Workspace do
235237

236238
# TODO: don't rely on string matching
237239
{:error, "404 Not Found"} ->
238-
Logger.error("Command response #{response.id} not found")
240+
Logger.error("Github comment for command response #{response.id} not found")
239241
{:error, {:comment_not_found, response.id}}
240242

241243
{:error, reason} ->
@@ -250,7 +252,7 @@ defmodule Algora.Workspace do
250252

251253
defp post_response(token, ticket_ref, command_id, command_source, ticket, body) do
252254
with {:ok, comment} <-
253-
Github.create_issue_comment(token, ticket_ref["owner"], ticket_ref["repo"], ticket_ref["number"], body) do
255+
Github.create_issue_comment(token, ticket_ref[:owner], ticket_ref[:repo], ticket_ref[:number], body) do
254256
create_command_response(%{
255257
comment: comment,
256258
command_source: command_source,
@@ -262,7 +264,7 @@ defmodule Algora.Workspace do
262264

263265
@spec create_command_response(%{
264266
comment: map(),
265-
command_source: :ticket | :comment,
267+
command_source: command_source(),
266268
command_id: integer(),
267269
ticket_id: integer()
268270
}) :: {:ok, CommandResponse.t()} | {:error, any()}

lib/algora_web/controllers/webhooks/github_controller.ex

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,35 @@ defmodule AlgoraWeb.Webhooks.GithubController do
140140
end
141141
end
142142

143+
defp execute_command(event_action, {:attempt, args}, author, params)
144+
when event_action in ["issue_comment.created", "issue_comment.edited"] do
145+
installation_id = params["installation"]["id"]
146+
repo = params["repository"]
147+
issue = params["issue"]
148+
149+
source_ticket_ref = %{
150+
owner: repo["owner"]["login"],
151+
repo: repo["name"],
152+
number: issue["number"]
153+
}
154+
155+
target_ticket_ref =
156+
%{
157+
owner: args[:ticket_ref][:owner] || source_ticket_ref.owner,
158+
repo: args[:ticket_ref][:repo] || source_ticket_ref.repo,
159+
number: args[:ticket_ref][:number]
160+
}
161+
162+
with true <- source_ticket_ref == target_ticket_ref,
163+
{:ok, token} <- Github.get_installation_token(installation_id),
164+
{:ok, ticket} <- Workspace.ensure_ticket(token, repo["owner"]["login"], repo["name"], issue["number"]),
165+
{:ok, user} <- Workspace.ensure_user(token, author["login"]),
166+
{:ok, attempt} <- Bounties.get_or_create_attempt(%{ticket: ticket, user: user}),
167+
{:ok, _} <- Bounties.refresh_bounty_response(token, target_ticket_ref, ticket) do
168+
{:ok, attempt}
169+
end
170+
end
171+
143172
defp execute_command(event_action, {:claim, args}, author, params)
144173
when event_action in ["pull_request.opened", "pull_request.reopened", "pull_request.edited"] do
145174
installation_id = params["installation"]["id"]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
defmodule Algora.Repo.Migrations.CreateAttempts do
2+
use Ecto.Migration
3+
4+
def change do
5+
create table(:attempts) do
6+
add :status, :string, null: false, default: "active"
7+
add :warnings_count, :integer, null: false, default: 0
8+
9+
add :ticket_id, references(:tickets, on_delete: :delete_all), null: false
10+
add :user_id, references(:users, on_delete: :delete_all), null: false
11+
12+
timestamps()
13+
end
14+
15+
create index(:attempts, [:ticket_id])
16+
create index(:attempts, [:user_id])
17+
18+
create unique_index(:attempts, [:ticket_id, :user_id])
19+
end
20+
end

0 commit comments

Comments
 (0)