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/DiffFingerprintUtil.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/DiffFingerprintUtil.java
new file mode 100644
index 00000000..6f8a381e
--- /dev/null
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/DiffFingerprintUtil.java
@@ -0,0 +1,102 @@
+package org.rostilos.codecrow.analysisengine.util;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Computes a content-based fingerprint of a unified diff.
+ *
+ * Only actual change lines ({@code +} / {@code -}) are included — context lines,
+ * hunk headers ({@code @@}), and file headers ({@code +++} / {@code ---} / {@code diff --git})
+ * are excluded. The change lines are sorted to make the fingerprint stable regardless
+ * of file ordering within the diff.
+ *
+ * This allows detecting that two PRs carry the same code changes even if they target
+ * different branches (different merge-base → different context/hunk headers).
+ */
+public final class DiffFingerprintUtil {
+
+ private DiffFingerprintUtil() { /* utility */ }
+
+ /**
+ * Compute a SHA-256 hex digest of the normalised change lines in the given diff.
+ *
+ * @param rawDiff the filtered unified diff (may be {@code null} or empty)
+ * @return 64-char lowercase hex string, or {@code null} if the diff is blank
+ */
+ public static String compute(String rawDiff) {
+ if (rawDiff == null || rawDiff.isBlank()) {
+ return null;
+ }
+
+ List changeLines = extractChangeLines(rawDiff);
+ if (changeLines.isEmpty()) {
+ return null;
+ }
+
+ // Sort for stability across different file orderings
+ Collections.sort(changeLines);
+
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ for (String line : changeLines) {
+ digest.update(line.getBytes(StandardCharsets.UTF_8));
+ digest.update((byte) '\n');
+ }
+ return bytesToHex(digest.digest());
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is guaranteed by the JVM spec — should never happen
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+
+ /**
+ * Extract only the actual change lines from a unified diff.
+ * A "change line" starts with exactly one {@code +} or {@code -} and is NOT
+ * a file header ({@code +++}, {@code ---}) or a diff metadata line.
+ */
+ private static List extractChangeLines(String diff) {
+ List lines = new ArrayList<>();
+ // Normalise line endings
+ String normalised = diff.replace("\r\n", "\n").replace("\r", "\n");
+ for (String raw : normalised.split("\n")) {
+ String line = trimTrailingWhitespace(raw);
+ if (line.isEmpty()) {
+ continue;
+ }
+ char first = line.charAt(0);
+ if (first != '+' && first != '-') {
+ continue;
+ }
+ // Skip file-level headers: "+++", "---", "diff --git"
+ if (line.startsWith("+++") || line.startsWith("---")) {
+ continue;
+ }
+ if (line.startsWith("diff ")) {
+ continue;
+ }
+ lines.add(line);
+ }
+ return lines;
+ }
+
+ private static String trimTrailingWhitespace(String s) {
+ int end = s.length();
+ while (end > 0 && Character.isWhitespace(s.charAt(end - 1))) {
+ end--;
+ }
+ return s.substring(0, end);
+ }
+
+ private static String bytesToHex(byte[] bytes) {
+ StringBuilder sb = new StringBuilder(bytes.length * 2);
+ for (byte b : bytes) {
+ sb.append(String.format("%02x", b & 0xff));
+ }
+ return sb.toString();
+ }
+}
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..86a7da1a
--- /dev/null
+++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/TokenEstimator.java
@@ -0,0 +1,88 @@
+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 final 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);
+
+ // Prevent instantiation
+ private TokenEstimator() {
+ throw new UnsupportedOperationException("Utility class");
+ }
+
+ /**
+ * 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..ebeb50a7 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.resolvedInAnalysisId()).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.resolvedInAnalysisId()).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/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessorTest.java b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessorTest.java
index c38168d3..6e7d5bac 100644
--- a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessorTest.java
+++ b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessorTest.java
@@ -28,7 +28,6 @@
import org.rostilos.codecrow.core.persistence.repository.branch.BranchRepository;
import org.rostilos.codecrow.core.persistence.repository.codeanalysis.CodeAnalysisIssueRepository;
import org.rostilos.codecrow.vcsclient.VcsClientProvider;
-import org.springframework.context.ApplicationEventPublisher;
import java.io.IOException;
import java.util.*;
@@ -73,9 +72,6 @@ class BranchAnalysisProcessorTest {
@Mock
private RagOperationsService ragOperationsService;
- @Mock
- private ApplicationEventPublisher eventPublisher;
-
@Mock
private VcsOperationsService operationsService;
@@ -255,14 +251,14 @@ void shouldThrowAnalysisLockedExceptionWhenLockCannotBeAcquired() throws IOExcep
when(projectService.getProjectWithConnections(1L)).thenReturn(project);
when(project.getId()).thenReturn(1L);
- when(project.getName()).thenReturn("Test Project");
when(analysisLockService.acquireLockWithWait(any(), anyString(), any(), anyString(), any(), any()))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> processor.process(request, consumer))
.isInstanceOf(AnalysisLockedException.class);
- verify(eventPublisher, times(2)).publishEvent(any());
+ // No consumer or event interactions should occur when lock is not acquired
+ verifyNoInteractions(consumer);
}
// Note: Full process() integration tests are complex and require extensive mocking.
diff --git a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessorTest.java b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessorTest.java
index 0b36db83..0a8c555d 100644
--- a/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessorTest.java
+++ b/java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/processor/analysis/PullRequestAnalysisProcessorTest.java
@@ -140,10 +140,13 @@ void shouldSuccessfullyProcessPRAnalysis() throws Exception {
when(codeAnalysisService.getCodeAnalysisCache(anyLong(), anyString(), anyLong()))
.thenReturn(Optional.empty());
- when(codeAnalysisService.getPreviousVersionCodeAnalysis(anyLong(), anyLong()))
+ when(codeAnalysisService.getAnalysisByCommitHash(anyLong(), anyString()))
.thenReturn(Optional.empty());
+ when(codeAnalysisService.getAllPrAnalyses(anyLong(), anyLong()))
+ .thenReturn(List.of());
- when(aiClientService.buildAiAnalysisRequest(any(), any(), any())).thenReturn(aiAnalysisRequest);
+ when(aiClientService.buildAiAnalysisRequest(any(), any(), any(), anyList())).thenReturn(aiAnalysisRequest);
+ when(aiAnalysisRequest.getRawDiff()).thenReturn("");
Map aiResponse = Map.of(
"comment", "Review comment",
@@ -151,7 +154,8 @@ void shouldSuccessfullyProcessPRAnalysis() throws Exception {
);
when(aiAnalysisClient.performAnalysis(any(), any())).thenReturn(aiResponse);
- when(codeAnalysisService.createAnalysisFromAiResponse(any(), any(), anyLong(), anyString(), anyString(), anyString(), any(), any()))
+ when(codeAnalysisService.createAnalysisFromAiResponse(
+ any(), any(), anyLong(), anyString(), anyString(), anyString(), any(), any(), any()))
.thenReturn(codeAnalysis);
Map result = processor.process(request, consumer, project);
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/codeanalysis/CodeAnalysis.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/codeanalysis/CodeAnalysis.java
index ae4377fa..14bd145d 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/codeanalysis/CodeAnalysis.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/codeanalysis/CodeAnalysis.java
@@ -38,6 +38,9 @@ public class CodeAnalysis {
@Column(name = "commit_hash", length = 40)
private String commitHash;
+ @Column(name = "diff_fingerprint", length = 64)
+ private String diffFingerprint;
+
@Column(name = "target_branch_name")
private String branchName;
@@ -113,6 +116,9 @@ public void updateIssueCounts() {
public String getCommitHash() { return commitHash; }
public void setCommitHash(String commitHash) { this.commitHash = commitHash; }
+ public String getDiffFingerprint() { return diffFingerprint; }
+ public void setDiffFingerprint(String diffFingerprint) { this.diffFingerprint = diffFingerprint; }
+
public String getBranchName() { return branchName; }
public void setBranchName(String branchName) { this.branchName = branchName; }
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/model/vcs/VcsConnection.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java
index 383ea9b1..9c3f231a 100644
--- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java
+++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java
@@ -116,6 +116,15 @@ public class VcsConnection {
@Column(name = "updated_at")
private LocalDateTime updatedAt;
+ /**
+ * Version field for optimistic locking.
+ * Prevents concurrent token refresh operations from overwriting each other.
+ * Initialized to 0 to handle existing records that don't have this field yet.
+ */
+ @Version
+ @Column(name = "version", nullable = false, columnDefinition = "BIGINT DEFAULT 0")
+ private Long version = 0L;
+
/**
* Provider-specific configuration (JSON column).
* Stores additional settings like OAuth keys for manual connections.
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..516ca3c9 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,52 @@ Page searchAnalyses(
})
@Query("SELECT ca FROM CodeAnalysis ca WHERE ca.id = :id")
Optional findByIdWithIssues(@Param("id") Long id);
+
+ /**
+ * Find the most recent ACCEPTED analysis for a project with the same diff fingerprint.
+ * Used for content-based cache: reuse analysis when the same code changes appear in a different PR.
+ */
+ @org.springframework.data.jpa.repository.EntityGraph(attributePaths = {
+ "issues",
+ "project",
+ "project.workspace",
+ "project.vcsBinding",
+ "project.vcsBinding.vcsConnection",
+ "project.aiBinding"
+ })
+ @Query("SELECT ca FROM CodeAnalysis ca WHERE ca.project.id = :projectId " +
+ "AND ca.diffFingerprint = :diffFingerprint " +
+ "AND ca.status = org.rostilos.codecrow.core.model.codeanalysis.AnalysisStatus.ACCEPTED " +
+ "ORDER BY ca.createdAt DESC LIMIT 1")
+ Optional findTopByProjectIdAndDiffFingerprint(
+ @Param("projectId") Long projectId,
+ @Param("diffFingerprint") String diffFingerprint);
+
+ /**
+ * Find the most recent ACCEPTED analysis for a project + commit hash (any PR number).
+ * Fallback cache for close/reopen scenarios where the same commit gets a new PR number.
+ */
+ @org.springframework.data.jpa.repository.EntityGraph(attributePaths = {
+ "issues",
+ "project",
+ "project.workspace",
+ "project.vcsBinding",
+ "project.vcsBinding.vcsConnection",
+ "project.aiBinding"
+ })
+ @Query("SELECT ca FROM CodeAnalysis ca WHERE ca.project.id = :projectId " +
+ "AND ca.commitHash = :commitHash " +
+ "AND ca.status = org.rostilos.codecrow.core.model.codeanalysis.AnalysisStatus.ACCEPTED " +
+ "ORDER BY ca.createdAt DESC LIMIT 1")
+ Optional findTopByProjectIdAndCommitHash(
+ @Param("projectId") Long projectId,
+ @Param("commitHash") String commitHash);
+
+ /**
+ * 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..f34a58ea 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
@@ -24,23 +24,20 @@
@Transactional
public class CodeAnalysisService {
- private final CodeAnalysisRepository analysisRepository;
- private final CodeAnalysisIssueRepository issueRepository;
private final CodeAnalysisRepository codeAnalysisRepository;
+ private final CodeAnalysisIssueRepository issueRepository;
private final QualityGateRepository qualityGateRepository;
private final QualityGateEvaluator qualityGateEvaluator;
private static final Logger log = LoggerFactory.getLogger(CodeAnalysisService.class);
@Autowired
public CodeAnalysisService(
- CodeAnalysisRepository analysisRepository,
- CodeAnalysisIssueRepository issueRepository,
CodeAnalysisRepository codeAnalysisRepository,
+ CodeAnalysisIssueRepository issueRepository,
QualityGateRepository qualityGateRepository
) {
- this.analysisRepository = analysisRepository;
- this.issueRepository = issueRepository;
this.codeAnalysisRepository = codeAnalysisRepository;
+ this.issueRepository = issueRepository;
this.qualityGateRepository = qualityGateRepository;
this.qualityGateEvaluator = new QualityGateEvaluator();
}
@@ -54,6 +51,21 @@ public CodeAnalysis createAnalysisFromAiResponse(
String commitHash,
String vcsAuthorId,
String vcsAuthorUsername
+ ) {
+ return createAnalysisFromAiResponse(project, analysisData, pullRequestId,
+ targetBranchName, sourceBranchName, commitHash, vcsAuthorId, vcsAuthorUsername, null);
+ }
+
+ public CodeAnalysis createAnalysisFromAiResponse(
+ Project project,
+ Map analysisData,
+ Long pullRequestId,
+ String targetBranchName,
+ String sourceBranchName,
+ String commitHash,
+ String vcsAuthorId,
+ String vcsAuthorUsername,
+ String diffFingerprint
) {
try {
// Check if analysis already exists for this commit (handles webhook retries)
@@ -74,6 +86,7 @@ public CodeAnalysis createAnalysisFromAiResponse(
analysis.setBranchName(targetBranchName);
analysis.setSourceBranchName(sourceBranchName);
analysis.setPrVersion(previousVersion + 1);
+ analysis.setDiffFingerprint(diffFingerprint);
return fillAnalysisData(analysis, analysisData, commitHash, vcsAuthorId, vcsAuthorUsername);
} catch (Exception e) {
@@ -107,9 +120,14 @@ private CodeAnalysis fillAnalysisData(
Object issuesObj = analysisData.get("issues");
if (issuesObj == null) {
log.warn("No issues found in analysis data");
- return analysisRepository.save(analysis);
+ return codeAnalysisRepository.save(analysis);
}
+ // Save analysis first to get its ID for resolution tracking
+ CodeAnalysis savedAnalysis = codeAnalysisRepository.save(analysis);
+ Long analysisId = savedAnalysis.getId();
+ Long prNumber = savedAnalysis.getPrNumber();
+
// Handle issues as List (array format from AI)
if (issuesObj instanceof List) {
List