dev vouch dev on at. thats about it atvouch.dev
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

wire pull record -> scrape -> vouch routing -> comment

way more complex than it deserves to be for three reasons:
- PR comments MUST be created through tangled appview
(pr comment records aren't wired into the tangled ingestor.
idk why, idk who i need to call to know why)
- tangled appview has its own oauth session i need to go through to get
the right cookies, its not an XRPC that goes through my PDS
(which would let me auth in some more atproto-native methods
instead of simulating a browser oauth flow, including consent)
- the tangled record doesn't assign PR numbers to pull records
(tbf it is a nontrivial problem, but i would've wished them to have
provided some sort of answer to this), which REQUIRES me to get
the PR number via scraping HTML. in a way tangled is more
using atproto for identity only, the actual PDS records don't reflect
a lot of the real appview beyond providing a loose, unrecoverable
(in terms of real URLs) backup of tangled's sqlite db at a given time

authored by

Luna and committed by tangled.org ae93f584 ad805e74

+1802 -2
+5
appview/config/config.exs
··· 25 25 pool_size: 1 26 26 end 27 27 28 + config :atvouch, :tangled, 29 + url: "https://tangled.org", 30 + bot_handle: nil, 31 + bot_password: nil 32 + 28 33 config :tesla, adapter: Tesla.Adapter.Mint 29 34 30 35 import_config "#{config_env()}.exs"
+7
appview/config/runtime.exs
··· 6 6 password: System.get_env("TAP_PASSWORD") 7 7 end 8 8 9 + if tangled_handle = System.get_env("TANGLED_BOT_HANDLE") do 10 + config :atvouch, :tangled, 11 + url: System.get_env("TANGLED_URL", "https://tangled.org"), 12 + bot_handle: tangled_handle, 13 + bot_password: System.get_env("TANGLED_BOT_PASSWORD") 14 + end 15 + 9 16 if config_env() == :prod do 10 17 config :atvouch, 11 18 port: System.fetch_env!("PORT"),
+5
appview/config/test.exs
··· 13 13 uri: "ws://localhost:123/channel", 14 14 password: "123" 15 15 16 + config :atvouch, :tangled, 17 + url: "http://localhost:0", 18 + bot_handle: nil, 19 + bot_password: nil 20 + 16 21 config :logger, 17 22 level: :warning
+18 -1
appview/lib/atvouch/application.ex
··· 12 12 [ 13 13 {Bandit, plug: Atvouch.Router, port: port, ip: {127, 0, 0, 1}}, 14 14 Atvouch.Tinycron.new(Atvouch.CounterTask, every: 60, jitter: -5..5) 15 - ] ++ tap_children() 15 + ] ++ tangled_children() ++ tap_children() 16 16 17 17 opts = [strategy: :one_for_one, name: Atvouch.Supervisor] 18 18 Supervisor.start_link(children, opts) ··· 27 27 else 28 28 [] 29 29 end 30 + end 31 + 32 + defp tangled_children do 33 + config = Application.get_env(:atvouch, :tangled, []) 34 + handle = Keyword.get(config, :bot_handle) 35 + password = Keyword.get(config, :bot_password) 36 + 37 + if handle && password do 38 + [ 39 + {Atvouch.Tangled.Session, 40 + handle: handle, 41 + password: password, 42 + tangled_url: Keyword.get(config, :url, "https://tangled.sh")} 43 + ] 44 + else 45 + [] 46 + end 30 47 end 31 48 32 49 defp tap_children do
+40
appview/lib/atvouch/bot_comment.ex
··· 1 + defmodule Atvouch.BotComment do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + import Ecto.Query 5 + 6 + schema "bot_comments" do 7 + field(:repo_at_uri, :string) 8 + field(:pull_number, :integer) 9 + field(:author_did, :string) 10 + field(:commented_at, :string) 11 + end 12 + 13 + def changeset(comment, attrs) do 14 + comment 15 + |> cast(attrs, [:repo_at_uri, :pull_number, :author_did, :commented_at]) 16 + |> validate_required([:repo_at_uri, :pull_number, :author_did, :commented_at]) 17 + |> unique_constraint([:repo_at_uri, :pull_number], name: :bot_comments_repo_pull) 18 + end 19 + 20 + def create(attrs) do 21 + %__MODULE__{} 22 + |> changeset(attrs) 23 + |> Atvouch.Repo.insert() 24 + end 25 + 26 + def exists?(repo_at_uri, pull_number) do 27 + from(c in __MODULE__, 28 + where: c.repo_at_uri == ^repo_at_uri and c.pull_number == ^pull_number 29 + ) 30 + |> Atvouch.Repo.replica().exists?() 31 + end 32 + 33 + def last_pull_number(repo_at_uri) do 34 + from(c in __MODULE__, 35 + where: c.repo_at_uri == ^repo_at_uri, 36 + select: max(c.pull_number) 37 + ) 38 + |> Atvouch.Repo.replica().one() 39 + end 40 + end
+64
appview/lib/atvouch/tangled/client.ex
··· 1 + defmodule Atvouch.Tangled.Client do 2 + @moduledoc """ 3 + HTTP client for posting comments to Tangled pull requests. 4 + """ 5 + 6 + require Logger 7 + 8 + @doc """ 9 + Post a comment on a Tangled pull request. 10 + 11 + Uses the session cookies from `Atvouch.Tangled.Session` for authentication. 12 + If a 401 is returned, invalidates the session and retries once. 13 + """ 14 + def post_comment(repo_handle, repo_rkey, pull_number, body, opts \\ []) do 15 + session = Keyword.get(opts, :session, Atvouch.Tangled.Session) 16 + retry = Keyword.get(opts, :retry, true) 17 + tangled_url = Atvouch.Tangled.Session.get_url(session) 18 + 19 + case Atvouch.Tangled.Session.get_cookies(session) do 20 + {:ok, cookies} -> 21 + url = "#{tangled_url}/#{repo_handle}/#{repo_rkey}/pulls/#{pull_number}/round/0/comment" 22 + form_body = URI.encode_query(%{"body" => body}) 23 + 24 + headers = [ 25 + {"content-type", "application/x-www-form-urlencoded"}, 26 + {"hx-request", "true"}, 27 + {"cookie", cookies} 28 + ] 29 + 30 + case do_post(url, form_body, headers) do 31 + {:ok, status} when status in 200..299 -> 32 + :ok 33 + 34 + {:ok, 401} when retry -> 35 + Logger.info("Tangled returned 401, invalidating session and retrying") 36 + Atvouch.Tangled.Session.invalidate(session) 37 + post_comment(repo_handle, repo_rkey, pull_number, body, Keyword.put(opts, :retry, false)) 38 + 39 + {:ok, status} -> 40 + {:error, {:http_error, status}} 41 + 42 + {:error, reason} -> 43 + {:error, reason} 44 + end 45 + 46 + {:error, reason} -> 47 + {:error, {:session_error, reason}} 48 + end 49 + end 50 + 51 + defp do_post(url, body, headers) do 52 + request = %Tesla.Env{ 53 + method: :post, 54 + url: url, 55 + body: body, 56 + headers: headers 57 + } 58 + 59 + case Tesla.Adapter.Mint.call(request, []) do 60 + {:ok, %Tesla.Env{status: status}} -> {:ok, status} 61 + {:error, reason} -> {:error, reason} 62 + end 63 + end 64 + end
+52
appview/lib/atvouch/tangled/comment_builder.ex
··· 1 + defmodule Atvouch.Tangled.CommentBuilder do 2 + @moduledoc """ 3 + Builds markdown comment text showing vouch routes between 4 + maintainers and a PR author. 5 + """ 6 + 7 + @doc """ 8 + Build a comment string showing vouch routes from maintainers to a PR author. 9 + 10 + `maintainer_routes` is a list of `{maintainer_did, maintainer_handle, route_result}` 11 + where `route_result` is the output of `Graph.find_routes/2`. 12 + """ 13 + def build_comment(author_did, author_handle, maintainer_routes) do 14 + author_display = display_name(author_did, author_handle) 15 + header = "## atvouch routes for #{author_display}\n" 16 + 17 + body = 18 + maintainer_routes 19 + |> Enum.map(fn {maintainer_did, maintainer_handle, route_result} -> 20 + format_maintainer_routes(maintainer_did, maintainer_handle, route_result) 21 + end) 22 + |> Enum.join("\n") 23 + 24 + header <> "\n" <> body <> "\n\n---\n*generated by [atvouch](https://atvouch.dev)*" 25 + end 26 + 27 + defp format_maintainer_routes(maintainer_did, maintainer_handle, {:direct, _target_did}) do 28 + maintainer = display_name(maintainer_did, maintainer_handle) 29 + "**#{maintainer}**: direct vouch" 30 + end 31 + 32 + defp format_maintainer_routes(maintainer_did, maintainer_handle, {:routes, _target_did, []}) do 33 + maintainer = display_name(maintainer_did, maintainer_handle) 34 + "**#{maintainer}**: no routes found" 35 + end 36 + 37 + defp format_maintainer_routes(maintainer_did, maintainer_handle, {:routes, _target_did, paths}) do 38 + maintainer = display_name(maintainer_did, maintainer_handle) 39 + route_lines = Enum.map(paths, &format_path/1) 40 + "**#{maintainer}**:\n" <> Enum.join(route_lines, "\n") 41 + end 42 + 43 + defp format_path(path) do 44 + path 45 + |> Enum.map(fn did -> "`#{did}`" end) 46 + |> Enum.join(" -> ") 47 + |> then(&"- #{&1}") 48 + end 49 + 50 + defp display_name(_did, handle) when is_binary(handle) and handle != "", do: "@#{handle}" 51 + defp display_name(did, _handle), do: "`#{did}`" 52 + end
+95
appview/lib/atvouch/tangled/scraper.ex
··· 1 + defmodule Atvouch.Tangled.Scraper do 2 + @moduledoc """ 3 + Scrapes the Tangled pulls page HTML to extract PR listings. 4 + """ 5 + 6 + require Logger 7 + 8 + @doc """ 9 + Parse a Tangled pulls page HTML to extract pull request info. 10 + 11 + Returns a list of `{pull_number, title, created_at}` tuples, 12 + where `created_at` is the ISO8601 datetime string from the `<time>` element, 13 + or `nil` if no timestamp is present (e.g. for merged/closed PRs in collapsed sections). 14 + """ 15 + def parse_pulls_page(html) do 16 + {:ok, doc} = Floki.parse_document(html) 17 + 18 + # Find all links whose href matches /*/pulls/NUMBER 19 + doc 20 + |> Floki.find("a[href]") 21 + |> Enum.flat_map(fn element -> 22 + href = Floki.attribute(element, "href") |> List.first("") 23 + 24 + case Regex.run(~r{/[^/]+/[^/]+/pulls/(\d+)$}, href) do 25 + [_, number_str] -> 26 + number = String.to_integer(number_str) 27 + title = element |> Floki.text() |> String.replace(~r/\s+/, " ") |> String.trim() 28 + created_at = find_time_near(element, doc, href) 29 + [{number, title, created_at}] 30 + 31 + _ -> 32 + [] 33 + end 34 + end) 35 + |> Enum.uniq_by(fn {number, _, _} -> number end) 36 + |> Enum.sort_by(fn {number, _, _} -> number end, :desc) 37 + end 38 + 39 + # Find the <time datetime="..."> element within the same PR entry container 40 + # as the PR link. We find the smallest div ancestor that contains both 41 + # the link and a <time> element. 42 + defp find_time_near(_link_element, doc, href) do 43 + doc 44 + |> Floki.find("div") 45 + |> Enum.filter(fn div -> 46 + has_link = div |> Floki.find("a[href='#{href}']") |> Enum.any?() 47 + has_time = div |> Floki.find("time[datetime]") |> Enum.any?() 48 + has_link and has_time 49 + end) 50 + |> Enum.min_by(&Floki.raw_html(&1, encode: false) |> String.length(), fn -> nil end) 51 + |> case do 52 + nil -> 53 + nil 54 + 55 + div -> 56 + div 57 + |> Floki.find("time[datetime]") 58 + |> List.first() 59 + |> Floki.attribute("datetime") 60 + |> List.first() 61 + |> decode_html_entities() 62 + end 63 + end 64 + 65 + defp decode_html_entities(nil), do: nil 66 + 67 + defp decode_html_entities(str) do 68 + str 69 + |> String.replace("&#43;", "+") 70 + |> String.replace("&amp;", "&") 71 + end 72 + 73 + @doc """ 74 + Fetch the pulls page for a repository from Tangled. 75 + 76 + Returns `{:ok, pulls}` where pulls is the result of `parse_pulls_page/1`, 77 + or `{:error, reason}`. 78 + """ 79 + def fetch_pulls(tangled_url, repo_handle, repo_rkey) do 80 + url = "#{tangled_url}/#{repo_handle}/#{repo_rkey}/pulls" 81 + 82 + case Tesla.get(url) do 83 + {:ok, %Tesla.Env{status: 200, body: body}} -> 84 + {:ok, parse_pulls_page(body)} 85 + 86 + {:ok, %Tesla.Env{status: status}} -> 87 + Logger.warning("Tangled pulls page returned #{status} for #{url}") 88 + {:error, {:http_error, status}} 89 + 90 + {:error, reason} -> 91 + Logger.warning("Failed to fetch Tangled pulls page #{url}: #{inspect(reason)}") 92 + {:error, reason} 93 + end 94 + end 95 + end
+264
appview/lib/atvouch/tangled/session.ex
··· 1 + defmodule Atvouch.Tangled.Session do 2 + @moduledoc """ 3 + GenServer that manages an authenticated Tangled session. 4 + 5 + Performs the OAuth login flow against a PDS and caches 6 + the resulting session cookies. Cookies are refreshed 7 + when they expire (23h TTL) or when explicitly invalidated. 8 + """ 9 + 10 + use GenServer 11 + require Logger 12 + 13 + @cookie_ttl_seconds 23 * 60 * 60 14 + 15 + # Public API 16 + 17 + def start_link(opts) do 18 + name = Keyword.get(opts, :name, __MODULE__) 19 + GenServer.start_link(__MODULE__, opts, name: name) 20 + end 21 + 22 + def get_cookies(server \\ __MODULE__) do 23 + GenServer.call(server, :get_cookies, 30_000) 24 + end 25 + 26 + def get_url(server \\ __MODULE__) do 27 + GenServer.call(server, :get_url) 28 + end 29 + 30 + def invalidate(server \\ __MODULE__) do 31 + GenServer.cast(server, :invalidate) 32 + end 33 + 34 + # GenServer callbacks 35 + 36 + @impl true 37 + def init(opts) do 38 + state = %{ 39 + cookies: nil, 40 + expires_at: nil, 41 + handle: Keyword.fetch!(opts, :handle), 42 + password: Keyword.fetch!(opts, :password), 43 + tangled_url: Keyword.fetch!(opts, :tangled_url) 44 + } 45 + 46 + {:ok, state} 47 + end 48 + 49 + @impl true 50 + def handle_call(:get_cookies, _from, state) do 51 + if state.cookies && state.expires_at && DateTime.compare(DateTime.utc_now(), state.expires_at) == :lt do 52 + {:reply, {:ok, state.cookies}, state} 53 + else 54 + case do_login(state) do 55 + {:ok, cookies} -> 56 + expires_at = DateTime.utc_now() |> DateTime.add(@cookie_ttl_seconds) 57 + new_state = %{state | cookies: cookies, expires_at: expires_at} 58 + {:reply, {:ok, cookies}, new_state} 59 + 60 + {:error, reason} -> 61 + {:reply, {:error, reason}, state} 62 + end 63 + end 64 + end 65 + 66 + @impl true 67 + def handle_call(:get_url, _from, state) do 68 + {:reply, state.tangled_url, state} 69 + end 70 + 71 + @impl true 72 + def handle_cast(:invalidate, state) do 73 + {:noreply, %{state | cookies: nil, expires_at: nil}} 74 + end 75 + 76 + # Login flow implementation 77 + 78 + defp do_login(state) do 79 + with {:ok, authorize_url} <- step1_post_login(state), 80 + {:ok, csrf_token, pds_cookies} <- step2_get_authorize(authorize_url), 81 + {:ok, callback_url} <- step3_sign_in(authorize_url, csrf_token, pds_cookies, state), 82 + {:ok, cookies} <- step4_oauth_callback(callback_url) do 83 + {:ok, cookies} 84 + end 85 + end 86 + 87 + # Step 1: POST /login to Tangled with HX-Request header 88 + defp step1_post_login(state) do 89 + url = "#{state.tangled_url}/login" 90 + body = URI.encode_query(%{"handle" => state.handle}) 91 + 92 + headers = [ 93 + {"content-type", "application/x-www-form-urlencoded"}, 94 + {"hx-request", "true"} 95 + ] 96 + 97 + case http_request(:post, url, body, headers) do 98 + {:ok, status, resp_headers, _body} when status in [200, 302] -> 99 + case get_header(resp_headers, "hx-redirect") do 100 + nil -> 101 + # Try location header as fallback 102 + case get_header(resp_headers, "location") do 103 + nil -> {:error, :no_redirect_in_login_response} 104 + url -> {:ok, url} 105 + end 106 + 107 + url -> 108 + {:ok, url} 109 + end 110 + 111 + {:ok, status, _headers, body} -> 112 + Logger.warning("Tangled login returned #{status}: #{inspect(body)}") 113 + {:error, {:login_failed, status}} 114 + 115 + {:error, reason} -> 116 + {:error, {:login_request_failed, reason}} 117 + end 118 + end 119 + 120 + # Step 2: GET the PDS authorize URL, capture csrf-token cookie 121 + defp step2_get_authorize(authorize_url) do 122 + case http_request(:get, authorize_url, "", []) do 123 + {:ok, status, resp_headers, _body} when status in [200, 302] -> 124 + csrf_token = extract_cookie_value(resp_headers, "csrf-token") 125 + pds_cookies = extract_set_cookies(resp_headers) 126 + 127 + if csrf_token do 128 + {:ok, csrf_token, pds_cookies} 129 + else 130 + {:error, :no_csrf_token} 131 + end 132 + 133 + {:ok, status, _headers, _body} -> 134 + {:error, {:authorize_failed, status}} 135 + 136 + {:error, reason} -> 137 + {:error, {:authorize_request_failed, reason}} 138 + end 139 + end 140 + 141 + # Step 3: POST sign-in to PDS with credentials 142 + defp step3_sign_in(authorize_url, csrf_token, pds_cookies, state) do 143 + # Derive the PDS base URL from the authorize URL 144 + uri = URI.parse(authorize_url) 145 + sign_in_url = "#{uri.scheme}://#{uri.authority}/@atproto/oauth-provider/~api/sign-in" 146 + 147 + body = Jason.encode!(%{ 148 + "username" => state.handle, 149 + "password" => state.password, 150 + "remember" => false 151 + }) 152 + 153 + cookie_string = format_cookies(pds_cookies, csrf_token) 154 + 155 + headers = [ 156 + {"content-type", "application/json"}, 157 + {"cookie", cookie_string} 158 + ] 159 + 160 + case http_request(:post, sign_in_url, body, headers) do 161 + {:ok, 200, _headers, resp_body} -> 162 + case Jason.decode(resp_body) do 163 + {:ok, %{"url" => url}} -> 164 + {:ok, url} 165 + 166 + {:ok, %{"consentRequired" => true}} -> 167 + # TODO: handle consent flow if needed 168 + {:error, :consent_required} 169 + 170 + _ -> 171 + {:error, :unexpected_sign_in_response} 172 + end 173 + 174 + {:ok, status, _headers, _body} -> 175 + {:error, {:sign_in_failed, status}} 176 + 177 + {:error, reason} -> 178 + {:error, {:sign_in_request_failed, reason}} 179 + end 180 + end 181 + 182 + # Step 4: Follow the callback URL to Tangled to get session cookies 183 + defp step4_oauth_callback(callback_url) do 184 + case http_request(:get, callback_url, "", []) do 185 + {:ok, status, resp_headers, _body} when status in [200, 302] -> 186 + session_cookie = extract_cookie_value(resp_headers, "appview-session-v2") 187 + accounts_cookie = extract_cookie_value(resp_headers, "appview-accounts-v2") 188 + 189 + if session_cookie do 190 + cookie_parts = ["appview-session-v2=#{session_cookie}"] 191 + cookie_parts = if accounts_cookie do 192 + cookie_parts ++ ["appview-accounts-v2=#{accounts_cookie}"] 193 + else 194 + cookie_parts 195 + end 196 + 197 + {:ok, Enum.join(cookie_parts, "; ")} 198 + else 199 + {:error, :no_session_cookie} 200 + end 201 + 202 + {:ok, status, _headers, _body} -> 203 + {:error, {:callback_failed, status}} 204 + 205 + {:error, reason} -> 206 + {:error, {:callback_request_failed, reason}} 207 + end 208 + end 209 + 210 + # HTTP helpers - uses Tesla without redirect following 211 + 212 + defp http_request(method, url, body, headers) do 213 + request = %Tesla.Env{ 214 + method: method, 215 + url: url, 216 + body: body, 217 + headers: headers 218 + } 219 + 220 + # Use Tesla.Adapter.Mint directly to avoid middleware redirect following 221 + case Tesla.Adapter.Mint.call(request, []) do 222 + {:ok, %Tesla.Env{status: status, headers: resp_headers, body: resp_body}} -> 223 + {:ok, status, resp_headers, resp_body || ""} 224 + 225 + {:error, reason} -> 226 + {:error, reason} 227 + end 228 + end 229 + 230 + defp get_header(headers, name) do 231 + name_lower = String.downcase(name) 232 + 233 + Enum.find_value(headers, fn {k, v} -> 234 + if String.downcase(k) == name_lower, do: v 235 + end) 236 + end 237 + 238 + defp extract_set_cookies(headers) do 239 + headers 240 + |> Enum.filter(fn {k, _v} -> String.downcase(k) == "set-cookie" end) 241 + |> Enum.map(fn {_k, v} -> 242 + [cookie_part | _] = String.split(v, ";") 243 + cookie_part 244 + end) 245 + end 246 + 247 + defp extract_cookie_value(headers, cookie_name) do 248 + headers 249 + |> Enum.filter(fn {k, _v} -> String.downcase(k) == "set-cookie" end) 250 + |> Enum.find_value(fn {_k, v} -> 251 + [cookie_part | _] = String.split(v, ";") 252 + 253 + case String.split(cookie_part, "=", parts: 2) do 254 + [^cookie_name, value] -> value 255 + _ -> nil 256 + end 257 + end) 258 + end 259 + 260 + defp format_cookies(pds_cookies, csrf_token) do 261 + cookies = pds_cookies ++ ["csrf-token=#{csrf_token}"] 262 + Enum.join(cookies, "; ") 263 + end 264 + end
+146
appview/lib/atvouch/tap_handler.ex
··· 4 4 5 5 @vouch_collection "dev.atvouch.graph.vouch" 6 6 @membership_collection "dev.atvouch.bot.membership" 7 + @pull_collection "sh.tangled.repo.pull" 7 8 8 9 @impl true 9 10 def handle_record(%{collection: @vouch_collection, action: :create} = event) do ··· 72 73 {:error, reason} -> {:error, reason} 73 74 end 74 75 end 76 + 77 + def handle_record(%{collection: @pull_collection, action: :create} = event) do 78 + process_pull(event) 79 + end 80 + 81 + # Ignore update/delete for pull records 82 + def handle_record(%{collection: @pull_collection, action: _}), do: :ok 75 83 76 84 def handle_record(event) do 77 85 Logger.debug( ··· 220 228 defp extract_did_from_at_uri("at://" <> rest) do 221 229 rest |> String.split("/") |> List.first() 222 230 end 231 + 232 + defp extract_rkey_from_at_uri(at_uri) do 233 + at_uri |> String.split("/") |> List.last() 234 + end 235 + 236 + defp process_pull(event) do 237 + repo_at_uri = event.record["target"]["repo"] 238 + author_did = event.did 239 + 240 + case Atvouch.Membership.one(repo_at_uri) do 241 + nil -> 242 + Logger.debug("No membership for repo #{repo_at_uri}, skipping pull") 243 + :ok 244 + 245 + membership -> 246 + process_pull_for_membership(membership, author_did) 247 + end 248 + end 249 + 250 + defp process_pull_for_membership(membership, author_did) do 251 + repo_did = membership.repo_did 252 + repo_rkey = extract_rkey_from_at_uri(membership.repo_at_uri) 253 + 254 + # Resolve repo owner handle for Tangled URL construction 255 + case Atvouch.Identity.one(repo_did) do 256 + nil -> 257 + Logger.warning("Cannot resolve handle for repo DID #{repo_did}, skipping pull") 258 + :ok 259 + 260 + %{handle: nil} -> 261 + Logger.warning("No handle for repo DID #{repo_did}, skipping pull") 262 + :ok 263 + 264 + identity -> 265 + process_pull_with_identity(membership, identity.handle, repo_rkey, author_did) 266 + end 267 + end 268 + 269 + defp process_pull_with_identity(membership, repo_handle, repo_rkey, author_did) do 270 + tangled_url = Atvouch.Tangled.Session.get_url() 271 + 272 + case Atvouch.Tangled.Scraper.fetch_pulls(tangled_url, repo_handle, repo_rkey) do 273 + {:ok, pulls} -> 274 + process_new_pulls(membership, repo_handle, repo_rkey, pulls, author_did) 275 + 276 + {:error, reason} -> 277 + Logger.warning("Failed to fetch pulls for #{repo_handle}/#{repo_rkey}: #{inspect(reason)}") 278 + :ok 279 + end 280 + end 281 + 282 + defp process_new_pulls(membership, repo_handle, repo_rkey, pulls, author_did) do 283 + maintainer_dids = Atvouch.Membership.maintainers(membership.repo_at_uri) 284 + membership_received_at = membership.received_at 285 + 286 + Enum.each(pulls, fn {pull_number, _title, created_at} -> 287 + cond do 288 + Atvouch.BotComment.exists?(membership.repo_at_uri, pull_number) -> 289 + :skip 290 + 291 + not pull_newer_than_membership?(created_at, membership_received_at) -> 292 + Logger.debug("Pull ##{pull_number} created at #{created_at} is not newer than membership #{membership_received_at}, skipping") 293 + :skip 294 + 295 + true -> 296 + comment_on_pull( 297 + membership.repo_at_uri, 298 + repo_handle, 299 + repo_rkey, 300 + pull_number, 301 + author_did, 302 + maintainer_dids 303 + ) 304 + end 305 + end) 306 + 307 + :ok 308 + end 309 + 310 + defp pull_newer_than_membership?(pull_created_at, membership_received_at) 311 + when is_binary(pull_created_at) and is_binary(membership_received_at) do 312 + case {DateTime.from_iso8601(pull_created_at), DateTime.from_iso8601(membership_received_at)} do 313 + {{:ok, pull_dt, _}, {:ok, membership_dt, _}} -> 314 + DateTime.compare(pull_dt, membership_dt) == :gt 315 + 316 + _ -> 317 + Logger.warning("Could not parse timestamps for pull age check: pull=#{pull_created_at} membership=#{membership_received_at}") 318 + false 319 + end 320 + end 321 + 322 + # If the scraper couldn't extract a timestamp, skip to be safe 323 + defp pull_newer_than_membership?(_pull_created_at, _membership_received_at), do: false 324 + 325 + defp comment_on_pull(repo_at_uri, repo_handle, repo_rkey, pull_number, author_did, maintainer_dids) do 326 + # Resolve author handle 327 + author_handle = 328 + case Atvouch.Identity.one(author_did) do 329 + %{handle: h} when is_binary(h) -> h 330 + _ -> nil 331 + end 332 + 333 + # Compute routes for each maintainer 334 + maintainer_routes = 335 + Enum.map(maintainer_dids, fn maintainer_did -> 336 + route_result = Atvouch.Graph.find_routes(maintainer_did, author_did) 337 + 338 + maintainer_handle = 339 + case Atvouch.Identity.one(maintainer_did) do 340 + %{handle: h} when is_binary(h) -> h 341 + _ -> nil 342 + end 343 + 344 + {maintainer_did, maintainer_handle, route_result} 345 + end) 346 + 347 + comment = Atvouch.Tangled.CommentBuilder.build_comment(author_did, author_handle, maintainer_routes) 348 + 349 + case Atvouch.Tangled.Client.post_comment(repo_handle, repo_rkey, pull_number, comment) do 350 + :ok -> 351 + now = DateTime.utc_now() |> DateTime.to_iso8601() 352 + 353 + Atvouch.BotComment.create(%{ 354 + repo_at_uri: repo_at_uri, 355 + pull_number: pull_number, 356 + author_did: author_did, 357 + commented_at: now 358 + }) 359 + 360 + Logger.info("Posted vouch comment on #{repo_handle}/#{repo_rkey}##{pull_number}") 361 + :ok 362 + 363 + {:error, reason} -> 364 + Logger.warning("Failed to post comment on #{repo_handle}/#{repo_rkey}##{pull_number}: #{inspect(reason)}") 365 + :ok 366 + end 367 + end 368 + 223 369 end
+2 -1
appview/mix.exs
··· 36 36 {:prometheus_ex, "~> 5.0"}, 37 37 {:gun, "~> 2.0"}, 38 38 {:cowlib, "~> 2.12", override: true}, 39 - {:atex, "~> 0.7", app: false} 39 + {:atex, "~> 0.7", app: false}, 40 + {:floki, "~> 0.38.0"} 40 41 ] 41 42 end 42 43
+1
appview/mix.lock
··· 16 16 "ex_cldr": {:hex, :ex_cldr, "2.47.1", "2dd2f0da2d5720bf413e0320cfd0ea7f0259a888c33e727c5f0db6bab3380252", [:mix], [{:cldr_utils, "~> 2.28", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "2555d6599d16311a096d8cb2d02e9dc3011ca02abbae446817d4f445a286c758"}, 17 17 "exqlite": {:hex, :exqlite, "0.35.0", "90741471945db42b66cd8ca3149af317f00c22c769cc6b06e8b0a08c5924aae5", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a009e303767a28443e546ac8aab2539429f605e9acdc38bd43f3b13f1568bca9"}, 18 18 "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, 19 + "floki": {:hex, :floki, "0.38.1", "f002ccac94b3bcb21d40d9b34cc2cc9fd88a8311879120330075b5dde657ebee", [:mix], [], "hexpm", "e744bf0db7ee34b2c8b62767f04071107af0516a81144b9a2f73fe0494200e5b"}, 19 20 "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, 20 21 "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 21 22 "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+15
appview/priv/repo/migrations/20260319000000_add_bot_comments.exs
··· 1 + defmodule Atvouch.Repo.Migrations.AddBotComments do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:bot_comments) do 6 + add(:repo_at_uri, :string, null: false) 7 + add(:pull_number, :integer, null: false) 8 + add(:author_did, :string, null: false) 9 + add(:commented_at, :string, null: false) 10 + end 11 + 12 + create unique_index(:bot_comments, [:repo_at_uri, :pull_number], name: :bot_comments_repo_pull) 13 + create index(:bot_comments, [:repo_at_uri]) 14 + end 15 + end
+442
appview/test/atvouch/pull_handler_test.exs
··· 1 + defmodule Atvouch.PullHandlerTest do 2 + use ExUnit.Case 3 + 4 + setup do 5 + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Atvouch.Repo) 6 + Ecto.Adapters.SQL.Sandbox.mode(Atvouch.Repo, {:shared, self()}) 7 + 8 + # Start fake Tangled server 9 + {tangled_pid, tangled_port, state_agent} = 10 + Atvouch.Test.FakeTangledServer.start(self()) 11 + 12 + # Start fake PDS server 13 + {pds_pid, pds_port} = 14 + Atvouch.Test.FakePdsServer.start(self(), 15 + expected_username: "bot.test", 16 + expected_password: "test-password", 17 + callback_url: "http://127.0.0.1:#{tangled_port}/oauth/callback?code=test&state=test" 18 + ) 19 + 20 + # Wire them together 21 + Atvouch.Test.FakeTangledServer.set_pds_url(state_agent, "http://127.0.0.1:#{pds_port}") 22 + 23 + tangled_url = "http://127.0.0.1:#{tangled_port}" 24 + 25 + # Ensure no leftover session from previous test 26 + case GenServer.whereis(Atvouch.Tangled.Session) do 27 + nil -> :ok 28 + old_pid -> 29 + GenServer.stop(old_pid, :normal, 1_000) 30 + Process.sleep(10) 31 + end 32 + 33 + # Start Session GenServer with the global name so TapHandler can find it 34 + {:ok, session_pid} = 35 + Atvouch.Tangled.Session.start_link( 36 + handle: "bot.test", 37 + password: "test-password", 38 + tangled_url: tangled_url, 39 + name: Atvouch.Tangled.Session 40 + ) 41 + 42 + # Configure tangled settings in app env 43 + prev_tangled = Application.get_env(:atvouch, :tangled) 44 + 45 + Application.put_env(:atvouch, :tangled, 46 + url: tangled_url, 47 + bot_handle: "bot.test", 48 + bot_password: "test-password" 49 + ) 50 + 51 + # Start fake TAP server 52 + {tap_pid, tap_port} = Atvouch.Test.FakeTapServer.start(self()) 53 + 54 + on_exit(fn -> 55 + Application.put_env(:atvouch, :tangled, prev_tangled) 56 + 57 + for pid <- [pds_pid, tangled_pid, tap_pid] do 58 + try do 59 + Supervisor.stop(pid, :normal, 1_000) 60 + catch 61 + :exit, _ -> :ok 62 + end 63 + end 64 + 65 + try do 66 + GenServer.stop(session_pid, :normal, 1_000) 67 + catch 68 + :exit, _ -> :ok 69 + end 70 + end) 71 + 72 + {:ok, 73 + tap_port: tap_port, 74 + tangled_port: tangled_port, 75 + state_agent: state_agent} 76 + end 77 + 78 + defp setup_vouch_graph do 79 + # Create identities 80 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:maintainer1", handle: "maintainer1.test"}) 81 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:maintainer2", handle: "maintainer2.test"}) 82 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:author", handle: "author.test"}) 83 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:middle", handle: "middle.test"}) 84 + {:ok, _} = Atvouch.Identity.create(%{did: "did:plc:repoowner", handle: "repoowner.test"}) 85 + 86 + # Create vouches: maintainer1 -> author (direct), maintainer2 -> middle -> author (2-hop) 87 + {:ok, _} = 88 + Atvouch.Vouch.create(%{ 89 + at_uri: "at://did:plc:maintainer1/dev.atvouch.graph.vouch/did:plc:author", 90 + creator_did: "did:plc:maintainer1", 91 + target_did: "did:plc:author", 92 + original_created_at: "2026-03-01T00:00:00Z", 93 + remote_created_at: "2026-03-01T00:00:00Z", 94 + at_cid: "bafytest1", 95 + live: true 96 + }) 97 + 98 + {:ok, _} = 99 + Atvouch.Vouch.create(%{ 100 + at_uri: "at://did:plc:maintainer2/dev.atvouch.graph.vouch/did:plc:middle", 101 + creator_did: "did:plc:maintainer2", 102 + target_did: "did:plc:middle", 103 + original_created_at: "2026-03-01T00:00:00Z", 104 + remote_created_at: "2026-03-01T00:00:00Z", 105 + at_cid: "bafytest2", 106 + live: true 107 + }) 108 + 109 + {:ok, _} = 110 + Atvouch.Vouch.create(%{ 111 + at_uri: "at://did:plc:middle/dev.atvouch.graph.vouch/did:plc:author", 112 + creator_did: "did:plc:middle", 113 + target_did: "did:plc:author", 114 + original_created_at: "2026-03-01T00:00:00Z", 115 + remote_created_at: "2026-03-01T00:00:00Z", 116 + at_cid: "bafytest3", 117 + live: true 118 + }) 119 + 120 + # Create membership for the repo 121 + repo_at_uri = "at://did:plc:repoowner/sh.tangled.repo/3abc123" 122 + 123 + {:ok, _} = 124 + Atvouch.Membership.create( 125 + %{ 126 + repo_at_uri: repo_at_uri, 127 + source_did: "did:plc:repoowner", 128 + repo_did: "did:plc:repoowner", 129 + remote_created_at: "2026-03-01T00:00:00Z", 130 + received_at: "2026-03-01T00:00:00Z" 131 + }, 132 + ["did:plc:maintainer1", "did:plc:maintainer2"] 133 + ) 134 + 135 + repo_at_uri 136 + end 137 + 138 + defp pulls_html(handle, rkey, opts \\ []) do 139 + datetime = Keyword.get(opts, :datetime, "2026-03-19T10:00:00+00:00") 140 + 141 + """ 142 + <html><body> 143 + <div class="flex flex-col gap-2 mt-2"> 144 + <div class="rounded bg-white dark:bg-gray-800"> 145 + <div class="px-6 py-4"> 146 + <div class="pb-2"> 147 + <a href="/#{handle}/#{rkey}/pulls/1" class="dark:text-white"> 148 + Fix tests <span class="text-gray-500">#1</span> 149 + </a> 150 + </div> 151 + <div class="text-sm text-gray-500"> 152 + <span><time datetime="#{datetime}">some time ago</time></span> 153 + </div> 154 + </div> 155 + </div> 156 + </div> 157 + </body></html> 158 + """ 159 + end 160 + 161 + test "processes pull event, computes routes, and posts comment", %{ 162 + tap_port: tap_port, 163 + state_agent: state_agent 164 + } do 165 + repo_at_uri = setup_vouch_graph() 166 + 167 + # Set up pulls page to show PR #1 168 + Atvouch.Test.FakeTangledServer.set_pulls_html( 169 + state_agent, 170 + "repoowner.test", 171 + "3abc123", 172 + pulls_html("repoowner.test", "3abc123") 173 + ) 174 + 175 + # Start TAP socket 176 + {:ok, _pid} = 177 + Atvouch.Tap.Socket.start_link( 178 + uri: "ws://localhost:#{tap_port}/channel", 179 + handler: Atvouch.TapHandler, 180 + password: "123", 181 + name: :"pull_handler_test_#{tap_port}" 182 + ) 183 + 184 + assert_receive {:ws_connected, ws_pid}, 5_000 185 + 186 + # Send a pull create event 187 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 188 + "id" => 700, 189 + "type" => "record", 190 + "record" => %{ 191 + "action" => "create", 192 + "did" => "did:plc:author", 193 + "rev" => "3mgmqjki6sz2n", 194 + "collection" => "sh.tangled.repo.pull", 195 + "rkey" => "3pull123", 196 + "record" => %{ 197 + "$type" => "sh.tangled.repo.pull", 198 + "title" => "Fix tests", 199 + "createdAt" => "2026-03-19T10:00:00Z", 200 + "target" => %{ 201 + "repo" => repo_at_uri, 202 + "branch" => "main" 203 + } 204 + }, 205 + "cid" => "bafypull1", 206 + "live" => true 207 + } 208 + }) 209 + 210 + # Wait for ack 211 + assert_receive {:ws_message, %{"type" => "ack", "id" => 700}}, 10_000 212 + 213 + # Verify comment was posted to Tangled 214 + assert_receive {:tangled_comment, comment}, 10_000 215 + assert comment.handle == "repoowner.test" 216 + assert comment.rkey == "3abc123" 217 + assert comment.number == 1 218 + assert comment.body =~ "atvouch routes for @author.test" 219 + assert comment.body =~ "@maintainer1.test" 220 + assert comment.body =~ "direct vouch" 221 + assert comment.body =~ "@maintainer2.test" 222 + 223 + # Verify dedup record was created 224 + assert Atvouch.BotComment.exists?(repo_at_uri, 1) 225 + end 226 + 227 + test "skips pull for repo without membership", %{tap_port: tap_port} do 228 + {:ok, _pid} = 229 + Atvouch.Tap.Socket.start_link( 230 + uri: "ws://localhost:#{tap_port}/channel", 231 + handler: Atvouch.TapHandler, 232 + password: "123", 233 + name: :"pull_no_membership_test_#{tap_port}" 234 + ) 235 + 236 + assert_receive {:ws_connected, ws_pid}, 5_000 237 + 238 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 239 + "id" => 701, 240 + "type" => "record", 241 + "record" => %{ 242 + "action" => "create", 243 + "did" => "did:plc:someone", 244 + "rev" => "3mgmqjki6sz2n", 245 + "collection" => "sh.tangled.repo.pull", 246 + "rkey" => "3pull456", 247 + "record" => %{ 248 + "$type" => "sh.tangled.repo.pull", 249 + "title" => "Some PR", 250 + "createdAt" => "2026-03-19T10:00:00Z", 251 + "target" => %{ 252 + "repo" => "at://did:plc:nobody/sh.tangled.repo/3xyz999", 253 + "branch" => "main" 254 + } 255 + }, 256 + "cid" => "bafypull2", 257 + "live" => true 258 + } 259 + }) 260 + 261 + assert_receive {:ws_message, %{"type" => "ack", "id" => 701}}, 5_000 262 + 263 + # No comment should be posted 264 + refute_receive {:tangled_comment, _}, 500 265 + end 266 + 267 + test "skips pull older than membership timestamp", %{ 268 + tap_port: tap_port, 269 + state_agent: state_agent 270 + } do 271 + repo_at_uri = setup_vouch_graph() 272 + 273 + # Set pulls HTML with a timestamp BEFORE membership received_at (2026-03-01) 274 + Atvouch.Test.FakeTangledServer.set_pulls_html( 275 + state_agent, 276 + "repoowner.test", 277 + "3abc123", 278 + pulls_html("repoowner.test", "3abc123", datetime: "2026-02-15T10:00:00+00:00") 279 + ) 280 + 281 + {:ok, _pid} = 282 + Atvouch.Tap.Socket.start_link( 283 + uri: "ws://localhost:#{tap_port}/channel", 284 + handler: Atvouch.TapHandler, 285 + password: "123", 286 + name: :"pull_old_pr_test_#{tap_port}" 287 + ) 288 + 289 + assert_receive {:ws_connected, ws_pid}, 5_000 290 + 291 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 292 + "id" => 710, 293 + "type" => "record", 294 + "record" => %{ 295 + "action" => "create", 296 + "did" => "did:plc:author", 297 + "rev" => "3mgmqjki6sz2n", 298 + "collection" => "sh.tangled.repo.pull", 299 + "rkey" => "3pullold", 300 + "record" => %{ 301 + "$type" => "sh.tangled.repo.pull", 302 + "title" => "Old PR before membership", 303 + "createdAt" => "2026-03-19T10:00:00Z", 304 + "target" => %{ 305 + "repo" => repo_at_uri, 306 + "branch" => "main" 307 + } 308 + }, 309 + "cid" => "bafypullold", 310 + "live" => true 311 + } 312 + }) 313 + 314 + assert_receive {:ws_message, %{"type" => "ack", "id" => 710}}, 5_000 315 + 316 + # No comment - the scraped PR timestamp is older than membership 317 + refute_receive {:tangled_comment, _}, 500 318 + refute Atvouch.BotComment.exists?(repo_at_uri, 1) 319 + end 320 + 321 + test "skips pull with same timestamp as membership", %{ 322 + tap_port: tap_port, 323 + state_agent: state_agent 324 + } do 325 + repo_at_uri = setup_vouch_graph() 326 + 327 + # Set pulls HTML with timestamp EQUAL to membership received_at 328 + Atvouch.Test.FakeTangledServer.set_pulls_html( 329 + state_agent, 330 + "repoowner.test", 331 + "3abc123", 332 + pulls_html("repoowner.test", "3abc123", datetime: "2026-03-01T00:00:00+00:00") 333 + ) 334 + 335 + {:ok, _pid} = 336 + Atvouch.Tap.Socket.start_link( 337 + uri: "ws://localhost:#{tap_port}/channel", 338 + handler: Atvouch.TapHandler, 339 + password: "123", 340 + name: :"pull_same_ts_test_#{tap_port}" 341 + ) 342 + 343 + assert_receive {:ws_connected, ws_pid}, 5_000 344 + 345 + Atvouch.Test.FakeTapServer.send_event(ws_pid, %{ 346 + "id" => 711, 347 + "type" => "record", 348 + "record" => %{ 349 + "action" => "create", 350 + "did" => "did:plc:author", 351 + "rev" => "3mgmqjki6sz2n", 352 + "collection" => "sh.tangled.repo.pull", 353 + "rkey" => "3pullsame", 354 + "record" => %{ 355 + "$type" => "sh.tangled.repo.pull", 356 + "title" => "PR at exact membership time", 357 + "createdAt" => "2026-03-19T10:00:00Z", 358 + "target" => %{ 359 + "repo" => repo_at_uri, 360 + "branch" => "main" 361 + } 362 + }, 363 + "cid" => "bafypullsame", 364 + "live" => true 365 + } 366 + }) 367 + 368 + assert_receive {:ws_message, %{"type" => "ack", "id" => 711}}, 5_000 369 + 370 + # No comment - equal timestamp is NOT greater than, so skip 371 + refute_receive {:tangled_comment, _}, 500 372 + end 373 + 374 + test "duplicate pull event does not post again", %{ 375 + tap_port: tap_port, 376 + state_agent: state_agent 377 + } do 378 + repo_at_uri = setup_vouch_graph() 379 + 380 + Atvouch.Test.FakeTangledServer.set_pulls_html( 381 + state_agent, 382 + "repoowner.test", 383 + "3abc123", 384 + pulls_html("repoowner.test", "3abc123") 385 + ) 386 + 387 + {:ok, _pid} = 388 + Atvouch.Tap.Socket.start_link( 389 + uri: "ws://localhost:#{tap_port}/channel", 390 + handler: Atvouch.TapHandler, 391 + password: "123", 392 + name: :"pull_dedup_test_#{tap_port}" 393 + ) 394 + 395 + assert_receive {:ws_connected, ws_pid}, 5_000 396 + 397 + pull_event = %{ 398 + "type" => "record", 399 + "record" => %{ 400 + "action" => "create", 401 + "did" => "did:plc:author", 402 + "rev" => "3mgmqjki6sz2n", 403 + "collection" => "sh.tangled.repo.pull", 404 + "rkey" => "3pull789", 405 + "record" => %{ 406 + "$type" => "sh.tangled.repo.pull", 407 + "title" => "Fix tests", 408 + "createdAt" => "2026-03-19T10:00:00Z", 409 + "target" => %{ 410 + "repo" => repo_at_uri, 411 + "branch" => "main" 412 + } 413 + }, 414 + "cid" => "bafypull3", 415 + "live" => true 416 + } 417 + } 418 + 419 + # First event 420 + Atvouch.Test.FakeTapServer.send_event(ws_pid, Map.put(pull_event, "id", 702)) 421 + assert_receive {:ws_message, %{"type" => "ack", "id" => 702}}, 10_000 422 + assert_receive {:tangled_comment, _}, 10_000 423 + 424 + # Drain remaining messages 425 + drain_messages() 426 + 427 + # Second event for same repo 428 + Atvouch.Test.FakeTapServer.send_event(ws_pid, Map.put(pull_event, "id", 703)) 429 + assert_receive {:ws_message, %{"type" => "ack", "id" => 703}}, 10_000 430 + 431 + # Should NOT post another comment 432 + refute_receive {:tangled_comment, _}, 1_000 433 + end 434 + 435 + defp drain_messages do 436 + receive do 437 + _ -> drain_messages() 438 + after 439 + 100 -> :ok 440 + end 441 + end 442 + end
+68
appview/test/atvouch/tangled/client_test.exs
··· 1 + defmodule Atvouch.Tangled.ClientTest do 2 + use ExUnit.Case 3 + 4 + alias Atvouch.Tangled.Client 5 + 6 + setup do 7 + # Start Tangled server 8 + {tangled_pid, tangled_port, state_agent} = 9 + Atvouch.Test.FakeTangledServer.start(self()) 10 + 11 + # Start PDS server with callback pointing to tangled 12 + {pds_pid, pds_port} = 13 + Atvouch.Test.FakePdsServer.start(self(), 14 + expected_username: "bot.test", 15 + expected_password: "test-password", 16 + callback_url: "http://127.0.0.1:#{tangled_port}/oauth/callback?code=test&state=test" 17 + ) 18 + 19 + # Set PDS URL on tangled 20 + Atvouch.Test.FakeTangledServer.set_pds_url(state_agent, "http://127.0.0.1:#{pds_port}") 21 + 22 + # Start session GenServer 23 + {:ok, session_pid} = 24 + Atvouch.Tangled.Session.start_link( 25 + handle: "bot.test", 26 + password: "test-password", 27 + tangled_url: "http://127.0.0.1:#{tangled_port}", 28 + name: :"client_test_session_#{tangled_port}" 29 + ) 30 + 31 + on_exit(fn -> 32 + for pid <- [pds_pid, tangled_pid] do 33 + try do 34 + Supervisor.stop(pid, :normal, 1_000) 35 + catch 36 + :exit, _ -> :ok 37 + end 38 + end 39 + 40 + try do 41 + GenServer.stop(session_pid, :normal, 1_000) 42 + catch 43 + :exit, _ -> :ok 44 + end 45 + end) 46 + 47 + {:ok, 48 + tangled_port: tangled_port, 49 + session: session_pid, 50 + state_agent: state_agent} 51 + end 52 + 53 + test "successfully posts a comment", %{session: session} do 54 + result = 55 + Client.post_comment("alice.test", "3abc123", 1, "Hello from bot!", 56 + session: session 57 + ) 58 + 59 + assert result == :ok 60 + 61 + assert_receive {:tangled_comment, comment}, 5_000 62 + assert comment.handle == "alice.test" 63 + assert comment.rkey == "3abc123" 64 + assert comment.number == 1 65 + assert comment.body == "Hello from bot!" 66 + assert String.contains?(comment.cookies, "appview-session-v2=") 67 + end 68 + end
+99
appview/test/atvouch/tangled/comment_builder_test.exs
··· 1 + defmodule Atvouch.Tangled.CommentBuilderTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atvouch.Tangled.CommentBuilder 5 + 6 + @alice_did "did:plc:alice" 7 + @bob_did "did:plc:bob" 8 + @carol_did "did:plc:carol" 9 + @dave_did "did:plc:dave" 10 + @author_did "did:plc:author" 11 + 12 + test "formats direct vouch" do 13 + routes = [{@alice_did, "alice.test", {:direct, @author_did}}] 14 + result = CommentBuilder.build_comment(@author_did, "author.test", routes) 15 + 16 + assert result =~ "## atvouch routes for @author.test" 17 + assert result =~ "**@alice.test**: direct vouch" 18 + assert result =~ "generated by [atvouch]" 19 + end 20 + 21 + test "formats multi-hop routes" do 22 + routes = [ 23 + {@alice_did, "alice.test", 24 + {:routes, @author_did, [[@alice_did, @bob_did, @author_did]]}} 25 + ] 26 + 27 + result = CommentBuilder.build_comment(@author_did, "author.test", routes) 28 + 29 + assert result =~ "**@alice.test**:" 30 + assert result =~ "- `#{@alice_did}` -> `#{@bob_did}` -> `#{@author_did}`" 31 + end 32 + 33 + test "formats no routes found" do 34 + routes = [{@alice_did, "alice.test", {:routes, @author_did, []}}] 35 + result = CommentBuilder.build_comment(@author_did, "author.test", routes) 36 + 37 + assert result =~ "**@alice.test**: no routes found" 38 + end 39 + 40 + test "formats multiple maintainers with mixed results" do 41 + routes = [ 42 + {@alice_did, "alice.test", {:direct, @author_did}}, 43 + {@bob_did, "bob.test", 44 + {:routes, @author_did, [[@bob_did, @carol_did, @author_did]]}}, 45 + {@dave_did, "dave.test", {:routes, @author_did, []}} 46 + ] 47 + 48 + result = CommentBuilder.build_comment(@author_did, "author.test", routes) 49 + 50 + assert result =~ "**@alice.test**: direct vouch" 51 + assert result =~ "**@bob.test**:" 52 + assert result =~ "- `#{@bob_did}` -> `#{@carol_did}` -> `#{@author_did}`" 53 + assert result =~ "**@dave.test**: no routes found" 54 + end 55 + 56 + test "falls back to DID when handle is nil" do 57 + routes = [{@alice_did, nil, {:direct, @author_did}}] 58 + result = CommentBuilder.build_comment(@author_did, nil, routes) 59 + 60 + assert result =~ "## atvouch routes for `#{@author_did}`" 61 + assert result =~ "**`#{@alice_did}`**: direct vouch" 62 + end 63 + 64 + test "falls back to DID when handle is empty string" do 65 + routes = [{@alice_did, "", {:direct, @author_did}}] 66 + result = CommentBuilder.build_comment(@author_did, "", routes) 67 + 68 + assert result =~ "## atvouch routes for `#{@author_did}`" 69 + assert result =~ "**`#{@alice_did}`**: direct vouch" 70 + end 71 + 72 + test "formats three-hop path" do 73 + routes = [ 74 + {@alice_did, "alice.test", 75 + {:routes, @author_did, [[@alice_did, @bob_did, @carol_did, @author_did]]}} 76 + ] 77 + 78 + result = CommentBuilder.build_comment(@author_did, "author.test", routes) 79 + 80 + assert result =~ 81 + "- `#{@alice_did}` -> `#{@bob_did}` -> `#{@carol_did}` -> `#{@author_did}`" 82 + end 83 + 84 + test "formats multiple routes for one maintainer" do 85 + routes = [ 86 + {@alice_did, "alice.test", 87 + {:routes, @author_did, 88 + [ 89 + [@alice_did, @bob_did, @author_did], 90 + [@alice_did, @carol_did, @author_did] 91 + ]}} 92 + ] 93 + 94 + result = CommentBuilder.build_comment(@author_did, "author.test", routes) 95 + 96 + assert result =~ "- `#{@alice_did}` -> `#{@bob_did}` -> `#{@author_did}`" 97 + assert result =~ "- `#{@alice_did}` -> `#{@carol_did}` -> `#{@author_did}`" 98 + end 99 + end
+145
appview/test/atvouch/tangled/scraper_test.exs
··· 1 + defmodule Atvouch.Tangled.ScraperTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias Atvouch.Tangled.Scraper 5 + 6 + # Realistic Tangled pulls page structure 7 + defp pr_entry(handle, rkey, number, title, datetime) do 8 + """ 9 + <div class="rounded bg-white dark:bg-gray-800"> 10 + <div class="px-6 py-4"> 11 + <div class="pb-2"> 12 + <a href="/#{handle}/#{rkey}/pulls/#{number}" class="dark:text-white"> 13 + #{title} 14 + <span class="text-gray-500">##{number}</span> 15 + </a> 16 + </div> 17 + <div class="text-sm text-gray-500"> 18 + <span class="inline-flex items-center"><span class="text-white">open</span></span> 19 + <span><time datetime="#{datetime}">some time ago</time></span> 20 + </div> 21 + </div> 22 + </div> 23 + """ 24 + end 25 + 26 + defp pr_entry_no_time(handle, rkey, number, title) do 27 + """ 28 + <div class="rounded bg-white dark:bg-gray-800"> 29 + <div class="px-6 py-4"> 30 + <div class="pb-2"> 31 + <a href="/#{handle}/#{rkey}/pulls/#{number}" class="dark:text-white"> 32 + #{title} 33 + <span class="text-gray-500">##{number}</span> 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + """ 39 + end 40 + 41 + defp wrap_page(entries) do 42 + """ 43 + <html><body> 44 + <div class="flex flex-col gap-2 mt-2"> 45 + #{entries} 46 + </div> 47 + </body></html> 48 + """ 49 + end 50 + 51 + test "parses pulls page HTML to extract PR list with timestamps" do 52 + html = 53 + wrap_page( 54 + pr_entry("alice.test", "3abc123", 3, "Fix broken tests", "2026-03-19T14:37:19+00:00") <> 55 + pr_entry("alice.test", "3abc123", 2, "Add new feature", "2026-03-10T16:25:26+00:00") <> 56 + pr_entry("alice.test", "3abc123", 1, "Initial setup", "2026-02-15T10:00:00+00:00") 57 + ) 58 + 59 + pulls = Scraper.parse_pulls_page(html) 60 + 61 + assert length(pulls) == 3 62 + {3, title3, ts3} = Enum.at(pulls, 0) 63 + assert title3 =~ "Fix broken tests" 64 + assert ts3 == "2026-03-19T14:37:19+00:00" 65 + 66 + {2, title2, ts2} = Enum.at(pulls, 1) 67 + assert title2 =~ "Add new feature" 68 + assert ts2 == "2026-03-10T16:25:26+00:00" 69 + 70 + {1, title1, ts1} = Enum.at(pulls, 2) 71 + assert title1 =~ "Initial setup" 72 + assert ts1 == "2026-02-15T10:00:00+00:00" 73 + end 74 + 75 + test "handles empty pulls page" do 76 + html = wrap_page("<p>No pull requests found.</p>") 77 + pulls = Scraper.parse_pulls_page(html) 78 + assert pulls == [] 79 + end 80 + 81 + test "returns pulls sorted by number descending" do 82 + html = 83 + wrap_page( 84 + pr_entry("alice.test", "3abc123", 1, "First", "2026-03-01T00:00:00+00:00") <> 85 + pr_entry("alice.test", "3abc123", 5, "Fifth", "2026-03-15T00:00:00+00:00") <> 86 + pr_entry("alice.test", "3abc123", 3, "Third", "2026-03-10T00:00:00+00:00") 87 + ) 88 + 89 + pulls = Scraper.parse_pulls_page(html) 90 + numbers = Enum.map(pulls, fn {n, _, _} -> n end) 91 + assert numbers == [5, 3, 1] 92 + end 93 + 94 + test "returns nil created_at when no time element in PR entry" do 95 + html = wrap_page(pr_entry_no_time("alice.test", "3abc123", 1, "No timestamp PR")) 96 + pulls = Scraper.parse_pulls_page(html) 97 + assert [{1, _, nil}] = pulls 98 + end 99 + 100 + test "decodes HTML entities in datetime attribute" do 101 + # Floki decodes &#43; automatically, but test that the full pipeline works 102 + html = 103 + wrap_page( 104 + pr_entry("alice.test", "3abc123", 1, "PR", "2026-03-20T22:16:41&#43;00:00") 105 + ) 106 + 107 + [{1, _, created_at}] = Scraper.parse_pulls_page(html) 108 + assert created_at == "2026-03-20T22:16:41+00:00" 109 + end 110 + 111 + test "parses real Tangled HTML structure" do 112 + # Snippet matching actual Tangled HTML from tangled.org/core/pulls 113 + html = """ 114 + <html><body> 115 + <div class="flex flex-col gap-2 mt-2"> 116 + <div class="rounded bg-white dark:bg-gray-800"> 117 + <div class="px-6 py-4 z-5"> 118 + <div class="pb-2"> 119 + <a href="/tangled.org/core/pulls/1189" class="dark:text-white"> 120 + spindled/engine: store workflow logs in s3 121 + <span class="text-gray-500 dark:text-gray-400">#1189</span> 122 + </a> 123 + </div> 124 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 125 + <span class="inline-flex items-center rounded px-2 py-[5px] bg-green-600 dark:bg-green-700 text-sm"> 126 + <span class="text-white">open</span> 127 + </span> 128 + <span class="ml-1"> 129 + <a href="/jobala.tngl.sh">jobala.tngl.sh</a> 130 + </span> 131 + <span class="before:content-['·']"> 132 + <time datetime="2026-03-20T22:16:41+00:00" title="Mar 20, 2026, 10:16 PM UTC">2 hours ago</time> 133 + </span> 134 + </div> 135 + </div> 136 + </div> 137 + </div> 138 + </body></html> 139 + """ 140 + 141 + pulls = Scraper.parse_pulls_page(html) 142 + assert [{1189, title, "2026-03-20T22:16:41+00:00"}] = pulls 143 + assert title =~ "spindled/engine: store workflow logs in s3" 144 + end 145 + end
+117
appview/test/atvouch/tangled/session_test.exs
··· 1 + defmodule Atvouch.Tangled.SessionTest do 2 + use ExUnit.Case 3 + 4 + alias Atvouch.Tangled.Session 5 + 6 + setup do 7 + # Start Tangled server (PDS URL will be set after PDS starts) 8 + {tangled_pid, tangled_port, state_agent} = 9 + Atvouch.Test.FakeTangledServer.start(self()) 10 + 11 + # Start PDS server with callback URL pointing to tangled 12 + {pds_pid, pds_port} = 13 + Atvouch.Test.FakePdsServer.start(self(), 14 + expected_username: "bot.test", 15 + expected_password: "test-password", 16 + callback_url: "http://127.0.0.1:#{tangled_port}/oauth/callback?code=test-code&state=test-state" 17 + ) 18 + 19 + # Now set the PDS URL on the tangled server 20 + Atvouch.Test.FakeTangledServer.set_pds_url(state_agent, "http://127.0.0.1:#{pds_port}") 21 + 22 + on_exit(fn -> 23 + for pid <- [pds_pid, tangled_pid] do 24 + try do 25 + Supervisor.stop(pid, :normal, 1_000) 26 + catch 27 + :exit, _ -> :ok 28 + end 29 + end 30 + end) 31 + 32 + {:ok, tangled_port: tangled_port} 33 + end 34 + 35 + test "successful login returns cookies", %{tangled_port: tangled_port} do 36 + {:ok, pid} = 37 + Session.start_link( 38 + handle: "bot.test", 39 + password: "test-password", 40 + tangled_url: "http://127.0.0.1:#{tangled_port}", 41 + name: :"session_test_#{tangled_port}" 42 + ) 43 + 44 + result = Session.get_cookies(pid) 45 + assert {:ok, cookies} = result 46 + assert String.contains?(cookies, "appview-session-v2=") 47 + end 48 + 49 + test "cookies are cached on second call", %{tangled_port: tangled_port} do 50 + {:ok, pid} = 51 + Session.start_link( 52 + handle: "bot.test", 53 + password: "test-password", 54 + tangled_url: "http://127.0.0.1:#{tangled_port}", 55 + name: :"session_cache_test_#{tangled_port}" 56 + ) 57 + 58 + {:ok, cookies1} = Session.get_cookies(pid) 59 + 60 + # Drain messages from first login 61 + drain_messages() 62 + 63 + {:ok, cookies2} = Session.get_cookies(pid) 64 + 65 + assert cookies1 == cookies2 66 + 67 + # Should NOT have received another login attempt 68 + refute_received {:tangled_login, _} 69 + end 70 + 71 + test "invalidate forces re-login", %{tangled_port: tangled_port} do 72 + {:ok, pid} = 73 + Session.start_link( 74 + handle: "bot.test", 75 + password: "test-password", 76 + tangled_url: "http://127.0.0.1:#{tangled_port}", 77 + name: :"session_invalidate_test_#{tangled_port}" 78 + ) 79 + 80 + {:ok, _cookies1} = Session.get_cookies(pid) 81 + 82 + # Drain messages from first login 83 + drain_messages() 84 + 85 + Session.invalidate(pid) 86 + Process.sleep(50) 87 + 88 + {:ok, _cookies2} = Session.get_cookies(pid) 89 + 90 + # Should have received a second login 91 + assert_received {:tangled_login, _} 92 + end 93 + 94 + test "failed login returns error without crashing", %{tangled_port: tangled_port} do 95 + {:ok, pid} = 96 + Session.start_link( 97 + handle: "bot.test", 98 + password: "wrong-password", 99 + tangled_url: "http://127.0.0.1:#{tangled_port}", 100 + name: :"session_fail_test_#{tangled_port}" 101 + ) 102 + 103 + result = Session.get_cookies(pid) 104 + assert {:error, _reason} = result 105 + 106 + # GenServer should still be alive 107 + assert Process.alive?(pid) 108 + end 109 + 110 + defp drain_messages do 111 + receive do 112 + _ -> drain_messages() 113 + after 114 + 50 -> :ok 115 + end 116 + end 117 + end
+88
appview/test/support/fake_pds_server.ex
··· 1 + defmodule Atvouch.Test.FakePdsServer do 2 + @moduledoc """ 3 + Fake PDS server for testing the AT Protocol OAuth sign-in flow. 4 + """ 5 + 6 + defmodule Router do 7 + use Plug.Router 8 + 9 + plug(:match) 10 + plug(:dispatch) 11 + 12 + # OAuth authorize page - sets CSRF cookie and returns 200 13 + get "/@atproto/oauth-provider/authorize" do 14 + test_pid = conn.private[:test_pid] 15 + send(test_pid, {:pds_authorize, conn.query_string}) 16 + 17 + conn 18 + |> put_resp_header("set-cookie", "csrf-token=test-csrf-token; Path=/; HttpOnly") 19 + |> put_resp_content_type("text/html") 20 + |> send_resp(200, "<html><body>Authorize</body></html>") 21 + end 22 + 23 + # Sign-in API endpoint 24 + post "/@atproto/oauth-provider/~api/sign-in" do 25 + test_pid = conn.private[:test_pid] 26 + callback_url = conn.private[:callback_url] 27 + {:ok, body, conn} = Plug.Conn.read_body(conn) 28 + params = Jason.decode!(body) 29 + 30 + send(test_pid, {:pds_sign_in, params}) 31 + 32 + valid_username = conn.private[:expected_username] 33 + valid_password = conn.private[:expected_password] 34 + 35 + if params["username"] == valid_username and params["password"] == valid_password do 36 + conn 37 + |> put_resp_content_type("application/json") 38 + |> send_resp(200, Jason.encode!(%{ 39 + "consentRequired" => false, 40 + "url" => callback_url 41 + })) 42 + else 43 + conn 44 + |> put_resp_content_type("application/json") 45 + |> send_resp(401, Jason.encode!(%{"error" => "invalid_credentials"})) 46 + end 47 + end 48 + 49 + match _ do 50 + send_resp(conn, 404, "not found") 51 + end 52 + end 53 + 54 + defmodule PlugWithState do 55 + @behaviour Plug 56 + 57 + def init(opts), do: opts 58 + 59 + def call(conn, opts) do 60 + conn 61 + |> Plug.Conn.put_private(:test_pid, opts[:test_pid]) 62 + |> Plug.Conn.put_private(:callback_url, opts[:callback_url]) 63 + |> Plug.Conn.put_private(:expected_username, opts[:expected_username]) 64 + |> Plug.Conn.put_private(:expected_password, opts[:expected_password]) 65 + |> Router.call(Router.init([])) 66 + end 67 + end 68 + 69 + def start(test_pid, opts \\ []) do 70 + callback_url = Keyword.get(opts, :callback_url, "http://localhost:0/oauth/callback") 71 + expected_username = Keyword.get(opts, :expected_username, "bot.test") 72 + expected_password = Keyword.get(opts, :expected_password, "test-password") 73 + 74 + {:ok, server_pid} = 75 + Bandit.start_link( 76 + plug: {PlugWithState, 77 + test_pid: test_pid, 78 + callback_url: callback_url, 79 + expected_username: expected_username, 80 + expected_password: expected_password}, 81 + port: 0, 82 + ip: {127, 0, 0, 1} 83 + ) 84 + 85 + {:ok, {_ip, port}} = ThousandIsland.listener_info(server_pid) 86 + {server_pid, port} 87 + end 88 + end
+129
appview/test/support/fake_tangled_server.ex
··· 1 + defmodule Atvouch.Test.FakeTangledServer do 2 + @moduledoc """ 3 + Fake Tangled HTTP server for testing the OAuth login flow and comment posting. 4 + """ 5 + 6 + defmodule Router do 7 + use Plug.Router 8 + 9 + plug(:match) 10 + plug(:dispatch) 11 + 12 + # Login endpoint - returns HX-Redirect to PDS authorize URL 13 + post "/login" do 14 + {:ok, body, conn} = Plug.Conn.read_body(conn) 15 + params = URI.decode_query(body) 16 + test_pid = conn.private[:test_pid] 17 + state_agent = conn.private[:state_agent] 18 + 19 + pds_url = Agent.get(state_agent, fn state -> Map.get(state, :pds_url, "http://localhost:0") end) 20 + 21 + send(test_pid, {:tangled_login, params}) 22 + 23 + authorize_url = "#{pds_url}/@atproto/oauth-provider/authorize?client_id=tangled&state=test-state" 24 + 25 + conn 26 + |> put_resp_header("hx-redirect", authorize_url) 27 + |> send_resp(200, "") 28 + end 29 + 30 + # OAuth callback - sets session cookies 31 + get "/oauth/callback" do 32 + test_pid = conn.private[:test_pid] 33 + send(test_pid, {:tangled_oauth_callback, conn.query_string}) 34 + 35 + conn 36 + |> put_resp_header("set-cookie", "appview-session-v2=test-session-cookie; Path=/; HttpOnly") 37 + |> prepend_resp_headers([{"set-cookie", "appview-accounts-v2=test-accounts-cookie; Path=/; HttpOnly"}]) 38 + |> put_resp_header("location", "/") 39 + |> send_resp(302, "") 40 + end 41 + 42 + # Pulls listing page 43 + get "/:handle/:rkey/pulls" do 44 + test_pid = conn.private[:test_pid] 45 + send(test_pid, {:tangled_pulls_page, handle, rkey}) 46 + 47 + pulls_html = Agent.get(conn.private[:state_agent], fn state -> 48 + Map.get(state, {:pulls_html, handle, rkey}, default_pulls_html(handle, rkey)) 49 + end) 50 + 51 + conn 52 + |> put_resp_content_type("text/html") 53 + |> send_resp(200, pulls_html) 54 + end 55 + 56 + # Comment posting endpoint 57 + post "/:handle/:rkey/pulls/:number/round/0/comment" do 58 + test_pid = conn.private[:test_pid] 59 + {:ok, body, conn} = Plug.Conn.read_body(conn) 60 + params = URI.decode_query(body) 61 + number = String.to_integer(number) 62 + 63 + cookies = Plug.Conn.get_req_header(conn, "cookie") |> List.first("") 64 + 65 + send(test_pid, {:tangled_comment, %{ 66 + handle: handle, 67 + rkey: rkey, 68 + number: number, 69 + body: params["body"], 70 + cookies: cookies 71 + }}) 72 + 73 + if String.contains?(cookies, "appview-session-v2=") do 74 + conn 75 + |> send_resp(200, "ok") 76 + else 77 + conn 78 + |> send_resp(401, "unauthorized") 79 + end 80 + end 81 + 82 + match _ do 83 + send_resp(conn, 404, "not found") 84 + end 85 + 86 + defp default_pulls_html(_handle, _rkey) do 87 + "<html><body><p>No pull requests.</p></body></html>" 88 + end 89 + end 90 + 91 + defmodule PlugWithState do 92 + @behaviour Plug 93 + 94 + def init(opts), do: opts 95 + 96 + def call(conn, opts) do 97 + conn 98 + |> Plug.Conn.put_private(:test_pid, opts[:test_pid]) 99 + |> Plug.Conn.put_private(:state_agent, opts[:state_agent]) 100 + |> Router.call(Router.init([])) 101 + end 102 + end 103 + 104 + def start(test_pid, opts \\ []) do 105 + {:ok, state_agent} = Agent.start_link(fn -> 106 + %{pds_url: Keyword.get(opts, :pds_url, "http://localhost:0")} 107 + end) 108 + 109 + {:ok, server_pid} = 110 + Bandit.start_link( 111 + plug: {PlugWithState, test_pid: test_pid, state_agent: state_agent}, 112 + port: 0, 113 + ip: {127, 0, 0, 1} 114 + ) 115 + 116 + {:ok, {_ip, port}} = ThousandIsland.listener_info(server_pid) 117 + {server_pid, port, state_agent} 118 + end 119 + 120 + def set_pds_url(state_agent, pds_url) do 121 + Agent.update(state_agent, fn state -> Map.put(state, :pds_url, pds_url) end) 122 + end 123 + 124 + def set_pulls_html(state_agent, handle, rkey, html) do 125 + Agent.update(state_agent, fn state -> 126 + Map.put(state, {:pulls_html, handle, rkey}, html) 127 + end) 128 + end 129 + end