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