Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions elixir-mcp/lib/feedback_a_tron/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")

Expand Down
157 changes: 157 additions & 0 deletions elixir-mcp/lib/feedback_a_tron/http_intake/router.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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
4 changes: 4 additions & 0 deletions elixir-mcp/mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
3 changes: 3 additions & 0 deletions elixir-mcp/mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down Expand Up @@ -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"},
}
44 changes: 44 additions & 0 deletions elixir-mcp/test/feedback_a_tron/http_intake/router_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
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
Loading