From 1cc2da3e59943e10c600b78ac4b6df092b40cd88 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 26 Jan 2025 12:21:11 +0300 Subject: [PATCH 1/3] init attempts --- lib/algora/bounties/schemas/attempt.ex | 11 ++++++---- .../20250126122024_create_attempts.exs | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 priv/repo/migrations/20250126122024_create_attempts.exs diff --git a/lib/algora/bounties/schemas/attempt.ex b/lib/algora/bounties/schemas/attempt.ex index d7d50e9ef..1ccbf278b 100644 --- a/lib/algora/bounties/schemas/attempt.ex +++ b/lib/algora/bounties/schemas/attempt.ex @@ -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.Tickets.Ticket, null: false + belongs_to :user, Algora.Accounts.User, null: false has_many :activities, {"attempt_activities", Activity}, foreign_key: :assoc_id @@ -15,7 +18,7 @@ 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]) + |> validate_required([:ticket_id, :user_id]) end end diff --git a/priv/repo/migrations/20250126122024_create_attempts.exs b/priv/repo/migrations/20250126122024_create_attempts.exs new file mode 100644 index 000000000..d9eaa403f --- /dev/null +++ b/priv/repo/migrations/20250126122024_create_attempts.exs @@ -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 From be917e5c949ea247864456f427ef3f6134a9c292 Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 26 Jan 2025 12:24:32 +0300 Subject: [PATCH 2/3] add /attempt command --- lib/algora/integrations/github/command.ex | 13 ++++++++++++- .../controllers/webhooks/github_controller.ex | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/algora/integrations/github/command.ex b/lib/algora/integrations/github/command.ex index 26245da4d..08f4f24aa 100644 --- a/lib/algora/integrations/github/command.ex +++ b/lib/algora/integrations/github/command.ex @@ -9,7 +9,8 @@ defmodule Algora.Github.Command do bounty: "/bounty ", tip: "/tip @username or /tip @username ", claim: "/claim (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)", - split: "/split @username" + split: "/split @username", + attempt: "/attempt (e.g. #123, repo#123, owner/repo#123, or full GitHub URL)" } def commands do @@ -79,6 +80,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()) diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 0ed347339..982ff9785 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -140,6 +140,14 @@ 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 + ticket_ref = args[:ticket_ref] + + # TODO: implement + dbg("#{author["login"]} is attempting #{ticket_ref}") + 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"] From 818ee8fa6573894297c20777af1f2a8ba35caeff Mon Sep 17 00:00:00 2001 From: zafer Date: Sun, 26 Jan 2025 21:04:12 +0300 Subject: [PATCH 3/3] implement /attempt --- lib/algora/bounties/bounties.ex | 77 +++++++++++++++++-- lib/algora/bounties/jobs/notify_bounty.ex | 14 +++- lib/algora/bounties/schemas/attempt.ex | 6 +- lib/algora/integrations/github/command.ex | 3 +- lib/algora/workspace/workspace.ex | 32 ++++---- .../controllers/webhooks/github_controller.ex | 29 ++++++- 6 files changed, 132 insertions(+), 29 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 587cb4c73..a75200c4b 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -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 @@ -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(), @@ -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 diff --git a/lib/algora/bounties/jobs/notify_bounty.ex b/lib/algora/bounties/jobs/notify_bounty.ex index 38f900d00..b27f4f156 100644 --- a/lib/algora/bounties/jobs/notify_bounty.ex +++ b/lib/algora/bounties/jobs/notify_bounty.ex @@ -66,10 +66,18 @@ 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, @@ -77,7 +85,7 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do 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 diff --git a/lib/algora/bounties/schemas/attempt.ex b/lib/algora/bounties/schemas/attempt.ex index 1ccbf278b..ece24c526 100644 --- a/lib/algora/bounties/schemas/attempt.ex +++ b/lib/algora/bounties/schemas/attempt.ex @@ -8,7 +8,7 @@ defmodule Algora.Bounties.Attempt do field :status, Ecto.Enum, values: [:active, :inactive], default: :active, null: false field :warnings_count, :integer, default: 0, null: false - belongs_to :ticket, Algora.Tickets.Ticket, 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 @@ -19,6 +19,10 @@ defmodule Algora.Bounties.Attempt do def changeset(attempt, attrs) do attempt |> 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 diff --git a/lib/algora/integrations/github/command.ex b/lib/algora/integrations/github/command.ex index 08f4f24aa..6724aa926 100644 --- a/lib/algora/integrations/github/command.ex +++ b/lib/algora/integrations/github/command.ex @@ -24,7 +24,8 @@ defmodule Algora.Github.Command do bounty_command(), tip_command(), claim_command(), - split_command() + split_command(), + attempt_command() ]), # Unknown command diff --git a/lib/algora/workspace/workspace.ex b/lib/algora/workspace/workspace.ex index db7dd0032..1c6a62088 100644 --- a/lib/algora/workspace/workspace.ex +++ b/lib/algora/workspace/workspace.ex @@ -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 = @@ -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()} @@ -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 @@ -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} -> @@ -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, @@ -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()} diff --git a/lib/algora_web/controllers/webhooks/github_controller.ex b/lib/algora_web/controllers/webhooks/github_controller.ex index 982ff9785..25e1d1283 100644 --- a/lib/algora_web/controllers/webhooks/github_controller.ex +++ b/lib/algora_web/controllers/webhooks/github_controller.ex @@ -140,12 +140,33 @@ defmodule AlgoraWeb.Webhooks.GithubController do end end - defp execute_command(event_action, {:attempt, args}, author, _params) + defp execute_command(event_action, {:attempt, args}, author, params) when event_action in ["issue_comment.created", "issue_comment.edited"] do - ticket_ref = args[:ticket_ref] + installation_id = params["installation"]["id"] + repo = params["repository"] + issue = params["issue"] - # TODO: implement - dbg("#{author["login"]} is attempting #{ticket_ref}") + 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)