diff --git a/README.md b/README.md
index e2bfba5..249b463 100644
--- a/README.md
+++ b/README.md
@@ -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 | |
diff --git a/code-review-bot/README.md b/code-review-bot/README.md
new file mode 100644
index 0000000..4d4bc03
--- /dev/null
+++ b/code-review-bot/README.md
@@ -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.
diff --git a/code-review-bot/output/01-diff-chunks.txt b/code-review-bot/output/01-diff-chunks.txt
new file mode 100644
index 0000000..c2a4af3
--- /dev/null
+++ b/code-review-bot/output/01-diff-chunks.txt
@@ -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| }
diff --git a/code-review-bot/output/02-chunk-size-limit.txt b/code-review-bot/output/02-chunk-size-limit.txt
new file mode 100644
index 0000000..263ce89
--- /dev/null
+++ b/code-review-bot/output/02-chunk-size-limit.txt
@@ -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
diff --git a/code-review-bot/output/03-review-end-to-end.txt b/code-review-bot/output/03-review-end-to-end.txt
new file mode 100644
index 0000000..39cd788
--- /dev/null
+++ b/code-review-bot/output/03-review-end-to-end.txt
@@ -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]
diff --git a/code-review-bot/output/04-hallucinated-lines-dropped.txt b/code-review-bot/output/04-hallucinated-lines-dropped.txt
new file mode 100644
index 0000000..13a1bac
--- /dev/null
+++ b/code-review-bot/output/04-hallucinated-lines-dropped.txt
@@ -0,0 +1,3 @@
+posted line 2
+posted line 5
+dropped because the line is not an added line: 2
diff --git a/code-review-bot/output/05-cache-saves-model-calls.txt b/code-review-bot/output/05-cache-saves-model-calls.txt
new file mode 100644
index 0000000..750275a
--- /dev/null
+++ b/code-review-bot/output/05-cache-saves-model-calls.txt
@@ -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
diff --git a/code-review-bot/output/06-chunk-cap.txt b/code-review-bot/output/06-chunk-cap.txt
new file mode 100644
index 0000000..181e400
--- /dev/null
+++ b/code-review-bot/output/06-chunk-cap.txt
@@ -0,0 +1,4 @@
+chunks in diff: 8
+model calls: 3
+chunks skipped by the cap: 5
+approximate characters sent: 993
diff --git a/code-review-bot/output/07-webhook.txt b/code-review-bot/output/07-webhook.txt
new file mode 100644
index 0000000..8b2fff0
--- /dev/null
+++ b/code-review-bot/output/07-webhook.txt
@@ -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
diff --git a/code-review-bot/pom.xml b/code-review-bot/pom.xml
new file mode 100644
index 0000000..5cbfdb8
--- /dev/null
+++ b/code-review-bot/pom.xml
@@ -0,0 +1,44 @@
+
+
+ 4.0.0
+
+
+ com.ankurm.agents
+ java-ai-agents
+ 1.0.0
+
+
+ code-review-bot
+ AI code-review bot: Gitea webhook plus Spring AI
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring-boot.version}
+ pom
+ import
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.ai
+ spring-ai-client-chat
+ ${spring-ai.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/DiffChunker.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/DiffChunker.java
new file mode 100644
index 0000000..e59bec8
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/DiffChunker.java
@@ -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.
+ *
+ *
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 addedLines) {}
+
+ public record Result(List 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 out = new ArrayList<>();
+ int skipped = 0;
+ String file = null;
+ boolean skipFile = false;
+ int newLine = 0;
+ StringBuilder text = new StringBuilder();
+ Set 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 out, String file, StringBuilder text, Set added) {
+ if (file != null && text.length() > 0 && !added.isEmpty()) {
+ out.add(new Chunk(file, text.toString(), new TreeSet<>(added)));
+ }
+ text.setLength(0);
+ added.clear();
+ }
+}
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/GitHost.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/GitHost.java
new file mode 100644
index 0000000..4d5eb53
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/GitHost.java
@@ -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 comments);
+}
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/GiteaHost.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/GiteaHost.java
new file mode 100644
index 0000000..5a3c336
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/GiteaHost.java
@@ -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 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 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") + "\"";
+ }
+}
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/Hmac.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/Hmac.java
new file mode 100644
index 0000000..4e72dc5
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/Hmac.java
@@ -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);
+ }
+}
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/ReviewBotApplication.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/ReviewBotApplication.java
new file mode 100644
index 0000000..48ea9bf
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/ReviewBotApplication.java
@@ -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();
+ }
+}
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/ReviewService.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/ReviewService.java
new file mode 100644
index 0000000..32b6ff1
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/ReviewService.java
@@ -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.
+ *
+ * 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 "| <+ added | - removed | space context>".
+ Reply with zero or more lines in exactly this form and nothing else:
+ ||
+ 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> 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 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 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);
+ }
+ }
+}
diff --git a/code-review-bot/src/main/java/com/ankurm/reviewbot/WebhookController.java b/code-review-bot/src/main/java/com/ankurm/reviewbot/WebhookController.java
new file mode 100644
index 0000000..acfd927
--- /dev/null
+++ b/code-review-bot/src/main/java/com/ankurm/reviewbot/WebhookController.java
@@ -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 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);
+ }
+}
diff --git a/code-review-bot/src/test/java/com/ankurm/reviewbot/Fixtures.java b/code-review-bot/src/test/java/com/ankurm/reviewbot/Fixtures.java
new file mode 100644
index 0000000..33d837a
--- /dev/null
+++ b/code-review-bot/src/test/java/com/ankurm/reviewbot/Fixtures.java
@@ -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 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";
+ }
+}
diff --git a/code-review-bot/src/test/java/com/ankurm/reviewbot/RecordingGitHost.java b/code-review-bot/src/test/java/com/ankurm/reviewbot/RecordingGitHost.java
new file mode 100644
index 0000000..0f56b37
--- /dev/null
+++ b/code-review-bot/src/test/java/com/ankurm/reviewbot/RecordingGitHost.java
@@ -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 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 comments) {
+ reviewsPosted++;
+ lastCommit = commitSha;
+ posted.clear();
+ posted.addAll(comments);
+ }
+}
diff --git a/code-review-bot/src/test/java/com/ankurm/reviewbot/ReviewBotTest.java b/code-review-bot/src/test/java/com/ankurm/reviewbot/ReviewBotTest.java
new file mode 100644
index 0000000..6e058c0
--- /dev/null
+++ b/code-review-bot/src/test/java/com/ankurm/reviewbot/ReviewBotTest.java
@@ -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 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);
+ }
+}
diff --git a/code-review-bot/src/test/java/com/ankurm/reviewbot/ScriptedChatModel.java b/code-review-bot/src/test/java/com/ankurm/reviewbot/ScriptedChatModel.java
new file mode 100644
index 0000000..c5d26e0
--- /dev/null
+++ b/code-review-bot/src/test/java/com/ankurm/reviewbot/ScriptedChatModel.java
@@ -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 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()))));
+ }
+}
diff --git a/code-review-bot/src/test/java/com/ankurm/reviewbot/Transcript.java b/code-review-bot/src/test/java/com/ankurm/reviewbot/Transcript.java
new file mode 100644
index 0000000..5781e64
--- /dev/null
+++ b/code-review-bot/src/test/java/com/ankurm/reviewbot/Transcript.java
@@ -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 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);
+ }
+}
diff --git a/pom.xml b/pom.xml
index 61af776..b652cbc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,6 +16,7 @@
onnx-djl
langgraph4j
adk
+ code-review-bot
@@ -29,6 +30,7 @@
1.9.5
1.11.0
+ 2.0.1
1.31.0
4.1.1
6.1.3