From 5c71a816daa0fa98ac88e352e0c6a8687b3f5212 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Mon, 27 Oct 2025 10:56:22 -0400 Subject: [PATCH 01/23] Bump erlang and elixir versions Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- .github/workflows/ci.yml | 12 ++++++------ .tool-versions | 4 ++-- elixir_buildpack.config | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa5f25f4..53ab9855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: [1.17.2] - otp: [27.0.1] + elixir: [1.19.0] + otp: [28.1] steps: - uses: actions/checkout@v3 - name: Set up Elixir @@ -61,8 +61,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: [1.17.2] - otp: [27.0.1] + elixir: [1.19.0] + otp: [28.1] services: postgres: image: postgres:14 @@ -111,8 +111,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: [1.17.2] - otp: [27.0.1] + elixir: [1.19.0] + otp: [28.1] steps: - uses: actions/checkout@v3 - name: Set up Elixir diff --git a/.tool-versions b/.tool-versions index 37072833..f3e3a8df 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.17.2-otp-27 -erlang 27.0.1 +elixir 1.19.0-otp-28 +erlang 28.1 nodejs 19.0.0 diff --git a/elixir_buildpack.config b/elixir_buildpack.config index 1a72ae8f..43b0f239 100644 --- a/elixir_buildpack.config +++ b/elixir_buildpack.config @@ -1,3 +1,3 @@ config_vars_to_export=(BASIC_AUTH_PASSWORD BASIC_AUTH_USERNAME) -elixir_version=1.14.1 -erlang_version=25.1.2 +elixir_version=1.19.0 +erlang_version=28.1 From 40c3f8da0d18c13bd0358a340f8f3c3db1c7b206 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Mon, 27 Oct 2025 11:01:00 -0400 Subject: [PATCH 02/23] Bump credo Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- mix.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mix.lock b/mix.lock index c74d90a4..a4e7a129 100644 --- a/mix.lock +++ b/mix.lock @@ -11,7 +11,7 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, - "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, + "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, @@ -21,7 +21,7 @@ "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, - "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [: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", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, From 69e42b4519b0df3b0e9c417a1d8a7f69ae991bcc Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Mon, 27 Oct 2025 10:40:51 -0400 Subject: [PATCH 03/23] Create new MCP server to create TILs Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/application.ex | 2 ++ lib/tilex/mcp/new_post.ex | 24 ++++++++++++++++++++++++ lib/tilex/mcp/server.ex | 8 ++++++++ lib/tilex_web/endpoint.ex | 1 - lib/tilex_web/router.ex | 4 ++++ mix.exs | 3 ++- mix.lock | 34 ++++++++++++++++++---------------- 7 files changed, 58 insertions(+), 18 deletions(-) create mode 100644 lib/tilex/mcp/new_post.ex create mode 100644 lib/tilex/mcp/server.ex diff --git a/lib/tilex/application.ex b/lib/tilex/application.ex index cdf2c1ed..249e65ad 100644 --- a/lib/tilex/application.ex +++ b/lib/tilex/application.ex @@ -15,6 +15,8 @@ defmodule Tilex.Application do {Cachex, name: :tilex_cache}, Tilex.Notifications, Tilex.RateLimiter, + Hermes.Server.Registry, + {Tilex.MCP.Server, transport: :streamable_http}, Tilex.Notifications.NotifiersSupervisor ] diff --git a/lib/tilex/mcp/new_post.ex b/lib/tilex/mcp/new_post.ex new file mode 100644 index 00000000..e0510036 --- /dev/null +++ b/lib/tilex/mcp/new_post.ex @@ -0,0 +1,24 @@ +defmodule Tilex.MCP.NewPost do + @moduledoc "Post new TIL" + + use Hermes.Server.Component, type: :tool + + alias Hermes.Server.Response + + schema do + field :title, :string, required: true + field :content, :string, required: true + end + + def execute(%{title: _title, content: _content}, frame) do + response = + Response.tool() + |> Response.resource_link( + "https://til.hashrocket.com/posts/nlxtvqyfl3-check-your-shell-scripts-with-shellcheck", + "til-post", + description: "Link to the TIL post preview" + ) + + {:reply, response, frame} + end +end diff --git a/lib/tilex/mcp/server.ex b/lib/tilex/mcp/server.ex new file mode 100644 index 00000000..b2876c90 --- /dev/null +++ b/lib/tilex/mcp/server.ex @@ -0,0 +1,8 @@ +defmodule Tilex.MCP.Server do + use Hermes.Server, + name: "TIL", + version: "1.0.0", + capabilities: [:tools] + + component(Tilex.MCP.NewPost) +end diff --git a/lib/tilex_web/endpoint.ex b/lib/tilex_web/endpoint.ex index 04408c5d..2216ae73 100644 --- a/lib/tilex_web/endpoint.ex +++ b/lib/tilex_web/endpoint.ex @@ -1,6 +1,5 @@ defmodule TilexWeb.Endpoint do use Phoenix.Endpoint, otp_app: :tilex - use Appsignal.Phoenix if sandbox = Application.compile_env(:tilex, :sandbox) do plug(Phoenix.Ecto.SQL.Sandbox, sandbox: sandbox) diff --git a/lib/tilex_web/router.ex b/lib/tilex_web/router.ex index b6a43737..aa86892d 100644 --- a/lib/tilex_web/router.ex +++ b/lib/tilex_web/router.ex @@ -112,4 +112,8 @@ defmodule TilexWeb.Router do forward "/mailbox", Plug.Swoosh.MailboxPreview end end + + forward "/mcp", + Hermes.Server.Transport.StreamableHTTP.Plug, + server: Tilex.MCP.Server end diff --git a/mix.exs b/mix.exs index 2436cb66..d427cc62 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,8 @@ defmodule Tilex.Mixfile do {:floki, "~>0.34"}, {:gettext, "~> 0.18"}, {:guardian, "~> 2.0"}, - {:hackney, "~>1.18.1"}, + {:hackney, "~>1.25.0"}, + {:hermes_mcp, "~> 0.14.1"}, {:html_sanitize_ex, "~> 1.2"}, {:jason, "~> 1.2"}, {:oauther, diff --git a/mix.lock b/mix.lock index a4e7a129..786872d6 100644 --- a/mix.lock +++ b/mix.lock @@ -4,30 +4,31 @@ "appsignal_plug": {:hex, :appsignal_plug, "2.0.15", "758a8a78944878e8461bbc77ca86219121a56f4299c6d79940ab083cf9afea00", [:mix], [{:appsignal, ">= 2.7.6 and < 3.0.0", [hex: :appsignal, repo: "hexpm", optional: false]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c6059049e2081e808aaef04e2b9917e06277f61a35a0e103db860d08cbc41f1"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, - "castore": {:hex, :castore, "1.0.12", "053f0e32700cbec356280c0e835df425a3be4bc1e0627b714330ad9d0f05497f", [:mix], [], "hexpm", "3dca286b2186055ba0c9449b4e95b97bf1b57b47c1f2644555879e659960c224"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, + "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, - "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, - "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, + "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [: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", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, - "guardian": {:hex, :guardian, "2.3.2", "78003504b987f2b189d76ccf9496ceaa6a454bb2763627702233f31eb7212881", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "b189ff38cd46a22a8a824866a6867ca8722942347f13c33f7d23126af8821b52"}, - "hackney": {:hex, :hackney, "1.18.2", "d7ff544ddae5e1cb49e9cf7fa4e356d7f41b283989a1c304bfc47a8cc1cf966f", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "af94d5c9f97857db257090a4a10e5426ecb6f4918aa5cc666798566ae14b65fd"}, - "hpax": {:hex, :hpax, "1.0.2", "762df951b0c399ff67cc57c3995ec3cf46d696e41f0bba17da0518d94acd4aac", [:mix], [], "hexpm", "2f09b4c1074e0abd846747329eaa26d535be0eb3d189fa69d812bfb8bfefd32f"}, + "guardian": {:hex, :guardian, "2.4.0", "efbbb397ecca881bb548560169922fc4433a05bc98c2eb96a7ed88ede9e17d64", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "5c80103a9c538fbc2505bf08421a82e8f815deba9eaedb6e734c66443154c518"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hermes_mcp": {:hex, :hermes_mcp, "0.14.1", "cfe4321c21c6a5fe01e4e27d6f45037573ce9f02a296fa1112ff7b3d0ee396d9", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "~> 0.4", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "86ec898911d633d8e4a8ad94a79123beab95ba86baa1b6fe6197ab1ccf76f9a9"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, @@ -35,8 +36,8 @@ "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mochiweb": {:hex, :mochiweb, "3.2.2", "bb435384b3b9fd1f92f2f3fe652ea644432877a3e8a81ed6459ce951e0482ad3", [:rebar3], [], "hexpm", "4114e51f1b44c270b3242d91294fe174ce1ed989100e8b65a1fab58e0cba41d5"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, @@ -45,6 +46,7 @@ "oauther": {:git, "https://github.com/tobstarr/oauther.git", "e81fc6588e52eeaf41cdf51ed9d968da46b0b67d", [branch: "master"]}, "optimus": {:hex, :optimus, "0.3.0", "72754d1a06bab7b4b7f59b05622e442e5ab7909b9db5d8b01dc3d2792559fa9e", [:mix], [], "hexpm", "d0d026fdf068e461e1173c463ea2da15e109942135aa4e2490dd2dc9ef05946c"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"}, "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, @@ -54,12 +56,12 @@ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, - "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.7.2", "fdadb973799ae691bf9ecad99125b16625b1c6039999da5fe544d99218e662e4", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "245d8a11ee2306094840c000e8816f0cbed69a23fc0ac2bcf8d7835ae019bb2f"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, + "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.17.1", "01295a82bddd2c6cac1e65856e29444d7c23c4501e0ebc69cea8a82018227e25", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b20d25e580cb79af631335a1bdcfbffd835c08ebcdc16e98577223a241a18a1"}, @@ -71,7 +73,7 @@ "tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, "ueberauth_google": {:hex, :ueberauth_google, "0.12.1", "90cf49743588193334f7a00da252f92d90bfd178d766c0e4291361681fafec7d", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "7f7deacd679b2b66e3bffb68ecc77aa1b5396a0cbac2941815f253128e458c38"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, "wallaby": {:hex, :wallaby, "0.30.9", "51d60682092c3c428c63b656b818e2258202b9f9a31ec37230659647ae20325b", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "62e3ccb89068b231b50ed046219022020516d44f443eebef93a19db4be95b808"}, "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, From 1162ba02aedafe810274471991c6199c0cb5d737 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 09:51:40 -0400 Subject: [PATCH 04/23] Add tidewave for better dev-AI tooling Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex_web/endpoint.ex | 4 ++++ mix.exs | 1 + mix.lock | 6 ++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/tilex_web/endpoint.ex b/lib/tilex_web/endpoint.ex index 2216ae73..b256d26f 100644 --- a/lib/tilex_web/endpoint.ex +++ b/lib/tilex_web/endpoint.ex @@ -14,6 +14,10 @@ defmodule TilexWeb.Endpoint do plug(CORSPlug, origin: origin) end + if Code.ensure_loaded?(Tidewave) do + plug Tidewave + end + # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. diff --git a/mix.exs b/mix.exs index d427cc62..bcecad88 100644 --- a/mix.exs +++ b/mix.exs @@ -62,6 +62,7 @@ defmodule Tilex.Mixfile do {:req, "~> 0.5.8"}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 1.0"}, + {:tidewave, "~> 0.5", only: :dev}, {:timex, "~> 3.1"}, {:tzdata, "~> 1.1.0"}, {:ueberauth_google, "~> 0.5"}, diff --git a/mix.lock b/mix.lock index 786872d6..79be90c3 100644 --- a/mix.lock +++ b/mix.lock @@ -6,6 +6,7 @@ "cachex": {:hex, :cachex, "3.6.0", "14a1bfbeee060dd9bec25a5b6f4e4691e3670ebda28c8ba2884b12fe30b36bf8", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "ebf24e373883bc8e0c8d894a63bbe102ae13d918f790121f5cfe6e485cc8e2e2"}, "castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"}, "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, @@ -51,9 +52,9 @@ "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, - "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, + "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, @@ -69,6 +70,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, "tesla": {:hex, :tesla, "1.12.1", "fe2bf4250868ee72e5d8b8dfa408d13a00747c41b7237b6aa3b9a24057346681", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2391efc6243d37ead43afd0327b520314c7b38232091d4a440c1212626fdd6e7"}, + "tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"}, "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, "ueberauth": {:hex, :ueberauth, "0.10.8", "ba78fbcbb27d811a6cd06ad851793aaf7d27c3b30c9e95349c2c362b344cd8f0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f2d3172e52821375bccb8460e5fa5cb91cfd60b19b636b6e57e9759b6f8c10c1"}, From 0c45f282bbf7ea6ccb1c35556e24d24c47f74a43 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Mon, 27 Oct 2025 15:41:43 -0400 Subject: [PATCH 05/23] Assign current user into mcp server Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/mcp/server.ex | 24 ++++++++++++++++---- lib/tilex/notifications/notifiers/webhook.ex | 2 -- lib/tilex_web/templates/layout/app.html.heex | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/tilex/mcp/server.ex b/lib/tilex/mcp/server.ex index b2876c90..a91a0f10 100644 --- a/lib/tilex/mcp/server.ex +++ b/lib/tilex/mcp/server.ex @@ -1,8 +1,24 @@ defmodule Tilex.MCP.Server do - use Hermes.Server, - name: "TIL", - version: "1.0.0", - capabilities: [:tools] + use Hermes.Server, name: "TIL", version: "1.0.0", capabilities: [:tools] + + import Ecto.Query, only: [from: 2] + + alias Tilex.Repo + alias Tilex.Blog.Developer component(Tilex.MCP.NewPost) + + def init(arg, frame) do + headers = Enum.into(frame.transport.req_headers, %{}) + user = get_current_user(headers["x-api-key"]) + assigns = Map.put(frame.assigns, :current_user, user) + frame = Map.put(frame, :assigns, assigns) + {:ok, frame} + end + + defp get_current_user("" <> _ = api_key) do + Repo.one(from d in Developer, where: d.id == ^api_key) + end + + defp get_current_user(_api_key), do: nil end diff --git a/lib/tilex/notifications/notifiers/webhook.ex b/lib/tilex/notifications/notifiers/webhook.ex index a7c7883c..fe5ef6a5 100644 --- a/lib/tilex/notifications/notifiers/webhook.ex +++ b/lib/tilex/notifications/notifiers/webhook.ex @@ -1,6 +1,4 @@ defmodule Tilex.Notifications.Notifiers.Webhook do - alias Tilex.Blog.Developer - use Tilex.Notifications.Notifier def handle_post_created(post, developer, channel, url) do diff --git a/lib/tilex_web/templates/layout/app.html.heex b/lib/tilex_web/templates/layout/app.html.heex index c28f4c07..fb2a78f8 100644 --- a/lib/tilex_web/templates/layout/app.html.heex +++ b/lib/tilex_web/templates/layout/app.html.heex @@ -1,4 +1,4 @@ -
+
<%= if message = get_flash(@conn, :info) do %> From 8d8fa30a1043ef004c2d66b040e83631881dd981 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 10:13:33 -0400 Subject: [PATCH 06/23] Create TIL from MCP tool Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/mcp/new_post.ex | 51 ++++++++++++++++++++++++++++++--------- lib/tilex/mcp/server.ex | 2 +- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/lib/tilex/mcp/new_post.ex b/lib/tilex/mcp/new_post.ex index e0510036..e4a44e88 100644 --- a/lib/tilex/mcp/new_post.ex +++ b/lib/tilex/mcp/new_post.ex @@ -1,24 +1,51 @@ defmodule Tilex.MCP.NewPost do - @moduledoc "Post new TIL" + @moduledoc """ + Create a new TIL ("Today I Learned") post. + + TIL is a place for sharing something you've learned today with others. + """ use Hermes.Server.Component, type: :tool alias Hermes.Server.Response + alias Tilex.Blog.Developer + alias Tilex.Blog.Post + alias TilexWeb.Endpoint + alias TilexWeb.Router.Helpers, as: Routes schema do - field :title, :string, required: true - field :content, :string, required: true + field :title, :string, + required: true, + description: "Max #{Post.title_max_chars()} chars." + + field :body, :string, + required: true, + description: + "Max #{Post.body_max_words()} words in a Markdown format." + end + + def execute(input, frame) do + current_user = frame.assigns.current_user + resp = Response.tool() + + resp = + case create_til_post(current_user, input) do + {:ok, %Post{} = post} -> + url = Routes.post_url(Endpoint, :show, post) + Response.resource_link(resp, url, "til-post", description: "Link to the TIL post preview") + + {:error, reason} -> + Response.error(resp, "ERROR => #{reason}") + end + + {:reply, resp, frame} end - def execute(%{title: _title, content: _content}, frame) do - response = - Response.tool() - |> Response.resource_link( - "https://til.hashrocket.com/posts/nlxtvqyfl3-check-your-shell-scripts-with-shellcheck", - "til-post", - description: "Link to the TIL post preview" - ) + defp create_til_post(nil, _input) do + {:error, "User is not authenticated to create TILs"} + end - {:reply, response, frame} + defp create_til_post(%Developer{} = current_user, %{title: title, body: body}) do + {:ok, %Post{title: title, body: body, slug: "foo-bar"}} end end diff --git a/lib/tilex/mcp/server.ex b/lib/tilex/mcp/server.ex index a91a0f10..0f6df831 100644 --- a/lib/tilex/mcp/server.ex +++ b/lib/tilex/mcp/server.ex @@ -8,7 +8,7 @@ defmodule Tilex.MCP.Server do component(Tilex.MCP.NewPost) - def init(arg, frame) do + def init(_arg, frame) do headers = Enum.into(frame.transport.req_headers, %{}) user = get_current_user(headers["x-api-key"]) assigns = Map.put(frame.assigns, :current_user, user) From 594348c90a31edb0faa072b6b6f208ddd81c7370 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 10:36:39 -0400 Subject: [PATCH 07/23] Create MCP server for listing channels Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/mcp/list_channels.ex | 29 +++++++++++++++++++++++++++++ lib/tilex/mcp/server.ex | 1 + 2 files changed, 30 insertions(+) create mode 100644 lib/tilex/mcp/list_channels.ex diff --git a/lib/tilex/mcp/list_channels.ex b/lib/tilex/mcp/list_channels.ex new file mode 100644 index 00000000..30246d26 --- /dev/null +++ b/lib/tilex/mcp/list_channels.ex @@ -0,0 +1,29 @@ +defmodule Tilex.MCP.ListChannels do + @moduledoc """ + List channels of TIL posts. + + Channel are used to group posts by the same topic. + """ + + use Hermes.Server.Component, type: :tool + + import Ecto.Query, only: [from: 2] + + alias Hermes.Server.Response + alias Tilex.Blog.Channel + alias Tilex.Repo + + schema do + end + + def execute(input, frame) do + channels = list_channels() + resp = Response.tool() |> Response.json(channels) + {:reply, resp, frame} + end + + defp list_channels() do + from(c in Channel, select: c.name) + |> Repo.all() + end +end diff --git a/lib/tilex/mcp/server.ex b/lib/tilex/mcp/server.ex index 0f6df831..ec657116 100644 --- a/lib/tilex/mcp/server.ex +++ b/lib/tilex/mcp/server.ex @@ -6,6 +6,7 @@ defmodule Tilex.MCP.Server do alias Tilex.Repo alias Tilex.Blog.Developer + component(Tilex.MCP.ListChannels) component(Tilex.MCP.NewPost) def init(_arg, frame) do From 87c327fff2198052c1e3e5c6bcdd7ffdd46e6436 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 11:21:33 -0400 Subject: [PATCH 08/23] Setup openspec for new features Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- .gitignore | 1 + AGENTS.md | 17 ++ CLAUDE.md | 1 + openspec/AGENTS.md | 456 ++++++++++++++++++++++++++++++++++++++++++++ openspec/project.md | 31 +++ 5 files changed, 506 insertions(+) create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 openspec/AGENTS.md create mode 100644 openspec/project.md diff --git a/.gitignore b/.gitignore index b8a35821..edbeb145 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.claude/ .env *.ez /_build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..46810e6a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md new file mode 100644 index 00000000..687036e1 --- /dev/null +++ b/openspec/AGENTS.md @@ -0,0 +1,456 @@ +# OpenSpec Instructions + +Instructions for AI coding assistants using OpenSpec for spec-driven development. + +## TL;DR Quick Checklist + +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict` and fix issues +- Request approval: Do not start implementation until proposal is approved + +## Three-Stage Workflow + +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns + +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" + +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` + +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior + +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. + +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved + +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes +- Run `openspec validate --strict` to confirm the archived change passes checks + +## Before Any Task + +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities + +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding + +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` + +## Quick Start + +### CLI Commands + +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec diff [change] # Show spec differences +openspec validate [item] # Validate changes or specs +openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) + +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files + +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode + +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` + +### Command Flags + +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) + +## Directory Structure + +``` +openspec/ +├── project.md # Project conventions +├── specs/ # Current truth - what IS built +│ └── [capability]/ # Single focused capability +│ ├── spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +├── changes/ # Proposals - what SHOULD change +│ ├── [change-name]/ +│ │ ├── proposal.md # Why, what, impact +│ │ ├── tasks.md # Implementation checklist +│ │ ├── design.md # Technical decisions (optional; see criteria) +│ │ └── specs/ # Delta changes +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # Completed changes +``` + +## Creating Change Proposals + +### Decision Tree + +``` +New request? +├─ Bug fix restoring spec behavior? → Fix directly +├─ Typo/format/comment? → Fix directly +├─ New feature/capability? → Create proposal +├─ Breaking change? → Create proposal +├─ Architecture change? → Create proposal +└─ Unclear? → Create proposal (safer) +``` + +### Proposal Structure + +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) + +2. **Write proposal.md:** +```markdown +## Why +[1-2 sentences on problem/opportunity] + +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] + +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` + +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... + +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result + +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] + +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. + +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` + +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding + +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] + +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] + +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] + +## Risks / Trade-offs +- [Risk] → Mitigation + +## Migration Plan +[Steps, rollback] + +## Open Questions +- [...] +``` + +## Spec File Format + +### Critical: Scenario Formatting + +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` + +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` + +Every requirement MUST have at least one scenario. + +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) + +### Delta Operations + +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes + +Headers matched with `trim(header)` - whitespace ignored. + +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. + +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. + +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` + +## Troubleshooting + +### Common Errors + +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) + +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers + +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` + +### Validation Tips + +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict + +# Debug delta parsing +openspec show [change] --json | jq '.deltas' + +# Check specific requirement +openspec show [spec] --json -r 1 +``` + +## Happy Path Script + +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes + +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md + +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. + +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF + +# 4) Validate +openspec validate $CHANGE --strict +``` + +## Multi-Capability Example + +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` + +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` + +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` + +## Best Practices + +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns + +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction + +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs + +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" + +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. + +## Tool Selection Guide + +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | + +## Error Recovery + +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals + +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted + +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification + +## Quick Reference + +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes + +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior + +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec diff [change] # What's changing? +openspec validate --strict # Is it correct? +openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation) +``` + +Remember: Specs are truth. Changes are proposals. Keep them in sync. diff --git a/openspec/project.md b/openspec/project.md new file mode 100644 index 00000000..3da5119d --- /dev/null +++ b/openspec/project.md @@ -0,0 +1,31 @@ +# Project Context + +## Purpose +[Describe your project's purpose and goals] + +## Tech Stack +- [List your primary technologies] +- [e.g., TypeScript, React, Node.js] + +## Project Conventions + +### Code Style +[Describe your code style preferences, formatting rules, and naming conventions] + +### Architecture Patterns +[Document your architectural decisions and patterns] + +### Testing Strategy +[Explain your testing approach and requirements] + +### Git Workflow +[Describe your branching strategy and commit conventions] + +## Domain Context +[Add domain-specific knowledge that AI assistants need to understand] + +## Important Constraints +[List any technical, business, or regulatory constraints] + +## External Dependencies +[Document key external services, APIs, or systems] From 00f75fddabe1861f0ab3cfc795315c7339ba4722 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 11:30:51 -0400 Subject: [PATCH 09/23] Introspect the project to fill openspec/project.md Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- openspec/project.md | 229 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 10 deletions(-) diff --git a/openspec/project.md b/openspec/project.md index 3da5119d..64c19dc5 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -1,31 +1,240 @@ # Project Context ## Purpose -[Describe your project's purpose and goals] + +**Tilex** is an open-source "Today I Learned" (TIL) sharing platform built by Hashrocket. It catalogs and shares knowledge snippets learned day-to-day by developers. + +Key characteristics: +- Posts limited to **200 words maximum** and **50 character titles** +- Organized by channels (topics/categories like Elixir, Ruby, JavaScript, etc.) +- Originally Ruby on Rails (hr-til), reimplemented in Elixir/Phoenix +- Production: https://til.hashrocket.com ## Tech Stack -- [List your primary technologies] -- [e.g., TypeScript, React, Node.js] + +### Backend +- **Language**: Elixir 1.19.0 / Erlang OTP 28.1 +- **Web Framework**: Phoenix ~> 1.6.14 with LiveView ~> 0.18 +- **Database**: PostgreSQL with Ecto ~> 3.6 ORM +- **Authentication**: Ueberauth + Google OAuth 2.0, Guardian JWT tokens +- **Caching**: Cachex ~> 3.1 +- **Email**: Swoosh ~> 1.3 +- **Monitoring**: AppSignal Phoenix ~> 2.0 +- **HTTP Client**: Req ~> 0.5.8 + +### Frontend +- **Runtime**: Node.js 19.0.0 +- **Build Tool**: esbuild ~> 0.4 +- **Libraries**: jQuery 3.6.1, CodeMirror 5.65.9 (code editor), Autosize 5.0.1 +- **Rendering**: Phoenix HTML/LiveView (server-rendered) +- **Styling**: Custom CSS (no framework), Prism syntax highlighting + +### APIs & Integrations +- **MCP (Model Context Protocol)**: Hermes.MCP ~> 0.14.1 for AI tool integration +- **Markdown**: Earmark ~> 1.4.4 +- **HTML Processing**: Floki ~> 0.34, html_sanitize_ex ~> 1.2 + +### Development Tools +- **Linting**: Credo ~> 1.6 +- **Testing**: ExUnit, Wallaby ~> 0.30.1 (browser tests) +- **AI Dev Tools**: Tidewave ~> 0.5 ## Project Conventions ### Code Style -[Describe your code style preferences, formatting rules, and naming conventions] + +**Formatting:** +- Elixir built-in formatter (`mix format`) configured in `.formatter.exs` +- Auto-imports Ecto and Phoenix deps formatting +- Required to pass CI checks + +**Linting:** +- Credo linter in non-strict mode +- Checks `lib/`, `test/`, and `web/` directories +- Required to pass CI checks + +**Naming Conventions:** +- Modules: PascalCase (e.g., `Tilex.Blog.Post`) +- Functions/variables: snake_case +- Private functions: `defp` +- Schema fields: atom keys in changesets + +**Organization:** +- Business logic: `lib/tilex/` organized by domain (blog/, auth/, notifications/, etc.) +- Web layer: `lib/tilex_web/` (controllers, views, templates) +- Mix tasks: `lib/mix/tasks/` +- Strict separation of concerns (MVC pattern) ### Architecture Patterns -[Document your architectural decisions and patterns] + +**Application Structure:** +- OTP application with supervision tree +- GenServer-based services: Notifications, RateLimiter, MCP Server +- Phoenix Endpoint with custom Plug middleware pipeline + +**Database Patterns:** +- Schema-first ORM with Ecto +- Changesets for validation and transformations +- Composable queries using `Ecto.Query` +- Foreign keys and unique constraints at DB + code levels +- UTC datetime format (`:utc_datetime`) + +**Web Patterns:** +- REST-like JSON API endpoints (`/api/recent_posts.json`, `/api/developer_posts.json`) +- Separate view modules for rendering (HTML, JSON, RSS, sitemap) +- WebSocket support for real-time features (text conversion preview) +- Rate limiting middleware to prevent abuse + +**Core Services:** +- **Cachex**: In-memory caching for frequently accessed data +- **PubSub**: Real-time communication via Phoenix.PubSub +- **Telemetry**: Metrics collection and monitoring +- **Notifications**: Event-driven system with pluggable notifiers (Slack, Twitter, Webhooks) ### Testing Strategy -[Explain your testing approach and requirements] + +**Unit Tests:** +- Test models/schemas directly (e.g., `test/tilex/blog/post_test.exs`) +- Validates business logic, changesets, and constraints + +**Integration Tests:** +- Wallaby browser tests in `test/features/` +- Cover user workflows: homepage visits, post viewing/creation, profile editing, RSS feeds +- Example: `test/features/visitor_visits_homepage_test.exs` + +**CI Pipeline:** +- GitHub Actions with PostgreSQL service +- Checks: `mix format --check-formatted`, `mix credo`, `mix test` +- Must pass before merging + +**Test Support:** +- Test doubles and helpers in `lib/test/` +- Factory-style fixtures via Ecto ### Git Workflow -[Describe your branching strategy and commit conventions] + +**Branching:** +- Main branch: `master` +- Feature branches: descriptive names (e.g., `mcp-server`, `my-new-feature`) +- PR-based workflow for all contributions + +**Commit Conventions:** +- Prefix style: `feat:`, `fix:`, `refactor:`, `chore:`, `ci:`, `docs:` +- Clear, descriptive messages +- Examples: + - `feat: use Req basic auth in webhook notifier` + - `refactor: use developer username on webhook` + - `chore: bump ci elixir and otp versions` + +**PR Requirements:** +1. Fork and create feature branch +2. Make changes with accompanying tests +3. Run `mix test` (all tests must pass) +4. Run `mix format` (code must be formatted) +5. Run `mix credo` (no linting errors) +6. Stage changes with `git add --patch` +7. Clear commit message +8. Push and create PR + +**Database Migrations:** +- Must be reversible (checked with `mix ecto.twiki`) +- Timestamped filenames in `priv/repo/migrations/` + +**Deployment:** +- Custom mix task: `mix deploy ` +- Post-deploy: `mix ecto.migrate && mix run priv/repo/seeds.exs` +- Heroku-based with Elixir + Phoenix static buildpacks ## Domain Context -[Add domain-specific knowledge that AI assistants need to understand] + +**Core Entities:** + +1. **Post** (TIL post) + - Fields: `title` (max 50 chars), `body` (max 200 words), `slug`, `likes`, `max_likes`, `tweeted_at` + - Relationships: `belongs_to :channel`, `belongs_to :developer` + - Slug auto-generated from random bytes for uniqueness + - Markdown body with HTML sanitization via `Tilex.Blog.PostScrubber` + +2. **Channel** (Topic category) + - Fields: `name`, `twitter_hashtag` + - Relationships: `has_many :posts` + - URL-friendly names (e.g., elixir, ruby, javascript) + +3. **Developer** (User/Author) + - Fields: `email`, `username`, `twitter_handle`, `admin`, `editor` + - Relationships: `has_many :posts` + - OAuth-integrated: creates on first Google login, retrieves on subsequent + - Admin/editor flags for permissions + +**Key Domain Logic:** + +- **Post Liking**: Users can like posts; `max_likes` tracks peak popularity +- **Rate Limiting**: Custom rate limiter prevents abuse (configurable via env vars) +- **Markdown Rendering**: Posts use Earmark with HTML sanitization +- **Slug Generation**: Format `{slug}-{slugified-title}` for shareable URLs +- **Page Views**: Tracked and reported for statistics +- **Search**: Basic search with Floki HTML parsing +- **Notifications**: Post creation triggers Slack, Twitter, and Webhook notifications +- **MCP Tools**: AI assistants can list channels and create posts via MCP server ## Important Constraints -[List any technical, business, or regulatory constraints] + +**Technical:** +- Posts: 200 word limit (validated at model level) +- Titles: 50 character limit +- Database foreign key constraints on `channel_id` and `developer_id` +- Slug uniqueness via random generation +- Rate limiting enforced via custom middleware + +**Business:** +- Google OAuth required for authentication +- Optional domain restrictions via `HOSTED_DOMAIN` env var +- Guest author allowlist for external contributors +- Admin/editor permissions for moderation + +**Deployment:** +- Heroku platform constraints +- Environment variables required for all secrets +- PostgreSQL addon required +- AppSignal addon for monitoring ## External Dependencies -[Document key external services, APIs, or systems] + +1. **Google OAuth 2.0** (ueberauth_google) + - User authentication with Google accounts + - Domain restrictions via `HOSTED_DOMAIN` + - Guest author allowlist: `GUEST_AUTHOR_ALLOWLIST` + +2. **Twitter API** (custom via Req) + - Post TILs to Twitter with hashtags + - Config: `TWITTER_CONSUMER_KEY`, `TWITTER_CONSUMER_SECRET`, `TWITTER_ACCESS_TOKEN`, `TWITTER_ACCESS_TOKEN_SECRET` + +3. **Slack Webhooks** + - Notify Slack channel on new posts + - Config: `SLACK_POST_ENDPOINT` + +4. **Imgur API** + - Image upload for post content + - Config: `IMGUR_CLIENT_ID` + +5. **PostgreSQL** + - Heroku Postgres addon + - Connection pooling via Ecto + +6. **Heroku Platform** + - Hosting and deployment + - Buildpacks: Elixir + Phoenix static + - Procfile for process management + +7. **Model Context Protocol (MCP)** (Hermes.MCP) + - AI assistant integration (Claude, etc.) + - Tools: `list_channels`, `new_post` + - Auth: `X-API-Key` header + +8. **Generic Webhooks** + - POST requests on post creation + - Config: `WEBHOOK_URL`, `WEBHOOK_BASIC_AUTH` + +9. **AppSignal** + - APM and error tracking + - Ecto query monitoring From 47f5a5ac8eda8fd8ff8f681b13a4e307707aa329 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 11:35:03 -0400 Subject: [PATCH 10/23] Setup usage_rules for community driven AGENTS content Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- AGENTS.md | 145 +++++++++++++++++++++++++++++++++++++++++++++++++++++- Makefile | 1 + mix.exs | 1 + mix.lock | 8 +++ 4 files changed, 154 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 46810e6a..d224a5bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,4 +14,147 @@ Use `@/openspec/AGENTS.md` to learn: - Project structure and guidelines Keep this managed block so 'openspec update' can refresh the instructions. - \ No newline at end of file + + + +# Usage Rules + +**IMPORTANT**: Consult these usage rules early and often when working with the packages listed below. +Before attempting to use any of these packages or to discover if you should use them, review their +usage rules to understand the correct patterns, conventions, and best practices. + + + +## usage_rules usage +_A dev tool for Elixir projects to gather LLM usage rules from dependencies_ + +## Using Usage Rules + +Many packages have usage rules, which you should *thoroughly* consult before taking any +action. These usage rules contain guidelines and rules *directly from the package authors*. +They are your best source of knowledge for making decisions. + +## Modules & functions in the current app and dependencies + +When looking for docs for modules & functions that are dependencies of the current project, +or for Elixir itself, use `mix usage_rules.docs` + +``` +# Search a whole module +mix usage_rules.docs Enum + +# Search a specific function +mix usage_rules.docs Enum.zip + +# Search a specific function & arity +mix usage_rules.docs Enum.zip/1 +``` + + +## Searching Documentation + +You should also consult the documentation of any tools you are using, early and often. The best +way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have +found what you are looking for, use the links in the search results to get more detail. For example: + +``` +# Search docs for all packages in the current application, including Elixir +mix usage_rules.search_docs Enum.zip + +# Search docs for specific packages +mix usage_rules.search_docs Req.get -p req + +# Search docs for multi-word queries +mix usage_rules.search_docs "making requests" -p req + +# Search only in titles (useful for finding specific functions/modules) +mix usage_rules.search_docs "Enum.zip" --query-by title +``` + + + + +## usage_rules:elixir usage +# Elixir Core Usage Rules + +## Pattern Matching +- Use pattern matching over conditional logic when possible +- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies +- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps + +## Error Handling +- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail +- Avoid raising exceptions for control flow +- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}` + +## Common Mistakes to Avoid +- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned. +- Don't use `Enum` functions on large collections when `Stream` is more appropriate +- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions +- Don't use `String.to_atom/1` on user input (memory leak risk) +- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions +- Prefer `Enum` functions like `Enum.reduce` over recursion +- When recursion is necessary, prefer to use pattern matching in function heads for base case detection +- Using the process dictionary is typically a sign of unidiomatic code +- Only use macros if explicitly requested +- There are many useful standard library functions, prefer to use them where possible + +## Function Design +- Use guard clauses: `when is_binary(name) and byte_size(name) > 0` +- Prefer multiple function clauses over complex conditional logic +- Name functions descriptively: `calculate_total_price/2` not `calc/2` +- Predicate function names should not start with `is` and should end in a question mark. +- Names like `is_thing` should be reserved for guards + +## Data Structures +- Use structs over maps when the shape is known: `defstruct [:name, :age]` +- Prefer keyword lists for options: `[timeout: 5000, retries: 3]` +- Use maps for dynamic key-value data +- Prefer to prepend to lists `[new | list]` not `list ++ [new]` + +## Mix Tasks + +- Use `mix help` to list available mix tasks +- Use `mix help task_name` to get docs for an individual task +- Read the docs and options fully before using tasks + +## Testing +- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123` +- Limit the number of failed tests with `mix test --max-failures n` +- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests +- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end` +- Use `mix help test` to for full documentation on running tests + +## Debugging + +- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console. + + + +## usage_rules:otp usage +# OTP Usage Rules + +## GenServer Best Practices +- Keep state simple and serializable +- Handle all expected messages explicitly +- Use `handle_continue/2` for post-init work +- Implement proper cleanup in `terminate/2` when necessary + +## Process Communication +- Use `GenServer.call/3` for synchronous requests expecting replies +- Use `GenServer.cast/2` for fire-and-forget messages. +- When in doubt, use `call` over `cast`, to ensure back-pressure +- Set appropriate timeouts for `call/3` operations + +## Fault Tolerance +- Set up processes such that they can handle crashing and being restarted by supervisors +- Use `:max_restarts` and `:max_seconds` to prevent restart loops + +## Task and Async +- Use `Task.Supervisor` for better fault tolerance +- Handle task failures with `Task.yield/2` or `Task.shutdown/2` +- Set appropriate task timeouts +- Use `Task.async_stream/3` for concurrent enumeration with back-pressure + + + diff --git a/Makefile b/Makefile index 098017c0..1f11da23 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ setup: ## Setup the App. mix local.hex --force mix setup mix gettext.extract --merge --no-fuzzy + mix usage_rules.sync AGENTS.md --all --inline usage_rules:all --link-to-folder deps server: ## Start the App server. npm install --prefix assets/ diff --git a/mix.exs b/mix.exs index bcecad88..0dc1ecce 100644 --- a/mix.exs +++ b/mix.exs @@ -33,6 +33,7 @@ defmodule Tilex.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ + {:usage_rules, "~> 0.1", only: [:dev]}, {:appsignal_phoenix, "~> 2.0"}, {:cachex, "~> 3.1"}, {:cors_plug, "~> 3.0"}, diff --git a/mix.lock b/mix.lock index 79be90c3..7ad7d279 100644 --- a/mix.lock +++ b/mix.lock @@ -26,6 +26,7 @@ "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, + "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "guardian": {:hex, :guardian, "2.4.0", "efbbb397ecca881bb548560169922fc4433a05bc98c2eb96a7ed88ede9e17d64", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "5c80103a9c538fbc2505bf08421a82e8f815deba9eaedb6e734c66443154c518"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, "hermes_mcp": {:hex, :hermes_mcp, "0.14.1", "cfe4321c21c6a5fe01e4e27d6f45037573ce9f02a296fa1112ff7b3d0ee396d9", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "~> 0.4", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "86ec898911d633d8e4a8ad94a79123beab95ba86baa1b6fe6197ab1ccf76f9a9"}, @@ -33,6 +34,7 @@ "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, @@ -46,6 +48,7 @@ "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"}, "oauther": {:git, "https://github.com/tobstarr/oauther.git", "e81fc6588e52eeaf41cdf51ed9d968da46b0b67d", [branch: "master"]}, "optimus": {:hex, :optimus, "0.3.0", "72754d1a06bab7b4b7f59b05622e442e5ab7909b9db5d8b01dc3d2792559fa9e", [:mix], [], "hexpm", "d0d026fdf068e461e1173c463ea2da15e109942135aa4e2490dd2dc9ef05946c"}, + "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"}, "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, @@ -63,13 +66,17 @@ "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, + "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, "sleeplocks": {:hex, :sleeplocks, "1.1.3", "96a86460cc33b435c7310dbd27ec82ca2c1f24ae38e34f8edde97f756503441a", [:rebar3], [], "hexpm", "d3b3958552e6eb16f463921e70ae7c767519ef8f5be46d7696cc1ed649421321"}, + "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, + "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "swoosh": {:hex, :swoosh, "1.17.1", "01295a82bddd2c6cac1e65856e29444d7c23c4501e0ebc69cea8a82018227e25", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b20d25e580cb79af631335a1bdcfbffd835c08ebcdc16e98577223a241a18a1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.2", "2caabe9344ec17eafe5403304771c3539f3b6e2f7fb6a6f602558c825d0d0bfb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b43db0dc33863930b9ef9d27137e78974756f5f198cae18409970ed6fa5b561"}, "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, "tesla": {:hex, :tesla, "1.12.1", "fe2bf4250868ee72e5d8b8dfa408d13a00747c41b7237b6aa3b9a24057346681", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "2391efc6243d37ead43afd0327b520314c7b38232091d4a440c1212626fdd6e7"}, + "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, "tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"}, "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, "tzdata": {:hex, :tzdata, "1.1.2", "45e5f1fcf8729525ec27c65e163be5b3d247ab1702581a94674e008413eef50b", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "cec7b286e608371602318c414f344941d5eb0375e14cfdab605cca2fe66cba8b"}, @@ -77,6 +84,7 @@ "ueberauth_google": {:hex, :ueberauth_google, "0.12.1", "90cf49743588193334f7a00da252f92d90bfd178d766c0e4291361681fafec7d", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.10.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "7f7deacd679b2b66e3bffb68ecc77aa1b5396a0cbac2941815f253128e458c38"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "unsafe": {:hex, :unsafe, "1.0.2", "23c6be12f6c1605364801f4b47007c0c159497d0446ad378b5cf05f1855c0581", [:mix], [], "hexpm", "b485231683c3ab01a9cd44cb4a79f152c6f3bb87358439c6f68791b85c2df675"}, + "usage_rules": {:hex, :usage_rules, "0.1.25", "bad5b2cbd45da053423051a752f35ae5249e33ec90c83d0f1ac1be3d90ad9bde", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "406598fa133a424d0a8b2d21eb86b8c6e345626d822d7247cdb408a5c3dbe66a"}, "wallaby": {:hex, :wallaby, "0.30.9", "51d60682092c3c428c63b656b818e2258202b9f9a31ec37230659647ae20325b", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:httpoison, "~> 0.12 or ~> 1.0 or ~> 2.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_ecto, ">= 3.0.0", [hex: :phoenix_ecto, repo: "hexpm", optional: true]}, {:web_driver_client, "~> 0.2.0", [hex: :web_driver_client, repo: "hexpm", optional: false]}], "hexpm", "62e3ccb89068b231b50ed046219022020516d44f443eebef93a19db4be95b808"}, "web_driver_client": {:hex, :web_driver_client, "0.2.0", "63b76cd9eb3b0716ec5467a0f8bead73d3d9612e63f7560d21357f03ad86e31a", [:mix], [{:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:tesla, "~> 1.3", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "83cc6092bc3e74926d1c8455f0ce927d5d1d36707b74d9a65e38c084aab0350f"}, } From 657bc3b57efc37301c3f104d4cc80c9358fc711b Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 11:12:12 -0400 Subject: [PATCH 11/23] Create til post in the db from the mcp server Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- config/test.exs | 2 +- lib/tilex/blog/post.ex | 2 +- lib/tilex/mcp/list_channels.ex | 2 +- lib/tilex/mcp/new_post.ex | 67 ++++++++++-- test/tilex/mcp/list_channels_test.exs | 50 +++++++++ test/tilex/mcp/new_post_test.exs | 147 ++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 14 deletions(-) create mode 100644 test/tilex/mcp/list_channels_test.exs create mode 100644 test/tilex/mcp/new_post_test.exs diff --git a/config/test.exs b/config/test.exs index 653cc207..77948faf 100644 --- a/config/test.exs +++ b/config/test.exs @@ -25,7 +25,7 @@ config :tilex, TilexWeb.Endpoint, config :tilex, Tilex.Mailer, adapter: Swoosh.Adapters.Test # Print only warnings and errors during test -config :logger, level: :warn +config :logger, level: :warning # Initialize plugs at runtime for faster test compilation config :phoenix, :plug_init_mode, :runtime diff --git a/lib/tilex/blog/post.ex b/lib/tilex/blog/post.ex index 66a85bd0..f98982b6 100644 --- a/lib/tilex/blog/post.ex +++ b/lib/tilex/blog/post.ex @@ -65,7 +65,7 @@ defmodule Tilex.Blog.Post do def changeset(post, params \\ %{}) do post |> cast(params, @permitted_params) - |> add_slug + |> add_slug() |> validate_required(@required_params) |> validate_length(:title, max: title_max_chars()) |> validate_length_of_body diff --git a/lib/tilex/mcp/list_channels.ex b/lib/tilex/mcp/list_channels.ex index 30246d26..bb4e6277 100644 --- a/lib/tilex/mcp/list_channels.ex +++ b/lib/tilex/mcp/list_channels.ex @@ -16,7 +16,7 @@ defmodule Tilex.MCP.ListChannels do schema do end - def execute(input, frame) do + def execute(_input, frame) do channels = list_channels() resp = Response.tool() |> Response.json(channels) {:reply, resp, frame} diff --git a/lib/tilex/mcp/new_post.ex b/lib/tilex/mcp/new_post.ex index e4a44e88..57cbb780 100644 --- a/lib/tilex/mcp/new_post.ex +++ b/lib/tilex/mcp/new_post.ex @@ -7,9 +7,14 @@ defmodule Tilex.MCP.NewPost do use Hermes.Server.Component, type: :tool + import Ecto.Query, only: [from: 2] + + alias Ecto.Changeset alias Hermes.Server.Response + alias Tilex.Blog.Channel alias Tilex.Blog.Developer alias Tilex.Blog.Post + alias Tilex.Repo alias TilexWeb.Endpoint alias TilexWeb.Router.Helpers, as: Routes @@ -20,19 +25,26 @@ defmodule Tilex.MCP.NewPost do field :body, :string, required: true, - description: - "Max #{Post.body_max_words()} words in a Markdown format." + description: "Max #{Post.body_max_words()} words in a Markdown format." + + field :channel, :string, + required: true, + description: "Channel is given by the list_channels MCP tool from this same server." end def execute(input, frame) do - current_user = frame.assigns.current_user resp = Response.tool() resp = - case create_til_post(current_user, input) do - {:ok, %Post{} = post} -> - url = Routes.post_url(Endpoint, :show, post) - Response.resource_link(resp, url, "til-post", description: "Link to the TIL post preview") + with {:ok, current_user} <- get_current_user(frame), + {:ok, channel} <- get_channel(input), + {:ok, %Post{} = post} <- create_til_post(current_user, channel, input) do + url = Routes.post_url(Endpoint, :show, post) + + Response.resource_link(resp, url, "til-post", description: "Link to the TIL post preview") + else + {:error, %Changeset{} = cs} -> + Response.error(resp, changeset_errors(cs)) {:error, reason} -> Response.error(resp, "ERROR => #{reason}") @@ -41,11 +53,44 @@ defmodule Tilex.MCP.NewPost do {:reply, resp, frame} end - defp create_til_post(nil, _input) do - {:error, "User is not authenticated to create TILs"} + defp get_current_user(frame) do + case Map.get(frame.assigns, :current_user) do + nil -> {:error, "User is not authenticated to create TILs"} + %Developer{} = user -> {:ok, user} + end + end + + defp get_channel(%{channel: channel}) do + query = from(c in Channel, where: c.name == ^channel) + + case Repo.one(query) do + nil -> {:error, "Channel does not exist: #{channel}"} + %Channel{} = channel -> {:ok, channel} + end + end + + defp create_til_post(%Developer{} = current_user, channel, %{title: title, body: body}) do + attrs = %{ + developer_id: current_user.id, + title: title, + body: body, + channel_id: channel.id + } + + %Post{} + |> Post.changeset(attrs) + |> Repo.insert() end - defp create_til_post(%Developer{} = current_user, %{title: title, body: body}) do - {:ok, %Post{title: title, body: body, slug: "foo-bar"}} + defp changeset_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + |> Enum.flat_map(fn {field, errors} -> + Enum.map(errors, &"#{Phoenix.Naming.humanize(field)} #{&1}") + end) + |> Enum.join("\n") end end diff --git a/test/tilex/mcp/list_channels_test.exs b/test/tilex/mcp/list_channels_test.exs new file mode 100644 index 00000000..155c5212 --- /dev/null +++ b/test/tilex/mcp/list_channels_test.exs @@ -0,0 +1,50 @@ +defmodule Tilex.MCP.ListChannelsTest do + use Tilex.DataCase, async: false + + alias Hermes.Server.Response + alias Tilex.Factory + alias Tilex.MCP.ListChannels + + @frame %{assigns: %{}} + @input %{} + + describe "execute/2" do + test "returns list of channel names when channels exist" do + Factory.insert!(:channel, name: "elixir") + Factory.insert!(:channel, name: "ruby") + Factory.insert!(:channel, name: "javascript") + + assert {:reply, response, returned_frame} = ListChannels.execute(@input, @frame) + + assert returned_frame == @frame + + assert %Response{ + type: :tool, + isError: false, + content: [content] + } = response + + assert %{ + "text" => "[\"elixir\",\"ruby\",\"javascript\"]", + "type" => "text" + } == content + end + + test "returns empty list when no channels exist" do + assert {:reply, response, returned_frame} = ListChannels.execute(@input, @frame) + + assert returned_frame == @frame + + assert %Response{ + type: :tool, + isError: false, + content: [content] + } = response + + assert %{ + "text" => "[]", + "type" => "text" + } == content + end + end +end diff --git a/test/tilex/mcp/new_post_test.exs b/test/tilex/mcp/new_post_test.exs new file mode 100644 index 00000000..ed8ddaac --- /dev/null +++ b/test/tilex/mcp/new_post_test.exs @@ -0,0 +1,147 @@ +defmodule Tilex.MCP.NewPostTest do + use Tilex.DataCase, async: false + + alias Hermes.Server.Response + alias Tilex.Blog.Post + alias Tilex.Factory + alias Tilex.MCP.NewPost + alias Tilex.Repo + + describe "execute/2" do + test "creates post successfully with valid data and authenticated user" do + developer = Factory.insert!(:developer) + channel = Factory.insert!(:channel, name: "elixir") + + title = "My First TIL" + body = "Today I learned something amazing about Elixir." + channel_id = channel.id + developer_id = developer.id + + input = %{ + channel: channel.name, + title: title, + body: body + } + + frame = %{assigns: %{current_user: developer}} + + assert {:reply, response, returned_frame} = NewPost.execute(input, frame) + + assert returned_frame == frame + + assert %Response{ + type: :tool, + isError: false, + content: [ + %{ + "description" => "Link to the TIL post preview", + "name" => "til-post", + "type" => "resource_link", + "uri" => "http" <> _ + } + ] + } = response + + assert [post] = Repo.all(Post) + + assert %Post{ + channel_id: ^channel_id, + title: ^title, + body: ^body, + developer_id: ^developer_id + } = post + end + + test "returns error when user is not authenticated" do + channel = Factory.insert!(:channel, name: "elixir") + + title = "My First TIL" + body = "Today I learned something amazing about Elixir." + + input = %{ + channel: channel.name, + title: title, + body: body + } + + frame = %{assigns: %{}} + + assert {:reply, response, returned_frame} = NewPost.execute(input, frame) + + assert returned_frame == frame + + assert %Response{ + type: :tool, + isError: true, + content: [ + %{ + "text" => "ERROR => User is not authenticated to create TILs", + "type" => "text" + } + ] + } = response + end + + test "raises error when channel does not exist" do + developer = Factory.insert!(:developer) + + title = "My First TIL" + body = "Today I learned something amazing about Elixir." + + input = %{ + channel: "missing-channel", + title: title, + body: body + } + + frame = %{assigns: %{current_user: developer}} + + assert {:reply, response, returned_frame} = NewPost.execute(input, frame) + + assert returned_frame == frame + + assert %Response{ + type: :tool, + isError: true, + content: [ + %{ + "text" => "ERROR => Channel does not exist: missing-channel", + "type" => "text" + } + ] + } = response + end + + test "returns validation error" do + developer = Factory.insert!(:developer) + channel = Factory.insert!(:channel, name: "elixir") + + title = String.duplicate("a", 51) + body = String.duplicate("word ", 201) + + input = %{ + channel: channel.name, + title: title, + body: body + } + + frame = %{assigns: %{current_user: developer}} + + assert {:reply, response, returned_frame} = NewPost.execute(input, frame) + + assert returned_frame == frame + + assert %Response{ + type: :tool, + isError: true, + content: [ + %{ + "text" => + "Title should be at most 50 character(s)\nBody should be at most 200 word(s)", + "type" => "text" + } + ] + } = response + end + end +end From 906e985dbffcf02edc052fcd249c4c929bf897c7 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 14:55:33 -0400 Subject: [PATCH 12/23] Create new unique mcp_api_key_hash in Developer Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/blog/developer.ex | 1 + .../20251028182138_add_api_key_hash_to_developers.exs | 11 +++++++++++ priv/repo/structure.sql | 11 ++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 priv/repo/migrations/20251028182138_add_api_key_hash_to_developers.exs diff --git a/lib/tilex/blog/developer.ex b/lib/tilex/blog/developer.ex index 9444e50f..5d92e4dc 100644 --- a/lib/tilex/blog/developer.ex +++ b/lib/tilex/blog/developer.ex @@ -13,6 +13,7 @@ defmodule Tilex.Blog.Developer do field(:twitter_handle, :string) field(:admin, :boolean) field(:editor, :string) + field(:mcp_api_key, :string) has_many(:posts, Post) diff --git a/priv/repo/migrations/20251028182138_add_api_key_hash_to_developers.exs b/priv/repo/migrations/20251028182138_add_api_key_hash_to_developers.exs new file mode 100644 index 00000000..76d6ca6c --- /dev/null +++ b/priv/repo/migrations/20251028182138_add_api_key_hash_to_developers.exs @@ -0,0 +1,11 @@ +defmodule Tilex.Repo.Migrations.AddApiKeyHashToDevelopers do + use Ecto.Migration + + def change do + alter table(:developers) do + add :mcp_api_key, :string + end + + create unique_index(:developers, [:mcp_api_key]) + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 22fa40ce..2a7d8aa2 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -64,7 +64,8 @@ CREATE TABLE public.developers ( updated_at timestamp(0) without time zone NOT NULL, twitter_handle character varying(255), admin boolean DEFAULT false, - editor character varying(255) DEFAULT 'Text Field'::character varying + editor character varying(255) DEFAULT 'Text Field'::character varying, + mcp_api_key character varying(255) ); @@ -208,6 +209,13 @@ ALTER TABLE ONLY public.schema_migrations CREATE UNIQUE INDEX channels_name_index ON public.channels USING btree (name); +-- +-- Name: developers_mcp_api_key_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX developers_mcp_api_key_index ON public.developers USING btree (mcp_api_key); + + -- -- Name: index_requests_on_request_time_in_app_tz; Type: INDEX; Schema: public; Owner: - -- @@ -295,3 +303,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20190827182708); INSERT INTO public."schema_migrations" (version) VALUES (20200518184142); INSERT INTO public."schema_migrations" (version) VALUES (20220425135720); INSERT INTO public."schema_migrations" (version) VALUES (20220429184256); +INSERT INTO public."schema_migrations" (version) VALUES (20251028182138); From 47e53ac70c2ffc18b1fd55f72b6cb3fb7a82f3b8 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Tue, 28 Oct 2025 16:06:57 -0400 Subject: [PATCH 13/23] Allow developer to generate a new MCP API Key Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/blog/developer.ex | 23 ++++++++ lib/tilex/mcp/server.ex | 13 +++-- .../controllers/developer_controller.ex | 31 ++++++++++ lib/tilex_web/router.ex | 1 + .../templates/developer/edit.html.eex | 50 ++++++++++++++++ test/tilex/mcp/server_test.exs | 58 +++++++++++++++++++ 6 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/tilex/mcp/server_test.exs diff --git a/lib/tilex/blog/developer.ex b/lib/tilex/blog/developer.ex index 5d92e4dc..52ea205b 100644 --- a/lib/tilex/blog/developer.ex +++ b/lib/tilex/blog/developer.ex @@ -7,6 +7,9 @@ defmodule Tilex.Blog.Developer do alias Tilex.Blog.Developer alias Tilex.Blog.Post + @mcp_api_key_name "mcp-api-key" + @one_year :timer.hours(365 * 24) + schema "developers" do field(:email, :string) field(:username, :string) @@ -51,6 +54,26 @@ defmodule Tilex.Blog.Developer do |> String.replace(" ", "") end + def generate_mcp_api_key(endpoint) do + mcp_api_key = Ecto.UUID.generate() + + %{ + mcp_api_key: mcp_api_key, + signed_token: Phoenix.Token.sign(endpoint, @mcp_api_key_name, mcp_api_key) + } + end + + def verify_mcp_api_key(endpoint, signed_token) do + Phoenix.Token.verify(endpoint, @mcp_api_key_name, signed_token, max_age: @one_year) + end + + def mcp_api_key_changeset(developer, mcp_api_key) do + developer + |> cast(%{}, []) + |> put_change(:mcp_api_key, mcp_api_key) + |> validate_required([:mcp_api_key]) + end + defp clean_twitter_handle(changeset) do twitter_handle = get_change(changeset, :twitter_handle) diff --git a/lib/tilex/mcp/server.ex b/lib/tilex/mcp/server.ex index ec657116..fa78e0bc 100644 --- a/lib/tilex/mcp/server.ex +++ b/lib/tilex/mcp/server.ex @@ -12,14 +12,17 @@ defmodule Tilex.MCP.Server do def init(_arg, frame) do headers = Enum.into(frame.transport.req_headers, %{}) user = get_current_user(headers["x-api-key"]) - assigns = Map.put(frame.assigns, :current_user, user) + assigns = Map.put(frame.assigns || %{}, :current_user, user) frame = Map.put(frame, :assigns, assigns) {:ok, frame} end - defp get_current_user("" <> _ = api_key) do - Repo.one(from d in Developer, where: d.id == ^api_key) + defp get_current_user(signed_token) do + with "" <> _ <- signed_token, + {:ok, mcp_api_key} <- Developer.verify_mcp_api_key(TilexWeb.Endpoint, signed_token) do + Repo.one(from d in Developer, where: d.mcp_api_key == ^mcp_api_key) + else + _ -> nil + end end - - defp get_current_user(_api_key), do: nil end diff --git a/lib/tilex_web/controllers/developer_controller.ex b/lib/tilex_web/controllers/developer_controller.ex index aa0a1e52..eec5ff36 100644 --- a/lib/tilex_web/controllers/developer_controller.ex +++ b/lib/tilex_web/controllers/developer_controller.ex @@ -43,4 +43,35 @@ defmodule TilexWeb.DeveloperController do render(conn, "edit.html", developer: developer, changeset: changeset) end end + + def generate_api_key(conn, _params) do + developer = Auth.Guardian.Plug.current_resource(conn) + + %{ + mcp_api_key: mcp_api_key, + signed_token: signed_token + } = Developer.generate_mcp_api_key(TilexWeb.Endpoint) + + developer + |> Developer.mcp_api_key_changeset(mcp_api_key) + |> Repo.update() + |> case do + {:ok, %Developer{} = developer} -> + conn + |> put_flash( + :info, + "API key generated successfully. Save it securely - you won't be able to see it again!" + ) + |> render("edit.html", + developer: developer, + changeset: Developer.changeset(developer), + mcp_signed_token: signed_token + ) + + {:error, _changeset} -> + conn + |> put_flash(:error, "Failed to generate API key. Please try again.") + |> redirect(to: Routes.developer_path(conn, :edit)) + end + end end diff --git a/lib/tilex_web/router.ex b/lib/tilex_web/router.ex index aa86892d..29a29aa4 100644 --- a/lib/tilex_web/router.ex +++ b/lib/tilex_web/router.ex @@ -76,6 +76,7 @@ defmodule TilexWeb.Router do get "/authors/:name", DeveloperController, :show get "/profile/edit", DeveloperController, :edit put "/profile/edit", DeveloperController, :update + post "/profile/api_key/generate", DeveloperController, :generate_api_key get "/", PostController, :index resources "/posts", PostController, param: "titled_slug" diff --git a/lib/tilex_web/templates/developer/edit.html.eex b/lib/tilex_web/templates/developer/edit.html.eex index fd3d2cc4..c5d84797 100644 --- a/lib/tilex_web/templates/developer/edit.html.eex +++ b/lib/tilex_web/templates/developer/edit.html.eex @@ -30,5 +30,55 @@ <%= submit "Submit" %> <%= link("cancel", to: Routes.post_path(@conn, :index)) %> <% end %> + + <%= form_for @conn, Routes.developer_path(@conn, :generate_api_key), [method: :post], fn _f -> %> +
+ <%= if mcp_signed_token = assigns[:mcp_signed_token] do %> +
+ +
+
+
<%= mcp_signed_token %>
+
+
+ +
+
+
claude mcp add --scope user --transport http \
+  til <%= TilexWeb.Endpoint.url()<> "/mcp" %> \
+  --header "X-API-KEY: <%= mcp_signed_token %>";
+
+
+ +
+
+
{
+  "mcpServers": {
+    "til": {
+      "url": "<%= TilexWeb.Endpoint.url()<> "/mcp" %>",
+      "headers": {
+        "X-API-KEY": "<%= mcp_signed_token %>"
+      }
+    }
+  }
+}
+
+ <% else %> + <%= if !!@developer.mcp_api_key do %> +
+ You've already generated a MCP API KEY, but for security reasons we can't retrieve it. +
+
+ You must regenerate a new one and setup the MCP server again. +
+ <% else %> +
+ You've never generated a MCP API KEY yet +
+ <% end %> + <% end %> +
+ <%= submit "Generate new MCP API Key" %> + <% end %>
diff --git a/test/tilex/mcp/server_test.exs b/test/tilex/mcp/server_test.exs new file mode 100644 index 00000000..51fa6452 --- /dev/null +++ b/test/tilex/mcp/server_test.exs @@ -0,0 +1,58 @@ +defmodule Tilex.MCP.ServerTest do + use Tilex.DataCase, async: true + + alias Tilex.Blog.Developer + alias Tilex.Factory + alias Tilex.MCP.Server + + describe "MCP server authentication" do + test "valid API key authenticates developer" do + %{ + mcp_api_key: mcp_api_key, + signed_token: signed_token + } = Developer.generate_mcp_api_key(TilexWeb.Endpoint) + + developer = Factory.insert!(:developer, mcp_api_key: mcp_api_key) + developer_id = developer.id + + frame = %{ + transport: %{ + req_headers: [{"x-api-key", signed_token}] + }, + assigns: %{} + } + + assert {:ok, frame} = Server.init(nil, frame) + + assert %{ + current_user: %Tilex.Blog.Developer{ + id: ^developer_id + } + } = frame.assigns + end + + test "invalid API key fails authentication" do + %{ + mcp_api_key: _old_mcp_api_key, + signed_token: old_signed_token + } = Developer.generate_mcp_api_key(TilexWeb.Endpoint) + + %{ + mcp_api_key: mcp_api_key, + signed_token: _signed_token + } = Developer.generate_mcp_api_key(TilexWeb.Endpoint) + + Factory.insert!(:developer, mcp_api_key: mcp_api_key) + + frame = %{ + transport: %{ + req_headers: [{"x-api-key", old_signed_token}] + }, + assigns: %{} + } + + assert {:ok, frame} = Server.init(nil, frame) + assert %{current_user: nil} = frame.assigns + end + end +end From cf8afff8939a9b19540186b22c89ec626541b3a6 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Wed, 29 Oct 2025 11:19:13 -0400 Subject: [PATCH 14/23] Allow posts to be created as draft Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- assets/css/components.css | 14 ++- assets/css/pages.css | 6 ++ lib/tilex/blog/post.ex | 3 +- lib/tilex/posts.ex | 55 ++++++----- lib/tilex/stats.ex | 95 ++++++++++--------- .../api/developer_post_controller.ex | 2 +- .../controllers/developer_controller.ex | 5 +- lib/tilex_web/controllers/feed_controller.ex | 1 + lib/tilex_web/controllers/post_controller.ex | 38 ++++++-- .../controllers/sitemap_controller.ex | 6 +- .../templates/developer/edit.html.eex | 4 +- lib/tilex_web/templates/post/form.html.eex | 13 ++- ...51029135003_make_published_at_nullable.exs | 18 ++++ priv/repo/structure.sql | 3 +- test/features/admin_edits_post_test.exs | 8 +- test/features/developer_creates_post_test.exs | 50 +++++++++- test/features/developer_edits_post_test.exs | 2 +- .../features/developer_edits_profile_test.exs | 2 +- test/support/factory.ex | 5 +- test/support/pages/create_post_page.ex | 9 +- test/support/pages/post_form.ex | 9 +- 21 files changed, 241 insertions(+), 107 deletions(-) create mode 100644 priv/repo/migrations/20251029135003_make_published_at_nullable.exs diff --git a/assets/css/components.css b/assets/css/components.css index cf668777..0cbe34b5 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -286,7 +286,17 @@ form dl dd { form fieldset.actions { padding-top: 2rem; } -form input[type='text'], form input[type='search'], form textarea { +form select { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--light); + font-family: var(--primary-typeface); + box-sizing: border-box; + border-radius: 0; + height: 4rem; + line-height: 3rem; +} +form input[type='text'], form input[type='search'], form input[type='datetime-local'], form textarea { display: inline-block; vertical-align: middle; width: 100%; @@ -298,7 +308,7 @@ form input[type='text'], form input[type='search'], form textarea { height: 4rem; line-height: 3rem; } -form input[type='text']:focus, form input[type='search']:focus, form textarea:focus { +form input[type='text']:focus, form input[type='search']:focus, form input[type='datetime-local']:focus, form textarea:focus, form select:focus { border-color: var(--blue); outline: none; } diff --git a/assets/css/pages.css b/assets/css/pages.css index b596eb6b..aa1c756d 100644 --- a/assets/css/pages.css +++ b/assets/css/pages.css @@ -13,6 +13,12 @@ #profile_edit .post footer .negative, #post_edit .post footer .negative { color: var(--red); } +#post_edit .actions { + display: flex; + gap: 2rem; + align-items: center; + justify-content: flex-end; +} #statistics { margin-bottom: 3rem; } diff --git a/lib/tilex/blog/post.ex b/lib/tilex/blog/post.ex index f98982b6..fcdc7d47 100644 --- a/lib/tilex/blog/post.ex +++ b/lib/tilex/blog/post.ex @@ -15,7 +15,7 @@ defmodule Tilex.Blog.Post do def title_max_chars, do: @title_max_chars @required_params ~w(body channel_id developer_id title)a - @permitted_params @required_params ++ ~w(developer_id likes max_likes)a + @permitted_params @required_params ++ ~w(developer_id likes max_likes published_at)a schema "posts" do field(:title, :string) @@ -24,6 +24,7 @@ defmodule Tilex.Blog.Post do field(:likes, :integer, default: 1) field(:max_likes, :integer, default: 1) field(:tweeted_at, :utc_datetime) + field(:published_at, :utc_datetime_usec) belongs_to(:channel, Channel) belongs_to(:developer, Developer) diff --git a/lib/tilex/posts.ex b/lib/tilex/posts.ex index 89f512f4..8b1664bb 100644 --- a/lib/tilex/posts.ex +++ b/lib/tilex/posts.ex @@ -7,9 +7,13 @@ defmodule Tilex.Posts do alias Tilex.Blog.Post alias Tilex.Repo + def published(query \\ Post) do + from(p in query, where: not is_nil(p.published_at) and p.published_at <= fragment("now()")) + end + def all(page) do page - |> posts + |> posts() |> Repo.all() end @@ -18,7 +22,7 @@ defmodule Tilex.Posts do query = page - |> posts + |> posts() |> where([p], p.channel_id == ^channel.id) posts_count = @@ -28,6 +32,7 @@ defmodule Tilex.Posts do where: p.channel_id == ^channel.id, select: fragment("count(*)") ) + |> published() ) {Repo.all(query), posts_count, channel} @@ -45,41 +50,42 @@ defmodule Tilex.Posts do limit: ^posts_count, preload: [:channel, :developer] ) + |> published() post = Repo.one(query) {[post], posts_count, post.channel} end - def by_developer(username, limit: limit) do + def by_developer(username, opts \\ []) do + page = opts[:page] || 1 + limit = opts[:limit] || limit() + include_unpublished = opts[:include_unpublished] || false + developer = Repo.get_by!(Developer, username: username) + query = from( p in Post, - order_by: [desc: p.inserted_at], + join: c in assoc(p, :channel), join: d in assoc(p, :developer), + preload: [channel: c, developer: d], + where: p.developer_id == ^developer.id, + order_by: [desc: p.inserted_at], limit: ^limit, - where: d.username == ^username + offset: ^offset(page) ) - - Repo.all(query) - end - - def by_developer(username, page) do - developer = Repo.get_by!(Developer, username: username) - - query = - page - |> posts - |> where([p], p.developer_id == ^developer.id) + |> then(fn q -> + if include_unpublished do + q + else + published(q) + end + end) posts_count = - Repo.one( - from( - p in Post, - where: p.developer_id == ^developer.id, - select: fragment("count(*)") - ) - ) + query + |> Ecto.Query.exclude([:limit, :offset]) + |> Repo.aggregate(:count, :id) {Repo.all(query), posts_count, developer} end @@ -102,7 +108,7 @@ defmodule Tilex.Posts do plainto_tsquery('english', $1) ) as rank ) ranks on true - where ranks.rank > 0 + where ranks.rank > 0 and p.published_at IS NOT NULL and p.published_at <= now() order by ranks.rank desc, p.inserted_at desc """ @@ -127,6 +133,7 @@ defmodule Tilex.Posts do limit: ^limit(), offset: ^offset(page) ) + |> published() end defp offset(page) do diff --git a/lib/tilex/stats.ex b/lib/tilex/stats.ex index 06fd9126..dd955117 100644 --- a/lib/tilex/stats.ex +++ b/lib/tilex/stats.ex @@ -3,27 +3,30 @@ defmodule Tilex.Stats do import Tilex.QueryHelpers, only: [between: 3, greatest: 2, hours_since: 1] alias Ecto.Adapters.SQL - alias Tilex.Repo alias Tilex.Blog.Channel + alias Tilex.Blog.Post + alias Tilex.Repo def developer(%{start_date: start_date, end_date: end_date}) do start_time = Timex.to_datetime(start_date) end_time = end_date |> Timex.to_datetime() |> Timex.end_of_day() - posts_where = fn query -> - query - |> where([p], between(p.inserted_at, ^start_time, ^end_time)) - end + posts_query = + from(p in Post, + where: + not is_nil(p.published_at) and p.published_at <= fragment("now()") and + between(p.inserted_at, ^start_time, ^end_time) + ) [ start_date: format_date(start_date), end_date: format_date(end_date), - channels: Repo.all(posts_by_channels_count() |> posts_where.()), - developers: Repo.all(posts_by_developers_count() |> posts_where.()), - developers_count: Repo.one(developers_count() |> posts_where.()), - most_liked_posts: Repo.all(most_liked_posts() |> posts_where.()), - hottest_posts: Repo.all(hottest_posts() |> posts_where.()), - posts_count: Repo.one(posts_count() |> posts_where.()), + channels: get_posts_by_channels_count(posts_query), + developers: get_posts_by_developers_count(posts_query), + developers_count: get_developers_count(posts_query), + most_liked_posts: get_most_liked_posts(posts_query), + hottest_posts: get_hottest_posts(posts_query), + posts_count: Repo.aggregate(posts_query, :count, :id), channels_count: Repo.one(channels_count(start_time, end_time)), most_viewed_posts: Tilex.Tracking.most_viewed_posts(start_time, end_time), total_page_views: Tilex.Tracking.total_page_views(start_time, end_time) @@ -33,14 +36,19 @@ defmodule Tilex.Stats do def all do posts_for_days = query_posts_for_days!() + posts_query = + from(p in Post, + where: not is_nil(p.published_at) and p.published_at <= fragment("now()") + ) + [ - channels: Repo.all(posts_by_channels_count()), - developers: Repo.all(posts_by_developers_count()), - developers_count: Repo.one(developers_count()), - most_liked_posts: Repo.all(most_liked_posts()), - hottest_posts: Repo.all(hottest_posts()), + channels: get_posts_by_channels_count(posts_query), + developers: get_posts_by_developers_count(posts_query), + developers_count: get_developers_count(posts_query), + most_liked_posts: get_most_liked_posts(posts_query), + hottest_posts: get_hottest_posts(posts_query), posts_for_days: posts_for_days, - posts_count: Repo.one(posts_count()), + posts_count: Repo.aggregate(posts_query, :count, :id), channels_count: Repo.one(channels_count()), max_count: ([1] ++ Enum.map(posts_for_days, fn [_, count] -> count end)) @@ -62,7 +70,7 @@ defmodule Tilex.Stats do with posts as ( select date((inserted_at at time zone 'America/New_York')::timestamptz) as post_date from posts - #{where} + #{where} and published_at IS NOT NULL and published_at <= now() ) select dates_table.date, count(posts.post_date) from ( select ( @@ -85,62 +93,58 @@ defmodule Tilex.Stats do Channel |> join(:inner, [c], p in assoc(c, :posts)) |> select([c, p], fragment("count(distinct(c0.id))")) - |> where([c, p], between(p.inserted_at, ^start_time, ^end_time)) + |> where( + [c, p], + between(p.inserted_at, ^start_time, ^end_time) and not is_nil(p.published_at) and + p.published_at <= fragment("now()") + ) end defp channels_count, do: from(c in "channels", select: fragment("count(*)")) - defp developers_count, do: from(p in "posts", select: fragment("count(distinct(developer_id))")) - defp posts_count, do: from(p in "posts", select: fragment("count(*)")) - - defp posts_and_channels do - from( - p in "posts", - join: c in "channels", - on: p.channel_id == c.id - ) - end - - defp posts_and_developers do - from( - p in "posts", - join: d in "developers", - on: p.developer_id == d.id - ) + defp get_developers_count(query) do + query + |> select([p], fragment("count(distinct(developer_id))")) + |> Repo.one() end - defp posts_by_channels_count do + defp get_posts_by_channels_count(posts_query) do from( - [p, c] in posts_and_channels(), + p in posts_query, + join: c in assoc(p, :channel), group_by: c.name, order_by: [desc: count(p.id)], select: {count(p.id), c.name} ) + |> Repo.all() end - defp posts_by_developers_count do + defp get_posts_by_developers_count(posts_query) do from( - [p, d] in posts_and_developers(), + p in posts_query, + join: d in assoc(p, :developer), group_by: d.username, order_by: [desc: count(p.id)], select: {count(p.id), d.username} ) + |> Repo.all() end - defp most_liked_posts do + defp get_most_liked_posts(posts_query) do from( - [p, c] in posts_and_channels(), + p in posts_query, + join: c in assoc(p, :channel), order_by: [desc: p.likes], limit: 10, select: {p.title, p.likes, p.slug, c.name} ) + |> Repo.all() end - defp hottest_posts do + defp get_hottest_posts(posts_query) do posts_with_age_in_hours = from( - p in "posts", - where: not is_nil(p.published_at), + p in posts_query, select: %{ inserted_at: p.inserted_at, id: p.id, @@ -165,6 +169,7 @@ defmodule Tilex.Stats do }, limit: 10 ) + |> Repo.all() end defp format_date(date) do diff --git a/lib/tilex_web/controllers/api/developer_post_controller.ex b/lib/tilex_web/controllers/api/developer_post_controller.ex index 2a10567b..924486d3 100644 --- a/lib/tilex_web/controllers/api/developer_post_controller.ex +++ b/lib/tilex_web/controllers/api/developer_post_controller.ex @@ -11,7 +11,7 @@ defmodule TilexWeb.Api.DeveloperPostController do recent posts. """ def index(conn, params) do - posts = Posts.by_developer(params["username"], limit: 3) + {posts, _count, _developer} = Posts.by_developer(params["username"], limit: 3) render(conn, "index.json", posts: posts) end diff --git a/lib/tilex_web/controllers/developer_controller.ex b/lib/tilex_web/controllers/developer_controller.ex index eec5ff36..20e51677 100644 --- a/lib/tilex_web/controllers/developer_controller.ex +++ b/lib/tilex_web/controllers/developer_controller.ex @@ -7,8 +7,11 @@ defmodule TilexWeb.DeveloperController do alias Tilex.Auth def show(conn, %{"name" => username} = params) do + developer = Auth.Guardian.Plug.current_resource(conn) page = robust_page(params) - {posts, posts_count, developer} = Posts.by_developer(username, page) + + {posts, posts_count, developer} = + Posts.by_developer(username, page: page, include_unpublished: !!developer) conn |> assign(:meta_robots, "noindex") diff --git a/lib/tilex_web/controllers/feed_controller.ex b/lib/tilex_web/controllers/feed_controller.ex index 58e06f83..45b0cc7e 100644 --- a/lib/tilex_web/controllers/feed_controller.ex +++ b/lib/tilex_web/controllers/feed_controller.ex @@ -6,6 +6,7 @@ defmodule TilexWeb.FeedController do Repo.all( from( p in Tilex.Blog.Post, + where: not is_nil(p.published_at) and p.published_at <= fragment("now()"), order_by: [desc: p.inserted_at], preload: [:channel, :developer], limit: 25 diff --git a/lib/tilex_web/controllers/post_controller.ex b/lib/tilex_web/controllers/post_controller.ex index 6c31734a..2963eac2 100644 --- a/lib/tilex_web/controllers/post_controller.ex +++ b/lib/tilex_web/controllers/post_controller.ex @@ -1,13 +1,14 @@ defmodule TilexWeb.PostController do use TilexWeb, :controller + import Tilex.Pageable import Ecto.Query import TilexWeb.StructuredDataView, only: [post_ld: 2] alias Tilex.Blog.Channel - alias Tilex.Notifications - alias Tilex.Liking alias Tilex.Blog.Post + alias Tilex.Liking + alias Tilex.Notifications alias Tilex.Posts plug(:load_channels when action in [:new, :create, :edit, :update]) @@ -62,8 +63,12 @@ defmodule TilexWeb.PostController do def show(%{assigns: %{slug: slug}} = conn, _) do post = - Post - |> Repo.get_by!(slug: slug) + from(p in Post, + where: + not is_nil(p.published_at) and p.published_at <= fragment("now()") and + p.slug == ^slug + ) + |> Repo.one!(slug: slug) |> Repo.preload([:channel]) |> Repo.preload([:developer]) @@ -77,6 +82,7 @@ defmodule TilexWeb.PostController do query = from( post in Post, + where: not is_nil(post.published_at) and post.published_at <= fragment("now()"), order_by: fragment("random()"), limit: 1, preload: [:channel, :developer] @@ -116,6 +122,11 @@ defmodule TilexWeb.PostController do |> send_resp(200, Jason.encode!(%{likes: likes})) end + def create(conn, %{"post" => params, "submit" => "Save & Publish"}) do + params = Map.put(params, "published_at", DateTime.utc_now() |> DateTime.add(-10, :second)) + create(conn, %{"post" => params}) + end + def create(conn, %{"post" => params}) do developer = Guardian.Plug.current_resource(conn) @@ -132,7 +143,7 @@ defmodule TilexWeb.PostController do conn |> put_flash(:info, "Post created") - |> redirect(to: Routes.post_path(conn, :index)) + |> redirect(to: Routes.developer_path(conn, :show, developer)) {:error, changeset} -> conn @@ -165,7 +176,16 @@ defmodule TilexWeb.PostController do |> render("edit.html") end - def update(conn, %{"post" => params}) do + def update(conn, %{"post" => params, "submit" => "Save & Publish"}) do + params = Map.put(params, "published_at", DateTime.utc_now() |> DateTime.add(-10, :second)) + do_update(conn, %{"post" => params}) + end + + def update(conn, params) do + do_update(conn, params) + end + + defp do_update(conn, %{"post" => params}) do current_user = Guardian.Plug.current_resource(conn) post = @@ -185,9 +205,11 @@ defmodule TilexWeb.PostController do case Repo.update(changeset) do {:ok, post} -> + post = Repo.preload(post, [:developer]) + conn |> put_flash(:info, "Post Updated") - |> redirect(to: Routes.post_path(conn, :show, post)) + |> redirect(to: Routes.developer_path(conn, :show, post.developer)) {:error, changeset} -> render(conn, "edit.html", post: post, changeset: changeset, current_user: current_user) @@ -218,6 +240,6 @@ defmodule TilexWeb.PostController do defp extracted_slug(_), do: :error defp post_params(params) do - Map.take(params, ["body", "channel_id", "title"]) + Map.take(params, ["body", "channel_id", "title", "published_at"]) end end diff --git a/lib/tilex_web/controllers/sitemap_controller.ex b/lib/tilex_web/controllers/sitemap_controller.ex index 9ca2e967..5ce4b5a4 100644 --- a/lib/tilex_web/controllers/sitemap_controller.ex +++ b/lib/tilex_web/controllers/sitemap_controller.ex @@ -1,9 +1,13 @@ defmodule TilexWeb.SitemapController do use TilexWeb, :controller + alias Tilex.Posts + def index(conn, _) do + posts = Posts.published() |> Repo.all() + conn - |> assign(:posts, Repo.all(Tilex.Blog.Post)) + |> assign(:posts, posts) |> assign(:channels, Repo.all(Tilex.Blog.Channel)) |> put_layout(false) |> render("sitemap.xml") diff --git a/lib/tilex_web/templates/developer/edit.html.eex b/lib/tilex_web/templates/developer/edit.html.eex index c5d84797..f638fdb0 100644 --- a/lib/tilex_web/templates/developer/edit.html.eex +++ b/lib/tilex_web/templates/developer/edit.html.eex @@ -3,7 +3,7 @@

My Profile

- <%= form_for @changeset, Routes.developer_path(@conn, :update), fn f -> %> + <%= form_for @changeset, Routes.developer_path(@conn, :update), [id: "edit-form"], fn f -> %>
<%= label f, :email %> @@ -31,7 +31,7 @@ <%= link("cancel", to: Routes.post_path(@conn, :index)) %> <% end %> - <%= form_for @conn, Routes.developer_path(@conn, :generate_api_key), [method: :post], fn _f -> %> + <%= form_for @conn, Routes.developer_path(@conn, :generate_api_key), [method: :post, id: "mcp-api-key-form"], fn _f -> %>
<%= if mcp_signed_token = assigns[:mcp_signed_token] do %>
diff --git a/lib/tilex_web/templates/post/form.html.eex b/lib/tilex_web/templates/post/form.html.eex index cdc796a1..977f1284 100644 --- a/lib/tilex_web/templates/post/form.html.eex +++ b/lib/tilex_web/templates/post/form.html.eex @@ -30,8 +30,17 @@ <%= select f, :channel_id, @channels, prompt: "" %>
- <%= submit "Submit" %> - <%= link("cancel", to: "/") %> + +
+ <%= link("cancel", to: "/") %> + + + + <%= if !input_value(f, :published_at) do %> + + <% end %> +
+ <% end %>
diff --git a/priv/repo/migrations/20251029135003_make_published_at_nullable.exs b/priv/repo/migrations/20251029135003_make_published_at_nullable.exs new file mode 100644 index 00000000..6f0acc4a --- /dev/null +++ b/priv/repo/migrations/20251029135003_make_published_at_nullable.exs @@ -0,0 +1,18 @@ +defmodule Tilex.Repo.Migrations.MakePublishedAtNullable do + use Ecto.Migration + + def up do + alter table(:posts) do + modify :published_at, :timestamptz, null: true, default: nil + end + end + + def down do + # Set any null published_at values to now() before re-adding NOT NULL constraint + execute "UPDATE posts SET published_at = NOW() WHERE published_at IS NULL" + + alter table(:posts) do + modify :published_at, :timestamptz, null: false, default: fragment("now()") + end + end +end diff --git a/priv/repo/structure.sql b/priv/repo/structure.sql index 2a7d8aa2..c301523f 100644 --- a/priv/repo/structure.sql +++ b/priv/repo/structure.sql @@ -102,7 +102,7 @@ CREATE TABLE public.posts ( slug character varying(255) NOT NULL, likes integer DEFAULT 1 NOT NULL, max_likes integer DEFAULT 1 NOT NULL, - published_at timestamp with time zone DEFAULT now() NOT NULL, + published_at timestamp with time zone, developer_id bigint, tweeted_at timestamp with time zone, CONSTRAINT likes_must_be_greater_than_zero CHECK ((likes > 0)), @@ -304,3 +304,4 @@ INSERT INTO public."schema_migrations" (version) VALUES (20200518184142); INSERT INTO public."schema_migrations" (version) VALUES (20220425135720); INSERT INTO public."schema_migrations" (version) VALUES (20220429184256); INSERT INTO public."schema_migrations" (version) VALUES (20251028182138); +INSERT INTO public."schema_migrations" (version) VALUES (20251029135003); diff --git a/test/features/admin_edits_post_test.exs b/test/features/admin_edits_post_test.exs index 166387fb..5cb359fc 100644 --- a/test/features/admin_edits_post_test.exs +++ b/test/features/admin_edits_post_test.exs @@ -6,8 +6,8 @@ defmodule AdminEditsPostTest do test "fills out form and updates post from post show", %{session: session} do Factory.insert!(:channel, name: "phoenix") - developer = Factory.insert!(:developer) - admin = Factory.insert!(:developer, %{admin: true}) + developer = Factory.insert!(:developer, %{username: "luke-skywalker"}) + admin = Factory.insert!(:developer, %{admin: true, username: "darth-vader"}) post = Factory.insert!( @@ -23,7 +23,7 @@ defmodule AdminEditsPostTest do |> PostForm.ensure_page_loaded() |> PostForm.expect_title_preview("Awesome Post!") |> PostForm.fill_in_title("Even Awesomer Post!") - |> PostForm.click_submit() + |> PostForm.click_save() session |> PostShowPage.ensure_page_loaded("Even Awesomer Post!") @@ -46,7 +46,7 @@ defmodule AdminEditsPostTest do |> PostForm.navigate(post) |> PostForm.ensure_page_loaded() |> PostForm.fill_in_title(String.duplicate("I can codez ", 10)) - |> PostForm.click_submit() + |> PostForm.click_save() session |> PostForm.expect_form_has_error("Title should be at most 50 character(s)") diff --git a/test/features/developer_creates_post_test.exs b/test/features/developer_creates_post_test.exs index 7739f33f..834a46cc 100644 --- a/test/features/developer_creates_post_test.exs +++ b/test/features/developer_creates_post_test.exs @@ -7,7 +7,7 @@ defmodule DeveloperCreatesPostTest do alias Tilex.Integration.Pages.PostShowPage alias Tilex.Integration.Pages.PostForm - test "fills out form and submits", %{session: session} do + test "fills out form and save", %{session: session} do Ecto.Adapters.SQL.Sandbox.allow(Tilex.Repo, self(), Process.whereis(Tilex.Notifications)) Factory.insert!(:channel, name: "phoenix") developer = Factory.insert!(:developer) @@ -23,7 +23,47 @@ defmodule DeveloperCreatesPostTest do body: "Example Body", channel: "phoenix" }) - |> CreatePostPage.submit_form() + |> CreatePostPage.save_form() + |> PostShowPage.ensure_info_flash("Post created") + |> PostShowPage.ensure_page_loaded("Example Title") + |> PostShowPage.expect_post_attributes(%{ + title: "Example Title", + body: "Example Body", + channel: "phoenix", + likes_count: 1 + }) + + post = + Post + |> Repo.all() + |> Enum.reverse() + |> hd + + assert post.body == "Example Body" + assert post.title == "Example Title" + refute is_nil(post.tweeted_at) + + session + |> Navigation.ensure_heading("TODAY I LEARNED") + end + + test "fills out form and save & publish", %{session: session} do + Ecto.Adapters.SQL.Sandbox.allow(Tilex.Repo, self(), Process.whereis(Tilex.Notifications)) + Factory.insert!(:channel, name: "phoenix") + developer = Factory.insert!(:developer) + + session + |> sign_in(developer) + |> IndexPage.navigate() + |> IndexPage.ensure_page_loaded() + |> Navigation.click_create_post() + |> CreatePostPage.ensure_page_loaded() + |> CreatePostPage.fill_in_form(%{ + title: "Example Title", + body: "Example Body", + channel: "phoenix" + }) + |> CreatePostPage.publish_form() |> PostShowPage.ensure_info_flash("Post created") |> PostShowPage.ensure_page_loaded("Example Title") |> PostShowPage.expect_post_attributes(%{ @@ -65,7 +105,7 @@ defmodule DeveloperCreatesPostTest do |> sign_in(developer) |> CreatePostPage.navigate() |> CreatePostPage.ensure_page_loaded() - |> CreatePostPage.submit_form() + |> CreatePostPage.publish_form() |> CreatePostPage.ensure_page_loaded() |> CreatePostPage.expect_form_has_error("Title can't be blank") |> CreatePostPage.expect_form_has_error("Body can't be blank") @@ -85,7 +125,7 @@ defmodule DeveloperCreatesPostTest do body: "Example Body", channel: "phoenix" }) - |> CreatePostPage.submit_form() + |> CreatePostPage.publish_form() |> CreatePostPage.ensure_page_loaded() |> CreatePostPage.expect_form_has_error("Title should be at most 50 character(s)") end @@ -103,7 +143,7 @@ defmodule DeveloperCreatesPostTest do body: String.duplicate("wordy ", 201), channel: "phoenix" }) - |> CreatePostPage.submit_form() + |> CreatePostPage.publish_form() |> CreatePostPage.ensure_page_loaded() |> CreatePostPage.expect_form_has_error("Body should be at most 200 word(s)") end diff --git a/test/features/developer_edits_post_test.exs b/test/features/developer_edits_post_test.exs index c21f658b..6b860c1e 100644 --- a/test/features/developer_edits_post_test.exs +++ b/test/features/developer_edits_post_test.exs @@ -31,7 +31,7 @@ defmodule DeveloperEditsPostTest do |> PostForm.fill_in_title("Even Awesomer Post!") |> PostForm.fill_in_body("This is how to be super awesome!") |> PostForm.select_channel("phoenix") - |> PostForm.click_submit() + |> PostForm.click_save() session |> PostShowPage.ensure_page_loaded("Even Awesomer Post!") diff --git a/test/features/developer_edits_profile_test.exs b/test/features/developer_edits_profile_test.exs index 719709d6..909e226d 100644 --- a/test/features/developer_edits_profile_test.exs +++ b/test/features/developer_edits_profile_test.exs @@ -9,7 +9,7 @@ defmodule DeveloperEditsProfileTest do click(session, Query.link("Profile")) h1_heading = Element.text(find(session, Query.css("#profile_edit header h1"))) - profile_form = Element.text(find(session, Query.css("#profile_edit form"))) + profile_form = Element.text(find(session, Query.css("#profile_edit form#edit-form"))) assert h1_heading == "My Profile" assert profile_form =~ "fine@sixdollareggs.com" diff --git a/test/support/factory.ex b/test/support/factory.ex index 80e7fa90..d34871f5 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -20,14 +20,15 @@ defmodule Tilex.Factory do body: "A body", channel: find_first_or_build(:channel), developer: find_first_or_build(:developer), - slug: Post.generate_slug() + slug: Post.generate_slug(), + published_at: DateTime.utc_now() |> DateTime.add(-10, :second) } end def build(:developer) do %Developer{ email: "developer@hashrocket.com", - username: "Ricky Rocketeer" + username: "ricky-rocketeer" } end diff --git a/test/support/pages/create_post_page.ex b/test/support/pages/create_post_page.ex index babeb797..332446bd 100644 --- a/test/support/pages/create_post_page.ex +++ b/test/support/pages/create_post_page.ex @@ -25,9 +25,12 @@ defmodule Tilex.Integration.Pages.CreatePostPage do end).() end - def submit_form(session) do - session - |> click(Query.button("Submit")) + def save_form(session) do + click(session, Query.button("Save")) + end + + def publish_form(session) do + click(session, Query.button("Save & Publish")) end def click_cancel(session) do diff --git a/test/support/pages/post_form.ex b/test/support/pages/post_form.ex index be36dbfa..0c12cd43 100644 --- a/test/support/pages/post_form.ex +++ b/test/support/pages/post_form.ex @@ -66,9 +66,12 @@ defmodule Tilex.Integration.Pages.PostForm do end).() end - def click_submit(session) do - session - |> click(Query.button("Submit")) + def click_save(session) do + click(session, Query.button("Save")) + end + + def click_publish(session) do + click(session, Query.button("Save & Publish")) end def expect_form_has_error(session, error_text) do From b842408d295ce6371aec2d0c3bbc6326995ddaed Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Wed, 29 Oct 2025 15:07:34 -0400 Subject: [PATCH 15/23] Add draft badge to post when not published Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- assets/css/components.css | 16 +++++++ lib/tilex/mcp/new_post.ex | 6 ++- lib/tilex_web/templates/shared/post.html.eex | 3 ++ test/features/developer_creates_post_test.exs | 7 +-- test/features/developer_edits_post_test.exs | 2 +- test/features/visitor_views_post_test.exs | 6 +-- test/support/pages/post_show_page.ex | 45 ++++++++++++------- test/tilex/mcp/new_post_test.exs | 2 +- 8 files changed, 62 insertions(+), 25 deletions(-) diff --git a/assets/css/components.css b/assets/css/components.css index 0cbe34b5..f5a0f6b2 100644 --- a/assets/css/components.css +++ b/assets/css/components.css @@ -69,6 +69,7 @@ nav.pagination a:hover { opacity: 0.8; } .post section { + position: relative; flex: 0 1 auto; box-sizing: border-box; min-width: 35rem; @@ -95,6 +96,21 @@ nav.pagination a:hover { .post .post__content h1:first-of-type a:hover { color: var(--red); } +.post .post__badge { + position: absolute; + top: 2rem; + right: 2rem; + background: var(--red, #d7263d); + color: #fff; + padding: 0.5em 1.2em; + border-radius: 2em; + font-weight: bold; + letter-spacing: 0.05em; + box-shadow: 0 2px 10px rgba(0,0,0,0.08); + font-size: 1.2rem; + text-transform: uppercase; + z-index: 2; +} .post footer { font-style: italic; text-align: right; diff --git a/lib/tilex/mcp/new_post.ex b/lib/tilex/mcp/new_post.ex index 57cbb780..bdb78b91 100644 --- a/lib/tilex/mcp/new_post.ex +++ b/lib/tilex/mcp/new_post.ex @@ -39,9 +39,11 @@ defmodule Tilex.MCP.NewPost do with {:ok, current_user} <- get_current_user(frame), {:ok, channel} <- get_channel(input), {:ok, %Post{} = post} <- create_til_post(current_user, channel, input) do - url = Routes.post_url(Endpoint, :show, post) + url = Routes.post_url(Endpoint, :edit, post) - Response.resource_link(resp, url, "til-post", description: "Link to the TIL post preview") + Response.resource_link(resp, url, "til-post", + description: "Open this link in order to review the TIL and publish it!" + ) else {:error, %Changeset{} = cs} -> Response.error(resp, changeset_errors(cs)) diff --git a/lib/tilex_web/templates/shared/post.html.eex b/lib/tilex_web/templates/shared/post.html.eex index b03e6d2b..0b058cfc 100644 --- a/lib/tilex_web/templates/shared/post.html.eex +++ b/lib/tilex_web/templates/shared/post.html.eex @@ -1,5 +1,8 @@
+ <%= if !@post.published_at do %> +
DRAFT
+ <% end %>

<%= link(@post.title, to: Routes.post_path(@conn, :show, @post)) %> diff --git a/test/features/developer_creates_post_test.exs b/test/features/developer_creates_post_test.exs index 834a46cc..5ba37e11 100644 --- a/test/features/developer_creates_post_test.exs +++ b/test/features/developer_creates_post_test.exs @@ -29,8 +29,9 @@ defmodule DeveloperCreatesPostTest do |> PostShowPage.expect_post_attributes(%{ title: "Example Title", body: "Example Body", - channel: "phoenix", - likes_count: 1 + channel: "#PHOENIX", + likes_count: 1, + badge: "DRAFT" }) post = @@ -69,7 +70,7 @@ defmodule DeveloperCreatesPostTest do |> PostShowPage.expect_post_attributes(%{ title: "Example Title", body: "Example Body", - channel: "phoenix", + channel: "#PHOENIX", likes_count: 1 }) diff --git a/test/features/developer_edits_post_test.exs b/test/features/developer_edits_post_test.exs index 6b860c1e..644d9b79 100644 --- a/test/features/developer_edits_post_test.exs +++ b/test/features/developer_edits_post_test.exs @@ -39,7 +39,7 @@ defmodule DeveloperEditsPostTest do |> PostShowPage.expect_post_attributes(%{ title: "Even Awesomer Post!", body: "This is how to be super awesome!", - channel: "#phoenix", + channel: "#PHOENIX", likes_count: 1 }) end diff --git a/test/features/visitor_views_post_test.exs b/test/features/visitor_views_post_test.exs index 9c6dc5b6..940addfc 100644 --- a/test/features/visitor_views_post_test.exs +++ b/test/features/visitor_views_post_test.exs @@ -25,7 +25,7 @@ defmodule VisitorViewsPostTest do |> PostShowPage.expect_post_attributes(%{ title: "A special post", body: "This is how to be super awesome!", - channel: "#command-line", + channel: "#COMMAND-LINE", likes_count: 1 }) @@ -226,7 +226,7 @@ defmodule VisitorViewsPostTest do |> PostShowPage.expect_post_attributes(%{ title: post.title, body: post.body, - channel: post.channel.name, + channel: "#" <> String.upcase(post.channel.name), likes_count: 1 }) @@ -241,7 +241,7 @@ defmodule VisitorViewsPostTest do |> PostShowPage.expect_post_attributes(%{ title: post.title, body: post.body, - channel: post.channel.name, + channel: "#" <> String.upcase(post.channel.name), likes_count: 1 }) |> assert_text(Query.css(".post__tag-link"), "##{String.upcase(post.channel.name)}") diff --git a/test/support/pages/post_show_page.ex b/test/support/pages/post_show_page.ex index 24b9de30..38bf9a04 100644 --- a/test/support/pages/post_show_page.ex +++ b/test/support/pages/post_show_page.ex @@ -22,32 +22,47 @@ defmodule Tilex.Integration.Pages.PostShowPage do session end - def expect_post_attributes(session, attrs \\ %{}) do - expected_title = Map.fetch!(attrs, :title) - expected_body = Map.fetch!(attrs, :body) - expected_channel = Map.fetch!(attrs, :channel) - expected_likes_count = attrs |> Map.fetch!(:likes_count) |> to_string() + defp assert_contains(session, query, expected_text) do + texts = session |> Browser.all(query) |> Enum.map(&Element.text/1) - session - |> Browser.find(Query.css(".post h1", text: expected_title)) + ExUnit.Assertions.assert( + Enum.any?(texts, &String.contains?(&1, expected_text)), + "Unable to find contains text: '#{expected_text}', instead found '#{texts}'" + ) session - |> Browser.find(Query.css(".post .copy", text: expected_body)) + end - channel_name = - session - |> Browser.find(Query.css(".post aside .post__tag-link")) - |> Element.text() + defp assert_texts(session, query, expected_texts) do + texts = session |> Browser.all(query) |> Enum.map(&Element.text/1) ExUnit.Assertions.assert( - channel_name =~ ~r/#{expected_channel}/i, - "Unable to find text channel #{expected_channel}, instead found #{channel_name}" + texts == expected_texts, + "Unable to find text: '#{expected_texts}', instead found '#{texts}'" ) session - |> Browser.find(Query.css(".js-like-action", text: expected_likes_count)) + end + + def expect_post_attributes(session, attrs \\ %{}) do + expected_title = Map.fetch!(attrs, :title) + expected_body = Map.fetch!(attrs, :body) + expected_channel = attrs.channel + expected_likes_count = attrs |> Map.fetch!(:likes_count) |> to_string() + badge = attrs[:badge] session + |> assert_texts(Query.css(".post h1"), [expected_title]) + |> assert_texts(Query.css(".post aside .post__tag-link"), [expected_channel]) + |> assert_contains(Query.css(".post .copy"), expected_body) + |> assert_texts(Query.css(".post__like-count"), [expected_likes_count]) + |> then(fn s -> + if badge do + assert_texts(s, Query.css(".post__badge"), [badge]) + else + s + end + end) end def click_edit(session) do diff --git a/test/tilex/mcp/new_post_test.exs b/test/tilex/mcp/new_post_test.exs index ed8ddaac..bd3e6cfb 100644 --- a/test/tilex/mcp/new_post_test.exs +++ b/test/tilex/mcp/new_post_test.exs @@ -34,7 +34,7 @@ defmodule Tilex.MCP.NewPostTest do isError: false, content: [ %{ - "description" => "Link to the TIL post preview", + "description" => "Open this link in order to review the TIL and publish it!", "name" => "til-post", "type" => "resource_link", "uri" => "http" <> _ From 61a779707cea606f41218377b6db194758ea10b3 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Wed, 29 Oct 2025 16:19:48 -0400 Subject: [PATCH 16/23] Notify new TILs only if they are marked as published Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/blog/post.ex | 32 ++++++++++++---- lib/tilex/posts.ex | 37 +++++++++++++++++++ lib/tilex_web/controllers/post_controller.ex | 34 +++-------------- test/features/developer_creates_post_test.exs | 2 +- 4 files changed, 69 insertions(+), 36 deletions(-) diff --git a/lib/tilex/blog/post.ex b/lib/tilex/blog/post.ex index fcdc7d47..046c812a 100644 --- a/lib/tilex/blog/post.ex +++ b/lib/tilex/blog/post.ex @@ -69,20 +69,38 @@ defmodule Tilex.Blog.Post do |> add_slug() |> validate_required(@required_params) |> validate_length(:title, max: title_max_chars()) - |> validate_length_of_body + |> validate_length_of_body() |> validate_number(:likes, greater_than: 0) |> foreign_key_constraint(:channel_id) |> foreign_key_constraint(:developer_id) end + def create_changeset(developer_id, attrs \\ %{}) do + %Post{} + |> cast(attrs, [:body, :channel_id, :title, :published_at]) + |> put_change(:developer_id, developer_id) + |> add_slug() + |> validate_required(@required_params) + |> validate_length(:title, max: title_max_chars()) + |> validate_length_of_body() + |> foreign_key_constraint(:channel_id) + |> foreign_key_constraint(:developer_id) + end + + def update_changeset(post, attrs \\ %{}) do + post + |> cast(attrs, [:body, :channel_id, :title, :published_at]) + |> validate_required(@required_params) + |> validate_length(:title, max: title_max_chars()) + |> validate_length_of_body() + |> foreign_key_constraint(:channel_id) + |> foreign_key_constraint(:developer_id) + end + defp add_slug(changeset) do case get_field(changeset, :slug) do - nil -> - generate_slug() - |> (&put_change(changeset, :slug, &1)).() - - _ -> - changeset + nil -> put_change(changeset, :slug, generate_slug()) + _ -> changeset end end diff --git a/lib/tilex/posts.ex b/lib/tilex/posts.ex index 8b1664bb..e1b80a17 100644 --- a/lib/tilex/posts.ex +++ b/lib/tilex/posts.ex @@ -5,8 +5,45 @@ defmodule Tilex.Posts do alias Tilex.Blog.Channel alias Tilex.Blog.Developer alias Tilex.Blog.Post + alias Tilex.Notifications alias Tilex.Repo + def create_post(developer, attrs) do + post_changeset = Post.create_changeset(developer.id, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.insert(:post, post_changeset) + |> Ecto.Multi.run(:notification, fn _repo, %{post: post} -> + case post.published_at do + nil -> {:ok, nil} + %DateTime{} -> {:ok, Notifications.post_created(post)} + end + end) + |> Repo.transaction() + |> case do + {:ok, %{post: post}} -> {:ok, post} + {:error, _operation, reason, _changes} -> {:error, reason} + end + end + + def update_post(post, attrs) do + post_changeset = Post.update_changeset(post, attrs) + + Ecto.Multi.new() + |> Ecto.Multi.update(:post, post_changeset) + |> Ecto.Multi.run(:notification, fn _repo, %{post: updated_post} -> + case {post.published_at, updated_post.published_at} do + {nil, %DateTime{}} -> {:ok, Notifications.post_created(post)} + _ -> {:ok, nil} + end + end) + |> Repo.transaction() + |> case do + {:ok, %{post: post}} -> {:ok, post} + {:error, _operation, reason, _changes} -> {:error, reason} + end + end + def published(query \\ Post) do from(p in query, where: not is_nil(p.published_at) and p.published_at <= fragment("now()")) end diff --git a/lib/tilex_web/controllers/post_controller.ex b/lib/tilex_web/controllers/post_controller.ex index 2963eac2..3eca1c6a 100644 --- a/lib/tilex_web/controllers/post_controller.ex +++ b/lib/tilex_web/controllers/post_controller.ex @@ -8,7 +8,6 @@ defmodule TilexWeb.PostController do alias Tilex.Blog.Channel alias Tilex.Blog.Post alias Tilex.Liking - alias Tilex.Notifications alias Tilex.Posts plug(:load_channels when action in [:new, :create, :edit, :update]) @@ -130,17 +129,8 @@ defmodule TilexWeb.PostController do def create(conn, %{"post" => params}) do developer = Guardian.Plug.current_resource(conn) - sanitized_params = - params - |> post_params() - |> Map.merge(%{"developer_id" => developer.id}) - - changeset = Post.changeset(%Post{}, sanitized_params) - - case Repo.insert(changeset) do - {:ok, post} -> - Notifications.post_created(post) - + case Posts.create_post(developer, params) do + {:ok, _post} -> conn |> put_flash(:info, "Post created") |> redirect(to: Routes.developer_path(conn, :show, developer)) @@ -190,20 +180,12 @@ defmodule TilexWeb.PostController do post = case current_user.admin do - false -> - current_user - |> assoc(:posts) - |> Repo.get_by!(slug: conn.assigns.slug) - - true -> - Repo.get_by!(Post, slug: conn.assigns.slug) + false -> assoc(current_user, :posts) + true -> Post end + |> Repo.get_by!(slug: conn.assigns.slug) - sanitized_params = post_params(params) - - changeset = Post.changeset(post, sanitized_params) - - case Repo.update(changeset) do + case Posts.update_post(post, params) do {:ok, post} -> post = Repo.preload(post, [:developer]) @@ -238,8 +220,4 @@ defmodule TilexWeb.PostController do defp extracted_slug(<>), do: {:ok, slug} defp extracted_slug(_), do: :error - - defp post_params(params) do - Map.take(params, ["body", "channel_id", "title", "published_at"]) - end end diff --git a/test/features/developer_creates_post_test.exs b/test/features/developer_creates_post_test.exs index 5ba37e11..3dda3b45 100644 --- a/test/features/developer_creates_post_test.exs +++ b/test/features/developer_creates_post_test.exs @@ -42,7 +42,7 @@ defmodule DeveloperCreatesPostTest do assert post.body == "Example Body" assert post.title == "Example Title" - refute is_nil(post.tweeted_at) + assert is_nil(post.tweeted_at) session |> Navigation.ensure_heading("TODAY I LEARNED") From a4e843a1f1627b3ac3afce3ac8c8967b430739cf Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 11:05:54 -0400 Subject: [PATCH 17/23] Use Anubis instead of Hemes for MCP We are also changing the ListChannels to be a resource type Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/application.ex | 2 +- lib/tilex/mcp/list_channels.ex | 16 +++++++++------- lib/tilex/mcp/new_post.ex | 7 ++++--- lib/tilex/mcp/server.ex | 3 ++- lib/tilex_web/router.ex | 2 +- mix.exs | 4 ++-- mix.lock | 4 ++-- test/tilex/mcp/list_channels_test.exs | 24 +++++++++--------------- test/tilex/mcp/new_post_test.exs | 2 +- 9 files changed, 31 insertions(+), 33 deletions(-) diff --git a/lib/tilex/application.ex b/lib/tilex/application.ex index 249e65ad..f589aa0a 100644 --- a/lib/tilex/application.ex +++ b/lib/tilex/application.ex @@ -15,7 +15,7 @@ defmodule Tilex.Application do {Cachex, name: :tilex_cache}, Tilex.Notifications, Tilex.RateLimiter, - Hermes.Server.Registry, + Anubis.Server.Registry, {Tilex.MCP.Server, transport: :streamable_http}, Tilex.Notifications.NotifiersSupervisor ] diff --git a/lib/tilex/mcp/list_channels.ex b/lib/tilex/mcp/list_channels.ex index bb4e6277..23a4b327 100644 --- a/lib/tilex/mcp/list_channels.ex +++ b/lib/tilex/mcp/list_channels.ex @@ -5,20 +5,22 @@ defmodule Tilex.MCP.ListChannels do Channel are used to group posts by the same topic. """ - use Hermes.Server.Component, type: :tool + use Anubis.Server.Component, + type: :resource, + uri: "til:///channels", + name: "list_channels", + mime_type: "application/json" import Ecto.Query, only: [from: 2] - alias Hermes.Server.Response + alias Anubis.Server.Response alias Tilex.Blog.Channel alias Tilex.Repo - schema do - end - - def execute(_input, frame) do + @impl true + def read(_input, frame) do channels = list_channels() - resp = Response.tool() |> Response.json(channels) + resp = Response.json(Response.resource(), channels) {:reply, resp, frame} end diff --git a/lib/tilex/mcp/new_post.ex b/lib/tilex/mcp/new_post.ex index bdb78b91..070b579c 100644 --- a/lib/tilex/mcp/new_post.ex +++ b/lib/tilex/mcp/new_post.ex @@ -5,12 +5,12 @@ defmodule Tilex.MCP.NewPost do TIL is a place for sharing something you've learned today with others. """ - use Hermes.Server.Component, type: :tool + use Anubis.Server.Component, type: :tool import Ecto.Query, only: [from: 2] alias Ecto.Changeset - alias Hermes.Server.Response + alias Anubis.Server.Response alias Tilex.Blog.Channel alias Tilex.Blog.Developer alias Tilex.Blog.Post @@ -29,9 +29,10 @@ defmodule Tilex.MCP.NewPost do field :channel, :string, required: true, - description: "Channel is given by the list_channels MCP tool from this same server." + description: "Channel is given by the list_channels MCP resource from this same server." end + @impl true def execute(input, frame) do resp = Response.tool() diff --git a/lib/tilex/mcp/server.ex b/lib/tilex/mcp/server.ex index fa78e0bc..560a23e8 100644 --- a/lib/tilex/mcp/server.ex +++ b/lib/tilex/mcp/server.ex @@ -1,5 +1,5 @@ defmodule Tilex.MCP.Server do - use Hermes.Server, name: "TIL", version: "1.0.0", capabilities: [:tools] + use Anubis.Server, name: "TIL", version: "1.0.0", capabilities: [:resources, :tools] import Ecto.Query, only: [from: 2] @@ -9,6 +9,7 @@ defmodule Tilex.MCP.Server do component(Tilex.MCP.ListChannels) component(Tilex.MCP.NewPost) + @impl true def init(_arg, frame) do headers = Enum.into(frame.transport.req_headers, %{}) user = get_current_user(headers["x-api-key"]) diff --git a/lib/tilex_web/router.ex b/lib/tilex_web/router.ex index 29a29aa4..6c688330 100644 --- a/lib/tilex_web/router.ex +++ b/lib/tilex_web/router.ex @@ -115,6 +115,6 @@ defmodule TilexWeb.Router do end forward "/mcp", - Hermes.Server.Transport.StreamableHTTP.Plug, + Anubis.Server.Transport.StreamableHTTP.Plug, server: Tilex.MCP.Server end diff --git a/mix.exs b/mix.exs index 0dc1ecce..2d2d3d6c 100644 --- a/mix.exs +++ b/mix.exs @@ -33,7 +33,7 @@ defmodule Tilex.Mixfile do # Type `mix help deps` for examples and options. defp deps do [ - {:usage_rules, "~> 0.1", only: [:dev]}, + {:anubis_mcp, "~> 0.14.1"}, {:appsignal_phoenix, "~> 2.0"}, {:cachex, "~> 3.1"}, {:cors_plug, "~> 3.0"}, @@ -45,7 +45,6 @@ defmodule Tilex.Mixfile do {:gettext, "~> 0.18"}, {:guardian, "~> 2.0"}, {:hackney, "~>1.25.0"}, - {:hermes_mcp, "~> 0.14.1"}, {:html_sanitize_ex, "~> 1.2"}, {:jason, "~> 1.2"}, {:oauther, @@ -67,6 +66,7 @@ defmodule Tilex.Mixfile do {:timex, "~> 3.1"}, {:tzdata, "~> 1.1.0"}, {:ueberauth_google, "~> 0.5"}, + {:usage_rules, "~> 0.1", only: [:dev]}, {:wallaby, "~>0.30.1", [runtime: false, only: :test]} ] end diff --git a/mix.lock b/mix.lock index 7ad7d279..95c282c1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "anubis_mcp": {:hex, :anubis_mcp, "0.14.1", "a59c74a94ba177485cfaed34d1c08549da50d018217f2f97add7301b8738fca6", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "0.6.0", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ac5a1116c511ad8a85320d7f6abfc8ef458c55e07a7386ab1618a7cd7ff5ce19"}, "appsignal": {:hex, :appsignal, "2.12.3", "de6aff6241bf7f1b5c6e058d976d3dcf00071613f7800f66049d9a77a3aa15d8", [:make, :mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:decorator, "~> 1.2.3 or ~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2744685894960b5b0421e77240d1a8ccc1ff3a256344f42055af892efe6d1f06"}, "appsignal_phoenix": {:hex, :appsignal_phoenix, "2.5.0", "b0f5855d02d1f522f6029e71d3b11ebb1d37df63b562e380750841de25d35a7c", [:mix], [{:appsignal, ">= 2.11.0 and < 3.0.0", [hex: :appsignal, repo: "hexpm", optional: false]}, {:appsignal_plug, ">= 2.0.15 and < 3.0.0", [hex: :appsignal_plug, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.11 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.9 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "01fe404add5fe32b837f6cf922cbc7f0047a8abbc78f1e943268cd48e0ed3169"}, "appsignal_plug": {:hex, :appsignal_plug, "2.0.15", "758a8a78944878e8461bbc77ca86219121a56f4299c6d79940ab083cf9afea00", [:mix], [{:appsignal, ">= 2.7.6 and < 3.0.0", [hex: :appsignal, repo: "hexpm", optional: false]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c6059049e2081e808aaef04e2b9917e06277f61a35a0e103db860d08cbc41f1"}, @@ -29,7 +30,6 @@ "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "guardian": {:hex, :guardian, "2.4.0", "efbbb397ecca881bb548560169922fc4433a05bc98c2eb96a7ed88ede9e17d64", [:mix], [{:jose, "~> 1.11.9", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "5c80103a9c538fbc2505bf08421a82e8f815deba9eaedb6e734c66443154c518"}, "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, - "hermes_mcp": {:hex, :hermes_mcp, "0.14.1", "cfe4321c21c6a5fe01e4e27d6f45037573ce9f02a296fa1112ff7b3d0ee396d9", [:mix], [{:burrito, "~> 1.0", [hex: :burrito, repo: "hexpm", optional: true]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:gun, "~> 2.2", [hex: :gun, repo: "hexpm", optional: true]}, {:peri, "~> 0.4", [hex: :peri, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "86ec898911d633d8e4a8ad94a79123beab95ba86baa1b6fe6197ab1ccf76f9a9"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.3", "67b3d9fa8691b727317e0cc96b9b3093be00ee45419ffb221cdeee88e75d1360", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "87748d3c4afe949c7c6eb7150c958c2bcba43fc5b2a02686af30e636b74bccb7"}, "httpoison": {:hex, :httpoison, "2.2.1", "87b7ed6d95db0389f7df02779644171d7319d319178f6680438167d7b69b1f3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "51364e6d2f429d80e14fe4b5f8e39719cacd03eb3f9a9286e61e216feac2d2df"}, @@ -50,7 +50,7 @@ "optimus": {:hex, :optimus, "0.3.0", "72754d1a06bab7b4b7f59b05622e442e5ab7909b9db5d8b01dc3d2792559fa9e", [:mix], [], "hexpm", "d0d026fdf068e461e1173c463ea2da15e109942135aa4e2490dd2dc9ef05946c"}, "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, - "peri": {:hex, :peri, "0.6.1", "6a90ca728a27aef8fef37ce307444255d20364b0c8f8d39e52499d8d825cb514", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e20ffc659967baf9c4f28799fe7302b656d6662a8b3db7646fdafd017e192743"}, + "peri": {:hex, :peri, "0.6.0", "0758aa037f862f7a3aa0823cb82195916f61a8071f6eaabcff02103558e61a70", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "b27f118f3317fbc357c4a04b3f3c98561efdd8865edd4ec0e24fd936c7ff36c8"}, "phoenix": {:hex, :phoenix, "1.6.16", "e5bdd18c7a06da5852a25c7befb72246de4ddc289182285f8685a40b7b5f5451", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e15989ff34f670a96b95ef6d1d25bad0d9c50df5df40b671d8f4a669e050ac39"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "3.3.4", "42a09fc443bbc1da37e372a5c8e6755d046f22b9b11343bf885067357da21cb3", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0249d3abec3714aff3415e7ee3d9786cb325be3151e6c4b3021502c585bf53fb"}, diff --git a/test/tilex/mcp/list_channels_test.exs b/test/tilex/mcp/list_channels_test.exs index 155c5212..76d612f5 100644 --- a/test/tilex/mcp/list_channels_test.exs +++ b/test/tilex/mcp/list_channels_test.exs @@ -1,7 +1,7 @@ defmodule Tilex.MCP.ListChannelsTest do use Tilex.DataCase, async: false - alias Hermes.Server.Response + alias Anubis.Server.Response alias Tilex.Factory alias Tilex.MCP.ListChannels @@ -14,37 +14,31 @@ defmodule Tilex.MCP.ListChannelsTest do Factory.insert!(:channel, name: "ruby") Factory.insert!(:channel, name: "javascript") - assert {:reply, response, returned_frame} = ListChannels.execute(@input, @frame) + assert {:reply, response, returned_frame} = ListChannels.read(@input, @frame) assert returned_frame == @frame assert %Response{ - type: :tool, + type: :resource, isError: false, - content: [content] + contents: %{"text" => content} } = response - assert %{ - "text" => "[\"elixir\",\"ruby\",\"javascript\"]", - "type" => "text" - } == content + assert content == "[\"elixir\",\"ruby\",\"javascript\"]" end test "returns empty list when no channels exist" do - assert {:reply, response, returned_frame} = ListChannels.execute(@input, @frame) + assert {:reply, response, returned_frame} = ListChannels.read(@input, @frame) assert returned_frame == @frame assert %Response{ - type: :tool, + type: :resource, isError: false, - content: [content] + contents: %{"text" => content} } = response - assert %{ - "text" => "[]", - "type" => "text" - } == content + assert content == "[]" end end end diff --git a/test/tilex/mcp/new_post_test.exs b/test/tilex/mcp/new_post_test.exs index bd3e6cfb..13369da9 100644 --- a/test/tilex/mcp/new_post_test.exs +++ b/test/tilex/mcp/new_post_test.exs @@ -1,7 +1,7 @@ defmodule Tilex.MCP.NewPostTest do use Tilex.DataCase, async: false - alias Hermes.Server.Response + alias Anubis.Server.Response alias Tilex.Blog.Post alias Tilex.Factory alias Tilex.MCP.NewPost From 54b44eb91cfd941a985655ae4e24265d066203e7 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 11:59:00 -0400 Subject: [PATCH 18/23] Improve README with new mcp info Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- README.md | 90 +++++++++++++++++++++++++++++++++++-------------------- mix.exs | 8 ++--- 2 files changed, 59 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ef4f0fb9..ca2022dc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,7 @@ [![CI](https://github.com/hashrocket/tilex/actions/workflows/ci.yml/badge.svg)](https://github.com/hashrocket/tilex/actions/workflows/ci.yml) -> Today I Learned is an open-source project by the team at -> [Hashrocket][hashrocket] that catalogues the sharing & accumulation of -> knowledge as it happens day-to-day. Posts have a 200-word limit, and posting -> is open to any Rocketeer as well as select friends of the team. We hope you -> enjoy learning along with us. +> Today I Learned is an open-source project by the team at [Hashrocket][hashrocket] that catalogues the sharing & accumulation of knowledge as it happens day-to-day. Posts have a 200-word limit, and posting is open to any Rocketeer as well as select friends of the team. We hope you enjoy learning along with us. Today I Learned was open-sourced to: - provide a window into our development process @@ -28,60 +24,86 @@ $ git clone https://github.com//tilex $ cd tilex ``` -Then, install [Erlang][erlang], [Elixir][elixir], Node, and PostgreSQL. -[asdf][asdf] can do this in a single command: +Then, install [Erlang][erlang], [Elixir][elixir], Node, and PostgreSQL. [mise][mise] can do this in a single command: ```shell -$ asdf install +$ mise install ``` -From here, we recommend using `make`: +The first step in the setup is to clone the `.env` file: + +```shell +$ cp .env.example .env +``` + +#### Option 1 - Makefile + +From here, we recommend using `make` which will print out the help message with all dev tools we have: ```shell $ make + +Makefile console ## Opens the App console. +Makefile help ## Shows this help. +Makefile outdated ## Shows outdated packages. +Makefile server ## Start the App server. +Makefile setup ## Setup the App. +Makefile test ## Run the test suite. +Makefile update ## Update dependencies. +``` + +To **setup** the project and start the **server** you can: + +```shell $ make setup server ``` -#### Option 1 (seeded db) +Now you can visit http://localhost:4000 from your browser. + +#### Option 2 - Manual + +All Makefile tasks will **automatically** load the `.env` file, so if you want to run any command manually you may have to load that file first: + +```shell +source .env +``` Source your environment variables, install dependencies, seed the db, and start the server: ```shell -$ cp .env{.example,} -$ source .env $ mix deps.get $ mix ecto.setup $ npm install --prefix assets $ mix phx.server ``` -### Option 2 (empty db) +Now you can visit http://localhost:4000 from your browser. + +#### Seeds the local Database -For those who prefer to start with a blank slate: +**Optionally**, And if you want to run a seeds task to populate your local database with some fake data you can run: ```shell -$ cp .env{.example,} -$ source .env -$ mix deps.get -$ mix ecto.create && mix ecto.migrate -$ npm install --prefix assets -$ mix phx.server +$ DATE_DISPLAY_TZ=America/Chicago mix ecto.seeds ``` -### Running the application +### AI Tooling -```shell -$ mix phx.server -``` +#### Creating TILs: from AI via MCP -Now you can visit http://localhost:4000 from your browser. +If you are an user of TIL and wants to write TIL posts with some help of your AI tooling you can use our MCP servers. Log in into your TIL service, then go to the **profile** page. There's a box there to generate an MCP API Key that's needed to authenticate. We added a few helper code snippets to help you to setup the TIL MCP into your tooling. -To serve the application at a different port, include the `PORT` environment -variable when starting the server: +image -```shell -$ PORT=4444 mix phx.server +After the MCP is setup correctly you can start asking your AI to write TILs. The response on each new TIL should be a link where you'd have to review the title, content and channel and publish it. + +#### Developing TIL code: Improving AI Context + +So in case you want to use your AI tools to improve the TIL code we've added the [usage_rules](https://hexdocs.pm/usage_rules) into the project and we also added [tidewave](https://hexdocs.pm/tidewave) served as a MCP. + +``` +http://localhost:4000/tidewave/mcp ``` ### Authentication @@ -158,14 +180,16 @@ see [LICENSE](LICENSE.md) for more information. --- -

- -

+ +

+ +

+
Tilex is supported by the team at [Hashrocket][hashrocket], a multidisciplinary design and development consultancy. If you'd like to work with us, don't hesitate to [contact us][hire-us] today! -[asdf]: https://github.com/asdf-vm/asdf +[mise]: https://github.com/jdx/mise [cc]: http://contributor-covenant.org [chromedriver]: https://sites.google.com/a/chromium.org/chromedriver/ [contrib]: https://github.com/hashrocket/tilex/graphs/contributors diff --git a/mix.exs b/mix.exs index 2d2d3d6c..52333114 100644 --- a/mix.exs +++ b/mix.exs @@ -82,12 +82,8 @@ defmodule Tilex.Mixfile do setup: ["deps.get", "ecto.setup"], "ecto.migrate": ["ecto.migrate", "ecto.dump"], "ecto.rollback": ["ecto.rollback", "ecto.dump"], - "ecto.setup": [ - "ecto.create", - "ecto.load --skip-if-loaded", - "ecto.migrate", - "run priv/repo/seeds.exs" - ], + "ecto.setup": ["ecto.create", "ecto.load --skip-if-loaded", "ecto.migrate"], + "ecto.seeds": ["run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.load --skip-if-loaded", "ecto.migrate --quiet", "test"], "assets.deploy": ["esbuild default --minify", "phx.digest"] From 75e7c22745d776404ca7c40aa04117f9950f76d0 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 12:06:09 -0400 Subject: [PATCH 19/23] Address PR comments Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- lib/tilex/stats.ex | 12 ++++-------- lib/tilex/tracking.ex | 2 +- lib/tilex_web/controllers/feed_controller.ex | 17 +++++++++-------- lib/tilex_web/controllers/post_controller.ex | 12 +++++------- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/lib/tilex/stats.ex b/lib/tilex/stats.ex index dd955117..ba6fe6e1 100644 --- a/lib/tilex/stats.ex +++ b/lib/tilex/stats.ex @@ -5,6 +5,7 @@ defmodule Tilex.Stats do alias Ecto.Adapters.SQL alias Tilex.Blog.Channel alias Tilex.Blog.Post + alias Tilex.Posts alias Tilex.Repo def developer(%{start_date: start_date, end_date: end_date}) do @@ -13,10 +14,9 @@ defmodule Tilex.Stats do posts_query = from(p in Post, - where: - not is_nil(p.published_at) and p.published_at <= fragment("now()") and - between(p.inserted_at, ^start_time, ^end_time) + where: between(p.inserted_at, ^start_time, ^end_time) ) + |> Posts.published() [ start_date: format_date(start_date), @@ -35,11 +35,7 @@ defmodule Tilex.Stats do def all do posts_for_days = query_posts_for_days!() - - posts_query = - from(p in Post, - where: not is_nil(p.published_at) and p.published_at <= fragment("now()") - ) + posts_query = Posts.published() [ channels: get_posts_by_channels_count(posts_query), diff --git a/lib/tilex/tracking.ex b/lib/tilex/tracking.ex index d893dfc4..ad79c466 100644 --- a/lib/tilex/tracking.ex +++ b/lib/tilex/tracking.ex @@ -25,7 +25,7 @@ defmodule Tilex.Tracking do where: matches?(req.page, "/posts/"), where: not matches?(req.page, "/posts/.+/edit$"), where: between(req.request_time, ^start_date, ^end_date), - order_by: [desc: count(req.page)], + order_by: [desc: count(req.page), asc: req.page], select: %{ url: req.page, view_count: count(req.page), diff --git a/lib/tilex_web/controllers/feed_controller.ex b/lib/tilex_web/controllers/feed_controller.ex index 45b0cc7e..b5178a69 100644 --- a/lib/tilex_web/controllers/feed_controller.ex +++ b/lib/tilex_web/controllers/feed_controller.ex @@ -1,17 +1,18 @@ defmodule TilexWeb.FeedController do use TilexWeb, :controller + alias Tilex.Posts + def index(conn, _params) do posts = - Repo.all( - from( - p in Tilex.Blog.Post, - where: not is_nil(p.published_at) and p.published_at <= fragment("now()"), - order_by: [desc: p.inserted_at], - preload: [:channel, :developer], - limit: 25 - ) + from( + p in Tilex.Blog.Post, + order_by: [desc: p.inserted_at], + preload: [:channel, :developer], + limit: 25 ) + |> Posts.published() + |> Repo.all() conn |> put_resp_content_type("application/xml") diff --git a/lib/tilex_web/controllers/post_controller.ex b/lib/tilex_web/controllers/post_controller.ex index 3eca1c6a..cfac74fb 100644 --- a/lib/tilex_web/controllers/post_controller.ex +++ b/lib/tilex_web/controllers/post_controller.ex @@ -63,10 +63,9 @@ defmodule TilexWeb.PostController do def show(%{assigns: %{slug: slug}} = conn, _) do post = from(p in Post, - where: - not is_nil(p.published_at) and p.published_at <= fragment("now()") and - p.slug == ^slug + where: p.slug == ^slug ) + |> Posts.published() |> Repo.one!(slug: slug) |> Repo.preload([:channel]) |> Repo.preload([:developer]) @@ -78,16 +77,15 @@ defmodule TilexWeb.PostController do end def random(conn, _) do - query = + post = from( post in Post, - where: not is_nil(post.published_at) and post.published_at <= fragment("now()"), order_by: fragment("random()"), limit: 1, preload: [:channel, :developer] ) - - post = Repo.one(query) + |> Posts.published() + |> Repo.one() conn |> assign(:meta_robots, "noindex") From 7406787459a946fa9f69b58449bcc805c5253998 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 13:39:48 -0400 Subject: [PATCH 20/23] Downgrade to elixir 1.18.4 Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- .github/workflows/ci.yml | 6 +++--- .tool-versions | 2 +- elixir_buildpack.config | 2 +- lib/tilex/plug/request_rejector.ex | 6 +----- mix.lock | 2 +- openspec/project.md | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53ab9855..a2020a9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: [1.19.0] + elixir: [1.18.4] otp: [28.1] steps: - uses: actions/checkout@v3 @@ -61,7 +61,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: [1.19.0] + elixir: [1.18.4] otp: [28.1] services: postgres: @@ -111,7 +111,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - elixir: [1.19.0] + elixir: [1.18.4] otp: [28.1] steps: - uses: actions/checkout@v3 diff --git a/.tool-versions b/.tool-versions index f3e3a8df..2f658731 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -elixir 1.19.0-otp-28 +elixir 1.18.4-otp-28 erlang 28.1 nodejs 19.0.0 diff --git a/elixir_buildpack.config b/elixir_buildpack.config index 43b0f239..67f104de 100644 --- a/elixir_buildpack.config +++ b/elixir_buildpack.config @@ -1,3 +1,3 @@ config_vars_to_export=(BASIC_AUTH_PASSWORD BASIC_AUTH_USERNAME) -elixir_version=1.19.0 +elixir_version=1.18.4 erlang_version=28.1 diff --git a/lib/tilex/plug/request_rejector.ex b/lib/tilex/plug/request_rejector.ex index 850c60c2..6b2bfdee 100644 --- a/lib/tilex/plug/request_rejector.ex +++ b/lib/tilex/plug/request_rejector.ex @@ -1,12 +1,8 @@ defmodule Tilex.Plug.RequestRejector do - @rejected_paths [ - ~r/\.php$/ - ] - def init([]), do: [] def call(%Plug.Conn{request_path: path} = conn, _default) do - if Enum.any?(@rejected_paths, &match_rejected_path(path, &1)) do + if Enum.any?([~r/\.php$/], &match_rejected_path(path, &1)) do TilexWeb.ErrorView.render_error_page(conn, 404) else conn diff --git a/mix.lock b/mix.lock index 95c282c1..3fc31787 100644 --- a/mix.lock +++ b/mix.lock @@ -17,7 +17,7 @@ "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, - "earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"}, + "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "ecto": {:hex, :ecto, "3.13.4", "27834b45d58075d4a414833d9581e8b7bb18a8d9f264a21e42f653d500dbeeb5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ad7d1505685dfa7aaf86b133d54f5ad6c42df0b4553741a1ff48796736e88b2"}, "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, diff --git a/openspec/project.md b/openspec/project.md index 64c19dc5..61dfc45a 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -13,7 +13,7 @@ Key characteristics: ## Tech Stack ### Backend -- **Language**: Elixir 1.19.0 / Erlang OTP 28.1 +- **Language**: Elixir 1.18.4 / Erlang OTP 28.1 - **Web Framework**: Phoenix ~> 1.6.14 with LiveView ~> 0.18 - **Database**: PostgreSQL with Ecto ~> 3.6 ORM - **Authentication**: Ueberauth + Google OAuth 2.0, Guardian JWT tokens From c357a0881d671bf041e44ec79e75f41c25ae2071 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 14:14:35 -0400 Subject: [PATCH 21/23] Add published_at in the seeds file Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- priv/repo/seeds.exs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index bd5b0f21..f20b22ea 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -33,7 +33,8 @@ developer = body: "A Gold Master Test in Practice", channel: phoenix_channel, developer: developer, - slug: Post.generate_slug() + slug: Post.generate_slug(), + published_at: DateTime.utc_now() }) Repo.insert!(%Post{ @@ -41,7 +42,8 @@ developer = body: "Slow browser integration tests are a hard problem", channel: elixir_channel, developer: developer, - slug: Post.generate_slug() + slug: Post.generate_slug(), + published_at: DateTime.utc_now() }) Repo.insert!(%Post{ @@ -49,6 +51,7 @@ developer = body: "A Rubyist's Journey", channel: erlang_channel, developer: developer, - slug: Post.generate_slug() + slug: Post.generate_slug(), + published_at: DateTime.utc_now() }) end) From 92892832a42de6f84de1831d12850af8edfe4a6e Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 14:24:36 -0400 Subject: [PATCH 22/23] Disable ssh cert verification Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- config/runtime.exs | 1 + 1 file changed, 1 insertion(+) diff --git a/config/runtime.exs b/config/runtime.exs index 496e86ae..b96faee8 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -32,6 +32,7 @@ if config_env() == :prod do config :tilex, Tilex.Repo, ssl: true, + ssl_opts: [verify: :verify_none], url: database_url, pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), socket_options: maybe_ipv6 From ba1aeb9d8befbdf4685597082775e5a8321fdac9 Mon Sep 17 00:00:00 2001 From: Hashrocket Workstation Date: Thu, 30 Oct 2025 14:32:55 -0400 Subject: [PATCH 23/23] Remove seeds from heroku app json file Co-authored-by: Vinicius Negrisolo Co-authored-by: Gabriel Reis --- app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.json b/app.json index f3681d69..f87a8679 100644 --- a/app.json +++ b/app.json @@ -1,7 +1,7 @@ { "name": "tilex", "scripts": { - "postdeploy": "mix ecto.migrate && POOL_SIZE=1 mix run priv/repo/seeds.exs" + "postdeploy": "POOL_SIZE=1 mix ecto.migrate" }, "env": { "BASIC_AUTH_PASSWORD": {