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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.git
.serena
.pruefbyte
*.exe
111 changes: 111 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
name: CI

on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
IMAGE: ghcr.io/${{ github.repository }}

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- name: Check formatting
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "::error::Run gofmt -w on these files:"
echo "$unformatted"
exit 1
fi
- name: Check go.mod and go.sum are tidy
run: |
go mod tidy
git diff --exit-code -- go.mod go.sum
- uses: golangci/golangci-lint-action@v9
with:
version: v2.14.0 # keep in sync with mise.toml

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go tool cover -func=coverage.out | tail -1

build:
runs-on: ubuntu-latest
strategy:
matrix:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: go build -trimpath -o pruefbyte-linux-${{ matrix.goarch }} ./cmd/pruefbyte
env:
CGO_ENABLED: "0"
GOOS: linux
GOARCH: ${{ matrix.goarch }}

image:
needs: [lint, test, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
# latest follows main only; the default (auto) would also move it on every v* tag.
flavor: latest=false
# Lowest priority, so the version passed to the build is sha-<commit> on main, not "latest".
tags: |
type=ref,event=pr
type=raw,value=latest,enable={{is_default_branch}},priority=50
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
# Pull requests only check that the image builds.
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.meta.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
39 changes: 39 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
# Build output
/pruefbyte
/pruefbyte.exe
/bin/
/dist/
*.exe
*.exe~
*.dll
*.so
*.dylib

# Go test and profiling artifacts
*.test
*.out
coverage.*
*.coverprofile
/go.work
/go.work.sum

# pruefbyte runtime artifacts (OCR results kept by the CI job)
/.pruefbyte/
/.ocr/

# Local configuration and secrets
.env
.env.*
!.env.example
/pruefbyte.yml
mise.local.toml
.mise.local.toml

# Editor / tool state
.idea/
.vscode/
*.swp
*~
.serena/
.claude/settings.local.json

# OS files
.DS_Store
Thumbs.db
desktop.ini
26 changes: 26 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
version: "2"

linters:
default: standard # errcheck, govet, ineffassign, staticcheck, unused
enable:
- bodyclose
- errorlint
- gosec
- misspell
- nolintlint
- unconvert
settings:
gosec:
excludes:
- G204 # subprocess with variable args: running git and ocr is the point
- G304 # reading files from variable paths: config, rule and result files are the point
exclusions:
presets:
- std-error-handling
rules:
- path: _test\.go
linters: [errcheck, gosec]

formatters:
enable:
- gofmt
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
ARG GO_VERSION=1.25

# Build stages run on the build machine's platform and cross-compile, so a
# multi-arch build only emulates the final stage's package install.
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
# BuildKit sets these for the target platform; defaults would override them.
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/pruefbyte ./cmd/pruefbyte

FROM --platform=$BUILDPLATFORM alpine:3 AS ocr
ARG OCR_VERSION=v1.12.10
# BuildKit sets TARGETARCH for the platform being built; a default here would
# override it (e.g. an amd64 binary in an arm64 image).
ARG TARGETARCH
RUN apk add --no-cache curl \
&& arch="${TARGETARCH:-amd64}" \
&& cd /tmp \
&& curl -fsSLO "https://github.com/alibaba/open-code-review/releases/download/${OCR_VERSION}/opencodereview-linux-${arch}" \
&& curl -fsSLO "https://github.com/alibaba/open-code-review/releases/download/${OCR_VERSION}/sha256sum.txt" \
&& grep " opencodereview-linux-${arch}\$" sha256sum.txt | sha256sum -c - \
&& install -m 0755 "opencodereview-linux-${arch}" /out-ocr

FROM alpine:3
# OCR needs git >= 2.41; alpine:3 ships a current git.
RUN apk add --no-cache git ca-certificates
COPY --from=ocr /out-ocr /usr/local/bin/ocr
COPY --from=build /out/pruefbyte /usr/local/bin/pruefbyte
ENTRYPOINT []
CMD ["pruefbyte", "review"]
155 changes: 155 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# pruefbyte

AI code review for GitLab merge requests, in the spirit of
[pr-agent](https://github.com/The-PR-Agent/pr-agent). The review itself is done by
[OpenCodeReview](https://github.com/alibaba/open-code-review) (`ocr`). pruefbyte
runs it in a merge request pipeline and posts the findings as inline discussions
from a dedicated bot account.

- Findings are anchored to the right diff line, with GitLab "apply suggestion" blocks where possible.
- Re-running the pipeline never duplicates a comment.
- The bot resolves its own threads once the flagged code has changed and the finding is gone.
- Findings that can't be placed on the diff, or that go over the comment limit, go into a single summary note, which is updated in place.
- Works with every LLM provider OCR supports (Anthropic, OpenAI, Bedrock, DashScope, OpenRouter, …) and with any OpenAI- or Anthropic-compatible endpoint.

## Setup

1. **Bot user.** Create a GitLab user (e.g. `pruefbyte-bot`) and add it to your group or projects as **Developer**. Create a personal access token for it with scope `api`.
2. **CI/CD variables** (group level, masked):
| Variable | Value |
|---|---|
| `PRUEFBYTE_GITLAB_TOKEN` | the bot's PAT |
| `PRUEFBYTE_LLM_API_KEY` | the LLM API key |
| `PRUEFBYTE_LLM_PROVIDER` | e.g. `anthropic` |
| `PRUEFBYTE_LLM_MODEL` | e.g. `claude-sonnet-5` |
3. **Image.** CI publishes `ghcr.io/feinarbyte/pruefbyte` for linux/amd64 and linux/arm64:
`latest` from `main`, `X.Y.Z` and `X.Y` from `vX.Y.Z` tags, and `sha-<commit>` for each of these pushes.
If the package is private, give the GitLab runners pull access (a GitHub token with
`read:packages` in `DOCKER_AUTH_CONFIG`). To build your own:
```sh
docker buildx build --platform linux/amd64,linux/arm64 --build-arg OCR_VERSION=v1.12.10 \
-t registry.example.com/tools/pruefbyte:latest --push .
```
4. **Pipeline.** Include the template in each project (or in a shared CI config):
```yaml
include:
- project: tools/pruefbyte
file: templates/pruefbyte.gitlab-ci.yml
variables:
PRUEFBYTE_IMAGE: ghcr.io/feinarbyte/pruefbyte:latest
```
The job runs in merge request pipelines (not in merge train pipelines, and not in pipelines that run in a fork, which lack the CI/CD variables), in the `test` stage: if the project defines `stages:`, keep `test` or override the job's `stage:`. If the project's other jobs run in branch pipelines, switch to merge request pipelines while a merge request is open, as GitLab recommends. Otherwise every push runs two pipelines, and the merge check only looks at the merge request pipeline, which then holds nothing but this job:
```yaml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
```

## Configuration

Settings are layered; later layers win:

1. Built-in defaults.
2. The global file, from `--config` or `PRUEFBYTE_CONFIG`. See [`pruefbyte.example.yml`](pruefbyte.example.yml) for every key.
3. `.pruefbyte.yml` in the repository, **read from the merge request's base commit**. A merge request can't change its own review settings: config changes take effect once they are merged. The same holds for OCR's own rule files, `.opencodereview/rule.json` and `ocr.rule_file`: pruefbyte reads them at the base commit and passes one rule file that keeps the merge request's copies from applying.
4. Environment variables `PRUEFBYTE_<SECTION>_<KEY>`, e.g. `PRUEFBYTE_REVIEW_MIN_SEVERITY=medium`. Lists are comma-separated.

The repository file may only set `llm.model`, `ocr.*` (except `binary` and `extra_args`), and `review.*`. Anything that decides where credentials are sent, or what gets executed, is rejected there.

Secrets are only ever read from the env vars named by `gitlab.token_env` and `llm.api_key_env`. `ocr` runs with a private, temporary `HOME`, so its config file and session logs never touch the runner.

Example `.pruefbyte.yml`:

```yaml
ocr:
effort: high
exclude: ["**/generated/**"]
rules:
- path: "**/*.go"
rule: "Errors must be wrapped with %w; flag bare returns of err from external calls."
merge_system_rule: true # add to OCR's built-in Go rules instead of replacing them
review:
min_severity: medium
max_comments: 15
```

## When it runs

pruefbyte reviews the merge request whenever it is run; it has no draft, label,
author or branch filters of its own. When it runs is decided by the CI job's
`rules:`. Override the job in your project to add conditions, for example:

```yaml
pruefbyte-review:
rules:
- if: $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"
when: never
- if: $CI_PROJECT_ID != $CI_MERGE_REQUEST_PROJECT_ID
when: never
- if: $CI_MERGE_REQUEST_DRAFT == "true" # skip drafts
when: never
- if: $CI_MERGE_REQUEST_LABELS =~ /(^|,)no-review(,|$)/
when: never
- if: $GITLAB_USER_LOGIN == "renovate-bot"
when: never
- if: $CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^release\//
when: never
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
```

Overriding `rules:` replaces the template's list, so keep its first two entries
(merge trains, fork pipelines). To review on demand only, use `when: manual`.

## Behaviour

| Situation | What happens |
|---|---|
| MR has new commits since the pipeline started | Skipped; the newer pipeline reviews it. |
| Finding on a line in the diff | Inline discussion. Suggestion block if OCR proposed replacement code for exactly the lines it quotes. |
| Finding about removed code | Inline discussion on the removed line, without a suggestion block. |
| Finding outside the diff, or GitLab rejects the position | Listed in the summary note. |
| Same finding as an earlier run (open or resolved) | Not posted again. Matched by a hidden fingerprint of file + the code the finding quotes (its text if it quotes none), so rewording doesn't count as new. |
| Earlier bot thread not reported again **and** its code changed | Reply and resolve, but only after a complete review that covered the file, and only once: a thread someone reopens stays open. |
| OCR fails | A failure note with the redacted error; job exits 1. |
| `review.fail_on_severity` reached | Comments are posted; job exits 3. |

## Local use

```sh
export PRUEFBYTE_GITLAB_TOKEN=glpat-... PRUEFBYTE_LLM_API_KEY=sk-...
pruefbyte review --config pruefbyte.yml --gitlab-url https://gitlab.example.com \
--project group/project --mr 42 --repo . --dry-run
```

`--dry-run` prints the discussions instead of posting them. `pruefbyte config print` shows the effective configuration.

## Development

```sh
mise install # Go toolchain and golangci-lint
gofmt -l . # must print nothing
golangci-lint run ./...
go test -race ./...
```

GitHub Actions (`.github/workflows/ci.yml`) runs these checks plus `go mod tidy` and
linux/amd64 + linux/arm64 builds on pushes to `main` and `v*` tags and on every pull
request. Once they pass, it
builds the multi-arch image. On `main` and `v*` tags the image is pushed to GHCR;
for pull requests it is only built.

Layout:

| Path | Purpose |
|---|---|
| `cmd/pruefbyte` | CLI (cobra) and wiring |
| `internal/config` | layered config, repo-file allow-list, env overrides |
| `internal/ocr` | drives the `ocr` CLI and parses its JSON |
| `internal/gitlab` | client-go wrapper bound to one MR; dry-run decorator |
| `internal/review` | orchestration: filter, place, dedupe, publish, resolve |
| `internal/gitutil` | reads the repo config at the base commit; fetches missing commits |

OCR is used as a subprocess. Its Go packages all live under `internal/`, so they can't be imported from another module; its JSON output is the stable interface.
Loading
Loading