diff --git a/lib/algora/accounts/schemas/user.ex b/lib/algora/accounts/schemas/user.ex index 16057d41f..59150ba77 100644 --- a/lib/algora/accounts/schemas/user.ex +++ b/lib/algora/accounts/schemas/user.ex @@ -97,6 +97,7 @@ defmodule Algora.Accounts.User do has_many :client_contracts, Contract, foreign_key: :client_id has_many :activities, {"user_activities", Activity}, foreign_key: :assoc_id + has_one :bot_template, Algora.BotTemplates.BotTemplate, foreign_key: :user_id has_one :customer, Algora.Payments.Customer, foreign_key: :user_id timestamps() diff --git a/lib/algora/admin/migration/migration.ex b/lib/algora/admin/migration/migration.ex index 602817dd3..988fd0c56 100644 --- a/lib/algora/admin/migration/migration.ex +++ b/lib/algora/admin/migration/migration.ex @@ -22,6 +22,7 @@ defmodule Algora.Admin.Migration do alias Algora.Accounts.Identity alias Algora.Accounts.User alias Algora.Admin + alias Algora.BotTemplates.BotTemplate alias Algora.Bounties.Attempt alias Algora.Bounties.Bounty alias Algora.Bounties.Claim @@ -54,6 +55,7 @@ defmodule Algora.Admin.Migration do {"BountyCharge", Transaction}, {"BountyTransfer", Tip}, {"BountyTransfer", Transaction}, + {"BotMessage", BotTemplate}, {"OrgBalanceTransaction", Transaction}, {"GithubInstallation", Installation}, {"StripeAccount", Account}, @@ -627,6 +629,24 @@ defmodule Algora.Admin.Migration do end end + defp transform({"BotMessage", BotTemplate}, row, db) do + user = find_by_index(db, "_MergedUser", "id", row["org_id"]) + + if !user do + raise "User not found: #{inspect(row)}" + end + + %{ + "id" => row["id"], + "inserted_at" => row["created_at"], + "updated_at" => row["updated_at"], + "active" => row["active"], + "template" => row["template"], + "type" => row["type"], + "user_id" => user["id"] + } + end + defp transform({"OrgBalanceTransaction", Transaction}, row, db) do user = find_by_index(db, "_MergedUser", "id", row["org_id"]) diff --git a/lib/algora/admin/migration/v1-progress.yaml b/lib/algora/admin/migration/v1-progress.yaml index f1e4c26a2..d46a93700 100644 --- a/lib/algora/admin/migration/v1-progress.yaml +++ b/lib/algora/admin/migration/v1-progress.yaml @@ -57,13 +57,13 @@ - github_res_comment_id: -1 - type: -1 - "BotMessage": - - id: -1 - - created_at: -1 - - updated_at: -1 - - active: -1 - - template: -1 - - type: -1 - - org_id: -1 + - id: 1 + - created_at: 1 + - updated_at: 1 + - active: 1 + - template: 1 + - type: 1 + - org_id: 1 - "Bounty": - id: 1 - created_at: 1 diff --git a/lib/algora/bot_templates/bot_templates.ex b/lib/algora/bot_templates/bot_templates.ex new file mode 100644 index 000000000..a6b459961 --- /dev/null +++ b/lib/algora/bot_templates/bot_templates.ex @@ -0,0 +1,71 @@ +defmodule Algora.BotTemplates do + @moduledoc false + + alias Algora.BotTemplates.BotTemplate + alias Algora.Repo + + def get_default_template(:bounty_created) do + """ + ${PRIZE_POOL} + ### Steps to solve: + 1. **Start working**: Comment `/attempt #${ISSUE_NUMBER}` with your implementation plan + 2. **Submit work**: Create a pull request including `/claim #${ISSUE_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 ${REPO_FULL_NAME}! + ${ATTEMPTS} + """ + end + + def get_default_template(_type), do: raise("Not implemented") + + def placeholders(:bounty_created, user) do + %{ + "PRIZE_POOL" => "## 💎 $1,000 bounty [• #{user.name}](#{AlgoraWeb.Endpoint.url()}/@/#{user.handle})", + "ISSUE_NUMBER" => "100", + "REPO_FULL_NAME" => "#{user.provider_login || user.handle}/repo", + "ATTEMPTS" => """ + | Attempt | Started (UTC) | Solution | Actions | + | --- | --- | --- | --- | + | 🟢 [@jsmith](https://github.com/jsmith) | #{Calendar.strftime(DateTime.utc_now(), "%b %d, %Y, %I:%M:%S %p")} | [#101](https://github.com/#{user.provider_login || user.handle}/repo/pull/101) | [Reward](#{AlgoraWeb.Endpoint.url()}/claims/:id) | + """, + "FUND_URL" => AlgoraWeb.Endpoint.url(), + "TWEET_URL" => + "https://twitter.com/intent/tweet?related=algoraio&text=%241%2C000+bounty%21+%F0%9F%92%8E+https%3A%2F%2Fgithub.com%2F#{user.provider_login || user.handle}%2Frepo%2Fissues%2F100", + "ADDITIONAL_OPPORTUNITIES" => "" + } + end + + def placeholders(_type, _user), do: raise("Not implemented") + + def available_variables(:bounty_created) do + [ + "PRIZE_POOL", + "ISSUE_NUMBER", + "REPO_FULL_NAME", + "ATTEMPTS", + "FUND_URL", + "TWEET_URL" + ] + end + + def get_template(org_id, type) do + Repo.get_by(BotTemplate, user_id: org_id, type: type, active: true) + end + + def save_template(org_id, type, template) do + params = %{ + user_id: org_id, + type: type, + template: template, + active: true + } + + %BotTemplate{} + |> BotTemplate.changeset(params) + |> Repo.insert( + on_conflict: [set: [template: template, active: true]], + conflict_target: [:user_id, :type] + ) + end +end diff --git a/lib/algora/bot_templates/schemas/bot_template.ex b/lib/algora/bot_templates/schemas/bot_template.ex new file mode 100644 index 000000000..5828844fb --- /dev/null +++ b/lib/algora/bot_templates/schemas/bot_template.ex @@ -0,0 +1,32 @@ +defmodule Algora.BotTemplates.BotTemplate do + @moduledoc false + use Algora.Schema + + import Ecto.Changeset + + @types [ + :multiple_attempts_detected, + :attempt_rejected, + :bounty_created, + :claim_submitted, + :bounty_awarded + ] + + typed_schema "bot_templates" do + field :template, :string, null: false + field :type, Ecto.Enum, values: @types, null: false + field :active, :boolean, null: false, default: true + belongs_to :user, Algora.Accounts.User, null: false + + timestamps() + end + + def changeset(bot_template, attrs) do + bot_template + |> cast(attrs, [:template, :type, :active, :user_id]) + |> validate_required([:template, :type, :user_id]) + |> generate_id() + |> foreign_key_constraint(:user_id) + |> unique_constraint([:user_id, :type]) + end +end diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 993eec02c..56fff3e6a 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -4,6 +4,8 @@ defmodule Algora.Bounties do import Ecto.Query alias Algora.Accounts.User + alias Algora.BotTemplates + alias Algora.BotTemplates.BotTemplate alias Algora.Bounties.Attempt alias Algora.Bounties.Bounty alias Algora.Bounties.Claim @@ -168,11 +170,62 @@ defmodule Algora.Bounties do claims :: list(Claim.t()) ) :: String.t() def get_response_body(bounties, ticket_ref, attempts, claims) do - header = - Enum.map_join(bounties, "\n", fn bounty -> - "## 💎 #{bounty.amount} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})" - end) + custom_template = + Repo.one( + from bt in BotTemplate, + where: bt.type == :bounty_created, + where: bt.active == true, + join: u in assoc(bt, :user), + join: r in assoc(u, :repositories), + join: t in assoc(r, :tickets), + where: t.id == ^List.first(bounties).ticket_id + ) + + prize_pool = format_prize_pool(bounties) + attempts_table = format_attempts_table(attempts, claims) + + template = + if custom_template do + custom_template.template + else + BotTemplates.get_default_template(:bounty_created) + end + template + |> String.replace("${PRIZE_POOL}", prize_pool) + |> String.replace("${ISSUE_NUMBER}", to_string(ticket_ref[:number])) + |> String.replace("${REPO_FULL_NAME}", "#{ticket_ref[:owner]}/#{ticket_ref[:repo]}") + |> String.replace("${ATTEMPTS}", attempts_table) + |> String.replace("${FUND_URL}", AlgoraWeb.Endpoint.url()) + |> String.replace("${TWEET_URL}", generate_tweet_url(bounties, ticket_ref)) + |> String.replace("${ADDITIONAL_OPPORTUNITIES}", "") + |> String.trim() + end + + defp generate_tweet_url(bounties, ticket_ref) do + total_amount = Enum.reduce(bounties, Money.new(0, :USD), &Money.add!(&2, &1.amount)) + + text = + "#{Money.to_string!(total_amount, no_fraction_if_integer: true)} bounty! 💎 https://github.com/#{ticket_ref[:owner]}/#{ticket_ref[:repo]}/issues/#{ticket_ref[:number]}" + + uri = URI.parse("https://twitter.com/intent/tweet") + + query = + URI.encode_query(%{ + "text" => text, + "related" => "algoraio" + }) + + URI.to_string(%{uri | query: query}) + end + + defp format_prize_pool(bounties) do + Enum.map_join(bounties, "\n", fn bounty -> + "## 💎 #{Money.to_string!(bounty.amount, no_fraction_if_integer: true)} bounty [• #{bounty.owner.name}](#{User.url(bounty.owner)})" + end) + end + + defp format_attempts_table(attempts, claims) do solutions = [] |> Enum.concat(Enum.map(claims, &claim_to_solution/1)) @@ -205,28 +258,16 @@ defmodule Algora.Bounties do "| #{primary_solution.indicator} #{users} | #{timestamp} | #{primary_solution.solution} | #{actions} |" end) - solutions_table = - if solutions == [] do - "" - else - """ - - | Attempt | Started (UTC) | Solution | Actions | - | --- | --- | --- | --- | - #{Enum.join(solutions, "\n")} - """ - end + if solutions == [] do + "" + else + """ - String.trim(""" - #{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 - 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]}! - #{solutions_table} - """) + | Attempt | Started (UTC) | Solution | Actions | + | --- | --- | --- | --- | + #{Enum.join(solutions, "\n")} + """ + end end def refresh_bounty_response(token, ticket_ref, ticket) do @@ -1009,6 +1050,7 @@ defmodule Algora.Bounties do avatar_url: o.avatar_url, tech_stack: o.tech_stack }, + ticket_id: t.id, ticket: %{ id: t.id, title: t.title, diff --git a/lib/algora_web/components/core_components.ex b/lib/algora_web/components/core_components.ex index 09bfa62e0..6b957be0e 100644 --- a/lib/algora_web/components/core_components.ex +++ b/lib/algora_web/components/core_components.ex @@ -756,7 +756,7 @@ defmodule AlgoraWeb.CoreComponents do id={@id || @name} name={@name} class={[ - "min-h-[6rem] py-[7px] px-[11px] mt-2 block w-full rounded-lg border-input bg-background", + "min-h-[6rem] py-[7px] px-[11px] block w-full rounded-lg border-input bg-background", "text-foreground focus:border-ring focus:outline-none focus:ring-4 focus:ring-ring/5 sm:text-sm sm:leading-6", "border-input focus:border-ring focus:ring-ring/5", @errors != [] && "border-destructive focus:border-destructive focus:ring-destructive/10", diff --git a/lib/algora_web/live/org/settings_live.ex b/lib/algora_web/live/org/settings_live.ex index bc6a32ce2..3d5e04f3c 100644 --- a/lib/algora_web/live/org/settings_live.ex +++ b/lib/algora_web/live/org/settings_live.ex @@ -4,7 +4,10 @@ defmodule AlgoraWeb.Org.SettingsLive do alias Algora.Accounts alias Algora.Accounts.User + alias Algora.BotTemplates + alias Algora.BotTemplates.BotTemplate alias Algora.Github + alias Algora.Markdown alias Algora.Payments alias AlgoraWeb.Components.Logos @@ -134,6 +137,53 @@ defmodule AlgoraWeb.Org.SettingsLive do + + <.card> + <.card_header> + <.card_title>Bot Templates + <.card_description> + Customize the messages that Algora bot sends on your repositories + + + <.card_content> + <.simple_form for={@template_form} phx-change="validate_template" phx-submit="save_template"> +