diff --git a/.claude/knowledge/architecture-boundaries.md b/.claude/knowledge/architecture-boundaries.md new file mode 100644 index 0000000..687cb27 --- /dev/null +++ b/.claude/knowledge/architecture-boundaries.md @@ -0,0 +1,10 @@ +# Architecture & System Boundaries + +- merger is a **control plane made of two services**, not a CLI: `cmd/merger-ingest` (GitHub webhook ingest) and `cmd/merger-controlplane` (analysis + gRPC/HTTP). Platform deps are PostgreSQL, Redis, and NATS JetStream. +- The Go module path is `github.com/devr-tools/merger`, matching the GitHub home. It is part of the devr-tools tool family (siblings: `codeguard`, `cleanr`). (It was renamed from `github.com/mergerhq/merger` so `go install`/Homebrew resolve against the real repo — if you find any lingering `mergerhq` references, they are stale.) +- Core pipeline lives under `internal/`: `ingest → mutations → runtimegraph → risk → policy → lanes`. A PR becomes a Change Packet, mutations are classified, blast radius is estimated, risk is scored, policy is applied, and a merge lane (`GREEN`/`YELLOW`/`RED`/`BLACK`) is assigned. +- The `merger` CLI (`cmd/merger`, logic in `internal/cli`) is the installable local face. It does NOT run the services; it drives `internal/scan`, an offline pipeline that reuses the same engines (mutations → runtimegraph → risk → policy → lanes) against a raw unified diff, with a filesystem-backed content loader instead of the GitHub content API. Keep `internal/scan` in sync with `internal/ingest`'s stage order if the pipeline changes. +- Config/policy auto-discovery lives in `internal/resolve` (shared by `internal/cli` and `internal/mcpserver` — the latter must not import `cli`, which would cycle). `internal/mcpserver` is a hand-rolled stdio JSON-RPC MCP server (`merger mcp`) exposing `merger_scan`/`merger_validate`. The public SDK is `pkg/merger` (`Scan`, `LoadPolicy`, `DefaultLanes` + domain type aliases). +- Public extension seams live in `pkg/extensions` (SCM, topology, event, analyzer, persistence adapters). First-party impls (GitHub, NATS, PostgreSQL) are the reference implementations; the seams exist so other orgs can swap them. +- `pkg/merger` is public type aliases only; `pkg/diff` is reusable unified-diff parsing; `pkg/identity` is shared identity types. +- Policies are YAML (`config/policies`) and composable: `when` mutation conditions → `require` reviewers/evidence/deployment → `action` minimum lane. diff --git a/.claude/knowledge/local-dev-setup.md b/.claude/knowledge/local-dev-setup.md new file mode 100644 index 0000000..5c8054a --- /dev/null +++ b/.claude/knowledge/local-dev-setup.md @@ -0,0 +1,8 @@ +# Local Development Setup + +- Requires Go `1.25.10` (pinned as part of the security baseline, not a preference). Bootstrap with `eval "$(./scripts/dev/use-go-1.25.10.sh)"` — it installs the toolchain into `$HOME/sdk/go1.25.10` and exports `GOROOT`/`PATH`/`GO`. +- After the one-time install, `make` targets auto-prefer `$HOME/sdk/go1.25.10/bin/go` when present, so `make ci` does not depend on the shell default. `make print-go` shows which Go binary the Makefile resolved. +- Build cache is redirected to `.build/go-cache` (via `GOCACHE` in the Makefile); `make clean` removes `.build`. +- Run the stack locally: `make compose-up` (Postgres/Redis/NATS via `deployments/local/docker-compose.yml`), then `make run-ingest` and `make run-controlplane`. Services read config from `MERGER_CONFIG_PATH` (default `config/merger.yaml`). +- Default ports: ingest HTTP `:8080`, control-plane HTTP `:8081`, control-plane gRPC `:9091`, Postgres `:5432`, Redis `:6379`, NATS `:4222`. +- Regenerate protobufs with `make proto` (needs `protoc` plus the Go plugins on PATH). diff --git a/.claude/knowledge/testing-patterns.md b/.claude/knowledge/testing-patterns.md new file mode 100644 index 0000000..792b74c --- /dev/null +++ b/.claude/knowledge/testing-patterns.md @@ -0,0 +1,8 @@ +# Testing Patterns + +- Tests are **black-box, kept out of source packages**: they live under `tests/` (e.g. `tests/mutations`, `tests/policy`, `tests/lanes`, `tests/ingest`, `tests/github`, `tests/controlplane`), mirroring `internal/` package names. +- `make test` runs only packages that contain `*_test.go`; `make test-all` runs `./...`. `make smoke` runs a fast subset (`tests/controlplane`, `tests/github`, `tests/ingest`). +- Coverage is gated on **internal packages only**: `make coverage` runs the `tests/...` tree with `-coverpkg=./internal/...` and fails below `MIN_INTERNAL_COVERAGE` (default 60%). +- CI additionally enforces: `make gocyclo` (cyclomatic complexity ≤ 15 over `cmd`/`internal`/`pkg`), `make security` (govulncheck), a test-presence gate on changed code paths (`scripts/ci/check_test_presence.sh`), semgrep, and a DCO `Signed-off-by` check. Sign commits with `git commit -s`. +- The 3-OS matrix (ubuntu/macos/windows) runs `go test ./tests/...`, so keep tests OS-portable (no hardcoded `/tmp`, path separators, etc.). +- CI runs the sibling tool **codeguard** (`.codeguard/codeguard.yaml`) as a quality/security gate in `diff` mode. Gotchas: config artifact paths (baseline, cache) resolve **inside** `.codeguard/` and may not escape it (path-traversal guard) — the baseline is `.codeguard/baseline.json` referenced as `path: baseline.json`; `required_*` CI paths join onto the repo root, so they are repo-relative with **no** `../` prefix. Generated code (`**/*.pb.go`) is excluded, and codeguard's govulncheck is off because merger's own `security` job covers it. Pre-existing findings are baselined; regenerate after intentional churn with `codeguard baseline -config .codeguard -output .codeguard/baseline.json`. Only `.codeguard/{codeguard.yaml,baseline.json}` are tracked; caches are gitignored. diff --git a/.codeguard/baseline.json b/.codeguard/baseline.json new file mode 100644 index 0000000..614e94c --- /dev/null +++ b/.codeguard/baseline.json @@ -0,0 +1,215 @@ +{ + "generated_at": "2026-07-10T16:56:25Z", + "entries": [ + { + "fingerprint": "14601059c0d9667d432ffed1715980b37f2ad8d17702e8ef271bae1e9e155eb0", + "context_fingerprint": "843da2e994ce2dc662ec64f1bb7f2d5a392fc5bdc8ef98d69db15757005a9911", + "rule_id": "security.hardcoded-credential", + "path": "internal/config/config.go", + "message": "possible hardcoded credential detected (database connection string with embedded credentials): ******" + }, + { + "fingerprint": "1f6b29e5b08bbe363cb7d027c91543b75345d721be738dbb6efb935e3e2c20cf", + "context_fingerprint": "2478d68fe74c9172df4ff9760a9e7529e15660b9495164b37e7ce69081fb8f0c", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/scan.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "2084c18ccd09670accf0246676686b342a0b83e18f47d4b472746f7222efee3b", + "context_fingerprint": "6c5c5c89c9be72dd2a851665dc4aea77e64f4e888e72edf92dd3af1d43030b29", + "rule_id": "quality.duplicate-code", + "path": "cmd/merger-controlplane/main.go", + "message": "duplicate normalized token sequence of 92 tokens also found in cmd/merger-ingest/main.go:26 (threshold 90)" + }, + { + "fingerprint": "2464127d69f2124c3285223fdbc2f8bef491c49bf24d55dde202762b4c536e98", + "context_fingerprint": "33b5f2c8fd3588fe5c96980897bd5e2d04982aac410d9d58ab95e7cbb7fbeff8", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/scan.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "2d204b2f89f52f9fab8c0edb6904cf18372b68646c77b4d2b2f62c28234b96e4", + "context_fingerprint": "f70c4ba2af33456da81aeb592ab4f7b045eabd588c01be1b77ba735d3e163043", + "rule_id": "quality.duplicate-code", + "path": "internal/ingest/processor.go", + "message": "duplicate normalized token sequence of 93 tokens also found in internal/app/ingest/app.go:12 (threshold 90)" + }, + { + "fingerprint": "3068708d7222f290d0eda93f24a3c82b52531b4afe4975a6130188c2145927cb", + "context_fingerprint": "137779ad5d08fa40b9bf9273b90c9889df34bced59383cee68546cd3e36e6a3d", + "rule_id": "quality.n-plus-one-query", + "path": "internal/store/postgres.go", + "message": "query call ExecContext inside a loop suggests an N+1 query pattern; batch the query or hoist it out of the loop" + }, + { + "fingerprint": "4c189c4632fd4a224c78010ffca577629ad3b5fd1c04499353dd027497ec6a4c", + "context_fingerprint": "f49269b2e0b593a1335d70ec165d4ce47171c8b60897e80b8682a25991c6aa6c", + "rule_id": "design.max-decls-per-file", + "path": "pkg/merger/types.go", + "message": "file has 26 declarations; max is 16" + }, + { + "fingerprint": "6be23f9b699bc0b768f65a16a31c2c377a38b6130dcc6054848c28f41d4e10fc", + "context_fingerprint": "d141c66012209276b4b7e3d10d48e47433e93c113762570d38ce33c0a96ddf94", + "rule_id": "security.unsanitized-error-response", + "path": "internal/ingest/webhook_handler.go", + "message": "raw error value written to the HTTP response; return a generic message and log the detail server-side" + }, + { + "fingerprint": "835fff9f850f2a7e5150d6bcfd0cb99a7d5fe49a5588f150c5b4d061a10b1744", + "context_fingerprint": "e2513bc28cc552c5d5e57295b88131dde9c572e3b4cff0338ef5b5bf94da4a02", + "rule_id": "security.unsanitized-error-response", + "path": "internal/controlplane/http.go", + "message": "raw error value written to the HTTP response; return a generic message and log the detail server-side" + }, + { + "fingerprint": "849f45e633e3b54f732196b9ff62bf30ee7140accb193017633b83adacc0c9e7", + "context_fingerprint": "dc4b559d6ba36c664014d7d5ec8c60ea1b8c65598570b914dd852095afd2a8e5", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/scan.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "8c7baf1bfa8942e33fac4a624c5f51b7f14148b4480526e2959f8a6e1a451fa0", + "context_fingerprint": "4ad0300e5a8d1498cd358d2c211acbe341f574691b91ee75a578efe22d6365df", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/scan.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "8f116f2eac39179ad24202b12f45201a82ef19153f6eb6620169fe823b23c726", + "context_fingerprint": "92ebcf7bbc14bdbcb8080e40ba2fc6d0e1f63cfbaa12239f615576e89db80940", + "rule_id": "security.hardcoded-credential", + "path": "config/merger.yaml", + "message": "possible hardcoded credential detected (database connection string with embedded credentials): ******" + }, + { + "fingerprint": "957e8b8700f29f3f75950db59fe05f1dbcbff925b6beeb9b8c438e3931fe4ea3", + "context_fingerprint": "49f00ed8b52c4f833cda5b602fb8dc3a4be0508d51b7668c047a2352fa7f1abb", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/types.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "99ab8e16867326a777c8a64970c5ccf95832e2c2c9f794449998de16acb74bed", + "context_fingerprint": "b539e1dd4b3266332b504c918e7e27ba0cf5616b45dd70b68dc5d8e44860d92e", + "rule_id": "quality.ai.narrative-comment", + "path": "internal/mcpserver/server.go", + "message": "comment narrates the code instead of explaining intent or constraints" + }, + { + "fingerprint": "99e0e40d07018feefbce9229a74a1ea4a0eaa378afb218e63c345d65004cad40", + "context_fingerprint": "d3799c529ef8c5feb563e230deed8b0ba69d6704adc9b861bbe1b5b2548685bb", + "rule_id": "quality.duplicate-code", + "path": "internal/app/ingest/app.go", + "message": "duplicate normalized token sequence of 93 tokens also found in internal/ingest/processor.go:10 (threshold 90)" + }, + { + "fingerprint": "9d1fab2c8753c4d31731c0fc6fc8488a166e334097fbc8365936bb007fdd9cc3", + "context_fingerprint": "54521b89d5eadc04139ca684d89405cdc67b019f88d69f493bd9c4941655e3bf", + "rule_id": "security.unsanitized-error-response", + "path": "internal/controlplane/http.go", + "message": "raw error value written to the HTTP response; return a generic message and log the detail server-side" + }, + { + "fingerprint": "ab17c91b40f2cab1a8c20c30126da7e69862dfc3faf38abc66bea3694ade4210", + "context_fingerprint": "19efd6c600bd3b0d671645d8f3950d04861f1428b3394a59fd209bd2c74e5a98", + "rule_id": "quality.duplicate-code", + "path": "cmd/merger-ingest/main.go", + "message": "duplicate normalized token sequence of 92 tokens also found in cmd/merger-controlplane/main.go:20 (threshold 90)" + }, + { + "fingerprint": "b784b1ffc79eaa0fa469a28030437f8cea137b00f20b5abc7d52a99c0cce5a21", + "context_fingerprint": "d429e81312f0106c7f9bbeb5360840a2d68c4e244cfe2ab1d234d0e841f59925", + "rule_id": "design.max-decls-per-file", + "path": "internal/mutations/extractors.go", + "message": "file has 18 declarations; max is 16" + }, + { + "fingerprint": "c0a3762a24aadf3b8c127f5507b808d993eb44a071b2276226039faa3e2239be", + "context_fingerprint": "bb3d8408be1e0d24e23c713458d71177ebda60eab0c8eac0ad116bebac9fddf2", + "rule_id": "security.unsanitized-error-response", + "path": "internal/controlplane/http.go", + "message": "raw error value written to the HTTP response; return a generic message and log the detail server-side" + }, + { + "fingerprint": "c864b6157617b1176500bf3d43d4a387f3490f0f17d6c75cff0361ef06ef615f", + "context_fingerprint": "588f5c8442bab51b553aefff23832fe8282940b5cacbbf1863740e7f14b0843d", + "rule_id": "quality.duplicate-code", + "path": "internal/ingest/files.go", + "message": "duplicate normalized token sequence of 173 tokens also found in internal/scan/scan.go:151 (threshold 90)" + }, + { + "fingerprint": "d0ec9ec96ab2b8c9c045b2f0f1b0553d68e38e66b2d764b7551d3e1a1dbceed2", + "context_fingerprint": "e292b2fd3c1d289924fa3c5d4284aaa223658dc584fc675a4bd5e8c47ef598e0", + "rule_id": "security.hardcoded-secret", + "path": "config/merger.yaml", + "message": "possible hardcoded secret detected: repl…e-me" + }, + { + "fingerprint": "d5b15969c80a95bfab766e0245505f10e8e376e5aaa06b84c950f056f924ca7d", + "context_fingerprint": "8178bb8fac72aab47ddf964c240f935a47caa90e26a2b7d509a64de7c13b702e", + "rule_id": "quality.go.alloc-in-loop", + "path": "internal/cli/scan.go", + "message": "string \"label\" grows by concatenation inside a loop; use strings.Builder" + }, + { + "fingerprint": "d5f4a2cc597596677f40a86c065588fcd4abecc840f88d25a496130772c3a7b9", + "context_fingerprint": "fa313d63fcf0092515f350b335f6c2bd8c9666b905d728654b542b1c5c2376cd", + "rule_id": "quality.go.alloc-in-loop", + "path": "internal/cli/scan.go", + "message": "string \"label\" grows by concatenation inside a loop; use strings.Builder" + }, + { + "fingerprint": "e00ef594ab252b02e560dd91a397bbe08527b082f2b2b2f6501733d60a53a420", + "context_fingerprint": "71a4939b81fcc48a132563d5496ec675a6fe33fb99e70e3769201c669c42e39e", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/types.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "ea2459eb71264121d8fab825eb1d5f08ae9218a684e5514d6e9aec1a21eefcca", + "context_fingerprint": "61334af2e2b590b0855cd96d6dce593856a133819f8ae4bb593265901c504d13", + "rule_id": "quality.ai.narrative-comment", + "path": "internal/mcpserver/tools.go", + "message": "comment narrates the code instead of explaining intent or constraints" + }, + { + "fingerprint": "f5acf321e5f4cb2139e95f072559a6decbf6bddb9851ce7b3e9bf493c6a4f158", + "context_fingerprint": "f493e5464dcf828b0bc8f3faa298900fabeafa3814ef0137511012ea06b0b741", + "rule_id": "security.unsanitized-error-response", + "path": "internal/controlplane/http.go", + "message": "raw error value written to the HTTP response; return a generic message and log the detail server-side" + }, + { + "fingerprint": "f6766d61f2294cc8fd31e60dd7955e19631c83e1fb700d8d958a5a6b39cc3639", + "context_fingerprint": "ce9405b974574f3a0eff2f4892c0040b8e17488bc8e1cf9839b6562000c442ec", + "rule_id": "quality.duplicate-code", + "path": "internal/scan/scan.go", + "message": "duplicate normalized token sequence of 173 tokens also found in internal/ingest/files.go:11 (threshold 90)" + }, + { + "fingerprint": "f926acffc81704ec8fe4e2dfd7b84e87da32810462918259493de20b8ab2be2b", + "context_fingerprint": "9ef56cdf8c6de314900e2246be220d5b127f5a94d50c5aada0641cc799b66a45", + "rule_id": "quality.dependency-direction", + "path": "pkg/merger/types.go", + "message": "non-CLI package imports internal implementation detail" + }, + { + "fingerprint": "fc319cded3abcd14e8344ba6e4aa2dc78e8ebec4c7bd421df53d411dd2ab448e", + "context_fingerprint": "505176ea843bc21068c5764c14f2f12f75ec21fdf5424d49439cfa0893cfa1e8", + "rule_id": "quality.ai.narrative-comment", + "path": "internal/cli/cli.go", + "message": "comment narrates the code instead of explaining intent or constraints" + }, + { + "fingerprint": "fc46f082e91b81f574a98d86d2b1c94d172f5a816505a543cfb31e984dd55a6b", + "context_fingerprint": "ea353a4ca6a29e3d1be5e4f715cb018d5a3d178d7e7e581d1709eba51aeaae3b", + "rule_id": "security.unsanitized-error-response", + "path": "internal/ingest/webhook_handler.go", + "message": "raw error value written to the HTTP response; return a generic message and log the detail server-side" + } + ] +} diff --git a/.codeguard/codeguard.yaml b/.codeguard/codeguard.yaml new file mode 100644 index 0000000..5a52686 --- /dev/null +++ b/.codeguard/codeguard.yaml @@ -0,0 +1,66 @@ +name: merger +profile: startup +targets: + - name: repository + path: .. + language: go + entrypoints: + - cmd/merger + - cmd/merger-ingest + - cmd/merger-controlplane +checks: + quality: true + design: false + security: true + prompts: false + ci: true + quality_rules: + max_file_lines: 500 + max_function_lines: 120 + max_parameters: 12 + max_cyclomatic_complexity: 15 + ci_rules: + require_workflow_dir: true + required_workflow_files: + - .github/workflows/ci.yml + - .github/workflows/cd.yml + - .github/workflows/release.yml + required_release_files: + - .goreleaser.yaml + - Dockerfile.release + - .github/release-please-config.json + - .release-please-manifest.json + - CHANGELOG.md + required_automation_paths: + - Makefile + - scripts/commit.sh + security_rules: + # merger runs govulncheck in its own dedicated CI `security` job + # (make security). Disable codeguard's duplicate invocation here. + govulncheck_mode: off +exclude: + - vendor/** + - "**/testdata/**" + - dist/** + - .gocache/** + - .build/** + # Generated code is not hand-maintained and should not be linted. + - "**/*.pb.go" + - "**/*_grpc.pb.go" +waivers: + - rule: security.shell-execution + path: internal/cli/scan.go + reason: >- + `merger scan -base-ref` shells out to `git diff` with a fixed argv and no + shell interpolation to obtain the diff to analyze. The invocation is + constrained and reviewed. +baseline: + # Pre-existing findings captured at codeguard adoption. Paths resolve inside + # the config directory, so this is .codeguard/baseline.json. Regenerate with + # `codeguard baseline -config .codeguard -output .codeguard/baseline.json`. + path: baseline.json +cache: + enabled: true + path: cache.json +output: + format: text diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 0000000..144dcd4 --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "bootstrap-sha": "7e52488e698d3e978ddcdb2d319e35a162aebf16", + "packages": { + ".": { + "release-type": "go", + "package-name": "merger", + "include-component-in-tag": false, + "changelog-path": "CHANGELOG.md", + "include-v-in-tag": true, + "extra-files": [ + { + "type": "generic", + "path": "internal/version/version.go" + } + ] + } + } +} diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..62d36b1 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,161 @@ +name: cd.yml + +on: + push: + branches: + - develop + - master + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cd-${{ github.ref_name }} + cancel-in-progress: ${{ github.ref_name == 'develop' }} + +jobs: + guard-manual-stable: + if: github.event_name == 'workflow_dispatch' && (github.ref_name == 'main' || github.ref_name == 'master') + runs-on: ubuntu-latest + steps: + - name: Stop manual stable runs here + run: | + echo "Use .github/workflows/release.yml for manual stable releases." + exit 1 + + prepare-prerelease: + if: | + (github.event_name == 'push' && github.ref_name == 'develop') || + (github.event_name == 'workflow_dispatch' && github.ref_name != 'main' && github.ref_name != 'master') + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Compute prerelease tag + id: version + shell: bash + run: | + set -euo pipefail + + manifest_version="$(sed -n 's/.*"\." *: *"\([0-9][0-9.]*\)".*/\1/p' .release-please-manifest.json)" + bootstrap_sha="$(sed -n 's/.*"bootstrap-sha" *: *"\([0-9a-f]\{40\}\)".*/\1/p' .github/release-please-config.json)" + latest_tag="$( + { + git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname + git tag --list 'merger-v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname + } | head -n 1 + )" + + if [ -n "$latest_tag" ]; then + if [[ "$latest_tag" == merger-v* ]]; then + base_version="${latest_tag#merger-v}" + else + base_version="${latest_tag#v}" + fi + commit_range="${latest_tag}..HEAD" + else + base_version="${manifest_version:-0.1.0}" + if [ -n "$bootstrap_sha" ]; then + commit_range="${bootstrap_sha}..HEAD" + else + commit_range="HEAD" + fi + fi + + subjects="$(git log --format='%s' "$commit_range" 2>/dev/null || true)" + bodies="$(git log --format='%b' "$commit_range" 2>/dev/null || true)" + + bump="patch" + if printf '%s\n%s\n' "$subjects" "$bodies" | grep -Eq 'BREAKING CHANGE|^[^[:space:]]+(\([^)]+\))?!:'; then + bump="major" + elif printf '%s\n' "$subjects" | grep -Eq '^feat(\([^)]+\))?:'; then + bump="minor" + fi + + IFS=. read -r major minor patch <<<"$base_version" + + case "$bump" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch) + patch=$((patch + 1)) + ;; + esac + + next_version="${major}.${minor}.${patch}" + tag="v${next_version}-rc.${GITHUB_RUN_NUMBER}" + + echo "tag=$tag" >>"$GITHUB_OUTPUT" + echo "Using prerelease tag $tag" + + prerelease: + needs: prepare-prerelease + if: needs.prepare-prerelease.result == 'success' + uses: ./.github/workflows/release.yml + permissions: + contents: write + packages: write + with: + tag: ${{ needs.prepare-prerelease.outputs.tag }} + prerelease: true + secrets: + RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + + release-please: + if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master') + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: Require release bot token + shell: bash + env: + RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + run: | + set -euo pipefail + + if [ -z "$RELEASE_PLEASE_TOKEN" ]; then + echo "RELEASE_PLEASE_TOKEN is required." + echo "Use a PAT or GitHub App token for the release bot so release PRs trigger required pull_request workflows." + exit 1 + fi + + - name: Run release-please + id: release + uses: googleapis/release-please-action@v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + config-file: .github/release-please-config.json + manifest-file: .release-please-manifest.json + + stable-release: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + uses: ./.github/workflows/release.yml + permissions: + contents: write + packages: write + with: + tag: ${{ needs.release-please.outputs.tag_name }} + prerelease: false + secrets: + RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b16d301..af89d41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ env: GOVULNCHECK_VERSION: v1.3.0 GOCYCLO_VERSION: v0.6.0 MIN_INTERNAL_COVERAGE: "60.0" + CODEGUARD_VERSION: v0.8.1 concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -115,6 +116,31 @@ jobs: fi } >> "$GITHUB_STEP_SUMMARY" + codeguard: + name: codeguard + needs: + - vet + - gocyclo + runs-on: ubuntu-latest + env: + PR_BASE_REF: ${{ github.base_ref }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fetch base branch + run: git fetch origin "${PR_BASE_REF}" --depth=1 + + - name: Run codeguard + uses: devr-tools/codeguard@v0.8.1 + with: + config: .codeguard + mode: diff + base-ref: origin/${{ github.base_ref }} + format: github + version: ${{ env.CODEGUARD_VERSION }} + test: name: test (${{ matrix.label }}) needs: @@ -321,7 +347,9 @@ jobs: fetch-depth: 0 - name: Run Semgrep - run: semgrep scan --config auto --baseline-commit "${{ github.event.pull_request.base.sha }}" --error + # mutable-action-tag is excluded: merger intentionally pins actions to + # version tags (matching the sibling devr-tools tools), not commit SHAs. + run: semgrep scan --config auto --baseline-commit "${{ github.event.pull_request.base.sha }}" --exclude-rule yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag --error benchmark: name: benchmark diff --git a/.github/workflows/homebrew-validation.yml b/.github/workflows/homebrew-validation.yml new file mode 100644 index 0000000..09f009b --- /dev/null +++ b/.github/workflows/homebrew-validation.yml @@ -0,0 +1,98 @@ +name: homebrew-validation.yml + +on: + pull_request: + branches: + - develop + - master + - main + +permissions: + contents: read + +jobs: + formula-validation: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@main + + - name: Clone Homebrew tap + shell: bash + env: + TAP_REPOSITORY: devr-tools/homebrew-tap + run: | + set -euo pipefail + git clone --depth 1 "https://github.com/${TAP_REPOSITORY}.git" "$RUNNER_TEMP/homebrew-tap" + + - name: Patch tap formula for current checkout + shell: bash + run: | + set -euo pipefail + + version="$(awk -F'"' '/^var Number = / { print $2; exit }' internal/version/version.go)" + archive_path="$RUNNER_TEMP/merger-src.tar.gz" + tap_dir="$RUNNER_TEMP/homebrew-tap" + formula_path="$RUNNER_TEMP/homebrew-tap/Formula/merger.rb" + git archive --format=tar.gz --output "$archive_path" HEAD + sha256="$(shasum -a 256 "$archive_path" | awk '{print $1}')" + + if [ -z "$version" ]; then + echo "failed to extract merger version from internal/version/version.go" + exit 1 + fi + + mkdir -p "$tap_dir/Formula" + cat > "$formula_path" < :build + + def install + ldflags = "-s -w -X github.com/devr-tools/merger/internal/version.Number=#{version}" + system "go", "build", *std_go_args(output: bin/"merger", ldflags: ldflags), "./cmd/merger" + end + + test do + assert_match version.to_s, shell_output("#{bin}/merger version") + end + end + EOF + + git -C "$tap_dir" config user.name "github-actions[bot]" + git -C "$tap_dir" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "$tap_dir" add Formula/merger.rb + git -C "$tap_dir" commit -m "test: patch merger formula for validation" + + - name: Tap current formula checkout + shell: bash + run: | + set -euo pipefail + brew tap devr-tools/tap "$RUNNER_TEMP/homebrew-tap" + + - name: Build formula from source + shell: bash + run: | + set -euo pipefail + HOMEBREW_NO_AUTO_UPDATE=1 brew install --build-from-source devr-tools/tap/merger + + - name: Run formula test + shell: bash + run: | + set -euo pipefail + brew test devr-tools/tap/merger diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0011289 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,350 @@ +name: release.yml + +on: + workflow_call: + inputs: + tag: + description: Release tag to build from. + required: true + type: string + prerelease: + description: Whether this release should stay in the prerelease channel. + required: true + type: boolean + secrets: + RELEASE_PLEASE_TOKEN: + description: Token used to open the Homebrew formula PR on stable releases. + required: false + workflow_dispatch: + inputs: + tag: + description: Release tag to create or publish. + required: true + type: string + prerelease: + description: Mark the release as a prerelease. + required: true + type: boolean + +permissions: + contents: write + packages: write + +jobs: + build-release: + runs-on: ubuntu-latest + outputs: + prerelease: ${{ steps.release.outputs.prerelease }} + tag: ${{ steps.release.outputs.tag }} + steps: + - name: Resolve release context + id: release + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_TAG: ${{ github.event.inputs.tag }} + DISPATCH_PRERELEASE: ${{ github.event.inputs.prerelease }} + CALL_TAG: ${{ inputs.tag }} + CALL_PRERELEASE: ${{ inputs.prerelease }} + run: | + set -euo pipefail + + if [ -n "${CALL_TAG:-}" ]; then + raw_tag="$CALL_TAG" + prerelease="$CALL_PRERELEASE" + elif [ "$EVENT_NAME" = "push" ]; then + tag="${GITHUB_REF_NAME}" + if [[ "$tag" =~ ^([A-Za-z0-9._-]+-)?v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$ ]]; then + prerelease="true" + else + prerelease="false" + fi + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then + raw_tag="$DISPATCH_TAG" + prerelease="$DISPATCH_PRERELEASE" + else + echo "unable to resolve release tag from inputs or event context" + exit 1 + fi + + if [ -n "${raw_tag:-}" ]; then + tag="$(printf '%s' "$raw_tag" | tr -d '[:space:]')" + tag="${tag#refs/tags/}" + tag="${tag#tag=}" + tag="${tag##*:}" + + if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + tag="v$tag" + fi + fi + + echo "tag=$tag" >>"$GITHUB_OUTPUT" + echo "prerelease=$prerelease" >>"$GITHUB_OUTPUT" + + - name: Validate inputs + shell: bash + run: | + set -euo pipefail + + tag="${{ steps.release.outputs.tag }}" + prerelease="${{ steps.release.outputs.prerelease }}" + tag_pattern='^([A-Za-z0-9._-]+-)?v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + prerelease_pattern='^([A-Za-z0-9._-]+-)?v[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$' + + if ! [[ "$tag" =~ $tag_pattern ]]; then + echo "tag must look like v1.2.3, v1.2.3-rc.4, or merger-v1.2.3" + echo "received: '$tag'" + exit 1 + fi + + if [ "$prerelease" = "true" ] && [[ ! "$tag" =~ $prerelease_pattern ]]; then + echo "prerelease tags must include a prerelease suffix, for example -rc.4" + exit 1 + fi + + if [ "$prerelease" != "true" ] && [[ "$tag" =~ $prerelease_pattern ]]; then + echo "stable releases must use a stable semantic version tag" + exit 1 + fi + + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Ensure requested tag exists + shell: bash + run: | + set -euo pipefail + + tag="${{ steps.release.outputs.tag }}" + + git fetch --tags --force + + if git rev-parse "$tag" >/dev/null 2>&1; then + : + elif git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + git fetch origin "refs/tags/$tag:refs/tags/$tag" + else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$tag" -m "Release $tag" "$GITHUB_SHA" + git push origin "refs/tags/$tag" + fi + + git checkout "$tag" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v7 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --config .goreleaser.yaml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload release bundle + uses: actions/upload-artifact@v7 + with: + name: dist-${{ steps.release.outputs.tag }} + path: dist/* + + sync-homebrew-formula: + needs: build-release + if: needs.build-release.outputs.prerelease != 'true' + runs-on: ubuntu-latest + env: + TAP_REPOSITORY: devr-tools/homebrew-tap + steps: + - name: Detect release bot token + id: release_token + shell: bash + env: + RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + run: | + if [ -z "$RELEASE_PLEASE_TOKEN" ]; then + echo "available=false" >>"$GITHUB_OUTPUT" + else + echo "available=true" >>"$GITHUB_OUTPUT" + fi + + - name: Resolve tap default branch + id: tap_repo + shell: bash + run: | + set -euo pipefail + + default_branch="$(git ls-remote --symref "https://github.com/${TAP_REPOSITORY}.git" HEAD | awk '/^ref:/ {sub("refs/heads/", "", $2); print $2}')" + if [ -z "$default_branch" ]; then + echo "Could not resolve default branch for ${TAP_REPOSITORY}." + exit 1 + fi + + echo "default_branch=$default_branch" >>"$GITHUB_OUTPUT" + + - name: Clone tap repository + shell: bash + env: + TAP_DEFAULT_BRANCH: ${{ steps.tap_repo.outputs.default_branch }} + run: | + set -euo pipefail + + git clone --branch "$TAP_DEFAULT_BRANCH" "https://github.com/${TAP_REPOSITORY}.git" "$RUNNER_TEMP/homebrew-tap" + + - name: Update Homebrew formula + id: formula + shell: bash + env: + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + run: | + set -euo pipefail + + if [[ "$RELEASE_TAG" == *-v* ]]; then + version="${RELEASE_TAG##*-v}" + else + version="${RELEASE_TAG#v}" + fi + tarball_url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${RELEASE_TAG}.tar.gz" + tarball_path="$RUNNER_TEMP/${RELEASE_TAG}.tar.gz" + branch="automation/homebrew-formula-${RELEASE_TAG}" + tap_dir="$RUNNER_TEMP/homebrew-tap" + formula_path="$tap_dir/Formula/merger.rb" + + if [ ! -f "$formula_path" ]; then + echo "::error::Formula/merger.rb does not exist in ${TAP_REPOSITORY}; seed it once before automated syncs." + exit 1 + fi + + curl -fsSL "$tarball_url" -o "$tarball_path" + sha256="$(sha256sum "$tarball_path" | awk '{print $1}')" + + perl -0pi -e 's/version "[^"]+"/version "'"$version"'"/' "$formula_path" + perl -0pi -e 's#url "https://github\.com/devr-tools/merger/archive/refs/tags/[^"]+\.tar\.gz"#url "'"$tarball_url"'"#' "$formula_path" + perl -0pi -e 's/sha256 "[0-9a-f]+"/sha256 "'"$sha256"'"/' "$formula_path" + + echo "branch=$branch" >>"$GITHUB_OUTPUT" + echo "tap_dir=$tap_dir" >>"$GITHUB_OUTPUT" + echo "formula_path=Formula/merger.rb" >>"$GITHUB_OUTPUT" + echo "version=$version" >>"$GITHUB_OUTPUT" + echo "tarball_url=$tarball_url" >>"$GITHUB_OUTPUT" + echo "sha256=$sha256" >>"$GITHUB_OUTPUT" + + if git -C "$tap_dir" diff --quiet -- Formula/merger.rb; then + echo "changed=false" >>"$GITHUB_OUTPUT" + exit 0 + fi + + echo "changed=true" >>"$GITHUB_OUTPUT" + + git -C "$tap_dir" checkout -b "$branch" + git -C "$tap_dir" config user.name "please-release" + git -C "$tap_dir" config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git -C "$tap_dir" add Formula/merger.rb + git -C "$tap_dir" commit -m "chore(homebrew): update formula for ${RELEASE_TAG}" + + - name: Push formula update branch + id: push_formula_branch + if: steps.formula.outputs.changed == 'true' && steps.release_token.outputs.available == 'true' + continue-on-error: true + shell: bash + env: + RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + run: | + set -euo pipefail + + tap_dir="${{ steps.formula.outputs.tap_dir }}" + + git -C "$tap_dir" remote set-url origin "https://x-access-token:${RELEASE_PLEASE_TOKEN}@github.com/${TAP_REPOSITORY}.git" + git -C "$tap_dir" push --force-with-lease origin "${{ steps.formula.outputs.branch }}" + + - name: Open formula update pull request + id: open_formula_pr + if: steps.formula.outputs.changed == 'true' && steps.release_token.outputs.available == 'true' && steps.push_formula_branch.outcome == 'success' + continue-on-error: true + shell: bash + env: + GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + FORMULA_BRANCH: ${{ steps.formula.outputs.branch }} + TAP_DEFAULT_BRANCH: ${{ steps.tap_repo.outputs.default_branch }} + run: | + set -euo pipefail + + title="chore(homebrew): update formula for ${RELEASE_TAG}" + body=$(cat <>"$GITHUB_STEP_SUMMARY" + diff --git a/.gitignore b/.gitignore index d7b8670..b1b1ba2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ dist/ coverage.out *.test vendor/ +# codeguard: track only the config and baseline; everything else is generated. +.codeguard/* +!.codeguard/codeguard.yaml +!.codeguard/baseline.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..3c8a4e2 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +version: "2" + +run: + timeout: 5m + relative-path-mode: gomod + tests: false + +# NOTE: merger's CI gate is currently fmt/vet/gocyclo/coverage/security (see +# .github/workflows/ci.yml, which has no golangci-lint job). This config is +# introduced ahead of a gating `lint` job so the ruleset can be reviewed and +# any pre-existing findings cleaned up first. Promote `make lint` to a required +# CI check once the tree is clean under these linters. +linters: + default: standard + enable: + - bodyclose + - errcheck + - staticcheck + - funlen + - gocognit + - maintidx + settings: + funlen: + lines: 120 + statements: 60 + ignore-comments: true + gocognit: + min-complexity: 25 + maintidx: + under: 20 + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7e72250 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,94 @@ +version: 2 + +project_name: merger + +builds: + - id: merger + main: ./cmd/merger + binary: merger + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/devr-tools/merger/internal/version.Number=v{{ .Version }} + - id: merger-ingest + main: ./cmd/merger-ingest + binary: merger-ingest + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/devr-tools/merger/internal/version.Number=v{{ .Version }} + - id: merger-controlplane + main: ./cmd/merger-controlplane + binary: merger-controlplane + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/devr-tools/merger/internal/version.Number=v{{ .Version }} + +archives: + - id: merger + ids: + - merger + - merger-ingest + - merger-controlplane + formats: + - tar.gz + name_template: "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: SHA256SUMS + +dockers: + - id: merger-amd64 + ids: + - merger-ingest + - merger-controlplane + dockerfile: Dockerfile.release + goos: linux + goarch: amd64 + use: buildx + image_templates: + - "ghcr.io/devr-tools/merger:{{ .Tag }}-amd64" + build_flag_templates: + - "--pull" + - "--platform=linux/amd64" + - id: merger-arm64 + ids: + - merger-ingest + - merger-controlplane + dockerfile: Dockerfile.release + goos: linux + goarch: arm64 + use: buildx + image_templates: + - "ghcr.io/devr-tools/merger:{{ .Tag }}-arm64" + build_flag_templates: + - "--pull" + - "--platform=linux/arm64" + +docker_manifests: + - name_template: "ghcr.io/devr-tools/merger:{{ .Tag }}" + image_templates: + - "ghcr.io/devr-tools/merger:{{ .Tag }}-amd64" + - "ghcr.io/devr-tools/merger:{{ .Tag }}-arm64" + +release: + prerelease: auto diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4dc248a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## Unreleased + +### Features + +* add the user-facing `merger` CLI (`cmd/merger`) with `scan`, `validate`, + `init`, and `version` commands; `scan` runs the analysis pipeline offline + against a diff and assigns a merge lane, with `-format text|json` and a + `-fail-on-lane` CI gate. Config is auto-discovered from `.merger/`. +* add `internal/scan`, an offline pipeline that reuses the mutations, runtime + graph, risk, policy, and lane engines without the ingest/service dependencies +* add `merger mcp`, a Model Context Protocol server (stdio) exposing + `merger_scan` and `merger_validate` as agent tools +* scaffold the Phase 1 control-plane slice: GitHub webhook ingest, PR diff + parsing, Change Packet generation, rule-based semantic mutation detection, + risk scoring, policy evaluation, and merge-lane assignment + (`GREEN`/`YELLOW`/`RED`/`BLACK`) +* publish public extension seams (`pkg/extensions`) for SCM, topology, event, + analyzer, and persistence adapters +* add first-party GitHub, NATS JetStream, and PostgreSQL implementations + +### Ecosystem + +* add the `github.com/devr-tools/merger/pkg/merger` SDK (`Scan`, `LoadPolicy`, + `DefaultLanes`) wrapping the offline pipeline +* rename the Go module to `github.com/devr-tools/merger` to match the GitHub + home so `go install` and Homebrew resolve correctly (**breaking**: import + paths change from `github.com/mergerhq/merger/...`) +* publish a composite GitHub Action (`action.yml`) that installs the CLI and + runs `merger scan` with an optional `fail-on-lane` gate +* wire Homebrew distribution: `sync-homebrew-formula` in the release workflow + and `homebrew-validation.yml` for PR validation +* dogfood the sibling tool `codeguard` as a CI quality/security gate + (`.codeguard/codeguard.yaml` + baseline), running in `diff` mode on PRs +* adopt the devr-tools tool-family conventions: Apache-2.0 `LICENSE`, + `internal/version` package, `.golangci.yml` lint config, `SECURITY.md`, + `CONTRIBUTING.md`, and this changelog diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..672559a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,9 @@ +# merger + +## Available Context + +Additional context is available in the files below. Consult the relevant file when working in a related area — see each description for scope. + +- `.claude/knowledge/architecture-boundaries.md` — Key architectural decisions, service boundaries, data flow, integration points, and why things are the way they are. +- `.claude/knowledge/local-dev-setup.md` — How to set up, run, and work with this project locally. Non-obvious dependencies, environment config, common setup issues. +- `.claude/knowledge/testing-patterns.md` — Testing strategies, test infrastructure quirks, how to run/debug specific test suites, mocking conventions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4fad3f0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to merger + +Thanks for contributing to `merger`. + +This file covers the fast path for contributors. Keep the repository root +[README](README.md) focused on the architecture and usage, and use the +[docs/](docs/) tree for deeper detail. + +## Development Setup + +merger requires Go `1.25.10` for local development and CI. Bootstrap the +expected toolchain into your shell before running repo commands: + +```bash +eval "$(./scripts/dev/use-go-1.25.10.sh)" +go version +``` + +After that one-time install, plain `make` targets automatically prefer +`$HOME/sdk/go1.25.10/bin/go` when it exists. + +Verify the local toolchain works: + +```bash +make build +make test +``` + +## Common Commands + +Run these before opening or updating a pull request: + +```bash +make fmt +make lint +make test +make verify +make ci +``` + +`make verify` runs the full test tree and builds the commands. `make ci` is the +closest local match to GitHub Actions and additionally enforces the cyclomatic +complexity budget, the internal coverage gate, and the vulnerability scan. + +## Running Locally + +merger is a control plane made of two services plus platform dependencies +(PostgreSQL, Redis, NATS). Use the provided compose stack: + +```bash +make compose-up +make run-ingest +make run-controlplane +``` + +See the [README](README.md#local-development) for default ports. + +## Pull Requests + +- keep changes scoped and update docs when behavior changes; +- add or update tests with code changes (CI enforces test presence on changed + code paths); +- sign off your commits — CI verifies a `Signed-off-by` trailer (`git commit -s`); +- run `make ci` for CI-parity validation before pushing. + +If your change touches install, release, or packaging behavior, also update +[README.md](README.md) and the relevant files in [docs/](docs/). + +## Release and Packaging + +Release automation (Release Please, tagged releases, GHCR publishing, and +Homebrew sync) is documented in [docs/release-automation.md](docs/release-automation.md). diff --git a/Dockerfile.release b/Dockerfile.release new file mode 100644 index 0000000..6339f43 --- /dev/null +++ b/Dockerfile.release @@ -0,0 +1,11 @@ +FROM alpine:3.20 + +RUN apk --no-cache add ca-certificates + +COPY merger-ingest /usr/bin/merger-ingest +COPY merger-controlplane /usr/bin/merger-controlplane + +# merger ships two services in one image. The default command runs the +# control plane; override it to run ingest instead, for example: +# docker run ghcr.io/devr-tools/merger: merger-ingest +CMD ["merger-controlplane"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cb195e3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile index 6d400f2..29a80e0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SHELL := /bin/zsh +SHELL := /bin/bash APP_NAME := merger GO_TOOLCHAIN_VERSION ?= 1.25.10 @@ -18,13 +18,14 @@ TEST_TREE := ./tests/... GO_FILES := $(shell find cmd internal pkg proto tests -type f -name '*.go' | sort) GOCYCLO_VERSION ?= v0.6.0 GOVULNCHECK_VERSION ?= v1.3.0 +GOLANGCI_LINT_VERSION ?= v2.1.6 MIN_INTERNAL_COVERAGE ?= 60.0 BASE_REF ?= origin/main export GOCACHE := $(abspath $(BUILD_DIR)/go-cache) export GOROOT export PATH := $(GOROOT)/bin:$(PATH) -.PHONY: fmt fmt-check test test-all vet gocyclo coverage smoke benchmark security proto ci-fast ci build verify run-ingest run-controlplane compose-up compose-down docker-build clean +.PHONY: fmt fmt-check lint test test-all vet gocyclo coverage smoke benchmark security proto ci-fast ci build verify run-ingest run-controlplane compose-up compose-down docker-build release release-check release-snapshot deploy commit clean $(BUILD_DIR): mkdir -p $(GOCACHE) @@ -52,6 +53,10 @@ test-all: $(BUILD_DIR) vet: $(BUILD_DIR) $(GO) vet $(GO_PACKAGES) +lint: $(BUILD_DIR) + $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + "$$($(GO) env GOPATH)/bin/golangci-lint" run ./... + gocyclo: $(BUILD_DIR) $(GO) install github.com/fzipp/gocyclo/cmd/gocyclo@$(GOCYCLO_VERSION) @files="$$(find cmd internal pkg -type f -name '*.go' ! -name '*_test.go' | sort)"; \ @@ -108,5 +113,18 @@ compose-down: docker-build: docker build -f deployments/docker/Dockerfile -t $(APP_NAME):dev . +release: release-snapshot + +release-check: + goreleaser check + +release-snapshot: + goreleaser release --snapshot --clean + +deploy: release + +commit: + @./scripts/commit.sh + clean: rm -rf ./.build diff --git a/README.md b/README.md index 42421c3..da18884 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,66 @@ This repository scaffolds the first production-oriented control plane slice: - Internal event bus abstraction - Structured logging and trace-ready instrumentation +## CLI + +The `merger` CLI is the local, installable face of the control plane. It runs +the same analysis pipeline offline — no services, database, or event bus +required — so you can classify a diff and preview its merge lane from a laptop +or a CI job. + +```bash +go install github.com/devr-tools/merger/cmd/merger@latest +# or: brew install devr-tools/tap/merger + +merger init # scaffold .merger/ config + policy +merger validate # check config and policy resolve +merger scan -base-ref origin/main # analyze the diff vs a base ref +merger scan -diff change.diff -format json +``` + +`merger scan` parses a unified diff (from `-diff ` or a +`-base-ref ` git range), runs mutation detection, runtime-graph, risk, +policy, and lane assignment, and prints a report (`-format text|json`). Pass +`-fail-on-lane RED` to exit non-zero when a change lands in a given lane or +higher — useful as a CI gate. + +Configuration is auto-discovered from `merger.yaml` or `.merger/merger.yaml` +(see [internal/cli](/Users/alex/Documents/GitHub/merger/internal/cli:1) and the +offline pipeline in [internal/scan](/Users/alex/Documents/GitHub/merger/internal/scan/scan.go:1)). + +`merger mcp` serves the same analysis as agent tools over the Model Context +Protocol (stdio) — see [docs/mcp.md](docs/mcp.md). + +## SDK + +The same offline pipeline is available as a library from +`github.com/devr-tools/merger/pkg/merger`: + +```go +package main + +import ( + "context" + "fmt" + + "github.com/devr-tools/merger/pkg/merger" +) + +func main() { + packet, err := merger.Scan(context.Background(), merger.ScanOptions{ + Diff: rawUnifiedDiff, + Lanes: merger.DefaultLanes(), + }) + if err != nil { + panic(err) + } + fmt.Println(packet.MergeLane) +} +``` + +Load a policy rule set with `merger.LoadPolicy(path)` and pass it as +`ScanOptions.Policy`. See [docs/sdk.md](docs/sdk.md). + ## Architecture The repository is organized around domain boundaries instead of a single service package: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1fe9e50 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,61 @@ +# Security Policy + +## Reporting a vulnerability + +We take the security of merger seriously. If you believe you have found a +security vulnerability, please report it privately — **do not open a public +issue, pull request, or discussion for security reports.** + +Preferred channel: use GitHub's private vulnerability reporting for this +repository. Go to the **Security** tab → **Report a vulnerability** +(). This opens a +private advisory visible only to you and the maintainers. + +If private reporting is unavailable to you, contact a maintainer directly rather +than disclosing publicly, and we will open a private advisory on your behalf. + +Please include, where possible: + +- a description of the vulnerability and its impact; +- the affected version and platform; +- step-by-step reproduction, ideally with a minimal repository or config; and +- any proof-of-concept, logs, or suggested remediation. + +## Our commitment + +- **Acknowledgement** within **3 business days** of your report. +- **Triage and severity assessment** (CVSS-based) within **10 business days**, + including whether we accept the report and an initial remediation plan. +- **Progress updates** at least every **10 business days** until resolution. +- **Coordinated disclosure**: we aim to ship a fix and publish an advisory + within **90 days** of triage, and will credit reporters who wish to be named. + +Please give us a reasonable opportunity to remediate before any public +disclosure. + +## Supported versions + +merger is pre-1.0 and released from the latest tag. Security fixes are applied +to the most recent release only; please upgrade to the latest version before +reporting. + +| Version | Supported | +| --- | --- | +| Latest release | ✅ | +| Older releases | ❌ | + +## Scope + +In scope: the merger ingest and control-plane services, the GitHub App webhook +verification path, the policy and lane-assignment engines, released container +images, and the release/supply-chain pipeline in this repository. + +Because merger ingests untrusted pull-request content (diffs, file contents, +webhook payloads) and evaluates operator-supplied policy, reports that merger +executes attacker-controlled input, mishandles webhook signature verification, +reaches non-allowlisted network endpoints, leaks credentials, or escalates a +change out of its assigned merge lane are in scope and valued. + +Out of scope: vulnerabilities in third-party dependencies that have no impact on +merger as shipped, and findings that require the operator to have explicitly +disabled a documented safety control. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..9b8d543 --- /dev/null +++ b/action.yml @@ -0,0 +1,73 @@ +name: Devr Merger +description: Analyze a pull request diff, classify mutations, and assign a merge lane with Devr Merger +branding: + icon: git-merge + color: purple + +inputs: + base-ref: + description: Base ref to diff against (scans `git diff ...HEAD`) + required: false + default: main + config: + description: "Path to a merger config file or directory (default: auto-discover)" + required: false + default: "" + repo-root: + description: Repository root for content lookups and relative paths + required: false + default: "." + format: + description: Output format (text or json) + required: false + default: text + fail-on-lane: + description: Fail the workflow when the assigned lane is at or above this lane (GREEN|YELLOW|RED|BLACK). Empty means report only. + required: false + default: "" + version: + description: Devr Merger version to install + required: false + default: latest + +runs: + using: composite + steps: + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + + - name: Install Devr Merger + shell: bash + env: + VERSION: ${{ inputs.version }} + run: go install "github.com/devr-tools/merger/cmd/merger@${VERSION}" + + - name: Run Devr Merger scan + id: scan + shell: bash + env: + BASE_REF: ${{ inputs.base-ref }} + REPO_ROOT: ${{ inputs.repo-root }} + FORMAT: ${{ inputs.format }} + CONFIG: ${{ inputs.config }} + FAIL_ON_LANE: ${{ inputs.fail-on-lane }} + run: | + set +e + args=(scan -base-ref "$BASE_REF" -repo-root "$REPO_ROOT" -format "$FORMAT") + if [ -n "$CONFIG" ]; then + args+=(-config "$CONFIG") + fi + if [ -n "$FAIL_ON_LANE" ]; then + args+=(-fail-on-lane "$FAIL_ON_LANE") + fi + merger "${args[@]}" + status=$? + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Fail workflow on Devr Merger gate + if: ${{ steps.scan.outputs.exit_code != '0' }} + shell: bash + run: exit "${{ steps.scan.outputs.exit_code }}" diff --git a/cmd/merger-controlplane/main.go b/cmd/merger-controlplane/main.go index 47309d1..c7a5846 100644 --- a/cmd/merger-controlplane/main.go +++ b/cmd/merger-controlplane/main.go @@ -5,10 +5,10 @@ import ( "log" "os" - controlplaneapp "github.com/mergerhq/merger/internal/app/controlplane" - "github.com/mergerhq/merger/internal/bootstrap" - "github.com/mergerhq/merger/internal/config" - "github.com/mergerhq/merger/internal/telemetry" + controlplaneapp "github.com/devr-tools/merger/internal/app/controlplane" + "github.com/devr-tools/merger/internal/bootstrap" + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/telemetry" ) func main() { diff --git a/cmd/merger-ingest/main.go b/cmd/merger-ingest/main.go index 7451928..75fd08c 100644 --- a/cmd/merger-ingest/main.go +++ b/cmd/merger-ingest/main.go @@ -5,11 +5,11 @@ import ( "log" "os" - ingestapp "github.com/mergerhq/merger/internal/app/ingest" - "github.com/mergerhq/merger/internal/bootstrap" - "github.com/mergerhq/merger/internal/config" - "github.com/mergerhq/merger/internal/policy" - "github.com/mergerhq/merger/internal/telemetry" + ingestapp "github.com/devr-tools/merger/internal/app/ingest" + "github.com/devr-tools/merger/internal/bootstrap" + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/telemetry" ) func main() { diff --git a/cmd/merger/main.go b/cmd/merger/main.go new file mode 100644 index 0000000..ba5fef5 --- /dev/null +++ b/cmd/merger/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/devr-tools/merger/internal/cli" +) + +func main() { + err := cli.Run(context.Background(), os.Args[1:]) + if err == nil { + return + } + + var exit cli.ExitError + if errors.As(err, &exit) { + if exit.Message != "" { + fmt.Fprintln(os.Stderr, "merger: "+exit.Message) + } + os.Exit(exit.Code) + } + + fmt.Fprintln(os.Stderr, "merger: "+err.Error()) + os.Exit(1) +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..874c2b4 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,51 @@ +# MCP Server + +merger ships a minimal [Model Context Protocol](https://modelcontextprotocol.io) +server over stdio, so an agent can run the offline analysis pipeline as tools. + +```bash +merger mcp +``` + +The server speaks newline-delimited JSON-RPC 2.0 on stdin/stdout. Point an MCP +client at the `merger mcp` command as a stdio server. + +## Tools + +### `merger_scan` + +Analyze a unified diff and return the resulting Change Packet as JSON — +mutations, runtime impact, risk score, policy decision, and assigned merge lane. + +| Argument | Required | Description | +| --- | --- | --- | +| `diff` | yes | Raw unified diff (as produced by `git diff`) | +| `repo_root` | no | Root for content lookups and relative config paths (default `.`) | +| `repo` | no | Repository identifier as `owner/name` | +| `ref` | no | Revision the diff targets | +| `config` | no | Config file or directory (auto-discovered otherwise) | +| `policy` | no | Policy file (defaults to the config's policy path) | + +### `merger_validate` + +Validate a repository's merger config and policy, reporting the resolved config +path, policy rule count, and lane thresholds. + +| Argument | Required | Description | +| --- | --- | --- | +| `repo_root` | no | Root to resolve config and policy against (default `.`) | +| `config` | no | Config file or directory (auto-discovered otherwise) | +| `policy` | no | Policy file (defaults to the config's policy path) | + +## Example session + +```jsonl +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"merger_scan","arguments":{"diff":""}}} +``` + +Ordinary failures (bad config path, unparseable diff) come back as `isError` +tool results the model can read and self-correct from; only an unknown tool or a +handler panic is surfaced as a JSON-RPC protocol error. diff --git a/docs/release-automation.md b/docs/release-automation.md new file mode 100644 index 0000000..1e6617f --- /dev/null +++ b/docs/release-automation.md @@ -0,0 +1,95 @@ +# Release Automation + +merger follows the same devr-tools tool-family release shape as its siblings +(`codeguard`, `cleanr`, `szr`). Because merger is a control-plane **service** +rather than a single CLI, its primary released artifact is a multi-arch +container image; cross-platform binary archives are published alongside it. + +## Workflows + +### `.github/workflows/cd.yml` + +Branch-driven CD entry point. It currently: + +- runs on pushes to `develop`, `master`, and `main`; +- computes a prerelease tag automatically for `develop` (conventional-commit + bump: `feat` → minor, `BREAKING CHANGE`/`!` → major, otherwise patch); +- reuses `.github/workflows/release.yml` for prerelease packaging; +- runs Release Please on `main`/`master` to open and land release PRs. + +### `.github/workflows/release.yml` + +Reusable publisher invoked by CD or manual dispatch. It currently: + +- supports `workflow_dispatch` and `workflow_call`; +- normalizes and validates the tag before releasing; +- runs GoReleaser using `.goreleaser.yaml`; +- uploads release archives and `SHA256SUMS`; +- publishes multi-arch GHCR images (`linux/amd64`, `linux/arm64`) plus a + combined manifest for the release tag. + +## Release Please Files + +Stable-branch release preparation is driven by: + +- `.github/release-please-config.json` +- `.release-please-manifest.json` +- `CHANGELOG.md` +- `internal/version/version.go` (the `Number` var is updated in place via the + `x-release-please-version` marker and injected into release binaries by + GoReleaser) + +## Required Secrets + +- `GITHUB_TOKEN`: used by the release workflow for GitHub Releases and GHCR + publishing. +- `RELEASE_PLEASE_TOKEN`: used for Release Please PRs (a PAT or GitHub App token + so release PRs trigger required `pull_request` workflows). + +## Published Outputs + +Each tagged release currently publishes: + +- `darwin/amd64`, `darwin/arm64`, `linux/amd64`, `linux/arm64` archives, each + containing the `merger` CLI plus the `merger-ingest` and `merger-controlplane` + binaries; +- `SHA256SUMS`; +- `ghcr.io/devr-tools/merger:` (multi-arch: `-amd64`, `-arm64`). + +The image bundles both services; select one at runtime by overriding the +command (default is `merger-controlplane`): + +```bash +docker run ghcr.io/devr-tools/merger: merger-ingest +``` + +## Local Developer Commands + +```bash +make release-check # goreleaser check (validate .goreleaser.yaml) +make release-snapshot # build a local snapshot into dist/ without publishing +make commit # guided conventional-commit helper +``` + +## Homebrew + +`brew install devr-tools/tap/merger` installs the CLI. Stable releases open a +formula-update PR against `devr-tools/homebrew-tap` via the +`sync-homebrew-formula` job in `release.yml`; `homebrew-validation.yml` builds +and tests the formula from source on every PR. + +One-time setup: seed `Formula/merger.rb` in the tap repository before the first +automated sync (the sync job patches an existing formula; the validation +workflow generates one from scratch and works as a template). + +## GitHub Action + +`action.yml` publishes a composite action that installs the CLI and runs +`merger scan`; point it at a base ref and optionally set `fail-on-lane` to gate +a workflow. + +## Not Yet Wired + +- **npm/pip distribution** — deferred. Both distribute the CLI binary and can be + added the same way the sibling tools package it (`@devr-tools/merger`, + `devr-merger`). diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..06b1157 --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,58 @@ +# SDK Guide + +The `github.com/devr-tools/merger/pkg/merger` package exposes merger's offline +analysis pipeline as a library. It runs the same mutation, runtime-graph, risk, +policy, and lane engines the control-plane services use, with no services, +database, or event bus required. + +```bash +go get github.com/devr-tools/merger/pkg/merger +``` + +## Scan a diff + +```go +import ( + "context" + + "github.com/devr-tools/merger/pkg/merger" +) + +packet, err := merger.Scan(ctx, merger.ScanOptions{ + Diff: rawUnifiedDiff, // e.g. `git diff main...HEAD` + RepoRoot: ".", // for analyzers that read file bodies + Repo: merger.RepoRef{Owner: "acme", Name: "payments", FullName: "acme/payments"}, + Ref: "HEAD", + Lanes: merger.DefaultLanes(), + EnableCodeOwners: true, +}) +``` + +`Scan` returns a `*merger.ChangePacket` with `Mutations`, `Runtime`, +`RiskSummary`, `Decision`, `Reviewers`, `Evidence`, `Deployment`, and +`MergeLane` populated. The struct is JSON-serializable with the same shape the +services persist and publish. + +## Apply policy + +```go +policyConfig, err := merger.LoadPolicy(".merger/policies/default.yaml") +if err != nil { + return err +} + +packet, err := merger.Scan(ctx, merger.ScanOptions{ + Diff: rawUnifiedDiff, + Lanes: merger.DefaultLanes(), + Policy: policyConfig, +}) +``` + +A zero `Policy` evaluates no rules (every packet is approved by default). A zero +`Lanes` disables thresholds; most callers want `merger.DefaultLanes()`. + +## Types + +`pkg/merger` re-exports the domain types (`ChangePacket`, `Mutation`, +`MergeLane`, `RiskSummary`, …) so callers never import `internal/` packages +directly. diff --git a/go.mod b/go.mod index 641fbf6..28c0ec7 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/mergerhq/merger +module github.com/devr-tools/merger go 1.25.0 diff --git a/internal/app/controlplane/app.go b/internal/app/controlplane/app.go index 3ebd06f..952055e 100644 --- a/internal/app/controlplane/app.go +++ b/internal/app/controlplane/app.go @@ -6,13 +6,13 @@ import ( "net" "net/http" - "github.com/mergerhq/merger/internal/config" - "github.com/mergerhq/merger/internal/controlplane" - controlplanegrpc "github.com/mergerhq/merger/internal/controlplanegrpc" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/store" - "github.com/mergerhq/merger/internal/telemetry" - mergerv1 "github.com/mergerhq/merger/proto/merger/v1" + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/controlplane" + controlplanegrpc "github.com/devr-tools/merger/internal/controlplanegrpc" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/store" + "github.com/devr-tools/merger/internal/telemetry" + mergerv1 "github.com/devr-tools/merger/proto/merger/v1" "google.golang.org/grpc" ) diff --git a/internal/app/ingest/app.go b/internal/app/ingest/app.go index 8115d84..8dd381b 100644 --- a/internal/app/ingest/app.go +++ b/internal/app/ingest/app.go @@ -4,18 +4,18 @@ import ( "context" "net/http" - "github.com/mergerhq/merger/internal/checks" - "github.com/mergerhq/merger/internal/config" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/ingest" - "github.com/mergerhq/merger/internal/lanes" - "github.com/mergerhq/merger/internal/mutations" - "github.com/mergerhq/merger/internal/policy" - "github.com/mergerhq/merger/internal/risk" - "github.com/mergerhq/merger/internal/runtimegraph" - "github.com/mergerhq/merger/internal/store" - "github.com/mergerhq/merger/internal/telemetry" + "github.com/devr-tools/merger/internal/checks" + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/ingest" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/mutations" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/risk" + "github.com/devr-tools/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/store" + "github.com/devr-tools/merger/internal/telemetry" ) type App struct { diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index c5f4d3f..2a34b39 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -4,10 +4,10 @@ import ( "context" "fmt" - "github.com/mergerhq/merger/internal/config" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/store" + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/store" ) func BuildRepository(ctx context.Context, cfg config.Config) (store.Repository, error) { diff --git a/internal/checks/publisher.go b/internal/checks/publisher.go index a5aee3b..883f0b1 100644 --- a/internal/checks/publisher.go +++ b/internal/checks/publisher.go @@ -5,8 +5,8 @@ import ( "fmt" "strconv" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/github" ) type Publisher interface { diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..0eedd33 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,75 @@ +// Package cli implements the user-facing `merger` command. It is the local, +// installable face of the control plane: it discovers configuration, validates +// it, and runs the analysis pipeline offline against a diff (see internal/scan) +// without requiring the ingest/control-plane services or their dependencies. +package cli + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/devr-tools/merger/internal/version" +) + +// ExitError carries an explicit process exit code. Commands return it when the +// exit status is meaningful beyond success/failure (for example a scan that +// trips a merge-lane gate). +type ExitError struct { + Code int + Message string +} + +func (e ExitError) Error() string { + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("exit code %d", e.Code) +} + +// Run dispatches a merger subcommand. args excludes the program name. +func Run(ctx context.Context, args []string) error { + if len(args) == 0 { + printUsage(os.Stderr) + return ExitError{Code: 2} + } + + command, rest := args[0], args[1:] + switch command { + case "version", "--version", "-v": + return runVersion(rest) + case "init": + return runInit(rest) + case "validate": + return runValidate(rest) + case "scan": + return runScan(ctx, rest) + case "mcp": + return runMCP(ctx, rest) + case "help", "--help", "-h": + printUsage(os.Stdout) + return nil + default: + printUsage(os.Stderr) + return ExitError{Code: 2, Message: fmt.Sprintf("unknown command %q", command)} + } +} + +func printUsage(w io.Writer) { + fmt.Fprintf(w, `merger %s — mutation control plane CLI + +Usage: + merger [flags] + +Commands: + scan Analyze a diff and assign a merge lane (offline pipeline) + validate Validate a merger configuration and its policy file + init Scaffold a .merger/ configuration in the current repository + mcp Serve the analysis tools over MCP (stdio) + version Print the merger version + help Show this help + +Run "merger -h" for command-specific flags. +`, version.Number) +} diff --git a/internal/cli/init.go b/internal/cli/init.go new file mode 100644 index 0000000..038ea29 --- /dev/null +++ b/internal/cli/init.go @@ -0,0 +1,94 @@ +package cli + +import ( + "flag" + "fmt" + "os" + "path/filepath" +) + +const defaultConfigTemplate = `# merger configuration +# Docs: https://github.com/devr-tools/merger + +service: + ingest_address: ":8080" + controlplane_address: ":8081" + controlplane_grpc_address: ":9091" + +logging: + level: info + +policy: + path: .merger/policies/default.yaml + +lanes: + green_max: 20 + yellow_max: 55 + red_max: 85 + +runtime_graph: + enable_codeowners: true +` + +const defaultPolicyTemplate = `# merger policy rules +# Policies are evaluated against detected mutations, risk, and ownership. +policies: + - name: auth_requires_security_review + when: + mutations: + - auth_behavior_change + require: + reviewers: + - security + evidence: + - auth_integration_tests + deployment: + strategy: canary + requires_canary: true + action: + minimum_lane: RED +` + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func runInit(args []string) error { + fs := flag.NewFlagSet("init", flag.ContinueOnError) + dir := fs.String("dir", ".", "repository root to initialize") + force := fs.Bool("force", false, "overwrite existing files") + if err := fs.Parse(args); err != nil { + return err + } + + mergerDir := filepath.Join(*dir, ".merger") + policiesDir := filepath.Join(mergerDir, "policies") + if err := os.MkdirAll(policiesDir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", policiesDir, err) + } + + files := []struct { + path string + content string + }{ + {filepath.Join(mergerDir, "merger.yaml"), defaultConfigTemplate}, + {filepath.Join(policiesDir, "default.yaml"), defaultPolicyTemplate}, + } + + for _, file := range files { + if fileExists(file.path) && !*force { + return ExitError{Code: 1, Message: fmt.Sprintf("%s already exists (use -force to overwrite)", file.path)} + } + } + + for _, file := range files { + if err := os.WriteFile(file.path, []byte(file.content), 0o644); err != nil { + return fmt.Errorf("write %s: %w", file.path, err) + } + fmt.Printf("wrote %s\n", file.path) + } + + fmt.Println("initialized .merger/ — run `merger validate` to check it") + return nil +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go new file mode 100644 index 0000000..0fb913e --- /dev/null +++ b/internal/cli/mcp.go @@ -0,0 +1,16 @@ +package cli + +import ( + "context" + "flag" + + "github.com/devr-tools/merger/internal/mcpserver" +) + +func runMCP(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("mcp", flag.ContinueOnError) + if err := fs.Parse(args); err != nil { + return err + } + return mcpserver.Run(ctx) +} diff --git a/internal/cli/scan.go b/internal/cli/scan.go new file mode 100644 index 0000000..29080a8 --- /dev/null +++ b/internal/cli/scan.go @@ -0,0 +1,175 @@ +package cli + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/resolve" + "github.com/devr-tools/merger/internal/scan" +) + +var laneRank = map[domain.MergeLane]int{ + domain.MergeLaneGreen: 1, + domain.MergeLaneYellow: 2, + domain.MergeLaneRed: 3, + domain.MergeLaneBlack: 4, +} + +func runScan(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("scan", flag.ContinueOnError) + root := fs.String("repo-root", ".", "repository root for content lookups and relative paths") + configPath := fs.String("config", "", "path to a merger config file or directory (default: auto-discover)") + policyPath := fs.String("policy", "", "path to a policy file (default: config's policy.path)") + diffPath := fs.String("diff", "", "unified diff file to scan, or '-' for stdin") + baseRef := fs.String("base-ref", "", "git base ref; scans `git diff ...HEAD` when -diff is unset") + format := fs.String("format", "text", "output format: text or json") + failOn := fs.String("fail-on-lane", "", "exit non-zero when the assigned lane is at or above this lane (GREEN|YELLOW|RED|BLACK)") + repo := fs.String("repo", "", "repository identifier as owner/name (optional)") + ref := fs.String("ref", "", "revision the diff targets (optional)") + if err := fs.Parse(args); err != nil { + return err + } + + if *format != "text" && *format != "json" { + return ExitError{Code: 2, Message: fmt.Sprintf("unknown format %q (want text or json)", *format)} + } + + failLane, err := parseFailLane(*failOn) + if err != nil { + return ExitError{Code: 2, Message: err.Error()} + } + + rawDiff, err := readDiff(*root, *diffPath, *baseRef) + if err != nil { + return err + } + + options, policyFound, err := resolve.ScanOptions(*root, *configPath, *policyPath, *repo, *ref, rawDiff) + if err != nil { + return err + } + if !policyFound && *format == "text" { + fmt.Fprintln(os.Stderr, "merger: no policy file found — scanning with an empty rule set") + } + + packet, err := scan.Run(ctx, options) + if err != nil { + return err + } + + if *format == "json" { + if err := writeJSON(os.Stdout, packet); err != nil { + return err + } + } else { + writeTextReport(os.Stdout, packet) + } + + if failLane != "" && laneRank[packet.MergeLane] >= laneRank[failLane] { + return ExitError{Code: 2, Message: fmt.Sprintf("merge lane %s is at or above the -fail-on-lane threshold %s", packet.MergeLane, failLane)} + } + return nil +} + +func parseFailLane(raw string) (domain.MergeLane, error) { + if raw == "" { + return "", nil + } + lane := domain.MergeLane(strings.ToUpper(raw)) + if _, ok := laneRank[lane]; !ok { + return "", fmt.Errorf("invalid -fail-on-lane %q (want GREEN, YELLOW, RED, or BLACK)", raw) + } + return lane, nil +} + +func readDiff(root, diffPath, baseRef string) (string, error) { + switch { + case diffPath == "-": + data, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read diff from stdin: %w", err) + } + return string(data), nil + case diffPath != "": + data, err := os.ReadFile(diffPath) + if err != nil { + return "", fmt.Errorf("read diff file: %w", err) + } + return string(data), nil + case baseRef != "": + cmd := exec.Command("git", "-C", root, "diff", "--no-color", baseRef+"...HEAD") + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("git diff %s...HEAD: %w", baseRef, err) + } + return string(out), nil + default: + return "", ExitError{Code: 2, Message: "provide a diff with -diff or a range with -base-ref "} + } +} + +func writeJSON(w io.Writer, payload any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(payload); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil +} + +func writeTextReport(w io.Writer, packet *domain.ChangePacket) { + repo := packet.Repo.FullName + if repo == "" { + repo = "-" + } + fmt.Fprintf(w, "merger scan\n") + fmt.Fprintf(w, "repo: %s\n", repo) + fmt.Fprintf(w, "files: %d changed\n", len(packet.Files)) + + fmt.Fprintf(w, "mutations: %d\n", len(packet.Mutations)) + for _, mutation := range packet.Mutations { + fmt.Fprintf(w, " - [%s] %s: %s (%s)\n", mutation.Severity, mutation.Kind, mutation.Title, mutation.Detector) + } + + fmt.Fprintf(w, "risk: score %d (%s)\n", packet.RiskSummary.Score, packet.RiskSummary.Severity) + fmt.Fprintf(w, "runtime: blast radius %s, criticality %s\n", packet.Runtime.BlastRadius, packet.Runtime.Criticality) + fmt.Fprintf(w, "decision: %s\n", packet.Decision.Status) + + if len(packet.Reviewers) > 0 { + labels := make([]string, 0, len(packet.Reviewers)) + for _, reviewer := range packet.Reviewers { + label := reviewer.Team + if reviewer.Mandatory { + label += " (mandatory)" + } + labels = append(labels, label) + } + fmt.Fprintf(w, "reviewers: %s\n", strings.Join(labels, ", ")) + } + + if len(packet.Evidence) > 0 { + labels := make([]string, 0, len(packet.Evidence)) + for _, evidence := range packet.Evidence { + label := string(evidence.Type) + if evidence.Required { + label += " (required)" + } + labels = append(labels, label) + } + fmt.Fprintf(w, "evidence: %s\n", strings.Join(labels, ", ")) + } + + deployment := string(packet.Deployment.Strategy) + if packet.Deployment.RequiresCanary { + deployment += " (canary required)" + } + fmt.Fprintf(w, "deployment: %s\n", deployment) + fmt.Fprintf(w, "merge lane: %s\n", packet.MergeLane) +} diff --git a/internal/cli/validate.go b/internal/cli/validate.go new file mode 100644 index 0000000..50b459b --- /dev/null +++ b/internal/cli/validate.go @@ -0,0 +1,62 @@ +package cli + +import ( + "flag" + "fmt" + "os" + + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/resolve" +) + +func runValidate(args []string) error { + fs := flag.NewFlagSet("validate", flag.ContinueOnError) + root := fs.String("repo-root", ".", "repository root to resolve relative paths against") + configPath := fs.String("config", "", "path to a merger config file or directory (default: auto-discover)") + policyPath := fs.String("policy", "", "path to a policy file (default: config's policy.path)") + if err := fs.Parse(args); err != nil { + return err + } + + cfg, resolvedConfig, err := resolve.Config(*root, *configPath) + if err != nil { + return err + } + + if resolvedConfig == "" { + fmt.Println("config: (none found — validated defaults)") + } else { + fmt.Printf("config: %s\n", resolvedConfig) + } + + // A policy file is required for a configuration to be considered valid: + // the whole point of merger is to evaluate policy against mutations. + policyConfig, resolvedPolicy, found, err := resolve.Policy(*root, *policyPath, cfg) + if err != nil { + return err + } + if !found { + wanted := *policyPath + if wanted == "" { + wanted = cfg.Policy.Path + } + return ExitError{Code: 1, Message: fmt.Sprintf("policy file not found (looked for %q); pass -policy or set policy.path", wanted)} + } + + if err := validateLanes(cfg); err != nil { + return ExitError{Code: 1, Message: err.Error()} + } + + fmt.Printf("policy: %s (%d rule(s))\n", resolvedPolicy, len(policyConfig.Policies)) + fmt.Printf("lanes: green<=%d yellow<=%d red<=%d\n", cfg.Lanes.GreenMax, cfg.Lanes.YellowMax, cfg.Lanes.RedMax) + fmt.Fprintln(os.Stdout, "ok") + return nil +} + +func validateLanes(cfg config.Config) error { + l := cfg.Lanes + if !(l.GreenMax < l.YellowMax && l.YellowMax < l.RedMax) { + return fmt.Errorf("lane thresholds must be strictly increasing: green(%d) < yellow(%d) < red(%d)", l.GreenMax, l.YellowMax, l.RedMax) + } + return nil +} diff --git a/internal/cli/version.go b/internal/cli/version.go new file mode 100644 index 0000000..358f38f --- /dev/null +++ b/internal/cli/version.go @@ -0,0 +1,17 @@ +package cli + +import ( + "flag" + "fmt" + + "github.com/devr-tools/merger/internal/version" +) + +func runVersion(args []string) error { + fs := flag.NewFlagSet("version", flag.ContinueOnError) + if err := fs.Parse(args); err != nil { + return err + } + fmt.Printf("merger v%s\n", version.Number) + return nil +} diff --git a/internal/controlplane/http.go b/internal/controlplane/http.go index 07f39e8..030f32d 100644 --- a/internal/controlplane/http.go +++ b/internal/controlplane/http.go @@ -7,8 +7,8 @@ import ( "strconv" "strings" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/store" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/store" ) type HTTPHandler struct { diff --git a/internal/controlplane/service.go b/internal/controlplane/service.go index 6d17b2d..23e6253 100644 --- a/internal/controlplane/service.go +++ b/internal/controlplane/service.go @@ -4,8 +4,8 @@ import ( "context" "time" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/store" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/store" ) type Service struct { diff --git a/internal/controlplanegrpc/mapping.go b/internal/controlplanegrpc/mapping.go index d6e5ae3..3c04e7f 100644 --- a/internal/controlplanegrpc/mapping.go +++ b/internal/controlplanegrpc/mapping.go @@ -3,9 +3,9 @@ package controlplanegrpc import ( "time" - "github.com/mergerhq/merger/internal/controlplane" - "github.com/mergerhq/merger/internal/domain" - mergerv1 "github.com/mergerhq/merger/proto/merger/v1" + "github.com/devr-tools/merger/internal/controlplane" + "github.com/devr-tools/merger/internal/domain" + mergerv1 "github.com/devr-tools/merger/proto/merger/v1" ) func toGetChangePacketResponse(view controlplane.ChangePacketView) *mergerv1.GetChangePacketResponse { diff --git a/internal/controlplanegrpc/server.go b/internal/controlplanegrpc/server.go index da47ca1..ec36b53 100644 --- a/internal/controlplanegrpc/server.go +++ b/internal/controlplanegrpc/server.go @@ -4,9 +4,9 @@ import ( "context" "errors" - "github.com/mergerhq/merger/internal/controlplane" - "github.com/mergerhq/merger/internal/store" - mergerv1 "github.com/mergerhq/merger/proto/merger/v1" + "github.com/devr-tools/merger/internal/controlplane" + "github.com/devr-tools/merger/internal/store" + mergerv1 "github.com/devr-tools/merger/proto/merger/v1" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) diff --git a/internal/events/bus.go b/internal/events/bus.go index 13d0e88..8cae29c 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -5,7 +5,7 @@ import ( "errors" "sync" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/pkg/identity" ) type EventType string diff --git a/internal/events/factory.go b/internal/events/factory.go index fc0ace8..dfa693b 100644 --- a/internal/events/factory.go +++ b/internal/events/factory.go @@ -3,7 +3,7 @@ package events import ( "fmt" - "github.com/mergerhq/merger/internal/config" + "github.com/devr-tools/merger/internal/config" ) func NewBusFromConfig(cfg config.EventsConfig) (Bus, error) { diff --git a/internal/ingest/change_packet.go b/internal/ingest/change_packet.go index f317d79..dd74757 100644 --- a/internal/ingest/change_packet.go +++ b/internal/ingest/change_packet.go @@ -6,12 +6,12 @@ import ( "strings" "time" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/telemetry" - "github.com/mergerhq/merger/pkg/diff" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/telemetry" + "github.com/devr-tools/merger/pkg/diff" + "github.com/devr-tools/merger/pkg/identity" ) func (p *Processor) buildChangePacket( diff --git a/internal/ingest/content_loader.go b/internal/ingest/content_loader.go index f5faa85..69ecb99 100644 --- a/internal/ingest/content_loader.go +++ b/internal/ingest/content_loader.go @@ -3,7 +3,7 @@ package ingest import ( "context" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/github" ) type githubContentLoader struct { diff --git a/internal/ingest/event_publishers.go b/internal/ingest/event_publishers.go index 872a985..e0961f2 100644 --- a/internal/ingest/event_publishers.go +++ b/internal/ingest/event_publishers.go @@ -3,8 +3,8 @@ package ingest import ( "context" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" ) func (p *Processor) publishPROpened(ctx context.Context, fullName string, prNumber int, action string) error { diff --git a/internal/ingest/files.go b/internal/ingest/files.go index 4898b70..d92507d 100644 --- a/internal/ingest/files.go +++ b/internal/ingest/files.go @@ -4,8 +4,8 @@ import ( "path/filepath" "strings" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/diff" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/diff" ) func mapChangedFiles(files []diff.File) []domain.ChangedFile { diff --git a/internal/ingest/github_helpers.go b/internal/ingest/github_helpers.go index 62271de..093b770 100644 --- a/internal/ingest/github_helpers.go +++ b/internal/ingest/github_helpers.go @@ -3,7 +3,7 @@ package ingest import ( "fmt" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/github" ) func bindInstallation(service github.Service, installationID int64) github.Service { diff --git a/internal/ingest/processor.go b/internal/ingest/processor.go index 2487ef6..17fda4e 100644 --- a/internal/ingest/processor.go +++ b/internal/ingest/processor.go @@ -3,17 +3,17 @@ package ingest import ( "context" - "github.com/mergerhq/merger/internal/checks" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/lanes" - "github.com/mergerhq/merger/internal/mutations" - "github.com/mergerhq/merger/internal/policy" - "github.com/mergerhq/merger/internal/risk" - "github.com/mergerhq/merger/internal/runtimegraph" - "github.com/mergerhq/merger/internal/store" - "github.com/mergerhq/merger/internal/telemetry" + "github.com/devr-tools/merger/internal/checks" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/mutations" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/risk" + "github.com/devr-tools/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/store" + "github.com/devr-tools/merger/internal/telemetry" ) type Processor struct { diff --git a/internal/ingest/stages.go b/internal/ingest/stages.go index 4e496aa..faba220 100644 --- a/internal/ingest/stages.go +++ b/internal/ingest/stages.go @@ -4,10 +4,10 @@ import ( "context" "time" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/mutations" - "github.com/mergerhq/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/mutations" + "github.com/devr-tools/merger/internal/runtimegraph" ) func (p *Processor) enrichMutations(ctx context.Context, packet *domain.ChangePacket, service github.Service, repoOwner, repoName string) error { diff --git a/internal/ingest/webhook_handler.go b/internal/ingest/webhook_handler.go index 6d7552e..90df3dc 100644 --- a/internal/ingest/webhook_handler.go +++ b/internal/ingest/webhook_handler.go @@ -4,9 +4,9 @@ import ( "encoding/json" "net/http" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/telemetry" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/telemetry" + "github.com/devr-tools/merger/pkg/identity" ) type WebhookHandler struct { diff --git a/internal/ingest/workflow.go b/internal/ingest/workflow.go index 3287534..5e0ec0d 100644 --- a/internal/ingest/workflow.go +++ b/internal/ingest/workflow.go @@ -3,8 +3,8 @@ package ingest import ( "context" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/github" ) func (p *Processor) processPROpened(ctx context.Context, payload github.PullRequestWebhookPayload) (*domain.ChangePacket, error) { diff --git a/internal/lanes/assigner.go b/internal/lanes/assigner.go index abfa050..4626d0b 100644 --- a/internal/lanes/assigner.go +++ b/internal/lanes/assigner.go @@ -3,7 +3,7 @@ package lanes import ( "context" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type Config struct { diff --git a/internal/mcpserver/protocol.go b/internal/mcpserver/protocol.go new file mode 100644 index 0000000..88c1747 --- /dev/null +++ b/internal/mcpserver/protocol.go @@ -0,0 +1,83 @@ +package mcpserver + +import ( + "encoding/json" + "fmt" + "io" +) + +const protocolVersion = "2025-06-18" + +const ( + jsonRPCParseError = -32700 + jsonRPCInvalidRequest = -32600 + jsonRPCMethodNotFound = -32601 + jsonRPCInvalidParams = -32602 + jsonRPCInternalError = -32603 + jsonRPCServerError = -32000 +) + +// The request/response id is kept as raw JSON so it round-trips exactly: +// decoding into `any` and re-encoding with omitempty dropped falsy ids +// (id: 0, id: ""), leaving clients unable to correlate the response. A nil +// RawMessage marshals as null, which is what JSON-RPC requires on responses +// to unparseable requests. +type requestEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` +} + +type responseEnvelope struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *errorEnvelope `json:"error,omitempty"` +} + +type errorEnvelope struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +type initializeParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities map[string]any `json:"capabilities"` + ClientInfo map[string]any `json:"clientInfo"` +} + +type toolCallParams struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +func successResponse(id json.RawMessage, result any) *responseEnvelope { + return &responseEnvelope{ + JSONRPC: "2.0", + ID: id, + Result: result, + } +} + +func errorResponse(id json.RawMessage, code int, message string, data any) *responseEnvelope { + return &responseEnvelope{ + JSONRPC: "2.0", + ID: id, + Error: &errorEnvelope{ + Code: code, + Message: message, + Data: data, + }, + } +} + +func writeMessage(w io.Writer, msg *responseEnvelope) error { + data, err := json.Marshal(msg) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "%s\n", data) + return err +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..4c2d305 --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,182 @@ +// Package mcpserver implements a minimal Model Context Protocol server over +// stdio (newline-delimited JSON-RPC 2.0). It exposes merger's offline analysis +// as agent tools (merger_scan, merger_validate) so an agent can classify a diff +// and preview its merge lane without the control-plane services. +package mcpserver + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/devr-tools/merger/internal/version" +) + +type Server struct { + initialized bool + tools []Definition +} + +func New() *Server { + return &Server{tools: Definitions()} +} + +func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error { + reader := bufio.NewReader(in) + for { + // ReadBytes can return data together with io.EOF when the final request + // has no trailing newline (common for one-shot piped clients); process + // the line before acting on the error so that request is answered + // instead of silently dropped. + line, readErr := reader.ReadBytes('\n') + line = bytes.TrimSpace(line) + if len(line) > 0 { + if resp := s.HandleLine(ctx, line); resp != nil { + if err := writeMessage(out, resp); err != nil { + return err + } + } + } + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + } +} + +func (s *Server) HandleLine(ctx context.Context, line []byte) *responseEnvelope { + var req requestEnvelope + if err := json.Unmarshal(line, &req); err != nil { + return errorResponse(nil, jsonRPCParseError, "Parse error", nil) + } + return s.HandleRequest(ctx, req) +} + +func (s *Server) HandleRequest(ctx context.Context, req requestEnvelope) *responseEnvelope { + if req.JSONRPC != "2.0" { + return errorResponse(req.ID, jsonRPCInvalidRequest, "invalid JSON-RPC version", nil) + } + + switch req.Method { + case "initialize": + return s.handleInitialize(req) + case "notifications/initialized": + s.initialized = true + return nil + case "ping": + return successResponse(req.ID, map[string]any{}) + case "tools/list": + if errResp := s.requireInitialized(req.ID); errResp != nil { + return errResp + } + return successResponse(req.ID, map[string]any{"tools": s.tools}) + case "tools/call": + if errResp := s.requireInitialized(req.ID); errResp != nil { + return errResp + } + return s.handleToolCall(ctx, req) + default: + if strings.HasPrefix(req.Method, "notifications/") { + return nil + } + return errorResponse(req.ID, jsonRPCMethodNotFound, fmt.Sprintf("method not found: %s", req.Method), nil) + } +} + +func (s *Server) handleInitialize(req requestEnvelope) *responseEnvelope { + var params initializeParams + if len(req.Params) > 0 { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return errorResponse(req.ID, jsonRPCInvalidParams, "invalid initialize params", nil) + } + } + + negotiatedVersion := protocolVersion + if strings.TrimSpace(params.ProtocolVersion) == protocolVersion { + negotiatedVersion = params.ProtocolVersion + } + + return successResponse(req.ID, map[string]any{ + "protocolVersion": negotiatedVersion, + "capabilities": map[string]any{ + "tools": map[string]any{ + "listChanged": false, + }, + }, + "serverInfo": map[string]any{ + "name": "merger", + "title": "merger MCP Server", + "version": version.Number, + }, + "instructions": "Use merger_scan to analyze a unified diff and get its Change Packet and merge lane, and merger_validate to check a repository's merger config and policy.", + }) +} + +func (s *Server) handleToolCall(ctx context.Context, req requestEnvelope) *responseEnvelope { + var params toolCallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return errorResponse(req.ID, jsonRPCInvalidParams, "invalid tool call params", nil) + } + if strings.TrimSpace(params.Name) == "" { + return errorResponse(req.ID, jsonRPCInvalidParams, "tool name is required", nil) + } + if params.Arguments == nil { + params.Arguments = map[string]any{} + } + + result, err := safeToolCall(ctx, params.Name, params.Arguments) + if err != nil { + // Per the MCP spec: an unknown tool is a protocol-level invalid-params + // error and a panic is a genuine server fault, but ordinary execution + // failures (bad config path, invalid arguments) are returned as isError + // results so the calling model can read them and self-correct instead + // of the client treating the server as broken. + switch { + case errors.Is(err, ErrUnknownTool): + return errorResponse(req.ID, jsonRPCInvalidParams, err.Error(), nil) + case errors.Is(err, errToolPanicked): + return errorResponse(req.ID, jsonRPCInternalError, err.Error(), nil) + default: + return successResponse(req.ID, Result{ + Content: []Content{{Type: "text", Text: err.Error()}}, + IsError: true, + }) + } + } + return successResponse(req.ID, result) +} + +// errToolPanicked marks a contained tool-handler panic so it surfaces as an +// internal JSON-RPC error rather than an isError tool result. +var errToolPanicked = errors.New("tool handler panicked") + +// safeToolCall contains a panicking tool handler: the server speaks stdio, so +// an uncaught panic would kill the whole process and every session with it. +func safeToolCall(ctx context.Context, name string, args map[string]any) (result Result, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%w: tool %s panicked: %v", errToolPanicked, name, r) + } + }() + return Call(ctx, name, args) +} + +func (s *Server) requireInitialized(id json.RawMessage) *responseEnvelope { + if s.initialized { + return nil + } + return errorResponse(id, jsonRPCServerError, "server not initialized", nil) +} + +// Run serves the MCP protocol over stdin/stdout until EOF. +func Run(ctx context.Context) error { + return New().Serve(ctx, os.Stdin, os.Stdout) +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000..6aa16b7 --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,153 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/devr-tools/merger/internal/resolve" + "github.com/devr-tools/merger/internal/scan" +) + +// ErrUnknownTool is returned by Call for a tool name the server does not expose. +var ErrUnknownTool = errors.New("unknown tool") + +// Content is a single MCP tool-result content block. +type Content struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// Result is an MCP tool-call result. +type Result struct { + Content []Content `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +// Definition describes a tool for tools/list. +type Definition struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +// Definitions returns the tools this server exposes. +func Definitions() []Definition { + return []Definition{ + { + Name: "merger_scan", + Description: "Analyze a unified diff and return the resulting Change Packet as JSON: detected mutations, runtime impact, risk score, policy decision, and assigned merge lane.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "diff": map[string]any{"type": "string", "description": "Raw unified diff to analyze (as produced by `git diff`)."}, + "repo_root": map[string]any{"type": "string", "description": "Repository root for file-content lookups and relative config paths. Defaults to the current directory."}, + "repo": map[string]any{"type": "string", "description": "Repository identifier as owner/name (optional)."}, + "ref": map[string]any{"type": "string", "description": "Revision the diff targets (optional)."}, + "config": map[string]any{"type": "string", "description": "Path to a merger config file or directory (optional; auto-discovered otherwise)."}, + "policy": map[string]any{"type": "string", "description": "Path to a policy file (optional; defaults to the config's policy path)."}, + }, + "required": []string{"diff"}, + }, + }, + { + Name: "merger_validate", + Description: "Validate a merger configuration and its policy file, reporting the resolved config path, policy rule count, and lane thresholds.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "repo_root": map[string]any{"type": "string", "description": "Repository root to resolve config and policy against. Defaults to the current directory."}, + "config": map[string]any{"type": "string", "description": "Path to a merger config file or directory (optional; auto-discovered otherwise)."}, + "policy": map[string]any{"type": "string", "description": "Path to a policy file (optional; defaults to the config's policy path)."}, + }, + }, + }, + } +} + +// Call dispatches a tool by name. +func Call(ctx context.Context, name string, args map[string]any) (Result, error) { + switch name { + case "merger_scan": + return toolScan(ctx, args) + case "merger_validate": + return toolValidate(args) + default: + return Result{}, fmt.Errorf("%w: %s", ErrUnknownTool, name) + } +} + +func toolScan(ctx context.Context, args map[string]any) (Result, error) { + root := stringArg(args, "repo_root") + if root == "" { + root = "." + } + + options, _, err := resolve.ScanOptions( + root, + stringArg(args, "config"), + stringArg(args, "policy"), + stringArg(args, "repo"), + stringArg(args, "ref"), + stringArg(args, "diff"), + ) + if err != nil { + return Result{}, err + } + + packet, err := scan.Run(ctx, options) + if err != nil { + return Result{}, err + } + + data, err := json.MarshalIndent(packet, "", " ") + if err != nil { + return Result{}, err + } + return Result{Content: []Content{{Type: "text", Text: string(data)}}}, nil +} + +func toolValidate(args map[string]any) (Result, error) { + root := stringArg(args, "repo_root") + if root == "" { + root = "." + } + + cfg, cfgPath, err := resolve.Config(root, stringArg(args, "config")) + if err != nil { + return Result{}, err + } + policyConfig, policyPath, found, err := resolve.Policy(root, stringArg(args, "policy"), cfg) + if err != nil { + return Result{}, err + } + if !found { + return Result{ + Content: []Content{{Type: "text", Text: "no policy file found; set policy.path in the config or pass a policy path"}}, + IsError: true, + }, nil + } + + var b strings.Builder + if cfgPath == "" { + b.WriteString("config: (none found — using defaults)\n") + } else { + fmt.Fprintf(&b, "config: %s\n", cfgPath) + } + fmt.Fprintf(&b, "policy: %s (%d rule(s))\n", policyPath, len(policyConfig.Policies)) + fmt.Fprintf(&b, "lanes: green<=%d yellow<=%d red<=%d\n", cfg.Lanes.GreenMax, cfg.Lanes.YellowMax, cfg.Lanes.RedMax) + b.WriteString("ok") + + return Result{Content: []Content{{Type: "text", Text: b.String()}}}, nil +} + +func stringArg(args map[string]any, key string) string { + if raw, ok := args[key]; ok { + if s, ok := raw.(string); ok { + return s + } + } + return "" +} diff --git a/internal/mutations/analyzer_contract.go b/internal/mutations/analyzer_contract.go index f1101ea..50c6965 100644 --- a/internal/mutations/analyzer_contract.go +++ b/internal/mutations/analyzer_contract.go @@ -6,8 +6,8 @@ import ( "gopkg.in/yaml.v3" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) func (openAPIAnalyzer) Name() string { return "openapi-analyzer" } diff --git a/internal/mutations/analyzer_go.go b/internal/mutations/analyzer_go.go index 4ac5556..5391d88 100644 --- a/internal/mutations/analyzer_go.go +++ b/internal/mutations/analyzer_go.go @@ -8,8 +8,8 @@ import ( "path/filepath" "strings" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) func (goASTAnalyzer) Name() string { return "go-ast-analyzer" } diff --git a/internal/mutations/analyzer_manifest.go b/internal/mutations/analyzer_manifest.go index aadd3b2..fa88ad9 100644 --- a/internal/mutations/analyzer_manifest.go +++ b/internal/mutations/analyzer_manifest.go @@ -5,8 +5,8 @@ import ( "path/filepath" "strings" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) func (manifestAnalyzer) Name() string { return "manifest-analyzer" } diff --git a/internal/mutations/analyzer_runtime_config.go b/internal/mutations/analyzer_runtime_config.go index 2451153..f054067 100644 --- a/internal/mutations/analyzer_runtime_config.go +++ b/internal/mutations/analyzer_runtime_config.go @@ -7,8 +7,8 @@ import ( "gopkg.in/yaml.v3" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) func (runtimeConfigAnalyzer) Name() string { return "runtime-config-analyzer" } diff --git a/internal/mutations/analyzer_sql.go b/internal/mutations/analyzer_sql.go index 46988a7..f3c88ef 100644 --- a/internal/mutations/analyzer_sql.go +++ b/internal/mutations/analyzer_sql.go @@ -6,8 +6,8 @@ import ( "path/filepath" "strings" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) func (sqlDDLAnalyzer) Name() string { return "sql-ddl-analyzer" } diff --git a/internal/mutations/engine.go b/internal/mutations/engine.go index fdb045b..995cfa3 100644 --- a/internal/mutations/engine.go +++ b/internal/mutations/engine.go @@ -3,8 +3,8 @@ package mutations import ( "context" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) type SignalExtractor interface { diff --git a/internal/mutations/engine_helpers.go b/internal/mutations/engine_helpers.go index 9fd25bd..84280ee 100644 --- a/internal/mutations/engine_helpers.go +++ b/internal/mutations/engine_helpers.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) func (e *RuleBasedEngine) extractSignals(ctx context.Context, file domain.ChangedFile) ([]domain.MutationSignal, error) { diff --git a/internal/mutations/extractors.go b/internal/mutations/extractors.go index f5731e0..1911aae 100644 --- a/internal/mutations/extractors.go +++ b/internal/mutations/extractors.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type goPatchSignalExtractor struct{} diff --git a/internal/mutations/mutation_index.go b/internal/mutations/mutation_index.go index e6cc5ac..29d87e6 100644 --- a/internal/mutations/mutation_index.go +++ b/internal/mutations/mutation_index.go @@ -1,8 +1,8 @@ package mutations import ( - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/pkg/identity" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/pkg/identity" ) func addMutation(index map[domain.MutationKind]*domain.Mutation, candidate domain.Mutation) { diff --git a/internal/mutations/rule_catalog.go b/internal/mutations/rule_catalog.go index 2012647..c9aaa6c 100644 --- a/internal/mutations/rule_catalog.go +++ b/internal/mutations/rule_catalog.go @@ -1,6 +1,6 @@ package mutations -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" type Rule struct { Name string diff --git a/internal/mutations/rule_matchers.go b/internal/mutations/rule_matchers.go index 9ca04a4..1545fb3 100644 --- a/internal/mutations/rule_matchers.go +++ b/internal/mutations/rule_matchers.go @@ -4,7 +4,7 @@ import ( "path" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) func (r Rule) Matches(filePath string, signals []domain.MutationSignal) bool { diff --git a/internal/policy/engine.go b/internal/policy/engine.go index f1f45a5..07af19a 100644 --- a/internal/policy/engine.go +++ b/internal/policy/engine.go @@ -4,7 +4,7 @@ import ( "context" "time" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type Evaluation struct { diff --git a/internal/policy/evaluation.go b/internal/policy/evaluation.go index fe1c281..aa74f29 100644 --- a/internal/policy/evaluation.go +++ b/internal/policy/evaluation.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) func applyRule(evaluation *Evaluation, rule RuleConfig) { diff --git a/internal/policy/helpers.go b/internal/policy/helpers.go index b868108..9fe29ba 100644 --- a/internal/policy/helpers.go +++ b/internal/policy/helpers.go @@ -3,7 +3,7 @@ package policy import ( "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) func appendUniqueReviewer(values []domain.ReviewerRequirement, candidate domain.ReviewerRequirement) []domain.ReviewerRequirement { diff --git a/internal/policy/matchers.go b/internal/policy/matchers.go index 6366e33..198e1a5 100644 --- a/internal/policy/matchers.go +++ b/internal/policy/matchers.go @@ -3,7 +3,7 @@ package policy import ( "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) func matchesRule(rule RuleConfig, packet domain.ChangePacket) bool { diff --git a/internal/policy/model.go b/internal/policy/model.go index 90964c7..c5a4233 100644 --- a/internal/policy/model.go +++ b/internal/policy/model.go @@ -1,6 +1,6 @@ package policy -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" type Config struct { Policies []RuleConfig `yaml:"policies"` diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go new file mode 100644 index 0000000..51e612a --- /dev/null +++ b/internal/resolve/resolve.go @@ -0,0 +1,163 @@ +// Package resolve turns a repository root and optional config/policy paths into +// a ready-to-run scan.Options. It centralizes merger's config auto-discovery so +// the CLI and the MCP server resolve configuration identically. +package resolve + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/scan" +) + +// configCandidates lists the config locations merger auto-discovers, in +// priority order, relative to the repository root. This mirrors the devr-tools +// convention (a root file, then a tool-named dot directory). +var configCandidates = []string{ + "merger.yaml", + "merger.yml", + "merger.json", + ".merger/merger.yaml", + ".merger/merger.yml", + ".merger/merger.json", + ".merger/config.yaml", + ".merger/config.yml", + ".merger/config.json", +} + +var configDirCandidates = []string{ + "merger.yaml", "merger.yml", "merger.json", + "config.yaml", "config.yml", "config.json", +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +// discoverConfigPath resolves the config file to use. An explicit path is +// honored (and, if it is a directory, searched for a merger config). Otherwise +// the standard candidates under root are tried. An empty return with a nil +// error means no config was found and defaults should be used. +func discoverConfigPath(root, explicit string) (string, error) { + if explicit != "" { + info, err := os.Stat(explicit) + if err != nil { + return "", err + } + if !info.IsDir() { + return explicit, nil + } + for _, name := range configDirCandidates { + candidate := filepath.Join(explicit, name) + if fileExists(candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("no merger config (merger.* or config.*) found in %s", explicit) + } + + for _, candidate := range configCandidates { + path := filepath.Join(root, candidate) + if fileExists(path) { + return path, nil + } + } + return "", nil +} + +// Config discovers and loads configuration, falling back to defaults when none +// is found. It returns the resolved path ("" when defaults were used). +func Config(root, explicit string) (config.Config, string, error) { + path, err := discoverConfigPath(root, explicit) + if err != nil { + return config.Config{}, "", err + } + if path == "" { + return config.Defaults(), "", nil + } + cfg, err := config.Load(path) + if err != nil { + return config.Config{}, "", fmt.Errorf("load config %s: %w", path, err) + } + return cfg, path, nil +} + +// PolicyPath determines the policy file for a run. An explicit flag wins; +// otherwise the config's policy path is resolved relative to root. The returned +// path is empty when no policy file could be located. +func PolicyPath(root, explicit string, cfg config.Config) string { + if explicit != "" { + return explicit + } + if cfg.Policy.Path == "" { + return "" + } + path := cfg.Policy.Path + if !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + if fileExists(path) { + return path + } + return "" +} + +// Policy loads a policy file when one is resolvable. A missing policy is not an +// error: it yields an empty rule set and found=false. +func Policy(root, explicit string, cfg config.Config) (policy.Config, string, bool, error) { + path := PolicyPath(root, explicit, cfg) + if path == "" { + return policy.Config{}, "", false, nil + } + policyConfig, err := policy.LoadConfig(path) + if err != nil { + return policy.Config{}, path, false, fmt.Errorf("load policy %s: %w", path, err) + } + return policyConfig, path, true, nil +} + +// RepoRef parses an "owner/name" identifier into a domain.RepoRef. +func RepoRef(raw string) domain.RepoRef { + if raw == "" { + return domain.RepoRef{} + } + owner, name, found := strings.Cut(raw, "/") + if !found { + return domain.RepoRef{Name: raw, FullName: raw} + } + return domain.RepoRef{Owner: owner, Name: name, FullName: raw} +} + +// ScanOptions discovers configuration and policy and assembles the scan +// options for a diff. The bool return reports whether a policy file was found. +func ScanOptions(root, configPath, policyPath, repo, ref, diff string) (scan.Options, bool, error) { + cfg, _, err := Config(root, configPath) + if err != nil { + return scan.Options{}, false, err + } + policyConfig, _, policyFound, err := Policy(root, policyPath, cfg) + if err != nil { + return scan.Options{}, false, err + } + + return scan.Options{ + Diff: diff, + RepoRoot: root, + Repo: RepoRef(repo), + Ref: ref, + Policy: policyConfig, + Lanes: lanes.Config{ + GreenMax: cfg.Lanes.GreenMax, + YellowMax: cfg.Lanes.YellowMax, + RedMax: cfg.Lanes.RedMax, + }, + EnableCodeOwners: cfg.RuntimeGraph.EnableCodeOwners, + }, policyFound, nil +} diff --git a/internal/risk/classification.go b/internal/risk/classification.go index 61c3748..6e584d4 100644 --- a/internal/risk/classification.go +++ b/internal/risk/classification.go @@ -1,6 +1,6 @@ package risk -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" func classifyRisk(kind domain.MutationKind) (domain.RiskType, string, []string) { switch kind { diff --git a/internal/risk/defaults.go b/internal/risk/defaults.go index d1d6490..d345ecd 100644 --- a/internal/risk/defaults.go +++ b/internal/risk/defaults.go @@ -1,6 +1,6 @@ package risk -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" func defaultWeights() map[domain.MutationKind]int { return map[domain.MutationKind]int{ diff --git a/internal/risk/engine.go b/internal/risk/engine.go index 868feeb..7b49390 100644 --- a/internal/risk/engine.go +++ b/internal/risk/engine.go @@ -3,7 +3,7 @@ package risk import ( "context" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type Engine interface { diff --git a/internal/risk/evaluation.go b/internal/risk/evaluation.go index 334aff3..9feea29 100644 --- a/internal/risk/evaluation.go +++ b/internal/risk/evaluation.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) func (e *WeightedEngine) Evaluate(_ context.Context, packet domain.ChangePacket) (domain.RiskSummary, []domain.Risk, error) { diff --git a/internal/risk/helpers.go b/internal/risk/helpers.go index 0fd8a93..eaaef42 100644 --- a/internal/risk/helpers.go +++ b/internal/risk/helpers.go @@ -1,6 +1,6 @@ package risk -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" func severityFromScore(score int) domain.Severity { switch { diff --git a/internal/runtimegraph/contracts.go b/internal/runtimegraph/contracts.go index 6b10c44..8041d9c 100644 --- a/internal/runtimegraph/contracts.go +++ b/internal/runtimegraph/contracts.go @@ -3,7 +3,7 @@ package runtimegraph import ( "context" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type Snapshot interface { diff --git a/internal/runtimegraph/criticality.go b/internal/runtimegraph/criticality.go index e657ee0..72c984d 100644 --- a/internal/runtimegraph/criticality.go +++ b/internal/runtimegraph/criticality.go @@ -1,6 +1,6 @@ package runtimegraph -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" func criticalityRank(value domain.Criticality) int { switch value { diff --git a/internal/runtimegraph/model.go b/internal/runtimegraph/model.go index b063f2c..3eadb32 100644 --- a/internal/runtimegraph/model.go +++ b/internal/runtimegraph/model.go @@ -1,6 +1,6 @@ package runtimegraph -import "github.com/mergerhq/merger/internal/domain" +import "github.com/devr-tools/merger/internal/domain" type NodeKind string diff --git a/internal/runtimegraph/resolver.go b/internal/runtimegraph/resolver.go index 82accc9..f765aa2 100644 --- a/internal/runtimegraph/resolver.go +++ b/internal/runtimegraph/resolver.go @@ -3,7 +3,7 @@ package runtimegraph import ( "context" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type Builder struct { diff --git a/internal/runtimegraph/source_codeowners.go b/internal/runtimegraph/source_codeowners.go index 6c81193..0bf4d43 100644 --- a/internal/runtimegraph/source_codeowners.go +++ b/internal/runtimegraph/source_codeowners.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type codeOwnersSource struct{} diff --git a/internal/runtimegraph/source_manifest.go b/internal/runtimegraph/source_manifest.go index 87f78de..e495777 100644 --- a/internal/runtimegraph/source_manifest.go +++ b/internal/runtimegraph/source_manifest.go @@ -7,7 +7,7 @@ import ( "gopkg.in/yaml.v3" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type manifestSource struct{} diff --git a/internal/runtimegraph/source_repository_topology.go b/internal/runtimegraph/source_repository_topology.go index b87fc04..2531060 100644 --- a/internal/runtimegraph/source_repository_topology.go +++ b/internal/runtimegraph/source_repository_topology.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/domain" ) type repositoryTopologySource struct{} diff --git a/internal/scan/scan.go b/internal/scan/scan.go new file mode 100644 index 0000000..2657f87 --- /dev/null +++ b/internal/scan/scan.go @@ -0,0 +1,181 @@ +// Package scan runs the merger analysis pipeline offline against a raw unified +// diff, without the webhook ingest, event bus, GitHub App, or persistence +// dependencies of the control-plane services. It reuses the same core engines +// (mutations, runtime graph, risk, policy, lanes) so a local `merger scan` +// produces the same Change Packet and merge-lane decision the services would. +package scan + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/mutations" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/risk" + "github.com/devr-tools/merger/internal/runtimegraph" + "github.com/devr-tools/merger/pkg/diff" + "github.com/devr-tools/merger/pkg/identity" +) + +// Options configures an offline scan. +type Options struct { + // Diff is a raw unified diff (as produced by `git diff`). + Diff string + // RepoRoot is the filesystem root used to load file content for analyzers + // that inspect file bodies (for example the OpenAPI/proto analyzer). It is + // best-effort: missing files are treated as empty content. + RepoRoot string + // Repo optionally identifies the repository for the Change Packet. + Repo domain.RepoRef + // Ref is the revision the diff targets (used as the content-loader ref). + Ref string + // Title optionally labels the Change Packet. + Title string + // Author optionally attributes the Change Packet. + Author domain.Author + // Policy is the policy rule set to evaluate. A zero value evaluates no + // policies (every packet is approved by default). + Policy policy.Config + // Lanes configures the merge-lane thresholds. + Lanes lanes.Config + // EnableCodeOwners toggles CODEOWNERS-based ownership resolution. + EnableCodeOwners bool +} + +// fsContentLoader loads file content from the local filesystem. It satisfies +// both mutations.ContentLoader and runtimegraph.ContentLoader. +type fsContentLoader struct { + root string +} + +func (l fsContentLoader) Load(_ context.Context, path string) ([]byte, error) { + if l.root == "" { + return nil, nil + } + data, err := os.ReadFile(filepath.Join(l.root, path)) + if err != nil { + // Content is best-effort; analyzers tolerate empty content, and a file + // present in a diff may be absent locally (deletions, foreign checkout). + return nil, nil + } + return data, nil +} + +// Run executes the full offline pipeline and returns the resulting Change +// Packet with mutations, runtime impact, risk, policy decision, and merge lane +// populated. +func Run(ctx context.Context, opts Options) (*domain.ChangePacket, error) { + parsed, err := diff.ParseUnified(opts.Diff) + if err != nil { + return nil, fmt.Errorf("parse diff: %w", err) + } + + now := time.Now().UTC() + packet := &domain.ChangePacket{ + ID: identity.New("cp"), + Repo: opts.Repo, + PR: domain.PullRequestRef{HeadSHA: opts.Ref}, + Author: opts.Author, + Title: opts.Title, + Source: "cli.scan", + Files: mapChangedFiles(parsed), + MergeLane: domain.MergeLaneYellow, + Decision: domain.PolicyDecision{Status: domain.DecisionPending}, + Deployment: domain.DeploymentRequirement{ + Strategy: domain.DeployDirect, + }, + CreatedAt: now, + UpdatedAt: now, + } + + loader := fsContentLoader{root: opts.RepoRoot} + + mutationList, err := mutations.DefaultEngine().Classify(ctx, mutations.AnalysisRequest{ + Repo: packet.Repo, + Ref: opts.Ref, + Files: packet.Files, + Content: loader, + }) + if err != nil { + return nil, fmt.Errorf("classify mutations: %w", err) + } + packet.Mutations = mutationList + + runtimeImpact, ownership, err := runtimegraph.NewResolver(runtimegraph.Options{ + EnableCodeOwners: opts.EnableCodeOwners, + }).ResolveImpact(ctx, runtimegraph.ResolutionInput{ + Packet: *packet, + Ref: opts.Ref, + Loader: loader, + }) + if err != nil { + return nil, fmt.Errorf("resolve runtime impact: %w", err) + } + packet.Runtime = runtimeImpact + packet.Ownership = ownership + + riskSummary, risks, err := risk.DefaultEngine().Evaluate(ctx, *packet) + if err != nil { + return nil, fmt.Errorf("evaluate risk: %w", err) + } + packet.RiskSummary = riskSummary + packet.Risks = risks + + evaluation, err := policy.NewRuleEngine(opts.Policy).Evaluate(ctx, *packet) + if err != nil { + return nil, fmt.Errorf("evaluate policy: %w", err) + } + packet.Decision = evaluation.Decision + packet.Evidence = evaluation.Evidence + packet.Reviewers = evaluation.Reviewers + packet.Deployment = evaluation.Deployment + + mergeLane, err := lanes.NewAssigner(opts.Lanes).Assign(ctx, *packet) + if err != nil { + return nil, fmt.Errorf("assign merge lane: %w", err) + } + packet.MergeLane = mergeLane + packet.UpdatedAt = time.Now().UTC() + + return packet, nil +} + +// mapChangedFiles mirrors the ingest service's diff-to-domain mapping so +// offline scans classify files identically to webhook-driven ingestion. +func mapChangedFiles(files []diff.File) []domain.ChangedFile { + mapped := make([]domain.ChangedFile, 0, len(files)) + for _, file := range files { + mapped = append(mapped, domain.ChangedFile{ + Path: file.Path, + PreviousPath: file.PreviousPath, + Status: domain.FileStatus(file.Status), + Language: languageFromPath(file.Path), + Additions: file.Additions, + Deletions: file.Deletions, + Changes: file.Additions + file.Deletions, + Patch: file.Patch, + }) + } + return mapped +} + +func languageFromPath(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".go": + return "go" + case ".sql": + return "sql" + case ".yaml", ".yml": + return "yaml" + case ".proto": + return "proto" + default: + return "unknown" + } +} diff --git a/internal/store/memory.go b/internal/store/memory.go index 1a1feaf..591de76 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -6,8 +6,8 @@ import ( "sync" "time" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" ) type MemoryRepository struct { diff --git a/internal/store/postgres.go b/internal/store/postgres.go index 88e6ede..7c470f4 100644 --- a/internal/store/postgres.go +++ b/internal/store/postgres.go @@ -10,8 +10,8 @@ import ( _ "github.com/lib/pq" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" ) type PostgresRepository struct { diff --git a/internal/store/store.go b/internal/store/store.go index fc15af4..bf61a60 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -4,8 +4,8 @@ import ( "context" "errors" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" ) var ErrChangePacketNotFound = errors.New("change packet not found") diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..46224e6 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,9 @@ +package version + +// Number is the merger version. It must be a var (not a const) so the release +// build can override it via the linker: GoReleaser injects the git tag with +// `-X github.com/devr-tools/merger/internal/version.Number=v{{.Version}}` (see +// .goreleaser.yaml). The linker's -X flag only sets string vars, so a const +// would silently leave released binaries reporting this default. Release Please +// keeps the default below in sync with the manifest via the marker comment. +var Number = "0.1.0" // x-release-please-version diff --git a/pkg/extensions/interfaces.go b/pkg/extensions/interfaces.go index 3a4414d..3d83805 100644 --- a/pkg/extensions/interfaces.go +++ b/pkg/extensions/interfaces.go @@ -3,7 +3,7 @@ package extensions import ( "context" - "github.com/mergerhq/merger/pkg/merger" + "github.com/devr-tools/merger/pkg/merger" ) type EventHandler func(context.Context, merger.Envelope) error diff --git a/pkg/merger/scan.go b/pkg/merger/scan.go new file mode 100644 index 0000000..29e40c8 --- /dev/null +++ b/pkg/merger/scan.go @@ -0,0 +1,44 @@ +package merger + +import ( + "context" + + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/scan" +) + +// ScanOptions configures an offline scan. See Scan. +type ScanOptions = scan.Options + +// PolicyConfig is a merger policy rule set. +type PolicyConfig = policy.Config + +// LanesConfig configures merge-lane thresholds. +type LanesConfig = lanes.Config + +// DefaultLanes returns merger's default merge-lane thresholds. +func DefaultLanes() LanesConfig { + d := config.Defaults().Lanes + return LanesConfig{GreenMax: d.GreenMax, YellowMax: d.YellowMax, RedMax: d.RedMax} +} + +// LoadPolicy reads a policy rule set from a YAML file. A nil error with an empty +// rule set means the file parsed but declared no policies. +func LoadPolicy(path string) (PolicyConfig, error) { + return policy.LoadConfig(path) +} + +// Scan runs the merger analysis pipeline offline against opts.Diff — mutation +// detection, runtime-graph resolution, risk scoring, policy evaluation, and +// merge-lane assignment — and returns the resulting Change Packet. It requires +// no services, database, or event bus, so it is safe to call from a CLI, a CI +// job, or an agent tool. +// +// The zero value of opts.Lanes disables lane thresholds; most callers want +// DefaultLanes(). opts.Policy may be loaded with LoadPolicy or left empty to +// evaluate no policies. +func Scan(ctx context.Context, opts ScanOptions) (*ChangePacket, error) { + return scan.Run(ctx, opts) +} diff --git a/pkg/merger/types.go b/pkg/merger/types.go index 5389d60..42420a1 100644 --- a/pkg/merger/types.go +++ b/pkg/merger/types.go @@ -1,9 +1,9 @@ package merger import ( - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/runtimegraph" ) type Author = domain.Author diff --git a/proto/merger/v1/controlplane.proto b/proto/merger/v1/controlplane.proto index 43990ab..d2d0b79 100644 --- a/proto/merger/v1/controlplane.proto +++ b/proto/merger/v1/controlplane.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package merger.v1; -option go_package = "github.com/mergerhq/merger/proto/merger/v1;mergerv1"; +option go_package = "github.com/devr-tools/merger/proto/merger/v1;mergerv1"; message ChangePacketRef { string id = 1; diff --git a/scripts/commit.sh b/scripts/commit.sh new file mode 100755 index 0000000..8c55235 --- /dev/null +++ b/scripts/commit.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ ! -t 0 ] || [ ! -t 1 ]; then + echo "commit helper requires an interactive terminal" + exit 1 +fi + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "not inside a git repository" + exit 1 +fi + +branch="$(git branch --show-current)" +if [ -z "$branch" ]; then + echo "could not determine current branch" + exit 1 +fi + +if git diff --quiet && git diff --cached --quiet; then + echo "no changes to commit" + exit 0 +fi + +echo "current branch: $branch" +echo +git status --short +echo + +read -r -p "stage all changes with 'git add .'? [y/N] " stage_all +case "$stage_all" in + y|Y) git add . ;; + *) echo "aborted"; exit 1 ;; +esac + +if git diff --cached --quiet; then + echo "no staged changes to commit" + exit 1 +fi + +echo +echo "select commit type:" +types=(feat fix chore docs refactor test ci perf build revert) +for i in "${!types[@]}"; do + printf " %d) %s\n" "$((i + 1))" "${types[$i]}" +done + +read -r -p "type [1-${#types[@]}] (default: chore): " type_choice +if [ -z "$type_choice" ]; then + commit_type="chore" +elif [[ "$type_choice" =~ ^[0-9]+$ ]] && [ "$type_choice" -ge 1 ] && [ "$type_choice" -le "${#types[@]}" ]; then + commit_type="${types[$((type_choice - 1))]}" +else + echo "invalid commit type selection" + exit 1 +fi + +read -r -p "optional scope (leave blank for none): " commit_scope + +while :; do + read -r -p "commit summary: " commit_summary + if [ -n "${commit_summary// }" ]; then + break + fi + echo "summary is required" +done + +commit_message="$commit_type" +if [ -n "${commit_scope// }" ]; then + commit_message="$commit_message($commit_scope)" +fi +commit_message="$commit_message: $commit_summary" + +echo +echo "commit message: $commit_message" +read -r -p "create commit? [Y/n] " confirm_commit +case "$confirm_commit" in + n|N) echo "aborted"; exit 1 ;; +esac + +git commit -m "$commit_message" + +echo +read -r -p "push branch '$branch' to origin? [Y/n] " confirm_push +case "$confirm_push" in + n|N) + echo "commit created locally; push skipped" + exit 0 + ;; +esac + +if git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then + git push origin "$branch" +else + git push -u origin "$branch" +fi diff --git a/tests/checks/publisher_test.go b/tests/checks/publisher_test.go index 90f64f1..ae0fba4 100644 --- a/tests/checks/publisher_test.go +++ b/tests/checks/publisher_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/checks" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/checks" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/github" ) func TestPublishNoopsWithoutClient(t *testing.T) { diff --git a/tests/cli/cli_test.go b/tests/cli/cli_test.go new file mode 100644 index 0000000..9d9ca82 --- /dev/null +++ b/tests/cli/cli_test.go @@ -0,0 +1,120 @@ +package cli_test + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/devr-tools/merger/internal/cli" +) + +const authDiff = `diff --git a/internal/auth/session.go b/internal/auth/session.go +index 3333333..4444444 100644 +--- a/internal/auth/session.go ++++ b/internal/auth/session.go +@@ -10,6 +10,9 @@ func Authenticate(token string) bool { +- return legacyCheck(token) ++ if token == "" { ++ return false ++ } ++ return verify(token) + } +` + +// captureStdout redirects os.Stdout for the duration of fn and returns what was +// written. Tests here run sequentially, so the swap is safe. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + _ = w.Close() + out, _ := io.ReadAll(r) + return string(out) +} + +func TestRunVersionPrintsVersion(t *testing.T) { + var err error + out := captureStdout(t, func() { err = cli.Run(context.Background(), []string{"version"}) }) + if err != nil { + t.Fatalf("version: %v", err) + } + if !strings.Contains(out, "merger v") { + t.Fatalf("expected version output, got %q", out) + } +} + +func TestRunUnknownCommandExitsTwo(t *testing.T) { + err := cli.Run(context.Background(), []string{"nope"}) + assertExitCode(t, err, 2) +} + +func TestInitThenValidate(t *testing.T) { + dir := t.TempDir() + + var err error + captureStdout(t, func() { err = cli.Run(context.Background(), []string{"init", "-dir", dir}) }) + if err != nil { + t.Fatalf("init: %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, ".merger", "merger.yaml")); statErr != nil { + t.Fatalf("expected .merger/merger.yaml: %v", statErr) + } + + captureStdout(t, func() { err = cli.Run(context.Background(), []string{"validate", "-repo-root", dir}) }) + if err != nil { + t.Fatalf("validate: %v", err) + } +} + +func TestInitTwiceWithoutForceFails(t *testing.T) { + dir := t.TempDir() + captureStdout(t, func() { _ = cli.Run(context.Background(), []string{"init", "-dir", dir}) }) + + err := cli.Run(context.Background(), []string{"init", "-dir", dir}) + assertExitCode(t, err, 1) +} + +func TestScanFailOnLaneTripsExitCode(t *testing.T) { + dir := t.TempDir() + captureStdout(t, func() { _ = cli.Run(context.Background(), []string{"init", "-dir", dir}) }) + + diffPath := filepath.Join(dir, "change.diff") + if err := os.WriteFile(diffPath, []byte(authDiff), 0o644); err != nil { + t.Fatalf("write diff: %v", err) + } + + var err error + captureStdout(t, func() { + err = cli.Run(context.Background(), []string{ + "scan", "-repo-root", dir, "-diff", diffPath, "-fail-on-lane", "RED", + }) + }) + assertExitCode(t, err, 2) +} + +func TestScanWithoutDiffSourceFails(t *testing.T) { + err := cli.Run(context.Background(), []string{"scan", "-repo-root", t.TempDir()}) + assertExitCode(t, err, 2) +} + +func assertExitCode(t *testing.T, err error, want int) { + t.Helper() + var exit cli.ExitError + if !errors.As(err, &exit) { + t.Fatalf("expected cli.ExitError, got %v", err) + } + if exit.Code != want { + t.Fatalf("expected exit code %d, got %d (%s)", want, exit.Code, exit.Message) + } +} diff --git a/tests/config/config_test.go b/tests/config/config_test.go index 650e02c..32901cb 100644 --- a/tests/config/config_test.go +++ b/tests/config/config_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/mergerhq/merger/internal/config" + "github.com/devr-tools/merger/internal/config" ) func TestDefaults(t *testing.T) { diff --git a/tests/controlplane/http_test.go b/tests/controlplane/http_test.go index f519c28..c9c5c2b 100644 --- a/tests/controlplane/http_test.go +++ b/tests/controlplane/http_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "github.com/mergerhq/merger/internal/controlplane" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/store" + "github.com/devr-tools/merger/internal/controlplane" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/store" ) func TestHTTPHandlerReturnsChangePacketView(t *testing.T) { diff --git a/tests/controlplane/list_test.go b/tests/controlplane/list_test.go index a8b1ec3..922db15 100644 --- a/tests/controlplane/list_test.go +++ b/tests/controlplane/list_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/mergerhq/merger/internal/controlplane" - "github.com/mergerhq/merger/internal/domain" + "github.com/devr-tools/merger/internal/controlplane" + "github.com/devr-tools/merger/internal/domain" ) func TestHTTPHandlerListsChangePackets(t *testing.T) { diff --git a/tests/controlplanegrpc/server_test.go b/tests/controlplanegrpc/server_test.go index 61ccf1e..f63336f 100644 --- a/tests/controlplanegrpc/server_test.go +++ b/tests/controlplanegrpc/server_test.go @@ -6,11 +6,11 @@ import ( "testing" "time" - "github.com/mergerhq/merger/internal/controlplane" - controlplanegrpc "github.com/mergerhq/merger/internal/controlplanegrpc" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/store" - mergerv1 "github.com/mergerhq/merger/proto/merger/v1" + "github.com/devr-tools/merger/internal/controlplane" + controlplanegrpc "github.com/devr-tools/merger/internal/controlplanegrpc" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/store" + mergerv1 "github.com/devr-tools/merger/proto/merger/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" diff --git a/tests/events/bus_test.go b/tests/events/bus_test.go index 91549b6..1e3b1f9 100644 --- a/tests/events/bus_test.go +++ b/tests/events/bus_test.go @@ -5,8 +5,8 @@ import ( "errors" "testing" - "github.com/mergerhq/merger/internal/config" - "github.com/mergerhq/merger/internal/events" + "github.com/devr-tools/merger/internal/config" + "github.com/devr-tools/merger/internal/events" ) func TestMemoryBusPublishSubscribeAndClose(t *testing.T) { diff --git a/tests/github/checks_test.go b/tests/github/checks_test.go index afa7565..dc27ede 100644 --- a/tests/github/checks_test.go +++ b/tests/github/checks_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/github" ) func TestClientPublishCheckRun(t *testing.T) { diff --git a/tests/github/test_helpers_test.go b/tests/github/test_helpers_test.go index 8d69ae0..1221706 100644 --- a/tests/github/test_helpers_test.go +++ b/tests/github/test_helpers_test.go @@ -13,7 +13,7 @@ import ( "path/filepath" "testing" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/github" ) func newTestClient(t *testing.T, transport http.RoundTripper) *github.Client { diff --git a/tests/github/webhook_test.go b/tests/github/webhook_test.go index ec3a060..724da60 100644 --- a/tests/github/webhook_test.go +++ b/tests/github/webhook_test.go @@ -8,7 +8,7 @@ import ( "net/http/httptest" "testing" - "github.com/mergerhq/merger/internal/github" + "github.com/devr-tools/merger/internal/github" ) func TestWebhookDecoderRejectsInvalidSignature(t *testing.T) { diff --git a/tests/ingest/processor_test.go b/tests/ingest/processor_test.go index 32e50c3..352945e 100644 --- a/tests/ingest/processor_test.go +++ b/tests/ingest/processor_test.go @@ -4,16 +4,16 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/ingest" - "github.com/mergerhq/merger/internal/lanes" - "github.com/mergerhq/merger/internal/mutations" - "github.com/mergerhq/merger/internal/policy" - "github.com/mergerhq/merger/internal/risk" - "github.com/mergerhq/merger/internal/runtimegraph" - "github.com/mergerhq/merger/internal/telemetry" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/ingest" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/mutations" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/risk" + "github.com/devr-tools/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/telemetry" ) func TestProcessPROpenedBuildsChangePacket(t *testing.T) { diff --git a/tests/ingest/webhook_handler_test.go b/tests/ingest/webhook_handler_test.go index 4fce24a..a615d91 100644 --- a/tests/ingest/webhook_handler_test.go +++ b/tests/ingest/webhook_handler_test.go @@ -7,15 +7,15 @@ import ( "net/http/httptest" "testing" - "github.com/mergerhq/merger/internal/events" - "github.com/mergerhq/merger/internal/github" - "github.com/mergerhq/merger/internal/ingest" - "github.com/mergerhq/merger/internal/lanes" - "github.com/mergerhq/merger/internal/mutations" - "github.com/mergerhq/merger/internal/policy" - "github.com/mergerhq/merger/internal/risk" - "github.com/mergerhq/merger/internal/runtimegraph" - "github.com/mergerhq/merger/internal/telemetry" + "github.com/devr-tools/merger/internal/events" + "github.com/devr-tools/merger/internal/github" + "github.com/devr-tools/merger/internal/ingest" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/mutations" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/risk" + "github.com/devr-tools/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/telemetry" ) func TestWebhookHandlerRejectsInvalidWebhook(t *testing.T) { diff --git a/tests/lanes/assigner_test.go b/tests/lanes/assigner_test.go index cbefcd3..c337ca5 100644 --- a/tests/lanes/assigner_test.go +++ b/tests/lanes/assigner_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/lanes" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/lanes" ) func TestAssignerReturnsGreenForLowRiskAutomatableChanges(t *testing.T) { diff --git a/tests/mcpserver/server_test.go b/tests/mcpserver/server_test.go new file mode 100644 index 0000000..9bd4b06 --- /dev/null +++ b/tests/mcpserver/server_test.go @@ -0,0 +1,87 @@ +package mcpserver_test + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/devr-tools/merger/internal/mcpserver" +) + +const authDiff = `diff --git a/internal/auth/session.go b/internal/auth/session.go +index 3333333..4444444 100644 +--- a/internal/auth/session.go ++++ b/internal/auth/session.go +@@ -10,6 +10,9 @@ func Authenticate(token string) bool { +- return legacyCheck(token) ++ if token == "" { ++ return false ++ } ++ return verify(token) + } +` + +// drive feeds newline-delimited JSON-RPC requests through a fresh server and +// returns the emitted response lines. +func drive(t *testing.T, requests ...string) []string { + t.Helper() + in := bytes.NewBufferString(strings.Join(requests, "\n") + "\n") + var out bytes.Buffer + if err := mcpserver.New().Serve(context.Background(), in, &out); err != nil { + t.Fatalf("Serve: %v", err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + return lines +} + +func TestInitializeAdvertisesServerInfo(t *testing.T) { + lines := drive(t, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`) + if len(lines) != 1 { + t.Fatalf("expected 1 response, got %d: %v", len(lines), lines) + } + if !strings.Contains(lines[0], `"name":"merger"`) { + t.Fatalf("expected serverInfo name merger, got %s", lines[0]) + } +} + +func TestToolsListRequiresInitialize(t *testing.T) { + lines := drive(t, `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`) + if !strings.Contains(lines[0], "not initialized") { + t.Fatalf("expected not-initialized error before initialize, got %s", lines[0]) + } +} + +func TestToolsListAndScanCall(t *testing.T) { + args, err := json.Marshal(map[string]any{"diff": authDiff}) + if err != nil { + t.Fatalf("marshal args: %v", err) + } + lines := drive(t, + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, + `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"merger_scan","arguments":`+string(args)+`}}`, + ) + + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "merger_scan") { + t.Fatalf("expected merger_scan in tools/list, got:\n%s", joined) + } + if !strings.Contains(joined, "mergeLane") { + t.Fatalf("expected a Change Packet with mergeLane in the scan result, got:\n%s", joined) + } +} + +func TestUnknownToolIsInvalidParams(t *testing.T) { + lines := drive(t, + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`, + `{"jsonrpc":"2.0","method":"notifications/initialized"}`, + `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"does_not_exist","arguments":{}}}`, + ) + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "unknown tool") { + t.Fatalf("expected unknown tool error, got:\n%s", joined) + } +} diff --git a/tests/merger/sdk_test.go b/tests/merger/sdk_test.go new file mode 100644 index 0000000..120ab18 --- /dev/null +++ b/tests/merger/sdk_test.go @@ -0,0 +1,44 @@ +package merger_test + +import ( + "context" + "testing" + + "github.com/devr-tools/merger/pkg/merger" +) + +const schemaDiff = `diff --git a/db/migrations/007_add_orders.sql b/db/migrations/007_add_orders.sql +new file mode 100644 +index 0000000..5555555 +--- /dev/null ++++ b/db/migrations/007_add_orders.sql +@@ -0,0 +1,1 @@ ++CREATE TABLE orders (id uuid primary key); +` + +func TestScanThroughSDK(t *testing.T) { + packet, err := merger.Scan(context.Background(), merger.ScanOptions{ + Diff: schemaDiff, + Repo: merger.RepoRef{Owner: "acme", Name: "orders", FullName: "acme/orders"}, + Lanes: merger.DefaultLanes(), + }) + if err != nil { + t.Fatalf("merger.Scan: %v", err) + } + if len(packet.Files) != 1 { + t.Fatalf("expected 1 file, got %d", len(packet.Files)) + } + if len(packet.Mutations) == 0 { + t.Fatalf("expected at least one mutation for a schema migration") + } + if packet.MergeLane == "" { + t.Fatalf("expected a merge lane to be assigned") + } +} + +func TestDefaultLanesMatchConfigDefaults(t *testing.T) { + lanes := merger.DefaultLanes() + if !(lanes.GreenMax < lanes.YellowMax && lanes.YellowMax < lanes.RedMax) { + t.Fatalf("default lanes must be strictly increasing, got %+v", lanes) + } +} diff --git a/tests/mutations/analyzers_test.go b/tests/mutations/analyzers_test.go index 4af4a1d..385d493 100644 --- a/tests/mutations/analyzers_test.go +++ b/tests/mutations/analyzers_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/mutations" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/mutations" ) type staticContentLoader map[string][]byte diff --git a/tests/mutations/engine_test.go b/tests/mutations/engine_test.go index a29f87b..df96235 100644 --- a/tests/mutations/engine_test.go +++ b/tests/mutations/engine_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/mutations" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/mutations" ) func TestDefaultEngineClassifiesSchemaMutation(t *testing.T) { diff --git a/tests/mutations/rules_test.go b/tests/mutations/rules_test.go index dff4977..c54b96f 100644 --- a/tests/mutations/rules_test.go +++ b/tests/mutations/rules_test.go @@ -3,8 +3,8 @@ package mutations_test import ( "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/mutations" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/mutations" ) func TestDefaultRulesMatchOpenAPIBySignal(t *testing.T) { diff --git a/tests/policy/engine_test.go b/tests/policy/engine_test.go index e833b72..4747788 100644 --- a/tests/policy/engine_test.go +++ b/tests/policy/engine_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/policy" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/policy" ) func TestPolicyRequiresSecurityReviewForAuthMutation(t *testing.T) { diff --git a/tests/runtimegraph/resolver_test.go b/tests/runtimegraph/resolver_test.go index 033b331..e70978e 100644 --- a/tests/runtimegraph/resolver_test.go +++ b/tests/runtimegraph/resolver_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/runtimegraph" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/runtimegraph" ) func TestResolverDerivesOwnershipFromTopology(t *testing.T) { diff --git a/tests/scan/scan_test.go b/tests/scan/scan_test.go new file mode 100644 index 0000000..2399093 --- /dev/null +++ b/tests/scan/scan_test.go @@ -0,0 +1,120 @@ +package scan_test + +import ( + "context" + "strings" + "testing" + + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/lanes" + "github.com/devr-tools/merger/internal/policy" + "github.com/devr-tools/merger/internal/scan" +) + +const authDiff = `diff --git a/internal/auth/session.go b/internal/auth/session.go +index 3333333..4444444 100644 +--- a/internal/auth/session.go ++++ b/internal/auth/session.go +@@ -10,6 +10,9 @@ func Authenticate(token string) bool { +- return legacyCheck(token) ++ if token == "" { ++ return false ++ } ++ return verify(token) + } +` + +func defaultLanes() lanes.Config { + return lanes.Config{GreenMax: 20, YellowMax: 55, RedMax: 85} +} + +func authPolicy() policy.Config { + return policy.Config{Policies: []policy.RuleConfig{{ + Name: "auth_requires_security_review", + When: policy.WhenClause{Mutations: []domain.MutationKind{domain.MutationAuthBehaviorChange}}, + Require: policy.RequirementClause{ + Reviewers: []string{"security"}, + Evidence: []string{"auth_integration_tests"}, + Deployment: policy.DeploymentClause{Strategy: "canary", RequiresCanary: true}, + }, + Action: policy.ActionClause{MinimumLane: domain.MergeLaneRed}, + }}} +} + +func TestRunClassifiesAuthMutationAndEscalatesLane(t *testing.T) { + packet, err := scan.Run(context.Background(), scan.Options{ + Diff: authDiff, + Repo: domain.RepoRef{Owner: "acme", Name: "payments", FullName: "acme/payments"}, + Policy: authPolicy(), + Lanes: defaultLanes(), + }) + if err != nil { + t.Fatalf("scan.Run: %v", err) + } + + if len(packet.Files) != 1 { + t.Fatalf("expected 1 changed file, got %d", len(packet.Files)) + } + + if !hasMutationKind(packet.Mutations, domain.MutationAuthBehaviorChange) { + t.Fatalf("expected an auth_behavior_change mutation, got %+v", packet.Mutations) + } + + if packet.MergeLane != domain.MergeLaneRed { + t.Fatalf("expected RED lane after auth policy escalation, got %s", packet.MergeLane) + } + + if !hasReviewer(packet.Reviewers, "security") { + t.Fatalf("expected a security reviewer requirement, got %+v", packet.Reviewers) + } +} + +func TestRunWithEmptyDiffProducesNoMutations(t *testing.T) { + packet, err := scan.Run(context.Background(), scan.Options{ + Diff: "", + Lanes: defaultLanes(), + }) + if err != nil { + t.Fatalf("scan.Run: %v", err) + } + if len(packet.Files) != 0 { + t.Fatalf("expected no files, got %d", len(packet.Files)) + } + if len(packet.Mutations) != 0 { + t.Fatalf("expected no mutations, got %d", len(packet.Mutations)) + } + if packet.MergeLane != domain.MergeLaneGreen { + t.Fatalf("expected GREEN lane for an empty change, got %s", packet.MergeLane) + } +} + +func TestRunAssignsIDAndSource(t *testing.T) { + packet, err := scan.Run(context.Background(), scan.Options{Diff: authDiff, Lanes: defaultLanes()}) + if err != nil { + t.Fatalf("scan.Run: %v", err) + } + if !strings.HasPrefix(packet.ID, "cp_") { + t.Fatalf("expected change packet id prefixed with cp_, got %q", packet.ID) + } + if packet.Source != "cli.scan" { + t.Fatalf("expected source cli.scan, got %q", packet.Source) + } +} + +func hasMutationKind(mutations []domain.Mutation, kind domain.MutationKind) bool { + for _, mutation := range mutations { + if mutation.Kind == kind { + return true + } + } + return false +} + +func hasReviewer(reviewers []domain.ReviewerRequirement, team string) bool { + for _, reviewer := range reviewers { + if reviewer.Team == team { + return true + } + } + return false +} diff --git a/tests/store/memory_test.go b/tests/store/memory_test.go index ceb0482..f5aef17 100644 --- a/tests/store/memory_test.go +++ b/tests/store/memory_test.go @@ -5,8 +5,8 @@ import ( "testing" "time" - "github.com/mergerhq/merger/internal/domain" - "github.com/mergerhq/merger/internal/store" + "github.com/devr-tools/merger/internal/domain" + "github.com/devr-tools/merger/internal/store" ) func TestMemoryRepositoryPersistsEvidenceFromChangePacket(t *testing.T) {