Build an AI Code-Review Bot in Java (Gitea/GitHub Webhook + Spring AI)
A pull-request review bot in Spring Boot 4.1.1 and Spring AI 2.0.1: verify the webhook signature, chunk the diff with new-file line numbers, review each chunk, drop comments on lines the model invented, and keep the bill down with a cache and a chunk cap. Runs with a scripted model and no API key.
A code-review bot does one job: when someone opens or updates a pull request, read the diff, and leave comments on specific lines. Writing the “ask a model about some code” part takes ten lines. Everything around it — proving the request really came from your Git server, cutting a large diff into pieces a model can handle, refusing to post comments on lines that are not in the diff, and not paying twice for the same chunk — is where bots go wrong, and it is the part this post builds and tests.
The bot receives a Gitea webhook (the same idea works for GitHub, with differences noted below), fetches the pull request’s diff, asks a Spring AI ChatClient about each chunk, and posts one review with inline comments.
Versions this was tested against. JDK 25 (Temurin 25.0.4.1+1), Spring Boot 4.1.1, Spring AI spring-ai-client-chat2.0.1 (GA; 2.1.0-M1 is a milestone), JUnit 6.1.3, Jackson 3 (tools.jackson). Code: the code-review-bot module of java-ai-agents. Seven tests, all green.
What is real and what is scripted. There is no API key in the sandbox that built this post, so every review below comes from ScriptedChatModel, a stand-in ChatModel that flags three patterns (a System.out.println, a hard-coded password, a TODO) and, when asked, invents comments on lines that do not exist. The webhook, signature check, chunker, cache, cap, filters and line guard are all real code running for real. Two things are not verified: how a real LLM behaves with this prompt, and GiteaHost against a live Gitea (its two endpoints were checked against Gitea 1.27.3’s own swagger document, but the tests do not call a server, because posting a review is a side effect a test suite should not have).
What goes wrong first: the model comments on a line that is not there
Language models are good at sounding sure and poor at counting. Ask one to review a diff and answer with line numbers, and sooner or later it returns a number that is not in the diff, or points at an unchanged context line, or is simply off by a few. A hosting service has no good way to attach a comment to a line that is not part of the change, so a naive bot either gets errors back or puts the comment in the wrong place. I did not test what a live Gitea does with such a comment; the bot never gets that far, because it checks first.
The test makes the scripted model misbehave on purpose: it invents a comment on line 9999 and another on line 1, which is a context line the prompt told it not to touch. Result:
posted line 2
posted line 5
dropped because the line is not an added line: 2
Transcript: output/04-hallucinated-lines-dropped.txt. Two real comments (lines 2 and 5) were posted, and the two invented ones were counted and discarded. The guard is simple: the chunker remembers which new-file line numbers are added lines (starting with +), and a comment is kept only if its line is in that set. It costs nothing and removes the whole class of problem.
The same idea guards against a second risk. A diff is untrusted text: a contributor can put instructions inside a code comment aimed at your model. I did not test prompt injection. What the design does limit is the damage: the model’s reply is parsed as plain line|severity|text lines and is never executed, and anything that does not match the format or the added-lines set is ignored. The worst a hostile diff can do here is produce a misleading comment on a line the author already changed.
The flow, end to end
Read the diagram left to right on the top row, then right to left on the bottom. The webhook handler must reply quickly, so it only verifies, queues and returns; the slow work (HTTP to the Git host, the model calls) happens in the background. The two boxes that matter most for your bill and your credibility are cache-and-cap (what gets sent) and filter (what gets posted).
Going deeper: shape of the bot
Module README — what each class does and what is deliberately not covered.
Step 1: trust the webhook only if the signature matches
A webhook endpoint is a public URL that causes your bot to spend money. Anyone who finds it can post to it, so the first job is proving the request came from your Git server. Gitea signs the request body with a shared secret using HMAC-SHA256 and sends the result in a header. You recompute it over the exact bytes you received and compare.
Source: Hmac.java. Two details matter. MessageDigest.isEqual compares in constant time, so an attacker cannot learn how many leading characters were right from response timing. And the controller takes @RequestBody byte[]: if you let a framework parse the JSON and then re-serialise it, whitespace and key order change and the signature can never match.
Gitea’s X-Gitea-Signature header is the bare lowercase hex digest. GitHub’s X-Hub-Signature-256 header carries a sha256= prefix in front of it. The bot is written for the Gitea form; to serve GitHub you would strip that prefix before comparing, and change the event names and payload fields. I checked the Gitea behaviour while building the module but did not receive a live delivery from a Gitea server in these tests, only synthetic requests.
wrong signature -> 401
no signature -> 401
comment event -> 200 ignored: pull_request_comment/synchronized
valid sync event -> 202 queued asmhatre/demo#7
jobs queued: 1, reviews posted before the job runs: 0
reviews posted after the job runs: 1
Transcript: output/07-webhook.txt, produced by sending four requests through MockMvc. A wrong or missing signature gets 401. A correctly signed event that the bot does not care about (a comment event) gets 200 and a note, so Gitea does not keep retrying. A correctly signed pull_request_sync gets 202 and exactly one job is queued, and no review has been posted yet: the last two lines show the review only happens when the queued job runs. That is the “answer fast” property, observed.
Source: WebhookController.java, abridged. One Spring detail cost a cycle: the controller takes the secret through @Value("${webhook.secret}"). With a plain String constructor parameter, component scanning cannot build the bean. The event names it accepts are pull_request with action opened or reopened, and pull_request_sync with action synchronized, which is how Gitea 1.27.3 reports a push to an open pull request.
Replay protection is not implemented: a captured valid request could be sent again. Gitea retries failed deliveries, so de-duplicating on the head SHA is a sensible next step; I did not build it.
Step 2: cut the diff into pieces the model can use
A pull request diff can be huge, and a model works best on a focused piece. The chunker does three things: it never splits a file across chunks or a line across chunks, it skips files nobody should review (lockfiles, minified and generated files, images, jars), and it prefixes every line with its line number in the new file, because that is the number a review comment needs.
Transcript: output/01-diff-chunks.txt, from the fixture diff in Fixtures.java. The diff touched a Java file and a package-lock.json; the lockfile was skipped (one skipped file) and the Java file became one chunk. Look at the numbering: context lines (spaces) and added lines (+) share one running count, which is the new file’s line count. Removed lines have no number. The set [2, 3, 5] is exactly the allow-list the line guard uses.
Chunks are also capped in size. With a 200-character limit, a 40-line added file splits like this:
Transcript: output/02-chunk-size-limit.txt. No chunk exceeds the limit and none ends mid-line. The production wiring uses 2,000 characters; the small value here just makes the splitting visible.
The core of the splitting rule, from DiffChunker.java: before appending a line, if it would overflow and the chunk is not empty, close the chunk first. A single line longer than the limit still becomes its own oversized chunk, which is a limit of this simple design.
Going deeper: chunking
A chunk with no added lines is dropped, so a hunk that only deletes code is never sent.
Reviewing a chunk in isolation loses context (the rest of the file, callers, tests). Sending surrounding code improves answers and raises cost; that trade-off is yours to tune.
Step 3: ask the model, in a format you can parse
The prompt tells the model what each line looks like and demands a rigid reply format:
static final String SYSTEM = """
You review a code diff. Each line is "<new-file-line>| <+ added | - removed | space context><code>".
Reply with zero or more lines in exactly this form and nothing else:
<line>|<MAJOR or MINOR or NIT>|<one sentence>
Only comment on lines that start with +. Reply NONE when there is nothing worth saying.
""";
From ReviewService.java. A pipe-separated line is deliberately dull: it is easy to parse, and a malformed line is just skipped. If your model supports structured output, typed responses in Spring AI are the stricter alternative. Running the whole service on the fixture diff with the scripted model:
comment src/Login.java:2 [MAJOR] This looks like a hard-coded credential.
comment src/Login.java:5 [MINOR] Use a logger instead of System.out.
summary: Summary[chunksTotal=1, skippedFiles=1, skippedByBudget=0, cacheHits=0, modelCalls=1, commentsPosted=2, droppedBadLine=0, droppedNits=1, approxInputChars=455]
Transcript: output/03-review-end-to-end.txt. The scripted model produced three findings; the TODO was a NIT and the severity filter dropped it (droppedNits=1), leaving two posted comments. The summary record is worth keeping in a real bot: every run reports calls, cache hits, drops and approximate characters sent, which is how you notice a cost problem.
Step 4: keep the bill down
Cost control is four mechanisms, all visible in the code and the tests: ignored files never reach the model, a hard cap on chunks per review, a cache so unchanged chunks are not re-paid, and a severity filter that drops nits. The cache key is a SHA-256 of the prompt version, the file name and the chunk text, so editing the prompt (bump PROMPT_VERSION) invalidates old answers and an untouched chunk is recognised on the next push. Three reviews of the same pull request:
first review: model calls 3, cache hits 0
same diff again: model calls 0, cache hits 3
one file edited: model calls 1, cache hits 2
Transcript: output/05-cache-saves-model-calls.txt. A three-file pull request costs three calls the first time. Pushing a commit that changes nothing in the diff costs zero. Editing one file costs one. This is the case that matters most in practice, because every push to an open pull request re-triggers the webhook.
The cap handles the other extreme, a giant pull request:
chunks in diff: 8
model calls: 3
chunks skipped by the cap: 5
approximate characters sent: 993
Transcript: output/06-chunk-cap.txt. Eight chunks, cap of three: three calls, five chunks silently unreviewed. “Silently” is the flaw to fix before relying on this: the summary reports skippedByBudget, but the review posted to the pull request does not yet tell the author that part of their change was not looked at. The characters figure counts the system prompt plus chunk text per call; it is characters, not tokens, and not a price.
Cache limits. The cache is an in-memory ConcurrentHashMap that grows without bound and is lost on restart. That is fine for a demonstration and wrong for a long-running service; swap it for a bounded or external cache before deploying.
A per-pull-request token budget measured with real usage metadata is more honest than a chunk count; the scripted model reports no usage, so I could not measure it here.
Step 5: post one review
The bot batches every surviving comment into a single review (one notification, not twenty). The Gitea call is POST /repos/{owner}/{repo}/pulls/{index}/reviews with comments[].new_position carrying the new-file line number the chunker computed, and the diff comes from GET /repos/{owner}/{repo}/pulls/{index}.diff.
From GiteaHost.java. The JSON is built by hand with a small quote helper. That is a shortcut: for production use a JSON library so escaping is not your job. This class compiles and is wired into the application, but the tests never call a live server, so treat it as the least-verified file in the module.
Reference: how to run it, and what you must add for a real deployment
$ git clone https://ankurm.com/git.app/asmhatre/java-ai-agents.git
$ cd java-ai-agents
$ mvn test -pl code-review-bot
The tests rewrite the seven files in code-review-bot/output. To run the application you must supply what the tests replace: a Spring AI model starter (for example spring-ai-starter-model-openai) so a real ChatModel bean exists, and the properties webhook.secret, gitea.api and gitea.token that ReviewBotApplication.java reads. Then create a webhook in your repository pointing at /webhook/gitea with the same secret. I did none of that live. Also not covered: review threading on later pushes, GitHub’s payload shape, and resolving earlier bot comments.
Should you even do this? A bot is worth it when your team’s pull requests wait hours for a first look and the usual comments are mechanical — logging, hard-coded secrets, missing null checks. It is not a replacement for a human reviewer, and a model that is wrong with confidence can cost reviewers more time than it saves. Start with the severity filter strict (MAJOR only), make the bot say what it did not review, and measure how many comments authors actually act on before widening it. Send your code to a hosted model only after checking what your company allows; the diff is your source code. And remember what this post did not test: a real model, a live Gitea and prompt injection.
No Comments yet!