diff --git a/appview/config/config.exs b/appview/config/config.exs index 1da3f37..adf4227 100644 --- a/appview/config/config.exs +++ b/appview/config/config.exs @@ -25,6 +25,11 @@ for repo <- all_repos do pool_size: 1 end +config :atvouch, :tangled, + url: "https://tangled.org", + bot_handle: nil, + bot_password: nil + config :tesla, adapter: Tesla.Adapter.Mint import_config "#{config_env()}.exs" diff --git a/appview/config/runtime.exs b/appview/config/runtime.exs index 7708265..735086f 100644 --- a/appview/config/runtime.exs +++ b/appview/config/runtime.exs @@ -6,6 +6,13 @@ if tap_uri = System.get_env("TAP_URI") do password: System.get_env("TAP_PASSWORD") end +if tangled_handle = System.get_env("TANGLED_BOT_HANDLE") do + config :atvouch, :tangled, + url: System.get_env("TANGLED_URL", "https://tangled.org"), + bot_handle: tangled_handle, + bot_password: System.get_env("TANGLED_BOT_PASSWORD") +end + if config_env() == :prod do config :atvouch, port: System.fetch_env!("PORT"), diff --git a/appview/config/test.exs b/appview/config/test.exs index a3a2907..2581d14 100644 --- a/appview/config/test.exs +++ b/appview/config/test.exs @@ -13,5 +13,10 @@ config :atvouch, :tap, uri: "ws://localhost:123/channel", password: "123" +config :atvouch, :tangled, + url: "http://localhost:0", + bot_handle: nil, + bot_password: nil + config :logger, level: :warning diff --git a/appview/lib/atvouch/application.ex b/appview/lib/atvouch/application.ex index 67e42f7..1d5cb5b 100644 --- a/appview/lib/atvouch/application.ex +++ b/appview/lib/atvouch/application.ex @@ -12,7 +12,7 @@ defmodule Atvouch.Application do [ {Bandit, plug: Atvouch.Router, port: port, ip: {127, 0, 0, 1}}, Atvouch.Tinycron.new(Atvouch.CounterTask, every: 60, jitter: -5..5) - ] ++ tap_children() + ] ++ tangled_children() ++ tap_children() opts = [strategy: :one_for_one, name: Atvouch.Supervisor] Supervisor.start_link(children, opts) @@ -29,6 +29,23 @@ defmodule Atvouch.Application do end end + defp tangled_children do + config = Application.get_env(:atvouch, :tangled, []) + handle = Keyword.get(config, :bot_handle) + password = Keyword.get(config, :bot_password) + + if handle && password do + [ + {Atvouch.Tangled.Session, + handle: handle, + password: password, + tangled_url: Keyword.get(config, :url, "https://tangled.sh")} + ] + else + [] + end + end + defp tap_children do case Application.get_env(:atvouch, :tap) do nil -> diff --git a/appview/lib/atvouch/bot_comment.ex b/appview/lib/atvouch/bot_comment.ex new file mode 100644 index 0000000..2add0e8 --- /dev/null +++ b/appview/lib/atvouch/bot_comment.ex @@ -0,0 +1,40 @@ +defmodule Atvouch.BotComment do + use Ecto.Schema + import Ecto.Changeset + import Ecto.Query + + schema "bot_comments" do + field(:repo_at_uri, :string) + field(:pull_number, :integer) + field(:author_did, :string) + field(:commented_at, :string) + end + + def changeset(comment, attrs) do + comment + |> cast(attrs, [:repo_at_uri, :pull_number, :author_did, :commented_at]) + |> validate_required([:repo_at_uri, :pull_number, :author_did, :commented_at]) + |> unique_constraint([:repo_at_uri, :pull_number], name: :bot_comments_repo_pull) + end + + def create(attrs) do + %__MODULE__{} + |> changeset(attrs) + |> Atvouch.Repo.insert() + end + + def exists?(repo_at_uri, pull_number) do + from(c in __MODULE__, + where: c.repo_at_uri == ^repo_at_uri and c.pull_number == ^pull_number + ) + |> Atvouch.Repo.replica().exists?() + end + + def last_pull_number(repo_at_uri) do + from(c in __MODULE__, + where: c.repo_at_uri == ^repo_at_uri, + select: max(c.pull_number) + ) + |> Atvouch.Repo.replica().one() + end +end diff --git a/appview/lib/atvouch/tangled/client.ex b/appview/lib/atvouch/tangled/client.ex new file mode 100644 index 0000000..443b464 --- /dev/null +++ b/appview/lib/atvouch/tangled/client.ex @@ -0,0 +1,64 @@ +defmodule Atvouch.Tangled.Client do + @moduledoc """ + HTTP client for posting comments to Tangled pull requests. + """ + + require Logger + + @doc """ + Post a comment on a Tangled pull request. + + Uses the session cookies from `Atvouch.Tangled.Session` for authentication. + If a 401 is returned, invalidates the session and retries once. + """ + def post_comment(repo_handle, repo_rkey, pull_number, body, opts \\ []) do + session = Keyword.get(opts, :session, Atvouch.Tangled.Session) + retry = Keyword.get(opts, :retry, true) + tangled_url = Atvouch.Tangled.Session.get_url(session) + + case Atvouch.Tangled.Session.get_cookies(session) do + {:ok, cookies} -> + url = "#{tangled_url}/#{repo_handle}/#{repo_rkey}/pulls/#{pull_number}/round/0/comment" + form_body = URI.encode_query(%{"body" => body}) + + headers = [ + {"content-type", "application/x-www-form-urlencoded"}, + {"hx-request", "true"}, + {"cookie", cookies} + ] + + case do_post(url, form_body, headers) do + {:ok, status} when status in 200..299 -> + :ok + + {:ok, 401} when retry -> + Logger.info("Tangled returned 401, invalidating session and retrying") + Atvouch.Tangled.Session.invalidate(session) + post_comment(repo_handle, repo_rkey, pull_number, body, Keyword.put(opts, :retry, false)) + + {:ok, status} -> + {:error, {:http_error, status}} + + {:error, reason} -> + {:error, reason} + end + + {:error, reason} -> + {:error, {:session_error, reason}} + end + end + + defp do_post(url, body, headers) do + request = %Tesla.Env{ + method: :post, + url: url, + body: body, + headers: headers + } + + case Tesla.Adapter.Mint.call(request, []) do + {:ok, %Tesla.Env{status: status}} -> {:ok, status} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/appview/lib/atvouch/tangled/comment_builder.ex b/appview/lib/atvouch/tangled/comment_builder.ex new file mode 100644 index 0000000..0d58a81 --- /dev/null +++ b/appview/lib/atvouch/tangled/comment_builder.ex @@ -0,0 +1,52 @@ +defmodule Atvouch.Tangled.CommentBuilder do + @moduledoc """ + Builds markdown comment text showing vouch routes between + maintainers and a PR author. + """ + + @doc """ + Build a comment string showing vouch routes from maintainers to a PR author. + + `maintainer_routes` is a list of `{maintainer_did, maintainer_handle, route_result}` + where `route_result` is the output of `Graph.find_routes/2`. + """ + def build_comment(author_did, author_handle, maintainer_routes) do + author_display = display_name(author_did, author_handle) + header = "## atvouch routes for #{author_display}\n" + + body = + maintainer_routes + |> Enum.map(fn {maintainer_did, maintainer_handle, route_result} -> + format_maintainer_routes(maintainer_did, maintainer_handle, route_result) + end) + |> Enum.join("\n") + + header <> "\n" <> body <> "\n\n---\n*generated by [atvouch](https://atvouch.dev)*" + end + + defp format_maintainer_routes(maintainer_did, maintainer_handle, {:direct, _target_did}) do + maintainer = display_name(maintainer_did, maintainer_handle) + "**#{maintainer}**: direct vouch" + end + + defp format_maintainer_routes(maintainer_did, maintainer_handle, {:routes, _target_did, []}) do + maintainer = display_name(maintainer_did, maintainer_handle) + "**#{maintainer}**: no routes found" + end + + defp format_maintainer_routes(maintainer_did, maintainer_handle, {:routes, _target_did, paths}) do + maintainer = display_name(maintainer_did, maintainer_handle) + route_lines = Enum.map(paths, &format_path/1) + "**#{maintainer}**:\n" <> Enum.join(route_lines, "\n") + end + + defp format_path(path) do + path + |> Enum.map(fn did -> "`#{did}`" end) + |> Enum.join(" -> ") + |> then(&"- #{&1}") + end + + defp display_name(_did, handle) when is_binary(handle) and handle != "", do: "@#{handle}" + defp display_name(did, _handle), do: "`#{did}`" +end diff --git a/appview/lib/atvouch/tangled/scraper.ex b/appview/lib/atvouch/tangled/scraper.ex new file mode 100644 index 0000000..487a089 --- /dev/null +++ b/appview/lib/atvouch/tangled/scraper.ex @@ -0,0 +1,95 @@ +defmodule Atvouch.Tangled.Scraper do + @moduledoc """ + Scrapes the Tangled pulls page HTML to extract PR listings. + """ + + require Logger + + @doc """ + Parse a Tangled pulls page HTML to extract pull request info. + + Returns a list of `{pull_number, title, created_at}` tuples, + where `created_at` is the ISO8601 datetime string from the `