Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The RAG pipeline (codebase indexing for context-aware reviews) provides enhanced
- **Interactive Commands**: Command CodeCrow directly from PR comments using `/ask`, `/analyze`, `/summarize`, and `/qa-doc`.
- **QA Auto-Documentation**: Automatically generate QA testing documentation from PR analysis and post it to linked Jira tickets. Task IDs are auto-detected from branch names, PR titles, or PR descriptions — or you can specify one explicitly with `/qa-doc PROJ-123`.
- **Issue Lifecycle**: Automatic tracking of resolved vs. open issues across analyses with deterministic and AI-based reconciliation.
- **Bring Your Own Model**: Connect your preferred LLM provider — OpenRouter, Anthropic, Google, or OpenAI.
- **Bring Your Own Model**: Connect your preferred LLM provider — OpenRouter, Anthropic, Google, OpenAI, or any OpenAI-compatible endpoint (vLLM, Ollama, Cloudflare Workers AI, etc.).

## Documentation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ private Map<String, Object> buildSerializableRequestPayload(AiAnalysisRequest re
payload.put("aiProvider", request.getAiProvider());
payload.put("aiModel", request.getAiModel());
payload.put("aiApiKey", request.getAiApiKey());
payload.put("aiBaseUrl", request.getAiBaseUrl());
payload.put("pullRequestId", request.getPullRequestId());
payload.put("oAuthClient", request.getOAuthClient());
payload.put("oAuthSecret", request.getOAuthSecret());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ public record SummarizeRequest(
String aiProvider,
String aiModel,
String aiApiKey,
String aiBaseUrl,
Long pullRequestId,
String sourceBranch,
String targetBranch,
Expand All @@ -188,6 +189,7 @@ public record AskRequest(
String aiProvider,
String aiModel,
String aiApiKey,
String aiBaseUrl,
String question,
Long pullRequestId,
String commitHash,
Expand Down Expand Up @@ -228,6 +230,7 @@ public record ReviewRequest(
String aiProvider,
String aiModel,
String aiApiKey,
String aiBaseUrl,
Long pullRequestId,
String sourceBranch,
String targetBranch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ public interface AiAnalysisRequest {

String getAiApiKey();

/**
* Custom base URL for OPENAI_COMPATIBLE provider.
* Null for standard providers.
*/
default String getAiBaseUrl() { return null; }

Long getPullRequestId();

String getOAuthClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public class AiAnalysisRequestImpl implements AiAnalysisRequest {
protected final AIProviderKey aiProvider;
protected final String aiModel;
protected final String aiApiKey;
protected final String aiBaseUrl;
protected final Long pullRequestId;
@JsonProperty("oAuthClient")
protected final String oAuthClient;
Expand Down Expand Up @@ -66,6 +67,7 @@ protected AiAnalysisRequestImpl(Builder<?> builder) {
this.aiProvider = builder.aiProvider;
this.aiModel = builder.aiModel;
this.aiApiKey = builder.aiApiKey;
this.aiBaseUrl = builder.aiBaseUrl;
this.pullRequestId = builder.pullRequestId;
this.oAuthClient = builder.oAuthClient;
this.oAuthSecret = builder.oAuthSecret;
Expand Down Expand Up @@ -123,6 +125,11 @@ public String getAiApiKey() {
return aiApiKey;
}

@Override
public String getAiBaseUrl() {
return aiBaseUrl;
}

public Long getPullRequestId() {
return pullRequestId;
}
Expand Down Expand Up @@ -242,6 +249,7 @@ public static class Builder<T extends Builder<T>> {
private AIProviderKey aiProvider;
private String aiModel;
private String aiApiKey;
private String aiBaseUrl;
private Long pullRequestId;
private String oAuthClient;
private String oAuthSecret;
Expand Down Expand Up @@ -292,6 +300,7 @@ public T withPullRequestId(Long pullRequestId) {
public T withProjectAiConnection(AIConnection projectAiConnection) {
this.aiProvider = projectAiConnection.getProviderKey();
this.aiModel = projectAiConnection.getAiModel();
this.aiBaseUrl = projectAiConnection.getBaseUrl();
return self();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,27 @@ public void mapCodeAnalysisIssuesToBranch(Set<String> changedFiles,

Set<String> branchContentFingerprints = new HashSet<>();
Set<Long> allLinkedOriginIds = new HashSet<>();
// Location fingerprints: file + lineHash + category — catches title-variant
// duplicates where the LLM phrased the same issue differently across analyses.
Set<String> branchLocationFingerprints = new HashSet<>();
for (BranchIssue bi : allBranchIssues) {
if (bi.getContentFingerprint() != null) {
branchContentFingerprints.add(bi.getContentFingerprint());
}
if (bi.getOriginIssue() != null) {
allLinkedOriginIds.add(bi.getOriginIssue().getId());
}
// Build location fingerprint (title-agnostic)
if (bi.getLineHash() != null && !bi.isResolved()) {
String locFp = bi.getFilePath() + ":" + bi.getLineHash() + ":"
+ (bi.getIssueCategory() != null ? bi.getIssueCategory().name() : "UNKNOWN");
branchLocationFingerprints.add(locFp);
}
}

log.debug("Branch {} pre-loaded {} content fingerprints and {} origin IDs for dedup",
branch.getBranchName(), branchContentFingerprints.size(), allLinkedOriginIds.size());
log.debug("Branch {} pre-loaded {} content fingerprints, {} origin IDs, {} location fingerprints for dedup",
branch.getBranchName(), branchContentFingerprints.size(),
allLinkedOriginIds.size(), branchLocationFingerprints.size());

// ── Per-file mapping loop ─────────────────────────────────────────────
for (String filePath : changedFiles) {
Expand All @@ -73,8 +83,11 @@ public void mapCodeAnalysisIssuesToBranch(Set<String> changedFiles,
continue;
}

// Scope to current branch — prevents pulling in issues from unrelated
// branches / PRs that happen to touch the same file.
List<CodeAnalysisIssue> allIssues = codeAnalysisIssueRepository
.findByProjectIdAndFilePath(project.getId(), filePath);
.findByProjectIdAndBranchNameAndFilePath(
project.getId(), branch.getBranchName(), filePath);

List<CodeAnalysisIssue> unresolvedIssues = allIssues.stream()
.filter(issue -> !issue.isResolved())
Expand Down Expand Up @@ -110,6 +123,18 @@ public void mapCodeAnalysisIssuesToBranch(Set<String> changedFiles,
continue;
}

// Tier 2.5: location-based dedup (title-agnostic)
// Catches issues where the LLM used different phrasing for the
// same problem at the same code location across analyses.
if (issue.getLineHash() != null) {
String locFp = issue.getFilePath() + ":" + issue.getLineHash() + ":"
+ (issue.getIssueCategory() != null ? issue.getIssueCategory().name() : "UNKNOWN");
if (branchLocationFingerprints.contains(locFp)) {
skipped++;
continue;
}
}

// Tier 3: legacy key dedup (per-file)
String legacyKey = buildLegacyContentKeyFromCAI(issue);
if (legacyKeyMap.containsKey(legacyKey)) {
Expand Down Expand Up @@ -138,6 +163,11 @@ public void mapCodeAnalysisIssuesToBranch(Set<String> changedFiles,
if (bi.getContentFingerprint() != null) {
branchContentFingerprints.add(bi.getContentFingerprint());
}
if (bi.getLineHash() != null) {
String locFp = bi.getFilePath() + ":" + bi.getLineHash() + ":"
+ (bi.getIssueCategory() != null ? bi.getIssueCategory().name() : "UNKNOWN");
branchLocationFingerprints.add(locFp);
}
legacyKeyMap.put(buildLegacyContentKey(bi), bi);
allLinkedOriginIds.add(issue.getId());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package org.rostilos.codecrow.analysisengine.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;

/**
* Shared utility for computing delta diffs between commits with content filtering.
* <p>
* Centralises the fetch + filter + error-handling logic that was previously
* duplicated across BitbucketAiClientService, GithubAiClientService, and
* GitlabAiClientService. The caller passes a provider-agnostic
* {@link CommitRangeDiffFetcher} lambda so no VCS-specific imports are needed.
*/
public final class VcsDiffUtils {

private static final Logger log = LoggerFactory.getLogger(VcsDiffUtils.class);

/**
* When the delta diff size exceeds this fraction of the full diff size,
* the analysis escalates from INCREMENTAL back to FULL mode because the
* delta is almost as large as the original.
*/
public static final double INCREMENTAL_ESCALATION_THRESHOLD = 0.5;

/**
* Minimum delta-diff size (in characters) below which the diff is considered
* trivially small and always qualifies for incremental mode.
*/
public static final int MIN_DELTA_DIFF_SIZE = 500;

private VcsDiffUtils() {
// utility class
}

/**
* Provider-agnostic callback for obtaining the raw diff between two commits.
* <p>
* Implementations typically delegate to a VCS-specific action class
* (e.g.&nbsp;{@code GetCommitRangeDiffAction}) or to
* {@code VcsOperationsService.getCommitRangeDiff}.
*/
@FunctionalInterface
public interface CommitRangeDiffFetcher {
/**
* @param workspace workspace slug / owner / namespace
* @param repoSlug repository slug
* @param baseCommit base (previously analysed) commit hash
* @param headCommit head (current) commit hash
* @return raw unified diff between the two commits
* @throws IOException on network or parsing errors
*/
String fetch(String workspace, String repoSlug,
String baseCommit, String headCommit) throws IOException;
}

/**
* Fetches the delta diff between two commits, applies the content filter,
* and returns the filtered result. Returns {@code null} on failure
* (non-blocking — errors are logged as warnings).
*
* @param fetcher provider-agnostic diff retriever
* @param workspace workspace slug / owner / namespace
* @param repoSlug repository slug
* @param baseCommit base commit hash (the last successfully analysed one)
* @param headCommit head commit hash (the current one)
* @param contentFilter content-size filter to strip oversised file diffs
* @return filtered delta diff, or {@code null} if fetching failed
*/
public static String fetchDeltaDiff(
CommitRangeDiffFetcher fetcher,
String workspace,
String repoSlug,
String baseCommit,
String headCommit,
DiffContentFilter contentFilter) {
try {
String rawDeltaDiff = fetcher.fetch(workspace, repoSlug, baseCommit, headCommit);
return contentFilter.filterDiff(rawDeltaDiff);
} catch (IOException e) {
log.warn("Failed to fetch delta diff from {} to {}: {}",
truncateHash(baseCommit),
truncateHash(headCommit),
e.getMessage());
return null;
}
}

/**
* Determines whether an incremental analysis should be escalated back to
* FULL mode based on the delta-diff size relative to the full diff.
*
* @param deltaDiffLength length of the delta diff in characters
* @param fullDiffLength length of the full PR/commit diff in characters
* @return {@code true} if the delta is large enough to warrant full re-analysis
*/
public static boolean shouldEscalateToFull(int deltaDiffLength, int fullDiffLength) {
if (deltaDiffLength <= MIN_DELTA_DIFF_SIZE) {
return false;
}
if (fullDiffLength <= 0) {
return false;
}
return (double) deltaDiffLength / fullDiffLength > INCREMENTAL_ESCALATION_THRESHOLD;
}

private static String truncateHash(String hash) {
return (hash != null && hash.length() > 7)
? hash.substring(0, 7)
: String.valueOf(hash);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class SummarizeRequestTests {
void shouldCreateWithAllFields() {
AiCommandClient.SummarizeRequest request = new AiCommandClient.SummarizeRequest(
1L, "workspace", "repo-slug", "project-workspace", "namespace",
"openai", "gpt-4", "api-key", 42L, "feature", "main", "abc123",
"openai", "gpt-4", "api-key", null, 42L, "feature", "main", "abc123",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add explicit assertions for the new aiBaseUrl record field.
These tests updated constructor arguments but never verify request.aiBaseUrl(), so regressions on this new component can slip through.

Suggested assertion additions
@@
             assertThat(request.aiProvider()).isEqualTo("openai");
             assertThat(request.aiModel()).isEqualTo("gpt-4");
             assertThat(request.aiApiKey()).isEqualTo("api-key");
+            assertThat(request.aiBaseUrl()).isNull();
             assertThat(request.pullRequestId()).isEqualTo(42L);
@@
             assertThat(request.projectId()).isEqualTo(1L);
             assertThat(request.aiProvider()).isEqualTo("anthropic");
             assertThat(request.aiModel()).isEqualTo("claude-3");
+            assertThat(request.aiBaseUrl()).isNull();
             assertThat(request.question()).isEqualTo("What is this code doing?");
@@
             assertThat(request.projectId()).isEqualTo(1L);
+            assertThat(request.aiBaseUrl()).isNull();
             assertThat(request.pullRequestId()).isEqualTo(42L);

Also applies to: 57-57, 77-77, 149-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@java-ecosystem/libs/analysis-engine/src/test/java/org/rostilos/codecrow/analysisengine/aiclient/AiCommandClientRecordsTest.java`
at line 23, Tests in AiCommandClientRecordsTest updated constructor args to
include the new aiBaseUrl field but never assert it; add explicit assertions
calling request.aiBaseUrl() in each affected test (the tests that construct the
record and name the variable request) to verify the expected value (e.g., null
or "..." as appropriate for each case), alongside existing assertions so
regressions for the new aiBaseUrl property are caught; locate the record
instantiations in methods within AiCommandClientRecordsTest and add a matching
assertEquals(expected, request.aiBaseUrl()) for each scenario.

"oauth-client", "oauth-secret", "access-token", true, 4096, "bitbucket"
);

Expand Down Expand Up @@ -54,7 +54,7 @@ class AskRequestTests {
void shouldCreateWithAllFields() {
AiCommandClient.AskRequest request = new AiCommandClient.AskRequest(
1L, "workspace", "repo-slug", "project-workspace", "namespace",
"anthropic", "claude-3", "api-key", "What is this code doing?",
"anthropic", "claude-3", "api-key", null, "What is this code doing?",
42L, "abc123", "oauth-client", "oauth-secret", "access-token",
8192, "github", "analysis context", List.of("issue-1", "issue-2")
);
Expand All @@ -74,7 +74,7 @@ void shouldCreateWithAllFields() {
void shouldSupportNullOptionalFields() {
AiCommandClient.AskRequest request = new AiCommandClient.AskRequest(
1L, "workspace", "repo-slug", null, null,
"openai", "gpt-4", "api-key", "question",
"openai", "gpt-4", "api-key", null, "question",
null, null, null, null, null,
null, "bitbucket", null, null
);
Expand Down Expand Up @@ -146,7 +146,7 @@ class ReviewRequestTests {
void shouldCreateWithAllFields() {
AiCommandClient.ReviewRequest request = new AiCommandClient.ReviewRequest(
1L, "workspace", "repo-slug", "project-workspace", "namespace",
"openai", "gpt-4", "api-key", 42L, "feature", "main", "abc123",
"openai", "gpt-4", "api-key", null, 42L, "feature", "main", "abc123",
"oauth-client", "oauth-secret", "access-token", 4096, "bitbucket"
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,22 @@ void setUp() {
private AiCommandClient.SummarizeRequest createSummarizeRequest() {
return new AiCommandClient.SummarizeRequest(
1L, "workspace", "repo-slug", "project-workspace", "namespace",
"openai", "gpt-4", "api-key", 42L, "feature", "main", "abc123",
"openai", "gpt-4", "api-key", null, 42L, "feature", "main", "abc123",
"oauth-client", "oauth-secret", "access-token", true, 4096, "bitbucket");
}

private AiCommandClient.AskRequest createAskRequest() {
return new AiCommandClient.AskRequest(
1L, "workspace", "repo-slug", "project-workspace", "namespace",
"openai", "gpt-4", "api-key", "What is this code doing?",
"openai", "gpt-4", "api-key", null, "What is this code doing?",
42L, "abc123", "oauth-client", "oauth-secret", "access-token",
4096, "bitbucket", null, null);
}

private AiCommandClient.ReviewRequest createReviewRequest() {
return new AiCommandClient.ReviewRequest(
1L, "workspace", "repo-slug", "project-workspace", "namespace",
"openai", "gpt-4", "api-key", 42L, "feature", "main", "abc123",
"openai", "gpt-4", "api-key", null, 42L, "feature", "main", "abc123",
"oauth-client", "oauth-secret", "access-token", 4096, "bitbucket");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ void unresolvedIssueAlreadyLinked_shouldUpdateSeverityOnly() throws Exception {
when(linkedBi.getContentFingerprint()).thenReturn(null);
when(branchIssueRepository.findByBranchId(1L)).thenReturn(List.of(linkedBi));

when(codeAnalysisIssueRepository.findByProjectIdAndFilePath(1L, "a.java"))
when(codeAnalysisIssueRepository.findByProjectIdAndBranchNameAndFilePath(1L, "main", "a.java"))
.thenReturn(List.of(issue));
when(branchIssueRepository.findByBranchIdAndFilePath(1L, "a.java"))
.thenReturn(List.of());
Expand All @@ -127,7 +127,7 @@ void noIssuesForFile_shouldNotCreateBranchIssues() throws Exception {
Project project = new Project();
setId(project, 1L);
when(branchIssueRepository.findByBranchId(1L)).thenReturn(List.of());
when(codeAnalysisIssueRepository.findByProjectIdAndFilePath(1L, "a.java"))
when(codeAnalysisIssueRepository.findByProjectIdAndBranchNameAndFilePath(1L, "main", "a.java"))
.thenReturn(List.of());

service.mapCodeAnalysisIssuesToBranch(
Expand All @@ -149,7 +149,7 @@ void resolvedIssues_shouldBeFilteredOut() throws Exception {
resolved.setResolved(true);

when(branchIssueRepository.findByBranchId(1L)).thenReturn(List.of());
when(codeAnalysisIssueRepository.findByProjectIdAndFilePath(1L, "a.java"))
when(codeAnalysisIssueRepository.findByProjectIdAndBranchNameAndFilePath(1L, "main", "a.java"))
.thenReturn(List.of(resolved));
when(branchIssueRepository.findByBranchIdAndFilePath(1L, "a.java"))
.thenReturn(List.of());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Manifest-Version: 1.0
Automatic-Module-Name: org.rostilos.codecrow.astparser
9 changes: 9 additions & 0 deletions java-ecosystem/libs/core/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,13 @@

opens org.rostilos.codecrow.core.model.taskmanagement
to org.hibernate.orm.core, spring.beans, spring.context, spring.core;

// QA Documentation state tracking (server-side delta-diff state)
exports org.rostilos.codecrow.core.model.qadoc;
exports org.rostilos.codecrow.core.persistence.repository.qadoc;

opens org.rostilos.codecrow.core.model.qadoc
to org.hibernate.orm.core, spring.beans, spring.context, spring.core;
opens org.rostilos.codecrow.core.persistence.repository.qadoc
to spring.core, spring.beans, spring.context;
}
Loading