Add code-review-bot module: Gitea webhook, diff chunking and a Spring AI reviewer with cost controls

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjfyTgmERTWLqzafu34Cth
This commit is contained in:
2026-10-14 06:52:09 +00:00
co-authored by Claude Sonnet 5.5
parent d5725f66ba
commit ef782060dd
23 changed files with 831 additions and 0 deletions
+2
View File
@@ -12,6 +12,7 @@ facts, so the build fails when a claim stops being true.
| `jlama` | Local LLM Inference in Pure Java with Jlama | load and run a 4-bit TinyLlama inside the JVM on the Vector API, tokens per second, streaming, and the same measurement against Ollama |
| `langgraph4j` | LangGraph4j: Stateful Agent Graphs in Java | a loan-approval graph with conditional edges, checkpoints, a human-in-the-loop pause and resume, and the `releaseThread` default that erases a finished run |
| `adk` | Google ADK for Java: Your First Multi-Agent App | two `LlmAgent`s in a `SequentialAgent`, a tool call, session state handed from one agent to the next, with a scripted stand-in model |
| `code-review-bot` | Build an AI Code-Review Bot in Java | a signed-webhook receiver, a diff chunker, a Spring AI reviewer with a cache and a chunk cap, and a guard that drops comments on lines the model invented |
## Versions (verified on Maven Central, 2026-10-11)
@@ -27,6 +28,7 @@ facts, so the build fails when a claim stops being true.
| Model | `distilbert/distilbert-base-uncased-finetuned-sst-2-english` | `onnx/model.onnx` (268 MB), `onnx/tokenizer.json`, `config.json` in `onnx-djl/models/sst2/` (git-ignored) |
| LangGraph4j `org.bsc.langgraph4j:langgraph4j-core` | 1.9.5 | verified on Maven Central 2026-10-14; `langgraph4j` module only |
| Google ADK `com.google.adk:google-adk` | 1.11.0 | verified on Maven Central 2026-10-14; `adk` module only; no Gemini key needed |
| Spring AI `spring-ai-client-chat` | 2.0.1 | `code-review-bot` only; GA, with 2.1.0-M1 a milestone as of 2026-10-14 |
| JDK | 25 LTS | |
| JUnit | 6.1.3 | |
+23
View File
@@ -0,0 +1,23 @@
# code-review-bot
Companion code for *Build an AI Code-Review Bot in Java (Gitea/GitHub Webhook + Spring AI)* on
[ankurm.com](https://ankurm.com). A webhook receiver that checks Gitea's HMAC signature, a diff chunker that numbers
lines of the new file, a Spring AI `ChatClient` that reviews each chunk, and a posting step that keeps only comments
that can be attached to a line. No API key: the tests use `ScriptedChatModel`, a scripted `ChatModel`.
```bash
mvn test -pl code-review-bot # rewrites output/*.txt
```
| File | Role |
|---|---|
| `WebhookController` | Verifies `X-Gitea-Signature` over the raw body, filters events, queues the review and answers `202` |
| `Hmac` | Bare lowercase hex HMAC-SHA256 with a constant-time comparison |
| `DiffChunker` | Unified diff to chunks: file boundaries respected, new-file line numbers, lockfiles and binaries skipped |
| `ReviewService` | The prompt, the content-keyed cache, the chunk cap, the severity filter, the "is this an added line?" guard |
| `GitHost`, `GiteaHost` | The two calls the bot needs; the Gitea endpoints were checked against Gitea 1.27.3's swagger. `GiteaHost` is not exercised against a live server by the tests |
Not covered here: running it against a real model, GitHub's payload (the `X-Hub-Signature-256` header carries a
`sha256=` prefix), and review threading on later pushes.
Verified with Spring Boot 4.1.1, Spring AI `spring-ai-client-chat` **2.0.1**, JDK 25, JUnit 6.1.3.
@@ -0,0 +1,9 @@
chunks: 1, skipped files: 1
file src/Login.java added lines [2, 3, 5]
1| class Login {
2| + String password = "hunter2";
3| + // TODO move to vault
4| void run() {
5| + System.out.println("login");
6| }
7| }
@@ -0,0 +1,5 @@
chunk 1: 199 chars, lines 1..13
chunk 2: 190 chars, lines 14..23
chunk 3: 190 chars, lines 24..33
chunk 4: 152 chars, lines 34..40
chunks: 4
@@ -0,0 +1,3 @@
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]
@@ -0,0 +1,3 @@
posted line 2
posted line 5
dropped because the line is not an added line: 2
@@ -0,0 +1,3 @@
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
+4
View File
@@ -0,0 +1,4 @@
chunks in diff: 8
model calls: 3
chunks skipped by the cap: 5
approximate characters sent: 993
+6
View File
@@ -0,0 +1,6 @@
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
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm.agents</groupId>
<artifactId>java-ai-agents</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>code-review-bot</artifactId>
<name>AI code-review bot: Gitea webhook plus Spring AI</name>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,97 @@
package com.ankurm.reviewbot;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
/**
* Turns a unified diff into review-sized chunks.
*
* <p>A chunk never crosses a file boundary and never splits a diff line. Each line is prefixed with
* its line number in the NEW file so the model can answer with a number the host can attach a
* comment to. Lockfiles and minified or generated files are skipped before anything is sent.
*/
public final class DiffChunker {
public record Chunk(String file, String text, Set<Integer> addedLines) {}
public record Result(List<Chunk> chunks, int skippedFiles) {}
private final int maxChars;
public DiffChunker(int maxChars) {
this.maxChars = maxChars;
}
static boolean ignored(String path) {
return path.endsWith(".lock") || path.endsWith("-lock.json") || path.endsWith(".min.js")
|| path.contains("/generated/") || path.endsWith(".png") || path.endsWith(".jar");
}
public Result chunk(String diff) {
List<Chunk> out = new ArrayList<>();
int skipped = 0;
String file = null;
boolean skipFile = false;
int newLine = 0;
StringBuilder text = new StringBuilder();
Set<Integer> added = new TreeSet<>();
for (String line : diff.split("\n", -1)) {
if (line.startsWith("diff --git ")) {
flush(out, file, text, added);
int b = line.lastIndexOf(" b/");
file = b >= 0 ? line.substring(b + 3) : line;
skipFile = ignored(file);
if (skipFile) {
skipped++;
}
continue;
}
if (file == null || skipFile) {
continue;
}
if (line.startsWith("@@")) {
int plus = line.indexOf('+');
int end = plus + 1;
while (end < line.length() && Character.isDigit(line.charAt(end))) {
end++;
}
newLine = Integer.parseInt(line.substring(plus + 1, end));
continue;
}
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("index ")
|| line.startsWith("new file") || line.startsWith("deleted file")) {
continue;
}
String rendered;
if (line.startsWith("+")) {
rendered = newLine + "| +" + line.substring(1);
added.add(newLine);
newLine++;
} else if (line.startsWith("-")) {
rendered = " | -" + line.substring(1);
} else if (line.startsWith(" ")) {
rendered = newLine + "| " + line.substring(1);
newLine++;
} else {
continue;
}
if (text.length() + rendered.length() + 1 > maxChars && text.length() > 0) {
flush(out, file, text, added);
}
text.append(rendered).append('\n');
}
flush(out, file, text, added);
return new Result(out, skipped);
}
private static void flush(List<Chunk> out, String file, StringBuilder text, Set<Integer> added) {
if (file != null && text.length() > 0 && !added.isEmpty()) {
out.add(new Chunk(file, text.toString(), new TreeSet<>(added)));
}
text.setLength(0);
added.clear();
}
}
@@ -0,0 +1,13 @@
package com.ankurm.reviewbot;
import java.util.List;
/** The two things the bot needs from a Git host. Gitea and GitHub differ only inside the implementation. */
public interface GitHost {
record InlineComment(String path, int line, String body) {}
String fetchDiff(String repo, int pullNumber);
void postReview(String repo, int pullNumber, String commitSha, String summary, List<InlineComment> comments);
}
@@ -0,0 +1,64 @@
package com.ankurm.reviewbot;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.stream.Collectors;
/**
* Gitea implementation. The two endpoints were checked against this server's own swagger document
* (Gitea 1.27.3): {@code GET /repos/{owner}/{repo}/pulls/{index}.diff} and
* {@code POST /repos/{owner}/{repo}/pulls/{index}/reviews} with {@code comments[].new_position}.
* It is compiled and used by the application but is not exercised against a live server by the tests,
* because posting a review to a real repository is a side effect a test suite should not have.
*/
public final class GiteaHost implements GitHost {
private final HttpClient http = HttpClient.newHttpClient();
private final String apiBase;
private final String token;
public GiteaHost(String apiBase, String token) {
this.apiBase = apiBase;
this.token = token;
}
@Override
public String fetchDiff(String repo, int pullNumber) {
var req = HttpRequest.newBuilder(URI.create(apiBase + "/repos/" + repo + "/pulls/" + pullNumber + ".diff"))
.header("Authorization", "token " + token).GET().build();
return send(req);
}
@Override
public void postReview(String repo, int pullNumber, String commitSha, String summary, List<InlineComment> comments) {
String items = comments.stream()
.map(c -> "{\"path\":" + quote(c.path()) + ",\"new_position\":" + c.line() + ",\"body\":" + quote(c.body()) + "}")
.collect(Collectors.joining(","));
String json = "{\"commit_id\":" + quote(commitSha) + ",\"event\":\"COMMENT\",\"body\":" + quote(summary)
+ ",\"comments\":[" + items + "]}";
var req = HttpRequest.newBuilder(URI.create(apiBase + "/repos/" + repo + "/pulls/" + pullNumber + "/reviews"))
.header("Authorization", "token " + token).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json)).build();
send(req);
}
private String send(HttpRequest req) {
try {
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) {
throw new IllegalStateException("Gitea answered " + res.statusCode() + ": " + res.body());
}
return res.body();
} catch (IOException | InterruptedException e) {
throw new IllegalStateException(e);
}
}
static String quote(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
}
}
@@ -0,0 +1,33 @@
package com.ankurm.reviewbot;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
/** Webhook signature check. Gitea sends the bare lowercase hex HMAC-SHA256 of the raw body. */
public final class Hmac {
private Hmac() {}
public static String sha256Hex(String secret, byte[] body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(body));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/** Constant-time comparison, so a wrong signature does not leak how many characters matched. */
public static boolean matches(String secret, byte[] body, String headerValue) {
if (headerValue == null) {
return false;
}
byte[] expected = sha256Hex(secret, body).getBytes(StandardCharsets.UTF_8);
byte[] actual = headerValue.trim().toLowerCase().getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(expected, actual);
}
}
@@ -0,0 +1,38 @@
package com.ankurm.reviewbot;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
/**
* Wiring for a real deployment. Add a Spring AI model starter (for example
* spring-ai-starter-model-openai) to get a {@link ChatModel} bean; the tests in this module use a scripted one.
*/
@SpringBootApplication
public class ReviewBotApplication {
public static void main(String[] args) {
SpringApplication.run(ReviewBotApplication.class, args);
}
@Bean
GitHost gitHost(@Value("${gitea.api}") String api, @Value("${gitea.token}") String token) {
return new GiteaHost(api, token);
}
@Bean
ReviewService reviewService(ChatModel model, GitHost host) {
return new ReviewService(ChatClient.create(model), host, new ReviewService.Settings(2_000, 10, true));
}
@Bean
Executor reviewExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
@@ -0,0 +1,105 @@
package com.ankurm.reviewbot;
import org.springframework.ai.chat.client.ChatClient;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Chunk the diff, ask the model about each chunk, keep only comments that can be attached, post one review.
*
* <p>Cost control lives here, in four places: ignored files never reach the model, a hard cap on
* chunks per review, a cache keyed by the chunk's content so an unchanged chunk is never paid for twice
* when a push re-triggers the review, and a severity filter so nits are dropped.
*/
public final class ReviewService {
public static final String PROMPT_VERSION = "v1";
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.
""";
public record Settings(int maxChunkChars, int maxChunks, boolean dropNits) {}
public record Summary(int chunksTotal, int skippedFiles, int skippedByBudget, int cacheHits, int modelCalls,
int commentsPosted, int droppedBadLine, int droppedNits, int approxInputChars) {}
private final ChatClient chat;
private final GitHost host;
private final Settings settings;
private final Map<String, List<String>> cache = new ConcurrentHashMap<>();
public ReviewService(ChatClient chat, GitHost host, Settings settings) {
this.chat = chat;
this.host = host;
this.settings = settings;
}
public Summary review(String repo, int pull, String headSha) {
String diff = host.fetchDiff(repo, pull);
var result = new DiffChunker(settings.maxChunkChars()).chunk(diff);
List<GitHost.InlineComment> comments = new ArrayList<>();
int calls = 0, hits = 0, badLine = 0, nits = 0, chars = 0;
int budgeted = Math.min(result.chunks().size(), settings.maxChunks());
for (int i = 0; i < budgeted; i++) {
var chunk = result.chunks().get(i);
String key = sha256(PROMPT_VERSION + "\n" + chunk.file() + "\n" + chunk.text());
List<String> replies = cache.get(key);
if (replies != null) {
hits++;
} else {
String answer = chat.prompt().system(SYSTEM).user("File: " + chunk.file() + "\n" + chunk.text())
.call().content();
calls++;
chars += SYSTEM.length() + chunk.text().length();
replies = List.of(answer == null ? new String[0] : answer.split("\n"));
cache.put(key, replies);
}
for (String line : replies) {
String[] p = line.split("\\|", 3);
if (p.length != 3) {
continue; // NONE, blank, or prose: ignore
}
int n;
try {
n = Integer.parseInt(p[0].trim());
} catch (NumberFormatException e) {
continue;
}
if (!chunk.addedLines().contains(n)) {
badLine++; // a line the model invented or a context line
continue;
}
if (settings.dropNits() && p[1].trim().equals("NIT")) {
nits++;
continue;
}
comments.add(new GitHost.InlineComment(chunk.file(), n, "[" + p[1].trim() + "] " + p[2].trim()));
}
}
if (!comments.isEmpty()) {
host.postReview(repo, pull, headSha, "Automated review: " + comments.size() + " comment(s).", comments);
}
return new Summary(result.chunks().size(), result.skippedFiles(),
result.chunks().size() - budgeted, hits, calls, comments.size(), badLine, nits, chars);
}
private static String sha256(String s) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,53 @@
package com.ankurm.reviewbot;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executor;
/**
* Receives Gitea webhooks. The body is taken as raw bytes because the signature is computed over
* exactly those bytes: parse first and re-serialise, and the HMAC no longer matches.
*/
@RestController
public class WebhookController {
private final JsonMapper json = JsonMapper.builder().build();
private final String secret;
private final Executor executor;
private final ReviewService service;
public WebhookController(@Value("${webhook.secret}") String secret, Executor reviewExecutor, ReviewService service) {
this.secret = secret;
this.executor = reviewExecutor;
this.service = service;
}
@PostMapping("/webhook/gitea")
public ResponseEntity<String> receive(@RequestHeader(value = "X-Gitea-Signature", required = false) String signature,
@RequestHeader(value = "X-Gitea-Event", required = false) String event,
@RequestBody byte[] body) {
if (!Hmac.matches(secret, body, signature)) {
return ResponseEntity.status(401).body("bad signature");
}
JsonNode root = json.readTree(new String(body, StandardCharsets.UTF_8));
String action = root.path("action").asString("");
boolean wanted = ("pull_request".equals(event) && (action.equals("opened") || action.equals("reopened")))
|| ("pull_request_sync".equals(event) && action.equals("synchronized"));
if (!wanted) {
return ResponseEntity.ok("ignored: " + event + "/" + action);
}
String repo = root.path("repository").path("full_name").asString();
int number = root.path("pull_request").path("number").asInt();
String sha = root.path("pull_request").path("head").path("sha").asString();
executor.execute(() -> service.review(repo, number, sha)); // answer fast, review in the background
return ResponseEntity.accepted().body("queued " + repo + "#" + number);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.reviewbot;
import java.util.ArrayList;
import java.util.List;
final class Fixtures {
private Fixtures() {}
/** A small PR: one Java file with two hunks, one lockfile, and a context line at new line 1. */
static final String DIFF = """
diff --git a/src/Login.java b/src/Login.java
index 111..222 100644
--- a/src/Login.java
+++ b/src/Login.java
@@ -1,4 +1,7 @@
class Login {
+ String password = "hunter2";
+ // TODO move to vault
void run() {
+ System.out.println("login");
}
}
diff --git a/package-lock.json b/package-lock.json
index 333..444 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,2 +1,3 @@
{
+ "lockfileVersion": 3,
}
""";
/** The webhook body Gitea sends for pull_request_sync, trimmed to the fields the bot reads. */
static final String PAYLOAD = """
{"action":"synchronized","repository":{"full_name":"asmhatre/demo"},
"pull_request":{"number":7,"head":{"sha":"abc123"}}}""";
/** A synthetic diff with {@code files} files of one added line each, for the budget test. */
static String manyFiles(int files) {
List<String> parts = new ArrayList<>();
for (int i = 1; i <= files; i++) {
parts.add("diff --git a/F" + i + ".java b/F" + i + ".java\n--- a/F" + i + ".java\n+++ b/F" + i
+ ".java\n@@ -0,0 +1,1 @@\n+System.out.println(" + i + ");");
}
return String.join("\n", parts) + "\n";
}
}
@@ -0,0 +1,25 @@
package com.ankurm.reviewbot;
import java.util.ArrayList;
import java.util.List;
/** Serves a canned diff and records the reviews the bot posts. */
final class RecordingGitHost implements GitHost {
String diff;
final List<InlineComment> posted = new ArrayList<>();
int reviewsPosted;
String lastCommit;
RecordingGitHost(String diff) { this.diff = diff; }
@Override public String fetchDiff(String repo, int pullNumber) { return diff; }
@Override
public void postReview(String repo, int pullNumber, String commitSha, String summary, List<InlineComment> comments) {
reviewsPosted++;
lastCommit = commitSha;
posted.clear();
posted.addAll(comments);
}
}
@@ -0,0 +1,175 @@
package com.ankurm.reviewbot;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
class ReviewBotTest {
private static ReviewService service(ScriptedChatModel model, RecordingGitHost host, int maxChunks, boolean dropNits) {
return new ReviewService(ChatClient.create(model), host, new ReviewService.Settings(2_000, maxChunks, dropNits));
}
@Test
void chunkerNumbersLinesOfTheNewFileAndSkipsLockfiles() throws Exception {
var t = new Transcript("01-diff-chunks.txt");
var r = new DiffChunker(2_000).chunk(Fixtures.DIFF);
t.line("chunks: " + r.chunks().size() + ", skipped files: " + r.skippedFiles());
for (var c : r.chunks()) {
t.line("file " + c.file() + " added lines " + c.addedLines());
c.text().lines().forEach(l -> t.line(" " + l));
}
t.write();
assertEquals(1, r.chunks().size());
assertEquals(1, r.skippedFiles());
assertEquals(java.util.Set.of(2, 3, 5), r.chunks().get(0).addedLines());
}
@Test
void chunkerNeverExceedsTheCharacterBudgetOrSplitsALine() throws Exception {
var t = new Transcript("02-chunk-size-limit.txt");
StringBuilder diff = new StringBuilder("diff --git a/Big.java b/Big.java\n--- a/Big.java\n+++ b/Big.java\n@@ -0,0 +1,40 @@\n");
for (int i = 0; i < 40; i++) {
diff.append("+int v").append(i).append(" = ").append(i).append(";\n");
}
var r = new DiffChunker(200).chunk(diff.toString());
for (int i = 0; i < r.chunks().size(); i++) {
t.line("chunk " + (i + 1) + ": " + r.chunks().get(i).text().length() + " chars, lines "
+ r.chunks().get(i).addedLines().iterator().next() + ".." + r.chunks().get(i).addedLines().stream().reduce((a, b) -> b).get());
}
t.line("chunks: " + r.chunks().size());
t.write();
assertTrue(r.chunks().size() > 1);
r.chunks().forEach(c -> assertTrue(c.text().length() <= 200));
}
@Test
void reviewPostsOnlyCommentsThatCanBeAttachedAndDropsNits() throws Exception {
var t = new Transcript("03-review-end-to-end.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.DIFF);
var s = service(model, host, 10, true).review("asmhatre/demo", 7, "abc123");
host.posted.forEach(c -> t.line("comment " + c.path() + ":" + c.line() + " " + c.body()));
t.line("summary: " + s);
t.write();
assertEquals(2, host.posted.size());
assertEquals(2, host.posted.get(0).line());
assertTrue(host.posted.get(0).body().startsWith("[MAJOR]"));
assertEquals(5, host.posted.get(1).line());
assertEquals(1, s.droppedNits());
assertEquals("abc123", host.lastCommit);
}
@Test
void commentsOnLinesTheModelInventedAreDroppedNotPosted() throws Exception {
var t = new Transcript("04-hallucinated-lines-dropped.txt");
var model = new ScriptedChatModel();
model.hallucinate = true;
var host = new RecordingGitHost(Fixtures.DIFF);
var s = service(model, host, 10, true).review("asmhatre/demo", 7, "abc123");
host.posted.forEach(c -> t.line("posted line " + c.line()));
t.line("dropped because the line is not an added line: " + s.droppedBadLine());
t.write();
assertEquals(2, s.droppedBadLine());
assertTrue(host.posted.stream().noneMatch(c -> c.line() == 9999 || c.line() == 1));
}
@Test
void anUnchangedChunkIsNotPaidForTwice() throws Exception {
var t = new Transcript("05-cache-saves-model-calls.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.manyFiles(3));
var svc = service(model, host, 10, true);
var first = svc.review("asmhatre/demo", 8, "sha1");
var again = svc.review("asmhatre/demo", 8, "sha2"); // a push that changed nothing in the diff
host.diff = Fixtures.manyFiles(3).replace("println(3)", "println(33)");
var changed = svc.review("asmhatre/demo", 8, "sha3"); // one chunk changed
t.line("first review: model calls " + first.modelCalls() + ", cache hits " + first.cacheHits());
t.line("same diff again: model calls " + again.modelCalls() + ", cache hits " + again.cacheHits());
t.line("one file edited: model calls " + changed.modelCalls() + ", cache hits " + changed.cacheHits());
t.write();
assertEquals(3, first.modelCalls());
assertEquals(0, again.modelCalls());
assertEquals(3, again.cacheHits());
assertEquals(1, changed.modelCalls());
}
@Test
void theChunkCapStopsTheSpend() throws Exception {
var t = new Transcript("06-chunk-cap.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.manyFiles(8));
var s = service(model, host, 3, true).review("asmhatre/demo", 9, "sha");
t.line("chunks in diff: " + s.chunksTotal());
t.line("model calls: " + s.modelCalls());
t.line("chunks skipped by the cap: " + s.skippedByBudget());
t.line("approximate characters sent: " + s.approxInputChars());
t.write();
assertEquals(8, s.chunksTotal());
assertEquals(3, s.modelCalls());
assertEquals(5, s.skippedByBudget());
}
@Test
void webhookChecksTheSignatureAndFiltersEvents() throws Exception {
var t = new Transcript("07-webhook.txt");
var model = new ScriptedChatModel();
var host = new RecordingGitHost(Fixtures.DIFF);
List<Runnable> queued = new ArrayList<>();
Executor capture = queued::add;
var controller = new WebhookController("s3cret", capture, service(model, host, 10, true));
MockMvc mvc = MockMvcBuilders.standaloneSetup(controller).build();
byte[] body = Fixtures.PAYLOAD.getBytes(StandardCharsets.UTF_8);
String good = Hmac.sha256Hex("s3cret", body);
int bad = mvc.perform(post("/webhook/gitea").header("X-Gitea-Signature", "00" + good.substring(2))
.header("X-Gitea-Event", "pull_request_sync").contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse().getStatus();
int none = mvc.perform(post("/webhook/gitea").header("X-Gitea-Event", "pull_request_sync")
.contentType(MediaType.APPLICATION_JSON).content(body)).andReturn().getResponse().getStatus();
var ignored = mvc.perform(post("/webhook/gitea").header("X-Gitea-Signature", good)
.header("X-Gitea-Event", "pull_request_comment").contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse();
var ok = mvc.perform(post("/webhook/gitea").header("X-Gitea-Signature", good)
.header("X-Gitea-Event", "pull_request_sync").contentType(MediaType.APPLICATION_JSON).content(body))
.andReturn().getResponse();
t.line("wrong signature -> " + bad);
t.line("no signature -> " + none);
t.line("comment event -> " + ignored.getStatus() + " " + ignored.getContentAsString());
t.line("valid sync event -> " + ok.getStatus() + " " + ok.getContentAsString());
t.line("jobs queued: " + queued.size() + ", reviews posted before the job runs: " + host.reviewsPosted);
queued.forEach(Runnable::run);
t.line("reviews posted after the job runs: " + host.reviewsPosted);
t.write();
assertEquals(401, bad);
assertEquals(401, none);
assertEquals(200, ignored.getStatus());
assertEquals(202, ok.getStatus());
assertEquals(1, queued.size());
assertEquals(1, host.reviewsPosted);
}
}
@@ -0,0 +1,53 @@
package com.ankurm.reviewbot;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
/**
* A stand-in reviewer, so the bot runs with no API key. It flags three things on lines that start
* with "+": System.out.println (MINOR), a hard-coded password (MAJOR), and TODO (NIT). When
* {@code hallucinate} is set it also invents a comment on line 9999 and on a context line.
*/
final class ScriptedChatModel implements ChatModel {
final List<String> prompts = new ArrayList<>();
boolean hallucinate;
@Override
public ChatResponse call(Prompt prompt) {
String text = prompt.getContents();
prompts.add(text);
StringBuilder out = new StringBuilder();
for (String line : text.split("\n")) {
int bar = line.indexOf("| +");
if (bar <= 0) {
continue;
}
String n = line.substring(0, bar).trim();
String code = line.substring(bar + 3);
if (code.contains("System.out.println")) {
out.append(n).append("|MINOR|Use a logger instead of System.out.\n");
}
if (code.toLowerCase().contains("password") && code.contains("=")) {
out.append(n).append("|MAJOR|This looks like a hard-coded credential.\n");
}
if (code.contains("TODO")) {
out.append(n).append("|NIT|Track this TODO in an issue.\n");
}
}
if (hallucinate) {
out.append("9999|MAJOR|A comment on a line that is not in this diff.\n");
out.append("1|MAJOR|A comment on a context line the model was told not to touch.\n");
}
if (out.length() == 0) {
out.append("NONE");
}
return new ChatResponse(List.of(new Generation(new AssistantMessage(out.toString()))));
}
}
@@ -0,0 +1,23 @@
package com.ankurm.reviewbot;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Collects lines and writes them to output/NN-name.txt, so the test IS the transcript. */
final class Transcript {
private final List<String> lines = new ArrayList<>();
private final String file;
Transcript(String file) { this.file = file; }
void line(String s) { lines.add(s); }
void write() throws IOException {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.write(dir.resolve(file), lines);
}
}
+2
View File
@@ -16,6 +16,7 @@
<module>onnx-djl</module>
<module>langgraph4j</module>
<module>adk</module>
<module>code-review-bot</module>
</modules>
<properties>
@@ -29,6 +30,7 @@
<!-- Verified on Maven Central 2026-10-14 -->
<langgraph4j.version>1.9.5</langgraph4j.version>
<adk.version>1.11.0</adk.version>
<spring-ai.version>2.0.1</spring-ai.version>
<onnxruntime.version>1.31.0</onnxruntime.version>
<spring-boot.version>4.1.1</spring-boot.version>
<junit.version>6.1.3</junit.version>