AI-powered dependency migration analyzer.
Discovers breaking upgrades, explains exactly what failed, and tells you how to fix it.
Migratowl answers one question: "If I upgrade this dependency, will anything break — and how do I fix it?"
It receives a webhook, clones the target repository, scans all dependency manifests, queries package registries for newer versions, and runs the project inside an isolated Kubernetes sandbox with every dependency bumped. An AI agent executes the test suite, reads the error output, fetches the relevant changelog, and produces a structured report per dependency.
The result tells developers:
- Whether the upgrade is breaking
- What specifically went wrong
- A verbatim citation from the changelog
- A plain-English fix suggestion
Use them together, not instead. Here is what each one does not do:
"SemVer already tells me if a major version breaks things." It tells you that something changed. It does not tell you which of your files imports the removed API, or whether your project even calls the affected code path.
"I just let CI run on the Dependabot PR." CI tells you it failed. Migratowl tells you why, links it to a specific changelog entry, and scores how confident it is in that attribution — for every package in the PR, not just the one that happened to fail loudest.
"The LLM is just guessing the confidence score." No. Tests run first in an isolated sandbox. The LLM reads the real error output and decides whether the failure is attributable clearly enough to report, or whether the package needs an isolated re-run. It is routing logic, not prediction.
"We have no tests." For compiled languages (Go, Rust, Java), build failures are caught automatically — no tests needed. For interpreted languages (Python, Node.js), a test suite is currently required to detect runtime breakage. Zero-test support for interpreted ecosystems is planned.
| Snyk | SonarQube | Dependabot | Renovate | Your CI | Migratowl | |
|---|---|---|---|---|---|---|
| Security CVE alerts | ✅ | ✅ | ❌ | ❌ | ||
| License compliance | ✅ | ❌ | ❌ | ❌ | ❌ | |
| Automated version-bump PRs | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
| Tests run on upgrade | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
| No CI config required | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Which file / call site breaks | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Changelog-cited explanation | ❌ | ❌ | ❌ | ❌ | ✅ |
- Getting Started
- Supported Ecosystems
- How It Works
- Self-Hosted Quick Start
- API Reference
- Response Schema
- Configuration
- Kubernetes Setup
- Observability
- GitHub Actions
- Architecture
- Project Layout
- Development
- Contributing
- License
Choose the path that matches your setup:
For individual developers and small teams with no existing Kubernetes infrastructure. Migratowl spins up a temporary cluster inside the CI runner — nothing to install or host.
GitHub Actions — add one workflow file and one secret:
name: migratowl
on:
pull_request:
branches: [main]
concurrency:
group: migratowl-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
statuses: write
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: bitkaio/migratowl-action@v1
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}See bitkaio/migratowl-action for the full inputs reference, scheduled mode, and GitLab usage.
GitLab CI — include the component once:
stages: [test]
include:
- component: gitlab.com/bitkaio/migratowl-gitlab-component/scan@v1
inputs:
anthropic-api-key: $ANTHROPIC_API_KEY
stage: testSee bitkaio/migratowl-gitlab-component for details.
For teams that already operate a Kubernetes cluster and want a persistent Migratowl deployment. Follow the Self-Hosted Quick Start below.
| Language | Manifest files | Registry |
|---|---|---|
| Python | pyproject.toml, requirements.txt |
PyPI |
| Node.js | package.json |
npm |
| Go | go.mod |
proxy.golang.org |
| Rust | Cargo.toml |
crates.io |
| Java | pom.xml (Maven), build.gradle (Gradle) |
Maven Central |
Migratowl runs a four-phase agent workflow inside an ephemeral Kubernetes sandbox.
flowchart TB
subgraph Phase1["Phase 1 — Setup"]
A[POST /webhook] --> B[clone_repo]
B --> C[detect_languages]
C --> D[scan_dependencies]
D --> E[check_outdated_deps]
end
subgraph Phase2["Phase 2 — Main Analysis"]
F["copy_source('main')"] --> G["update_dependencies (all)"]
G --> H["execute_project (install + test)"]
end
subgraph Phase3["Phase 3 — Subagent Routing"]
I{All tests pass?}
I -->|Yes| J["Every package: is_breaking=false"]
I -->|No| K{Clear attribution?}
K -->|Yes| L["fetch_changelog + write report"]
K -->|No| M["Delegate to package-analyzer subagent"]
end
subgraph Phase4["Phase 4 — Compile Results"]
N["Merge reports from main agent + subagents"]
N --> O["ScanAnalysisReport (POST to callback_url)"]
end
E -->|outdated dep list| F
H -->|pass/fail + error output| I
J --> N
L --> N
M --> N
Routing rules (applied in Phase 3 when tests fail):
- Error message directly names the package → attributed directly; report written
- Import or attribute error for a known package API → attributed directly
- Ambiguous failure with no clear link to a specific package → delegated to an isolated package-analyzer subagent run
The attribution threshold is configurable via MIGRATOWL_CONFIDENCE_THRESHOLD (default 0.7).
Sandbox workspace layout:
/home/user/workspace/
├── source/ # Immutable clone — never executed
├── main/ # All deps bumped, executed in Phase 2
└── <package-name>/ # Per-package isolation (created on demand by subagent)
Prerequisites: Python 3.13+, uv, Docker, minikube, kubectl.
# 1. Install dependencies
uv sync
# 2. Configure environment
cp .env.example .env
# Edit .env — set at minimum: ANTHROPIC_API_KEY
# 3. Start local Kubernetes cluster
minikube start --driver=docker --memory=8192 --cpus=4
# 4. Install agent-sandbox controller and CRDs
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.2.1/manifest.yaml
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.2.1/extensions.yaml
# 5. Build sandbox runner image inside minikube
eval $(minikube docker-env)
docker build -t sandbox-runtime:latest k8s/runtime/
# 6. Apply RBAC and sandbox template
kubectl apply -f k8s/rbac.yaml
kubectl apply -f k8s/sandbox-template.yaml
# 7. Start the server
uv run uvicorn migratowl.api.main:app --reloadTrigger a scan:
curl -X POST http://localhost:8000/webhook \
-H 'Content-Type: application/json' \
-d '{
"repo_url": "https://github.com/org/repo",
"callback_url": "https://yourservice.example.com/results"
}'
# → {"job_id": "...", "status_url": "/jobs/..."}Accepts a scan request. Returns 202 Accepted immediately; analysis runs in the background and POSTs the result to callback_url when done.
Request body (ScanWebhookPayload):
| Field | Type | Default | Description |
|---|---|---|---|
repo_url |
string |
required | Git repository URL to scan |
branch_name |
string |
"main" |
Branch to clone and analyze |
git_provider |
"github" | "gitlab" |
"github" |
Git provider — determines which API is used for PR/MR comments and commit statuses |
pr_number |
integer | null |
null |
PR (GitHub) or MR IID (GitLab) — when set, Migratowl posts a comment with the analysis result |
commit_sha |
string | null |
null |
Full commit SHA — when set, Migratowl posts a pending status at scan start and a success/failure status on completion |
callback_url |
string | null |
null |
URL to POST ScanAnalysisReport on completion |
exclude_deps |
string[] |
[] |
Dependency names to skip entirely |
check_deps |
string[] |
[] |
When non-empty, only these dependencies are checked (all others are ignored) |
max_deps |
integer |
50 |
Maximum outdated deps to analyze (must be > 0) |
ecosystems |
string[] | null |
null |
Limit to specific ecosystems: "python", "nodejs", "go", "rust", "java". null = auto-detect all |
mode |
string |
"normal" |
Version resolution mode — see below |
include_prerelease |
boolean |
false |
When true, pre-release versions (alpha, beta, RC) are considered when finding the latest version |
Version resolution modes (mode):
| Mode | Behaviour |
|---|---|
"safe" |
Respects the declared semver constraint. ^4.21.2 only reports a newer version if one exists within the >=4.21.2,<5.0.0 range. A package already at the top of its pinned range is reported as up-to-date even when a new major exists. |
"normal" |
Ignores the constraint operator. ^4.21.2 compares the bare version 4.21.2 against the globally highest published version — including major bumps like 5.x. |
Example:
{
"repo_url": "https://github.com/org/repo",
"branch_name": "main",
"git_provider": "github",
"pr_number": 42,
"commit_sha": "abc123...",
"callback_url": "https://yourservice.example.com/results",
"exclude_deps": ["boto3"],
"check_deps": [],
"max_deps": 20,
"ecosystems": ["python"],
"mode": "normal",
"include_prerelease": false
}202 response (WebhookAcceptedResponse):
{
"job_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status_url": "/jobs/3fa85f64-5717-4562-b3fc-2c963f66afa6"
}Poll the status of a scan job.
Response (JobStatus):
| Field | Type | Description |
|---|---|---|
job_id |
string |
UUID assigned at webhook acceptance |
state |
string |
Job lifecycle state (see below) |
created_at |
datetime |
ISO 8601, UTC |
updated_at |
datetime |
ISO 8601, UTC |
payload |
ScanWebhookPayload |
Original request payload |
result |
ScanAnalysisReport | null |
Set when state = "completed" |
error |
string | null |
Set when state = "failed" |
Job lifecycle:
stateDiagram-v2
[*] --> pending
pending --> running
running --> completed
running --> failed
completed --> [*]
failed --> [*]
| State | Meaning |
|---|---|
pending |
Queued, not yet started (v1 runs one scan at a time) |
running |
Agent is actively analyzing the repository |
completed |
Analysis finished; result is populated |
failed |
Unrecoverable error; error describes what went wrong |
404 when job_id is not found.
Liveness check. Returns 200 {"status": "ok"} when the server is running.
The ScanAnalysisReport delivered to callback_url (and returned in GET /jobs/{job_id} when completed):
ScanAnalysisReport
├── repo_url string — repository that was analyzed
├── branch_name string — branch that was cloned
├── scan_result ScanResult
│ ├── all_deps Dependency[] — every declared dependency found
│ │ ├── name string
│ │ ├── current_version string
│ │ ├── ecosystem string
│ │ └── manifest_path string
│ ├── outdated OutdatedDependency[] — deps with newer versions
│ │ ├── name string
│ │ ├── current_version string
│ │ ├── latest_version string
│ │ ├── ecosystem string
│ │ ├── manifest_path string
│ │ ├── homepage_url string | null
│ │ ├── repository_url string | null
│ │ └── changelog_url string | null
│ ├── manifests_found string[] — manifest file paths discovered
│ └── scan_duration_seconds float
├── reports AnalysisReport[] — one per analyzed package
│ ├── dependency_name string
│ ├── is_breaking bool
│ ├── error_summary string — what failed (empty if not breaking)
│ ├── changelog_citation string — verbatim excerpt from changelog
│ └── suggested_human_fix string — plain-English remediation step
├── skipped string[] — package names not analyzed
└── total_duration_seconds float
Example report entry:
{
"dependency_name": "requests",
"is_breaking": true,
"error_summary": "ImportError: cannot import name 'PreparedRequest'",
"changelog_citation": "## 3.0.0 — Removed PreparedRequest from the public API.",
"suggested_human_fix": "Replace `from requests import PreparedRequest` with `requests.models.PreparedRequest`."
}All MIGRATOWL_* variables are optional (defaults shown). Third-party SDK keys use their standard names without the MIGRATOWL_ prefix.
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required when MIGRATOWL_MODEL_PROVIDER=anthropic (default) |
OPENAI_API_KEY |
— | Required when MIGRATOWL_MODEL_PROVIDER=openai or litellm |
MIGRATOWL_MODEL_PROVIDER |
anthropic |
LLM provider: anthropic, openai, or litellm |
MIGRATOWL_MODEL_NAME |
claude-sonnet-5 |
Model name (must match provider) |
MIGRATOWL_MODEL_ALIAS |
— | Override model name sent to provider (for proxies with different naming) |
MIGRATOWL_MODEL_RATE_LIMIT_RPS |
0.1 |
Max LLM requests/second (0.1 = 6 req/min) |
ANTHROPIC_BASE_URL |
— | Custom base URL for Anthropic API |
OPENAI_BASE_URL |
— | Custom base URL for OpenAI API |
LITELLM_BASE_URL |
— | LiteLLM unified proxy endpoint (use with MIGRATOWL_MODEL_PROVIDER=litellm) |
For developers routing through corporate proxies (LiteLLM, Azure API Management, etc.):
Pattern 1 — Provider-specific proxy (Anthropic/OpenAI compatible):
ANTHROPIC_BASE_URL=https://proxy.mycompany.com/anthropic/v1
ANTHROPIC_API_KEY=<proxy-provided-key>
MIGRATOWL_MODEL_ALIAS=anthropic--claude-sonnet-latest # if proxy uses different namingPattern 2 — LiteLLM unified proxy (one endpoint for all providers):
MIGRATOWL_MODEL_PROVIDER=litellm
LITELLM_BASE_URL=https://proxy.mycompany.com/v1
OPENAI_API_KEY=<proxy-provided-key>
MIGRATOWL_MODEL_NAME=anthropic--claude-sonnet-latestSee docs/proxy-setup.md for troubleshooting, model name mapping, and additional examples.
| Variable | Default | Description |
|---|---|---|
MIGRATOWL_SANDBOX_MODE |
agent-sandbox |
agent-sandbox (requires controller + CRDs) or raw (any cluster, no CRDs) |
MIGRATOWL_SANDBOX_TEMPLATE |
migratowl-sandbox-template |
agent-sandbox AgentSandboxTemplate name (agent-sandbox mode only) |
MIGRATOWL_SANDBOX_NAMESPACE |
default |
Kubernetes namespace for sandbox pods |
MIGRATOWL_SANDBOX_CONNECTION_MODE |
tunnel |
Connection mode: tunnel or direct (agent-sandbox mode only) |
MIGRATOWL_SANDBOX_IMAGE |
python:3.12-slim |
Container image for sandbox pods (raw mode only). Must include the runtime for the target ecosystem — e.g. python:3.13-slim, node:22-slim, golang:1.24-bookworm, rust:1.86-slim, maven:3.9-eclipse-temurin-21-alpine. For mixed-ecosystem repos use a fat image that bundles all runtimes (see k8s/runtime/). |
MIGRATOWL_SANDBOX_BLOCK_NETWORK |
true |
Attach deny-all NetworkPolicy to sandbox pods (raw mode only; requires Calico/Cilium — kindnet ignores it) |
MIGRATOWL_WORKSPACE_PATH |
/home/user/workspace |
Workspace root inside the sandbox |
| Variable | Default | Description |
|---|---|---|
MIGRATOWL_CONFIDENCE_THRESHOLD |
0.7 |
Packages above this are analyzed directly; below → subagent |
MIGRATOWL_SCAN_REGISTRY_CONCURRENCY |
10 |
Concurrent registry queries when checking outdated deps |
MIGRATOWL_MAX_OUTPUT_CHARS |
30000 |
Truncation limit for sandbox command output |
MIGRATOWL_MAX_CHANGELOG_CHARS |
15000 |
Truncation limit for fetched changelogs |
MIGRATOWL_MAX_OUTDATED_DEPS |
100 |
Hard cap on registry scan results |
| Variable | Default | Description |
|---|---|---|
MIGRATOWL_HTTP_TIMEOUT |
30.0 |
Outbound request timeout (seconds) |
MIGRATOWL_HTTP_RETRY_COUNT |
3 |
Retries on 429 / 5xx responses |
MIGRATOWL_HTTP_RETRY_BACKOFF_BASE |
0.5 |
Base delay (seconds) for exponential backoff |
| Variable | Default | Description |
|---|---|---|
MIGRATOWL_API_HOST |
0.0.0.0 |
Bind address |
MIGRATOWL_API_PORT |
8000 |
Bind port |
| Variable | Default | Description |
|---|---|---|
GITHUB_TOKEN |
— | GitHub personal access token; needs repo:status and public_repo (or repo for private repos) scopes to post PR comments and commit statuses |
GITHUB_API_URL |
https://api.github.com |
Override for GitHub Enterprise Server (e.g. https://github.corp.com/api/v3) |
GITLAB_TOKEN |
— | GitLab personal access token with api scope; needed to post MR comments and commit statuses |
GITLAB_API_URL |
https://gitlab.com/api/v4 |
Override for self-hosted GitLab |
| Variable | Default | Description |
|---|---|---|
LANGFUSE_PUBLIC_KEY |
— | Enables LangFuse tracing when both keys are set |
LANGFUSE_SECRET_KEY |
— | See above |
LANGFUSE_HOST |
https://cloud.langfuse.com |
LangFuse instance URL |
Migratowl uses langchain-kubernetes in agent-sandbox mode by default, which requires the kubernetes-sigs/agent-sandbox controller and CRDs installed in your cluster. This provides warm pod pools and gVisor/Kata isolation.
# Install controller + CRDs (one-time)
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.2.1/manifest.yaml
kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.2.1/extensions.yaml
# Build runtime image (must be visible to the cluster — use minikube docker-env locally)
eval $(minikube docker-env)
docker build -t sandbox-runtime:latest k8s/runtime/
# Apply manifests
kubectl apply -f k8s/rbac.yaml
kubectl apply -f k8s/sandbox-template.yamlOptional warm pool (reduces cold-start latency):
kubectl apply -f k8s/warm-pool.yamlRaw mode — if you can't install the agent-sandbox controller (locked-down clusters, CI environments), switch to raw mode. No CRDs required — Migratowl manages ephemeral pods directly:
MIGRATOWL_SANDBOX_MODE=raw # set in .env
MIGRATOWL_SANDBOX_IMAGE=python:3.12-slim
MIGRATOWL_SANDBOX_BLOCK_NETWORK=true # requires Calico/Cilium; set false for kind (kindnet ignores NetworkPolicy)Apply the raw-mode RBAC instead of the default one:
kubectl apply -f k8s/rbac-raw.yamlSecurity defaults applied to every pod:
runAsNonRoot: true,runAsUser: 1000allowPrivilegeEscalation: false,capabilities.drop: [ALL]automountServiceAccountToken: false- Deny-all
NetworkPolicy(ingress + egress)
Migratowl integrates with LangFuse for trace-level observability. Tracing is off by default and activates when both keys are present.
# .env
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=https://cloud.langfuse.com # or your self-hosted instanceWhen enabled, every scan produces a LangFuse session (keyed by job_id) containing:
- Main agent trace — all LLM calls and tool invocations
- Tool call spans —
clone_repo,scan_dependencies,execute_project, etc. - Subagent spans —
package-analyzersubagent runs nested under the parent trace
No additional code changes are needed — the observability.py module initializes the handler at startup and patches the LangGraph graph to inject session IDs automatically.
Two ready-to-use example workflows are in docs/examples/. Both trigger on Dependabot PRs (targeted single-dep scan) and on workflow_dispatch (manual full scan).
Spins up a temporary kind cluster and Migratowl instance inside the runner. Nothing to host.
Setup (2 steps):
- Copy
docs/examples/ci-only.ymlinto.github/workflows/in your repo - Add one repository secret:
ANTHROPIC_API_KEY(Settings → Secrets and variables → Actions → New repository secret)
That's it. The built-in GITHUB_TOKEN is used automatically for PR comments.
Triggers your existing Migratowl deployment via webhook. Near-instant trigger, no cluster spin-up in CI.
Setup:
- Copy
docs/examples/with-migratowl-server.ymlinto.github/workflows/ - Add a repository Actions variable
MIGRATOWL_URLpointing at your deployment (Settings → Secrets and variables → Actions → Variables), e.g.https://migratowl.yourcompany.com - Ensure your Migratowl instance has
GITHUB_TOKENset withrepo:statusandpublic_reposcopes (orrepofor private repos)
For GitLab, change "git_provider": "github" to "gitlab" in the payload and configure:
GITLAB_TOKEN=glpat-...
GITLAB_API_URL=https://gitlab.com/api/v4 # or your self-hosted URLGitHub Enterprise Server — set GITHUB_API_URL on your Migratowl instance:
GITHUB_API_URL=https://github.corp.com/api/v3flowchart TB
subgraph API["FastAPI Server"]
W["POST /webhook"]
J["GET /jobs/{id}"]
H["GET /healthz"]
end
subgraph Agent["Migratowl Agent<br/>(deepagents / LangGraph)"]
direction TB
Tools["Tools:<br/>• clone_repo<br/>• detect_languages<br/>• scan_dependencies<br/>• check_outdated_deps<br/>• copy_source<br/>• update_dependencies<br/>• execute_project<br/>• fetch_changelog<br/>• read_manifest<br/>• patch_manifest"]
Sub["Subagent:<br/>• package-analyzer"]
end
subgraph Sandbox["Kubernetes Sandbox<br/>(langchain-kubernetes)"]
Pod["Ephemeral Pod<br/>• Non-root, no caps<br/>• Deny-all NetworkPolicy<br/>• gVisor / Kata isolation"]
end
Client["HTTP Client"] --> W
W -->|"asyncio.create_task"| Agent
Agent -->|"executes via"| Pod
migratowl/
├── api/
│ ├── main.py # FastAPI app, /webhook + /jobs endpoints, lifespan
│ ├── jobs.py # In-memory JobStore (PENDING→RUNNING→COMPLETED|FAILED)
│ └── helpers.py # build_user_message, extract_report
├── agent/
│ ├── graph.py # graph singleton + sandbox lifecycle (langgraph.json entrypoint)
│ ├── factory.py # create_migratowl_agent() — builds the LangGraph
│ ├── sandbox.py # KubernetesProvider init/teardown helpers
│ ├── subagents.py # package-analyzer subagent definition
│ ├── session_graph.py # Patches ainvoke/astream to inject LangFuse session IDs
│ └── tools/
│ ├── clone.py # clone_repo, copy_source
│ ├── detect.py # detect_languages
│ ├── scan.py # scan_dependencies
│ ├── registry.py # check_outdated_deps
│ ├── update.py # update_dependencies
│ ├── execute.py # execute_project (runs install + test in sandbox)
│ ├── changelog.py # fetch_changelog (PyPI / npm / GitHub / raw HTTP)
│ └── manifest.py # read_manifest, patch_manifest (sandbox file I/O)
├── models/
│ └── schemas.py # All Pydantic models (ScanWebhookPayload, ScanAnalysisReport, …)
├── config.py # pydantic-settings Settings class (MIGRATOWL_ prefix)
├── observability.py # LangFuse CallbackHandler setup + session ID injection
├── registry.py # Registry query logic (PyPI, npm, crates.io, Go proxy)
├── parsers.py # Manifest parsers per ecosystem
├── changelog.py # Changelog fetch strategies (multi-strategy fallback)
├── patches.py # Dependency version patching helpers
└── http.py # Shared HTTPX async client with retry logic
k8s/
├── rbac.yaml # RBAC for agent-sandbox mode (manages Sandbox CRs)
├── rbac-raw.yaml # RBAC for raw mode (manages Pods + NetworkPolicies directly)
├── sandbox-template.yaml# AgentSandboxTemplate CRD for the runner pod
├── warm-pool.yaml # Optional warm pool for faster pod startup
├── sandbox-router.yaml # Optional sandbox router service
└── runtime/ # Dockerfile + entrypoint for the sandbox runner image
tests/ # Mirrors migratowl/ package structure
| Task | Command |
|---|---|
| Install | uv sync |
| Run | uv run uvicorn migratowl.api.main:app --reload |
| Test | uv run pytest tests/ -v |
| Lint | uv run ruff check migratowl/ |
TDD is mandatory for all production code in migratowl/. The Red-Green-Refactor cycle is enforced: write a failing test first, confirm RED, write minimal code to pass, confirm GREEN, then refactor. No production code without a corresponding test in tests/. See CLAUDE.md for details.
See CONTRIBUTING.md. All contributors must sign the CLA.
- Open an issue first
- Branch:
issue/<NUMBER>-short-description - Write a failing test before any production code (TDD — no exceptions)
- Open a PR with
Closes #<NUMBER>
Apache 2.0 — see LICENSE.
