Build a production-ready AI PR reviewer in Java with LangChain4j

Most AI pull request reviewers are a chat window with a job title. I wanted a Java system that returns a typed verdict, because APPROVE is a decision your code has to own.

Most AI pull request reviewers are a chat window with a job title. Someone pastes a diff. The model writes an essay. A human still has to decide whether that essay is a review. I wanted a Java system instead. Spec in, git diff in, ReviewVerdict out. APPROVE is a decision your code has to own. I built that slice inside an SDLC lab: Java 26, Spring Boot 4.1, LangChain4j 1.18.1 with agentic 1.18.1-beta28 . The lab is a six-role graph. This article is only the reviewer. Production is the same loop with a webhook around it, not a different personality for the model. Chat is not a review process The failure mode is familiar. The bot nags about files that are not in the diff. It requests a refactor you did not ask for. It APPROVEs a missing test because the prose sounded careful. Or it REQUEST_CHANGES forever because the prompt never defined what "done" means. None of that is an intelligence problem. It is a systems problem. A process has artifacts with shapes, gates that are not optional, and a cap on how many times you are allowed to argue. I already had the vocabulary on Agent UI Editor : sequence, conditional, loop. Those nodes are coordination, not intelligence. A PR reviewer is the same instinct pointed at a working tree. Mental model: five stages, one typed exit A production reviewer is a pipeline you can test with the model switched off. PR event. Webhook or poll. Key it by repo, pull request id, and head SHA so retries are idempotent. Fetch spec and diff. Ticket or AiSpec , plus a real unified diff. Not a chat summary of the change. Build gate. Run the project's allowlisted test command. Review after green, or say so if you review red builds on purpose. LangChain4j reviewer. An @Agent interface. Tools are optional. The default I use is none: the diff is already in the prompt. Reconcile and act. Java may drop noisy findings. Then post a comment, request changes, or escalate. Do not merge from APPROVE unless a human policy says so. The lab stops at artifacts under runs/<id>/ . Production is an adapter that turns ReviewVerdict into your Git host's review API. Same record. Different sink. The agent is an interface. The verdict is a record. LangChain4j agentic lets the reviewer be a Java interface, not a stringly-typed chat call. The method returns ReviewVerdict . Downstream code branches on typed fields. public interface PrReviewerAgent { @UserMessage(""" AI spec: {{aiSpec}} Git diff: {{gitDiff}} Conventions: {{conventions}} Return JSON for ReviewVerdict with decision (APPROVE or REQUEST_CHANGES), findings, and blockingCount. """) @Agent(description = "Reviews the git diff against the AI spec", outputKey = "reviewVerdict") ReviewVerdict review( @V("aiSpec") AiSpec aiSpec, @V("gitDiff") String gitDiff, @V("conventions") String conventions); } Wire it with AgenticServices.agentBuilder . System prompt lives in a file. Chat model is injected. Output key is the state slot the loop reads. return AgenticServices.agentBuilder(PrReviewerAgent.class) .chatModel(models.modelFor(role)) .name("pr-reviewer") .systemMessage(prompts.load(role.prompt())) .outputKey(StateKeys.REVIEW_VERDICT) .listener(context.listener()) .build(); That is enough to call a model. It is not enough to trust APPROVE. The compact constructor recomputes the decision. Findings with severity error or blocker count. If blockingCount > 0 , decision becomes REQUEST_CHANGES even if the model wrote APPROVE . public ReviewVerdict { decision = Objects.requireNonNullElse(decision, REQUEST_CHANGES) .trim().toUpperCase(); findings = findings == null ? List.of() : List.copyOf(findings); blockingCount = (int) findings.stream() .filter(ReviewFinding::blocking) .count(); if (blockingCount > 0) { decision = REQUEST_CHANGES; } else { decision = APPROVE; } } Prompts still matter. Mine restates the exit condition so the model is not fighting the record: cite files that appear in the diff, ignore build/ and .gradle/ , keep blockingCount aligned with blocking findings. The record still wins. Review only the diff. Then reconcile what the model got wrong. Never dump the whole tree into a prompt. Tech Lead and Developer get path-jailed file tools when they need them. The reviewer gets a truncated unified diff. In the lab that cap is 20,000 characters. Truncation is a feature. An unbounded diff is a context incident. Truncation creates a second failure mode: the model requests a file that the cap cut off, even when the planned tests already ran. I do not fix that in the prompt. I fix it in Java. After the agent returns, an agentAction reconciles the verdict against the spec, the build result, and the diff the model actually saw. Empty-diff noise drops. If the planned tests executed on a green gate, the loop can APPROVE. That split is the production rule. LLM proposes. Deterministic code disposes. The loop has a cap, or it is not production REQUEST_CHANGES is useless if nobody has to respond, and dangerous if it can spin forever. The L3 loop is LangChain4j loopBuilder plus conditionalBuilder . Grade the diff. If the verdict asks for changes, run Developer then the build gate, refresh the diff, review again. Exit when ReviewVerdict.approved() is true. Hit maxReviewCycles and the run escalates. It does not invent a merge. In production, "Developer" may be a human pushing a new SHA, not an agent with write tools. The graph stays the same. The worker is replaceable. The checker is not. What I would add before I pointed this at a real host The lab does not open pull requests on your service. That is the boundary that makes the sandbox useful. A host-facing system needs a thin shell around the same core. Concern What I would ship Trigger Signed webhook; ignore bots you do not trust Idempotency Dedupe on repo + pr + headSha Secrets Env or a secret store; never in prompts or logs Diff hygiene Skip generated trees; cap size; never log the patch Authz App token that can comment, not admin on the org Action Comment + request-changes; merge stays a human policy Audit Persist the spec, truncated diff hash, raw JSON, reconciled verdict Failure Parse errors become REQUEST_CHANGES or skip, not silent APPROVE sdlc-core has no LangChain4j and no Spring. Records and ports live there so the loop can be unit-tested without a model. Keep that split when you add GitHub or Bitbucket adapters. The host is an adapter. The verdict stays in the domain. When a chat bot is still enough This is the wrong tax for a two-line typo on a personal repo. If you only want a second pair of eyes and a human will always read the comment, a hosted review bot is simpler. Use a typed agent when incorrect APPROVE is expensive: missing tests on an HTTP contract, a leaked secret, a path jail break, spec drift you will otherwise discover in production. That is the same reason I put a real test command in front of the reviewer. The model does not get to grade a red build as done. Takeaways Return a record Java can branch on. Do not parse an essay for merge intent. Feed a real git diff and a spec with planned tests. Do not feed the whole repo. Put the build gate before review. It is an agentAction , not a chat model. Recompute APPROVE in a constructor, then reconcile against evidence. Bound the rework loop. Escalation is a status, not an infinite argument. Production is webhook, idempotency, and a comment adapter. It is not a new prompt. If you already run an AI reviewer on a real host, I am curious: does your pipeline own APPROVE in code, or do you still trust the last sentence of the model?