diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 48ddfc130..d95de9623 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -1007,6 +1007,9 @@ defmodule Algora.Bounties do {:owner_id, owner_id}, query -> from([b, r: r] in query, where: b.owner_id == ^owner_id or r.user_id == ^owner_id) + {:owner_handle, owner_handle}, query -> + from([b, o: o] in query, where: o.handle == ^owner_handle) + {:status, status}, query -> query = where(query, [b], b.status == ^status) @@ -1114,8 +1117,10 @@ defmodule Algora.Bounties do status: b.status, owner: %{ id: o.id, + inserted_at: o.inserted_at, name: o.name, handle: o.handle, + provider_login: o.provider_login, avatar_url: o.avatar_url, tech_stack: o.tech_stack }, diff --git a/lib/algora_web/controllers/api/bounty_controller.ex b/lib/algora_web/controllers/api/bounty_controller.ex new file mode 100644 index 000000000..2479f0111 --- /dev/null +++ b/lib/algora_web/controllers/api/bounty_controller.ex @@ -0,0 +1,65 @@ +defmodule AlgoraWeb.API.BountyController do + use AlgoraWeb, :controller + + alias Algora.Bounties + alias AlgoraWeb.API.FallbackController + + action_fallback FallbackController + + @doc """ + Get a list of bounties with optional filtering parameters. + + Query Parameters: + - status: string (optional) - Filter by status (open, paid) + - org: string (optional) - Filter by organization handle + - limit: integer (optional) - Limit the number of bounties returned + """ + def index(conn, %{"batch" => _batch, "input" => input} = _params) do + with {:ok, decoded} <- Jason.decode(input), + %{"0" => %{"json" => json}} <- decoded do + criteria = to_criteria(json) + bounties = Bounties.list_bounties(criteria) + render(conn, :index, bounties: bounties) + end + end + + def index(conn, params) do + criteria = to_criteria(params) + bounties = Bounties.list_bounties(criteria) + render(conn, :index, bounties: bounties) + end + + # Convert JSON/map parameters to keyword list criteria + defp to_criteria(params) when is_map(params) do + params + |> Enum.map(&parse_params/1) + |> Enum.reject(&is_nil/1) + |> Keyword.new() + end + + defp to_criteria(_), do: [] + + defp parse_params({"status", status}) do + {:status, parse_status(status)} + end + + defp parse_params({"org", org_handle}) do + {:owner_handle, org_handle} + end + + defp parse_params({"limit", limit}) do + {:limit, limit} + end + + defp parse_params(_), do: nil + + # Parse status string to corresponding enum atom + defp parse_status(status) when is_binary(status) do + case String.downcase(status) do + "paid" -> :paid + _ -> :open + end + end + + defp parse_status(_), do: :open +end diff --git a/lib/algora_web/controllers/api/bounty_json.ex b/lib/algora_web/controllers/api/bounty_json.ex new file mode 100644 index 000000000..79d696006 --- /dev/null +++ b/lib/algora_web/controllers/api/bounty_json.ex @@ -0,0 +1,113 @@ +defmodule AlgoraWeb.API.BountyJSON do + alias Algora.Bounties.Bounty + + def index(%{bounties: bounties}) do + [ + %{ + result: %{ + data: %{ + json: %{ + next_cursor: nil, + items: for(bounty <- bounties, do: data(bounty)) + } + } + } + } + ] + end + + defp data(%{} = bounty) do + %{ + id: bounty.id, + point_reward: nil, + reward: %{ + amount: Algora.MoneyUtils.to_minor_units(bounty.amount), + currency: bounty.amount.currency + }, + reward_formatted: Money.to_string!(bounty.amount, no_fraction_if_integer: true), + reward_tiers: [], + tech: bounty.owner.tech_stack, + status: bounty.status, + is_external: false, + org: org_data(bounty.owner), + task: task_data(bounty), + type: "standard", + kind: "dev", + reward_type: "cash", + visibility: "public", + bids: [], + autopay_disabled: false, + timeouts_disabled: false, + manual_assignments: false, + created_at: bounty.inserted_at, + updated_at: bounty.inserted_at + } + end + + defp task_data(bounty) do + %{ + id: bounty.ticket.id, + forge: "github", + repo_owner: bounty.repository.owner.provider_login, + repo_name: bounty.repository.name, + number: bounty.ticket.number, + source: %{ + type: "github", + data: %{ + id: bounty.ticket.id, + html_url: bounty.ticket.url, + title: bounty.ticket.title, + body: "", + user: %{ + id: 0, + login: "", + avatar_url: "", + html_url: "", + name: "", + company: "", + location: "", + twitter_username: "" + } + } + }, + status: "open", + title: bounty.ticket.title, + url: bounty.ticket.url, + body: "", + type: "issue", + hash: Bounty.path(bounty), + tech: [] + } + end + + defp org_data(nil), do: nil + + defp org_data(org) do + %{ + id: org.id, + created_at: org.inserted_at, + handle: org.handle, + name: org.name, + display_name: org.name, + description: "", + avatar_url: org.avatar_url, + website_url: "", + twitter_url: "", + youtube_url: "", + discord_url: "", + slack_url: "", + stargazers_count: 0, + tech: org.tech_stack, + accepts_sponsorships: false, + members: members_data(org), + enabled_expert_recs: false, + enabled_private_bounties: false, + days_until_timeout: nil, + github_handle: org.provider_login + } + end + + defp members_data(_org) do + [] + end +end diff --git a/lib/algora_web/controllers/api/error_json.ex b/lib/algora_web/controllers/api/error_json.ex new file mode 100644 index 000000000..3dfe962cd --- /dev/null +++ b/lib/algora_web/controllers/api/error_json.ex @@ -0,0 +1,24 @@ +defmodule AlgoraWeb.API.ErrorJSON do + def error(%{changeset: changeset}) do + %{ + errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1) + } + end + + def error(%{message: message}) do + %{error: message} + end + + defp translate_error({msg, opts}) do + Enum.reduce(opts, msg, fn + {key, value}, acc when is_binary(value) -> + String.replace(acc, "%{#{key}}", value) + + {key, value}, acc when is_integer(value) -> + String.replace(acc, "%{#{key}}", Integer.to_string(value)) + + {key, value}, acc -> + String.replace(acc, "%{#{key}}", inspect(value)) + end) + end +end diff --git a/lib/algora_web/controllers/api/fallback_controller.ex b/lib/algora_web/controllers/api/fallback_controller.ex new file mode 100644 index 000000000..249b4109a --- /dev/null +++ b/lib/algora_web/controllers/api/fallback_controller.ex @@ -0,0 +1,34 @@ +defmodule AlgoraWeb.API.FallbackController do + use AlgoraWeb, :controller + + alias AlgoraWeb.API.ErrorJSON + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + conn + |> put_status(:unprocessable_entity) + |> put_view(json: ErrorJSON) + |> render(:error, changeset: changeset) + end + + def call(conn, {:error, :not_found}) do + conn + |> put_status(:not_found) + |> put_view(json: ErrorJSON) + |> render(:error, message: "Not found") + end + + def call(conn, {:error, :unauthorized}) do + conn + |> put_status(:unauthorized) + |> put_view(json: ErrorJSON) + |> render(:error, message: "Unauthorized") + end + + # Catch-all error handler + def call(conn, {:error, error}) do + conn + |> put_status(:bad_request) + |> put_view(json: ErrorJSON) + |> render(:error, message: to_string(error)) + end +end diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index 9393828a7..6b224d4bc 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -158,6 +158,14 @@ defmodule AlgoraWeb.Router do end end + scope "/api", AlgoraWeb.API do + pipe_through :api + + # Legacy tRPC endpoints + get "/trpc/bounty.list", BountyController, :index + post "/trpc/bounty.list", BountyController, :index + end + # Other scopes may use custom stacks. # scope "/api", AlgoraWeb do # pipe_through :api