Skip to content

Commit 00806b5

Browse files
Merge branch 'main' into fix/submit-batch-matcherror
2 parents 2e6ffc1 + 05d2d3b commit 00806b5

5 files changed

Lines changed: 262 additions & 0 deletions

File tree

elixir-mcp/lib/feedback_a_tron/application.ex

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,31 @@ defmodule FeedbackATron.Application do
3030
]
3131

3232
children = maybe_add_mcp_server(children)
33+
children = maybe_add_http_intake(children)
3334
children = maybe_add_migration_observer(children)
3435

3536
opts = [strategy: :one_for_one, name: FeedbackATron.Supervisor]
3637
Supervisor.start_link(children, opts)
3738
end
3839

40+
# Optional HTTP intake — the localhost wire the boj gateway/`bug-filing-mcp`
41+
# cartridge drives to reach the engine (docs/AUTONOMOUS-BUG-PIPELINE.adoc, C2/D0).
42+
# Off by default; bound to loopback only.
43+
defp maybe_add_http_intake(children) do
44+
if http_intake_enabled?() do
45+
children ++
46+
[
47+
{Bandit,
48+
plug: FeedbackATron.HTTPIntake.Router,
49+
scheme: :http,
50+
ip: http_intake_ip(),
51+
port: http_intake_port()}
52+
]
53+
else
54+
children
55+
end
56+
end
57+
3958
defp maybe_add_mcp_server(children) do
4059
if mcp_enabled?() do
4160
mcp_child = {
@@ -82,6 +101,41 @@ defmodule FeedbackATron.Application do
82101
env_on? || Enum.any?(System.argv(), &(&1 == "--mcp-server"))
83102
end
84103

104+
defp http_intake_enabled? do
105+
env_on?(System.get_env("FEEDBACK_A_TRON_HTTP")) ||
106+
Enum.any?(System.argv(), &(&1 == "--http-intake"))
107+
end
108+
109+
defp http_intake_port do
110+
case System.get_env("FEEDBACK_A_TRON_HTTP_PORT") do
111+
nil ->
112+
7722
113+
114+
value ->
115+
case Integer.parse(String.trim(value)) do
116+
{port, _} -> port
117+
:error -> 7722
118+
end
119+
end
120+
end
121+
122+
# Loopback only by default; override with FEEDBACK_A_TRON_HTTP_BIND (e.g. "0.0.0.0").
123+
defp http_intake_ip do
124+
bind = System.get_env("FEEDBACK_A_TRON_HTTP_BIND") || "127.0.0.1"
125+
126+
case :inet.parse_address(String.to_charlist(String.trim(bind))) do
127+
{:ok, ip} -> ip
128+
{:error, _} -> {127, 0, 0, 1}
129+
end
130+
end
131+
132+
defp env_on?(nil), do: false
133+
134+
defp env_on?(value) do
135+
normalized = value |> String.trim() |> String.downcase()
136+
Enum.member?(["1", "true", "yes", "on"], normalized)
137+
end
138+
85139
defp migration_observer_enabled? do
86140
env_val = System.get_env("FEEDBACK_A_TRON_MIGRATION_MODE")
87141

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule FeedbackATron.HTTPIntake.Router do
4+
@moduledoc """
5+
Optional HTTP intake for the autonomous bug-reporting pipeline.
6+
7+
This is the wire the boj gateway → `bug-filing-mcp` cartridge drives to reach the
8+
real engine (see `docs/AUTONOMOUS-BUG-PIPELINE.adoc`, contract C2 / D0 = new wrapping
9+
cartridge). It is a thin, localhost-only JSON adapter over the *same* `Submitter.submit/2`
10+
path used by the MCP `submit_feedback` tool — no separate submission logic, so audit
11+
logging, dedup, rate-limiting and dry-run all behave identically.
12+
13+
Off by default; enabled via `FEEDBACK_A_TRON_HTTP` (see `FeedbackATron.Application`).
14+
15+
## Routes
16+
17+
- `GET /health` → `{"status":"ok"}`
18+
- `POST /api/v1/submit_feedback` → body `{title, body, repo, platforms?, labels?, dry_run?, skip_dedupe?}`
19+
20+
The request/response shapes intentionally match the MCP tool's `input_schema` so a
21+
cartridge can forward the same arguments unchanged.
22+
"""
23+
24+
use Plug.Router
25+
require Logger
26+
27+
alias FeedbackATron.Submitter
28+
29+
plug(:match)
30+
plug(Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason)
31+
plug(:dispatch)
32+
33+
get "/health" do
34+
send_json(conn, 200, %{status: "ok", service: "feedback-a-tron", intake: "http"})
35+
end
36+
37+
post "/api/v1/submit_feedback" do
38+
try do
39+
case validate(conn.body_params) do
40+
{:ok, issue, opts} ->
41+
case Submitter.submit(issue, opts) do
42+
{:ok, submission_id, results} ->
43+
send_json(conn, 200, format_results(submission_id, results))
44+
45+
{:error, reason} ->
46+
Logger.error("HTTP submit_feedback failed: #{inspect(reason)}")
47+
send_json(conn, 502, %{error: "submission_failed", detail: inspect(reason)})
48+
end
49+
50+
{:error, missing} ->
51+
send_json(conn, 400, %{error: "missing_required_fields", fields: missing})
52+
end
53+
rescue
54+
exception ->
55+
Logger.error("HTTP submit_feedback exception: #{Exception.message(exception)}")
56+
send_json(conn, 500, %{error: "internal_error", detail: Exception.message(exception)})
57+
end
58+
end
59+
60+
match _ do
61+
send_json(conn, 404, %{error: "not_found"})
62+
end
63+
64+
# --- helpers ---------------------------------------------------------------
65+
66+
# Mirrors FeedbackATron.MCP.Tools.SubmitFeedback: required [title, body, repo].
67+
defp validate(params) when is_map(params) do
68+
missing =
69+
["title", "body", "repo"]
70+
|> Enum.filter(fn k -> blank?(Map.get(params, k)) end)
71+
72+
if missing == [] do
73+
issue = %{
74+
title: params["title"],
75+
body: params["body"],
76+
repo: params["repo"]
77+
}
78+
79+
opts = [
80+
platforms: parse_platforms(params["platforms"]),
81+
labels: params["labels"] || [],
82+
dry_run: params["dry_run"] || false,
83+
dedupe: not (params["skip_dedupe"] || false)
84+
]
85+
86+
{:ok, issue, opts}
87+
else
88+
{:error, missing}
89+
end
90+
end
91+
92+
defp validate(_), do: {:error, ["title", "body", "repo"]}
93+
94+
defp blank?(nil), do: true
95+
defp blank?(v) when is_binary(v), do: String.trim(v) == ""
96+
defp blank?(_), do: false
97+
98+
defp parse_platforms(nil), do: [:github]
99+
100+
defp parse_platforms(platforms) when is_list(platforms) do
101+
platforms
102+
|> Enum.map(&platform_atom/1)
103+
|> Enum.filter(& &1)
104+
|> case do
105+
[] -> [:github]
106+
list -> list
107+
end
108+
end
109+
110+
defp parse_platforms(_), do: [:github]
111+
112+
defp platform_atom("github"), do: :github
113+
defp platform_atom("gitlab"), do: :gitlab
114+
defp platform_atom("bitbucket"), do: :bitbucket
115+
defp platform_atom("codeberg"), do: :codeberg
116+
defp platform_atom("bugzilla"), do: :bugzilla
117+
defp platform_atom("email"), do: :email
118+
defp platform_atom(_), do: nil
119+
120+
defp format_results(submission_id, results) do
121+
formatted =
122+
Enum.map(results, fn
123+
{:ok, %{platform: platform, url: url}} ->
124+
%{platform: platform, status: "success", url: url}
125+
126+
{:ok, %{platform: platform, status: :dry_run, would_submit: issue}} ->
127+
%{platform: platform, status: "dry_run", title: issue.title}
128+
129+
{:error, %{platform: platform, error: error}} ->
130+
%{platform: platform, status: "error", error: inspect(error)}
131+
132+
{:error, {:duplicate_found, similar}} ->
133+
%{status: "skipped", reason: "duplicate", similar_issues: similar}
134+
135+
{:error, {:similar_found, matches}} ->
136+
%{status: "skipped", reason: "similar_found", similar_issues: matches}
137+
138+
{:error, other} ->
139+
%{status: "error", error: inspect(other)}
140+
end)
141+
142+
%{submission_id: submission_id, results: formatted, summary: summarize(formatted)}
143+
end
144+
145+
defp summarize(results) do
146+
count = fn status -> Enum.count(results, &(Map.get(&1, :status) == status)) end
147+
148+
"Submitted: #{count.("success")}, Errors: #{count.("error")}, " <>
149+
"Skipped: #{count.("skipped")}, Dry run: #{count.("dry_run")}"
150+
end
151+
152+
defp send_json(conn, status, payload) do
153+
conn
154+
|> put_resp_content_type("application/json")
155+
|> send_resp(status, Jason.encode!(payload))
156+
end
157+
end

elixir-mcp/mix.exs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ defmodule FeedbackATron.MixProject do
4141
# HTTP client
4242
{:req, "~> 0.5"},
4343

44+
# HTTP intake server (optional localhost bridge for the boj gateway/cartridge)
45+
{:bandit, "~> 1.5"},
46+
{:plug, "~> 1.16"},
47+
4448
# JSON
4549
{:jason, "~> 1.4"},
4650
{:yaml_elixir, "~> 2.9"},

elixir-mcp/mix.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
%{
2+
"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"},
23
"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"},
34
"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"},
45
"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 @@
2930
"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"},
3031
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
3132
"the_fuzz": {:hex, :the_fuzz, "0.6.0", "b5ec2bfa099d8cf66f18c3c8cb148428021ff0ede070489857960d66de6f0c12", [:mix], [], "hexpm", "1bd9e19c42ef40d5d5aa419f040765fb970028c6d20e6702d634da1be1363f0d"},
33+
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
3234
"toml": {:hex, :toml, "0.7.0", "fbcd773caa937d0c7a02c301a1feea25612720ac3fa1ccb8bfd9d30d822911de", [:mix], [], "hexpm", "0690246a2478c1defd100b0c9b89b4ea280a22be9a7b313a8a058a2408a2fa70"},
35+
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
3336
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
3437
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
3538
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
defmodule FeedbackATron.HTTPIntake.RouterTest do
4+
use ExUnit.Case, async: true
5+
import Plug.Test
6+
import Plug.Conn
7+
8+
alias FeedbackATron.HTTPIntake.Router
9+
10+
@opts Router.init([])
11+
12+
defp json_post(path, body) do
13+
conn(:post, path, Jason.encode!(body))
14+
|> put_req_header("content-type", "application/json")
15+
|> Router.call(@opts)
16+
end
17+
18+
test "GET /health returns ok" do
19+
conn = conn(:get, "/health") |> Router.call(@opts)
20+
assert conn.status == 200
21+
assert %{"status" => "ok"} = Jason.decode!(conn.resp_body)
22+
end
23+
24+
test "POST /api/v1/submit_feedback rejects missing required fields" do
25+
conn = json_post("/api/v1/submit_feedback", %{title: "t", body: "b"})
26+
assert conn.status == 400
27+
body = Jason.decode!(conn.resp_body)
28+
assert body["error"] == "missing_required_fields"
29+
assert "repo" in body["fields"]
30+
end
31+
32+
test "POST /api/v1/submit_feedback treats blank strings as missing" do
33+
conn = json_post("/api/v1/submit_feedback", %{title: " ", body: "b", repo: "o/r"})
34+
assert conn.status == 400
35+
body = Jason.decode!(conn.resp_body)
36+
assert "title" in body["fields"]
37+
end
38+
39+
test "unknown route returns 404 json" do
40+
conn = conn(:get, "/nope") |> Router.call(@opts)
41+
assert conn.status == 404
42+
assert %{"error" => "not_found"} = Jason.decode!(conn.resp_body)
43+
end
44+
end

0 commit comments

Comments
 (0)