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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions .github/workflows/elixir-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# SPDX-License-Identifier: PMPL-1.0-or-later
name: Elixir CI

on:
push:
branches: [main]
paths:
- 'elixir-mcp/**'
- '.github/workflows/elixir-ci.yml'
pull_request:
branches: [main]
paths:
- 'elixir-mcp/**'
- '.github/workflows/elixir-ci.yml'

permissions:
contents: read

jobs:
test:
name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }})
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: elixir-mcp

strategy:
matrix:
elixir: ['1.15', '1.16', '1.17']
otp: ['26']

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Elixir
uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2
with:
elixir-version: ${{ matrix.elixir }}
otp-version: ${{ matrix.otp }}

- name: Cache deps
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: |
elixir-mcp/deps
elixir-mcp/_build
key: mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('elixir-mcp/mix.lock') }}
restore-keys: |
mix-${{ matrix.elixir }}-${{ matrix.otp }}-

- name: Install dependencies
run: mix deps.get

- name: Compile (warnings as errors)
run: mix compile --warnings-as-errors

- name: Check formatting
run: mix format --check-formatted

- name: Run tests
run: mix test --trace

dialyzer:
name: Dialyzer
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: elixir-mcp

steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Elixir
uses: erlef/setup-beam@5304e04ea2b355f03681464e683d92e3b2f18451 # v1.18.2
with:
elixir-version: '1.17'
otp-version: '26'

- name: Cache deps
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: |
elixir-mcp/deps
elixir-mcp/_build
key: dialyzer-${{ hashFiles('elixir-mcp/mix.lock') }}
restore-keys: |
dialyzer-

- name: Install dependencies
run: mix deps.get

- name: Run Dialyzer
run: mix dialyzer
continue-on-error: true
28 changes: 23 additions & 5 deletions .machine_readable/6a2/STATE.a2ml
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
# SPDX-License-Identifier: PMPL-1.0-or-later
# STATE.a2ml — Project state checkpoint
# Converted from STATE.scm on 2026-03-15
# Updated 2026-04-09

[metadata]
project = "feedback-o-tron"
version = "0.1.0"
last-updated = "2026-03-15"
status = "active"
version = "1.0.0"
last-updated = "2026-04-09"
status = "production"

[project-context]
name = "feedback-o-tron"
completion-percentage = 0
phase = "In development"
completion-percentage = 95
phase = "Production"

[components]
cli = "100%"
mcp-server = "100%"
channels-github = "100%"
channels-gitlab = "100%"
channels-bitbucket = "100%"
channels-codeberg = "100%"
channels-bugzilla = "100%"
channels-email = "80%"
deduplication = "100%"
audit-logging = "100%"
network-verifier = "90%"
rate-limiter = "100%"
retry-backoff = "100%"
zig-ffi = "95%"
idris2-proofs = "80%"
121 changes: 121 additions & 0 deletions CONTRIBUTING.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
= Contributing to feedback-o-tron
:toc:

Thank you for your interest in contributing to feedback-o-tron!

== Language Policy

This project follows the **Hyperpolymath RSR 2026** language standard. Before contributing, review the allowed and banned languages in `CLAUDE.md`.

**Allowed:**

* **Elixir** — primary application code (all `elixir-mcp/` modules)
* **Zig** — FFI bridge (`ffi/zig/`)
* **Rust** — performance-critical components
* **Bash/POSIX Shell** — scripts and automation
* **Julia** — statistics (`julia-stats/`)

**Banned:** TypeScript, Node.js, npm, Python, Go, Java, Kotlin, Swift. See `CLAUDE.md` for the full list and rationale.

== Getting Started

=== Prerequisites

* Elixir >= 1.15 with OTP >= 26
* Zig (for FFI development)
* Nix or Guix (optional, for reproducible environments)

=== Setup

[source,bash]
----
# Using Nix flake
nix develop

# Or manually
cd elixir-mcp
mix deps.get
mix compile
----

=== Running Tests

[source,bash]
----
cd elixir-mcp
mix test # Run all tests
mix test --trace # With verbose output
----

== Development Guidelines

=== Code Style

* Run `mix format` before committing — CI enforces formatting
* Follow existing patterns in the codebase
* All channel adapters must implement the `FeedbackATron.Channel` behaviour
* All transports must be encrypted (HTTPS, NNTPS, SMTPS, Matrix)

=== Security Requirements

* No MD5 or SHA1 for security purposes (use SHA-256+)
* HTTPS only — no plaintext HTTP URLs
* No hardcoded secrets or credentials
* All dependencies must be SHA-pinned
* SPDX license headers on all source files

=== Error Handling

Use the structured error types in `FeedbackATron.Error`:

* `AuthenticationError` — credential failures
* `RateLimitError` — platform rate limits
* `NetworkError` — connectivity issues
* `ValidationError` — bad input
* `PlatformError` — unexpected API responses

=== Adding a New Channel

1. Create `lib/feedback_a_tron/channels/your_platform.ex`
2. Implement `@behaviour FeedbackATron.Channel`
3. Add the platform to `Channel.registry/0`
4. Add credential loading to `Credentials`
5. Add rate limit config to `RateLimiter`
6. Add tests in `test/`
7. Update the CLI help text

=== Commit Messages

* Use present tense: "Add feature" not "Added feature"
* Keep the first line under 72 characters
* Reference issues where applicable

== Architecture

See `TOPOLOGY.md` for the full architecture diagram.

Key modules:

* `Submitter` — orchestrates multi-platform submission with retry and rate limiting
* `Channel` — behaviour and registry for platform adapters
* `Deduplicator` — fuzzy matching to prevent duplicate filings
* `RateLimiter` — per-platform token bucket rate limiting
* `Retry` — exponential backoff for transient failures
* `AuditLog` — JSON-lines audit trail
* `NetworkVerifier` — pre/post-submission network safety checks
* `Credentials` — multi-source credential loading with rotation

== Licensing

All contributions must be licensed under PMPL-1.0-or-later.
Add the SPDX header to all new files:

[source,elixir]
----
# SPDX-License-Identifier: PMPL-1.0-or-later
----

== Reporting Issues

Use feedback-o-tron itself to report issues! Or open an issue on the GitHub repository.
1 change: 1 addition & 0 deletions elixir-mcp/lib/feedback_a_tron/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule FeedbackATron.Application do
def start(_type, _args) do
children = [
# Core services
FeedbackATron.RateLimiter,
FeedbackATron.Submitter,
FeedbackATron.Deduplicator,
FeedbackATron.AuditLog,
Expand Down
13 changes: 11 additions & 2 deletions elixir-mcp/lib/feedback_a_tron/channels/bitbucket.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,20 @@ defmodule FeedbackATron.Channels.Bitbucket do
{:ok, %{status: 201, body: resp}} ->
{:ok, %{platform: :bitbucket, url: resp["links"]["html"]["href"]}}

{:ok, %{status: 401, body: _error}} ->
{: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}}

{: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, %{platform: :bitbucket, status: status, error: error}}
{:error, %FeedbackATron.Error.PlatformError{platform: :bitbucket, status: status, body: inspect(error)}}

{:error, reason} ->
{:error, %{platform: :bitbucket, error: reason}}
{:error, %FeedbackATron.Error.NetworkError{platform: :bitbucket, reason: inspect(reason), url: url}}
end
end
end
13 changes: 11 additions & 2 deletions elixir-mcp/lib/feedback_a_tron/channels/bugzilla.ex
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,20 @@ defmodule FeedbackATron.Channels.Bugzilla do
bug_id = resp["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"}}

{:ok, %{status: 429, body: _error}} ->
{: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, %{platform: :bugzilla, status: status, error: error}}
{:error, %FeedbackATron.Error.PlatformError{platform: :bugzilla, status: status, body: inspect(error)}}

{:error, reason} ->
{:error, %{platform: :bugzilla, error: reason}}
{:error, %FeedbackATron.Error.NetworkError{platform: :bugzilla, reason: inspect(reason), url: url}}
end
end
end
13 changes: 11 additions & 2 deletions elixir-mcp/lib/feedback_a_tron/channels/codeberg.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,20 @@ defmodule FeedbackATron.Channels.Codeberg do
{:ok, %{status: 201, body: resp}} ->
{:ok, %{platform: :codeberg, url: resp["html_url"]}}

{:ok, %{status: 401, body: _error}} ->
{: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}}

{: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, %{platform: :codeberg, status: status, error: error}}
{:error, %FeedbackATron.Error.PlatformError{platform: :codeberg, status: status, body: inspect(error)}}

{:error, reason} ->
{:error, %{platform: :codeberg, error: reason}}
{:error, %FeedbackATron.Error.NetworkError{platform: :codeberg, reason: inspect(reason), url: url}}
end
end
end
23 changes: 21 additions & 2 deletions elixir-mcp/lib/feedback_a_tron/channels/github.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,27 @@ defmodule FeedbackATron.Channels.GitHub do
label_args

case System.cmd("gh", args, env: [{"GH_TOKEN", cred.token}]) do
{url, 0} -> {:ok, %{platform: :github, url: String.trim(url)}}
{error, _} -> {:error, %{platform: :github, error: error}}
{url, 0} ->
{:ok, %{platform: :github, url: String.trim(url)}}

{error, code} ->
{:error, classify_gh_error(:github, error, code)}
end
end

defp classify_gh_error(platform, output, _code) do
cond do
String.contains?(output, "401") or String.contains?(output, "auth") ->
%FeedbackATron.Error.AuthenticationError{platform: platform, reason: "token rejected"}

String.contains?(output, "403") or String.contains?(output, "rate limit") ->
%FeedbackATron.Error.RateLimitError{platform: platform, resets_at: nil, remaining: 0}

String.contains?(output, "422") or String.contains?(output, "validation") ->
%FeedbackATron.Error.ValidationError{field: "issue", reason: String.trim(output)}

true ->
%FeedbackATron.Error.PlatformError{platform: platform, status: nil, body: String.trim(output)}
end
end
end
16 changes: 14 additions & 2 deletions elixir-mcp/lib/feedback_a_tron/channels/gitlab.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,20 @@ defmodule FeedbackATron.Channels.GitLab do
]

case System.cmd("glab", args, env: [{"GITLAB_TOKEN", cred.token}]) do
{url, 0} -> {:ok, %{platform: :gitlab, url: String.trim(url)}}
{error, _} -> {:error, %{platform: :gitlab, error: error}}
{url, 0} ->
{:ok, %{platform: :gitlab, url: String.trim(url)}}

{error, _code} ->
cond do
String.contains?(error, "401") or String.contains?(error, "auth") ->
{:error, %FeedbackATron.Error.AuthenticationError{platform: :gitlab, reason: "token rejected"}}

String.contains?(error, "429") ->
{:error, %FeedbackATron.Error.RateLimitError{platform: :gitlab, resets_at: nil, remaining: 0}}

true ->
{:error, %FeedbackATron.Error.PlatformError{platform: :gitlab, status: nil, body: String.trim(error)}}
end
end
end
end
Loading
Loading