diff --git a/elixir-mcp/lib/feedback_a_tron/application.ex b/elixir-mcp/lib/feedback_a_tron/application.ex index 5df7afa..8f1f43c 100644 --- a/elixir-mcp/lib/feedback_a_tron/application.ex +++ b/elixir-mcp/lib/feedback_a_tron/application.ex @@ -30,12 +30,31 @@ defmodule FeedbackATron.Application do ] children = maybe_add_mcp_server(children) + children = maybe_add_http_intake(children) children = maybe_add_migration_observer(children) opts = [strategy: :one_for_one, name: FeedbackATron.Supervisor] Supervisor.start_link(children, opts) end + # Optional HTTP intake — the localhost wire the boj gateway/`bug-filing-mcp` + # cartridge drives to reach the engine (docs/AUTONOMOUS-BUG-PIPELINE.adoc, C2/D0). + # Off by default; bound to loopback only. + defp maybe_add_http_intake(children) do + if http_intake_enabled?() do + children ++ + [ + {Bandit, + plug: FeedbackATron.HTTPIntake.Router, + scheme: :http, + ip: http_intake_ip(), + port: http_intake_port()} + ] + else + children + end + end + defp maybe_add_mcp_server(children) do if mcp_enabled?() do mcp_child = { @@ -82,6 +101,41 @@ defmodule FeedbackATron.Application do env_on? || Enum.any?(System.argv(), &(&1 == "--mcp-server")) end + defp http_intake_enabled? do + env_on?(System.get_env("FEEDBACK_A_TRON_HTTP")) || + Enum.any?(System.argv(), &(&1 == "--http-intake")) + end + + defp http_intake_port do + case System.get_env("FEEDBACK_A_TRON_HTTP_PORT") do + nil -> + 7722 + + value -> + case Integer.parse(String.trim(value)) do + {port, _} -> port + :error -> 7722 + end + end + end + + # Loopback only by default; override with FEEDBACK_A_TRON_HTTP_BIND (e.g. "0.0.0.0"). + defp http_intake_ip do + bind = System.get_env("FEEDBACK_A_TRON_HTTP_BIND") || "127.0.0.1" + + case :inet.parse_address(String.to_charlist(String.trim(bind))) do + {:ok, ip} -> ip + {:error, _} -> {127, 0, 0, 1} + end + end + + defp env_on?(nil), do: false + + defp env_on?(value) do + normalized = value |> String.trim() |> String.downcase() + Enum.member?(["1", "true", "yes", "on"], normalized) + end + defp migration_observer_enabled? do env_val = System.get_env("FEEDBACK_A_TRON_MIGRATION_MODE") diff --git a/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex b/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex new file mode 100644 index 0000000..c102af3 --- /dev/null +++ b/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex @@ -0,0 +1,157 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule FeedbackATron.HTTPIntake.Router do + @moduledoc """ + Optional HTTP intake for the autonomous bug-reporting pipeline. + + This is the wire the boj gateway → `bug-filing-mcp` cartridge drives to reach the + real engine (see `docs/AUTONOMOUS-BUG-PIPELINE.adoc`, contract C2 / D0 = new wrapping + cartridge). It is a thin, localhost-only JSON adapter over the *same* `Submitter.submit/2` + path used by the MCP `submit_feedback` tool — no separate submission logic, so audit + logging, dedup, rate-limiting and dry-run all behave identically. + + Off by default; enabled via `FEEDBACK_A_TRON_HTTP` (see `FeedbackATron.Application`). + + ## Routes + + - `GET /health` → `{"status":"ok"}` + - `POST /api/v1/submit_feedback` → body `{title, body, repo, platforms?, labels?, dry_run?, skip_dedupe?}` + + The request/response shapes intentionally match the MCP tool's `input_schema` so a + cartridge can forward the same arguments unchanged. + """ + + use Plug.Router + require Logger + + alias FeedbackATron.Submitter + + plug(:match) + plug(Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason) + plug(:dispatch) + + get "/health" do + send_json(conn, 200, %{status: "ok", service: "feedback-a-tron", intake: "http"}) + end + + post "/api/v1/submit_feedback" do + try do + case validate(conn.body_params) do + {:ok, issue, opts} -> + case Submitter.submit(issue, opts) do + {:ok, submission_id, results} -> + send_json(conn, 200, format_results(submission_id, results)) + + {:error, reason} -> + Logger.error("HTTP submit_feedback failed: #{inspect(reason)}") + send_json(conn, 502, %{error: "submission_failed", detail: inspect(reason)}) + end + + {:error, missing} -> + send_json(conn, 400, %{error: "missing_required_fields", fields: missing}) + end + rescue + exception -> + Logger.error("HTTP submit_feedback exception: #{Exception.message(exception)}") + send_json(conn, 500, %{error: "internal_error", detail: Exception.message(exception)}) + end + end + + match _ do + send_json(conn, 404, %{error: "not_found"}) + end + + # --- helpers --------------------------------------------------------------- + + # Mirrors FeedbackATron.MCP.Tools.SubmitFeedback: required [title, body, repo]. + defp validate(params) when is_map(params) do + missing = + ["title", "body", "repo"] + |> Enum.filter(fn k -> blank?(Map.get(params, k)) end) + + if missing == [] do + issue = %{ + title: params["title"], + body: params["body"], + repo: params["repo"] + } + + opts = [ + platforms: parse_platforms(params["platforms"]), + labels: params["labels"] || [], + dry_run: params["dry_run"] || false, + dedupe: not (params["skip_dedupe"] || false) + ] + + {:ok, issue, opts} + else + {:error, missing} + end + end + + defp validate(_), do: {:error, ["title", "body", "repo"]} + + defp blank?(nil), do: true + defp blank?(v) when is_binary(v), do: String.trim(v) == "" + defp blank?(_), do: false + + defp parse_platforms(nil), do: [:github] + + defp parse_platforms(platforms) when is_list(platforms) do + platforms + |> Enum.map(&platform_atom/1) + |> Enum.filter(& &1) + |> case do + [] -> [:github] + list -> list + end + end + + defp parse_platforms(_), do: [:github] + + defp platform_atom("github"), do: :github + defp platform_atom("gitlab"), do: :gitlab + defp platform_atom("bitbucket"), do: :bitbucket + defp platform_atom("codeberg"), do: :codeberg + defp platform_atom("bugzilla"), do: :bugzilla + defp platform_atom("email"), do: :email + defp platform_atom(_), do: nil + + defp format_results(submission_id, results) do + formatted = + Enum.map(results, fn + {:ok, %{platform: platform, url: url}} -> + %{platform: platform, status: "success", url: url} + + {:ok, %{platform: platform, status: :dry_run, would_submit: issue}} -> + %{platform: platform, status: "dry_run", title: issue.title} + + {:error, %{platform: platform, error: error}} -> + %{platform: platform, status: "error", error: inspect(error)} + + {:error, {:duplicate_found, similar}} -> + %{status: "skipped", reason: "duplicate", similar_issues: similar} + + {:error, {:similar_found, matches}} -> + %{status: "skipped", reason: "similar_found", similar_issues: matches} + + {:error, other} -> + %{status: "error", error: inspect(other)} + end) + + %{submission_id: submission_id, results: formatted, summary: summarize(formatted)} + end + + defp summarize(results) do + count = fn status -> Enum.count(results, &(Map.get(&1, :status) == status)) end + + "Submitted: #{count.("success")}, Errors: #{count.("error")}, " <> + "Skipped: #{count.("skipped")}, Dry run: #{count.("dry_run")}" + end + + defp send_json(conn, status, payload) do + conn + |> put_resp_content_type("application/json") + |> send_resp(status, Jason.encode!(payload)) + end +end diff --git a/elixir-mcp/mix.exs b/elixir-mcp/mix.exs index be95d22..3f69620 100644 --- a/elixir-mcp/mix.exs +++ b/elixir-mcp/mix.exs @@ -41,6 +41,10 @@ defmodule FeedbackATron.MixProject do # HTTP client {:req, "~> 0.5"}, + # HTTP intake server (optional localhost bridge for the boj gateway/cartridge) + {:bandit, "~> 1.5"}, + {:plug, "~> 1.16"}, + # JSON {:jason, "~> 1.4"}, {:yaml_elixir, "~> 2.9"}, diff --git a/elixir-mcp/mix.lock b/elixir-mcp/mix.lock index 68e312c..f424e5d 100644 --- a/elixir-mcp/mix.lock +++ b/elixir-mcp/mix.lock @@ -1,4 +1,5 @@ %{ + "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "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"}, @@ -29,7 +30,9 @@ "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [: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", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "the_fuzz": {:hex, :the_fuzz, "0.6.0", "b5ec2bfa099d8cf66f18c3c8cb148428021ff0ede070489857960d66de6f0c12", [:mix], [], "hexpm", "1bd9e19c42ef40d5d5aa419f040765fb970028c6d20e6702d634da1be1363f0d"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, "toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/elixir-mcp/test/feedback_a_tron/http_intake/router_test.exs b/elixir-mcp/test/feedback_a_tron/http_intake/router_test.exs new file mode 100644 index 0000000..e8588ea --- /dev/null +++ b/elixir-mcp/test/feedback_a_tron/http_intake/router_test.exs @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule FeedbackATron.HTTPIntake.RouterTest do + use ExUnit.Case, async: true + import Plug.Test + import Plug.Conn + + alias FeedbackATron.HTTPIntake.Router + + @opts Router.init([]) + + defp json_post(path, body) do + conn(:post, path, Jason.encode!(body)) + |> put_req_header("content-type", "application/json") + |> Router.call(@opts) + end + + test "GET /health returns ok" do + conn = conn(:get, "/health") |> Router.call(@opts) + assert conn.status == 200 + assert %{"status" => "ok"} = Jason.decode!(conn.resp_body) + end + + test "POST /api/v1/submit_feedback rejects missing required fields" do + conn = json_post("/api/v1/submit_feedback", %{title: "t", body: "b"}) + assert conn.status == 400 + body = Jason.decode!(conn.resp_body) + assert body["error"] == "missing_required_fields" + assert "repo" in body["fields"] + end + + test "POST /api/v1/submit_feedback treats blank strings as missing" do + conn = json_post("/api/v1/submit_feedback", %{title: " ", body: "b", repo: "o/r"}) + assert conn.status == 400 + body = Jason.decode!(conn.resp_body) + assert "title" in body["fields"] + end + + test "unknown route returns 404 json" do + conn = conn(:get, "/nope") |> Router.call(@opts) + assert conn.status == 404 + assert %{"error" => "not_found"} = Jason.decode!(conn.resp_body) + end +end