From 399dc3cfd112e69fbf6a6354b54a575705fa87cb Mon Sep 17 00:00:00 2001 From: rostislav Date: Thu, 19 Feb 2026 14:08:22 +0200 Subject: [PATCH 1/2] feat: Enhance code review process with LLM reranking and MCP tool integration - Introduced LLM reranking in the file review stages to improve issue prioritization based on changed files. - Added MCP tool execution capabilities for verifying HIGH/CRITICAL issues during the aggregation stage. - Updated prompt builder to conditionally include MCP tool instructions based on configuration. - Simplified scoring configuration by removing path-based and metadata boosts, focusing solely on content-type boosts. - Implemented unit tests for LLM reranker and scoring configuration to ensure correct functionality and boost application. - Enhanced collection manager to support on-disk vector storage configuration. - Qdrant vectors on disk --- deployment/config/rag-pipeline/.env.sample | 2 + .../dto/request/ai/AiAnalysisRequest.java | 23 + .../dto/request/ai/AiAnalysisRequestImpl.java | 83 +- .../codecrow/core/dto/project/ProjectDTO.java | 61 +- .../model/project/config/ProjectConfig.java | 332 +++-- .../core/dto/project/ProjectDTOTest.java | 51 +- .../service/VcsRagIndexingServiceTest.java | 21 +- .../service/BitbucketAiClientService.java | 127 +- .../github/service/GitHubAiClientService.java | 120 +- .../gitlab/service/GitLabAiClientService.java | 120 +- .../project/controller/ProjectController.java | 1268 ++++++++--------- .../project/service/IProjectService.java | 105 +- .../project/service/ProjectService.java | 29 +- .../inference-orchestrator/model/dtos.py | 2 + .../service/rag/llm_reranker.py | 255 ++-- .../review/orchestrator/context_helpers.py | 4 +- .../review/orchestrator/mcp_tool_executor.py | 155 ++ .../review/orchestrator/orchestrator.py | 12 +- .../service/review/orchestrator/stages.py | 113 +- .../service/review/review_service.py | 20 +- .../tests/test_llm_reranker.py | 166 +++ .../utils/prompts/prompt_builder.py | 43 +- .../utils/prompts/prompt_constants.py | 40 + .../core/index_manager/collection_manager.py | 16 +- .../src/rag_pipeline/models/scoring_config.py | 202 +-- .../rag_pipeline/services/query_service.py | 23 +- .../rag-pipeline/tests/test_scoring_config.py | 157 ++ 27 files changed, 2112 insertions(+), 1438 deletions(-) create mode 100644 python-ecosystem/inference-orchestrator/service/review/orchestrator/mcp_tool_executor.py create mode 100644 python-ecosystem/inference-orchestrator/tests/test_llm_reranker.py create mode 100644 python-ecosystem/rag-pipeline/tests/test_scoring_config.py diff --git a/deployment/config/rag-pipeline/.env.sample b/deployment/config/rag-pipeline/.env.sample index 24aec3cd..c87c5b24 100644 --- a/deployment/config/rag-pipeline/.env.sample +++ b/deployment/config/rag-pipeline/.env.sample @@ -30,6 +30,8 @@ OPENROUTER_MODEL=qwen/qwen3-embedding-8b #================================================================================================ #================================================================================================ +# QDRANT_VECTORS_ON_DISK=true + ## === Path Traversal Guard === ## Root directory that repo_path arguments are allowed under. ## The rag-pipeline will reject any index/query request whose resolved diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequest.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequest.java index 8c4e2424..82845409 100644 --- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequest.java +++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/ai/AiAnalysisRequest.java @@ -7,27 +7,50 @@ public interface AiAnalysisRequest { Long getProjectId(); + String getProjectVcsWorkspace(); + String getProjectVcsRepoSlug(); + AIProviderKey getAiProvider(); + String getAiModel(); + String getAiApiKey(); + Long getPullRequestId(); + String getOAuthClient(); + String getOAuthSecret(); + String getAccessToken(); + int getMaxAllowedTokens(); + boolean getUseLocalMcp(); + + boolean getUseMcpTools(); + AnalysisType getAnalysisType(); + String getVcsProvider(); + String getPrTitle(); + String getPrDescription(); + List getChangedFiles(); + List getDiffSnippets(); + String getRawDiff(); AnalysisMode getAnalysisMode(); + String getDeltaDiff(); + String getPreviousCommitHash(); + String getCurrentCommitHash(); } 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 54f05b5a..77a3b1d7 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 @@ -12,7 +12,7 @@ import java.util.List; import java.util.Optional; -public class AiAnalysisRequestImpl implements AiAnalysisRequest{ +public class AiAnalysisRequestImpl implements AiAnalysisRequest { protected final Long projectId; protected final String projectWorkspace; protected final String projectNamespace; @@ -31,6 +31,7 @@ public class AiAnalysisRequestImpl implements AiAnalysisRequest{ protected final int maxAllowedTokens; protected final List previousCodeAnalysisIssues; protected final boolean useLocalMcp; + protected final boolean useMcpTools; protected final AnalysisType analysisType; protected final String prTitle; protected final String prDescription; @@ -39,13 +40,13 @@ public class AiAnalysisRequestImpl implements AiAnalysisRequest{ protected final String targetBranchName; protected final String vcsProvider; protected final String rawDiff; - + // Incremental analysis fields protected final AnalysisMode analysisMode; protected final String deltaDiff; protected final String previousCommitHash; protected final String currentCommitHash; - + // File enrichment data (full file contents + dependency graph) protected final PrEnrichmentDataDto enrichmentData; @@ -63,6 +64,7 @@ protected AiAnalysisRequestImpl(Builder builder) { this.maxAllowedTokens = builder.maxAllowedTokens; this.previousCodeAnalysisIssues = builder.previousCodeAnalysisIssues; this.useLocalMcp = builder.useLocalMcp; + this.useMcpTools = builder.useMcpTools; this.analysisType = builder.analysisType; this.prTitle = builder.prTitle; this.prDescription = builder.prDescription; @@ -125,7 +127,9 @@ public String getAccessToken() { return accessToken; } - public int getMaxAllowedTokens() { return maxAllowedTokens; } + public int getMaxAllowedTokens() { + return maxAllowedTokens; + } public List getPreviousCodeAnalysisIssues() { return previousCodeAnalysisIssues; @@ -191,7 +195,6 @@ public PrEnrichmentDataDto getEnrichmentData() { return enrichmentData; } - public static Builder builder() { return new Builder<>(); } @@ -213,6 +216,7 @@ public static class Builder> { private int maxAllowedTokens; private List previousCodeAnalysisIssues; private boolean useLocalMcp; + private boolean useMcpTools; private AnalysisType analysisType; private String prTitle; private String prDescription; @@ -281,12 +285,11 @@ public T withAccessToken(String accessToken) { } public T withPreviousAnalysisData(Optional optionalPreviousAnalysis) { - optionalPreviousAnalysis.ifPresent(codeAnalysis -> - this.previousCodeAnalysisIssues = codeAnalysis.getIssues() + optionalPreviousAnalysis + .ifPresent(codeAnalysis -> this.previousCodeAnalysisIssues = codeAnalysis.getIssues() .stream() .map(AiRequestPreviousIssueDTO::fromEntity) - .toList() - ); + .toList()); return self(); } @@ -295,11 +298,13 @@ public T withPreviousAnalysisData(Optional optionalPreviousAnalysi * This provides the LLM with complete issue history including resolved issues, * helping it understand what was already found and fixed. * - * Issues are deduplicated by fingerprint (file + line ±3 + severity + truncated reason). + * Issues are deduplicated by fingerprint (file + line ±3 + severity + truncated + * reason). * When duplicates exist across versions, we keep the most recent version's data * but preserve resolved status if ANY version marked it resolved. * - * @param allPrAnalyses List of all analyses for this PR, ordered by version DESC (newest first) + * @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()) { @@ -311,14 +316,15 @@ public T withAllPrAnalysesData(List allPrAnalyses) { .flatMap(analysis -> analysis.getIssues().stream()) .map(AiRequestPreviousIssueDTO::fromEntity) .toList(); - - // Deduplicate: group by fingerprint, keep most recent version but preserve resolved status + + // Deduplicate: group by fingerprint, keep most recent version but preserve + // resolved status java.util.Map deduped = new java.util.LinkedHashMap<>(); - + for (AiRequestPreviousIssueDTO issue : allIssues) { String fingerprint = computeIssueFingerprint(issue); AiRequestPreviousIssueDTO existing = deduped.get(fingerprint); - + if (existing == null) { // First occurrence of this issue deduped.put(fingerprint, issue); @@ -327,14 +333,16 @@ public T withAllPrAnalysesData(List allPrAnalyses) { // But if older version is resolved and newer is not, preserve resolved status int existingVersion = existing.prVersion() != null ? existing.prVersion() : 0; int currentVersion = issue.prVersion() != null ? issue.prVersion() : 0; - + boolean existingResolved = "resolved".equalsIgnoreCase(existing.status()); boolean currentResolved = "resolved".equalsIgnoreCase(issue.status()); - + if (currentVersion > existingVersion) { - // Current is newer - use it, but preserve resolved status if existing was resolved + // Current is newer - use it, but preserve resolved status if existing was + // resolved if (existingResolved && !currentResolved) { - // Older version was resolved but newer one isn't marked - use resolved data from older + // Older version was resolved but newer one isn't marked - use resolved data + // from older deduped.put(fingerprint, mergeResolvedStatus(issue, existing)); } else { deduped.put(fingerprint, issue); @@ -348,34 +356,36 @@ public T withAllPrAnalysesData(List allPrAnalyses) { // If existing is newer, keep it (already in map) } } - + this.previousCodeAnalysisIssues = new java.util.ArrayList<>(deduped.values()); - + return self(); } - + /** * Compute a fingerprint for an issue to detect duplicates across PR versions. - * Uses: file + normalized line (±3 tolerance) + severity + first 50 chars of reason. + * Uses: file + normalized line (±3 tolerance) + severity + first 50 chars of + * reason. */ private String computeIssueFingerprint(AiRequestPreviousIssueDTO issue) { String file = issue.file() != null ? issue.file() : ""; // Normalize line to nearest multiple of 3 for tolerance int lineGroup = issue.line() != null ? (issue.line() / 3) : 0; String severity = issue.severity() != null ? issue.severity() : ""; - String reasonPrefix = issue.reason() != null - ? issue.reason().substring(0, Math.min(50, issue.reason().length())).toLowerCase().trim() - : ""; - + String reasonPrefix = issue.reason() != null + ? issue.reason().substring(0, Math.min(50, issue.reason().length())).toLowerCase().trim() + : ""; + return file + "::" + lineGroup + "::" + severity + "::" + reasonPrefix; } - + /** * Merge resolved status from an older issue version into a newer one. - * Creates a new DTO with the newer issue's data but the older issue's resolution info. + * Creates a new DTO with the newer issue's data but the older issue's + * resolution info. */ private AiRequestPreviousIssueDTO mergeResolvedStatus( - AiRequestPreviousIssueDTO newer, + AiRequestPreviousIssueDTO newer, AiRequestPreviousIssueDTO resolvedOlder) { return new AiRequestPreviousIssueDTO( newer.id(), @@ -393,8 +403,7 @@ private AiRequestPreviousIssueDTO mergeResolvedStatus( newer.prVersion(), resolvedOlder.resolvedDescription(), resolvedOlder.resolvedByCommit(), - resolvedOlder.resolvedInAnalysisId() - ); + resolvedOlder.resolvedInAnalysisId()); } public T withMaxAllowedTokens(int maxAllowedTokens) { @@ -407,6 +416,11 @@ public T withUseLocalMcp(boolean useLocalMcp) { return self(); } + public T withUseMcpTools(boolean useMcpTools) { + this.useMcpTools = useMcpTools; + return self(); + } + public T withAnalysisType(AnalysisType analysisType) { this.analysisType = analysisType; return self(); @@ -486,4 +500,9 @@ public AiAnalysisRequestImpl build() { public boolean getUseLocalMcp() { return useLocalMcp; } + + @Override + public boolean getUseMcpTools() { + return useMcpTools; + } } 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 0e72056f..d6560b4e 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 @@ -23,7 +23,7 @@ public record ProjectDTO( Long aiConnectionId, String namespace, String mainBranch, - String defaultBranch, // Deprecated: use mainBranch. Kept for backward compatibility. + String defaultBranch, // Deprecated: use mainBranch. Kept for backward compatibility. Long defaultBranchId, DefaultBranchStats defaultBranchStats, RagConfigDTO ragConfig, @@ -33,16 +33,17 @@ public record ProjectDTO( CommentCommandsConfigDTO commentCommandsConfig, Boolean webhooksConfigured, Long qualityGateId, - Integer maxAnalysisTokenLimit -) { + Integer maxAnalysisTokenLimit, + Boolean useMcpTools) { public static ProjectDTO fromProject(Project project) { Long vcsConnectionId = null; String vcsConnectionType = null; String vcsProvider = null; String vcsWorkspace = null; String repoSlug = null; - - // Use unified method to get VCS info (prefers VcsRepoBinding over legacy vcsBinding) + + // Use unified method to get VCS info (prefers VcsRepoBinding over legacy + // vcsBinding) VcsRepoInfo vcsInfo = project.getEffectiveVcsRepoInfo(); if (vcsInfo != null) { VcsConnection conn = vcsInfo.getVcsConnection(); @@ -78,8 +79,7 @@ public static ProjectDTO fromProject(Project project) { branch.getHighSeverityCount(), branch.getMediumSeverityCount(), branch.getLowSeverityCount(), - branch.getResolvedCount() - ); + branch.getResolvedCount()); } String mainBranch = null; @@ -88,21 +88,21 @@ public static ProjectDTO fromProject(Project project) { Boolean prAnalysisEnabled = project.isPrAnalysisEnabled(); Boolean branchAnalysisEnabled = project.isBranchAnalysisEnabled(); String installationMethod = null; - + Boolean useMcpTools = false; + ProjectConfig config = project.getConfiguration(); if (config != null) { mainBranch = config.mainBranch(); - + if (config.ragConfig() != null) { RagConfig rc = config.ragConfig(); ragConfigDTO = new RagConfigDTO( - rc.enabled(), - rc.branch(), + rc.enabled(), + rc.branch(), rc.includePatterns(), rc.excludePatterns(), rc.multiBranchEnabled(), - rc.branchRetentionDays() - ); + rc.branchRetentionDays()); } if (config.prAnalysisEnabled() != null) { prAnalysisEnabled = config.prAnalysisEnabled(); @@ -113,8 +113,9 @@ public static ProjectDTO fromProject(Project project) { if (config.installationMethod() != null) { installationMethod = config.installationMethod().name(); } + useMcpTools = config.useMcpTools(); } - + CommentCommandsConfigDTO commentCommandsConfigDTO = null; if (config != null) { commentCommandsConfigDTO = CommentCommandsConfigDTO.fromConfig(config.getCommentCommandsConfig()); @@ -125,9 +126,10 @@ 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; + Integer maxAnalysisTokenLimit = config != null ? config.maxAnalysisTokenLimit() + : ProjectConfig.DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT; return new ProjectDTO( project.getId(), @@ -152,8 +154,8 @@ public static ProjectDTO fromProject(Project project) { commentCommandsConfigDTO, webhooksConfigured, project.getQualityGate() != null ? project.getQualityGate().getId() : null, - maxAnalysisTokenLimit - ); + maxAnalysisTokenLimit, + useMcpTools); } public record DefaultBranchStats( @@ -162,8 +164,7 @@ public record DefaultBranchStats( int highSeverityCount, int mediumSeverityCount, int lowSeverityCount, - int resolvedCount - ) { + int resolvedCount) { } public record RagConfigDTO( @@ -172,16 +173,16 @@ public record RagConfigDTO( java.util.List includePatterns, java.util.List excludePatterns, Boolean multiBranchEnabled, - Integer branchRetentionDays - ) { + Integer branchRetentionDays) { /** - * Backward-compatible constructor without include patterns and multi-branch fields. + * Backward-compatible constructor without include patterns and multi-branch + * fields. */ public RagConfigDTO(boolean enabled, String branch, java.util.List excludePatterns) { this(enabled, branch, null, excludePatterns, null, null); } } - + public record CommentCommandsConfigDTO( boolean enabled, Integer rateLimit, @@ -189,15 +190,14 @@ public record CommentCommandsConfigDTO( Boolean allowPublicRepoCommands, List allowedCommands, String authorizationMode, - Boolean allowPrAuthor - ) { + Boolean allowPrAuthor) { public static CommentCommandsConfigDTO fromConfig(CommentCommandsConfig config) { if (config == null) { return new CommentCommandsConfigDTO(false, null, null, null, null, null, null); } - String authMode = config.authorizationMode() != null - ? config.authorizationMode().name() - : CommentCommandsConfig.DEFAULT_AUTHORIZATION_MODE.name(); + String authMode = config.authorizationMode() != null + ? config.authorizationMode().name() + : CommentCommandsConfig.DEFAULT_AUTHORIZATION_MODE.name(); return new CommentCommandsConfigDTO( config.enabled(), config.rateLimit(), @@ -205,8 +205,7 @@ public static CommentCommandsConfigDTO fromConfig(CommentCommandsConfig config) config.allowPublicRepoCommands(), config.allowedCommands(), authMode, - config.allowPrAuthor() - ); + config.allowPrAuthor()); } } } \ No newline at end of file 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 00fe979e..0eec3cc4 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 @@ -8,24 +8,39 @@ import java.util.Objects; /** - * Project-level configuration stored as JSON in the project.configuration column. + * Project-level configuration stored as JSON in the project.configuration + * column. * * Currently supports: - * - useLocalMcp: when true, MCP servers should prefer local repository access (LocalRepoClient) - * when a local repository path is available (for example when analysis is executed from an uploaded archive). - * - mainBranch: the primary branch (master/main) used as base for RAG training and analysis. - * IMPORTANT: This is the single source of truth for the project's main branch. It should be set during - * project creation and is used for: RAG base index, multi-branch context base, and always included in - * analysis patterns (PR targets and branch pushes). - * - defaultBranch: (DEPRECATED - use mainBranch) optional default branch name for the project. - * - branchAnalysis: configuration for branch-based analysis filtering. - * - ragConfig: configuration for RAG (Retrieval-Augmented Generation) indexing. - * - prAnalysisEnabled: whether to auto-analyze PRs on creation/updates (default: true). - * - 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. + * - useLocalMcp: when true, MCP servers should prefer local repository access + * (LocalRepoClient) + * when a local repository path is available (for example when analysis is + * executed from an uploaded archive). + * - useMcpTools: when true, enables the LLM to call VCS MCP tools during PR + * review stages + * (Stage 1 for context gap filling, Stage 3 for issue re-verification). + * Disabled by default. + * - mainBranch: the primary branch (master/main) used as base for RAG training + * and analysis. + * IMPORTANT: This is the single source of truth for the project's main branch. + * It should be set during + * project creation and is used for: RAG base index, multi-branch context base, + * and always included in + * analysis patterns (PR targets and branch pushes). + * - defaultBranch: (DEPRECATED - use mainBranch) optional default branch name + * for the project. + * - branchAnalysis: configuration for branch-based analysis filtering. + * - ragConfig: configuration for RAG (Retrieval-Augmented Generation) indexing. + * - prAnalysisEnabled: whether to auto-analyze PRs on creation/updates + * (default: true). + * - 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 @@ -35,19 +50,22 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class ProjectConfig { public static final int DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT = 200000; - + @JsonProperty("useLocalMcp") private boolean useLocalMcp; + @JsonProperty("useMcpTools") + private boolean useMcpTools; + @JsonProperty("mainBranch") private String mainBranch; - + /** * @deprecated Use mainBranch instead. Kept for backward compatibility. */ @JsonProperty("defaultBranch") private String defaultBranch; - + @JsonProperty("branchAnalysis") private BranchAnalysisConfig branchAnalysis; @JsonProperty("ragConfig") @@ -62,26 +80,45 @@ public class ProjectConfig { private CommentCommandsConfig commentCommands; @JsonProperty("maxAnalysisTokenLimit") private Integer maxAnalysisTokenLimit; - + public ProjectConfig() { this.useLocalMcp = false; + this.useMcpTools = 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); + RagConfig ragConfig, Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled, + InstallationMethod installationMethod, CommentCommandsConfig commentCommands) { + this(useLocalMcp, false, mainBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, + installationMethod, commentCommands, DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT); + } + + public ProjectConfig(boolean useLocalMcp, boolean useMcpTools, String mainBranch, + BranchAnalysisConfig branchAnalysis, + RagConfig ragConfig, Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled, + InstallationMethod installationMethod, CommentCommandsConfig commentCommands) { + this(useLocalMcp, useMcpTools, 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) { + RagConfig ragConfig, Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled, + InstallationMethod installationMethod, CommentCommandsConfig commentCommands, + Integer maxAnalysisTokenLimit) { + this(useLocalMcp, false, mainBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, + installationMethod, commentCommands, maxAnalysisTokenLimit); + } + + public ProjectConfig(boolean useLocalMcp, boolean useMcpTools, String mainBranch, + BranchAnalysisConfig branchAnalysis, + RagConfig ragConfig, Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled, + InstallationMethod installationMethod, CommentCommandsConfig commentCommands, + Integer maxAnalysisTokenLimit) { this.useLocalMcp = useLocalMcp; + this.useMcpTools = useMcpTools; this.mainBranch = mainBranch; this.defaultBranch = mainBranch; // Keep in sync for backward compatibility this.branchAnalysis = branchAnalysis; @@ -90,44 +127,71 @@ 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; + this.maxAnalysisTokenLimit = maxAnalysisTokenLimit != null ? maxAnalysisTokenLimit + : DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT; } - + public ProjectConfig(boolean useLocalMcp, String mainBranch) { this(useLocalMcp, mainBranch, null, null, true, true, null, null); } - + public ProjectConfig(boolean useLocalMcp, String mainBranch, BranchAnalysisConfig branchAnalysis) { this(useLocalMcp, mainBranch, branchAnalysis, null, true, true, null, null); } - - public ProjectConfig(boolean useLocalMcp, String mainBranch, BranchAnalysisConfig branchAnalysis, RagConfig ragConfig) { + + public ProjectConfig(boolean useLocalMcp, String mainBranch, BranchAnalysisConfig branchAnalysis, + RagConfig ragConfig) { this(useLocalMcp, mainBranch, branchAnalysis, ragConfig, true, true, null, null); } - - public boolean useLocalMcp() { return useLocalMcp; } - public String mainBranch() { - if (mainBranch != null) return mainBranch; - if (defaultBranch != null) return defaultBranch; + public boolean useLocalMcp() { + return useLocalMcp; + } + + public boolean useMcpTools() { + return useMcpTools; + } + + public String mainBranch() { + if (mainBranch != null) + return mainBranch; + if (defaultBranch != null) + return defaultBranch; return "main"; } - + /** * @deprecated Use mainBranch() instead. */ @Deprecated - public String defaultBranch() { - return mainBranch != null ? mainBranch : defaultBranch; - } - - public BranchAnalysisConfig branchAnalysis() { return branchAnalysis; } - public RagConfig ragConfig() { return ragConfig; } - public Boolean prAnalysisEnabled() { return prAnalysisEnabled; } - public Boolean branchAnalysisEnabled() { return branchAnalysisEnabled; } - public InstallationMethod installationMethod() { return installationMethod; } - public CommentCommandsConfig commentCommands() { return commentCommands; } - + public String defaultBranch() { + return mainBranch != null ? mainBranch : defaultBranch; + } + + public BranchAnalysisConfig branchAnalysis() { + return branchAnalysis; + } + + public RagConfig ragConfig() { + return ragConfig; + } + + public Boolean prAnalysisEnabled() { + return prAnalysisEnabled; + } + + public Boolean branchAnalysisEnabled() { + return branchAnalysisEnabled; + } + + 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. @@ -135,152 +199,182 @@ public String defaultBranch() { public int maxAnalysisTokenLimit() { return maxAnalysisTokenLimit != null ? maxAnalysisTokenLimit : DEFAULT_MAX_ANALYSIS_TOKEN_LIMIT; } - + // Setters for Jackson - public void setUseLocalMcp(boolean useLocalMcp) { this.useLocalMcp = useLocalMcp; } + public void setUseLocalMcp(boolean useLocalMcp) { + this.useLocalMcp = useLocalMcp; + } - public void setMainBranch(String mainBranch) { + public void setUseMcpTools(boolean useMcpTools) { + this.useMcpTools = useMcpTools; + } + + public void setMainBranch(String mainBranch) { this.mainBranch = mainBranch; this.defaultBranch = mainBranch; // Keep in sync - + // Auto-sync RAG config branch when main branch is set if (mainBranch != null && this.ragConfig != null && this.ragConfig.enabled()) { this.ragConfig = new RagConfig( - this.ragConfig.enabled(), - mainBranch, // Use main branch for RAG - this.ragConfig.includePatterns(), - this.ragConfig.excludePatterns(), - this.ragConfig.multiBranchEnabled(), - this.ragConfig.branchRetentionDays() - ); + this.ragConfig.enabled(), + mainBranch, // Use main branch for RAG + this.ragConfig.includePatterns(), + this.ragConfig.excludePatterns(), + this.ragConfig.multiBranchEnabled(), + this.ragConfig.branchRetentionDays()); } } - + /** * @deprecated Use setMainBranch() instead. */ @Deprecated - public void setDefaultBranch(String defaultBranch) { + public void setDefaultBranch(String defaultBranch) { // If mainBranch is not set, treat defaultBranch as mainBranch if (this.mainBranch == null) { this.mainBranch = defaultBranch; } - this.defaultBranch = defaultBranch; + this.defaultBranch = defaultBranch; + } + + public void setBranchAnalysis(BranchAnalysisConfig branchAnalysis) { + this.branchAnalysis = branchAnalysis; } - - public void setBranchAnalysis(BranchAnalysisConfig branchAnalysis) { this.branchAnalysis = branchAnalysis; } - public void setRagConfig(RagConfig ragConfig) { this.ragConfig = ragConfig; } - public void setPrAnalysisEnabled(Boolean prAnalysisEnabled) { this.prAnalysisEnabled = prAnalysisEnabled; } - 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 setRagConfig(RagConfig ragConfig) { + this.ragConfig = ragConfig; + } + + public void setPrAnalysisEnabled(Boolean prAnalysisEnabled) { + this.prAnalysisEnabled = prAnalysisEnabled; + } + + 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(); - if (main == null) return; - + if (main == null) + return; + if (this.branchAnalysis != null) { List prTargets = this.branchAnalysis.prTargetBranches(); List pushPatterns = this.branchAnalysis.branchPushPatterns(); - + boolean prNeedsUpdate = prTargets == null || !prTargets.contains(main); boolean pushNeedsUpdate = pushPatterns == null || !pushPatterns.contains(main); - + if (prNeedsUpdate || pushNeedsUpdate) { - List newPrTargets = prTargets != null ? new java.util.ArrayList<>(prTargets) : new java.util.ArrayList<>(); - List newPushPatterns = pushPatterns != null ? new java.util.ArrayList<>(pushPatterns) : new java.util.ArrayList<>(); - + List newPrTargets = prTargets != null ? new java.util.ArrayList<>(prTargets) + : new java.util.ArrayList<>(); + List newPushPatterns = pushPatterns != null ? new java.util.ArrayList<>(pushPatterns) + : new java.util.ArrayList<>(); + if (!newPrTargets.contains(main)) { newPrTargets.add(0, main); // Add at beginning } if (!newPushPatterns.contains(main)) { newPushPatterns.add(0, main); // Add at beginning } - + this.branchAnalysis = new BranchAnalysisConfig(newPrTargets, newPushPatterns); } } else { this.branchAnalysis = new BranchAnalysisConfig( - java.util.List.of(main), - java.util.List.of(main) - ); + java.util.List.of(main), + java.util.List.of(main)); } } - + /** * Handle legacy field name from database. */ @JsonSetter("commentCommandsConfig") - public void setCommentCommandsConfig(CommentCommandsConfig commentCommands) { - this.commentCommands = commentCommands; + public void setCommentCommandsConfig(CommentCommandsConfig commentCommands) { + this.commentCommands = commentCommands; } - + /** * Check if PR analysis is enabled (defaults to true if null). */ public boolean isPrAnalysisEnabled() { return prAnalysisEnabled == null || prAnalysisEnabled; } - + /** * Check if branch analysis is enabled (defaults to true if null). */ public boolean isBranchAnalysisEnabled() { return branchAnalysisEnabled == null || branchAnalysisEnabled; } - + /** * Check if comment commands are enabled for this project. */ public boolean isCommentCommandsEnabled() { return commentCommands != null && commentCommands.enabled(); } - + /** * Get the comment commands configuration, or a default disabled config if null. */ public CommentCommandsConfig getCommentCommandsConfig() { return commentCommands != null ? commentCommands : new CommentCommandsConfig(); } - + @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; ProjectConfig that = (ProjectConfig) o; return useLocalMcp == that.useLocalMcp && - Objects.equals(mainBranch, that.mainBranch) && - Objects.equals(branchAnalysis, that.branchAnalysis) && - Objects.equals(ragConfig, that.ragConfig) && - Objects.equals(prAnalysisEnabled, that.prAnalysisEnabled) && - Objects.equals(branchAnalysisEnabled, that.branchAnalysisEnabled) && - installationMethod == that.installationMethod && - Objects.equals(commentCommands, that.commentCommands) && - Objects.equals(maxAnalysisTokenLimit, that.maxAnalysisTokenLimit); - } - + useMcpTools == that.useMcpTools && + Objects.equals(mainBranch, that.mainBranch) && + Objects.equals(branchAnalysis, that.branchAnalysis) && + Objects.equals(ragConfig, that.ragConfig) && + Objects.equals(prAnalysisEnabled, that.prAnalysisEnabled) && + Objects.equals(branchAnalysisEnabled, that.branchAnalysisEnabled) && + installationMethod == that.installationMethod && + 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, maxAnalysisTokenLimit); + return Objects.hash(useLocalMcp, useMcpTools, mainBranch, branchAnalysis, ragConfig, + prAnalysisEnabled, branchAnalysisEnabled, installationMethod, + commentCommands, maxAnalysisTokenLimit); } - + @Override public String toString() { return "ProjectConfig{" + - "useLocalMcp=" + useLocalMcp + - ", mainBranch='" + mainBranch + '\'' + - ", branchAnalysis=" + branchAnalysis + - ", ragConfig=" + ragConfig + - ", prAnalysisEnabled=" + prAnalysisEnabled + - ", branchAnalysisEnabled=" + branchAnalysisEnabled + - ", installationMethod=" + installationMethod + - ", commentCommands=" + commentCommands + - ", maxAnalysisTokenLimit=" + maxAnalysisTokenLimit + - '}'; + "useLocalMcp=" + useLocalMcp + + ", useMcpTools=" + useMcpTools + + ", mainBranch='" + mainBranch + '\'' + + ", branchAnalysis=" + branchAnalysis + + ", ragConfig=" + ragConfig + + ", prAnalysisEnabled=" + prAnalysisEnabled + + ", branchAnalysisEnabled=" + branchAnalysisEnabled + + ", installationMethod=" + installationMethod + + ", commentCommands=" + commentCommands + + ", maxAnalysisTokenLimit=" + maxAnalysisTokenLimit + + '}'; } } diff --git a/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/dto/project/ProjectDTOTest.java b/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/dto/project/ProjectDTOTest.java index a16e1f93..9691e6fc 100644 --- a/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/dto/project/ProjectDTOTest.java +++ b/java-ecosystem/libs/core/src/test/java/org/rostilos/codecrow/core/dto/project/ProjectDTOTest.java @@ -34,14 +34,11 @@ class RecordConstructorTests { @DisplayName("should create ProjectDTO with all fields") void shouldCreateWithAllFields() { ProjectDTO.DefaultBranchStats stats = new ProjectDTO.DefaultBranchStats( - "main", 10, 3, 4, 2, 1 - ); + "main", 10, 3, 4, 2, 1); ProjectDTO.RagConfigDTO ragConfig = new ProjectDTO.RagConfigDTO( - true, "main", null, List.of("*.log"), true, 30 - ); + true, "main", null, List.of("*.log"), true, 30); ProjectDTO.CommentCommandsConfigDTO commandsConfig = new ProjectDTO.CommentCommandsConfigDTO( - true, 10, 60, true, List.of("/review", "/fix"), "ANYONE", true - ); + true, 10, 60, true, List.of("/review", "/fix"), "ANYONE", true); ProjectDTO dto = new ProjectDTO( 1L, "Test Project", "Description", true, @@ -50,8 +47,7 @@ void shouldCreateWithAllFields() { 20L, "namespace", "main", "main", 100L, stats, ragConfig, true, false, "WEBHOOK", - commandsConfig, true, 50L, 200000 - ); + commandsConfig, true, 50L, 200000, false); assertThat(dto.id()).isEqualTo(1L); assertThat(dto.name()).isEqualTo("Test Project"); @@ -84,8 +80,7 @@ void shouldCreateWithNullOptionalFields() { 1L, "Test", null, true, 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.description()).isNull(); assertThat(dto.vcsConnectionId()).isNull(); @@ -186,11 +181,9 @@ void shouldConvertProjectWithFullConfiguration() { RagConfig ragConfig = new RagConfig(true, "develop", null, List.of("*.log", "build/*"), true, 14); CommentCommandsConfig commandsConfig = new CommentCommandsConfig( true, 5, 30, true, List.of("/analyze"), - CommandAuthorizationMode.ALLOWED_USERS_ONLY, true - ); + CommandAuthorizationMode.ALLOWED_USERS_ONLY, true); ProjectConfig config = new ProjectConfig( - false, "main", null, ragConfig, true, true, InstallationMethod.WEBHOOK, commandsConfig - ); + false, "main", null, ragConfig, true, true, InstallationMethod.WEBHOOK, commandsConfig); project.setConfiguration(config); ProjectDTO dto = ProjectDTO.fromProject(project); @@ -278,8 +271,7 @@ class DefaultBranchStatsTests { @DisplayName("should create with all fields") void shouldCreateWithAllFields() { ProjectDTO.DefaultBranchStats stats = new ProjectDTO.DefaultBranchStats( - "main", 100, 25, 35, 30, 10 - ); + "main", 100, 25, 35, 30, 10); assertThat(stats.branchName()).isEqualTo("main"); assertThat(stats.totalIssues()).isEqualTo(100); @@ -293,8 +285,7 @@ void shouldCreateWithAllFields() { @DisplayName("should support zero values") void shouldSupportZeroValues() { ProjectDTO.DefaultBranchStats stats = new ProjectDTO.DefaultBranchStats( - "empty-branch", 0, 0, 0, 0, 0 - ); + "empty-branch", 0, 0, 0, 0, 0); assertThat(stats.totalIssues()).isZero(); assertThat(stats.highSeverityCount()).isZero(); @@ -309,8 +300,7 @@ class RagConfigDTOTests { @DisplayName("should create with all fields using full constructor") void shouldCreateWithAllFieldsUsingFullConstructor() { ProjectDTO.RagConfigDTO config = new ProjectDTO.RagConfigDTO( - true, "main", null, List.of("*.log", "build/*"), true, 30 - ); + true, "main", null, List.of("*.log", "build/*"), true, 30); assertThat(config.enabled()).isTrue(); assertThat(config.branch()).isEqualTo("main"); @@ -323,8 +313,7 @@ void shouldCreateWithAllFieldsUsingFullConstructor() { @DisplayName("should create with backward-compatible constructor") void shouldCreateWithBackwardCompatibleConstructor() { ProjectDTO.RagConfigDTO config = new ProjectDTO.RagConfigDTO( - true, "develop", List.of("*.tmp") - ); + true, "develop", List.of("*.tmp")); assertThat(config.enabled()).isTrue(); assertThat(config.branch()).isEqualTo("develop"); @@ -337,8 +326,7 @@ void shouldCreateWithBackwardCompatibleConstructor() { @DisplayName("should handle disabled RAG") void shouldHandleDisabledRag() { ProjectDTO.RagConfigDTO config = new ProjectDTO.RagConfigDTO( - false, null, null, null, null, null - ); + false, null, null, null, null, null); assertThat(config.enabled()).isFalse(); assertThat(config.branch()).isNull(); @@ -349,8 +337,7 @@ void shouldHandleDisabledRag() { @DisplayName("should handle empty exclude patterns") void shouldHandleEmptyExcludePatterns() { ProjectDTO.RagConfigDTO config = new ProjectDTO.RagConfigDTO( - true, "main", null, List.of(), true, 7 - ); + true, "main", null, List.of(), true, 7); assertThat(config.excludePatterns()).isEmpty(); } @@ -365,8 +352,7 @@ class CommentCommandsConfigDTOTests { void shouldCreateWithAllFields() { ProjectDTO.CommentCommandsConfigDTO config = new ProjectDTO.CommentCommandsConfigDTO( true, 10, 60, true, List.of("/review", "/fix", "/ignore"), - "ALLOWED_USERS_ONLY", true - ); + "ALLOWED_USERS_ONLY", true); assertThat(config.enabled()).isTrue(); assertThat(config.rateLimit()).isEqualTo(10); @@ -396,8 +382,7 @@ void shouldCreateFromNullConfig() { void shouldCreateFromValidConfig() { CommentCommandsConfig config = new CommentCommandsConfig( true, 5, 30, false, List.of("/analyze"), - CommandAuthorizationMode.PR_AUTHOR_ONLY, false - ); + CommandAuthorizationMode.PR_AUTHOR_ONLY, false); ProjectDTO.CommentCommandsConfigDTO dto = ProjectDTO.CommentCommandsConfigDTO.fromConfig(config); @@ -415,8 +400,7 @@ void shouldCreateFromValidConfig() { void shouldUseDefaultAuthorizationModeWhenNull() { CommentCommandsConfig config = new CommentCommandsConfig( true, 5, 30, false, List.of("/test"), - null, true - ); + null, true); ProjectDTO.CommentCommandsConfigDTO dto = ProjectDTO.CommentCommandsConfigDTO.fromConfig(config); @@ -427,8 +411,7 @@ void shouldUseDefaultAuthorizationModeWhenNull() { @DisplayName("should handle disabled commands config") void shouldHandleDisabledCommandsConfig() { ProjectDTO.CommentCommandsConfigDTO config = new ProjectDTO.CommentCommandsConfigDTO( - false, null, null, null, null, null, null - ); + false, null, null, null, null, null, null); assertThat(config.enabled()).isFalse(); assertThat(config.rateLimit()).isNull(); diff --git a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java index ae70034c..ebef1df2 100644 --- a/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java +++ b/java-ecosystem/libs/rag-engine/src/test/java/org/rostilos/codecrow/ragengine/service/VcsRagIndexingServiceTest.java @@ -57,8 +57,7 @@ class VcsRagIndexingServiceTest { void setUp() { service = new VcsRagIndexingService( projectRepository, vcsClientProvider, ragIndexingService, - ragIndexTrackingService, analysisLockService, jobService - ); + ragIndexTrackingService, analysisLockService, jobService); ReflectionTestUtils.setField(service, "ragApiEnabled", true); testProject = new Project(); @@ -67,7 +66,8 @@ void setUp() { } private ProjectDTO createProjectDTO(Long id) { - return new ProjectDTO(id, null, null, false, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null); + return new ProjectDTO(id, null, null, false, null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); } @Nested @@ -242,7 +242,8 @@ void shouldCompleteFullIndexing() throws Exception { when(mockVcs.downloadRepositoryArchiveToFile(eq("my-workspace"), eq("my-repo"), eq("main"), any())) .thenReturn(2048L); - when(ragIndexingService.indexFromArchiveFile(any(), eq("test-ws"), eq("test-ns"), eq("main"), eq("abc123"), isNull(), isNull())) + when(ragIndexingService.indexFromArchiveFile(any(), eq("test-ws"), eq("test-ns"), eq("main"), eq("abc123"), + isNull(), isNull())) .thenReturn(Map.of("document_count", 42)); Map result = service.indexProjectFromVcs(createProjectDTO(100L), "main", messageConsumer); @@ -333,7 +334,8 @@ void shouldUseRagConfigBranch() throws Exception { when(mockVcs.getLatestCommitHash("my-workspace", "my-repo", "develop")).thenReturn("c1"); when(mockVcs.downloadRepositoryArchiveToFile(eq("my-workspace"), eq("my-repo"), eq("develop"), any())) .thenReturn(512L); - when(ragIndexingService.indexFromArchiveFile(any(), anyString(), anyString(), eq("develop"), eq("c1"), isNull(), isNull())) + when(ragIndexingService.indexFromArchiveFile(any(), anyString(), anyString(), eq("develop"), eq("c1"), + isNull(), isNull())) .thenReturn(Map.of("document_count", 10)); Map result = service.indexProjectFromVcs(createProjectDTO(100L), null, messageConsumer); @@ -364,7 +366,8 @@ void shouldUseDefaultBranch() throws Exception { when(mockVcs.getLatestCommitHash("my-workspace", "my-repo", "master")).thenReturn("c1"); when(mockVcs.downloadRepositoryArchiveToFile(eq("my-workspace"), eq("my-repo"), eq("master"), any())) .thenReturn(100L); - when(ragIndexingService.indexFromArchiveFile(any(), anyString(), anyString(), eq("master"), eq("c1"), isNull(), isNull())) + when(ragIndexingService.indexFromArchiveFile(any(), anyString(), anyString(), eq("master"), eq("c1"), + isNull(), isNull())) .thenReturn(Map.of("document_count", 5)); Map result = service.indexProjectFromVcs(createProjectDTO(100L), "", messageConsumer); @@ -396,13 +399,15 @@ void shouldApplyExcludePatterns() throws Exception { when(mockVcs.getLatestCommitHash(anyString(), anyString(), anyString())).thenReturn("c1"); when(mockVcs.downloadRepositoryArchiveToFile(anyString(), anyString(), anyString(), any())) .thenReturn(1024L); - when(ragIndexingService.indexFromArchiveFile(any(), anyString(), anyString(), anyString(), anyString(), isNull(), eq(excludePatterns))) + when(ragIndexingService.indexFromArchiveFile(any(), anyString(), anyString(), anyString(), anyString(), + isNull(), eq(excludePatterns))) .thenReturn(Map.of("document_count", 20)); Map result = service.indexProjectFromVcs(createProjectDTO(100L), "main", messageConsumer); assertThat(result).containsEntry("status", "completed"); - verify(ragIndexingService).indexFromArchiveFile(any(), anyString(), anyString(), anyString(), anyString(), isNull(), eq(excludePatterns)); + verify(ragIndexingService).indexFromArchiveFile(any(), anyString(), anyString(), anyString(), anyString(), + isNull(), eq(excludePatterns)); } @Test diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketAiClientService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketAiClientService.java index 7060f11e..d580a3fd 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketAiClientService.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketAiClientService.java @@ -42,15 +42,16 @@ @Service public class BitbucketAiClientService implements VcsAiClientService { private static final Logger log = LoggerFactory.getLogger(BitbucketAiClientService.class); - + /** * Threshold for escalating from incremental to full analysis. * If delta diff is larger than this percentage of full diff, use full analysis. */ private static final double INCREMENTAL_ESCALATION_THRESHOLD = 0.5; - + /** - * Minimum delta diff size in characters to consider incremental analysis worthwhile. + * Minimum delta diff size in characters to consider incremental analysis + * worthwhile. * Below this threshold, full analysis might be more effective. */ private static final int MIN_DELTA_DIFF_SIZE = 500; @@ -63,8 +64,7 @@ public class BitbucketAiClientService implements VcsAiClientService { public BitbucketAiClientService( TokenEncryptionService tokenEncryptionService, VcsClientProvider vcsClientProvider, - @Autowired(required = false) PrFileEnrichmentService enrichmentService - ) { + @Autowired(required = false) PrFileEnrichmentService enrichmentService) { this.tokenEncryptionService = tokenEncryptionService; this.vcsClientProvider = vcsClientProvider; this.credentialsExtractor = new VcsConnectionCredentialsExtractor(tokenEncryptionService); @@ -79,7 +79,8 @@ public EVcsProvider getProvider() { /** * Helper class to hold VCS connection info. */ - private record VcsInfo(VcsConnection vcsConnection, String workspace, String repoSlug) {} + private record VcsInfo(VcsConnection vcsConnection, String workspace, String repoSlug) { + } /** * Get VCS info from the project using the unified accessor. @@ -98,12 +99,12 @@ private VcsInfo getVcsInfo(Project project) { public AiAnalysisRequest buildAiAnalysisRequest( Project project, AnalysisProcessRequest request, - Optional previousAnalysis - ) throws GeneralSecurityException { - if(request.getAnalysisType() == AnalysisType.BRANCH_ANALYSIS){ + Optional previousAnalysis) throws GeneralSecurityException { + if (request.getAnalysisType() == AnalysisType.BRANCH_ANALYSIS) { return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); } else { - return buildPrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, Collections.emptyList()); + return buildPrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, + Collections.emptyList()); } } @@ -112,9 +113,8 @@ public AiAnalysisRequest buildAiAnalysisRequest( Project project, AnalysisProcessRequest request, Optional previousAnalysis, - List allPrAnalyses - ) throws GeneralSecurityException { - if(request.getAnalysisType() == AnalysisType.BRANCH_ANALYSIS){ + List allPrAnalyses) throws GeneralSecurityException { + if (request.getAnalysisType() == AnalysisType.BRANCH_ANALYSIS) { return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); } else { return buildPrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, allPrAnalyses); @@ -125,14 +125,13 @@ public AiAnalysisRequest buildPrAnalysisRequest( Project project, PrProcessRequest request, Optional previousAnalysis, - List allPrAnalyses - ) throws GeneralSecurityException { + List allPrAnalyses) throws GeneralSecurityException { VcsInfo vcsInfo = getVcsInfo(project); VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); - + // CRITICAL: Log the AI connection being used for debugging - log.info("Building PR analysis request for project={}, AI model={}, provider={}, aiConnectionId={}", + log.info("Building PR analysis request for project={}, AI model={}, provider={}, aiConnectionId={}", project.getId(), aiConnection.getAiModel(), aiConnection.getProviderKey(), aiConnection.getId()); // Initialize variables @@ -154,8 +153,7 @@ public AiAnalysisRequest buildPrAnalysisRequest( GetPullRequestAction.PullRequestMetadata prMetadata = prAction.getPullRequest( vcsInfo.workspace(), vcsInfo.repoSlug(), - String.valueOf(request.getPullRequestId()) - ); + String.valueOf(request.getPullRequestId())); prTitle = prMetadata.getTitle(); prDescription = prMetadata.getDescription(); @@ -168,63 +166,63 @@ public AiAnalysisRequest buildPrAnalysisRequest( String fetchedDiff = diffAction.getPullRequestDiff( vcsInfo.workspace(), vcsInfo.repoSlug(), - String.valueOf(request.getPullRequestId()) - ); - + String.valueOf(request.getPullRequestId())); + // Apply content filter DiffContentFilter contentFilter = new DiffContentFilter(); rawDiff = contentFilter.filterDiff(fetchedDiff); - + int originalSize = fetchedDiff != null ? fetchedDiff.length() : 0; int filteredSize = rawDiff != null ? rawDiff.length() : 0; - + if (originalSize != filteredSize) { - log.info("Diff filtered: {} -> {} chars ({}% reduction)", - originalSize, filteredSize, + log.info("Diff filtered: {} -> {} chars ({}% reduction)", + originalSize, filteredSize, originalSize > 0 ? (100 - (filteredSize * 100 / originalSize)) : 0); } // Check token limit before proceeding with analysis int maxTokenLimit = project.getEffectiveConfig().maxAnalysisTokenLimit(); - TokenEstimator.TokenEstimationResult tokenEstimate = TokenEstimator.estimateAndCheck(rawDiff, maxTokenLimit); + TokenEstimator.TokenEstimationResult tokenEstimate = TokenEstimator.estimateAndCheck(rawDiff, + maxTokenLimit); log.info("Token estimation for PR diff: {}", tokenEstimate.toLogString()); - + if (tokenEstimate.exceedsLimit()) { log.warn("PR diff exceeds token limit - skipping analysis. Project={}, PR={}, Tokens={}/{}", - project.getId(), request.getPullRequestId(), + project.getId(), request.getPullRequestId(), tokenEstimate.estimatedTokens(), tokenEstimate.maxAllowedTokens()); throw new DiffTooLargeException( tokenEstimate.estimatedTokens(), tokenEstimate.maxAllowedTokens(), project.getId(), - request.getPullRequestId() - ); + request.getPullRequestId()); } - // Determine analysis mode: INCREMENTAL if we have previous analysis with different commit - boolean canUseIncremental = previousAnalysis.isPresent() - && previousCommitHash != null + // Determine analysis mode: INCREMENTAL if we have previous analysis with + // different commit + boolean canUseIncremental = previousAnalysis.isPresent() + && previousCommitHash != null && currentCommitHash != null && !previousCommitHash.equals(currentCommitHash); if (canUseIncremental) { // Try to fetch delta diff (changes since last analyzed commit) deltaDiff = fetchDeltaDiff(client, vcsInfo, previousCommitHash, currentCommitHash, contentFilter); - + if (deltaDiff != null && !deltaDiff.isEmpty()) { // Check if delta is worth using (not too large compared to full diff) int deltaSize = deltaDiff.length(); int fullSize = rawDiff != null ? rawDiff.length() : 0; - + if (deltaSize >= MIN_DELTA_DIFF_SIZE && fullSize > 0) { double deltaRatio = (double) deltaSize / fullSize; - + if (deltaRatio <= INCREMENTAL_ESCALATION_THRESHOLD) { analysisMode = AnalysisMode.INCREMENTAL; - log.info("Using INCREMENTAL analysis mode: delta={} chars ({}% of full diff {})", + log.info("Using INCREMENTAL analysis mode: delta={} chars ({}% of full diff {})", deltaSize, Math.round(deltaRatio * 100), fullSize); } else { - log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", + log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", Math.round(deltaRatio * 100)); deltaDiff = null; // Don't send delta if not using incremental mode } @@ -263,8 +261,7 @@ public AiAnalysisRequest buildPrAnalysisRequest( vcsInfo.workspace(), vcsInfo.repoSlug(), request.getSourceBranchName(), - changedFiles - ); + changedFiles); log.info("PR enrichment completed: {} files enriched, {} relationships", enrichmentData.stats().filesEnriched(), enrichmentData.stats().relationshipsFound()); @@ -278,8 +275,10 @@ public AiAnalysisRequest buildPrAnalysisRequest( .withPullRequestId(request.getPullRequestId()) .withProjectAiConnection(aiConnection) .withProjectVcsConnectionBindingInfo(vcsInfo.workspace(), vcsInfo.repoSlug()) - .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withProjectAiConnectionTokenDecrypted( + tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) .withUseLocalMcp(true) + .withUseMcpTools(project.getEffectiveConfig().useMcpTools()) .withAllPrAnalysesData(allPrAnalyses) // Use full PR history instead of just previous version .withMaxAllowedTokens(project.getEffectiveConfig().maxAnalysisTokenLimit()) .withAnalysisType(request.getAnalysisType()) @@ -298,39 +297,37 @@ public AiAnalysisRequest buildPrAnalysisRequest( .withCurrentCommitHash(currentCommitHash) // File enrichment data .withEnrichmentData(enrichmentData); - + // Add VCS credentials based on connection type addVcsCredentials(builder, vcsConnection); - + return builder.build(); } - + /** * Fetches the delta diff between two commits. * Returns null if fetching fails. */ private String fetchDeltaDiff( - OkHttpClient client, - VcsInfo vcsInfo, - String baseCommit, + OkHttpClient client, + VcsInfo vcsInfo, + String baseCommit, String headCommit, - DiffContentFilter contentFilter - ) { + DiffContentFilter contentFilter) { try { GetCommitRangeDiffAction rangeDiffAction = new GetCommitRangeDiffAction(client); String fetchedDeltaDiff = rangeDiffAction.getCommitRangeDiff( vcsInfo.workspace(), vcsInfo.repoSlug(), baseCommit, - headCommit - ); - + headCommit); + // Apply same content filter as full diff return contentFilter.filterDiff(fetchedDeltaDiff); } catch (IOException e) { - log.warn("Failed to fetch delta diff from {} to {}: {}", - baseCommit.substring(0, Math.min(7, baseCommit.length())), - headCommit.substring(0, Math.min(7, headCommit.length())), + log.warn("Failed to fetch delta diff from {} to {}: {}", + baseCommit.substring(0, Math.min(7, baseCommit.length())), + headCommit.substring(0, Math.min(7, headCommit.length())), e.getMessage()); return null; } @@ -339,8 +336,7 @@ private String fetchDeltaDiff( public AiAnalysisRequest buildBranchAnalysisRequest( Project project, BranchProcessRequest request, - Optional previousAnalysis - ) throws GeneralSecurityException { + Optional previousAnalysis) throws GeneralSecurityException { VcsInfo vcsInfo = getVcsInfo(project); VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); @@ -350,8 +346,10 @@ public AiAnalysisRequest buildBranchAnalysisRequest( .withPullRequestId(null) .withProjectAiConnection(aiConnection) .withProjectVcsConnectionBindingInfo(vcsInfo.workspace(), vcsInfo.repoSlug()) - .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withProjectAiConnectionTokenDecrypted( + tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) .withUseLocalMcp(true) + .withUseMcpTools(project.getEffectiveConfig().useMcpTools()) .withPreviousAnalysisData(previousAnalysis) .withMaxAllowedTokens(project.getEffectiveConfig().maxAnalysisTokenLimit()) .withAnalysisType(request.getAnalysisType()) @@ -359,18 +357,18 @@ public AiAnalysisRequest buildBranchAnalysisRequest( .withCurrentCommitHash(request.getCommitHash()) .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()) .withVcsProvider("bitbucket_cloud"); - + addVcsCredentials(builder, vcsConnection); - + return builder.build(); } - + /** * Add VCS credentials to the builder based on connection type. * For OAUTH_MANUAL: uses OAuth consumer key/secret from config * For APP: uses bearer token directly via accessToken field */ - private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) + private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) throws GeneralSecurityException { VcsConnectionCredentials credentials = credentialsExtractor.extractCredentials(connection); if (VcsConnectionCredentialsExtractor.hasAccessToken(credentials)) { @@ -378,8 +376,7 @@ private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConn } else if (VcsConnectionCredentialsExtractor.hasOAuthCredentials(credentials)) { builder.withProjectVcsConnectionCredentials( credentials.oAuthClient(), - credentials.oAuthSecret() - ); + credentials.oAuthSecret()); } else { log.warn("No credentials available for VCS connection type: {}", connection.getConnectionType()); } diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubAiClientService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubAiClientService.java index fcd28019..ae7fad55 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubAiClientService.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubAiClientService.java @@ -42,15 +42,16 @@ @Service public class GitHubAiClientService implements VcsAiClientService { private static final Logger log = LoggerFactory.getLogger(GitHubAiClientService.class); - + /** * Threshold for escalating from incremental to full analysis. * If delta diff is larger than this percentage of full diff, use full analysis. */ private static final double INCREMENTAL_ESCALATION_THRESHOLD = 0.5; - + /** - * Minimum delta diff size in characters to consider incremental analysis worthwhile. + * Minimum delta diff size in characters to consider incremental analysis + * worthwhile. */ private static final int MIN_DELTA_DIFF_SIZE = 500; @@ -62,8 +63,7 @@ public class GitHubAiClientService implements VcsAiClientService { public GitHubAiClientService( TokenEncryptionService tokenEncryptionService, VcsClientProvider vcsClientProvider, - @Autowired(required = false) PrFileEnrichmentService enrichmentService - ) { + @Autowired(required = false) PrFileEnrichmentService enrichmentService) { this.tokenEncryptionService = tokenEncryptionService; this.vcsClientProvider = vcsClientProvider; this.credentialsExtractor = new VcsConnectionCredentialsExtractor(tokenEncryptionService); @@ -75,7 +75,8 @@ public EVcsProvider getProvider() { return EVcsProvider.GITHUB; } - private record VcsInfo(VcsConnection vcsConnection, String owner, String repoSlug) {} + private record VcsInfo(VcsConnection vcsConnection, String owner, String repoSlug) { + } private VcsInfo getVcsInfo(Project project) { // Use unified method that prefers VcsRepoBinding over legacy vcsBinding @@ -91,13 +92,13 @@ private VcsInfo getVcsInfo(Project project) { public AiAnalysisRequest buildAiAnalysisRequest( Project project, AnalysisProcessRequest request, - Optional previousAnalysis - ) throws GeneralSecurityException { + Optional previousAnalysis) throws GeneralSecurityException { switch (request.getAnalysisType()) { case BRANCH_ANALYSIS: return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); default: - return buildPrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, Collections.emptyList()); + return buildPrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, + Collections.emptyList()); } } @@ -106,8 +107,7 @@ public AiAnalysisRequest buildAiAnalysisRequest( Project project, AnalysisProcessRequest request, Optional previousAnalysis, - List allPrAnalyses - ) throws GeneralSecurityException { + List allPrAnalyses) throws GeneralSecurityException { switch (request.getAnalysisType()) { case BRANCH_ANALYSIS: return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); @@ -120,14 +120,13 @@ private AiAnalysisRequest buildPrAnalysisRequest( Project project, PrProcessRequest request, Optional previousAnalysis, - List allPrAnalyses - ) throws GeneralSecurityException { + List allPrAnalyses) throws GeneralSecurityException { VcsInfo vcsInfo = getVcsInfo(project); VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); - + // CRITICAL: Log the AI connection being used for debugging - log.info("Building PR analysis request for project={}, AI model={}, provider={}, aiConnectionId={}", + log.info("Building PR analysis request for project={}, AI model={}, provider={}, aiConnectionId={}", project.getId(), aiConnection.getAiModel(), aiConnection.getProviderKey(), aiConnection.getId()); // Initialize variables @@ -148,8 +147,7 @@ private AiAnalysisRequest buildPrAnalysisRequest( JsonNode prData = prAction.getPullRequest( vcsInfo.owner(), vcsInfo.repoSlug(), - request.getPullRequestId().intValue() - ); + request.getPullRequestId().intValue()); prTitle = prData.has("title") ? prData.get("title").asText() : null; prDescription = prData.has("body") ? prData.get("body").asText() : null; @@ -162,63 +160,63 @@ private AiAnalysisRequest buildPrAnalysisRequest( String fetchedDiff = diffAction.getPullRequestDiff( vcsInfo.owner(), vcsInfo.repoSlug(), - request.getPullRequestId().intValue() - ); - + request.getPullRequestId().intValue()); + // Apply content filter DiffContentFilter contentFilter = new DiffContentFilter(); rawDiff = contentFilter.filterDiff(fetchedDiff); - + int originalSize = fetchedDiff != null ? fetchedDiff.length() : 0; int filteredSize = rawDiff != null ? rawDiff.length() : 0; - + if (originalSize != filteredSize) { - log.info("Diff filtered: {} -> {} chars ({}% reduction)", - originalSize, filteredSize, + log.info("Diff filtered: {} -> {} chars ({}% reduction)", + originalSize, filteredSize, originalSize > 0 ? (100 - (filteredSize * 100 / originalSize)) : 0); } // Check token limit before proceeding with analysis int maxTokenLimit = project.getEffectiveConfig().maxAnalysisTokenLimit(); - TokenEstimator.TokenEstimationResult tokenEstimate = TokenEstimator.estimateAndCheck(rawDiff, maxTokenLimit); + TokenEstimator.TokenEstimationResult tokenEstimate = TokenEstimator.estimateAndCheck(rawDiff, + maxTokenLimit); log.info("Token estimation for PR diff: {}", tokenEstimate.toLogString()); - + if (tokenEstimate.exceedsLimit()) { log.warn("PR diff exceeds token limit - skipping analysis. Project={}, PR={}, Tokens={}/{}", - project.getId(), request.getPullRequestId(), + project.getId(), request.getPullRequestId(), tokenEstimate.estimatedTokens(), tokenEstimate.maxAllowedTokens()); throw new DiffTooLargeException( tokenEstimate.estimatedTokens(), tokenEstimate.maxAllowedTokens(), project.getId(), - request.getPullRequestId() - ); + request.getPullRequestId()); } - // Determine analysis mode: INCREMENTAL if we have previous analysis with different commit - boolean canUseIncremental = previousAnalysis.isPresent() - && previousCommitHash != null + // Determine analysis mode: INCREMENTAL if we have previous analysis with + // different commit + boolean canUseIncremental = previousAnalysis.isPresent() + && previousCommitHash != null && currentCommitHash != null && !previousCommitHash.equals(currentCommitHash); if (canUseIncremental) { // Try to fetch delta diff (changes since last analyzed commit) deltaDiff = fetchDeltaDiff(client, vcsInfo, previousCommitHash, currentCommitHash, contentFilter); - + if (deltaDiff != null && !deltaDiff.isEmpty()) { // Check if delta is worth using (not too large compared to full diff) int deltaSize = deltaDiff.length(); int fullSize = rawDiff != null ? rawDiff.length() : 0; - + if (deltaSize >= MIN_DELTA_DIFF_SIZE && fullSize > 0) { double deltaRatio = (double) deltaSize / fullSize; - + if (deltaRatio <= INCREMENTAL_ESCALATION_THRESHOLD) { analysisMode = AnalysisMode.INCREMENTAL; - log.info("Using INCREMENTAL analysis mode: delta={} chars ({}% of full diff {})", + log.info("Using INCREMENTAL analysis mode: delta={} chars ({}% of full diff {})", deltaSize, Math.round(deltaRatio * 100), fullSize); } else { - log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", + log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", Math.round(deltaRatio * 100)); deltaDiff = null; } @@ -255,8 +253,7 @@ private AiAnalysisRequest buildPrAnalysisRequest( vcsInfo.owner(), vcsInfo.repoSlug(), request.getSourceBranchName(), - changedFiles - ); + changedFiles); log.info("PR enrichment completed: {} files enriched, {} relationships", enrichmentData.stats().filesEnriched(), enrichmentData.stats().relationshipsFound()); @@ -270,8 +267,10 @@ private AiAnalysisRequest buildPrAnalysisRequest( .withPullRequestId(request.getPullRequestId()) .withProjectAiConnection(aiConnection) .withProjectVcsConnectionBindingInfo(vcsInfo.owner(), vcsInfo.repoSlug()) - .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withProjectAiConnectionTokenDecrypted( + tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) .withUseLocalMcp(true) + .withUseMcpTools(project.getEffectiveConfig().useMcpTools()) .withAllPrAnalysesData(allPrAnalyses) // Use full PR history instead of just previous version .withMaxAllowedTokens(project.getEffectiveConfig().maxAnalysisTokenLimit()) .withAnalysisType(request.getAnalysisType()) @@ -290,37 +289,35 @@ private AiAnalysisRequest buildPrAnalysisRequest( .withCurrentCommitHash(currentCommitHash) // File enrichment data .withEnrichmentData(enrichmentData); - + addVcsCredentials(builder, vcsConnection); - + return builder.build(); } - + /** * Fetches the delta diff between two commits. * Returns null if fetching fails. */ private String fetchDeltaDiff( - OkHttpClient client, - VcsInfo vcsInfo, - String baseCommit, + OkHttpClient client, + VcsInfo vcsInfo, + String baseCommit, String headCommit, - DiffContentFilter contentFilter - ) { + DiffContentFilter contentFilter) { try { GetCommitRangeDiffAction rangeDiffAction = new GetCommitRangeDiffAction(client); String fetchedDeltaDiff = rangeDiffAction.getCommitRangeDiff( vcsInfo.owner(), vcsInfo.repoSlug(), baseCommit, - headCommit - ); - + headCommit); + return contentFilter.filterDiff(fetchedDeltaDiff); } catch (IOException e) { - log.warn("Failed to fetch delta diff from {} to {}: {}", - baseCommit.substring(0, Math.min(7, baseCommit.length())), - headCommit.substring(0, Math.min(7, headCommit.length())), + log.warn("Failed to fetch delta diff from {} to {}: {}", + baseCommit.substring(0, Math.min(7, baseCommit.length())), + headCommit.substring(0, Math.min(7, headCommit.length())), e.getMessage()); return null; } @@ -329,8 +326,7 @@ private String fetchDeltaDiff( private AiAnalysisRequest buildBranchAnalysisRequest( Project project, BranchProcessRequest request, - Optional previousAnalysis - ) throws GeneralSecurityException { + Optional previousAnalysis) throws GeneralSecurityException { VcsInfo vcsInfo = getVcsInfo(project); VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); @@ -340,8 +336,10 @@ private AiAnalysisRequest buildBranchAnalysisRequest( .withPullRequestId(null) .withProjectAiConnection(aiConnection) .withProjectVcsConnectionBindingInfo(vcsInfo.owner(), vcsInfo.repoSlug()) - .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withProjectAiConnectionTokenDecrypted( + tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) .withUseLocalMcp(true) + .withUseMcpTools(project.getEffectiveConfig().useMcpTools()) .withPreviousAnalysisData(previousAnalysis) .withMaxAllowedTokens(project.getEffectiveConfig().maxAnalysisTokenLimit()) .withAnalysisType(request.getAnalysisType()) @@ -349,13 +347,13 @@ private AiAnalysisRequest buildBranchAnalysisRequest( .withCurrentCommitHash(request.getCommitHash()) .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()) .withVcsProvider("github"); - + addVcsCredentials(builder, vcsConnection); - + return builder.build(); } - - private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) + + private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) throws GeneralSecurityException { VcsConnectionCredentials credentials = credentialsExtractor.extractCredentials(connection); if (VcsConnectionCredentialsExtractor.hasAccessToken(credentials)) { diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabAiClientService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabAiClientService.java index e6b48c10..a3877091 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabAiClientService.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabAiClientService.java @@ -42,15 +42,16 @@ @Service public class GitLabAiClientService implements VcsAiClientService { private static final Logger log = LoggerFactory.getLogger(GitLabAiClientService.class); - + /** * Threshold for escalating from incremental to full analysis. * If delta diff is larger than this percentage of full diff, use full analysis. */ private static final double INCREMENTAL_ESCALATION_THRESHOLD = 0.5; - + /** - * Minimum delta diff size in characters to consider incremental analysis worthwhile. + * Minimum delta diff size in characters to consider incremental analysis + * worthwhile. */ private static final int MIN_DELTA_DIFF_SIZE = 500; @@ -62,8 +63,7 @@ public class GitLabAiClientService implements VcsAiClientService { public GitLabAiClientService( TokenEncryptionService tokenEncryptionService, VcsClientProvider vcsClientProvider, - @Autowired(required = false) PrFileEnrichmentService enrichmentService - ) { + @Autowired(required = false) PrFileEnrichmentService enrichmentService) { this.tokenEncryptionService = tokenEncryptionService; this.vcsClientProvider = vcsClientProvider; this.credentialsExtractor = new VcsConnectionCredentialsExtractor(tokenEncryptionService); @@ -75,7 +75,8 @@ public EVcsProvider getProvider() { return EVcsProvider.GITLAB; } - private record VcsInfo(VcsConnection vcsConnection, String namespace, String repoSlug) {} + private record VcsInfo(VcsConnection vcsConnection, String namespace, String repoSlug) { + } private VcsInfo getVcsInfo(Project project) { // Use unified method that prefers VcsRepoBinding over legacy vcsBinding @@ -91,13 +92,13 @@ private VcsInfo getVcsInfo(Project project) { public AiAnalysisRequest buildAiAnalysisRequest( Project project, AnalysisProcessRequest request, - Optional previousAnalysis - ) throws GeneralSecurityException { + Optional previousAnalysis) throws GeneralSecurityException { switch (request.getAnalysisType()) { case BRANCH_ANALYSIS: return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); default: - return buildMrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, Collections.emptyList()); + return buildMrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis, + Collections.emptyList()); } } @@ -106,8 +107,7 @@ public AiAnalysisRequest buildAiAnalysisRequest( Project project, AnalysisProcessRequest request, Optional previousAnalysis, - List allPrAnalyses - ) throws GeneralSecurityException { + List allPrAnalyses) throws GeneralSecurityException { switch (request.getAnalysisType()) { case BRANCH_ANALYSIS: return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); @@ -120,14 +120,13 @@ private AiAnalysisRequest buildMrAnalysisRequest( Project project, PrProcessRequest request, Optional previousAnalysis, - List allPrAnalyses - ) throws GeneralSecurityException { + List allPrAnalyses) throws GeneralSecurityException { VcsInfo vcsInfo = getVcsInfo(project); VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); - + // CRITICAL: Log the AI connection being used for debugging - log.info("Building MR analysis request for project={}, AI model={}, provider={}, aiConnectionId={}", + log.info("Building MR analysis request for project={}, AI model={}, provider={}, aiConnectionId={}", project.getId(), aiConnection.getAiModel(), aiConnection.getProviderKey(), aiConnection.getId()); // Initialize variables @@ -149,8 +148,7 @@ private AiAnalysisRequest buildMrAnalysisRequest( JsonNode mrData = mrAction.getMergeRequest( vcsInfo.namespace(), vcsInfo.repoSlug(), - request.getPullRequestId().intValue() - ); + request.getPullRequestId().intValue()); mrTitle = mrData.has("title") ? mrData.get("title").asText() : null; mrDescription = mrData.has("description") ? mrData.get("description").asText() : null; @@ -163,63 +161,63 @@ private AiAnalysisRequest buildMrAnalysisRequest( String fetchedDiff = diffAction.getMergeRequestDiff( vcsInfo.namespace(), vcsInfo.repoSlug(), - request.getPullRequestId().intValue() - ); - + request.getPullRequestId().intValue()); + // Apply content filter DiffContentFilter contentFilter = new DiffContentFilter(); rawDiff = contentFilter.filterDiff(fetchedDiff); - + int originalSize = fetchedDiff != null ? fetchedDiff.length() : 0; int filteredSize = rawDiff != null ? rawDiff.length() : 0; - + if (originalSize != filteredSize) { - log.info("Diff filtered: {} -> {} chars ({}% reduction)", - originalSize, filteredSize, + log.info("Diff filtered: {} -> {} chars ({}% reduction)", + originalSize, filteredSize, originalSize > 0 ? (100 - (filteredSize * 100 / originalSize)) : 0); } // Check token limit before proceeding with analysis int maxTokenLimit = project.getEffectiveConfig().maxAnalysisTokenLimit(); - TokenEstimator.TokenEstimationResult tokenEstimate = TokenEstimator.estimateAndCheck(rawDiff, maxTokenLimit); + TokenEstimator.TokenEstimationResult tokenEstimate = TokenEstimator.estimateAndCheck(rawDiff, + maxTokenLimit); log.info("Token estimation for MR diff: {}", tokenEstimate.toLogString()); - + if (tokenEstimate.exceedsLimit()) { log.warn("MR diff exceeds token limit - skipping analysis. Project={}, PR={}, Tokens={}/{}", - project.getId(), request.getPullRequestId(), + project.getId(), request.getPullRequestId(), tokenEstimate.estimatedTokens(), tokenEstimate.maxAllowedTokens()); throw new DiffTooLargeException( tokenEstimate.estimatedTokens(), tokenEstimate.maxAllowedTokens(), project.getId(), - request.getPullRequestId() - ); + request.getPullRequestId()); } - // Determine analysis mode: INCREMENTAL if we have previous analysis with different commit - boolean canUseIncremental = previousAnalysis.isPresent() - && previousCommitHash != null + // Determine analysis mode: INCREMENTAL if we have previous analysis with + // different commit + boolean canUseIncremental = previousAnalysis.isPresent() + && previousCommitHash != null && currentCommitHash != null && !previousCommitHash.equals(currentCommitHash); if (canUseIncremental) { // Try to fetch delta diff (changes since last analyzed commit) deltaDiff = fetchDeltaDiff(client, vcsInfo, previousCommitHash, currentCommitHash, contentFilter); - + if (deltaDiff != null && !deltaDiff.isEmpty()) { // Check if delta is worth using (not too large compared to full diff) int deltaSize = deltaDiff.length(); int fullSize = rawDiff != null ? rawDiff.length() : 0; - + if (deltaSize >= MIN_DELTA_DIFF_SIZE && fullSize > 0) { double deltaRatio = (double) deltaSize / fullSize; - + if (deltaRatio <= INCREMENTAL_ESCALATION_THRESHOLD) { analysisMode = AnalysisMode.INCREMENTAL; - log.info("Using INCREMENTAL analysis mode: delta={} chars ({}% of full diff {})", + log.info("Using INCREMENTAL analysis mode: delta={} chars ({}% of full diff {})", deltaSize, Math.round(deltaRatio * 100), fullSize); } else { - log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", + log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", Math.round(deltaRatio * 100)); deltaDiff = null; } @@ -256,8 +254,7 @@ private AiAnalysisRequest buildMrAnalysisRequest( vcsInfo.namespace(), vcsInfo.repoSlug(), request.getSourceBranchName(), - changedFiles - ); + changedFiles); log.info("PR enrichment completed: {} files enriched, {} relationships", enrichmentData.stats().filesEnriched(), enrichmentData.stats().relationshipsFound()); @@ -271,8 +268,10 @@ private AiAnalysisRequest buildMrAnalysisRequest( .withPullRequestId(request.getPullRequestId()) .withProjectAiConnection(aiConnection) .withProjectVcsConnectionBindingInfo(vcsInfo.namespace(), vcsInfo.repoSlug()) - .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withProjectAiConnectionTokenDecrypted( + tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) .withUseLocalMcp(true) + .withUseMcpTools(project.getEffectiveConfig().useMcpTools()) .withAllPrAnalysesData(allPrAnalyses) // Use full PR history instead of just previous version .withMaxAllowedTokens(project.getEffectiveConfig().maxAnalysisTokenLimit()) .withAnalysisType(request.getAnalysisType()) @@ -291,37 +290,35 @@ private AiAnalysisRequest buildMrAnalysisRequest( .withCurrentCommitHash(currentCommitHash) // File enrichment data .withEnrichmentData(enrichmentData); - + addVcsCredentials(builder, vcsConnection); - + return builder.build(); } - + /** * Fetches the delta diff between two commits. * Returns null if fetching fails. */ private String fetchDeltaDiff( - OkHttpClient client, - VcsInfo vcsInfo, - String baseCommit, + OkHttpClient client, + VcsInfo vcsInfo, + String baseCommit, String headCommit, - DiffContentFilter contentFilter - ) { + DiffContentFilter contentFilter) { try { GetCommitRangeDiffAction rangeDiffAction = new GetCommitRangeDiffAction(client); String fetchedDeltaDiff = rangeDiffAction.getCommitRangeDiff( vcsInfo.namespace(), vcsInfo.repoSlug(), baseCommit, - headCommit - ); - + headCommit); + return contentFilter.filterDiff(fetchedDeltaDiff); } catch (IOException e) { - log.warn("Failed to fetch delta diff from {} to {}: {}", - baseCommit.substring(0, Math.min(7, baseCommit.length())), - headCommit.substring(0, Math.min(7, headCommit.length())), + log.warn("Failed to fetch delta diff from {} to {}: {}", + baseCommit.substring(0, Math.min(7, baseCommit.length())), + headCommit.substring(0, Math.min(7, headCommit.length())), e.getMessage()); return null; } @@ -330,8 +327,7 @@ private String fetchDeltaDiff( private AiAnalysisRequest buildBranchAnalysisRequest( Project project, BranchProcessRequest request, - Optional previousAnalysis - ) throws GeneralSecurityException { + Optional previousAnalysis) throws GeneralSecurityException { VcsInfo vcsInfo = getVcsInfo(project); VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); @@ -341,8 +337,10 @@ private AiAnalysisRequest buildBranchAnalysisRequest( .withPullRequestId(null) .withProjectAiConnection(aiConnection) .withProjectVcsConnectionBindingInfo(vcsInfo.namespace(), vcsInfo.repoSlug()) - .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withProjectAiConnectionTokenDecrypted( + tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) .withUseLocalMcp(true) + .withUseMcpTools(project.getEffectiveConfig().useMcpTools()) .withPreviousAnalysisData(previousAnalysis) .withMaxAllowedTokens(project.getEffectiveConfig().maxAnalysisTokenLimit()) .withAnalysisType(request.getAnalysisType()) @@ -350,13 +348,13 @@ private AiAnalysisRequest buildBranchAnalysisRequest( .withCurrentCommitHash(request.getCommitHash()) .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()) .withVcsProvider("gitlab"); - + addVcsCredentials(builder, vcsConnection); - + return builder.build(); } - - private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) + + private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) throws GeneralSecurityException { VcsConnectionCredentials credentials = credentialsExtractor.extractCredentials(connection); if (VcsConnectionCredentialsExtractor.hasAccessToken(credentials)) { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java index 336530da..5ea5d770 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java @@ -54,650 +54,626 @@ @IsWorkspaceMember @RequestMapping("/api/{workspaceSlug}/project") public class ProjectController { - private final ProjectService projectService; - private final ProjectTokenService projectTokenService; - private final WorkspaceService workspaceService; - private final RagIndexStatusService ragIndexStatusService; - private final RagIndexingTriggerService ragIndexingTriggerService; - private final TwoFactorAuthService twoFactorAuthService; - - public ProjectController( - ProjectService projectService, - ProjectTokenService projectTokenService, - WorkspaceService workspaceService, - RagIndexStatusService ragIndexStatusService, - RagIndexingTriggerService ragIndexingTriggerService, - TwoFactorAuthService twoFactorAuthService - ) { - this.projectService = projectService; - this.projectTokenService = projectTokenService; - this.workspaceService = workspaceService; - this.ragIndexStatusService = ragIndexStatusService; - this.ragIndexingTriggerService = ragIndexingTriggerService; - this.twoFactorAuthService = twoFactorAuthService; - } - - @GetMapping("/project_list") - public ResponseEntity> getUserWorkspaceProjectsList(@PathVariable String workspaceSlug) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - List userWorkspaceProjects = projectService.listWorkspaceProjects(workspace.getId()); - - List projectDTOs = userWorkspaceProjects.stream() - .map(ProjectDTO::fromProject) - .toList(); - - return new ResponseEntity<>(projectDTOs, HttpStatus.OK); - } - - @GetMapping("/projects") - public ResponseEntity> getProjectsPaginated( - @PathVariable String workspaceSlug, - @RequestParam(required = false, defaultValue = "") String search, - @RequestParam(required = false, defaultValue = "0") int page, - @RequestParam(required = false, defaultValue = "50") int size - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - - // Validate and cap page size - int validSize = Math.min(Math.max(size, 1), 100); - int validPage = Math.max(page, 0); - - var projectsPage = projectService.listWorkspaceProjectsPaginated( - workspace.getId(), - search, - validPage, - validSize - ); - - List projectDTOs = projectsPage.getContent().stream() - .map(ProjectDTO::fromProject) - .toList(); - - Map response = new java.util.HashMap<>(); - response.put("projects", projectDTOs); - response.put("page", projectsPage.getNumber()); - response.put("pageSize", projectsPage.getSize()); - response.put("totalElements", projectsPage.getTotalElements()); - response.put("totalPages", projectsPage.getTotalPages()); - - return ResponseEntity.ok(response); - } - - @PostMapping("/create") - @HasOwnerOrAdminRights - public ResponseEntity createProject( - @PathVariable String workspaceSlug, - @Valid @RequestBody CreateProjectRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project created = projectService.createProject(workspace.getId(), request); - return new ResponseEntity<>(ProjectDTO.fromProject(created), HttpStatus.CREATED); - } - - @PostMapping("/{projectNamespace}/token/generate") - @HasOwnerOrAdminRights - public ResponseEntity> generateProjectJwt( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @AuthenticationPrincipal UserDetailsImpl userDetails, - @RequestBody CreateProjectTokenRequest request - ) throws GeneralSecurityException { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - String jwt = projectTokenService.generateProjectJwt( - workspace.getId(), - project.getId(), - userDetails.getId(), - request.getName(), - request.getLifetime() - ); - return ResponseEntity.ok(java.util.Map.of("token", jwt)); - } - - @GetMapping("/{projectNamespace}/token") - @HasOwnerOrAdminRights - public ResponseEntity> listProjectTokens( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - List tokens = projectTokenService.listTokens(workspace.getId(), project.getId()).stream() - .map(ProjectTokenDTO::from) - .toList(); - return new ResponseEntity<>(tokens, HttpStatus.OK); - } - - @DeleteMapping("/{projectNamespace}/token/{tokenId}") - @HasOwnerOrAdminRights - public ResponseEntity deleteProjectToken( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @PathVariable Long tokenId - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - projectTokenService.deleteToken(workspace.getId(), project.getId(), tokenId); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } - - - //TODO: service implementation, more fields to update - @PatchMapping("/{projectNamespace}") - @HasOwnerOrAdminRights - public ResponseEntity updateProject( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestBody UpdateProjectRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project updated = projectService.updateProject(workspace.getId(), project.getId(), request); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } - - @DeleteMapping("/{projectNamespace}") - @HasOwnerOrAdminRights - public ResponseEntity deleteProject( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestHeader(value = "X-2FA-Code", required = false) String twoFactorCode, - @AuthenticationPrincipal UserDetailsImpl userDetails - ) { - // Verify 2FA if enabled for the user (throws exception if verification fails) - twoFactorAuthService.verifySensitiveOperation(userDetails.getId(), twoFactorCode); - - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - projectService.deleteProjectByNamespace(workspace.getId(), projectNamespace); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } - - @PutMapping("/{projectNamespace}/repository/bind") - @HasOwnerOrAdminRights - public ResponseEntity bindRepository( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestBody BindRepositoryRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project p = projectService.bindRepository(workspace.getId(), project.getId(), request); - return new ResponseEntity<>(ProjectDTO.fromProject(p), HttpStatus.OK); - } - - @DeleteMapping("/{projectNamespace}/repository/unbind") - @HasOwnerOrAdminRights - public ResponseEntity unbindRepository( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestHeader(value = "X-2FA-Code", required = false) String twoFactorCode, - @AuthenticationPrincipal UserDetailsImpl userDetails - ) { - // Verify 2FA if enabled for the user (throws exception if verification fails) - twoFactorAuthService.verifySensitiveOperation(userDetails.getId(), twoFactorCode); - - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project p = projectService.unbindRepository(workspace.getId(), project.getId()); - return new ResponseEntity<>(ProjectDTO.fromProject(p), HttpStatus.OK); - } - - @PatchMapping("/{projectNamespace}/repository/settings") - @HasOwnerOrAdminRights - public ResponseEntity updateRepositorySettings( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestBody UpdateRepositorySettingsRequest request - ) throws GeneralSecurityException { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - projectService.updateRepositorySettings(workspace.getId(), project.getId(), request); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } - - @PutMapping("/{projectNamespace}/ai/bind") - @HasOwnerOrAdminRights - public ResponseEntity bindAiConnection( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestBody BindAiConnectionRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - boolean status = projectService.bindAiConnection(workspace.getId(), project.getId(), request); - return new ResponseEntity<>(status ? HttpStatus.NO_CONTENT : HttpStatus.NOT_FOUND); - } - - /** - * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/branches - * Returns list of analyzed branches for the project - */ - @GetMapping("/{projectNamespace}/branches") - public ResponseEntity> getProjectBranches( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - List branches = projectService.getProjectBranches(workspace.getId(), projectNamespace); - List branchDTOs = branches.stream() - .map(b -> new BranchDTO( - b.getId(), - b.getBranchName(), - b.getCommitHash(), - b.getTotalIssues(), - b.getHighSeverityCount(), - b.getMediumSeverityCount(), - b.getLowSeverityCount(), - b.getResolvedCount(), - b.getUpdatedAt() - )) - .toList(); - return new ResponseEntity<>(branchDTOs, HttpStatus.OK); - } - - /** - * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/default-branch - * Sets the default branch for a project - * Request body: { "branchId": 123 } or { "branchName": "main" } - */ - @PutMapping("/{projectNamespace}/default-branch") - @HasOwnerOrAdminRights - public ResponseEntity setDefaultBranch( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestBody SetDefaultBranchRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project; - if (request.branchId() != null) { - project = projectService.setDefaultBranch(workspace.getId(), projectNamespace, request.branchId()); - } else if (request.branchName() != null && !request.branchName().isBlank()) { - project = projectService.setDefaultBranchByName(workspace.getId(), projectNamespace, request.branchName()); - } else { - throw new IllegalArgumentException("Either branchId or branchName must be provided"); - } - return new ResponseEntity<>(ProjectDTO.fromProject(project), HttpStatus.OK); - } - - // DTOs - public record BranchDTO( - Long id, - String branchName, - String commitHash, - int totalIssues, - int highSeverityCount, - int mediumSeverityCount, - int lowSeverityCount, - int resolvedCount, - java.time.OffsetDateTime updatedAt - ) {} - - public record SetDefaultBranchRequest( - Long branchId, - String branchName - ) {} - - /** - * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/branch-analysis-config - * Returns the branch analysis configuration for the project - */ - @GetMapping("/{projectNamespace}/branch-analysis-config") - public ResponseEntity getBranchAnalysisConfig( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - var config = projectService.getBranchAnalysisConfig(project); - - if (config == null) { - return ResponseEntity.ok(new BranchAnalysisConfigResponse(List.of(), List.of())); - } - - return ResponseEntity.ok(new BranchAnalysisConfigResponse( - config.prTargetBranches() != null ? config.prTargetBranches() : List.of(), - config.branchPushPatterns() != null ? config.branchPushPatterns() : List.of() - )); - } - - public record BranchAnalysisConfigResponse( - List prTargetBranches, - List branchPushPatterns - ) {} - - /** - * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/branch-analysis-config - * Updates the branch analysis configuration for the project - * Request body: { "prTargetBranches": ["main", "develop", "release/*"], "branchPushPatterns": ["main", "develop"] } - */ - @PutMapping("/{projectNamespace}/branch-analysis-config") - @HasOwnerOrAdminRights - public ResponseEntity updateBranchAnalysisConfig( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestBody BranchAnalysisConfigRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project updated = projectService.updateBranchAnalysisConfig( - workspace.getId(), - project.getId(), - request.prTargetBranches(), - request.branchPushPatterns() - ); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } - - public record BranchAnalysisConfigRequest( - List prTargetBranches, - List branchPushPatterns - ) {} - - // ==================== RAG Config Endpoints ==================== - - /** - * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/rag/status - * Returns the RAG indexing status for the project - */ - @GetMapping("/{projectNamespace}/rag/status") - public ResponseEntity getRagIndexStatus( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - var status = ragIndexStatusService.getIndexStatus(project); - - if (status.isEmpty()) { - return new ResponseEntity<>(new RagStatusResponse( - false, - null, - true // canStartIndexing - ), HttpStatus.OK); - } - - return new ResponseEntity<>(new RagStatusResponse( - ragIndexStatusService.isProjectIndexed(project.getId()), - RagIndexStatusDTO.fromEntity(status.get()), - ragIndexStatusService.canStartIndexing(project) - ), HttpStatus.OK); - } - - /** - * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/rag/config - * Updates the RAG configuration for the project (enable/disable, set branch, exclude patterns, delta config) - */ - @PutMapping("/{projectNamespace}/rag/config") - @HasOwnerOrAdminRights - public ResponseEntity updateRagConfig( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @Valid @RequestBody UpdateRagConfigRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project updated = projectService.updateRagConfig( - workspace.getId(), - project.getId(), - request.getEnabled(), - request.getBranch(), - request.getIncludePatterns(), - request.getExcludePatterns(), - request.getMultiBranchEnabled(), - request.getBranchRetentionDays() - ); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } - - /** - * POST /api/workspace/{workspaceSlug}/project/{projectNamespace}/rag/trigger - * Triggers RAG indexing for the project. Returns an SSE stream with progress updates. - * - * Security: - * - Requires authenticated user with owner/admin rights on the workspace - * - Generates a short-lived project JWT for pipeline-agent authentication - * - Rate-limited to prevent abuse (min 60 seconds between triggers per project) - */ - @PostMapping(value = "/{projectNamespace}/rag/trigger", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - @HasOwnerOrAdminRights - public SseEmitter triggerRagIndexing( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestParam(required = false) String branch, - @AuthenticationPrincipal UserDetailsImpl userDetails - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - - // Validate before creating emitter - var validation = ragIndexingTriggerService.validateTrigger(project.getId(), userDetails.getId()); - - // Create SSE emitter with 30 minute timeout (long-running indexing operation) - SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); - - // Set up emitter callbacks for proper cleanup - emitter.onCompletion(() -> { - // Cleanup if needed - }); - emitter.onTimeout(emitter::complete); - emitter.onError(ex -> emitter.complete()); - - if (!validation.valid()) { - // Send error and complete immediately - try { - emitter.send(SseEmitter.event() - .name("error") - .data("{\"type\":\"error\",\"status\":\"error\",\"message\":\"" + - validation.message().replace("\"", "\\\"") + "\"}")); - emitter.complete(); - } catch (Exception e) { - emitter.completeWithError(e); - } - return emitter; - } - - // Trigger indexing asynchronously - ragIndexingTriggerService.triggerIndexing( - workspace.getId(), - project.getId(), - userDetails.getId(), - branch, - emitter - ); - - return emitter; - } - - public record RagStatusResponse( - boolean isIndexed, - RagIndexStatusDTO indexStatus, - boolean canStartIndexing - ) {} - - // ==================== Comment Commands Config Endpoints ==================== - - /** - * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/comment-commands-config - * Returns the comment commands configuration for the project - */ - @GetMapping("/{projectNamespace}/comment-commands-config") - public ResponseEntity getCommentCommandsConfig( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - CommentCommandsConfig config = projectService.getCommentCommandsConfig(project); - return new ResponseEntity<>(config, HttpStatus.OK); - } - - /** - * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/comment-commands-config - * Updates the comment commands configuration for the project. - * Requires App-based VCS connection for comment commands to work. - */ - @PutMapping("/{projectNamespace}/comment-commands-config") - @HasOwnerOrAdminRights - public ResponseEntity updateCommentCommandsConfig( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @Valid @RequestBody UpdateCommentCommandsConfigRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project updated = projectService.updateCommentCommandsConfig( - workspace.getId(), - project.getId(), - request - ); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } - - /** - * Updates analysis settings for the project (PR auto-analysis, branch analysis, installation method) - */ - @PutMapping("/{projectNamespace}/analysis-settings") - @HasOwnerOrAdminRights - public ResponseEntity updateAnalysisSettings( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @Valid @RequestBody UpdateAnalysisSettingsRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - - InstallationMethod installationMethod = null; - if (request.installationMethod() != null) { - try { - installationMethod = InstallationMethod.valueOf(request.installationMethod()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException("Invalid installation method: " + request.installationMethod()); - } - } - - Project updated = projectService.updateAnalysisSettings( - workspace.getId(), - project.getId(), - request.prAnalysisEnabled(), - request.branchAnalysisEnabled(), - installationMethod, - request.maxAnalysisTokenLimit() - ); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } - - public record UpdateAnalysisSettingsRequest( - Boolean prAnalysisEnabled, - Boolean branchAnalysisEnabled, - String installationMethod, - Integer maxAnalysisTokenLimit - ) {} - - /** - * Updates the quality gate for a project - */ - @PutMapping("/{projectNamespace}/quality-gate") - @HasOwnerOrAdminRights - public ResponseEntity updateProjectQualityGate( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @Valid @RequestBody UpdateProjectQualityGateRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - - Project updated = projectService.updateProjectQualityGate( - workspace.getId(), - project.getId(), - request.qualityGateId() - ); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } - - public record UpdateProjectQualityGateRequest( - Long qualityGateId - ) {} - - // ==================== Webhook Management Endpoints ==================== - - /** - * POST /api/workspace/{workspaceSlug}/project/{projectNamespace}/webhooks/setup - * Triggers webhook setup for the project's bound repository. - * Useful when: - * - Moving from one repository to another - * - Switching from user-based to app-based connection - * - Webhook was accidentally deleted in the VCS provider - */ - @PostMapping("/{projectNamespace}/webhooks/setup") - @HasOwnerOrAdminRights - public ResponseEntity setupWebhooks( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - ProjectService.WebhookSetupResult result = projectService.setupWebhooks(workspace.getId(), project.getId()); - return new ResponseEntity<>(new WebhookSetupResponse( - result.success(), - result.webhookId(), - result.webhookUrl(), - result.message() - ), result.success() ? HttpStatus.OK : HttpStatus.BAD_REQUEST); - } - - /** - * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/webhooks/info - * Returns webhook configuration info for the project. - */ - @GetMapping("/{projectNamespace}/webhooks/info") - @HasOwnerOrAdminRights - public ResponseEntity getWebhookInfo( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - ProjectService.WebhookInfo info = projectService.getWebhookInfo(workspace.getId(), project.getId()); - return new ResponseEntity<>(new WebhookInfoResponse( - info.webhooksConfigured(), - info.webhookId(), - info.webhookUrl(), - info.provider() != null ? info.provider().name() : null - ), HttpStatus.OK); - } - - public record WebhookSetupResponse( - boolean success, - String webhookId, - String webhookUrl, - String message - ) {} - - public record WebhookInfoResponse( - boolean webhooksConfigured, - String webhookId, - String webhookUrl, - String provider - ) {} - - // ==================== Change VCS Connection Endpoint ==================== - - /** - * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/vcs-connection - * Changes the VCS connection for a project. - * This will update the VCS binding and optionally setup webhooks. - * Warning: Changing VCS connection may require manual cleanup of old webhooks. - */ - @PutMapping("/{projectNamespace}/vcs-connection") - @HasOwnerOrAdminRights - public ResponseEntity changeVcsConnection( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @Valid @RequestBody ChangeVcsConnectionRequest request - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - Project updated = projectService.changeVcsConnection(workspace.getId(), project.getId(), request); - return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); - } + private final ProjectService projectService; + private final ProjectTokenService projectTokenService; + private final WorkspaceService workspaceService; + private final RagIndexStatusService ragIndexStatusService; + private final RagIndexingTriggerService ragIndexingTriggerService; + private final TwoFactorAuthService twoFactorAuthService; + + public ProjectController( + ProjectService projectService, + ProjectTokenService projectTokenService, + WorkspaceService workspaceService, + RagIndexStatusService ragIndexStatusService, + RagIndexingTriggerService ragIndexingTriggerService, + TwoFactorAuthService twoFactorAuthService) { + this.projectService = projectService; + this.projectTokenService = projectTokenService; + this.workspaceService = workspaceService; + this.ragIndexStatusService = ragIndexStatusService; + this.ragIndexingTriggerService = ragIndexingTriggerService; + this.twoFactorAuthService = twoFactorAuthService; + } + + @GetMapping("/project_list") + public ResponseEntity> getUserWorkspaceProjectsList(@PathVariable String workspaceSlug) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + List userWorkspaceProjects = projectService.listWorkspaceProjects(workspace.getId()); + + List projectDTOs = userWorkspaceProjects.stream() + .map(ProjectDTO::fromProject) + .toList(); + + return new ResponseEntity<>(projectDTOs, HttpStatus.OK); + } + + @GetMapping("/projects") + public ResponseEntity> getProjectsPaginated( + @PathVariable String workspaceSlug, + @RequestParam(required = false, defaultValue = "") String search, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "50") int size) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + + // Validate and cap page size + int validSize = Math.min(Math.max(size, 1), 100); + int validPage = Math.max(page, 0); + + var projectsPage = projectService.listWorkspaceProjectsPaginated( + workspace.getId(), + search, + validPage, + validSize); + + List projectDTOs = projectsPage.getContent().stream() + .map(ProjectDTO::fromProject) + .toList(); + + Map response = new java.util.HashMap<>(); + response.put("projects", projectDTOs); + response.put("page", projectsPage.getNumber()); + response.put("pageSize", projectsPage.getSize()); + response.put("totalElements", projectsPage.getTotalElements()); + response.put("totalPages", projectsPage.getTotalPages()); + + return ResponseEntity.ok(response); + } + + @PostMapping("/create") + @HasOwnerOrAdminRights + public ResponseEntity createProject( + @PathVariable String workspaceSlug, + @Valid @RequestBody CreateProjectRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project created = projectService.createProject(workspace.getId(), request); + return new ResponseEntity<>(ProjectDTO.fromProject(created), HttpStatus.CREATED); + } + + @PostMapping("/{projectNamespace}/token/generate") + @HasOwnerOrAdminRights + public ResponseEntity> generateProjectJwt( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @AuthenticationPrincipal UserDetailsImpl userDetails, + @RequestBody CreateProjectTokenRequest request) throws GeneralSecurityException { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + String jwt = projectTokenService.generateProjectJwt( + workspace.getId(), + project.getId(), + userDetails.getId(), + request.getName(), + request.getLifetime()); + return ResponseEntity.ok(java.util.Map.of("token", jwt)); + } + + @GetMapping("/{projectNamespace}/token") + @HasOwnerOrAdminRights + public ResponseEntity> listProjectTokens( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + List tokens = projectTokenService.listTokens(workspace.getId(), project.getId()) + .stream() + .map(ProjectTokenDTO::from) + .toList(); + return new ResponseEntity<>(tokens, HttpStatus.OK); + } + + @DeleteMapping("/{projectNamespace}/token/{tokenId}") + @HasOwnerOrAdminRights + public ResponseEntity deleteProjectToken( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @PathVariable Long tokenId) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + projectTokenService.deleteToken(workspace.getId(), project.getId(), tokenId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + // TODO: service implementation, more fields to update + @PatchMapping("/{projectNamespace}") + @HasOwnerOrAdminRights + public ResponseEntity updateProject( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestBody UpdateProjectRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project updated = projectService.updateProject(workspace.getId(), project.getId(), request); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } + + @DeleteMapping("/{projectNamespace}") + @HasOwnerOrAdminRights + public ResponseEntity deleteProject( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestHeader(value = "X-2FA-Code", required = false) String twoFactorCode, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + // Verify 2FA if enabled for the user (throws exception if verification fails) + twoFactorAuthService.verifySensitiveOperation(userDetails.getId(), twoFactorCode); + + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + projectService.deleteProjectByNamespace(workspace.getId(), projectNamespace); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @PutMapping("/{projectNamespace}/repository/bind") + @HasOwnerOrAdminRights + public ResponseEntity bindRepository( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestBody BindRepositoryRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project p = projectService.bindRepository(workspace.getId(), project.getId(), request); + return new ResponseEntity<>(ProjectDTO.fromProject(p), HttpStatus.OK); + } + + @DeleteMapping("/{projectNamespace}/repository/unbind") + @HasOwnerOrAdminRights + public ResponseEntity unbindRepository( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestHeader(value = "X-2FA-Code", required = false) String twoFactorCode, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + // Verify 2FA if enabled for the user (throws exception if verification fails) + twoFactorAuthService.verifySensitiveOperation(userDetails.getId(), twoFactorCode); + + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project p = projectService.unbindRepository(workspace.getId(), project.getId()); + return new ResponseEntity<>(ProjectDTO.fromProject(p), HttpStatus.OK); + } + + @PatchMapping("/{projectNamespace}/repository/settings") + @HasOwnerOrAdminRights + public ResponseEntity updateRepositorySettings( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestBody UpdateRepositorySettingsRequest request) throws GeneralSecurityException { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + projectService.updateRepositorySettings(workspace.getId(), project.getId(), request); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @PutMapping("/{projectNamespace}/ai/bind") + @HasOwnerOrAdminRights + public ResponseEntity bindAiConnection( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestBody BindAiConnectionRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + boolean status = projectService.bindAiConnection(workspace.getId(), project.getId(), request); + return new ResponseEntity<>(status ? HttpStatus.NO_CONTENT : HttpStatus.NOT_FOUND); + } + + /** + * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/branches + * Returns list of analyzed branches for the project + */ + @GetMapping("/{projectNamespace}/branches") + public ResponseEntity> getProjectBranches( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + List branches = projectService + .getProjectBranches(workspace.getId(), projectNamespace); + List branchDTOs = branches.stream() + .map(b -> new BranchDTO( + b.getId(), + b.getBranchName(), + b.getCommitHash(), + b.getTotalIssues(), + b.getHighSeverityCount(), + b.getMediumSeverityCount(), + b.getLowSeverityCount(), + b.getResolvedCount(), + b.getUpdatedAt())) + .toList(); + return new ResponseEntity<>(branchDTOs, HttpStatus.OK); + } + + /** + * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/default-branch + * Sets the default branch for a project + * Request body: { "branchId": 123 } or { "branchName": "main" } + */ + @PutMapping("/{projectNamespace}/default-branch") + @HasOwnerOrAdminRights + public ResponseEntity setDefaultBranch( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestBody SetDefaultBranchRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project; + if (request.branchId() != null) { + project = projectService.setDefaultBranch(workspace.getId(), projectNamespace, + request.branchId()); + } else if (request.branchName() != null && !request.branchName().isBlank()) { + project = projectService.setDefaultBranchByName(workspace.getId(), projectNamespace, + request.branchName()); + } else { + throw new IllegalArgumentException("Either branchId or branchName must be provided"); + } + return new ResponseEntity<>(ProjectDTO.fromProject(project), HttpStatus.OK); + } + + // DTOs + public record BranchDTO( + Long id, + String branchName, + String commitHash, + int totalIssues, + int highSeverityCount, + int mediumSeverityCount, + int lowSeverityCount, + int resolvedCount, + java.time.OffsetDateTime updatedAt) { + } + + public record SetDefaultBranchRequest( + Long branchId, + String branchName) { + } + + /** + * GET + * /api/workspace/{workspaceSlug}/project/{projectNamespace}/branch-analysis-config + * Returns the branch analysis configuration for the project + */ + @GetMapping("/{projectNamespace}/branch-analysis-config") + public ResponseEntity getBranchAnalysisConfig( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + var config = projectService.getBranchAnalysisConfig(project); + + if (config == null) { + return ResponseEntity.ok(new BranchAnalysisConfigResponse(List.of(), List.of())); + } + + return ResponseEntity.ok(new BranchAnalysisConfigResponse( + config.prTargetBranches() != null ? config.prTargetBranches() : List.of(), + config.branchPushPatterns() != null ? config.branchPushPatterns() : List.of())); + } + + public record BranchAnalysisConfigResponse( + List prTargetBranches, + List branchPushPatterns) { + } + + /** + * PUT + * /api/workspace/{workspaceSlug}/project/{projectNamespace}/branch-analysis-config + * Updates the branch analysis configuration for the project + * Request body: { "prTargetBranches": ["main", "develop", "release/*"], + * "branchPushPatterns": ["main", "develop"] } + */ + @PutMapping("/{projectNamespace}/branch-analysis-config") + @HasOwnerOrAdminRights + public ResponseEntity updateBranchAnalysisConfig( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestBody BranchAnalysisConfigRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project updated = projectService.updateBranchAnalysisConfig( + workspace.getId(), + project.getId(), + request.prTargetBranches(), + request.branchPushPatterns()); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } + + public record BranchAnalysisConfigRequest( + List prTargetBranches, + List branchPushPatterns) { + } + + // ==================== RAG Config Endpoints ==================== + + /** + * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/rag/status + * Returns the RAG indexing status for the project + */ + @GetMapping("/{projectNamespace}/rag/status") + public ResponseEntity getRagIndexStatus( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + var status = ragIndexStatusService.getIndexStatus(project); + + if (status.isEmpty()) { + return new ResponseEntity<>(new RagStatusResponse( + false, + null, + true // canStartIndexing + ), HttpStatus.OK); + } + + return new ResponseEntity<>(new RagStatusResponse( + ragIndexStatusService.isProjectIndexed(project.getId()), + RagIndexStatusDTO.fromEntity(status.get()), + ragIndexStatusService.canStartIndexing(project)), HttpStatus.OK); + } + + /** + * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/rag/config + * Updates the RAG configuration for the project (enable/disable, set branch, + * exclude patterns, delta config) + */ + @PutMapping("/{projectNamespace}/rag/config") + @HasOwnerOrAdminRights + public ResponseEntity updateRagConfig( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @Valid @RequestBody UpdateRagConfigRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project updated = projectService.updateRagConfig( + workspace.getId(), + project.getId(), + request.getEnabled(), + request.getBranch(), + request.getIncludePatterns(), + request.getExcludePatterns(), + request.getMultiBranchEnabled(), + request.getBranchRetentionDays()); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } + + /** + * POST /api/workspace/{workspaceSlug}/project/{projectNamespace}/rag/trigger + * Triggers RAG indexing for the project. Returns an SSE stream with progress + * updates. + * + * Security: + * - Requires authenticated user with owner/admin rights on the workspace + * - Generates a short-lived project JWT for pipeline-agent authentication + * - Rate-limited to prevent abuse (min 60 seconds between triggers per project) + */ + @PostMapping(value = "/{projectNamespace}/rag/trigger", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @HasOwnerOrAdminRights + public SseEmitter triggerRagIndexing( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestParam(required = false) String branch, + @AuthenticationPrincipal UserDetailsImpl userDetails) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + + // Validate before creating emitter + var validation = ragIndexingTriggerService.validateTrigger(project.getId(), userDetails.getId()); + + // Create SSE emitter with 30 minute timeout (long-running indexing operation) + SseEmitter emitter = new SseEmitter(30 * 60 * 1000L); + + // Set up emitter callbacks for proper cleanup + emitter.onCompletion(() -> { + // Cleanup if needed + }); + emitter.onTimeout(emitter::complete); + emitter.onError(ex -> emitter.complete()); + + if (!validation.valid()) { + // Send error and complete immediately + try { + emitter.send(SseEmitter.event() + .name("error") + .data("{\"type\":\"error\",\"status\":\"error\",\"message\":\"" + + validation.message().replace("\"", "\\\"") + "\"}")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + // Trigger indexing asynchronously + ragIndexingTriggerService.triggerIndexing( + workspace.getId(), + project.getId(), + userDetails.getId(), + branch, + emitter); + + return emitter; + } + + public record RagStatusResponse( + boolean isIndexed, + RagIndexStatusDTO indexStatus, + boolean canStartIndexing) { + } + + // ==================== Comment Commands Config Endpoints ==================== + + /** + * GET + * /api/workspace/{workspaceSlug}/project/{projectNamespace}/comment-commands-config + * Returns the comment commands configuration for the project + */ + @GetMapping("/{projectNamespace}/comment-commands-config") + public ResponseEntity getCommentCommandsConfig( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + CommentCommandsConfig config = projectService.getCommentCommandsConfig(project); + return new ResponseEntity<>(config, HttpStatus.OK); + } + + /** + * PUT + * /api/workspace/{workspaceSlug}/project/{projectNamespace}/comment-commands-config + * Updates the comment commands configuration for the project. + * Requires App-based VCS connection for comment commands to work. + */ + @PutMapping("/{projectNamespace}/comment-commands-config") + @HasOwnerOrAdminRights + public ResponseEntity updateCommentCommandsConfig( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @Valid @RequestBody UpdateCommentCommandsConfigRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project updated = projectService.updateCommentCommandsConfig( + workspace.getId(), + project.getId(), + request); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } + + /** + * Updates analysis settings for the project (PR auto-analysis, branch analysis, + * installation method) + */ + @PutMapping("/{projectNamespace}/analysis-settings") + @HasOwnerOrAdminRights + public ResponseEntity updateAnalysisSettings( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @Valid @RequestBody UpdateAnalysisSettingsRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + + InstallationMethod installationMethod = null; + if (request.installationMethod() != null) { + try { + installationMethod = InstallationMethod.valueOf(request.installationMethod()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "Invalid installation method: " + request.installationMethod()); + } + } + + Project updated = projectService.updateAnalysisSettings( + workspace.getId(), + project.getId(), + request.prAnalysisEnabled(), + request.branchAnalysisEnabled(), + installationMethod, + request.maxAnalysisTokenLimit(), + request.useMcpTools()); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } + + public record UpdateAnalysisSettingsRequest( + Boolean prAnalysisEnabled, + Boolean branchAnalysisEnabled, + String installationMethod, + Integer maxAnalysisTokenLimit, + Boolean useMcpTools) { + } + + /** + * Updates the quality gate for a project + */ + @PutMapping("/{projectNamespace}/quality-gate") + @HasOwnerOrAdminRights + public ResponseEntity updateProjectQualityGate( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @Valid @RequestBody UpdateProjectQualityGateRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + + Project updated = projectService.updateProjectQualityGate( + workspace.getId(), + project.getId(), + request.qualityGateId()); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } + + public record UpdateProjectQualityGateRequest( + Long qualityGateId) { + } + + // ==================== Webhook Management Endpoints ==================== + + /** + * POST /api/workspace/{workspaceSlug}/project/{projectNamespace}/webhooks/setup + * Triggers webhook setup for the project's bound repository. + * Useful when: + * - Moving from one repository to another + * - Switching from user-based to app-based connection + * - Webhook was accidentally deleted in the VCS provider + */ + @PostMapping("/{projectNamespace}/webhooks/setup") + @HasOwnerOrAdminRights + public ResponseEntity setupWebhooks( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + ProjectService.WebhookSetupResult result = projectService.setupWebhooks(workspace.getId(), + project.getId()); + return new ResponseEntity<>(new WebhookSetupResponse( + result.success(), + result.webhookId(), + result.webhookUrl(), + result.message()), result.success() ? HttpStatus.OK : HttpStatus.BAD_REQUEST); + } + + /** + * GET /api/workspace/{workspaceSlug}/project/{projectNamespace}/webhooks/info + * Returns webhook configuration info for the project. + */ + @GetMapping("/{projectNamespace}/webhooks/info") + @HasOwnerOrAdminRights + public ResponseEntity getWebhookInfo( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + ProjectService.WebhookInfo info = projectService.getWebhookInfo(workspace.getId(), project.getId()); + return new ResponseEntity<>(new WebhookInfoResponse( + info.webhooksConfigured(), + info.webhookId(), + info.webhookUrl(), + info.provider() != null ? info.provider().name() : null), HttpStatus.OK); + } + + public record WebhookSetupResponse( + boolean success, + String webhookId, + String webhookUrl, + String message) { + } + + public record WebhookInfoResponse( + boolean webhooksConfigured, + String webhookId, + String webhookUrl, + String provider) { + } + + // ==================== Change VCS Connection Endpoint ==================== + + /** + * PUT /api/workspace/{workspaceSlug}/project/{projectNamespace}/vcs-connection + * Changes the VCS connection for a project. + * This will update the VCS binding and optionally setup webhooks. + * Warning: Changing VCS connection may require manual cleanup of old webhooks. + */ + @PutMapping("/{projectNamespace}/vcs-connection") + @HasOwnerOrAdminRights + public ResponseEntity changeVcsConnection( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @Valid @RequestBody ChangeVcsConnectionRequest request) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + Project updated = projectService.changeVcsConnection(workspace.getId(), project.getId(), request); + return new ResponseEntity<>(ProjectDTO.fromProject(updated), HttpStatus.OK); + } } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/IProjectService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/IProjectService.java index e2780202..cf763491 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/IProjectService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/IProjectService.java @@ -26,88 +26,93 @@ */ public interface IProjectService { - // ==================== Core CRUD ==================== + // ==================== Core CRUD ==================== - List listWorkspaceProjects(Long workspaceId); + List listWorkspaceProjects(Long workspaceId); - Page listWorkspaceProjectsPaginated(Long workspaceId, String search, int page, int size); + Page listWorkspaceProjectsPaginated(Long workspaceId, String search, int page, int size); - Project createProject(Long workspaceId, CreateProjectRequest request); + Project createProject(Long workspaceId, CreateProjectRequest request); - Project getProjectById(Long projectId); + Project getProjectById(Long projectId); - Project getProjectByWorkspaceAndNamespace(Long workspaceId, String namespace); + Project getProjectByWorkspaceAndNamespace(Long workspaceId, String namespace); - Project updateProject(Long workspaceId, Long projectId, UpdateProjectRequest request); + Project updateProject(Long workspaceId, Long projectId, UpdateProjectRequest request); - void deleteProject(Long workspaceId, Long projectId); + void deleteProject(Long workspaceId, Long projectId); - void deleteProjectByNamespace(Long workspaceId, String namespace); + void deleteProjectByNamespace(Long workspaceId, String namespace); - // ==================== Repository Binding ==================== + // ==================== Repository Binding ==================== - Project bindRepository(Long workspaceId, Long projectId, BindRepositoryRequest request); + Project bindRepository(Long workspaceId, Long projectId, BindRepositoryRequest request); - Project unbindRepository(Long workspaceId, Long projectId); + Project unbindRepository(Long workspaceId, Long projectId); - void updateRepositorySettings(Long workspaceId, Long projectId, UpdateRepositorySettingsRequest request) - throws GeneralSecurityException; + void updateRepositorySettings(Long workspaceId, Long projectId, UpdateRepositorySettingsRequest request) + throws GeneralSecurityException; - Project changeVcsConnection(Long workspaceId, Long projectId, ChangeVcsConnectionRequest request); + Project changeVcsConnection(Long workspaceId, Long projectId, ChangeVcsConnectionRequest request); - // ==================== AI Connection ==================== + // ==================== AI Connection ==================== - boolean bindAiConnection(Long workspaceId, Long projectId, BindAiConnectionRequest request); + boolean bindAiConnection(Long workspaceId, Long projectId, BindAiConnectionRequest request); - // ==================== Branch Management ==================== + // ==================== Branch Management ==================== - List getProjectBranches(Long workspaceId, String namespace); + List getProjectBranches(Long workspaceId, String namespace); - Project setDefaultBranch(Long workspaceId, String namespace, Long branchId); + Project setDefaultBranch(Long workspaceId, String namespace, Long branchId); - Project setDefaultBranchByName(Long workspaceId, String namespace, String branchName); + Project setDefaultBranchByName(Long workspaceId, String namespace, String branchName); - // ==================== Configuration ==================== + // ==================== Configuration ==================== - BranchAnalysisConfig getBranchAnalysisConfig(Project project); + BranchAnalysisConfig getBranchAnalysisConfig(Project project); - Project updateBranchAnalysisConfig(Long workspaceId, Long projectId, - List prTargetBranches, List branchPushPatterns); + Project updateBranchAnalysisConfig(Long workspaceId, Long projectId, + List prTargetBranches, List branchPushPatterns); - Project updateRagConfig(Long workspaceId, Long projectId, boolean enabled, String branch, List includePatterns, - List excludePatterns, Boolean multiBranchEnabled, Integer branchRetentionDays); + Project updateRagConfig(Long workspaceId, Long projectId, boolean enabled, String branch, + List includePatterns, + List excludePatterns, Boolean multiBranchEnabled, Integer branchRetentionDays); - Project updateRagConfig(Long workspaceId, Long projectId, boolean enabled, String branch, List includePatterns, - List excludePatterns); + Project updateRagConfig(Long workspaceId, Long projectId, boolean enabled, String branch, + List includePatterns, + List excludePatterns); - Project updateAnalysisSettings(Long workspaceId, Long projectId, Boolean prAnalysisEnabled, - Boolean branchAnalysisEnabled, InstallationMethod installationMethod, Integer maxAnalysisTokenLimit); + Project updateAnalysisSettings(Long workspaceId, Long projectId, Boolean prAnalysisEnabled, + Boolean branchAnalysisEnabled, InstallationMethod installationMethod, + Integer maxAnalysisTokenLimit, + Boolean useMcpTools); - Project updateProjectQualityGate(Long workspaceId, Long projectId, Long qualityGateId); + Project updateProjectQualityGate(Long workspaceId, Long projectId, Long qualityGateId); - CommentCommandsConfig getCommentCommandsConfig(Project project); + CommentCommandsConfig getCommentCommandsConfig(Project project); - Project updateCommentCommandsConfig(Long workspaceId, Long projectId, UpdateCommentCommandsConfigRequest request); + Project updateCommentCommandsConfig(Long workspaceId, Long projectId, + UpdateCommentCommandsConfigRequest request); - // ==================== Webhooks ==================== + // ==================== Webhooks ==================== - WebhookSetupResult setupWebhooks(Long workspaceId, Long projectId); + WebhookSetupResult setupWebhooks(Long workspaceId, Long projectId); - WebhookInfo getWebhookInfo(Long workspaceId, Long projectId); + WebhookInfo getWebhookInfo(Long workspaceId, Long projectId); - // ==================== DTOs ==================== + // ==================== DTOs ==================== - record WebhookSetupResult( - boolean success, - String webhookId, - String webhookUrl, - String message) { - } + record WebhookSetupResult( + boolean success, + String webhookId, + String webhookUrl, + String message) { + } - record WebhookInfo( - boolean webhooksConfigured, - String webhookId, - String webhookUrl, - EVcsProvider provider) { - } + record WebhookInfo( + boolean webhooksConfigured, + String webhookId, + String webhookUrl, + EVcsProvider provider) { + } } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java index 1dff7a02..87483995 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java @@ -18,6 +18,7 @@ import org.rostilos.codecrow.core.model.vcs.config.cloud.BitbucketCloudConfig; import org.rostilos.codecrow.core.model.workspace.Workspace; import org.rostilos.codecrow.core.persistence.repository.ai.AiConnectionRepository; +import org.rostilos.codecrow.core.persistence.repository.project.AllowedCommandUserRepository; import org.rostilos.codecrow.core.persistence.repository.analysis.AnalysisLockRepository; import org.rostilos.codecrow.core.persistence.repository.analysis.RagIndexStatusRepository; import org.rostilos.codecrow.core.persistence.repository.branch.BranchFileRepository; @@ -81,6 +82,7 @@ public class ProjectService implements IProjectService { private final VcsClientProvider vcsClientProvider; private final QualityGateRepository qualityGateRepository; private final SiteSettingsProvider siteSettingsProvider; + private final AllowedCommandUserRepository allowedCommandUserRepository; public ProjectService( ProjectRepository projectRepository, @@ -102,7 +104,8 @@ public ProjectService( PrSummarizeCacheRepository prSummarizeCacheRepository, VcsClientProvider vcsClientProvider, QualityGateRepository qualityGateRepository, - SiteSettingsProvider siteSettingsProvider) { + SiteSettingsProvider siteSettingsProvider, + AllowedCommandUserRepository allowedCommandUserRepository) { this.projectRepository = projectRepository; this.vcsConnectionRepository = vcsConnectionRepository; this.tokenEncryptionService = tokenEncryptionService; @@ -123,6 +126,7 @@ public ProjectService( this.vcsClientProvider = vcsClientProvider; this.qualityGateRepository = qualityGateRepository; this.siteSettingsProvider = siteSettingsProvider; + this.allowedCommandUserRepository = allowedCommandUserRepository; } @Transactional(readOnly = true) @@ -274,6 +278,7 @@ public void deleteProjectByNamespace(Long workspaceId, String namespace) { analysisLockRepository.deleteByProjectId(projectId); ragIndexStatusRepository.deleteByProjectId(projectId); prSummarizeCacheRepository.deleteByProjectId(projectId); + allowedCommandUserRepository.deleteByProjectId(projectId); // Finally delete the project (cascade will handle vcsBinding and aiBinding) projectRepository.delete(project); @@ -337,6 +342,7 @@ public void deleteProject(Long workspaceId, Long projectId) { analysisLockRepository.deleteByProjectId(projectId); ragIndexStatusRepository.deleteByProjectId(projectId); prSummarizeCacheRepository.deleteByProjectId(projectId); + allowedCommandUserRepository.deleteByProjectId(projectId); // Finally delete the project (cascade will handle vcsBinding and aiBinding) projectRepository.delete(project); @@ -533,7 +539,8 @@ public Project updateBranchAnalysisConfig( * @param enabled whether RAG indexing is enabled * @param branch the branch to index (null uses defaultBranch or * 'main') - * @param includePatterns patterns to include in indexing (applied before exclusion) + * @param includePatterns patterns to include in indexing (applied before + * exclusion) * @param excludePatterns patterns to exclude from indexing * @param multiBranchEnabled whether multi-branch indexing is enabled * @param branchRetentionDays how long to keep branch index metadata @@ -554,6 +561,7 @@ public Project updateRagConfig( ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); + boolean useMcpTools = currentConfig != null && currentConfig.useMcpTools(); String mainBranch = currentConfig != null ? currentConfig.mainBranch() : null; var branchAnalysis = currentConfig != null ? currentConfig.branchAnalysis() : null; Boolean prAnalysisEnabled = currentConfig != null ? currentConfig.prAnalysisEnabled() : true; @@ -564,7 +572,7 @@ public Project updateRagConfig( RagConfig ragConfig = new RagConfig( enabled, branch, includePatterns, excludePatterns, multiBranchEnabled, branchRetentionDays); - project.setConfiguration(new ProjectConfig(useLocalMcp, mainBranch, branchAnalysis, ragConfig, + project.setConfiguration(new ProjectConfig(useLocalMcp, useMcpTools, mainBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, installationMethod, commentCommands)); return projectRepository.save(project); } @@ -579,7 +587,7 @@ public Project updateRagConfig( boolean enabled, String branch, java.util.List includePatterns, - java.util.List excludePatterns) { + java.util.List excludePatterns) { return updateRagConfig(workspaceId, projectId, enabled, branch, includePatterns, excludePatterns, null, null); } @@ -590,12 +598,15 @@ public Project updateAnalysisSettings( Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled, InstallationMethod installationMethod, - Integer maxAnalysisTokenLimit) { + Integer maxAnalysisTokenLimit, + Boolean useMcpTools) { Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) .orElseThrow(() -> new NoSuchElementException("Project not found")); ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); + boolean newUseMcpTools = useMcpTools != null ? useMcpTools + : (currentConfig != null && currentConfig.useMcpTools()); String mainBranch = currentConfig != null ? currentConfig.mainBranch() : null; var branchAnalysis = currentConfig != null ? currentConfig.branchAnalysis() : null; var ragConfig = currentConfig != null ? currentConfig.ragConfig() : null; @@ -616,7 +627,7 @@ public Project updateAnalysisSettings( project.setPrAnalysisEnabled(newPrAnalysis != null ? newPrAnalysis : true); project.setBranchAnalysisEnabled(newBranchAnalysis != null ? newBranchAnalysis : true); - project.setConfiguration(new ProjectConfig(useLocalMcp, mainBranch, branchAnalysis, ragConfig, + project.setConfiguration(new ProjectConfig(useLocalMcp, newUseMcpTools, mainBranch, branchAnalysis, ragConfig, newPrAnalysis, newBranchAnalysis, newInstallationMethod, commentCommands, newMaxTokenLimit)); return projectRepository.save(project); } @@ -676,6 +687,7 @@ public Project updateCommentCommandsConfig( ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); + boolean useMcpTools = currentConfig != null && currentConfig.useMcpTools(); String mainBranch = currentConfig != null ? currentConfig.mainBranch() : null; var branchAnalysis = currentConfig != null ? currentConfig.branchAnalysis() : null; var ragConfig = currentConfig != null ? currentConfig.ragConfig() : null; @@ -708,7 +720,7 @@ public Project updateCommentCommandsConfig( enabled, rateLimit, rateLimitWindow, allowPublicRepoCommands, allowedCommands, authorizationMode, allowPrAuthor); - project.setConfiguration(new ProjectConfig(useLocalMcp, mainBranch, branchAnalysis, ragConfig, + project.setConfiguration(new ProjectConfig(useLocalMcp, useMcpTools, mainBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, installationMethod, commentCommands)); return projectRepository.save(project); } @@ -822,7 +834,8 @@ public WebhookInfo getWebhookInfo(Long workspaceId, Long projectId) { private String generateWebhookUrl(EVcsProvider provider, Project project) { var urls = siteSettingsProvider.getBaseUrlSettings(); String base = (urls.webhookBaseUrl() != null && !urls.webhookBaseUrl().isBlank()) - ? urls.webhookBaseUrl() : urls.baseUrl(); + ? urls.webhookBaseUrl() + : urls.baseUrl(); return base + "/api/webhooks/" + provider.getId() + "/" + project.getAuthToken(); } diff --git a/python-ecosystem/inference-orchestrator/model/dtos.py b/python-ecosystem/inference-orchestrator/model/dtos.py index 192369fc..0d792c6d 100644 --- a/python-ecosystem/inference-orchestrator/model/dtos.py +++ b/python-ecosystem/inference-orchestrator/model/dtos.py @@ -72,6 +72,8 @@ class ReviewRequestDto(BaseModel): currentCommitHash: Optional[str] = Field(default=None, description="Current commit hash being analyzed") # File enrichment data (full file contents + pre-computed dependency graph) enrichmentData: Optional[PrEnrichmentDataDto] = Field(default=None, description="Pre-computed file contents and dependency relationships from Java") + # MCP tools for enhanced context in Stage 1 and issue verification in Stage 3 + useMcpTools: Optional[bool] = Field(default=False, description="Enable LLM to call VCS tools for context gaps and issue verification") class ReviewResponseDto(BaseModel): diff --git a/python-ecosystem/inference-orchestrator/service/rag/llm_reranker.py b/python-ecosystem/inference-orchestrator/service/rag/llm_reranker.py index fa8657ba..d236f851 100644 --- a/python-ecosystem/inference-orchestrator/service/rag/llm_reranker.py +++ b/python-ecosystem/inference-orchestrator/service/rag/llm_reranker.py @@ -1,23 +1,27 @@ """ LLM-based reranking service for RAG results. -Implements listwise reranking for improved relevance in large PRs. + +Implements two strategies: +1. LLM listwise reranking — sends snippets to Gemini Flash for intelligent ordering +2. Heuristic fallback — PR-file proximity + directory match + type penalty + +The reranker is designed to be ALWAYS-ON for any PR with ≥5 RAG chunks, +ensuring every review gets intelligent context ordering. """ import os import logging import json -import asyncio from typing import List, Dict, Any, Optional -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime logger = logging.getLogger(__name__) LLM_RERANK_ENABLED = os.environ.get("LLM_RERANK_ENABLED", "true").lower() == "true" -# Threshold for when to use LLM reranking (file count) -LLM_RERANK_THRESHOLD = int(os.environ.get("LLM_RERANK_THRESHOLD", "20")) -# Maximum items to send to LLM for reranking -MAX_ITEMS_FOR_LLM = int(os.environ.get("LLM_RERANK_MAX_ITEMS", "20")) - +# Minimum number of result chunks to trigger reranking +LLM_RERANK_THRESHOLD = int(os.environ.get("LLM_RERANK_THRESHOLD", "5")) +# Maximum items to send to LLM for reranking (keep small for speed/cost) +MAX_ITEMS_FOR_LLM = int(os.environ.get("LLM_RERANK_MAX_ITEMS", "15")) @dataclass @@ -26,7 +30,7 @@ class RerankResult: original_count: int reranked_count: int processing_time_ms: float - method: str # "llm" or "heuristic" + method: str # "llm", "heuristic", "none", "fallback" success: bool error: Optional[str] = None @@ -34,42 +38,46 @@ class RerankResult: class LLMReranker: """ LLM-based reranking for RAG results. - Uses listwise ranking approach for better relevance ordering. + Uses listwise ranking via Gemini Flash for intelligent ordering, + with heuristic fallback for speed or when LLM parsing fails. """ - - RERANK_PROMPT_TEMPLATE = """You are an expert code reviewer. Given a PR context and code snippets from a codebase, -rank the snippets by their RELEVANCE to reviewing this PR. + + RERANK_PROMPT_TEMPLATE = """You are an expert code reviewer reranking RAG results for a PR review. PR CONTEXT: - Title: {pr_title} - Description: {pr_description} -- Changed files: {changed_files} +- Changed files in this PR: {changed_files} -CODE SNIPPETS TO RANK: +CODE SNIPPETS TO RANK (each has an ID, file path, and code preview): {snippets_json} RANKING CRITERIA (in order of importance): -1. Direct dependency - code that imports or is imported by changed files -2. Same module/package - code in the same logical area -3. Similar functionality - code that does similar things -4. Shared patterns - code that uses similar patterns or APIs -5. Test coverage - tests for the changed functionality +1. **PR-file relevance**: Code from files IN the changed files list, or that directly import/reference those files, is MOST relevant +2. **Same module/package**: Code in the same directory or package as changed files +3. **Direct dependency**: Code that is imported by or imports the changed files +4. **Similar functionality**: Code that implements similar patterns (useful for duplication detection) +5. **Test coverage**: Tests for the changed functionality -Return ONLY a JSON object with this exact structure: -{{"rankings": [, , , ...], "reasoning": ""}} +CRITICAL RULES: +- Files from COMPLETELY UNRELATED modules/packages should be ranked LOWEST +- A file in the same directory/package as a changed file is MUCH more relevant than a file with high textual similarity from a different module +- Code that happens to use similar method names but is in a different feature area should be ranked LOW -Where the IDs are ordered from MOST relevant to LEAST relevant. -Include ALL snippet IDs in your ranking. Return ONLY valid JSON, no other text.""" +Return ONLY a JSON object: +{{"rankings": [, , ...], "reasoning": ""}} + +Order IDs from MOST to LEAST relevant. Include ALL IDs. Return ONLY valid JSON.""" def __init__(self, llm_client=None): """ Initialize reranker. - + Args: llm_client: LangChain-compatible LLM client for reranking """ self.llm_client = llm_client - + async def rerank( self, results: List[Dict[str, Any]], @@ -80,42 +88,43 @@ async def rerank( ) -> tuple[List[Dict[str, Any]], RerankResult]: """ Rerank RAG results for better relevance. - + + Decision logic: + - LLM reranking if: enabled + client available + enough results + - Heuristic fallback otherwise (or on LLM failure) + - Both methods benefit from PR changed file awareness + Args: results: RAG search results to rerank pr_title: PR title for context pr_description: PR description for context changed_files: List of changed file paths - use_llm: Whether to use LLM for reranking (falls back to heuristic if False or fails) - + use_llm: Whether to use LLM for reranking + Returns: Tuple of (reranked results, rerank metadata) """ start_time = datetime.now() - + if not results: return results, RerankResult( - original_count=0, - reranked_count=0, - processing_time_ms=0, - method="none", - success=True + original_count=0, reranked_count=0, + processing_time_ms=0, method="none", success=True ) - + # Decide reranking method should_use_llm = ( - use_llm and - self.llm_client is not None and - len(results) >= LLM_RERANK_THRESHOLD + use_llm + and LLM_RERANK_ENABLED + and self.llm_client is not None + and len(results) >= LLM_RERANK_THRESHOLD ) - + try: if should_use_llm: reranked = await self._llm_rerank( results[:MAX_ITEMS_FOR_LLM], - pr_title, - pr_description, - changed_files + pr_title, pr_description, changed_files ) # Append remaining results that weren't sent to LLM if len(results) > MAX_ITEMS_FOR_LLM: @@ -124,30 +133,27 @@ async def rerank( else: reranked = self._heuristic_rerank(results, changed_files) method = "heuristic" - + elapsed_ms = (datetime.now() - start_time).total_seconds() * 1000 - + return reranked, RerankResult( original_count=len(results), reranked_count=len(reranked), processing_time_ms=elapsed_ms, - method=method, - success=True + method=method, success=True ) - + except Exception as e: logger.warning(f"Reranking failed, returning original order: {e}") elapsed_ms = (datetime.now() - start_time).total_seconds() * 1000 - + return results, RerankResult( original_count=len(results), reranked_count=len(results), processing_time_ms=elapsed_ms, - method="fallback", - success=False, - error=str(e) + method="fallback", success=False, error=str(e) ) - + async def _llm_rerank( self, results: List[Dict[str, Any]], @@ -155,68 +161,53 @@ async def _llm_rerank( pr_description: Optional[str], changed_files: Optional[List[str]] ) -> List[Dict[str, Any]]: - """Use LLM to rerank results.""" - # Prepare snippets for LLM + """Use LLM to rerank results with PR-aware context.""" + # Prepare snippets — truncate previews to save tokens snippets = [] for i, result in enumerate(results): path = result.get("metadata", {}).get("path", "unknown") - text_preview = result.get("text", "") + text = result.get("text", "") + # Truncate long previews to ~500 chars for token efficiency + preview = text[:500] + "..." if len(text) > 500 else text snippets.append({ "id": i, "path": path, - "preview": text_preview, - "original_score": result.get("score", 0) + "preview": preview, + "original_score": round(result.get("score", 0), 3) }) - + # Build prompt prompt = self.RERANK_PROMPT_TEMPLATE.format( pr_title=pr_title or "Not provided", - pr_description=(pr_description or "Not provided"), + pr_description=(pr_description or "Not provided")[:300], changed_files=", ".join(changed_files) if changed_files else "Not provided", snippets_json=json.dumps(snippets, indent=2) ) - + # Call LLM response = await self.llm_client.ainvoke(prompt) - - # Handle different response types - if hasattr(response, 'content'): - content = response.content - # Handle case where content is a list (e.g., from some LLM providers) - if isinstance(content, list): - # Extract text from list elements - response_text = "" - for item in content: - if isinstance(item, str): - response_text += item - elif isinstance(item, dict) and 'text' in item: - response_text += item['text'] - elif hasattr(item, 'text'): - response_text += item.text - else: - response_text = str(content) - else: - response_text = str(response) - + + # Extract text from response (handles various LangChain response types) + response_text = self._extract_response_text(response) + # Parse response try: - # Try to extract JSON from response json_start = response_text.find('{') json_end = response_text.rfind('}') + 1 if json_start >= 0 and json_end > json_start: json_str = response_text[json_start:json_end] parsed = json.loads(json_str) rankings = parsed.get("rankings", []) - - if rankings and len(rankings) == len(results): + + if rankings: # Reorder results based on LLM ranking reranked = [] for idx in rankings: - if 0 <= idx < len(results): + if isinstance(idx, int) and 0 <= idx < len(results): result = results[idx].copy() result["_llm_rank"] = len(reranked) + 1 reranked.append(result) - + # Add any missing items at the end included_ids = set(rankings) for i, result in enumerate(results): @@ -224,69 +215,105 @@ async def _llm_rerank( result_copy = result.copy() result_copy["_llm_rank"] = len(reranked) + 1 reranked.append(result_copy) - - logger.info(f"LLM reranking successful: {len(reranked)} items") + + logger.info(f"LLM reranking successful: {len(reranked)} items reordered") + if parsed.get("reasoning"): + logger.debug(f"LLM reasoning: {parsed['reasoning']}") return reranked except json.JSONDecodeError as e: logger.warning(f"Failed to parse LLM reranking response: {e}") - + # Fallback to heuristic if LLM parsing failed logger.warning("LLM reranking parse failed, falling back to heuristic") return self._heuristic_rerank(results, changed_files) - + + @staticmethod + def _extract_response_text(response) -> str: + """Extract text content from various LangChain response types.""" + if hasattr(response, 'content'): + content = response.content + if isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict) and 'text' in item: + parts.append(item['text']) + elif hasattr(item, 'text'): + parts.append(item.text) + return "".join(parts) + return str(content) + return str(response) + def _heuristic_rerank( self, results: List[Dict[str, Any]], changed_files: Optional[List[str]] ) -> List[Dict[str, Any]]: """ - Heuristic-based reranking when LLM is not available or for smaller result sets. - - Scoring factors: - 1. Original relevance score (from embedding similarity) - 2. Path proximity to changed files - 3. File type priority (implementation > test > config) + Heuristic reranking with strong PR-file proximity awareness. + + Scoring factors (in order of impact): + 1. PR-file match: chunk IS from a changed file (+50% boost) + 2. Directory proximity: chunk is in the same directory as a changed file (+30%) + 3. Same extension: chunk shares file type with changed files (+10%) + 4. Penalty: test/spec files (-20%), config files (-30%) """ + # Pre-compute changed file metadata + changed_set = set() changed_dirs = set() changed_extensions = set() - + changed_basenames = set() + if changed_files: for f in changed_files: + changed_set.add(f) parts = f.rsplit('/', 1) if len(parts) > 1: changed_dirs.add(parts[0]) + changed_basenames.add(parts[1]) + else: + changed_basenames.add(f) ext = f.rsplit('.', 1)[-1] if '.' in f else '' - changed_extensions.add(ext) - + if ext: + changed_extensions.add(ext) + scored_results = [] for result in results: score = result.get("score", 0) - path = result.get("metadata", {}).get("path", "") - - # Boost for same directory as changed files + metadata = result.get("metadata", {}) + path = metadata.get("path", result.get("path", "")) + + # Factor 1: PR-file match (strongest signal — this IS a changed file) + is_pr_file = ( + path in changed_set + or any(path.endswith(f) or f.endswith(path) for f in changed_set) + ) + if is_pr_file: + score *= 1.5 + + # Factor 2: Same directory as changed files result_dir = path.rsplit('/', 1)[0] if '/' in path else '' - if result_dir in changed_dirs: + if result_dir and result_dir in changed_dirs: score *= 1.3 - - # Boost for same extension as changed files + + # Factor 3: Same extension as changed files result_ext = path.rsplit('.', 1)[-1] if '.' in path else '' if result_ext in changed_extensions: score *= 1.1 - - # Penalize test files (less relevant for review context) + + # Factor 4: Penalties path_lower = path.lower() if 'test' in path_lower or 'spec' in path_lower: score *= 0.8 - - # Penalize config files - if any(ext in path_lower for ext in ['.json', '.yaml', '.yml', '.toml', '.ini']): + if any(path_lower.endswith(ext) for ext in ('.json', '.yaml', '.yml', '.toml', '.ini', '.xml')): score *= 0.7 - + result_copy = result.copy() - result_copy["_heuristic_score"] = score + result_copy["_heuristic_score"] = round(score, 4) scored_results.append((score, result_copy)) - + # Sort by adjusted score scored_results.sort(key=lambda x: x[0], reverse=True) - + return [r[1] for r in scored_results] diff --git a/python-ecosystem/inference-orchestrator/service/review/orchestrator/context_helpers.py b/python-ecosystem/inference-orchestrator/service/review/orchestrator/context_helpers.py index 28651721..ef895427 100644 --- a/python-ecosystem/inference-orchestrator/service/review/orchestrator/context_helpers.py +++ b/python-ecosystem/inference-orchestrator/service/review/orchestrator/context_helpers.py @@ -160,8 +160,8 @@ def format_rag_context( skipped_stale = 0 for chunk in chunks: - if included_count >= 15: - logger.debug(f"Reached chunk limit of 15") + if included_count >= 20: + logger.debug(f"Reached chunk limit of 20") break metadata = chunk.get("metadata", {}) diff --git a/python-ecosystem/inference-orchestrator/service/review/orchestrator/mcp_tool_executor.py b/python-ecosystem/inference-orchestrator/service/review/orchestrator/mcp_tool_executor.py new file mode 100644 index 00000000..a39fd3b2 --- /dev/null +++ b/python-ecosystem/inference-orchestrator/service/review/orchestrator/mcp_tool_executor.py @@ -0,0 +1,155 @@ +""" +Controlled MCP tool executor with per-stage whitelist and call budget. + +Stage 1 (context gaps): getBranchFileContent — max 3 calls/batch +Stage 3 (issue verification): getBranchFileContent, getPullRequestComments — max 5 calls total +""" +import asyncio +import logging +from typing import Any, Dict, List, Optional, Set + +logger = logging.getLogger(__name__) + + +class McpToolExecutor: + """ + Wraps an MCP client session with safety controls: + - Tool whitelist per stage + - Call budget (hard limit) + - Pre-filled workspace/repoSlug from request context + - Call logging for observability + """ + + STAGE_CONFIG = { + "stage_1": { + "tools": {"getBranchFileContent"}, + "max_calls": 3, + }, + "stage_3": { + "tools": {"getBranchFileContent", "getPullRequestComments"}, + "max_calls": 5, + }, + } + + def __init__(self, mcp_client, request, stage: str): + if stage not in self.STAGE_CONFIG: + raise ValueError(f"Unknown stage '{stage}'. Valid: {list(self.STAGE_CONFIG)}") + + config = self.STAGE_CONFIG[stage] + self.client = mcp_client + self.request = request + self.stage = stage + self.allowed_tools: Set[str] = config["tools"] + self.max_calls: int = config["max_calls"] + self.call_count: int = 0 + self.call_log: List[Dict[str, Any]] = [] + self._lock = asyncio.Lock() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str: + """Execute a single MCP tool call with safety checks.""" + async with self._lock: + if tool_name not in self.allowed_tools: + msg = f"Tool '{tool_name}' not allowed in {self.stage}. Allowed: {self.allowed_tools}" + logger.warning(msg) + return msg + + if self.call_count >= self.max_calls: + msg = f"Tool budget exhausted ({self.max_calls} calls used in {self.stage})." + logger.warning(msg) + return msg + + self.call_count += 1 + + # Pre-fill workspace/repo from request context so the LLM doesn't + # have to guess these values. + arguments.setdefault("workspace", self.request.projectVcsWorkspace) + arguments.setdefault("repoSlug", self.request.projectVcsRepoSlug) + + logger.info( + f"[MCP {self.stage}] Calling {tool_name} " + f"(call {self.call_count}/{self.max_calls}): {arguments}" + ) + + try: + result = await self.client.session.call_tool(tool_name, arguments) + self.call_log.append( + {"tool": tool_name, "args": arguments, "success": True} + ) + # Extract text content from MCP result + if hasattr(result, "content") and result.content: + return "\n".join( + block.text for block in result.content if hasattr(block, "text") + ) + return str(result) + except Exception as e: + logger.error(f"[MCP {self.stage}] Tool call failed: {e}") + self.call_log.append( + {"tool": tool_name, "args": arguments, "success": False, "error": str(e)} + ) + return f"Tool call failed: {e}" + + def get_tool_definitions(self) -> List[Dict[str, Any]]: + """Return OpenAI-compatible function definitions for allowed tools.""" + definitions = [] + for tool_name in self.allowed_tools: + if tool_name == "getBranchFileContent": + definitions.append({ + "type": "function", + "function": { + "name": "getBranchFileContent", + "description": "Read a file's content from the target branch.", + "parameters": { + "type": "object", + "properties": { + "branch": { + "type": "string", + "description": "Branch name (e.g. 'main', 'develop')" + }, + "filePath": { + "type": "string", + "description": "Path to the file in the repository" + }, + }, + "required": ["branch", "filePath"], + }, + }, + }) + elif tool_name == "getPullRequestComments": + definitions.append({ + "type": "function", + "function": { + "name": "getPullRequestComments", + "description": "Get comments from the pull request.", + "parameters": { + "type": "object", + "properties": { + "pullRequestId": { + "type": "string", + "description": "Pull request ID" + }, + }, + "required": ["pullRequestId"], + }, + }, + }) + return definitions + + @property + def budget_remaining(self) -> int: + return max(0, self.max_calls - self.call_count) + + @property + def budget_exhausted(self) -> bool: + return self.call_count >= self.max_calls + + def summary(self) -> str: + """Return a human-readable summary for logging.""" + return ( + f"McpToolExecutor({self.stage}): " + f"{self.call_count}/{self.max_calls} calls used, " + f"{len(self.call_log)} logged" + ) diff --git a/python-ecosystem/inference-orchestrator/service/review/orchestrator/orchestrator.py b/python-ecosystem/inference-orchestrator/service/review/orchestrator/orchestrator.py index 78451b9d..81bf9a89 100644 --- a/python-ecosystem/inference-orchestrator/service/review/orchestrator/orchestrator.py +++ b/python-ecosystem/inference-orchestrator/service/review/orchestrator/orchestrator.py @@ -42,12 +42,14 @@ def __init__( llm, mcp_client, rag_client=None, - event_callback: Optional[Callable[[Dict], None]] = None + event_callback: Optional[Callable[[Dict], None]] = None, + llm_reranker=None ): self.llm = llm self.client = mcp_client self.rag_client = rag_client self.event_callback = event_callback + self.llm_reranker = llm_reranker self.max_parallel_stage_1 = 5 self._pr_number: Optional[int] = None self._pr_indexed: bool = False @@ -165,6 +167,7 @@ async def orchestrate_review( # === STAGE 1: File Reviews === _emit_status(self.event_callback, "stage_1_started", f"Stage 1: Analyzing {self._count_files(review_plan)} files...") + use_mcp = getattr(request, 'useMcpTools', False) or False file_issues = await execute_stage_1_file_reviews( self.llm, request, @@ -175,7 +178,8 @@ async def orchestrate_review( is_incremental, self.max_parallel_stage_1, self.event_callback, - self._pr_indexed + self._pr_indexed, + llm_reranker=self.llm_reranker, ) _emit_progress(self.event_callback, 60, f"Stage 1 Complete: {len(file_issues)} issues found across files") @@ -200,7 +204,9 @@ async def orchestrate_review( _emit_status(self.event_callback, "stage_3_started", "Stage 3: Generating final report...") final_report = await execute_stage_3_aggregation( self.llm, request, review_plan, file_issues, cross_file_results, - is_incremental, processed_diff=processed_diff + is_incremental, processed_diff=processed_diff, + mcp_client=self.client if use_mcp else None, + use_mcp_tools=use_mcp, ) _emit_progress(self.event_callback, 100, "Stage 3 Complete: Report generated") diff --git a/python-ecosystem/inference-orchestrator/service/review/orchestrator/stages.py b/python-ecosystem/inference-orchestrator/service/review/orchestrator/stages.py index 4a80bc0b..df2c87e9 100644 --- a/python-ecosystem/inference-orchestrator/service/review/orchestrator/stages.py +++ b/python-ecosystem/inference-orchestrator/service/review/orchestrator/stages.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional, Callable from model.dtos import ReviewRequestDto +from service.review.orchestrator.mcp_tool_executor import McpToolExecutor from model.enrichment import PrEnrichmentDataDto from model.output_schemas import CodeReviewOutput, CodeReviewIssue from model.multi_stage import ( @@ -231,11 +232,13 @@ async def execute_stage_1_file_reviews( is_incremental: bool = False, max_parallel: int = 5, event_callback: Optional[Callable[[Dict], None]] = None, - pr_indexed: bool = False + pr_indexed: bool = False, + llm_reranker=None, ) -> List[CodeReviewIssue]: """ Stage 1: Execute batch file reviews with per-batch RAG context. Uses dependency-aware batching to keep related files together. + MCP tools are NOT used in Stage 1 to avoid multiplied cost/latency per batch. """ # Use smart batching with RAG-based relationship discovery batches = create_smart_batches_wrapper( @@ -269,7 +272,8 @@ async def execute_stage_1_file_reviews( # Create coroutine with batch_idx for tracking tasks.append(_review_batch_with_timing( batch_idx, llm, request, batch, rag_client, processed_diff, - is_incremental, rag_context, pr_indexed + is_incremental, rag_context, pr_indexed, + llm_reranker=llm_reranker )) # asyncio.gather runs all tasks CONCURRENTLY @@ -306,7 +310,8 @@ async def _review_batch_with_timing( processed_diff: Optional[ProcessedDiff], is_incremental: bool, fallback_rag_context: Optional[Dict[str, Any]], - pr_indexed: bool + pr_indexed: bool, + llm_reranker=None, ) -> List[CodeReviewIssue]: """ Wrapper that adds timing logs to show parallel execution. @@ -318,7 +323,8 @@ async def _review_batch_with_timing( try: result = await review_file_batch( llm, request, batch, rag_client, processed_diff, is_incremental, - fallback_rag_context=fallback_rag_context, pr_indexed=pr_indexed + fallback_rag_context=fallback_rag_context, pr_indexed=pr_indexed, + llm_reranker=llm_reranker ) elapsed = time.time() - start_time logger.info(f"[Batch {batch_idx}] FINISHED in {elapsed:.2f}s - {len(result)} issues") @@ -334,7 +340,8 @@ async def fetch_batch_rag_context( request: ReviewRequestDto, batch_file_paths: List[str], batch_diff_snippets: List[str], - pr_indexed: bool = False + pr_indexed: bool = False, + llm_reranker=None, ) -> Optional[Dict[str, Any]]: """ Fetch RAG context specifically for this batch of files. @@ -508,6 +515,24 @@ async def fetch_batch_rag_context( total_chunks = len(context.get("relevant_code", [])) logger.info(f"Total RAG context: {total_chunks} chunks for files {batch_file_paths}") + + # Apply LLM reranking to the merged results + if llm_reranker and total_chunks > 0: + try: + chunks = context.get("relevant_code", []) + reranked, rerank_result = await llm_reranker.rerank( + chunks, + pr_title=request.prTitle, + pr_description=request.prDescription, + changed_files=request.changedFiles + ) + context["relevant_code"] = reranked + logger.info(f"Per-batch reranking: {rerank_result.method} " + f"({rerank_result.processing_time_ms:.0f}ms, " + f"{rerank_result.original_count}→{rerank_result.reranked_count} chunks)") + except Exception as rerank_err: + logger.warning(f"Per-batch reranking failed (non-critical): {rerank_err}") + return context return None @@ -734,11 +759,13 @@ async def review_file_batch( processed_diff: Optional[ProcessedDiff] = None, is_incremental: bool = False, fallback_rag_context: Optional[Dict[str, Any]] = None, - pr_indexed: bool = False + pr_indexed: bool = False, + llm_reranker=None, ) -> List[CodeReviewIssue]: """ Review a batch of files in a single LLM call with per-batch RAG context. In incremental mode, uses delta diff and focuses on new changes only. + MCP tools are NOT used here to avoid multiplied cost/latency across batches. """ batch_files_data = [] batch_file_paths = [] @@ -785,7 +812,8 @@ async def review_file_batch( if rag_client: batch_rag_context = await fetch_batch_rag_context( - rag_client, request, batch_file_paths, batch_diff_snippets, pr_indexed + rag_client, request, batch_file_paths, batch_diff_snippets, pr_indexed, + llm_reranker=llm_reranker ) # Use batch-specific RAG context if available, otherwise fall back to initial context @@ -836,10 +864,10 @@ async def review_file_batch( rag_context=rag_context_text, is_incremental=is_incremental, previous_issues=previous_issues_for_batch, - all_pr_files=request.changedFiles # Enable cross-file awareness in prompt + all_pr_files=request.changedFiles, # Enable cross-file awareness in prompt ) - # Stage 1 uses direct LLM call (no tools needed - diff is already provided) + # --- Standard path --- try: # Try structured output first structured_llm = llm.with_structured_output(FileReviewBatchOutput) @@ -869,6 +897,8 @@ async def review_file_batch( return [] + + # --------------------------------------------------------------------------- # Stage 2 / Stage 3 context helpers # --------------------------------------------------------------------------- @@ -1277,11 +1307,14 @@ async def execute_stage_3_aggregation( stage_1_issues: List[CodeReviewIssue], stage_2_results: CrossFileAnalysisResult, is_incremental: bool = False, - processed_diff: Optional[ProcessedDiff] = None + processed_diff: Optional[ProcessedDiff] = None, + mcp_client=None, + use_mcp_tools: bool = False, ) -> str: """ Stage 3: Generate Markdown report. In incremental mode, includes summary of resolved vs new issues. + When use_mcp_tools=True, the LLM can verify HIGH/CRITICAL issues via MCP tool calls. """ # Compact summary — the full issue list is posted as a separate comment stage_1_json = _summarize_issues_for_stage_3(stage_1_issues) @@ -1305,6 +1338,7 @@ async def execute_stage_3_aggregation( # Use real diff stats when available, fall back to 0 additions = processed_diff.total_additions if processed_diff else 0 deletions = processed_diff.total_deletions if processed_diff else 0 + target_branch = request.targetBranchName or "" prompt = PromptBuilder.build_stage_3_aggregation_prompt( repo_slug=request.projectVcsRepoSlug, @@ -1318,9 +1352,66 @@ async def execute_stage_3_aggregation( stage_1_issues_json=stage_1_json, stage_2_findings_json=stage_2_json, recommendation=stage_2_results.pr_recommendation, - incremental_context=incremental_context + incremental_context=incremental_context, + use_mcp_tools=use_mcp_tools, + target_branch=target_branch, ) + # --- MCP-enabled agentic path for issue verification --- + if use_mcp_tools and mcp_client and target_branch: + return await _stage_3_with_mcp( + llm, request, prompt, mcp_client, target_branch + ) + + # --- Standard path --- + response = await llm.ainvoke(prompt) + return extract_llm_response_text(response) + + +async def _stage_3_with_mcp( + llm, + request: ReviewRequestDto, + prompt: str, + mcp_client, + target_branch: str, +) -> str: + """ + Agentic Stage 3 loop: the LLM can verify HIGH/CRITICAL issues by reading + file content or PR comments before producing the final executive summary. + """ + executor = McpToolExecutor(mcp_client, request, stage="stage_3") + tool_defs = executor.get_tool_definitions() + max_iterations = 15 # 5 tool calls + 5 responses + margin + + messages = [{"role": "user", "content": prompt}] + + for iteration in range(max_iterations): + try: + llm_with_tools = llm.bind_tools(tool_defs) + response = await llm_with_tools.ainvoke(messages) + messages.append(response) + + tool_calls = getattr(response, 'tool_calls', None) + if not tool_calls: + content = extract_llm_response_text(response) + logger.info(f"[MCP Stage 3] Completed in {iteration + 1} iterations, " + f"{executor.call_count} verification calls") + return content + + for tc in tool_calls: + tool_result = await executor.execute_tool(tc["name"], tc["args"]) + messages.append({ + "role": "tool", + "content": str(tool_result), + "tool_call_id": tc["id"], + }) + + except Exception as e: + logger.warning(f"[MCP Stage 3] Iteration {iteration + 1} failed: {e}") + break + + # Fallback: plain LLM call + logger.warning("[MCP Stage 3] Agentic loop exhausted, falling back to plain call") response = await llm.ainvoke(prompt) return extract_llm_response_text(response) diff --git a/python-ecosystem/inference-orchestrator/service/review/review_service.py b/python-ecosystem/inference-orchestrator/service/review/review_service.py index d47bbc58..eacd9367 100644 --- a/python-ecosystem/inference-orchestrator/service/review/review_service.py +++ b/python-ecosystem/inference-orchestrator/service/review/review_service.py @@ -26,9 +26,6 @@ class ReviewService: # Maximum retries for LLM-based response fixing MAX_FIX_RETRIES = 2 - - # Threshold for using LLM reranking (number of changed files) - LLM_RERANK_FILE_THRESHOLD = 20 # Maximum concurrent reviews (each spawns a JVM subprocess + LLM calls) MAX_CONCURRENT_REVIEWS = int(os.environ.get("MAX_CONCURRENT_REVIEWS", "4")) @@ -171,7 +168,8 @@ async def _process_review( llm=llm, mcp_client=client, rag_client=self.rag_client, - event_callback=event_callback + event_callback=event_callback, + llm_reranker=llm_reranker ) try: @@ -359,16 +357,8 @@ async def _fetch_rag_context( context = rag_response.get("context") relevant_code = context.get("relevant_code", []) - # Apply LLM reranking for large PRs - if len(changed_files) >= self.LLM_RERANK_FILE_THRESHOLD and llm_reranker: - reranked, rerank_result = await llm_reranker.rerank( - relevant_code, - pr_title=request.prTitle, - pr_description=request.prDescription, - changed_files=changed_files - ) - context["relevant_code"] = reranked - logger.info(f"LLM reranking result: {rerank_result}") + # LLM reranking is now applied per-batch in stages.py + # via the orchestrator, not at the global level # Cache the result self.rag_cache.set( @@ -386,7 +376,7 @@ async def _fetch_rag_context( metrics = RAGMetrics.from_results( relevant_code, processing_time_ms=elapsed_ms, - reranking_applied=len(changed_files) >= self.LLM_RERANK_FILE_THRESHOLD, + reranking_applied=False, # Reranking now happens per-batch in stages.py cache_hit=False ) logger.info(f"RAG metrics: {metrics.to_dict()}") diff --git a/python-ecosystem/inference-orchestrator/tests/test_llm_reranker.py b/python-ecosystem/inference-orchestrator/tests/test_llm_reranker.py new file mode 100644 index 00000000..01f631c4 --- /dev/null +++ b/python-ecosystem/inference-orchestrator/tests/test_llm_reranker.py @@ -0,0 +1,166 @@ +""" +Unit tests for the LLMReranker — heuristic reranking logic. +Tests PR-file proximity boosting, directory matching, and penalty logic. +""" +import pytest +from unittest.mock import AsyncMock, MagicMock +from service.rag.llm_reranker import LLMReranker, RerankResult + + +def make_result(path: str, score: float, text: str = "some code") -> dict: + """Helper to create a mock RAG result.""" + return { + "metadata": {"path": path}, + "text": text, + "score": score, + } + + +class TestHeuristicRerank: + """Tests for _heuristic_rerank method.""" + + def test_pr_file_boosted_highest(self): + """Files that ARE in the changed files list should be boosted to the top.""" + reranker = LLMReranker() + + results = [ + make_result("app/code/Magezon/PageBuilder/Block/Widget.php", 0.85), + make_result("app/code/Perspective/Akeneo/Model/VariantGroup.php", 0.75), + make_result("app/code/Other/Module/Helper.php", 0.80), + ] + + changed_files = [ + "app/code/Perspective/Akeneo/Model/VariantGroup.php", + "app/code/Perspective/Akeneo/view/frontend/templates/selector.phtml", + ] + + reranked = reranker._heuristic_rerank(results, changed_files) + + # VariantGroup.php should be first despite lower raw score (0.75) + # because it's in the changed files list (1.5x boost → 1.125) + assert reranked[0]["metadata"]["path"] == "app/code/Perspective/Akeneo/Model/VariantGroup.php" + + def test_same_directory_boosted(self): + """Files in the same directory as changed files should be boosted.""" + reranker = LLMReranker() + + results = [ + make_result("app/code/Unrelated/Module/Service.php", 0.82), + make_result("app/code/MyModule/Model/Helper.php", 0.78), + ] + + changed_files = ["app/code/MyModule/Model/Product.php"] + + reranked = reranker._heuristic_rerank(results, changed_files) + + # Helper.php should be boosted above Service.php due to same directory (1.3x) + assert reranked[0]["metadata"]["path"] == "app/code/MyModule/Model/Helper.php" + + def test_test_files_penalized(self): + """Test files should be penalized.""" + reranker = LLMReranker() + + results = [ + make_result("tests/unit/TestHelper.php", 0.85), + make_result("app/code/Module/Service.php", 0.80), + ] + + reranked = reranker._heuristic_rerank(results, []) + + # Service.php should outrank the test file despite lower raw score + assert reranked[0]["metadata"]["path"] == "app/code/Module/Service.php" + + def test_config_files_penalized(self): + """Config files (.xml, .json, etc.) should be penalized.""" + reranker = LLMReranker() + + results = [ + make_result("app/code/Module/etc/di.xml", 0.90), + make_result("app/code/Module/Model/Service.php", 0.80), + ] + + reranked = reranker._heuristic_rerank(results, []) + + # Service.php should outrank the config file + # xml: 0.90 * 0.7 = 0.63 vs Service: 0.80 + assert reranked[0]["metadata"]["path"] == "app/code/Module/Model/Service.php" + + def test_empty_results(self): + """Empty results should return empty.""" + reranker = LLMReranker() + assert reranker._heuristic_rerank([], None) == [] + + def test_no_changed_files(self): + """Without changed files, should sort by raw score.""" + reranker = LLMReranker() + + results = [ + make_result("b.php", 0.90), + make_result("a.php", 0.95), + ] + + reranked = reranker._heuristic_rerank(results, None) + assert reranked[0]["metadata"]["path"] == "a.php" + + def test_heuristic_score_annotated(self): + """Reranked results should have _heuristic_score annotation.""" + reranker = LLMReranker() + + results = [make_result("a.php", 0.80)] + reranked = reranker._heuristic_rerank(results, []) + + assert "_heuristic_score" in reranked[0] + + +class TestRerankEntryPoint: + """Tests for the main rerank() method.""" + + @pytest.mark.asyncio + async def test_empty_results(self): + """Empty input returns empty output with 'none' method.""" + reranker = LLMReranker() + results, metadata = await reranker.rerank([]) + + assert results == [] + assert metadata.method == "none" + assert metadata.success is True + + @pytest.mark.asyncio + async def test_heuristic_when_no_llm(self): + """Without LLM client, should use heuristic method.""" + reranker = LLMReranker(llm_client=None) + + results = [ + make_result("a.php", 0.90), + make_result("b.php", 0.80), + make_result("c.php", 0.70), + make_result("d.php", 0.60), + make_result("e.php", 0.50), + ] + + reranked, metadata = await reranker.rerank(results) + + assert metadata.method == "heuristic" + assert metadata.success is True + assert len(reranked) == 5 + + @pytest.mark.asyncio + async def test_heuristic_when_below_threshold(self): + """Below threshold, should use heuristic even with LLM client.""" + mock_llm = MagicMock() + reranker = LLMReranker(llm_client=mock_llm) + + # Only 3 results, below default threshold of 5 + results = [ + make_result("a.php", 0.90), + make_result("b.php", 0.80), + make_result("c.php", 0.70), + ] + + reranked, metadata = await reranker.rerank(results) + + assert metadata.method == "heuristic" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python-ecosystem/inference-orchestrator/utils/prompts/prompt_builder.py b/python-ecosystem/inference-orchestrator/utils/prompts/prompt_builder.py index 869878bd..38c7b892 100644 --- a/python-ecosystem/inference-orchestrator/utils/prompts/prompt_builder.py +++ b/python-ecosystem/inference-orchestrator/utils/prompts/prompt_builder.py @@ -7,7 +7,9 @@ STAGE_0_PLANNING_PROMPT_TEMPLATE, STAGE_1_BATCH_PROMPT_TEMPLATE, STAGE_2_CROSS_FILE_PROMPT_TEMPLATE, - STAGE_3_AGGREGATION_PROMPT_TEMPLATE + STAGE_3_AGGREGATION_PROMPT_TEMPLATE, + STAGE_1_MCP_TOOL_SECTION, + STAGE_3_MCP_VERIFICATION_SECTION, ) class PromptBuilder: @@ -74,11 +76,14 @@ def build_stage_1_batch_prompt( rag_context: str = "", is_incremental: bool = False, previous_issues: str = "", - all_pr_files: List[str] = None # All files in this PR for cross-file awareness + all_pr_files: List[str] = None, # All files in this PR for cross-file awareness + use_mcp_tools: bool = False, + target_branch: str = "", ) -> str: """ Build prompt for Stage 1: Batch File Review. In incremental mode, includes previous issues context and focuses on delta changes. + When use_mcp_tools=True, appends MCP tool instructions. """ files_context = "" for i, f in enumerate(files): @@ -120,7 +125,7 @@ def build_stage_1_batch_prompt( Consider potential interactions with these files when reviewing. """ - return STAGE_1_BATCH_PROMPT_TEMPLATE.format( + prompt = STAGE_1_BATCH_PROMPT_TEMPLATE.format( project_rules=project_rules, priority=priority, files_context=files_context, @@ -130,6 +135,17 @@ def build_stage_1_batch_prompt( pr_files_context=pr_files_context ) + # Conditionally append MCP tool instructions + if use_mcp_tools and target_branch: + from service.review.orchestrator.mcp_tool_executor import McpToolExecutor + max_calls = McpToolExecutor.STAGE_CONFIG["stage_1"]["max_calls"] + prompt += STAGE_1_MCP_TOOL_SECTION.format( + max_calls=max_calls, + target_branch=target_branch + ) + + return prompt + @staticmethod def build_stage_2_cross_file_prompt( repo_slug: str, @@ -171,12 +187,15 @@ def build_stage_3_aggregation_prompt( stage_1_issues_json: str, stage_2_findings_json: str, recommendation: str, - incremental_context: str = "" + incremental_context: str = "", + use_mcp_tools: bool = False, + target_branch: str = "", ) -> str: """ Build prompt for Stage 3: Aggregation & Final Report. + When use_mcp_tools=True, appends MCP verification instructions. """ - return STAGE_3_AGGREGATION_PROMPT_TEMPLATE.format( + prompt = STAGE_3_AGGREGATION_PROMPT_TEMPLATE.format( repo_slug=repo_slug, pr_id=pr_id, author=author, @@ -189,4 +208,16 @@ def build_stage_3_aggregation_prompt( stage_2_findings_json=stage_2_findings_json, recommendation=recommendation, incremental_context=incremental_context - ) \ No newline at end of file + ) + + # Conditionally append MCP verification instructions + if use_mcp_tools and target_branch: + from service.review.orchestrator.mcp_tool_executor import McpToolExecutor + max_calls = McpToolExecutor.STAGE_CONFIG["stage_3"]["max_calls"] + prompt += STAGE_3_MCP_VERIFICATION_SECTION.format( + max_calls=max_calls, + target_branch=target_branch, + pr_id=pr_id + ) + + return prompt \ No newline at end of file diff --git a/python-ecosystem/inference-orchestrator/utils/prompts/prompt_constants.py b/python-ecosystem/inference-orchestrator/utils/prompts/prompt_constants.py index 6fa4dc6c..49bd01a5 100644 --- a/python-ecosystem/inference-orchestrator/utils/prompts/prompt_constants.py +++ b/python-ecosystem/inference-orchestrator/utils/prompts/prompt_constants.py @@ -592,3 +592,43 @@ - If any CRITICAL issue exists, set Status to FAIL - If HIGH issues exist but no CRITICAL, set Status to PASS WITH WARNINGS """ + +# --------------------------------------------------------------------------- +# Conditional MCP Tool Sections (appended when useMcpTools=True) +# --------------------------------------------------------------------------- + +STAGE_1_MCP_TOOL_SECTION = """ +## Available VCS Tools (Context Gap Filling) +If the diff and RAG context are INSUFFICIENT to understand the code changes, +you may call the following tool to read related files from the target branch: + +- **getBranchFileContent(branch, filePath)** — Read a file's full content from the repository. + +RULES: +1. You have a MAXIMUM of {max_calls} tool calls for this batch. +2. Use tools ONLY when context is truly missing (e.g., an interface definition, a parent class, a config file referenced in the diff). +3. Do NOT call tools for files already present in the diff or RAG context above. +4. After tool calls, continue your review with the enriched context. + +TARGET BRANCH: {target_branch} +""" + +STAGE_3_MCP_VERIFICATION_SECTION = """ +## Issue Re-verification (Optional) +Before producing the final report, you may verify HIGH/CRITICAL issues that seem uncertain +by reading actual file content from the repository. + +Available tools: +- **getBranchFileContent(branch, filePath)** — Read a file to verify an issue's existence +- **getPullRequestComments(pullRequestId)** — Read PR comments for additional context + +RULES: +1. You have a MAXIMUM of {max_calls} verification calls total. +2. Only verify issues you are UNCERTAIN about — do not verify every issue. +3. Focus on HIGH and CRITICAL severity issues. +4. If verification reveals a false positive, downgrade or remove it from your report. +5. After verification, produce the final executive summary. + +TARGET BRANCH: {target_branch} +PR ID: {pr_id} +""" diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager/collection_manager.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager/collection_manager.py index 039f06bf..1cf24408 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager/collection_manager.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager/collection_manager.py @@ -5,6 +5,7 @@ """ import logging +import os import time from typing import Optional, List @@ -24,6 +25,7 @@ class CollectionManager: def __init__(self, client: QdrantClient, embedding_dim: int): self.client = client self.embedding_dim = embedding_dim + self.vectors_on_disk = os.environ.get("QDRANT_VECTORS_ON_DISK", "true").lower() == "true" def ensure_collection_exists(self, collection_name: str) -> None: """Ensure Qdrant collection exists with proper configuration. @@ -39,13 +41,15 @@ def ensure_collection_exists(self, collection_name: str) -> None: logger.debug(f"Existing collections: {collection_names}") if collection_name not in collection_names: - logger.info(f"Creating Qdrant collection: {collection_name}") + logger.info(f"Creating Qdrant collection: {collection_name} (vectors_on_disk={self.vectors_on_disk})") self.client.create_collection( collection_name=collection_name, vectors_config=VectorParams( size=self.embedding_dim, - distance=Distance.COSINE - ) + distance=Distance.COSINE, + on_disk=self.vectors_on_disk, + ), + on_disk_payload=self.vectors_on_disk, ) logger.info(f"Created collection {collection_name}") self._ensure_payload_indexes(collection_name) @@ -62,8 +66,10 @@ def create_versioned_collection(self, base_name: str) -> str: collection_name=versioned_name, vectors_config=VectorParams( size=self.embedding_dim, - distance=Distance.COSINE - ) + distance=Distance.COSINE, + on_disk=self.vectors_on_disk, + ), + on_disk_payload=self.vectors_on_disk, ) self._ensure_payload_indexes(versioned_name) return versioned_name diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/models/scoring_config.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/models/scoring_config.py index f5735c5a..de2e858a 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/models/scoring_config.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/models/scoring_config.py @@ -1,26 +1,27 @@ """ -Scoring configuration for RAG query result reranking. +Scoring configuration for RAG query result scoring. -Provides configurable boost factors and priority patterns that can be +Provides configurable boost factors for content types that can be overridden via environment variables. + +DESIGN NOTE: We deliberately keep scoring simple — only content-type boost. +File-path-based boosting and metadata bonuses were removed because they +caused irrelevant results to be ranked above relevant ones (e.g., a Magezon +helper function beating an actual PR file because "helper" matched a +high-priority pattern). + +Intelligent reranking (PR-file awareness, dependency proximity) is handled +by the LLM reranker in the inference-orchestrator service. """ import os -from typing import Dict, List +from typing import List from pydantic import BaseModel, Field import logging logger = logging.getLogger(__name__) -def _parse_list_env(env_var: str, default: List[str]) -> List[str]: - """Parse comma-separated environment variable into list.""" - value = os.getenv(env_var) - if not value: - return default - return [item.strip() for item in value.split(',') if item.strip()] - - def _parse_float_env(env_var: str, default: float) -> float: """Parse float from environment variable.""" value = os.getenv(env_var) @@ -34,11 +35,19 @@ def _parse_float_env(env_var: str, default: float) -> float: class ContentTypeBoost(BaseModel): - """Boost factors for different content types from AST parsing.""" + """Boost factors for different content types from AST parsing. + + These are the ONLY score adjustments applied at the RAG pipeline level. + Kept because content type genuinely reflects chunk quality: + - functions_classes: Full, parseable definitions (highest value) + - fallback: Regex-based splits (neutral) + - oversized_split: Large chunks that were force-split (slightly penalized) + - simplified_code: Placeholders only (significantly penalized) + """ functions_classes: float = Field( - default_factory=lambda: _parse_float_env("RAG_BOOST_FUNCTIONS_CLASSES", 1.2), - description="Boost for full function/class definitions (highest value)" + default_factory=lambda: _parse_float_env("RAG_BOOST_FUNCTIONS_CLASSES", 1.1), + description="Boost for full function/class definitions" ) fallback: float = Field( default_factory=lambda: _parse_float_env("RAG_BOOST_FALLBACK", 1.0), @@ -49,7 +58,7 @@ class ContentTypeBoost(BaseModel): description="Boost for large chunks that were split" ) simplified_code: float = Field( - default_factory=lambda: _parse_float_env("RAG_BOOST_SIMPLIFIED", 0.7), + default_factory=lambda: _parse_float_env("RAG_BOOST_SIMPLIFIED", 0.8), description="Boost for code with placeholders (context only)" ) @@ -58,125 +67,28 @@ def get(self, content_type: str) -> float: return getattr(self, content_type, 1.0) -class FilePriorityPatterns(BaseModel): - """File path patterns for priority-based boosting.""" - - high: List[str] = Field( - default_factory=lambda: _parse_list_env( - "RAG_HIGH_PRIORITY_PATTERNS", - ['service', 'controller', 'handler', 'api', 'core', 'auth', 'security', - 'permission', 'repository', 'dao', 'migration'] - ), - description="Patterns for high-priority files (1.3x boost)" - ) - - medium: List[str] = Field( - default_factory=lambda: _parse_list_env( - "RAG_MEDIUM_PRIORITY_PATTERNS", - ['model', 'entity', 'dto', 'schema', 'util', 'helper', 'common', - 'shared', 'component', 'hook', 'client', 'integration'] - ), - description="Patterns for medium-priority files (1.1x boost)" - ) - - low: List[str] = Field( - default_factory=lambda: _parse_list_env( - "RAG_LOW_PRIORITY_PATTERNS", - ['test', 'spec', 'config', 'mock', 'fixture', 'stub'] - ), - description="Patterns for low-priority files (0.8x penalty)" - ) - - high_boost: float = Field( - default_factory=lambda: _parse_float_env("RAG_HIGH_PRIORITY_BOOST", 1.3) - ) - medium_boost: float = Field( - default_factory=lambda: _parse_float_env("RAG_MEDIUM_PRIORITY_BOOST", 1.1) - ) - low_boost: float = Field( - default_factory=lambda: _parse_float_env("RAG_LOW_PRIORITY_BOOST", 0.8) - ) - - def get_priority(self, file_path: str) -> tuple: - """ - Get priority level and boost factor for a file path. - - Uses word-boundary matching to avoid false positives like 'test' matching 'latest.py'. - Patterns are matched against path segments (directories and filename). - - Returns: - Tuple of (priority_name, boost_factor) - """ - import re - - path_lower = file_path.lower() - # Extract path segments for word-boundary matching - segments = re.split(r'[/\\]', path_lower) - # Also consider filename without extension - filename = segments[-1] if segments else '' - name_without_ext = filename.rsplit('.', 1)[0] if '.' in filename else filename - - def pattern_matches(patterns: List[str]) -> bool: - for p in patterns: - # Check if pattern matches as a complete segment - if p in segments: - return True - # Check if pattern matches at word boundaries in filename - # E.g., 'test' matches 'test_utils.py' or 'UserServiceTest.java' but not 'latest.py' - if re.search(rf'\b{re.escape(p)}\b', name_without_ext): - return True - # Also check directory names for patterns like 'tests/', '__tests__/' - for seg in segments[:-1]: - if re.search(rf'\b{re.escape(p)}\b', seg): - return True - return False - - if pattern_matches(self.high): - return ('HIGH', self.high_boost) - elif pattern_matches(self.medium): - return ('MEDIUM', self.medium_boost) - elif pattern_matches(self.low): - return ('LOW', self.low_boost) - else: - return ('MEDIUM', 1.0) - - -class MetadataBonus(BaseModel): - """Bonus multipliers for metadata presence.""" - - semantic_names: float = Field( - default_factory=lambda: _parse_float_env("RAG_BONUS_SEMANTIC_NAMES", 1.1), - description="Bonus for chunks with extracted semantic names" - ) - docstring: float = Field( - default_factory=lambda: _parse_float_env("RAG_BONUS_DOCSTRING", 1.05), - description="Bonus for chunks with docstrings" - ) - signature: float = Field( - default_factory=lambda: _parse_float_env("RAG_BONUS_SIGNATURE", 1.02), - description="Bonus for chunks with function signatures" - ) - - class ScoringConfig(BaseModel): """ - Complete scoring configuration for RAG query reranking. + Scoring configuration for RAG query results. - All values can be overridden via environment variables: - - RAG_BOOST_FUNCTIONS_CLASSES, RAG_BOOST_FALLBACK, etc. - - RAG_HIGH_PRIORITY_PATTERNS (comma-separated) - - RAG_HIGH_PRIORITY_BOOST, RAG_MEDIUM_PRIORITY_BOOST, etc. - - RAG_BONUS_SEMANTIC_NAMES, RAG_BONUS_DOCSTRING, etc. + Deliberately minimal — only content-type boost is applied here. + Intelligent reranking (PR context, dependency proximity) is handled + by the LLM reranker in the inference-orchestrator. + + Environment variables: + - RAG_BOOST_FUNCTIONS_CLASSES (default: 1.1) + - RAG_BOOST_FALLBACK (default: 1.0) + - RAG_BOOST_OVERSIZED (default: 0.95) + - RAG_BOOST_SIMPLIFIED (default: 0.8) + - RAG_MIN_RELEVANCE_SCORE (default: 0.7) + - RAG_MAX_SCORE_CAP (default: 1.0) Usage: config = ScoringConfig() - boost = config.content_type_boost.get('functions_classes') - priority, boost = config.file_priority.get_priority('/src/UserService.java') + score, _ = config.calculate_boosted_score(0.85, 'functions_classes') """ content_type_boost: ContentTypeBoost = Field(default_factory=ContentTypeBoost) - file_priority: FilePriorityPatterns = Field(default_factory=FilePriorityPatterns) - metadata_bonus: MetadataBonus = Field(default_factory=MetadataBonus) # Score thresholds min_relevance_score: float = Field( @@ -192,48 +104,39 @@ class ScoringConfig(BaseModel): def calculate_boosted_score( self, base_score: float, - file_path: str, content_type: str, + # Legacy parameters kept for API compatibility but ignored + file_path: str = "", has_semantic_names: bool = False, has_docstring: bool = False, has_signature: bool = False ) -> tuple: """ - Calculate final boosted score for a result. + Calculate final score for a result. + + Only applies content-type boost. File-path and metadata boosts + were removed to prevent irrelevant result inflation. Args: base_score: Original similarity score - file_path: File path of the chunk content_type: Content type (functions_classes, fallback, etc.) - has_semantic_names: Whether chunk has semantic names - has_docstring: Whether chunk has docstring - has_signature: Whether chunk has signature + file_path: (ignored, kept for API compatibility) + has_semantic_names: (ignored, kept for API compatibility) + has_docstring: (ignored, kept for API compatibility) + has_signature: (ignored, kept for API compatibility) Returns: Tuple of (boosted_score, priority_level) """ - score = base_score - - # File priority boost - priority, priority_boost = self.file_priority.get_priority(file_path) - score *= priority_boost - - # Content type boost + # Single factor: content type quality content_boost = self.content_type_boost.get(content_type) - score *= content_boost - - # Metadata bonuses - if has_semantic_names: - score *= self.metadata_bonus.semantic_names - if has_docstring: - score *= self.metadata_bonus.docstring - if has_signature: - score *= self.metadata_bonus.signature + score = base_score * content_boost # Cap the score score = min(score, self.max_score_cap) - return (score, priority) + # Priority is always MEDIUM now (no path-based priority) + return (score, 'MEDIUM') # Global singleton @@ -245,8 +148,7 @@ def get_scoring_config() -> ScoringConfig: global _scoring_config if _scoring_config is None: _scoring_config = ScoringConfig() - logger.info("ScoringConfig initialized with:") - logger.info(f" High priority patterns: {_scoring_config.file_priority.high[:5]}...") + logger.info("ScoringConfig initialized (simplified: content-type boost only)") logger.info(f" Content type boosts: functions_classes={_scoring_config.content_type_boost.functions_classes}") return _scoring_config diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/services/query_service.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/services/query_service.py index 3b98d80c..37b5a41e 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/services/query_service.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/services/query_service.py @@ -1121,12 +1121,11 @@ def _add_query(text: str, weight: float = 1.3, top_k: int = 8): def _merge_and_rank_results(self, results: List[Dict], min_score_threshold: float = 0.75) -> List[Dict]: """ - Deduplicate matches and filter by relevance score with priority-based reranking. + Deduplicate matches and apply content-type score adjustment. - Uses ScoringConfig for configurable boosting factors: - 1. File path priority (service/controller vs test/config) - 2. Content type priority (functions_classes vs simplified_code) - 3. Semantic name bonus (chunks with extracted function/class names) + Only content-type boost is applied here (functions_classes > fallback > oversized > simplified). + Intelligent reranking (PR-file proximity, dependency awareness) is handled downstream + by the LLM reranker in the inference-orchestrator. """ scoring_config = get_scoring_config() grouped = {} @@ -1144,28 +1143,18 @@ def _merge_and_rank_results(self, results: List[Dict], min_score_threshold: floa unique_results = list(grouped.values()) - # Apply multi-factor score boosting using ScoringConfig + # Apply content-type boost only for result in unique_results: metadata = result.get('metadata', {}) - file_path = metadata.get('path', metadata.get('file_path', '')) content_type = metadata.get('content_type', 'fallback') - semantic_names = metadata.get('semantic_names', []) - has_docstring = bool(metadata.get('docstring')) - has_signature = bool(metadata.get('signature')) - boosted_score, priority = scoring_config.calculate_boosted_score( + boosted_score, _ = scoring_config.calculate_boosted_score( base_score=result['score'], - file_path=file_path, content_type=content_type, - has_semantic_names=bool(semantic_names), - has_docstring=has_docstring, - has_signature=has_signature ) result['score'] = boosted_score - result['_priority'] = priority result['_content_type'] = content_type - result['_has_semantic_names'] = bool(semantic_names) # Filter by threshold filtered = [r for r in unique_results if r['score'] >= min_score_threshold] diff --git a/python-ecosystem/rag-pipeline/tests/test_scoring_config.py b/python-ecosystem/rag-pipeline/tests/test_scoring_config.py new file mode 100644 index 00000000..aaeb1f09 --- /dev/null +++ b/python-ecosystem/rag-pipeline/tests/test_scoring_config.py @@ -0,0 +1,157 @@ +""" +Unit tests for the simplified ScoringConfig. +Tests that only content-type boost is applied (no path-based or metadata boosts). +""" +import pytest +import os + + +def test_content_type_boost_functions_classes(): + """functions_classes content type gets a 1.1 boost.""" + # Reset singleton to pick up test defaults + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + score, priority = config.calculate_boosted_score( + base_score=0.80, + content_type="functions_classes" + ) + + assert abs(score - 0.88) < 0.001 # 0.80 * 1.1 = 0.88 + assert priority == "MEDIUM" + + +def test_content_type_boost_fallback(): + """fallback content type gets neutral boost (1.0).""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + score, priority = config.calculate_boosted_score( + base_score=0.80, + content_type="fallback" + ) + + assert abs(score - 0.80) < 0.001 # 0.80 * 1.0 = 0.80 + + +def test_content_type_boost_simplified(): + """simplified_code content type gets penalized (0.8).""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + score, _ = config.calculate_boosted_score( + base_score=0.80, + content_type="simplified_code" + ) + + assert abs(score - 0.64) < 0.001 # 0.80 * 0.8 = 0.64 + + +def test_content_type_boost_oversized(): + """oversized_split content type gets slight penalty (0.95).""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + score, _ = config.calculate_boosted_score( + base_score=0.80, + content_type="oversized_split" + ) + + assert abs(score - 0.76) < 0.001 # 0.80 * 0.95 = 0.76 + + +def test_score_cap(): + """Score should not exceed max_score_cap.""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + score, _ = config.calculate_boosted_score( + base_score=0.99, + content_type="functions_classes" + ) + + # 0.99 * 1.1 = 1.089, should be capped at 1.0 + assert score <= 1.0 + + +def test_no_path_based_boost(): + """File path should NOT affect the score (path-based boosting was removed).""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + # "Helper" path should get same score as any other path + score_helper, _ = config.calculate_boosted_score( + base_score=0.70, + content_type="functions_classes", + file_path="app/code/Magezon/SomeModule/Helper/Data.php" + ) + + score_normal, _ = config.calculate_boosted_score( + base_score=0.70, + content_type="functions_classes", + file_path="app/code/Perspective/Akeneo/Model/VariantGroup.php" + ) + + assert score_helper == score_normal, "Path-based boosting should be removed" + + +def test_no_metadata_boost(): + """Metadata (semantic names, docstring, signature) should NOT affect the score.""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + # All metadata flags true + score_with_meta, _ = config.calculate_boosted_score( + base_score=0.70, + content_type="fallback", + has_semantic_names=True, + has_docstring=True, + has_signature=True + ) + + # No metadata + score_without_meta, _ = config.calculate_boosted_score( + base_score=0.70, + content_type="fallback", + has_semantic_names=False, + has_docstring=False, + has_signature=False + ) + + assert score_with_meta == score_without_meta, "Metadata should not affect score" + + +def test_singleton_get_scoring_config(): + """get_scoring_config should return a singleton.""" + from rag_pipeline.models.scoring_config import get_scoring_config, reset_scoring_config + reset_scoring_config() + + config1 = get_scoring_config() + config2 = get_scoring_config() + + assert config1 is config2 + + +def test_unknown_content_type_defaults_to_1(): + """Unknown content types should default to 1.0 boost.""" + from rag_pipeline.models.scoring_config import ScoringConfig, reset_scoring_config + reset_scoring_config() + config = ScoringConfig() + + score, _ = config.calculate_boosted_score( + base_score=0.80, + content_type="some_unknown_type" + ) + + assert abs(score - 0.80) < 0.001 # 0.80 * 1.0 = 0.80 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 5fe00baba163cfac4aac4045a29495488b90b9c3 Mon Sep 17 00:00:00 2001 From: rostislav Date: Thu, 19 Feb 2026 14:08:40 +0200 Subject: [PATCH 2/2] feat: Integrate CommentCommandRateLimitRepository into ProjectService for project deletion handling --- .../CommentCommandRateLimitRepository.java | 33 ++++++++++--------- .../project/service/ProjectService.java | 14 +++++++- 2 files changed, 31 insertions(+), 16 deletions(-) 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 35527873..8f845bb1 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 @@ -16,8 +16,8 @@ public interface CommentCommandRateLimitRepository extends JpaRepository findByProjectIdAndWindowStart( @Param("projectId") Long projectId, - @Param("windowStart") OffsetDateTime windowStart - ); + @Param("windowStart") OffsetDateTime windowStart); + @Query("SELECT r FROM CommentCommandRateLimit r WHERE r.project.id = :projectId ORDER BY r.windowStart DESC LIMIT 1") Optional findLatestByProjectId(@Param("projectId") Long projectId); @@ -26,27 +26,30 @@ Optional findByProjectIdAndWindowStart( int deleteOldRecords(@Param("cutoffTime") OffsetDateTime cutoffTime); @Query("SELECT COALESCE(SUM(r.commandCount), 0) FROM CommentCommandRateLimit r " + - "WHERE r.project.id = :projectId AND r.windowStart >= :windowStart") + "WHERE r.project.id = :projectId AND r.windowStart >= :windowStart") int countCommandsInWindow( @Param("projectId") Long projectId, - @Param("windowStart") OffsetDateTime windowStart - ); + @Param("windowStart") OffsetDateTime windowStart); /** - * Atomic upsert: increments command count if record exists, creates with count=1 if not. + * 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) + 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 - ); + @Param("windowStart") OffsetDateTime windowStart); + + @Modifying + @Query("DELETE FROM CommentCommandRateLimit r WHERE r.project.id = :projectId") + void deleteByProjectId(@Param("projectId") Long projectId); } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java index 87483995..ad527381 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java @@ -20,6 +20,7 @@ import org.rostilos.codecrow.core.persistence.repository.ai.AiConnectionRepository; import org.rostilos.codecrow.core.persistence.repository.project.AllowedCommandUserRepository; import org.rostilos.codecrow.core.persistence.repository.analysis.AnalysisLockRepository; +import org.rostilos.codecrow.core.persistence.repository.analysis.CommentCommandRateLimitRepository; import org.rostilos.codecrow.core.persistence.repository.analysis.RagIndexStatusRepository; import org.rostilos.codecrow.core.persistence.repository.branch.BranchFileRepository; import org.rostilos.codecrow.core.persistence.repository.branch.BranchIssueRepository; @@ -31,6 +32,7 @@ import org.rostilos.codecrow.core.persistence.repository.project.ProjectRepository; import org.rostilos.codecrow.core.persistence.repository.project.ProjectTokenRepository; import org.rostilos.codecrow.core.persistence.repository.pullrequest.PullRequestRepository; +import org.rostilos.codecrow.core.persistence.repository.rag.RagBranchIndexRepository; import org.rostilos.codecrow.core.persistence.repository.vcs.VcsConnectionRepository; import org.rostilos.codecrow.core.persistence.repository.vcs.VcsRepoBindingRepository; import org.rostilos.codecrow.core.persistence.repository.workspace.WorkspaceRepository; @@ -83,6 +85,8 @@ public class ProjectService implements IProjectService { private final QualityGateRepository qualityGateRepository; private final SiteSettingsProvider siteSettingsProvider; private final AllowedCommandUserRepository allowedCommandUserRepository; + private final RagBranchIndexRepository ragBranchIndexRepository; + private final CommentCommandRateLimitRepository commentCommandRateLimitRepository; public ProjectService( ProjectRepository projectRepository, @@ -105,7 +109,9 @@ public ProjectService( VcsClientProvider vcsClientProvider, QualityGateRepository qualityGateRepository, SiteSettingsProvider siteSettingsProvider, - AllowedCommandUserRepository allowedCommandUserRepository) { + AllowedCommandUserRepository allowedCommandUserRepository, + RagBranchIndexRepository ragBranchIndexRepository, + CommentCommandRateLimitRepository commentCommandRateLimitRepository) { this.projectRepository = projectRepository; this.vcsConnectionRepository = vcsConnectionRepository; this.tokenEncryptionService = tokenEncryptionService; @@ -127,6 +133,8 @@ public ProjectService( this.qualityGateRepository = qualityGateRepository; this.siteSettingsProvider = siteSettingsProvider; this.allowedCommandUserRepository = allowedCommandUserRepository; + this.ragBranchIndexRepository = ragBranchIndexRepository; + this.commentCommandRateLimitRepository = commentCommandRateLimitRepository; } @Transactional(readOnly = true) @@ -279,6 +287,8 @@ public void deleteProjectByNamespace(Long workspaceId, String namespace) { ragIndexStatusRepository.deleteByProjectId(projectId); prSummarizeCacheRepository.deleteByProjectId(projectId); allowedCommandUserRepository.deleteByProjectId(projectId); + ragBranchIndexRepository.deleteByProjectId(projectId); + commentCommandRateLimitRepository.deleteByProjectId(projectId); // Finally delete the project (cascade will handle vcsBinding and aiBinding) projectRepository.delete(project); @@ -343,6 +353,8 @@ public void deleteProject(Long workspaceId, Long projectId) { ragIndexStatusRepository.deleteByProjectId(projectId); prSummarizeCacheRepository.deleteByProjectId(projectId); allowedCommandUserRepository.deleteByProjectId(projectId); + ragBranchIndexRepository.deleteByProjectId(projectId); + commentCommandRateLimitRepository.deleteByProjectId(projectId); // Finally delete the project (cascade will handle vcsBinding and aiBinding) projectRepository.delete(project);