diff --git a/frontend b/frontend
index 9ce813e0..d97b8264 160000
--- a/frontend
+++ b/frontend
@@ -1 +1 @@
-Subproject commit 9ce813e0ad5798c7059cf6c23c08b4d0a36c74b5
+Subproject commit d97b826464e13edb3da8a2b9a3e4b32680f23001
diff --git a/java-ecosystem/libs/analysis-engine/pom.xml b/java-ecosystem/libs/analysis-engine/pom.xml
index 4d16d658..3fba0c89 100644
--- a/java-ecosystem/libs/analysis-engine/pom.xml
+++ b/java-ecosystem/libs/analysis-engine/pom.xml
@@ -68,6 +68,12 @@
okhttp
+
+
+ com.knuddels
+ jtokkit
+
+
org.junit.jupiter
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/module-info.java b/java-ecosystem/libs/analysis-engine/src/main/java/module-info.java
index b3e30345..d03cdf59 100644
--- a/java-ecosystem/libs/analysis-engine/src/main/java/module-info.java
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/module-info.java
@@ -18,6 +18,7 @@
requires com.fasterxml.jackson.annotation;
requires jakarta.persistence;
requires kotlin.stdlib;
+ requires jtokkit;
exports org.rostilos.codecrow.analysisengine.aiclient;
exports org.rostilos.codecrow.analysisengine.config;
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequestImpl.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequestImpl.java
index a8244eca..711027fc 100644
--- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequestImpl.java
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequestImpl.java
@@ -278,6 +278,29 @@ public T withPreviousAnalysisData(Optional optionalPreviousAnalysi
return self();
}
+ /**
+ * Set previous issues from ALL PR analysis versions.
+ * This provides the LLM with complete issue history including resolved issues,
+ * helping it understand what was already found and fixed.
+ *
+ * Issues are deduplicated by keeping only the most recent version of each issue.
+ * Resolved issues are included so the LLM knows what was already addressed.
+ *
+ * @param allPrAnalyses List of all analyses for this PR, ordered by version DESC (newest first)
+ */
+ public T withAllPrAnalysesData(List allPrAnalyses) {
+ if (allPrAnalyses == null || allPrAnalyses.isEmpty()) {
+ return self();
+ }
+
+ this.previousCodeAnalysisIssues = allPrAnalyses.stream()
+ .flatMap(analysis -> analysis.getIssues().stream())
+ .map(AiRequestPreviousIssueDTO::fromEntity)
+ .toList();
+
+ return self();
+ }
+
public T withMaxAllowedTokens(int maxAllowedTokens) {
this.maxAllowedTokens = maxAllowedTokens;
return self();
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTO.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTO.java
index 8d166455..d8bfedd9 100644
--- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTO.java
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTO.java
@@ -15,12 +15,23 @@ public record AiRequestPreviousIssueDTO(
String branch,
String pullRequestId,
String status, // open|resolved|ignored
- String category
+ String category,
+ // Resolution tracking fields
+ Integer prVersion, // Which PR iteration this issue was found in
+ String resolvedDescription, // Description of how the issue was resolved
+ String resolvedByCommit, // Commit hash that resolved the issue
+ Long resolvedInPrVersion // PR version where this was resolved (null if still open)
) {
public static AiRequestPreviousIssueDTO fromEntity(CodeAnalysisIssue issue) {
String categoryStr = issue.getIssueCategory() != null
? issue.getIssueCategory().name()
: IssueCategory.CODE_QUALITY.name();
+
+ Integer prVersion = null;
+ if (issue.getAnalysis() != null) {
+ prVersion = issue.getAnalysis().getPrVersion();
+ }
+
return new AiRequestPreviousIssueDTO(
String.valueOf(issue.getId()),
categoryStr,
@@ -33,7 +44,11 @@ public static AiRequestPreviousIssueDTO fromEntity(CodeAnalysisIssue issue) {
issue.getAnalysis() == null ? null : issue.getAnalysis().getBranchName(),
issue.getAnalysis() == null || issue.getAnalysis().getPrNumber() == null ? null : String.valueOf(issue.getAnalysis().getPrNumber()),
issue.isResolved() ? "resolved" : "open",
- categoryStr
+ categoryStr,
+ prVersion,
+ issue.getResolvedDescription(),
+ issue.getResolvedCommitHash(),
+ issue.getResolvedAnalysisId()
);
}
}
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/PrProcessRequest.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/PrProcessRequest.java
index 5dc47f1e..752efc2a 100644
--- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/PrProcessRequest.java
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/PrProcessRequest.java
@@ -35,6 +35,13 @@ public class PrProcessRequest implements AnalysisProcessRequest {
public String prAuthorId;
public String prAuthorUsername;
+
+ /**
+ * Optional pre-acquired lock key. If set, the processor will skip lock acquisition
+ * and use this lock key directly. This prevents double-locking when the webhook handler
+ * has already acquired the lock before calling the processor.
+ */
+ public String preAcquiredLockKey;
public Long getProjectId() {
@@ -64,4 +71,6 @@ public String getSourceBranchName() {
public String getPrAuthorId() { return prAuthorId; }
public String getPrAuthorUsername() { return prAuthorUsername; }
+
+ public String getPreAcquiredLockKey() { return preAcquiredLockKey; }
}
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/exception/DiffTooLargeException.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/exception/DiffTooLargeException.java
new file mode 100644
index 00000000..7304448c
--- /dev/null
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/exception/DiffTooLargeException.java
@@ -0,0 +1,47 @@
+package org.rostilos.codecrow.analysisengine.exception;
+
+/**
+ * Exception thrown when a diff exceeds the configured token limit for analysis.
+ * This is a soft skip - the analysis is not performed but the job is not marked as failed.
+ */
+public class DiffTooLargeException extends RuntimeException {
+
+ private final int estimatedTokens;
+ private final int maxAllowedTokens;
+ private final Long projectId;
+ private final Long pullRequestId;
+
+ public DiffTooLargeException(int estimatedTokens, int maxAllowedTokens, Long projectId, Long pullRequestId) {
+ super(String.format(
+ "PR diff exceeds token limit: estimated %d tokens, max allowed %d tokens (project=%d, PR=%d)",
+ estimatedTokens, maxAllowedTokens, projectId, pullRequestId
+ ));
+ this.estimatedTokens = estimatedTokens;
+ this.maxAllowedTokens = maxAllowedTokens;
+ this.projectId = projectId;
+ this.pullRequestId = pullRequestId;
+ }
+
+ public int getEstimatedTokens() {
+ return estimatedTokens;
+ }
+
+ public int getMaxAllowedTokens() {
+ return maxAllowedTokens;
+ }
+
+ public Long getProjectId() {
+ return projectId;
+ }
+
+ public Long getPullRequestId() {
+ return pullRequestId;
+ }
+
+ /**
+ * Returns the percentage of the token limit that would be used.
+ */
+ public double getUtilizationPercentage() {
+ return maxAllowedTokens > 0 ? (estimatedTokens * 100.0 / maxAllowedTokens) : 0;
+ }
+}
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessor.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessor.java
index 634f0419..5c7783c0 100644
--- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessor.java
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessor.java
@@ -29,6 +29,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -90,34 +91,45 @@ public Map process(
// Publish analysis started event
publishAnalysisStartedEvent(project, request, correlationId);
- Optional lockKey = analysisLockService.acquireLockWithWait(
- project,
- request.getSourceBranchName(),
- AnalysisLockType.PR_ANALYSIS,
- request.getCommitHash(),
- request.getPullRequestId(),
- consumer::accept
- );
-
- if (lockKey.isEmpty()) {
- String message = String.format(
- "Failed to acquire lock after %d minutes for project=%s, PR=%d, branch=%s. Another analysis is still in progress.",
- analysisLockService.getLockWaitTimeoutMinutes(),
- project.getId(),
- request.getPullRequestId(),
- request.getSourceBranchName()
- );
- log.warn(message);
-
- // Publish failed event due to lock timeout
- publishAnalysisCompletedEvent(project, request, correlationId, startTime,
- AnalysisCompletedEvent.CompletionStatus.FAILED, 0, 0, "Lock acquisition timeout");
-
- throw new AnalysisLockedException(
- AnalysisLockType.PR_ANALYSIS.name(),
+ // Check if a lock was already acquired by the caller (e.g., webhook handler)
+ // to prevent double-locking which causes unnecessary 2-minute waits
+ String lockKey;
+ boolean isPreAcquired = false;
+ if (request.getPreAcquiredLockKey() != null && !request.getPreAcquiredLockKey().isBlank()) {
+ lockKey = request.getPreAcquiredLockKey();
+ isPreAcquired = true;
+ log.info("Using pre-acquired lock: {} for project={}, PR={}", lockKey, project.getId(), request.getPullRequestId());
+ } else {
+ Optional acquiredLock = analysisLockService.acquireLockWithWait(
+ project,
request.getSourceBranchName(),
- project.getId()
+ AnalysisLockType.PR_ANALYSIS,
+ request.getCommitHash(),
+ request.getPullRequestId(),
+ consumer::accept
);
+
+ if (acquiredLock.isEmpty()) {
+ String message = String.format(
+ "Failed to acquire lock after %d minutes for project=%s, PR=%d, branch=%s. Another analysis is still in progress.",
+ analysisLockService.getLockWaitTimeoutMinutes(),
+ project.getId(),
+ request.getPullRequestId(),
+ request.getSourceBranchName()
+ );
+ log.warn(message);
+
+ // Publish failed event due to lock timeout
+ publishAnalysisCompletedEvent(project, request, correlationId, startTime,
+ AnalysisCompletedEvent.CompletionStatus.FAILED, 0, 0, "Lock acquisition timeout");
+
+ throw new AnalysisLockedException(
+ AnalysisLockType.PR_ANALYSIS.name(),
+ request.getSourceBranchName(),
+ project.getId()
+ );
+ }
+ lockKey = acquiredLock.get();
}
try {
@@ -139,16 +151,24 @@ public Map process(
return Map.of("status", "cached", "cached", true);
}
- Optional previousAnalysis = codeAnalysisService.getPreviousVersionCodeAnalysis(
+ // Get all previous analyses for this PR to provide full issue history to AI
+ List allPrAnalyses = codeAnalysisService.getAllPrAnalyses(
project.getId(),
request.getPullRequestId()
);
+
+ // Get the most recent analysis for incremental diff calculation
+ Optional previousAnalysis = allPrAnalyses.isEmpty()
+ ? Optional.empty()
+ : Optional.of(allPrAnalyses.get(0));
- // Ensure branch index exists for target branch if configured
+ // Ensure branch index exists for TARGET branch (e.g., "1.2.1-rc")
+ // This is where the PR will merge TO - we want RAG context from this branch
ensureRagIndexForTargetBranch(project, request.getTargetBranchName(), consumer);
VcsAiClientService aiClientService = vcsServiceFactory.getAiClientService(provider);
- AiAnalysisRequest aiRequest = aiClientService.buildAiAnalysisRequest(project, request, previousAnalysis);
+ AiAnalysisRequest aiRequest = aiClientService.buildAiAnalysisRequest(
+ project, request, previousAnalysis, allPrAnalyses);
Map aiResponse = aiAnalysisClient.performAnalysis(aiRequest, event -> {
try {
@@ -208,7 +228,9 @@ public Map process(
return Map.of("status", "error", "message", e.getMessage());
} finally {
- analysisLockService.releaseLock(lockKey.get());
+ if (!isPreAcquired) {
+ analysisLockService.releaseLock(lockKey);
+ }
}
}
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsAiClientService.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsAiClientService.java
index 1ee29fbd..3b894fa9 100644
--- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsAiClientService.java
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsAiClientService.java
@@ -7,6 +7,7 @@
import org.rostilos.codecrow.analysisengine.dto.request.processor.AnalysisProcessRequest;
import java.security.GeneralSecurityException;
+import java.util.List;
import java.util.Optional;
/**
@@ -31,4 +32,23 @@ AiAnalysisRequest buildAiAnalysisRequest(
AnalysisProcessRequest request,
Optional previousAnalysis
) throws GeneralSecurityException;
+
+ /**
+ * Builds an AI analysis request with full PR issue history.
+ *
+ * @param project The project being analyzed
+ * @param request The analysis process request
+ * @param previousAnalysis Optional previous analysis for incremental analysis (used for delta diff calculation)
+ * @param allPrAnalyses All analyses for this PR, ordered by version DESC (for issue history)
+ * @return The AI analysis request ready to be sent to the AI client
+ */
+ default AiAnalysisRequest buildAiAnalysisRequest(
+ Project project,
+ AnalysisProcessRequest request,
+ Optional previousAnalysis,
+ List allPrAnalyses
+ ) throws GeneralSecurityException {
+ // Default implementation falls back to the previous method for backward compatibility
+ return buildAiAnalysisRequest(project, request, previousAnalysis);
+ }
}
diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/TokenEstimator.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/TokenEstimator.java
new file mode 100644
index 00000000..4ccb613e
--- /dev/null
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/TokenEstimator.java
@@ -0,0 +1,83 @@
+package org.rostilos.codecrow.analysisengine.util;
+
+import com.knuddels.jtokkit.Encodings;
+import com.knuddels.jtokkit.api.Encoding;
+import com.knuddels.jtokkit.api.EncodingRegistry;
+import com.knuddels.jtokkit.api.EncodingType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class for estimating token counts in text content.
+ * Uses the cl100k_base encoding (used by GPT-4, Claude, and most modern LLMs).
+ */
+public class TokenEstimator {
+ private static final Logger log = LoggerFactory.getLogger(TokenEstimator.class);
+
+ private static final EncodingRegistry ENCODING_REGISTRY = Encodings.newDefaultEncodingRegistry();
+ private static final Encoding ENCODING = ENCODING_REGISTRY.getEncoding(EncodingType.CL100K_BASE);
+
+ /**
+ * Estimate the number of tokens in the given text.
+ *
+ * @param text The text to estimate tokens for
+ * @return The estimated token count, or 0 if text is null/empty
+ */
+ public static int estimateTokens(String text) {
+ if (text == null || text.isEmpty()) {
+ return 0;
+ }
+ try {
+ return ENCODING.countTokens(text);
+ } catch (Exception e) {
+ log.warn("Failed to count tokens, using fallback estimation: {}", e.getMessage());
+ // Fallback: rough estimate of ~4 characters per token
+ return text.length() / 4;
+ }
+ }
+
+ /**
+ * Check if the estimated token count exceeds the given limit.
+ *
+ * @param text The text to check
+ * @param maxTokens The maximum allowed tokens
+ * @return true if the text exceeds the limit, false otherwise
+ */
+ public static boolean exceedsLimit(String text, int maxTokens) {
+ return estimateTokens(text) > maxTokens;
+ }
+
+ /**
+ * Result of a token estimation check with details.
+ */
+ public record TokenEstimationResult(
+ int estimatedTokens,
+ int maxAllowedTokens,
+ boolean exceedsLimit,
+ double utilizationPercentage
+ ) {
+ public String toLogString() {
+ return String.format("Tokens: %d / %d (%.1f%%) - %s",
+ estimatedTokens, maxAllowedTokens, utilizationPercentage,
+ exceedsLimit ? "EXCEEDS LIMIT" : "within limit");
+ }
+ }
+
+ /**
+ * Estimate tokens and check against limit, returning detailed result.
+ *
+ * @param text The text to check
+ * @param maxTokens The maximum allowed tokens
+ * @return Detailed estimation result
+ */
+ public static TokenEstimationResult estimateAndCheck(String text, int maxTokens) {
+ int estimated = estimateTokens(text);
+ double utilization = maxTokens > 0 ? (estimated * 100.0 / maxTokens) : 0;
+ return new TokenEstimationResult(
+ estimated,
+ maxTokens,
+ estimated > maxTokens,
+ utilization
+ );
+ }
+}
diff --git a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTOTest.java b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTOTest.java
index 2fe1aca2..d36d1c74 100644
--- a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTOTest.java
+++ b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiRequestPreviousIssueDTOTest.java
@@ -30,7 +30,11 @@ void shouldCreateRecordWithAllFields() {
"main",
"100",
"open",
- "SECURITY"
+ "SECURITY",
+ 1, // prVersion
+ null, // resolvedDescription
+ null, // resolvedByCommit
+ null // resolvedInPrVersion
);
assertThat(dto.id()).isEqualTo("123");
@@ -45,13 +49,14 @@ void shouldCreateRecordWithAllFields() {
assertThat(dto.pullRequestId()).isEqualTo("100");
assertThat(dto.status()).isEqualTo("open");
assertThat(dto.category()).isEqualTo("SECURITY");
+ assertThat(dto.prVersion()).isEqualTo(1);
}
@Test
@DisplayName("should handle null values")
void shouldHandleNullValues() {
AiRequestPreviousIssueDTO dto = new AiRequestPreviousIssueDTO(
- null, null, null, null, null, null, null, null, null, null, null, null
+ null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null
);
assertThat(dto.id()).isNull();
@@ -64,13 +69,13 @@ void shouldHandleNullValues() {
@DisplayName("should implement equals correctly")
void shouldImplementEqualsCorrectly() {
AiRequestPreviousIssueDTO dto1 = new AiRequestPreviousIssueDTO(
- "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat"
+ "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat", 1, null, null, null
);
AiRequestPreviousIssueDTO dto2 = new AiRequestPreviousIssueDTO(
- "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat"
+ "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat", 1, null, null, null
);
AiRequestPreviousIssueDTO dto3 = new AiRequestPreviousIssueDTO(
- "2", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat"
+ "2", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat", 1, null, null, null
);
assertThat(dto1).isEqualTo(dto2);
@@ -81,10 +86,10 @@ void shouldImplementEqualsCorrectly() {
@DisplayName("should implement hashCode correctly")
void shouldImplementHashCodeCorrectly() {
AiRequestPreviousIssueDTO dto1 = new AiRequestPreviousIssueDTO(
- "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat"
+ "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat", 1, null, null, null
);
AiRequestPreviousIssueDTO dto2 = new AiRequestPreviousIssueDTO(
- "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat"
+ "1", "type", "HIGH", "reason", "fix", "diff", "file.java", 10, "main", "1", "open", "cat", 1, null, null, null
);
assertThat(dto1.hashCode()).isEqualTo(dto2.hashCode());
@@ -94,10 +99,14 @@ void shouldImplementHashCodeCorrectly() {
@DisplayName("should support resolved status")
void shouldSupportResolvedStatus() {
AiRequestPreviousIssueDTO dto = new AiRequestPreviousIssueDTO(
- "1", "type", "LOW", "reason", null, null, "file.java", 5, "dev", "2", "resolved", "CODE_QUALITY"
+ "1", "type", "LOW", "reason", null, null, "file.java", 5, "dev", "2", "resolved", "CODE_QUALITY",
+ 1, "Fixed by adding null check", "abc123", 2L
);
assertThat(dto.status()).isEqualTo("resolved");
+ assertThat(dto.resolvedDescription()).isEqualTo("Fixed by adding null check");
+ assertThat(dto.resolvedByCommit()).isEqualTo("abc123");
+ assertThat(dto.resolvedInPrVersion()).isEqualTo(2L);
}
@Nested
@@ -110,6 +119,7 @@ void shouldConvertEntityWithAllFields() {
CodeAnalysis analysis = mock(CodeAnalysis.class);
when(analysis.getBranchName()).thenReturn("feature-branch");
when(analysis.getPrNumber()).thenReturn(42L);
+ when(analysis.getPrVersion()).thenReturn(2);
CodeAnalysisIssue issue = mock(CodeAnalysisIssue.class);
when(issue.getId()).thenReturn(123L);
@@ -122,6 +132,9 @@ void shouldConvertEntityWithAllFields() {
when(issue.getFilePath()).thenReturn("src/Main.java");
when(issue.getLineNumber()).thenReturn(50);
when(issue.isResolved()).thenReturn(false);
+ when(issue.getResolvedDescription()).thenReturn(null);
+ when(issue.getResolvedCommitHash()).thenReturn(null);
+ when(issue.getResolvedAnalysisId()).thenReturn(null);
AiRequestPreviousIssueDTO dto = AiRequestPreviousIssueDTO.fromEntity(issue);
@@ -137,14 +150,16 @@ void shouldConvertEntityWithAllFields() {
assertThat(dto.pullRequestId()).isEqualTo("42");
assertThat(dto.status()).isEqualTo("open");
assertThat(dto.category()).isEqualTo("SECURITY");
+ assertThat(dto.prVersion()).isEqualTo(2);
}
@Test
- @DisplayName("should convert resolved entity")
+ @DisplayName("should convert resolved entity with resolution tracking")
void shouldConvertResolvedEntity() {
CodeAnalysis analysis = mock(CodeAnalysis.class);
when(analysis.getBranchName()).thenReturn("main");
when(analysis.getPrNumber()).thenReturn(10L);
+ when(analysis.getPrVersion()).thenReturn(3);
CodeAnalysisIssue issue = mock(CodeAnalysisIssue.class);
when(issue.getId()).thenReturn(456L);
@@ -155,10 +170,17 @@ void shouldConvertResolvedEntity() {
when(issue.getFilePath()).thenReturn("src/Utils.java");
when(issue.getLineNumber()).thenReturn(10);
when(issue.isResolved()).thenReturn(true);
+ when(issue.getResolvedDescription()).thenReturn("Fixed by refactoring");
+ when(issue.getResolvedCommitHash()).thenReturn("abc123def");
+ when(issue.getResolvedAnalysisId()).thenReturn(5L);
AiRequestPreviousIssueDTO dto = AiRequestPreviousIssueDTO.fromEntity(issue);
assertThat(dto.status()).isEqualTo("resolved");
+ assertThat(dto.prVersion()).isEqualTo(3);
+ assertThat(dto.resolvedDescription()).isEqualTo("Fixed by refactoring");
+ assertThat(dto.resolvedByCommit()).isEqualTo("abc123def");
+ assertThat(dto.resolvedInPrVersion()).isEqualTo(5L);
}
@Test
@@ -167,6 +189,7 @@ void shouldHandleNullIssueCategoryWithDefault() {
CodeAnalysis analysis = mock(CodeAnalysis.class);
when(analysis.getBranchName()).thenReturn("main");
when(analysis.getPrNumber()).thenReturn(1L);
+ when(analysis.getPrVersion()).thenReturn(1);
CodeAnalysisIssue issue = mock(CodeAnalysisIssue.class);
when(issue.getId()).thenReturn(1L);
@@ -186,6 +209,7 @@ void shouldHandleNullIssueCategoryWithDefault() {
void shouldHandleNullSeverity() {
CodeAnalysis analysis = mock(CodeAnalysis.class);
when(analysis.getBranchName()).thenReturn("main");
+ when(analysis.getPrVersion()).thenReturn(1);
CodeAnalysisIssue issue = mock(CodeAnalysisIssue.class);
when(issue.getId()).thenReturn(2L);
@@ -213,6 +237,7 @@ void shouldHandleNullAnalysis() {
assertThat(dto.branch()).isNull();
assertThat(dto.pullRequestId()).isNull();
+ assertThat(dto.prVersion()).isNull();
}
@Test
@@ -221,6 +246,7 @@ void shouldHandleAnalysisWithNullPrNumber() {
CodeAnalysis analysis = mock(CodeAnalysis.class);
when(analysis.getBranchName()).thenReturn("develop");
when(analysis.getPrNumber()).thenReturn(null);
+ when(analysis.getPrVersion()).thenReturn(1);
CodeAnalysisIssue issue = mock(CodeAnalysisIssue.class);
when(issue.getId()).thenReturn(4L);
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/ai/AIConnectionDTO.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/ai/AIConnectionDTO.java
index b9e16fb5..b04b4434 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/ai/AIConnectionDTO.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/ai/AIConnectionDTO.java
@@ -11,8 +11,7 @@ public record AIConnectionDTO(
AIProviderKey providerKey,
String aiModel,
OffsetDateTime createdAt,
- OffsetDateTime updatedAt,
- int tokenLimitation
+ OffsetDateTime updatedAt
) {
public static AIConnectionDTO fromAiConnection(AIConnection aiConnection) {
@@ -22,8 +21,7 @@ public static AIConnectionDTO fromAiConnection(AIConnection aiConnection) {
aiConnection.getProviderKey(),
aiConnection.getAiModel(),
aiConnection.getCreatedAt(),
- aiConnection.getUpdatedAt(),
- aiConnection.getTokenLimitation()
+ aiConnection.getUpdatedAt()
);
}
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/project/ProjectDTO.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/project/ProjectDTO.java
index 9c2a70a3..98027a0d 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/project/ProjectDTO.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/project/ProjectDTO.java
@@ -32,7 +32,8 @@ public record ProjectDTO(
String installationMethod,
CommentCommandsConfigDTO commentCommandsConfig,
Boolean webhooksConfigured,
- Long qualityGateId
+ Long qualityGateId,
+ Integer maxAnalysisTokenLimit
) {
public static ProjectDTO fromProject(Project project) {
Long vcsConnectionId = null;
@@ -123,6 +124,9 @@ public static ProjectDTO fromProject(Project project) {
if (project.getVcsRepoBinding() != null) {
webhooksConfigured = project.getVcsRepoBinding().isWebhooksConfigured();
}
+
+ // Get maxAnalysisTokenLimit from config
+ Integer maxAnalysisTokenLimit = config != null ? config.maxAnalysisTokenLimit() : ProjectConfig.DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT;
return new ProjectDTO(
project.getId(),
@@ -146,7 +150,8 @@ public static ProjectDTO fromProject(Project project) {
installationMethod,
commentCommandsConfigDTO,
webhooksConfigured,
- project.getQualityGate() != null ? project.getQualityGate().getId() : null
+ project.getQualityGate() != null ? project.getQualityGate().getId() : null,
+ maxAnalysisTokenLimit
);
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIConnection.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIConnection.java
index 2ca682c3..f6558f75 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIConnection.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIConnection.java
@@ -39,9 +39,6 @@ public class AIConnection {
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt = OffsetDateTime.now();
- @Column(name= "token_limitation", nullable = false)
- private int tokenLimitation = 100000;
-
@PreUpdate
public void onUpdate() {
this.updatedAt = OffsetDateTime.now();
@@ -98,12 +95,4 @@ public OffsetDateTime getCreatedAt() {
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
-
- public void setTokenLimitation(int tokenLimitation) {
- this.tokenLimitation = tokenLimitation;
- }
-
- public int getTokenLimitation() {
- return tokenLimitation;
- }
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/Project.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/Project.java
index 1a956edd..b12b4227 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/Project.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/Project.java
@@ -222,6 +222,15 @@ public void setConfiguration(org.rostilos.codecrow.core.model.project.config.Pro
this.configuration = configuration;
}
+ /**
+ * Returns the effective project configuration.
+ * If configuration is null, returns a new default ProjectConfig.
+ * This ensures callers always get a valid config with default values.
+ */
+ public org.rostilos.codecrow.core.model.project.config.ProjectConfig getEffectiveConfig() {
+ return configuration != null ? configuration : new org.rostilos.codecrow.core.model.project.config.ProjectConfig();
+ }
+
public org.rostilos.codecrow.core.model.branch.Branch getDefaultBranch() {
return defaultBranch;
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/config/ProjectConfig.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/config/ProjectConfig.java
index 66335185..99d18764 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/config/ProjectConfig.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/config/ProjectConfig.java
@@ -24,6 +24,8 @@
* - branchAnalysisEnabled: whether to analyze branch pushes (default: true).
* - installationMethod: how the project integration is installed (WEBHOOK, PIPELINE, GITHUB_ACTION).
* - commentCommands: configuration for PR comment-triggered commands (/codecrow analyze, summarize, ask).
+ * - maxAnalysisTokenLimit: maximum allowed tokens for PR analysis (default: 200000).
+ * Analysis will be skipped if the diff exceeds this limit.
*
* @see BranchAnalysisConfig
* @see RagConfig
@@ -32,6 +34,8 @@
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class ProjectConfig {
+ public static final int DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT = 200000;
+
@JsonProperty("useLocalMcp")
private boolean useLocalMcp;
@@ -56,16 +60,27 @@ public class ProjectConfig {
private InstallationMethod installationMethod;
@JsonProperty("commentCommands")
private CommentCommandsConfig commentCommands;
+ @JsonProperty("maxAnalysisTokenLimit")
+ private Integer maxAnalysisTokenLimit;
public ProjectConfig() {
this.useLocalMcp = false;
this.prAnalysisEnabled = true;
this.branchAnalysisEnabled = true;
+ this.maxAnalysisTokenLimit = DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT;
}
public ProjectConfig(boolean useLocalMcp, String mainBranch, BranchAnalysisConfig branchAnalysis,
RagConfig ragConfig, Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled,
InstallationMethod installationMethod, CommentCommandsConfig commentCommands) {
+ this(useLocalMcp, mainBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled,
+ installationMethod, commentCommands, DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT);
+ }
+
+ public ProjectConfig(boolean useLocalMcp, String mainBranch, BranchAnalysisConfig branchAnalysis,
+ RagConfig ragConfig, Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled,
+ InstallationMethod installationMethod, CommentCommandsConfig commentCommands,
+ Integer maxAnalysisTokenLimit) {
this.useLocalMcp = useLocalMcp;
this.mainBranch = mainBranch;
this.defaultBranch = mainBranch; // Keep in sync for backward compatibility
@@ -75,6 +90,7 @@ public ProjectConfig(boolean useLocalMcp, String mainBranch, BranchAnalysisConfi
this.branchAnalysisEnabled = branchAnalysisEnabled;
this.installationMethod = installationMethod;
this.commentCommands = commentCommands;
+ this.maxAnalysisTokenLimit = maxAnalysisTokenLimit != null ? maxAnalysisTokenLimit : DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT;
}
public ProjectConfig(boolean useLocalMcp, String mainBranch) {
@@ -112,6 +128,14 @@ public String defaultBranch() {
public InstallationMethod installationMethod() { return installationMethod; }
public CommentCommandsConfig commentCommands() { return commentCommands; }
+ /**
+ * Get the maximum token limit for PR analysis.
+ * Returns the configured value or the default (200000) if not set.
+ */
+ public int maxAnalysisTokenLimit() {
+ return maxAnalysisTokenLimit != null ? maxAnalysisTokenLimit : DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT;
+ }
+
// Setters for Jackson
public void setUseLocalMcp(boolean useLocalMcp) { this.useLocalMcp = useLocalMcp; }
@@ -149,6 +173,9 @@ public void setDefaultBranch(String defaultBranch) {
public void setBranchAnalysisEnabled(Boolean branchAnalysisEnabled) { this.branchAnalysisEnabled = branchAnalysisEnabled; }
public void setInstallationMethod(InstallationMethod installationMethod) { this.installationMethod = installationMethod; }
public void setCommentCommands(CommentCommandsConfig commentCommands) { this.commentCommands = commentCommands; }
+ public void setMaxAnalysisTokenLimit(Integer maxAnalysisTokenLimit) {
+ this.maxAnalysisTokenLimit = maxAnalysisTokenLimit != null ? maxAnalysisTokenLimit : DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT;
+ }
public void ensureMainBranchInPatterns() {
String main = mainBranch();
@@ -230,13 +257,15 @@ public boolean equals(Object o) {
Objects.equals(prAnalysisEnabled, that.prAnalysisEnabled) &&
Objects.equals(branchAnalysisEnabled, that.branchAnalysisEnabled) &&
installationMethod == that.installationMethod &&
- Objects.equals(commentCommands, that.commentCommands);
+ Objects.equals(commentCommands, that.commentCommands) &&
+ Objects.equals(maxAnalysisTokenLimit, that.maxAnalysisTokenLimit);
}
@Override
public int hashCode() {
return Objects.hash(useLocalMcp, mainBranch, branchAnalysis, ragConfig,
- prAnalysisEnabled, branchAnalysisEnabled, installationMethod, commentCommands);
+ prAnalysisEnabled, branchAnalysisEnabled, installationMethod,
+ commentCommands, maxAnalysisTokenLimit);
}
@Override
@@ -250,6 +279,7 @@ public String toString() {
", branchAnalysisEnabled=" + branchAnalysisEnabled +
", installationMethod=" + installationMethod +
", commentCommands=" + commentCommands +
+ ", maxAnalysisTokenLimit=" + maxAnalysisTokenLimit +
'}';
}
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/CommentCommandRateLimitRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/CommentCommandRateLimitRepository.java
index 9118391f..35527873 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/CommentCommandRateLimitRepository.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/analysis/CommentCommandRateLimitRepository.java
@@ -31,4 +31,22 @@ int countCommandsInWindow(
@Param("projectId") Long projectId,
@Param("windowStart") OffsetDateTime windowStart
);
+
+ /**
+ * Atomic upsert: increments command count if record exists, creates with count=1 if not.
+ * Uses PostgreSQL ON CONFLICT DO UPDATE to avoid race conditions.
+ */
+ @Modifying
+ @Query(value = """
+ INSERT INTO comment_command_rate_limit (project_id, window_start, command_count, last_command_at)
+ VALUES (:projectId, :windowStart, 1, NOW())
+ ON CONFLICT (project_id, window_start)
+ DO UPDATE SET
+ command_count = comment_command_rate_limit.command_count + 1,
+ last_command_at = NOW()
+ """, nativeQuery = true)
+ void upsertCommandCount(
+ @Param("projectId") Long projectId,
+ @Param("windowStart") OffsetDateTime windowStart
+ );
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/codeanalysis/CodeAnalysisRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/codeanalysis/CodeAnalysisRepository.java
index dbab64ca..27aff62d 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/codeanalysis/CodeAnalysisRepository.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/codeanalysis/CodeAnalysisRepository.java
@@ -111,4 +111,12 @@ Page searchAnalyses(
})
@Query("SELECT ca FROM CodeAnalysis ca WHERE ca.id = :id")
Optional findByIdWithIssues(@Param("id") Long id);
+
+ /**
+ * Find all analyses for a PR across all versions, ordered by version descending.
+ * Used to provide LLM with full issue history including resolved issues.
+ */
+ @org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"issues"})
+ @Query("SELECT ca FROM CodeAnalysis ca WHERE ca.project.id = :projectId AND ca.prNumber = :prNumber ORDER BY ca.prVersion DESC")
+ List findAllByProjectIdAndPrNumberOrderByPrVersionDesc(@Param("projectId") Long projectId, @Param("prNumber") Long prNumber);
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/job/JobRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/job/JobRepository.java
index 6dd799b5..b7f75a97 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/job/JobRepository.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/job/JobRepository.java
@@ -101,4 +101,8 @@ Page findByProjectIdAndDateRange(
@Modifying
@Query("DELETE FROM Job j WHERE j.project.id = :projectId")
void deleteByProjectId(@Param("projectId") Long projectId);
+
+ @Modifying
+ @Query("DELETE FROM Job j WHERE j.id = :jobId")
+ void deleteJobById(@Param("jobId") Long jobId);
}
diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java
index 7f3ca41f..d0fd4624 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java
@@ -110,6 +110,11 @@ private CodeAnalysis fillAnalysisData(
return analysisRepository.save(analysis);
}
+ // Save analysis first to get its ID for resolution tracking
+ CodeAnalysis savedAnalysis = analysisRepository.save(analysis);
+ Long analysisId = savedAnalysis.getId();
+ Long prNumber = savedAnalysis.getPrNumber();
+
// Handle issues as List (array format from AI)
if (issuesObj instanceof List) {
List