diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..a4026c6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: MPL-2.0 +name: "Bug report" +description: "Report something that is broken or behaving unexpectedly." +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: "What happened?" + description: "Describe the problem. What did you see?" + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: "Steps to reproduce" + description: "Exact steps that trigger the problem, one per line." + placeholder: "1. ...\n2. ...\n3. ..." + validations: + required: true + - type: textarea + id: expected + attributes: + label: "Expected behaviour" + description: "What did you expect to happen instead?" + - type: input + id: version + attributes: + label: "Version" + placeholder: "e.g. 1.0.0 / commit SHA" + validations: + required: true + - type: dropdown + id: component + attributes: + label: "Component" + description: "Which part of feedback-o-tron is affected?" + options: + - "MCP server" + - "HTTP intake" + - "Synthesis" + - "Channels/dispatch" + - "CLI" + - "Other" + - type: textarea + id: logs + attributes: + label: "Relevant logs / stack trace" + description: "Paste any logs or stack traces. This block is rendered as shell, so no backticks needed." + render: shell + - type: textarea + id: environment + attributes: + label: "Environment" + description: "OS, OTP/Elixir versions, network specifics" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..86cc18a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: MPL-2.0 +blank_issues_enabled: true diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..fdd8bd2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: MPL-2.0 +name: "Feature request" +description: "Suggest an improvement or new capability." +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: "Problem" + description: "What problem are you trying to solve? What is painful or missing today?" + validations: + required: true + - type: textarea + id: proposed-solution + attributes: + label: "Proposed solution" + description: "What would you like to happen?" + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: "Alternatives considered" + description: "Other approaches or workarounds you have considered." + - type: input + id: scope + attributes: + label: "Scope" + placeholder: "e.g. MCP server, HTTP intake, CLI" diff --git a/.github/workflows/elixir-ci.yml b/.github/workflows/elixir-ci.yml index e97d026..a451ba8 100644 --- a/.github/workflows/elixir-ci.yml +++ b/.github/workflows/elixir-ci.yml @@ -34,17 +34,24 @@ jobs: - name: Detect relevant changes id: detect working-directory: ${{ github.workspace }} - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail PATTERN='^elixir-mcp/' + # Fail-safe detector (estate required-gate convention): default to + # running the suite; skip ONLY when a successful diff against the + # base tip shows nothing relevant. Uses git (two-dot tree diff, so + # shallow checkouts work) instead of `gh api`, whose non-JSON error + # responses hard-failed this step (#51). if [ "${{ github.event_name }}" = "pull_request" ]; then - FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename') - if printf '%s\n' "$FILES" | grep -qE "$PATTERN"; then - echo "relevant=true" >> "$GITHUB_OUTPUT" + if git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" \ + && FILES=$(git diff --name-only FETCH_HEAD HEAD); then + if printf '%s\n' "$FILES" | grep -qE "$PATTERN"; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT"; echo "No elixir-mcp changes — pass-through (required-check shim)." + fi else - echo "relevant=false" >> "$GITHUB_OUTPUT"; echo "No elixir-mcp changes — pass-through (required-check shim)." + echo "relevant=true" >> "$GITHUB_OUTPUT"; echo "Detector could not compute the diff — failing safe (run=true)." fi else echo "relevant=true" >> "$GITHUB_OUTPUT" @@ -92,17 +99,24 @@ jobs: - name: Detect relevant changes id: detect working-directory: ${{ github.workspace }} - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail PATTERN='^elixir-mcp/' + # Fail-safe detector (estate required-gate convention): default to + # running the suite; skip ONLY when a successful diff against the + # base tip shows nothing relevant. Uses git (two-dot tree diff, so + # shallow checkouts work) instead of `gh api`, whose non-JSON error + # responses hard-failed this step (#51). if [ "${{ github.event_name }}" = "pull_request" ]; then - FILES=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --paginate --jq '.[].filename') - if printf '%s\n' "$FILES" | grep -qE "$PATTERN"; then - echo "relevant=true" >> "$GITHUB_OUTPUT" + if git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" \ + && FILES=$(git diff --name-only FETCH_HEAD HEAD); then + if printf '%s\n' "$FILES" | grep -qE "$PATTERN"; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT"; echo "No elixir-mcp changes — pass-through (required-check shim)." + fi else - echo "relevant=false" >> "$GITHUB_OUTPUT"; echo "No elixir-mcp changes — pass-through (required-check shim)." + echo "relevant=true" >> "$GITHUB_OUTPUT"; echo "Detector could not compute the diff — failing safe (run=true)." fi else echo "relevant=true" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/proofs.yml b/.github/workflows/proofs.yml new file mode 100644 index 0000000..5817dee --- /dev/null +++ b/.github/workflows/proofs.yml @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Proofs Gate — type-check the Idris2 contract layer (src/abi/) and enforce +# the empty trusted base (no believe_me / postulate / assert_total). +# +# FeedbackOTron.Contract is the verified CONTRACT SPEC for the Elixir +# FormValidator (see ABI-FFI-README.md). This gate keeps it honestly +# compiling: every push/PR touching src/abi/** re-checks the package under +# a pinned Idris2 toolchain (same asdf+Chez install pattern as boj-server's +# proofs gate). +# +# NOTE: this workflow is path-filtered because it is NOT a required status +# check. If it is ever promoted to required, the `on.*.paths` filters MUST +# be removed and replaced with an always-run `changes` detector job (see +# boj-server .github/workflows/proofs.yml and the estate CI conventions: +# a path-filtered required check that never triggers reports as permanently +# "Expected" and deadlocks the PR). +name: Proofs Gate + +on: + push: + branches: [main] + paths: + - 'src/abi/**' + - '.github/workflows/proofs.yml' + pull_request: + branches: [main] + paths: + - 'src/abi/**' + - '.github/workflows/proofs.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: proofs-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + IDRIS2_VERSION: '0.7.0' + +jobs: + # Cheap, dependency-free gate: the contract layer claims an EMPTY trusted + # base — no believe_me, no postulate, no assert_total anywhere in src/abi. + trusted-base: + name: Trusted-base audit (empty — no axioms) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Enforce empty trusted base + run: | + set -euo pipefail + found=0 + while IFS= read -r -d '' f; do + # Strip `--` and `|||` comments so documentation may mention the + # banned constructs without tripping the audit. + if sed -e 's/--.*$//' -e 's/|||.*$//' "$f" \ + | grep -nE 'believe_me|postulate|assert_total'; then + echo "::error file=$f::Unsound construct in $f — the contract layer must stay axiom-free." + found=1 + fi + done < <(find src/abi -name '*.idr' -print0) + if [ "$found" -eq 0 ]; then echo "Trusted base is empty."; fi + exit "$found" + + typecheck: + name: Idris2 type-check (contract layer) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install build deps (Chez Scheme + GMP) + run: sudo apt-get update && sudo apt-get install -y chezscheme libgmp-dev build-essential + + - name: Cache asdf + Idris2 toolchain + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.asdf + key: idris2-asdf-${{ runner.os }}-${{ env.IDRIS2_VERSION }} + + - name: Install asdf + Idris2 ${{ env.IDRIS2_VERSION }} + env: + # Ubuntu's chezscheme package installs the interpreter as `scheme`; + # Idris2's bootstrap Makefile reads $SCHEME (defaults to `chez`). + SCHEME: scheme + run: | + set -euo pipefail + if [ ! -f "$HOME/.asdf/asdf.sh" ]; then + git clone --depth 1 --branch v0.14.0 https://github.com/asdf-vm/asdf.git "$HOME/.asdf" + fi + . "$HOME/.asdf/asdf.sh" + asdf plugin add idris2 || true + asdf install idris2 "$IDRIS2_VERSION" + asdf global idris2 "$IDRIS2_VERSION" + + - name: Put Idris2 on PATH + run: echo "$HOME/.asdf/shims" >> "$GITHUB_PATH" + + - name: Idris2 version + run: idris2 --version + + - name: Type-check the contract package + working-directory: src/abi + run: idris2 --typecheck feedback-o-tron.ipkg diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index d25ec03..7709c99 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -1,32 +1,34 @@ # SPDX-License-Identifier: MPL-2.0 # STATE.a2ml — Project state checkpoint # Converted from STATE.scm on 2026-03-15 -# Updated 2026-04-09 +# Updated 2026-07-09 [metadata] project = "feedback-o-tron" -version = "1.0.0" -last-updated = "2026-04-09" -status = "production" +version = "1.2.0-dev" +last-updated = "2026-07-09" +status = "active-development" [project-context] name = "feedback-o-tron" -completion-percentage = 95 -phase = "Production" +completion-percentage = 75 +phase = "Synthesis Engine + honest reconciliation" [components] cli = "100%" -mcp-server = "100%" +mcp-server = "100% (research_feedback, synthesize_feedback, submit_feedback, migration_observe)" +http-intake = "done (Bandit :7722, localhost-only, off by default; boj bug-filing-mcp wire)" +synthesis-engine = "done (this PR: TemplateFetcher, TemplateCache, IntentClassifier, Hydrator, Research, Synthesizer, FormValidator, FormRenderer, TextSimilarity)" channels-github = "100%" -channels-gitlab = "100%" -channels-bitbucket = "100%" -channels-codeberg = "100%" +channels-gitlab = "90%" +channels-bitbucket = "90%" +channels-codeberg = "90%" channels-bugzilla = "100%" channels-email = "80%" -deduplication = "100%" +deduplication = "wired (record/3 called on successful submission; check/1 consulted pre-dispatch and by Research)" audit-logging = "100%" -network-verifier = "90%" +network-verifier = "70% (real probing; opt-in via Hydrator network_probe; BGP/RPKI partial)" rate-limiter = "100%" retry-backoff = "100%" -zig-ffi = "95%" -idris2-proofs = "80%" +zig-ffi = "stub" +idris2 = "contract spec only (Contract.idr), no FFI enforcement" diff --git a/ABI-FFI-README.md b/ABI-FFI-README.md index ada05ff..111a6ab 100644 --- a/ABI-FFI-README.md +++ b/ABI-FFI-README.md @@ -2,388 +2,120 @@ SPDX-License-Identifier: CC-BY-SA-4.0 Copyright (c) Jonathan D.A. Jewell --> -{{~ Aditionally delete this line and fill out the template below ~}} -# {{PROJECT}} ABI/FFI Documentation +# feedback-o-tron ABI/FFI Documentation -## Overview +This file describes what actually exists, honestly. Three layers, three +different maturity levels: -This library follows the **Hyperpolymath RSR Standard** for ABI and FFI design: +| Layer | Path | Status | +|---|---|---| +| Verified contract spec (Idris2) | `src/abi/FeedbackOTron/Contract.idr` | **REAL** — compiles, proofs machine-checked, CI-gated | +| Runtime implementation (Elixir) | `elixir-mcp/lib/feedback_a_tron/synthesis/form_validator.ex` | **REAL** — the validator on the live dispatch path | +| C-ABI FFI (Zig) | `ffi/zig/src/main.zig` | **STUB** — scaffolding only, not wired to anything | -- **ABI (Application Binary Interface)** defined in **Idris2** with formal proofs -- **FFI (Foreign Function Interface)** implemented in **Zig** for C compatibility -- **Generated C headers** bridge Idris2 ABI to Zig FFI -- **Any language** can call through standard C ABI +## 1. The verified contract spec (Idris2) — REAL -## Architecture +`src/abi/FeedbackOTron/Contract.idr` (package `src/abi/feedback-o-tron.ipkg`, +depends on `base` only) states the form-validation contract once, totally, +and proves that its `validate` function enforces it. It is type-checked in +CI by `.github/workflows/proofs.yml` on every push/PR touching `src/abi/**`, +under pinned Idris2 0.7.0. -``` -┌─────────────────────────────────────────────┐ -│ ABI Definitions (Idris2) │ -│ src/abi/ │ -│ - Types.idr (Type definitions) │ -│ - Layout.idr (Memory layout proofs) │ -│ - Foreign.idr (FFI declarations) │ -└─────────────────┬───────────────────────────┘ - │ - │ generates (at compile time) - ▼ -┌─────────────────────────────────────────────┐ -│ C Headers (auto-generated) │ -│ generated/abi/{{project}}.h │ -└─────────────────┬───────────────────────────┘ - │ - │ imported by - ▼ -┌─────────────────────────────────────────────┐ -│ FFI Implementation (Zig) │ -│ ffi/zig/src/main.zig │ -│ - Implements C-compatible functions │ -│ - Zero-cost abstractions │ -│ - Memory-safe by default │ -└─────────────────┬───────────────────────────┘ - │ - │ compiled to lib{{project}}.so/.a - ▼ -┌─────────────────────────────────────────────┐ -│ Any Language via C ABI │ -│ - Rust, ReScript, Julia, Python, etc. │ -└─────────────────────────────────────────────┘ -``` - -## Directory Structure - -``` -{{project}}/ -├── src/ -│ ├── abi/ # ABI definitions (Idris2) -│ │ ├── Types.idr # Core type definitions with proofs -│ │ ├── Layout.idr # Memory layout verification -│ │ └── Foreign.idr # FFI function declarations -│ └── lib/ # Core library (any language) -│ -├── ffi/ -│ └── zig/ # FFI implementation (Zig) -│ ├── build.zig # Build configuration -│ ├── build.zig.zon # Dependencies -│ ├── src/ -│ │ └── main.zig # C-compatible FFI implementation -│ ├── test/ -│ │ └── integration_test.zig -│ └── include/ -│ └── {{project}}.h # C header (optional, can be generated) -│ -├── generated/ # Auto-generated files -│ └── abi/ -│ └── {{project}}.h # Generated from Idris2 ABI -│ -└── bindings/ # Language-specific wrappers (optional) - ├── rust/ - ├── rescript/ - └── julia/ -``` - -## Why Idris2 for ABI? - -### 1. **Formal Verification** - -Idris2's dependent types allow proving properties about the ABI at compile-time: - -```idris --- Prove struct size is correct -public export -exampleStructSize : HasSize ExampleStruct 16 - --- Prove field alignment is correct -public export -fieldAligned : Divides 8 (offsetOf ExampleStruct.field) - --- Prove ABI is platform-compatible -public export -abiCompatible : Compatible (ABI 1) (ABI 2) -``` +The model: -### 2. **Type Safety** +- `Field` — `fieldId`, `label`, `required : Bool`, `options : List String`, + `fieldKind` (`Input | Textarea | Dropdown | Checkboxes | Markdown`) +- `Form` — a list of `Field`s +- `Answers` — `List (String, String)` (field id ↦ answer text) +- `Violation` — `RequiredMissing fieldId | UnknownField key | InvalidOption fieldId value` +- `validate : Form -> Answers -> Either (List Violation) ValidPayload` -Encode invariants that C/Zig cannot express: +The central safety device is `ValidPayload`: its data constructor is +**private** (the type is `export`, the constructor is not), so the only way +to obtain a `ValidPayload` is to get `validate` to say `Right`. Holding one +is machine-checked evidence the gate passed. `getAnswers : ValidPayload -> +Answers` reads the validated answers back out. -```idris --- Non-null pointer guaranteed at type level -data Handle : Type where - MkHandle : (ptr : Bits64) -> {auto 0 nonNull : So (ptr /= 0)} -> Handle +### Proved lemmas (no `believe_me`, no `postulate`, no `assert_total`) --- Array with length proof -data Buffer : (n : Nat) -> Type where - MkBuffer : Vect n Byte -> Buffer n -``` - -### 3. **Platform Abstraction** - -Platform-specific types with compile-time selection: - -```idris -CInt : Platform -> Type -CInt Linux = Bits32 -CInt Windows = Bits32 - -CSize : Platform -> Type -CSize Linux = Bits64 -CSize Windows = Bits64 -``` - -### 4. **Safe Evolution** +Everything is `%default total`; the trusted base is **empty** and CI enforces +that with a source audit (`trusted-base` job in `proofs.yml`). -Prove that new ABI versions are backward-compatible: +| Lemma | Statement | +|---|---| +| `validCompleteness` | `IsRight (validate f a)` → `AllRequiredAnswered f a` (Bool-reflection: `allRequiredAnswered f a = True`) — a successful validate means every required non-markdown field was answered | +| `validNoUnknownFields` | `IsRight (validate f a)` → `noUnknownFields f a = True` — no answer key outside the form's non-markdown field ids | +| `validOptionsValid` | `IsRight (validate f a)` → `allOptionsValid f a = True` — every dropdown answer is one of its field's options | +| `validGate` | `IsRight (validate f a)` → `checksPass f a = True` (the master gate; the three above are its `&&`-eliminations) | +| `checksPassValidates` | converse: `checksPass f a = True` → `IsRight (validate f a)` — validate is pinned to the boolean spec in both directions | +| `validateAnswersPreserved` | `validate f a = Right vp` → `getAnswers vp = a` — validate never invents or drops answers | +| `emptyFormEmptyAnswersOk` | sanity witness: the empty form with no answers validates | -```idris --- Compiler enforces compatibility -abiUpgrade : ABI 1 -> ABI 2 -abiUpgrade old = MkABI2 { - -- Must preserve all v1 fields - v1_compat = old, - -- Can add new fields - new_features = defaults -} -``` - -## Why Zig for FFI? - -### 1. **C ABI Compatibility** - -Zig exports C-compatible functions naturally: - -```zig -export fn library_function(param: i32) i32 { - return param * 2; -} -``` +The proofs are deliberately by-inspection: `validate` is *defined via* the +boolean reflection (`validateGo (checksPass f a) (violations f a) a`), so the +lemmas follow by case analysis and `&&`-elimination, not heroics. -### 2. **Memory Safety** - -Compile-time safety without runtime overhead: - -```zig -// Null check enforced at compile time -const handle = init() orelse return error.InitFailed; -defer free(handle); -``` - -### 3. **Cross-Compilation** - -Built-in cross-compilation to any platform: - -```bash -zig build -Dtarget=x86_64-linux -zig build -Dtarget=aarch64-macos -zig build -Dtarget=x86_64-windows -``` - -### 4. **Zero Dependencies** - -No runtime, no libc required (unless explicitly needed): - -```zig -// Minimal binary size -pub const lib = @import("std"); -// Only includes what you use -``` - -## Building - -### Build FFI Library - -```bash -cd ffi/zig -zig build # Build debug -zig build -Doptimize=ReleaseFast # Build optimized -zig build test # Run tests -``` - -### Generate C Header from Idris2 ABI +Check locally: ```bash cd src/abi -idris2 --cg c-header Types.idr -o ../../generated/abi/{{project}}.h -``` - -### Cross-Compile - -```bash -cd ffi/zig - -# Linux x86_64 -zig build -Dtarget=x86_64-linux - -# macOS ARM64 -zig build -Dtarget=aarch64-macos - -# Windows x86_64 -zig build -Dtarget=x86_64-windows -``` - -## Usage - -### From C - -```c -#include "{{project}}.h" - -int main() { - void* handle = {{project}}_init(); - if (!handle) return 1; - - int result = {{project}}_process(handle, 42); - if (result != 0) { - const char* err = {{project}}_last_error(); - fprintf(stderr, "Error: %s\n", err); - } - - {{project}}_free(handle); - return 0; -} -``` - -Compile with: -```bash -gcc -o example example.c -l{{project}} -L./zig-out/lib -``` - -### From Idris2 - -```idris -import {{PROJECT}}.ABI.Foreign - -main : IO () -main = do - Just handle <- init - | Nothing => putStrLn "Failed to initialize" - - Right result <- process handle 42 - | Left err => putStrLn $ "Error: " ++ errorDescription err - - free handle - putStrLn "Success" -``` - -### From Rust - -```rust -#[link(name = "{{project}}")] -extern "C" { - fn {{project}}_init() -> *mut std::ffi::c_void; - fn {{project}}_free(handle: *mut std::ffi::c_void); - fn {{project}}_process(handle: *mut std::ffi::c_void, input: u32) -> i32; -} - -fn main() { - unsafe { - let handle = {{project}}_init(); - assert!(!handle.is_null()); - - let result = {{project}}_process(handle, 42); - assert_eq!(result, 0); - - {{project}}_free(handle); - } -} -``` - -### From Julia - -```julia -const lib{{project}} = "lib{{project}}" - -function init() - handle = ccall((:{{project}}_init, lib{{project}}), Ptr{Cvoid}, ()) - handle == C_NULL && error("Failed to initialize") - handle -end - -function process(handle, input) - result = ccall((:{{project}}_process, lib{{project}}), Cint, (Ptr{Cvoid}, UInt32), handle, input) - result -end - -function cleanup(handle) - ccall((:{{project}}_free, lib{{project}}), Cvoid, (Ptr{Cvoid},), handle) -end - -# Usage -handle = init() -try - result = process(handle, 42) - println("Result: $result") -finally - cleanup(handle) -end -``` - -## Testing - -### Unit Tests (Zig) - -```bash -cd ffi/zig -zig build test -``` - -### Integration Tests - -```bash -cd ffi/zig -zig build test-integration -``` - -### ABI Verification (Idris2) - -```idris --- Compile-time verification -%runElab verifyABI - --- Runtime checks -main : IO () -main = do - verifyLayoutsCorrect - verifyAlignmentsCorrect - putStrLn "ABI verification passed" -``` - -## Contributing - -When modifying the ABI/FFI: - -1. **Update ABI first** (`src/abi/*.idr`) - - Modify type definitions - - Update proofs - - Ensure backward compatibility - -2. **Generate C header** - ```bash - idris2 --cg c-header src/abi/Types.idr -o generated/abi/{{project}}.h - ``` - -3. **Update FFI implementation** (`ffi/zig/src/main.zig`) - - Implement new functions - - Match ABI types exactly - -4. **Add tests** - - Unit tests in Zig - - Integration tests - - ABI verification tests - -5. **Update documentation** - - Function signatures - - Usage examples - - Migration guide (if breaking changes) +idris2 --typecheck feedback-o-tron.ipkg +``` + +## 2. The runtime implementation (Elixir) — REAL + +`FeedbackATron.Synthesis.FormValidator.validate/2` is the runtime +implementation of the same contract, on the live synthesis/dispatch path. +The correspondence, field by field: + +| Contract (Idris2) | Runtime (Elixir) | Meaning | +|---|---|---| +| `RequiredMissing fieldId` | `%{error: :required_missing}` | a required field is unanswered (Elixir also treats a whitespace-only answer, or an empty checkbox list, as missing) | +| `UnknownField key` | `%{error: :unknown_field}` | an answer key that is not a non-markdown field id of the form | +| `InvalidOption fieldId value` | `%{error: :invalid_option}` | a dropdown answer that is not one of the field's declared options | +| — (unrepresentable: `Answers` is typed `List (String, String)`) | `%{error: :not_a_string}` | a non-string answer value; the Idris contract rules this out by type, the dynamically-typed runtime must check it | +| markdown fields excluded from `knownFields` | markdown fields rejected from `known_fields`, answers to them are `:unknown_field` | markdown blocks are display-only | +| `Left` collects **all** violations (required first in form order, then options, then unknown keys) | `{:error, errors}` collects all violations (form-order field errors, then sorted unknown keys) | nothing fails fast; the caller sees the whole picture | +| `Right ValidPayload` (private constructor) | `:ok` | gate passed | + +Divergences to know about (deliberate, documented rather than hidden): + +- The Elixir validator trims whitespace before deciding blankness; the Idris + spec models blankness as `v /= ""` without trimming. +- Checkbox answers are lists in Elixir; the Idris spec models all answers as + strings, so checkbox-list type checks live only in the runtime. +- Nothing *mechanically* connects the two today — the Elixir module mirrors + the spec by construction and code review, not by extraction. See §4. + +## 3. The Zig FFI — STUB + +`ffi/zig/src/main.zig` is **scaffolding, not a working FFI**. It exports +generic `feedback_o_tron_init/free/process`-style lifecycle functions and +refers in comments to `src/abi/Types.idr` and `src/abi/Foreign.idr`, which +**do not exist** — the real Idris module is `FeedbackOTron/Contract.idr` and +no C headers are generated from it. Nothing in the running system calls this +library. It is kept as the intended shape of a future C-ABI surface and must +not be described as functional until it is. + +## 4. What full enforcement would look like (not built yet) + +The honest end state is an Idris-derived validator on the dispatch path — +either code generated from `Contract.idr` (via a C library that the Zig FFI +wraps and Elixir calls through a NIF/port), or a conformance test suite +generated from the spec that the Elixir validator must pass in CI. Until +then, the guarantee chain is: spec proved (Idris, CI-gated) → implementation +mirrors spec (review + unit tests). Full FFI enforcement is tracked in a +follow-up issue: (follow-up issue: filed at PR time). ## License -MPL-2.0 +- Code: MPL-2.0 +- This document: CC-BY-SA-4.0 ## See Also -- [Idris2 Documentation](https://idris2.readthedocs.io) -- [Zig Documentation](https://ziglang.org/documentation/master/) -- [Rhodium Standard Repositories](https://github.com/hyperpolymath/rhodium-standard-repositories) -- [FFI Migration Guide](../ffi-migration-guide.md) -- [ABI Migration Guide](../abi-migration-guide.md) +- `src/abi/FeedbackOTron/Contract.idr` — the contract, with per-lemma docs +- `.github/workflows/proofs.yml` — the CI gate (type-check + trusted-base audit) +- `elixir-mcp/lib/feedback_a_tron/synthesis/form_validator.ex` — the runtime validator +- [Idris2 documentation](https://idris2.readthedocs.io) diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index d375519..04d6754 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -11,13 +11,13 @@ ____ Autonomous multi-platform bug reporting for AI agents. Successfully validated on 2026-02-07 with 2 real bugs filed to Fedora Bugzilla: Bug #2437503 (maliit-keyboard crash) and Bug #2437504 (xwaylandvideobridge error). ____ -This is achieved through six submitters (GitHub, GitLab, Bitbucket, Codeberg, Bugzilla, Email), fuzzy deduplication (Levenshtein distance), and formal verification of ABI boundaries via Idris2. +This is achieved through six submitters (GitHub, GitLab, Bitbucket, Codeberg, Bugzilla, Email), fuzzy deduplication (Levenshtein distance), and an Idris2-verified contract spec (`src/abi/FeedbackOTron/Contract.idr`) mirrored at runtime by the Elixir validation boundary (`FormValidator`). Full FFI enforcement (the Zig bridge) is not built — the contract is a proof-carrying spec, not a boundary the payload physically crosses. == Two Verifiable Claims from How-It-Works === Claim 1: Bugzilla REST API Supports Product/Component/Version Targeting -**Location**: `/var/mnt/eclipse/repos/feedback-o-tron/elixir-mcp/lib/feedback_a_tron/submitter.ex` (Elixir submitter module) +**Location**: `elixir-mcp/lib/feedback_a_tron/submitter.ex` and `elixir-mcp/lib/feedback_a_tron/channels/bugzilla.ex` **How verified**: The Bugzilla submitter (in the above file) uses the Bugzilla REST API endpoint (`/rest/bug`) to create bugs. README (§Production Status) claims "Bugzilla REST API: Full product/component/version support" and provides a real-world example: `--repo "Fedora" --component "maliit-keyboard" --version "43"` successfully filed Bug #2437503. The submitter validates that component exists before submitting (via GET `/rest/product/{product}/valid_field_values?field=component`), rejects invalid versions, and includes full stack traces in the body. @@ -25,17 +25,17 @@ This is achieved through six submitters (GitHub, GitLab, Bitbucket, Codeberg, Bu === Claim 2: Deduplicator Prevents Duplicate Submissions Using Fuzzy Matching -**Location**: `/var/mnt/eclipse/repos/feedback-o-tron/elixir-mcp/lib/feedback_a_tron/deduplicator.ex` (Elixir deduplication) +**Location**: `elixir-mcp/lib/feedback_a_tron/deduplicator.ex` (Elixir deduplication) -**How verified**: The deduplicator implements three strategies: SHA-256 hash (exact match), Levenshtein distance (typos/minor rewording), and fuzzy match (substring matching). README (§Key Features) claims "Prevents duplicate submissions using fuzzy matching (Levenshtein distance)." The module is tested in `tests/deduplication_test.exs` which verifies: identical reports are caught, reports differing by <10% edit distance are flagged as similar, and unrelated reports pass through. +**How verified**: The deduplicator implements three strategies: SHA-256 hash (exact match), Levenshtein distance (typos/minor rewording), and fuzzy match (substring matching). README (§Key Features) claims "Prevents duplicate submissions using fuzzy matching (Levenshtein distance)." The module is tested in `elixir-mcp/test/deduplicator_test.exs` which verifies: identical reports are caught, reports differing by <10% edit distance are flagged as similar, and unrelated reports pass through. As of the Synthesis Engine branch the dedup is *wired end-to-end*: `Submitter` calls `Deduplicator.record/3` after each successful submission and `check/1` before dispatch, and `Synthesis.Research` consults `check/1` during research mode. **Caveat**: Fuzzy matching threshold (10%) is empirical, not formally justified. May have false positives/negatives. == Dogfooded Across The Account -Uses the hyperpolymath ABI/FFI standard (Idris2 + Zig) for formally verified credential handling. The Submitter module calls formally specified Idris2 ABI types (from the `proven` repo) to guarantee memory safety at FFI boundaries. This is reused in gossamer (window management credentials) and echidna (proof solver credentials). +Follows the hyperpolymath ABI/FFI repo layout (Idris2 spec + Zig bridge). To be precise about what that means here: the Idris2 side is a real, proof-carrying contract spec (`Contract.idr`), the Elixir `FormValidator` enforces the same rules at runtime, and the Zig FFI bridge is a stub — the Submitter does **not** call Idris2-compiled code or `proven` types at any FFI boundary. -Also integrates with boj-server (cartridge system) — feedback-o-tron is a cartridge itself. +Also integrates with boj-server (cartridge system) — but feedback-o-tron is **not** a cartridge itself. The engine is wrapped by the `bug-filing-mcp` cartridge in `boj-server-cartridges`, which forwards requests over the engine's localhost HTTP intake (`127.0.0.1:7722`). The similarly named `feedback-mcp` cartridge is an unrelated sentiment counter that merely shares the name (see `docs/AUTONOMOUS-BUG-PIPELINE.adoc` §5). == File Map @@ -44,28 +44,31 @@ Also integrates with boj-server (cartridge system) — feedback-o-tron is a cart | Path | What's There | `elixir-mcp/lib/feedback_a_tron/cli.ex` | CLI entry point: escript with submit, --mcp-server, --version, --help commands -| `elixir-mcp/lib/feedback_a_tron/submitter.ex` | Submitter module: six platform adapters (GitHub, GitLab, Bitbucket, Codeberg, Bugzilla, Email) +| `elixir-mcp/lib/feedback_a_tron/submitter.ex` | Submitter module: dispatch to platform channels; validates `template_data` against the fetched form before rendering/dispatch +| `elixir-mcp/lib/feedback_a_tron/channels/` | Platform channel adapters (GitHub, GitLab, Bitbucket, Codeberg, Bugzilla, Email, and more) | `elixir-mcp/lib/feedback_a_tron/deduplicator.ex` | Fuzzy deduplication: SHA-256, Levenshtein distance, substring matching -| `elixir-mcp/lib/feedback_a_tron/credential_loader.ex` | Environment variables + CLI config auto-detection (gh, glab) +| `elixir-mcp/lib/feedback_a_tron/credentials.ex` | Environment variables + CLI config auto-detection (gh, glab) | `elixir-mcp/lib/feedback_a_tron/audit_log.ex` | JSON-lines audit trail; records all submissions (success, failure, dry-run) -| `elixir-mcp/lib/feedback_a_tron/network_verifier.ex` | Pre/post-submission checks: latency, DNS, TLS, DNSSEC, BGP/RPKI -| `elixir-mcp/src/abi/` | Idris2 ABI definitions for Submitter interface (formal spec, generates C headers) -| `elixir-mcp/src/abi/Types.idr` | Idris2 type definitions with dependent type proofs for memory layout -| `elixir-mcp/src/abi/Foreign.idr` | Idris2 FFI declarations for C ABI boundary -| `tests/deduplication_test.exs` | Deduplicator tests: exact, fuzzy, substring matching -| `tests/submitter_test.exs` | Submitter tests for each platform (mocked HTTP) -| `tests/network_verification_test.exs` | Network verifier tests (DNS, TLS, DNSSEC) -| `tests/integration_test.exs` | End-to-end tests with real Bugzilla (dry-run mode, Bug #2437503/#2437504 validated) +| `elixir-mcp/lib/feedback_a_tron/network_verifier.ex` | Network checks: latency, DNS, TLS, DNSSEC, BGP/RPKI (opt-in via Hydrator `network_probe`) +| `elixir-mcp/lib/feedback_a_tron/synthesis/` | Synthesis Engine: TemplateFetcher/Cache, IntentClassifier, Hydrator, Research, Synthesizer, FormValidator, FormRenderer +| `elixir-mcp/lib/feedback_a_tron/http_intake/router.ex` | Optional localhost HTTP intake (`:7722`) — the boj `bug-filing-mcp` cartridge wire +| `src/abi/FeedbackOTron/Contract.idr` | Idris2-verified contract spec for form validation (total, proof-carrying; mirrored by FormValidator) +| `ffi/zig/src/main.zig` | Zig FFI bridge — currently a stub; no FFI enforcement +| `elixir-mcp/test/deduplicator_test.exs` | Deduplicator tests: exact, fuzzy, substring matching +| `elixir-mcp/test/submitter_test.exs` | Submitter tests for each platform (mocked HTTP) +| `elixir-mcp/test/network_verifier_test.exs` | Network verifier tests (DNS, TLS, DNSSEC) +| `elixir-mcp/test/synthesis/` | Synthesis Engine tests (classifier, hydrator, research, synthesizer, validator, renderer, fetcher) |=== == Testing Critical Paths -* **Bugzilla REST API**: `tests/submitter_test.exs` — mock Bugzilla API, verify product/component/version handling -* **Deduplication**: `tests/deduplication_test.exs` — test hash, Levenshtein, and fuzzy matching with edge cases -* **Credential loading**: `tests/credential_loader_test.exs` — verify env vars, gh, glab configs are loaded in priority order -* **Audit logging**: `tests/audit_log_test.exs` — verify all submissions recorded, JSON-lines format valid -* **Integration**: `tests/integration_test.exs` — real Bugzilla instance (dry-run), verify Bug #2437503/#2437504 creation flow -* **Network verification**: `tests/network_verification_test.exs` — mock DNS/TLS, verify latency, DNSSEC, route checks +* **Bugzilla REST API**: `elixir-mcp/test/submitter_test.exs` — mock Bugzilla API, verify product/component/version handling +* **Deduplication**: `elixir-mcp/test/deduplicator_test.exs` — test hash, Levenshtein, and fuzzy matching with edge cases +* **Credential loading**: `elixir-mcp/test/credentials_test.exs` — verify env vars, gh, glab configs are loaded in priority order +* **Audit logging**: `elixir-mcp/test/audit_log_test.exs` — verify all submissions recorded, JSON-lines format valid +* **Integration**: `elixir-mcp/test/integration/` and `elixir-mcp/test/e2e_test.exs` — end-to-end flows in dry-run mode +* **Network verification**: `elixir-mcp/test/network_verifier_test.exs` — mock DNS/TLS, verify latency, DNSSEC, route checks +* **Synthesis Engine**: `elixir-mcp/test/synthesis/` — classifier gate (salvage/reject/open-questions), hydration, research, form validation/rendering == Questions? diff --git a/README.adoc b/README.adoc index 305d64d..d330079 100644 --- a/README.adoc +++ b/README.adoc @@ -7,8 +7,7 @@ image:https://img.shields.io/badge/OpenSSF-Best_Practices-green?logo=openssource image:https://img.shields.io/badge/License-MPL--2.0-blue.svg[License: MPL-2.0,link="https://www.mozilla.org/en-US/MPL/2.0/"] image:https://img.shields.io/badge/version-1.0.0-blue[Version 1.0.0] image:https://img.shields.io/badge/RSR-Certified-gold[RSR Certified] -image:https://img.shields.io/badge/Idris-inside-purple[Idris Inside] -image:https://img.shields.io/badge/Status-Production%20Ready-green[Production Ready] +image:https://img.shields.io/badge/Idris2-contract_spec_%2B_runtime_validation-purple[Idris2 contract spec + Elixir runtime validation] Jonathan D.A. Jewell v1.0.0, 2026-02-07 @@ -21,12 +20,22 @@ v1.0.0, 2026-02-07 :url-bitbucket: https://bitbucket.org/hyperpolymath/feedback-o-tron :url-codeberg: https://codeberg.org/hyperpolymath/feedback-o-tron -**Autonomous multi-platform bug reporting for AI agents** — Fully tested in production with real Bugzilla submissions. +**Autonomous multi-platform bug reporting for AI agents** — validated with real Bugzilla submissions (2026-02-07). image:https://img.shields.io/badge/Validated-2%20Real%20Bugs%20Filed-brightgreen[Real World Tested] toc::[] +== Goal + +Feedback should be **fluid for the sender and shaped for the receiver**. + +* **Fluid for the sender** — raw feedback arrives from anywhere: an MCP tool call, the HTTP intake, the standalone CLI, or the boj `bug-filing-mcp` cartridge. No form-filling at the point of frustration. +* **Shaped for the receiver** — the engine fetches the target repo's *own* issue-form template (`.github/ISSUE_TEMPLATE/*.yml`), hydrates its fields from context and system state, and files a report in the receiver's taxonomy containing only what they asked for. +* **Intent-preserving** — the gate is *usefulness, not tone*. Zero-signal hostility is rejected with a stated reason, audit-logged, and never filed. Mixed content has its actionable core salvaged and the abuse stripped — and both facts are reported. Genuine-but-thin feedback is never silently discarded: it comes back as open questions. +* **Research before filing** — existing forge issues are searched and the local submission history is dedup-checked *before* anything is filed; fields only the user can answer return as `open_questions` for an interactive loop (see <>). +* **Foundational sorting** — deduplication plus text similarity means recurring themes are recognized as recurring patterns, while one-offs are treated as one-offs. + [TIP] ==== **AI-Assisted Install:** Just tell any AI: + @@ -146,19 +155,20 @@ For manual installation without AI assistance, see the < {:ok, :unique} | {:duplicate, existing} | {:similar, matches} ---- -=== As MCP Tool (Claude Code) +[[mcp-tools]] +=== As MCP Tools (Claude Code) + +The MCP server exposes **three** feedback tools, designed as an interactive loop: + +1. `research_feedback` — check for duplicates and discover the repo's templates +2. `synthesize_feedback` — shape the raw feedback into a template-fitting draft +3. Resolve any `open_questions` with your user +4. `submit_feedback` — file the report with `template` + `template_data` + +**`research_feedback`** — searches the target forge for existing similar issues, checks the local submission history (Deduplicator), and fetches the repo's issue-form templates: + +[source,json] +---- +{ + "tool": "research_feedback", + "params": { + "repo": "owner/repo", + "title": "Working title of the feedback", + "body": "Optional draft body to improve similarity matching" + } +} +---- + +**`synthesize_feedback`** — classifies intent (bug/feature/question/docs/praise), salvages the actionable core from mixed content, hydrates the repo's issue-form fields from context and system state, and returns a draft plus `open_questions` for anything only the user can answer. Zero-signal abuse is rejected with a stated reason (`{"rejected": true, "reason": ...}`), audit-logged, and never filed: -The MCP server exposes a `submit_feedback` tool for autonomous AI bug reporting: +[source,json] +---- +{ + "tool": "synthesize_feedback", + "params": { + "raw_feedback": "The raw feedback/crash text...", + "repo": "owner/repo", + "context": {"version": "1.2.3", "trajectory": "..."}, + "system_state": {"os": "Fedora 43"} + } +} +---- + +**`submit_feedback`** — files the report. With `template` + `template_data`, the answers are validated against the fetched form schema (the Elixir side of the Idris2 contract) and rendered as the issue body before dispatch: [source,json] ---- @@ -350,7 +397,9 @@ The MCP server exposes a `submit_feedback` tool for autonomous AI bug reporting: "title": "Bug report", "body": "Full description with reproduction steps...", "platforms": ["github", "gitlab", "bugzilla"], - "repo": "owner/repo" + "repo": "owner/repo", + "template": "bug.yml", + "template_data": {"what-happened": "...", "version": "1.2.3"} } } ---- @@ -371,13 +420,13 @@ MCP configuration in `~/.config/claude/mcp_servers.json`: } ---- -== Formal Specification with Idris2 (Planned) +== Formal Specification with Idris2 -feedback-o-tron has an **Idris2** ABI specification layer for defining platform types and FFI boundaries. Full dependent-type proofs for memory safety and correctness are planned but not yet materialized. +feedback-o-tron ships an **Idris2-verified contract spec** (`src/abi/FeedbackOTron/Contract.idr`) paired with an **Elixir runtime validation boundary** (`FeedbackATron.Synthesis.FormValidator`). The Idris2 module states the issue-form validation rules once, totally (`%default total`, no `believe_me`, no `postulate`), and proves that `validate` enforces them — completeness (every required field answered), no unknown answer keys, dropdown answers constrained to declared options, and answers-preserved. Its `ValidPayload` type has a private constructor, so the only way to obtain one is through `validate`. The Elixir module enforces the same rules at runtime on every template-shaped submission. -NOTE: The original template ABI files (Types.idr, Layout.idr, Foreign.idr) were removed on 2026-03-29 as they contained only scaffolding. See link:PROOF-NEEDS.md[PROOF-NEEDS.md] for what needs to be proven and the current status. +NOTE: The original template ABI files (Types.idr, Layout.idr, Foreign.idr) were removed on 2026-03-29 as they contained only scaffolding. `Contract.idr` replaced them with a real, proof-carrying spec. What does **not** exist yet: FFI-level enforcement — the Zig FFI bridge remains a stub, and the Elixir core does not call into Idris2-compiled code. The contract is a spec the runtime mirrors, not a boundary the payload physically crosses. See link:PROOF-NEEDS.md[PROOF-NEEDS.md] for status. -=== Planned Proofs +=== Planned Proofs (beyond Contract.idr) * **Deduplication correctness**: Prove the deduplicator never drops unique entries * **Submission atomicity**: Prove submissions fully succeed or fully fail @@ -393,18 +442,18 @@ Can leverage 90+ formally verified modules from the https://github.com/hyperpoly * **SafeJSON**: Type-safe JSON parsing with schema validation * **SafeURL**: URL parsing with format guarantees -Integration architecture: +Integration architecture (target — the Zig bridge is currently a stub): [source] ---- -Idris2 (Formal Specifications → Proofs) +Idris2 (Formal Specifications → Proofs) [Contract.idr: real, today] ↓ -Zig FFI (C ABI Bridge) +Zig FFI (C ABI Bridge) [stub, follow-up] ↓ -Elixir/BEAM (Runtime) +Elixir/BEAM (Runtime) [FormValidator mirrors the spec, today] ---- -This architecture will enable **provable correctness** at the ABI boundary while maintaining **runtime flexibility** in Elixir. +Full FFI enforcement would make the contract a boundary the payload physically crosses, rather than a spec the runtime mirrors. == Architecture @@ -417,7 +466,7 @@ See link:TOPOLOGY.md[TOPOLOGY.md] for a visual architecture map and completion d ├─────────────────────────────────────────────────────────────┤ │ ┌──────────┐ ┌──────────────┐ ┌─────────────────────┐ │ │ │Submitter │ │ Deduplicator │ │ NetworkVerifier │ │ -│ │ │ │ │ │ - Latency/jitter │ │ +│ │ │ │ (wired) │ │ - Latency/jitter │ │ │ │ - GitHub │ │ - SHA-256 │ │ - DNS verification │ │ │ │ - GitLab │ │ - Levenshtein│ │ - TLS validation │ │ │ │-Bitbucket│ │ - Fuzzy match│ │ - BGP/RPKI checks │ │ @@ -427,6 +476,12 @@ See link:TOPOLOGY.md[TOPOLOGY.md] for a visual architecture map and completion d │ └──────────┘ └──────────────┘ └─────────────────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────┐ │ +│ │ Synthesis Engine (agent-in-the-loop) │ │ +│ │ TemplateFetcher/Cache · IntentClassifier · Hydrator │ │ +│ │ Research · FormRenderer · FormValidator · Synthesizer│ │ +│ └──────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ │ │ AuditLog │ │ │ │ - All submissions recorded │ │ │ │ - JSON lines format │ │ @@ -438,10 +493,10 @@ See link:TOPOLOGY.md[TOPOLOGY.md] for a visual architecture map and completion d │ - CLI configs (gh, glab) │ │ - Rotation for rate limit avoidance │ ├─────────────────────────────────────────────────────────────┤ -│ Idris2 ABI Layer (Formal Specification) │ -│ - Platform type definitions │ -│ - FFI boundary specs via Zig C ABI bridge │ -│ - Dependent type proofs planned (see PROOF-NEEDS.md) │ +│ Idris2 contract spec (src/abi/FeedbackOTron/Contract.idr) │ +│ - Issue-form validation rules, machine-checked proofs │ +│ - Enforced at runtime by Elixir FormValidator │ +│ - Zig FFI bridge = stub (full FFI enforcement follow-up) │ └─────────────────────────────────────────────────────────────┘ ---- @@ -466,7 +521,7 @@ See link:TOPOLOGY.md[TOPOLOGY.md] for a visual architecture map and completion d ---- 2. Restart Claude Code -3. Use tool: `submit_feedback` with parameters +3. Use the tools: `research_feedback` → `synthesize_feedback` → `submit_feedback` (see <>) === ChatGPT / GPT-4 (OpenAI) diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 0881dbc..ab82205 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -4,20 +4,56 @@ == Current Status -Initial development phase. +The v1.0 submission core is shipped and real-world validated. Current work +(this branch) is the Synthesis Engine: shaping raw feedback to the receiver's +own issue template, with research-before-filing and an Idris2-verified +validation contract. See link:TOPOLOGY.md[TOPOLOGY.md] for the honest +per-component completion dashboard. == Milestones -=== v0.1.0 - Foundation -* [ ] Core functionality -* [ ] Basic documentation -* [ ] CI/CD pipeline +=== v1.0.0 — Core (shipped 2026-02-07) -=== v1.0.0 - Stable Release -* [ ] Full feature set -* [ ] Comprehensive tests -* [ ] Production ready +* [x] MCP server (stdio/TCP) with `submit_feedback` +* [x] Standalone CLI (escript) +* [x] Multi-platform channels (GitHub, GitLab, Bitbucket, Codeberg, Bugzilla, Email) +* [x] Audit logging (JSON-lines, secret-sanitised, rotated) +* [x] Credential loading + rotation, rate limiting, retry/backoff +* [x] Real-world validation: 2 bugs filed to Fedora Bugzilla + (https://bugzilla.redhat.com/show_bug.cgi?id=2437503[#2437503], + https://bugzilla.redhat.com/show_bug.cgi?id=2437504[#2437504]) + +=== v1.1 — HTTP intake + boj cartridge (2026-07-09) + +* [x] HTTP intake (Bandit, localhost-only, `127.0.0.1:7722`, off by default via + `FEEDBACK_A_TRON_HTTP`) — a thin JSON adapter over the same `Submitter.submit/2` + path as the MCP tools +* [x] `bug-filing-mcp` boj cartridge (in `boj-server-cartridges`) wrapping the + engine over that wire — resolving the D0 identity decision + (see `docs/AUTONOMOUS-BUG-PIPELINE.adoc` §5) + +=== v1.2 — Synthesis Engine + research mode + Idris2 contract (this branch) + +* [x] Synthesis Engine: `TemplateFetcher`/`TemplateCache` (engine-side fetch of + the target repo's issue forms), `IntentClassifier` (usefulness gate: + salvage/reject/open-questions), `Hydrator`, `Synthesizer`, `FormRenderer`, + `FormValidator`, `TextSimilarity` +* [x] Research mode: `research_feedback` MCP tool — forge issue search + local + dedup check + template discovery before anything is filed +* [x] Interactive loop: research → synthesize → resolve `open_questions` with + the user → submit with `template` + `template_data` +* [x] Deduplicator wired: `record/3` on successful submission, so recurring + themes are recognized as recurring +* [x] Idris2 contract spec (`src/abi/FeedbackOTron/Contract.idr`): total, + proof-carrying statement of the form-validation rules, mirrored at runtime + by `FormValidator` == Future Directions -_To be determined based on community feedback._ +* *Full FFI enforcement* — replace the stub Zig bridge so the validated payload + physically crosses an Idris2-backed boundary instead of the runtime mirroring + the spec +* *Forge-side clustering* — group recurring themes on the forge (linked issues, + labels) rather than only in local history +* *Org-level `.github` template support* — fall back to an organisation's + shared `.github` repo when the target repo declares no issue forms diff --git a/TOPOLOGY.md b/TOPOLOGY.md index 01abc96..fdfea2e 100644 --- a/TOPOLOGY.md +++ b/TOPOLOGY.md @@ -3,7 +3,7 @@ SPDX-License-Identifier: CC-BY-SA-4.0 Copyright (c) Jonathan D.A. Jewell --> - + # feedback-o-tron — Project Topology @@ -18,19 +18,40 @@ Copyright (c) Jonathan D.A. Jewell ▼ ┌─────────────────────────────────────────┐ │ INTERFACE LAYER │ - │ ┌───────────┐ ┌───────────────────┐ │ - │ │ MCP │ │ Standalone │ │ - │ │ Server │ │ CLI (Escript) │ │ - │ └─────┬─────┘ └────────┬──────────┘ │ - └────────│─────────────────│──────────────┘ - │ │ - ▼ ▼ + │ ┌────────┐ ┌────────────┐ ┌─────────┐ │ + │ │ MCP │ │ Standalone │ │ HTTP │ │ + │ │ Server │ │CLI(Escript)│ │ Intake │ │ + │ │3 tools │ │ │ │:7722 boj│ │ + │ └───┬────┘ └─────┬──────┘ └────┬────┘ │ + └──────│────────────│─────────────│──────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────┐ + │ SYNTHESIS ENGINE (ELIXIR) │ + │ agent-in-the-loop, no LLM │ + │ ┌──────────────┐ ┌──────────────────┐ │ + │ │TemplateFetch │ │ IntentClassifier │ │ + │ │ + Cache │ │ (usefulness gate)│ │ + │ └──────┬───────┘ └────────┬─────────┘ │ + │ ┌──────▼───────┐ ┌────────▼─────────┐ │ + │ │ Hydrator │ │ Research │ │ + │ │(+NetVerifier)│ │ (forge + dedup) │ │ + │ └──────┬───────┘ └────────┬─────────┘ │ + │ ┌──────▼──────────────────▼─────────┐ │ + │ │ Synthesizer → FormValidator → │ │ + │ │ FormRenderer │ │ + │ └──────────────────┬────────────────┘ │ + └───────────────────── │ ─────────────────┘ + │ + ▼ ┌─────────────────────────────────────────┐ │ CORE LOGIC (ELIXIR) │ │ │ │ ┌───────────┐ ┌───────────────────┐ │ │ │ Submitter │ │ Deduplicator │ │ │ │ (Multi-P) │ │ (Fuzzy/Lev) │ │ + │ │ validates │ │ WIRED: record/3 │ │ + │ │ template │ │ on success │ │ │ └─────┬─────┘ └────────┬──────────┘ │ │ │ │ │ │ ┌─────▼─────┐ ┌────────▼──────────┐ │ @@ -41,12 +62,11 @@ Copyright (c) Jonathan D.A. Jewell │ │ ▼ ▼ ┌─────────────────────────────────────────┐ - │ ABI LAYER (IDRIS2) │ - │ (Formal Verification, Zig FFI) │ - │ ┌───────────┐ ┌───────────────────┐ │ - │ │ Layout │ │ Types │ │ - │ │ Proofs │ │ Proofs │ │ - │ └───────────┘ └───────────────────┘ │ + │ VALIDATION CONTRACT (IDRIS2 + ELIXIR) │ + │ Idris2 contract spec (real, CI-checked)│ + │ src/abi/FeedbackOTron/Contract.idr │ + │ + Elixir FormValidator runtime boundary│ + │ Zig FFI = stub (no FFI enforcement) │ └───────────────────┬─────────────────────┘ │ ▼ @@ -67,45 +87,63 @@ Copyright (c) Jonathan D.A. Jewell COMPONENT STATUS NOTES ───────────────────────────────── ────────────────── ───────────────────────────────── INTERFACE LAYER - MCP Server (Stdio/TCP) ██████████ 100% Production ready + MCP Server (Stdio/TCP) ██████████ 100% research/synthesize/submit tools CLI Module (Escript) ██████████ 100% Full flag support + HTTP Intake (Bandit, :7722) ██████████ 100% Localhost-only; off by default; + drives boj bug-filing-mcp wire + +SYNTHESIS ENGINE (ELIXIR) + TemplateFetcher + Cache ██████████ 100% Engine-side fetch of bug.yml etc. + IntentClassifier ██████████ 100% Usefulness gate; salvage/reject + Hydrator ██████████ 100% Fields from context/system state + Research (forge + local dedup) ██████████ 100% Pre-filing duplicate search + FormValidator / FormRenderer ██████████ 100% Runtime side of Idris2 contract + Synthesizer (orchestrator) ██████████ 100% Agent-in-the-loop, no embedded LLM CORE LOGIC (ELIXIR) - Submitter (Multi-platform) ██████████ 100% All 6 platforms validated - Deduplicator (Fuzzy Match) ██████████ 100% Levenshtein distance active - NetworkVerifier ██████░░░░ 60% BGP/RPKI checks pending + Submitter (Multi-platform) █████████░ 90% Bugzilla validated real-world; + other channels API-complete + Deduplicator (Fuzzy Match) ██████████ 100% WIRED: record/3 called on success + NetworkVerifier ███████░░░ 70% Real probing; opt-in via Hydrator + network_probe; BGP/RPKI partial AuditLog (JSON-L) ██████████ 100% Complete event tracking -ABI LAYER (IDRIS2) - Types.idr (Formal Proofs) ██████████ 100% C ABI matching proven - Layout.idr (Memory Safety) ██████████ 100% Layout correctness proven - Zig FFI Bridge ████████░░ 80% Wait-free primitives refined +VALIDATION CONTRACT + Contract.idr (Idris2 spec) ██████████ 100% Real, total, proof-carrying, + CI-checked; no postulates + Elixir runtime boundary ██████████ 100% FormValidator enforces the spec + Zig FFI Bridge █░░░░░░░░░ 10% STUB — no FFI enforcement yet EXTERNAL PLATFORMS GitHub Integration ██████████ 100% Token/CLI config support Bugzilla REST API ██████████ 100% Validated with Fedora bugs - GitLab/Codeberg/Bitbucket ██████████ 100% API integration complete - Email (SMTP) ██████████ 100% Validated with local relays + GitLab/Codeberg/Bitbucket █████████░ 90% API integration; less real-world + Email (SMTP) █████████░ 90% Validated with local relays REPO INFRASTRUCTURE Justfile ██████████ 100% Standard build tasks - .machine_readable/ ██████████ 100% STATE.scm tracking - Test Suite (ExUnit) ██████████ 100% 26/26 passing + .machine_readable/ ██████████ 100% STATE.a2ml tracking + Test Suite (ExUnit) █████████░ 90% Suite in CI; coverage still growing ───────────────────────────────────────────────────────────────────────────── -OVERALL: ██████████ 100% v1.0.0 Production Ready +OVERALL: ████████░░ ~75% Core validated; synthesis new; + FFI enforcement not built ``` ## Key Dependencies ``` -Idris2 ABI ──────► Zig FFI ──────► Elixir Core - │ - ┌──────────┬───────┴───────┬──────────┐ - ▼ ▼ ▼ ▼ - Submitter Deduplicator AuditLog Verifier - │ │ │ │ - └──────────┴───────┬───────┴──────────┘ +Idris2 contract spec ──(mirrored by)──► Elixir FormValidator + │ │ + └──(Zig FFI bridge: STUB, ──► planned enforcement path) + │ + Elixir Core + │ + ┌──────────┬────────────┬────────┴──────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + Submitter Deduplicator Synthesis AuditLog Verifier + │ │ Engine │ │ + └──────────┴────────────┬──────────────┴──────────┘ ▼ EXTERNAL APIs ``` @@ -118,6 +156,9 @@ This file is maintained by both humans and AI agents. When updating: 2. **After adding a component**: Add a new row in the appropriate section 3. **After architectural changes**: Update the ASCII diagram 4. **Date**: Update the `Last updated` comment at the top of this file +5. **Honesty rule**: a bar only reaches 100% when the code exists, is wired + in, and does what the row says. "Aspirational" boxes get a low bar and a + note — never a full bar. No universal "100% Production Ready" rows. Progress bars use: `█` (filled) and `░` (empty), 10 characters wide. Percentages: 0%, 10%, 20%, ... 100% (in 10% increments). diff --git a/docs/AUTONOMOUS-BUG-PIPELINE.adoc b/docs/AUTONOMOUS-BUG-PIPELINE.adoc index 4e7fb94..0629caf 100644 --- a/docs/AUTONOMOUS-BUG-PIPELINE.adoc +++ b/docs/AUTONOMOUS-BUG-PIPELINE.adoc @@ -8,12 +8,17 @@ Jonathan D.A. Jewell [IMPORTANT] ==== -*Status: DRAFT / TARGET architecture.* This document describes a pipeline that spans -three repositories (`feedback-o-tron`, `boj-server`, `boj-server-cartridges`). Most of it -is *not yet built* — see <>. It exists to *define* the project that the -2026-06-28 issue triplet (boj-server #262/#263/#264) failed to capture, and to make the -built-vs-proposed boundary explicit and honest. Nothing here should be read as a -description of shipping behaviour except where a row says `BUILT`. +*Status: LARGELY BUILT (2026-07-09), with a precise remainder.* This document +describes a pipeline that spans three repositories (`feedback-o-tron`, `boj-server`, +`boj-server-cartridges`). When first written (2026-07-01) most of it was *not* built; +that is no longer true. As of the Synthesis Engine branch: C1 (engine-side template +fetch), C2 (synthesis/hydration), and C3 *as redefined* (Idris2 contract spec + +Elixir runtime validation boundary) are **BUILT**; C4 (dispatch) was always real and +now validates template payloads before dispatch. What remains: full FFI enforcement +(the Zig bridge is a stub — the payload does not physically cross an Idris2-backed +boundary), forge-side clustering of recurring themes, and org-level `.github` +template fallback. See <> for the row-by-row evidence; nothing here should +be read as shipping behaviour except where a row says `BUILT`. ==== toc::[] @@ -29,41 +34,54 @@ toc::[] * *2026-07-01* — this document written to consolidate the diagram and to record the honest gap between it and the code, so the project can be tracked. Canonical location: this file. The tracking issue lives in `boj-server` (per owner decision: doc here, tracker there). +* *2026-07-09* — D0 resolved (see <>), C1 redrawn as engine-side, and the + Synthesis Engine built; this document updated to match, with the built-vs-remaining + boundary restated precisely. == 1. Purpose An AI agent (or a system) hits a crash. Instead of emitting a cryptic stack trace, the pipeline turns that event into a *well-formed, deduplicated, schema-correct bug report* filed -to the right forge — autonomously. The design intent is that the report is shaped by the -*target repository's own issue template*, hydrated with real context, verified at an ABI -boundary, and dispatched to GitHub / GitLab / Bugzilla. +to the right forge — autonomously. The report is shaped by the *target repository's own issue +template*, hydrated with real context, validated against a verified contract, and dispatched +to GitHub / GitLab / Bugzilla. -The 2026-06-28 diagram, transcribed: +The original 2026-06-28 diagram put the template fetch (①) in the boj Gateway. That was +*redrawn on 2026-07-09*: the Gateway now passes only `{repo, raw_feedback, context, +system_state}` and the **engine fetches `bug.yml` itself** (`TemplateFetcher` + 15-minute +`TemplateCache`). Rationale: *every front door benefits* — MCP callers, the HTTP intake, +the CLI, and the boj cartridge all get template shaping without each re-implementing (and +inevitably skewing) the fetch. One fetcher, one cache, one parser, engine-side. + +The pipeline as built: [source,text] ---- Agent / System Crash Trigger - │ Initiate Bug Report - ▼ -boj-server Gateway - │ ① Fetch Template Schema + │ Initiate Bug Report (any front door) ▼ -.github/ISSUE_TEMPLATE/bug.yml - │ ② Pass Schema + System State +boj-server Gateway ──► bug-filing-mcp cartridge (boj-server-cartridges) + │ ① {repo, raw_feedback, context, system_state} ▼ feedback-o-tron Synthesis Engine - ├── Hydrate Questions - └── Environment Questions + (front doors: MCP tools / HTTP intake 127.0.0.1:7722 / CLI) + │ ② Engine-side template fetch + ▼ +.github/ISSUE_TEMPLATE/bug.yml (TemplateFetcher + TemplateCache) + │ ▼ -┌─ Autonomous Fulfilment & Verification ────────────────┐ -│ LLM Context: Trajectory & Logs │ -│ NetworkVerifier / OS Inspection │ -│ └── Deduplicator: Fuzzy Match Existing Issues │ -└────────────────────────────────────────────────────────┘ - │ ③ Clean Validated Payload +┌─ Synthesis (agent-in-the-loop, no embedded LLM) ───────────────┐ +│ IntentClassifier: usefulness gate — salvage core / reject │ +│ Hydrator: template fields ← context + system_state │ +│ (opt-in NetworkVerifier preflight for network reports) │ +│ Research + Deduplicator: fuzzy-match existing issues │ +│ open_questions ──► back to the caller's user │ +└──────────────────────────────────────────────────────────────────┘ + │ ③ template_data validated against the fetched form ▼ -Idris2 Verified ABI Layer - │ ④ Secure Multi-Platform Dispatch +Validation contract: + Idris2 Contract.idr (verified spec) + Elixir FormValidator (runtime boundary) + │ ④ Multi-Platform Dispatch (Elixir channels) ▼ GitHub / GitLab / Bugzilla API ---- @@ -72,31 +90,38 @@ GitHub / GitLab / Bugzilla API [cols="1,1,3",options="header"] |=== -| Component | Repo | Intended responsibility +| Component | Repo | Responsibility (as built) | *Gateway* | `boj-server` -| Entry point. Fetch the target repo's issue-template schema (`.github/ISSUE_TEMPLATE/bug.yml`), - gather system state, and hand both to the Synthesis Engine as a boj cartridge invocation. +| Entry point. Gathers `{repo, raw_feedback, context, system_state}` and hands it to the + Synthesis Engine via the `bug-filing-mcp` cartridge. It does **not** fetch the issue-template + schema — that moved engine-side on 2026-07-09 (see §1) so every front door benefits. | *Synthesis Engine* | `feedback-o-tron` -| Hydrate the template's questions ("Hydrate" + "Environment") from LLM trajectory/logs and - from `NetworkVerifier` / OS inspection; deduplicate against existing issues; emit a clean, - schema-valid payload. - -| *Verified ABI Layer* -| `feedback-o-tron` (+ `proven` types) -| Validate the payload's memory layout and shape at a formally-specified Idris2/Zig FFI - boundary before it leaves the process. +| Fetch the target repo's issue-form templates itself (`TemplateFetcher`/`TemplateCache`); + gate on usefulness (`IntentClassifier`: salvage the actionable core, reject zero-signal + abuse with a stated, audit-logged reason); hydrate the template's fields from caller + context and system state (`Hydrator`, with opt-in `NetworkVerifier` preflight); + research existing forge issues and the local dedup history (`Research`); emit a draft + plus `open_questions` for anything only the user can answer (`Synthesizer`). + +| *Validation contract* +| `feedback-o-tron` +| Validate the answered form before dispatch. Stated twice, deliberately: the Idris2 + contract spec (`src/abi/FeedbackOTron/Contract.idr`) proves the rules; the Elixir + `FormValidator` enforces the same rules on the live path. The Zig FFI bridge is a stub — + this is a spec the runtime mirrors, not (yet) a boundary the payload physically crosses. | *Dispatch* | `feedback-o-tron` channels | File the validated payload to GitHub / GitLab / Bugzilla (and other forges). | *Cartridge packaging* -| `boj-server-cartridges` / `boj-server/cartridges` -| Package the Synthesis Engine as a first-class boj cartridge so the Gateway can invoke it. +| `boj-server-cartridges` +| The `bug-filing-mcp` cartridge wraps the engine over its localhost HTTP intake + (`127.0.0.1:7722`), forwarding the same request shapes the MCP tools accept. |=== == 3. The four wire contracts @@ -105,14 +130,18 @@ GitHub / GitLab / Bugzilla API |=== | ID | Contract -| *C1* | `Fetch Template Schema` — Gateway → `.github/ISSUE_TEMPLATE/bug.yml` -| *C2* | `Pass Schema + System State` — (schema + state) → Synthesis Engine -| *C3* | `Clean Validated Payload` — Synthesis + Dedup → Idris2 Verified ABI Layer -| *C4* | `Secure Multi-Platform Dispatch` — Idris2 ABI → GitHub / GitLab / Bugzilla API +| *C1* | `Fetch Template Schema` — *Synthesis Engine* → `.github/ISSUE_TEMPLATE/*.yml` + (engine-side; redrawn 2026-07-09 from the original Gateway-side fetch — + rationale: every front door benefits) +| *C2* | `Pass Raw Feedback + Context` — Gateway/cartridge → Synthesis Engine as + `{repo, raw_feedback, context, system_state}` +| *C3* | `Clean Validated Payload` — Synthesis + Dedup → validation contract + (Idris2 `Contract.idr` spec + Elixir `FormValidator` runtime boundary) +| *C4* | `Multi-Platform Dispatch` — validated payload → GitHub / GitLab / Bugzilla API |=== [[reality]] -== 4. Current reality vs target (verified 2026-07-01) +== 4. Current reality vs target (verified 2026-07-09) Ground-truthed against the code in both local repos. Statuses: *BUILT* (real working code), *PARTIAL* (real code, but unwired / bypassed / inert), *PROPOSED* (docs/diagram only, no code). @@ -122,52 +151,80 @@ Ground-truthed against the code in both local repos. Statuses: *BUILT* (real wor | Contract / element | Status | Evidence | *C1* Fetch Template Schema -| PROPOSED -| `boj-server` Gateway (`elixir/lib/boj_rest/router.ex`) exposes only 5 cartridge endpoints - (`/health`, `/menu`, `/cartridges`, `/cartridge/:name`, `POST /cartridge/:name/invoke`). - No code fetches an issue-template schema. There is *no* `bug.yml` anywhere — both repos ship - plain Markdown templates (`bug_report.md`), not `.yml` issue forms. - -| *C2* Pass Schema + System State -| PROPOSED -| There is no "Synthesis Engine" (grep `synthesis` = 0 hits). `feedback-o-tron` has *no HTTP - intake* (no Bandit/Plug/Cowboy in the supervision tree); its only entry point is the MCP tool - `submit_feedback`, whose `input_schema` requires `[title, body, repo]` — a plain issue, not a - schema + system-state. The one cross-repo wire (`cartridges/feedback-mcp/mod.js` → POST - `http://127.0.0.1:7722`) targets a backend `feedback-o-tron` does not serve. - -| *C3* Clean Validated Payload → Idris2 ABI -| PROPOSED -| `feedback-o-tron` has *zero* `.idr`/`.ipkg` files — all `src/abi/*.idr` were removed - (`PROOF-NEEDS.md` L27–31: "only RSR template scaffolding, no proofs"). `ffi/zig/src/main.zig` - references phantom `src/abi/*.idr` and its `feedback_o_tron_process` is a stub - (`_ = input; return .ok`). The Elixir core never calls any NIF/FFI. The `Deduplicator` - algorithm is real (`deduplicator.ex`, normalized Levenshtein — comment mislabels it - "Jaro-Winkler"), but `Deduplicator.record/3` is *never called*, so its indexes stay empty and - `check/1` always returns `{:ok, :unique}` at runtime — the dedup is *inert as integrated*. - -| *C4* Secure Multi-Platform Dispatch +| BUILT +| Engine-side, per the 2026-07-09 redraw. `synthesis/template_fetcher.ex` lists + `.github/ISSUE_TEMPLATE/` via the forge contents API and fetches raw YAML issue forms; + `synthesis/template_cache.ex` is a 15-minute-TTL ETS cache. This repo now ships real + `.yml` issue forms (`.github/ISSUE_TEMPLATE/bug.yml`, `feature.yml`) so the pipeline can + be dogfooded against itself. The Gateway passes `{repo, raw_feedback, context}` and never + touches the schema — every front door (MCP, HTTP, CLI, cartridge) gets the same fetch. + +| *C2* Pass Raw Feedback + Context → Synthesis +| BUILT +| The Synthesis Engine exists (`elixir-mcp/lib/feedback_a_tron/synthesis/`: TemplateFetcher, + TemplateCache, IntentClassifier, Hydrator, Research, Synthesizer, FormValidator, + FormRenderer). Reachable three ways: MCP tools `research_feedback` + `synthesize_feedback` + (`mcp/tools/`), the HTTP intake (`http_intake/router.ex`, `POST /api/v1/synthesize_feedback` + on `127.0.0.1:7722`, off by default via `FEEDBACK_A_TRON_HTTP`), and library calls. + Design note: *agent-in-the-loop, no embedded LLM* — classification and hydration are + deterministic heuristics; the calling agent supplies trajectory/logs in `context` and + resolves the returned `open_questions` with its user. + +| *C3* Clean Validated Payload → verified contract +| BUILT (as redefined) +| Redefined from "Idris2 ABI the payload crosses" to "Idris2 contract spec + Elixir runtime + boundary". `src/abi/FeedbackOTron/Contract.idr` is a real, total, proof-carrying spec + (`%default total`, no `believe_me`/`postulate`; completeness, no-unknown-fields, + options-valid, answers-preserved lemmas; `ValidPayload` has a private constructor so the + only way to obtain one is `validate`). `FormValidator` enforces the same rules at runtime, + and `Submitter.submit/2` validates `template_data` against the fetched form *before* + rendering and dispatch (`submitter.ex`, `apply_template/3` → `{:error, + {:schema_violation, violations}}`). *Full FFI enforcement is follow-up*: `ffi/zig/src/main.zig` + remains a stub and the Elixir core calls no NIF — the contract is mirrored, not crossed. + +| *C4* Multi-Platform Dispatch | PARTIAL | Dispatch is *real*: `channels/github.ex` shells `gh issue create`; `channels/gitlab.ex` shells `glab issue create`; `channels/bugzilla.ex` does a real REST v1 POST; 14+ channels registered; - README documents 2 real Bugzilla bugs filed (#2437503/#2437504). *But* it runs entirely in - Elixir and does *not* originate from any Idris2 ABI — the "via Idris2 ABI" source in the - diagram is fictional. + README documents 2 real Bugzilla bugs filed (#2437503/#2437504). Template-shaped submissions + are now *validated-before-dispatch* (see C3). Still PARTIAL because it runs entirely in + Elixir and does not originate from any Idris2-compiled boundary — that is the FFI follow-up, + not a diagram fiction any more, but not built either. + +| MCP server + feedback tools +| BUILT +| Working JSON-RPC MCP server over stdio (`mcp/server.ex`), now exposing the three-tool + interactive loop — `research_feedback`, `synthesize_feedback`, `submit_feedback` (with + `template` + `template_data`) — plus `migration_observe`. Still the primary front door. -| MCP server + `submit_feedback` tool +| HTTP intake | BUILT -| Working JSON-RPC MCP server over stdio (`mcp/server.ex`), exposes `submit_feedback` + - `migration_observe`. The real front door. +| `http_intake/router.ex` (Bandit, localhost-only, `127.0.0.1:7722`, off by default). A thin + JSON adapter over the *same* `Submitter.submit/2` / `Research` / `Synthesizer` paths as the + MCP tools — no separate submission logic. This is the wire the `bug-filing-mcp` cartridge + drives; the port matches what the old cartridge always targeted. | Audit logging | BUILT | `audit_log.ex` — JSON-lines trail with rotation + secret-sanitisation, wired from `Submitter`. + The Synthesizer also logs every rejection with its stated reason (doctrine: rejected + feedback is audit-logged, never filed, never silently dropped). + +| Deduplicator +| BUILT (wired) +| Formerly inert-as-integrated. Now: `Submitter` calls `Deduplicator.check/1` before dispatch + and `Deduplicator.record/3` on every successful submission (`submitter.ex:114`), and + `Synthesis.Research` consults `check/1` during research mode — so recurring themes are + recognized as recurring, and one-offs stay one-offs. The comment mislabelling the algorithm + "Jaro-Winkler" is fixed (it computes normalized Levenshtein, and now says so). | NetworkVerifier -| PARTIAL -| 607 lines of real probing (ping/traceroute/dig/openssl/crt.sh/RPKI), supervised - (`application.ex:29`) — but *never called* by `Submitter` or any MCP tool. A disconnected - island; does network probing, not OS/environment inspection for a payload. +| PARTIAL (optionally wired) +| 607 lines of real probing (ping/traceroute/dig/openssl/crt.sh/RPKI), supervised. Now + reachable from the pipeline: `Hydrator` runs `NetworkVerifier.preflight_check/1` when the + caller opts in (`network_probe: true`) and the report looks network-related, in a + supervised task with a timeout so a hung probe cannot stall synthesis. Still PARTIAL: + opt-in only, and it does network probing, not general OS/environment inspection. | Migration-observation subsystem | PARTIAL @@ -175,68 +232,73 @@ Ground-truthed against the code in both local repos. Statuses: *BUILT* (real wor origin of the diagram's "gather logs" box, but it does not do what the diagram claims. |=== -*Net:* the terminal dispatch (C4) is real; the template-fetch (C1), synthesis/schema-passing (C2), -and verified-ABI (C3) stages that give the diagram its shape are unbuilt. +*Net:* C1, C2, and C3-as-redefined are built and wired; C4's dispatch was always real and now +receives validated payloads. The remainder is exactly: FFI-level enforcement (Zig stub), +forge-side clustering, and org-level `.github` template fallback. [[identity]] -== 5. OPEN DECISION — the `feedback-o-tron` vs `feedback-mcp` identity problem +== 5. RESOLVED (2026-07-09) — the `feedback-o-tron` vs `feedback-mcp` identity problem + +*Decision D0 is resolved.* Owner decision, 2026-07-09: **option 2 (Separate)** — the two +programs are different products, and a *new* first-class cartridge, `bug-filing-mcp` +(in `boj-server-cartridges`), wraps the real `feedback-o-tron` engine over its localhost +HTTP intake (`127.0.0.1:7722`). The `feedback-mcp` sentiment counter stays what it is; no +documentation may claim it implements or packages the engine (`EXPLAINME.adoc` corrected +accordingly). C2's cartridge wire is therefore drawn to `bug-filing-mcp`, as the sections +above already show. -The single most under-defined thing in this project. Today these are *two unrelated programs -that share a name*: +For the record, the two same-named programs that forced the decision: [cols="1,2,2",options="header"] |=== | | `feedback-o-tron` (this repo) | `feedback-mcp` (boj cartridge) -| What it is | v1.0.0 Elixir bug-*filing* engine | ~448-line Zig FFI *sentiment counter* -| Tools | `submit_feedback`, `migration_observe` | `register_channel`, `start_collecting`, `submit`, `get_stats` +| What it is | Elixir bug-*filing* engine + Synthesis Engine | ~448-line Zig FFI *sentiment counter* +| Tools | `research_feedback`, `synthesize_feedback`, `submit_feedback`, `migration_observe` | `register_channel`, `start_collecting`, `submit`, `get_stats` | Data model | issue → 15+ forge channels | 8-slot in-memory tally, fork-per-request (no persistence) -| Runtime | MCP stdio / escript; shells `gh`/`glab`, REST | `mod.js` → HTTP `127.0.0.1:7722` +| Runtime | MCP stdio / escript / HTTP intake; shells `gh`/`glab`, REST | `mod.js` → HTTP `127.0.0.1:7722` | Shared code | — | *none* — separate reimplementation, name only |=== -`EXPLAINME.adoc` says "feedback-o-tron is a cartridge itself" and the cartridge `README.adoc` -claims it "implements feedback-o-tron / is the self-dogfooding loop." *Both are false in code.* -The cartridge is even internally stale (README lists 10 tools; only 4 exist; `cartridge.json` -declares an `so_path` that isn't built). - -*Decision required (D0):* choose one — - -. *Converge* — make `feedback-mcp` the cartridge packaging of the real `feedback-o-tron` engine - (cartridge invokes the engine; retire the sentiment-counter or rename it). This is what the - diagram assumes ("feedback-o-tron Synthesis Engine" reachable via the Gateway). -. *Separate* — accept they are different products; rename one to end the collision - (e.g. cartridge → `sentiment-mcp`), and define a *new* first-class cartridge that wraps the engine. - -The rest of this architecture assumes *Converge*; if *Separate* is chosen, C2's cartridge wire -must be redrawn to a new cartridge name. - -== 6. Prerequisites & gaps to close (ordered) - -. *Resolve D0* (<>) — nothing downstream is well-defined until the identity is fixed. -. *Author `bug.yml` issue forms* — the diagram's C1 assumes structured `.yml` issue forms; - today only Markdown templates exist. No schema = nothing to fetch or hydrate. -. *Give `feedback-o-tron` a real intake* — either an HTTP backend on the port the cartridge - already targets (`127.0.0.1:7722`) or a boj-cartridge invocation path, accepting - `{template_schema, system_state}` — not just `{title, body, repo}`. -. *Build the Synthesis/Hydration step* — a module that maps template fields → hydrated answers - from LLM trajectory/logs + `NetworkVerifier`/OS inspection. -. *Wire the Deduplicator* — call `Deduplicator.record/3` so dedup is not inert. -. *Decide the ABI story* — either build a real Idris2/Zig verified boundary for the payload, or - *drop the "Idris-inside" claim*. Do not ship a badge with no code. - -== 7. Honesty debt to retire (independent of the build) - -These are current false-impression items surfaced during verification; fixing them is cheap and -should not wait for the pipeline: - -* `README.adoc` badges "Idris-inside" / "Production Ready" — no `.idr` files exist in the repo. -* `TOPOLOGY.md` asserts "100% / v1.0.0 Production Ready" for every box, while - `elixir-mcp/ARCHITECTURE.md` admits much is "aspirational/future." Reconcile to reality. -* `Deduplicator.record/3` never called → dedup inert. Either wire it or note it as disabled. -* `NetworkVerifier` supervised but never invoked → note as a standalone tool, not a pipeline stage. -* `ABI-FFI-README.md` is still an unfilled `{{PROJECT}}` RSR template. -* `deduplicator.ex` comment says "Jaro-Winkler"; the code computes normalized Levenshtein. +== 6. Prerequisites & gaps to close (ordered) — ALL CLOSED + +. *Resolve D0* (<>) — ✅ *done 2026-07-09*: option 2, new wrapping cartridge + `bug-filing-mcp` in `boj-server-cartridges`. +. *Author `bug.yml` issue forms* — ✅ *done (this branch)*: `.github/ISSUE_TEMPLATE/bug.yml` + and `feature.yml` are real structured issue forms; `TemplateFetcher` parses them. +. *Give `feedback-o-tron` a real intake* — ✅ *done (this branch)*: HTTP intake on + `127.0.0.1:7722` (`http_intake/router.ex`), accepting the synthesis-shaped request + (`{raw_feedback, repo, context, system_state}`), not just `{title, body, repo}`. +. *Build the Synthesis/Hydration step* — ✅ *done (this branch)*: + `synthesis/{intent_classifier,hydrator,synthesizer,research,form_validator,form_renderer, + template_fetcher,template_cache}.ex`, agent-in-the-loop. +. *Wire the Deduplicator* — ✅ *done (this branch)*: `Deduplicator.record/3` called on + successful submission (`submitter.ex:114`); `check/1` consulted pre-dispatch and in Research. +. *Decide the ABI story* — ✅ *decided (this branch)*: Idris2 contract spec + (`Contract.idr`) + Elixir `FormValidator` runtime boundary; the "Idris-inside" badge is + replaced with the honest claim, and full FFI enforcement is tracked as follow-up work — + no badge ships without code behind it. + +== 7. Honesty debt — RETIRED + +Every false-impression item surfaced during the 2026-07-01 verification has been retired: + +* `README.adoc` badges "Idris-inside" / "Production Ready" — *retired (this branch)*: the + "Production Ready" badge and claims are removed; "Idris-inside" is replaced with the honest + form, "Idris2-verified contract spec (src/abi) + Elixir runtime validation boundary". +* `TOPOLOGY.md` universal "100% / v1.0.0 Production Ready" — *retired (this branch)*: + dashboard reconciled to honest per-component statuses (overall ~75%), with an explicit + honesty rule added to its update protocol. +* `Deduplicator.record/3` never called → dedup inert — *retired (this branch)*: wired + (`submitter.ex:114`); dedup is live end-to-end. +* `NetworkVerifier` supervised but never invoked — *retired (this branch)*: optionally wired + via `Hydrator` (`network_probe: true`), and documented as opt-in rather than claimed as an + always-on pipeline stage. +* `ABI-FFI-README.md` an unfilled `{{PROJECT}}` RSR template — *retired (this branch)*: + rewritten with project-specific content that states the three layers' real maturity + (Idris2 spec REAL, Elixir validator REAL, Zig FFI STUB). +* `deduplicator.ex` comment says "Jaro-Winkler" while computing normalized Levenshtein — + *retired (this branch)*: comment corrected to Levenshtein. == 8. Relationship to the issue tracker @@ -247,6 +309,7 @@ should not wait for the pipeline: ** OCI containerisation & immutable runtime isolation (was #262) ** Offline pre-fetch & stale-while-revalidate cartridge caching (was #263) ** OpenTelemetry tracing across the boj ↔ ABI ↔ cartridge boundary (was #264) -* *Missing core-pipeline issues* — the actual "main effort," never filed: C1 template-fetch, - C2 synthesis/hydration, C3 verified-ABI payload, plus D0 (the identity decision). These are - the issues that make the pipeline exist; the triplet only hardens the box it would run in. +* *Core-pipeline issues* — the C1 template-fetch, C2 synthesis/hydration, C3 verified-contract, + and D0 identity items are delivered by this branch (see <>); the remaining + trackable work is FFI enforcement, forge-side clustering, and org-level `.github` + template support (see `ROADMAP.adoc`). diff --git a/elixir-mcp/.formatter.exs b/elixir-mcp/.formatter.exs new file mode 100644 index 0000000..66783fc --- /dev/null +++ b/elixir-mcp/.formatter.exs @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: MPL-2.0 +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/elixir-mcp/lib/feedback_a_tron/application.ex b/elixir-mcp/lib/feedback_a_tron/application.ex index 8f1f43c..ca51976 100644 --- a/elixir-mcp/lib/feedback_a_tron/application.ex +++ b/elixir-mcp/lib/feedback_a_tron/application.ex @@ -8,6 +8,7 @@ defmodule FeedbackATron.Application do - Submitter: Multi-platform issue submission - Deduplicator: Prevents duplicate submissions - AuditLog: Records all operations + - Synthesis.TemplateCache: TTL cache for fetched issue-form templates - NetworkVerifier: Pre-flight network checks - MigrationObserver: ReScript migration session tracking (optional) - BatchReviewer: Issue review queue (optional, with migration observer) @@ -24,6 +25,7 @@ defmodule FeedbackATron.Application do FeedbackATron.Submitter, FeedbackATron.Deduplicator, FeedbackATron.AuditLog, + FeedbackATron.Synthesis.TemplateCache, # Network verification (optional, can be disabled) {FeedbackATron.NetworkVerifier, enabled: true} @@ -61,7 +63,11 @@ defmodule FeedbackATron.Application do FeedbackATron.MCP.Server, name: "feedback-a-tron", version: Application.spec(:feedback_a_tron, :vsn) |> to_string(), - tools: [FeedbackATron.MCP.Tools.SubmitFeedback] + tools: [ + FeedbackATron.MCP.Tools.SubmitFeedback, + FeedbackATron.MCP.Tools.ResearchFeedback, + FeedbackATron.MCP.Tools.SynthesizeFeedback + ] } children ++ [mcp_child] @@ -88,7 +94,9 @@ defmodule FeedbackATron.Application do env_on? = case env_enabled? do - nil -> false + nil -> + false + value -> normalized = value @@ -141,7 +149,9 @@ defmodule FeedbackATron.Application do env_on? = case env_val do - nil -> false + nil -> + false + value -> normalized = value |> String.trim() |> String.downcase() Enum.member?(["1", "true", "yes", "on"], normalized) diff --git a/elixir-mcp/lib/feedback_a_tron/audit_log.ex b/elixir-mcp/lib/feedback_a_tron/audit_log.ex index b83ac5e..c8a15f9 100644 --- a/elixir-mcp/lib/feedback_a_tron/audit_log.ex +++ b/elixir-mcp/lib/feedback_a_tron/audit_log.ex @@ -21,7 +21,8 @@ defmodule FeedbackATron.AuditLog do require Logger @log_file "feedback_a_tron_audit.jsonl" - @max_log_size 10_000_000 # 10MB before rotation + # 10MB before rotation + @max_log_size 10_000_000 defstruct [ :log_file, @@ -64,11 +65,12 @@ defmodule FeedbackATron.AuditLog do Log a submission attempt. """ def log_submission(platform, issue, status, details \\ %{}) do - event_type = case status do - :success -> :submission_success - :failure -> :submission_failure - _ -> :submission_attempt - end + event_type = + case status do + :success -> :submission_success + :failure -> :submission_failure + _ -> :submission_attempt + end log(event_type, %{ platform: platform, @@ -95,11 +97,12 @@ defmodule FeedbackATron.AuditLog do Log deduplication check. """ def log_dedup(issue_hash, result, details \\ %{}) do - event_type = case result do - :duplicate -> :dedup_match - :similar -> :dedup_match - _ -> :dedup_check - end + event_type = + case result do + :duplicate -> :dedup_match + :similar -> :dedup_match + _ -> :dedup_check + end log(event_type, %{ hash: issue_hash, @@ -186,6 +189,7 @@ defmodule FeedbackATron.AuditLog do log_file: state.log_file, uptime_seconds: DateTime.diff(DateTime.utc_now(), state.started_at) } + {:reply, stats, state} end @@ -201,6 +205,7 @@ defmodule FeedbackATron.AuditLog do session_id: state.session_id, entry_count: state.entry_count }) + File.close(state.log_handle) :ok end @@ -230,6 +235,7 @@ defmodule FeedbackATron.AuditLog do case File.stat(state.log_file) do {:ok, %{size: size}} when size > @max_log_size -> rotate_log(state) + _ -> state end @@ -266,13 +272,14 @@ defmodule FeedbackATron.AuditLog do defp sanitize(data) when is_map(data) do # Remove sensitive fields - sensitive_keys = ~w(password token secret key api_key access_token)a ++ - ~w(password token secret key api_key access_token) + sensitive_keys = + ~w(password token secret key api_key access_token)a ++ + ~w(password token secret key api_key access_token) data |> Enum.reject(fn {k, _v} -> key_str = to_string(k) |> String.downcase() - Enum.any?(sensitive_keys, &(String.contains?(key_str, to_string(&1)))) + Enum.any?(sensitive_keys, &String.contains?(key_str, to_string(&1))) end) |> Enum.into(%{}) end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/bitbucket.ex b/elixir-mcp/lib/feedback_a_tron/channels/bitbucket.ex index dc425c1..bd70208 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/bitbucket.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/bitbucket.ex @@ -40,19 +40,31 @@ defmodule FeedbackATron.Channels.Bitbucket do {:ok, %{platform: :bitbucket, url: resp["links"]["html"]["href"]}} {:ok, %{status: 401, body: _error}} -> - {:error, %FeedbackATron.Error.AuthenticationError{platform: :bitbucket, reason: "token rejected"}} + {:error, + %FeedbackATron.Error.AuthenticationError{platform: :bitbucket, reason: "token rejected"}} {:ok, %{status: 429, body: _error}} -> - {:error, %FeedbackATron.Error.RateLimitError{platform: :bitbucket, resets_at: nil, remaining: 0}} + {:error, + %FeedbackATron.Error.RateLimitError{platform: :bitbucket, resets_at: nil, remaining: 0}} {:ok, %{status: status, body: error}} when status >= 400 and status < 500 -> {:error, %FeedbackATron.Error.ValidationError{field: "issue", reason: inspect(error)}} {:ok, %{status: status, body: error}} -> - {:error, %FeedbackATron.Error.PlatformError{platform: :bitbucket, status: status, body: inspect(error)}} + {:error, + %FeedbackATron.Error.PlatformError{ + platform: :bitbucket, + status: status, + body: inspect(error) + }} {:error, reason} -> - {:error, %FeedbackATron.Error.NetworkError{platform: :bitbucket, reason: inspect(reason), url: url}} + {:error, + %FeedbackATron.Error.NetworkError{ + platform: :bitbucket, + reason: inspect(reason), + url: url + }} end end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/bugzilla.ex b/elixir-mcp/lib/feedback_a_tron/channels/bugzilla.ex index 9d8e6d6..b1dda32 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/bugzilla.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/bugzilla.ex @@ -48,22 +48,32 @@ defmodule FeedbackATron.Channels.Bugzilla do case Req.post(url, json: body, headers: headers) do {:ok, %{status: 200, body: resp}} when is_map(resp) -> bug_id = resp["id"] - {:ok, %{platform: :bugzilla, url: "#{base_url}/show_bug.cgi?id=#{bug_id}", bug_id: bug_id}} + + {:ok, + %{platform: :bugzilla, url: "#{base_url}/show_bug.cgi?id=#{bug_id}", bug_id: bug_id}} {:ok, %{status: 401, body: _error}} -> - {:error, %FeedbackATron.Error.AuthenticationError{platform: :bugzilla, reason: "API key rejected"}} + {:error, + %FeedbackATron.Error.AuthenticationError{platform: :bugzilla, reason: "API key rejected"}} {:ok, %{status: 429, body: _error}} -> - {:error, %FeedbackATron.Error.RateLimitError{platform: :bugzilla, resets_at: nil, remaining: 0}} + {:error, + %FeedbackATron.Error.RateLimitError{platform: :bugzilla, resets_at: nil, remaining: 0}} {:ok, %{status: status, body: error}} when status >= 400 and status < 500 -> {:error, %FeedbackATron.Error.ValidationError{field: "bug", reason: inspect(error)}} {:ok, %{status: status, body: error}} -> - {:error, %FeedbackATron.Error.PlatformError{platform: :bugzilla, status: status, body: inspect(error)}} + {:error, + %FeedbackATron.Error.PlatformError{ + platform: :bugzilla, + status: status, + body: inspect(error) + }} {:error, reason} -> - {:error, %FeedbackATron.Error.NetworkError{platform: :bugzilla, reason: inspect(reason), url: url}} + {:error, + %FeedbackATron.Error.NetworkError{platform: :bugzilla, reason: inspect(reason), url: url}} end end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/codeberg.ex b/elixir-mcp/lib/feedback_a_tron/channels/codeberg.ex index c53e507..d18bc2a 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/codeberg.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/codeberg.ex @@ -34,19 +34,27 @@ defmodule FeedbackATron.Channels.Codeberg do {:ok, %{platform: :codeberg, url: resp["html_url"]}} {:ok, %{status: 401, body: _error}} -> - {:error, %FeedbackATron.Error.AuthenticationError{platform: :codeberg, reason: "token rejected"}} + {:error, + %FeedbackATron.Error.AuthenticationError{platform: :codeberg, reason: "token rejected"}} {:ok, %{status: 429, body: _error}} -> - {:error, %FeedbackATron.Error.RateLimitError{platform: :codeberg, resets_at: nil, remaining: 0}} + {:error, + %FeedbackATron.Error.RateLimitError{platform: :codeberg, resets_at: nil, remaining: 0}} {:ok, %{status: status, body: error}} when status >= 400 and status < 500 -> {:error, %FeedbackATron.Error.ValidationError{field: "issue", reason: inspect(error)}} {:ok, %{status: status, body: error}} -> - {:error, %FeedbackATron.Error.PlatformError{platform: :codeberg, status: status, body: inspect(error)}} + {:error, + %FeedbackATron.Error.PlatformError{ + platform: :codeberg, + status: status, + body: inspect(error) + }} {:error, reason} -> - {:error, %FeedbackATron.Error.NetworkError{platform: :codeberg, reason: inspect(reason), url: url}} + {:error, + %FeedbackATron.Error.NetworkError{platform: :codeberg, reason: inspect(reason), url: url}} end end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/discord.ex b/elixir-mcp/lib/feedback_a_tron/channels/discord.ex index 92a277d..62375f0 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/discord.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/discord.ex @@ -53,8 +53,10 @@ defmodule FeedbackATron.Channels.Discord do cond do not String.starts_with?(cred[:token] || "", "Bot ") -> {:error, "Discord bot token must start with 'Bot '"} + is_nil(cred[:channel_id]) -> {:error, "Discord channel_id required for bot mode"} + true -> :ok end @@ -93,7 +95,12 @@ defmodule FeedbackATron.Channels.Discord do message_id = resp["id"] guild_id = resp["guild_id"] || "unknown" msg_channel_id = resp["channel_id"] || channel_id - {:ok, %{platform: :discord, url: "https://discord.com/channels/#{guild_id}/#{msg_channel_id}/#{message_id}"}} + + {:ok, + %{ + platform: :discord, + url: "https://discord.com/channels/#{guild_id}/#{msg_channel_id}/#{message_id}" + }} {:ok, %{status: status, body: resp}} -> error_msg = extract_error(resp) diff --git a/elixir-mcp/lib/feedback_a_tron/channels/discourse.ex b/elixir-mcp/lib/feedback_a_tron/channels/discourse.ex index ae09826..80cd4e8 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/discourse.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/discourse.ex @@ -24,11 +24,20 @@ defmodule FeedbackATron.Channels.Discourse do @impl true def validate_creds(cred) do cond do - is_nil(cred[:base_url]) -> {:error, "Discourse base URL required (e.g. https://forum.example.com)"} - not String.starts_with?(cred[:base_url] || "", "https://") -> {:error, "Discourse URL must be HTTPS"} - is_nil(cred[:api_key]) -> {:error, "Discourse API key required"} - is_nil(cred[:api_username]) -> {:error, "Discourse API username required"} - true -> :ok + is_nil(cred[:base_url]) -> + {:error, "Discourse base URL required (e.g. https://forum.example.com)"} + + not String.starts_with?(cred[:base_url] || "", "https://") -> + {:error, "Discourse URL must be HTTPS"} + + is_nil(cred[:api_key]) -> + {:error, "Discourse API key required"} + + is_nil(cred[:api_username]) -> + {:error, "Discourse API username required"} + + true -> + :ok end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/email.ex b/elixir-mcp/lib/feedback_a_tron/channels/email.ex index 5bce93b..b09c3c4 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/email.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/email.ex @@ -35,6 +35,8 @@ defmodule FeedbackATron.Channels.Email do # Email submission delegates to Mailman's SMTPS transport. # Standalone email will be implemented when gen_smtp or swoosh is added. Logger.warning("Email channel: use Mailman channel for SMTPS email submission") - {:error, %{platform: :email, error: :not_implemented, reason: "Use Mailman channel for SMTPS"}} + + {:error, + %{platform: :email, error: :not_implemented, reason: "Use Mailman channel for SMTPS"}} end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/github.ex b/elixir-mcp/lib/feedback_a_tron/channels/github.ex index a3a6f3c..78db644 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/github.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/github.ex @@ -50,7 +50,11 @@ defmodule FeedbackATron.Channels.GitHub do %FeedbackATron.Error.ValidationError{field: "issue", reason: String.trim(output)} true -> - %FeedbackATron.Error.PlatformError{platform: platform, status: nil, body: String.trim(output)} + %FeedbackATron.Error.PlatformError{ + platform: platform, + status: nil, + body: String.trim(output) + } end end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/gitlab.ex b/elixir-mcp/lib/feedback_a_tron/channels/gitlab.ex index e163144..fc355a4 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/gitlab.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/gitlab.ex @@ -25,11 +25,16 @@ defmodule FeedbackATron.Channels.GitLab do labels = Keyword.get(opts, :labels, []) |> Enum.join(",") args = [ - "issue", "create", - "--repo", repo, - "--title", issue.title, - "--description", issue.body, - "--label", labels + "issue", + "create", + "--repo", + repo, + "--title", + issue.title, + "--description", + issue.body, + "--label", + labels ] case System.cmd("glab", args, env: [{"GITLAB_TOKEN", cred.token}]) do @@ -39,13 +44,20 @@ defmodule FeedbackATron.Channels.GitLab do {error, _code} -> cond do String.contains?(error, "401") or String.contains?(error, "auth") -> - {:error, %FeedbackATron.Error.AuthenticationError{platform: :gitlab, reason: "token rejected"}} + {:error, + %FeedbackATron.Error.AuthenticationError{platform: :gitlab, reason: "token rejected"}} String.contains?(error, "429") -> - {:error, %FeedbackATron.Error.RateLimitError{platform: :gitlab, resets_at: nil, remaining: 0}} + {:error, + %FeedbackATron.Error.RateLimitError{platform: :gitlab, resets_at: nil, remaining: 0}} true -> - {:error, %FeedbackATron.Error.PlatformError{platform: :gitlab, status: nil, body: String.trim(error)}} + {:error, + %FeedbackATron.Error.PlatformError{ + platform: :gitlab, + status: nil, + body: String.trim(error) + }} end end end diff --git a/elixir-mcp/lib/feedback_a_tron/channels/jira.ex b/elixir-mcp/lib/feedback_a_tron/channels/jira.ex index 69e6f3a..d1d7f30 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/jira.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/jira.ex @@ -78,11 +78,13 @@ defmodule FeedbackATron.Channels.Jira do case Req.post(url, json: body, headers: headers, receive_timeout: 15_000) do {:ok, %{status: 201, body: resp}} -> issue_key = resp["key"] - {:ok, %{ - platform: :jira, - url: "#{base_url}/browse/#{issue_key}", - issue_key: issue_key - }} + + {:ok, + %{ + platform: :jira, + url: "#{base_url}/browse/#{issue_key}", + issue_key: issue_key + }} {:ok, %{status: status, body: %{"errors" => errors}}} -> {:error, %{platform: :jira, status: status, error: errors}} diff --git a/elixir-mcp/lib/feedback_a_tron/channels/matrix.ex b/elixir-mcp/lib/feedback_a_tron/channels/matrix.ex index f4935c4..baf7312 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/matrix.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/matrix.ex @@ -64,7 +64,8 @@ defmodule FeedbackATron.Channels.Matrix do msgtype: "m.text", body: "**#{issue.title}**\n\n#{issue.body}", format: "org.matrix.custom.html", - formatted_body: "

#{html_escape(issue.title)}

\n#{markdown_to_html(issue.body)}" + formatted_body: + "

#{html_escape(issue.title)}

\n#{markdown_to_html(issue.body)}" } :plain -> @@ -92,11 +93,12 @@ defmodule FeedbackATron.Channels.Matrix do case Req.put(url, json: body, headers: headers, receive_timeout: 15_000) do {:ok, %{status: 200, body: %{"event_id" => event_id}}} -> - {:ok, %{ - platform: :matrix, - url: "https://matrix.to/#/#{room_id}/#{event_id}", - event_id: event_id - }} + {:ok, + %{ + platform: :matrix, + url: "https://matrix.to/#/#{room_id}/#{event_id}", + event_id: event_id + }} {:ok, %{status: status, body: error}} -> {:error, %{platform: :matrix, status: status, error: error}} diff --git a/elixir-mcp/lib/feedback_a_tron/channels/reddit.ex b/elixir-mcp/lib/feedback_a_tron/channels/reddit.ex index 1a86d4b..f9c2828 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/reddit.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/reddit.ex @@ -54,7 +54,8 @@ defmodule FeedbackATron.Channels.Reddit do subreddit = opts[:subreddit] if is_nil(subreddit) do - {:error, %{platform: :reddit, error: "subreddit option is required (e.g. [subreddit: \"somesub\"])"}} + {:error, + %{platform: :reddit, error: "subreddit option is required (e.g. [subreddit: \"somesub\"])"}} else with {:ok, _ips} <- SecureDNS.resolve("www.reddit.com"), {:ok, _ips} <- SecureDNS.resolve("oauth.reddit.com"), @@ -84,11 +85,12 @@ defmodule FeedbackATron.Channels.Reddit do {"Content-Type", "application/x-www-form-urlencoded"} ] - form_body = URI.encode_query(%{ - grant_type: "password", - username: cred.username, - password: cred.password - }) + form_body = + URI.encode_query(%{ + grant_type: "password", + username: cred.username, + password: cred.password + }) case Req.post(auth_url, body: form_body, headers: headers, receive_timeout: 15_000) do {:ok, %{status: 200, body: %{"access_token" => token}}} -> @@ -110,11 +112,12 @@ defmodule FeedbackATron.Channels.Reddit do submit_url = "https://oauth.reddit.com/api/submit" # Build the post body with repo context if available - post_body = if issue[:repo] do - "#{issue.body}\n\n---\n*Repository: #{issue.repo}*" - else - issue.body - end + post_body = + if issue[:repo] do + "#{issue.body}\n\n---\n*Repository: #{issue.repo}*" + else + issue.body + end headers = [ {"Authorization", "bearer #{access_token}"}, @@ -122,13 +125,14 @@ defmodule FeedbackATron.Channels.Reddit do {"Content-Type", "application/x-www-form-urlencoded"} ] - form_body = URI.encode_query(%{ - api_type: "json", - kind: "self", - sr: subreddit, - title: issue.title, - text: post_body - }) + form_body = + URI.encode_query(%{ + api_type: "json", + kind: "self", + sr: subreddit, + title: issue.title, + text: post_body + }) case Req.post(submit_url, body: form_body, headers: headers, receive_timeout: 15_000) do {:ok, %{status: 200, body: %{"json" => %{"data" => %{"permalink" => permalink}}}}} -> diff --git a/elixir-mcp/lib/feedback_a_tron/channels/sourcehut.ex b/elixir-mcp/lib/feedback_a_tron/channels/sourcehut.ex index 7e24ee1..48ccabb 100644 --- a/elixir-mcp/lib/feedback_a_tron/channels/sourcehut.ex +++ b/elixir-mcp/lib/feedback_a_tron/channels/sourcehut.ex @@ -26,9 +26,14 @@ defmodule FeedbackATron.Channels.SourceHut do @impl true def validate_creds(cred) do cond do - is_nil(cred[:token]) -> {:error, "SourceHut personal access token required"} - is_nil(cred[:tracker]) -> {:error, "SourceHut tracker name required (e.g. ~user/tracker-name)"} - true -> :ok + is_nil(cred[:token]) -> + {:error, "SourceHut personal access token required"} + + is_nil(cred[:tracker]) -> + {:error, "SourceHut tracker name required (e.g. ~user/tracker-name)"} + + true -> + :ok end end @@ -62,11 +67,13 @@ defmodule FeedbackATron.Channels.SourceHut do } labels = opts[:labels] || cred[:default_labels] || [] - variables = if labels != [] do - put_in(variables, [:input, :labels], labels) - else - variables - end + + variables = + if labels != [] do + put_in(variables, [:input, :labels], labels) + else + variables + end headers = [ {"Authorization", "Bearer #{cred.token}"}, @@ -79,11 +86,13 @@ defmodule FeedbackATron.Channels.SourceHut do case Req.post(gql_url, json: body, headers: headers, receive_timeout: 15_000) do {:ok, %{status: 200, body: %{"data" => %{"submitTicket" => ticket}}}} -> ticket_ref = ticket["ref"] - {:ok, %{ - platform: :sourcehut, - url: "#{api_base}/#{tracker}/#{ticket_ref}", - ticket_id: ticket["id"] - }} + + {:ok, + %{ + platform: :sourcehut, + url: "#{api_base}/#{tracker}/#{ticket_ref}", + ticket_id: ticket["id"] + }} {:ok, %{status: 200, body: %{"errors" => errors}}} -> {:error, %{platform: :sourcehut, error: errors}} diff --git a/elixir-mcp/lib/feedback_a_tron/cli.ex b/elixir-mcp/lib/feedback_a_tron/cli.ex index d184fbf..1512af8 100644 --- a/elixir-mcp/lib/feedback_a_tron/cli.ex +++ b/elixir-mcp/lib/feedback_a_tron/cli.ex @@ -27,6 +27,7 @@ defmodule FeedbackATron.CLI do IO.puts("\n✅ Submission #{id} completed") print_results(results) System.halt(0) + {:error, reason} -> IO.puts("\n❌ Submission failed: #{inspect(reason)}") System.halt(1) @@ -78,6 +79,7 @@ defmodule FeedbackATron.CLI do with {:ok, opts} <- extract_options(args), {:ok, issue} <- build_issue(opts) do platforms = Keyword.get(opts, :platforms, [:github]) + submit_opts = [ platforms: platforms, labels: Keyword.get(opts, :labels, []), @@ -86,6 +88,7 @@ defmodule FeedbackATron.CLI do component: Keyword.get(opts, :component), version: Keyword.get(opts, :bug_version) ] + {:ok, issue, submit_opts} end end @@ -155,10 +158,13 @@ defmodule FeedbackATron.CLI do Enum.each(results, fn {:ok, %{platform: platform, url: url}} -> IO.puts(" ✓ #{platform}: #{url}") + {:ok, %{platform: platform, status: :dry_run}} -> IO.puts(" [DRY RUN] #{platform}: Would submit") + {:error, %{platform: platform, error: error}} -> IO.puts(" ✗ #{platform}: #{inspect(error)}") + other -> IO.puts(" ? #{inspect(other)}") end) diff --git a/elixir-mcp/lib/feedback_a_tron/credentials.ex b/elixir-mcp/lib/feedback_a_tron/credentials.ex index e5cd5af..f94f79e 100644 --- a/elixir-mcp/lib/feedback_a_tron/credentials.ex +++ b/elixir-mcp/lib/feedback_a_tron/credentials.ex @@ -15,12 +15,21 @@ defmodule FeedbackATron.Credentials do require Logger - - defstruct [ - :github, :gitlab, :bitbucket, :codeberg, :bugzilla, :email, - :nntp, :discourse, :mailman, :sourcehut, :jira, :matrix, - :discord, :reddit + :github, + :gitlab, + :bitbucket, + :codeberg, + :bugzilla, + :email, + :nntp, + :discourse, + :mailman, + :sourcehut, + :jira, + :matrix, + :discord, + :reddit ] alias FeedbackATron.Credentials.FileStore @@ -89,16 +98,18 @@ defmodule FeedbackATron.Credentials do creds = [] # Environment variable - creds = case System.get_env("GITHUB_TOKEN") || System.get_env("GH_TOKEN") do - nil -> creds - token -> [%{source: :env, token: token} | creds] - end + creds = + case System.get_env("GITHUB_TOKEN") || System.get_env("GH_TOKEN") do + nil -> creds + token -> [%{source: :env, token: token} | creds] + end # gh CLI config - creds = case load_gh_cli_token() do - nil -> creds - token -> [%{source: :gh_cli, token: token} | creds] - end + creds = + case load_gh_cli_token() do + nil -> creds + token -> [%{source: :gh_cli, token: token} | creds] + end creds end @@ -113,7 +124,9 @@ defmodule FeedbackATron.Credentials do {:ok, %{"github.com" => %{"oauth_token" => token}}} -> token _ -> nil end - {:error, _} -> nil + + {:error, _} -> + nil end end @@ -121,15 +134,17 @@ defmodule FeedbackATron.Credentials do defp load_gitlab_creds do creds = [] - creds = case System.get_env("GITLAB_TOKEN") do - nil -> creds - token -> [%{source: :env, token: token, host: "gitlab.com"} | creds] - end + creds = + case System.get_env("GITLAB_TOKEN") do + nil -> creds + token -> [%{source: :env, token: token, host: "gitlab.com"} | creds] + end - creds = case load_glab_cli_token() do - nil -> creds - {token, host} -> [%{source: :glab_cli, token: token, host: host} | creds] - end + creds = + case load_glab_cli_token() do + nil -> creds + {token, host} -> [%{source: :glab_cli, token: token, host: host} | creds] + end creds end @@ -142,16 +157,22 @@ defmodule FeedbackATron.Credentials do case YamlElixir.read_from_string(content) do {:ok, %{"hosts" => %{"gitlab.com" => %{"token" => token}}}} -> {token, "gitlab.com"} - _ -> nil + + _ -> + nil end - {:error, _} -> nil + + {:error, _} -> + nil end end # Bitbucket: App password from env or config defp load_bitbucket_creds do case System.get_env("BITBUCKET_TOKEN") do - nil -> [] + nil -> + [] + token -> username = System.get_env("BITBUCKET_USERNAME", "hyperpolymath") [%{source: :env, token: token, username: username}] @@ -170,17 +191,42 @@ defmodule FeedbackATron.Credentials do defp load_bugzilla_creds do creds = [] - creds = case System.get_env("BUGZILLA_API_KEY") do - nil -> creds - token -> [%{source: :env, token: token, base_url: System.get_env("BUGZILLA_URL", "https://bugzilla.redhat.com")} | creds] - end + creds = + case System.get_env("BUGZILLA_API_KEY") do + nil -> + creds + + token -> + [ + %{ + source: :env, + token: token, + base_url: System.get_env("BUGZILLA_URL", "https://bugzilla.redhat.com") + } + | creds + ] + end # Also support username/password auth - creds = case {System.get_env("BUGZILLA_USERNAME"), System.get_env("BUGZILLA_PASSWORD")} do - {nil, _} -> creds - {_, nil} -> creds - {user, pass} -> [%{source: :env, username: user, password: pass, base_url: System.get_env("BUGZILLA_URL", "https://bugzilla.redhat.com")} | creds] - end + creds = + case {System.get_env("BUGZILLA_USERNAME"), System.get_env("BUGZILLA_PASSWORD")} do + {nil, _} -> + creds + + {_, nil} -> + creds + + {user, pass} -> + [ + %{ + source: :env, + username: user, + password: pass, + base_url: System.get_env("BUGZILLA_URL", "https://bugzilla.redhat.com") + } + | creds + ] + end creds end @@ -188,48 +234,59 @@ defmodule FeedbackATron.Credentials do # Email: SMTP config defp load_email_config do case System.get_env("SMTP_HOST") do - nil -> nil - host -> %{ - host: host, - port: String.to_integer(System.get_env("SMTP_PORT", "587")), - username: System.get_env("SMTP_USERNAME"), - password: System.get_env("SMTP_PASSWORD"), - from_address: System.get_env("SMTP_FROM", "feedback@localhost"), - default_recipient: System.get_env("FEEDBACK_EMAIL_TO") - } + nil -> + nil + + host -> + %{ + host: host, + port: String.to_integer(System.get_env("SMTP_PORT", "587")), + username: System.get_env("SMTP_USERNAME"), + password: System.get_env("SMTP_PASSWORD"), + from_address: System.get_env("SMTP_FROM", "feedback@localhost"), + default_recipient: System.get_env("FEEDBACK_EMAIL_TO") + } end end # NNTP: NNTPS server config defp load_nntp_creds do case System.get_env("NNTP_SERVER") do - nil -> [] + nil -> + [] + server -> - [%{ - source: :env, - server: server, - port: String.to_integer(System.get_env("NNTP_PORT", "563")), - newsgroup: System.get_env("NNTP_NEWSGROUP"), - username: System.get_env("NNTP_USERNAME"), - password: System.get_env("NNTP_PASSWORD"), - from: System.get_env("NNTP_FROM", "feedback-a-tron@localhost") - }] + [ + %{ + source: :env, + server: server, + port: String.to_integer(System.get_env("NNTP_PORT", "563")), + newsgroup: System.get_env("NNTP_NEWSGROUP"), + username: System.get_env("NNTP_USERNAME"), + password: System.get_env("NNTP_PASSWORD"), + from: System.get_env("NNTP_FROM", "feedback-a-tron@localhost") + } + ] end end # Discourse: HTTPS API defp load_discourse_creds do case System.get_env("DISCOURSE_URL") do - nil -> [] + nil -> + [] + url -> if String.starts_with?(url, "https://") do - [%{ - source: :env, - base_url: url, - api_key: System.get_env("DISCOURSE_API_KEY"), - api_username: System.get_env("DISCOURSE_API_USERNAME", "system"), - default_category_id: System.get_env("DISCOURSE_CATEGORY_ID") - }] + [ + %{ + source: :env, + base_url: url, + api_key: System.get_env("DISCOURSE_API_KEY"), + api_username: System.get_env("DISCOURSE_API_USERNAME", "system"), + default_category_id: System.get_env("DISCOURSE_CATEGORY_ID") + } + ] else Logger.warning("Discourse URL must be HTTPS, ignoring: #{url}") [] @@ -242,35 +299,47 @@ defmodule FeedbackATron.Credentials do creds = [] # HyperKitty REST API - creds = case System.get_env("HYPERKITTY_URL") do - nil -> creds - url -> - if String.starts_with?(url, "https://") do - [%{ - source: :env, - hyperkitty_url: url, - api_key: System.get_env("HYPERKITTY_API_KEY"), - list_id: System.get_env("MAILMAN_LIST_ID") - } | creds] - else + creds = + case System.get_env("HYPERKITTY_URL") do + nil -> creds - end - end + + url -> + if String.starts_with?(url, "https://") do + [ + %{ + source: :env, + hyperkitty_url: url, + api_key: System.get_env("HYPERKITTY_API_KEY"), + list_id: System.get_env("MAILMAN_LIST_ID") + } + | creds + ] + else + creds + end + end # Direct SMTPS to list address - creds = case System.get_env("MAILMAN_LIST_ADDRESS") do - nil -> creds - list_addr -> - [%{ - source: :env, - list_address: list_addr, - smtp_server: System.get_env("MAILMAN_SMTP_SERVER"), - smtp_port: String.to_integer(System.get_env("MAILMAN_SMTP_PORT", "465")), - smtp_username: System.get_env("MAILMAN_SMTP_USERNAME"), - smtp_password: System.get_env("MAILMAN_SMTP_PASSWORD"), - from: System.get_env("MAILMAN_FROM", "feedback-a-tron@localhost") - } | creds] - end + creds = + case System.get_env("MAILMAN_LIST_ADDRESS") do + nil -> + creds + + list_addr -> + [ + %{ + source: :env, + list_address: list_addr, + smtp_server: System.get_env("MAILMAN_SMTP_SERVER"), + smtp_port: String.to_integer(System.get_env("MAILMAN_SMTP_PORT", "465")), + smtp_username: System.get_env("MAILMAN_SMTP_USERNAME"), + smtp_password: System.get_env("MAILMAN_SMTP_PASSWORD"), + from: System.get_env("MAILMAN_FROM", "feedback-a-tron@localhost") + } + | creds + ] + end creds end @@ -278,33 +347,41 @@ defmodule FeedbackATron.Credentials do # SourceHut: personal access token defp load_sourcehut_creds do case System.get_env("SRHT_TOKEN") do - nil -> [] + nil -> + [] + token -> - [%{ - source: :env, - token: token, - tracker: System.get_env("SRHT_TRACKER"), - api_base: System.get_env("SRHT_API_BASE", "https://todo.sr.ht") - }] + [ + %{ + source: :env, + token: token, + tracker: System.get_env("SRHT_TRACKER"), + api_base: System.get_env("SRHT_API_BASE", "https://todo.sr.ht") + } + ] end end # Jira: Cloud (email + API token) or Server (PAT) defp load_jira_creds do case System.get_env("JIRA_URL") do - nil -> [] + nil -> + [] + url -> if String.starts_with?(url, "https://") do - [%{ - source: :env, - base_url: url, - email: System.get_env("JIRA_EMAIL"), - api_token: System.get_env("JIRA_API_TOKEN"), - token: System.get_env("JIRA_TOKEN"), - project_key: System.get_env("JIRA_PROJECT_KEY"), - default_issue_type: System.get_env("JIRA_ISSUE_TYPE", "Task"), - api_version: System.get_env("JIRA_API_VERSION", "2") - }] + [ + %{ + source: :env, + base_url: url, + email: System.get_env("JIRA_EMAIL"), + api_token: System.get_env("JIRA_API_TOKEN"), + token: System.get_env("JIRA_TOKEN"), + project_key: System.get_env("JIRA_PROJECT_KEY"), + default_issue_type: System.get_env("JIRA_ISSUE_TYPE", "Task"), + api_version: System.get_env("JIRA_API_VERSION", "2") + } + ] else Logger.warning("Jira URL must be HTTPS, ignoring: #{url}") [] @@ -315,15 +392,19 @@ defmodule FeedbackATron.Credentials do # Matrix: homeserver + access token defp load_matrix_creds do case System.get_env("MATRIX_HOMESERVER") do - nil -> [] + nil -> + [] + homeserver -> if String.starts_with?(homeserver, "https://") do - [%{ - source: :env, - homeserver: homeserver, - access_token: System.get_env("MATRIX_ACCESS_TOKEN"), - room_id: System.get_env("MATRIX_ROOM_ID") - }] + [ + %{ + source: :env, + homeserver: homeserver, + access_token: System.get_env("MATRIX_ACCESS_TOKEN"), + room_id: System.get_env("MATRIX_ROOM_ID") + } + ] else Logger.warning("Matrix homeserver must be HTTPS, ignoring: #{homeserver}") [] @@ -336,29 +417,39 @@ defmodule FeedbackATron.Credentials do creds = [] # Bot mode: token + channel_id - creds = case System.get_env("DISCORD_BOT_TOKEN") do - nil -> creds - token -> - channel_id = System.get_env("DISCORD_CHANNEL_ID") - if channel_id do - [%{source: :env, token: "Bot #{token}", channel_id: channel_id} | creds] - else - Logger.warning("DISCORD_BOT_TOKEN set but DISCORD_CHANNEL_ID missing, skipping bot mode") + creds = + case System.get_env("DISCORD_BOT_TOKEN") do + nil -> creds - end - end + + token -> + channel_id = System.get_env("DISCORD_CHANNEL_ID") + + if channel_id do + [%{source: :env, token: "Bot #{token}", channel_id: channel_id} | creds] + else + Logger.warning( + "DISCORD_BOT_TOKEN set but DISCORD_CHANNEL_ID missing, skipping bot mode" + ) + + creds + end + end # Webhook mode: standalone webhook URL - creds = case System.get_env("DISCORD_WEBHOOK_URL") do - nil -> creds - url -> - if String.starts_with?(url, "https://discord.com/api/webhooks/") do - [%{source: :env, webhook_url: url} | creds] - else - Logger.warning("DISCORD_WEBHOOK_URL must be HTTPS Discord webhook, ignoring: #{url}") + creds = + case System.get_env("DISCORD_WEBHOOK_URL") do + nil -> creds - end - end + + url -> + if String.starts_with?(url, "https://discord.com/api/webhooks/") do + [%{source: :env, webhook_url: url} | creds] + else + Logger.warning("DISCORD_WEBHOOK_URL must be HTTPS Discord webhook, ignoring: #{url}") + creds + end + end creds end @@ -366,20 +457,26 @@ defmodule FeedbackATron.Credentials do # Reddit: OAuth2 script app credentials defp load_reddit_creds do case {System.get_env("REDDIT_CLIENT_ID"), System.get_env("REDDIT_CLIENT_SECRET")} do - {nil, _} -> [] - {_, nil} -> [] + {nil, _} -> + [] + + {_, nil} -> + [] + {client_id, client_secret} -> username = System.get_env("REDDIT_USERNAME") password = System.get_env("REDDIT_PASSWORD") if username && password do - [%{ - source: :env, - client_id: client_id, - client_secret: client_secret, - username: username, - password: password - }] + [ + %{ + source: :env, + client_id: client_id, + client_secret: client_secret, + username: username, + password: password + } + ] else Logger.warning("REDDIT_CLIENT_ID/SECRET set but REDDIT_USERNAME/PASSWORD missing") [] diff --git a/elixir-mcp/lib/feedback_a_tron/credentials/file_store.ex b/elixir-mcp/lib/feedback_a_tron/credentials/file_store.ex index 0e404de..1742dad 100644 --- a/elixir-mcp/lib/feedback_a_tron/credentials/file_store.ex +++ b/elixir-mcp/lib/feedback_a_tron/credentials/file_store.ex @@ -145,14 +145,20 @@ defmodule FeedbackATron.Credentials.FileStore do defp extract_netrc_entries(["machine " <> machine | rest], entries, current) do entries = if current, do: [current | entries], else: entries - extract_netrc_entries(rest, entries, %{machine: String.trim(machine), login: nil, password: nil}) + + extract_netrc_entries(rest, entries, %{ + machine: String.trim(machine), + login: nil, + password: nil + }) end defp extract_netrc_entries(["login " <> login | rest], entries, current) when current != nil do extract_netrc_entries(rest, entries, %{current | login: String.trim(login)}) end - defp extract_netrc_entries(["password " <> password | rest], entries, current) when current != nil do + defp extract_netrc_entries(["password " <> password | rest], entries, current) + when current != nil do extract_netrc_entries(rest, entries, %{current | password: String.trim(password)}) end @@ -170,10 +176,11 @@ defmodule FeedbackATron.Credentials.FileStore do platform = safe_to_atom(platform_str) if platform do - cred = Map.put(values, "source", "config_file") - |> Enum.map(fn {k, v} -> {String.to_atom(k), v} end) - |> Enum.into(%{}) - |> Map.put(:source, :config_file) + cred = + Map.put(values, "source", "config_file") + |> Enum.map(fn {k, v} -> {String.to_atom(k), v} end) + |> Enum.into(%{}) + |> Map.put(:source, :config_file) Map.update(acc, platform, [cred], &[cred | &1]) else diff --git a/elixir-mcp/lib/feedback_a_tron/deduplicator.ex b/elixir-mcp/lib/feedback_a_tron/deduplicator.ex index c72d202..bd77754 100644 --- a/elixir-mcp/lib/feedback_a_tron/deduplicator.ex +++ b/elixir-mcp/lib/feedback_a_tron/deduplicator.ex @@ -16,6 +16,8 @@ defmodule FeedbackATron.Deduplicator do use GenServer require Logger + alias FeedbackATron.TextSimilarity + @similarity_threshold 0.85 @ets_table :feedback_submissions @@ -93,44 +95,49 @@ defmodule FeedbackATron.Deduplicator do body = issue[:body] || issue["body"] || "" hash = compute_hash(title, body) - result = cond do - # Check exact hash match - Map.has_key?(state.hash_index, hash) -> - existing = state.hash_index[hash] - {:duplicate, existing} - - # Check fuzzy title match - similar = find_similar_titles(title, state.title_index) -> - if length(similar) > 0 do - {:similar, similar} - else + result = + cond do + # Check exact hash match + Map.has_key?(state.hash_index, hash) -> + existing = state.hash_index[hash] + {:duplicate, existing} + + # Check fuzzy title match + similar = find_similar_titles(title, state.title_index) -> + if length(similar) > 0 do + {:similar, similar} + else + {:ok, :unique} + end + + true -> {:ok, :unique} - end - - true -> - {:ok, :unique} - end + end {:reply, result, state} end @impl true def handle_call({:get_history, issue_hash}, _from, state) do - history = case :ets.lookup(@ets_table, issue_hash) do - [{^issue_hash, data}] -> data - [] -> nil - end + history = + case :ets.lookup(@ets_table, issue_hash) do + [{^issue_hash, data}] -> data + [] -> nil + end + {:reply, history, state} end @impl true def handle_call(:clear, _from, _state) do :ets.delete_all_objects(@ets_table) - {:reply, :ok, %__MODULE__{ - submissions: %{}, - title_index: %{}, - hash_index: %{} - }} + + {:reply, :ok, + %__MODULE__{ + submissions: %{}, + title_index: %{}, + hash_index: %{} + }} end @impl true @@ -141,6 +148,7 @@ defmodule FeedbackATron.Deduplicator do unique_hashes: map_size(state.hash_index), ets_size: :ets.info(@ets_table, :size) } + {:reply, stats, state} end @@ -160,10 +168,11 @@ defmodule FeedbackATron.Deduplicator do } # Store in ETS - existing = case :ets.lookup(@ets_table, hash) do - [{^hash, data}] -> data - [] -> %{platforms: [], submissions: []} - end + existing = + case :ets.lookup(@ets_table, hash) do + [{^hash, data}] -> data + [] -> %{platforms: [], submissions: []} + end updated = %{ platforms: [platform | existing.platforms] |> Enum.uniq(), @@ -173,10 +182,11 @@ defmodule FeedbackATron.Deduplicator do :ets.insert(@ets_table, {hash, updated}) # Update indexes - new_state = %{state | - submissions: Map.put(state.submissions, hash, submission), - title_index: Map.put(state.title_index, normalize_title(title), hash), - hash_index: Map.put(state.hash_index, hash, submission) + new_state = %{ + state + | submissions: Map.put(state.submissions, hash, submission), + title_index: Map.put(state.title_index, TextSimilarity.normalize_title(title), hash), + hash_index: Map.put(state.hash_index, hash, submission) } Logger.info("Recorded submission: #{platform} - #{truncate(title, 50)}") @@ -187,91 +197,28 @@ defmodule FeedbackATron.Deduplicator do # Private functions defp compute_hash(title, body) do - content = "#{normalize_title(title)}:#{normalize_body(body)}" + content = "#{TextSimilarity.normalize_title(title)}:#{TextSimilarity.normalize_body(body)}" :crypto.hash(:sha256, content) |> Base.encode16(case: :lower) |> binary_part(0, 16) end - defp normalize_title(title) do - title - |> String.downcase() - |> String.replace(~r/[^\w\s]/, "") - |> String.replace(~r/\s+/, " ") - |> String.trim() - end - - defp normalize_body(body) do - normalized = body - |> String.downcase() - |> String.replace(~r/\s+/, " ") - |> String.trim() - - # Take first 500 bytes safely (after normalization) - size = byte_size(normalized) - if size > 500 do - binary_part(normalized, 0, 500) - else - normalized - end - end - defp find_similar_titles(title, title_index) do - normalized = normalize_title(title) + normalized = TextSimilarity.normalize_title(title) title_index |> Enum.filter(fn {indexed_title, _hash} -> - similarity(normalized, indexed_title) >= @similarity_threshold + TextSimilarity.similarity(normalized, indexed_title) >= @similarity_threshold end) |> Enum.map(fn {indexed_title, hash} -> - %{title: indexed_title, hash: hash, similarity: similarity(normalized, indexed_title)} + %{ + title: indexed_title, + hash: hash, + similarity: TextSimilarity.similarity(normalized, indexed_title) + } end) |> Enum.sort_by(& &1.similarity, :desc) |> Enum.take(5) end - defp similarity(s1, s2) do - # Jaro-Winkler similarity - cond do - s1 == s2 -> 1.0 - String.length(s1) == 0 or String.length(s2) == 0 -> 0.0 - true -> - # Simple implementation - use TheFuzz library in production - len1 = String.length(s1) - len2 = String.length(s2) - max_len = max(len1, len2) - distance = levenshtein(s1, s2) - 1.0 - (distance / max_len) - end - end - - defp levenshtein(s1, s2) do - s1_len = String.length(s1) - s2_len = String.length(s2) - - if s1_len == 0, do: s2_len, - else: (if s2_len == 0, do: s1_len, - else: do_levenshtein(String.graphemes(s1), String.graphemes(s2), s1_len, s2_len)) - end - - defp do_levenshtein(s1, s2, _len1, len2) do - # Dynamic programming approach - row = 0..len2 |> Enum.to_list() - - {final_row, _} = Enum.reduce(Enum.with_index(s1), {row, 0}, fn {c1, i}, {prev_row, _} -> - new_row = Enum.reduce(Enum.with_index(s2), [i + 1], fn {c2, j}, acc -> - cost = if c1 == c2, do: 0, else: 1 - val = Enum.min([ - Enum.at(acc, j) + 1, # deletion - Enum.at(prev_row, j + 1) + 1, # insertion - Enum.at(prev_row, j) + cost # substitution - ]) - acc ++ [val] - end) - {new_row, i + 1} - end) - - List.last(final_row) - end - defp truncate(string, max_length) do if String.length(string) > max_length do String.slice(string, 0, max_length - 3) <> "..." diff --git a/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex b/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex index c102af3..900d64d 100644 --- a/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex +++ b/elixir-mcp/lib/feedback_a_tron/http_intake/router.ex @@ -14,17 +14,20 @@ defmodule FeedbackATron.HTTPIntake.Router do ## Routes - - `GET /health` → `{"status":"ok"}` - - `POST /api/v1/submit_feedback` → body `{title, body, repo, platforms?, labels?, dry_run?, skip_dedupe?}` + - `GET /health` → `{"status":"ok"}` + - `POST /api/v1/submit_feedback` → body `{title, body, repo, platforms?, labels?, dry_run?, skip_dedupe?, template?, template_data?}` + - `POST /api/v1/research_feedback` → body `{repo, title, body?, limit?, include_templates?}` + - `POST /api/v1/synthesize_feedback` → body `{raw_feedback, repo, context?, system_state?, template?, network_probe?}` - The request/response shapes intentionally match the MCP tool's `input_schema` so a + The request/response shapes intentionally match the MCP tools' `input_schema` so a cartridge can forward the same arguments unchanged. """ use Plug.Router require Logger - alias FeedbackATron.Submitter + alias FeedbackATron.{Params, Submitter} + alias FeedbackATron.Synthesis.{Research, Synthesizer} plug(:match) plug(Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason) @@ -42,6 +45,12 @@ defmodule FeedbackATron.HTTPIntake.Router do {:ok, submission_id, results} -> send_json(conn, 200, format_results(submission_id, results)) + {:error, {:schema_violation, violations}} -> + send_json(conn, 422, %{error: "schema_violation", violations: violations}) + + {:error, {:template_unavailable, why}} -> + send_json(conn, 502, %{error: "template_unavailable", detail: inspect(why)}) + {:error, reason} -> Logger.error("HTTP submit_feedback failed: #{inspect(reason)}") send_json(conn, 502, %{error: "submission_failed", detail: inspect(reason)}) @@ -57,6 +66,84 @@ defmodule FeedbackATron.HTTPIntake.Router do end end + post "/api/v1/research_feedback" do + try do + params = conn.body_params + + case missing_fields(params, ["repo", "title"]) do + [] -> + request = %{ + repo: params["repo"], + title: params["title"], + body: params["body"] + } + + opts = + [] + |> put_opt(:limit, params["limit"]) + |> put_opt(:include_templates, params["include_templates"]) + + case Research.research(request, opts) do + {:ok, result} -> + send_json(conn, 200, result) + + {:error, reason} -> + Logger.error("HTTP research_feedback failed: #{inspect(reason)}") + send_json(conn, 502, %{error: "research_failed", detail: inspect(reason)}) + end + + missing -> + send_json(conn, 400, %{error: "missing_required_fields", fields: missing}) + end + rescue + exception -> + Logger.error("HTTP research_feedback exception: #{Exception.message(exception)}") + send_json(conn, 500, %{error: "internal_error", detail: Exception.message(exception)}) + end + end + + post "/api/v1/synthesize_feedback" do + try do + params = conn.body_params + + case missing_fields(params, ["raw_feedback", "repo"]) do + [] -> + request = %{ + raw_feedback: params["raw_feedback"], + repo: params["repo"], + context: params["context"] || %{}, + system_state: params["system_state"] || %{} + } + + opts = + [] + |> put_opt(:template, params["template"]) + |> put_opt(:network_probe, params["network_probe"]) + + case Synthesizer.synthesize(request, opts) do + {:ok, result} -> + send_json(conn, 200, result) + + # A rejection is a successful response: the caller must learn the + # stated reason. Rejected feedback is audit-logged, never filed. + {:reject, rejection} -> + send_json(conn, 200, %{rejected: true, reason: rejection.reason}) + + {:error, reason} -> + Logger.error("HTTP synthesize_feedback failed: #{inspect(reason)}") + send_json(conn, 502, %{error: "synthesis_failed", detail: inspect(reason)}) + end + + missing -> + send_json(conn, 400, %{error: "missing_required_fields", fields: missing}) + end + rescue + exception -> + Logger.error("HTTP synthesize_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 @@ -65,57 +152,44 @@ defmodule FeedbackATron.HTTPIntake.Router do # 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} + case missing_fields(params, ["title", "body", "repo"]) do + [] -> + issue = %{ + title: params["title"], + body: params["body"], + repo: params["repo"], + template: params["template"], + template_data: params["template_data"] + } + + opts = [ + platforms: Params.parse_platforms(params["platforms"]), + labels: params["labels"] || [], + dry_run: params["dry_run"] || false, + dedupe: not (params["skip_dedupe"] || false) + ] + + {:ok, issue, opts} + + missing -> + {:error, missing} end end defp validate(_), do: {:error, ["title", "body", "repo"]} + defp missing_fields(params, required) when is_map(params) do + Enum.filter(required, fn k -> blank?(Map.get(params, k)) end) + end + + defp missing_fields(_params, required), do: required + 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 put_opt(opts, _key, nil), do: opts + defp put_opt(opts, key, value), do: Keyword.put(opts, key, value) defp format_results(submission_id, results) do formatted = diff --git a/elixir-mcp/lib/feedback_a_tron/mcp/server.ex b/elixir-mcp/lib/feedback_a_tron/mcp/server.ex index 943ac61..21ea0a1 100644 --- a/elixir-mcp/lib/feedback_a_tron/mcp/server.ex +++ b/elixir-mcp/lib/feedback_a_tron/mcp/server.ex @@ -81,6 +81,7 @@ defmodule FeedbackATron.MCP.Server do port = parse_int(env_port) || Keyword.get(opts, :tcp_port, 7979) env_bind = System.get_env("FEEDBACK_A_TRON_MCP_TCP_BIND") + binds = case env_bind do nil -> Keyword.get(opts, :tcp_bind, ["127.0.0.1"]) @@ -144,7 +145,13 @@ defmodule FeedbackATron.MCP.Server do Enum.each(binds, fn bind -> case parse_ip(bind) do {:ok, ip} -> - case :gen_tcp.listen(port, [:binary, packet: :line, active: false, reuseaddr: true, ip: ip]) do + case :gen_tcp.listen(port, [ + :binary, + packet: :line, + active: false, + reuseaddr: true, + ip: ip + ]) do {:ok, listen_socket} -> Logger.info("MCP TCP listening on #{bind}:#{port}") spawn_link(fn -> accept_loop(listen_socket, state) end) @@ -203,14 +210,17 @@ defmodule FeedbackATron.MCP.Server do case Protocol.decode(line) do {:ok, %{"method" => "initialize", "id" => id}} -> response = - Protocol.encode_response(%{ - "protocolVersion" => "2024-11-05", - "serverInfo" => %{ - "name" => state.name, - "version" => state.version + Protocol.encode_response( + %{ + "protocolVersion" => "2024-11-05", + "serverInfo" => %{ + "name" => state.name, + "version" => state.version + }, + "capabilities" => state.capabilities }, - "capabilities" => state.capabilities - }, id) + id + ) send_fun.(response) diff --git a/elixir-mcp/lib/feedback_a_tron/mcp/tools/research_feedback.ex b/elixir-mcp/lib/feedback_a_tron/mcp/tools/research_feedback.ex new file mode 100644 index 0000000..58e8eeb --- /dev/null +++ b/elixir-mcp/lib/feedback_a_tron/mcp/tools/research_feedback.ex @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule FeedbackATron.MCP.Tools.ResearchFeedback do + @moduledoc """ + MCP tool for researching feedback before it is filed. + + Checks the target forge for existing similar issues, consults the local + submission history, and fetches the repo's issue-form templates so the + eventual report can be shaped to fit the receiver. + """ + + use ElixirMcpServer.Tool + + alias FeedbackATron.Synthesis.Research + require Logger + + @impl true + def name, do: "research_feedback" + + @impl true + def description do + """ + Research a piece of feedback before filing it. + + Searches the target forge for existing similar issues, checks the local + submission history for duplicates, and (by default) fetches the repo's + issue-form templates so the eventual report can be shaped to fit. + + Recommended interactive loop: + 1. research_feedback — check for duplicates and discover the repo's templates + 2. synthesize_feedback — shape the raw feedback into a template-fitting draft + 3. Resolve any open_questions with your user + 4. submit_feedback — file the report with template + template_data + """ + end + + @impl true + def input_schema do + %{ + type: "object", + properties: %{ + repo: %{ + type: "string", + description: "Target repository (owner/repo)" + }, + title: %{ + type: "string", + description: "Working title / one-line summary of the feedback" + }, + body: %{ + type: "string", + description: "Optional draft body to improve similarity matching" + }, + limit: %{ + type: "integer", + description: "Max forge matches to return (default 5, max 20)" + }, + include_templates: %{ + type: "boolean", + description: "Also fetch the repo's issue-form templates (default true)" + } + }, + required: ["repo", "title"] + } + end + + @impl true + def execute(params, _context) do + request = %{ + repo: params["repo"], + title: params["title"], + body: params["body"] + } + + opts = + [] + |> put_opt(:limit, params["limit"]) + |> put_opt(:include_templates, params["include_templates"]) + + case Research.research(request, opts) do + {:ok, result} -> + {:ok, [%{type: "text", text: Jason.encode!(result)}]} + + {:error, reason} -> + Logger.error("MCP research_feedback failed: #{inspect(reason)}") + {:error, reason} + end + rescue + exception -> + Logger.error("MCP research_feedback exception: #{Exception.message(exception)}") + {:error, exception} + end + + defp put_opt(opts, _key, nil), do: opts + defp put_opt(opts, key, value), do: Keyword.put(opts, key, value) +end diff --git a/elixir-mcp/lib/feedback_a_tron/mcp/tools/submit_feedback.ex b/elixir-mcp/lib/feedback_a_tron/mcp/tools/submit_feedback.ex index 6f4879c..abb84e9 100644 --- a/elixir-mcp/lib/feedback_a_tron/mcp/tools/submit_feedback.ex +++ b/elixir-mcp/lib/feedback_a_tron/mcp/tools/submit_feedback.ex @@ -10,7 +10,7 @@ defmodule FeedbackATron.MCP.Tools.SubmitFeedback do use ElixirMcpServer.Tool - alias FeedbackATron.Submitter + alias FeedbackATron.{Params, Submitter} require Logger @impl true @@ -24,6 +24,12 @@ defmodule FeedbackATron.MCP.Tools.SubmitFeedback do Supports GitHub, GitLab, Bitbucket, Codeberg, and email. Can submit to multiple platforms simultaneously. Includes deduplication to avoid creating duplicate issues. + + Recommended interactive loop: + 1. research_feedback — check for duplicates and discover the repo's templates + 2. synthesize_feedback — shape the raw feedback into a template-fitting draft + 3. Resolve any open_questions with your user + 4. submit_feedback — file the report with template + template_data """ end @@ -64,6 +70,15 @@ defmodule FeedbackATron.MCP.Tools.SubmitFeedback do skip_dedupe: %{ type: "boolean", description: "If true, skip duplicate checking" + }, + template: %{ + type: "string", + description: "Issue-form template file this submission answers (e.g. bug.yml)" + }, + template_data: %{ + type: "object", + description: + "Map of template field id -> answer; validated against the fetched form schema and rendered as the issue body" } }, required: ["title", "body", "repo"] @@ -75,11 +90,13 @@ defmodule FeedbackATron.MCP.Tools.SubmitFeedback do issue = %{ title: params["title"], body: params["body"], - repo: params["repo"] + repo: params["repo"], + template: params["template"], + template_data: params["template_data"] } opts = [ - platforms: parse_platforms(params["platforms"]), + platforms: Params.parse_platforms(params["platforms"]), labels: params["labels"] || [], dry_run: params["dry_run"] || false, dedupe: not (params["skip_dedupe"] || false) @@ -90,6 +107,7 @@ defmodule FeedbackATron.MCP.Tools.SubmitFeedback do formatted = format_results(submission_id, results) text = format_text(formatted) {:ok, [%{type: "text", text: text}]} + {:error, reason} -> Logger.error("MCP submit_feedback failed: #{inspect(reason)}") {:error, reason} @@ -100,40 +118,27 @@ defmodule FeedbackATron.MCP.Tools.SubmitFeedback do {:error, exception} end - defp parse_platforms(nil), do: [:github] - defp parse_platforms(platforms) when is_list(platforms) do - platforms - |> Enum.map(&platform_atom/1) - |> Enum.filter(& &1) - end + defp format_results(submission_id, results) do + formatted = + Enum.map(results, fn + {:ok, %{platform: platform, url: url}} -> + %{platform: platform, status: "success", url: url} - defp platform_atom(platform) do - case platform do - "github" -> :github - "gitlab" -> :gitlab - "bitbucket" -> :bitbucket - "codeberg" -> :codeberg - "bugzilla" -> :bugzilla - "email" -> :email - _ -> nil - end - end + {:ok, %{platform: platform, status: :dry_run, would_submit: issue}} -> + %{platform: platform, status: "dry_run", title: issue.title} - 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) + {: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, diff --git a/elixir-mcp/lib/feedback_a_tron/mcp/tools/synthesize_feedback.ex b/elixir-mcp/lib/feedback_a_tron/mcp/tools/synthesize_feedback.ex new file mode 100644 index 0000000..7f2a943 --- /dev/null +++ b/elixir-mcp/lib/feedback_a_tron/mcp/tools/synthesize_feedback.ex @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule FeedbackATron.MCP.Tools.SynthesizeFeedback do + @moduledoc """ + MCP tool for shaping raw feedback into a receiver-ready report. + + The gate is usefulness, not tone: zero-signal abuse is rejected (with the + reason stated and audit-logged, never filed); mixed content has its + actionable core salvaged; genuine-but-thin feedback comes back with + open_questions rather than being silently discarded. + """ + + use ElixirMcpServer.Tool + + alias FeedbackATron.Synthesis.Synthesizer + require Logger + + @impl true + def name, do: "synthesize_feedback" + + @impl true + def description do + """ + Shape raw feedback into a report that fits the target repo's issue template. + + Classifies intent (bug/feature/question/docs/praise), salvages the + actionable core from mixed content, hydrates the repo's issue-form fields + from the provided context and system state, and returns a draft plus + open_questions for anything only the user can answer. Zero-signal abuse + is rejected — the result carries {"rejected": true, "reason": ...}; it is + audit-logged and never filed. + + Recommended interactive loop: + 1. research_feedback — check for duplicates and discover the repo's templates + 2. synthesize_feedback — shape the raw feedback into a template-fitting draft + 3. Resolve any open_questions with your user + 4. submit_feedback — file the report with template + template_data + """ + end + + @impl true + def input_schema do + %{ + type: "object", + properties: %{ + raw_feedback: %{ + type: "string", + description: "The raw feedback/crash text to shape into a report" + }, + repo: %{ + type: "string", + description: "Target repository (owner/repo)" + }, + context: %{ + type: "object", + description: "Caller-provided context: title, version, expected, trajectory, logs" + }, + system_state: %{ + type: "object", + description: "Caller-provided environment facts (OS, runtime versions)" + }, + template: %{ + type: "string", + description: "Force a specific template file (e.g. bug.yml)" + }, + network_probe: %{ + type: "boolean", + description: + "Run NetworkVerifier preflight when the report looks network-related (default false)" + } + }, + required: ["raw_feedback", "repo"] + } + end + + @impl true + def execute(params, _context) do + request = %{ + raw_feedback: params["raw_feedback"], + repo: params["repo"], + context: params["context"] || %{}, + system_state: params["system_state"] || %{} + } + + opts = + [] + |> put_opt(:template, params["template"]) + |> put_opt(:network_probe, params["network_probe"]) + + case Synthesizer.synthesize(request, opts) do + {:ok, result} -> + {:ok, [%{type: "text", text: Jason.encode!(result)}]} + + # A rejection is a successful tool result: the caller must learn the + # stated reason. Rejected feedback is audit-logged and never filed. + {:reject, rejection} -> + {:ok, [%{type: "text", text: Jason.encode!(%{rejected: true, reason: rejection.reason})}]} + + {:error, reason} -> + Logger.error("MCP synthesize_feedback failed: #{inspect(reason)}") + {:error, reason} + end + rescue + exception -> + Logger.error("MCP synthesize_feedback exception: #{Exception.message(exception)}") + {:error, exception} + end + + defp put_opt(opts, _key, nil), do: opts + defp put_opt(opts, key, value), do: Keyword.put(opts, key, value) +end diff --git a/elixir-mcp/lib/feedback_a_tron/migration_observer.ex b/elixir-mcp/lib/feedback_a_tron/migration_observer.ex index c05cdd7..e28a485 100644 --- a/elixir-mcp/lib/feedback_a_tron/migration_observer.ex +++ b/elixir-mcp/lib/feedback_a_tron/migration_observer.ex @@ -107,7 +107,10 @@ defmodule FeedbackATron.MigrationObserver do bundle_size: bundle_size } - Logger.info("[MigrationObserver] Session #{session.session_id} started for #{repo_path}") + Logger.info( + "[MigrationObserver] Session #{session.session_id} started for #{repo_path}" + ) + {:reply, {:ok, session.session_id}, %{state | current_session: session}} {:error, reason} -> @@ -210,7 +213,9 @@ defmodule FeedbackATron.MigrationObserver do end defp compute_diff(before_snapshot, after_snapshot) do - before_deprecated = get_in(before_snapshot, ["migration_metrics", "deprecated_api_count"]) || 0 + before_deprecated = + get_in(before_snapshot, ["migration_metrics", "deprecated_api_count"]) || 0 + after_deprecated = get_in(after_snapshot, ["migration_metrics", "deprecated_api_count"]) || 0 before_modern = get_in(before_snapshot, ["migration_metrics", "modern_api_count"]) || 0 after_modern = get_in(after_snapshot, ["migration_metrics", "modern_api_count"]) || 0 diff --git a/elixir-mcp/lib/feedback_a_tron/network_verifier.ex b/elixir-mcp/lib/feedback_a_tron/network_verifier.ex index bf07fcd..3387f3d 100644 --- a/elixir-mcp/lib/feedback_a_tron/network_verifier.ex +++ b/elixir-mcp/lib/feedback_a_tron/network_verifier.ex @@ -66,6 +66,7 @@ defmodule FeedbackATron.NetworkVerifier do verification_cache: %{}, opts: opts } + {:ok, state} end @@ -148,9 +149,11 @@ defmodule FeedbackATron.NetworkVerifier do stddev: String.to_float(mdev), status: :ok } + _ -> %{status: :parse_error, raw: output} end + {output, _} -> %{status: :error, error: output} end @@ -187,6 +190,7 @@ defmodule FeedbackATron.NetworkVerifier do else %{status: :insufficient_samples} end + {_, _} -> %{status: :error} end @@ -228,6 +232,7 @@ defmodule FeedbackATron.NetworkVerifier do {:ok, socket} -> :gen_tcp.close(socket) %{status: :ok, port: port} + {:error, reason} -> %{status: :error, reason: reason, port: port} end @@ -256,6 +261,7 @@ defmodule FeedbackATron.NetworkVerifier do case System.cmd("dig", ["+short", "TLSA", tlsa_name], stderr_to_stdout: true) do {output, 0} when output != "" -> %{status: :present, records: String.split(output, "\n", trim: true)} + _ -> %{status: :not_configured} end @@ -268,16 +274,18 @@ defmodule FeedbackATron.NetworkVerifier do case Req.get(url, receive_timeout: 15_000) do {:ok, %{status: 200, body: body}} when is_list(body) -> certs = Enum.take(body, 5) + %{ status: :present, certificate_count: length(body), - recent: Enum.map(certs, fn cert -> - %{ - issuer: cert["issuer_name"], - not_before: cert["not_before"], - not_after: cert["not_after"] - } - end) + recent: + Enum.map(certs, fn cert -> + %{ + issuer: cert["issuer_name"], + not_before: cert["not_before"], + not_after: cert["not_after"] + } + end) } {:ok, %{status: status}} -> @@ -293,6 +301,7 @@ defmodule FeedbackATron.NetworkVerifier do {output, 0} -> has_rrsig = String.contains?(output, "RRSIG") %{status: if(has_rrsig, do: :validated, else: :unsigned)} + _ -> %{status: :error} end @@ -334,27 +343,36 @@ defmodule FeedbackATron.NetworkVerifier do [_, owner, repo, num] -> {:github, owner, repo, String.to_integer(num)} _ -> :unknown end + String.contains?(uri.host, "gitlab") -> # Parse GitLab URL :gitlab_parse_needed + true -> :unknown end end defp verify_github_issue(owner, repo, issue_number) do - case System.cmd("gh", ["issue", "view", "#{issue_number}", "--repo", "#{owner}/#{repo}", "--json", "number,title,state"]) do + case System.cmd("gh", [ + "issue", + "view", + "#{issue_number}", + "--repo", + "#{owner}/#{repo}", + "--json", + "number,title,state" + ]) do {output, 0} -> case Jason.decode(output) do {:ok, data} -> %{status: :verified, data: data} _ -> %{status: :parse_error} end + {error, _} -> %{status: :not_found, error: error} end end - - end defmodule FeedbackATron.NetworkVerifier.DNSVerifier do @@ -364,6 +382,7 @@ defmodule FeedbackATron.NetworkVerifier.DNSVerifier do case :inet.gethostbyname(String.to_charlist(host)) do {:ok, {:hostent, _, _, _, _, addresses}} -> %{status: :ok, addresses: Enum.map(addresses, &:inet.ntoa/1)} + {:error, reason} -> %{status: :error, reason: reason} end @@ -390,7 +409,8 @@ defmodule FeedbackATron.NetworkVerifier.DNSVerifier do defp measure_resolution_time(host) do {time, _} = :timer.tc(fn -> :inet.gethostbyname(String.to_charlist(host)) end) - time / 1000 # Convert to ms + # Convert to ms + time / 1000 end end @@ -399,14 +419,22 @@ defmodule FeedbackATron.NetworkVerifier.TLSVerifier do def verify_certificate(host, port) do # Use OpenSSL for detailed cert info - case System.cmd("openssl", [ - "s_client", - "-connect", "#{host}:#{port}", - "-servername", host, - "-brief" - ], stderr_to_stdout: true, timeout: 10_000) do + case System.cmd( + "openssl", + [ + "s_client", + "-connect", + "#{host}:#{port}", + "-servername", + host, + "-brief" + ], + stderr_to_stdout: true, + timeout: 10_000 + ) do {output, 0} -> parse_tls_info(output) + {output, _} -> %{status: :error, output: output} end @@ -419,8 +447,7 @@ defmodule FeedbackATron.NetworkVerifier.TLSVerifier do status: :ok, protocol: extract_field(output, ~r/Protocol\s+:\s+(\S+)/), cipher: extract_field(output, ~r/Cipher\s+:\s+(.+)/), - verification: if(String.contains?(output, "Verification: OK"), - do: :verified, else: :failed) + verification: if(String.contains?(output, "Verification: OK"), do: :verified, else: :failed) } end @@ -440,12 +467,14 @@ defmodule FeedbackATron.NetworkVerifier.RouteAnalyzer do case :inet.gethostbyname(String.to_charlist(host)) do {:ok, {:hostent, _, _, _, _, [ip | _]}} -> ip_str = :inet.ntoa(ip) |> to_string() + %{ status: :ok, ip: ip_str, asn: lookup_asn(ip_str), geo: lookup_geo(ip_str) } + {:error, reason} -> %{status: :error, reason: reason} end @@ -487,6 +516,7 @@ defmodule FeedbackATron.NetworkVerifier.RouteAnalyzer do case Req.get(url, receive_timeout: 10_000) do {:ok, %{status: 200, body: body}} when is_map(body) -> validity = get_in(body, ["validated_route", "validity", "state"]) || "unknown" + %{ status: :ok, validity: validity, @@ -597,9 +627,12 @@ defmodule FeedbackATron.NetworkVerifier.PathAnalyzer do defp parse_hop(line) do case Regex.run(~r/^\s*(\d+)\s+(\S+)\s+(.*)/, line) do [_, hop, ip, rest] -> - times = Regex.scan(~r/(\d+\.?\d*)\s*ms/, rest) - |> Enum.map(fn [_, t] -> String.to_float(t) end) + times = + Regex.scan(~r/(\d+\.?\d*)\s*ms/, rest) + |> Enum.map(fn [_, t] -> String.to_float(t) end) + %{hop: String.to_integer(hop), ip: ip, times_ms: times} + _ -> %{raw: line} end diff --git a/elixir-mcp/lib/feedback_a_tron/params.ex b/elixir-mcp/lib/feedback_a_tron/params.ex new file mode 100644 index 0000000..719bc78 --- /dev/null +++ b/elixir-mcp/lib/feedback_a_tron/params.ex @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule FeedbackATron.Params do + @moduledoc """ + Shared request-parameter parsing for the intake surfaces. + + Both the MCP tools (`FeedbackATron.MCP.Tools.*`) and the HTTP intake + router (`FeedbackATron.HTTPIntake.Router`) accept the same wire + arguments; this module keeps their interpretation identical so a + cartridge can forward arguments to either surface unchanged. + """ + + @doc """ + Parse a list of platform name strings into platform atoms. + + Unknown platform names are dropped. `nil`, non-lists, and lists that + contain no known platforms all fall back to `[:github]`. + """ + def parse_platforms(nil), do: [:github] + + def parse_platforms(platforms) when is_list(platforms) do + platforms + |> Enum.map(&platform_atom/1) + |> Enum.filter(& &1) + |> case do + [] -> [:github] + list -> list + end + end + + def 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 +end diff --git a/elixir-mcp/lib/feedback_a_tron/report_generator.ex b/elixir-mcp/lib/feedback_a_tron/report_generator.ex index 420153a..56518d9 100644 --- a/elixir-mcp/lib/feedback_a_tron/report_generator.ex +++ b/elixir-mcp/lib/feedback_a_tron/report_generator.ex @@ -189,9 +189,7 @@ defmodule FeedbackATron.ReportGenerator do ## Regressions #{if length(regressions) > 0 do - regressions - |> Enum.map(fn r -> "- **#{r.repo}**: #{r.description}" end) - |> Enum.join("\n") + regressions |> Enum.map(fn r -> "- **#{r.repo}**: #{r.description}" end) |> Enum.join("\n") else "No regressions detected." end} @@ -227,7 +225,9 @@ defmodule FeedbackATron.ReportGenerator do defp delta(_, _), do: "-" defp format_duration(seconds) when seconds < 60, do: "#{seconds}s" - defp format_duration(seconds) when seconds < 3600, do: "#{div(seconds, 60)}m #{rem(seconds, 60)}s" + + defp format_duration(seconds) when seconds < 3600, + do: "#{div(seconds, 60)}m #{rem(seconds, 60)}s" defp format_duration(seconds), do: "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m" @@ -238,7 +238,11 @@ defmodule FeedbackATron.ReportGenerator do after_h = get_in(session.after_snapshot, ["migration_metrics", "health_score"]) || 0.0 before_d = get_in(session.before_snapshot, ["migration_metrics", "deprecated_api_count"]) || 0 after_d = get_in(session.after_snapshot, ["migration_metrics", "deprecated_api_count"]) || 0 - duration = if session.ended_at, do: DateTime.diff(session.ended_at, session.started_at, :second), else: 0 + + duration = + if session.ended_at, + do: DateTime.diff(session.ended_at, session.started_at, :second), + else: 0 "| #{repo} | #{Float.round(after_h - before_h, 3)} | #{before_d - after_d} | #{format_duration(duration)} |" end @@ -301,12 +305,15 @@ defmodule FeedbackATron.ReportGenerator do """ end - defp generate_recommendations(before, after_m) do + defp generate_recommendations(_before, after_m) do recs = [] recs = if (after_m["deprecated_api_count"] || 0) > 0 do - recs ++ ["- Migrate remaining #{after_m["deprecated_api_count"]} deprecated API calls to @rescript/core"] + recs ++ + [ + "- Migrate remaining #{after_m["deprecated_api_count"]} deprecated API calls to @rescript/core" + ] else recs end @@ -315,7 +322,9 @@ defmodule FeedbackATron.ReportGenerator do case after_m["config_format"] do f when f in ["BsConfig", "Both"] -> recs ++ ["- Migrate bsconfig.json to rescript.json"] - _ -> recs + + _ -> + recs end recs = diff --git a/elixir-mcp/lib/feedback_a_tron/retry.ex b/elixir-mcp/lib/feedback_a_tron/retry.ex index e9520de..c258ae5 100644 --- a/elixir-mcp/lib/feedback_a_tron/retry.ex +++ b/elixir-mcp/lib/feedback_a_tron/retry.ex @@ -54,9 +54,7 @@ defmodule FeedbackATron.Retry do {:error, reason} = error -> if attempt >= max or not retryable?(reason) do - Logger.warning( - "[Retry] Giving up after #{attempt}/#{max} attempts: #{inspect(reason)}" - ) + Logger.warning("[Retry] Giving up after #{attempt}/#{max} attempts: #{inspect(reason)}") error else diff --git a/elixir-mcp/lib/feedback_a_tron/secure_dns.ex b/elixir-mcp/lib/feedback_a_tron/secure_dns.ex index e203964..8f40871 100644 --- a/elixir-mcp/lib/feedback_a_tron/secure_dns.ex +++ b/elixir-mcp/lib/feedback_a_tron/secure_dns.ex @@ -68,7 +68,11 @@ defmodule FeedbackATron.SecureDNS do {"Accept", "application/dns-json"} ] - case Req.get(query_url, headers: headers, receive_timeout: 5_000, connect_options: [timeout: 3_000]) do + case Req.get(query_url, + headers: headers, + receive_timeout: 5_000, + connect_options: [timeout: 3_000] + ) do {:ok, %{status: 200, body: body}} when is_map(body) -> case parse_doh_response(body) do [] -> @@ -95,7 +99,8 @@ defmodule FeedbackATron.SecureDNS do Resolve via DNS over TLS (DoT) using Erlang's :ssl and raw DNS wire format. """ def resolve_dot(hostname) do - Enum.reduce_while(@dot_resolvers, {:error, :all_resolvers_failed}, fn {ip, port, name}, _acc -> + Enum.reduce_while(@dot_resolvers, {:error, :all_resolvers_failed}, fn {ip, port, name}, + _acc -> case do_dot_query(ip, port, hostname) do {:ok, ips} -> Logger.debug("DoT #{name}: resolved #{hostname} → #{inspect(ips)}") @@ -178,8 +183,10 @@ defmodule FeedbackATron.SecureDNS do end # Parse DNS response — extract A record IPs - defp parse_dns_response(<<_length::16, _txn::16, _flags::16, _qdcount::16, ancount::16, - _nscount::16, _arcount::16, rest::binary>>) do + defp parse_dns_response( + <<_length::16, _txn::16, _flags::16, _qdcount::16, ancount::16, _nscount::16, + _arcount::16, rest::binary>> + ) do # Skip question section {rest, _} = skip_question(rest) # Parse answer records diff --git a/elixir-mcp/lib/feedback_a_tron/submitter.ex b/elixir-mcp/lib/feedback_a_tron/submitter.ex index 18c7dfa..5d5e649 100644 --- a/elixir-mcp/lib/feedback_a_tron/submitter.ex +++ b/elixir-mcp/lib/feedback_a_tron/submitter.ex @@ -24,8 +24,7 @@ defmodule FeedbackATron.Submitter do require Logger alias FeedbackATron.{Channel, Credentials, Deduplicator, AuditLog, RateLimiter, Retry} - - + alias FeedbackATron.Synthesis.{FormRenderer, FormValidator, TemplateFetcher} # Client API @@ -79,51 +78,85 @@ defmodule FeedbackATron.Submitter do rate_limits: %{}, opts: opts } + {:ok, state} end @impl true def handle_call({:submit, issue, opts}, _from, state) do - platforms = Keyword.get(opts, :platforms, [:github]) - dry_run = Keyword.get(opts, :dry_run, false) - dedupe = Keyword.get(opts, :dedupe, true) + case maybe_apply_template(issue) do + {:error, reason} -> + {:reply, {:error, reason}, state} + + {:ok, issue} -> + platforms = Keyword.get(opts, :platforms, [:github]) + dry_run = Keyword.get(opts, :dry_run, false) + dedupe = Keyword.get(opts, :dedupe, true) + + results = + platforms + |> Enum.map(fn platform -> + with :ok <- RateLimiter.check(platform), + :ok <- maybe_dedupe(dedupe, platform, issue) do + # A dry run is a preview: it still passes rate-limit and dedup + # checks above, but must not require credentials. + if dry_run do + {:ok, %{platform: platform, status: :dry_run, would_submit: issue}} + else + with {:ok, cred} <- Credentials.get(state.credentials, platform) do + result = Retry.with_backoff(fn -> do_submit(platform, issue, cred, opts) end) + + # Record only real successes: never dry runs (which + # short-circuit above), never errors. Recording feeds the + # deduplicator so recurring themes are recognized as recurring. + case result do + {:ok, submission_result} -> + RateLimiter.record(platform) + Deduplicator.record(issue, platform, submission_result) + + _ -> + :ok + end + + result + end + end + end + end) + + submission_id = generate_id() + + new_state = + put_in(state.submissions[submission_id], %{ + issue: issue, + results: results, + submitted_at: DateTime.utc_now() + }) + + # Results are {:ok, map} / {:error, term} tuples, which Jason cannot + # encode — inspect them so the audit entry is JSON-safe. + AuditLog.log(:submission, %{ + id: submission_id, + issue: issue, + results: Enum.map(results, &inspect/1) + }) + + {:reply, {:ok, submission_id, results}, new_state} + end + end + @impl true + def handle_call({:submit_batch, issues, opts}, _from, state) do results = - platforms - |> Enum.map(fn platform -> - with :ok <- RateLimiter.check(platform), - :ok <- maybe_dedupe(dedupe, platform, issue), - {:ok, cred} <- Credentials.get(state.credentials, platform) do - if dry_run do - {:ok, %{platform: platform, status: :dry_run, would_submit: issue}} - else - result = Retry.with_backoff(fn -> do_submit(platform, issue, cred, opts) end) - if match?({:ok, _}, result), do: RateLimiter.record(platform) - result - end + Enum.map(issues, fn issue -> + # handle_call({:submit, ...}) returns a GenServer {:reply, payload, state} + # 3-tuple; destructure the reply payload, not a bare {:ok, id, result}. + case handle_call({:submit, issue, opts}, nil, state) do + {:reply, {:ok, id, result}, _new_state} -> {id, result} + {:reply, {:error, reason}, _new_state} -> {:error, reason} end end) - submission_id = generate_id() - new_state = put_in(state.submissions[submission_id], %{ - issue: issue, - results: results, - submitted_at: DateTime.utc_now() - }) - - AuditLog.log(:submission, %{id: submission_id, issue: issue, results: results}) - - {:reply, {:ok, submission_id, results}, new_state} - end - - @impl true - def handle_call({:submit_batch, issues, opts}, _from, state) do - results = Enum.map(issues, fn issue -> - # handle_call({:submit, ...}) returns a GenServer {:reply, payload, state} - # 3-tuple; destructure the reply payload, not a bare {:ok, id, result}. - {:reply, {:ok, id, result}, _new_state} = handle_call({:submit, issue, opts}, nil, state) - {id, result} - end) {:reply, {:ok, results}, state} end @@ -147,7 +180,58 @@ defmodule FeedbackATron.Submitter do # Helpers + # Template-shaped submissions: when the caller supplies template_data, + # fetch the repo's issue-form schema, validate the answers against it + # (fail closed if the form cannot be fetched), and render the validated + # answers as the issue body. Doctrine: shaped for the receiver — their + # template, their taxonomy, only what is needed. + defp maybe_apply_template(issue) do + case Map.get(issue, :template_data) do + nil -> + {:ok, issue} + + template_data -> + template_file = Map.get(issue, :template) || "bug.yml" + apply_template(issue, template_file, template_data) + end + end + + defp apply_template(issue, template_file, template_data) do + case TemplateFetcher.fetch_form(issue.repo, template_file) do + {:error, why} -> + log_template_attempt(issue, template_file, false, %{error: inspect(why)}) + {:error, {:template_unavailable, why}} + + {:ok, form} -> + case FormValidator.validate(form, template_data) do + {:error, violations} -> + log_template_attempt(issue, template_file, false, %{violations: violations}) + {:error, {:schema_violation, violations}} + + :ok -> + log_template_attempt(issue, template_file, true, %{}) + {:ok, Map.put(issue, :body, FormRenderer.render(form, template_data))} + end + end + end + + defp log_template_attempt(issue, template_file, schema_validated, extra) do + AuditLog.log( + :submission_attempt, + Map.merge( + %{ + repo: Map.get(issue, :repo), + title: Map.get(issue, :title), + template: template_file, + schema_validated: schema_validated + }, + extra + ) + ) + end + defp maybe_dedupe(false, _platform, _issue), do: :ok + defp maybe_dedupe(true, _platform, issue) do case Deduplicator.check(issue) do {:ok, :unique} -> :ok diff --git a/elixir-mcp/lib/feedback_a_tron/synthesis/form_renderer.ex b/elixir-mcp/lib/feedback_a_tron/synthesis/form_renderer.ex new file mode 100644 index 0000000..e49315c --- /dev/null +++ b/elixir-mcp/lib/feedback_a_tron/synthesis/form_renderer.ex @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) Jonathan D.A. Jewell +defmodule FeedbackATron.Synthesis.FormRenderer do + @moduledoc """ + Renders a filled issue-form into the Markdown body GitHub produces when + a user submits the same form, so synthesized issues are indistinguishable + from hand-filled ones (doctrine: shaped for the receiver). + + For each non-markdown field, in form order: + + ###