Skip to content

Commit d77d62d

Browse files
authored
feat: create bounties via GitHub app (#29)
1 parent 09e6d50 commit d77d62d

File tree

7 files changed

+205
-77
lines changed

7 files changed

+205
-77
lines changed

lib/algora/bounties/bounties.ex

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ defmodule Algora.Bounties do
1010
alias Algora.Bounties.Jobs
1111
alias Algora.Bounties.Tip
1212
alias Algora.FeeTier
13+
alias Algora.Github
1314
alias Algora.MoneyUtils
1415
alias Algora.Organizations.Member
1516
alias Algora.Payments
@@ -50,7 +51,6 @@ defmodule Algora.Bounties do
5051

5152
case Repo.insert(changeset) do
5253
{:ok, bounty} ->
53-
broadcast()
5454
{:ok, bounty}
5555

5656
{:error, %{errors: [ticket_id: {_, [constraint: :unique, constraint_name: _]}]}} ->
@@ -61,36 +61,61 @@ defmodule Algora.Bounties do
6161
end
6262
end
6363

64-
@spec create_bounty(%{
65-
creator: User.t(),
66-
owner: User.t(),
67-
amount: Money.t(),
68-
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}
69-
}) ::
64+
@spec create_bounty(
65+
%{
66+
creator: User.t(),
67+
owner: User.t(),
68+
amount: Money.t(),
69+
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}
70+
},
71+
opts :: [installation_id: integer()]
72+
) ::
7073
{:ok, Bounty.t()} | {:error, atom()}
71-
def create_bounty(%{
72-
creator: creator,
73-
owner: owner,
74-
amount: amount,
75-
ticket_ref: %{owner: repo_owner, repo: repo_name, number: number} = ticket_ref
76-
}) do
74+
def create_bounty(
75+
%{
76+
creator: creator,
77+
owner: owner,
78+
amount: amount,
79+
ticket_ref: %{owner: repo_owner, repo: repo_name, number: number} = ticket_ref
80+
},
81+
opts \\ []
82+
) do
83+
installation_id = opts[:installation_id]
84+
85+
token_res =
86+
if installation_id,
87+
do: Github.get_installation_token(installation_id),
88+
else: Accounts.get_access_token(creator)
89+
7790
Repo.transact(fn ->
78-
with {:ok, token} <- Accounts.get_access_token(creator),
91+
with {:ok, token} <- token_res,
7992
{:ok, ticket} <- Workspace.ensure_ticket(token, repo_owner, repo_name, number),
8093
{:ok, bounty} <- do_create_bounty(%{creator: creator, owner: owner, amount: amount, ticket: ticket}),
81-
{:ok, _job} <- notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}) do
94+
{:ok, _job} <-
95+
notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, installation_id: installation_id) do
96+
broadcast()
8297
{:ok, bounty}
8398
else
8499
{:error, _reason} = error -> error
85100
end
86101
end)
87102
end
88103

89-
def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}) do
104+
@spec notify_bounty(
105+
%{
106+
owner: User.t(),
107+
bounty: Bounty.t(),
108+
ticket_ref: %{owner: String.t(), repo: String.t(), number: integer()}
109+
},
110+
opts :: [installation_id: integer()]
111+
) ::
112+
{:ok, Oban.Job.t()} | {:error, atom()}
113+
def notify_bounty(%{owner: owner, bounty: bounty, ticket_ref: ticket_ref}, opts \\ []) do
90114
%{
91115
owner_login: owner.provider_login,
92116
amount: Money.to_string!(bounty.amount, no_fraction_if_integer: true),
93-
ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number}
117+
ticket_ref: %{owner: ticket_ref.owner, repo: ticket_ref.repo, number: ticket_ref.number},
118+
installation_id: opts[:installation_id]
94119
}
95120
|> Jobs.NotifyBounty.new()
96121
|> Oban.insert()

lib/algora/bounties/jobs/notify_bounty.ex

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
22
@moduledoc false
33
use Oban.Worker, queue: :notify_bounty
44

5+
alias Algora.Accounts
6+
alias Algora.Accounts.User
57
alias Algora.Github
8+
alias Algora.Workspace
69

710
require Logger
811

912
@impl Oban.Worker
10-
def perform(%Oban.Job{args: %{"owner_login" => owner_login, "amount" => amount, "ticket_ref" => ticket_ref}}) do
13+
def perform(%Oban.Job{
14+
args: %{"owner_login" => owner_login, "amount" => amount, "ticket_ref" => ticket_ref, "installation_id" => nil}
15+
}) do
1116
body = """
1217
💎 **#{owner_login}** is offering a **#{amount}** bounty for this issue
1318
@@ -31,4 +36,30 @@ defmodule Algora.Bounties.Jobs.NotifyBounty do
3136
""")
3237
end
3338
end
39+
40+
@impl Oban.Worker
41+
def perform(%Oban.Job{args: %{"amount" => amount, "ticket_ref" => ticket_ref, "installation_id" => installation_id}}) do
42+
with {:ok, token} <- Github.get_installation_token(installation_id),
43+
{:ok, installation} <-
44+
Workspace.fetch_installation_by(provider: "github", provider_id: to_string(installation_id)),
45+
{:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id) do
46+
body = """
47+
## 💎 #{amount} bounty [• #{owner.name}](#{User.url(owner)})
48+
### Steps to solve:
49+
1. **Start working**: Comment `/attempt ##{ticket_ref["number"]}` with your implementation plan
50+
2. **Submit work**: Create a pull request including `/claim ##{ticket_ref["number"]}` in the PR body to claim the bounty
51+
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)
52+
53+
Thank you for contributing to #{ticket_ref["owner"]}/#{ticket_ref["repo"]}!
54+
"""
55+
56+
Github.create_issue_comment(
57+
token,
58+
ticket_ref["owner"],
59+
ticket_ref["repo"],
60+
ticket_ref["number"],
61+
body
62+
)
63+
end
64+
end
3465
end

lib/algora/integrations/github/client.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,9 @@ defmodule Algora.Github.Client do
155155
def get_installation_token(installation_id) do
156156
path = "/app/installations/#{installation_id}/access_tokens"
157157

158-
case Crypto.generate_jwt() do
159-
{:ok, jwt, _claims} -> fetch(jwt, path, "POST")
160-
error -> error
158+
with {:ok, jwt, _claims} <- Crypto.generate_jwt(),
159+
{:ok, %{"token" => token}} <- fetch(jwt, path, "POST") do
160+
{:ok, token}
161161
end
162162
end
163163

lib/algora/workspace/workspace.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ defmodule Algora.Workspace do
126126
end
127127
end
128128

129+
@spec fetch_installation_by(clauses :: Keyword.t() | map()) ::
130+
{:ok, Installation.t()} | {:error, :not_found}
131+
def fetch_installation_by(clauses) do
132+
Repo.fetch_by(Installation, clauses)
133+
end
134+
129135
def get_installation_by(fields), do: Repo.get_by(Installation, fields)
130136
def get_installation_by!(fields), do: Repo.get_by!(Installation, fields)
131137

lib/algora_web/controllers/webhooks/github_controller.ex

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
defmodule AlgoraWeb.Webhooks.GithubController do
22
use AlgoraWeb, :controller
33

4+
alias Algora.Accounts
5+
alias Algora.Bounties
46
alias Algora.Github
57
alias Algora.Github.Webhook
8+
alias Algora.Workspace
69

710
require Logger
811

@@ -32,8 +35,9 @@ defmodule AlgoraWeb.Webhooks.GithubController do
3235
end
3336

3437
# TODO: cache installation tokens
38+
# TODO: check org permissions on algora
3539
defp get_permissions(author, %{"repository" => repository, "installation" => installation}) do
36-
with {:ok, %{"token" => access_token}} <- Github.get_installation_token(installation["id"]),
40+
with {:ok, access_token} <- Github.get_installation_token(installation["id"]),
3741
{:ok, %{"permission" => permission}} <-
3842
Github.get_repository_permissions(
3943
access_token,
@@ -48,49 +52,32 @@ defmodule AlgoraWeb.Webhooks.GithubController do
4852
defp get_permissions(_author, _params), do: {:error, :invalid_params}
4953

5054
defp execute_command({:bounty, args}, author, params) do
51-
amount = Keyword.fetch!(args, :amount)
52-
53-
case get_permissions(author, params) do
54-
{:ok, "admin"} ->
55-
# Get repository and issue details from params
56-
repo = params["repository"]
57-
issue = params["issue"]
58-
59-
# Construct the bounty message
60-
message = """
61-
## 💎 $#{amount} bounty [• #{repo["owner"]["login"]}](https://console.algora.io/org/#{repo["owner"]["login"]})
62-
### Steps to solve:
63-
1. **Start working**: Comment `/attempt ##{issue["number"]}` with your implementation plan
64-
2. **Submit work**: Create a pull request including `/claim ##{issue["number"]}` in the PR body to claim the bounty
65-
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)
66-
67-
Thank you for contributing to #{repo["full_name"]}!
68-
69-
**[Add a bounty](https://console.algora.io/org/#{repo["owner"]["login"]}/bounties/community?fund=#{repo["full_name"]}%23#{issue["number"]})** • **[Share on socials](https://twitter.com/intent/tweet?text=%24#{amount}+bounty%21+%F0%9F%92%8E+#{issue["html_url"]}&related=algoraio)**
70-
71-
Attempt | Started (GMT+0) | Solution
72-
--------|----------------|----------
73-
"""
74-
75-
# Post comment to the issue
76-
with {:ok, %{"token" => token}} <-
77-
Github.get_installation_token(params["installation"]["id"]) do
78-
Github.create_issue_comment(
79-
token,
80-
repo["owner"]["login"],
81-
repo["name"],
82-
issue["number"],
83-
message
84-
)
85-
end
86-
87-
{:ok, amount}
88-
55+
amount = args[:amount]
56+
repo = params["repository"]
57+
issue = params["issue"]
58+
installation_id = params["installation"]["id"]
59+
60+
with {:ok, "admin"} <- get_permissions(author, params),
61+
{:ok, token} <- Github.get_installation_token(installation_id),
62+
{:ok, installation} <-
63+
Workspace.fetch_installation_by(provider: "github", provider_id: to_string(installation_id)),
64+
{:ok, owner} <- Accounts.fetch_user_by(id: installation.connected_user_id),
65+
{:ok, creator} <- Workspace.ensure_user(token, repo["owner"]["login"]) do
66+
Bounties.create_bounty(
67+
%{
68+
creator: creator,
69+
owner: owner,
70+
amount: amount,
71+
ticket_ref: %{owner: repo["owner"]["login"], repo: repo["name"], number: issue["number"]}
72+
},
73+
installation_id: installation_id
74+
)
75+
else
8976
{:ok, _permission} ->
9077
{:error, :unauthorized}
9178

92-
{:error, error} ->
93-
{:error, error}
79+
{:error, _reason} = error ->
80+
error
9481
end
9582
end
9683

@@ -113,7 +100,7 @@ defmodule AlgoraWeb.Webhooks.GithubController do
113100
do: Logger.info("Unhandled command: #{command} #{inspect(args)}")
114101

115102
def process_commands(body, author, params) when is_binary(body) do
116-
case Algora.Github.Command.parse(body) do
103+
case Github.Command.parse(body) do
117104
{:ok, commands} -> Enum.map(commands, &execute_command(&1, author, params))
118105
# TODO: handle errors
119106
{:error, error} -> Logger.error("Error parsing commands: #{inspect(error)}")

0 commit comments

Comments
 (0)