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:
@@ -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 | |
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
chunks in diff: 8
|
||||
model calls: 3
|
||||
chunks skipped by the cap: 5
|
||||
approximate characters sent: 993
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user