From 680a83b3aba13fc8f486f2be5b0fecb5e49e917d Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 14 Mar 2025 17:02:46 +0200 Subject: [PATCH 1/4] feat: custom bot templates --- lib/algora/accounts/schemas/user.ex | 1 + lib/algora/admin/migration/migration.ex | 20 ++++ lib/algora/admin/migration/v1-progress.yaml | 14 +-- .../bot_templates/schemas/bot_template.ex | 32 ++++++ lib/algora/bounties/bounties.ex | 98 ++++++++++++++----- .../20250314161216_create_bot_templates.exs | 16 +++ test/algora/bounties_test.exs | 96 +++++++++++++++--- test/support/factory.ex | 7 ++ 8 files changed, 237 insertions(+), 47 deletions(-) create mode 100644 lib/algora/bot_templates/schemas/bot_template.ex create mode 100644 priv/repo/migrations/20250314161216_create_bot_templates.exs 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/schemas/bot_template.ex b/lib/algora/bot_templates/schemas/bot_template.ex new file mode 100644 index 000000000..57529a020 --- /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, :org_id]) + |> validate_required([:template, :type, :org_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..d7337407d 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -4,6 +4,7 @@ defmodule Algora.Bounties do import Ecto.Query alias Algora.Accounts.User + alias Algora.BotTemplates.BotTemplate alias Algora.Bounties.Attempt alias Algora.Bounties.Bounty alias Algora.Bounties.Claim @@ -168,11 +169,70 @@ 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) + + message = + if custom_template do + custom_template.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}", "") + else + """ + #{prize_pool} + ### 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]}! + #{attempts_table} + """ + end + + String.trim(message) + 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 +265,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 - - 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) + if solutions == [] do + "" + else + """ - 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 diff --git a/priv/repo/migrations/20250314161216_create_bot_templates.exs b/priv/repo/migrations/20250314161216_create_bot_templates.exs new file mode 100644 index 000000000..14172d326 --- /dev/null +++ b/priv/repo/migrations/20250314161216_create_bot_templates.exs @@ -0,0 +1,16 @@ +defmodule Algora.Repo.Migrations.CreateBotTemplates do + use Ecto.Migration + + def change do + create table(:bot_templates) do + add :template, :text, null: false + add :type, :string, null: false + add :active, :boolean, default: true, null: false + add :user_id, references(:users, on_delete: :delete_all), null: false + + timestamps() + end + + create unique_index(:bot_templates, [:user_id, :type]) + end +end diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs index ebf6ebab5..9d4b39bef 100644 --- a/test/algora/bounties_test.exs +++ b/test/algora/bounties_test.exs @@ -289,21 +289,77 @@ defmodule Algora.BountiesTest do end describe "get_response_body/4" do - test "generates correct response body with bounties and attempts" do + test "uses custom template when available" do repo_owner = insert!(:user, provider_login: "repo_owner") bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner") - bounty_owner = Repo.get!(User, bounty_owner.id) repository = insert!(:repository, user: repo_owner, name: "test_repo") + ticket = insert!(:ticket, number: 100, repository: repository) - bounties = [ - %Bounty{ - amount: Money.new(1000, :USD), - owner: bounty_owner - } - ] + _custom_template = + insert!(:bot_template, %{ + user: repo_owner, + type: :bounty_created, + template: """ + ${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) + + ### ❗ Important guidelines: + - To claim a bounty, you need to **provide a short demo video** of your changes in your pull request + - If anything is unclear, **ask for clarification** before starting as this will help avoid potential rework + - For assistance or questions, **[join our Discord](https://algora.io/discord)** + + Thank you for contributing to ${REPO_FULL_NAME}! + + **[Add a bounty](${FUND_URL})** • **[Share on socials](${TWEET_URL})** + + ${ATTEMPTS} + """ + }) + + bounties = [insert!(:bounty, amount: Money.new(1000, :USD), owner: bounty_owner, ticket: ticket)] + + ticket_ref = %{ + owner: repo_owner.provider_login, + repo: ticket.repository.name, + number: ticket.number + } + + response = Algora.Bounties.get_response_body(bounties, ticket_ref, [], []) + + expected_response = """ + ## 💎 $1,000 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner) + ### Steps to solve: + 1. **Start working**: Comment `/attempt #100` with your implementation plan + 2. **Submit work**: Create a pull request including `/claim #100` 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) + + ### ❗ Important guidelines: + - To claim a bounty, you need to **provide a short demo video** of your changes in your pull request + - If anything is unclear, **ask for clarification** before starting as this will help avoid potential rework + - For assistance or questions, **[join our Discord](https://algora.io/discord)** + + Thank you for contributing to repo_owner/test_repo! + + **[Add a bounty](http://localhost:4002)** • **[Share on socials](https://twitter.com/intent/tweet?related=algoraio&text=%241%2C000+bounty%21+%F0%9F%92%8E+https%3A%2F%2Fgithub.com%2Frepo_owner%2Ftest_repo%2Fissues%2F100)** + """ + + assert response == String.trim(expected_response) + end + + test "uses default template when no custom template exists" do + repo_owner = insert!(:user, provider_login: "repo_owner") + bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner") + bounty_owner = Repo.get!(User, bounty_owner.id) + repository = insert!(:repository, user: repo_owner, name: "test_repo") ticket = insert!(:ticket, number: 100, repository: repository) + bounties = [insert!(:bounty, amount: Money.new(1000, :USD), owner: bounty_owner, ticket: ticket)] + ticket_ref = %{ owner: repo_owner.provider_login, repo: ticket.repository.name, @@ -382,7 +438,7 @@ defmodule Algora.BountiesTest do response = Algora.Bounties.get_response_body(bounties, ticket_ref, attempts, claims) expected_response = """ - ## 💎 $1,000.00 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner) + ## 💎 $1,000 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner) ### Steps to solve: 1. **Start working**: Comment `/attempt #100` with your implementation plan 2. **Submit work**: Create a pull request including `/claim #100` in the PR body to claim the bounty @@ -402,21 +458,31 @@ defmodule Algora.BountiesTest do assert response == String.trim(expected_response) end - test "generates response body without attempts table when no attempts exist" do + test "uses default template when custom template is inactive" do repo_owner = insert!(:user, provider_login: "repo_owner") bounty_owner = insert!(:user, handle: "bounty_owner", display_name: "Bounty Owner") - bounty_owner = Repo.get!(User, bounty_owner.id) repository = insert!(:repository, user: repo_owner, name: "test_repo") + ticket = insert!(:ticket, number: 100, repository: repository) + + _custom_template = + insert!(:bot_template, %{ + user: repo_owner, + type: :bounty_created, + active: false, + template: """ + # Custom Template + Prize: ${PRIZE_POOL} + """ + }) bounties = [ %Bounty{ amount: Money.new(1000, :USD), - owner: bounty_owner + owner: bounty_owner, + ticket_id: ticket.id } ] - ticket = insert!(:ticket, number: 100, repository: repository) - ticket_ref = %{ owner: repo_owner.provider_login, repo: ticket.repository.name, @@ -426,7 +492,7 @@ defmodule Algora.BountiesTest do response = Algora.Bounties.get_response_body(bounties, ticket_ref, [], []) expected_response = """ - ## 💎 $1,000.00 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner) + ## 💎 $1,000 bounty [• Bounty Owner](http://localhost:4002/@/bounty_owner) ### Steps to solve: 1. **Start working**: Comment `/attempt #100` with your implementation plan 2. **Submit work**: Create a pull request including `/claim #100` in the PR body to claim the bounty diff --git a/test/support/factory.ex b/test/support/factory.ex index a2cce696c..7a4cc5224 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -259,6 +259,13 @@ defmodule Algora.Factory do } end + def bot_template_factory do + %Algora.BotTemplates.BotTemplate{ + id: Nanoid.generate(), + type: :bounty_created + } + end + # Convenience API def insert!(factory_name, attributes \\ []) From cb06a05675254cad2188f115e471f073466acb55 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 14 Mar 2025 17:18:35 +0200 Subject: [PATCH 2/4] fix: failing tests --- lib/algora/bounties/bounties.ex | 1 + .../controllers/webhooks/github_controller_test.exs | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index d7337407d..900864d20 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -1057,6 +1057,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/test/algora_web/controllers/webhooks/github_controller_test.exs b/test/algora_web/controllers/webhooks/github_controller_test.exs index ff2fe38ac..c93676536 100644 --- a/test/algora_web/controllers/webhooks/github_controller_test.exs +++ b/test/algora_web/controllers/webhooks/github_controller_test.exs @@ -575,6 +575,12 @@ defmodule AlgoraWeb.Webhooks.GithubControllerTest do user_type: :repo_admin, body: "/bounty $100", params: %{"issue" => %{"number" => issue_number1}} + }, + %{ + event_action: "issue_comment.created", + user_type: :repo_admin, + body: "/bounty $100", + params: %{"issue" => %{"number" => issue_number2}} } ]) From 08efc4eadeb37ee6d0aae0b7f7de5dbf624ebaa5 Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 14 Mar 2025 17:24:46 +0200 Subject: [PATCH 3/4] move default template to BotTemplates module --- lib/algora/bot_templates/bot_templates.ex | 20 +++++++++++++++ lib/algora/bounties/bounties.ex | 31 +++++++++-------------- 2 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 lib/algora/bot_templates/bot_templates.ex diff --git a/lib/algora/bot_templates/bot_templates.ex b/lib/algora/bot_templates/bot_templates.ex new file mode 100644 index 000000000..462ef31af --- /dev/null +++ b/lib/algora/bot_templates/bot_templates.ex @@ -0,0 +1,20 @@ +defmodule Algora.BotTemplates do + @moduledoc false + + 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" + end +end diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 900864d20..56fff3e6a 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -4,6 +4,7 @@ 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 @@ -183,30 +184,22 @@ defmodule Algora.Bounties do prize_pool = format_prize_pool(bounties) attempts_table = format_attempts_table(attempts, claims) - message = + template = if custom_template do custom_template.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}", "") else - """ - #{prize_pool} - ### 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]}! - #{attempts_table} - """ + BotTemplates.get_default_template(:bounty_created) end - String.trim(message) + 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 From b54bee83ee5f9a54d11de0cb9e1d4a812e5f81ef Mon Sep 17 00:00:00 2001 From: zafer Date: Fri, 14 Mar 2025 18:04:21 +0200 Subject: [PATCH 4/4] feat: bot template management --- lib/algora/bot_templates/bot_templates.ex | 55 ++++++++- .../bot_templates/schemas/bot_template.ex | 4 +- lib/algora_web/components/core_components.ex | 2 +- lib/algora_web/live/org/settings_live.ex | 105 ++++++++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/lib/algora/bot_templates/bot_templates.ex b/lib/algora/bot_templates/bot_templates.ex index 462ef31af..a6b459961 100644 --- a/lib/algora/bot_templates/bot_templates.ex +++ b/lib/algora/bot_templates/bot_templates.ex @@ -1,6 +1,9 @@ defmodule Algora.BotTemplates do @moduledoc false + alias Algora.BotTemplates.BotTemplate + alias Algora.Repo + def get_default_template(:bounty_created) do """ ${PRIZE_POOL} @@ -14,7 +17,55 @@ defmodule Algora.BotTemplates do """ end - def get_default_template(_type) do - raise "Not implemented" + 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 index 57529a020..5828844fb 100644 --- a/lib/algora/bot_templates/schemas/bot_template.ex +++ b/lib/algora/bot_templates/schemas/bot_template.ex @@ -23,8 +23,8 @@ defmodule Algora.BotTemplates.BotTemplate do def changeset(bot_template, attrs) do bot_template - |> cast(attrs, [:template, :type, :active, :org_id]) - |> validate_required([:template, :type, :org_id]) + |> 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]) 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"> +
+
+

Template

+
+ <.input + field={@template_form[:template]} + type="textarea" + class="h-full" + phx-debounce="300" + /> +
+
+
+

Preview

+
+
+ {raw(@template_preview)} +
+
+
+
+
+
+

Available variables

+
+ <%= for variable <- @available_variables do %> + <.badge variant="outline" class="font-mono"> + {"#{"${#{variable}}"}"} + + <% end %> +
+
+ <.button class="ml-auto">Save template +
+ + + """ end @@ -150,12 +200,24 @@ defmodule AlgoraWeb.Org.SettingsLive do changeset = User.settings_changeset(current_org, %{}) installations = Algora.Workspace.list_installations_by(connected_user_id: current_org.id, provider: "github") + template = + case BotTemplates.get_template(current_org.id, :bounty_created) do + nil -> BotTemplates.get_default_template(:bounty_created) + bot_template -> bot_template.template + end + + template_changeset = BotTemplate.changeset(%BotTemplate{}, %{template: template, type: :bounty_created}) + available_variables = BotTemplates.available_variables(:bounty_created) + {:ok, socket |> assign(:has_fresh_token?, Accounts.has_fresh_token?(socket.assigns.current_user)) |> assign(:installations, installations) |> assign(:oauth_url, Github.authorize_url(%{socket_id: socket.id})) |> assign_has_default_payment_method() + |> assign(:template_form, to_form(template_changeset)) + |> assign(:template_preview, preview_template(socket, template)) + |> assign(:available_variables, available_variables) |> assign_form(changeset)} end @@ -233,6 +295,36 @@ defmodule AlgoraWeb.Org.SettingsLive do end end + @impl true + def handle_event("validate_template", %{"bot_template" => params}, socket) do + template = params["template"] + + changeset = + %BotTemplate{} + |> BotTemplate.changeset(%{template: template, type: :bounty_created}) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:template_form, to_form(changeset)) + |> assign(:template_preview, preview_template(socket, template))} + end + + @impl true + def handle_event("save_template", %{"bot_template" => params}, socket) do + case BotTemplates.save_template( + socket.assigns.current_org.id, + :bounty_created, + params["template"] + ) do + {:ok, _template} -> + {:noreply, put_flash(socket, :info, "Template updated!")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to update template")} + end + end + @impl true def handle_params(params, _url, socket) do {:noreply, apply_action(socket, socket.assigns.live_action, params)} @@ -249,4 +341,17 @@ defmodule AlgoraWeb.Org.SettingsLive do defp assign_has_default_payment_method(socket) do assign(socket, :has_default_payment_method, Payments.has_default_payment_method?(socket.assigns.current_org.id)) end + + defp preview_template(socket, template) when is_binary(template) do + placeholders = BotTemplates.placeholders(:bounty_created, socket.assigns.current_org) + + preview = + Enum.reduce(placeholders, template, fn {key, value}, acc -> + String.replace(acc, "${#{key}}", value) + end) + + Markdown.render(preview) + end + + defp preview_template(_socket, _template), do: "" end