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
77 changes: 72 additions & 5 deletions lib/algora/bounties/bounties.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
14 changes: 11 additions & 3 deletions lib/algora/bounties/jobs/notify_bounty.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,26 @@ 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,
command_id: command_id,
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
Expand Down
15 changes: 11 additions & 4 deletions lib/algora/bounties/schemas/attempt.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
16 changes: 14 additions & 2 deletions lib/algora/integrations/github/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ defmodule Algora.Github.Command do
bounty: "/bounty <amount>",
tip: "/tip <amount> @username or /tip @username <amount>",
claim: "/claim <issue-ref> (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)",
split: "/split @username"
split: "/split @username",
attempt: "/attempt <issue-ref> (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)"
}

def commands do
Expand All @@ -23,7 +24,8 @@ defmodule Algora.Github.Command do
bounty_command(),
tip_command(),
claim_command(),
split_command()
split_command(),
attempt_command()
]),

# Unknown command
Expand Down Expand Up @@ -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())
Expand Down
32 changes: 17 additions & 15 deletions lib/algora/workspace/workspace.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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()}
Expand Down Expand Up @@ -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
Expand All @@ -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} ->
Expand All @@ -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,
Expand All @@ -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()}
Expand Down
29 changes: 29 additions & 0 deletions lib/algora_web/controllers/webhooks/github_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
20 changes: 20 additions & 0 deletions priv/repo/migrations/20250126122024_create_attempts.exs
Original file line number Diff line number Diff line change
@@ -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
Loading