diff --git a/deployment/config/java-shared/application.properties.sample b/deployment/config/java-shared/application.properties.sample index c5697afd..b1d29de1 100644 --- a/deployment/config/java-shared/application.properties.sample +++ b/deployment/config/java-shared/application.properties.sample @@ -42,6 +42,9 @@ codecrow.frontend-url=http://localhost:8080 # Enable/disable email sending (set to false for development without SMTP) codecrow.email.enabled=true +# Email templates frontend base url +codecrow.frontend.url=http://localhost:8080 + # Sender email address and display name codecrow.email.from=noreply@codecrow.io codecrow.email.from-name=CodeCrow @@ -158,6 +161,42 @@ codecrow.github.app.slug= - The URL-friendly app name (e.g., "codecrow") codecrow.github.app.private-key-path= - Path to the private key .pem file codecrow.github.app.webhook-secret= - Webhook secret for verification +# ============================================================================ +# GitLab OAuth Application Configuration (for 1-click integration) +# ============================================================================ +# Create a GitLab OAuth Application: +# +# For GitLab.com: +# 1. Go to https://gitlab.com/-/user_settings/applications +# 2. Click "Add new application" +# +# For Self-Hosted GitLab: +# 1. Go to https://your-gitlab-instance.com/-/user_settings/applications +# 2. Click "Add new application" +# +# Application settings: +# - Name: CodeCrow (or your preferred name) +# - Redirect URI: ${codecrow.web.base.url}/api/integrations/gitlab/app/callback +# Example: https://server.example.com/api/integrations/gitlab/app/callback +# - Confidential: Yes (checked) +# - Scopes (check all): +# * api - Full API access +# * read_user - Read authenticated user's profile +# * read_repository - Read repositories +# * write_repository - Write to repositories (for comments) +# +# After creation: +# - Copy "Application ID" to codecrow.gitlab.oauth.client-id +# - Copy "Secret" to codecrow.gitlab.oauth.client-secret +# +# Note: The redirect URI must match EXACTLY (including trailing slashes) +# ============================================================================ +codecrow.gitlab.oauth.client-id= +codecrow.gitlab.oauth.client-secret= +# For self-hosted GitLab instances, set the base URL (leave empty for gitlab.com) +# Example: https://gitlab.mycompany.com +codecrow.gitlab.oauth.base-url= + # Google OAuth Configuration (for social login) # Create OAuth 2.0 Client ID in Google Cloud Console: # 1. Go to https://console.cloud.google.com/apis/credentials diff --git a/deployment/config/rag-pipeline/.env.sample b/deployment/config/rag-pipeline/.env.sample index e42661c5..17db344a 100644 --- a/deployment/config/rag-pipeline/.env.sample +++ b/deployment/config/rag-pipeline/.env.sample @@ -18,6 +18,8 @@ LLAMA_INDEX_CACHE_DIR=/tmp/.llama_index RAG_MAX_CHUNKS_PER_INDEX=70000 RAG_MAX_FILES_PER_INDEX=40000 +RAG_USE_AST_SPLITTER=true + # Alternative OpenRouter models (use full format with provider prefix): # OPENROUTER_MODEL=openai/text-embedding-3-large # Higher quality, more expensive # OPENROUTER_MODEL=openai/text-embedding-ada-002 # Legacy model diff --git a/docs/architecture/mcp-scaling-strategy.md b/docs/architecture/mcp-scaling-strategy.md deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend b/frontend index df0b8e52..62359446 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit df0b8e52b495612a1ab99c086de1cbbf8f7168d3 +Subproject commit 62359446f44e5554753f257064ccada85aad24c0 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 cebc0a74..8c4e2424 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 @@ -1,6 +1,7 @@ package org.rostilos.codecrow.analysisengine.dto.request.ai; import org.rostilos.codecrow.core.model.ai.AIProviderKey; +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisMode; import org.rostilos.codecrow.core.model.codeanalysis.AnalysisType; import java.util.List; @@ -24,4 +25,9 @@ public interface AiAnalysisRequest { 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 3908c2bb..a8244eca 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 @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.rostilos.codecrow.core.model.ai.AIConnection; import org.rostilos.codecrow.core.model.ai.AIProviderKey; +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisMode; import org.rostilos.codecrow.core.model.codeanalysis.AnalysisType; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; import org.rostilos.codecrow.core.model.project.ProjectVcsConnectionBinding; @@ -37,6 +38,12 @@ 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; protected AiAnalysisRequestImpl(Builder builder) { this.projectId = builder.projectId; @@ -62,6 +69,11 @@ protected AiAnalysisRequestImpl(Builder builder) { this.targetBranchName = builder.targetBranchName; this.vcsProvider = builder.vcsProvider; this.rawDiff = builder.rawDiff; + // Incremental analysis fields + this.analysisMode = builder.analysisMode != null ? builder.analysisMode : AnalysisMode.FULL; + this.deltaDiff = builder.deltaDiff; + this.previousCommitHash = builder.previousCommitHash; + this.currentCommitHash = builder.currentCommitHash; } public Long getProjectId() { @@ -153,6 +165,22 @@ public String getRawDiff() { return rawDiff; } + public AnalysisMode getAnalysisMode() { + return analysisMode; + } + + public String getDeltaDiff() { + return deltaDiff; + } + + public String getPreviousCommitHash() { + return previousCommitHash; + } + + public String getCurrentCommitHash() { + return currentCommitHash; + } + public static Builder builder() { return new Builder<>(); @@ -183,6 +211,11 @@ public static class Builder> { private String targetBranchName; private String vcsProvider; private String rawDiff; + // Incremental analysis fields + private AnalysisMode analysisMode; + private String deltaDiff; + private String previousCommitHash; + private String currentCommitHash; protected Builder() { } @@ -301,6 +334,26 @@ public T withRawDiff(String rawDiff) { return self(); } + public T withAnalysisMode(AnalysisMode analysisMode) { + this.analysisMode = analysisMode; + return self(); + } + + public T withDeltaDiff(String deltaDiff) { + this.deltaDiff = deltaDiff; + return self(); + } + + public T withPreviousCommitHash(String previousCommitHash) { + this.previousCommitHash = previousCommitHash; + return self(); + } + + public T withCurrentCommitHash(String currentCommitHash) { + this.currentCommitHash = currentCommitHash; + return self(); + } + public AiAnalysisRequestImpl build() { return new AiAnalysisRequestImpl(this); } diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/BranchProcessRequest.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/BranchProcessRequest.java index a76bcb63..2f8e51af 100644 --- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/BranchProcessRequest.java +++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/dto/request/processor/BranchProcessRequest.java @@ -17,6 +17,13 @@ public class BranchProcessRequest implements AnalysisProcessRequest { @NotBlank(message = "Specify analysis type") public AnalysisType analysisType; + /** + * Optional: The PR number that triggered this branch analysis (for pullrequest:fulfilled events). + * When provided, the PR diff will be used instead of commit diff to get ALL changed files. + * This ensures all files from the original PR are analyzed, not just merge commit changes. + */ + public Long sourcePrNumber; + /** * Optional: ZIP archive of the repository for first-time full indexing in RAG pipeline. * If provided, the entire repository will be indexed. @@ -38,6 +45,8 @@ public String getCommitHash() { public AnalysisType getAnalysisType() { return analysisType; } + public Long getSourcePrNumber() { return sourcePrNumber; } + public byte[] getArchive() { return archive; } diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessor.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessor.java index e7fd4e64..2a9238d0 100644 --- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessor.java +++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/processor/analysis/BranchAnalysisProcessor.java @@ -155,19 +155,32 @@ public Map process(BranchProcessRequest request, Consumer changedFiles = parseFilePathsFromDiff(rawDiff); @@ -181,7 +194,18 @@ public Map process(BranchProcessRequest request, Consumer issueData, Branch branch, String commitHash) { - Object issueIdFromAi = issueData.get("issueId"); + Object issueIdFromAi = issueData.get("id"); Long actualIssueId = null; if (issueIdFromAi != null) { diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsOperationsService.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsOperationsService.java index 68486573..03655ad6 100644 --- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsOperationsService.java +++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/service/vcs/VcsOperationsService.java @@ -29,6 +29,33 @@ public interface VcsOperationsService { */ String getCommitDiff(OkHttpClient client, String workspace, String repoSlug, String commitHash) throws IOException; + /** + * Fetches the raw diff for a pull request. + * This returns ALL files changed in the PR, not just the merge commit. + * + * @param client authorized HTTP client + * @param workspace workspace or team/organization slug + * @param repoSlug repository slug + * @param prNumber pull request number + * @return raw unified diff as returned by VCS API + * @throws IOException on network / parsing errors + */ + String getPullRequestDiff(OkHttpClient client, String workspace, String repoSlug, String prNumber) throws IOException; + + /** + * Fetches the diff between two commits (delta diff for incremental analysis). + * This is used to get only the changes made since the last analyzed commit. + * + * @param client authorized HTTP client + * @param workspace workspace or team/organization slug + * @param repoSlug repository slug + * @param baseCommitHash the base commit (previously analyzed commit) + * @param headCommitHash the head commit (current commit to analyze) + * @return raw unified diff between the two commits + * @throws IOException on network / parsing errors + */ + String getCommitRangeDiff(OkHttpClient client, String workspace, String repoSlug, String baseCommitHash, String headCommitHash) throws IOException; + /** * Checks if a file exists in the specified branch. * diff --git a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/DiffContentFilter.java b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/DiffContentFilter.java index 0dee8129..88923a9a 100644 --- a/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/DiffContentFilter.java +++ b/java-ecosystem/libs/analysis-engine/src/main/java/org/rostilos/codecrow/analysisengine/util/DiffContentFilter.java @@ -12,7 +12,7 @@ * Utility class for filtering large files from diff content. * * Mirrors the behavior of org.rostilos.codecrow.mcp.filter.LargeContentFilter - * from bitbucket-mcp module to ensure consistent filtering across the system. + * from vcs-mcp module to ensure consistent filtering across the system. * TODO: reuse it in mcp, DRY pattern + use threshold bytes from configuration */ public class DiffContentFilter { diff --git a/java-ecosystem/libs/core/src/main/java/module-info.java b/java-ecosystem/libs/core/src/main/java/module-info.java index 99ed9f6b..f5c849c8 100644 --- a/java-ecosystem/libs/core/src/main/java/module-info.java +++ b/java-ecosystem/libs/core/src/main/java/module-info.java @@ -43,10 +43,12 @@ opens org.rostilos.codecrow.core.persistence.repository.ai to spring.core, spring.beans, spring.context; exports org.rostilos.codecrow.core.persistence.repository.vcs; exports org.rostilos.codecrow.core.model.vcs.config.github; + exports org.rostilos.codecrow.core.model.vcs.config.gitlab; exports org.rostilos.codecrow.core.model.vcs.config.cloud; exports org.rostilos.codecrow.core.model.vcs.config; exports org.rostilos.codecrow.core.dto.bitbucket; exports org.rostilos.codecrow.core.dto.github; + exports org.rostilos.codecrow.core.dto.gitlab; exports org.rostilos.codecrow.core.dto.ai; exports org.rostilos.codecrow.core.dto.user; exports org.rostilos.codecrow.core.dto.project; diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/analysis/issue/IssueDTO.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/analysis/issue/IssueDTO.java index 037abc87..c2733281 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/analysis/issue/IssueDTO.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/analysis/issue/IssueDTO.java @@ -20,12 +20,19 @@ public record IssueDTO ( String pullRequestId, String status, // open|resolved|ignored OffsetDateTime createdAt, - String issueCategory + String issueCategory, + // Detection info - where was this issue first found + Long analysisId, + Long prNumber, + String commitHash, + OffsetDateTime detectedAt ) { public static IssueDTO fromEntity(CodeAnalysisIssue issue) { String categoryStr = issue.getIssueCategory() != null ? issue.getIssueCategory().name() : IssueCategory.CODE_QUALITY.name(); + + var analysis = issue.getAnalysis(); return new IssueDTO( String.valueOf(issue.getId()), categoryStr, @@ -37,11 +44,16 @@ public static IssueDTO fromEntity(CodeAnalysisIssue issue) { issue.getLineNumber(), null, null, - issue.getAnalysis() == null ? null : issue.getAnalysis().getBranchName(), - issue.getAnalysis() == null || issue.getAnalysis().getPrNumber() == null ? null : String.valueOf(issue.getAnalysis().getPrNumber()), + analysis == null ? null : analysis.getBranchName(), + analysis == null || analysis.getPrNumber() == null ? null : String.valueOf(analysis.getPrNumber()), issue.isResolved() ? "resolved" : "open", issue.getCreatedAt(), - categoryStr + categoryStr, + // Detection info + analysis != null ? analysis.getId() : null, + analysis != null ? analysis.getPrNumber() : null, + analysis != null ? analysis.getCommitHash() : null, + issue.getCreatedAt() ); } } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/gitlab/GitLabDTO.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/gitlab/GitLabDTO.java new file mode 100644 index 00000000..106c77a2 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/dto/gitlab/GitLabDTO.java @@ -0,0 +1,54 @@ +package org.rostilos.codecrow.core.dto.gitlab; + +import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.EVcsSetupStatus; +import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig; + +import java.time.LocalDateTime; + +public record GitLabDTO( + Long id, + String connectionName, + String groupId, + int repoCount, + EVcsSetupStatus setupStatus, + Boolean hasAccessToken, + LocalDateTime updatedAt, + EVcsConnectionType connectionType, + String repositoryPath +) { + public static GitLabDTO fromVcsConnection(VcsConnection vcsConnection) { + if (vcsConnection.getProviderType() != EVcsProvider.GITLAB) { + throw new IllegalArgumentException("Expected GitLab connection"); + } + + if (vcsConnection.getConnectionType() == EVcsConnectionType.APP || vcsConnection.getConfiguration() == null) { + return new GitLabDTO( + vcsConnection.getId(), + vcsConnection.getConnectionName(), + vcsConnection.getExternalWorkspaceSlug(), + vcsConnection.getRepoCount(), + vcsConnection.getSetupStatus(), + vcsConnection.getAccessToken() != null && !vcsConnection.getAccessToken().isBlank(), + vcsConnection.getUpdatedAt(), + vcsConnection.getConnectionType(), + vcsConnection.getRepositoryPath() + ); + } + + GitLabConfig config = (GitLabConfig) vcsConnection.getConfiguration(); + return new GitLabDTO( + vcsConnection.getId(), + vcsConnection.getConnectionName(), + config.groupId(), + vcsConnection.getRepoCount(), + vcsConnection.getSetupStatus(), + config.accessToken() != null && !config.accessToken().isBlank(), + vcsConnection.getUpdatedAt(), + vcsConnection.getConnectionType(), + vcsConnection.getRepositoryPath() + ); + } +} 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 d38cbbfa..cb8f9460 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 @@ -25,7 +25,8 @@ public record ProjectDTO( Boolean prAnalysisEnabled, Boolean branchAnalysisEnabled, String installationMethod, - CommentCommandsConfigDTO commentCommandsConfig + CommentCommandsConfigDTO commentCommandsConfig, + Boolean webhooksConfigured ) { public static ProjectDTO fromProject(Project project) { Long vcsConnectionId = null; @@ -112,6 +113,12 @@ else if (project.getVcsRepoBinding() != null) { commentCommandsConfigDTO = CommentCommandsConfigDTO.fromConfig(config.getCommentCommandsConfig()); } + // Get webhooksConfigured from VcsRepoBinding + Boolean webhooksConfigured = null; + if (project.getVcsRepoBinding() != null) { + webhooksConfigured = project.getVcsRepoBinding().isWebhooksConfigured(); + } + return new ProjectDTO( project.getId(), project.getName(), @@ -131,7 +138,8 @@ else if (project.getVcsRepoBinding() != null) { prAnalysisEnabled, branchAnalysisEnabled, installationMethod, - commentCommandsConfigDTO + commentCommandsConfigDTO, + webhooksConfigured ); } @@ -157,18 +165,25 @@ public record CommentCommandsConfigDTO( Integer rateLimit, Integer rateLimitWindowMinutes, Boolean allowPublicRepoCommands, - List allowedCommands + List allowedCommands, + String authorizationMode, + Boolean allowPrAuthor ) { public static CommentCommandsConfigDTO fromConfig(ProjectConfig.CommentCommandsConfig config) { if (config == null) { - return new CommentCommandsConfigDTO(false, null, null, null, null); + return new CommentCommandsConfigDTO(false, null, null, null, null, null, null); } + String authMode = config.authorizationMode() != null + ? config.authorizationMode().name() + : ProjectConfig.CommentCommandsConfig.DEFAULT_AUTHORIZATION_MODE.name(); return new CommentCommandsConfigDTO( config.enabled(), config.rateLimit(), config.rateLimitWindowMinutes(), config.allowPublicRepoCommands(), - config.allowedCommands() + config.allowedCommands(), + authMode, + config.allowPrAuthor() ); } } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIProviderKey.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIProviderKey.java index b77e4955..d86ec995 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIProviderKey.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/ai/AIProviderKey.java @@ -3,5 +3,6 @@ public enum AIProviderKey { OPENAI, OPENROUTER, - ANTHROPIC + ANTHROPIC, + GOOGLE, } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/codeanalysis/AnalysisMode.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/codeanalysis/AnalysisMode.java new file mode 100644 index 00000000..bf0165b6 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/codeanalysis/AnalysisMode.java @@ -0,0 +1,30 @@ +package org.rostilos.codecrow.core.model.codeanalysis; + +/** + * Defines the analysis mode for PR code reviews. + * + * FULL - Analyzes the complete PR diff (source branch vs target branch) + * INCREMENTAL - Analyzes only the delta diff (changes since last analyzed commit) + */ +public enum AnalysisMode { + /** + * Full PR analysis - analyzes entire diff from source to target branch. + * Used for: + * - First review of a PR + * - When delta is too large (>50% of files changed) + * - When no previous analysis exists + */ + FULL, + + /** + * Incremental analysis - analyzes only changes since last review. + * Used for: + * - Subsequent PR updates + * - When previous analysis exists with a different commit + * Benefits: + * - Reduced context size + * - Faster analysis + * - Focus on new/changed code + */ + INCREMENTAL +} diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/Job.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/Job.java index 88bb4af7..a939d2f2 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/Job.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/Job.java @@ -156,8 +156,15 @@ public void cancel() { this.completedAt = OffsetDateTime.now(); } + public void skip(String reason) { + this.status = JobStatus.SKIPPED; + this.completedAt = OffsetDateTime.now(); + this.errorMessage = reason; + this.progress = 100; + } + public boolean isTerminal() { - return status == JobStatus.COMPLETED || status == JobStatus.FAILED || status == JobStatus.CANCELLED; + return status == JobStatus.COMPLETED || status == JobStatus.FAILED || status == JobStatus.CANCELLED || status == JobStatus.SKIPPED; } public JobLog addLog(JobLogLevel level, String message) { diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/JobStatus.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/JobStatus.java index 4d85e084..0ca2e211 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/JobStatus.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/job/JobStatus.java @@ -7,5 +7,6 @@ public enum JobStatus { COMPLETED, FAILED, CANCELLED, - WAITING + WAITING, + SKIPPED } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/AllowedCommandUser.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/AllowedCommandUser.java new file mode 100644 index 00000000..fd324d8b --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/project/AllowedCommandUser.java @@ -0,0 +1,180 @@ +package org.rostilos.codecrow.core.model.project; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Entity representing a VCS user who is allowed to execute CodeCrow commands. + * Used when CommandAuthorizationMode is set to ALLOWED_USERS_ONLY. + * + * This allows workspace admins to maintain a list of specific users + * who can trigger CodeCrow analysis commands via PR comments. + */ +@Entity +@Table(name = "allowed_command_users", + uniqueConstraints = @UniqueConstraint( + columnNames = {"project_id", "vcs_user_id"}, + name = "uk_allowed_command_user_project_vcs" + ), + indexes = { + @Index(name = "idx_allowed_cmd_user_project", columnList = "project_id"), + @Index(name = "idx_allowed_cmd_user_vcs_id", columnList = "vcs_user_id") + } +) +public class AllowedCommandUser { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + /** + * VCS provider (BITBUCKET_CLOUD, GITHUB, etc.) + */ + @Enumerated(EnumType.STRING) + @Column(name = "vcs_provider", nullable = false) + private EVcsProvider vcsProvider; + + /** + * Unique user ID from the VCS provider. + * - Bitbucket: Account UUID (e.g., "{abc123-def456-...}") + * - GitHub: User ID (numeric, stored as string) + */ + @Column(name = "vcs_user_id", nullable = false) + private String vcsUserId; + + /** + * VCS username for display purposes. + * - Bitbucket: Account nickname + * - GitHub: Login username + */ + @Column(name = "vcs_username", nullable = false) + private String vcsUsername; + + /** + * Display name from VCS profile (if available). + */ + @Column(name = "display_name") + private String displayName; + + /** + * Avatar URL from VCS profile (if available). + */ + @Column(name = "avatar_url") + private String avatarUrl; + + /** + * User's permission level on the repository (if known). + * - Bitbucket: "read", "write", "admin" + * - GitHub: "read", "triage", "write", "maintain", "admin" + */ + @Column(name = "repo_permission") + private String repoPermission; + + /** + * Whether this user was synced from VCS collaborators list + * or manually added by workspace admin. + */ + @Column(name = "synced_from_vcs", nullable = false) + private boolean syncedFromVcs = false; + + /** + * Whether this user is currently active/enabled. + * Allows "soft disable" without removing the user. + */ + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + /** + * Who added this user (CodeCrow user ID or "SYSTEM" for sync). + */ + @Column(name = "added_by") + private String addedBy; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + /** + * Last time this user was synced from VCS. + */ + @Column(name = "last_synced_at") + private OffsetDateTime lastSyncedAt; + + // Constructors + public AllowedCommandUser() {} + + public AllowedCommandUser(Project project, EVcsProvider vcsProvider, + String vcsUserId, String vcsUsername) { + this.project = project; + this.vcsProvider = vcsProvider; + this.vcsUserId = vcsUserId; + this.vcsUsername = vcsUsername; + } + + // Getters and Setters + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public Project getProject() { return project; } + public void setProject(Project project) { this.project = project; } + + public EVcsProvider getVcsProvider() { return vcsProvider; } + public void setVcsProvider(EVcsProvider vcsProvider) { this.vcsProvider = vcsProvider; } + + public String getVcsUserId() { return vcsUserId; } + public void setVcsUserId(String vcsUserId) { this.vcsUserId = vcsUserId; } + + public String getVcsUsername() { return vcsUsername; } + public void setVcsUsername(String vcsUsername) { this.vcsUsername = vcsUsername; } + + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + + public String getAvatarUrl() { return avatarUrl; } + public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } + + public String getRepoPermission() { return repoPermission; } + public void setRepoPermission(String repoPermission) { this.repoPermission = repoPermission; } + + public boolean isSyncedFromVcs() { return syncedFromVcs; } + public void setSyncedFromVcs(boolean syncedFromVcs) { this.syncedFromVcs = syncedFromVcs; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public String getAddedBy() { return addedBy; } + public void setAddedBy(String addedBy) { this.addedBy = addedBy; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + + public OffsetDateTime getLastSyncedAt() { return lastSyncedAt; } + public void setLastSyncedAt(OffsetDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; } + + @Override + public String toString() { + return "AllowedCommandUser{" + + "id=" + id + + ", vcsProvider=" + vcsProvider + + ", vcsUserId='" + vcsUserId + '\'' + + ", vcsUsername='" + vcsUsername + '\'' + + ", enabled=" + enabled + + '}'; + } +} 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 f85a2a48..c188f3ff 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 @@ -208,6 +208,16 @@ public RagConfig(boolean enabled, String branch) { } } + /** + * Authorization mode for command execution. + * Controls who can execute CodeCrow commands via PR comments. + */ + public enum CommandAuthorizationMode { + ANYONE, + ALLOWED_USERS_ONLY, + PR_AUTHOR_ONLY + } + /** * Configuration for comment-triggered commands (/codecrow analyze, summarize, ask). * Only available when project is connected via App integration (Bitbucket App or GitHub App). @@ -217,6 +227,8 @@ public RagConfig(boolean enabled, String branch) { * @param rateLimitWindowMinutes Duration of the rate limit window in minutes * @param allowPublicRepoCommands Whether to allow commands on public repositories (requires high privilege users) * @param allowedCommands List of allowed command types (null = all commands allowed) + * @param authorizationMode Controls who can execute commands (default: REPO_WRITE_ACCESS) + * @param allowPrAuthor If true, PR author can always execute commands regardless of mode */ @JsonIgnoreProperties(ignoreUnknown = true) public record CommentCommandsConfig( @@ -224,20 +236,25 @@ public record CommentCommandsConfig( @JsonProperty("rateLimit") Integer rateLimit, @JsonProperty("rateLimitWindowMinutes") Integer rateLimitWindowMinutes, @JsonProperty("allowPublicRepoCommands") Boolean allowPublicRepoCommands, - @JsonProperty("allowedCommands") List allowedCommands + @JsonProperty("allowedCommands") List allowedCommands, + @JsonProperty("authorizationMode") CommandAuthorizationMode authorizationMode, + @JsonProperty("allowPrAuthor") Boolean allowPrAuthor ) { public static final int DEFAULT_RATE_LIMIT = 10; public static final int DEFAULT_RATE_LIMIT_WINDOW_MINUTES = 60; + public static final CommandAuthorizationMode DEFAULT_AUTHORIZATION_MODE = CommandAuthorizationMode.ANYONE; /** - * Default constructor - commands are ENABLED by default. + * Default constructor - commands are ENABLED by default with ANYONE authorization. */ public CommentCommandsConfig() { - this(true, DEFAULT_RATE_LIMIT, DEFAULT_RATE_LIMIT_WINDOW_MINUTES, false, null); + this(true, DEFAULT_RATE_LIMIT, DEFAULT_RATE_LIMIT_WINDOW_MINUTES, false, null, + DEFAULT_AUTHORIZATION_MODE, true); } public CommentCommandsConfig(boolean enabled) { - this(enabled, DEFAULT_RATE_LIMIT, DEFAULT_RATE_LIMIT_WINDOW_MINUTES, false, null); + this(enabled, DEFAULT_RATE_LIMIT, DEFAULT_RATE_LIMIT_WINDOW_MINUTES, false, null, + DEFAULT_AUTHORIZATION_MODE, true); } /** @@ -269,5 +286,19 @@ public boolean isCommandAllowed(String commandType) { public boolean allowsPublicRepoCommands() { return allowPublicRepoCommands != null && allowPublicRepoCommands; } + + /** + * Get the effective authorization mode. + */ + public CommandAuthorizationMode getEffectiveAuthorizationMode() { + return authorizationMode != null ? authorizationMode : DEFAULT_AUTHORIZATION_MODE; + } + + /** + * Check if PR author is always allowed to execute commands. + */ + public boolean isPrAuthorAllowed() { + return allowPrAuthor == null || allowPrAuthor; + } } } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/EVcsConnectionType.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/EVcsConnectionType.java index d2b1e95d..bf31c0dd 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/EVcsConnectionType.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/EVcsConnectionType.java @@ -6,26 +6,22 @@ */ public enum EVcsConnectionType { // Bitbucket Cloud connection types - OAUTH_MANUAL, // User-created OAuth consumer (per-user access) + OAUTH_MANUAL, // User-created OAuth consumer (workspace-level access) APP, // OAuth-based app installation (per-user access) CONNECT_APP, // Atlassian Connect App (workspace-level access) - - /** - * @deprecated Forge App approach abandoned due to workspace-level webhook limitations. - * Kept for backward compatibility with existing database records. - * Use CONNECT_APP or OAUTH_MANUAL instead. - */ - @Deprecated - FORGE_APP, // Atlassian Forge App (deprecated - do not use) - + // GitHub connection types GITHUB_APP, // GitHub App installation (org/account level) OAUTH_APP, // GitHub OAuth App (per-user access) - // GitLab connection types (future) + // GitLab connection types PERSONAL_TOKEN, APPLICATION, + // Repository-scoped token (single repo access) + // Works with GitLab Project Access Tokens, GitHub Fine-grained PATs, Bitbucket Repo Tokens + REPOSITORY_TOKEN, + // Bitbucket Server / Data Center (future) ACCESS_TOKEN } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java index fd620a54..383ea9b1 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/VcsConnection.java @@ -80,6 +80,16 @@ public class VcsConnection { @Column(name = "installation_id", length = 128) private String installationId; + /** + * Full repository path for REPOSITORY_TOKEN connections. + * For GitLab: "namespace/project" or project ID + * For GitHub: "owner/repo" + * For Bitbucket: "workspace/repo-slug" + * Only set when connectionType = REPOSITORY_TOKEN + */ + @Column(name = "repository_path", length = 512) + private String repositoryPath; + @Column(name = "access_token", length = 1024) private String accessToken; @@ -186,6 +196,14 @@ public void setInstallationId(String installationId) { this.installationId = installationId; } + public String getRepositoryPath() { + return repositoryPath; + } + + public void setRepositoryPath(String repositoryPath) { + this.repositoryPath = repositoryPath; + } + public String getAccessToken() { return accessToken; } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/VcsConnectionConfig.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/VcsConnectionConfig.java index d3e3bff1..4504ca83 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/VcsConnectionConfig.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/VcsConnectionConfig.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.rostilos.codecrow.core.model.vcs.config.cloud.BitbucketCloudConfig; import org.rostilos.codecrow.core.model.vcs.config.github.GitHubConfig; +import org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig; @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -12,7 +13,8 @@ ) @JsonSubTypes({ @JsonSubTypes.Type(value = BitbucketCloudConfig.class, name = "bitbucket"), - @JsonSubTypes.Type(value = GitHubConfig.class, name = "github") + @JsonSubTypes.Type(value = GitHubConfig.class, name = "github"), + @JsonSubTypes.Type(value = GitLabConfig.class, name = "gitlab") }) -public sealed interface VcsConnectionConfig permits GitHubConfig, BitbucketCloudConfig { +public sealed interface VcsConnectionConfig permits GitHubConfig, BitbucketCloudConfig, GitLabConfig { } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/gitlab/GitLabConfig.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/gitlab/GitLabConfig.java new file mode 100644 index 00000000..08d53f31 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/model/vcs/config/gitlab/GitLabConfig.java @@ -0,0 +1,33 @@ +package org.rostilos.codecrow.core.model.vcs.config.gitlab; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import org.rostilos.codecrow.core.model.vcs.config.VcsConnectionConfig; + +import java.util.List; + +/** + * GitLab connection configuration. + * Supports both GitLab.com and self-hosted GitLab instances. + */ +@JsonTypeName("gitlab") +public record GitLabConfig( + String accessToken, + String groupId, + List allowedRepos, + String baseUrl // For self-hosted GitLab instances (e.g., "https://gitlab.mycompany.com") +) implements VcsConnectionConfig { + + /** + * Constructor for backward compatibility (without baseUrl). + */ + public GitLabConfig(String accessToken, String groupId, List allowedRepos) { + this(accessToken, groupId, allowedRepos, null); + } + + /** + * Returns the effective base URL (defaults to gitlab.com if not specified). + */ + public String effectiveBaseUrl() { + return (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "https://gitlab.com"; + } +} diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/branch/BranchIssueRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/branch/BranchIssueRepository.java index ff2c61bb..50475879 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/branch/BranchIssueRepository.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/branch/BranchIssueRepository.java @@ -1,6 +1,8 @@ package org.rostilos.codecrow.core.persistence.repository.branch; import org.rostilos.codecrow.core.model.branch.BranchIssue; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -53,7 +55,51 @@ List findUnresolvedByBranchIdAndFilePaths( @Query("SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId AND bi.resolved = false") long countUnresolvedByBranchId(@Param("branchId") Long branchId); + @Query("SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId AND bi.resolved = true") + long countResolvedByBranchId(@Param("branchId") Long branchId); + + @Query("SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId") + long countAllByBranchId(@Param("branchId") Long branchId); + + @Query(value = "SELECT bi FROM BranchIssue bi " + + "JOIN FETCH bi.codeAnalysisIssue cai " + + "WHERE bi.branch.id = :branchId AND bi.resolved = false " + + "ORDER BY cai.id DESC", + countQuery = "SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId AND bi.resolved = false") + Page findUnresolvedByBranchIdPaged( + @Param("branchId") Long branchId, + Pageable pageable + ); + + @Query(value = "SELECT bi FROM BranchIssue bi " + + "JOIN FETCH bi.codeAnalysisIssue cai " + + "WHERE bi.branch.id = :branchId AND bi.resolved = true " + + "ORDER BY cai.id DESC", + countQuery = "SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId AND bi.resolved = true") + Page findResolvedByBranchIdPaged( + @Param("branchId") Long branchId, + Pageable pageable + ); + + @Query(value = "SELECT bi FROM BranchIssue bi " + + "JOIN FETCH bi.codeAnalysisIssue cai " + + "WHERE bi.branch.id = :branchId " + + "ORDER BY cai.id DESC", + countQuery = "SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId") + Page findAllByBranchIdPaged( + @Param("branchId") Long branchId, + Pageable pageable + ); + @Modifying @Query("DELETE FROM BranchIssue bi WHERE bi.branch.id IN (SELECT b.id FROM Branch b WHERE b.project.id = :projectId)") void deleteByProjectId(@Param("projectId") Long projectId); + + // Base query for filtered branch issues - filtering is done in service layer + @Query(value = "SELECT bi FROM BranchIssue bi " + + "JOIN FETCH bi.codeAnalysisIssue cai " + + "WHERE bi.branch.id = :branchId " + + "ORDER BY cai.id DESC", + countQuery = "SELECT COUNT(bi) FROM BranchIssue bi WHERE bi.branch.id = :branchId") + List findAllByBranchIdWithIssues(@Param("branchId") Long branchId); } diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/project/AllowedCommandUserRepository.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/project/AllowedCommandUserRepository.java new file mode 100644 index 00000000..4459cc41 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/persistence/repository/project/AllowedCommandUserRepository.java @@ -0,0 +1,106 @@ +package org.rostilos.codecrow.core.persistence.repository.project; + +import org.rostilos.codecrow.core.model.project.AllowedCommandUser; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository for managing allowed command users. + */ +@Repository +public interface AllowedCommandUserRepository extends JpaRepository { + + /** + * Find all allowed users for a project. + */ + List findByProjectId(Long projectId); + + /** + * Find all enabled allowed users for a project. + */ + List findByProjectIdAndEnabledTrue(Long projectId); + + /** + * Find a specific user by project and VCS user ID. + */ + Optional findByProjectIdAndVcsUserId(Long projectId, String vcsUserId); + + /** + * Find a specific user by project and VCS username. + */ + Optional findByProjectIdAndVcsUsername(Long projectId, String vcsUsername); + + /** + * Check if a user is allowed for a project. + */ + boolean existsByProjectIdAndVcsUserIdAndEnabledTrue(Long projectId, String vcsUserId); + + /** + * Check if a user (by username) is allowed for a project. + */ + boolean existsByProjectIdAndVcsUsernameAndEnabledTrue(Long projectId, String vcsUsername); + + /** + * Delete all users for a project. + */ + @Modifying + @Query("DELETE FROM AllowedCommandUser u WHERE u.project.id = :projectId") + void deleteByProjectId(@Param("projectId") Long projectId); + + /** + * Delete synced users for a project (to refresh from VCS). + */ + @Modifying + @Query("DELETE FROM AllowedCommandUser u WHERE u.project.id = :projectId AND u.syncedFromVcs = true") + void deleteSyncedByProjectId(@Param("projectId") Long projectId); + + /** + * Mark all synced users as disabled (for refresh). + */ + @Modifying + @Query("UPDATE AllowedCommandUser u SET u.enabled = false WHERE u.project.id = :projectId AND u.syncedFromVcs = true") + void disableSyncedByProjectId(@Param("projectId") Long projectId); + + /** + * Update last synced timestamp for synced users. + */ + @Modifying + @Query("UPDATE AllowedCommandUser u SET u.lastSyncedAt = :timestamp WHERE u.project.id = :projectId AND u.syncedFromVcs = true") + void updateLastSyncedAt(@Param("projectId") Long projectId, @Param("timestamp") OffsetDateTime timestamp); + + /** + * Count allowed users for a project. + */ + long countByProjectId(Long projectId); + + /** + * Count enabled allowed users for a project. + */ + long countByProjectIdAndEnabledTrue(Long projectId); + + /** + * Find users by VCS provider for a project. + */ + List findByProjectIdAndVcsProvider(Long projectId, EVcsProvider vcsProvider); + + /** + * Delete a specific user from a project. + */ + @Modifying + @Query("DELETE FROM AllowedCommandUser u WHERE u.project.id = :projectId AND u.vcsUserId = :vcsUserId") + void deleteByProjectIdAndVcsUserId(@Param("projectId") Long projectId, @Param("vcsUserId") String vcsUserId); + + /** + * Check if user exists (regardless of enabled status). + */ + boolean existsByProjectIdAndVcsUserId(Long projectId, String vcsUserId); +} diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/BranchService.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/BranchService.java index 9bc27fab..891ea3f4 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/BranchService.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/BranchService.java @@ -52,12 +52,19 @@ public BranchStats getBranchStats(Long projectId, String branchName) { List mostProblematicFiles = getMostProblematicFiles(issues); + // Calculate counts directly from issues list for accuracy + long openCount = issues.stream().filter(i -> !i.isResolved()).count(); + long resolvedCount = issues.stream().filter(BranchIssue::isResolved).count(); + long highCount = issues.stream().filter(i -> i.getSeverity() == org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity.HIGH && !i.isResolved()).count(); + long mediumCount = issues.stream().filter(i -> i.getSeverity() == org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity.MEDIUM && !i.isResolved()).count(); + long lowCount = issues.stream().filter(i -> i.getSeverity() == org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity.LOW && !i.isResolved()).count(); + return new BranchStats( - branch.getTotalIssues(), - branch.getHighSeverityCount(), - branch.getMediumSeverityCount(), - branch.getLowSeverityCount(), - branch.getResolvedCount(), + openCount, + highCount, + mediumCount, + lowCount, + resolvedCount, 1L, mostProblematicFiles, branch.getUpdatedAt(), diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java index d7fdacf5..2f6dfa6d 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/CodeAnalysisService.java @@ -176,6 +176,10 @@ public int getMaxAnalysisPrVersion(Long projectId, Long prNumber) { return codeAnalysisRepository.findMaxPrVersion(projectId, prNumber).orElse(0); } + public Optional findAnalysisByProjectAndPrNumberAndVersion(Long projectId, Long prNumber, int prVersion) { + return codeAnalysisRepository.findByProjectIdAndPrNumberAndPrVersion(projectId, prNumber, prVersion); + } + private CodeAnalysisIssue createIssueFromData(Map issueData, String issueKey) { try { CodeAnalysisIssue issue = new CodeAnalysisIssue(); diff --git a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/JobService.java b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/JobService.java index 1bf971c8..f239b9c1 100644 --- a/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/JobService.java +++ b/java-ecosystem/libs/core/src/main/java/org/rostilos/codecrow/core/service/JobService.java @@ -294,6 +294,33 @@ public Job cancelJob(Job job) { return job; } + /** + * Skip a job (e.g., due to branch pattern settings). + */ + @Transactional + public Job skipJob(Job job, String reason) { + job.skip(reason); + job = jobRepository.save(job); + addLog(job, JobLogLevel.INFO, "skipped", reason); + notifyJobComplete(job); + return job; + } + + /** + * Delete an ignored job without saving to DB history. + * Used for jobs that were created but then determined to be unnecessary + * (e.g., branch not matching pattern, PR analysis disabled). + * This prevents DB clutter from ignored webhooks. + */ + @Transactional + public void deleteIgnoredJob(Job job, String reason) { + log.info("Deleting ignored job {} ({}): {}", job.getExternalId(), job.getJobType(), reason); + // Delete any logs first (foreign key constraint) + jobLogRepository.deleteByJobId(job.getId()); + // Delete the job + jobRepository.delete(job); + } + /** * Update job progress. */ diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_allowed_command_users_table.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_allowed_command_users_table.sql new file mode 100644 index 00000000..c3730562 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_allowed_command_users_table.sql @@ -0,0 +1,31 @@ +-- Create table for allowed command users +CREATE TABLE IF NOT EXISTS allowed_command_users ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + vcs_provider VARCHAR(50) NOT NULL, + vcs_user_id VARCHAR(255) NOT NULL, + vcs_username VARCHAR(255), + display_name VARCHAR(255), + avatar_url VARCHAR(1024), + enabled BOOLEAN DEFAULT TRUE, + synced_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_allowed_users_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + CONSTRAINT uk_allowed_users_project_vcs_user UNIQUE (project_id, vcs_provider, vcs_user_id) +); + +-- Create indexes for efficient lookups +CREATE INDEX IF NOT EXISTS idx_allowed_users_project_id ON allowed_command_users(project_id); +CREATE INDEX IF NOT EXISTS idx_allowed_users_project_enabled ON allowed_command_users(project_id, enabled); +CREATE INDEX IF NOT EXISTS idx_allowed_users_vcs_user_id ON allowed_command_users(project_id, vcs_user_id); +CREATE INDEX IF NOT EXISTS idx_allowed_users_vcs_username ON allowed_command_users(project_id, vcs_username); + +-- Add comments +COMMENT ON TABLE allowed_command_users IS 'Stores VCS users allowed to execute comment commands for projects with ALLOWED_USERS authorization mode'; +COMMENT ON COLUMN allowed_command_users.vcs_provider IS 'VCS provider type (BITBUCKET_CLOUD, GITHUB, etc.)'; +COMMENT ON COLUMN allowed_command_users.vcs_user_id IS 'User ID from the VCS provider'; +COMMENT ON COLUMN allowed_command_users.vcs_username IS 'Username from the VCS provider'; +COMMENT ON COLUMN allowed_command_users.enabled IS 'Whether this user is currently allowed to execute commands'; +COMMENT ON COLUMN allowed_command_users.synced_at IS 'When this user was last synced from VCS'; diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_gitlab_provider_type.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_gitlab_provider_type.sql new file mode 100644 index 00000000..d46b238a --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_gitlab_provider_type.sql @@ -0,0 +1,14 @@ +-- Migration: Add GITLAB to vcs_connection provider_type CHECK constraint +-- Description: Updates the CHECK constraint on vcs_connection.provider_type to include GITLAB +-- Date: 2025-01-XX + +-- ===================================================== +-- Step 1: Drop the existing CHECK constraint +-- ===================================================== +ALTER TABLE vcs_connection DROP CONSTRAINT IF EXISTS vcs_connection_provider_type_check; + +-- ===================================================== +-- Step 2: Add updated CHECK constraint with GITLAB +-- ===================================================== +ALTER TABLE vcs_connection ADD CONSTRAINT vcs_connection_provider_type_check + CHECK (provider_type IN ('BITBUCKET_CLOUD', 'BITBUCKET_SERVER', 'GITHUB', 'GITLAB')); diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_google_provider_key.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_google_provider_key.sql new file mode 100644 index 00000000..22a30417 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_google_provider_key.sql @@ -0,0 +1,4 @@ +-- Add GOOGLE to ai_connection provider_key constraint +ALTER TABLE ai_connection DROP CONSTRAINT IF EXISTS ai_connection_provider_key_check; +ALTER TABLE ai_connection ADD CONSTRAINT ai_connection_provider_key_check + CHECK (provider_key IN ('OPENAI', 'OPENROUTER', 'ANTHROPIC', 'GOOGLE')); diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_skipped_job_status.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_skipped_job_status.sql new file mode 100644 index 00000000..f91c9400 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__add_skipped_job_status.sql @@ -0,0 +1,17 @@ +-- Add SKIPPED status to job_status_check constraint +-- This is used for IGNORED_COMMENT job types that are immediately skipped + +ALTER TABLE job DROP CONSTRAINT IF EXISTS job_status_check; + +ALTER TABLE job ADD CONSTRAINT job_status_check CHECK ( + status IN ( + 'PENDING', + 'QUEUED', + 'RUNNING', + 'COMPLETED', + 'FAILED', + 'CANCELLED', + 'WAITING', + 'SKIPPED' + ) +); diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__backfill_code_analysis_commit_hash.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__backfill_code_analysis_commit_hash.sql new file mode 100644 index 00000000..e826bf89 --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__backfill_code_analysis_commit_hash.sql @@ -0,0 +1,11 @@ +-- Backfill commit_hash in code_analysis from the associated pull_request +-- For existing PR analyses that don't have a commit_hash, copy it from the PR entity +-- This is acceptable for historical data as we're capturing the PR's latest commit state + +UPDATE code_analysis ca +SET commit_hash = pr.commit_hash +FROM pull_request pr +WHERE ca.pr_number = pr.pr_number + AND ca.project_id = pr.project_id + AND ca.commit_hash IS NULL + AND pr.commit_hash IS NOT NULL; diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__remove_forge_app_vsc_connection_type_and_add_repository_token.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__remove_forge_app_vsc_connection_type_and_add_repository_token.sql new file mode 100644 index 00000000..3a589f5a --- /dev/null +++ b/java-ecosystem/libs/core/src/main/resources/db/migration/0.2.0/V0.2.0__remove_forge_app_vsc_connection_type_and_add_repository_token.sql @@ -0,0 +1,7 @@ +alter table vcs_connection + drop constraint vcs_connection_connection_type_check; + +alter table vcs_connection + add constraint vcs_connection_connection_type_check + check ((connection_type)::text = ANY + ((ARRAY ['OAUTH_MANUAL'::character varying, 'APP'::character varying, 'CONNECT_APP'::character varying, 'GITHUB_APP'::character varying, 'OAUTH_APP'::character varying, 'PERSONAL_TOKEN'::character varying, 'APPLICATION'::character varying, 'ACCESS_TOKEN'::character varying, 'REPOSITORY_TOKEN'::character varying])::text[])); \ No newline at end of file diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V10__add_branch_column_to_branch_file.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V10__add_branch_column_to_branch_file.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V10__add_branch_column_to_branch_file.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V10__add_branch_column_to_branch_file.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V10_migration-add-workspace-slug.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V10_migration-add-workspace-slug.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V10_migration-add-workspace-slug.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V10_migration-add-workspace-slug.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V11_add_analysis_locks_and_rag_tracking.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V11_add_analysis_locks_and_rag_tracking.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V11_add_analysis_locks_and_rag_tracking.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V11_add_analysis_locks_and_rag_tracking.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V12__add_vcs_integration_layer.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V12__add_vcs_integration_layer.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V12__add_vcs_integration_layer.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V12__add_vcs_integration_layer.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V13__add_name_to_ai_connection.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V13__add_name_to_ai_connection.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V13__add_name_to_ai_connection.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V13__add_name_to_ai_connection.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V14__add_comment_commands_config.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V14__add_comment_commands_config.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V14__add_comment_commands_config.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V14__add_comment_commands_config.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V15__create_comment_command_rate_limit_table.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V15__create_comment_command_rate_limit_table.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V15__create_comment_command_rate_limit_table.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V15__create_comment_command_rate_limit_table.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V16__create_pr_summarize_cache_table.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V16__create_pr_summarize_cache_table.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V16__create_pr_summarize_cache_table.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V16__create_pr_summarize_cache_table.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V17__add_comment_commands_indexes.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V17__add_comment_commands_indexes.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V17__add_comment_commands_indexes.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V17__add_comment_commands_indexes.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V18__add_comment_webhook_audit_log.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V18__add_comment_webhook_audit_log.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V18__add_comment_webhook_audit_log.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V18__add_comment_webhook_audit_log.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V19__add_failed_incremental_count_to_rag_status.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V19__add_failed_incremental_count_to_rag_status.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V19__add_failed_incremental_count_to_rag_status.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V19__add_failed_incremental_count_to_rag_status.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V20__create_bitbucket_connect_installation.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V20__create_bitbucket_connect_installation.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V20__create_bitbucket_connect_installation.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V20__create_bitbucket_connect_installation.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V21__add_command_job_types.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V21__add_command_job_types.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V21__add_command_job_types.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V21__add_command_job_types.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V22__add_ignored_comment_job_type.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V22__add_ignored_comment_job_type.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V22__add_ignored_comment_job_type.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V22__add_ignored_comment_job_type.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V2__add_project_namespace.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V2__add_project_namespace.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V2__add_project_namespace.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V2__add_project_namespace.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V3__add_project_token_table.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V3__add_project_token_table.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V3__add_project_token_table.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V3__add_project_token_table.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V4_add_joined_at_to_workspace_member_table.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V4_add_joined_at_to_workspace_member_table.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V4_add_joined_at_to_workspace_member_table.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V4_add_joined_at_to_workspace_member_table.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V5__add_project_flags.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__add_project_flags.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V5__add_project_flags.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__add_project_flags.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V5__add_resolved_count_to_code_analysis.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__add_resolved_count_to_code_analysis.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V5__add_resolved_count_to_code_analysis.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__add_resolved_count_to_code_analysis.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V5__add_token_limitation_to_ai_connection.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__add_token_limitation_to_ai_connection.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V5__add_token_limitation_to_ai_connection.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__add_token_limitation_to_ai_connection.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V5__remove_unnecesary_contraint_on_pr_table.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__remove_unnecesary_contraint_on_pr_table.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V5__remove_unnecesary_contraint_on_pr_table.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V5__remove_unnecesary_contraint_on_pr_table.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V6__add_project_files_and_branch_tables.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V6__add_project_files_and_branch_tables.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V6__add_project_files_and_branch_tables.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V6__add_project_files_and_branch_tables.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V7__add_branch_issue_tracking_fields.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V7__add_branch_issue_tracking_fields.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V7__add_branch_issue_tracking_fields.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V7__add_branch_issue_tracking_fields.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V8__add_default_branch_to_project.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V8__add_default_branch_to_project.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V8__add_default_branch_to_project.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V8__add_default_branch_to_project.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V8_default_resolved_count_on_branch.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V8_default_resolved_count_on_branch.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V8_default_resolved_count_on_branch.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V8_default_resolved_count_on_branch.sql diff --git a/java-ecosystem/libs/core/src/main/resources/db/migration/V9__add_source_branch_to_code_analysis.sql b/java-ecosystem/libs/core/src/main/resources/db/migration/old/V9__add_source_branch_to_code_analysis.sql similarity index 100% rename from java-ecosystem/libs/core/src/main/resources/db/migration/V9__add_source_branch_to_code_analysis.sql rename to java-ecosystem/libs/core/src/main/resources/db/migration/old/V9__add_source_branch_to_code_analysis.sql diff --git a/java-ecosystem/libs/vcs-client/src/main/java/module-info.java b/java-ecosystem/libs/vcs-client/src/main/java/module-info.java index 7840f8e6..284f1a62 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/module-info.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/module-info.java @@ -24,4 +24,7 @@ exports org.rostilos.codecrow.vcsclient.github; exports org.rostilos.codecrow.vcsclient.github.actions; exports org.rostilos.codecrow.vcsclient.github.dto.response; + exports org.rostilos.codecrow.vcsclient.gitlab; + exports org.rostilos.codecrow.vcsclient.gitlab.actions; + exports org.rostilos.codecrow.vcsclient.gitlab.dto.response; } \ No newline at end of file diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/HttpAuthorizedClientFactory.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/HttpAuthorizedClientFactory.java index a3f65d29..49d813bb 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/HttpAuthorizedClientFactory.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/HttpAuthorizedClientFactory.java @@ -87,6 +87,32 @@ public OkHttpClient createGitHubClient(String accessToken) { .build(); } + /** + * Create an OkHttpClient configured for GitLab API with bearer token authentication. + * + * @param accessToken the GitLab personal access token or OAuth token + * @return configured OkHttpClient for GitLab API + */ + public OkHttpClient createGitLabClient(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + throw new IllegalArgumentException("Access token cannot be null or empty"); + } + + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + Request authorized = original.newBuilder() + .header("Authorization", "Bearer " + accessToken) + .header("Accept", "application/json") + .build(); + return chain.proceed(authorized); + }) + .build(); + } + private void validateSettings(String clientId, String clientSecret) { if (clientId.isEmpty()) { throw new IllegalArgumentException("No ClientId key has been set for Bitbucket connections"); diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClient.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClient.java index da11604a..95e6444a 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClient.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClient.java @@ -161,4 +161,15 @@ default int getRepositoryCount(String workspaceId) throws IOException { * @return commit hash */ String getLatestCommitHash(String workspaceId, String repoIdOrSlug, String branchName) throws IOException; + + /** + * Get collaborators/members with access to a repository. + * Returns users with their permission levels. + * @param workspaceId the external workspace/org ID + * @param repoIdOrSlug the repository ID or slug + * @return list of collaborators with permissions + */ + default List getRepositoryCollaborators(String workspaceId, String repoIdOrSlug) throws IOException { + throw new UnsupportedOperationException("Repository collaborators not supported by this provider"); + } } diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientFactory.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientFactory.java index b83943b7..02a4f489 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientFactory.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientFactory.java @@ -5,6 +5,7 @@ import org.rostilos.codecrow.core.model.vcs.VcsConnection; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.BitbucketCloudClient; import org.rostilos.codecrow.vcsclient.github.GitHubClient; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabClient; /** * Factory for creating VcsClient instances based on provider and connection configuration. @@ -32,7 +33,7 @@ public VcsClient createClient(VcsConnection connection, String accessToken, Stri case BITBUCKET_CLOUD -> createBitbucketCloudClient(connection, accessToken, refreshToken); case BITBUCKET_SERVER -> throw new UnsupportedOperationException("Bitbucket Server not yet implemented"); case GITHUB -> createGitHubClient(accessToken); - case GITLAB -> throw new UnsupportedOperationException("GitLab not yet implemented"); + case GITLAB -> createGitLabClient(accessToken); }; } @@ -41,7 +42,7 @@ public VcsClient createClient(EVcsProvider provider, String accessToken, String case BITBUCKET_CLOUD -> createBitbucketCloudClientFromTokens(accessToken, refreshToken); case BITBUCKET_SERVER -> throw new UnsupportedOperationException("Bitbucket Server not yet implemented"); case GITHUB -> createGitHubClient(accessToken); - case GITLAB -> throw new UnsupportedOperationException("GitLab not yet implemented"); + case GITLAB -> createGitLabClient(accessToken); }; } @@ -59,6 +60,11 @@ private GitHubClient createGitHubClient(String accessToken) { OkHttpClient httpClient = httpClientFactory.createClientWithBearerToken(accessToken); return new GitHubClient(httpClient); } + + private GitLabClient createGitLabClient(String accessToken) { + OkHttpClient httpClient = httpClientFactory.createClientWithBearerToken(accessToken); + return new GitLabClient(httpClient); + } public VcsClient createClientWithOAuth(EVcsProvider provider, String oAuthKey, String oAuthSecret) { if (provider != EVcsProvider.BITBUCKET_CLOUD) { diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientProvider.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientProvider.java index 6b8e434d..a68c3ebd 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientProvider.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/VcsClientProvider.java @@ -2,15 +2,19 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Jwts; import okhttp3.FormBody; +import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import org.rostilos.codecrow.core.model.vcs.BitbucketConnectInstallation; import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; import org.rostilos.codecrow.core.model.vcs.VcsConnection; import org.rostilos.codecrow.core.model.vcs.config.cloud.BitbucketCloudConfig; +import org.rostilos.codecrow.core.persistence.repository.vcs.BitbucketConnectInstallationRepository; import org.rostilos.codecrow.core.persistence.repository.vcs.VcsConnectionRepository; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.BitbucketCloudClient; @@ -21,11 +25,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import javax.crypto.SecretKey; +import io.jsonwebtoken.security.Keys; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.time.LocalDateTime; import java.util.Base64; +import java.util.Optional; /** * Unified VCS Client Provider. @@ -46,9 +53,12 @@ public class VcsClientProvider { private static final Logger log = LoggerFactory.getLogger(VcsClientProvider.class); private static final String BITBUCKET_TOKEN_URL = "https://bitbucket.org/site/oauth2/access_token"; + private static final String GITLAB_TOKEN_URL = "https://gitlab.com/oauth/token"; + private static final MediaType FORM_MEDIA_TYPE = MediaType.parse("application/x-www-form-urlencoded"); private static final ObjectMapper objectMapper = new ObjectMapper(); private final VcsConnectionRepository connectionRepository; + private final BitbucketConnectInstallationRepository connectInstallationRepository; private final TokenEncryptionService encryptionService; private final HttpAuthorizedClientFactory httpClientFactory; @@ -63,13 +73,24 @@ public class VcsClientProvider { @Value("${codecrow.github.app.private-key-path:}") private String githubAppPrivateKeyPath; + + @Value("${codecrow.gitlab.oauth.client-id:}") + private String gitlabOAuthClientId; + + @Value("${codecrow.gitlab.oauth.client-secret:}") + private String gitlabOAuthClientSecret; + + @Value("${codecrow.gitlab.base-url:https://gitlab.com}") + private String gitlabBaseUrl; public VcsClientProvider( VcsConnectionRepository connectionRepository, + BitbucketConnectInstallationRepository connectInstallationRepository, TokenEncryptionService encryptionService, HttpAuthorizedClientFactory httpClientFactory ) { this.connectionRepository = connectionRepository; + this.connectInstallationRepository = connectInstallationRepository; this.encryptionService = encryptionService; this.httpClientFactory = httpClientFactory; } @@ -162,6 +183,13 @@ public OkHttpClient getHttpClient(Long workspaceId, Long connectionId) { */ public OkHttpClient getHttpClient(VcsConnection connection) { try { + log.debug("getHttpClient called for connection: id={}, type={}, provider={}, hasRefreshToken={}, tokenExpiresAt={}", + connection.getId(), + connection.getConnectionType(), + connection.getProviderType(), + connection.getRefreshToken() != null && !connection.getRefreshToken().isBlank(), + connection.getTokenExpiresAt()); + // Refresh token if needed for APP connections VcsConnection activeConnection = ensureValidToken(connection); return createHttpClient(activeConnection); @@ -172,17 +200,13 @@ public OkHttpClient getHttpClient(VcsConnection connection) { /** * Ensure the connection has a valid (non-expired) token. - * Automatically refreshes APP connection tokens if they're about to expire. + * Automatically refreshes tokens if they're about to expire. + * Supports both APP and OAUTH_MANUAL connections with refresh tokens. * * @param connection the VCS connection * @return the connection with valid tokens (may be refreshed) */ private VcsConnection ensureValidToken(VcsConnection connection) { - // Only APP connections have expiring tokens - if (connection.getConnectionType() != EVcsConnectionType.APP) { - return connection; - } - // Check if token needs refresh if (needsTokenRefresh(connection)) { log.info("Token for connection {} is expired or about to expire, refreshing...", connection.getId()); @@ -199,16 +223,43 @@ private VcsConnection ensureValidToken(VcsConnection connection) { * @return true if token is expired or will expire within 5 minutes */ public boolean needsTokenRefresh(VcsConnection connection) { + // Check if connection has a refresh token - required for token refresh + if (connection.getRefreshToken() == null || connection.getRefreshToken().isBlank()) { + // For APP connections without refresh token, check if it's a Connect App + if (connection.getConnectionType() == EVcsConnectionType.APP) { + // For Bitbucket Connect Apps, we can refresh using JWT (checked in refreshBitbucketConnection) + // For GitHub Apps, we can refresh using the app credentials + if (connection.getTokenExpiresAt() != null && + connection.getTokenExpiresAt().isBefore(LocalDateTime.now().plusMinutes(5))) { + log.info("needsTokenRefresh: Connection {} (APP without refresh token) - token expired, needs refresh", + connection.getId()); + return true; + } + } + log.debug("needsTokenRefresh: Connection {} has no refresh token, skipping refresh check", connection.getId()); + return false; + } + + // If no expiration is set but we have a refresh token, assume token might be stale + // This handles cases where tokenExpiresAt wasn't properly set during migration/import if (connection.getTokenExpiresAt() == null) { - return false; // No expiration = doesn't need refresh (e.g., OAuth Consumer) + log.info("needsTokenRefresh: Connection {} has refresh token but no expiration time set, forcing refresh", + connection.getId()); + return true; } + // Refresh if token expires within 5 minutes - return connection.getTokenExpiresAt().isBefore(LocalDateTime.now().plusMinutes(5)); + boolean needsRefresh = connection.getTokenExpiresAt().isBefore(LocalDateTime.now().plusMinutes(5)); + if (needsRefresh) { + log.info("needsTokenRefresh: Connection {} token expires at {}, needs refresh", + connection.getId(), connection.getTokenExpiresAt()); + } + return needsRefresh; } /** * Refresh the access token for a connection. - * Handles both Bitbucket (refresh token) and GitHub App (installation token) refresh. + * Handles both APP connections (Connect Apps, GitHub Apps) and OAUTH_MANUAL connections with refresh tokens. * * @param connection the VCS connection * @return updated connection with new tokens @@ -216,17 +267,14 @@ public boolean needsTokenRefresh(VcsConnection connection) { */ @Transactional public VcsConnection refreshToken(VcsConnection connection) { - if (connection.getConnectionType() != EVcsConnectionType.APP) { - throw new VcsClientException("Token refresh only supported for APP connections"); - } - - log.info("Refreshing access token for connection: {} (provider: {})", - connection.getId(), connection.getProviderType()); + log.info("Refreshing access token for connection: {} (provider: {}, type: {})", + connection.getId(), connection.getProviderType(), connection.getConnectionType()); try { return switch (connection.getProviderType()) { case BITBUCKET_CLOUD -> refreshBitbucketConnection(connection); case GITHUB -> refreshGitHubAppConnection(connection); + case GITLAB -> refreshGitLabConnection(connection); default -> throw new VcsClientException("Token refresh not supported for provider: " + connection.getProviderType()); }; } catch (GeneralSecurityException e) { @@ -239,10 +287,23 @@ public VcsConnection refreshToken(VcsConnection connection) { } /** - * Refresh Bitbucket Cloud connection using refresh token. + * Refresh Bitbucket Cloud connection. + * For Connect Apps (APP type without refresh token), uses JWT Bearer grant. + * For OAuth apps with refresh token, uses standard refresh_token grant. */ private VcsConnection refreshBitbucketConnection(VcsConnection connection) throws GeneralSecurityException, IOException { + + // Check if this is a Connect App connection (linked to BitbucketConnectInstallation) + Optional installationOpt = + connectInstallationRepository.findByVcsConnection_Id(connection.getId()); + + if (installationOpt.isPresent()) { + // Use JWT Bearer grant for Connect Apps + return refreshBitbucketConnectAppConnection(connection, installationOpt.get()); + } + + // Standard OAuth refresh token flow if (connection.getRefreshToken() == null) { throw new VcsClientException("No refresh token available for connection: " + connection.getId()); } @@ -262,6 +323,80 @@ private VcsConnection refreshBitbucketConnection(VcsConnection connection) return connection; } + /** + * Refresh Bitbucket Connect App connection using JWT Bearer grant. + * Connect Apps use shared secret to create JWT and exchange for access token. + */ + private VcsConnection refreshBitbucketConnectAppConnection(VcsConnection connection, + BitbucketConnectInstallation installation) throws GeneralSecurityException, IOException { + + String sharedSecret = installation.getSharedSecret(); + if (sharedSecret == null || sharedSecret.isBlank()) { + throw new VcsClientException("No shared secret available for Connect App installation: " + + installation.getClientKey()); + } + + // Decrypt the shared secret + String decryptedSecret; + try { + decryptedSecret = encryptionService.decrypt(sharedSecret); + } catch (Exception e) { + // Shared secret might be stored unencrypted (old installation) - use as-is + log.warn("Could not decrypt shared secret, using as stored: {}", e.getMessage()); + decryptedSecret = sharedSecret; + } + + // Create JWT for authentication + long now = System.currentTimeMillis() / 1000; + SecretKey key = Keys.hmacShaKeyFor(decryptedSecret.getBytes(StandardCharsets.UTF_8)); + + String jwt = Jwts.builder() + .setIssuer(installation.getClientKey()) + .setSubject(installation.getClientKey()) + .setIssuedAt(new java.util.Date(now * 1000)) + .setExpiration(new java.util.Date((now + 180) * 1000)) // 3 minutes validity + .signWith(key) + .compact(); + + log.debug("Created JWT for token exchange, iss: {}", installation.getClientKey()); + + // Exchange JWT for access token using JWT Bearer grant + OkHttpClient httpClient = new OkHttpClient(); + Request request = new Request.Builder() + .url(BITBUCKET_TOKEN_URL) + .addHeader("Authorization", "JWT " + jwt) + .addHeader("Content-Type", "application/x-www-form-urlencoded") + .post(RequestBody.create("grant_type=urn:bitbucket:oauth2:jwt", FORM_MEDIA_TYPE)) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + + if (!response.isSuccessful()) { + log.error("Failed to get access token via JWT Bearer: {} - {}", response.code(), responseBody); + throw new IOException("Failed to refresh Connect App token: " + response.code() + " - " + responseBody); + } + + JsonNode json = objectMapper.readTree(responseBody); + String accessToken = json.path("access_token").asText(); + int expiresIn = json.path("expires_in").asInt(3600); + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expiresIn); + + // Update connection with new token + connection.setAccessToken(encryptionService.encrypt(accessToken)); + connection.setTokenExpiresAt(expiresAt); + connection = connectionRepository.save(connection); + + // Also update the installation's cached token + installation.setAccessToken(encryptionService.encrypt(accessToken)); + installation.setTokenExpiresAt(expiresAt); + connectInstallationRepository.save(installation); + + log.info("Successfully refreshed Bitbucket Connect App token for connection: {}", connection.getId()); + return connection; + } + } + /** * Refresh GitHub App connection using installation access token. * GitHub App installation tokens expire after 1 hour. @@ -301,6 +436,81 @@ private VcsConnection refreshGitHubAppConnection(VcsConnection connection) throw return connection; } + /** + * Refresh GitLab OAuth connection using refresh token. + */ + private VcsConnection refreshGitLabConnection(VcsConnection connection) + throws GeneralSecurityException, IOException { + + if (connection.getRefreshToken() == null) { + throw new VcsClientException("No refresh token available for GitLab connection: " + connection.getId()); + } + + String decryptedRefreshToken = encryptionService.decrypt(connection.getRefreshToken()); + TokenResponse newTokens = refreshGitLabToken(decryptedRefreshToken); + + // Update connection with new tokens + connection.setAccessToken(encryptionService.encrypt(newTokens.accessToken())); + if (newTokens.refreshToken() != null) { + connection.setRefreshToken(encryptionService.encrypt(newTokens.refreshToken())); + } + connection.setTokenExpiresAt(newTokens.expiresAt()); + connection = connectionRepository.save(connection); + + log.info("Successfully refreshed GitLab access token for connection: {}", connection.getId()); + return connection; + } + + /** + * Refresh GitLab access token using refresh token. + */ + private TokenResponse refreshGitLabToken(String refreshToken) throws IOException { + if (gitlabOAuthClientId == null || gitlabOAuthClientId.isBlank() || + gitlabOAuthClientSecret == null || gitlabOAuthClientSecret.isBlank()) { + throw new IOException("GitLab OAuth credentials not configured. Set codecrow.gitlab.oauth.client-id and codecrow.gitlab.oauth.client-secret"); + } + + OkHttpClient httpClient = new OkHttpClient(); + + // Determine GitLab token URL (support self-hosted) + String tokenUrl = (gitlabBaseUrl != null && !gitlabBaseUrl.isBlank() && !gitlabBaseUrl.equals("https://gitlab.com")) + ? gitlabBaseUrl.replaceAll("/$", "") + "/oauth/token" + : GITLAB_TOKEN_URL; + + RequestBody body = new FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", refreshToken) + .add("client_id", gitlabOAuthClientId) + .add("client_secret", gitlabOAuthClientSecret) + .build(); + + Request request = new Request.Builder() + .url(tokenUrl) + .header("Accept", "application/json") + .post(body) + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : ""; + throw new IOException("Failed to refresh GitLab token: " + response.code() + " - " + errorBody); + } + + String responseBody = response.body().string(); + JsonNode json = objectMapper.readTree(responseBody); + + String accessToken = json.get("access_token").asText(); + String newRefreshToken = json.has("refresh_token") ? json.get("refresh_token").asText() : null; + int expiresIn = json.has("expires_in") ? json.get("expires_in").asInt() : 7200; + + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expiresIn); + + log.debug("GitLab token refreshed successfully. New token expires at: {}", expiresAt); + + return new TokenResponse(accessToken, newRefreshToken, expiresAt); + } + } + /** * Refresh Bitbucket access token using refresh token. */ @@ -359,13 +569,17 @@ private OkHttpClient createHttpClient(VcsConnection connection) throws GeneralSe // Handle null connectionType as OAUTH_MANUAL (legacy connections) if (connectionType == null) { + log.warn("Connection {} has null connectionType, treating as OAUTH_MANUAL", connection.getId()); connectionType = EVcsConnectionType.OAUTH_MANUAL; } + log.info("createHttpClient: connection={}, connectionType={}, provider={}", + connection.getId(), connectionType, connection.getProviderType()); + return switch (connectionType) { case APP -> createAppHttpClient(connection); case OAUTH_MANUAL -> createOAuthManualHttpClient(connection); - case PERSONAL_TOKEN -> createPersonalTokenHttpClient(connection); + case PERSONAL_TOKEN, REPOSITORY_TOKEN -> createPersonalTokenHttpClient(connection); default -> throw new VcsClientException("Unsupported connection type: " + connectionType); }; } @@ -412,13 +626,34 @@ private OkHttpClient createOAuthManualHttpClient(VcsConnection connection) throw * Uses personal access token (bearer token authentication). */ private OkHttpClient createPersonalTokenHttpClient(VcsConnection connection) throws GeneralSecurityException { - String accessToken = connection.getAccessToken(); - if (accessToken == null || accessToken.isBlank()) { + String accessToken; + + // Get access token - either from direct field or from configuration + if (connection.getAccessToken() != null && !connection.getAccessToken().isBlank()) { + accessToken = encryptionService.decrypt(connection.getAccessToken()); + } else if (connection.getConfiguration() != null) { + // Extract token from config for GitHub/GitLab + accessToken = extractTokenFromConfig(connection); + } else { throw new VcsClientException("No access token found for PERSONAL_TOKEN connection: " + connection.getId()); } - String decryptedToken = encryptionService.decrypt(accessToken); - return httpClientFactory.createClientWithBearerToken(decryptedToken); + // Use provider-specific client factory for proper headers + return switch (connection.getProviderType()) { + case GITHUB -> httpClientFactory.createGitHubClient(accessToken); + case GITLAB -> httpClientFactory.createGitLabClient(accessToken); + default -> httpClientFactory.createClientWithBearerToken(accessToken); + }; + } + + private String extractTokenFromConfig(VcsConnection connection) throws GeneralSecurityException { + if (connection.getConfiguration() instanceof org.rostilos.codecrow.core.model.vcs.config.github.GitHubConfig config) { + return config.accessToken(); // GitHub config stores token in plain text + } + if (connection.getConfiguration() instanceof org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig config) { + return config.accessToken(); // GitLab config stores token in plain text + } + throw new VcsClientException("Cannot extract token from config for connection: " + connection.getId()); } /** @@ -428,8 +663,7 @@ private VcsClient createVcsClient(EVcsProvider provider, OkHttpClient httpClient return switch (provider) { case BITBUCKET_CLOUD -> new BitbucketCloudClient(httpClient); case GITHUB -> new GitHubClient(httpClient); - // TODO: Add other providers - // case GITLAB -> new GitLabClient(httpClient); + case GITLAB -> new org.rostilos.codecrow.vcsclient.gitlab.GitLabClient(httpClient); default -> throw new VcsClientException("Unsupported provider: " + provider); }; } diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/BitbucketCloudClient.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/BitbucketCloudClient.java index dcf68218..c325899b 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/BitbucketCloudClient.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/BitbucketCloudClient.java @@ -616,4 +616,95 @@ public String getLatestCommitHash(String workspaceId, String repoIdOrSlug, Strin return null; } } + + @Override + public List getRepositoryCollaborators(String workspaceId, String repoIdOrSlug) throws IOException { + List collaborators = new ArrayList<>(); + + // Use the workspace permissions API for repository-level permissions + // GET /workspaces/{workspace}/permissions/repositories/{repo_slug} + String url = API_BASE + "/workspaces/" + workspaceId + "/permissions/repositories/" + repoIdOrSlug + "?pagelen=" + DEFAULT_PAGE_SIZE; + + while (url != null) { + Request request = new Request.Builder() + .url(url) + .header("Accept", "application/json") + .get() + .build(); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + // 403 might mean we don't have permission to view collaborators + if (response.code() == 403) { + throw new IOException("No permission to view repository collaborators. " + + "Ensure the connection has 'workspace:read' and 'repository:read' scopes."); + } + throw createException("get repository collaborators", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + JsonNode values = root.get("values"); + + if (values != null && values.isArray()) { + for (JsonNode permNode : values) { + VcsCollaborator collab = parseCollaboratorPermission(permNode); + if (collab != null) { + collaborators.add(collab); + } + } + } + + url = root.has("next") && !root.get("next").isNull() ? root.get("next").asText() : null; + } + } + + return collaborators; + } + + /** + * Parse a collaborator from Bitbucket's permission response. + * Response format: + * { + * "permission": "read|write|admin", + * "user": { + * "uuid": "{...}", + * "username": "...", + * "display_name": "...", + * "links": { "avatar": { "href": "..." }, "html": { "href": "..." } } + * } + * } + */ + private VcsCollaborator parseCollaboratorPermission(JsonNode permNode) { + if (permNode == null) return null; + + String permission = getTextOrNull(permNode, "permission"); + JsonNode userNode = permNode.get("user"); + + if (userNode == null) { + // Could be a group permission, skip for now + return null; + } + + String uuid = userNode.has("uuid") ? normalizeUuid(userNode.get("uuid").asText()) : null; + String username = getTextOrNull(userNode, "username"); + String displayName = getTextOrNull(userNode, "display_name"); + + // Account ID is the preferred unique identifier for Bitbucket users + String accountId = getTextOrNull(userNode, "account_id"); + String userId = accountId != null ? accountId : uuid; + + String avatarUrl = null; + String htmlUrl = null; + if (userNode.has("links")) { + JsonNode links = userNode.get("links"); + if (links.has("avatar") && links.get("avatar").has("href")) { + avatarUrl = links.get("avatar").get("href").asText(); + } + if (links.has("html") && links.get("html").has("href")) { + htmlUrl = links.get("html").get("href").asText(); + } + } + + return new VcsCollaborator(userId, username, displayName, avatarUrl, permission, htmlUrl); + } } diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/actions/GetCommitRangeDiffAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/actions/GetCommitRangeDiffAction.java new file mode 100644 index 00000000..1e9cd477 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/bitbucket/cloud/actions/GetCommitRangeDiffAction.java @@ -0,0 +1,70 @@ +package org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.bitbucket.cloud.BitbucketCloudConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Optional; + +/** + * Action to retrieve diff between two commits (commit range) from Bitbucket Cloud. + * Used for incremental/delta analysis to get only changes since the last analyzed commit. + */ +public class GetCommitRangeDiffAction { + + private static final Logger log = LoggerFactory.getLogger(GetCommitRangeDiffAction.class); + private final OkHttpClient authorizedOkHttpClient; + + public GetCommitRangeDiffAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Fetches the diff between two commits. + * + * Bitbucket API: GET /repositories/{workspace}/{repo_slug}/diff/{spec} + * where spec can be "commit1..commit2" format + * + * @param workspace workspace or team slug + * @param repoSlug repository slug + * @param baseCommitHash the base commit (previously analyzed commit) + * @param headCommitHash the head commit (current commit to analyze) + * @return raw unified diff between the two commits + * @throws IOException on network / parsing errors + */ + public String getCommitRangeDiff(String workspace, String repoSlug, String baseCommitHash, String headCommitHash) throws IOException { + String ws = Optional.ofNullable(workspace).orElse(""); + + // Bitbucket uses the spec format: base..head + String spec = baseCommitHash + ".." + headCommitHash; + String apiUrl = String.format("%s/repositories/%s/%s/diff/%s", + BitbucketCloudConfig.BITBUCKET_API_BASE, ws, repoSlug, spec); + + log.info("Fetching commit range diff: {} from {} to {}", repoSlug, baseCommitHash.substring(0, 7), headCommitHash.substring(0, 7)); + + Request req = new Request.Builder() + .url(apiUrl) + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("Bitbucket returned non-success response %d for commit range diff URL %s: %s", + resp.code(), apiUrl, body); + log.warn(msg); + throw new IOException(msg); + } + String diff = resp.body() != null ? resp.body().string() : ""; + log.info("Retrieved commit range diff: {} chars", diff.length()); + return diff; + } catch (IOException e) { + log.error("Failed to get commit range diff: {}", e.getMessage(), e); + throw e; + } + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/GitHubClient.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/GitHubClient.java index 31627747..537dcef2 100644 --- a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/GitHubClient.java +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/GitHubClient.java @@ -480,6 +480,147 @@ public String getLatestCommitHash(String workspaceId, String repoIdOrSlug, Strin } } + /** + * Get repository collaborators with their permission levels. + * Uses the GitHub Collaborators API to fetch users with access to the repository. + * + * API: GET /repos/{owner}/{repo}/collaborators + * + * Note: Requires push access to the repository to list collaborators. + * For organization repos, may require organization admin permissions. + * + * @param workspaceId the owner (user or organization) + * @param repoIdOrSlug the repository name + * @return list of collaborators with permissions + */ + @Override + public List getRepositoryCollaborators(String workspaceId, String repoIdOrSlug) throws IOException { + List collaborators = new ArrayList<>(); + + // GitHub API: GET /repos/{owner}/{repo}/collaborators + // Requires "affiliation=all" to get all collaborators (direct, outside, and from teams) + String url = API_BASE + "/repos/" + workspaceId + "/" + repoIdOrSlug + "/collaborators?per_page=" + DEFAULT_PAGE_SIZE + "&affiliation=all"; + + while (url != null) { + Request request = createGetRequest(url); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + // 403 means we don't have permission to view collaborators + if (response.code() == 403) { + throw new IOException("No permission to view repository collaborators. " + + "Requires push access to the repository."); + } + throw createException("get repository collaborators", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + + if (root != null && root.isArray()) { + for (JsonNode collabNode : root) { + VcsCollaborator collab = parseCollaborator(collabNode); + if (collab != null) { + collaborators.add(collab); + } + } + } + + // Check for pagination via Link header + url = getNextPageUrl(response); + } + } + + return collaborators; + } + + /** + * Parse a collaborator from GitHub's collaborator response. + * Response format: + * { + * "id": 12345, + * "login": "username", + * "avatar_url": "https://...", + * "html_url": "https://github.com/username", + * "permissions": { + * "admin": false, + * "maintain": false, + * "push": true, + * "triage": true, + * "pull": true + * }, + * "role_name": "write" + * } + */ + private VcsCollaborator parseCollaborator(JsonNode node) { + if (node == null) return null; + + String id = String.valueOf(node.get("id").asLong()); + String login = getTextOrNull(node, "login"); + String avatarUrl = getTextOrNull(node, "avatar_url"); + String htmlUrl = getTextOrNull(node, "html_url"); + + // Get permission level - prefer role_name if available, otherwise derive from permissions object + String permission = getTextOrNull(node, "role_name"); + if (permission == null && node.has("permissions")) { + permission = derivePermissionFromObject(node.get("permissions")); + } + + // GitHub doesn't have a separate display name, use login + return new VcsCollaborator(id, login, login, avatarUrl, permission, htmlUrl); + } + + /** + * Derive permission level from GitHub's permissions object. + * Priority: admin > maintain > push > triage > pull + */ + private String derivePermissionFromObject(JsonNode permissions) { + if (permissions == null) return null; + + if (permissions.has("admin") && permissions.get("admin").asBoolean()) { + return "admin"; + } + if (permissions.has("maintain") && permissions.get("maintain").asBoolean()) { + return "maintain"; + } + if (permissions.has("push") && permissions.get("push").asBoolean()) { + return "write"; + } + if (permissions.has("triage") && permissions.get("triage").asBoolean()) { + return "triage"; + } + if (permissions.has("pull") && permissions.get("pull").asBoolean()) { + return "read"; + } + + return null; + } + + /** + * Extract the next page URL from GitHub's Link header. + * Format: ; rel="next", ; rel="last" + */ + private String getNextPageUrl(Response response) { + String linkHeader = response.header("Link"); + if (linkHeader == null) return null; + + // Parse Link header for rel="next" + for (String link : linkHeader.split(",")) { + String[] parts = link.split(";"); + if (parts.length >= 2) { + String rel = parts[1].trim(); + if (rel.equals("rel=\"next\"")) { + String url = parts[0].trim(); + // Remove < and > brackets + if (url.startsWith("<") && url.endsWith(">")) { + return url.substring(1, url.length() - 1); + } + } + } + } + + return null; + } + private boolean isCurrentUser(String workspaceId) { try { diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/actions/GetCommitRangeDiffAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/actions/GetCommitRangeDiffAction.java new file mode 100644 index 00000000..1b91f67a --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/github/actions/GetCommitRangeDiffAction.java @@ -0,0 +1,73 @@ +package org.rostilos.codecrow.vcsclient.github.actions; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.github.GitHubConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Action to retrieve diff between two commits (commit range) from GitHub. + * Used for incremental/delta analysis to get only changes since the last analyzed commit. + */ +public class GetCommitRangeDiffAction { + + private static final Logger log = LoggerFactory.getLogger(GetCommitRangeDiffAction.class); + private final OkHttpClient authorizedOkHttpClient; + + public GetCommitRangeDiffAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Fetches the diff between two commits using GitHub compare API. + * + * GitHub API: GET /repos/{owner}/{repo}/compare/{basehead} + * where basehead is "base...head" format + * + * @param owner repository owner + * @param repo repository name + * @param baseCommitHash the base commit (previously analyzed commit) + * @param headCommitHash the head commit (current commit to analyze) + * @return raw unified diff between the two commits + * @throws IOException on network / parsing errors + */ + public String getCommitRangeDiff(String owner, String repo, String baseCommitHash, String headCommitHash) throws IOException { + // GitHub uses the basehead format: base...head (three dots for merge-base comparison) + // Using two dots (..) would give direct comparison, but three dots is more common for PRs + String basehead = baseCommitHash + "..." + headCommitHash; + String apiUrl = String.format("%s/repos/%s/%s/compare/%s", + GitHubConfig.API_BASE, owner, repo, basehead); + + log.info("Fetching commit range diff: {}/{} from {} to {}", + owner, repo, + baseCommitHash.length() >= 7 ? baseCommitHash.substring(0, 7) : baseCommitHash, + headCommitHash.length() >= 7 ? headCommitHash.substring(0, 7) : headCommitHash); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/vnd.github.v3.diff") + .header("X-GitHub-Api-Version", "2022-11-28") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("GitHub returned non-success response %d for commit range diff URL %s: %s", + resp.code(), apiUrl, body); + log.warn(msg); + throw new IOException(msg); + } + String diff = resp.body() != null ? resp.body().string() : ""; + log.info("Retrieved commit range diff: {} chars", diff.length()); + return diff; + } catch (IOException e) { + log.error("Failed to get commit range diff: {}", e.getMessage(), e); + throw e; + } + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabClient.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabClient.java new file mode 100644 index 00000000..23263fbf --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabClient.java @@ -0,0 +1,741 @@ +package org.rostilos.codecrow.vcsclient.gitlab; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.rostilos.codecrow.vcsclient.VcsClient; +import org.rostilos.codecrow.vcsclient.model.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * VcsClient implementation for GitLab. + * Supports OAuth token-based connections. + */ +public class GitLabClient implements VcsClient { + + private static final Logger log = LoggerFactory.getLogger(GitLabClient.class); + + private static final String API_BASE = GitLabConfig.API_BASE; + private static final int DEFAULT_PAGE_SIZE = GitLabConfig.DEFAULT_PAGE_SIZE; + private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json"); + + private static final String ACCEPT_HEADER = "Accept"; + private static final String GITLAB_ACCEPT_HEADER = "application/json"; + + private final OkHttpClient httpClient; + private final ObjectMapper objectMapper; + private final String baseUrl; + + public GitLabClient(OkHttpClient httpClient) { + this(httpClient, API_BASE); + } + + public GitLabClient(OkHttpClient httpClient, String baseUrl) { + this.httpClient = httpClient; + this.objectMapper = new ObjectMapper(); + this.baseUrl = baseUrl != null ? baseUrl : API_BASE; + } + + @Override + public boolean validateConnection() throws IOException { + Request request = createGetRequest(baseUrl + "/user"); + try (Response response = httpClient.newCall(request).execute()) { + return response.isSuccessful(); + } + } + + @Override + public List listWorkspaces() throws IOException { + List workspaces = new ArrayList<>(); + + // Add user's personal namespace as a "workspace" + VcsUser currentUser = getCurrentUser(); + if (currentUser != null) { + workspaces.add(new VcsWorkspace( + currentUser.id(), + currentUser.username(), + currentUser.displayName() != null ? currentUser.displayName() : currentUser.username(), + false, + currentUser.avatarUrl(), + currentUser.htmlUrl() + )); + } + + // GitLab uses groups instead of organizations + int page = 1; + while (true) { + String url = baseUrl + "/groups?per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page + "&min_access_level=10"; + + Request request = createGetRequest(url); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("list groups", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + if (!root.isArray() || root.isEmpty()) { + break; + } + + for (JsonNode node : root) { + workspaces.add(parseGroup(node)); + } + + // Check for pagination via headers + String nextPage = response.header("X-Next-Page"); + if (nextPage == null || nextPage.isBlank()) { + break; + } + page++; + } + } + + return workspaces; + } + + @Override + public VcsRepositoryPage listRepositories(String workspaceId, int page) throws IOException { + String url; + String sortParams = "&order_by=updated_at&sort=desc"; + + // Check if workspaceId is a group or user + if (isCurrentUser(workspaceId)) { + url = baseUrl + "/projects?membership=true&per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page + sortParams; + } else { + // Try as group first + String encodedWorkspace = URLEncoder.encode(workspaceId, StandardCharsets.UTF_8); + url = baseUrl + "/groups/" + encodedWorkspace + "/projects?per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page + sortParams; + } + + return fetchRepositoryPage(url, workspaceId, page); + } + + @Override + public VcsRepositoryPage searchRepositories(String workspaceId, String query, int page) throws IOException { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + + String url; + if (isCurrentUser(workspaceId)) { + url = baseUrl + "/projects?search=" + encodedQuery + "&membership=true&per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page; + } else { + String encodedWorkspace = URLEncoder.encode(workspaceId, StandardCharsets.UTF_8); + url = baseUrl + "/groups/" + encodedWorkspace + "/projects?search=" + encodedQuery + "&per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page; + } + + return fetchRepositoryPage(url, workspaceId, page); + } + + @Override + public VcsRepository getRepository(String workspaceId, String repoIdOrSlug) throws IOException { + // GitLab uses project ID or URL-encoded path + // If workspaceId is empty or null, repoIdOrSlug contains the full path (e.g., "namespace/repo") + String projectPath; + String effectiveNamespace; + if (workspaceId == null || workspaceId.isBlank()) { + projectPath = repoIdOrSlug; + // Extract namespace from full path for parseRepository + effectiveNamespace = repoIdOrSlug.contains("/") + ? repoIdOrSlug.substring(0, repoIdOrSlug.lastIndexOf("/")) + : repoIdOrSlug; + } else { + projectPath = workspaceId + "/" + repoIdOrSlug; + effectiveNamespace = workspaceId; + } + + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath; + + Request request = createGetRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + if (response.code() == 404) { + // Try with just the repo ID (might be a numeric ID) + url = baseUrl + "/projects/" + URLEncoder.encode(repoIdOrSlug, StandardCharsets.UTF_8); + Request retryRequest = createGetRequest(url); + try (Response retryResponse = httpClient.newCall(retryRequest).execute()) { + if (!retryResponse.isSuccessful()) { + if (retryResponse.code() == 404) { + return null; + } + throw createException("get repository", retryResponse); + } + JsonNode node = objectMapper.readTree(retryResponse.body().string()); + return parseRepository(node, effectiveNamespace); + } + } + throw createException("get repository", response); + } + + JsonNode node = objectMapper.readTree(response.body().string()); + return parseRepository(node, effectiveNamespace); + } + } + + @Override + public String ensureWebhook(String workspaceId, String repoIdOrSlug, String targetUrl, List events) throws IOException { + // Try to list existing webhooks first, but handle permission errors gracefully + try { + List existingWebhooks = listWebhooks(workspaceId, repoIdOrSlug); + for (VcsWebhook webhook : existingWebhooks) { + if (webhook.matchesUrl(targetUrl)) { + return updateWebhook(workspaceId, repoIdOrSlug, webhook.id(), targetUrl, events); + } + } + } catch (IOException e) { + // If listing fails (e.g., 403 Forbidden with repository tokens), + // proceed to create a new webhook directly + log.warn("Could not list webhooks (token may lack read permission), attempting direct creation: {}", e.getMessage()); + } + + return createWebhook(workspaceId, repoIdOrSlug, targetUrl, events); + } + + private String createWebhook(String workspaceId, String repoIdOrSlug, String targetUrl, List events) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath + "/hooks"; + + log.info("createWebhook: projectPath={}, encodedPath={}, url={}", projectPath, encodedPath, url); + + StringBuilder body = new StringBuilder(); + body.append("{\"url\":\"").append(targetUrl).append("\""); + + // Convert generic events to GitLab events + for (String event : events) { + String gitlabEvent = convertToGitLabEvent(event); + if (gitlabEvent != null) { + body.append(",\"").append(gitlabEvent).append("\":true"); + } + } + body.append("}"); + + log.info("createWebhook: body={}", body); + + Request request = createPostRequest(url, body.toString()); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + String responseBody = response.body() != null ? response.body().string() : "null"; + log.error("createWebhook failed: code={}, body={}", response.code(), responseBody); + + // Provide helpful error messages for common issues + if (response.code() == 403) { + throw new IOException("GitLab webhook creation failed (403 Forbidden). " + + "The token must have the Maintainer role to manage webhooks. " + + "Please recreate your Project Access Token with Role: Maintainer and Scopes: api, read_repository, write_repository."); + } + + if (response.code() == 422 && responseBody.contains("Invalid url")) { + throw new IOException("GitLab webhook creation failed (422 Invalid URL). " + + "GitLab requires a publicly accessible webhook URL. " + + "The URL '" + targetUrl + "' is not reachable from GitLab. " + + "Please configure a public URL in your CodeCrow settings or use a tunnel service like ngrok for local development."); + } + + throw createException("create webhook", response); + } + + JsonNode node = objectMapper.readTree(response.body().string()); + String webhookId = String.valueOf(node.get("id").asLong()); + log.info("createWebhook succeeded: webhookId={}", webhookId); + return webhookId; + } + } + + private String updateWebhook(String workspaceId, String repoIdOrSlug, String webhookId, String targetUrl, List events) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath + "/hooks/" + webhookId; + + StringBuilder body = new StringBuilder(); + body.append("{\"url\":\"").append(targetUrl).append("\""); + + for (String event : events) { + String gitlabEvent = convertToGitLabEvent(event); + if (gitlabEvent != null) { + body.append(",\"").append(gitlabEvent).append("\":true"); + } + } + body.append("}"); + + Request request = createPutRequest(url, body.toString()); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("update webhook", response); + } + + return webhookId; + } + } + + private String convertToGitLabEvent(String event) { + return switch (event.toLowerCase()) { + // GitLab native event names (pass through) - support both singular and plural forms + case "merge_requests_events", "merge_request_events" -> "merge_requests_events"; + case "note_events" -> "note_events"; + case "push_events" -> "push_events"; + // Generic event names (convert to GitLab format) + case "pullrequest:created", "pullrequest:opened", "pr:opened", + "pullrequest:updated", "pr:updated", "pullrequest:merged", + "pr:merged", "pull_request" -> "merge_requests_events"; + case "pullrequest:comment_created", "pr:comment:added", + "pull_request_review_comment", "issue_comment" -> "note_events"; + case "repo:push", "push" -> "push_events"; + default -> null; + }; + } + + @Override + public void deleteWebhook(String workspaceId, String repoIdOrSlug, String webhookId) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath + "/hooks/" + webhookId; + + Request request = createDeleteRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful() && response.code() != 404) { + throw createException("delete webhook", response); + } + } + } + + @Override + public List listWebhooks(String workspaceId, String repoIdOrSlug) throws IOException { + List webhooks = new ArrayList<>(); + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + int page = 1; + + log.debug("listWebhooks: projectPath={}, encodedPath={}", projectPath, encodedPath); + + while (true) { + String url = baseUrl + "/projects/" + encodedPath + "/hooks?per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page; + log.debug("listWebhooks: calling URL={}", url); + Request request = createGetRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("list webhooks", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + if (!root.isArray() || root.isEmpty()) { + break; + } + + for (JsonNode node : root) { + webhooks.add(parseWebhook(node)); + } + + String nextPage = response.header("X-Next-Page"); + if (nextPage == null || nextPage.isBlank()) { + break; + } + page++; + } + } + + return webhooks; + } + + @Override + public VcsUser getCurrentUser() throws IOException { + Request request = createGetRequest(baseUrl + "/user"); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("get current user", response); + } + + JsonNode node = objectMapper.readTree(response.body().string()); + return parseUser(node); + } + } + + @Override + public VcsWorkspace getWorkspace(String workspaceId) throws IOException { + // Try as group first + String encodedWorkspace = URLEncoder.encode(workspaceId, StandardCharsets.UTF_8); + Request request = createGetRequest(baseUrl + "/groups/" + encodedWorkspace); + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful()) { + JsonNode node = objectMapper.readTree(response.body().string()); + return parseGroup(node); + } + } + + // Try as user + request = createGetRequest(baseUrl + "/users?username=" + encodedWorkspace); + try (Response response = httpClient.newCall(request).execute()) { + if (response.isSuccessful()) { + JsonNode root = objectMapper.readTree(response.body().string()); + if (root.isArray() && !root.isEmpty()) { + JsonNode node = root.get(0); + VcsUser user = parseUser(node); + return new VcsWorkspace( + user.id(), + user.username(), + user.displayName() != null ? user.displayName() : user.username(), + false, + user.avatarUrl(), + user.htmlUrl() + ); + } + } + + if (response.code() == 404) { + return null; + } + throw createException("get workspace/user", response); + } + } + + @Override + public byte[] downloadRepositoryArchive(String workspaceId, String repoIdOrSlug, String branchOrCommit) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath + "/repository/archive.zip?sha=" + + URLEncoder.encode(branchOrCommit, StandardCharsets.UTF_8); + + Request request = createGetRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("download repository archive", response); + } + + ResponseBody body = response.body(); + if (body == null) { + throw new IOException("Empty response body when downloading archive"); + } + + return body.bytes(); + } + } + + @Override + public long downloadRepositoryArchiveToFile(String workspaceId, String repoIdOrSlug, String branchOrCommit, Path targetFile) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath + "/repository/archive.zip?sha=" + + URLEncoder.encode(branchOrCommit, StandardCharsets.UTF_8); + + Request request = createGetRequest(url); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("download repository archive", response); + } + + ResponseBody body = response.body(); + if (body == null) { + throw new IOException("Empty response body when downloading archive"); + } + + try (InputStream inputStream = body.byteStream(); + OutputStream outputStream = java.nio.file.Files.newOutputStream(targetFile)) { + byte[] buffer = new byte[8192]; + long totalBytesRead = 0; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalBytesRead += bytesRead; + } + return totalBytesRead; + } + } + } + + @Override + public String getFileContent(String workspaceId, String repoIdOrSlug, String filePath, String branchOrCommit) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedProjectPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String encodedFilePath = URLEncoder.encode(filePath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedProjectPath + "/repository/files/" + encodedFilePath + + "/raw?ref=" + URLEncoder.encode(branchOrCommit, StandardCharsets.UTF_8); + + Request request = createGetRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + if (response.code() == 404) { + return null; + } + throw createException("get file content", response); + } + + ResponseBody body = response.body(); + if (body == null) { + return null; + } + + return body.string(); + } + } + + @Override + public String getLatestCommitHash(String workspaceId, String repoIdOrSlug, String branchName) throws IOException { + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = baseUrl + "/projects/" + encodedPath + "/repository/branches/" + + URLEncoder.encode(branchName, StandardCharsets.UTF_8); + + Request request = createGetRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("get latest commit", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + JsonNode commit = root.get("commit"); + return commit != null ? getTextOrNull(commit, "id") : null; + } + } + + @Override + public List getRepositoryCollaborators(String workspaceId, String repoIdOrSlug) throws IOException { + List collaborators = new ArrayList<>(); + String projectPath = workspaceId + "/" + repoIdOrSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + int page = 1; + + while (true) { + String url = baseUrl + "/projects/" + encodedPath + "/members/all?per_page=" + DEFAULT_PAGE_SIZE + "&page=" + page; + Request request = createGetRequest(url); + + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + if (response.code() == 403) { + throw new IOException("No permission to view project members."); + } + throw createException("get project members", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + + if (root != null && root.isArray()) { + for (JsonNode memberNode : root) { + VcsCollaborator collab = parseCollaborator(memberNode); + if (collab != null) { + collaborators.add(collab); + } + } + } + + String nextPage = response.header("X-Next-Page"); + if (nextPage == null || nextPage.isBlank()) { + break; + } + page++; + } + } + + return collaborators; + } + + private VcsCollaborator parseCollaborator(JsonNode node) { + if (node == null) return null; + + String id = String.valueOf(node.get("id").asLong()); + String username = getTextOrNull(node, "username"); + String name = getTextOrNull(node, "name"); + String avatarUrl = getTextOrNull(node, "avatar_url"); + String webUrl = getTextOrNull(node, "web_url"); + + // GitLab uses access_level numbers + int accessLevel = node.has("access_level") ? node.get("access_level").asInt() : 0; + String permission = mapAccessLevel(accessLevel); + + return new VcsCollaborator(id, username, name != null ? name : username, avatarUrl, permission, webUrl); + } + + private String mapAccessLevel(int accessLevel) { + return switch (accessLevel) { + case 50 -> "owner"; + case 40 -> "maintainer"; + case 30 -> "developer"; + case 20 -> "reporter"; + case 10 -> "guest"; + default -> "unknown"; + }; + } + + private boolean isCurrentUser(String workspaceId) { + try { + VcsUser currentUser = getCurrentUser(); + return currentUser != null && currentUser.username().equalsIgnoreCase(workspaceId); + } catch (IOException e) { + return false; + } + } + + private VcsRepositoryPage fetchRepositoryPage(String url, String workspaceId, int page) throws IOException { + Request request = createGetRequest(url); + try (Response response = httpClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw createException("fetch repositories", response); + } + + JsonNode root = objectMapper.readTree(response.body().string()); + + List repos = new ArrayList<>(); + Integer totalCount = null; + + // GitLab returns total count in headers + String totalHeader = response.header("X-Total"); + if (totalHeader != null && !totalHeader.isBlank()) { + totalCount = Integer.parseInt(totalHeader); + } + + if (root.isArray()) { + for (JsonNode node : root) { + repos.add(parseRepository(node, workspaceId)); + } + } + + String nextPage = response.header("X-Next-Page"); + boolean hasNext = nextPage != null && !nextPage.isBlank(); + boolean hasPrevious = page > 1; + + return new VcsRepositoryPage( + repos, + page, + DEFAULT_PAGE_SIZE, + repos.size(), + totalCount, + hasNext, + hasPrevious + ); + } + } + + private VcsRepository parseRepository(JsonNode node, String workspaceIdFallback) { + String id = String.valueOf(node.get("id").asLong()); + String name = getTextOrNull(node, "name"); + String path = getTextOrNull(node, "path"); + String pathWithNamespace = getTextOrNull(node, "path_with_namespace"); + String description = getTextOrNull(node, "description"); + boolean isPrivate = node.has("visibility") && !"public".equals(node.get("visibility").asText()); + String defaultBranch = getTextOrNull(node, "default_branch"); + String httpUrlToRepo = getTextOrNull(node, "http_url_to_repo"); + String webUrl = getTextOrNull(node, "web_url"); + + String workspaceSlug = workspaceIdFallback; + if (node.has("namespace") && node.get("namespace").has("path")) { + workspaceSlug = node.get("namespace").get("path").asText(); + } else if (pathWithNamespace != null && pathWithNamespace.contains("/")) { + workspaceSlug = pathWithNamespace.substring(0, pathWithNamespace.indexOf('/')); + } + + String avatarUrl = null; + if (node.has("avatar_url") && !node.get("avatar_url").isNull()) { + avatarUrl = node.get("avatar_url").asText(); + } + + return new VcsRepository( + id, + path != null ? path : name, + name, + pathWithNamespace, + description, + isPrivate, + defaultBranch, + httpUrlToRepo, + webUrl, + workspaceSlug, + avatarUrl + ); + } + + private VcsWorkspace parseGroup(JsonNode node) { + String id = String.valueOf(node.get("id").asLong()); + String path = getTextOrNull(node, "path"); + String name = getTextOrNull(node, "name"); + if (name == null) { + name = path; + } + + String avatarUrl = getTextOrNull(node, "avatar_url"); + String webUrl = getTextOrNull(node, "web_url"); + + return new VcsWorkspace(id, path, name, true, avatarUrl, webUrl); + } + + private VcsUser parseUser(JsonNode node) { + String id = String.valueOf(node.get("id").asLong()); + String username = getTextOrNull(node, "username"); + String name = getTextOrNull(node, "name"); + String email = getTextOrNull(node, "email"); + String avatarUrl = getTextOrNull(node, "avatar_url"); + String webUrl = getTextOrNull(node, "web_url"); + + return new VcsUser(id, username, name != null ? name : username, email, avatarUrl, webUrl); + } + + private VcsWebhook parseWebhook(JsonNode node) { + String id = String.valueOf(node.get("id").asLong()); + String url = getTextOrNull(node, "url"); + boolean active = !node.has("enable_ssl_verification") || node.get("enable_ssl_verification").asBoolean(); + + List events = new ArrayList<>(); + if (node.has("push_events") && node.get("push_events").asBoolean()) { + events.add("push"); + } + if (node.has("merge_requests_events") && node.get("merge_requests_events").asBoolean()) { + events.add("merge_request"); + } + if (node.has("note_events") && node.get("note_events").asBoolean()) { + events.add("note"); + } + + return new VcsWebhook(id, url, active, events, null); + } + + private String getTextOrNull(JsonNode node, String field) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; + } + + private IOException createException(String operation, Response response) throws IOException { + String body = response.body() != null ? response.body().string() : ""; + GitLabException cause = new GitLabException(operation, response.code(), body); + return new IOException(cause.getMessage(), cause); + } + + private Request createPostRequest(String url, String jsonBody) { + return new Request.Builder() + .url(url) + .header(ACCEPT_HEADER, GITLAB_ACCEPT_HEADER) + .post(RequestBody.create(jsonBody, JSON_MEDIA_TYPE)) + .build(); + } + + private Request createPutRequest(String url, String jsonBody) { + return new Request.Builder() + .url(url) + .header(ACCEPT_HEADER, GITLAB_ACCEPT_HEADER) + .put(RequestBody.create(jsonBody, JSON_MEDIA_TYPE)) + .build(); + } + + private Request createDeleteRequest(String url) { + return new Request.Builder() + .url(url) + .header(ACCEPT_HEADER, GITLAB_ACCEPT_HEADER) + .delete() + .build(); + } + + private Request createGetRequest(String url) { + return new Request.Builder() + .url(url) + .header(ACCEPT_HEADER, GITLAB_ACCEPT_HEADER) + .get() + .build(); + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabConfig.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabConfig.java new file mode 100644 index 00000000..3adbdee0 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabConfig.java @@ -0,0 +1,14 @@ +package org.rostilos.codecrow.vcsclient.gitlab; + +/** + * Configuration constants for GitLab API access. + */ +public final class GitLabConfig { + + public static final String API_BASE = "https://gitlab.com/api/v4"; + public static final int DEFAULT_PAGE_SIZE = 20; + + private GitLabConfig() { + // Utility class + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabException.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabException.java new file mode 100644 index 00000000..c2d0a54d --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/GitLabException.java @@ -0,0 +1,36 @@ +package org.rostilos.codecrow.vcsclient.gitlab; + +/** + * Exception for GitLab API errors. + */ +public class GitLabException extends RuntimeException { + + private final int statusCode; + private final String responseBody; + + public GitLabException(String operation, int statusCode, String responseBody) { + super(String.format("GitLab %s failed: %d - %s", operation, statusCode, responseBody)); + this.statusCode = statusCode; + this.responseBody = responseBody; + } + + public GitLabException(String message) { + super(message); + this.statusCode = -1; + this.responseBody = null; + } + + public GitLabException(String message, Throwable cause) { + super(message, cause); + this.statusCode = -1; + this.responseBody = null; + } + + public int getStatusCode() { + return statusCode; + } + + public String getResponseBody() { + return responseBody; + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/CheckFileExistsInBranchAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/CheckFileExistsInBranchAction.java new file mode 100644 index 00000000..5426e7c6 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/CheckFileExistsInBranchAction.java @@ -0,0 +1,62 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Action to check if a file exists in a specific branch in GitLab. + */ +public class CheckFileExistsInBranchAction { + + private static final Logger log = LoggerFactory.getLogger(CheckFileExistsInBranchAction.class); + private final OkHttpClient authorizedOkHttpClient; + + public CheckFileExistsInBranchAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Check if a file exists in a specific branch. + * + * @param namespace the project namespace (group or user) + * @param project the project path + * @param branchName the branch name + * @param filePath the file path to check + * @return true if the file exists, false otherwise + */ + public boolean fileExists(String namespace, String project, String branchName, String filePath) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String encodedFilePath = URLEncoder.encode(filePath, StandardCharsets.UTF_8); + + String apiUrl = String.format("%s/projects/%s/repository/files/%s?ref=%s", + GitLabConfig.API_BASE, encodedPath, encodedFilePath, + URLEncoder.encode(branchName, StandardCharsets.UTF_8)); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .head() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (resp.code() == 404) { + return false; + } + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + log.warn("GitLab returned non-success response {} for file check: {}", resp.code(), body); + throw new IOException("Failed to check file existence: " + resp.code()); + } + return true; + } + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/CommentOnMergeRequestAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/CommentOnMergeRequestAction.java new file mode 100644 index 00000000..f5400d73 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/CommentOnMergeRequestAction.java @@ -0,0 +1,189 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Action to comment on GitLab Merge Requests. + */ +public class CommentOnMergeRequestAction { + + private static final Logger log = LoggerFactory.getLogger(CommentOnMergeRequestAction.class); + private static final MediaType JSON = MediaType.parse("application/json"); + private final OkHttpClient authorizedOkHttpClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public CommentOnMergeRequestAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Post a general comment on a merge request. + */ + public void postComment(String namespace, String project, int mergeRequestIid, String body) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String apiUrl = String.format("%s/projects/%s/merge_requests/%d/notes", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid); + + Map payload = new HashMap<>(); + payload.put("body", body); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .post(RequestBody.create(objectMapper.writeValueAsString(payload), JSON)) + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String respBody = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("GitLab returned non-success response %d for URL %s: %s", + resp.code(), apiUrl, respBody); + log.warn(msg); + throw new IOException(msg); + } + } + } + + /** + * Post an inline comment on a specific file and line in a merge request. + */ + public void postLineComment(String namespace, String project, int mergeRequestIid, + String body, String baseSha, String headSha, String startSha, + String filePath, int newLine) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String apiUrl = String.format("%s/projects/%s/merge_requests/%d/discussions", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid); + + Map position = new HashMap<>(); + position.put("base_sha", baseSha); + position.put("head_sha", headSha); + position.put("start_sha", startSha); + position.put("position_type", "text"); + position.put("new_path", filePath); + position.put("new_line", newLine); + + Map payload = new HashMap<>(); + payload.put("body", body); + payload.put("position", position); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .post(RequestBody.create(objectMapper.writeValueAsString(payload), JSON)) + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String respBody = resp.body() != null ? resp.body().string() : ""; + log.warn("Failed to post line comment: {} - {}", resp.code(), respBody); + } + } + } + + /** + * List all notes (comments) on a merge request. + */ + public List> listNotes(String namespace, String project, int mergeRequestIid) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String apiUrl = String.format("%s/projects/%s/merge_requests/%d/notes?per_page=100", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String respBody = resp.body() != null ? resp.body().string() : ""; + log.warn("Failed to list notes: {} - {}", resp.code(), respBody); + return List.of(); + } + String body = resp.body() != null ? resp.body().string() : "[]"; + return objectMapper.readValue(body, new TypeReference>>() {}); + } + } + + /** + * Update an existing note on a merge request. + */ + public void updateNote(String namespace, String project, int mergeRequestIid, long noteId, String body) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String apiUrl = String.format("%s/projects/%s/merge_requests/%d/notes/%d", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid, noteId); + + Map payload = new HashMap<>(); + payload.put("body", body); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .put(RequestBody.create(objectMapper.writeValueAsString(payload), JSON)) + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String respBody = resp.body() != null ? resp.body().string() : ""; + log.warn("Failed to update note: {} - {}", resp.code(), respBody); + throw new IOException("Failed to update note: " + resp.code()); + } + } + } + + /** + * Delete a note from a merge request. + */ + public void deleteNote(String namespace, String project, int mergeRequestIid, long noteId) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String apiUrl = String.format("%s/projects/%s/merge_requests/%d/notes/%d", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid, noteId); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .delete() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful() && resp.code() != 404) { + String respBody = resp.body() != null ? resp.body().string() : ""; + log.warn("Failed to delete note: {} - {}", resp.code(), respBody); + } + } + } + + /** + * Find an existing comment by marker. + */ + public Long findCommentByMarker(String namespace, String project, int mergeRequestIid, String marker) throws IOException { + List> notes = listNotes(namespace, project, mergeRequestIid); + for (Map note : notes) { + Object bodyObj = note.get("body"); + if (bodyObj != null && bodyObj.toString().contains(marker)) { + Object idObj = note.get("id"); + if (idObj instanceof Number) { + return ((Number) idObj).longValue(); + } + } + } + return null; + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetCommitDiffAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetCommitDiffAction.java new file mode 100644 index 00000000..92bfb83c --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetCommitDiffAction.java @@ -0,0 +1,119 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Action to get the diff for a specific commit in GitLab. + */ +public class GetCommitDiffAction { + + private static final Logger log = LoggerFactory.getLogger(GetCommitDiffAction.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private final OkHttpClient authorizedOkHttpClient; + + public GetCommitDiffAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Get the diff for a specific commit. + * + * @param namespace the project namespace (group or user) + * @param project the project path + * @param commitSha the commit SHA + * @return the diff as a unified diff string + */ + public String getCommitDiff(String namespace, String project, String commitSha) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + + String apiUrl = String.format("%s/projects/%s/repository/commits/%s/diff", + GitLabConfig.API_BASE, encodedPath, commitSha); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("GitLab returned non-success response %d for URL %s: %s", + resp.code(), apiUrl, body); + log.warn(msg); + throw new IOException(msg); + } + + String responseBody = resp.body() != null ? resp.body().string() : "[]"; + return buildUnifiedDiff(responseBody); + } + } + + /** + * Build a unified diff from GitLab's diff response. + */ + private String buildUnifiedDiff(String responseBody) throws IOException { + StringBuilder combinedDiff = new StringBuilder(); + JsonNode diffs = objectMapper.readTree(responseBody); + + if (diffs == null || !diffs.isArray()) { + log.warn("No diffs found in commit response"); + return ""; + } + + for (JsonNode diffEntry : diffs) { + String oldPath = diffEntry.has("old_path") ? diffEntry.get("old_path").asText() : ""; + String newPath = diffEntry.has("new_path") ? diffEntry.get("new_path").asText() : ""; + String diff = diffEntry.has("diff") ? diffEntry.get("diff").asText() : ""; + boolean newFile = diffEntry.has("new_file") && diffEntry.get("new_file").asBoolean(); + boolean deletedFile = diffEntry.has("deleted_file") && diffEntry.get("deleted_file").asBoolean(); + boolean renamedFile = diffEntry.has("renamed_file") && diffEntry.get("renamed_file").asBoolean(); + + // Build unified diff header + String fromFile = renamedFile ? oldPath : newPath; + combinedDiff.append("diff --git a/").append(fromFile).append(" b/").append(newPath).append("\n"); + + if (newFile) { + combinedDiff.append("new file mode 100644\n"); + combinedDiff.append("--- /dev/null\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else if (deletedFile) { + combinedDiff.append("deleted file mode 100644\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ /dev/null\n"); + } else if (renamedFile) { + combinedDiff.append("rename from ").append(oldPath).append("\n"); + combinedDiff.append("rename to ").append(newPath).append("\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else { + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } + + // Append the actual diff content + if (!diff.isEmpty()) { + combinedDiff.append(diff); + if (!diff.endsWith("\n")) { + combinedDiff.append("\n"); + } + } + + combinedDiff.append("\n"); + } + + return combinedDiff.toString(); + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetCommitRangeDiffAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetCommitRangeDiffAction.java new file mode 100644 index 00000000..226b6a29 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetCommitRangeDiffAction.java @@ -0,0 +1,122 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Action to compare two commits in GitLab and get the diff. + */ +public class GetCommitRangeDiffAction { + + private static final Logger log = LoggerFactory.getLogger(GetCommitRangeDiffAction.class); + private final OkHttpClient authorizedOkHttpClient; + + public GetCommitRangeDiffAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Get the diff between two commits. + * + * @param namespace the project namespace (group or user) + * @param project the project path + * @param baseCommitSha the base commit SHA + * @param headCommitSha the head commit SHA + * @return the diff as a unified diff string + */ + public String getCommitRangeDiff(String namespace, String project, String baseCommitSha, String headCommitSha) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + + // GitLab uses compare endpoint for commit range diff + String apiUrl = String.format("%s/projects/%s/repository/compare?from=%s&to=%s", + GitLabConfig.API_BASE, encodedPath, + URLEncoder.encode(baseCommitSha, StandardCharsets.UTF_8), + URLEncoder.encode(headCommitSha, StandardCharsets.UTF_8)); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("GitLab returned non-success response %d for URL %s: %s", + resp.code(), apiUrl, body); + log.warn(msg); + throw new IOException(msg); + } + + String responseBody = resp.body() != null ? resp.body().string() : "{}"; + return buildUnifiedDiff(responseBody); + } + } + + /** + * Build a unified diff from GitLab's compare response. + */ + private String buildUnifiedDiff(String responseBody) throws IOException { + StringBuilder combinedDiff = new StringBuilder(); + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(responseBody); + com.fasterxml.jackson.databind.JsonNode diffs = root.get("diffs"); + + if (diffs == null || !diffs.isArray()) { + log.warn("No diffs found in compare response"); + return ""; + } + + for (com.fasterxml.jackson.databind.JsonNode diffEntry : diffs) { + String oldPath = diffEntry.has("old_path") ? diffEntry.get("old_path").asText() : ""; + String newPath = diffEntry.has("new_path") ? diffEntry.get("new_path").asText() : ""; + String diff = diffEntry.has("diff") ? diffEntry.get("diff").asText() : ""; + boolean newFile = diffEntry.has("new_file") && diffEntry.get("new_file").asBoolean(); + boolean deletedFile = diffEntry.has("deleted_file") && diffEntry.get("deleted_file").asBoolean(); + boolean renamedFile = diffEntry.has("renamed_file") && diffEntry.get("renamed_file").asBoolean(); + + // Build unified diff header + String fromFile = renamedFile ? oldPath : newPath; + combinedDiff.append("diff --git a/").append(fromFile).append(" b/").append(newPath).append("\n"); + + if (newFile) { + combinedDiff.append("new file mode 100644\n"); + combinedDiff.append("--- /dev/null\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else if (deletedFile) { + combinedDiff.append("deleted file mode 100644\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ /dev/null\n"); + } else if (renamedFile) { + combinedDiff.append("rename from ").append(oldPath).append("\n"); + combinedDiff.append("rename to ").append(newPath).append("\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else { + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } + + // Append the actual diff content + if (!diff.isEmpty()) { + combinedDiff.append(diff); + if (!diff.endsWith("\n")) { + combinedDiff.append("\n"); + } + } + + combinedDiff.append("\n"); + } + + return combinedDiff.toString(); + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetMergeRequestAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetMergeRequestAction.java new file mode 100644 index 00000000..b742a294 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetMergeRequestAction.java @@ -0,0 +1,63 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Action to get GitLab Merge Request metadata. + */ +public class GetMergeRequestAction { + + private static final Logger log = LoggerFactory.getLogger(GetMergeRequestAction.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private final OkHttpClient authorizedOkHttpClient; + + public GetMergeRequestAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Get merge request metadata. + * + * @param namespace the project namespace (group or user) + * @param project the project path + * @param mergeRequestIid the merge request IID (internal ID) + * @return JsonNode containing MR metadata (title, description, source_branch, target_branch, etc.) + */ + public JsonNode getMergeRequest(String namespace, String project, int mergeRequestIid) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + + String apiUrl = String.format("%s/projects/%s/merge_requests/%d", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("GitLab returned non-success response %d for URL %s: %s", + resp.code(), apiUrl, body); + log.warn(msg); + throw new IOException(msg); + } + + String responseBody = resp.body() != null ? resp.body().string() : "{}"; + return objectMapper.readTree(responseBody); + } + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetMergeRequestDiffAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetMergeRequestDiffAction.java new file mode 100644 index 00000000..8add4509 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/GetMergeRequestDiffAction.java @@ -0,0 +1,121 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * Action to get the diff for a GitLab Merge Request. + */ +public class GetMergeRequestDiffAction { + + private static final Logger log = LoggerFactory.getLogger(GetMergeRequestDiffAction.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + private final OkHttpClient authorizedOkHttpClient; + + public GetMergeRequestDiffAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Get the diff for a merge request. + * + * @param namespace the project namespace (group or user) + * @param project the project path + * @param mergeRequestIid the merge request IID (internal ID) + * @return the diff as a unified diff string + */ + public String getMergeRequestDiff(String namespace, String project, int mergeRequestIid) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + + // First, get the merge request changes (diffs) + String apiUrl = String.format("%s/projects/%s/merge_requests/%d/changes", + GitLabConfig.API_BASE, encodedPath, mergeRequestIid); + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + String msg = String.format("GitLab returned non-success response %d for URL %s: %s", + resp.code(), apiUrl, body); + log.warn(msg); + throw new IOException(msg); + } + + String responseBody = resp.body() != null ? resp.body().string() : "{}"; + return buildUnifiedDiff(responseBody); + } + } + + /** + * Build a unified diff from GitLab's changes response. + */ + private String buildUnifiedDiff(String responseBody) throws IOException { + StringBuilder combinedDiff = new StringBuilder(); + JsonNode root = objectMapper.readTree(responseBody); + JsonNode changes = root.get("changes"); + + if (changes == null || !changes.isArray()) { + log.warn("No changes found in merge request response"); + return ""; + } + + for (JsonNode change : changes) { + String oldPath = change.has("old_path") ? change.get("old_path").asText() : ""; + String newPath = change.has("new_path") ? change.get("new_path").asText() : ""; + String diff = change.has("diff") ? change.get("diff").asText() : ""; + boolean newFile = change.has("new_file") && change.get("new_file").asBoolean(); + boolean deletedFile = change.has("deleted_file") && change.get("deleted_file").asBoolean(); + boolean renamedFile = change.has("renamed_file") && change.get("renamed_file").asBoolean(); + + // Build unified diff header + String fromFile = renamedFile ? oldPath : newPath; + combinedDiff.append("diff --git a/").append(fromFile).append(" b/").append(newPath).append("\n"); + + if (newFile) { + combinedDiff.append("new file mode 100644\n"); + combinedDiff.append("--- /dev/null\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else if (deletedFile) { + combinedDiff.append("deleted file mode 100644\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ /dev/null\n"); + } else if (renamedFile) { + combinedDiff.append("rename from ").append(oldPath).append("\n"); + combinedDiff.append("rename to ").append(newPath).append("\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else { + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } + + // Append the actual diff content + if (!diff.isEmpty()) { + combinedDiff.append(diff); + if (!diff.endsWith("\n")) { + combinedDiff.append("\n"); + } + } + + combinedDiff.append("\n"); + } + + return combinedDiff.toString(); + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/SearchRepositoriesAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/SearchRepositoriesAction.java new file mode 100644 index 00000000..2ae8b713 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/SearchRepositoriesAction.java @@ -0,0 +1,194 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.rostilos.codecrow.vcsclient.gitlab.dto.response.RepositorySearchResult; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Action to search GitLab repositories. + */ +public class SearchRepositoriesAction { + + private static final Logger log = LoggerFactory.getLogger(SearchRepositoriesAction.class); + private static final int PAGE_SIZE = 30; + private final OkHttpClient authorizedOkHttpClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public SearchRepositoriesAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Get all repositories accessible to the authenticated user. + */ + public RepositorySearchResult getRepositories(String groupId, int page) throws IOException { + String url; + if (groupId != null && !groupId.isBlank()) { + // Get repositories within a specific group + String encodedGroup = URLEncoder.encode(groupId, StandardCharsets.UTF_8); + url = String.format("%s/groups/%s/projects?per_page=%d&page=%d&order_by=updated_at&sort=desc&include_subgroups=true", + GitLabConfig.API_BASE, encodedGroup, PAGE_SIZE, page); + } else { + // Get all repositories accessible to the user + url = String.format("%s/projects?per_page=%d&page=%d&order_by=updated_at&sort=desc&membership=true", + GitLabConfig.API_BASE, PAGE_SIZE, page); + } + return fetchRepositories(url); + } + + /** + * Get repositories for a specific group/namespace. + */ + public RepositorySearchResult getGroupRepositories(String groupId, int page) throws IOException { + String encodedGroup = URLEncoder.encode(groupId, StandardCharsets.UTF_8); + String url = String.format("%s/groups/%s/projects?per_page=%d&page=%d&order_by=updated_at&sort=desc&include_subgroups=true", + GitLabConfig.API_BASE, encodedGroup, PAGE_SIZE, page); + return fetchRepositories(url); + } + + /** + * Search repositories by name. + */ + public RepositorySearchResult searchRepositories(String groupId, String query, int page) throws IOException { + String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8); + String url; + + if (groupId != null && !groupId.isBlank()) { + String encodedGroup = URLEncoder.encode(groupId, StandardCharsets.UTF_8); + url = String.format("%s/groups/%s/projects?search=%s&per_page=%d&page=%d&order_by=updated_at&sort=desc&include_subgroups=true", + GitLabConfig.API_BASE, encodedGroup, encodedQuery, PAGE_SIZE, page); + } else { + url = String.format("%s/projects?search=%s&per_page=%d&page=%d&order_by=updated_at&sort=desc&membership=true", + GitLabConfig.API_BASE, encodedQuery, PAGE_SIZE, page); + } + + return fetchRepositories(url); + } + + /** + * Get total count of repositories in a group. + */ + public int getRepositoriesCount(String groupId) throws IOException { + if (groupId == null || groupId.isBlank()) { + // Get count of all accessible repositories + String url = String.format("%s/projects?per_page=1&membership=true", GitLabConfig.API_BASE); + return fetchTotalCount(url); + } else { + String encodedGroup = URLEncoder.encode(groupId, StandardCharsets.UTF_8); + String url = String.format("%s/groups/%s/projects?per_page=1&include_subgroups=true", + GitLabConfig.API_BASE, encodedGroup); + return fetchTotalCount(url); + } + } + + private int fetchTotalCount(String url) throws IOException { + Request req = new Request.Builder() + .url(url) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + log.warn("Failed to get repository count: {}", resp.code()); + return 0; + } + + // GitLab returns total count in X-Total header + String totalHeader = resp.header("X-Total"); + if (totalHeader != null) { + try { + return Integer.parseInt(totalHeader); + } catch (NumberFormatException e) { + log.warn("Failed to parse X-Total header: {}", totalHeader); + } + } + return 0; + } + } + + private RepositorySearchResult fetchRepositories(String url) throws IOException { + Request req = new Request.Builder() + .url(url) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + log.warn("Failed to fetch repositories: {} - {}", resp.code(), body); + return new RepositorySearchResult(List.of(), false, 0); + } + + String body = resp.body() != null ? resp.body().string() : "[]"; + JsonNode root = objectMapper.readTree(body); + + List> items = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode node : root) { + items.add(parseRepository(node)); + } + } + + // Check for next page using X-Next-Page header + String nextPageHeader = resp.header("X-Next-Page"); + boolean hasNext = nextPageHeader != null && !nextPageHeader.isBlank(); + + // Get total count from X-Total header + String totalHeader = resp.header("X-Total"); + Integer totalCount = null; + if (totalHeader != null) { + try { + totalCount = Integer.parseInt(totalHeader); + } catch (NumberFormatException e) { + log.debug("Failed to parse X-Total header: {}", totalHeader); + } + } + + return new RepositorySearchResult(items, hasNext, totalCount); + } + } + + private Map parseRepository(JsonNode node) { + Map repo = new HashMap<>(); + repo.put("id", node.has("id") ? node.get("id").asLong() : null); + repo.put("name", node.has("name") ? node.get("name").asText() : null); + repo.put("full_name", node.has("path_with_namespace") ? node.get("path_with_namespace").asText() : null); + repo.put("description", node.has("description") && !node.get("description").isNull() + ? node.get("description").asText() : null); + repo.put("html_url", node.has("web_url") ? node.get("web_url").asText() : null); + repo.put("clone_url", node.has("http_url_to_repo") ? node.get("http_url_to_repo").asText() : null); + repo.put("ssh_url", node.has("ssh_url_to_repo") ? node.get("ssh_url_to_repo").asText() : null); + repo.put("default_branch", node.has("default_branch") ? node.get("default_branch").asText() : "main"); + repo.put("private", node.has("visibility") ? !"public".equals(node.get("visibility").asText()) : true); + repo.put("updated_at", node.has("last_activity_at") ? node.get("last_activity_at").asText() : null); + repo.put("created_at", node.has("created_at") ? node.get("created_at").asText() : null); + + // GitLab uses namespace for owner info + if (node.has("namespace")) { + JsonNode ns = node.get("namespace"); + Map owner = new HashMap<>(); + owner.put("login", ns.has("path") ? ns.get("path").asText() : null); + owner.put("avatar_url", ns.has("avatar_url") && !ns.get("avatar_url").isNull() + ? ns.get("avatar_url").asText() : null); + repo.put("owner", owner); + } + + return repo; + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/ValidateConnectionAction.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/ValidateConnectionAction.java new file mode 100644 index 00000000..0f2acd1b --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/actions/ValidateConnectionAction.java @@ -0,0 +1,43 @@ +package org.rostilos.codecrow.vcsclient.gitlab.actions; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Action to validate a GitLab connection. + */ +public class ValidateConnectionAction { + + private static final Logger log = LoggerFactory.getLogger(ValidateConnectionAction.class); + private final OkHttpClient authorizedOkHttpClient; + + public ValidateConnectionAction(OkHttpClient authorizedOkHttpClient) { + this.authorizedOkHttpClient = authorizedOkHttpClient; + } + + /** + * Check if the connection is valid by calling the /user endpoint. + */ + public boolean isConnectionValid() { + String apiUrl = GitLabConfig.API_BASE + "/user"; + + Request req = new Request.Builder() + .url(apiUrl) + .header("Accept", "application/json") + .get() + .build(); + + try (Response resp = authorizedOkHttpClient.newCall(req).execute()) { + return resp.isSuccessful(); + } catch (IOException e) { + log.warn("Failed to validate GitLab connection: {}", e.getMessage()); + return false; + } + } +} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/dto/response/RepositorySearchResult.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/dto/response/RepositorySearchResult.java new file mode 100644 index 00000000..c611e3d7 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/gitlab/dto/response/RepositorySearchResult.java @@ -0,0 +1,10 @@ +package org.rostilos.codecrow.vcsclient.gitlab.dto.response; + +import java.util.List; +import java.util.Map; + +public record RepositorySearchResult( + List> items, + boolean hasNext, + Integer totalCount +) {} diff --git a/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/model/VcsCollaborator.java b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/model/VcsCollaborator.java new file mode 100644 index 00000000..dd919561 --- /dev/null +++ b/java-ecosystem/libs/vcs-client/src/main/java/org/rostilos/codecrow/vcsclient/model/VcsCollaborator.java @@ -0,0 +1,39 @@ +package org.rostilos.codecrow.vcsclient.model; + +/** + * Represents a repository collaborator/member with their permission level. + * + * @param userId Unique user ID in the VCS system + * @param username The user's login/username + * @param displayName Human-readable display name + * @param avatarUrl URL to the user's avatar image + * @param permission Permission level (e.g., "read", "write", "admin") + * @param htmlUrl URL to the user's profile page + */ +public record VcsCollaborator( + String userId, + String username, + String displayName, + String avatarUrl, + String permission, + String htmlUrl +) { + /** + * Check if this collaborator has write access or higher. + */ + public boolean hasWriteAccess() { + if (permission == null) return false; + String p = permission.toLowerCase(); + return p.equals("write") || p.equals("admin") || p.equals("owner") + || p.equals("maintain") || p.equals("push"); + } + + /** + * Check if this collaborator has admin access. + */ + public boolean hasAdminAccess() { + if (permission == null) return false; + String p = permission.toLowerCase(); + return p.equals("admin") || p.equals("owner"); + } +} diff --git a/java-ecosystem/mcp-servers/platform-mcp/src/main/java/org/rostilos/codecrow/platformmcp/service/VcsService.java b/java-ecosystem/mcp-servers/platform-mcp/src/main/java/org/rostilos/codecrow/platformmcp/service/VcsService.java index 11e4eae6..e79860a9 100644 --- a/java-ecosystem/mcp-servers/platform-mcp/src/main/java/org/rostilos/codecrow/platformmcp/service/VcsService.java +++ b/java-ecosystem/mcp-servers/platform-mcp/src/main/java/org/rostilos/codecrow/platformmcp/service/VcsService.java @@ -26,6 +26,7 @@ * - pullRequest.id: PR number * - vcs.provider: "bitbucket" or "github" (default: bitbucket) */ +//TODO: to delete ( we already have vcs-mcp for that.... ) public class VcsService { private static final Logger log = LoggerFactory.getLogger(VcsService.class); diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/.gitignore b/java-ecosystem/mcp-servers/vcs-mcp/.gitignore similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/.gitignore rename to java-ecosystem/mcp-servers/vcs-mcp/.gitignore diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/MCP_CORRECTNESS.MD b/java-ecosystem/mcp-servers/vcs-mcp/MCP_CORRECTNESS.MD similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/MCP_CORRECTNESS.MD rename to java-ecosystem/mcp-servers/vcs-mcp/MCP_CORRECTNESS.MD diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/pom.xml b/java-ecosystem/mcp-servers/vcs-mcp/pom.xml similarity index 97% rename from java-ecosystem/mcp-servers/bitbucket-mcp/pom.xml rename to java-ecosystem/mcp-servers/vcs-mcp/pom.xml index eb5db099..4bd6bbfb 100644 --- a/java-ecosystem/mcp-servers/bitbucket-mcp/pom.xml +++ b/java-ecosystem/mcp-servers/vcs-mcp/pom.xml @@ -10,8 +10,8 @@ ../../pom.xml - codecrow-mcp-servers - codecrow-mcp-servers + codecrow-vcs-mcp + codecrow-vcs-mcp jar 1.0 diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/module-info.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/module-info.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/module-info.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/module-info.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.bak b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.bak similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.bak rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.bak diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpHttpServer.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpStdioServer.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpStdioServer.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpStdioServer.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpStdioServer.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpTools.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpTools.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/McpTools.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/McpTools.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketCloudException.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketCloudException.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketCloudException.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketCloudException.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketConfiguration.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketConfiguration.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketConfiguration.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/BitbucketConfiguration.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClient.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClient.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClient.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClient.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientAdapter.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientAdapter.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientAdapter.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientAdapter.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientFactory.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientFactory.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientFactory.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientFactory.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientImpl.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientImpl.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientImpl.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/BitbucketCloudClientImpl.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketAccount.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketAccount.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketAccount.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketAccount.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranch.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranch.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranch.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranch.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchReference.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchReference.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchReference.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchReference.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModel.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModel.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModel.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModel.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModelSettings.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModelSettings.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModelSettings.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketBranchingModelSettings.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketLink.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketLink.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketLink.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketLink.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketParticipant.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketParticipant.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketParticipant.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketParticipant.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProject.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProject.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProject.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProject.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProjectBranchingModel.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProjectBranchingModel.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProjectBranchingModel.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketProjectBranchingModel.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketPullRequest.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketPullRequest.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketPullRequest.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketPullRequest.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketRepository.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketRepository.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketRepository.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketRepository.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketWorkspace.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketWorkspace.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketWorkspace.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/cloud/model/BitbucketWorkspace.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/DiffType.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/DiffType.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/DiffType.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/DiffType.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/FileDiff.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/FileDiff.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/FileDiff.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/FileDiff.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/PullRequestDiff.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/PullRequestDiff.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/PullRequestDiff.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/PullRequestDiff.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/RawDiffParser.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/RawDiffParser.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/RawDiffParser.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/bitbucket/pullrequest/diff/RawDiffParser.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/filter/LargeContentFilter.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/filter/LargeContentFilter.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/filter/LargeContentFilter.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/filter/LargeContentFilter.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/FileDiffInfo.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/FileDiffInfo.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/FileDiffInfo.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/FileDiffInfo.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClient.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClient.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClient.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClient.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClientFactory.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClientFactory.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClientFactory.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/generic/VcsMcpClientFactory.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubClientFactory.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubClientFactory.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubClientFactory.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubClientFactory.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubConfiguration.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubConfiguration.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubConfiguration.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubConfiguration.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubException.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubException.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubException.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubException.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubMcpClientImpl.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubMcpClientImpl.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubMcpClientImpl.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/github/GitHubMcpClientImpl.java diff --git a/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabClientFactory.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabClientFactory.java new file mode 100644 index 00000000..fb26c87d --- /dev/null +++ b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabClientFactory.java @@ -0,0 +1,52 @@ +package org.rostilos.codecrow.mcp.gitlab; + +import okhttp3.OkHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.TimeUnit; + +/** + * Factory for creating GitLab MCP clients. + */ +public class GitLabClientFactory { + + private static final Logger LOGGER = LoggerFactory.getLogger(GitLabClientFactory.class); + + public GitLabMcpClientImpl createClient() { + // Use the same property names as other providers for consistency + String accessToken = System.getProperty("accessToken"); + String namespace = System.getProperty("workspace"); // GitLab uses namespace, but we receive workspace + String project = System.getProperty("repo.slug"); + String mrIid = System.getProperty("pullRequest.id"); // MR IID in GitLab + + if (accessToken == null || accessToken.isEmpty()) { + throw new IllegalStateException("accessToken system property is required for GitLab"); + } + if (namespace == null || namespace.isEmpty()) { + throw new IllegalStateException("workspace system property is required for GitLab"); + } + if (project == null || project.isEmpty()) { + throw new IllegalStateException("repo.slug system property is required for GitLab"); + } + + int fileLimit = Integer.parseInt(System.getProperty("file.limit", "0")); + GitLabConfiguration configuration = new GitLabConfiguration(accessToken, namespace, project, mrIid); + + OkHttpClient httpClient = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .addInterceptor(chain -> { + okhttp3.Request originalRequest = chain.request(); + okhttp3.Request.Builder builder = originalRequest.newBuilder() + .header("Authorization", "Bearer " + accessToken) + .header("Accept", "application/json"); + return chain.proceed(builder.build()); + }) + .build(); + + LOGGER.info("Created GitLab MCP client for {}/{}", namespace, project); + return new GitLabMcpClientImpl(httpClient, configuration, fileLimit); + } +} diff --git a/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabConfiguration.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabConfiguration.java new file mode 100644 index 00000000..a57865f5 --- /dev/null +++ b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabConfiguration.java @@ -0,0 +1,35 @@ +package org.rostilos.codecrow.mcp.gitlab; + +/** + * Configuration for GitLab MCP client. + */ +public class GitLabConfiguration { + + private final String accessToken; + private final String namespace; + private final String project; + private final String mrIid; + + public GitLabConfiguration(String accessToken, String namespace, String project, String mrIid) { + this.accessToken = accessToken; + this.namespace = namespace; + this.project = project; + this.mrIid = mrIid; + } + + public String getAccessToken() { + return accessToken; + } + + public String getNamespace() { + return namespace; + } + + public String getProject() { + return project; + } + + public String getMrIid() { + return mrIid; + } +} diff --git a/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabException.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabException.java new file mode 100644 index 00000000..ac0b59b6 --- /dev/null +++ b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabException.java @@ -0,0 +1,15 @@ +package org.rostilos.codecrow.mcp.gitlab; + +/** + * Exception for GitLab MCP operations. + */ +public class GitLabException extends RuntimeException { + + public GitLabException(String message) { + super(message); + } + + public GitLabException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabMcpClientImpl.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabMcpClientImpl.java new file mode 100644 index 00000000..7720b792 --- /dev/null +++ b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/gitlab/GitLabMcpClientImpl.java @@ -0,0 +1,575 @@ +package org.rostilos.codecrow.mcp.gitlab; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.*; +import org.rostilos.codecrow.mcp.generic.FileDiffInfo; +import org.rostilos.codecrow.mcp.generic.VcsMcpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * GitLab implementation of VcsMcpClient. + * Handles GitLab-specific API interactions for MCP tools. + */ +public class GitLabMcpClientImpl implements VcsMcpClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(GitLabMcpClientImpl.class); + private static final String API_BASE = "https://gitlab.com/api/v4"; + private static final MediaType JSON = MediaType.parse("application/json"); + private static final Pattern DIFF_FILE_PATTERN = Pattern.compile("^diff --git a/(\\S+) b/(\\S+)"); + + private final OkHttpClient httpClient; + private final GitLabConfiguration config; + private final ObjectMapper objectMapper; + private final int fileLimit; + private JsonNode mergeRequestCache; + + public GitLabMcpClientImpl(OkHttpClient httpClient, GitLabConfiguration config, int fileLimit) { + this.httpClient = httpClient; + this.config = config; + this.objectMapper = new ObjectMapper(); + this.fileLimit = fileLimit; + } + + @Override + public String getProviderType() { + return "gitlab"; + } + + @Override + public String getPrNumber() { + return config.getMrIid(); + } + + @Override + public String getPullRequestTitle() throws IOException { + JsonNode mr = getMergeRequestJson(); + return mr.has("title") ? mr.get("title").asText() : ""; + } + + @Override + public String getPullRequestDescription() throws IOException { + JsonNode mr = getMergeRequestJson(); + return mr.has("description") && !mr.get("description").isNull() ? mr.get("description").asText() : ""; + } + + @Override + public List getPullRequestChanges() throws IOException { + String diff = getMergeRequestDiff(config.getNamespace(), config.getProject(), config.getMrIid()); + List changes = parseDiff(diff); + + int count = 0; + for (FileDiffInfo change : changes) { + if (fileLimit > 0 && count >= fileLimit) break; + count++; + } + + return changes; + } + + private List parseDiff(String rawDiff) { + List files = new ArrayList<>(); + if (rawDiff == null || rawDiff.isEmpty()) return files; + + String[] lines = rawDiff.split("\n"); + StringBuilder currentDiff = new StringBuilder(); + String currentFile = null; + String diffType = "MODIFIED"; + + for (String line : lines) { + Matcher m = DIFF_FILE_PATTERN.matcher(line); + if (m.find()) { + if (currentFile != null) { + files.add(new FileDiffInfo(currentFile, diffType, null, currentDiff.toString())); + } + currentFile = m.group(2); + currentDiff = new StringBuilder(); + diffType = "MODIFIED"; + } + + if (line.startsWith("new file mode")) { + diffType = "ADDED"; + } else if (line.startsWith("deleted file mode")) { + diffType = "DELETED"; + } + + if (currentFile != null) { + currentDiff.append(line).append("\n"); + } + } + + if (currentFile != null) { + files.add(new FileDiffInfo(currentFile, diffType, null, currentDiff.toString())); + } + + return files; + } + + @Override + public List> listRepositories(String namespace, Integer limit) throws IOException { + int perPage = limit != null ? Math.min(limit, 100) : 20; + String encodedNamespace = URLEncoder.encode(namespace, StandardCharsets.UTF_8); + + // First try as group + String url = String.format("%s/groups/%s/projects?per_page=%d&order_by=updated_at&sort=desc", + API_BASE, encodedNamespace, perPage); + + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + if (resp.isSuccessful()) { + JsonNode root = objectMapper.readTree(resp.body().string()); + List> repos = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode node : root) { + repos.add(parseRepository(node)); + } + } + return repos; + } + } + + // Fallback to user projects + url = String.format("%s/users/%s/projects?per_page=%d&order_by=updated_at&sort=desc", + API_BASE, encodedNamespace, perPage); + req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + JsonNode root = parseResponse(resp, "listRepositories"); + List> repos = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode node : root) { + repos.add(parseRepository(node)); + } + } + return repos; + } + } + + @Override + public Map getRepository(String namespace, String projectSlug) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s", API_BASE, encodedPath); + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + JsonNode node = parseResponse(resp, "getRepository"); + return parseRepository(node); + } + } + + @Override + public List> getPullRequests(String namespace, String projectSlug, String state, Integer limit) throws IOException { + String gitlabState = state != null ? mapMrState(state) : "opened"; + int perPage = limit != null ? Math.min(limit, 100) : 20; + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests?state=%s&per_page=%d", + API_BASE, encodedPath, gitlabState, perPage); + + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + JsonNode root = parseResponse(resp, "getPullRequests"); + List> mrs = new ArrayList<>(); + if (root.isArray()) { + for (JsonNode node : root) { + mrs.add(parseMergeRequest(node)); + } + } + return mrs; + } + } + + @Override + public Map createPullRequest(String namespace, String projectSlug, String title, String description, + String sourceBranch, String targetBranch, List reviewers) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests", API_BASE, encodedPath); + + Map body = new HashMap<>(); + body.put("title", title); + body.put("description", description); + body.put("source_branch", sourceBranch); + body.put("target_branch", targetBranch); + + if (reviewers != null && !reviewers.isEmpty()) { + body.put("reviewer_ids", reviewers); + } + + Request req = new Request.Builder() + .url(url) + .post(RequestBody.create(objectMapper.writeValueAsString(body), JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + JsonNode node = parseResponse(resp, "createPullRequest"); + return parseMergeRequest(node); + } + } + + @Override + public Map getPullRequest(String namespace, String projectSlug, String mrIid) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s", API_BASE, encodedPath, mrIid); + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + return parseMergeRequest(parseResponse(resp, "getPullRequest")); + } + } + + @Override + public Map updatePullRequest(String namespace, String projectSlug, String mrIid, + String title, String description) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s", API_BASE, encodedPath, mrIid); + + Map body = new HashMap<>(); + if (title != null) body.put("title", title); + if (description != null) body.put("description", description); + + Request req = new Request.Builder() + .url(url) + .put(RequestBody.create(objectMapper.writeValueAsString(body), JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + return parseMergeRequest(parseResponse(resp, "updatePullRequest")); + } + } + + @Override + public Object getPullRequestActivity(String namespace, String projectSlug, String mrIid) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/resource_state_events", + API_BASE, encodedPath, mrIid); + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public Object approvePullRequest(String namespace, String projectSlug, String mrIid) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/approve", API_BASE, encodedPath, mrIid); + + Request req = new Request.Builder() + .url(url) + .post(RequestBody.create("{}", JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public Object unapprovePullRequest(String namespace, String projectSlug, String mrIid) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/unapprove", API_BASE, encodedPath, mrIid); + + Request req = new Request.Builder() + .url(url) + .post(RequestBody.create("{}", JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public Object declinePullRequest(String namespace, String projectSlug, String mrIid, String message) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s", API_BASE, encodedPath, mrIid); + + Map body = Map.of("state_event", "close"); + + Request req = new Request.Builder() + .url(url) + .put(RequestBody.create(objectMapper.writeValueAsString(body), JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public Object mergePullRequest(String namespace, String projectSlug, String mrIid, String message, String strategy) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/merge", API_BASE, encodedPath, mrIid); + + Map body = new HashMap<>(); + if (message != null) body.put("merge_commit_message", message); + if (strategy != null) { + if ("squash".equalsIgnoreCase(strategy)) { + body.put("squash", true); + } + } + + Request req = new Request.Builder() + .url(url) + .put(RequestBody.create(objectMapper.writeValueAsString(body), JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public Object getPullRequestComments(String namespace, String projectSlug, String mrIid) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/notes", API_BASE, encodedPath, mrIid); + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public String getPullRequestDiff(String namespace, String projectSlug, String mrIid) throws IOException { + return getMergeRequestDiff(namespace, projectSlug, mrIid); + } + + private String getMergeRequestDiff(String namespace, String project, String mrIid) throws IOException { + String projectPath = namespace + "/" + project; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/changes", API_BASE, encodedPath, mrIid); + + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + throw new IOException("Failed to get MR diff: " + resp.code() + " - " + body); + } + + String responseBody = resp.body() != null ? resp.body().string() : "{}"; + return buildUnifiedDiff(responseBody); + } + } + + private String buildUnifiedDiff(String responseBody) throws IOException { + StringBuilder combinedDiff = new StringBuilder(); + JsonNode root = objectMapper.readTree(responseBody); + JsonNode changes = root.get("changes"); + + if (changes == null || !changes.isArray()) { + return ""; + } + + int fileCount = 0; + for (JsonNode change : changes) { + if (fileLimit > 0 && fileCount >= fileLimit) { + break; + } + fileCount++; + + String oldPath = change.has("old_path") ? change.get("old_path").asText() : ""; + String newPath = change.has("new_path") ? change.get("new_path").asText() : ""; + String diff = change.has("diff") ? change.get("diff").asText() : ""; + boolean newFile = change.has("new_file") && change.get("new_file").asBoolean(); + boolean deletedFile = change.has("deleted_file") && change.get("deleted_file").asBoolean(); + boolean renamedFile = change.has("renamed_file") && change.get("renamed_file").asBoolean(); + + String fromFile = renamedFile ? oldPath : newPath; + combinedDiff.append("diff --git a/").append(fromFile).append(" b/").append(newPath).append("\n"); + + if (newFile) { + combinedDiff.append("new file mode 100644\n"); + combinedDiff.append("--- /dev/null\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else if (deletedFile) { + combinedDiff.append("deleted file mode 100644\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ /dev/null\n"); + } else if (renamedFile) { + combinedDiff.append("rename from ").append(oldPath).append("\n"); + combinedDiff.append("rename to ").append(newPath).append("\n"); + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } else { + combinedDiff.append("--- a/").append(oldPath).append("\n"); + combinedDiff.append("+++ b/").append(newPath).append("\n"); + } + + if (!diff.isEmpty()) { + combinedDiff.append(diff); + if (!diff.endsWith("\n")) { + combinedDiff.append("\n"); + } + } + + combinedDiff.append("\n"); + } + + return combinedDiff.toString(); + } + + @Override + public Object getPullRequestCommits(String namespace, String projectSlug, String mrIid) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s/commits", API_BASE, encodedPath, mrIid); + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + return objectMapper.readValue(resp.body().string(), Object.class); + } + } + + @Override + public Map getBranchingModel(String namespace, String projectSlug) throws IOException { + return Map.of( + "message", "GitLab does not have a native branching model concept", + "default_branch", getDefaultBranch(namespace, projectSlug) + ); + } + + @Override + public Map getBranchingModelSettings(String namespace, String projectSlug) throws IOException { + return getBranchingModel(namespace, projectSlug); + } + + @Override + public Map updateBranchingModelSettings(String namespace, String projectSlug, + Map development, + Map production, + List> branchTypes) throws IOException { + return Map.of("message", "GitLab does not support branching model configuration via API"); + } + + @Override + public String getBranchFileContent(String namespace, String projectSlug, String branch, String filePath) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedProjectPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String encodedFilePath = URLEncoder.encode(filePath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/repository/files/%s/raw?ref=%s", + API_BASE, encodedProjectPath, encodedFilePath, URLEncoder.encode(branch, StandardCharsets.UTF_8)); + + Request req = new Request.Builder().url(url).get().build(); + + try (Response resp = httpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + if (resp.code() == 404) { + return "File not found: " + filePath; + } + throw new IOException("Failed to get file content: " + resp.code()); + } + return resp.body().string(); + } + } + + @Override + public String getRootDirectory(String namespace, String projectSlug, String branch) throws IOException { + return getDirectoryByPath(namespace, projectSlug, branch, ""); + } + + @Override + public String getDirectoryByPath(String namespace, String projectSlug, String branch, String dirPath) throws IOException { + String projectPath = namespace + "/" + projectSlug; + String encodedProjectPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String path = dirPath == null || dirPath.isEmpty() ? "" : "&path=" + URLEncoder.encode(dirPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/repository/tree?ref=%s%s", + API_BASE, encodedProjectPath, URLEncoder.encode(branch, StandardCharsets.UTF_8), path); + + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + if (!resp.isSuccessful()) { + throw new IOException("Failed to get directory: " + resp.code()); + } + return resp.body().string(); + } + } + + private JsonNode getMergeRequestJson() throws IOException { + if (mergeRequestCache != null) return mergeRequestCache; + + String projectPath = config.getNamespace() + "/" + config.getProject(); + String encodedPath = URLEncoder.encode(projectPath, StandardCharsets.UTF_8); + String url = String.format("%s/projects/%s/merge_requests/%s", API_BASE, encodedPath, config.getMrIid()); + Request req = new Request.Builder().url(url).get().build(); + + try (Response resp = httpClient.newCall(req).execute()) { + mergeRequestCache = parseResponse(resp, "getMergeRequestJson"); + return mergeRequestCache; + } + } + + private String getDefaultBranch(String namespace, String project) throws IOException { + Map repoInfo = getRepository(namespace, project); + return (String) repoInfo.getOrDefault("default_branch", "main"); + } + + private JsonNode parseResponse(Response resp, String operation) throws IOException { + if (!resp.isSuccessful()) { + String body = resp.body() != null ? resp.body().string() : ""; + throw new GitLabException(String.format("%s failed: %d - %s", operation, resp.code(), body)); + } + return objectMapper.readTree(resp.body().string()); + } + + private Map parseRepository(JsonNode node) { + Map repo = new HashMap<>(); + repo.put("id", node.get("id").asLong()); + repo.put("name", getTextOrNull(node, "name")); + repo.put("path", getTextOrNull(node, "path")); + repo.put("path_with_namespace", getTextOrNull(node, "path_with_namespace")); + repo.put("full_name", getTextOrNull(node, "path_with_namespace")); + repo.put("description", getTextOrNull(node, "description")); + repo.put("private", !"public".equals(getTextOrNull(node, "visibility"))); + repo.put("default_branch", getTextOrNull(node, "default_branch")); + repo.put("web_url", getTextOrNull(node, "web_url")); + repo.put("html_url", getTextOrNull(node, "web_url")); + repo.put("http_url_to_repo", getTextOrNull(node, "http_url_to_repo")); + repo.put("clone_url", getTextOrNull(node, "http_url_to_repo")); + return repo; + } + + private Map parseMergeRequest(JsonNode node) { + Map mr = new HashMap<>(); + mr.put("id", node.get("id").asLong()); + mr.put("iid", node.get("iid").asInt()); + mr.put("number", node.get("iid").asInt()); + mr.put("title", getTextOrNull(node, "title")); + mr.put("description", getTextOrNull(node, "description")); + mr.put("state", getTextOrNull(node, "state")); + mr.put("web_url", getTextOrNull(node, "web_url")); + mr.put("html_url", getTextOrNull(node, "web_url")); + mr.put("source_branch", getTextOrNull(node, "source_branch")); + mr.put("target_branch", getTextOrNull(node, "target_branch")); + mr.put("author", node.has("author") ? node.get("author").get("username").asText() : null); + mr.put("created_on", getTextOrNull(node, "created_at")); + mr.put("updated_on", getTextOrNull(node, "updated_at")); + mr.put("merged", "merged".equals(getTextOrNull(node, "state"))); + return mr; + } + + private String getTextOrNull(JsonNode node, String field) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; + } + + private String mapMrState(String state) { + return switch (state.toUpperCase()) { + case "OPEN", "OPENED" -> "opened"; + case "MERGED" -> "merged"; + case "CLOSED", "DECLINED" -> "closed"; + default -> "all"; + }; + } +} diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/util/TokenLimitGuard.java b/java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/util/TokenLimitGuard.java similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/java/org/rostilos/codecrow/mcp/util/TokenLimitGuard.java rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/java/org/rostilos/codecrow/mcp/util/TokenLimitGuard.java diff --git a/java-ecosystem/mcp-servers/bitbucket-mcp/src/main/resources/logback.xml b/java-ecosystem/mcp-servers/vcs-mcp/src/main/resources/logback.xml similarity index 100% rename from java-ecosystem/mcp-servers/bitbucket-mcp/src/main/resources/logback.xml rename to java-ecosystem/mcp-servers/vcs-mcp/src/main/resources/logback.xml diff --git a/java-ecosystem/pom.xml b/java-ecosystem/pom.xml index 976e5ea1..990e8424 100644 --- a/java-ecosystem/pom.xml +++ b/java-ecosystem/pom.xml @@ -194,7 +194,7 @@ services/web-server services/pipeline-agent - mcp-servers/bitbucket-mcp + mcp-servers/vcs-mcp mcp-servers/platform-mcp diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudBranchWebhookHandler.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudBranchWebhookHandler.java index 635981c9..13f49d5f 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudBranchWebhookHandler.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudBranchWebhookHandler.java @@ -91,8 +91,19 @@ public WebhookResult handle(WebhookPayload payload, Project project, Consumer> processorConsumer = event -> { if (eventConsumer != null) { diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudPullRequestWebhookHandler.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudPullRequestWebhookHandler.java index 4fbe38b6..14723acf 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudPullRequestWebhookHandler.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/handler/BitbucketCloudPullRequestWebhookHandler.java @@ -166,6 +166,12 @@ private WebhookResult handlePullRequestEvent(WebhookPayload payload, Project pro project ); + // Check if analysis failed (processor returns status=error on IOException) + if ("error".equals(result.get("status"))) { + String errorMessage = (String) result.getOrDefault("message", "Analysis failed"); + return WebhookResult.error("PR analysis failed: " + errorMessage); + } + boolean cached = Boolean.TRUE.equals(result.get("cached")); if (cached) { return WebhookResult.success("Analysis result retrieved from cache", result); 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 5b34848d..cd648813 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 @@ -2,6 +2,7 @@ import okhttp3.OkHttpClient; import org.rostilos.codecrow.core.model.ai.AIConnection; +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisMode; import org.rostilos.codecrow.core.model.codeanalysis.AnalysisType; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; import org.rostilos.codecrow.core.model.project.Project; @@ -22,6 +23,7 @@ import org.rostilos.codecrow.analysisengine.util.DiffParser; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; import org.rostilos.codecrow.vcsclient.VcsClientProvider; +import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.GetCommitRangeDiffAction; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.GetPullRequestAction; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.GetPullRequestDiffAction; import org.slf4j.Logger; @@ -37,6 +39,18 @@ @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. + * Below this threshold, full analysis might be more effective. + */ + private static final int MIN_DELTA_DIFF_SIZE = 500; private final TokenEncryptionService tokenEncryptionService; private final VcsClientProvider vcsClientProvider; @@ -100,12 +114,16 @@ public AiAnalysisRequest buildPrAnalysisRequest( AIConnection aiConnection = project.getAiBinding().getAiConnection(); AIConnection projectAiConnection = project.getAiBinding().getAiConnection(); - // Fetch PR diff and extract metadata for RAG + // Initialize variables List changedFiles = Collections.emptyList(); List diffSnippets = Collections.emptyList(); String prTitle = null; String prDescription = null; String rawDiff = null; + String deltaDiff = null; + AnalysisMode analysisMode = AnalysisMode.FULL; + String previousCommitHash = previousAnalysis.map(CodeAnalysis::getCommitHash).orElse(null); + String currentCommitHash = request.getCommitHash(); try { OkHttpClient client = vcsClientProvider.getHttpClient(vcsConnection); @@ -124,7 +142,7 @@ public AiAnalysisRequest buildPrAnalysisRequest( log.info("Fetched PR metadata: title='{}', description length={}", prTitle, prDescription != null ? prDescription.length() : 0); - // Fetch PR diff + // Fetch full PR diff GetPullRequestDiffAction diffAction = new GetPullRequestDiffAction(client); String fetchedDiff = diffAction.getPullRequestDiff( vcsInfo.workspace(), @@ -132,8 +150,7 @@ public AiAnalysisRequest buildPrAnalysisRequest( String.valueOf(request.getPullRequestId()) ); - // Apply content filter (same rules as LargeContentFilter in MCP server) - // Filters out files larger than 25KB to reduce token usage + // Apply content filter DiffContentFilter contentFilter = new DiffContentFilter(); rawDiff = contentFilter.filterDiff(fetchedDiff); @@ -146,12 +163,52 @@ public AiAnalysisRequest buildPrAnalysisRequest( originalSize > 0 ? (100 - (filteredSize * 100 / originalSize)) : 0); } + // 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 {})", + deltaSize, Math.round(deltaRatio * 100), fullSize); + } else { + 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 + } + } else if (deltaSize < MIN_DELTA_DIFF_SIZE) { + log.info("Delta diff too small ({} chars), using FULL analysis", deltaSize); + deltaDiff = null; + } + } else { + log.info("Could not fetch delta diff, using FULL analysis"); + } + } else { + log.info("Using FULL analysis mode (first analysis or same commit)"); + } + // Parse diff to extract changed files and code snippets - changedFiles = DiffParser.extractChangedFiles(rawDiff); - diffSnippets = DiffParser.extractDiffSnippets(rawDiff, 20); + // For incremental mode, parse from delta diff; for full mode, from full diff + String diffToParse = analysisMode == AnalysisMode.INCREMENTAL && deltaDiff != null ? deltaDiff : rawDiff; + changedFiles = DiffParser.extractChangedFiles(diffToParse); + diffSnippets = DiffParser.extractDiffSnippets(diffToParse, 20); - log.info("Extracted {} changed files, {} code snippets, raw diff size: {} chars", - changedFiles.size(), diffSnippets.size(), rawDiff != null ? rawDiff.length() : 0); + log.info("Analysis mode: {}, extracted {} changed files, {} code snippets", + analysisMode, changedFiles.size(), diffSnippets.size()); } catch (IOException e) { log.warn("Failed to fetch/parse PR metadata/diff for RAG context: {}", e.getMessage()); @@ -175,13 +232,49 @@ public AiAnalysisRequest buildPrAnalysisRequest( .withRawDiff(rawDiff) .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()) .withTargetBranchName(request.targetBranchName) - .withVcsProvider("bitbucket_cloud"); + .withVcsProvider("bitbucket_cloud") + // Incremental analysis fields + .withAnalysisMode(analysisMode) + .withDeltaDiff(deltaDiff) + .withPreviousCommitHash(previousCommitHash) + .withCurrentCommitHash(currentCommitHash); // 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, + String headCommit, + DiffContentFilter contentFilter + ) { + try { + GetCommitRangeDiffAction rangeDiffAction = new GetCommitRangeDiffAction(client); + String fetchedDeltaDiff = rangeDiffAction.getCommitRangeDiff( + vcsInfo.workspace(), + vcsInfo.repoSlug(), + baseCommit, + 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())), + e.getMessage()); + return null; + } + } public AiAnalysisRequest buildBranchAnalysisRequest( Project project, diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketOperationsService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketOperationsService.java index d08c8b16..ffdeb57b 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketOperationsService.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/service/BitbucketOperationsService.java @@ -5,6 +5,8 @@ import org.rostilos.codecrow.analysisengine.service.vcs.VcsOperationsService; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.CheckFileExistsInBranchAction; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.GetCommitDiffAction; +import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.GetCommitRangeDiffAction; +import org.rostilos.codecrow.vcsclient.bitbucket.cloud.actions.GetPullRequestDiffAction; import org.springframework.stereotype.Service; import java.io.IOException; @@ -27,6 +29,18 @@ public String getCommitDiff(OkHttpClient client, String workspace, String repoSl return action.getCommitDiff(workspace, repoSlug, commitHash); } + @Override + public String getPullRequestDiff(OkHttpClient client, String workspace, String repoSlug, String prNumber) throws IOException { + GetPullRequestDiffAction action = new GetPullRequestDiffAction(client); + return action.getPullRequestDiff(workspace, repoSlug, prNumber); + } + + @Override + public String getCommitRangeDiff(OkHttpClient client, String workspace, String repoSlug, String baseCommitHash, String headCommitHash) throws IOException { + GetCommitRangeDiffAction action = new GetCommitRangeDiffAction(client); + return action.getCommitRangeDiff(workspace, repoSlug, baseCommitHash, headCommitHash); + } + @Override public boolean checkFileExistsInBranch(OkHttpClient client, String workspace, String repoSlug, String branchName, String filePath) throws IOException { CheckFileExistsInBranchAction action = new CheckFileExistsInBranchAction(client); diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/webhook/BitbucketCloudWebhookParser.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/webhook/BitbucketCloudWebhookParser.java index 13c6328e..44e9f20f 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/webhook/BitbucketCloudWebhookParser.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/bitbucket/webhook/BitbucketCloudWebhookParser.java @@ -29,6 +29,8 @@ public WebhookPayload parse(String eventType, JsonNode payload) { String targetBranch = null; String commitHash = null; CommentData commentData = null; + String prAuthorId = null; + String prAuthorUsername = null; JsonNode repository = payload.path("repository"); if (!repository.isMissingNode()) { @@ -60,6 +62,14 @@ public WebhookPayload parse(String eventType, JsonNode payload) { if (!destination.isMissingNode()) { targetBranch = destination.path("branch").path("name").asText(null); } + + JsonNode author = pullRequest.path("author"); + if (!author.isMissingNode()) { + prAuthorId = extractUuid(author.path("uuid")); + prAuthorUsername = author.path("username").asText( + author.path("nickname").asText(null) + ); + } } JsonNode push = payload.path("push"); @@ -93,7 +103,9 @@ public WebhookPayload parse(String eventType, JsonNode payload) { targetBranch, commitHash, payload, - commentData + commentData, + prAuthorId, + prAuthorUsername ); } diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderPipelineActionController.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderPipelineActionController.java index 003833ec..fa3e56b0 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderPipelineActionController.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderPipelineActionController.java @@ -188,13 +188,15 @@ private ResponseEntity processWebhookWithJob( try { Map quickResult = processingFuture.get(100, java.util.concurrent.TimeUnit.MILLISECONDS); if ("locked".equals(quickResult.get("status"))) { + String lockMessage = (String) quickResult.get("message"); String lockMsg = objectMapper.writeValueAsString( - Map.of("type", "locked", "message", quickResult.get("message")) + Map.of("type", "locked", "message", lockMessage) ); queue.put(lockMsg); enqueueEOF(queue); if (job != null) { - pipelineJobService.getJobService().cancelJob(job); + // Mark as FAILED not cancelled - lock timeout is a failure condition + pipelineJobService.failJob(job, "Analysis lock timeout: " + lockMessage); } return ResponseEntity.ok() .contentType(MediaType.parseMediaType("application/x-ndjson")) @@ -225,10 +227,12 @@ private ResponseEntity processWebhookWithJob( enqueueFinalResult(queue, result); pipelineJobService.failJob(job, (String) result.get("message")); } else if ("locked".equals(status)) { - log.info("Webhook processing locked: {}", result.get("message")); + String lockMessage = (String) result.get("message"); + log.info("Webhook processing locked: {}", lockMessage); enqueueFinalResult(queue, result); if (job != null) { - pipelineJobService.getJobService().cancelJob(job); + // Mark as FAILED not cancelled - lock timeout is a failure condition + pipelineJobService.failJob(job, "Analysis lock timeout: " + lockMessage); } } else { enqueueFinalResult(queue); diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderWebhookController.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderWebhookController.java index 2f622d6d..90b7f01b 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderWebhookController.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/controller/ProviderWebhookController.java @@ -11,6 +11,7 @@ import org.rostilos.codecrow.core.service.JobService; import org.rostilos.codecrow.pipelineagent.bitbucket.webhook.BitbucketCloudWebhookParser; import org.rostilos.codecrow.pipelineagent.github.webhook.GitHubWebhookParser; +import org.rostilos.codecrow.pipelineagent.gitlab.webhook.GitLabWebhookParser; import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload; import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookProjectResolver; import org.rostilos.codecrow.pipelineagent.generic.webhook.handler.WebhookHandler; @@ -44,6 +45,7 @@ public class ProviderWebhookController { private final WebhookProjectResolver projectResolver; private final BitbucketCloudWebhookParser bitbucketParser; private final GitHubWebhookParser githubParser; + private final GitLabWebhookParser gitlabParser; private final ObjectMapper objectMapper; private final WebhookHandlerFactory webhookHandlerFactory; private final JobService jobService; @@ -56,6 +58,7 @@ public ProviderWebhookController( WebhookProjectResolver projectResolver, BitbucketCloudWebhookParser bitbucketParser, GitHubWebhookParser githubParser, + GitLabWebhookParser gitlabParser, ObjectMapper objectMapper, WebhookHandlerFactory webhookHandlerFactory, JobService jobService, @@ -64,6 +67,7 @@ public ProviderWebhookController( this.projectResolver = projectResolver; this.bitbucketParser = bitbucketParser; this.githubParser = githubParser; + this.gitlabParser = gitlabParser; this.objectMapper = objectMapper; this.webhookHandlerFactory = webhookHandlerFactory; this.jobService = jobService; @@ -208,7 +212,7 @@ private WebhookPayload parsePayload(EVcsProvider provider, String eventType, Jso case BITBUCKET_CLOUD -> bitbucketParser.parse(eventType, payload); case BITBUCKET_SERVER -> throw new UnsupportedOperationException("Bitbucket Server not yet implemented"); case GITHUB -> githubParser.parse(eventType, payload); - case GITLAB -> throw new UnsupportedOperationException("GitLab not yet implemented"); + case GITLAB -> gitlabParser.parse(eventType, payload); }; } @@ -237,6 +241,18 @@ private ResponseEntity processWebhook(EVcsProvider provider, WebhookPayload p )); } + // For comment events without CodeCrow commands, ignore immediately without creating a Job + // This prevents DB clutter from non-command comments + if (payload.isCommentEvent() && !payload.hasCodecrowCommand()) { + log.info("Comment event without CodeCrow command - ignoring without creating Job"); + return ResponseEntity.ok(Map.of( + "status", "ignored", + "message", "Not a CodeCrow command comment", + "projectId", project.getId(), + "eventType", payload.eventType() + )); + } + // Create a Job for tracking Job job = createJobForWebhook(payload, project); @@ -288,18 +304,15 @@ private Job createJobForWebhook(WebhookPayload payload, Project project) { ); } - // Comment events without codecrow commands - create generic comment job - if (payload.isCommentEvent()) { - Long prNumber = payload.pullRequestId() != null ? Long.parseLong(payload.pullRequestId()) : null; - return jobService.createIgnoredCommentJob( - project, - prNumber, - payload.eventType(), - JobTriggerSource.WEBHOOK - ); - } + // PR merge events (pullrequest:fulfilled) should be treated as branch analysis, not PR analysis + // because they update the target branch, not review the PR + String eventType = payload.eventType(); + boolean isPrMergeEvent = "pullrequest:fulfilled".equals(eventType) || + "pull_request.closed".equals(eventType) && payload.rawPayload() != null && + payload.rawPayload().path("pull_request").path("merged").asBoolean(false); - if (payload.isPullRequestEvent()) { + if (payload.isPullRequestEvent() && !isPrMergeEvent) { + // PR created/updated - actual PR analysis return jobService.createPrAnalysisJob( project, Long.parseLong(payload.pullRequestId()), @@ -309,10 +322,12 @@ private Job createJobForWebhook(WebhookPayload payload, Project project) { JobTriggerSource.WEBHOOK, null // No user for webhook triggers ); - } else if (payload.isPushEvent()) { + } else if (payload.isPushEvent() || isPrMergeEvent) { + // Push event or PR merge - branch analysis + String branchName = isPrMergeEvent ? payload.targetBranch() : payload.sourceBranch(); return jobService.createBranchAnalysisJob( project, - payload.sourceBranch(), + branchName, payload.commitHash(), JobTriggerSource.WEBHOOK, null diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/handler/CommentCommandWebhookHandler.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/handler/CommentCommandWebhookHandler.java index d94c8f9a..6bd83b18 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/handler/CommentCommandWebhookHandler.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/handler/CommentCommandWebhookHandler.java @@ -15,11 +15,12 @@ import org.rostilos.codecrow.core.service.CodeAnalysisService; import org.rostilos.codecrow.analysisengine.dto.request.processor.PrProcessRequest; import org.rostilos.codecrow.analysisengine.processor.analysis.PullRequestAnalysisProcessor; +import org.rostilos.codecrow.pipelineagent.generic.service.CommandAuthorizationService; +import org.rostilos.codecrow.pipelineagent.generic.service.CommandAuthorizationService.AuthorizationResult; import org.rostilos.codecrow.pipelineagent.generic.service.CommentCommandRateLimitService; import org.rostilos.codecrow.pipelineagent.generic.service.PromptSanitizationService; import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload; import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload.CodecrowCommand; -import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload.CommentData; import org.rostilos.codecrow.pipelineagent.generic.webhook.handler.WebhookHandler; import org.rostilos.codecrow.vcsclient.VcsClientProvider; import org.rostilos.codecrow.vcsclient.github.actions.GetPullRequestAction; @@ -54,6 +55,7 @@ public class CommentCommandWebhookHandler implements WebhookHandler { private final PrSummarizeCacheRepository summarizeCacheRepository; private final PullRequestAnalysisProcessor pullRequestAnalysisProcessor; private final VcsClientProvider vcsClientProvider; + private final CommandAuthorizationService authorizationService; // Command processors injected via Spring private final CommentCommandProcessor summarizeProcessor; @@ -66,6 +68,7 @@ public CommentCommandWebhookHandler( PrSummarizeCacheRepository summarizeCacheRepository, PullRequestAnalysisProcessor pullRequestAnalysisProcessor, VcsClientProvider vcsClientProvider, + CommandAuthorizationService authorizationService, @org.springframework.beans.factory.annotation.Qualifier("summarizeCommandProcessor") CommentCommandProcessor summarizeProcessor, @org.springframework.beans.factory.annotation.Qualifier("askCommandProcessor") CommentCommandProcessor askProcessor ) { @@ -75,6 +78,7 @@ public CommentCommandWebhookHandler( this.summarizeCacheRepository = summarizeCacheRepository; this.pullRequestAnalysisProcessor = pullRequestAnalysisProcessor; this.vcsClientProvider = vcsClientProvider; + this.authorizationService = authorizationService; this.summarizeProcessor = summarizeProcessor; this.askProcessor = askProcessor; } @@ -87,7 +91,7 @@ public EVcsProvider getProvider() { /** * Check if this handler supports the given event type. - * Supports comment events from both Bitbucket and GitHub. + * Supports comment events from Bitbucket, GitHub, and GitLab. */ @Override public boolean supportsEvent(String eventType) { @@ -95,7 +99,8 @@ public boolean supportsEvent(String eventType) { return eventType.contains("comment") || eventType.equals("issue_comment") || - eventType.equals("pull_request_review_comment"); + eventType.equals("pull_request_review_comment") || + eventType.equals("note"); // GitLab Note Hook } /** @@ -107,6 +112,7 @@ public boolean supportsProviderEvent(EVcsProvider provider, String eventType) { return switch (provider) { case BITBUCKET_CLOUD -> eventType.startsWith("pullrequest:comment"); case GITHUB -> eventType.equals("issue_comment") || eventType.equals("pull_request_review_comment"); + case GITLAB -> eventType.equals("note"); default -> false; }; } @@ -404,6 +410,12 @@ private WebhookResult runPrAnalysis( log.debug("PR analysis result: {}", result); + // Check if analysis failed (processor returns status=error on IOException) + if ("error".equals(result.get("status"))) { + String errorMessage = (String) result.getOrDefault("message", "Analysis failed"); + return WebhookResult.error("PR analysis failed: " + errorMessage); + } + boolean cached = Boolean.TRUE.equals(result.get("cached")); Object analysisId = result.get("analysisId"); @@ -581,15 +593,28 @@ private ValidationResult validateRequest(Project project, WebhookPayload payload /** * Check if the comment author is authorized to use commands. + * Uses the CommandAuthorizationService which supports multiple authorization modes: + * - ANYONE: Allow all users + * - PR_AUTHOR_ONLY: Only PR author can execute commands + * - ALLOWED_USERS_ONLY: Only users in the allowed list + * - WORKSPACE_MEMBERS: CodeCrow workspace members + * - REPO_WRITE_ACCESS: Users with write access to the repository */ private boolean isAuthorizedUser(Project project, String authorId, WebhookPayload payload) { - // For now, any workspace member can use commands - // In the future, this could check specific permissions + AuthorizationResult result = authorizationService.checkAuthorization( + project, + payload, + payload.prAuthorId(), + payload.prAuthorUsername() + ); - // TODO: Implement proper user mapping from VCS provider user ID to CodeCrow user - // For now, allow all commands from workspace with connected repos + if (!result.authorized()) { + log.info("User {} not authorized for project {}: {}", authorId, project.getId(), result.reason()); + } else { + log.debug("User {} authorized for project {}: {}", authorId, project.getId(), result.reason()); + } - return project.getWorkspace() != null; + return result.authorized(); } /** diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/CommandAuthorizationService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/CommandAuthorizationService.java new file mode 100644 index 00000000..27dbe56a --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/CommandAuthorizationService.java @@ -0,0 +1,218 @@ +package org.rostilos.codecrow.pipelineagent.generic.service; + +import org.rostilos.codecrow.core.model.project.AllowedCommandUser; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.config.ProjectConfig; +import org.rostilos.codecrow.core.model.project.config.ProjectConfig.CommandAuthorizationMode; +import org.rostilos.codecrow.core.model.project.config.ProjectConfig.CommentCommandsConfig; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.persistence.repository.project.AllowedCommandUserRepository; +import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.*; + +/** + * Service for managing command authorization. + * + * Handles: + * - Checking if a user is authorized to execute commands + * - Managing the allowed users list + * + * Supports three authorization modes: + * - ANYONE: Any user who can comment on a PR can execute commands + * - ALLOWED_USERS_ONLY: Only users in the allowed list can execute commands + * - PR_AUTHOR_ONLY: Only the PR author can execute commands + */ +@Service +public class CommandAuthorizationService { + + private static final Logger log = LoggerFactory.getLogger(CommandAuthorizationService.class); + + private final AllowedCommandUserRepository allowedUserRepository; + + public CommandAuthorizationService(AllowedCommandUserRepository allowedUserRepository) { + this.allowedUserRepository = allowedUserRepository; + } + + /** + * Check if a user is authorized to execute commands for a project. + * + * @param project The project + * @param payload The webhook payload containing user info + * @param prAuthorId PR author's VCS ID (from API enrichment) + * @param prAuthorUsername PR author's username (from API enrichment) + * @return Authorization result with details + */ + public AuthorizationResult checkAuthorization( + Project project, + WebhookPayload payload, + String prAuthorId, + String prAuthorUsername) { + + ProjectConfig config = project.getConfiguration(); + if (config == null) { + return AuthorizationResult.denied("Project configuration not found"); + } + + CommentCommandsConfig commandsConfig = config.getCommentCommandsConfig(); + CommandAuthorizationMode mode = commandsConfig.getEffectiveAuthorizationMode(); + + String vcsUserId = payload.commentData() != null ? payload.commentData().commentAuthorId() : null; + String vcsUsername = payload.commentData() != null ? payload.commentData().commentAuthorUsername() : null; + + log.debug("Checking authorization: mode={}, vcsUserId={}, vcsUsername={}, prAuthorId={}", + mode, vcsUserId, vcsUsername, prAuthorId); + + // Check if PR author bypass is enabled (allowPrAuthor setting) + if (commandsConfig.isPrAuthorAllowed() && mode != CommandAuthorizationMode.PR_AUTHOR_ONLY) { + if (isPrAuthor(vcsUserId, vcsUsername, prAuthorId, prAuthorUsername)) { + log.debug("User is PR author, authorized via PR author bypass"); + return AuthorizationResult.allowed("PR author"); + } + } + + // Check based on authorization mode + return switch (mode) { + case ANYONE -> AuthorizationResult.allowed("ANYONE mode - all commenters allowed"); + + case PR_AUTHOR_ONLY -> { + if (isPrAuthor(vcsUserId, vcsUsername, prAuthorId, prAuthorUsername)) { + yield AuthorizationResult.allowed("PR author"); + } + yield AuthorizationResult.denied("Only the PR author can execute commands on this project"); + } + + case ALLOWED_USERS_ONLY -> checkAllowedUsersList(project.getId(), vcsUserId, vcsUsername); + }; + } + + /** + * Simplified authorization check without PR author info. + */ + public AuthorizationResult checkAuthorization(Project project, WebhookPayload payload) { + return checkAuthorization(project, payload, null, null); + } + + private boolean isPrAuthor(String vcsUserId, String vcsUsername, String prAuthorId, String prAuthorUsername) { + if (vcsUserId != null && prAuthorId != null && vcsUserId.equals(prAuthorId)) { + return true; + } + if (vcsUsername != null && prAuthorUsername != null && + vcsUsername.equalsIgnoreCase(prAuthorUsername)) { + return true; + } + return false; + } + + private AuthorizationResult checkAllowedUsersList(Long projectId, String vcsUserId, String vcsUsername) { + if (vcsUserId != null && allowedUserRepository.existsByProjectIdAndVcsUserIdAndEnabledTrue(projectId, vcsUserId)) { + log.debug("User {} found in allowed list by ID", vcsUserId); + return AuthorizationResult.allowed("In allowed users list"); + } + + if (vcsUsername != null && allowedUserRepository.existsByProjectIdAndVcsUsernameAndEnabledTrue(projectId, vcsUsername)) { + log.debug("User {} found in allowed list by username", vcsUsername); + return AuthorizationResult.allowed("In allowed users list"); + } + + return AuthorizationResult.denied("You are not in the allowed users list for this project"); + } + + public List getAllowedUsers(Long projectId) { + return allowedUserRepository.findByProjectId(projectId); + } + + public List getEnabledAllowedUsers(Long projectId) { + return allowedUserRepository.findByProjectIdAndEnabledTrue(projectId); + } + + @Transactional + public AllowedCommandUser addAllowedUser( + Project project, + String vcsUserId, + String vcsUsername, + String displayName, + String avatarUrl, + String repoPermission, + boolean syncedFromVcs, + String addedBy) { + + Optional existing = allowedUserRepository + .findByProjectIdAndVcsUserId(project.getId(), vcsUserId); + + if (existing.isPresent()) { + AllowedCommandUser user = existing.get(); + user.setVcsUsername(vcsUsername); + user.setDisplayName(displayName); + user.setAvatarUrl(avatarUrl); + user.setRepoPermission(repoPermission); + user.setEnabled(true); + if (syncedFromVcs) { + user.setLastSyncedAt(OffsetDateTime.now()); + } + return allowedUserRepository.save(user); + } + + EVcsProvider provider = getVcsProvider(project); + + AllowedCommandUser user = new AllowedCommandUser(project, provider, vcsUserId, vcsUsername); + user.setDisplayName(displayName); + user.setAvatarUrl(avatarUrl); + user.setRepoPermission(repoPermission); + user.setSyncedFromVcs(syncedFromVcs); + user.setAddedBy(addedBy); + if (syncedFromVcs) { + user.setLastSyncedAt(OffsetDateTime.now()); + } + + return allowedUserRepository.save(user); + } + + @Transactional + public void removeAllowedUser(Long projectId, String vcsUserId) { + allowedUserRepository.deleteByProjectIdAndVcsUserId(projectId, vcsUserId); + } + + @Transactional + public AllowedCommandUser setUserEnabled(Long projectId, String vcsUserId, boolean enabled) { + Optional userOpt = allowedUserRepository + .findByProjectIdAndVcsUserId(projectId, vcsUserId); + + if (userOpt.isEmpty()) { + throw new IllegalArgumentException("User not found: " + vcsUserId); + } + + AllowedCommandUser user = userOpt.get(); + user.setEnabled(enabled); + return allowedUserRepository.save(user); + } + + /** + * Get the VCS provider for a project. + */ + private EVcsProvider getVcsProvider(Project project) { + if (project.getVcsBinding() != null) { + if (project.getVcsBinding().getVcsProvider() != null) { + return project.getVcsBinding().getVcsProvider(); + } + if (project.getVcsBinding().getVcsConnection() != null) { + return project.getVcsBinding().getVcsConnection().getProviderType(); + } + } + return null; + } + + public record AuthorizationResult(boolean authorized, String reason) { + public static AuthorizationResult allowed(String reason) { + return new AuthorizationResult(true, reason); + } + public static AuthorizationResult denied(String reason) { + return new AuthorizationResult(false, reason); + } + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/WebhookAsyncProcessor.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/WebhookAsyncProcessor.java index f182d6d7..25ca9209 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/WebhookAsyncProcessor.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/service/WebhookAsyncProcessor.java @@ -119,15 +119,15 @@ public void processWebhookAsync( jobService.info(job, state, message); }); - // Check if the webhook was ignored (e.g., not a CodeCrow command) + // Check if the webhook was ignored (e.g., branch not matching pattern, analysis disabled) if ("ignored".equals(result.status())) { log.info("Webhook ignored: {}", result.message()); - jobService.info(job, "ignored", result.message()); // Delete placeholder if we posted one for an ignored command if (finalPlaceholderCommentId != null) { deletePlaceholderComment(provider, project, payload, finalPlaceholderCommentId); } - jobService.completeJob(job); + // Delete the job entirely - don't clutter DB with ignored webhooks + jobService.deleteIgnoredJob(job, result.message()); return; } @@ -358,8 +358,12 @@ private void postErrorToVcs(EVcsProvider provider, Project project, WebhookPaylo VcsReportingService reportingService = vcsServiceFactory.getReportingService(provider); + // Sanitize error message for user display - hide sensitive technical details + String sanitizedMessage = sanitizeErrorForVcs(errorMessage); + // Don't prepend marker - it will be added as HTML comment by postComment/updateComment - String content = "⚠️ **CodeCrow Command Failed**\n\n" + errorMessage; + String content = "⚠️ **CodeCrow Command Failed**\n\n" + sanitizedMessage + + "\n\n---\n_Check the job logs in CodeCrow for detailed error information._"; // If we have a placeholder comment, update it with the error if (placeholderCommentId != null) { @@ -387,6 +391,86 @@ private void postErrorToVcs(EVcsProvider provider, Project project, WebhookPaylo } } + /** + * Sanitize error messages for display on VCS platforms. + * Removes sensitive technical details like API keys, quotas, and internal stack traces. + */ + private String sanitizeErrorForVcs(String errorMessage) { + if (errorMessage == null) { + return "An unexpected error occurred during processing."; + } + + String lowerMessage = errorMessage.toLowerCase(); + + // AI provider quota/rate limit errors + if (lowerMessage.contains("quota") || lowerMessage.contains("rate limit") || + lowerMessage.contains("429") || lowerMessage.contains("exceeded")) { + return "The AI provider is currently rate-limited or quota has been exceeded. " + + "Please try again later or contact your administrator to check the AI connection settings."; + } + + // Authentication/API key errors + if (lowerMessage.contains("401") || lowerMessage.contains("403") || + lowerMessage.contains("unauthorized") || lowerMessage.contains("authentication") || + lowerMessage.contains("api key") || lowerMessage.contains("apikey") || + lowerMessage.contains("invalid_api_key")) { + return "AI provider authentication failed. " + + "Please contact your administrator to verify the AI connection configuration."; + } + + // Model not found/invalid + if (lowerMessage.contains("model") && (lowerMessage.contains("not found") || + lowerMessage.contains("invalid") || lowerMessage.contains("does not exist"))) { + return "The configured AI model is not available. " + + "Please contact your administrator to update the AI connection settings."; + } + + // Token limit errors + if (lowerMessage.contains("token") && (lowerMessage.contains("limit") || + lowerMessage.contains("too long") || lowerMessage.contains("maximum"))) { + return "The PR content exceeds the AI model's token limit. " + + "Consider breaking down large PRs or adjusting the token limitation setting."; + } + + // Network/connectivity errors + if (lowerMessage.contains("connection") || lowerMessage.contains("timeout") || + lowerMessage.contains("network") || lowerMessage.contains("unreachable")) { + return "Failed to connect to the AI provider. " + + "Please try again later."; + } + + // Command authorization errors - check BEFORE VCS errors (contains "repository") + if (lowerMessage.contains("not authorized to use") || + lowerMessage.contains("not authorized to execute")) { + // Pass through the authorization message as-is - it's already user-friendly + return errorMessage; + } + + // VCS API errors + if (lowerMessage.contains("vcs") || lowerMessage.contains("bitbucket") || + lowerMessage.contains("github") || lowerMessage.contains("repository")) { + return "An error occurred while communicating with the VCS platform. " + + "Please try again or contact your administrator."; + } + + // Generic AI service errors - don't expose internal details + if (lowerMessage.contains("ai service") || lowerMessage.contains("ai failed") || + lowerMessage.contains("generation failed") || lowerMessage.contains("unexpected error")) { + return "The AI service encountered an error while processing your request. " + + "Please try again later."; + } + + // For other errors, provide a generic message but don't expose technical details + // Only show the first 200 chars if they don't contain sensitive info + if (errorMessage.length() > 200 || errorMessage.contains("{") || + errorMessage.contains("Exception") || errorMessage.contains("at org.")) { + return "An error occurred while processing your request. " + + "Please check the job logs for more details."; + } + + return errorMessage; + } + /** * Post a placeholder comment indicating CodeCrow is processing. * Returns the comment ID for later updating. diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/webhook/WebhookPayload.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/webhook/WebhookPayload.java index 27bd1725..ef5e51fd 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/webhook/WebhookPayload.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/generic/webhook/WebhookPayload.java @@ -26,7 +26,11 @@ public record WebhookPayload( String commitHash, com.fasterxml.jackson.databind.JsonNode rawPayload, - CommentData commentData + CommentData commentData, + + String prAuthorId, + + String prAuthorUsername ) { /** * Data about a PR comment that triggered this webhook. @@ -115,7 +119,27 @@ public WebhookPayload( com.fasterxml.jackson.databind.JsonNode rawPayload ) { this(provider, eventType, externalRepoId, repoSlug, workspaceSlug, - pullRequestId, sourceBranch, targetBranch, commitHash, rawPayload, null); + pullRequestId, sourceBranch, targetBranch, commitHash, rawPayload, null, null, null); + } + + /** + * Constructor with comment data but without PR author info (backwards compatibility). + */ + public WebhookPayload( + EVcsProvider provider, + String eventType, + String externalRepoId, + String repoSlug, + String workspaceSlug, + String pullRequestId, + String sourceBranch, + String targetBranch, + String commitHash, + com.fasterxml.jackson.databind.JsonNode rawPayload, + CommentData commentData + ) { + this(provider, eventType, externalRepoId, repoSlug, workspaceSlug, + pullRequestId, sourceBranch, targetBranch, commitHash, rawPayload, commentData, null, null); } /** @@ -187,7 +211,49 @@ public WebhookPayload withEnrichedPrDetails(String enrichedSourceBranch, String enrichedTargetBranch != null ? enrichedTargetBranch : this.targetBranch, enrichedCommitHash != null ? enrichedCommitHash : this.commitHash, this.rawPayload, - this.commentData + this.commentData, + this.prAuthorId, + this.prAuthorUsername + ); + } + + /** + * Create a new WebhookPayload with PR author information. + * + * @param authorId the PR author's VCS user ID + * @param authorUsername the PR author's VCS username + * @return a new WebhookPayload with the PR author info + */ + public WebhookPayload withPrAuthor(String authorId, String authorUsername) { + return new WebhookPayload( + this.provider, + this.eventType, + this.externalRepoId, + this.repoSlug, + this.workspaceSlug, + this.pullRequestId, + this.sourceBranch, + this.targetBranch, + this.commitHash, + this.rawPayload, + this.commentData, + authorId, + authorUsername ); } + + /** + * Check if the comment author is the PR author. + */ + public boolean isCommentByPrAuthor() { + if (commentData == null || prAuthorId == null) { + return false; + } + // Check by ID first (more reliable) + if (prAuthorId.equals(commentData.commentAuthorId())) { + return true; + } + // Fallback to username comparison + return prAuthorUsername != null && prAuthorUsername.equals(commentData.commentAuthorUsername()); + } } diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/handler/GitHubPullRequestWebhookHandler.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/handler/GitHubPullRequestWebhookHandler.java index 586d7189..7bcf990c 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/handler/GitHubPullRequestWebhookHandler.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/handler/GitHubPullRequestWebhookHandler.java @@ -175,6 +175,12 @@ private WebhookResult handlePullRequestEvent( project ); + // Check if analysis failed (processor returns status=error on IOException) + if ("error".equals(result.get("status"))) { + String errorMessage = (String) result.getOrDefault("message", "Analysis failed"); + return WebhookResult.error("PR analysis failed: " + errorMessage); + } + boolean cached = Boolean.TRUE.equals(result.get("cached")); if (cached) { return WebhookResult.success("Analysis result retrieved from cache", result); 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 1b02c7ad..b72e740e 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 @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import okhttp3.OkHttpClient; import org.rostilos.codecrow.core.model.ai.AIConnection; +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisMode; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.model.project.ProjectVcsConnectionBinding; @@ -21,6 +22,7 @@ import org.rostilos.codecrow.analysisengine.util.DiffParser; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; import org.rostilos.codecrow.vcsclient.VcsClientProvider; +import org.rostilos.codecrow.vcsclient.github.actions.GetCommitRangeDiffAction; import org.rostilos.codecrow.vcsclient.github.actions.GetPullRequestAction; import org.rostilos.codecrow.vcsclient.github.actions.GetPullRequestDiffAction; import org.slf4j.Logger; @@ -36,6 +38,17 @@ @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. + */ + private static final int MIN_DELTA_DIFF_SIZE = 500; private final TokenEncryptionService tokenEncryptionService; private final VcsClientProvider vcsClientProvider; @@ -93,11 +106,16 @@ private AiAnalysisRequest buildPrAnalysisRequest( VcsConnection vcsConnection = vcsInfo.vcsConnection(); AIConnection aiConnection = project.getAiBinding().getAiConnection(); + // Initialize variables List changedFiles = Collections.emptyList(); List diffSnippets = Collections.emptyList(); String prTitle = null; String prDescription = null; String rawDiff = null; + String deltaDiff = null; + AnalysisMode analysisMode = AnalysisMode.FULL; + String previousCommitHash = previousAnalysis.map(CodeAnalysis::getCommitHash).orElse(null); + String currentCommitHash = request.getCommitHash(); try { OkHttpClient client = vcsClientProvider.getHttpClient(vcsConnection); @@ -115,6 +133,7 @@ private AiAnalysisRequest buildPrAnalysisRequest( log.info("Fetched PR metadata: title='{}', description length={}", prTitle, prDescription != null ? prDescription.length() : 0); + // Fetch full PR diff GetPullRequestDiffAction diffAction = new GetPullRequestDiffAction(client); String fetchedDiff = diffAction.getPullRequestDiff( vcsInfo.owner(), @@ -122,8 +141,7 @@ private AiAnalysisRequest buildPrAnalysisRequest( request.getPullRequestId().intValue() ); - // Apply content filter (same rules as LargeContentFilter in MCP server) - // Filters out files larger than 25KB to reduce token usage + // Apply content filter DiffContentFilter contentFilter = new DiffContentFilter(); rawDiff = contentFilter.filterDiff(fetchedDiff); @@ -136,11 +154,51 @@ private AiAnalysisRequest buildPrAnalysisRequest( originalSize > 0 ? (100 - (filteredSize * 100 / originalSize)) : 0); } - changedFiles = DiffParser.extractChangedFiles(rawDiff); - diffSnippets = DiffParser.extractDiffSnippets(rawDiff, 20); + // 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 {})", + deltaSize, Math.round(deltaRatio * 100), fullSize); + } else { + log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", + Math.round(deltaRatio * 100)); + deltaDiff = null; + } + } else if (deltaSize < MIN_DELTA_DIFF_SIZE) { + log.info("Delta diff too small ({} chars), using FULL analysis", deltaSize); + deltaDiff = null; + } + } else { + log.info("Could not fetch delta diff, using FULL analysis"); + } + } else { + log.info("Using FULL analysis mode (first analysis or same commit)"); + } + + // Parse diff to extract changed files and code snippets + String diffToParse = analysisMode == AnalysisMode.INCREMENTAL && deltaDiff != null ? deltaDiff : rawDiff; + changedFiles = DiffParser.extractChangedFiles(diffToParse); + diffSnippets = DiffParser.extractDiffSnippets(diffToParse, 20); - log.info("Extracted {} changed files, {} code snippets, raw diff size: {} chars", - changedFiles.size(), diffSnippets.size(), rawDiff != null ? rawDiff.length() : 0); + log.info("Analysis mode: {}, extracted {} changed files, {} code snippets", + analysisMode, changedFiles.size(), diffSnippets.size()); } catch (IOException e) { log.warn("Failed to fetch/parse PR metadata/diff for RAG context: {}", e.getMessage()); @@ -163,12 +221,47 @@ private AiAnalysisRequest buildPrAnalysisRequest( .withRawDiff(rawDiff) .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()) .withTargetBranchName(request.targetBranchName) - .withVcsProvider("github"); + .withVcsProvider("github") + // Incremental analysis fields + .withAnalysisMode(analysisMode) + .withDeltaDiff(deltaDiff) + .withPreviousCommitHash(previousCommitHash) + .withCurrentCommitHash(currentCommitHash); 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, + String headCommit, + DiffContentFilter contentFilter + ) { + try { + GetCommitRangeDiffAction rangeDiffAction = new GetCommitRangeDiffAction(client); + String fetchedDeltaDiff = rangeDiffAction.getCommitRangeDiff( + vcsInfo.owner(), + vcsInfo.repoSlug(), + baseCommit, + 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())), + e.getMessage()); + return null; + } + } private AiAnalysisRequest buildBranchAnalysisRequest( Project project, diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubOperationsService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubOperationsService.java index 140326d2..2084bbb3 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubOperationsService.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/service/GitHubOperationsService.java @@ -5,6 +5,8 @@ import org.rostilos.codecrow.analysisengine.service.vcs.VcsOperationsService; import org.rostilos.codecrow.vcsclient.github.actions.CheckFileExistsInBranchAction; import org.rostilos.codecrow.vcsclient.github.actions.GetCommitDiffAction; +import org.rostilos.codecrow.vcsclient.github.actions.GetCommitRangeDiffAction; +import org.rostilos.codecrow.vcsclient.github.actions.GetPullRequestDiffAction; import org.springframework.stereotype.Service; import java.io.IOException; @@ -27,6 +29,18 @@ public String getCommitDiff(OkHttpClient client, String owner, String repoSlug, return action.getCommitDiff(owner, repoSlug, commitHash); } + @Override + public String getPullRequestDiff(OkHttpClient client, String owner, String repoSlug, String prNumber) throws IOException { + GetPullRequestDiffAction action = new GetPullRequestDiffAction(client); + return action.getPullRequestDiff(owner, repoSlug, Integer.parseInt(prNumber)); + } + + @Override + public String getCommitRangeDiff(OkHttpClient client, String owner, String repoSlug, String baseCommitHash, String headCommitHash) throws IOException { + GetCommitRangeDiffAction action = new GetCommitRangeDiffAction(client); + return action.getCommitRangeDiff(owner, repoSlug, baseCommitHash, headCommitHash); + } + @Override public boolean checkFileExistsInBranch(OkHttpClient client, String owner, String repoSlug, String branchName, String filePath) throws IOException { CheckFileExistsInBranchAction action = new CheckFileExistsInBranchAction(client); diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/webhook/GitHubWebhookParser.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/webhook/GitHubWebhookParser.java index 3c8cfdc3..dd89755a 100644 --- a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/webhook/GitHubWebhookParser.java +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/github/webhook/GitHubWebhookParser.java @@ -29,6 +29,8 @@ public WebhookPayload parse(String eventType, JsonNode payload) { String targetBranch = null; String commitHash = null; CommentData commentData = null; + String prAuthorId = null; + String prAuthorUsername = null; JsonNode repository = payload.path("repository"); if (!repository.isMissingNode()) { @@ -56,6 +58,12 @@ public WebhookPayload parse(String eventType, JsonNode payload) { if (!base.isMissingNode()) { targetBranch = base.path("ref").asText(null); } + + JsonNode prUser = pullRequest.path("user"); + if (!prUser.isMissingNode()) { + prAuthorId = String.valueOf(prUser.path("id").asLong()); + prAuthorUsername = prUser.path("login").asText(null); + } } // Push events @@ -81,6 +89,13 @@ public WebhookPayload parse(String eventType, JsonNode payload) { JsonNode issue = payload.path("issue"); if (!issue.isMissingNode() && !issue.path("pull_request").isMissingNode()) { pullRequestId = String.valueOf(issue.path("number").asInt()); + + // Extract PR author from issue (issue author = PR author for PR comments) + JsonNode issueUser = issue.path("user"); + if (!issueUser.isMissingNode()) { + prAuthorId = String.valueOf(issueUser.path("id").asLong()); + prAuthorUsername = issueUser.path("login").asText(null); + } // Note: We may need to fetch additional PR details for commit hash } } @@ -96,7 +111,9 @@ public WebhookPayload parse(String eventType, JsonNode payload) { targetBranch, commitHash, payload, - commentData + commentData, + prAuthorId, + prAuthorUsername ); } diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/handler/GitLabBranchWebhookHandler.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/handler/GitLabBranchWebhookHandler.java new file mode 100644 index 00000000..c9224d6a --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/handler/GitLabBranchWebhookHandler.java @@ -0,0 +1,162 @@ +package org.rostilos.codecrow.pipelineagent.gitlab.handler; + +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisType; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.config.ProjectConfig; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.util.BranchPatternMatcher; +import org.rostilos.codecrow.analysisengine.dto.request.processor.BranchProcessRequest; +import org.rostilos.codecrow.analysisengine.processor.analysis.BranchAnalysisProcessor; +import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload; +import org.rostilos.codecrow.pipelineagent.generic.webhook.handler.WebhookHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Webhook handler for GitLab Push events (branch analysis). + */ +@Component +public class GitLabBranchWebhookHandler implements WebhookHandler { + + private static final Logger log = LoggerFactory.getLogger(GitLabBranchWebhookHandler.class); + + private static final Set SUPPORTED_EVENTS = Set.of("push"); + + private final BranchAnalysisProcessor branchAnalysisProcessor; + + public GitLabBranchWebhookHandler(BranchAnalysisProcessor branchAnalysisProcessor) { + this.branchAnalysisProcessor = branchAnalysisProcessor; + } + + @Override + public EVcsProvider getProvider() { + return EVcsProvider.GITLAB; + } + + @Override + public boolean supportsEvent(String eventType) { + return SUPPORTED_EVENTS.contains(eventType); + } + + @Override + public WebhookResult handle(WebhookPayload payload, Project project, Consumer> eventConsumer) { + String eventType = payload.eventType(); + + log.info("Handling GitLab push event for project {} on branch {}", project.getId(), payload.sourceBranch()); + + // Skip if no commit hash (branch deletion) + if (payload.commitHash() == null) { + log.info("Ignoring branch deletion event"); + return WebhookResult.ignored("Branch deletion event, no analysis required"); + } + + try { + String validationError = validateProjectConnections(project); + if (validationError != null) { + log.warn("Project {} validation failed: {}", project.getId(), validationError); + return WebhookResult.error(validationError); + } + + if (!project.isBranchAnalysisEnabled()) { + log.info("Branch analysis is disabled for project {}", project.getId()); + return WebhookResult.ignored("Branch analysis is disabled for this project"); + } + + String branchName = payload.sourceBranch(); + if (!shouldAnalyzeBranch(project, branchName)) { + log.info("Skipping branch analysis: branch '{}' does not match configured patterns for project {}", + branchName, project.getId()); + return WebhookResult.ignored("Branch '" + branchName + "' does not match configured analysis patterns"); + } + + return handlePushEvent(payload, project, eventConsumer); + } catch (Exception e) { + log.error("Error processing push event for project {}", project.getId(), e); + return WebhookResult.error("Processing failed: " + e.getMessage()); + } + } + + private String validateProjectConnections(Project project) { + boolean hasVcsConnection = project.getVcsBinding() != null || + (project.getVcsRepoBinding() != null && project.getVcsRepoBinding().getVcsConnection() != null); + + if (!hasVcsConnection) { + return "VCS connection is not configured for project: " + project.getId(); + } + + if (project.getAiBinding() == null) { + return "AI connection is not configured for project: " + project.getId(); + } + + return null; + } + + /** + * Check if a branch matches the configured analysis patterns. + */ + private boolean shouldAnalyzeBranch(Project project, String branchName) { + if (project.getConfiguration() == null) { + return true; + } + + ProjectConfig.BranchAnalysisConfig branchConfig = project.getConfiguration().branchAnalysis(); + if (branchConfig == null) { + return true; + } + + List branchPushPatterns = branchConfig.branchPushPatterns(); + return BranchPatternMatcher.shouldAnalyze(branchName, branchPushPatterns); + } + + private WebhookResult handlePushEvent( + WebhookPayload payload, + Project project, + Consumer> eventConsumer + ) { + try { + String branchName = payload.sourceBranch(); + + // Create branch analysis request + BranchProcessRequest request = new BranchProcessRequest(); + request.projectId = project.getId(); + request.targetBranchName = branchName; + request.commitHash = payload.commitHash(); + request.analysisType = AnalysisType.BRANCH_ANALYSIS; + + log.info("Processing branch analysis: project={}, branch={}, commit={}", + project.getId(), branchName, request.commitHash); + + Consumer> processorConsumer = event -> { + if (eventConsumer != null) { + eventConsumer.accept(event); + } + }; + + // Delegate to branch analysis processor + Map result = branchAnalysisProcessor.process(request, processorConsumer); + + // Check if analysis failed + if ("error".equals(result.get("status"))) { + String errorMessage = (String) result.getOrDefault("message", "Analysis failed"); + return WebhookResult.error("Branch analysis failed: " + errorMessage); + } + + boolean cached = Boolean.TRUE.equals(result.get("cached")); + if (cached) { + return WebhookResult.success("Analysis result retrieved from cache", result); + } + + return WebhookResult.success("Branch analysis completed", result); + + } catch (Exception e) { + log.error("Branch analysis failed for project {}", project.getId(), e); + return WebhookResult.error("Branch analysis failed: " + e.getMessage()); + } + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/handler/GitLabMergeRequestWebhookHandler.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/handler/GitLabMergeRequestWebhookHandler.java new file mode 100644 index 00000000..08140334 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/handler/GitLabMergeRequestWebhookHandler.java @@ -0,0 +1,251 @@ +package org.rostilos.codecrow.pipelineagent.gitlab.handler; + +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisType; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.config.ProjectConfig; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.util.BranchPatternMatcher; +import org.rostilos.codecrow.analysisengine.dto.request.processor.PrProcessRequest; +import org.rostilos.codecrow.analysisengine.processor.analysis.PullRequestAnalysisProcessor; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsReportingService; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsServiceFactory; +import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload; +import org.rostilos.codecrow.pipelineagent.generic.webhook.handler.WebhookHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +/** + * Webhook handler for GitLab Merge Request events. + */ +@Component +public class GitLabMergeRequestWebhookHandler implements WebhookHandler { + + private static final Logger log = LoggerFactory.getLogger(GitLabMergeRequestWebhookHandler.class); + + private static final Set SUPPORTED_MR_EVENTS = Set.of("merge_request"); + + private static final Set TRIGGERING_ACTIONS = Set.of( + "open", + "reopen", + "update" + ); + + /** Comment marker for CodeCrow analysis responses */ + private static final String CODECROW_ANALYSIS_MARKER = ""; + + /** Placeholder message for auto-MR analysis */ + private static final String PLACEHOLDER_MR_ANALYSIS = """ + 🔄 **CodeCrow is analyzing this MR...** + + This may take a few minutes depending on the size of the changes. + This comment will be updated with the analysis results when complete. + """; + + private final PullRequestAnalysisProcessor pullRequestAnalysisProcessor; + private final VcsServiceFactory vcsServiceFactory; + + public GitLabMergeRequestWebhookHandler( + PullRequestAnalysisProcessor pullRequestAnalysisProcessor, + VcsServiceFactory vcsServiceFactory + ) { + this.pullRequestAnalysisProcessor = pullRequestAnalysisProcessor; + this.vcsServiceFactory = vcsServiceFactory; + } + + @Override + public EVcsProvider getProvider() { + return EVcsProvider.GITLAB; + } + + @Override + public boolean supportsEvent(String eventType) { + return SUPPORTED_MR_EVENTS.contains(eventType); + } + + @Override + public WebhookResult handle(WebhookPayload payload, Project project, Consumer> eventConsumer) { + String eventType = payload.eventType(); + + log.info("Handling GitLab MR event: {} for project {}", eventType, project.getId()); + + // Check if the action is one we care about + String action = null; + if (payload.rawPayload().has("object_attributes")) { + action = payload.rawPayload().get("object_attributes").path("action").asText(null); + } + + if (action == null || !TRIGGERING_ACTIONS.contains(action)) { + log.info("Ignoring GitLab MR event with action: {}", action); + return WebhookResult.ignored("MR action '" + action + "' does not trigger analysis"); + } + + try { + String validationError = validateProjectConnections(project); + if (validationError != null) { + log.warn("Project {} validation failed: {}", project.getId(), validationError); + return WebhookResult.error(validationError); + } + + if (!project.isPrAnalysisEnabled()) { + log.info("MR analysis is disabled for project {}", project.getId()); + return WebhookResult.ignored("MR analysis is disabled for this project"); + } + + String targetBranch = payload.targetBranch(); + if (!shouldAnalyzeMergeRequest(project, targetBranch)) { + log.info("Skipping MR analysis: target branch '{}' does not match configured patterns for project {}", + targetBranch, project.getId()); + return WebhookResult.ignored("Target branch '" + targetBranch + "' does not match configured analysis patterns"); + } + + return handleMergeRequestEvent(payload, project, eventConsumer); + } catch (Exception e) { + log.error("Error processing {} event for project {}", eventType, project.getId(), e); + return WebhookResult.error("Processing failed: " + e.getMessage()); + } + } + + private String validateProjectConnections(Project project) { + boolean hasVcsConnection = project.getVcsBinding() != null || + (project.getVcsRepoBinding() != null && project.getVcsRepoBinding().getVcsConnection() != null); + + if (!hasVcsConnection) { + return "VCS connection is not configured for project: " + project.getId(); + } + + if (project.getAiBinding() == null) { + return "AI connection is not configured for project: " + project.getId(); + } + + return null; + } + + /** + * Check if an MR's target branch matches the configured analysis patterns. + */ + private boolean shouldAnalyzeMergeRequest(Project project, String targetBranch) { + if (project.getConfiguration() == null) { + return true; + } + + ProjectConfig.BranchAnalysisConfig branchConfig = project.getConfiguration().branchAnalysis(); + if (branchConfig == null) { + return true; + } + + List prTargetBranches = branchConfig.prTargetBranches(); + return BranchPatternMatcher.shouldAnalyze(targetBranch, prTargetBranches); + } + + private WebhookResult handleMergeRequestEvent( + WebhookPayload payload, + Project project, + Consumer> eventConsumer + ) { + String placeholderCommentId = null; + + try { + // Post placeholder comment immediately to show analysis has started + placeholderCommentId = postPlaceholderComment(project, Long.parseLong(payload.pullRequestId())); + + // Convert WebhookPayload to PrProcessRequest + PrProcessRequest request = new PrProcessRequest(); + request.projectId = project.getId(); + request.pullRequestId = Long.parseLong(payload.pullRequestId()); + request.sourceBranchName = payload.sourceBranch(); + request.targetBranchName = payload.targetBranch(); + request.commitHash = payload.commitHash(); + request.analysisType = AnalysisType.PR_REVIEW; + request.placeholderCommentId = placeholderCommentId; + + log.info("Processing MR analysis: project={}, MR={}, source={}, target={}, placeholderCommentId={}", + project.getId(), request.pullRequestId, request.sourceBranchName, request.targetBranchName, placeholderCommentId); + + // Delegate to existing processor + Map result = pullRequestAnalysisProcessor.process( + request, + eventConsumer != null ? eventConsumer::accept : event -> {}, + project + ); + + // Check if analysis failed + if ("error".equals(result.get("status"))) { + String errorMessage = (String) result.getOrDefault("message", "Analysis failed"); + return WebhookResult.error("MR analysis failed: " + errorMessage); + } + + boolean cached = Boolean.TRUE.equals(result.get("cached")); + if (cached) { + return WebhookResult.success("Analysis result retrieved from cache", result); + } + + return WebhookResult.success("MR analysis completed", result); + + } catch (Exception e) { + log.error("MR analysis failed for project {}", project.getId(), e); + // Try to update placeholder with error message + if (placeholderCommentId != null) { + try { + updatePlaceholderWithError(project, Long.parseLong(payload.pullRequestId()), placeholderCommentId, e.getMessage()); + } catch (Exception updateError) { + log.warn("Failed to update placeholder with error: {}", updateError.getMessage()); + } + } + return WebhookResult.error("MR analysis failed: " + e.getMessage()); + } + } + + /** + * Post a placeholder comment indicating CodeCrow is analyzing the MR. + * Returns the comment ID for later updating. + */ + private String postPlaceholderComment(Project project, Long mergeRequestIid) { + try { + VcsReportingService reportingService = vcsServiceFactory.getReportingService(EVcsProvider.GITLAB); + + // Delete any previous analysis comments before posting placeholder + try { + int deleted = reportingService.deleteCommentsByMarker(project, mergeRequestIid, CODECROW_ANALYSIS_MARKER); + if (deleted > 0) { + log.info("Deleted {} previous analysis comment(s) before posting placeholder", deleted); + } + } catch (Exception e) { + log.warn("Failed to delete previous comments: {}", e.getMessage()); + } + + String commentId = reportingService.postComment( + project, + mergeRequestIid, + PLACEHOLDER_MR_ANALYSIS, + CODECROW_ANALYSIS_MARKER + ); + + log.info("Posted placeholder comment {} for MR {}", commentId, mergeRequestIid); + return commentId; + + } catch (Exception e) { + log.error("Failed to post placeholder comment: {}", e.getMessage(), e); + return null; + } + } + + /** + * Update a placeholder comment with an error message. + */ + private void updatePlaceholderWithError(Project project, Long mergeRequestIid, String commentId, String errorMessage) { + try { + VcsReportingService reportingService = vcsServiceFactory.getReportingService(EVcsProvider.GITLAB); + String errorContent = "⚠️ **CodeCrow Analysis Failed**\n\n" + errorMessage; + reportingService.updateComment(project, mergeRequestIid, commentId, errorContent, CODECROW_ANALYSIS_MARKER); + log.info("Updated placeholder comment {} with error message", commentId); + } catch (Exception e) { + log.error("Failed to update placeholder with error: {}", e.getMessage(), e); + } + } +} 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 new file mode 100644 index 00000000..bafa3499 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabAiClientService.java @@ -0,0 +1,305 @@ +package org.rostilos.codecrow.pipelineagent.gitlab.service; + +import com.fasterxml.jackson.databind.JsonNode; +import okhttp3.OkHttpClient; +import org.rostilos.codecrow.core.model.ai.AIConnection; +import org.rostilos.codecrow.core.model.codeanalysis.AnalysisMode; +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.ProjectVcsConnectionBinding; +import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding; +import org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig; +import org.rostilos.codecrow.analysisengine.dto.request.ai.AiAnalysisRequest; +import org.rostilos.codecrow.analysisengine.dto.request.ai.AiAnalysisRequestImpl; +import org.rostilos.codecrow.analysisengine.dto.request.processor.AnalysisProcessRequest; +import org.rostilos.codecrow.analysisengine.dto.request.processor.BranchProcessRequest; +import org.rostilos.codecrow.analysisengine.dto.request.processor.PrProcessRequest; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsAiClientService; +import org.rostilos.codecrow.analysisengine.util.DiffContentFilter; +import org.rostilos.codecrow.analysisengine.util.DiffParser; +import org.rostilos.codecrow.security.oauth.TokenEncryptionService; +import org.rostilos.codecrow.vcsclient.VcsClientProvider; +import org.rostilos.codecrow.vcsclient.gitlab.actions.GetCommitRangeDiffAction; +import org.rostilos.codecrow.vcsclient.gitlab.actions.GetMergeRequestAction; +import org.rostilos.codecrow.vcsclient.gitlab.actions.GetMergeRequestDiffAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@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. + */ + private static final int MIN_DELTA_DIFF_SIZE = 500; + + private final TokenEncryptionService tokenEncryptionService; + private final VcsClientProvider vcsClientProvider; + + public GitLabAiClientService( + TokenEncryptionService tokenEncryptionService, + VcsClientProvider vcsClientProvider + ) { + this.tokenEncryptionService = tokenEncryptionService; + this.vcsClientProvider = vcsClientProvider; + } + + @Override + public EVcsProvider getProvider() { + return EVcsProvider.GITLAB; + } + + private record VcsInfo(VcsConnection vcsConnection, String namespace, String repoSlug) {} + + private VcsInfo getVcsInfo(Project project) { + ProjectVcsConnectionBinding vcsBinding = project.getVcsBinding(); + if (vcsBinding != null && vcsBinding.getVcsConnection() != null) { + return new VcsInfo(vcsBinding.getVcsConnection(), vcsBinding.getWorkspace(), vcsBinding.getRepoSlug()); + } + + VcsRepoBinding repoBinding = project.getVcsRepoBinding(); + if (repoBinding != null && repoBinding.getVcsConnection() != null) { + log.debug("Using VcsRepoBinding for project {} as fallback", project.getId()); + return new VcsInfo(repoBinding.getVcsConnection(), repoBinding.getExternalNamespace(), repoBinding.getExternalRepoSlug()); + } + + throw new IllegalStateException("No VCS connection configured for project: " + project.getId()); + } + + @Override + public AiAnalysisRequest buildAiAnalysisRequest( + Project project, + AnalysisProcessRequest request, + Optional previousAnalysis + ) throws GeneralSecurityException { + switch (request.getAnalysisType()) { + case BRANCH_ANALYSIS: + return buildBranchAnalysisRequest(project, (BranchProcessRequest) request, previousAnalysis); + default: + return buildMrAnalysisRequest(project, (PrProcessRequest) request, previousAnalysis); + } + } + + private AiAnalysisRequest buildMrAnalysisRequest( + Project project, + PrProcessRequest request, + Optional previousAnalysis + ) throws GeneralSecurityException { + VcsInfo vcsInfo = getVcsInfo(project); + VcsConnection vcsConnection = vcsInfo.vcsConnection(); + AIConnection aiConnection = project.getAiBinding().getAiConnection(); + + // Initialize variables + List changedFiles = Collections.emptyList(); + List diffSnippets = Collections.emptyList(); + String mrTitle = null; + String mrDescription = null; + String rawDiff = null; + String deltaDiff = null; + AnalysisMode analysisMode = AnalysisMode.FULL; + String previousCommitHash = previousAnalysis.map(CodeAnalysis::getCommitHash).orElse(null); + String currentCommitHash = request.getCommitHash(); + + try { + OkHttpClient client = vcsClientProvider.getHttpClient(vcsConnection); + + // Fetch MR metadata + GetMergeRequestAction mrAction = new GetMergeRequestAction(client); + JsonNode mrData = mrAction.getMergeRequest( + vcsInfo.namespace(), + vcsInfo.repoSlug(), + request.getPullRequestId().intValue() + ); + + mrTitle = mrData.has("title") ? mrData.get("title").asText() : null; + mrDescription = mrData.has("description") ? mrData.get("description").asText() : null; + + log.info("Fetched MR metadata: title='{}', description length={}", + mrTitle, mrDescription != null ? mrDescription.length() : 0); + + // Fetch full MR diff + GetMergeRequestDiffAction diffAction = new GetMergeRequestDiffAction(client); + String fetchedDiff = diffAction.getMergeRequestDiff( + vcsInfo.namespace(), + vcsInfo.repoSlug(), + 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, + originalSize > 0 ? (100 - (filteredSize * 100 / originalSize)) : 0); + } + + // 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 {})", + deltaSize, Math.round(deltaRatio * 100), fullSize); + } else { + log.info("Escalating to FULL analysis: delta too large ({}% of full diff)", + Math.round(deltaRatio * 100)); + deltaDiff = null; + } + } else if (deltaSize < MIN_DELTA_DIFF_SIZE) { + log.info("Delta diff too small ({} chars), using FULL analysis", deltaSize); + deltaDiff = null; + } + } else { + log.info("Could not fetch delta diff, using FULL analysis"); + } + } else { + log.info("Using FULL analysis mode (first analysis or same commit)"); + } + + // Parse diff to extract changed files and code snippets + String diffToParse = analysisMode == AnalysisMode.INCREMENTAL && deltaDiff != null ? deltaDiff : rawDiff; + changedFiles = DiffParser.extractChangedFiles(diffToParse); + diffSnippets = DiffParser.extractDiffSnippets(diffToParse, 20); + + log.info("Analysis mode: {}, extracted {} changed files, {} code snippets", + analysisMode, changedFiles.size(), diffSnippets.size()); + + } catch (IOException e) { + log.warn("Failed to fetch/parse MR metadata/diff for RAG context: {}", e.getMessage()); + } + + var builder = AiAnalysisRequestImpl.builder() + .withProjectId(project.getId()) + .withPullRequestId(request.getPullRequestId()) + .withProjectAiConnection(aiConnection) + .withProjectVcsConnectionBindingInfo(vcsInfo.namespace(), vcsInfo.repoSlug()) + .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withUseLocalMcp(true) + .withPreviousAnalysisData(previousAnalysis) + .withMaxAllowedTokens(aiConnection.getTokenLimitation()) + .withAnalysisType(request.getAnalysisType()) + .withPrTitle(mrTitle) + .withPrDescription(mrDescription) + .withChangedFiles(changedFiles) + .withDiffSnippets(diffSnippets) + .withRawDiff(rawDiff) + .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()) + .withTargetBranchName(request.targetBranchName) + .withVcsProvider("gitlab") + // Incremental analysis fields + .withAnalysisMode(analysisMode) + .withDeltaDiff(deltaDiff) + .withPreviousCommitHash(previousCommitHash) + .withCurrentCommitHash(currentCommitHash); + + 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, + String headCommit, + DiffContentFilter contentFilter + ) { + try { + GetCommitRangeDiffAction rangeDiffAction = new GetCommitRangeDiffAction(client); + String fetchedDeltaDiff = rangeDiffAction.getCommitRangeDiff( + vcsInfo.namespace(), + vcsInfo.repoSlug(), + baseCommit, + 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())), + e.getMessage()); + return null; + } + } + + private AiAnalysisRequest buildBranchAnalysisRequest( + Project project, + BranchProcessRequest request, + Optional previousAnalysis + ) throws GeneralSecurityException { + VcsInfo vcsInfo = getVcsInfo(project); + VcsConnection vcsConnection = vcsInfo.vcsConnection(); + AIConnection aiConnection = project.getAiBinding().getAiConnection(); + + var builder = AiAnalysisRequestImpl.builder() + .withProjectId(project.getId()) + .withPullRequestId(null) + .withProjectAiConnection(aiConnection) + .withProjectVcsConnectionBindingInfo(vcsInfo.namespace(), vcsInfo.repoSlug()) + .withProjectAiConnectionTokenDecrypted(tokenEncryptionService.decrypt(aiConnection.getApiKeyEncrypted())) + .withUseLocalMcp(true) + .withPreviousAnalysisData(previousAnalysis) + .withMaxAllowedTokens(aiConnection.getTokenLimitation()) + .withAnalysisType(request.getAnalysisType()) + .withProjectMetadata(project.getWorkspace().getName(), project.getNamespace()); + + addVcsCredentials(builder, vcsConnection); + + return builder.build(); + } + + private void addVcsCredentials(AiAnalysisRequestImpl.Builder builder, VcsConnection connection) + throws GeneralSecurityException { + if (connection.getConnectionType() == EVcsConnectionType.APPLICATION && connection.getAccessToken() != null) { + String accessToken = tokenEncryptionService.decrypt(connection.getAccessToken()); + builder.withAccessToken(accessToken); + } else if (connection.getConnectionType() == EVcsConnectionType.PERSONAL_TOKEN && + connection.getConfiguration() instanceof GitLabConfig config) { + builder.withAccessToken(config.accessToken()); + } else { + log.warn("Unknown connection type for VCS credentials: {}", connection.getConnectionType()); + } + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabOperationsService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabOperationsService.java new file mode 100644 index 00000000..785058f8 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabOperationsService.java @@ -0,0 +1,49 @@ +package org.rostilos.codecrow.pipelineagent.gitlab.service; + +import okhttp3.OkHttpClient; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsOperationsService; +import org.rostilos.codecrow.vcsclient.gitlab.actions.CheckFileExistsInBranchAction; +import org.rostilos.codecrow.vcsclient.gitlab.actions.GetCommitDiffAction; +import org.rostilos.codecrow.vcsclient.gitlab.actions.GetCommitRangeDiffAction; +import org.rostilos.codecrow.vcsclient.gitlab.actions.GetMergeRequestDiffAction; +import org.springframework.stereotype.Service; + +import java.io.IOException; + +/** + * GitLab implementation of VcsOperationsService. + * Delegates to GitLab-specific action classes for API calls. + */ +@Service +public class GitLabOperationsService implements VcsOperationsService { + + @Override + public EVcsProvider getProvider() { + return EVcsProvider.GITLAB; + } + + @Override + public String getCommitDiff(OkHttpClient client, String namespace, String project, String commitHash) throws IOException { + GetCommitDiffAction action = new GetCommitDiffAction(client); + return action.getCommitDiff(namespace, project, commitHash); + } + + @Override + public String getPullRequestDiff(OkHttpClient client, String namespace, String project, String mergeRequestIid) throws IOException { + GetMergeRequestDiffAction action = new GetMergeRequestDiffAction(client); + return action.getMergeRequestDiff(namespace, project, Integer.parseInt(mergeRequestIid)); + } + + @Override + public String getCommitRangeDiff(OkHttpClient client, String namespace, String project, String baseCommitHash, String headCommitHash) throws IOException { + GetCommitRangeDiffAction action = new GetCommitRangeDiffAction(client); + return action.getCommitRangeDiff(namespace, project, baseCommitHash, headCommitHash); + } + + @Override + public boolean checkFileExistsInBranch(OkHttpClient client, String namespace, String project, String branchName, String filePath) throws IOException { + CheckFileExistsInBranchAction action = new CheckFileExistsInBranchAction(client); + return action.fileExists(namespace, project, branchName, filePath); + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabReportingService.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabReportingService.java new file mode 100644 index 00000000..b1532fd4 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/service/GitLabReportingService.java @@ -0,0 +1,371 @@ +package org.rostilos.codecrow.pipelineagent.gitlab.service; + +import okhttp3.OkHttpClient; +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding; +import org.rostilos.codecrow.core.model.vcs.VcsRepoInfo; +import org.rostilos.codecrow.core.persistence.repository.vcs.VcsRepoBindingRepository; +import org.rostilos.codecrow.analysisengine.service.vcs.VcsReportingService; +import org.rostilos.codecrow.vcsclient.VcsClientProvider; +import org.rostilos.codecrow.vcsclient.bitbucket.model.report.AnalysisSummary; +import org.rostilos.codecrow.vcsclient.bitbucket.service.ReportGenerator; +import org.rostilos.codecrow.vcsclient.gitlab.actions.CommentOnMergeRequestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * GitLab implementation of VcsReportingService. + * Posts analysis results as MR comments. + */ +@Service +public class GitLabReportingService implements VcsReportingService { + private static final Logger log = LoggerFactory.getLogger(GitLabReportingService.class); + + /** + * Marker text used to identify CodeCrow comments for deletion. + * This should be unique enough to not match user comments. + */ + private static final String CODECROW_COMMENT_MARKER = ""; + + private final ReportGenerator reportGenerator; + private final VcsClientProvider vcsClientProvider; + private final VcsRepoBindingRepository vcsRepoBindingRepository; + + public GitLabReportingService( + ReportGenerator reportGenerator, + VcsClientProvider vcsClientProvider, + VcsRepoBindingRepository vcsRepoBindingRepository + ) { + this.reportGenerator = reportGenerator; + this.vcsClientProvider = vcsClientProvider; + this.vcsRepoBindingRepository = vcsRepoBindingRepository; + } + + @Override + public EVcsProvider getProvider() { + return EVcsProvider.GITLAB; + } + + private VcsRepoInfo getVcsRepoInfo(Project project) { + if (project.getVcsBinding() != null) { + return project.getVcsBinding(); + } + + VcsRepoBinding vcsRepoBinding = vcsRepoBindingRepository.findByProject_Id(project.getId()) + .orElseThrow(() -> new IllegalStateException( + "No VCS binding found for project " + project.getId() + + ". Neither ProjectVcsConnectionBinding nor VcsRepoBinding is configured." + )); + + log.debug("Using VcsRepoBinding fallback for project {}: {}/{}", + project.getId(), vcsRepoBinding.getRepoWorkspace(), vcsRepoBinding.getRepoSlug()); + + return vcsRepoBinding; + } + + @Override + @Transactional(readOnly = true) + public void postAnalysisResults( + CodeAnalysis codeAnalysis, + Project project, + Long mergeRequestIid, + Long platformMrEntityId + ) throws IOException { + postAnalysisResults(codeAnalysis, project, mergeRequestIid, platformMrEntityId, null); + } + + @Override + @Transactional(readOnly = true) + public void postAnalysisResults( + CodeAnalysis codeAnalysis, + Project project, + Long mergeRequestIid, + Long platformMrEntityId, + String placeholderCommentId + ) throws IOException { + + log.info("Posting analysis results to GitLab for MR {} (placeholderCommentId={})", + mergeRequestIid, placeholderCommentId); + + AnalysisSummary summary = reportGenerator.createAnalysisSummary(codeAnalysis, platformMrEntityId); + // Use GitLab-specific markdown with collapsible sections for suggested fixes + String markdownSummary = reportGenerator.createMarkdownSummary(codeAnalysis, summary, true); + + VcsRepoInfo vcsRepoInfo = getVcsRepoInfo(project); + + OkHttpClient httpClient = vcsClientProvider.getHttpClient( + vcsRepoInfo.getVcsConnection() + ); + + // Post or update MR comment with detailed analysis + postOrUpdateComment(httpClient, vcsRepoInfo, mergeRequestIid, markdownSummary, placeholderCommentId); + + log.info("Successfully posted analysis results to GitLab"); + } + + private void postOrUpdateComment( + OkHttpClient httpClient, + VcsRepoInfo vcsRepoInfo, + Long mergeRequestIid, + String markdownSummary, + String placeholderCommentId + ) throws IOException { + + log.debug("Posting/updating summary comment to MR {} (placeholderCommentId={})", + mergeRequestIid, placeholderCommentId); + + CommentOnMergeRequestAction commentAction = new CommentOnMergeRequestAction(httpClient); + + if (placeholderCommentId != null) { + // Update the placeholder comment with the analysis results + String markedComment = CODECROW_COMMENT_MARKER + "\n" + markdownSummary; + commentAction.updateNote( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + Long.parseLong(placeholderCommentId), + markedComment + ); + log.debug("Updated placeholder comment {} with analysis results", placeholderCommentId); + } else { + // Delete previous CodeCrow comments before posting new one + try { + deletePreviousComments(commentAction, vcsRepoInfo, mergeRequestIid.intValue()); + log.debug("Deleted previous CodeCrow comments from MR {}", mergeRequestIid); + } catch (Exception e) { + log.warn("Failed to delete previous comments: {}", e.getMessage()); + } + + // Add marker to the comment for future identification + String markedComment = CODECROW_COMMENT_MARKER + "\n" + markdownSummary; + + commentAction.postComment( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + markedComment + ); + } + } + + private void deletePreviousComments( + CommentOnMergeRequestAction commentAction, + VcsRepoInfo vcsRepoInfo, + int mergeRequestIid + ) throws IOException { + List> notes = commentAction.listNotes( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid + ); + + for (Map note : notes) { + Object bodyObj = note.get("body"); + if (bodyObj != null && bodyObj.toString().contains(CODECROW_COMMENT_MARKER)) { + Object idObj = note.get("id"); + if (idObj instanceof Number) { + long noteId = ((Number) idObj).longValue(); + try { + commentAction.deleteNote( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid, + noteId + ); + log.debug("Deleted previous CodeCrow comment {}", noteId); + } catch (Exception e) { + log.warn("Failed to delete comment {}: {}", noteId, e.getMessage()); + } + } + } + } + } + + @Override + public String postComment( + Project project, + Long mergeRequestIid, + String content, + String marker + ) throws IOException { + VcsRepoInfo vcsRepoInfo = getVcsRepoInfo(project); + OkHttpClient httpClient = vcsClientProvider.getHttpClient(vcsRepoInfo.getVcsConnection()); + + CommentOnMergeRequestAction commentAction = new CommentOnMergeRequestAction(httpClient); + + // Add marker at the END as HTML comment (invisible to users) if provided + String markedContent = content; + if (marker != null && !marker.isBlank()) { + markedContent = content + "\n\n" + marker; + } + + // Post the comment + commentAction.postComment( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + markedContent + ); + + // Find the comment we just posted to get its ID + Long commentId = commentAction.findCommentByMarker( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + marker != null ? marker : markedContent.substring(0, Math.min(50, markedContent.length())) + ); + + return commentId != null ? commentId.toString() : null; + } + + @Override + public String postCommentReply( + Project project, + Long mergeRequestIid, + String parentCommentId, + String content + ) throws IOException { + // GitLab doesn't support direct comment replies on MR notes + // Use basic postComment without context - caller should use postCommentReplyWithContext + return postComment(project, mergeRequestIid, content, null); + } + + @Override + public String postCommentReplyWithContext( + Project project, + Long mergeRequestIid, + String parentCommentId, + String content, + String originalAuthorUsername, + String originalCommentBody + ) throws IOException { + // GitLab doesn't support threading on MR notes + // Format reply with quote and @mention to create a visual connection + StringBuilder formattedReply = new StringBuilder(); + + // Add mention of original author + if (originalAuthorUsername != null && !originalAuthorUsername.isBlank()) { + formattedReply.append("@").append(originalAuthorUsername).append(" "); + } + + // Add quoted command (truncated to keep it clean) + if (originalCommentBody != null && !originalCommentBody.isBlank()) { + String truncatedQuestion = originalCommentBody.length() > 100 + ? originalCommentBody.substring(0, 100) + "..." + : originalCommentBody; + formattedReply.append("\n> ").append(truncatedQuestion.replace("\n", "\n> ")).append("\n\n"); + } + + formattedReply.append(content); + + return postComment(project, mergeRequestIid, formattedReply.toString(), null); + } + + @Override + public int deleteCommentsByMarker( + Project project, + Long mergeRequestIid, + String marker + ) throws IOException { + VcsRepoInfo vcsRepoInfo = getVcsRepoInfo(project); + OkHttpClient httpClient = vcsClientProvider.getHttpClient(vcsRepoInfo.getVcsConnection()); + + CommentOnMergeRequestAction commentAction = new CommentOnMergeRequestAction(httpClient); + + int deletedCount = 0; + try { + List> notes = commentAction.listNotes( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue() + ); + + for (Map note : notes) { + Object bodyObj = note.get("body"); + if (bodyObj != null && bodyObj.toString().contains(marker)) { + Object idObj = note.get("id"); + if (idObj instanceof Number) { + long noteId = ((Number) idObj).longValue(); + try { + commentAction.deleteNote( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + noteId + ); + deletedCount++; + } catch (Exception e) { + log.warn("Failed to delete comment {}: {}", noteId, e.getMessage()); + } + } + } + } + } catch (Exception e) { + log.warn("Failed to delete comments with marker {}: {}", marker, e.getMessage()); + } + + return deletedCount; + } + + @Override + public void deleteComment( + Project project, + Long mergeRequestIid, + String commentId + ) throws IOException { + VcsRepoInfo vcsRepoInfo = getVcsRepoInfo(project); + OkHttpClient httpClient = vcsClientProvider.getHttpClient(vcsRepoInfo.getVcsConnection()); + + CommentOnMergeRequestAction commentAction = new CommentOnMergeRequestAction(httpClient); + commentAction.deleteNote( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + Long.parseLong(commentId) + ); + } + + @Override + public void updateComment( + Project project, + Long mergeRequestIid, + String commentId, + String newContent, + String marker + ) throws IOException { + VcsRepoInfo vcsRepoInfo = getVcsRepoInfo(project); + OkHttpClient httpClient = vcsClientProvider.getHttpClient(vcsRepoInfo.getVcsConnection()); + + CommentOnMergeRequestAction commentAction = new CommentOnMergeRequestAction(httpClient); + + // Add marker at the END as HTML comment (invisible to users) if provided + String markedContent = newContent; + if (marker != null && !marker.isBlank()) { + markedContent = newContent + "\n\n" + marker; + } + + commentAction.updateNote( + vcsRepoInfo.getRepoWorkspace(), + vcsRepoInfo.getRepoSlug(), + mergeRequestIid.intValue(), + Long.parseLong(commentId), + markedContent + ); + } + + @Override + public boolean supportsMermaidDiagrams() { + // GitLab supports Mermaid diagrams in markdown + // TODO: Mermaid diagrams disabled for now - AI-generated Mermaid often has syntax errors + // that fail to render. Using ASCII diagrams until we add validation/fixing. + return false; + } +} diff --git a/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/webhook/GitLabWebhookParser.java b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/webhook/GitLabWebhookParser.java new file mode 100644 index 00000000..fd746ff4 --- /dev/null +++ b/java-ecosystem/services/pipeline-agent/src/main/java/org/rostilos/codecrow/pipelineagent/gitlab/webhook/GitLabWebhookParser.java @@ -0,0 +1,207 @@ +package org.rostilos.codecrow.pipelineagent.gitlab.webhook; + +import com.fasterxml.jackson.databind.JsonNode; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload; +import org.rostilos.codecrow.pipelineagent.generic.webhook.WebhookPayload.CommentData; +import org.springframework.stereotype.Component; + +/** + * Parser for GitLab webhook payloads. + * Handles MR events, push events, and note (comment) events. + */ +@Component +public class GitLabWebhookParser { + + /** + * Parse a GitLab webhook payload. + * + * @param eventType The X-Gitlab-Event header value + * @param payload The raw JSON payload + * @return Parsed webhook payload + */ + public WebhookPayload parse(String eventType, JsonNode payload) { + String externalRepoId = null; + String repoSlug = null; + String namespace = null; + String mergeRequestIid = null; + String sourceBranch = null; + String targetBranch = null; + String commitHash = null; + CommentData commentData = null; + String mrAuthorId = null; + String mrAuthorUsername = null; + + // GitLab uses "project" for repository info + JsonNode project = payload.path("project"); + if (!project.isMissingNode()) { + externalRepoId = String.valueOf(project.path("id").asLong()); + repoSlug = project.path("path").asText(null); + + // Extract namespace from path_with_namespace + String pathWithNamespace = project.path("path_with_namespace").asText(null); + if (pathWithNamespace != null && pathWithNamespace.contains("/")) { + namespace = pathWithNamespace.substring(0, pathWithNamespace.lastIndexOf('/')); + } + } + + // Map GitLab event types to normalized event types + String normalizedEventType = normalizeEventType(eventType); + + // Merge Request events + JsonNode objectAttributes = payload.path("object_attributes"); + if (!objectAttributes.isMissingNode()) { + if ("Merge Request Hook".equals(eventType) || "merge_request".equals(eventType)) { + mergeRequestIid = String.valueOf(objectAttributes.path("iid").asInt()); + sourceBranch = objectAttributes.path("source_branch").asText(null); + targetBranch = objectAttributes.path("target_branch").asText(null); + commitHash = objectAttributes.path("last_commit").path("id").asText(null); + + // Extract MR author + JsonNode author = objectAttributes.path("last_commit").path("author"); + if (!author.isMissingNode()) { + mrAuthorUsername = author.path("name").asText(null); + } + // Try user attribute + JsonNode user = payload.path("user"); + if (!user.isMissingNode()) { + mrAuthorId = String.valueOf(user.path("id").asLong()); + if (mrAuthorUsername == null) { + mrAuthorUsername = user.path("username").asText(null); + } + } + } + + // Note (comment) events + if ("Note Hook".equals(eventType) || "note".equals(eventType)) { + commentData = parseCommentData(payload); + + // For notes on MRs, get MR info + JsonNode mergeRequest = payload.path("merge_request"); + if (!mergeRequest.isMissingNode()) { + mergeRequestIid = String.valueOf(mergeRequest.path("iid").asInt()); + sourceBranch = mergeRequest.path("source_branch").asText(null); + targetBranch = mergeRequest.path("target_branch").asText(null); + commitHash = mergeRequest.path("last_commit").path("id").asText(null); + + mrAuthorId = String.valueOf(mergeRequest.path("author_id").asLong()); + } + } + } + + // Push events + if ("Push Hook".equals(eventType) || "push".equals(eventType)) { + String ref = payload.path("ref").asText(null); + if (ref != null && ref.startsWith("refs/heads/")) { + sourceBranch = ref.substring("refs/heads/".length()); + } + + commitHash = payload.path("after").asText(null); + + // Skip if this is a branch deletion (all zeros commit) + if ("0000000000000000000000000000000000000000".equals(commitHash)) { + commitHash = null; + } + } + + return new WebhookPayload( + EVcsProvider.GITLAB, + normalizedEventType, + externalRepoId, + repoSlug, + namespace, + mergeRequestIid, + sourceBranch, + targetBranch, + commitHash, + payload, + commentData, + mrAuthorId, + mrAuthorUsername + ); + } + + /** + * Normalize GitLab event types to common event names. + */ + private String normalizeEventType(String gitlabEventType) { + if (gitlabEventType == null) return null; + + return switch (gitlabEventType) { + case "Merge Request Hook" -> "merge_request"; + case "Push Hook" -> "push"; + case "Note Hook" -> "note"; + case "Tag Push Hook" -> "tag_push"; + case "Issue Hook" -> "issue"; + case "Pipeline Hook" -> "pipeline"; + case "Job Hook" -> "job"; + default -> gitlabEventType.toLowerCase().replace(" hook", ""); + }; + } + + /** + * Parse comment data from a GitLab note webhook payload. + */ + private CommentData parseCommentData(JsonNode payload) { + JsonNode objectAttributes = payload.path("object_attributes"); + if (objectAttributes.isMissingNode()) { + return null; + } + + String noteableType = objectAttributes.path("noteable_type").asText(null); + if (!"MergeRequest".equals(noteableType)) { + // Only handle MR comments + return null; + } + + String commentId = String.valueOf(objectAttributes.path("id").asLong()); + String commentBody = objectAttributes.path("note").asText(null); + + // Get comment author + String authorId = null; + String authorUsername = null; + JsonNode user = payload.path("user"); + if (!user.isMissingNode()) { + authorId = String.valueOf(user.path("id").asLong()); + authorUsername = user.path("username").asText(null); + } + + // GitLab uses discussion_id for threaded comments + String parentCommentId = null; + String discussionId = objectAttributes.path("discussion_id").asText(null); + // If this is a reply, the discussion_id references the parent + if (discussionId != null && objectAttributes.path("type").asText("").equals("DiscussionNote")) { + parentCommentId = discussionId; + } + + // Check if this is an inline comment (on a specific file/line) + boolean isInlineComment = !objectAttributes.path("position").isMissingNode(); + String filePath = null; + Integer lineNumber = null; + + if (isInlineComment) { + JsonNode position = objectAttributes.path("position"); + filePath = position.path("new_path").asText(null); + if (filePath == null) { + filePath = position.path("old_path").asText(null); + } + + if (!position.path("new_line").isMissingNode()) { + lineNumber = position.path("new_line").asInt(); + } else if (!position.path("old_line").isMissingNode()) { + lineNumber = position.path("old_line").asInt(); + } + } + + return new CommentData( + commentId, + commentBody, + authorId, + authorUsername, + parentCommentId, + isInlineComment, + filePath, + lineNumber + ); + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/ai/AIConnectionController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/controller/AIConnectionController.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/ai/AIConnectionController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/controller/AIConnectionController.java index 35f24b7e..1274b515 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/ai/AIConnectionController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/controller/AIConnectionController.java @@ -1,15 +1,15 @@ -package org.rostilos.codecrow.webserver.controller.ai; +package org.rostilos.codecrow.webserver.ai.controller; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; import jakarta.validation.Valid; import org.rostilos.codecrow.core.model.ai.AIConnection; import org.rostilos.codecrow.core.model.workspace.Workspace; -import org.rostilos.codecrow.webserver.dto.request.ai.CreateAIConnectionRequest; -import org.rostilos.codecrow.webserver.dto.request.ai.UpdateAiConnectionRequest; +import org.rostilos.codecrow.webserver.ai.dto.request.CreateAIConnectionRequest; +import org.rostilos.codecrow.webserver.ai.dto.request.UpdateAiConnectionRequest; import org.rostilos.codecrow.core.dto.ai.AIConnectionDTO; -import org.rostilos.codecrow.webserver.service.ai.AIConnectionService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.ai.service.AIConnectionService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/ai/CreateAIConnectionRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/dto/request/CreateAIConnectionRequest.java similarity index 91% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/ai/CreateAIConnectionRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/dto/request/CreateAIConnectionRequest.java index 3c87a6d5..558f236e 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/ai/CreateAIConnectionRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/dto/request/CreateAIConnectionRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.ai; +package org.rostilos.codecrow.webserver.ai.dto.request; import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/ai/UpdateAiConnectionRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/dto/request/UpdateAiConnectionRequest.java similarity index 86% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/ai/UpdateAiConnectionRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/dto/request/UpdateAiConnectionRequest.java index 490ab647..ae834bb5 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/ai/UpdateAiConnectionRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/dto/request/UpdateAiConnectionRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.ai; +package org.rostilos.codecrow.webserver.ai.dto.request; import com.fasterxml.jackson.annotation.JsonInclude; import org.rostilos.codecrow.core.model.ai.AIProviderKey; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/ai/AIConnectionService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/service/AIConnectionService.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/ai/AIConnectionService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/service/AIConnectionService.java index 9a598f22..510f0901 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/ai/AIConnectionService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/ai/service/AIConnectionService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.ai; +package org.rostilos.codecrow.webserver.ai.service; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -7,8 +7,8 @@ import org.rostilos.codecrow.core.persistence.repository.ai.AiConnectionRepository; import org.rostilos.codecrow.core.persistence.repository.workspace.WorkspaceRepository; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; -import org.rostilos.codecrow.webserver.dto.request.ai.CreateAIConnectionRequest; -import org.rostilos.codecrow.webserver.dto.request.ai.UpdateAiConnectionRequest; +import org.rostilos.codecrow.webserver.ai.dto.request.CreateAIConnectionRequest; +import org.rostilos.codecrow.webserver.ai.dto.request.UpdateAiConnectionRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/AnalysisIssueController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/controller/AnalysisIssueController.java similarity index 84% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/AnalysisIssueController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/controller/AnalysisIssueController.java index 34aeb2b6..b30a3a00 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/AnalysisIssueController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/controller/AnalysisIssueController.java @@ -1,16 +1,18 @@ -package org.rostilos.codecrow.webserver.controller.analysis; +package org.rostilos.codecrow.webserver.analysis.controller; import org.rostilos.codecrow.core.dto.analysis.issue.IssueDTO; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysisIssue; import org.rostilos.codecrow.core.model.workspace.Workspace; import org.rostilos.codecrow.core.service.CodeAnalysisService; -import org.rostilos.codecrow.webserver.dto.request.analysis.issue.IssueStatusUpdateRequest; -import org.rostilos.codecrow.webserver.service.project.AnalysisService; -import org.rostilos.codecrow.webserver.service.project.ProjectService; +import org.rostilos.codecrow.webserver.analysis.dto.request.IssueStatusUpdateRequest; +import org.rostilos.codecrow.webserver.analysis.service.AnalysisService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.dto.analysis.issue.IssuesSummaryDTO; -import org.rostilos.codecrow.webserver.dto.response.analysis.AnalysisIssueResponse; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.analysis.dto.response.AnalysisIssueResponse; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -24,6 +26,8 @@ @RequestMapping("/api/{workspaceSlug}/project/{projectNamespace}/analysis/issues") public class AnalysisIssueController { + private static final Logger log = LoggerFactory.getLogger(AnalysisIssueController.class); + private final AnalysisService analysisService; private final ProjectService projectService; private final CodeAnalysisService codeAnalysisService; @@ -67,12 +71,25 @@ public ResponseEntity listIssues( if(pullRequestId != null) { maxVersion = codeAnalysisService.getMaxAnalysisPrVersion(project.getId(), Long.parseLong(pullRequestId)); resp.setMaxVersion(maxVersion); + + // Fetch the analysis comment/summary and commit hash for the specific version + int versionToFetch = prVersion > 0 ? prVersion : maxVersion; + resp.setCurrentVersion(versionToFetch); + + var analysisOpt = codeAnalysisService.findAnalysisByProjectAndPrNumberAndVersion( + project.getId(), + Long.parseLong(pullRequestId), + versionToFetch + ); + analysisOpt.ifPresent(analysis -> { + resp.setAnalysisSummary(analysis.getComment()); + resp.setCommitHash(analysis.getCommitHash()); + }); } List issues = analysisService.findIssues(project.getId(), branch, pullRequestId, severity, type, (prVersion > 0 ? prVersion : maxVersion)); List issueDTOs = issues.stream() .map(IssueDTO::fromEntity) .toList(); - IssuesSummaryDTO summary = IssuesSummaryDTO.fromIssuesDTOs(issueDTOs); diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/controller/PullRequestController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/controller/PullRequestController.java new file mode 100644 index 00000000..00cccb46 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/controller/PullRequestController.java @@ -0,0 +1,245 @@ +package org.rostilos.codecrow.webserver.analysis.controller; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.rostilos.codecrow.core.model.pullrequest.PullRequest; +import org.rostilos.codecrow.core.model.workspace.Workspace; +import org.rostilos.codecrow.core.persistence.repository.pullrequest.PullRequestRepository; +import org.rostilos.codecrow.webserver.project.service.ProjectService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.persistence.repository.branch.BranchRepository; +import org.rostilos.codecrow.core.persistence.repository.branch.BranchIssueRepository; +import org.rostilos.codecrow.core.model.branch.Branch; +import org.rostilos.codecrow.core.model.branch.BranchIssue; +import org.rostilos.codecrow.core.dto.analysis.issue.IssueDTO; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.rostilos.codecrow.core.dto.pullrequest.PullRequestDTO; + +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +@RequestMapping("/api/{workspaceSlug}/project/{projectNamespace}/pull-requests") +public class PullRequestController { + private final PullRequestRepository pullRequestRepository; + private final ProjectService projectService; + private final WorkspaceService workspaceService; + private final BranchRepository branchRepository; + private final BranchIssueRepository branchIssueRepository; + + public PullRequestController(PullRequestRepository pullRequestRepository, ProjectService projectService, + WorkspaceService workspaceService, BranchRepository branchRepository, + BranchIssueRepository branchIssueRepository) { + this.pullRequestRepository = pullRequestRepository; + this.projectService = projectService; + this.workspaceService = workspaceService; + this.branchRepository = branchRepository; + this.branchIssueRepository = branchIssueRepository; + } + + @GetMapping + @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") + public ResponseEntity> listPullRequests( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestParam(name = "page", defaultValue = "1") int page, + @RequestParam(name = "pageSize", defaultValue = "20") int pageSize + ) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + List pullRequestList = pullRequestRepository.findByProject_Id(project.getId()); + List pullRequestDTOs = pullRequestList.stream() + .map(PullRequestDTO::fromPullRequest) + .toList(); + + return new ResponseEntity<>(pullRequestDTOs, HttpStatus.OK); + } + + @GetMapping("/by-branch") + @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") + public ResponseEntity>> listPullRequestsByBranch( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace + ) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + List pullRequestList = pullRequestRepository.findByProject_Id(project.getId()); + Map> grouped = pullRequestList.stream() + .collect(Collectors.groupingBy( + pr -> pr.getTargetBranchName() == null ? "unknown" : pr.getTargetBranchName(), + Collectors.mapping(PullRequestDTO::fromPullRequest, Collectors.toList()) + )); + + // Include branches that have been analyzed but don't have PRs + List analyzedBranches = branchRepository.findByProjectId(project.getId()).stream() + .filter(branch -> branch.getTotalIssues() > 0 || !branch.getIssues().isEmpty()) + .toList(); + + for (Branch branch : analyzedBranches) { + String branchName = branch.getBranchName(); + if (!grouped.containsKey(branchName)) { + grouped.put(branchName, Collections.emptyList()); + } + } + + return new ResponseEntity<>(grouped, HttpStatus.OK); + } + + @GetMapping("/branches/{branchName}/issues") + @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") + public ResponseEntity> listBranchIssues( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @PathVariable String branchName, + @RequestParam(required = false, defaultValue = "open") String status, + @RequestParam(required = false) String severity, + @RequestParam(required = false) String category, + @RequestParam(required = false) String filePath, + @RequestParam(required = false) String dateFrom, + @RequestParam(required = false) String dateTo, + @RequestParam(required = false, defaultValue = "1") int page, + @RequestParam(required = false, defaultValue = "50") int pageSize + ) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + var branchOpt = branchRepository.findByProjectIdAndBranchName(project.getId(), branchName); + if (branchOpt.isEmpty()) { + return ResponseEntity.ok(Map.of( + "issues", List.of(), + "total", 0, + "page", page, + "pageSize", pageSize + )); + } + Branch branch = branchOpt.get(); + + // Normalize filter values + String normalizedStatus = (status == null || status.isBlank()) ? "open" : status.toLowerCase(); + String normalizedSeverity = (severity == null || severity.isBlank() || "ALL".equalsIgnoreCase(severity)) ? null : severity.toUpperCase(); + String normalizedCategory = (category == null || category.isBlank() || "ALL".equalsIgnoreCase(category)) ? null : category.toUpperCase(); + String normalizedFilePath = (filePath == null || filePath.isBlank()) ? null : filePath.toLowerCase(); + + // Parse date filters + java.time.OffsetDateTime parsedDateFrom = null; + java.time.OffsetDateTime parsedDateTo = null; + if (dateFrom != null && !dateFrom.isBlank()) { + try { + parsedDateFrom = java.time.OffsetDateTime.parse(dateFrom); + } catch (Exception e) { + try { + parsedDateFrom = java.time.LocalDate.parse(dateFrom).atStartOfDay().atOffset(java.time.ZoneOffset.UTC); + } catch (Exception ignored) {} + } + } + if (dateTo != null && !dateTo.isBlank()) { + try { + parsedDateTo = java.time.OffsetDateTime.parse(dateTo); + } catch (Exception e) { + try { + parsedDateTo = java.time.LocalDate.parse(dateTo).atTime(23, 59, 59).atOffset(java.time.ZoneOffset.UTC); + } catch (Exception ignored) {} + } + } + + //TODO: use SQL instead.... + // Fetch all issues for the branch and filter in Java (avoids complex SQL with nullable params) + List allBranchIssues = branchIssueRepository.findAllByBranchIdWithIssues(branch.getId()); + + // Apply filters in Java + final java.time.OffsetDateTime finalDateFrom = parsedDateFrom; + final java.time.OffsetDateTime finalDateTo = parsedDateTo; + + List filteredIssues = allBranchIssues.stream() + .filter(bi -> { + // Status filter + if ("open".equals(normalizedStatus) && bi.isResolved()) return false; + if ("resolved".equals(normalizedStatus) && !bi.isResolved()) return false; + // "all" passes everything + + var issue = bi.getCodeAnalysisIssue(); + if (issue == null) return false; + + // Severity filter + if (normalizedSeverity != null) { + if (issue.getSeverity() == null) return false; + if (!normalizedSeverity.equals(issue.getSeverity().name())) return false; + } + + // Category filter + if (normalizedCategory != null) { + if (issue.getIssueCategory() == null) return false; + if (!normalizedCategory.equals(issue.getIssueCategory().name())) return false; + } + + // File path filter (partial match, case insensitive) + if (normalizedFilePath != null) { + if (issue.getFilePath() == null) return false; + if (!issue.getFilePath().toLowerCase().contains(normalizedFilePath)) return false; + } + + // Date from filter + if (finalDateFrom != null && issue.getCreatedAt() != null) { + if (issue.getCreatedAt().isBefore(finalDateFrom)) return false; + } + + // Date to filter + if (finalDateTo != null && issue.getCreatedAt() != null) { + if (issue.getCreatedAt().isAfter(finalDateTo)) return false; + } + + return true; + }) + .sorted((a, b) -> Long.compare(b.getCodeAnalysisIssue().getId(), a.getCodeAnalysisIssue().getId())) + .toList(); + + long total = filteredIssues.size(); + + // Apply pagination + int startIndex = (page - 1) * pageSize; + int endIndex = Math.min(startIndex + pageSize, filteredIssues.size()); + + List pagedIssues = (startIndex < filteredIssues.size()) + ? filteredIssues.subList(startIndex, endIndex).stream() + .map(bi -> IssueDTO.fromEntity(bi.getCodeAnalysisIssue())) + .toList() + : List.of(); + + Map response = new HashMap<>(); + response.put("issues", pagedIssues); + response.put("total", total); + response.put("page", page); + response.put("pageSize", pageSize); + + return ResponseEntity.ok(response); + } + + public static class UpdatePullRequestStatusRequest { + private String status; // approved|changes_requested|merged|closed + private String comment; + + public UpdatePullRequestStatusRequest() { + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/analysis/issue/IssueStatusUpdateRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/request/IssueStatusUpdateRequest.java similarity index 59% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/analysis/issue/IssueStatusUpdateRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/request/IssueStatusUpdateRequest.java index 7acda7af..6d90025d 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/analysis/issue/IssueStatusUpdateRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/request/IssueStatusUpdateRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.analysis.issue; +package org.rostilos.codecrow.webserver.analysis.dto.request; public record IssueStatusUpdateRequest( boolean isResolved, diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/analysis/AnalysesHistoryResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/AnalysesHistoryResponse.java similarity index 81% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/analysis/AnalysesHistoryResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/AnalysesHistoryResponse.java index 7d4ca548..3dc26a27 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/analysis/AnalysesHistoryResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/AnalysesHistoryResponse.java @@ -1,6 +1,6 @@ -package org.rostilos.codecrow.webserver.dto.response.analysis; +package org.rostilos.codecrow.webserver.analysis.dto.response; import org.rostilos.codecrow.core.dto.analysis.AnalysisItemDTO; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/analysis/AnalysisIssueResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/AnalysisIssueResponse.java similarity index 53% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/analysis/AnalysisIssueResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/AnalysisIssueResponse.java index f0f54dcd..e495fdae 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/analysis/AnalysisIssueResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/dto/response/AnalysisIssueResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.analysis; +package org.rostilos.codecrow.webserver.analysis.dto.response; import org.rostilos.codecrow.core.dto.analysis.issue.IssueDTO; import org.rostilos.codecrow.core.dto.analysis.issue.IssuesSummaryDTO; @@ -10,6 +10,9 @@ public class AnalysisIssueResponse { private List issues = new ArrayList<>(); private IssuesSummaryDTO summary = new IssuesSummaryDTO(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); private int maxVersion; + private int currentVersion; + private String analysisSummary; // The comment/summary from the CodeAnalysis + private String commitHash; // The commit hash for this specific analysis version public AnalysisIssueResponse() { } @@ -34,4 +37,25 @@ public int getMaxVersion() { public void setMaxVersion(int maxVersion) { this.maxVersion = maxVersion; } + + public int getCurrentVersion() { + return currentVersion; + } + public void setCurrentVersion(int currentVersion) { + this.currentVersion = currentVersion; + } + + public String getAnalysisSummary() { + return analysisSummary; + } + public void setAnalysisSummary(String analysisSummary) { + this.analysisSummary = analysisSummary; + } + + public String getCommitHash() { + return commitHash; + } + public void setCommitHash(String commitHash) { + this.commitHash = commitHash; + } } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/service/AnalysisService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/service/AnalysisService.java new file mode 100644 index 00000000..191ca4d5 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analysis/service/AnalysisService.java @@ -0,0 +1,173 @@ +package org.rostilos.codecrow.webserver.analysis.service; + +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysisIssue; +import org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity; +import org.rostilos.codecrow.core.model.branch.BranchIssue; +import org.rostilos.codecrow.core.persistence.repository.branch.BranchRepository; +import org.rostilos.codecrow.core.service.CodeAnalysisService; +import org.rostilos.codecrow.core.persistence.repository.codeanalysis.CodeAnalysisIssueRepository; +import org.rostilos.codecrow.core.persistence.repository.branch.BranchIssueRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +@Transactional +public class AnalysisService { + + private static final Logger log = LoggerFactory.getLogger(AnalysisService.class); + + private final CodeAnalysisService codeAnalysisService; + private final CodeAnalysisIssueRepository issueRepository; + private final BranchIssueRepository branchIssueRepository; + private final BranchRepository branchRepository; + + public AnalysisService( + CodeAnalysisService codeAnalysisService, + CodeAnalysisIssueRepository issueRepository, + BranchIssueRepository branchIssueRepository, + BranchRepository branchRepository + ) { + this.codeAnalysisService = codeAnalysisService; + this.issueRepository = issueRepository; + this.branchIssueRepository = branchIssueRepository; + this.branchRepository = branchRepository; + } + + /** + * Find issues for a project with optional filters. + * - If pullRequestId is provided, prefer the analysis attached to that PR (prNumber). + * - Otherwise, filter analyses by branch (if provided) and collect issues. + * - Filter by severity and issue category (type) if provided. + */ + public List findIssues( + Long projectId, + String branch, + String pullRequestId, + String severity, + String type, + int prVersion + ) { + List analyses = new ArrayList<>(); + + if (pullRequestId != null && !pullRequestId.isBlank()) { + try { + Long prNum = Long.parseLong(pullRequestId); + Optional pa = codeAnalysisService.findByProjectIdAndPrNumberAndPrVersion(projectId, prNum, prVersion); + pa.ifPresent(analyses::add); + } catch (NumberFormatException ignored) { + // fall back to scanning all analyses + analyses = codeAnalysisService.findByProjectId(projectId); + } + } else { + analyses = codeAnalysisService.findByProjectId(projectId); + if (branch != null && !branch.isBlank()) { + analyses = analyses.stream() + .filter(a -> branch.equals(a.getBranchName())) + .toList(); + } + } + + // collect issues from selected analyses + List issues = analyses.stream() + .flatMap(a -> a.getIssues().stream()) + .toList(); + + // filter by severity if requested (supports "critical" -> HIGH mapping) + if (severity != null && !severity.isBlank()) { + String sev = severity.trim().toLowerCase(); + IssueSeverity target = null; + if ("critical".equals(sev)) { + target = IssueSeverity.HIGH; + } else { + try { + target = IssueSeverity.valueOf(sev.toUpperCase()); + } catch (IllegalArgumentException e) { + // unknown, ignore severity filter + target = null; + } + } + if (target != null) { + IssueSeverity finalTarget = target; + issues = issues.stream() + .filter(i -> i.getSeverity() == finalTarget) + .toList(); + } + } + + // filter by issue type (mapped from issueCategory on CodeAnalysisIssue) + if (type != null && !type.isBlank()) { + String t = type.trim().toUpperCase().replace(" ", "_").replace("-", "_"); + issues = issues.stream() + .filter(i -> i.getIssueCategory() != null && i.getIssueCategory().name().equals(t)) + .toList(); + } + + return issues; + } + + /** + * Update issue status: resolved|ignored|reopened + * Also updates all related BranchIssue records to keep them in sync. + */ + public boolean updateIssueStatus(Long issueId, boolean isResolved, String comment, String actor) { + log.info("updateIssueStatus called: issueId={}, isResolved={}", issueId, isResolved); + + Optional oi = issueRepository.findById(issueId); + if (oi.isEmpty()) { + log.warn("updateIssueStatus: CodeAnalysisIssue not found for id={}", issueId); + return false; + } + + CodeAnalysisIssue issue = oi.get(); + log.info("updateIssueStatus: Found issue id={}, current isResolved={}", issue.getId(), issue.isResolved()); + + issue.setResolved(isResolved); + + // optionally append the comment into reason/suggestedFix (not overwriting) + if (comment != null && !comment.isBlank()) { + String prev = issue.getReason() == null ? "" : issue.getReason(); + String appended = prev + "\n[status change by " + (actor == null ? "system" : actor) + "]: " + comment; + issue.setReason(appended); + } + + issueRepository.save(issue); + log.info("updateIssueStatus: Saved CodeAnalysisIssue id={}, isResolved={}", issue.getId(), issue.isResolved()); + + List branchIssues = branchIssueRepository.findByCodeAnalysisIssueId(issueId); + log.info("updateIssueStatus: Found {} BranchIssue records for codeAnalysisIssueId={}", branchIssues.size(), issueId); + + if (!branchIssues.isEmpty()) { + for (BranchIssue branchIssue : branchIssues) { + branchIssue.setResolved(isResolved); + } + branchIssueRepository.saveAll(branchIssues); + // Flush to ensure changes are visible for subsequent queries + branchIssueRepository.flush(); + log.info("updateIssueStatus: Updated {} BranchIssue records", branchIssues.size()); + + //Issue counts on Branch need to be updated as well + Set branchIds = branchIssues.stream() + .map(bi -> bi.getBranch().getId()) + .collect(Collectors.toSet()); + + for (Long branchId : branchIds) { + branchRepository.findByIdWithIssues(branchId).ifPresent(branch -> { + branch.updateIssueCounts(); + branchRepository.save(branch); + }); + } + } + + return true; + } + + public Optional findIssueById(Long issueId) { + return issueRepository.findById(issueId); + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/ProjectAnalysisController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analytics/controller/ProjectAnalyticsController.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/ProjectAnalysisController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analytics/controller/ProjectAnalyticsController.java index 27b2b0ce..ea395db6 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/ProjectAnalysisController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analytics/controller/ProjectAnalyticsController.java @@ -1,36 +1,43 @@ -package org.rostilos.codecrow.webserver.controller.analysis; +package org.rostilos.codecrow.webserver.analytics.controller; import org.rostilos.codecrow.core.dto.analysis.AnalysisItemDTO; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysisIssue; import org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity; -import org.rostilos.codecrow.webserver.dto.response.analysis.AnalysesHistoryResponse; -import org.rostilos.codecrow.webserver.service.project.AnalysisService; -import org.rostilos.codecrow.webserver.service.project.ProjectService; +import org.rostilos.codecrow.webserver.analysis.dto.response.AnalysesHistoryResponse; +import org.rostilos.codecrow.webserver.analysis.service.AnalysisService; +import org.rostilos.codecrow.webserver.analytics.service.ProjectAnalyticsService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.service.CodeAnalysisService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.time.OffsetDateTime; import java.util.*; -import java.util.stream.Collectors; @CrossOrigin(origins = "*", maxAge = 3600) @RestController @RequestMapping("/api/{workspaceSlug}/projects/{projectNamespace}/analysis") -public class ProjectAnalysisController { +public class ProjectAnalyticsController { private final AnalysisService analysisService; private final ProjectService projectService; private final WorkspaceService workspaceService; + private final ProjectAnalyticsService projectAnalyticsService; - public ProjectAnalysisController(AnalysisService analysisService, ProjectService projectService, WorkspaceService workspaceService) { + public ProjectAnalyticsController( + AnalysisService analysisService, + ProjectService projectService, + WorkspaceService workspaceService, + ProjectAnalyticsService projectAnalyticsService + ) { this.analysisService = analysisService; this.projectService = projectService; this.workspaceService = workspaceService; + this.projectAnalyticsService = projectAnalyticsService; } /** @@ -60,7 +67,7 @@ public ResponseEntity getProjectSummary( ProjectSummaryResponse resp = new ProjectSummaryResponse(); if (branch != null && !branch.isBlank()) { - org.rostilos.codecrow.core.service.BranchService.BranchStats stats = analysisService.getBranchStats(projectId, branch); + org.rostilos.codecrow.core.service.BranchService.BranchStats stats = projectAnalyticsService.getBranchStats(projectId, branch); resp.setTotalIssues((int) stats.getTotalIssues()); resp.setCriticalIssues((int) stats.getHighSeverityCount()); resp.setHighIssues((int) stats.getHighSeverityCount()); @@ -70,13 +77,13 @@ public ResponseEntity getProjectSummary( resp.setLastAnalysisDate(stats.getLastAnalysisDate().toString()); } } else { - CodeAnalysisService.AnalysisStats stats = analysisService.getProjectStats(projectId); + CodeAnalysisService.AnalysisStats stats = projectAnalyticsService.getProjectStats(projectId); resp.setTotalIssues((int) stats.getTotalIssues()); resp.setCriticalIssues((int) stats.getHighSeverityCount()); resp.setHighIssues((int) stats.getHighSeverityCount()); resp.setMediumIssues((int) stats.getMediumSeverityCount()); resp.setLowIssues((int) stats.getLowSeverityCount()); - Optional latest = analysisService.findLatestAnalysis(projectId); + Optional latest = projectAnalyticsService.findLatestAnalysis(projectId); latest.ifPresent(a -> resp.setLastAnalysisDate(a.getUpdatedAt() == null ? null : a.getUpdatedAt().toString())); } @@ -104,21 +111,23 @@ public ResponseEntity getDetailedStats( //TODO: service method to avoid code duplication if (branch != null && !branch.isBlank()) { - org.rostilos.codecrow.core.service.BranchService.BranchStats stats = analysisService.getBranchStats(projectId, branch); - List branchIssues = analysisService.getBranchIssues(projectId, branch); - List history = analysisService.getBranchAnalysisHistory(projectId, branch); + org.rostilos.codecrow.core.service.BranchService.BranchStats stats = projectAnalyticsService.getBranchStats(projectId, branch); + List branchIssues = projectAnalyticsService.getBranchIssues(projectId, branch); + List history = projectAnalyticsService.getBranchAnalysisHistory(projectId, branch); resp.setTotalIssues((int) stats.getTotalIssues()); resp.setCriticalIssues((int) stats.getHighSeverityCount()); resp.setHighIssues((int) stats.getHighSeverityCount()); resp.setMediumIssues((int) stats.getMediumSeverityCount()); resp.setLowIssues((int) stats.getLowSeverityCount()); + resp.setResolvedIssuesCount((int) stats.getResolvedCount()); + resp.setOpenIssuesCount((int) stats.getTotalIssues()); if (stats.getLastAnalysisDate() != null) { resp.setLastAnalysisDate(stats.getLastAnalysisDate().toString()); } - String trend = analysisService.calculateTrend(projectId, branch, timeframeDays); + String trend = projectAnalyticsService.calculateTrend(projectId, branch, timeframeDays); resp.setTrend(trend); Map issuesByType = new HashMap<>(); @@ -176,7 +185,7 @@ public ResponseEntity getDetailedStats( .filter(bi -> file.equals(bi.getCodeAnalysisIssue().getFilePath())) .map(org.rostilos.codecrow.core.model.branch.BranchIssue::getSeverity) .filter(Objects::nonNull) - .sorted(Comparator.comparingInt(ProjectAnalysisController::severityRank)) + .sorted(Comparator.comparingInt(ProjectAnalyticsController::severityRank)) .findFirst(); String sev = "medium"; if (max.isPresent()) { @@ -199,22 +208,28 @@ public ResponseEntity getDetailedStats( resp.setBranchStats(branchMap); } else { - CodeAnalysisService.AnalysisStats stats = analysisService.getProjectStats(projectId); + CodeAnalysisService.AnalysisStats stats = projectAnalyticsService.getProjectStats(projectId); List allIssues = analysisService.findIssues(projectId, null, null, null, null, 0); - List history = analysisService.getAnalysisHistory(projectId, null); + List history = projectAnalyticsService.getAnalysisHistory(projectId, null); resp.setTotalIssues((int) stats.getTotalIssues()); resp.setCriticalIssues((int) stats.getHighSeverityCount()); resp.setHighIssues((int) stats.getHighSeverityCount()); resp.setMediumIssues((int) stats.getMediumSeverityCount()); resp.setLowIssues((int) stats.getLowSeverityCount()); + + // Calculate resolved count from all issues + long resolvedCount = allIssues.stream().filter(CodeAnalysisIssue::isResolved).count(); + long openCount = allIssues.stream().filter(i -> !i.isResolved()).count(); + resp.setResolvedIssuesCount((int) resolvedCount); + resp.setOpenIssuesCount((int) openCount); if (!history.isEmpty()) { CodeAnalysis latest = history.get(0); resp.setLastAnalysisDate(latest.getUpdatedAt() == null ? null : latest.getUpdatedAt().toString()); } - String trend = analysisService.calculateTrend(projectId, null, timeframeDays); + String trend = projectAnalyticsService.calculateTrend(projectId, null, timeframeDays); resp.setTrend(trend); Map issuesByType = new HashMap<>(); @@ -269,7 +284,7 @@ public ResponseEntity getDetailedStats( .filter(i -> file.equals(i.getFilePath())) .map(CodeAnalysisIssue::getSeverity) .filter(Objects::nonNull) - .sorted(Comparator.comparingInt(ProjectAnalysisController::severityRank)) + .sorted(Comparator.comparingInt(ProjectAnalyticsController::severityRank)) .findFirst(); String sev = "medium"; if (max.isPresent()) { @@ -324,7 +339,7 @@ public ResponseEntity getAnalysisHistory( ) { Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); Project project = projectService.getProjectByWorkspaceAndNamespace(workspaceId, projectNamespace); - List analyses = analysisService.getAnalysisHistory(project.getId(), branch); + List analyses = projectAnalyticsService.getAnalysisHistory(project.getId(), branch); List items = analyses.stream() .map(AnalysisItemDTO::fromEntity) @@ -341,7 +356,7 @@ public ResponseEntity getAnalysisHistory( */ @GetMapping("/trends/resolved") @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") - public ResponseEntity> getResolvedTrend( + public ResponseEntity> getResolvedTrend( @PathVariable String workspaceSlug, @PathVariable String projectNamespace, @RequestParam(name = "branch", required = false) String branch, @@ -350,8 +365,8 @@ public ResponseEntity trend = - analysisService.getResolvedTrend(project.getId(), branch, limit, timeframeDays); + List trend = + projectAnalyticsService.getResolvedTrend(project.getId(), branch, limit, timeframeDays); return ResponseEntity.ok(trend); } @@ -363,7 +378,7 @@ public ResponseEntity> getBranchIssuesTrend( + public ResponseEntity> getBranchIssuesTrend( @PathVariable String workspaceSlug, @PathVariable String projectNamespace, @RequestParam(name = "branch", required = true) String branch, @@ -372,8 +387,8 @@ public ResponseEntity trend = - analysisService.getBranchIssuesTrend(project.getId(), branch, limit, timeframeDays); + List trend = + projectAnalyticsService.getBranchIssuesTrend(project.getId(), branch, limit, timeframeDays); return ResponseEntity.ok(trend); } @@ -531,7 +546,9 @@ public static class DetailedStatsResponse { private int highIssues; private int mediumIssues; private int lowIssues; - //TODO: add resolved issues count to the stats + private int resolvedIssuesCount; + private int openIssuesCount; + private int ignoredIssuesCount; private String lastAnalysisDate; private Map issuesByType; private List recentAnalyses; @@ -554,6 +571,15 @@ public static class DetailedStatsResponse { public int getLowIssues() { return lowIssues; } public void setLowIssues(int lowIssues) { this.lowIssues = lowIssues; } + public int getResolvedIssuesCount() { return resolvedIssuesCount; } + public void setResolvedIssuesCount(int resolvedIssuesCount) { this.resolvedIssuesCount = resolvedIssuesCount; } + + public int getOpenIssuesCount() { return openIssuesCount; } + public void setOpenIssuesCount(int openIssuesCount) { this.openIssuesCount = openIssuesCount; } + + public int getIgnoredIssuesCount() { return ignoredIssuesCount; } + public void setIgnoredIssuesCount(int ignoredIssuesCount) { this.ignoredIssuesCount = ignoredIssuesCount; } + public String getLastAnalysisDate() { return lastAnalysisDate; } public void setLastAnalysisDate(String lastAnalysisDate) { this.lastAnalysisDate = lastAnalysisDate; } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/AnalysisService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analytics/service/ProjectAnalyticsService.java similarity index 56% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/AnalysisService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analytics/service/ProjectAnalyticsService.java index 407e8f49..d595e6b2 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/AnalysisService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/analytics/service/ProjectAnalyticsService.java @@ -1,165 +1,93 @@ -package org.rostilos.codecrow.webserver.service.project; +package org.rostilos.codecrow.webserver.analytics.service; -import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; -import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysisIssue; -import org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity; import org.rostilos.codecrow.core.model.branch.Branch; import org.rostilos.codecrow.core.model.branch.BranchIssue; -import org.rostilos.codecrow.core.service.CodeAnalysisService; +import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; import org.rostilos.codecrow.core.service.BranchService; -import org.rostilos.codecrow.core.persistence.repository.codeanalysis.CodeAnalysisIssueRepository; -import org.rostilos.codecrow.core.persistence.repository.branch.BranchIssueRepository; +import org.rostilos.codecrow.core.service.CodeAnalysisService; +import org.rostilos.codecrow.webserver.analysis.service.AnalysisService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; @Service @Transactional -public class AnalysisService { - +public class ProjectAnalyticsService { private static final Logger log = LoggerFactory.getLogger(AnalysisService.class); private final CodeAnalysisService codeAnalysisService; private final BranchService branchService; - private final CodeAnalysisIssueRepository issueRepository; - private final BranchIssueRepository branchIssueRepository; private final ProjectService projectService; - public AnalysisService(CodeAnalysisService codeAnalysisService, - BranchService branchService, - CodeAnalysisIssueRepository issueRepository, - BranchIssueRepository branchIssueRepository, - ProjectService projectService) { + public ProjectAnalyticsService( + CodeAnalysisService codeAnalysisService, + BranchService branchService, + ProjectService projectService + ) { this.codeAnalysisService = codeAnalysisService; this.branchService = branchService; - this.issueRepository = issueRepository; - this.branchIssueRepository = branchIssueRepository; this.projectService = projectService; } /** - * Find issues for a project with optional filters. - * - If pullRequestId is provided, prefer the analysis attached to that PR (prNumber). - * - Otherwise, filter analyses by branch (if provided) and collect issues. - * - Filter by severity and issue category (type) if provided. + * Return total issues trend for a specific branch. + * The trend is a list of points with timestamp and total issues count from branch analysis history. + * - branch is required for this method + * - limit controls how many recent analyses are returned (use 0 or negative for all) + * - timeframeDays controls the date range to include (default 30 days) */ - public List findIssues( - Long projectId, - String branch, - String pullRequestId, - String severity, - String type, - int prVersion - ) { - List analyses = new ArrayList<>(); - - if (pullRequestId != null && !pullRequestId.isBlank()) { - try { - Long prNum = Long.parseLong(pullRequestId); - Optional pa = codeAnalysisService.findByProjectIdAndPrNumberAndPrVersion(projectId, prNum, prVersion); - pa.ifPresent(analyses::add); - } catch (NumberFormatException ignored) { - // fall back to scanning all analyses - analyses = codeAnalysisService.findByProjectId(projectId); - } - } else { - analyses = codeAnalysisService.findByProjectId(projectId); - if (branch != null && !branch.isBlank()) { - analyses = analyses.stream() - .filter(a -> branch.equals(a.getBranchName())) - .toList(); - } + public List getBranchIssuesTrend(Long projectId, String branch, int limit, int timeframeDays) { + if (branch == null || branch.isBlank()) { + return new ArrayList<>(); } - // collect issues from selected analyses - List issues = analyses.stream() - .flatMap(a -> a.getIssues().stream()) - .toList(); - - // filter by severity if requested (supports "critical" -> HIGH mapping) - if (severity != null && !severity.isBlank()) { - String sev = severity.trim().toLowerCase(); - IssueSeverity target = null; - if ("critical".equals(sev)) { - target = IssueSeverity.HIGH; - } else { - try { - target = IssueSeverity.valueOf(sev.toUpperCase()); - } catch (IllegalArgumentException e) { - // unknown, ignore severity filter - target = null; - } - } - if (target != null) { - IssueSeverity finalTarget = target; - issues = issues.stream() - .filter(i -> i.getSeverity() == finalTarget) - .toList(); - } - } + List analyses = getBranchAnalysisHistory(projectId, branch); - // filter by issue type (mapped from issueCategory on CodeAnalysisIssue) - if (type != null && !type.isBlank()) { - String t = type.trim().toUpperCase().replace(" ", "_").replace("-", "_"); - issues = issues.stream() - .filter(i -> i.getIssueCategory() != null && i.getIssueCategory().name().equals(t)) + // Filter by timeframe if specified + if (timeframeDays > 0) { + java.time.OffsetDateTime cutoff = java.time.OffsetDateTime.now().minusDays(timeframeDays); + analyses = analyses.stream() + .filter(a -> a.getCreatedAt() != null && a.getCreatedAt().isAfter(cutoff)) .toList(); } - return issues; - } - - /** - * Update issue status: resolved|ignored|reopened - * Also updates all related BranchIssue records to keep them in sync. - */ - public boolean updateIssueStatus(Long issueId, boolean isResolved, String comment, String actor) { - log.info("updateIssueStatus called: issueId={}, isResolved={}", issueId, isResolved); - - Optional oi = issueRepository.findById(issueId); - if (oi.isEmpty()) { - log.warn("updateIssueStatus: CodeAnalysisIssue not found for id={}", issueId); - return false; - } - - CodeAnalysisIssue issue = oi.get(); - log.info("updateIssueStatus: Found issue id={}, current isResolved={}", issue.getId(), issue.isResolved()); - - issue.setResolved(isResolved); + // order by createdAt ascending (older -> newer) to produce trend + analyses = analyses.stream() + .sorted(Comparator.comparing(CodeAnalysis::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) + .toList(); - // optionally append the comment into reason/suggestedFix (not overwriting) - if (comment != null && !comment.isBlank()) { - String prev = issue.getReason() == null ? "" : issue.getReason(); - String appended = prev + "\n[status change by " + (actor == null ? "system" : actor) + "]: " + comment; - issue.setReason(appended); + // if limit provided, take last 'limit' items + if (limit > 0 && analyses.size() > limit) { + analyses = analyses.subList(Math.max(0, analyses.size() - limit), analyses.size()); } - issueRepository.save(issue); - log.info("updateIssueStatus: Saved CodeAnalysisIssue id={}, isResolved={}", issue.getId(), issue.isResolved()); - - List branchIssues = branchIssueRepository.findByCodeAnalysisIssueId(issueId); - log.info("updateIssueStatus: Found {} BranchIssue records for codeAnalysisIssueId={}", branchIssues.size(), issueId); - - if (!branchIssues.isEmpty()) { - for (BranchIssue branchIssue : branchIssues) { - branchIssue.setResolved(isResolved); - } - branchIssueRepository.saveAll(branchIssues); - log.info("updateIssueStatus: Updated {} BranchIssue records", branchIssues.size()); + List trend = new ArrayList<>(); + for (CodeAnalysis a : analyses) { + String date = a.getCreatedAt() == null ? a.getUpdatedAt().toString() : a.getCreatedAt().toString(); + trend.add(new BranchIssuesTrendPoint( + date, + a.getTotalIssues(), + a.getHighSeverityCount(), + a.getMediumSeverityCount(), + a.getLowSeverityCount() + )); } - - return true; + return trend; } - public Optional findIssueById(Long issueId) { - return issueRepository.findById(issueId); + public List getBranchAnalysisHistory(Long projectId, String branchName) { + return branchService.getBranchAnalysisHistory(projectId, branchName); } - public Optional findLatestAnalysis(Long projectId) { - return codeAnalysisService.findLatestByProjectId(projectId); + public BranchService.BranchStats getBranchStats(Long projectId, String branchName) { + return branchService.getBranchStats(projectId, branchName); } public List getAnalysisHistory(Long projectId, String branch) { @@ -172,98 +100,11 @@ public List getAnalysisHistory(Long projectId, String branch) { return analyses; } - public CodeAnalysisService.AnalysisStats getProjectStats(Long projectId) { - return codeAnalysisService.getProjectAnalysisStats(projectId); - } - - public Optional findIssueByNamespaceAndId(Long workspaceId, String namespace, Long issueId) { - // Issues are global by id; ensure the issue belongs to the project - Long projectId = projectService.getProjectByWorkspaceAndNamespace(workspaceId, namespace).getId(); - Optional oi = issueRepository.findById(issueId); - if (oi.isPresent() && oi.get().getAnalysis() != null && oi.get().getAnalysis().getProject() != null) { - Long issueProjectId = oi.get().getAnalysis().getProject().getId(); - if (!projectId.equals(issueProjectId)) { - return Optional.empty(); - } - } - return oi; - } - - public Optional findLatestAnalysis(Long workspaceId, String namespace) { - Long projectId = projectService.getProjectByWorkspaceAndNamespace(workspaceId, namespace).getId(); - return findLatestAnalysis(projectId); - } - public List getAnalysisHistory(Long workspaceId, String namespace, String branch) { Long projectId = projectService.getProjectByWorkspaceAndNamespace(workspaceId, namespace).getId(); return getAnalysisHistory(projectId, branch); } - public CodeAnalysisService.AnalysisStats getProjectStats(Long workspaceId, String namespace) { - Long projectId = projectService.getProjectByWorkspaceAndNamespace(workspaceId, namespace).getId(); - return getProjectStats(projectId); - } - - public BranchService.BranchStats getBranchStats(Long projectId, String branchName) { - return branchService.getBranchStats(projectId, branchName); - } - - public BranchService.BranchStats getBranchStats(Long workspaceId, String namespace, String branchName) { - Long projectId = projectService.getProjectByWorkspaceAndNamespace(workspaceId, namespace).getId(); - return getBranchStats(projectId, branchName); - } - - public List getBranchIssues(Long projectId, String branchName) { - Optional branchOpt = branchService.findByProjectIdAndBranchName(projectId, branchName); - return branchOpt.map(branch -> branchService.findIssuesByBranchId(branch.getId())).orElse(new ArrayList<>()); - } - - public List getBranchAnalysisHistory(Long projectId, String branchName) { - return branchService.getBranchAnalysisHistory(projectId, branchName); - } - - /** - * Return resolved-issues trend for a project. - * The trend is a list of points with timestamp, resolvedCount, totalIssues and resolvedRate (0.0-1.0). - * - branch may be null to include all branches - * - limit controls how many recent analyses are returned (use 0 or negative for all) - * - timeframeDays controls the date range to include (default 30 days) - */ - public List getResolvedTrend(Long projectId, String branch, int limit, int timeframeDays) { - List analyses = getAnalysisHistory(projectId, branch); - - // Filter by timeframe if specified - if (timeframeDays > 0) { - java.time.OffsetDateTime cutoff = java.time.OffsetDateTime.now().minusDays(timeframeDays); - analyses = analyses.stream() - .filter(a -> a.getCreatedAt() != null && a.getCreatedAt().isAfter(cutoff)) - .toList(); - } - - // order by createdAt ascending (older -> newer) to produce trend - analyses = analyses.stream() - .sorted(Comparator.comparing(CodeAnalysis::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder()))) - .toList(); - - // if limit provided, take last 'limit' items - if (limit > 0 && analyses.size() > limit) { - analyses = analyses.subList(Math.max(0, analyses.size() - limit), analyses.size()); - } - - List trend = new ArrayList<>(); - for (CodeAnalysis a : analyses) { - int total = a.getTotalIssues(); - int resolved = a.getResolvedCount(); - double rate = 0.0; - if (total > 0) { - rate = (double) resolved / (double) total; - } - String date = a.getCreatedAt() == null ? a.getUpdatedAt().toString() : a.getCreatedAt().toString(); - trend.add(new ResolvedTrendPoint(date, resolved, total, rate)); - } - return trend; - } - /** * Calculate issue trend direction based on historical data within the timeframe. * Returns "up" if issues are increasing, "down" if decreasing, "stable" if no significant change. @@ -323,19 +164,28 @@ public String calculateTrend(Long projectId, String branch, int timeframeDays) { } } + public List getBranchIssues(Long projectId, String branchName) { + Optional branchOpt = branchService.findByProjectIdAndBranchName(projectId, branchName); + return branchOpt.map(branch -> branchService.findIssuesByBranchId(branch.getId())).orElse(new ArrayList<>()); + } + + public CodeAnalysisService.AnalysisStats getProjectStats(Long projectId) { + return codeAnalysisService.getProjectAnalysisStats(projectId); + } + + public Optional findLatestAnalysis(Long projectId) { + return codeAnalysisService.findLatestByProjectId(projectId); + } + /** - * Return total issues trend for a specific branch. - * The trend is a list of points with timestamp and total issues count from branch analysis history. - * - branch is required for this method + * Return resolved-issues trend for a project. + * The trend is a list of points with timestamp, resolvedCount, totalIssues and resolvedRate (0.0-1.0). + * - branch may be null to include all branches * - limit controls how many recent analyses are returned (use 0 or negative for all) * - timeframeDays controls the date range to include (default 30 days) */ - public List getBranchIssuesTrend(Long projectId, String branch, int limit, int timeframeDays) { - if (branch == null || branch.isBlank()) { - return new ArrayList<>(); - } - - List analyses = getBranchAnalysisHistory(projectId, branch); + public List getResolvedTrend(Long projectId, String branch, int limit, int timeframeDays) { + List analyses = getAnalysisHistory(projectId, branch); // Filter by timeframe if specified if (timeframeDays > 0) { @@ -355,16 +205,16 @@ public List getBranchIssuesTrend(Long projectId, String analyses = analyses.subList(Math.max(0, analyses.size() - limit), analyses.size()); } - List trend = new ArrayList<>(); + List trend = new ArrayList<>(); for (CodeAnalysis a : analyses) { + int total = a.getTotalIssues(); + int resolved = a.getResolvedCount(); + double rate = 0.0; + if (total > 0) { + rate = (double) resolved / (double) total; + } String date = a.getCreatedAt() == null ? a.getUpdatedAt().toString() : a.getCreatedAt().toString(); - trend.add(new BranchIssuesTrendPoint( - date, - a.getTotalIssues(), - a.getHighSeverityCount(), - a.getMediumSeverityCount(), - a.getLowSeverityCount() - )); + trend.add(new ResolvedTrendPoint(date, resolved, total, rate)); } return trend; } @@ -410,7 +260,8 @@ public static class BranchIssuesTrendPoint { private int mediumSeverityCount; private int lowSeverityCount; - public BranchIssuesTrendPoint() {} + public BranchIssuesTrendPoint() { + } public BranchIssuesTrendPoint(String date, int totalIssues, int highSeverityCount, int mediumSeverityCount, int lowSeverityCount) { this.date = date; @@ -420,19 +271,44 @@ public BranchIssuesTrendPoint(String date, int totalIssues, int highSeverityCoun this.lowSeverityCount = lowSeverityCount; } - public String getDate() { return date; } - public void setDate(String date) { this.date = date; } + public String getDate() { + return date; + } - public int getTotalIssues() { return totalIssues; } - public void setTotalIssues(int totalIssues) { this.totalIssues = totalIssues; } + public void setDate(String date) { + this.date = date; + } + + public int getTotalIssues() { + return totalIssues; + } + + public void setTotalIssues(int totalIssues) { + this.totalIssues = totalIssues; + } - public int getHighSeverityCount() { return highSeverityCount; } - public void setHighSeverityCount(int highSeverityCount) { this.highSeverityCount = highSeverityCount; } + public int getHighSeverityCount() { + return highSeverityCount; + } + + public void setHighSeverityCount(int highSeverityCount) { + this.highSeverityCount = highSeverityCount; + } + + public int getMediumSeverityCount() { + return mediumSeverityCount; + } - public int getMediumSeverityCount() { return mediumSeverityCount; } - public void setMediumSeverityCount(int mediumSeverityCount) { this.mediumSeverityCount = mediumSeverityCount; } + public void setMediumSeverityCount(int mediumSeverityCount) { + this.mediumSeverityCount = mediumSeverityCount; + } + + public int getLowSeverityCount() { + return lowSeverityCount; + } - public int getLowSeverityCount() { return lowSeverityCount; } - public void setLowSeverityCount(int lowSeverityCount) { this.lowSeverityCount = lowSeverityCount; } + public void setLowSeverityCount(int lowSeverityCount) { + this.lowSeverityCount = lowSeverityCount; + } } } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/AuthController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/AuthController.java similarity index 91% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/AuthController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/AuthController.java index 95e89461..2abf67d5 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/AuthController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/AuthController.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.controller.auth; +package org.rostilos.codecrow.webserver.auth.controller; import java.util.HashSet; import java.util.List; @@ -16,28 +16,24 @@ import org.rostilos.codecrow.core.model.user.status.EStatus; import org.rostilos.codecrow.core.model.user.twofactor.ETwoFactorType; import org.rostilos.codecrow.core.model.user.twofactor.TwoFactorAuth; -import org.rostilos.codecrow.webserver.dto.message.ErrorMessageResponse; -import org.rostilos.codecrow.webserver.dto.response.auth.JwtResponse; -import org.rostilos.codecrow.webserver.dto.response.auth.ResetTokenValidationResponse; -import org.rostilos.codecrow.webserver.dto.response.auth.TwoFactorRequiredResponse; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.JwtResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.ResetTokenValidationResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.TwoFactorRequiredResponse; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; import org.rostilos.codecrow.core.persistence.repository.user.RoleRepository; import org.rostilos.codecrow.core.persistence.repository.user.UserRepository; -import org.rostilos.codecrow.webserver.dto.request.auth.ForgotPasswordRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.LoginRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.RefreshTokenRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.ResetPasswordRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.SignupRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.TwoFactorLoginRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.ValidateResetTokenRequest; -import org.rostilos.codecrow.webserver.exception.InvalidResetTokenException; -import org.rostilos.codecrow.webserver.exception.TwoFactorInvalidException; -import org.rostilos.codecrow.webserver.service.auth.PasswordResetService; -import org.rostilos.codecrow.webserver.service.auth.RefreshTokenService; -import org.rostilos.codecrow.webserver.service.auth.TwoFactorAuthService; +import org.rostilos.codecrow.webserver.auth.dto.request.ForgotPasswordRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.LoginRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.RefreshTokenRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.ResetPasswordRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.SignupRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.TwoFactorLoginRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.ValidateResetTokenRequest; +import org.rostilos.codecrow.webserver.auth.service.PasswordResetService; +import org.rostilos.codecrow.webserver.auth.service.RefreshTokenService; +import org.rostilos.codecrow.webserver.auth.service.TwoFactorAuthService; import org.rostilos.codecrow.security.jwt.utils.JwtUtils; import org.rostilos.codecrow.security.service.UserDetailsImpl; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/GoogleAuthController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/GoogleAuthController.java similarity index 71% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/GoogleAuthController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/GoogleAuthController.java index 624b6777..eb67b78b 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/GoogleAuthController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/GoogleAuthController.java @@ -1,13 +1,11 @@ -package org.rostilos.codecrow.webserver.controller.auth; +package org.rostilos.codecrow.webserver.auth.controller; import jakarta.validation.Valid; -import org.rostilos.codecrow.webserver.dto.message.ErrorMessageResponse; -import org.rostilos.codecrow.webserver.dto.request.auth.GoogleAuthRequest; -import org.rostilos.codecrow.webserver.dto.response.auth.JwtResponse; -import org.rostilos.codecrow.webserver.service.auth.GoogleOAuthService; +import org.rostilos.codecrow.webserver.auth.dto.request.GoogleAuthRequest; +import org.rostilos.codecrow.webserver.auth.dto.response.JwtResponse; +import org.rostilos.codecrow.webserver.auth.service.GoogleOAuthService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/TwoFactorAuthController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/TwoFactorAuthController.java similarity index 90% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/TwoFactorAuthController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/TwoFactorAuthController.java index 60e0c354..da4477ba 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/auth/TwoFactorAuthController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/controller/TwoFactorAuthController.java @@ -1,14 +1,14 @@ -package org.rostilos.codecrow.webserver.controller.auth; +package org.rostilos.codecrow.webserver.auth.controller; import jakarta.validation.Valid; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; import org.rostilos.codecrow.core.model.user.twofactor.TwoFactorAuth; -import org.rostilos.codecrow.webserver.dto.request.auth.TwoFactorSetupRequest; -import org.rostilos.codecrow.webserver.dto.request.auth.TwoFactorVerifyRequest; -import org.rostilos.codecrow.webserver.dto.response.auth.TwoFactorEnableResponse; -import org.rostilos.codecrow.webserver.dto.response.auth.TwoFactorSetupResponse; -import org.rostilos.codecrow.webserver.dto.response.auth.TwoFactorStatusResponse; -import org.rostilos.codecrow.webserver.service.auth.TwoFactorAuthService; +import org.rostilos.codecrow.webserver.auth.dto.request.TwoFactorSetupRequest; +import org.rostilos.codecrow.webserver.auth.dto.request.TwoFactorVerifyRequest; +import org.rostilos.codecrow.webserver.auth.dto.response.TwoFactorEnableResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.TwoFactorSetupResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.TwoFactorStatusResponse; +import org.rostilos.codecrow.webserver.auth.service.TwoFactorAuthService; import org.rostilos.codecrow.security.service.UserDetailsImpl; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ForgotPasswordRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ForgotPasswordRequest.java similarity index 90% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ForgotPasswordRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ForgotPasswordRequest.java index 1d142819..7a6b36c0 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ForgotPasswordRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ForgotPasswordRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/GoogleAuthRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/GoogleAuthRequest.java similarity index 88% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/GoogleAuthRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/GoogleAuthRequest.java index 6e8ed427..a58c5fc4 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/GoogleAuthRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/GoogleAuthRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/LoginRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/LoginRequest.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/LoginRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/LoginRequest.java index 0e114170..eb6c7c92 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/LoginRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/RefreshTokenRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/RefreshTokenRequest.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/RefreshTokenRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/RefreshTokenRequest.java index 9390c2c2..20c463cd 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/RefreshTokenRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/RefreshTokenRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ResetPasswordRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ResetPasswordRequest.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ResetPasswordRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ResetPasswordRequest.java index 5ea69253..fb1c05d3 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ResetPasswordRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ResetPasswordRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/SignupRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/SignupRequest.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/SignupRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/SignupRequest.java index 8105c319..19b0ed3b 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/SignupRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/SignupRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import java.util.Set; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorLoginRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorLoginRequest.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorLoginRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorLoginRequest.java index edf18ee5..562e5980 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorLoginRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorLoginRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorSetupRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorSetupRequest.java similarity index 83% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorSetupRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorSetupRequest.java index 52d1ddf4..d01b769d 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorSetupRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorSetupRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorVerifyRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorVerifyRequest.java similarity index 81% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorVerifyRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorVerifyRequest.java index 51ed7afa..63e6618d 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/TwoFactorVerifyRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/TwoFactorVerifyRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ValidateResetTokenRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ValidateResetTokenRequest.java similarity index 88% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ValidateResetTokenRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ValidateResetTokenRequest.java index d1cdf514..9695c51f 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/auth/ValidateResetTokenRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/request/ValidateResetTokenRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.auth; +package org.rostilos.codecrow.webserver.auth.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/JwtResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/JwtResponse.java similarity index 97% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/JwtResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/JwtResponse.java index 62126a45..e30e3156 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/JwtResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/JwtResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.auth; +package org.rostilos.codecrow.webserver.auth.dto.response; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/ResetTokenValidationResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/ResetTokenValidationResponse.java similarity index 96% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/ResetTokenValidationResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/ResetTokenValidationResponse.java index 39cf577a..1da3772b 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/ResetTokenValidationResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/ResetTokenValidationResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.auth; +package org.rostilos.codecrow.webserver.auth.dto.response; public class ResetTokenValidationResponse { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorEnableResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorEnableResponse.java similarity index 93% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorEnableResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorEnableResponse.java index 46201275..c056b46d 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorEnableResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorEnableResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.auth; +package org.rostilos.codecrow.webserver.auth.dto.response; public class TwoFactorEnableResponse { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorRequiredResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorRequiredResponse.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorRequiredResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorRequiredResponse.java index abfdcc44..d22d5544 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorRequiredResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorRequiredResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.auth; +package org.rostilos.codecrow.webserver.auth.dto.response; public class TwoFactorRequiredResponse { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorSetupResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorSetupResponse.java similarity index 94% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorSetupResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorSetupResponse.java index f33e6d7b..71f9dc29 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorSetupResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorSetupResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.auth; +package org.rostilos.codecrow.webserver.auth.dto.response; public class TwoFactorSetupResponse { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorStatusResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorStatusResponse.java similarity index 93% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorStatusResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorStatusResponse.java index 6557153c..15ad7b2a 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/auth/TwoFactorStatusResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/dto/response/TwoFactorStatusResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.auth; +package org.rostilos.codecrow.webserver.auth.dto.response; public class TwoFactorStatusResponse { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/GoogleOAuthService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/GoogleOAuthService.java similarity index 97% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/GoogleOAuthService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/GoogleOAuthService.java index 9fb88117..6df766fe 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/GoogleOAuthService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/GoogleOAuthService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.auth; +package org.rostilos.codecrow.webserver.auth.service; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; @@ -11,9 +11,7 @@ import org.rostilos.codecrow.core.persistence.repository.user.UserRepository; import org.rostilos.codecrow.security.jwt.utils.JwtUtils; import org.rostilos.codecrow.security.service.UserDetailsImpl; -import org.rostilos.codecrow.webserver.dto.response.auth.JwtResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import org.rostilos.codecrow.webserver.auth.dto.response.JwtResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/PasswordResetService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/PasswordResetService.java similarity index 97% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/PasswordResetService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/PasswordResetService.java index 7d62f21b..1654cbce 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/PasswordResetService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/PasswordResetService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.auth; +package org.rostilos.codecrow.webserver.auth.service; import org.rostilos.codecrow.core.model.user.PasswordResetToken; import org.rostilos.codecrow.core.model.user.User; @@ -7,7 +7,7 @@ import org.rostilos.codecrow.core.persistence.repository.user.PasswordResetTokenRepository; import org.rostilos.codecrow.core.persistence.repository.user.UserRepository; import org.rostilos.codecrow.email.service.EmailService; -import org.rostilos.codecrow.webserver.dto.response.auth.ResetTokenValidationResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.ResetTokenValidationResponse; import org.rostilos.codecrow.webserver.exception.InvalidResetTokenException; import org.rostilos.codecrow.webserver.exception.TwoFactorInvalidException; import org.slf4j.Logger; @@ -34,7 +34,7 @@ public class PasswordResetService { private final PasswordEncoder passwordEncoder; private final SecureRandom secureRandom; - @Value("${codecrow.frontend.url:http://localhost:5173}") + @Value("${codecrow.frontend.url:http://localhost:8080}") private String frontendUrl; public PasswordResetService( diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/RefreshTokenService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/RefreshTokenService.java similarity index 98% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/RefreshTokenService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/RefreshTokenService.java index 14b3b0c7..cf66bd8d 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/RefreshTokenService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/RefreshTokenService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.auth; +package org.rostilos.codecrow.webserver.auth.service; import org.rostilos.codecrow.core.model.user.RefreshToken; import org.rostilos.codecrow.core.model.user.User; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/TwoFactorAuthService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/TwoFactorAuthService.java similarity index 99% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/TwoFactorAuthService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/TwoFactorAuthService.java index bd3f0693..4c22121f 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/auth/TwoFactorAuthService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/auth/service/TwoFactorAuthService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.auth; +package org.rostilos.codecrow.webserver.auth.service; import jakarta.persistence.EntityExistsException; import org.rostilos.codecrow.core.model.user.User; @@ -7,7 +7,7 @@ import org.rostilos.codecrow.core.persistence.repository.user.TwoFactorAuthRepository; import org.rostilos.codecrow.core.persistence.repository.user.UserRepository; import org.rostilos.codecrow.email.service.EmailService; -import org.rostilos.codecrow.webserver.dto.response.auth.TwoFactorSetupResponse; +import org.rostilos.codecrow.webserver.auth.dto.response.TwoFactorSetupResponse; import org.rostilos.codecrow.webserver.exception.TwoFactorInvalidException; import org.rostilos.codecrow.webserver.exception.TwoFactorRequiredException; import org.slf4j.Logger; @@ -26,7 +26,6 @@ import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Base64; import java.util.NoSuchElementException; import java.util.Optional; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/PullRequestController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/PullRequestController.java deleted file mode 100644 index 9c548c44..00000000 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/analysis/PullRequestController.java +++ /dev/null @@ -1,134 +0,0 @@ -package org.rostilos.codecrow.webserver.controller.analysis; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import org.rostilos.codecrow.core.model.pullrequest.PullRequest; -import org.rostilos.codecrow.core.model.workspace.Workspace; -import org.rostilos.codecrow.core.persistence.repository.pullrequest.PullRequestRepository; -import org.rostilos.codecrow.webserver.service.project.ProjectService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; -import org.rostilos.codecrow.core.model.project.Project; -import org.rostilos.codecrow.core.persistence.repository.branch.BranchRepository; -import org.rostilos.codecrow.core.persistence.repository.branch.BranchIssueRepository; -import org.rostilos.codecrow.core.model.branch.Branch; -import org.rostilos.codecrow.core.model.branch.BranchIssue; -import org.rostilos.codecrow.core.dto.analysis.issue.IssueDTO; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.*; -import org.rostilos.codecrow.core.dto.pullrequest.PullRequestDTO; - -@CrossOrigin(origins = "*", maxAge = 3600) -@RestController -@RequestMapping("/api/{workspaceSlug}/project/{projectNamespace}/pull-requests") -public class PullRequestController { - private final PullRequestRepository pullRequestRepository; - private final ProjectService projectService; - private final WorkspaceService workspaceService; - private final BranchRepository branchRepository; - private final BranchIssueRepository branchIssueRepository; - - public PullRequestController(PullRequestRepository pullRequestRepository, ProjectService projectService, - WorkspaceService workspaceService, BranchRepository branchRepository, - BranchIssueRepository branchIssueRepository) { - this.pullRequestRepository = pullRequestRepository; - this.projectService = projectService; - this.workspaceService = workspaceService; - this.branchRepository = branchRepository; - this.branchIssueRepository = branchIssueRepository; - } - - @GetMapping - @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") - public ResponseEntity> listPullRequests( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @RequestParam(name = "page", defaultValue = "1") int page, - @RequestParam(name = "pageSize", defaultValue = "20") int pageSize - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - List pullRequestList = pullRequestRepository.findByProject_Id(project.getId()); - List pullRequestDTOs = pullRequestList.stream() - .map(PullRequestDTO::fromPullRequest) - .toList(); - - return new ResponseEntity<>(pullRequestDTOs, HttpStatus.OK); - } - - @GetMapping("/by-branch") - @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") - public ResponseEntity>> listPullRequestsByBranch( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - List pullRequestList = pullRequestRepository.findByProject_Id(project.getId()); - Map> grouped = pullRequestList.stream() - .collect(Collectors.groupingBy( - pr -> pr.getTargetBranchName() == null ? "unknown" : pr.getTargetBranchName(), - Collectors.mapping(PullRequestDTO::fromPullRequest, Collectors.toList()) - )); - return new ResponseEntity<>(grouped, HttpStatus.OK); - } - - @GetMapping("/branches/{branchName}/issues") - @PreAuthorize("@workspaceSecurity.isWorkspaceMember(#workspaceSlug, authentication)") - public ResponseEntity> listBranchIssues( - @PathVariable String workspaceSlug, - @PathVariable String projectNamespace, - @PathVariable String branchName, - @RequestParam(required = false, defaultValue = "open") String status - ) { - Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); - Project project = projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); - var branchOpt = branchRepository.findByProjectIdAndBranchName(project.getId(), branchName); - if (branchOpt.isEmpty()) { - return ResponseEntity.ok(List.of()); - } - Branch branch = branchOpt.get(); - List branchIssues = branchIssueRepository.findByBranchId(branch.getId()); - - List issues = branchIssues.stream() - .filter(bi -> { - if ("all".equalsIgnoreCase(status)) { - return true; - } else if ("resolved".equalsIgnoreCase(status)) { - return bi.isResolved(); - } else { - return !bi.isResolved(); - } - }) - .map(bi -> IssueDTO.fromEntity(bi.getCodeAnalysisIssue())) - .toList(); - return ResponseEntity.ok(issues); - } - - public static class UpdatePullRequestStatusRequest { - private String status; // approved|changes_requested|merged|closed - private String comment; - - public UpdatePullRequestStatusRequest() { - } - - public String getStatus() { - return status; - } - - public void setStatus(String status) { - this.status = status; - } - - public String getComment() { - return comment; - } - - public void setComment(String comment) { - this.comment = comment; - } - } -} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/permission/PermissionTemplateDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/permission/PermissionTemplateDTO.java deleted file mode 100644 index 2c296157..00000000 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/permission/PermissionTemplateDTO.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.rostilos.codecrow.webserver.dto.permission; - -import java.time.OffsetDateTime; -import java.util.Set; - -public class PermissionTemplateDTO { - private Long id; - private String name; - private String description; - private Set permissions; - private Long createdByUserId; - private OffsetDateTime createdAt; - - public PermissionTemplateDTO() { - } - - public PermissionTemplateDTO(Long id, String name, String description, Set permissions, Long createdByUserId, OffsetDateTime createdAt) { - this.id = id; - this.name = name; - this.description = description; - this.permissions = permissions; - this.createdByUserId = createdByUserId; - this.createdAt = createdAt; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public Set getPermissions() { - return permissions; - } - - public void setPermissions(Set permissions) { - this.permissions = permissions; - } - - public Long getCreatedByUserId() { - return createdByUserId; - } - - public void setCreatedByUserId(Long createdByUserId) { - this.createdByUserId = createdByUserId; - } - - public OffsetDateTime getCreatedAt() { - return createdAt; - } - - public void setCreatedAt(OffsetDateTime createdAt) { - this.createdAt = createdAt; - } -} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/permission/ProjectPermissionAssignmentDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/permission/ProjectPermissionAssignmentDTO.java deleted file mode 100644 index 3683a71c..00000000 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/permission/ProjectPermissionAssignmentDTO.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.rostilos.codecrow.webserver.dto.permission; - -public class ProjectPermissionAssignmentDTO { - private Long id; - private String projectName; - private Long userId; - private PermissionTemplateDTO template; - - public ProjectPermissionAssignmentDTO() { - } - - public ProjectPermissionAssignmentDTO(Long id, String projectName, Long userId, PermissionTemplateDTO template) { - this.id = id; - this.projectName = projectName; - this.userId = userId; - this.template = template; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getProjectName() { - return projectName; - } - - public void setProjectName(String projectName) { - this.projectName = projectName; - } - - public Long getUserId() { - return userId; - } - - public void setUserId(Long userId) { - this.userId = userId; - } - - public PermissionTemplateDTO getTemplate() { - return template; - } - - public void setTemplate(PermissionTemplateDTO template) { - this.template = template; - } -} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/EProjectCreationMode.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/EProjectCreationMode.java deleted file mode 100644 index 87bdb173..00000000 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/EProjectCreationMode.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.rostilos.codecrow.webserver.dto.request.project; - -public enum EProjectCreationMode { - MANUAL, IMPORT -} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/exception/GlobalExceptionHandler.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/exception/GlobalExceptionHandler.java index 4289e7ed..8f4976cc 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/exception/GlobalExceptionHandler.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/exception/GlobalExceptionHandler.java @@ -1,7 +1,7 @@ package org.rostilos.codecrow.webserver.exception; import jakarta.persistence.EntityExistsException; -import org.rostilos.codecrow.webserver.dto.message.ErrorMessageResponse; +import org.rostilos.codecrow.webserver.generic.dto.message.ErrorMessageResponse; import org.rostilos.codecrow.webserver.exception.comment.*; import org.rostilos.codecrow.webserver.exception.user.UserIdNotFoundException; import org.slf4j.Logger; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/helpers/HealthCheckController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/controller/HealthCheckController.java similarity index 88% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/helpers/HealthCheckController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/controller/HealthCheckController.java index 4b4db4c9..55274a13 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/helpers/HealthCheckController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/controller/HealthCheckController.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.controller.helpers; +package org.rostilos.codecrow.webserver.generic.controller; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/message/ErrorMessageResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/dto/message/ErrorMessageResponse.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/message/ErrorMessageResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/dto/message/ErrorMessageResponse.java index d7c2e3a8..45c95174 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/message/ErrorMessageResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/dto/message/ErrorMessageResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.message; +package org.rostilos.codecrow.webserver.generic.dto.message; import org.springframework.http.HttpStatus; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/message/MessageResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/dto/message/MessageResponse.java similarity index 82% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/message/MessageResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/dto/message/MessageResponse.java index 2a282bf5..e2276fa8 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/message/MessageResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/generic/dto/message/MessageResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.message; +package org.rostilos.codecrow.webserver.generic.dto.message; public class MessageResponse { private String message; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/BitbucketConnectController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/BitbucketConnectController.java similarity index 99% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/BitbucketConnectController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/BitbucketConnectController.java index 11c1777a..431748c7 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/BitbucketConnectController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/BitbucketConnectController.java @@ -1,13 +1,13 @@ -package org.rostilos.codecrow.webserver.controller.integration; +package org.rostilos.codecrow.webserver.integration.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import org.rostilos.codecrow.core.model.vcs.BitbucketConnectInstallation; import org.rostilos.codecrow.security.service.UserDetailsImpl; -import org.rostilos.codecrow.webserver.dto.response.integration.VcsConnectionDTO; +import org.rostilos.codecrow.webserver.integration.dto.response.VcsConnectionDTO; import org.rostilos.codecrow.webserver.exception.IntegrationException; -import org.rostilos.codecrow.webserver.service.integration.BitbucketConnectService; +import org.rostilos.codecrow.webserver.integration.service.BitbucketConnectService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/OAuthCallbackController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/OAuthCallbackController.java similarity index 74% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/OAuthCallbackController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/OAuthCallbackController.java index 8575a368..32a2afeb 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/OAuthCallbackController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/OAuthCallbackController.java @@ -1,12 +1,12 @@ -package org.rostilos.codecrow.webserver.controller.integration; +package org.rostilos.codecrow.webserver.integration.controller; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; -import org.rostilos.codecrow.webserver.dto.response.integration.VcsConnectionDTO; +import org.rostilos.codecrow.webserver.integration.dto.response.VcsConnectionDTO; import org.rostilos.codecrow.webserver.exception.IntegrationException; -import org.rostilos.codecrow.webserver.service.integration.OAuthStateService; -import org.rostilos.codecrow.webserver.service.integration.VcsIntegrationService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.integration.service.OAuthStateService; +import org.rostilos.codecrow.webserver.integration.service.VcsIntegrationService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -244,6 +244,83 @@ public ResponseEntity handleBitbucketCallback( .build(); } } + + /** + * Handle OAuth callback from GitLab. + * The workspace ID is extracted from the state parameter. + * Supports both GitLab.com and self-hosted GitLab instances. + * + * GET /api/integrations/gitlab/app/callback + */ + @GetMapping("/gitlab/app/callback") + public ResponseEntity handleGitLabCallback( + @RequestParam(required = false) String code, + @RequestParam(required = false) String state, + @RequestParam(required = false) String error, + @RequestParam(name = "error_description", required = false) String errorDescription + ) { + if (error != null) { + log.warn("GitLab OAuth callback error: {} - {}", error, errorDescription); + String redirectUrl = frontendUrl + "/workspace?error=" + error; + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + } + + if (code == null) { + return ResponseEntity.badRequest() + .body(new MessageResponse("Missing authorization code")); + } + + if (state == null) { + return ResponseEntity.badRequest() + .body(new MessageResponse("Missing state parameter")); + } + + try { + // Validate state and extract workspace ID + Long workspaceId = extractWorkspaceIdFromState(state); + + if (workspaceId == null) { + log.error("Could not extract workspace ID from GitLab OAuth state: {}", state); + String redirectUrl = frontendUrl + "/workspace?error=invalid_state"; + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + } + + // Get workspace slug for the redirect URL + String workspaceSlug = getWorkspaceSlug(workspaceId); + + // Handle the OAuth callback and create/update the connection + VcsConnectionDTO connection = integrationService.handleAppCallback( + EVcsProvider.GITLAB, code, state, workspaceId); + + log.info("GitLab OAuth successful for workspace {} (connection: {})", + workspaceSlug, connection.id()); + + // Redirect to frontend project import page with the new connection + String redirectUrl = frontendUrl + "/dashboard/" + workspaceSlug + + "/projects/import?connectionId=" + connection.id() + + "&provider=gitlab&connectionType=APP"; + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + + } catch (GeneralSecurityException | IOException e) { + log.error("Failed to handle GitLab OAuth callback", e); + String redirectUrl = frontendUrl + "/workspace?error=callback_failed"; + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + } catch (Exception e) { + log.error("Unexpected error during GitLab OAuth callback", e); + String redirectUrl = frontendUrl + "/workspace?error=" + e.getMessage(); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)) + .build(); + } + } private Long extractWorkspaceIdFromState(String state) { return oAuthStateService.validateAndExtractWorkspaceId(state); diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/VcsIntegrationCallbackController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/VcsIntegrationCallbackController.java similarity index 94% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/VcsIntegrationCallbackController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/VcsIntegrationCallbackController.java index 9f01a390..925bd257 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/VcsIntegrationCallbackController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/VcsIntegrationCallbackController.java @@ -1,9 +1,9 @@ -package org.rostilos.codecrow.webserver.controller.integration; +package org.rostilos.codecrow.webserver.integration.controller; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; -import org.rostilos.codecrow.webserver.dto.response.integration.VcsConnectionDTO; -import org.rostilos.codecrow.webserver.service.integration.VcsIntegrationService; +import org.rostilos.codecrow.webserver.integration.dto.response.VcsConnectionDTO; +import org.rostilos.codecrow.webserver.integration.service.VcsIntegrationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/VcsIntegrationController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/VcsIntegrationController.java similarity index 94% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/VcsIntegrationController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/VcsIntegrationController.java index 4509edc9..cb91bceb 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/integration/VcsIntegrationController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/VcsIntegrationController.java @@ -1,14 +1,17 @@ -package org.rostilos.codecrow.webserver.controller.integration; +package org.rostilos.codecrow.webserver.integration.controller; import jakarta.validation.Valid; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; -import org.rostilos.codecrow.webserver.dto.request.integration.RepoOnboardRequest; -import org.rostilos.codecrow.webserver.dto.response.integration.*; +import org.rostilos.codecrow.webserver.integration.dto.request.RepoOnboardRequest; import org.rostilos.codecrow.webserver.exception.IntegrationException; -import org.rostilos.codecrow.webserver.service.integration.VcsIntegrationService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.integration.dto.response.InstallUrlResponse; +import org.rostilos.codecrow.webserver.integration.dto.response.RepoOnboardResponse; +import org.rostilos.codecrow.webserver.integration.dto.response.VcsConnectionDTO; +import org.rostilos.codecrow.webserver.integration.dto.response.VcsRepositoryListDTO; +import org.rostilos.codecrow.webserver.integration.service.VcsIntegrationService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/integration/RepoOnboardRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/request/RepoOnboardRequest.java similarity index 97% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/integration/RepoOnboardRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/request/RepoOnboardRequest.java index f1313a44..8ceaebef 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/integration/RepoOnboardRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/request/RepoOnboardRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.integration; +package org.rostilos.codecrow.webserver.integration.dto.request; import jakarta.validation.constraints.NotNull; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/InstallUrlResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/InstallUrlResponse.java similarity index 69% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/InstallUrlResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/InstallUrlResponse.java index 26f283a7..449c9bf1 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/InstallUrlResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/InstallUrlResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.integration; +package org.rostilos.codecrow.webserver.integration.dto.response; /** * Response for app installation URL. diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/RepoOnboardResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/RepoOnboardResponse.java similarity index 93% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/RepoOnboardResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/RepoOnboardResponse.java index 4fd2ef9d..4e30bc72 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/RepoOnboardResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/RepoOnboardResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.integration; +package org.rostilos.codecrow.webserver.integration.dto.response; /** * Response for onboarding a repository. diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsConnectionDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsConnectionDTO.java similarity index 94% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsConnectionDTO.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsConnectionDTO.java index 6c2f8cc9..5403800a 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsConnectionDTO.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsConnectionDTO.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.integration; +package org.rostilos.codecrow.webserver.integration.dto.response; import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsRepoBindingDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsRepoBindingDTO.java similarity index 96% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsRepoBindingDTO.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsRepoBindingDTO.java index a1dfd0bb..91c8a150 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsRepoBindingDTO.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsRepoBindingDTO.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.integration; +package org.rostilos.codecrow.webserver.integration.dto.response; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsRepositoryListDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsRepositoryListDTO.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsRepositoryListDTO.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsRepositoryListDTO.java index 05929d08..44ccd59e 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/integration/VcsRepositoryListDTO.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/dto/response/VcsRepositoryListDTO.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.integration; +package org.rostilos.codecrow.webserver.integration.dto.response; import org.rostilos.codecrow.vcsclient.model.VcsRepository; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/BitbucketConnectService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/BitbucketConnectService.java similarity index 99% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/BitbucketConnectService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/BitbucketConnectService.java index 1dc6928f..78beddf0 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/BitbucketConnectService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/BitbucketConnectService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.integration; +package org.rostilos.codecrow.webserver.integration.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,7 +15,7 @@ import org.rostilos.codecrow.core.persistence.repository.workspace.WorkspaceMemberRepository; import org.rostilos.codecrow.core.persistence.repository.workspace.WorkspaceRepository; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; -import org.rostilos.codecrow.webserver.dto.response.integration.VcsConnectionDTO; +import org.rostilos.codecrow.webserver.integration.dto.response.VcsConnectionDTO; import org.rostilos.codecrow.webserver.exception.IntegrationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/OAuthStateService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/OAuthStateService.java similarity index 98% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/OAuthStateService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/OAuthStateService.java index e656a96f..c05120ab 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/OAuthStateService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/OAuthStateService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.integration; +package org.rostilos.codecrow.webserver.integration.service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/VcsIntegrationService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/VcsIntegrationService.java similarity index 74% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/VcsIntegrationService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/VcsIntegrationService.java index af18a04c..7491cc2c 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/integration/VcsIntegrationService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/VcsIntegrationService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.integration; +package org.rostilos.codecrow.webserver.integration.service; import org.rostilos.codecrow.core.model.ai.AIConnection; import org.rostilos.codecrow.core.model.project.Project; @@ -20,9 +20,9 @@ import org.rostilos.codecrow.vcsclient.model.VcsRepository; import org.rostilos.codecrow.vcsclient.model.VcsRepositoryPage; import org.rostilos.codecrow.vcsclient.model.VcsWorkspace; -import org.rostilos.codecrow.webserver.dto.request.integration.RepoOnboardRequest; -import org.rostilos.codecrow.webserver.dto.response.integration.*; +import org.rostilos.codecrow.webserver.integration.dto.request.RepoOnboardRequest; import org.rostilos.codecrow.webserver.exception.IntegrationException; +import org.rostilos.codecrow.webserver.integration.dto.response.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -67,6 +67,13 @@ public class VcsIntegrationService { "issue_comment" ); + // GitLab webhook events (mapped from generic events) + private static final List GITLAB_WEBHOOK_EVENTS = List.of( + "merge_requests_events", // MR created/updated + "note_events", // Comments on MRs + "push_events" // Push to branch + ); + private final VcsConnectionRepository connectionRepository; private final VcsRepoBindingRepository bindingRepository; private final WorkspaceRepository workspaceRepository; @@ -102,6 +109,17 @@ public class VcsIntegrationService { @Value("${codecrow.github.oauth.client-secret:}") private String githubOAuthClientSecret; + // GitLab OAuth Application configuration (for 1-click integration) + @Value("${codecrow.gitlab.oauth.client-id:}") + private String gitlabOAuthClientId; + + @Value("${codecrow.gitlab.oauth.client-secret:}") + private String gitlabOAuthClientSecret; + + // GitLab base URL (empty for gitlab.com, or set for self-hosted instances) + @Value("${codecrow.gitlab.oauth.base-url:}") + private String gitlabBaseUrl; + @Value("${codecrow.web.base.url:http://localhost:8081}") private String apiBaseUrl; @@ -142,6 +160,7 @@ public InstallUrlResponse getInstallUrl(EVcsProvider provider, Long workspaceId) return switch (provider) { case BITBUCKET_CLOUD -> getBitbucketCloudInstallUrl(workspaceId); case GITHUB -> getGitHubInstallUrl(workspaceId); + case GITLAB -> getGitLabInstallUrl(workspaceId); default -> throw new IntegrationException("Provider " + provider + " does not support app installation"); }; } @@ -225,17 +244,65 @@ private InstallUrlResponse getGitHubInstallUrl(Long workspaceId) { return new InstallUrlResponse(installUrl, EVcsProvider.GITHUB.getId(), state); } + /** + * Get the GitLab OAuth installation URL. + * Supports both GitLab.com and self-hosted GitLab instances. + */ + private InstallUrlResponse getGitLabInstallUrl(Long workspaceId) { + if (gitlabOAuthClientId == null || gitlabOAuthClientId.isBlank()) { + throw new IntegrationException( + "GitLab OAuth Application is not configured. " + + "Please set 'codecrow.gitlab.oauth.client-id' and 'codecrow.gitlab.oauth.client-secret' " + + "in your application.properties. See documentation for setup instructions." + ); + } + + if (gitlabOAuthClientSecret == null || gitlabOAuthClientSecret.isBlank()) { + throw new IntegrationException( + "GitLab OAuth Application secret is not configured. " + + "Please set 'codecrow.gitlab.oauth.client-secret' in your application.properties." + ); + } + + String state = generateState(EVcsProvider.GITLAB, workspaceId); + String callbackUrl = apiBaseUrl + "/api/integrations/gitlab/app/callback"; + + // Determine GitLab base URL (gitlab.com or self-hosted) + String gitlabHost = (gitlabBaseUrl != null && !gitlabBaseUrl.isBlank()) + ? gitlabBaseUrl.replaceAll("/$", "") // Remove trailing slash + : "https://gitlab.com"; + + log.info("Generated GitLab OAuth URL with callback: {} (host: {})", callbackUrl, gitlabHost); + + // GitLab OAuth scopes (space-separated) + // - api: Full access to the API + // - read_user: Read the authenticated user's personal information + // - read_repository: Read repository content + // - write_repository: Write to repository (for comments) + String scope = "api read_user read_repository write_repository"; + + String installUrl = gitlabHost + "/oauth/authorize" + + "?client_id=" + URLEncoder.encode(gitlabOAuthClientId, StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(callbackUrl, StandardCharsets.UTF_8) + + "&response_type=code" + + "&scope=" + URLEncoder.encode(scope, StandardCharsets.UTF_8) + + "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8); + + return new InstallUrlResponse(installUrl, EVcsProvider.GITLAB.getId(), state); + } + /** * Handle OAuth callback from a VCS provider app. */ @Transactional - public VcsConnectionDTO handleAppCallback(EVcsProvider provider, String code, String state, Long workspaceId) + public VcsConnectionDTO handleAppCallback(EVcsProvider provider, String code, String state, Long workspaceId) throws GeneralSecurityException, IOException { validateProviderSupported(provider); return switch (provider) { case BITBUCKET_CLOUD -> handleBitbucketCloudCallback(code, state, workspaceId); case GITHUB -> handleGitHubCallback(code, state, workspaceId); + case GITLAB -> handleGitLabCallback(code, state, workspaceId); default -> throw new IntegrationException("Provider " + provider + " does not support app callback"); }; } @@ -538,15 +605,191 @@ private TokenResponse exchangeGitHubCode(String code) throws IOException { } } + /** + * Handle GitLab OAuth callback. + * Exchanges the authorization code for tokens and creates/updates the VCS connection. + */ + private VcsConnectionDTO handleGitLabCallback(String code, String state, Long workspaceId) + throws GeneralSecurityException, IOException { + + TokenResponse tokens = exchangeGitLabCode(code); + + VcsClient client = vcsClientFactory.createClient(EVcsProvider.GITLAB, tokens.accessToken, tokens.refreshToken); + + // Get current user info from GitLab + var currentUser = client.getCurrentUser(); + String username = currentUser != null ? currentUser.username() : null; + + Workspace workspace = workspaceRepository.findById(workspaceId) + .orElseThrow(() -> new IntegrationException("Workspace not found")); + + // Check for existing connection with same external user + VcsConnection connection = null; + if (username != null) { + List existingConnections = connectionRepository + .findByWorkspace_IdAndProviderType(workspaceId, EVcsProvider.GITLAB); + + connection = existingConnections.stream() + .filter(c -> c.getConnectionType() == EVcsConnectionType.APP) + .filter(c -> username.equals(c.getExternalWorkspaceSlug())) + .findFirst() + .orElse(null); + + if (connection != null) { + log.info("Updating existing GitLab OAuth connection {} for workspace {}", + connection.getId(), workspaceId); + } + } + + // Create new connection if none exists + if (connection == null) { + connection = new VcsConnection(); + connection.setWorkspace(workspace); + connection.setProviderType(EVcsProvider.GITLAB); + connection.setConnectionType(EVcsConnectionType.APP); // OAuth connection type + } + + // Update connection with new tokens (encrypted at rest) + connection.setSetupStatus(EVcsSetupStatus.CONNECTED); + connection.setAccessToken(encryptionService.encrypt(tokens.accessToken)); + connection.setRefreshToken(tokens.refreshToken != null ? encryptionService.encrypt(tokens.refreshToken) : null); + connection.setTokenExpiresAt(tokens.expiresAt); + connection.setScopes(tokens.scopes); + + // Set the GitLab base URL in the configuration for self-hosted instances + String gitlabHost = (gitlabBaseUrl != null && !gitlabBaseUrl.isBlank()) + ? gitlabBaseUrl.replaceAll("/$", "") + : "https://gitlab.com"; + + // Store GitLab-specific configuration + connection.setConfiguration(new org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig( + null, // accessToken is stored separately (encrypted) + username, // groupId = username for personal projects + null, // allowedRepos + gitlabHost // baseUrl for self-hosted instances + )); + + if (username != null) { + connection.setExternalWorkspaceId(username); + connection.setExternalWorkspaceSlug(username); + connection.setConnectionName("GitLab – " + username); + + // Get repository count + try { + int repoCount = client.getRepositoryCount(username); + connection.setRepoCount(repoCount); + } catch (Exception e) { + log.warn("Could not fetch repository count for GitLab user {}: {}", username, e.getMessage()); + connection.setRepoCount(0); + } + } else { + connection.setConnectionName("GitLab OAuth"); + } + + VcsConnection saved = connectionRepository.save(connection); + log.info("Saved GitLab OAuth connection {} for workspace {} (user: {})", + saved.getId(), workspaceId, username); + + return VcsConnectionDTO.fromEntity(saved); + } + + /** + * Exchange GitLab authorization code for access tokens. + * Follows OAuth 2.0 spec with proper error handling. + */ + private TokenResponse exchangeGitLabCode(String code) throws IOException { + okhttp3.OkHttpClient httpClient = new okhttp3.OkHttpClient(); + + String callbackUrl = apiBaseUrl + "/api/integrations/gitlab/app/callback"; + + // Determine GitLab base URL + String gitlabHost = (gitlabBaseUrl != null && !gitlabBaseUrl.isBlank()) + ? gitlabBaseUrl.replaceAll("/$", "") + : "https://gitlab.com"; + + // GitLab token exchange - POST with form body + okhttp3.RequestBody body = new okhttp3.FormBody.Builder() + .add("client_id", gitlabOAuthClientId) + .add("client_secret", gitlabOAuthClientSecret) + .add("code", code) + .add("grant_type", "authorization_code") + .add("redirect_uri", callbackUrl) + .build(); + + okhttp3.Request request = new okhttp3.Request.Builder() + .url(gitlabHost + "/oauth/token") + .header("Accept", "application/json") + .post(body) + .build(); + + try (okhttp3.Response response = httpClient.newCall(request).execute()) { + String responseBody = response.body() != null ? response.body().string() : ""; + + if (!response.isSuccessful()) { + log.error("GitLab token exchange failed: {} - {}", response.code(), responseBody); + throw new IOException("Failed to exchange GitLab code: " + response.code() + " - " + responseBody); + } + + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.JsonNode json = mapper.readTree(responseBody); + + if (json.has("error")) { + String error = json.get("error").asText(); + String errorDesc = json.path("error_description").asText(""); + log.error("GitLab OAuth error: {} - {}", error, errorDesc); + throw new IOException("GitLab OAuth error: " + error + " - " + errorDesc); + } + + String accessToken = json.get("access_token").asText(); + String refreshToken = json.has("refresh_token") ? json.get("refresh_token").asText() : null; + + // GitLab tokens typically expire in 2 hours (7200 seconds) + int expiresIn = json.has("expires_in") ? json.get("expires_in").asInt() : 7200; + LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(expiresIn); + + // GitLab returns scope (singular), not scopes + String scopes = json.has("scope") ? json.get("scope").asText() : + (json.has("scopes") ? json.get("scopes").asText() : null); + + log.info("GitLab token exchange successful. Token expires at: {}, scopes: {}", expiresAt, scopes); + + return new TokenResponse(accessToken, refreshToken, expiresAt, scopes); + } + } + /** * List repositories from a VCS connection. + * For REPOSITORY_TOKEN connections, returns only the single repository the token has access to. */ - public VcsRepositoryListDTO listRepositories(Long workspaceId, Long connectionId, String query, int page) + public VcsRepositoryListDTO listRepositories(Long workspaceId, Long connectionId, String query, int page) throws IOException { VcsConnection connection = getConnection(workspaceId, connectionId); VcsClient client = createClientForConnection(connection); + // For REPOSITORY_TOKEN connections, we can only access the single repository + // Return that repository directly without listing + if (connection.getConnectionType() == EVcsConnectionType.REPOSITORY_TOKEN) { + String repoPath = connection.getRepositoryPath(); + if (repoPath != null && !repoPath.isBlank()) { + // Fetch the single repository + VcsRepository repo = client.getRepository("", repoPath); + if (repo == null) { + // Return empty list if repo not found + return new VcsRepositoryListDTO(List.of(), 1, 1, 0, 0, false, false); + } + + boolean isOnboarded = bindingRepository.existsByProviderAndExternalRepoId( + connection.getProviderType(), repo.id()); + + List items = List.of( + VcsRepositoryListDTO.VcsRepositoryDTO.fromModel(repo, isOnboarded) + ); + + return new VcsRepositoryListDTO(items, 1, 1, 1, 1, false, false); + } + } + String externalWorkspaceId = getExternalWorkspaceId(connection); VcsRepositoryPage repoPage; @@ -580,6 +823,7 @@ public VcsRepositoryListDTO listRepositories(Long workspaceId, Long connectionId /** * Get a specific repository from a VCS connection. + * For REPOSITORY_TOKEN connections, uses the stored repository path. */ public VcsRepositoryListDTO.VcsRepositoryDTO getRepository(Long workspaceId, Long connectionId, String externalRepoId) throws IOException { @@ -589,9 +833,19 @@ public VcsRepositoryListDTO.VcsRepositoryDTO getRepository(Long workspaceId, Lon String externalWorkspaceId = getExternalWorkspaceId(connection); - VcsRepository repo = client.getRepository(externalWorkspaceId, externalRepoId); + // For REPOSITORY_TOKEN connections, use stored repository path + String effectiveRepoId = externalRepoId; + if (connection.getConnectionType() == EVcsConnectionType.REPOSITORY_TOKEN) { + String repoPath = connection.getRepositoryPath(); + if (repoPath != null && !repoPath.isBlank()) { + effectiveRepoId = repoPath; + externalWorkspaceId = ""; + } + } + + VcsRepository repo = client.getRepository(externalWorkspaceId, effectiveRepoId); if (repo == null) { - throw new IntegrationException("Repository not found: " + externalRepoId); + throw new IntegrationException("Repository not found: " + effectiveRepoId); } boolean isOnboarded = bindingRepository.existsByProviderAndExternalRepoId( @@ -606,8 +860,8 @@ public VcsRepositoryListDTO.VcsRepositoryDTO getRepository(Long workspaceId, Lon * @param externalRepoId The repository slug (used for API calls) or UUID */ @Transactional - public RepoOnboardResponse onboardRepository(Long workspaceId, EVcsProvider provider, - String externalRepoId, RepoOnboardRequest request) + public RepoOnboardResponse onboardRepository(Long workspaceId, EVcsProvider provider, + String externalRepoId, RepoOnboardRequest request) throws IOException { VcsConnection connection = getConnection(workspaceId, request.getVcsConnectionId()); @@ -620,14 +874,30 @@ public RepoOnboardResponse onboardRepository(Long workspaceId, EVcsProvider prov VcsClient client = createClientForConnection(connection); String externalWorkspaceId = getExternalWorkspaceId(connection); + // For REPOSITORY_TOKEN connections, use the stored repository path directly + // This is needed because Project Access Tokens authenticate as a bot user, + // not the actual namespace owner, so we can't use bot_username/repo + String effectiveRepoId = externalRepoId; + if (connection.getConnectionType() == EVcsConnectionType.REPOSITORY_TOKEN) { + String repoPath = connection.getRepositoryPath(); + if (repoPath != null && !repoPath.isBlank()) { + // For repository tokens, the repoPath is the full path (e.g., "rostilos/codecrow-sample") + // Use it directly since it's the only repo this token has access to + log.debug("Using stored repositoryPath for REPOSITORY_TOKEN connection: {}", repoPath); + effectiveRepoId = repoPath; + // Also update externalWorkspaceId to be empty/ignored since we're using the full path + externalWorkspaceId = ""; + } + } + log.debug("Onboarding repo: externalRepoId={}, externalWorkspaceId={}, connectionId={}, connectionType={}", - externalRepoId, externalWorkspaceId, connection.getId(), connection.getConnectionType()); + effectiveRepoId, externalWorkspaceId, connection.getId(), connection.getConnectionType()); - // Get repository details (externalRepoId can be slug or UUID) - VcsRepository repo = client.getRepository(externalWorkspaceId, externalRepoId); + // Get repository details (externalRepoId can be slug or UUID, or full path for repository tokens) + VcsRepository repo = client.getRepository(externalWorkspaceId, effectiveRepoId); if (repo == null) { - log.warn("Repository not found: workspace={}, repo={}", externalWorkspaceId, externalRepoId); - throw new IntegrationException("Repository not found: " + externalRepoId); + log.warn("Repository not found: workspace={}, repo={}", externalWorkspaceId, effectiveRepoId); + throw new IntegrationException("Repository not found: " + effectiveRepoId); } // Check if already onboarded using the stable UUID @@ -749,6 +1019,7 @@ private List getWebhookEvents(EVcsProvider provider) { return switch (provider) { case BITBUCKET_CLOUD -> BITBUCKET_WEBHOOK_EVENTS; case GITHUB -> GITHUB_WEBHOOK_EVENTS; + case GITLAB -> GITLAB_WEBHOOK_EVENTS; default -> List.of(); }; } @@ -897,6 +1168,7 @@ private VcsClient createClientForConnection(VcsConnection connection) { * Get external workspace ID from connection - supports APP and OAUTH_MANUAL connection types. * For APP connections, uses the stored external workspace slug/id. * For OAUTH_MANUAL connections, gets from the BitbucketCloudConfig. + * For REPOSITORY_TOKEN connections, extracts namespace from repositoryPath. */ private String getExternalWorkspaceId(VcsConnection connection) { // For APP connections, use the stored external workspace slug/id @@ -908,6 +1180,19 @@ private String getExternalWorkspaceId(VcsConnection connection) { : connection.getExternalWorkspaceId(); } + // For REPOSITORY_TOKEN connections, extract namespace from repositoryPath + // Repository path is stored as "namespace/repo-name" (e.g., "rostilos/codecrow-sample") + if (connection.getConnectionType() == EVcsConnectionType.REPOSITORY_TOKEN) { + String repoPath = connection.getRepositoryPath(); + if (repoPath != null && repoPath.contains("/")) { + return repoPath.substring(0, repoPath.lastIndexOf("/")); + } + // Fallback to stored values + return connection.getExternalWorkspaceSlug() != null + ? connection.getExternalWorkspaceSlug() + : connection.getExternalWorkspaceId(); + } + // For OAUTH_MANUAL connections (Bitbucket), get from config if (connection.getConfiguration() instanceof BitbucketCloudConfig config) { return config.workspaceId(); @@ -920,7 +1205,9 @@ private String getExternalWorkspaceId(VcsConnection connection) { } private void validateProviderSupported(EVcsProvider provider) { - if (provider != EVcsProvider.BITBUCKET_CLOUD && provider != EVcsProvider.GITHUB) { + if (provider != EVcsProvider.BITBUCKET_CLOUD && + provider != EVcsProvider.GITHUB && + provider != EVcsProvider.GITLAB) { throw new IntegrationException("Provider " + provider + " is not yet supported"); } } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/internal/InternalAnalysisController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/internal/controller/InternalAnalysisController.java similarity index 98% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/internal/InternalAnalysisController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/internal/controller/InternalAnalysisController.java index a025c314..e9d95f1c 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/internal/InternalAnalysisController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/internal/controller/InternalAnalysisController.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.controller.internal; +package org.rostilos.codecrow.webserver.internal.controller; import org.rostilos.codecrow.core.model.codeanalysis.AnalysisStatus; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysis; @@ -6,7 +6,7 @@ import org.rostilos.codecrow.core.model.codeanalysis.IssueSeverity; import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.service.CodeAnalysisService; -import org.rostilos.codecrow.webserver.service.project.ProjectService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/internal/InternalIssueController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/internal/controller/InternalIssueController.java similarity index 96% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/internal/InternalIssueController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/internal/controller/InternalIssueController.java index 7ed184dd..951a8a93 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/internal/InternalIssueController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/internal/controller/InternalIssueController.java @@ -1,8 +1,8 @@ -package org.rostilos.codecrow.webserver.controller.internal; +package org.rostilos.codecrow.webserver.internal.controller; import org.rostilos.codecrow.core.dto.analysis.issue.IssueDTO; import org.rostilos.codecrow.core.model.codeanalysis.CodeAnalysisIssue; -import org.rostilos.codecrow.webserver.service.project.AnalysisService; +import org.rostilos.codecrow.webserver.analysis.service.AnalysisService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/job/JobController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/job/controller/JobController.java similarity index 98% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/job/JobController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/job/controller/JobController.java index 81802d9a..65bbdc9a 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/job/JobController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/job/controller/JobController.java @@ -1,12 +1,12 @@ -package org.rostilos.codecrow.webserver.controller.job; +package org.rostilos.codecrow.webserver.job.controller; import org.rostilos.codecrow.core.dto.job.*; import org.rostilos.codecrow.core.model.job.*; import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.persistence.repository.job.JobLogRepository; import org.rostilos.codecrow.core.service.JobService; -import org.rostilos.codecrow.webserver.service.project.ProjectService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; @@ -21,11 +21,9 @@ import java.io.IOException; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Consumer; -import java.util.stream.Collectors; /** * REST controller for job management and log streaming. diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/AllowedCommandUserController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/AllowedCommandUserController.java new file mode 100644 index 00000000..dd4e597f --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/AllowedCommandUserController.java @@ -0,0 +1,260 @@ +package org.rostilos.codecrow.webserver.project.controller; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.rostilos.codecrow.core.model.project.AllowedCommandUser; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.workspace.Workspace; +import org.rostilos.codecrow.webserver.project.service.AllowedCommandUserService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST controller for managing allowed command users. + * + * Allows workspace admins to: + * - View the list of users allowed to execute CodeCrow commands + * - Add/remove users from the allowed list + * - Sync users from VCS collaborators + * - Enable/disable individual users + */ +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +@RequestMapping("/api/{workspaceSlug}/project/{projectNamespace}/allowed-users") +public class AllowedCommandUserController { + + private static final Logger log = LoggerFactory.getLogger(AllowedCommandUserController.class); + + private final AllowedCommandUserService allowedUserService; + private final ProjectService projectService; + private final WorkspaceService workspaceService; + + public AllowedCommandUserController( + AllowedCommandUserService allowedUserService, + ProjectService projectService, + WorkspaceService workspaceService + ) { + this.allowedUserService = allowedUserService; + this.projectService = projectService; + this.workspaceService = workspaceService; + } + + /** + * GET /api/{workspaceSlug}/project/{projectNamespace}/allowed-users + * Get all allowed command users for a project. + */ + @GetMapping + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity getAllowedUsers( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @RequestParam(required = false, defaultValue = "false") boolean enabledOnly + ) { + Project project = getProject(workspaceSlug, projectNamespace); + + List users = enabledOnly + ? allowedUserService.getEnabledAllowedUsers(project.getId()) + : allowedUserService.getAllowedUsers(project.getId()); + + List userDTOs = users.stream() + .map(AllowedUserDTO::fromEntity) + .collect(Collectors.toList()); + + long totalCount = allowedUserService.countAllowedUsers(project.getId()); + long enabledCount = allowedUserService.countEnabledAllowedUsers(project.getId()); + + return ResponseEntity.ok(new AllowedUsersResponse(userDTOs, totalCount, enabledCount)); + } + + /** + * POST /api/{workspaceSlug}/project/{projectNamespace}/allowed-users + * Add a user to the allowed list. + */ + @PostMapping + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity addAllowedUser( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @Valid @RequestBody AddAllowedUserRequest request + ) { + Project project = getProject(workspaceSlug, projectNamespace); + + AllowedCommandUser user = allowedUserService.addAllowedUser( + project, + request.vcsUserId(), + request.vcsUsername(), + request.displayName(), + request.avatarUrl(), + null, // repoPermission - will be fetched during sync + false, // syncedFromVcs + "manual" // addedBy + ); + + log.info("Added allowed user {} to project {}", request.vcsUsername(), project.getId()); + + return ResponseEntity.status(HttpStatus.CREATED).body(AllowedUserDTO.fromEntity(user)); + } + + /** + * DELETE /api/{workspaceSlug}/project/{projectNamespace}/allowed-users/{vcsUserId} + * Remove a user from the allowed list. + */ + @DeleteMapping("/{vcsUserId}") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity removeAllowedUser( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @PathVariable String vcsUserId + ) { + Project project = getProject(workspaceSlug, projectNamespace); + + allowedUserService.removeAllowedUser(project.getId(), vcsUserId); + + log.info("Removed allowed user {} from project {}", vcsUserId, project.getId()); + + return ResponseEntity.noContent().build(); + } + + /** + * PATCH /api/{workspaceSlug}/project/{projectNamespace}/allowed-users/{vcsUserId}/enabled + * Enable or disable a user. + */ + @PatchMapping("/{vcsUserId}/enabled") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity setUserEnabled( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace, + @PathVariable String vcsUserId, + @RequestBody SetEnabledRequest request + ) { + Project project = getProject(workspaceSlug, projectNamespace); + + AllowedCommandUser user = allowedUserService.setUserEnabled( + project.getId(), + vcsUserId, + request.enabled() + ); + + log.info("Set user {} enabled={} for project {}", vcsUserId, request.enabled(), project.getId()); + + return ResponseEntity.ok(AllowedUserDTO.fromEntity(user)); + } + + /** + * POST /api/{workspaceSlug}/project/{projectNamespace}/allowed-users/sync + * Sync allowed users from VCS collaborators. + * Fetches repository collaborators with write access and adds them to the allowed list. + */ + @PostMapping("/sync") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity syncFromVcs( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace + ) { + Project project = getProject(workspaceSlug, projectNamespace); + + AllowedCommandUserService.SyncResult result = allowedUserService.syncFromVcs(project, "api"); + + log.info("Synced allowed users for project {}: success={}, added={}, updated={}", + project.getId(), result.success(), result.added(), result.updated()); + + return ResponseEntity.ok(new SyncResultResponse( + result.success(), + result.added(), + result.updated(), + result.totalFetched(), + result.error() + )); + } + + /** + * DELETE /api/{workspaceSlug}/project/{projectNamespace}/allowed-users + * Clear all allowed users for a project. + */ + @DeleteMapping + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity clearAllowedUsers( + @PathVariable String workspaceSlug, + @PathVariable String projectNamespace + ) { + Project project = getProject(workspaceSlug, projectNamespace); + + allowedUserService.clearAllowedUsers(project.getId()); + + log.info("Cleared all allowed users for project {}", project.getId()); + + return ResponseEntity.noContent().build(); + } + + private Project getProject(String workspaceSlug, String projectNamespace) { + Workspace workspace = workspaceService.getWorkspaceBySlug(workspaceSlug); + return projectService.getProjectByWorkspaceAndNamespace(workspace.getId(), projectNamespace); + } + + // ==================== DTOs ==================== + + public record AllowedUsersResponse( + List users, + long totalCount, + long enabledCount + ) {} + + public record AllowedUserDTO( + String id, + String vcsProvider, + String vcsUserId, + String vcsUsername, + String displayName, + String avatarUrl, + String repoPermission, + boolean syncedFromVcs, + boolean enabled, + String addedBy, + OffsetDateTime createdAt, + OffsetDateTime lastSyncedAt + ) { + public static AllowedUserDTO fromEntity(AllowedCommandUser entity) { + return new AllowedUserDTO( + entity.getId() != null ? entity.getId().toString() : null, + entity.getVcsProvider() != null ? entity.getVcsProvider().name() : null, + entity.getVcsUserId(), + entity.getVcsUsername(), + entity.getDisplayName(), + entity.getAvatarUrl(), + entity.getRepoPermission(), + entity.isSyncedFromVcs(), + entity.isEnabled(), + entity.getAddedBy(), + entity.getCreatedAt(), + entity.getLastSyncedAt() + ); + } + } + + public record AddAllowedUserRequest( + @NotBlank String vcsUserId, + @NotBlank String vcsUsername, + String displayName, + String avatarUrl + ) {} + + public record SetEnabledRequest(boolean enabled) {} + + public record SyncResultResponse( + boolean success, + int added, + int updated, + int totalFetched, + String error + ) {} +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/project/ProjectController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java similarity index 83% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/project/ProjectController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java index 14ee6f89..2a755b69 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/project/ProjectController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/controller/ProjectController.java @@ -1,39 +1,36 @@ -package org.rostilos.codecrow.webserver.controller.project; +package org.rostilos.codecrow.webserver.project.controller; import java.security.GeneralSecurityException; import java.util.List; import java.util.Map; -import java.util.stream.Collectors; import org.rostilos.codecrow.core.dto.project.ProjectDTO; import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.model.project.config.ProjectConfig; import org.rostilos.codecrow.core.model.workspace.Workspace; import org.rostilos.codecrow.security.service.UserDetailsImpl; -import org.rostilos.codecrow.webserver.dto.request.project.BindAiConnectionRequest; -import org.rostilos.codecrow.webserver.dto.request.project.BindRepositoryRequest; -import org.rostilos.codecrow.webserver.dto.request.project.CreateProjectRequest; -import org.rostilos.codecrow.webserver.dto.request.project.UpdateProjectRequest; -import org.rostilos.codecrow.webserver.dto.request.project.UpdateRepositorySettingsRequest; -import org.rostilos.codecrow.webserver.dto.request.project.CreateProjectTokenRequest; -import org.rostilos.codecrow.webserver.dto.request.project.UpdateRagConfigRequest; -import org.rostilos.codecrow.webserver.dto.request.project.UpdateCommentCommandsConfigRequest; -import org.rostilos.codecrow.webserver.dto.project.ProjectTokenDTO; -import org.rostilos.codecrow.webserver.dto.response.project.RagIndexStatusDTO; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; -import org.rostilos.codecrow.webserver.service.auth.TwoFactorAuthService; -import org.rostilos.codecrow.webserver.service.project.ProjectService; -import org.rostilos.codecrow.webserver.service.project.ProjectTokenService; -import org.rostilos.codecrow.webserver.service.project.RagIndexStatusService; -import org.rostilos.codecrow.webserver.service.project.RagIndexingTriggerService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.project.dto.request.BindAiConnectionRequest; +import org.rostilos.codecrow.webserver.project.dto.request.BindRepositoryRequest; +import org.rostilos.codecrow.webserver.project.dto.request.CreateProjectRequest; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateProjectRequest; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateRepositorySettingsRequest; +import org.rostilos.codecrow.webserver.project.dto.request.CreateProjectTokenRequest; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateRagConfigRequest; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateCommentCommandsConfigRequest; +import org.rostilos.codecrow.webserver.project.dto.request.ChangeVcsConnectionRequest; +import org.rostilos.codecrow.webserver.project.dto.ProjectTokenDTO; +import org.rostilos.codecrow.webserver.project.dto.response.RagIndexStatusDTO; +import org.rostilos.codecrow.webserver.auth.service.TwoFactorAuthService; +import org.rostilos.codecrow.webserver.project.service.ProjectService; +import org.rostilos.codecrow.webserver.project.service.ProjectTokenService; +import org.rostilos.codecrow.webserver.project.service.RagIndexStatusService; +import org.rostilos.codecrow.webserver.project.service.RagIndexingTriggerService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.BindingResult; -import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -558,4 +555,87 @@ public record UpdateAnalysisSettingsRequest( Boolean branchAnalysisEnabled, String installationMethod ) {} + + // ==================== 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") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + 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") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + 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") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + 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/dto/project/ProjectTokenDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/ProjectTokenDTO.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/project/ProjectTokenDTO.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/ProjectTokenDTO.java index 28b5faca..cc8d08e0 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/project/ProjectTokenDTO.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/ProjectTokenDTO.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.project; +package org.rostilos.codecrow.webserver.project.dto; import java.time.Instant; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/BindAiConnectionRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/BindAiConnectionRequest.java similarity index 71% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/BindAiConnectionRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/BindAiConnectionRequest.java index 921f17e6..ff2d608a 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/BindAiConnectionRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/BindAiConnectionRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; public class BindAiConnectionRequest { private Long aiConnectionId; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/BindRepositoryRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/BindRepositoryRequest.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/BindRepositoryRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/BindRepositoryRequest.java index 9a38c0a8..1b9e68e5 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/BindRepositoryRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/BindRepositoryRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; public class BindRepositoryRequest { private String provider; // e.g., BITBUCKET_CLOUD diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/ChangeVcsConnectionRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/ChangeVcsConnectionRequest.java new file mode 100644 index 00000000..ed3db442 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/ChangeVcsConnectionRequest.java @@ -0,0 +1,82 @@ +package org.rostilos.codecrow.webserver.project.dto.request; + +import jakarta.validation.constraints.NotNull; + +/** + * Request to change the VCS connection for a project. + * This will unbind the current repository and bind a new one. + */ +public class ChangeVcsConnectionRequest { + + @NotNull(message = "Connection ID is required") + private Long connectionId; + + @NotNull(message = "Repository slug is required") + private String repositorySlug; + + private String workspaceId; + + private String repositoryId; + + private String defaultBranch; + + private boolean setupWebhooks = true; + + private boolean clearAnalysisHistory = false; + + public Long getConnectionId() { + return connectionId; + } + + public void setConnectionId(Long connectionId) { + this.connectionId = connectionId; + } + + public String getRepositorySlug() { + return repositorySlug; + } + + public void setRepositorySlug(String repositorySlug) { + this.repositorySlug = repositorySlug; + } + + public String getWorkspaceId() { + return workspaceId; + } + + public void setWorkspaceId(String workspaceId) { + this.workspaceId = workspaceId; + } + + public String getRepositoryId() { + return repositoryId; + } + + public void setRepositoryId(String repositoryId) { + this.repositoryId = repositoryId; + } + + public String getDefaultBranch() { + return defaultBranch; + } + + public void setDefaultBranch(String defaultBranch) { + this.defaultBranch = defaultBranch; + } + + public boolean isSetupWebhooks() { + return setupWebhooks; + } + + public void setSetupWebhooks(boolean setupWebhooks) { + this.setupWebhooks = setupWebhooks; + } + + public boolean isClearAnalysisHistory() { + return clearAnalysisHistory; + } + + public void setClearAnalysisHistory(boolean clearAnalysisHistory) { + this.clearAnalysisHistory = clearAnalysisHistory; + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/CreateProjectRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/CreateProjectRequest.java similarity index 96% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/CreateProjectRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/CreateProjectRequest.java index 6c6fdf9a..6804ba15 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/CreateProjectRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/CreateProjectRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; import java.util.UUID; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/CreateProjectTokenRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/CreateProjectTokenRequest.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/CreateProjectTokenRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/CreateProjectTokenRequest.java index a3981ef2..0a1a3ba7 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/CreateProjectTokenRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/CreateProjectTokenRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; public class CreateProjectTokenRequest { private String name; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/EProjectCreationMode.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/EProjectCreationMode.java new file mode 100644 index 00000000..35c18766 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/EProjectCreationMode.java @@ -0,0 +1,5 @@ +package org.rostilos.codecrow.webserver.project.dto.request; + +public enum EProjectCreationMode { + MANUAL, IMPORT +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/SetLocalMcpRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/SetLocalMcpRequest.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/SetLocalMcpRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/SetLocalMcpRequest.java index f8ce52a7..8b7c4487 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/SetLocalMcpRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/SetLocalMcpRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; import jakarta.validation.constraints.NotNull; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateCommentCommandsConfigRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateCommentCommandsConfigRequest.java similarity index 71% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateCommentCommandsConfigRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateCommentCommandsConfigRequest.java index 22e3f31c..cb31f001 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateCommentCommandsConfigRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateCommentCommandsConfigRequest.java @@ -1,4 +1,6 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; + +import org.rostilos.codecrow.core.model.project.config.ProjectConfig.CommandAuthorizationMode; import java.util.List; @@ -40,7 +42,19 @@ public record UpdateCommentCommandsConfigRequest( * List of allowed commands. Valid values: "analyze", "summarize", "ask" * If null or empty, all commands are disabled. */ - List allowedCommands + List allowedCommands, + + /** + * Authorization mode controlling who can execute commands. + * Options: ANYONE, WORKSPACE_MEMBERS, ALLOWED_USERS_ONLY, PR_AUTHOR_ONLY + */ + CommandAuthorizationMode authorizationMode, + + /** + * If true, PR authors can always execute commands on their own PRs, + * regardless of the authorization mode (except ANYONE which allows everyone). + */ + Boolean allowPrAuthor ) { public List validatedAllowedCommands() { if (allowedCommands == null || allowedCommands.isEmpty()) { diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateProjectRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateProjectRequest.java similarity index 91% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateProjectRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateProjectRequest.java index 4ab6b4ed..bc687c73 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateProjectRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateProjectRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateRagConfigRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateRagConfigRequest.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateRagConfigRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateRagConfigRequest.java index 69399ee0..365c2bed 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateRagConfigRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateRagConfigRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; import jakarta.validation.constraints.NotNull; import java.util.List; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateRepositorySettingsRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateRepositorySettingsRequest.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateRepositorySettingsRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateRepositorySettingsRequest.java index 9dcf298f..cb6a0586 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/project/UpdateRepositorySettingsRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/request/UpdateRepositorySettingsRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.project; +package org.rostilos.codecrow.webserver.project.dto.request; public class UpdateRepositorySettingsRequest { // single encrypted token field as agreed diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/project/RagIndexStatusDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/response/RagIndexStatusDTO.java similarity index 94% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/project/RagIndexStatusDTO.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/response/RagIndexStatusDTO.java index 7cd86b37..24c312b2 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/project/RagIndexStatusDTO.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/dto/response/RagIndexStatusDTO.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.project; +package org.rostilos.codecrow.webserver.project.dto.response; import org.rostilos.codecrow.core.model.analysis.RagIndexStatus; import org.rostilos.codecrow.core.model.analysis.RagIndexingStatus; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/AllowedCommandUserService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/AllowedCommandUserService.java new file mode 100644 index 00000000..f13faf23 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/AllowedCommandUserService.java @@ -0,0 +1,310 @@ +package org.rostilos.codecrow.webserver.project.service; + +import org.rostilos.codecrow.core.model.project.AllowedCommandUser; +import org.rostilos.codecrow.core.model.project.Project; +import org.rostilos.codecrow.core.model.project.ProjectVcsConnectionBinding; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding; +import org.rostilos.codecrow.core.persistence.repository.project.AllowedCommandUserRepository; +import org.rostilos.codecrow.vcsclient.VcsClient; +import org.rostilos.codecrow.vcsclient.VcsClientProvider; +import org.rostilos.codecrow.vcsclient.model.VcsCollaborator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.*; + +/** + * Service for managing allowed command users. + * + * This service provides methods to: + * - Add/remove users from the allowed list + * - Enable/disable individual users + * - Sync users from VCS collaborators + * - Query allowed users + */ +@Service +public class AllowedCommandUserService { + + private static final Logger log = LoggerFactory.getLogger(AllowedCommandUserService.class); + + private final AllowedCommandUserRepository allowedUserRepository; + private final VcsClientProvider vcsClientProvider; + + // Permission levels that grant command access (for REPO_WRITE_ACCESS mode) + private static final Set WRITE_PERMISSIONS = Set.of( + "write", "admin", "maintain", "push", "owner" + ); + + public AllowedCommandUserService( + AllowedCommandUserRepository allowedUserRepository, + VcsClientProvider vcsClientProvider) { + this.allowedUserRepository = allowedUserRepository; + this.vcsClientProvider = vcsClientProvider; + } + + /** + * Get all allowed users for a project. + */ + public List getAllowedUsers(Long projectId) { + return allowedUserRepository.findByProjectId(projectId); + } + + /** + * Get enabled allowed users for a project. + */ + public List getEnabledAllowedUsers(Long projectId) { + return allowedUserRepository.findByProjectIdAndEnabledTrue(projectId); + } + + /** + * Count total allowed users for a project. + */ + public long countAllowedUsers(Long projectId) { + return allowedUserRepository.countByProjectId(projectId); + } + + /** + * Count enabled allowed users for a project. + */ + public long countEnabledAllowedUsers(Long projectId) { + return allowedUserRepository.countByProjectIdAndEnabledTrue(projectId); + } + + /** + * Add a user to the allowed list. + */ + @Transactional + public AllowedCommandUser addAllowedUser( + Project project, + String vcsUserId, + String vcsUsername, + String displayName, + String avatarUrl, + String repoPermission, + boolean syncedFromVcs, + String addedBy) { + + Optional existing = allowedUserRepository + .findByProjectIdAndVcsUserId(project.getId(), vcsUserId); + + if (existing.isPresent()) { + AllowedCommandUser user = existing.get(); + user.setVcsUsername(vcsUsername); + user.setDisplayName(displayName); + user.setAvatarUrl(avatarUrl); + user.setRepoPermission(repoPermission); + user.setEnabled(true); + if (syncedFromVcs) { + user.setLastSyncedAt(OffsetDateTime.now()); + } + return allowedUserRepository.save(user); + } + + EVcsProvider provider = getVcsProvider(project); + + AllowedCommandUser user = new AllowedCommandUser(project, provider, vcsUserId, vcsUsername); + user.setDisplayName(displayName); + user.setAvatarUrl(avatarUrl); + user.setRepoPermission(repoPermission); + user.setSyncedFromVcs(syncedFromVcs); + user.setAddedBy(addedBy); + if (syncedFromVcs) { + user.setLastSyncedAt(OffsetDateTime.now()); + } + + return allowedUserRepository.save(user); + } + + /** + * Remove a user from the allowed list. + */ + @Transactional + public void removeAllowedUser(Long projectId, String vcsUserId) { + allowedUserRepository.deleteByProjectIdAndVcsUserId(projectId, vcsUserId); + } + + /** + * Enable or disable a user. + */ + @Transactional + public AllowedCommandUser setUserEnabled(Long projectId, String vcsUserId, boolean enabled) { + Optional userOpt = allowedUserRepository + .findByProjectIdAndVcsUserId(projectId, vcsUserId); + + if (userOpt.isEmpty()) { + throw new IllegalArgumentException("User not found: " + vcsUserId); + } + + AllowedCommandUser user = userOpt.get(); + user.setEnabled(enabled); + return allowedUserRepository.save(user); + } + + /** + * Clear all allowed users for a project. + */ + @Transactional + public void clearAllowedUsers(Long projectId) { + allowedUserRepository.deleteByProjectId(projectId); + } + + /** + * Sync allowed users from VCS collaborators. + * Fetches repository collaborators with write access and adds them to the allowed list. + */ + @Transactional + public SyncResult syncFromVcs(Project project, String initiatedBy) { + VcsConnection connection = getVcsConnection(project); + + if (connection == null) { + return new SyncResult(false, 0, 0, 0, "No VCS connection available"); + } + + try { + List collaborators = fetchCollaborators(connection, project); + + // Disable all synced users first (so we can re-enable those that still exist) + allowedUserRepository.disableSyncedByProjectId(project.getId()); + + int added = 0; + int updated = 0; + + for (VcsCollaborator collab : collaborators) { + // Only sync users with write permissions + if (!hasWritePermission(collab.permission())) { + continue; + } + + // Skip users without valid identifiers + if (collab.userId() == null) { + log.warn("Skipping collaborator with null userId: {}", collab.displayName()); + continue; + } + + // Use displayName or userId as fallback if username is null + // Bitbucket deprecated usernames, some accounts only have account_id + String effectiveUsername = collab.username() != null ? collab.username() + : (collab.displayName() != null ? collab.displayName() : collab.userId()); + + boolean exists = allowedUserRepository.existsByProjectIdAndVcsUserId( + project.getId(), collab.userId()); + + addAllowedUser(project, collab.userId(), effectiveUsername, + collab.displayName(), collab.avatarUrl(), collab.permission(), + true, initiatedBy); + + if (exists) updated++; + else added++; + } + + log.info("Synced {} collaborators for project {} ({} added, {} updated)", + collaborators.size(), project.getId(), added, updated); + + return new SyncResult(true, added, updated, collaborators.size(), null); + + } catch (Exception e) { + log.error("Failed to sync collaborators for project {}: {}", project.getId(), e.getMessage()); + return new SyncResult(false, 0, 0, 0, e.getMessage()); + } + } + + /** + * Fetch collaborators from VCS provider. + */ + private List fetchCollaborators(VcsConnection connection, Project project) { + EVcsProvider provider = connection.getProviderType(); + + try { + VcsClient client = vcsClientProvider.getClient(connection); + String workspace = getWorkspaceSlug(project); + String repoSlug = getRepoSlug(project); + + if (workspace == null || repoSlug == null) { + log.warn("Cannot fetch collaborators: missing workspace ({}) or repo slug ({})", workspace, repoSlug); + return Collections.emptyList(); + } + + log.info("Fetching collaborators for {}/{} from {}", workspace, repoSlug, provider); + return client.getRepositoryCollaborators(workspace, repoSlug); + + } catch (UnsupportedOperationException e) { + log.warn("Collaborator sync not supported for provider: {}", provider); + return Collections.emptyList(); + } catch (Exception e) { + log.error("Error fetching collaborators for provider {}: {}", provider, e.getMessage(), e); + throw new RuntimeException("Failed to fetch collaborators: " + e.getMessage(), e); + } + } + + /** + * Get the workspace slug from project bindings. + */ + private String getWorkspaceSlug(Project project) { + ProjectVcsConnectionBinding vcsBinding = project.getVcsBinding(); + if (vcsBinding != null && vcsBinding.getWorkspace() != null) { + return vcsBinding.getWorkspace(); + } + + VcsRepoBinding repoBinding = project.getVcsRepoBinding(); + if (repoBinding != null && repoBinding.getExternalNamespace() != null) { + return repoBinding.getExternalNamespace(); + } + + return null; + } + + /** + * Get the repository slug from project bindings. + */ + private String getRepoSlug(Project project) { + ProjectVcsConnectionBinding vcsBinding = project.getVcsBinding(); + if (vcsBinding != null && vcsBinding.getRepoSlug() != null) { + return vcsBinding.getRepoSlug(); + } + + VcsRepoBinding repoBinding = project.getVcsRepoBinding(); + if (repoBinding != null && repoBinding.getExternalRepoSlug() != null) { + return repoBinding.getExternalRepoSlug(); + } + + return null; + } + + private boolean hasWritePermission(String permission) { + return permission != null && WRITE_PERMISSIONS.contains(permission.toLowerCase()); + } + + /** + * Get VCS connection from project (via VcsBinding or VcsRepoBinding). + */ + private VcsConnection getVcsConnection(Project project) { + ProjectVcsConnectionBinding vcsBinding = project.getVcsBinding(); + if (vcsBinding != null && vcsBinding.getVcsConnection() != null) { + return vcsBinding.getVcsConnection(); + } + + VcsRepoBinding repoBinding = project.getVcsRepoBinding(); + if (repoBinding != null && repoBinding.getVcsConnection() != null) { + return repoBinding.getVcsConnection(); + } + + return null; + } + + /** + * Get VCS provider from project. + */ + private EVcsProvider getVcsProvider(Project project) { + VcsConnection conn = getVcsConnection(project); + return conn != null ? conn.getProviderType() : null; + } + + // ==================== Result Records ==================== + + public record SyncResult(boolean success, int added, int updated, int totalFetched, String error) {} +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/ProjectService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java similarity index 71% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/ProjectService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java index 73c9efaa..b0c7f31f 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/ProjectService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.project; +package org.rostilos.codecrow.webserver.project.service; import java.security.GeneralSecurityException; import java.security.SecureRandom; @@ -11,7 +11,10 @@ import org.rostilos.codecrow.core.model.project.Project; import org.rostilos.codecrow.core.model.project.ProjectAiConnectionBinding; import org.rostilos.codecrow.core.model.project.ProjectVcsConnectionBinding; +import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.model.vcs.VcsRepoBinding; 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; @@ -32,19 +35,27 @@ import org.rostilos.codecrow.core.persistence.repository.workspace.WorkspaceRepository; import org.rostilos.codecrow.core.model.project.config.ProjectConfig; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; -import org.rostilos.codecrow.webserver.dto.request.project.BindAiConnectionRequest; -import org.rostilos.codecrow.webserver.dto.request.project.BindRepositoryRequest; -import org.rostilos.codecrow.webserver.dto.request.project.CreateProjectRequest; -import org.rostilos.codecrow.webserver.dto.request.project.UpdateProjectRequest; -import org.rostilos.codecrow.webserver.dto.request.project.UpdateRepositorySettingsRequest; +import org.rostilos.codecrow.vcsclient.VcsClientProvider; +import org.rostilos.codecrow.webserver.project.dto.request.BindAiConnectionRequest; +import org.rostilos.codecrow.webserver.project.dto.request.BindRepositoryRequest; +import org.rostilos.codecrow.webserver.project.dto.request.ChangeVcsConnectionRequest; +import org.rostilos.codecrow.webserver.project.dto.request.CreateProjectRequest; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateProjectRequest; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateRepositorySettingsRequest; import org.rostilos.codecrow.webserver.exception.InvalidProjectRequestException; +import org.rostilos.codecrow.webserver.project.dto.request.UpdateCommentCommandsConfigRequest; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import io.jsonwebtoken.security.SecurityException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Service public class ProjectService { + private static final Logger log = LoggerFactory.getLogger(ProjectService.class); + private final ProjectRepository projectRepository; private final VcsConnectionRepository vcsConnectionRepository; private final TokenEncryptionService tokenEncryptionService; @@ -62,6 +73,10 @@ public class ProjectService { private final JobRepository jobRepository; private final JobLogRepository jobLogRepository; private final PrSummarizeCacheRepository prSummarizeCacheRepository; + private final VcsClientProvider vcsClientProvider; + + @Value("${codecrow.webhook.base-url:http://localhost:8082}") + private String apiBaseUrl; public ProjectService( ProjectRepository projectRepository, @@ -80,7 +95,8 @@ public ProjectService( RagIndexStatusRepository ragIndexStatusRepository, JobRepository jobRepository, JobLogRepository jobLogRepository, - PrSummarizeCacheRepository prSummarizeCacheRepository + PrSummarizeCacheRepository prSummarizeCacheRepository, + VcsClientProvider vcsClientProvider ) { this.projectRepository = projectRepository; this.vcsConnectionRepository = vcsConnectionRepository; @@ -99,6 +115,7 @@ public ProjectService( this.jobRepository = jobRepository; this.jobLogRepository = jobLogRepository; this.prSummarizeCacheRepository = prSummarizeCacheRepository; + this.vcsClientProvider = vcsClientProvider; } @Transactional(readOnly = true) @@ -191,13 +208,13 @@ public Project getProjectByWorkspaceAndNamespace(Long workspaceId, String namesp public void deleteProjectByNamespace(Long workspaceId, String namespace) { Project project = projectRepository.findByWorkspaceIdAndNamespace(workspaceId, namespace) .orElseThrow(() -> new NoSuchElementException("Project not found")); - + Long projectId = project.getId(); - + // Clear the default branch reference first to avoid circular FK constraint project.setDefaultBranch(null); projectRepository.save(project); - + // Delete all related entities in correct order (respect FK constraints) // Job logs must be deleted before jobs (job_log references job) // Jobs must be deleted before codeAnalysis (job references analysis) @@ -213,7 +230,7 @@ public void deleteProjectByNamespace(Long workspaceId, String namespace) { analysisLockRepository.deleteByProjectId(projectId); ragIndexStatusRepository.deleteByProjectId(projectId); prSummarizeCacheRepository.deleteByProjectId(projectId); - + // Finally delete the project (cascade will handle vcsBinding and aiBinding) projectRepository.delete(project); } @@ -226,9 +243,9 @@ public Project updateProject(Long workspaceId, Long projectId, UpdateProjectRequ if (request.getName() != null) { project.setName(request.getName()); } - + // Namespace is immutable - reject any attempt to change it - if (request.getNamespace() != null && !request.getNamespace().trim().isEmpty() + if (request.getNamespace() != null && !request.getNamespace().trim().isEmpty() && !request.getNamespace().equals(project.getNamespace())) { throw new InvalidProjectRequestException("Project namespace cannot be changed after creation"); } @@ -258,11 +275,11 @@ public Project updateProject(Long workspaceId, Long projectId, UpdateProjectRequ public void deleteProject(Long workspaceId, Long projectId) { Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) .orElseThrow(() -> new NoSuchElementException("Project not found")); - + // Clear the default branch reference first to avoid circular FK constraint project.setDefaultBranch(null); projectRepository.save(project); - + // Delete all related entities in correct order (respect FK constraints) // Job logs must be deleted before jobs (job_log references job) // Jobs must be deleted before codeAnalysis (job references analysis) @@ -278,7 +295,7 @@ public void deleteProject(Long workspaceId, Long projectId) { analysisLockRepository.deleteByProjectId(projectId); ragIndexStatusRepository.deleteByProjectId(projectId); prSummarizeCacheRepository.deleteByProjectId(projectId); - + // Finally delete the project (cascade will handle vcsBinding and aiBinding) projectRepository.delete(project); } @@ -372,15 +389,15 @@ public List getProjectBranches(Long workspaceId, String namespace) { @Transactional public Project setDefaultBranch(Long workspaceId, String namespace, Long branchId) { Project project = getProjectByWorkspaceAndNamespace(workspaceId, namespace); - + Branch branch = branchRepository.findById(branchId) .orElseThrow(() -> new NoSuchElementException("Branch not found")); - + // Verify the branch belongs to this project if (!branch.getProject().getId().equals(project.getId())) { throw new IllegalArgumentException("Branch does not belong to this project"); } - + project.setDefaultBranch(branch); return projectRepository.save(project); } @@ -391,10 +408,10 @@ public Project setDefaultBranch(Long workspaceId, String namespace, Long branchI @Transactional public Project setDefaultBranchByName(Long workspaceId, String namespace, String branchName) { Project project = getProjectByWorkspaceAndNamespace(workspaceId, namespace); - + Branch branch = branchRepository.findByProjectIdAndBranchName(project.getId(), branchName) .orElseThrow(() -> new NoSuchElementException("Branch '" + branchName + "' not found for this project")); - + project.setDefaultBranch(branch); return projectRepository.save(project); } @@ -428,7 +445,7 @@ public Project updateBranchAnalysisConfig( ) { Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) .orElseThrow(() -> new NoSuchElementException("Project not found")); - + ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); String defaultBranch = currentConfig != null ? currentConfig.defaultBranch() : null; @@ -437,12 +454,12 @@ public Project updateBranchAnalysisConfig( Boolean branchAnalysisEnabled = currentConfig != null ? currentConfig.branchAnalysisEnabled() : true; var installationMethod = currentConfig != null ? currentConfig.installationMethod() : null; var commentCommands = currentConfig != null ? currentConfig.commentCommands() : null; - + ProjectConfig.BranchAnalysisConfig branchConfig = new ProjectConfig.BranchAnalysisConfig( prTargetBranches, branchPushPatterns ); - + project.setConfiguration(new ProjectConfig(useLocalMcp, defaultBranch, branchConfig, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, installationMethod, commentCommands)); return projectRepository.save(project); @@ -466,7 +483,7 @@ public Project updateRagConfig( ) { Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) .orElseThrow(() -> new NoSuchElementException("Project not found")); - + ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); String defaultBranch = currentConfig != null ? currentConfig.defaultBranch() : null; @@ -475,9 +492,9 @@ public Project updateRagConfig( Boolean branchAnalysisEnabled = currentConfig != null ? currentConfig.branchAnalysisEnabled() : true; var installationMethod = currentConfig != null ? currentConfig.installationMethod() : null; var commentCommands = currentConfig != null ? currentConfig.commentCommands() : null; - + ProjectConfig.RagConfig ragConfig = new ProjectConfig.RagConfig(enabled, branch, excludePatterns); - + project.setConfiguration(new ProjectConfig(useLocalMcp, defaultBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, installationMethod, commentCommands)); return projectRepository.save(project); @@ -493,31 +510,31 @@ public Project updateAnalysisSettings( ) { Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) .orElseThrow(() -> new NoSuchElementException("Project not found")); - + ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); String defaultBranch = currentConfig != null ? currentConfig.defaultBranch() : null; var branchAnalysis = currentConfig != null ? currentConfig.branchAnalysis() : null; var ragConfig = currentConfig != null ? currentConfig.ragConfig() : null; var commentCommands = currentConfig != null ? currentConfig.commentCommands() : null; - + Boolean newPrAnalysis = prAnalysisEnabled != null ? prAnalysisEnabled : (currentConfig != null ? currentConfig.prAnalysisEnabled() : true); - Boolean newBranchAnalysis = branchAnalysisEnabled != null ? branchAnalysisEnabled : + Boolean newBranchAnalysis = branchAnalysisEnabled != null ? branchAnalysisEnabled : (currentConfig != null ? currentConfig.branchAnalysisEnabled() : true); var newInstallationMethod = installationMethod != null ? installationMethod : (currentConfig != null ? currentConfig.installationMethod() : null); - + // Update both the direct column and the JSON config //TODO: remove duplication project.setPrAnalysisEnabled(newPrAnalysis != null ? newPrAnalysis : true); project.setBranchAnalysisEnabled(newBranchAnalysis != null ? newBranchAnalysis : true); - + project.setConfiguration(new ProjectConfig(useLocalMcp, defaultBranch, branchAnalysis, ragConfig, newPrAnalysis, newBranchAnalysis, newInstallationMethod, commentCommands)); return projectRepository.save(project); } - + /** * Get the comment commands configuration for a project. * Returns a CommentCommandsConfig record (never null, returns default disabled config if not configured). @@ -529,7 +546,7 @@ public ProjectConfig.CommentCommandsConfig getCommentCommandsConfig(Project proj } return project.getConfiguration().getCommentCommandsConfig(); } - + /** * Update the comment commands configuration for a project. * @param workspaceId the workspace ID @@ -541,11 +558,11 @@ public ProjectConfig.CommentCommandsConfig getCommentCommandsConfig(Project proj public Project updateCommentCommandsConfig( Long workspaceId, Long projectId, - org.rostilos.codecrow.webserver.dto.request.project.UpdateCommentCommandsConfigRequest request + org.rostilos.codecrow.webserver.project.dto.request.UpdateCommentCommandsConfigRequest request ) { Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) .orElseThrow(() -> new NoSuchElementException("Project not found")); - + ProjectConfig currentConfig = project.getConfiguration(); boolean useLocalMcp = currentConfig != null && currentConfig.useLocalMcp(); String defaultBranch = currentConfig != null ? currentConfig.defaultBranch() : null; @@ -554,11 +571,11 @@ public Project updateCommentCommandsConfig( Boolean prAnalysisEnabled = currentConfig != null ? currentConfig.prAnalysisEnabled() : true; Boolean branchAnalysisEnabled = currentConfig != null ? currentConfig.branchAnalysisEnabled() : true; var installationMethod = currentConfig != null ? currentConfig.installationMethod() : null; - + // Build new comment commands config var existingCommentConfig = currentConfig != null ? currentConfig.commentCommands() : null; - - boolean enabled = request.enabled() != null ? request.enabled() : + + boolean enabled = request.enabled() != null ? request.enabled() : (existingCommentConfig != null ? existingCommentConfig.enabled() : false); Integer rateLimit = request.rateLimit() != null ? request.rateLimit() : (existingCommentConfig != null ? existingCommentConfig.rateLimit() : ProjectConfig.CommentCommandsConfig.DEFAULT_RATE_LIMIT); @@ -568,13 +585,239 @@ public Project updateCommentCommandsConfig( (existingCommentConfig != null ? existingCommentConfig.allowPublicRepoCommands() : false); List allowedCommands = request.allowedCommands() != null ? request.validatedAllowedCommands() : (existingCommentConfig != null ? existingCommentConfig.allowedCommands() : null); - + ProjectConfig.CommandAuthorizationMode authorizationMode = request.authorizationMode() != null ? request.authorizationMode() : + (existingCommentConfig != null ? existingCommentConfig.authorizationMode() : ProjectConfig.CommentCommandsConfig.DEFAULT_AUTHORIZATION_MODE); + Boolean allowPrAuthor = request.allowPrAuthor() != null ? request.allowPrAuthor() : + (existingCommentConfig != null ? existingCommentConfig.allowPrAuthor() : true); + var commentCommands = new ProjectConfig.CommentCommandsConfig( - enabled, rateLimit, rateLimitWindow, allowPublicRepoCommands, allowedCommands + enabled, rateLimit, rateLimitWindow, allowPublicRepoCommands, allowedCommands, + authorizationMode, allowPrAuthor ); - + project.setConfiguration(new ProjectConfig(useLocalMcp, defaultBranch, branchAnalysis, ragConfig, prAnalysisEnabled, branchAnalysisEnabled, installationMethod, commentCommands)); return projectRepository.save(project); } + + // ==================== Webhook Management ==================== + + /** + * Setup webhooks for a project's bound repository. + * This is useful when: + * - Moving from one repository to another + * - Switching VCS connection types + * - Webhook was accidentally deleted in the VCS provider + */ + @Transactional + public WebhookSetupResult setupWebhooks(Long workspaceId, Long projectId) { + Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) + .orElseThrow(() -> new NoSuchElementException("Project not found")); + + VcsRepoBinding binding = vcsRepoBindingRepository.findByProject_Id(projectId) + .orElse(null); + + if (binding == null) { + return new WebhookSetupResult(false, null, null, "No repository binding found for this project"); + } + + VcsConnection connection = binding.getVcsConnection(); + if (connection == null) { + return new WebhookSetupResult(false, null, null, "No VCS connection found for this project"); + } + + // Generate webhook URL + String webhookUrl = generateWebhookUrl(binding.getProvider(), project); + + // Ensure auth token exists + if (project.getAuthToken() == null || project.getAuthToken().isBlank()) { + byte[] randomBytes = new byte[32]; + new SecureRandom().nextBytes(randomBytes); + String authToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes); + project.setAuthToken(authToken); + projectRepository.save(project); + } + + try { + // Get VCS client and setup webhook + org.rostilos.codecrow.vcsclient.VcsClient client = vcsClientProvider.getClient(connection); + List events = getWebhookEvents(binding.getProvider()); + + String workspaceIdOrNamespace; + String repoSlug; + + // For REPOSITORY_TOKEN connections, use the repositoryPath from the connection + // because the token is scoped to that specific repository + if (connection.getConnectionType() == EVcsConnectionType.REPOSITORY_TOKEN + && connection.getRepositoryPath() != null + && !connection.getRepositoryPath().isBlank()) { + String repositoryPath = connection.getRepositoryPath(); + int lastSlash = repositoryPath.lastIndexOf('/'); + if (lastSlash > 0) { + workspaceIdOrNamespace = repositoryPath.substring(0, lastSlash); + repoSlug = repositoryPath.substring(lastSlash + 1); + } else { + workspaceIdOrNamespace = binding.getExternalNamespace(); + repoSlug = repositoryPath; + } + log.info("REPOSITORY_TOKEN webhook setup - using repositoryPath: {}, namespace: {}, slug: {}", + repositoryPath, workspaceIdOrNamespace, repoSlug); + } else { + workspaceIdOrNamespace = binding.getExternalNamespace(); + repoSlug = binding.getExternalRepoSlug(); + log.info("Standard webhook setup - namespace: {}, slug: {}", workspaceIdOrNamespace, repoSlug); + } + + String webhookId = client.ensureWebhook(workspaceIdOrNamespace, repoSlug, webhookUrl, events); + + if (webhookId != null) { + binding.setWebhookId(webhookId); + binding.setWebhooksConfigured(true); + vcsRepoBindingRepository.save(binding); + return new WebhookSetupResult(true, webhookId, webhookUrl, "Webhook configured successfully"); + } else { + return new WebhookSetupResult(false, null, webhookUrl, "Failed to create webhook"); + } + } catch (Exception e) { + return new WebhookSetupResult(false, null, webhookUrl, "Error setting up webhook: " + e.getMessage()); + } + } + + /** + * Get webhook information for a project. + */ + @Transactional(readOnly = true) + public WebhookInfo getWebhookInfo(Long workspaceId, Long projectId) { + Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) + .orElseThrow(() -> new NoSuchElementException("Project not found")); + + VcsRepoBinding binding = vcsRepoBindingRepository.findByProject_Id(projectId) + .orElse(null); + + if (binding == null) { + return new WebhookInfo(false, null, null, null); + } + + String webhookUrl = generateWebhookUrl(binding.getProvider(), project); + return new WebhookInfo( + binding.isWebhooksConfigured(), + binding.getWebhookId(), + webhookUrl, + binding.getProvider() + ); + } + + private String generateWebhookUrl(EVcsProvider provider, Project project) { + String base = apiBaseUrl != null && !apiBaseUrl.isBlank() ? apiBaseUrl : "http://localhost:8082"; + return base + "/api/webhooks/" + provider.getId() + "/" + project.getAuthToken(); + } + + private List getWebhookEvents(EVcsProvider provider) { + return switch (provider) { + case BITBUCKET_CLOUD -> List.of("pullrequest:created", "pullrequest:updated", "pullrequest:fulfilled", "pullrequest:comment_created", "repo:push"); + case GITHUB -> List.of("pull_request", "pull_request_review_comment", "issue_comment", "push"); + case GITLAB -> List.of("merge_requests_events", "note_events", "push_events"); + default -> List.of(); + }; + } + + // ==================== Change VCS Connection ==================== + + /** + * Change 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 + * in the previous repository. + */ + @Transactional + public Project changeVcsConnection(Long workspaceId, Long projectId, ChangeVcsConnectionRequest request) { + Project project = projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) + .orElseThrow(() -> new NoSuchElementException("Project not found")); + + Workspace workspace = workspaceRepository.findById(workspaceId) + .orElseThrow(() -> new NoSuchElementException("Workspace not found")); + + VcsConnection newConnection = vcsConnectionRepository.findByWorkspace_IdAndId(workspaceId, request.getConnectionId()) + .orElseThrow(() -> new NoSuchElementException("VCS Connection not found")); + + // Get or create VcsRepoBinding + VcsRepoBinding binding = vcsRepoBindingRepository.findByProject_Id(projectId) + .orElse(null); + + boolean isNewBinding = (binding == null); + if (isNewBinding) { + binding = new VcsRepoBinding(); + binding.setProject(project); + binding.setWorkspace(workspace); + } + + // Clear analysis history if requested + if (request.isClearAnalysisHistory()) { + clearProjectAnalysisData(projectId); + } + + // Update binding with new connection info + binding.setVcsConnection(newConnection); + binding.setProvider(newConnection.getProviderType()); + binding.setExternalRepoSlug(request.getRepositorySlug()); + binding.setExternalNamespace(request.getWorkspaceId() != null ? request.getWorkspaceId() : newConnection.getExternalWorkspaceSlug()); + binding.setExternalRepoId(request.getRepositoryId() != null ? request.getRepositoryId() : request.getRepositorySlug()); + + if (request.getDefaultBranch() != null && !request.getDefaultBranch().isBlank()) { + binding.setDefaultBranch(request.getDefaultBranch()); + } + + // Reset webhook status - will be set up fresh + binding.setWebhooksConfigured(false); + binding.setWebhookId(null); + + vcsRepoBindingRepository.save(binding); + + // Setup webhooks if requested + if (request.isSetupWebhooks()) { + WebhookSetupResult webhookResult = setupWebhooks(workspaceId, projectId); + // Log the result but don't fail the whole operation + if (!webhookResult.success()) { + // Webhook setup failed but connection change succeeded + // The user can retry webhook setup later + } + } + + return projectRepository.findByWorkspaceIdAndId(workspaceId, projectId) + .orElseThrow(() -> new NoSuchElementException("Project not found after update")); + } + + /** + * Clear all analysis data for a project (used when changing repositories). + */ + private void clearProjectAnalysisData(Long projectId) { + // Delete in reverse dependency order + jobLogRepository.deleteByProjectId(projectId); + jobRepository.deleteByProjectId(projectId); + prSummarizeCacheRepository.deleteByProjectId(projectId); + codeAnalysisRepository.deleteByProjectId(projectId); + branchIssueRepository.deleteByProjectId(projectId); + branchFileRepository.deleteByProjectId(projectId); + branchRepository.deleteByProjectId(projectId); + pullRequestRepository.deleteByProject_Id(projectId); + analysisLockRepository.deleteByProjectId(projectId); + ragIndexStatusRepository.deleteByProjectId(projectId); + } + + // ==================== DTOs for Webhook Operations ==================== + + public record WebhookSetupResult( + boolean success, + String webhookId, + String webhookUrl, + String message + ) {} + + public 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/service/project/ProjectTokenService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectTokenService.java similarity index 98% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/ProjectTokenService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectTokenService.java index a74a4c44..d621ada5 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/ProjectTokenService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/ProjectTokenService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.project; +package org.rostilos.codecrow.webserver.project.service; import java.security.GeneralSecurityException; import java.time.Instant; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/RagIndexStatusService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/RagIndexStatusService.java similarity index 96% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/RagIndexStatusService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/RagIndexStatusService.java index c8e7729e..521f4b8e 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/RagIndexStatusService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/RagIndexStatusService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.project; +package org.rostilos.codecrow.webserver.project.service; import org.rostilos.codecrow.core.model.analysis.RagIndexStatus; import org.rostilos.codecrow.core.model.project.Project; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/RagIndexingTriggerService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/RagIndexingTriggerService.java similarity index 99% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/RagIndexingTriggerService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/RagIndexingTriggerService.java index f8239995..687a78fd 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/project/RagIndexingTriggerService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/project/service/RagIndexingTriggerService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.project; +package org.rostilos.codecrow.webserver.project.service; import com.fasterxml.jackson.databind.ObjectMapper; import okhttp3.*; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/ConfigurationService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/ConfigurationService.java deleted file mode 100644 index 508336bb..00000000 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/ConfigurationService.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.rostilos.codecrow.webserver.service; - -import org.rostilos.codecrow.core.model.Configuration; -import org.rostilos.codecrow.core.persistence.repository.ConfigurationRepository; -import org.springframework.stereotype.Service; - -import java.util.List; - -@Service -public class ConfigurationService { - - private final ConfigurationRepository configurationRepository; - - public ConfigurationService(ConfigurationRepository configurationRepository) { - this.configurationRepository = configurationRepository; - } - - public Configuration saveConfiguration(Configuration config) { - return configurationRepository.save(config); - } - - public Configuration getConfiguration(Long userId, String configKey) { - return configurationRepository.findByUserIdAndConfigKey(userId, configKey); - } - - public List getAllConfigurationsForUser(Long userId) { - return configurationRepository.findByUserId(userId); - } - - public void deleteConfiguration(Long id) { - configurationRepository.deleteById(id); - } -} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/user/UserDataController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/controller/UserDataController.java similarity index 89% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/user/UserDataController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/controller/UserDataController.java index 114105b4..c124311c 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/user/UserDataController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/controller/UserDataController.java @@ -1,17 +1,15 @@ -package org.rostilos.codecrow.webserver.controller.user; +package org.rostilos.codecrow.webserver.user.controller; import jakarta.validation.Valid; import org.rostilos.codecrow.core.dto.user.UserDTO; import org.rostilos.codecrow.core.model.user.User; -import org.rostilos.codecrow.webserver.exception.user.UserIdNotFoundException; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; -import org.rostilos.codecrow.webserver.dto.response.user.UpdatedUserDataResponse; -import org.rostilos.codecrow.webserver.dto.request.user.UpdateUserDataRequest; -import org.rostilos.codecrow.webserver.dto.request.user.ChangePasswordRequest; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.user.dto.response.UpdatedUserDataResponse; +import org.rostilos.codecrow.webserver.user.dto.request.UpdateUserDataRequest; +import org.rostilos.codecrow.webserver.user.dto.request.ChangePasswordRequest; import org.rostilos.codecrow.security.jwt.utils.JwtUtils; import org.rostilos.codecrow.security.service.UserDetailsImpl; -import org.rostilos.codecrow.webserver.service.UserService; -import org.springframework.http.HttpStatus; +import org.rostilos.codecrow.webserver.user.service.UserService; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.authentication.BadCredentialsException; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/user/ChangePasswordRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/request/ChangePasswordRequest.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/user/ChangePasswordRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/request/ChangePasswordRequest.java index 4c1bb92f..56b9461e 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/user/ChangePasswordRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/request/ChangePasswordRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.user; +package org.rostilos.codecrow.webserver.user.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/user/UpdateUserDataRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/request/UpdateUserDataRequest.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/user/UpdateUserDataRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/request/UpdateUserDataRequest.java index 924834d4..709d9e1a 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/user/UpdateUserDataRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/request/UpdateUserDataRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.user; +package org.rostilos.codecrow.webserver.user.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Size; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/user/UpdatedUserDataResponse.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/response/UpdatedUserDataResponse.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/user/UpdatedUserDataResponse.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/response/UpdatedUserDataResponse.java index 9c7ae614..d41c380b 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/user/UpdatedUserDataResponse.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/dto/response/UpdatedUserDataResponse.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.user; +package org.rostilos.codecrow.webserver.user.dto.response; public class UpdatedUserDataResponse { private String token; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/UserService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/service/UserService.java similarity index 98% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/UserService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/service/UserService.java index 448a0f18..99ac563a 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/UserService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/user/service/UserService.java @@ -1,10 +1,10 @@ -package org.rostilos.codecrow.webserver.service; +package org.rostilos.codecrow.webserver.user.service; import org.rostilos.codecrow.core.dto.user.UserDTO; import org.rostilos.codecrow.core.model.user.User; import org.rostilos.codecrow.webserver.exception.user.UserIdNotFoundException; import org.rostilos.codecrow.core.persistence.repository.user.UserRepository; -import org.rostilos.codecrow.webserver.dto.request.user.UpdateUserDataRequest; +import org.rostilos.codecrow.webserver.user.dto.request.UpdateUserDataRequest; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/vcs/bitbucket/cloud/BitbucketCloudController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/cloud/BitbucketCloudController.java similarity index 93% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/vcs/bitbucket/cloud/BitbucketCloudController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/cloud/BitbucketCloudController.java index dedf058c..5c0d4027 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/vcs/bitbucket/cloud/BitbucketCloudController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/cloud/BitbucketCloudController.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.controller.vcs.bitbucket.cloud; +package org.rostilos.codecrow.webserver.vcs.controller.cloud; import java.io.IOException; import java.security.GeneralSecurityException; @@ -8,12 +8,11 @@ import org.rostilos.codecrow.core.model.vcs.VcsConnection; import org.rostilos.codecrow.core.persistence.repository.vcs.VcsConnectionRepository; import org.rostilos.codecrow.vcsclient.bitbucket.cloud.dto.response.RepositorySearchResult; -import org.rostilos.codecrow.webserver.dto.request.vcs.bitbucket.cloud.BitbucketCloudCreateRequest; +import org.rostilos.codecrow.webserver.vcs.dto.request.cloud.BitbucketCloudCreateRequest; import org.rostilos.codecrow.core.dto.bitbucket.BitbucketCloudDTO; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; -import org.rostilos.codecrow.webserver.service.vcs.VcsConnectionWebService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; -import org.rostilos.codecrow.webserver.utils.BitbucketCloudConfigHandler; +import org.rostilos.codecrow.webserver.vcs.service.VcsConnectionWebService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; +import org.rostilos.codecrow.webserver.vcs.utils.BitbucketCloudConfigHandler; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/vcs/github/GitHubController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/github/GitHubController.java similarity index 95% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/vcs/github/GitHubController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/github/GitHubController.java index c4f0fdda..b6060b6e 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/vcs/github/GitHubController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/github/GitHubController.java @@ -1,18 +1,17 @@ -package org.rostilos.codecrow.webserver.controller.vcs.github; +package org.rostilos.codecrow.webserver.vcs.controller.github; import java.io.IOException; import java.util.List; import org.rostilos.codecrow.core.dto.github.GitHubDTO; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; import org.rostilos.codecrow.core.model.vcs.VcsConnection; import org.rostilos.codecrow.core.model.vcs.config.github.GitHubConfig; import org.rostilos.codecrow.core.persistence.repository.vcs.VcsConnectionRepository; import org.rostilos.codecrow.vcsclient.github.dto.response.RepositorySearchResult; -import org.rostilos.codecrow.webserver.dto.request.vcs.github.GitHubCreateRequest; -import org.rostilos.codecrow.webserver.service.vcs.VcsConnectionWebService; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.vcs.dto.request.github.GitHubCreateRequest; +import org.rostilos.codecrow.webserver.vcs.service.VcsConnectionWebService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/gitlab/GitLabController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/gitlab/GitLabController.java new file mode 100644 index 00000000..e6d469a3 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/controller/gitlab/GitLabController.java @@ -0,0 +1,162 @@ +package org.rostilos.codecrow.webserver.vcs.controller.gitlab; + +import java.io.IOException; +import java.util.List; + +import org.rostilos.codecrow.core.dto.gitlab.GitLabDTO; +import org.rostilos.codecrow.core.model.vcs.EVcsProvider; +import org.rostilos.codecrow.core.model.vcs.VcsConnection; +import org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig; +import org.rostilos.codecrow.core.persistence.repository.vcs.VcsConnectionRepository; +import org.rostilos.codecrow.vcsclient.gitlab.dto.response.RepositorySearchResult; +import org.rostilos.codecrow.webserver.vcs.dto.request.gitlab.GitLabCreateRequest; +import org.rostilos.codecrow.webserver.vcs.dto.request.gitlab.GitLabRepositoryTokenRequest; +import org.rostilos.codecrow.webserver.vcs.service.VcsConnectionWebService; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@CrossOrigin(origins = "*", maxAge = 3600) +@RestController +@RequestMapping("/api/{workspaceSlug}/vcs/gitlab") +public class GitLabController { + + private final VcsConnectionRepository vcsConnectionRepository; + private final VcsConnectionWebService vcsConnectionService; + private final WorkspaceService workspaceService; + + public GitLabController( + VcsConnectionRepository vcsConnectionRepository, + VcsConnectionWebService vcsConnectionService, + WorkspaceService workspaceService + ) { + this.vcsConnectionRepository = vcsConnectionRepository; + this.vcsConnectionService = vcsConnectionService; + this.workspaceService = workspaceService; + } + + @GetMapping("/list") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity> getGitLabConnections(@PathVariable String workspaceSlug) { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + List connections = vcsConnectionService.getWorkspaceGitLabConnections(workspaceId); + List connectionDTOs = connections.stream() + .map(GitLabDTO::fromVcsConnection) + .toList(); + + return new ResponseEntity<>(connectionDTOs, HttpStatus.OK); + } + + @PostMapping("/create") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity createGitLabConnection( + @PathVariable String workspaceSlug, + @RequestBody GitLabCreateRequest request + ) { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + + GitLabConfig config = new GitLabConfig( + request.getAccessToken(), + request.getGroupId(), + null + ); + + VcsConnection createdConnection = vcsConnectionService.createGitLabConnection( + workspaceId, + config, + request.getConnectionName() + ); + + return new ResponseEntity<>(GitLabDTO.fromVcsConnection(createdConnection), HttpStatus.CREATED); + } + + @PatchMapping("/connections/{connectionId}") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity updateGitLabConnection( + @PathVariable String workspaceSlug, + @PathVariable Long connectionId, + @RequestBody GitLabCreateRequest request + ) { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + VcsConnection updatedConnection = vcsConnectionService.updateGitLabConnection( + workspaceId, + connectionId, + request + ); + return new ResponseEntity<>(GitLabDTO.fromVcsConnection(updatedConnection), HttpStatus.OK); + } + + @DeleteMapping("/connections/{connectionId}") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity deleteGitLabConnection( + @PathVariable String workspaceSlug, + @PathVariable Long connectionId + ) { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + vcsConnectionService.deleteGitLabConnection(workspaceId, connectionId); + return new ResponseEntity<>(HttpStatus.OK); + } + + @GetMapping("/connections/{connectionId}") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity getConnectionInfo( + @PathVariable String workspaceSlug, + @PathVariable Long connectionId + ) { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + VcsConnection connection = vcsConnectionRepository.findByWorkspace_IdAndId(workspaceId, connectionId) + .orElseThrow(() -> new IllegalArgumentException("VCS connection not found")); + + if (connection.getProviderType() != EVcsProvider.GITLAB) { + throw new IllegalArgumentException("Not a GitLab connection"); + } + + return new ResponseEntity<>(GitLabDTO.fromVcsConnection(connection), HttpStatus.OK); + } + + @GetMapping("/{connectionId}/repositories") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity getGitLabConnectionRepositories( + @PathVariable String workspaceSlug, + @PathVariable Long connectionId, + @RequestParam(value = "q", required = false) String query, + @RequestParam(defaultValue = "1") int page + ) throws IOException { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + RepositorySearchResult repos = vcsConnectionService.searchGitLabRepositories( + workspaceId, connectionId, query, page); + + return new ResponseEntity<>(repos, HttpStatus.OK); + } + + /** + * Create a GitLab connection using a Project Access Token (repository-scoped token). + * Project Access Tokens are limited to a single project/repository. + */ + @PostMapping("/create-repository-token") + @PreAuthorize("@workspaceSecurity.hasOwnerOrAdminRights(#workspaceSlug, authentication)") + public ResponseEntity createGitLabRepositoryTokenConnection( + @PathVariable String workspaceSlug, + @RequestBody GitLabRepositoryTokenRequest request + ) { + Long workspaceId = workspaceService.getWorkspaceBySlug(workspaceSlug).getId(); + + VcsConnection createdConnection = vcsConnectionService.createGitLabRepositoryTokenConnection( + workspaceId, + request + ); + + return new ResponseEntity<>(GitLabDTO.fromVcsConnection(createdConnection), HttpStatus.CREATED); + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/vcs/bitbucket/cloud/BitbucketCloudCreateRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/cloud/BitbucketCloudCreateRequest.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/vcs/bitbucket/cloud/BitbucketCloudCreateRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/cloud/BitbucketCloudCreateRequest.java index 1bb8e80f..ed7376ce 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/vcs/bitbucket/cloud/BitbucketCloudCreateRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/cloud/BitbucketCloudCreateRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.vcs.bitbucket.cloud; +package org.rostilos.codecrow.webserver.vcs.dto.request.cloud; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/vcs/github/GitHubCreateRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/github/GitHubCreateRequest.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/vcs/github/GitHubCreateRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/github/GitHubCreateRequest.java index f2fe9d64..a84ab720 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/vcs/github/GitHubCreateRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/github/GitHubCreateRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.vcs.github; +package org.rostilos.codecrow.webserver.vcs.dto.request.github; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/gitlab/GitLabCreateRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/gitlab/GitLabCreateRequest.java new file mode 100644 index 00000000..4918be9f --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/gitlab/GitLabCreateRequest.java @@ -0,0 +1,37 @@ +package org.rostilos.codecrow.webserver.vcs.dto.request.gitlab; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class GitLabCreateRequest { + + @JsonProperty("accessToken") + private String accessToken; + + private String groupId; + + private String connectionName; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getGroupId() { + return groupId; + } + + public void setGroupId(String groupId) { + this.groupId = groupId; + } + + public String getConnectionName() { + return connectionName; + } + + public void setConnectionName(String connectionName) { + this.connectionName = connectionName; + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/gitlab/GitLabRepositoryTokenRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/gitlab/GitLabRepositoryTokenRequest.java new file mode 100644 index 00000000..75251395 --- /dev/null +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/request/gitlab/GitLabRepositoryTokenRequest.java @@ -0,0 +1,69 @@ +package org.rostilos.codecrow.webserver.vcs.dto.request.gitlab; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; + +/** + * Request DTO for creating a GitLab repository-scoped token connection. + * Used with GitLab Project Access Tokens that are scoped to a single project. + */ +public class GitLabRepositoryTokenRequest { + + @NotBlank(message = "Access token is required") + @JsonProperty("accessToken") + private String accessToken; + + /** + * Full repository path (e.g., "namespace/project-name") or numeric project ID. + * For project access tokens, this is the project the token is scoped to. + */ + @NotBlank(message = "Repository path is required") + @JsonProperty("repositoryPath") + private String repositoryPath; + + /** + * Optional custom name for the connection. + * If not provided, defaults to "GitLab – {repository_name}" + */ + @JsonProperty("connectionName") + private String connectionName; + + /** + * GitLab instance base URL for self-hosted instances. + * Defaults to "https://gitlab.com" if not specified. + */ + @JsonProperty("baseUrl") + private String baseUrl; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getRepositoryPath() { + return repositoryPath; + } + + public void setRepositoryPath(String repositoryPath) { + this.repositoryPath = repositoryPath; + } + + public String getConnectionName() { + return connectionName; + } + + public void setConnectionName(String connectionName) { + this.connectionName = connectionName; + } + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } +} diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/vcs/InternalVcsConnectionDto.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/response/InternalVcsConnectionDto.java similarity index 90% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/vcs/InternalVcsConnectionDto.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/response/InternalVcsConnectionDto.java index 9822d611..116321b2 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/vcs/InternalVcsConnectionDto.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/response/InternalVcsConnectionDto.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.vcs; +package org.rostilos.codecrow.webserver.vcs.dto.response; public class InternalVcsConnectionDto { public String clientId; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/vcs/RepoSummaryDTO.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/response/RepoSummaryDTO.java similarity index 96% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/vcs/RepoSummaryDTO.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/response/RepoSummaryDTO.java index 1fdb20ee..fa3c6c8f 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/response/vcs/RepoSummaryDTO.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/dto/response/RepoSummaryDTO.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.response.vcs; +package org.rostilos.codecrow.webserver.vcs.dto.response; public class RepoSummaryDTO { private String provider; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/vcs/VcsConnectionWebService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/service/VcsConnectionWebService.java similarity index 53% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/vcs/VcsConnectionWebService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/service/VcsConnectionWebService.java index 2b0d1a45..c0799562 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/vcs/VcsConnectionWebService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/service/VcsConnectionWebService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.vcs; +package org.rostilos.codecrow.webserver.vcs.service; import java.io.IOException; import java.security.GeneralSecurityException; @@ -10,6 +10,7 @@ import org.rostilos.codecrow.core.model.vcs.EVcsConnectionType; import org.rostilos.codecrow.core.model.vcs.config.cloud.BitbucketCloudConfig; import org.rostilos.codecrow.core.model.vcs.config.github.GitHubConfig; +import org.rostilos.codecrow.core.model.vcs.config.gitlab.GitLabConfig; import org.rostilos.codecrow.core.model.vcs.EVcsProvider; import org.rostilos.codecrow.core.model.vcs.EVcsSetupStatus; import org.rostilos.codecrow.core.model.vcs.VcsConnection; @@ -23,14 +24,21 @@ import org.rostilos.codecrow.vcsclient.bitbucket.cloud.dto.response.RepositorySearchResult; import org.rostilos.codecrow.vcsclient.github.actions.SearchRepositoriesAction; import org.rostilos.codecrow.vcsclient.github.actions.ValidateConnectionAction; -import org.rostilos.codecrow.webserver.dto.request.vcs.bitbucket.cloud.BitbucketCloudCreateRequest; -import org.rostilos.codecrow.webserver.dto.request.vcs.github.GitHubCreateRequest; -import org.rostilos.codecrow.webserver.utils.BitbucketCloudConfigHandler; +import org.rostilos.codecrow.vcsclient.gitlab.GitLabClient; +import org.rostilos.codecrow.vcsclient.model.VcsRepositoryPage; +import org.rostilos.codecrow.webserver.vcs.dto.request.cloud.BitbucketCloudCreateRequest; +import org.rostilos.codecrow.webserver.vcs.dto.request.github.GitHubCreateRequest; +import org.rostilos.codecrow.webserver.vcs.dto.request.gitlab.GitLabCreateRequest; +import org.rostilos.codecrow.webserver.vcs.dto.request.gitlab.GitLabRepositoryTokenRequest; +import org.rostilos.codecrow.webserver.vcs.utils.BitbucketCloudConfigHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class VcsConnectionWebService { + private static final Logger log = LoggerFactory.getLogger(VcsConnectionWebService.class); private final VcsConnectionRepository vcsConnectionRepository; private final VcsClientProvider vcsClientProvider; private final HttpAuthorizedClientFactory httpClientFactory; @@ -305,4 +313,250 @@ public org.rostilos.codecrow.vcsclient.github.dto.response.RepositorySearchResul return search.searchRepositories(owner, query, page); } } + + // ========== GitLab Methods ========== + + @Transactional + public List getWorkspaceGitLabConnections(Long workspaceId) { + List connections = vcsConnectionRepository.findByWorkspace_IdAndProviderType(workspaceId, EVcsProvider.GITLAB); + return connections != null ? connections : List.of(); + } + + @Transactional + public VcsConnection createGitLabConnection( + Long codecrowWorkspaceId, + GitLabConfig gitLabConfig, + String connectionName + ) { + Workspace ws = workspaceRepository.findById(codecrowWorkspaceId) + .orElseThrow(() -> new IllegalArgumentException("Workspace not found")); + + var connection = new VcsConnection(); + connection.setWorkspace(ws); + connection.setConnectionName(connectionName); + connection.setProviderType(EVcsProvider.GITLAB); + connection.setConnectionType(EVcsConnectionType.PERSONAL_TOKEN); + connection.setConfiguration(gitLabConfig); + connection.setSetupStatus(EVcsSetupStatus.PENDING); + connection.setExternalWorkspaceSlug(gitLabConfig.groupId()); + connection.setExternalWorkspaceId(gitLabConfig.groupId()); + + VcsConnection createdConnection = vcsConnectionRepository.save(connection); + VcsConnection updatedConnection = syncGitLabConnectionInfo(createdConnection, gitLabConfig); + + return vcsConnectionRepository.save(updatedConnection); + } + + @Transactional + public VcsConnection updateGitLabConnection( + Long codecrowWorkspaceId, + Long connectionId, + GitLabCreateRequest request + ) { + VcsConnection connection = vcsConnectionRepository.findByWorkspace_IdAndId(codecrowWorkspaceId, connectionId) + .orElseThrow(() -> new IllegalArgumentException("Connection not found")); + + if (connection.getProviderType() != EVcsProvider.GITLAB) { + throw new IllegalArgumentException("Not a GitLab connection"); + } + + GitLabConfig currentConfig = connection.getConfiguration() instanceof GitLabConfig + ? (GitLabConfig) connection.getConfiguration() + : null; + + GitLabConfig updatedConfig = new GitLabConfig( + request.getAccessToken() != null ? request.getAccessToken() : + (currentConfig != null ? currentConfig.accessToken() : null), + request.getGroupId() != null ? request.getGroupId() : + (currentConfig != null ? currentConfig.groupId() : null), + currentConfig != null ? currentConfig.allowedRepos() : null, + currentConfig != null ? currentConfig.baseUrl() : null + ); + + connection.setConfiguration(updatedConfig); + connection.setExternalWorkspaceSlug(updatedConfig.groupId()); + connection.setExternalWorkspaceId(updatedConfig.groupId()); + + if (request.getConnectionName() != null) { + connection.setConnectionName(request.getConnectionName()); + } + + // Use appropriate sync method based on connection type + VcsConnection updatedConnection; + if (connection.getConnectionType() == EVcsConnectionType.REPOSITORY_TOKEN) { + String repositoryPath = connection.getRepositoryPath(); + updatedConnection = syncGitLabRepositoryTokenInfo(connection, updatedConfig, repositoryPath); + } else { + updatedConnection = syncGitLabConnectionInfo(connection, updatedConfig); + } + return vcsConnectionRepository.save(updatedConnection); + } + + @Transactional + public void deleteGitLabConnection(Long workspaceId, Long connId) { + VcsConnection existing = getOwnedGitConnection(workspaceId, connId, EVcsProvider.GITLAB); + if (existing == null) { + throw new NoSuchElementException("Connection not found or not owned by workspace"); + } + vcsConnectionRepository.delete(existing); + } + + private VcsConnection syncGitLabConnectionInfo(VcsConnection vcsConnection, GitLabConfig gitLabConfig) { + try { + OkHttpClient httpClient = vcsClientProvider.getHttpClient(vcsConnection); + GitLabClient gitLabClient = new GitLabClient(httpClient, gitLabConfig.effectiveBaseUrl()); + + boolean isConnectionValid = gitLabClient.validateConnection(); + vcsConnection.setSetupStatus(isConnectionValid ? EVcsSetupStatus.CONNECTED : EVcsSetupStatus.ERROR); + + if (isConnectionValid && gitLabConfig.groupId() != null) { + VcsRepositoryPage repoPage = gitLabClient.listRepositories(gitLabConfig.groupId(), 1); + vcsConnection.setRepoCount(repoPage.totalCount() != null ? repoPage.totalCount() : repoPage.items().size()); + } + } catch (IOException e) { + vcsConnection.setSetupStatus(EVcsSetupStatus.ERROR); + } + return vcsConnection; + } + + public org.rostilos.codecrow.vcsclient.gitlab.dto.response.RepositorySearchResult searchGitLabRepositories( + Long workspaceId, + Long connectionId, + String query, + int page + ) throws IOException { + VcsConnection connection = vcsConnectionRepository.findByWorkspace_IdAndId(workspaceId, connectionId) + .orElseThrow(() -> new IllegalArgumentException("VCS connection not found")); + + if (connection.getProviderType() != EVcsProvider.GITLAB) { + throw new IllegalArgumentException("Not a GitLab connection"); + } + + OkHttpClient client = vcsClientProvider.getHttpClient(connection); + GitLabConfig gitLabConfig = connection.getConfiguration() instanceof GitLabConfig + ? (GitLabConfig) connection.getConfiguration() + : null; + String baseUrl = gitLabConfig != null ? gitLabConfig.effectiveBaseUrl() : "https://gitlab.com"; + GitLabClient gitLabClient = new GitLabClient(client, baseUrl); + + String groupId = getExternalWorkspaceId(connection); + + VcsRepositoryPage repoPage; + if (query == null || query.isBlank()) { + repoPage = gitLabClient.listRepositories(groupId, page); + } else { + repoPage = gitLabClient.searchRepositories(groupId, query, page); + } + + // Convert VcsRepositoryPage to RepositorySearchResult + List> items = repoPage.items().stream() + .map(repo -> { + java.util.Map map = new java.util.HashMap<>(); + map.put("id", repo.id()); + map.put("name", repo.name()); + map.put("full_name", repo.fullName()); + map.put("path_with_namespace", repo.fullName()); + map.put("description", repo.description()); + map.put("clone_url", repo.cloneUrl()); + map.put("html_url", repo.htmlUrl()); + map.put("default_branch", repo.defaultBranch()); + map.put("is_private", repo.isPrivate()); + return map; + }) + .toList(); + + return new org.rostilos.codecrow.vcsclient.gitlab.dto.response.RepositorySearchResult( + items, + repoPage.hasNext(), + repoPage.totalCount() + ); + } + + /** + * Create a GitLab connection using a Project Access Token (repository-scoped token). + * Project Access Tokens are limited to a single project/repository. + * + * @param codecrowWorkspaceId The workspace ID + * @param request The repository token request containing accessToken and repositoryPath + * @return The created VcsConnection + */ + @Transactional + public VcsConnection createGitLabRepositoryTokenConnection( + Long codecrowWorkspaceId, + GitLabRepositoryTokenRequest request + ) { + Workspace ws = workspaceRepository.findById(codecrowWorkspaceId) + .orElseThrow(() -> new IllegalArgumentException("Workspace not found")); + + // Extract namespace from repository path (e.g., "rostilos/codecrow-sample" -> "rostilos") + String repositoryPath = request.getRepositoryPath(); + String namespace = repositoryPath.contains("/") + ? repositoryPath.substring(0, repositoryPath.lastIndexOf("/")) + : repositoryPath; + String repoSlug = repositoryPath.contains("/") + ? repositoryPath.substring(repositoryPath.lastIndexOf("/") + 1) + : repositoryPath; + + // Create config with the repository path as "group" for lookup purposes + // For repository tokens, the access is limited to that single project + String baseUrl = request.getBaseUrl(); + GitLabConfig gitLabConfig = new GitLabConfig( + request.getAccessToken(), + namespace, // Use namespace as groupId for consistency + List.of(repoSlug), // Explicitly list the only allowed repo + baseUrl + ); + + String connectionName = request.getConnectionName() != null + ? request.getConnectionName() + : "GitLab – " + repoSlug; + + var connection = new VcsConnection(); + connection.setWorkspace(ws); + connection.setConnectionName(connectionName); + connection.setProviderType(EVcsProvider.GITLAB); + connection.setConnectionType(EVcsConnectionType.REPOSITORY_TOKEN); + connection.setConfiguration(gitLabConfig); + connection.setSetupStatus(EVcsSetupStatus.PENDING); + connection.setExternalWorkspaceSlug(namespace); + connection.setExternalWorkspaceId(namespace); + connection.setRepositoryPath(repositoryPath); // Store full repo path for single-repo lookups + connection.setRepoCount(1); // Repository tokens only have access to one repo + + VcsConnection createdConnection = vcsConnectionRepository.save(connection); + VcsConnection updatedConnection = syncGitLabRepositoryTokenInfo(createdConnection, gitLabConfig, repositoryPath); + + return vcsConnectionRepository.save(updatedConnection); + } + + /** + * Validate a GitLab repository token connection by checking access to the specific repository. + */ + private VcsConnection syncGitLabRepositoryTokenInfo(VcsConnection vcsConnection, GitLabConfig gitLabConfig, String repositoryPath) { + try { + OkHttpClient httpClient = vcsClientProvider.getHttpClient(vcsConnection); + GitLabClient gitLabClient = new GitLabClient(httpClient, gitLabConfig.effectiveBaseUrl()); + + // For repository tokens, validate by trying to access the specific project + boolean isConnectionValid = gitLabClient.validateConnection(); + + if (isConnectionValid) { + // Try to fetch the specific repository to confirm access + // For REPOSITORY_TOKEN, pass empty workspaceId and full path as repoIdOrSlug + try { + gitLabClient.getRepository("", repositoryPath); + vcsConnection.setSetupStatus(EVcsSetupStatus.CONNECTED); + } catch (IOException e) { + log.warn("Failed to access repository {} with token: {}", repositoryPath, e.getMessage()); + vcsConnection.setSetupStatus(EVcsSetupStatus.ERROR); + } + } else { + vcsConnection.setSetupStatus(EVcsSetupStatus.ERROR); + } + } catch (IOException e) { + log.error("Failed to sync repository token connection: {}", e.getMessage()); + vcsConnection.setSetupStatus(EVcsSetupStatus.ERROR); + } + return vcsConnection; + } } diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/utils/BitbucketCloudConfigHandler.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/utils/BitbucketCloudConfigHandler.java similarity index 91% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/utils/BitbucketCloudConfigHandler.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/utils/BitbucketCloudConfigHandler.java index c2a57b95..893d8f71 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/utils/BitbucketCloudConfigHandler.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/utils/BitbucketCloudConfigHandler.java @@ -1,7 +1,7 @@ -package org.rostilos.codecrow.webserver.utils; +package org.rostilos.codecrow.webserver.vcs.utils; import org.rostilos.codecrow.core.model.vcs.config.cloud.BitbucketCloudConfig; -import org.rostilos.codecrow.webserver.dto.request.vcs.bitbucket.cloud.BitbucketCloudCreateRequest; +import org.rostilos.codecrow.webserver.vcs.dto.request.cloud.BitbucketCloudCreateRequest; import org.rostilos.codecrow.security.oauth.TokenEncryptionService; import org.springframework.stereotype.Component; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/workspace/WorkspaceController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/controller/WorkspaceController.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/workspace/WorkspaceController.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/controller/WorkspaceController.java index 3f461eda..3b19f408 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/controller/workspace/WorkspaceController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/controller/WorkspaceController.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.controller.workspace; +package org.rostilos.codecrow.webserver.workspace.controller; import java.util.List; import java.util.NoSuchElementException; @@ -9,12 +9,12 @@ import org.rostilos.codecrow.core.model.workspace.Workspace; import org.rostilos.codecrow.core.model.workspace.WorkspaceMember; import org.rostilos.codecrow.security.service.UserDetailsImpl; -import org.rostilos.codecrow.webserver.dto.message.MessageResponse; -import org.rostilos.codecrow.webserver.dto.request.workspace.ChangeRoleRequest; -import org.rostilos.codecrow.webserver.dto.request.workspace.CreateRequest; -import org.rostilos.codecrow.webserver.dto.request.workspace.InviteRequest; -import org.rostilos.codecrow.webserver.dto.request.workspace.RemoveMemberRequest; -import org.rostilos.codecrow.webserver.service.workspace.WorkspaceService; +import org.rostilos.codecrow.webserver.generic.dto.message.MessageResponse; +import org.rostilos.codecrow.webserver.workspace.dto.request.ChangeRoleRequest; +import org.rostilos.codecrow.webserver.workspace.dto.request.CreateRequest; +import org.rostilos.codecrow.webserver.workspace.dto.request.InviteRequest; +import org.rostilos.codecrow.webserver.workspace.dto.request.RemoveMemberRequest; +import org.rostilos.codecrow.webserver.workspace.service.WorkspaceService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/ChangeRoleRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/ChangeRoleRequest.java similarity index 88% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/ChangeRoleRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/ChangeRoleRequest.java index 14a67cf4..a89f713f 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/ChangeRoleRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/ChangeRoleRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.workspace; +package org.rostilos.codecrow.webserver.workspace.dto.request; import jakarta.validation.constraints.NotBlank; import org.rostilos.codecrow.core.utils.EnumNamePattern; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/CreateRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/CreateRequest.java similarity index 92% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/CreateRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/CreateRequest.java index df1e23e1..9aa5ea20 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/CreateRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/CreateRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.workspace; +package org.rostilos.codecrow.webserver.workspace.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/InviteRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/InviteRequest.java similarity index 88% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/InviteRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/InviteRequest.java index bcc2477d..a28617e3 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/InviteRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/InviteRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.workspace; +package org.rostilos.codecrow.webserver.workspace.dto.request; import jakarta.validation.constraints.NotBlank; import org.rostilos.codecrow.core.utils.EnumNamePattern; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/RemoveMemberRequest.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/RemoveMemberRequest.java similarity index 73% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/RemoveMemberRequest.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/RemoveMemberRequest.java index b34968d0..135d8bf1 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/dto/request/workspace/RemoveMemberRequest.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/dto/request/RemoveMemberRequest.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.dto.request.workspace; +package org.rostilos.codecrow.webserver.workspace.dto.request; import jakarta.validation.constraints.NotBlank; diff --git a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/workspace/WorkspaceService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/service/WorkspaceService.java similarity index 99% rename from java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/workspace/WorkspaceService.java rename to java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/service/WorkspaceService.java index 64d8270a..aa388b18 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/service/workspace/WorkspaceService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/workspace/service/WorkspaceService.java @@ -1,4 +1,4 @@ -package org.rostilos.codecrow.webserver.service.workspace; +package org.rostilos.codecrow.webserver.workspace.service; import java.util.List; import java.util.NoSuchElementException; diff --git a/python-ecosystem/.gitignore b/python-ecosystem/.gitignore deleted file mode 100644 index 6f2ed013..00000000 --- a/python-ecosystem/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -.github -docker-compose.yml -mcp-client/logs/** \ No newline at end of file diff --git a/python-ecosystem/mcp-client/.gitignore b/python-ecosystem/mcp-client/.gitignore index 4cc49a37..5fa887fe 100644 --- a/python-ecosystem/mcp-client/.gitignore +++ b/python-ecosystem/mcp-client/.gitignore @@ -11,4 +11,7 @@ target/ .env server.log -*.jar \ No newline at end of file +*.jar + +logs/** +**/__pycache__/ \ No newline at end of file diff --git a/python-ecosystem/mcp-client/Dockerfile b/python-ecosystem/mcp-client/Dockerfile index bf30cf06..9dd14dba 100644 --- a/python-ecosystem/mcp-client/Dockerfile +++ b/python-ecosystem/mcp-client/Dockerfile @@ -28,7 +28,7 @@ COPY service ./service/ COPY utils ./utils/ COPY llm ./llm/ # Copy the JAR files (required by the Python service) -COPY codecrow-mcp-servers-1.0.jar ./codecrow-mcp-servers-1.0.jar +COPY codecrow-vcs-mcp-1.0.jar ./codecrow-vcs-mcp-1.0.jar # Platform MCP JAR - if it exists (optional) COPY codecrow-platform-mcp-1.0.jar* ./ @@ -62,7 +62,7 @@ COPY --from=builder /app/service ./service/ COPY --from=builder /app/utils ./utils/ COPY --from=builder /app/llm ./llm/ # Copy the JAR files -COPY --from=builder /app/codecrow-mcp-servers-1.0.jar ./codecrow-mcp-servers-1.0.jar +COPY --from=builder /app/codecrow-vcs-mcp-1.0.jar ./codecrow-vcs-mcp-1.0.jar # Copy Platform MCP JAR if present COPY --from=builder /app/codecrow-platform-mcp-1.0.jar* ./ diff --git a/python-ecosystem/mcp-client/__pycache__/main.cpython-313.pyc b/python-ecosystem/mcp-client/__pycache__/main.cpython-313.pyc deleted file mode 100644 index 69b21a3f..00000000 Binary files a/python-ecosystem/mcp-client/__pycache__/main.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/llm/llm_factory.py b/python-ecosystem/mcp-client/llm/llm_factory.py index fe663e09..470e7c4e 100644 --- a/python-ecosystem/mcp-client/llm/llm_factory.py +++ b/python-ecosystem/mcp-client/llm/llm_factory.py @@ -3,7 +3,10 @@ from typing import Optional from pydantic import SecretStr from langchain_openai import ChatOpenAI +from langchain_anthropic import ChatAnthropic from langchain_core.utils.utils import secret_from_env +from langchain_google_genai import ChatGoogleGenerativeAI + logger = logging.getLogger(__name__) @@ -11,25 +14,28 @@ DEFAULT_TEMPERATURE = float(os.environ.get("LLM_TEMPERATURE", "0.0")) # Gemini thinking/reasoning models that DON'T work with tool calls -# These models require thought_signature preservation which isn't supported -# by LangChain. Users must use non-thinking variants instead. +# These are experimental thinking models that have known issues with MCP tools. +# Standard Gemini 2.x and 3.x models work fine with thinking_level/thinking_budget settings. UNSUPPORTED_GEMINI_THINKING_MODELS = { + # Experimental thinking models - have known issues "google/gemini-2.0-flash-thinking-exp", "google/gemini-2.0-flash-thinking-exp:free", - "google/gemini-2.5-flash-preview-05-20", - "google/gemini-2.5-pro-preview-05-06", - "google/gemini-3-flash-preview", - "google/gemini-3-pro-preview", + "gemini-2.0-flash-thinking-exp", } # Mapping from unsupported thinking models to recommended alternatives GEMINI_MODEL_ALTERNATIVES = { "google/gemini-2.0-flash-thinking-exp": "google/gemini-2.0-flash", "google/gemini-2.0-flash-thinking-exp:free": "google/gemini-2.0-flash", - "google/gemini-2.5-flash-preview-05-20": "google/gemini-2.5-flash-preview", - "google/gemini-2.5-pro-preview-05-06": "google/gemini-2.5-pro-preview", - "google/gemini-3-flash-preview": "google/gemini-2.5", - "google/gemini-3-pro-preview": "google/gemini-2.5", + "gemini-2.0-flash-thinking-exp": "gemini-2.0-flash", +} + +# Supported AI providers with their identifiers +SUPPORTED_PROVIDERS = { + "openrouter": ["openrouter", "open-router"], + "openai": ["openai"], + "anthropic": ["anthropic"], + "google": ["google", "google-genai", "google-vertex", "google-ai"], } @@ -38,6 +44,11 @@ class UnsupportedModelError(Exception): pass +class UnsupportedProviderError(Exception): + """Raised when an unsupported provider is requested.""" + pass + + class ChatOpenRouter(ChatOpenAI): """ Small wrapper to support OpenRouter-style configuration via api_key. @@ -63,50 +74,86 @@ def __init__(self, class LLMFactory: + """ + Factory for creating LLM instances for different AI providers. + + Supported providers: + - OPENROUTER: Access to multiple models via OpenRouter API (recommended) + - OPENAI: Direct OpenAI API access (gpt-4o, gpt-4-turbo, etc.) + - ANTHROPIC: Direct Anthropic API access (claude-3-opus, claude-3-sonnet, etc.) + - GOOGLE: Direct Google AI API access (gemini-pro, gemini-1.5-pro, etc.) + """ + + @staticmethod + def get_supported_providers() -> list[str]: + """Return list of supported provider keys.""" + return ["OPENROUTER", "OPENAI", "ANTHROPIC", "GOOGLE"] + + @staticmethod + def _normalize_provider(provider: str) -> str: + """Normalize provider string to standard format.""" + provider_lower = provider.lower().strip() + for standard, aliases in SUPPORTED_PROVIDERS.items(): + if provider_lower in aliases: + return standard + return provider_lower + + @staticmethod + def _check_unsupported_gemini_model(ai_model: str) -> None: + """Check if model is an unsupported Gemini thinking model.""" + model_lower = ai_model.lower() + for unsupported in UNSUPPORTED_GEMINI_THINKING_MODELS: + if model_lower == unsupported.lower() or model_lower.startswith(unsupported.lower()): + alternative = GEMINI_MODEL_ALTERNATIVES.get(unsupported, "gemini-2.0-flash") + error_msg = ( + f"Model '{ai_model}' is a Gemini thinking model that requires thought_signature " + f"preservation for tool calls. This is not supported by the current LangChain integration. " + f"Please use a non-thinking variant instead, such as '{alternative}'." + ) + logger.error(error_msg) + raise UnsupportedModelError(error_msg) @staticmethod def create_llm(ai_model: str, ai_provider: str, ai_api_key: str, temperature: Optional[float] = None): """ - Create LLM instance. + Create LLM instance for the specified provider. Args: + ai_model: Model name/identifier + ai_provider: Provider key (OPENROUTER, OPENAI, ANTHROPIC, GOOGLE) + ai_api_key: API key for the provider temperature: LLM temperature. If None, uses LLM_TEMPERATURE env var or 0.0. 0.0 = deterministic results (recommended for code review) 0.1-0.3 = more creative but less consistent Raises: - UnsupportedModelError: If the model is a Gemini thinking model that doesn't - support tool calls. + UnsupportedModelError: If the model is unsupported (e.g., Gemini thinking models) + UnsupportedProviderError: If the provider is not supported + + Returns: + LangChain chat model instance """ if temperature is None: temperature = DEFAULT_TEMPERATURE + # Normalize provider + provider = LLMFactory._normalize_provider(ai_provider) + + # Check for unsupported Gemini thinking models (applies to all providers) + LLMFactory._check_unsupported_gemini_model(ai_model) + # model_kwargs to disable parallel tool calls at the API level # This prevents stdio transport concurrency issues with MCP servers model_kwargs = { "parallel_tool_calls": False } - # Check if this is an unsupported Gemini thinking model - model_lower = ai_model.lower() - for unsupported in UNSUPPORTED_GEMINI_THINKING_MODELS: - if model_lower == unsupported.lower() or model_lower.startswith(unsupported.lower()): - alternative = GEMINI_MODEL_ALTERNATIVES.get(unsupported, "google/gemini-2.0-flash") - error_msg = ( - f"Model '{ai_model}' is a Gemini thinking model that requires thought_signature " - f"preservation for tool calls. This is not supported by the current LangChain integration. " - f"Please use a non-thinking variant instead, such as '{alternative}'." - ) - logger.error(error_msg) - raise UnsupportedModelError(error_msg) - - if ai_provider.lower() in ("openrouter", "open-router"): - # Extra headers for OpenRouter + # OpenRouter provider - access multiple models via single API + if provider == "openrouter": extra_headers = { "HTTP-Referer": "https://codecrow.cloud", "X-Title": "CodeCrow AI" } - return ChatOpenRouter( api_key=ai_api_key, model_name=ai_model, @@ -115,10 +162,55 @@ def create_llm(ai_model: str, ai_provider: str, ai_api_key: str, temperature: Op model_kwargs=model_kwargs, default_headers=extra_headers ) - - return ChatOpenAI( - api_key=ai_api_key, - model_name=ai_model, - temperature=temperature, - model_kwargs=model_kwargs - ) \ No newline at end of file + + # Direct OpenAI provider + if provider == "openai": + return ChatOpenAI( + api_key=ai_api_key, + model=ai_model, + temperature=temperature, + model_kwargs=model_kwargs + ) + + # Direct Anthropic provider + if provider == "anthropic": + # Anthropic uses different parameter names + return ChatAnthropic( + api_key=ai_api_key, + model=ai_model, + temperature=temperature, + # Note: Anthropic doesn't use parallel_tool_calls the same way + # but we can pass extra kwargs if needed + ) + + # Google AI provider (Gemini models) + # langchain-google-genai >= 4.0.0 automatically handles thought signatures + if provider == "google": + model_lower = ai_model.lower() + is_gemini_3 = "gemini-3" in model_lower or "gemini3" in model_lower + + if is_gemini_3: + # Gemini 3 models use thinking_level parameter + # "minimal" reduces latency while preserving thought signatures for tool calls + # Temperature defaults to 1.0 for Gemini 3 per Google's recommendation + return ChatGoogleGenerativeAI( + google_api_key=ai_api_key, + model=ai_model, + temperature=temperature if temperature > 0 else 0.1, + thinking_level="minimal", + ) + else: + # Gemini 2.x models use thinking_budget parameter + # thinking_budget=0 disables thinking for faster responses + return ChatGoogleGenerativeAI( + google_api_key=ai_api_key, + model=ai_model, + temperature=temperature, + thinking_budget=0, + ) + + # Unknown provider - raise error with helpful message + supported = ", ".join(LLMFactory.get_supported_providers()) + error_msg = f"Unsupported AI provider: '{ai_provider}'. Supported providers: {supported}" + logger.error(error_msg) + raise UnsupportedProviderError(error_msg) \ No newline at end of file diff --git a/python-ecosystem/mcp-client/model/__pycache__/models.cpython-313.pyc b/python-ecosystem/mcp-client/model/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 67272203..00000000 Binary files a/python-ecosystem/mcp-client/model/__pycache__/models.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/model/models.py b/python-ecosystem/mcp-client/model/models.py index a540b5eb..553b21d5 100644 --- a/python-ecosystem/mcp-client/model/models.py +++ b/python-ecosystem/mcp-client/model/models.py @@ -1,5 +1,5 @@ from typing import Optional, Any, Dict, List -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, AliasChoices from datetime import datetime from enum import Enum @@ -18,6 +18,12 @@ class IssueCategory(str, Enum): ARCHITECTURE = "ARCHITECTURE" +class AnalysisMode(str, Enum): + """Analysis mode for PR reviews.""" + FULL = "FULL" # Full PR diff analysis (first review or escalation) + INCREMENTAL = "INCREMENTAL" # Delta diff analysis (subsequent reviews) + + class IssueDTO(BaseModel): id: Optional[str] = None type: Optional[str] = None # security|quality|performance|style @@ -48,7 +54,7 @@ class ReviewRequestDto(BaseModel): aiProvider: str aiModel: str aiApiKey: str - targetBranchName: Optional[str] = None + targetBranchName: Optional[str] = Field(default=None, alias="branch", validation_alias=AliasChoices("targetBranchName", "branch")) pullRequestId: Optional[int] = None commitHash: Optional[str] = None oAuthClient: Optional[str] = None @@ -65,6 +71,11 @@ class ReviewRequestDto(BaseModel): previousCodeAnalysisIssues: Optional[List[IssueDTO]] = Field(default_factory=list, description="List of issues from the previous CodeAnalysis version, if available.") vcsProvider: Optional[str] = Field(default=None, description="VCS provider type for MCP server selection (github, bitbucket_cloud)") + # Incremental analysis fields + analysisMode: Optional[str] = Field(default="FULL", description="Analysis mode: FULL or INCREMENTAL") + deltaDiff: Optional[str] = Field(default=None, description="Delta diff between previous and current commit (only for INCREMENTAL mode)") + previousCommitHash: Optional[str] = Field(default=None, description="Previously analyzed commit hash") + currentCommitHash: Optional[str] = Field(default=None, description="Current commit hash being analyzed") class ReviewResponseDto(BaseModel): result: Optional[Any] = None @@ -137,6 +148,8 @@ class AskResponseDto(BaseModel): class CodeReviewIssue(BaseModel): """Schema for a single code review issue.""" + # Optional issue identifier (preserve DB/client-side ids for reconciliation) + id: Optional[str] = Field(default=None, description="Optional issue id to link to existing issues") severity: str = Field(description="Issue severity: HIGH, MEDIUM, or LOW") category: str = Field(description="Issue category: SECURITY, PERFORMANCE, CODE_QUALITY, BUG_RISK, STYLE, DOCUMENTATION, BEST_PRACTICES, ERROR_HANDLING, TESTING, or ARCHITECTURE") file: str = Field(description="File path where the issue is located") diff --git a/python-ecosystem/mcp-client/requirements.txt b/python-ecosystem/mcp-client/requirements.txt index bf0e3269..abb24a77 100644 --- a/python-ecosystem/mcp-client/requirements.txt +++ b/python-ecosystem/mcp-client/requirements.txt @@ -4,6 +4,8 @@ uvicorn pydantic python-dotenv httpx -langchain-openai==0.3.32 -langchain-core==0.3.75 +langchain-core>=1.0.0,<2.0.0 +langchain-openai>=1.0.0,<2.0.0 +langchain-anthropic>=1.0.0,<2.0.0 +langchain-google-genai>=4.0.0 mcp-use \ No newline at end of file diff --git a/python-ecosystem/mcp-client/server/__pycache__/stdin_handler.cpython-313.pyc b/python-ecosystem/mcp-client/server/__pycache__/stdin_handler.cpython-313.pyc deleted file mode 100644 index f182f559..00000000 Binary files a/python-ecosystem/mcp-client/server/__pycache__/stdin_handler.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/server/__pycache__/web_server.cpython-313.pyc b/python-ecosystem/mcp-client/server/__pycache__/web_server.cpython-313.pyc deleted file mode 100644 index ae3e6052..00000000 Binary files a/python-ecosystem/mcp-client/server/__pycache__/web_server.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/service/__pycache__/review_service.cpython-313.pyc b/python-ecosystem/mcp-client/service/__pycache__/review_service.cpython-313.pyc deleted file mode 100644 index 7ada6f89..00000000 Binary files a/python-ecosystem/mcp-client/service/__pycache__/review_service.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/service/command_service.py b/python-ecosystem/mcp-client/service/command_service.py index cedcac3f..a07a8aea 100644 --- a/python-ecosystem/mcp-client/service/command_service.py +++ b/python-ecosystem/mcp-client/service/command_service.py @@ -14,6 +14,7 @@ from utils.mcp_config import MCPConfigBuilder from llm.llm_factory import LLMFactory from service.rag_client import RagClient +from utils.error_sanitizer import sanitize_error_for_display, create_user_friendly_error logger = logging.getLogger(__name__) @@ -29,7 +30,7 @@ def __init__(self): load_dotenv() self.default_jar_path = os.environ.get( "MCP_SERVER_JAR", - "/app/codecrow-mcp-servers-1.0.jar" + "/app/codecrow-vcs-mcp-1.0.jar" ) self.rag_client = RagClient() @@ -112,10 +113,10 @@ async def process_summarize( return result except Exception as e: - error_msg = f"Summarize failed: {str(e)}" - logger.error(error_msg, exc_info=True) - self._emit_event(event_callback, {"type": "error", "message": error_msg}) - return {"error": error_msg} + logger.error(f"Summarize failed: {str(e)}", exc_info=True) + sanitized_msg = create_user_friendly_error(e) + self._emit_event(event_callback, {"type": "error", "message": sanitized_msg}) + return {"error": sanitized_msg} async def process_ask( self, @@ -207,10 +208,10 @@ async def process_ask( return result except Exception as e: - error_msg = f"Ask failed: {str(e)}" - logger.error(error_msg, exc_info=True) - self._emit_event(event_callback, {"type": "error", "message": error_msg}) - return {"error": error_msg} + logger.error(f"Ask failed: {str(e)}", exc_info=True) + sanitized_msg = create_user_friendly_error(e) + self._emit_event(event_callback, {"type": "error", "message": sanitized_msg}) + return {"error": sanitized_msg} def _build_platform_jvm_props(self, request) -> Dict[str, str]: """Build JVM properties for Platform MCP server (API + VCS access).""" @@ -677,7 +678,8 @@ async def _execute_summarize( except Exception as e: logger.error(f"Summarize agent error: {e}", exc_info=True) - return {"error": str(e)} + sanitized_msg = create_user_friendly_error(e) + return {"error": sanitized_msg} def _extract_summary_field_fallback(self, text: str) -> Optional[str]: """ @@ -792,7 +794,8 @@ async def _execute_ask( except Exception as e: logger.error(f"Ask agent error: {e}", exc_info=True) - return {"error": str(e)} + sanitized_msg = create_user_friendly_error(e) + return {"error": sanitized_msg} async def _run_agent_with_heartbeat( self, diff --git a/python-ecosystem/mcp-client/service/issue_post_processor.py b/python-ecosystem/mcp-client/service/issue_post_processor.py new file mode 100644 index 00000000..95892a12 --- /dev/null +++ b/python-ecosystem/mcp-client/service/issue_post_processor.py @@ -0,0 +1,671 @@ +""" +Issue Post-Processor Service + +This service handles: +1. Line number validation and correction against actual file content +2. Issue deduplication/merging for semantically similar issues +3. Fix validation and cleanup +""" +import re +import logging +from typing import Dict, Any, List, Optional, Tuple +from difflib import SequenceMatcher + +logger = logging.getLogger(__name__) + + +class IssuePostProcessor: + """ + Post-processes LLM-generated issues to fix common problems: + - Line number drift (LLM reporting wrong lines) + - Duplicate/similar issues that should be merged + - Invalid or low-quality suggested fixes + """ + + # Similarity threshold for considering issues as duplicates + SIMILARITY_THRESHOLD = 0.75 + + # Maximum line number drift to attempt correction + MAX_LINE_DRIFT = 15 + + # Keywords that indicate similar issues (for grouping) + ISSUE_KEYWORDS = [ + 'hardcode', 'hardcoded', + 'sql injection', 'injection', + 'xss', 'cross-site', + 'authentication', 'auth bypass', + 'null pointer', 'null check', 'nullpointer', + 'memory leak', 'resource leak', + 'n+1', 'n+1 query', + 'store id', 'store_id', + 'environment', 'config', 'configuration', + 'secret', 'password', 'api key', 'apikey', + 'deprecated', 'deprecated method', + 'unused', 'dead code', + 'performance', 'slow', 'inefficient', + ] + + def __init__(self, diff_content: Optional[str] = None, file_contents: Optional[Dict[str, str]] = None): + """ + Initialize post-processor. + + Args: + diff_content: Raw diff content for line number validation + file_contents: Map of file paths to their content for validation + """ + self.diff_content = diff_content + self.file_contents = file_contents or {} + self._diff_line_map = self._parse_diff_lines() if diff_content else {} + + def _parse_diff_lines(self) -> Dict[str, Dict[int, str]]: + """ + Parse diff to create a map of file -> line number -> content. + This helps validate and correct LLM-reported line numbers. + + Returns: + Dict mapping file paths to dict of line numbers to line content + """ + if not self.diff_content: + return {} + + result = {} + current_file = None + current_new_line = 0 + + for line in self.diff_content.split('\n'): + # Detect file header + if line.startswith('+++ b/') or line.startswith('+++ '): + # Extract file path + match = re.match(r'\+\+\+ [ab]/(.+)', line) + if match: + current_file = match.group(1) + result[current_file] = {} + continue + + # Detect hunk header: @@ -old_start,old_count +new_start,new_count @@ + hunk_match = re.match(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@', line) + if hunk_match: + current_new_line = int(hunk_match.group(1)) + continue + + if current_file is None: + continue + + # Track lines in the new file + if line.startswith('+') and not line.startswith('+++'): + # Added line + result[current_file][current_new_line] = line[1:] # Remove + + current_new_line += 1 + elif line.startswith('-') and not line.startswith('---'): + # Deleted line - don't increment new line counter + pass + elif line.startswith(' ') or line == '': + # Context line or empty line + if current_new_line > 0: + result[current_file][current_new_line] = line[1:] if line.startswith(' ') else line + current_new_line += 1 + + logger.debug(f"Parsed diff lines for {len(result)} files") + return result + + def process_issues(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Main entry point: process a list of issues. + + Steps: + 1. Group issues by file + 2. Detect and merge duplicates + 3. Validate/correct line numbers + 4. Clean up fix suggestions + + Args: + issues: List of issue dictionaries from LLM + + Returns: + Processed list of issues with duplicates merged and lines corrected + """ + if not issues: + return [] + + logger.info(f"Post-processing {len(issues)} issues") + + # Step 1: Fix line numbers for each issue + issues_with_fixed_lines = [] + for issue in issues: + fixed_issue = self._fix_line_number(issue) + issues_with_fixed_lines.append(fixed_issue) + + # Step 2: Merge duplicates + merged_issues = self._merge_duplicate_issues(issues_with_fixed_lines) + + # Step 3: Validate and clean fix suggestions + cleaned_issues = [] + for issue in merged_issues: + cleaned = self._clean_fix_suggestion(issue) + cleaned_issues.append(cleaned) + + logger.info(f"Post-processing complete: {len(issues)} -> {len(cleaned_issues)} issues") + return cleaned_issues + + def _fix_line_number(self, issue: Dict[str, Any]) -> Dict[str, Any]: + """ + Attempt to correct the line number based on diff content or file content. + + Strategy: + 1. If we have diff content, find the line that best matches the issue context + 2. Search within a window (±MAX_LINE_DRIFT lines) for matching content + """ + file_path = issue.get('file', '') + reported_line = issue.get('line', 0) + + try: + reported_line = int(reported_line) + except (ValueError, TypeError): + reported_line = 0 + + if reported_line == 0: + return issue + + # Try to find the correct line using diff content + if file_path in self._diff_line_map: + corrected_line = self._find_correct_line_in_diff( + file_path, reported_line, issue.get('reason', '') + ) + if corrected_line and corrected_line != reported_line: + logger.debug(f"Corrected line for {file_path}: {reported_line} -> {corrected_line}") + issue = issue.copy() + issue['line'] = str(corrected_line) + issue['_line_corrected'] = True + + # Try to find the correct line using file content + elif file_path in self.file_contents: + corrected_line = self._find_correct_line_in_file( + file_path, reported_line, issue.get('reason', '') + ) + if corrected_line and corrected_line != reported_line: + logger.debug(f"Corrected line for {file_path}: {reported_line} -> {corrected_line}") + issue = issue.copy() + issue['line'] = str(corrected_line) + issue['_line_corrected'] = True + + return issue + + def _find_correct_line_in_diff( + self, + file_path: str, + reported_line: int, + reason: str + ) -> Optional[int]: + """ + Search the diff content for the line that best matches the issue. + """ + file_lines = self._diff_line_map.get(file_path, {}) + if not file_lines: + return None + + # Extract keywords from the reason to search for + keywords = self._extract_keywords_from_reason(reason) + if not keywords: + return None + + best_match_line = reported_line + best_match_score = 0 + + # Search within a window around the reported line + for line_num in range( + max(1, reported_line - self.MAX_LINE_DRIFT), + reported_line + self.MAX_LINE_DRIFT + 1 + ): + if line_num not in file_lines: + continue + + line_content = file_lines[line_num].lower() + score = sum(1 for kw in keywords if kw.lower() in line_content) + + # Prefer lines closer to reported line in case of tie + distance_penalty = abs(line_num - reported_line) * 0.1 + adjusted_score = score - distance_penalty + + if adjusted_score > best_match_score: + best_match_score = adjusted_score + best_match_line = line_num + + return best_match_line if best_match_score > 0 else None + + def _find_correct_line_in_file( + self, + file_path: str, + reported_line: int, + reason: str + ) -> Optional[int]: + """ + Search the file content for the line that best matches the issue. + """ + file_content = self.file_contents.get(file_path, '') + if not file_content: + return None + + lines = file_content.split('\n') + keywords = self._extract_keywords_from_reason(reason) + if not keywords: + return None + + best_match_line = reported_line + best_match_score = 0 + + # Search within a window around the reported line + for line_num in range( + max(1, reported_line - self.MAX_LINE_DRIFT), + min(len(lines), reported_line + self.MAX_LINE_DRIFT + 1) + ): + line_idx = line_num - 1 # 0-indexed + if line_idx < 0 or line_idx >= len(lines): + continue + + line_content = lines[line_idx].lower() + score = sum(1 for kw in keywords if kw.lower() in line_content) + + # Prefer lines closer to reported line in case of tie + distance_penalty = abs(line_num - reported_line) * 0.1 + adjusted_score = score - distance_penalty + + if adjusted_score > best_match_score: + best_match_score = adjusted_score + best_match_line = line_num + + return best_match_line if best_match_score > 0 else None + + def _extract_keywords_from_reason(self, reason: str) -> List[str]: + """ + Extract meaningful keywords from the issue reason. + These are used to locate the correct line. + """ + if not reason: + return [] + + # Look for code identifiers (variable names, function names, etc.) + # Pattern: words with underscores, camelCase, or quoted strings + identifiers = re.findall(r"['\"`]([^'\"`]+)['\"`]", reason) + identifiers.extend(re.findall(r'\b([a-z]+(?:_[a-z]+)+)\b', reason, re.IGNORECASE)) + identifiers.extend(re.findall(r'\b([a-z]+(?:[A-Z][a-z]+)+)\b', reason)) + + # Also look for numbers that might be specific values (like store ID 6) + numbers = re.findall(r"(?:ID|id|value|code)\s*[=:'\"`]?\s*(\d+)", reason) + identifiers.extend(numbers) + + # Check for known issue keywords + for keyword in self.ISSUE_KEYWORDS: + if keyword.lower() in reason.lower(): + # Add specific terms related to this keyword + if 'store' in keyword: + identifiers.extend(['store_id', 'storeId', 'getStoreId']) + elif 'hardcode' in keyword: + numbers = re.findall(r'\b(\d+)\b', reason) + identifiers.extend(numbers[:3]) # Take up to 3 hardcoded values + + return list(set(identifiers))[:10] # Limit to 10 keywords + + def _merge_duplicate_issues(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Merge issues that are semantically similar. + + Strategy: + 1. Group issues by file + 2. Within each file, compare issues for similarity + 3. Merge similar issues, keeping the one with the best fix suggestion + """ + if len(issues) < 2: + return issues + + # Group by file + by_file: Dict[str, List[Dict[str, Any]]] = {} + for issue in issues: + file_path = issue.get('file', 'unknown') + if file_path not in by_file: + by_file[file_path] = [] + by_file[file_path].append(issue) + + result = [] + + for file_path, file_issues in by_file.items(): + if len(file_issues) == 1: + result.extend(file_issues) + continue + + # Find similar issues within this file + merged_indices = set() + + for i, issue1 in enumerate(file_issues): + if i in merged_indices: + continue + + # Find all issues similar to issue1 + similar_group = [issue1] + + for j, issue2 in enumerate(file_issues[i+1:], i+1): + if j in merged_indices: + continue + + similarity = self._calculate_issue_similarity(issue1, issue2) + if similarity >= self.SIMILARITY_THRESHOLD: + similar_group.append(issue2) + merged_indices.add(j) + + if len(similar_group) > 1: + # Merge the group + merged = self._merge_issue_group(similar_group) + logger.info(f"Merged {len(similar_group)} similar issues in {file_path}") + result.append(merged) + else: + result.append(issue1) + + return result + + def _calculate_issue_similarity( + self, + issue1: Dict[str, Any], + issue2: Dict[str, Any] + ) -> float: + """ + Calculate semantic similarity between two issues. + + Returns: + Float between 0 and 1 indicating similarity + """ + # Same category bonus + category_match = issue1.get('category') == issue2.get('category') + + # Compare reasons + reason1 = issue1.get('reason', '').lower() + reason2 = issue2.get('reason', '').lower() + + # Quick keyword check + keywords1 = set(self._extract_core_keywords(reason1)) + keywords2 = set(self._extract_core_keywords(reason2)) + + if keywords1 and keywords2: + keyword_overlap = len(keywords1 & keywords2) / max(len(keywords1), len(keywords2)) + else: + keyword_overlap = 0 + + # Sequence similarity + sequence_sim = SequenceMatcher(None, reason1, reason2).ratio() + + # Line proximity (issues on nearby lines are more likely duplicates) + try: + line1 = int(issue1.get('line', 0)) + line2 = int(issue2.get('line', 0)) + line_distance = abs(line1 - line2) + line_proximity = max(0, 1 - (line_distance / 50)) # Decay over 50 lines + except (ValueError, TypeError): + line_proximity = 0 + + # Weighted combination + similarity = ( + 0.4 * keyword_overlap + + 0.3 * sequence_sim + + 0.2 * line_proximity + + 0.1 * (1 if category_match else 0) + ) + + return similarity + + def _extract_core_keywords(self, text: str) -> List[str]: + """Extract core keywords from issue text for comparison.""" + keywords = [] + text_lower = text.lower() + + for keyword in self.ISSUE_KEYWORDS: + if keyword in text_lower: + keywords.append(keyword) + + # Also extract identifiers + identifiers = re.findall(r'\b([a-zA-Z_][a-zA-Z0-9_]{2,})\b', text) + keywords.extend([id.lower() for id in identifiers[:5]]) + + return keywords + + def _merge_issue_group(self, issues: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Merge a group of similar issues into one. + + Strategy: + - Keep the issue with the best suggested fix + - Combine reasons if they provide different insights + - Keep the highest severity + """ + if not issues: + return {} + + if len(issues) == 1: + return issues[0] + + # Find issue with best fix (longest valid diff) + best_issue = max( + issues, + key=lambda i: len(i.get('suggestedFixDiff', '') or '') + if self._is_valid_diff(i.get('suggestedFixDiff')) + else 0 + ) + + # Determine highest severity + severity_order = {'HIGH': 3, 'MEDIUM': 2, 'LOW': 1} + highest_severity = max( + issues, + key=lambda i: severity_order.get(i.get('severity', 'LOW'), 0) + ).get('severity', 'MEDIUM') + + # Combine reasons if different + unique_insights = set() + for issue in issues: + reason = issue.get('reason', '') + # Extract the core insight (first sentence or 100 chars) + core = reason.split('.')[0][:100].strip() + if core: + unique_insights.add(core) + + combined_reason = best_issue.get('reason', '') + if len(unique_insights) > 1: + # Multiple unique insights - add a note + combined_reason = f"{combined_reason}\n\nNote: {len(issues)} similar instances of this issue were found." + + # Use the first (or lowest) line number + try: + line_numbers = [int(i.get('line', 0)) for i in issues if i.get('line')] + first_line = min(line_numbers) if line_numbers else 0 + except (ValueError, TypeError): + first_line = best_issue.get('line', 0) + + merged = best_issue.copy() + merged['severity'] = highest_severity + merged['reason'] = combined_reason + merged['line'] = str(first_line) + merged['_merged_count'] = len(issues) + + return merged + + def _is_valid_diff(self, diff: Optional[str]) -> bool: + """Check if a diff looks valid.""" + if not diff or not isinstance(diff, str): + return False + if diff == "No suggested fix provided": + return False + if len(diff.strip()) < 10: + return False + # Must have diff markers + return any(marker in diff for marker in ['---', '+++', '@@', '\n-', '\n+']) + + def _clean_fix_suggestion(self, issue: Dict[str, Any]) -> Dict[str, Any]: + """ + Clean and validate the suggested fix. + """ + issue = issue.copy() + + diff = issue.get('suggestedFixDiff', '') + + if not self._is_valid_diff(diff): + # Mark as needing fix but don't remove + issue['_needs_fix_review'] = True + else: + # Validate diff format + cleaned_diff = self._clean_diff_format(diff) + if cleaned_diff != diff: + issue['suggestedFixDiff'] = cleaned_diff + + return issue + + def _clean_diff_format(self, diff: str) -> str: + """ + Clean up diff format issues. + """ + if not diff: + return diff + + lines = diff.split('\n') + cleaned_lines = [] + + for line in lines: + # Remove markdown code blocks + if line.strip() in ['```', '```diff']: + continue + cleaned_lines.append(line) + + return '\n'.join(cleaned_lines) + + +class IssueDeduplicator: + """ + Specialized class for issue deduplication using multiple strategies. + """ + + def __init__(self): + self.processor = IssuePostProcessor() + + def find_duplicates( + self, + issues: List[Dict[str, Any]] + ) -> List[Tuple[int, int, float]]: + """ + Find pairs of duplicate issues. + + Returns: + List of (index1, index2, similarity) tuples + """ + duplicates = [] + + for i, issue1 in enumerate(issues): + for j, issue2 in enumerate(issues[i+1:], i+1): + similarity = self.processor._calculate_issue_similarity(issue1, issue2) + if similarity >= IssuePostProcessor.SIMILARITY_THRESHOLD: + duplicates.append((i, j, similarity)) + + return duplicates + + def auto_merge(self, issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Automatically merge duplicate issues. + """ + return self.processor._merge_duplicate_issues(issues) + + +def restore_missing_diffs_from_previous( + issues: List[Dict[str, Any]], + previous_issues: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """ + For branch reconciliation: restore missing suggestedFixDiff from previous issues. + + LLMs often omit the diff when reporting persisting issues. This function + looks up the original issue by issueId and copies the diff if missing. + + Args: + issues: Issues from LLM response (may have missing diffs) + previous_issues: Original previous issues with diffs + + Returns: + Issues with diffs restored from previous issues where missing + """ + if not previous_issues: + return issues + + # Build lookup by ID (handle both string and int IDs) + previous_by_id: Dict[str, Dict[str, Any]] = {} + for prev in previous_issues: + issue_id = prev.get('id') or prev.get('issueId') + if issue_id is not None: + previous_by_id[str(issue_id)] = prev + + restored_issues = [] + restored_count = 0 + + for issue in issues: + issue = issue.copy() + issue_id = issue.get('issueId') or issue.get('id') + is_resolved = issue.get('isResolved', False) + + # Only restore for unresolved issues + if issue_id and not is_resolved: + original = previous_by_id.get(str(issue_id)) + if original: + # Restore suggestedFixDiff if missing or empty + current_diff = issue.get('suggestedFixDiff', '') + if not current_diff or current_diff == 'No suggested fix provided' or len(current_diff.strip()) < 10: + original_diff = original.get('suggestedFixDiff', '') + if original_diff and original_diff != 'No suggested fix provided': + issue['suggestedFixDiff'] = original_diff + issue['_diff_restored'] = True + restored_count += 1 + logger.debug(f"Restored diff for issue {issue_id}") + + # Restore suggestedFixDescription if missing + current_desc = issue.get('suggestedFixDescription', '') + if not current_desc or current_desc == 'No suggested fix description provided': + original_desc = original.get('suggestedFixDescription', '') + if original_desc and original_desc != 'No suggested fix description provided': + issue['suggestedFixDescription'] = original_desc + + restored_issues.append(issue) + + if restored_count > 0: + logger.info(f"Restored diffs for {restored_count} persisting issues from previous analysis") + + return restored_issues + + +def post_process_analysis_result( + result: Dict[str, Any], + diff_content: Optional[str] = None, + file_contents: Optional[Dict[str, str]] = None, + previous_issues: Optional[List[Dict[str, Any]]] = None +) -> Dict[str, Any]: + """ + Convenience function to post-process an analysis result. + + Args: + result: Analysis result dict with 'comment' and 'issues' + diff_content: Optional diff for line validation + file_contents: Optional file contents for line validation + previous_issues: Optional previous issues for branch reconciliation (to restore missing diffs) + + Returns: + Processed result with cleaned issues + """ + if 'issues' not in result: + return result + + issues = result.get('issues', []) + + # Step 0: For branch reconciliation, restore missing diffs from previous issues + if previous_issues: + issues = restore_missing_diffs_from_previous(issues, previous_issues) + + processor = IssuePostProcessor(diff_content, file_contents) + processed_issues = processor.process_issues(issues) + + return { + **result, + 'issues': processed_issues, + '_post_processed': True, + '_original_issue_count': len(result.get('issues', [])), + '_final_issue_count': len(processed_issues) + } diff --git a/python-ecosystem/mcp-client/service/pooled_review_service.py b/python-ecosystem/mcp-client/service/pooled_review_service.py index 0979edb1..25d03e8b 100644 --- a/python-ecosystem/mcp-client/service/pooled_review_service.py +++ b/python-ecosystem/mcp-client/service/pooled_review_service.py @@ -53,7 +53,7 @@ def __init__(self): load_dotenv() self.default_jar_path = os.environ.get( "MCP_SERVER_JAR", - "/app/codecrow-mcp-servers-1.0.jar" + "/app/codecrow-vcs-mcp-1.0.jar" ) self.rag_client = RagClient() self._pool: Optional[McpProcessPool] = None diff --git a/python-ecosystem/mcp-client/service/rag_client.py b/python-ecosystem/mcp-client/service/rag_client.py index 489f6087..d5d4e978 100644 --- a/python-ecosystem/mcp-client/service/rag_client.py +++ b/python-ecosystem/mcp-client/service/rag_client.py @@ -38,7 +38,7 @@ async def get_pr_context( self, workspace: str, project: str, - branch: str, + branch: Optional[str], changed_files: List[str], diff_snippets: Optional[List[str]] = None, pr_title: Optional[str] = None, @@ -53,7 +53,7 @@ async def get_pr_context( Args: workspace: Workspace identifier project: Project identifier - branch: Branch name (typically target branch) + branch: Branch name (typically target branch) - required for RAG query changed_files: List of files changed in the PR diff_snippets: Code snippets extracted from diff for semantic search pr_title: PR title for semantic understanding @@ -69,6 +69,11 @@ async def get_pr_context( logger.debug("RAG disabled, returning empty context") return {"context": {"relevant_code": []}} + # Branch is required for RAG query + if not branch: + logger.warning("Branch not specified for RAG query, skipping") + return {"context": {"relevant_code": []}} + # Apply defaults from env vars if top_k is None: top_k = RAG_DEFAULT_TOP_K diff --git a/python-ecosystem/mcp-client/service/review_service.py b/python-ecosystem/mcp-client/service/review_service.py index ea583c5e..baeef56a 100644 --- a/python-ecosystem/mcp-client/service/review_service.py +++ b/python-ecosystem/mcp-client/service/review_service.py @@ -7,13 +7,14 @@ from mcp_use import MCPAgent, MCPClient from langchain_core.agents import AgentAction -from model.models import ReviewRequestDto, CodeReviewOutput, CodeReviewIssue +from model.models import ReviewRequestDto, CodeReviewOutput, CodeReviewIssue, AnalysisMode from utils.mcp_config import MCPConfigBuilder from llm.llm_factory import LLMFactory from utils.prompt_builder import PromptBuilder from utils.response_parser import ResponseParser from service.rag_client import RagClient, RAG_MIN_RELEVANCE_SCORE, RAG_DEFAULT_TOP_K from service.llm_reranker import LLMReranker +from service.issue_post_processor import IssuePostProcessor, post_process_analysis_result from utils.context_builder import ( ContextBuilder, ContextBudget, RagReranker, RAGMetrics, SmartChunker, get_rag_cache @@ -21,6 +22,7 @@ from utils.file_classifier import FileClassifier, FilePriority from utils.prompt_logger import PromptLogger from utils.diff_processor import DiffProcessor, ProcessedDiff, format_diff_for_prompt +from utils.error_sanitizer import sanitize_error_for_display, create_user_friendly_error logger = logging.getLogger(__name__) @@ -37,8 +39,8 @@ def __init__(self): load_dotenv() self.default_jar_path = os.environ.get( "MCP_SERVER_JAR", - #"/var/www/html/codecrow/codecrow-public/java-ecosystem/mcp-servers/bitbucket-mcp/target/codecrow-mcp-servers-1.0.jar", - "/app/codecrow-mcp-servers-1.0.jar" + #"/var/www/html/codecrow/codecrow-public/java-ecosystem/mcp-servers/vcs-mcp/target/codecrow-vcs-mcp-1.0.jar", + "/app/codecrow-vcs-mcp-1.0.jar" ) self.rag_client = RagClient() self.rag_cache = get_rag_cache() @@ -198,6 +200,37 @@ async def _process_review( request=request ) + # Post-process issues to fix line numbers and merge duplicates + if result and 'issues' in result: + self._emit_event(event_callback, { + "type": "status", + "state": "post_processing", + "message": "Post-processing issues (fixing line numbers, merging duplicates)..." + }) + + # Get diff content for line validation + diff_content = request.rawDiff if has_raw_diff else None + + # For branch reconciliation, pass previous issues to restore missing diffs + previous_issues = None + if request.previousCodeAnalysisIssues: + previous_issues = [ + issue.model_dump() if hasattr(issue, 'model_dump') else issue + for issue in request.previousCodeAnalysisIssues + ] + + result = post_process_analysis_result( + result, + diff_content=diff_content, + previous_issues=previous_issues + ) + + original_count = result.get('_original_issue_count', len(result.get('issues', []))) + final_count = result.get('_final_issue_count', len(result.get('issues', []))) + + if original_count != final_count: + logger.info(f"Post-processing: {original_count} issues -> {final_count} issues (merged duplicates)") + self._emit_event(event_callback, { "type": "status", "state": "completed", @@ -207,12 +240,16 @@ async def _process_review( return {"result": result} except Exception as e: + # Log full error for debugging, but sanitize for user display + logger.error(f"Review processing failed: {str(e)}", exc_info=True) + sanitized_message = create_user_friendly_error(e) + error_response = ResponseParser.create_error_response( - f"Agent execution failed", str(e) + f"Agent execution failed", sanitized_message ) self._emit_event(event_callback, { "type": "error", - "message": str(e) + "message": sanitized_message }) return {"result": error_response} @@ -373,9 +410,13 @@ async def _execute_review_with_streaming( ) except Exception as e: + # Log full error for debugging, sanitize for user display + logger.error(f"Agent execution error: {str(e)}", exc_info=True) + sanitized_message = create_user_friendly_error(e) + self._emit_event(event_callback, { "type": "error", - "message": f"Agent execution error: {str(e)}" + "message": sanitized_message }) raise @@ -551,10 +592,23 @@ def _build_prompt_with_diff( This prompt includes the actual diff content so agent doesn't need to call getPullRequestDiff, but still has access to all other MCP tools. + + Supports three modes: + - FULL analysis (first review): Standard first review prompt + - Previous analysis (subsequent full review): Review with previous issues + - INCREMENTAL analysis: Focus on delta diff since last analyzed commit """ analysis_type = request.analysisType + analysis_mode_str = request.analysisMode or "FULL" + # Convert string to AnalysisMode enum for comparison + try: + analysis_mode = AnalysisMode(analysis_mode_str) + except ValueError: + logger.warning(f"Unknown analysis mode '{analysis_mode_str}', defaulting to FULL") + analysis_mode = AnalysisMode.FULL has_previous_analysis = bool(request.previousCodeAnalysisIssues) - + has_delta_diff = bool(request.deltaDiff) + if analysis_type is not None and analysis_type == "BRANCH_ANALYSIS": return PromptBuilder.build_branch_review_prompt_with_branch_issues_data(pr_metadata) @@ -597,15 +651,38 @@ def _build_prompt_with_diff( logger.warning(f"Failed to build structured context: {e}") structured_context = None - # Format diff for prompt + # Format full diff for prompt formatted_diff = format_diff_for_prompt( processed_diff, include_stats=True, max_chars=None # Let token budget handle truncation ) - # Build the prompt using PromptBuilder with embedded diff - if has_previous_analysis: + # Check if we should use INCREMENTAL mode with delta diff + is_incremental = ( + analysis_mode == AnalysisMode.INCREMENTAL + and has_delta_diff + and has_previous_analysis + ) + + if is_incremental: + # INCREMENTAL mode: focus on delta diff + logger.info(f"Using INCREMENTAL analysis mode with delta diff") + + # Add commit hashes to pr_metadata for the prompt + pr_metadata["previousCommitHash"] = request.previousCommitHash + pr_metadata["currentCommitHash"] = request.currentCommitHash + + prompt = PromptBuilder.build_incremental_review_prompt( + pr_metadata=pr_metadata, + delta_diff_content=request.deltaDiff, + full_diff_content=formatted_diff, + rag_context=rag_context, + structured_context=structured_context + ) + elif has_previous_analysis: + # FULL mode with previous analysis + logger.info(f"Using FULL analysis mode with previous analysis data") prompt = PromptBuilder.build_direct_review_prompt_with_previous_analysis( pr_metadata=pr_metadata, diff_content=formatted_diff, @@ -613,6 +690,8 @@ def _build_prompt_with_diff( structured_context=structured_context ) else: + # FULL mode - first review + logger.info(f"Using FULL analysis mode (first review)") prompt = PromptBuilder.build_direct_first_review_prompt( pr_metadata=pr_metadata, diff_content=formatted_diff, @@ -627,10 +706,15 @@ def _build_prompt_with_diff( "pr_id": request.pullRequestId, "model": request.aiModel, "provider": request.aiProvider, - "mode": "direct", + "mode": "incremental" if is_incremental else "direct", + "analysis_mode": str(analysis_mode) if analysis_mode else "FULL", "has_previous_analysis": has_previous_analysis, + "has_delta_diff": has_delta_diff, + "previous_commit_hash": request.previousCommitHash, + "current_commit_hash": request.currentCommitHash, "changed_files_count": processed_diff.total_files, "diff_size_bytes": processed_diff.processed_size_bytes, + "delta_diff_size_bytes": len(request.deltaDiff) if request.deltaDiff else 0, "rag_chunks_count": len(rag_context.get("relevant_code", [])) if rag_context else 0, } @@ -676,6 +760,14 @@ async def _fetch_rag_context( start_time = datetime.now() cache_hit = False + # Determine branch for RAG query + # For PR analysis: use target branch (where code will be merged) + # For branch analysis: targetBranchName is set to the analyzed branch + rag_branch = request.targetBranchName + if not rag_branch: + logger.warning("No target branch specified for RAG query, skipping RAG context") + return None + try: self._emit_event(event_callback, { "type": "status", @@ -691,7 +783,7 @@ async def _fetch_rag_context( cached_result = self.rag_cache.get( workspace=request.projectWorkspace, project=request.projectNamespace, - branch=request.targetBranchName, + branch=rag_branch, changed_files=changed_files, pr_title=request.prTitle or "", pr_description=request.prDescription or "" @@ -721,7 +813,7 @@ async def _fetch_rag_context( rag_response = await self.rag_client.get_pr_context( workspace=request.projectWorkspace, project=request.projectNamespace, - branch=request.targetBranchName, + branch=rag_branch, changed_files=changed_files, diff_snippets=diff_snippets, pr_title=request.prTitle, @@ -748,7 +840,7 @@ async def _fetch_rag_context( self.rag_cache.set( workspace=request.projectWorkspace, project=request.projectNamespace, - branch=request.targetBranchName, + branch=rag_branch, changed_files=changed_files, result=context, pr_title=request.prTitle or "", diff --git a/python-ecosystem/mcp-client/utils/__pycache__/prompt_builder.cpython-313.pyc b/python-ecosystem/mcp-client/utils/__pycache__/prompt_builder.cpython-313.pyc deleted file mode 100644 index e519924d..00000000 Binary files a/python-ecosystem/mcp-client/utils/__pycache__/prompt_builder.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/utils/__pycache__/prompt_logger.cpython-313.pyc b/python-ecosystem/mcp-client/utils/__pycache__/prompt_logger.cpython-313.pyc deleted file mode 100644 index 572e0361..00000000 Binary files a/python-ecosystem/mcp-client/utils/__pycache__/prompt_logger.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/utils/__pycache__/response_parser.cpython-313.pyc b/python-ecosystem/mcp-client/utils/__pycache__/response_parser.cpython-313.pyc deleted file mode 100644 index ccb13759..00000000 Binary files a/python-ecosystem/mcp-client/utils/__pycache__/response_parser.cpython-313.pyc and /dev/null differ diff --git a/python-ecosystem/mcp-client/utils/context_builder.py b/python-ecosystem/mcp-client/utils/context_builder.py index 9601f40a..b2f996b9 100644 --- a/python-ecosystem/mcp-client/utils/context_builder.py +++ b/python-ecosystem/mcp-client/utils/context_builder.py @@ -83,6 +83,8 @@ "google/gemini-3-flash": 1000000, "google/gemini-2.5-pro": 2000000, "google/gemini-2.5-flash": 1000000, + "google/gemini-3-flash-preview": 1000000, + "google/gemini-3-pro-preview": 1000000, "llama-4-405b": 128000, "llama-4-70b": 128000, diff --git a/python-ecosystem/mcp-client/utils/error_sanitizer.py b/python-ecosystem/mcp-client/utils/error_sanitizer.py new file mode 100644 index 00000000..e3f78ad6 --- /dev/null +++ b/python-ecosystem/mcp-client/utils/error_sanitizer.py @@ -0,0 +1,161 @@ +""" +Utility for sanitizing error messages before displaying to users. +Removes sensitive technical details like API keys, quotas, and internal stack traces. +""" + +import re +import logging + +logger = logging.getLogger(__name__) + + +def sanitize_error_for_display(error_message: str) -> str: + """ + Sanitize error messages for user display. + Removes sensitive technical details and provides user-friendly messages. + + Args: + error_message: The raw error message + + Returns: + A sanitized, user-friendly error message + """ + if not error_message: + return "An unexpected error occurred during processing." + + error_lower = error_message.lower() + + # AI provider quota/rate limit errors + if any(term in error_lower for term in ["quota", "rate limit", "rate_limit", "429", "exceeded", "too many requests"]): + return ( + "The AI provider is currently rate-limited or quota has been exceeded. " + "Please try again later or contact your administrator to check the AI connection settings." + ) + + # Authentication/API key errors + if any(term in error_lower for term in ["401", "403", "unauthorized", "authentication", + "api key", "apikey", "invalid_api_key", "invalid key"]): + return ( + "AI provider authentication failed. " + "Please contact your administrator to verify the AI connection configuration." + ) + + # Model not found/invalid + if "model" in error_lower and any(term in error_lower for term in ["not found", "invalid", "does not exist", "unavailable"]): + return ( + "The configured AI model is not available. " + "Please contact your administrator to update the AI connection settings." + ) + + # Unsupported model errors (from LLMFactory) + if "unsupported" in error_lower and "model" in error_lower: + # Extract the alternative model suggestion if present + if "instead, such as" in error_lower: + try: + # Try to extract the suggested alternative + match = re.search(r"such as ['\"]?([^'\"]+)['\"]?", error_message, re.IGNORECASE) + if match: + alternative = match.group(1).strip().rstrip(".") + return ( + f"The selected AI model is not supported for this operation. " + f"Please use an alternative model such as '{alternative}'." + ) + except Exception: + pass + return ( + "The selected AI model is not supported for this operation. " + "Please contact your administrator to select a compatible model." + ) + + # Token limit errors + if "token" in error_lower and any(term in error_lower for term in ["limit", "too long", "maximum", "exceeded", "context"]): + return ( + "The PR content exceeds the AI model's token limit. " + "Consider breaking down large PRs or adjusting the token limitation setting." + ) + + # Network/connectivity errors + if any(term in error_lower for term in ["connection", "timeout", "network", "unreachable", + "connection refused", "connection reset"]): + return ( + "Failed to connect to the AI provider. " + "Please try again later." + ) + + # Content filter/safety errors + if any(term in error_lower for term in ["content filter", "safety", "blocked", "harmful", "policy"]): + return ( + "The AI provider's content filter blocked this request. " + "Please review the PR content or try a different model." + ) + + # Thought signature errors (Gemini thinking models) + if "thought_signature" in error_lower or "thinking" in error_lower: + return ( + "This AI model is not compatible with CodeCrow's tool calling feature. " + "Please use a non-thinking model variant (e.g., gemini-2.5-flash instead of gemini-2.5-pro)." + ) + + # MCP/tool errors + if any(term in error_lower for term in ["mcp", "tool call", "tool_call"]): + return ( + "An error occurred while executing analysis tools. " + "Please try again or contact your administrator." + ) + + # Generic AI service errors - don't expose internal details + if any(term in error_lower for term in ["ai service", "ai failed", "generation failed", + "llm", "langchain", "openai", "anthropic", "gemini"]): + return ( + "The AI service encountered an error while processing your request. " + "Please try again later." + ) + + # Check for stack traces or technical details + if any(term in error_message for term in ["Exception", "Traceback", "at org.", "at com.", + "File \"", "line ", " at "]): + return ( + "An internal error occurred while processing your request. " + "Please check the job logs for more details." + ) + + # Check for JSON/technical error structures + if error_message.startswith("{") or error_message.startswith("["): + return ( + "An error occurred while processing your request. " + "Please check the job logs for more details." + ) + + # If message is very long, truncate it + if len(error_message) > 200: + return ( + "An error occurred while processing your request. " + "Please check the job logs for more details." + ) + + # If it looks safe, return a cleaned version + # Remove any potential API keys or tokens + sanitized = re.sub(r'sk-[a-zA-Z0-9]{20,}', '[API_KEY_REDACTED]', error_message) + sanitized = re.sub(r'api[_-]?key["\s:=]+["\']?[a-zA-Z0-9-_]+["\']?', '[API_KEY_REDACTED]', sanitized, flags=re.IGNORECASE) + + return sanitized + + +def create_user_friendly_error(error: Exception) -> str: + """ + Create a user-friendly error message from an exception. + + Args: + error: The exception + + Returns: + A sanitized, user-friendly error message + """ + error_str = str(error) + error_type = type(error).__name__ + + # Log the full error for debugging + logger.error(f"Error ({error_type}): {error_str}") + + # Return sanitized message + return sanitize_error_for_display(error_str) diff --git a/python-ecosystem/mcp-client/utils/mcp_pool.py b/python-ecosystem/mcp-client/utils/mcp_pool.py index 96479701..c9c5876b 100644 --- a/python-ecosystem/mcp-client/utils/mcp_pool.py +++ b/python-ecosystem/mcp-client/utils/mcp_pool.py @@ -293,7 +293,7 @@ async def get_mcp_pool(jar_path: str = None) -> McpProcessPool: if jar_path is None: jar_path = os.environ.get( "MCP_SERVER_JAR", - "/app/codecrow-mcp-servers-1.0.jar" + "/app/codecrow-vcs-mcp-1.0.jar" ) _global_pool = McpProcessPool(jar_path) diff --git a/python-ecosystem/mcp-client/utils/prompt_builder.py b/python-ecosystem/mcp-client/utils/prompt_builder.py index 820dc94f..724829eb 100644 --- a/python-ecosystem/mcp-client/utils/prompt_builder.py +++ b/python-ecosystem/mcp-client/utils/prompt_builder.py @@ -6,7 +6,7 @@ ISSUE_CATEGORIES = """ Available issue categories (use EXACTLY one of these values): - SECURITY: Security vulnerabilities, injection risks, authentication issues -- PERFORMANCE: Performance bottlenecks, inefficient algorithms, resource leaks +- PERFORMANCE: Performance bottlenecks, inefficient algorithms, resource leaks - CODE_QUALITY: Code smells, maintainability issues, complexity problems - BUG_RISK: Potential bugs, edge cases, null pointer risks - STYLE: Code style, formatting, naming conventions @@ -17,6 +17,66 @@ - ARCHITECTURE: Design issues, coupling problems, SOLID violations """ +# Enhanced line number calculation instructions +LINE_NUMBER_INSTRUCTIONS = """ +⚠️ CRITICAL LINE NUMBER CALCULATION: +The "line" field MUST contain the EXACT line number where the issue occurs in the NEW version of the file. + +HOW TO CALCULATE LINE NUMBERS FROM UNIFIED DIFF: +1. Look at the hunk header: @@ -OLD_START,OLD_COUNT +NEW_START,NEW_COUNT @@ +2. Start counting from NEW_START (the number after the +) +3. For EACH line in the hunk: + - Lines starting with '+' (additions): Count them, they ARE in the new file + - Lines starting with ' ' (context): Count them, they ARE in the new file + - Lines starting with '-' (deletions): DO NOT count them, they are NOT in the new file +4. The issue line = NEW_START + (position in hunk, counting only '+' and ' ' lines) + +EXAMPLE: +@@ -10,5 +10,6 @@ + context line <- Line 10 in new file + context line <- Line 11 in new file +-deleted line <- NOT in new file (don't count) ++added line <- Line 12 in new file (issue might be here!) + context line <- Line 13 in new file + +If the issue is on the "added line", report line: "12" (not 14!) + +VALIDATION: Before reporting a line number, verify: +- Is this line actually in the NEW file version? +- Does the line content match what you're describing in the issue? +""" + +# Issue deduplication instructions +ISSUE_DEDUPLICATION_INSTRUCTIONS = """ +⚠️ CRITICAL: AVOID DUPLICATE ISSUES + +Before reporting an issue, check if you've already reported the SAME root cause: + +MERGE THESE INTO ONE ISSUE: +- Multiple instances of the same hardcoded value (e.g., store ID '6' in 3 places) +- Same security vulnerability pattern repeated in different methods +- Same missing validation across multiple endpoints +- Same deprecated API usage in multiple files + +HOW TO REPORT GROUPED ISSUES: +1. Report ONE issue for the root cause +2. In the "reason" field, mention: "Found in X locations: [list files/lines]" +3. Use the FIRST occurrence's line number +4. In suggestedFixDiff, show the fix for ONE location as example + +EXAMPLE - WRONG (duplicate issues): +Issue 1: "Hardcoded store ID '6' in getRewriteUrl()" +Issue 2: "Hardcoded store ID '6' in processUrl()" +Issue 3: "Store ID 6 is hardcoded" + +EXAMPLE - CORRECT (merged into one): +Issue 1: "Hardcoded store ID '6' prevents multi-store compatibility. Found in 3 locations: + - Model/UrlProcessor.php:45 (getRewriteUrl) + - Model/UrlProcessor.php:89 (processUrl) + - Helper/Data.php:23 + Recommended: Use configuration or store manager to get store ID dynamically." +""" + # Instructions for suggestedFixDiff format SUGGESTED_FIX_DIFF_FORMAT = """ 📝 SUGGESTED FIX DIFF FORMAT: @@ -36,10 +96,11 @@ 1. Include file path headers: `--- a/file` and `+++ b/file` 2. Include hunk header: `@@ -old_start,old_count +new_start,new_count @@` 3. Prefix removed lines with `-` (minus) -4. Prefix added lines with `+` (plus) +4. Prefix added lines with `+` (plus) 5. Prefix context lines with ` ` (single space) 6. Include 1-3 context lines before/after changes 7. Use actual file path from the issue +8. The line numbers in @@ must match the ACTUAL lines in the file EXAMPLE: "suggestedFixDiff": "--- a/src/UserService.java\\n+++ b/src/UserService.java\\n@@ -45,3 +45,4 @@\\n public User findById(Long id) {\\n- return repo.findById(id);\\n+ return repo.findById(id)\\n+ .orElseThrow(() -> new NotFoundException());\\n }" @@ -54,10 +115,10 @@ The context below is STRUCTURED BY PRIORITY. Follow this analysis order STRICTLY: 📋 ANALYSIS PRIORITY ORDER (MANDATORY): -1️⃣ HIGH PRIORITY (60% attention): Core business logic, security, auth - analyze FIRST +1️⃣ HIGH PRIORITY (50% attention): Core business logic, security, auth - analyze FIRST 2️⃣ MEDIUM PRIORITY (25% attention): Dependencies, shared utils, models 3️⃣ LOW PRIORITY (10% attention): Tests, configs - quick scan only -4️⃣ RAG CONTEXT (5% attention): Additional context from codebase +4️⃣ RAG CONTEXT (15% attention): Additional context from codebase 🎯 FOCUS HIERARCHY: - Security issues > Architecture problems > Performance > Code quality > Style @@ -74,7 +135,7 @@ class PromptBuilder: @staticmethod def build_first_review_prompt( - pr_metadata: Dict[str, Any], + pr_metadata: Dict[str, Any], rag_context: Dict[str, Any] = None, structured_context: Optional[str] = None ) -> str: @@ -87,7 +148,7 @@ def build_first_review_prompt( rag_section = "" if not structured_context and rag_context and rag_context.get("relevant_code"): rag_section = PromptBuilder._build_legacy_rag_section(rag_context) - + # Use structured context if provided (new Lost-in-Middle protected format) context_section = "" if structured_context: @@ -121,6 +182,8 @@ def build_first_review_prompt( {ISSUE_CATEGORIES} +{ISSUE_DEDUPLICATION_INSTRUCTIONS} + EFFICIENCY INSTRUCTIONS (YOU HAVE LIMITED STEPS - MAX 120): 1. First, retrieve the PR diff using getPullRequestDiff tool 2. Analyze the diff content directly - do NOT fetch each file individually unless absolutely necessary @@ -137,17 +200,9 @@ def build_first_review_prompt( 1. Fetch files one by one when the diff already shows the changes 2. Make more than 10-15 tool calls total 3. Continue making tool calls indefinitely +4. Report the SAME root cause as multiple separate issues -CRITICAL INSTRUCTION FOR LARGE PRs: -Report ALL issues found. Do not group them or omit them for brevity. If you find many issues, report ALL of them. The user wants a comprehensive list, no matter how long the output is. - - -IMPORTANT LINE NUMBER INSTRUCTIONS: -The "line" field MUST contain the line number in the NEW version of the file (after changes). -When reading unified diff format, use the line number from the '+' side of hunk headers: @@ -old_start,old_count +NEW_START,new_count @@ -Calculate the actual line number by: NEW_START + offset within the hunk (counting only context and added lines, not removed lines). -For added lines (+), count from NEW_START. For context lines (no prefix), also count from NEW_START. -If you retrieve the full source file content, use the line number as it appears in that file. +{LINE_NUMBER_INSTRUCTIONS} {SUGGESTED_FIX_DIFF_FORMAT} @@ -173,7 +228,7 @@ def build_first_review_prompt( - Do NOT include any "id" field in issues - it will be assigned by the system - Each issue MUST have: severity, category, file, line, reason, isResolved - REQUIRED FOR ALL ISSUES: Include "suggestedFixDescription" AND "suggestedFixDiff" with actual code fix in unified diff format -- The suggestedFixDiff must show the exact code change to fix the issue - this is MANDATORY, not optional +- The suggestedFixDiff must show the exact code change to fix the issue If no issues are found, return: {{ @@ -187,7 +242,7 @@ def build_first_review_prompt( @staticmethod def build_review_prompt_with_previous_analysis_data( - pr_metadata: Dict[str, Any], + pr_metadata: Dict[str, Any], rag_context: Dict[str, Any] = None, structured_context: Optional[str] = None ) -> str: @@ -205,7 +260,7 @@ def build_review_prompt_with_previous_analysis_data( rag_section = "" if not structured_context and rag_context and rag_context.get("relevant_code"): rag_section = PromptBuilder._build_legacy_rag_section(rag_context) - + # Use structured context if provided (new Lost-in-Middle protected format) context_section = "" if structured_context: @@ -247,6 +302,8 @@ def build_review_prompt_with_previous_analysis_data( {ISSUE_CATEGORIES} +{ISSUE_DEDUPLICATION_INSTRUCTIONS} + EFFICIENCY INSTRUCTIONS (YOU HAVE LIMITED STEPS - MAX 120): 1. First, retrieve the PR diff using getPullRequestDiff tool 2. Analyze the diff content directly - do NOT fetch each file individually unless absolutely necessary @@ -263,17 +320,12 @@ def build_review_prompt_with_previous_analysis_data( 1. Fetch files one by one when the diff already shows the changes 2. Make more than 10-15 tool calls total 3. Continue making tool calls indefinitely +4. Report the SAME root cause as multiple separate issues CRITICAL INSTRUCTION FOR LARGE PRs: -Report ALL issues found. Do not group them or omit them for brevity. If you find many issues, report ALL of them. The user wants a comprehensive list, no matter how long the output is. +Report ALL UNIQUE issues found. Merge similar issues (same root cause) into one. - -IMPORTANT LINE NUMBER INSTRUCTIONS: -The "line" field MUST contain the line number in the NEW version of the file (after changes). -When reading unified diff format, use the line number from the '+' side of hunk headers: @@ -old_start,old_count +NEW_START,new_count @@ -Calculate the actual line number by: NEW_START + offset within the hunk (counting only context and added lines, not removed lines). -For added lines (+), count from NEW_START. For context lines (no prefix), also count from NEW_START. -If you retrieve the full source file content, use the line number as it appears in that file. +{LINE_NUMBER_INSTRUCTIONS} {SUGGESTED_FIX_DIFF_FORMAT} @@ -289,7 +341,7 @@ def build_review_prompt_with_previous_analysis_data( "reason": "Detailed explanation of the issue", "suggestedFixDescription": "Clear description of how to fix the issue", "suggestedFixDiff": "Unified diff showing exact code changes (MUST follow SUGGESTED_FIX_DIFF_FORMAT above)", - "isResolved": false + "isResolved": false|true }} ] }} @@ -299,7 +351,7 @@ def build_review_prompt_with_previous_analysis_data( - Do NOT include any "id" field in issues - it will be assigned by the system - Each issue MUST have: severity, category, file, line, reason, isResolved - REQUIRED FOR ALL ISSUES: Include "suggestedFixDescription" AND "suggestedFixDiff" with actual code fix in unified diff format -- The suggestedFixDiff must show the exact code change to fix the issue - this is MANDATORY, not optional +- The suggestedFixDiff must show the exact code change to fix the issue If no issues are found, return: {{ @@ -358,7 +410,21 @@ def build_branch_review_prompt_with_branch_issues_data(pr_metadata: Dict[str, An - "isResolved": false (if the issue still persists) - "reason": "Explanation of why it's resolved or still present" 4. DO NOT report new issues - this is ONLY for checking resolution status of existing issues. -5. You MUST retrieve the current PR diff using MCP tools to compare against the previous issues ( e.g. via getBranchFileContent tool ). +5. You MUST retrieve the current file content using MCP tools to compare against the previous issues (e.g. via getBranchFileContent tool). +6. If you see similar errors, you MUST group them together. Set the duplicate to isResolved: true, and leave one of the errors in its original status. + +⚠️ CRITICAL FOR PERSISTING (UNRESOLVED) ISSUES: +When an issue PERSISTS (isResolved: false), you MUST: +1. COPY the "suggestedFixDiff" field EXACTLY from the original previous issue - DO NOT omit it +2. COPY the "suggestedFixDescription" field EXACTLY from the original previous issue +3. Keep the same severity and category +4. Only update the "reason" field to explain why it still persists +5. Update the "line" field if the line number changed due to other code changes + +Example for PERSISTING issue: +Previous issue had: {{"id": "123", "suggestedFixDiff": "--- a/file.py\\n+++ b/file.py\\n..."}} +Your response MUST include: {{"issueId": "123", "isResolved": false, "suggestedFixDiff": "--- a/file.py\\n+++ b/file.py\\n...", ...}} + --- PREVIOUS ANALYSIS ISSUES --- {previous_issues_json} @@ -374,7 +440,6 @@ def build_branch_review_prompt_with_branch_issues_data(pr_metadata: Dict[str, An 1. Retrieve file content for files with issues using getBranchFileContent MCP tool 2. For each previous issue, check if the current file content shows it resolved 3. STOP making tool calls and produce your final JSON response once you have analyzed all relevant files -4. If you see similar errors, you can group them together. Set the duplicate to isResolved: true, and leave one of the errors in its original status. DO NOT: 1. Report new issues - focus ONLY on the provided previous issues @@ -403,13 +468,14 @@ def build_branch_review_prompt_with_branch_issues_data(pr_metadata: Dict[str, An ] }} -IMPORTANT: +IMPORTANT: - The "issues" field MUST be a JSON array [], NOT an object with numeric keys. - You MUST include ALL previous issues in your response - Each issue MUST have the "issueId" field matching the original issue ID - Each issue MUST have "isResolved" as either true or false - Each issue MUST have a "category" field from the allowed list -- REQUIRED FOR ALL UNRESOLVED ISSUES: Include "suggestedFixDescription" AND "suggestedFixDiff" with actual code fix +- FOR UNRESOLVED ISSUES: COPY "suggestedFixDescription" AND "suggestedFixDiff" from the original issue - DO NOT OMIT THEM +- The suggestedFixDiff is MANDATORY for unresolved issues - copy it verbatim from the previous issue data Use the reportGenerator MCP tool if available to help structure this response. Do NOT include any markdown formatting, explanatory text, or other content - only the JSON object. """ @@ -439,7 +505,7 @@ def get_additional_instructions() -> str: "10. FOLLOW PRIORITY ORDER: Analyze HIGH priority sections FIRST, then MEDIUM, then LOW.\n" "11. For LARGE PRs: Focus 60% attention on HIGH priority, 25% on MEDIUM, 15% on LOW/RAG." ) - + @staticmethod def _build_legacy_rag_section(rag_context: Dict[str, Any]) -> str: """Build legacy RAG section for backward compatibility.""" @@ -450,7 +516,7 @@ def _build_legacy_rag_section(rag_context: Dict[str, Any]) -> str: rag_section += f"{chunk.get('text', '')}\n\n" rag_section += "--- END OF RELEVANT CONTEXT ---\n\n" return rag_section - + @staticmethod def build_structured_rag_section( rag_context: Dict[str, Any], @@ -459,49 +525,49 @@ def build_structured_rag_section( ) -> str: """ Build a structured RAG section with priority markers. - + Args: rag_context: RAG query results max_chunks: Maximum number of chunks to include token_budget: Approximate token budget for RAG section - + Returns: Formatted RAG section string """ if not rag_context or not rag_context.get("relevant_code"): return "" - + relevant_code = rag_context.get("relevant_code", []) related_files = rag_context.get("related_files", []) - + section_parts = [] section_parts.append("=== RAG CONTEXT: Additional Relevant Code (5% attention) ===") section_parts.append(f"Related files discovered: {len(related_files)}") section_parts.append("") - + current_tokens = 0 tokens_per_char = 0.25 - + for idx, chunk in enumerate(relevant_code[:max_chunks], 1): chunk_text = chunk.get("text", "") chunk_tokens = int(len(chunk_text) * tokens_per_char) - + if current_tokens + chunk_tokens > token_budget: section_parts.append(f"[Remaining {len(relevant_code) - idx + 1} chunks omitted for token budget]") break - + chunk_path = chunk.get("metadata", {}).get("path", "unknown") chunk_score = chunk.get("score", 0) - + section_parts.append(f"### RAG Chunk {idx}: {chunk_path}") section_parts.append(f"Relevance: {chunk_score:.3f}") section_parts.append("```") section_parts.append(chunk_text) section_parts.append("```") section_parts.append("") - + current_tokens += chunk_tokens - + section_parts.append("=== END RAG CONTEXT ===") return "\n".join(section_parts) @@ -514,7 +580,7 @@ def build_direct_first_review_prompt( ) -> str: """ Build prompt for review with embedded diff - first review. - + The diff is already embedded in the prompt. Agent still has access to other MCP tools (getFile, getComments, etc.) but should NOT call getPullRequestDiff. @@ -522,7 +588,7 @@ def build_direct_first_review_prompt( workspace = pr_metadata.get("workspace", "") repo = pr_metadata.get("repoSlug", "") pr_id = pr_metadata.get("pullRequestId", pr_metadata.get("prId", "")) - + # Build context section context_section = "" if structured_context: @@ -560,14 +626,13 @@ def build_direct_first_review_prompt( {ISSUE_CATEGORIES} -IMPORTANT LINE NUMBER INSTRUCTIONS: -The "line" field MUST contain the line number in the NEW version of the file (after changes). -When reading unified diff format, use the line number from the '+' side of hunk headers: @@ -old_start,old_count +NEW_START,new_count @@ -Calculate the actual line number by: NEW_START + offset within the hunk. +{ISSUE_DEDUPLICATION_INSTRUCTIONS} + +{LINE_NUMBER_INSTRUCTIONS} {SUGGESTED_FIX_DIFF_FORMAT} -CRITICAL: Report ALL issues found. Do not group them or omit them for brevity. +CRITICAL: Report ALL UNIQUE issues found. Merge similar issues (same root cause) into one. Your response must be ONLY a valid JSON object in this exact format: {{ @@ -613,7 +678,7 @@ def build_direct_review_prompt_with_previous_analysis( pr_id = pr_metadata.get("pullRequestId", pr_metadata.get("prId", "")) previous_issues: List[Dict[str, Any]] = pr_metadata.get("previousCodeAnalysisIssues", []) previous_issues_json = json.dumps(previous_issues, indent=2, default=str) - + # Build context section context_section = "" if structured_context: @@ -673,7 +738,7 @@ def build_direct_review_prompt_with_previous_analysis( "reason": "Explanation", "suggestedFixDescription": "Clear description of how to fix the issue", "suggestedFixDiff": "Unified diff showing exact code changes (MUST follow SUGGESTED_FIX_DIFF_FORMAT above)", - "isResolved": false + "isResolved": false|true }} ] }} @@ -681,5 +746,134 @@ def build_direct_review_prompt_with_previous_analysis( IMPORTANT: REQUIRED FOR ALL ISSUES - Include "suggestedFixDescription" AND "suggestedFixDiff" with actual code fix in unified diff format. Do NOT include any markdown formatting - only the JSON object. +""" + return prompt + + @staticmethod + def build_incremental_review_prompt( + pr_metadata: Dict[str, Any], + delta_diff_content: str, + full_diff_content: str, + rag_context: Dict[str, Any] = None, + structured_context: Optional[str] = None + ) -> str: + """ + Build prompt for INCREMENTAL analysis mode. + + This is used when re-reviewing a PR after new commits have been pushed. + The delta_diff contains only changes since the last analyzed commit, + while full_diff provides the complete PR diff for reference. + + Focus is on: + 1. Reviewing new/changed code in delta_diff + 2. Checking if previous issues are resolved + 3. Finding new issues introduced since last review + """ + print("Building INCREMENTAL review prompt with delta diff") + workspace = pr_metadata.get("workspace", "") + repo = pr_metadata.get("repoSlug", "") + pr_id = pr_metadata.get("pullRequestId", pr_metadata.get("prId", "")) + previous_commit = pr_metadata.get("previousCommitHash", "") + current_commit = pr_metadata.get("currentCommitHash", "") + previous_issues: List[Dict[str, Any]] = pr_metadata.get("previousCodeAnalysisIssues", []) + previous_issues_json = json.dumps(previous_issues, indent=2, default=str) + + # Build context section + context_section = "" + if structured_context: + context_section = f""" +{LOST_IN_MIDDLE_INSTRUCTIONS} + +{structured_context} +""" + elif rag_context and rag_context.get("relevant_code"): + context_section = PromptBuilder._build_legacy_rag_section(rag_context) + + prompt = f"""You are an expert code reviewer performing an INCREMENTAL review of changes since the last analysis. +Workspace: {workspace} +Repository slug: {repo} +Pull Request: {pr_id} + +## INCREMENTAL REVIEW MODE +This is a RE-REVIEW after new commits were pushed to the PR. +- Previous analyzed commit: {previous_commit} +- Current commit: {current_commit} + +{context_section} + +=== DELTA DIFF (CHANGES SINCE LAST REVIEW - PRIMARY FOCUS) === +IMPORTANT: This diff shows ONLY the changes made since the last analyzed commit. +Focus your review primarily on this delta diff as it contains the new code to review. + +{delta_diff_content} + +=== END OF DELTA DIFF === + +=== PREVIOUS ANALYSIS ISSUES === +These issues were found in the previous review iteration. +Check if each one has been RESOLVED in the new commits (delta diff): +{previous_issues_json} +=== END OF PREVIOUS ISSUES === + +## INCREMENTAL REVIEW TASKS (in order of priority): + +1. **DELTA DIFF ANALYSIS (80% attention)**: + - Focus on reviewing the DELTA DIFF (changes since last commit) + - Find NEW issues introduced in these changes + - These are the most important findings as they represent untested code + +2. **PREVIOUS ISSUES RESOLUTION CHECK (15% attention)**: + - Check each previous issue against the delta diff + - If the problematic code was modified/fixed in delta → mark "isResolved": true + - If the code is unchanged in delta → issue persists, report it again with "isResolved": false + - UPDATE line numbers if code has moved + +3. **CONTEXT VERIFICATION (5% attention)**: + - Use full PR diff only when needed to understand delta changes ( retrieve it via MCP tools ONLY if necessary ) + - Do NOT re-review code that hasn't changed + +{ISSUE_CATEGORIES} + +IMPORTANT LINE NUMBER INSTRUCTIONS: +The "line" field MUST contain the line number in the NEW version of the file (after changes). +For issues found in delta diff, calculate line numbers from the delta hunk headers. +For persisting issues, update line numbers if the code has moved. + +{SUGGESTED_FIX_DIFF_FORMAT} + +CRITICAL: Report ALL issues found in delta diff. Do not group them or omit them for brevity. + +Your response must be ONLY a valid JSON object in this exact format: +{{ + "comment": "Summary of incremental review: X new issues found in delta, Y previous issues resolved, Z issues persist", + "issues": [ + {{ + "issueId": "", + "severity": "HIGH|MEDIUM|LOW", + "category": "SECURITY|PERFORMANCE|CODE_QUALITY|BUG_RISK|STYLE|DOCUMENTATION|BEST_PRACTICES|ERROR_HANDLING|TESTING|ARCHITECTURE", + "file": "file-path", + "line": "line-number-in-new-file", + "reason": "Detailed explanation of the issue", + "suggestedFixDescription": "Clear description of how to fix the issue", + "suggestedFixDiff": "Unified diff showing exact code changes (MUST follow SUGGESTED_FIX_DIFF_FORMAT above)", + "isResolved": false|true + }} + ] +}} + +IMPORTANT SCHEMA RULES: +- The "issues" field MUST be a JSON array [], NOT an object with numeric keys +- Each issue MUST have: severity, category, file, line, reason, isResolved +- For resolved previous issues, still include them with "isResolved": true +- For new issues from delta diff, set "isResolved": false +- REQUIRED FOR ALL UNRESOLVED ISSUES: Include "suggestedFixDescription" AND "suggestedFixDiff" + +If no issues are found, return: +{{ + "comment": "Incremental review completed: All previous issues resolved, no new issues found", + "issues": [] +}} + +Do NOT include any markdown formatting, explanatory text, or other content - only the JSON object. """ return prompt \ No newline at end of file diff --git a/python-ecosystem/mcp-client/utils/response_parser.py b/python-ecosystem/mcp-client/utils/response_parser.py index c4c4c950..ebad474d 100644 --- a/python-ecosystem/mcp-client/utils/response_parser.py +++ b/python-ecosystem/mcp-client/utils/response_parser.py @@ -12,8 +12,8 @@ class ResponseParser: # Valid issue fields - others will be removed VALID_ISSUE_FIELDS = { - 'severity', 'category', 'file', 'line', 'reason', - 'suggestedFixDescription', 'suggestedFixDiff', 'isResolved' + 'id', 'severity', 'category', 'file', 'line', 'reason', + 'suggestedFixDescription', 'suggestedFixDiff', 'isResolved' } # Valid severity values @@ -115,6 +115,13 @@ def _clean_issue(issue: Dict[str, Any]) -> Dict[str, Any]: value = value.lower() == 'true' elif not isinstance(value, bool): value = False + + # Ensure id is a string when present (preserve mapping to DB ids) + if key == 'id': + try: + value = str(value) if value is not None else None + except Exception: + value = None cleaned[key] = value diff --git a/python-ecosystem/rag-pipeline/.gitignore b/python-ecosystem/rag-pipeline/.gitignore index 7da4005d..292e697b 100644 --- a/python-ecosystem/rag-pipeline/.gitignore +++ b/python-ecosystem/rag-pipeline/.gitignore @@ -18,3 +18,6 @@ build/ .idea/ .vscode/ +logs/** +**/__pycache__/ + diff --git a/python-ecosystem/rag-pipeline/docs/AST_CHUNKING.md b/python-ecosystem/rag-pipeline/docs/AST_CHUNKING.md new file mode 100644 index 00000000..cb4a86e1 --- /dev/null +++ b/python-ecosystem/rag-pipeline/docs/AST_CHUNKING.md @@ -0,0 +1,182 @@ +# AST-based Code Chunking + +This document describes the AST-based code chunking feature in the RAG pipeline. + +## Overview + +The `ASTCodeSplitter` uses tree-sitter via LangChain's `LanguageParser` to parse code into semantic units (classes, functions, methods) rather than arbitrary character-based chunks. + +### Benefits over Traditional Chunking + +| Feature | Traditional (Regex) | AST-based | +|---------|---------------------|-----------| +| Boundary Detection | Line-based, may split mid-function | Accurate function/class boundaries | +| Language Support | Limited patterns | 15+ languages via tree-sitter | +| Metadata | Basic (file path, line numbers) | Rich (semantic names, docstrings, signatures) | +| Context Quality | May include partial code | Complete semantic units | + +## Architecture + +``` +Document → ASTCodeSplitter → TextNodes (with metadata) + ↓ + ┌──────────────────┐ + │ Language Check │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ tree-sitter AST │ (for supported languages) + │ parsing │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Extract semantic │ + │ units (classes, │ + │ functions) │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Oversized chunk? │──Yes──→ RecursiveCharacterTextSplitter + └────────┬─────────┘ + │No + ┌────────▼─────────┐ + │ Enrich metadata │ + │ (names, docs, │ + │ signatures) │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ Create TextNode │ + └──────────────────┘ +``` + +## Supported Languages + +AST parsing is supported for: + +- **Python** (.py, .pyw, .pyi) +- **Java** (.java) +- **JavaScript** (.js, .jsx, .mjs, .cjs) +- **TypeScript** (.ts, .tsx) +- **Go** (.go) +- **Rust** (.rs) +- **C/C++** (.c, .h, .cpp, .cc, .hpp) +- **C#** (.cs) +- **Kotlin** (.kt, .kts) +- **PHP** (.php) +- **Ruby** (.rb) +- **Scala** (.scala) +- **Swift** (.swift) +- **Lua** (.lua) +- **Perl** (.pl, .pm) +- **Haskell** (.hs) +- **COBOL** (.cob, .cbl) + +## Content Types + +Each chunk has a `content_type` metadata field: + +| Content Type | Description | RAG Boost | +|--------------|-------------|-----------| +| `functions_classes` | Full function/class definitions | 1.2x | +| `simplified_code` | Remaining code with placeholders | 0.7x | +| `oversized_split` | Large chunks split by character | 0.95x | +| `fallback` | Non-AST parsed content | 1.0x | + +## Metadata Extraction + +For each chunk, the following metadata is extracted: + +```python +{ + # Standard fields + 'path': 'src/service/UserService.java', + 'language': 'java', + 'chunk_index': 0, + 'total_chunks': 5, + 'start_line': 15, + 'end_line': 45, + + # AST-specific fields + 'content_type': 'functions_classes', + 'semantic_names': ['UserService', 'createUser', 'getUserById'], + 'primary_name': 'UserService', + 'signature': 'public class UserService', + 'docstring': 'Service class for user management operations.' +} +``` + +## Configuration + +### Environment Variables + +```bash +# Enable/disable AST splitter (default: true) +RAG_USE_AST_SPLITTER=true +``` + +### Splitter Parameters + +```python +ASTCodeSplitter( + max_chunk_size=2000, # Max chars per chunk + min_chunk_size=100, # Min chars for valid chunk + chunk_overlap=200, # Overlap for oversized splits + parser_threshold=10 # Min lines for AST parsing +) +``` + +## RAG Retrieval Boost + +During retrieval, chunks are boosted based on: + +1. **File Path Priority** - Service/controller files get 1.3x boost +2. **Content Type** - `functions_classes` get 1.2x boost +3. **Semantic Names** - Chunks with extracted names get 1.1x boost +4. **Docstrings** - Chunks with docstrings get 1.05x boost + +Example combined boost: +``` +UserService.java (service file) × functions_classes × has_names × has_docstring += 1.3 × 1.2 × 1.1 × 1.05 = 1.8x boost +``` + +## Usage Example + +```python +from rag_pipeline.core import ASTCodeSplitter + +# Initialize splitter +splitter = ASTCodeSplitter( + max_chunk_size=2000, + parser_threshold=10 +) + +# Split documents +nodes = splitter.split_documents(documents) + +# Access enriched metadata +for node in nodes: + print(f"Path: {node.metadata['path']}") + print(f"Type: {node.metadata['content_type']}") + print(f"Names: {node.metadata.get('semantic_names', [])}") + print(f"Signature: {node.metadata.get('signature', 'N/A')}") +``` + +## Fallback Behavior + +When AST parsing fails or is not applicable: + +1. **Unsupported language** → Uses `RecursiveCharacterTextSplitter` with default separators +2. **Small files** (< `parser_threshold` lines) → Uses fallback splitter +3. **Parse errors** → Logs warning and uses fallback +4. **Missing tree-sitter** → Falls back to regex-based `SemanticCodeSplitter` + +## Dependencies + +``` +langchain-community>=0.3.0 +langchain-text-splitters>=0.3.0 +tree-sitter>=0.21.0 +tree-sitter-languages>=1.10.0 +``` diff --git a/python-ecosystem/rag-pipeline/docs/INTEGRATION_GUIDE.md b/python-ecosystem/rag-pipeline/docs/INTEGRATION_GUIDE.md index e11398c6..0e4aebc8 100644 --- a/python-ecosystem/rag-pipeline/docs/INTEGRATION_GUIDE.md +++ b/python-ecosystem/rag-pipeline/docs/INTEGRATION_GUIDE.md @@ -28,7 +28,7 @@ This guide explains how to integrate the RAG pipeline with the existing CodeCrow │ │ ▼ ▼ ┌──────────────────────────┐ ┌──────────────────────────────┐ -│ codecrow-mcp-servers │ │ MongoDB Atlas │ +│ codecrow-vcs-mcp │ │ MongoDB Atlas │ │ (Java - MCP tools) │ │ - Vector store │ └──────────────────────────┘ │ - Document store │ └──────────────────────────────┘ diff --git a/python-ecosystem/rag-pipeline/requirements.txt b/python-ecosystem/rag-pipeline/requirements.txt index ca1bd093..57748608 100644 --- a/python-ecosystem/rag-pipeline/requirements.txt +++ b/python-ecosystem/rag-pipeline/requirements.txt @@ -13,6 +13,27 @@ llama-index-embeddings-openai>=0.3.0 llama-index-vector-stores-qdrant>=0.5.0 llama-index-llms-openai>=0.3.0 +# LangChain text splitters (language-aware code splitting) +langchain-text-splitters>=0.3.0 + +# LangChain community (for LanguageParser with tree-sitter) +langchain-community>=0.3.0 +langchain-core>=0.3.0 + +# Tree-sitter for AST-based code parsing (new API) +tree-sitter>=0.23.0 +tree-sitter-python>=0.23.0 +tree-sitter-java>=0.23.0 +tree-sitter-javascript>=0.23.0 +tree-sitter-typescript>=0.23.0 +tree-sitter-go>=0.23.0 +tree-sitter-rust>=0.23.0 +tree-sitter-c>=0.23.0 +tree-sitter-cpp>=0.23.0 +tree-sitter-c-sharp>=0.23.0 +tree-sitter-ruby>=0.23.0 +tree-sitter-php>=0.23.0 + # Qdrant for vector storage qdrant-client>=1.7.0,<2.0.0 diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/api/api.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/api/api.py index 37fe4cc8..3899f0d9 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/api/api.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/api/api.py @@ -55,7 +55,7 @@ class QueryRequest(BaseModel): class PRContextRequest(BaseModel): workspace: str project: str - branch: str + branch: Optional[str] = None # Optional - if None, return empty context changed_files: List[str] diff_snippets: Optional[List[str]] = [] pr_title: Optional[str] = None @@ -262,6 +262,22 @@ def get_pr_context(request: PRContextRequest): - Deduplication """ try: + # If branch is not provided, return empty context + if not request.branch: + logger.warning("Branch not provided in PR context request, returning empty context") + return { + "context": { + "relevant_code": [], + "related_files": [], + "changed_files": request.changed_files, + "_metadata": { + "skipped_reason": "branch_not_provided", + "changed_files_count": len(request.changed_files), + "result_count": 0 + } + } + } + context = query_service.get_context_for_pr( workspace=request.workspace, project=request.project, diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/__init__.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/__init__.py index 55a25798..dcb908ff 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/__init__.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/__init__.py @@ -1,8 +1,17 @@ """Core functionality for indexing and document processing""" -__all__ = ["DocumentLoader", "CodeAwareSplitter", "FunctionAwareSplitter", "RAGIndexManager"] +__all__ = [ + "DocumentLoader", + "CodeAwareSplitter", + "FunctionAwareSplitter", + "SemanticCodeSplitter", + "ASTCodeSplitter", + "RAGIndexManager" +] from .index_manager import RAGIndexManager from .chunking import CodeAwareSplitter, FunctionAwareSplitter +from .semantic_splitter import SemanticCodeSplitter +from .ast_splitter import ASTCodeSplitter from .loader import DocumentLoader diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/ast_splitter.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/ast_splitter.py new file mode 100644 index 00000000..a3fb1426 --- /dev/null +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/ast_splitter.py @@ -0,0 +1,1009 @@ +""" +AST-based Code Splitter using Tree-sitter for accurate code parsing. + +This module provides true AST-aware code chunking that: +1. Uses Tree-sitter for accurate AST parsing (15+ languages) +2. Splits code into semantic units (classes, functions, methods) +3. Uses RecursiveCharacterTextSplitter for oversized chunks (large methods) +4. Enriches metadata for better RAG retrieval +5. Maintains parent context ("breadcrumbs") for nested structures +6. Uses deterministic IDs for Qdrant deduplication + +Key benefits over regex-based splitting: +- Accurate function/class boundary detection +- Language-aware parsing for 15+ languages +- Better metadata: content_type, language, semantic_names, parent_class +- Handles edge cases (nested functions, decorators, etc.) +- Deterministic chunk IDs prevent duplicates on re-indexing +""" + +import re +import hashlib +import logging +from typing import List, Dict, Any, Optional, Set +from pathlib import Path +from dataclasses import dataclass, field +from enum import Enum + +from langchain_text_splitters import RecursiveCharacterTextSplitter, Language +from llama_index.core.schema import Document as LlamaDocument, TextNode + +logger = logging.getLogger(__name__) + + +class ContentType(Enum): + """Content type as determined by AST parsing""" + FUNCTIONS_CLASSES = "functions_classes" # Full function/class definition + SIMPLIFIED_CODE = "simplified_code" # Remaining code with placeholders + FALLBACK = "fallback" # Non-AST parsed content + OVERSIZED_SPLIT = "oversized_split" # Large chunk split by RecursiveCharacterTextSplitter + + +# Map file extensions to LangChain Language enum +EXTENSION_TO_LANGUAGE: Dict[str, Language] = { + # Python + '.py': Language.PYTHON, + '.pyw': Language.PYTHON, + '.pyi': Language.PYTHON, + + # Java/JVM + '.java': Language.JAVA, + '.kt': Language.KOTLIN, + '.kts': Language.KOTLIN, + '.scala': Language.SCALA, + + # JavaScript/TypeScript + '.js': Language.JS, + '.jsx': Language.JS, + '.mjs': Language.JS, + '.cjs': Language.JS, + '.ts': Language.TS, + '.tsx': Language.TS, + + # Systems languages + '.go': Language.GO, + '.rs': Language.RUST, + '.c': Language.C, + '.h': Language.C, + '.cpp': Language.CPP, + '.cc': Language.CPP, + '.cxx': Language.CPP, + '.hpp': Language.CPP, + '.hxx': Language.CPP, + '.cs': Language.CSHARP, + + # Web/Scripting + '.php': Language.PHP, + '.rb': Language.RUBY, + '.lua': Language.LUA, + '.pl': Language.PERL, + '.pm': Language.PERL, + '.swift': Language.SWIFT, + + # Markup/Config + '.md': Language.MARKDOWN, + '.markdown': Language.MARKDOWN, + '.html': Language.HTML, + '.htm': Language.HTML, + '.rst': Language.RST, + '.tex': Language.LATEX, + '.proto': Language.PROTO, + '.sol': Language.SOL, + '.hs': Language.HASKELL, + '.cob': Language.COBOL, + '.cbl': Language.COBOL, + '.xml': Language.HTML, # Use HTML splitter for XML +} + +# Languages that support full AST parsing via tree-sitter +AST_SUPPORTED_LANGUAGES = { + Language.PYTHON, Language.JAVA, Language.KOTLIN, Language.JS, Language.TS, + Language.GO, Language.RUST, Language.C, Language.CPP, Language.CSHARP, + Language.PHP, Language.RUBY, Language.SCALA, Language.LUA, Language.PERL, + Language.SWIFT, Language.HASKELL, Language.COBOL +} + +# Tree-sitter language name mapping (tree-sitter-languages uses these names) +LANGUAGE_TO_TREESITTER: Dict[Language, str] = { + Language.PYTHON: 'python', + Language.JAVA: 'java', + Language.KOTLIN: 'kotlin', + Language.JS: 'javascript', + Language.TS: 'typescript', + Language.GO: 'go', + Language.RUST: 'rust', + Language.C: 'c', + Language.CPP: 'cpp', + Language.CSHARP: 'c_sharp', + Language.PHP: 'php', + Language.RUBY: 'ruby', + Language.SCALA: 'scala', + Language.LUA: 'lua', + Language.PERL: 'perl', + Language.SWIFT: 'swift', + Language.HASKELL: 'haskell', +} + +# Node types that represent semantic units (classes, functions, etc.) +SEMANTIC_NODE_TYPES: Dict[str, Dict[str, List[str]]] = { + 'python': { + 'class': ['class_definition'], + 'function': ['function_definition', 'async_function_definition'], + }, + 'java': { + 'class': ['class_declaration', 'interface_declaration', 'enum_declaration'], + 'function': ['method_declaration', 'constructor_declaration'], + }, + 'javascript': { + 'class': ['class_declaration'], + 'function': ['function_declaration', 'method_definition', 'arrow_function', 'generator_function_declaration'], + }, + 'typescript': { + 'class': ['class_declaration', 'interface_declaration'], + 'function': ['function_declaration', 'method_definition', 'arrow_function'], + }, + 'go': { + 'class': ['type_declaration'], # structs, interfaces + 'function': ['function_declaration', 'method_declaration'], + }, + 'rust': { + 'class': ['struct_item', 'impl_item', 'trait_item', 'enum_item'], + 'function': ['function_item'], + }, + 'c_sharp': { + 'class': ['class_declaration', 'interface_declaration', 'struct_declaration'], + 'function': ['method_declaration', 'constructor_declaration'], + }, + 'kotlin': { + 'class': ['class_declaration', 'object_declaration', 'interface_declaration'], + 'function': ['function_declaration'], + }, + 'php': { + 'class': ['class_declaration', 'interface_declaration', 'trait_declaration'], + 'function': ['function_definition', 'method_declaration'], + }, + 'ruby': { + 'class': ['class', 'module'], + 'function': ['method', 'singleton_method'], + }, + 'cpp': { + 'class': ['class_specifier', 'struct_specifier'], + 'function': ['function_definition'], + }, + 'c': { + 'class': ['struct_specifier'], + 'function': ['function_definition'], + }, + 'scala': { + 'class': ['class_definition', 'object_definition', 'trait_definition'], + 'function': ['function_definition'], + }, +} + +# Metadata extraction patterns (fallback when AST doesn't provide names) +METADATA_PATTERNS = { + 'python': { + 'class': re.compile(r'^class\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'^(?:async\s+)?def\s+(\w+)\s*\(', re.MULTILINE), + }, + 'java': { + 'class': re.compile(r'(?:public\s+|private\s+|protected\s+)?(?:abstract\s+|final\s+)?class\s+(\w+)', re.MULTILINE), + 'interface': re.compile(r'(?:public\s+)?interface\s+(\w+)', re.MULTILINE), + 'method': re.compile(r'(?:public|private|protected)\s+(?:static\s+)?[\w<>,\s]+\s+(\w+)\s*\(', re.MULTILINE), + }, + 'javascript': { + 'class': re.compile(r'(?:export\s+)?(?:default\s+)?class\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:export\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*\(', re.MULTILINE), + }, + 'typescript': { + 'class': re.compile(r'(?:export\s+)?(?:default\s+)?class\s+(\w+)', re.MULTILINE), + 'interface': re.compile(r'(?:export\s+)?interface\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:export\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*\(', re.MULTILINE), + }, + 'go': { + 'function': re.compile(r'^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(', re.MULTILINE), + 'struct': re.compile(r'^type\s+(\w+)\s+struct\s*\{', re.MULTILINE), + }, + 'rust': { + 'function': re.compile(r'^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)', re.MULTILINE), + 'struct': re.compile(r'^(?:pub\s+)?struct\s+(\w+)', re.MULTILINE), + }, + 'c_sharp': { + 'class': re.compile(r'(?:public\s+|private\s+|internal\s+)?(?:abstract\s+|sealed\s+)?class\s+(\w+)', re.MULTILINE), + 'method': re.compile(r'(?:public|private|protected|internal)\s+(?:static\s+)?[\w<>,\s]+\s+(\w+)\s*\(', re.MULTILINE), + }, + 'kotlin': { + 'class': re.compile(r'(?:data\s+|sealed\s+|open\s+)?class\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:fun|suspend\s+fun)\s+(\w+)\s*\(', re.MULTILINE), + }, + 'php': { + 'class': re.compile(r'(?:abstract\s+|final\s+)?class\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:public|private|protected|static|\s)*function\s+(\w+)\s*\(', re.MULTILINE), + }, +} + + +@dataclass +class ASTChunk: + """Represents a chunk of code from AST parsing""" + content: str + content_type: ContentType + language: str + path: str + semantic_names: List[str] = field(default_factory=list) + parent_context: List[str] = field(default_factory=list) # Breadcrumb: ["MyClass", "inner_method"] + docstring: Optional[str] = None + signature: Optional[str] = None + start_line: int = 0 + end_line: int = 0 + node_type: Optional[str] = None + + +def generate_deterministic_id(path: str, content: str, chunk_index: int = 0) -> str: + """ + Generate a deterministic ID for a chunk based on file path and content. + + This ensures the same code chunk always gets the same ID, preventing + duplicates in Qdrant during re-indexing. + + Args: + path: File path + content: Chunk content + chunk_index: Index of chunk within file (for disambiguation) + + Returns: + Deterministic hex ID string + """ + # Use path + content hash + index for uniqueness + hash_input = f"{path}:{chunk_index}:{content[:500]}" # First 500 chars for efficiency + return hashlib.sha256(hash_input.encode('utf-8')).hexdigest()[:32] + + +def compute_file_hash(content: str) -> str: + """Compute hash of file content for change detection""" + return hashlib.sha256(content.encode('utf-8')).hexdigest() + + +class ASTCodeSplitter: + """ + AST-based code splitter using Tree-sitter for accurate parsing. + + Features: + - True AST parsing via tree-sitter for accurate code structure detection + - Splits code into semantic units (classes, functions, methods) + - Maintains parent context (breadcrumbs) for nested structures + - Falls back to RecursiveCharacterTextSplitter for oversized chunks + - Uses deterministic IDs for Qdrant deduplication + - Enriches metadata for improved RAG retrieval + + Usage: + splitter = ASTCodeSplitter(max_chunk_size=2000) + nodes = splitter.split_documents(documents) + """ + + DEFAULT_MAX_CHUNK_SIZE = 2000 + DEFAULT_MIN_CHUNK_SIZE = 100 + DEFAULT_CHUNK_OVERLAP = 200 + DEFAULT_PARSER_THRESHOLD = 10 # Minimum lines for AST parsing + + def __init__( + self, + max_chunk_size: int = DEFAULT_MAX_CHUNK_SIZE, + min_chunk_size: int = DEFAULT_MIN_CHUNK_SIZE, + chunk_overlap: int = DEFAULT_CHUNK_OVERLAP, + parser_threshold: int = DEFAULT_PARSER_THRESHOLD + ): + """ + Initialize AST code splitter. + + Args: + max_chunk_size: Maximum characters per chunk. Larger chunks are split. + min_chunk_size: Minimum characters for a valid chunk. + chunk_overlap: Overlap between chunks when splitting oversized content. + parser_threshold: Minimum lines for AST parsing (smaller files use fallback). + """ + self.max_chunk_size = max_chunk_size + self.min_chunk_size = min_chunk_size + self.chunk_overlap = chunk_overlap + self.parser_threshold = parser_threshold + + # Cache text splitters for oversized chunks + self._splitter_cache: Dict[Language, RecursiveCharacterTextSplitter] = {} + + # Default text splitter for unknown languages + self._default_splitter = RecursiveCharacterTextSplitter( + chunk_size=max_chunk_size, + chunk_overlap=chunk_overlap, + length_function=len, + ) + + # Track if tree-sitter is available + self._tree_sitter_available: Optional[bool] = None + # Cache for language modules and parsers + self._language_cache: Dict[str, Any] = {} + + def _get_tree_sitter_language(self, lang_name: str): + """ + Get tree-sitter Language object for a language name. + Uses the new tree-sitter API with individual language packages. + + Note: Different packages have different APIs: + - Most use: module.language() + - PHP uses: module.language_php() + - TypeScript uses: module.language_typescript() + """ + if lang_name in self._language_cache: + return self._language_cache[lang_name] + + try: + from tree_sitter import Language + + # Map language names to their package modules and function names + # Format: (module_name, function_name or None for 'language') + lang_modules = { + 'python': ('tree_sitter_python', 'language'), + 'java': ('tree_sitter_java', 'language'), + 'javascript': ('tree_sitter_javascript', 'language'), + 'typescript': ('tree_sitter_typescript', 'language_typescript'), + 'go': ('tree_sitter_go', 'language'), + 'rust': ('tree_sitter_rust', 'language'), + 'c': ('tree_sitter_c', 'language'), + 'cpp': ('tree_sitter_cpp', 'language'), + 'c_sharp': ('tree_sitter_c_sharp', 'language'), + 'ruby': ('tree_sitter_ruby', 'language'), + 'php': ('tree_sitter_php', 'language_php'), + } + + lang_info = lang_modules.get(lang_name) + if not lang_info: + return None + + module_name, func_name = lang_info + + # Dynamic import of language module + import importlib + lang_module = importlib.import_module(module_name) + + # Get the language function + lang_func = getattr(lang_module, func_name, None) + if not lang_func: + logger.debug(f"Module {module_name} has no {func_name} function") + return None + + # Create Language object using the new API + language = Language(lang_func()) + self._language_cache[lang_name] = language + return language + + except Exception as e: + logger.debug(f"Could not load tree-sitter language '{lang_name}': {e}") + return None + + def _check_tree_sitter(self) -> bool: + """Check if tree-sitter is available""" + if self._tree_sitter_available is None: + try: + from tree_sitter import Parser, Language + import tree_sitter_python as tspython + + # Test with the new API + py_language = Language(tspython.language()) + parser = Parser(py_language) + parser.parse(b"def test(): pass") + + self._tree_sitter_available = True + logger.info("tree-sitter is available and working") + except ImportError as e: + logger.warning(f"tree-sitter not installed: {e}") + self._tree_sitter_available = False + except Exception as e: + logger.warning(f"tree-sitter error: {type(e).__name__}: {e}") + self._tree_sitter_available = False + return self._tree_sitter_available + + def _get_language_from_path(self, path: str) -> Optional[Language]: + """Determine Language enum from file path""" + ext = Path(path).suffix.lower() + return EXTENSION_TO_LANGUAGE.get(ext) + + def _get_treesitter_language(self, language: Language) -> Optional[str]: + """Get tree-sitter language name from Language enum""" + return LANGUAGE_TO_TREESITTER.get(language) + + def _get_text_splitter(self, language: Language) -> RecursiveCharacterTextSplitter: + """Get language-specific text splitter for oversized chunks""" + if language not in self._splitter_cache: + try: + self._splitter_cache[language] = RecursiveCharacterTextSplitter.from_language( + language=language, + chunk_size=self.max_chunk_size, + chunk_overlap=self.chunk_overlap, + ) + except Exception: + # Fallback if language not supported + self._splitter_cache[language] = self._default_splitter + return self._splitter_cache[language] + + def _parse_with_ast( + self, + text: str, + language: Language, + path: str + ) -> List[ASTChunk]: + """ + Parse code using AST via tree-sitter. + + Returns list of ASTChunk objects with content and metadata. + """ + if not self._check_tree_sitter(): + return [] + + ts_lang = self._get_treesitter_language(language) + if not ts_lang: + logger.debug(f"No tree-sitter mapping for {language}, using fallback") + return [] + + try: + from tree_sitter import Parser + + # Get Language object for this language + lang_obj = self._get_tree_sitter_language(ts_lang) + if not lang_obj: + logger.debug(f"tree-sitter language '{ts_lang}' not available") + return [] + + # Create parser with the language + parser = Parser(lang_obj) + tree = parser.parse(bytes(text, "utf8")) + + # Extract chunks with breadcrumb context + chunks = self._extract_ast_chunks_with_context( + tree.root_node, + text, + ts_lang, + path + ) + + return chunks + + except Exception as e: + logger.warning(f"AST parsing failed for {path}: {e}") + return [] + + def _extract_ast_chunks_with_context( + self, + root_node, + source_code: str, + language: str, + path: str + ) -> List[ASTChunk]: + """ + Extract function/class chunks from AST tree with parent context (breadcrumbs). + + This solves the "context loss" problem by tracking parent classes/modules + so that a method knows it belongs to a specific class. + """ + chunks = [] + processed_ranges: Set[tuple] = set() # Track (start, end) to avoid duplicates + + # Get node types for this language + lang_node_types = SEMANTIC_NODE_TYPES.get(language, {}) + class_types = set(lang_node_types.get('class', [])) + function_types = set(lang_node_types.get('function', [])) + all_semantic_types = class_types | function_types + + def get_node_name(node) -> Optional[str]: + """Extract name from a node (class/function name)""" + for child in node.children: + if child.type == 'identifier' or child.type == 'name': + return source_code[child.start_byte:child.end_byte] + # For some languages, name might be in a specific child + if child.type in ('type_identifier', 'property_identifier'): + return source_code[child.start_byte:child.end_byte] + return None + + def traverse(node, parent_context: List[str], depth: int = 0): + """ + Recursively traverse AST and extract semantic chunks with breadcrumbs. + + Args: + node: Current AST node + parent_context: List of parent class/function names (breadcrumb) + depth: Current depth in tree + """ + node_range = (node.start_byte, node.end_byte) + + # Check if this is a semantic unit + if node.type in all_semantic_types: + # Skip if already processed (nested in another chunk) + if node_range in processed_ranges: + return + + content = source_code[node.start_byte:node.end_byte] + + # Calculate line numbers + start_line = source_code[:node.start_byte].count('\n') + 1 + end_line = start_line + content.count('\n') + + # Get the name of this node + node_name = get_node_name(node) + + # Determine content type + is_class = node.type in class_types + + chunk = ASTChunk( + content=content, + content_type=ContentType.FUNCTIONS_CLASSES, + language=language, + path=path, + semantic_names=[node_name] if node_name else [], + parent_context=list(parent_context), # Copy the breadcrumb + start_line=start_line, + end_line=end_line, + node_type=node.type, + ) + + chunks.append(chunk) + processed_ranges.add(node_range) + + # If this is a class, traverse children with updated context + if is_class and node_name: + new_context = parent_context + [node_name] + for child in node.children: + traverse(child, new_context, depth + 1) + else: + # Continue traversing children with current context + for child in node.children: + traverse(child, parent_context, depth + 1) + + traverse(root_node, []) + + # Create simplified code (skeleton with placeholders) + simplified = self._create_simplified_code(source_code, chunks, language) + if simplified and simplified.strip() and len(simplified.strip()) > 50: + chunks.append(ASTChunk( + content=simplified, + content_type=ContentType.SIMPLIFIED_CODE, + language=language, + path=path, + semantic_names=[], + parent_context=[], + start_line=1, + end_line=source_code.count('\n') + 1, + node_type='simplified', + )) + + return chunks + + def _create_simplified_code( + self, + source_code: str, + chunks: List[ASTChunk], + language: str + ) -> str: + """ + Create simplified code with placeholders for extracted chunks. + + This gives RAG context about the overall file structure without + including full function/class bodies. + + Example output: + # Code for: class MyClass: + # Code for: def my_function(): + if __name__ == "__main__": + main() + """ + if not chunks: + return source_code + + # Get chunks that are functions_classes type (not simplified) + semantic_chunks = [c for c in chunks if c.content_type == ContentType.FUNCTIONS_CLASSES] + + if not semantic_chunks: + return source_code + + # Sort by start position (reverse) to replace from end + sorted_chunks = sorted( + semantic_chunks, + key=lambda x: source_code.find(x.content), + reverse=True + ) + + result = source_code + + # Comment style by language + comment_prefix = { + 'python': '#', + 'javascript': '//', + 'typescript': '//', + 'java': '//', + 'kotlin': '//', + 'go': '//', + 'rust': '//', + 'c': '//', + 'cpp': '//', + 'c_sharp': '//', + 'php': '//', + 'ruby': '#', + 'lua': '--', + 'perl': '#', + 'scala': '//', + }.get(language, '//') + + for chunk in sorted_chunks: + # Find the position of this chunk in the source + pos = result.find(chunk.content) + if pos == -1: + continue + + # Extract first line for placeholder + first_line = chunk.content.split('\n')[0].strip() + # Truncate if too long + if len(first_line) > 60: + first_line = first_line[:60] + '...' + + # Add breadcrumb context to placeholder + breadcrumb = "" + if chunk.parent_context: + breadcrumb = f" (in {'.'.join(chunk.parent_context)})" + + placeholder = f"{comment_prefix} Code for: {first_line}{breadcrumb}\n" + + result = result[:pos] + placeholder + result[pos + len(chunk.content):] + + return result.strip() + + def _extract_metadata( + self, + chunk: ASTChunk, + base_metadata: Dict[str, Any] + ) -> Dict[str, Any]: + """Extract and enrich metadata from an AST chunk""" + metadata = dict(base_metadata) + + # Core AST metadata + metadata['content_type'] = chunk.content_type.value + metadata['node_type'] = chunk.node_type + + # Breadcrumb context (critical for RAG) + if chunk.parent_context: + metadata['parent_context'] = chunk.parent_context + metadata['parent_class'] = chunk.parent_context[-1] if chunk.parent_context else None + metadata['full_path'] = '.'.join(chunk.parent_context + chunk.semantic_names[:1]) + + # Semantic names + if chunk.semantic_names: + metadata['semantic_names'] = chunk.semantic_names[:10] + metadata['primary_name'] = chunk.semantic_names[0] + + # Line numbers + metadata['start_line'] = chunk.start_line + metadata['end_line'] = chunk.end_line + + # Try to extract additional metadata via regex patterns + patterns = METADATA_PATTERNS.get(chunk.language, {}) + + # Extract docstring + docstring = self._extract_docstring(chunk.content, chunk.language) + if docstring: + metadata['docstring'] = docstring[:500] + + # Extract signature + signature = self._extract_signature(chunk.content, chunk.language) + if signature: + metadata['signature'] = signature + + # Extract additional names not caught by AST + if not chunk.semantic_names: + names = [] + for pattern_type, pattern in patterns.items(): + matches = pattern.findall(chunk.content) + names.extend(matches) + if names: + metadata['semantic_names'] = list(set(names))[:10] + metadata['primary_name'] = names[0] + + return metadata + + def _extract_docstring(self, content: str, language: str) -> Optional[str]: + """Extract docstring from code chunk""" + if language == 'python': + match = re.search(r'"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\'', content) + if match: + return (match.group(1) or match.group(2)).strip() + + elif language in ('javascript', 'typescript', 'java', 'kotlin', 'c_sharp', 'php', 'go', 'scala'): + match = re.search(r'/\*\*([\s\S]*?)\*/', content) + if match: + doc = match.group(1) + doc = re.sub(r'^\s*\*\s?', '', doc, flags=re.MULTILINE) + return doc.strip() + + elif language == 'rust': + lines = [] + for line in content.split('\n'): + if line.strip().startswith('///'): + lines.append(line.strip()[3:].strip()) + elif lines: + break + if lines: + return '\n'.join(lines) + + return None + + def _extract_signature(self, content: str, language: str) -> Optional[str]: + """Extract function/method signature from code chunk""" + lines = content.split('\n') + + for line in lines[:15]: + line = line.strip() + + if language == 'python': + if line.startswith(('def ', 'async def ', 'class ')): + sig = line + if line.startswith('class ') and ':' in line: + return line.split(':')[0] + ':' + if ')' not in sig and ':' not in sig: + idx = -1 + for i, l in enumerate(lines): + if l.strip() == line: + idx = i + break + if idx >= 0: + for next_line in lines[idx+1:idx+5]: + sig += ' ' + next_line.strip() + if ')' in next_line: + break + if ':' in sig: + return sig.split(':')[0] + ':' + return sig + + elif language in ('java', 'kotlin', 'c_sharp'): + if any(kw in line for kw in ['public ', 'private ', 'protected ', 'internal ', 'fun ']): + if '(' in line and not line.startswith('//'): + return line.split('{')[0].strip() + + elif language in ('javascript', 'typescript'): + if line.startswith(('function ', 'async function ', 'class ')): + return line.split('{')[0].strip() + if '=>' in line and '(' in line: + return line.split('=>')[0].strip() + ' =>' + + elif language == 'go': + if line.startswith('func ') or line.startswith('type '): + return line.split('{')[0].strip() + + elif language == 'rust': + if line.startswith(('fn ', 'pub fn ', 'async fn ', 'pub async fn ', 'impl ', 'struct ', 'trait ')): + return line.split('{')[0].strip() + + return None + + def _split_oversized_chunk( + self, + chunk: ASTChunk, + language: Optional[Language], + base_metadata: Dict[str, Any], + path: str + ) -> List[TextNode]: + """ + Split an oversized chunk using RecursiveCharacterTextSplitter. + + This is used when AST-parsed chunks (e.g., very large classes/functions) + still exceed the max_chunk_size. + """ + splitter = ( + self._get_text_splitter(language) + if language and language in AST_SUPPORTED_LANGUAGES + else self._default_splitter + ) + + sub_chunks = splitter.split_text(chunk.content) + nodes = [] + + # Parent ID for linking sub-chunks + parent_id = generate_deterministic_id(path, chunk.content, 0) + + for i, sub_chunk in enumerate(sub_chunks): + if not sub_chunk or not sub_chunk.strip(): + continue + + if len(sub_chunk.strip()) < self.min_chunk_size and len(sub_chunks) > 1: + continue + + metadata = dict(base_metadata) + metadata['content_type'] = ContentType.OVERSIZED_SPLIT.value + metadata['original_content_type'] = chunk.content_type.value + metadata['parent_chunk_id'] = parent_id + metadata['sub_chunk_index'] = i + metadata['total_sub_chunks'] = len(sub_chunks) + + # Preserve breadcrumb context + if chunk.parent_context: + metadata['parent_context'] = chunk.parent_context + metadata['parent_class'] = chunk.parent_context[-1] + + if chunk.semantic_names: + metadata['semantic_names'] = chunk.semantic_names + metadata['primary_name'] = chunk.semantic_names[0] + + # Deterministic ID for this sub-chunk + chunk_id = generate_deterministic_id(path, sub_chunk, i) + + node = TextNode( + id_=chunk_id, + text=sub_chunk, + metadata=metadata + ) + nodes.append(node) + + return nodes + + def split_documents(self, documents: List[LlamaDocument]) -> List[TextNode]: + """ + Split LlamaIndex documents using AST-based parsing. + + Args: + documents: List of LlamaIndex Document objects + + Returns: + List of TextNode objects with enriched metadata and deterministic IDs + """ + all_nodes = [] + + for doc in documents: + path = doc.metadata.get('path', 'unknown') + + # Determine Language enum + language = self._get_language_from_path(path) + + # Check if AST parsing is supported and beneficial + line_count = doc.text.count('\n') + 1 + use_ast = ( + language is not None + and language in AST_SUPPORTED_LANGUAGES + and line_count >= self.parser_threshold + and self._check_tree_sitter() + ) + + if use_ast: + nodes = self._split_with_ast(doc, language) + else: + nodes = self._split_fallback(doc, language) + + all_nodes.extend(nodes) + logger.debug(f"Split {path} into {len(nodes)} chunks (AST={use_ast})") + + return all_nodes + + def _split_with_ast( + self, + doc: LlamaDocument, + language: Language + ) -> List[TextNode]: + """Split document using AST parsing with breadcrumb context""" + text = doc.text + path = doc.metadata.get('path', 'unknown') + + # Try AST parsing + ast_chunks = self._parse_with_ast(text, language, path) + + if not ast_chunks: + return self._split_fallback(doc, language) + + nodes = [] + chunk_counter = 0 + + for ast_chunk in ast_chunks: + # Check if chunk is oversized + if len(ast_chunk.content) > self.max_chunk_size: + # Split oversized chunk + sub_nodes = self._split_oversized_chunk( + ast_chunk, + language, + doc.metadata, + path + ) + nodes.extend(sub_nodes) + chunk_counter += len(sub_nodes) + else: + # Create node with enriched metadata + metadata = self._extract_metadata(ast_chunk, doc.metadata) + metadata['chunk_index'] = chunk_counter + metadata['total_chunks'] = len(ast_chunks) + + # Deterministic ID + chunk_id = generate_deterministic_id(path, ast_chunk.content, chunk_counter) + + node = TextNode( + id_=chunk_id, + text=ast_chunk.content, + metadata=metadata + ) + nodes.append(node) + chunk_counter += 1 + + return nodes + + def _split_fallback( + self, + doc: LlamaDocument, + language: Optional[Language] = None + ) -> List[TextNode]: + """Fallback splitting using RecursiveCharacterTextSplitter""" + text = doc.text + path = doc.metadata.get('path', 'unknown') + + if not text or not text.strip(): + return [] + + splitter = ( + self._get_text_splitter(language) + if language and language in AST_SUPPORTED_LANGUAGES + else self._default_splitter + ) + + chunks = splitter.split_text(text) + nodes = [] + text_offset = 0 + + for i, chunk in enumerate(chunks): + if not chunk or not chunk.strip(): + continue + + if len(chunk.strip()) < self.min_chunk_size and len(chunks) > 1: + continue + + # Truncate if too large + if len(chunk) > 30000: + chunk = chunk[:30000] + + # Calculate line numbers + start_line = text[:text_offset].count('\n') + 1 if text_offset > 0 else 1 + chunk_pos = text.find(chunk, text_offset) + if chunk_pos >= 0: + text_offset = chunk_pos + len(chunk) + end_line = start_line + chunk.count('\n') + + # Extract metadata using regex patterns + lang_str = doc.metadata.get('language', 'text') + metadata = dict(doc.metadata) + metadata['content_type'] = ContentType.FALLBACK.value + metadata['chunk_index'] = i + metadata['total_chunks'] = len(chunks) + metadata['start_line'] = start_line + metadata['end_line'] = end_line + + # Try to extract semantic names + patterns = METADATA_PATTERNS.get(lang_str, {}) + names = [] + for pattern_type, pattern in patterns.items(): + matches = pattern.findall(chunk) + names.extend(matches) + if names: + metadata['semantic_names'] = list(set(names))[:10] + metadata['primary_name'] = names[0] + + # Deterministic ID + chunk_id = generate_deterministic_id(path, chunk, i) + + node = TextNode( + id_=chunk_id, + text=chunk, + metadata=metadata + ) + nodes.append(node) + + return nodes + + @staticmethod + def get_supported_languages() -> List[str]: + """Return list of languages with AST support""" + return list(LANGUAGE_TO_TREESITTER.values()) + + @staticmethod + def is_ast_supported(path: str) -> bool: + """Check if AST parsing is supported for a file""" + ext = Path(path).suffix.lower() + lang = EXTENSION_TO_LANGUAGE.get(ext) + return lang is not None and lang in AST_SUPPORTED_LANGUAGES diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/chunking.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/chunking.py index 9d7fe2b4..3c8cd8cc 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/chunking.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/chunking.py @@ -6,7 +6,17 @@ class CodeAwareSplitter: - """Code-aware text splitter that handles code and text differently""" + """ + Code-aware text splitter that handles code and text differently. + + DEPRECATED: Use SemanticCodeSplitter instead, which provides: + - Full AST-aware parsing for multiple languages + - Better metadata extraction (docstrings, signatures, imports) + - Smarter chunk merging and boundary detection + + This class just wraps SentenceSplitter with different chunk sizes for + code vs text. For truly semantic code splitting, use SemanticCodeSplitter. + """ def __init__(self, code_chunk_size: int = 800, code_overlap: int = 200, text_chunk_size: int = 1000, text_overlap: int = 200): @@ -69,7 +79,16 @@ def split_text_for_language(self, text: str, language: str) -> List[str]: class FunctionAwareSplitter: - """Advanced splitter that tries to preserve function boundaries""" + """ + Advanced splitter that tries to preserve function boundaries. + + DEPRECATED: Use SemanticCodeSplitter instead, which provides: + - Full AST-aware parsing for multiple languages + - Better metadata extraction (docstrings, signatures, imports) + - Smarter chunk merging and boundary detection + + This class is kept for backward compatibility only. + """ def __init__(self, max_chunk_size: int = 800, overlap: int = 200): self.max_chunk_size = max_chunk_size @@ -126,7 +145,8 @@ def _split_brace_language(self, text: str) -> List[str]: lines = text.split('\n') for line in lines: - if any(keyword in line for keyword in ['function ', 'class ', 'def ', 'fn ', 'func ', 'public ', 'private ', 'protected ']): + if any(keyword in line for keyword in + ['function ', 'class ', 'def ', 'fn ', 'func ', 'public ', 'private ', 'protected ']): if '{' in line: in_function = True diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager.py index 5639d4a4..4b6984cb 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/index_manager.py @@ -1,18 +1,24 @@ -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Generator, Iterator from datetime import datetime, timezone from pathlib import Path import logging import gc +import os +import time from llama_index.core import VectorStoreIndex, StorageContext, Settings from llama_index.core.schema import Document, TextNode from llama_index.vector_stores.qdrant import QdrantVectorStore from qdrant_client import QdrantClient -from qdrant_client.models import Distance, VectorParams, Filter, FieldCondition, MatchAny, PointStruct +from qdrant_client.models import ( + Distance, VectorParams, Filter, FieldCondition, MatchAny, + CreateAlias, DeleteAlias, CreateAliasOperation, DeleteAliasOperation +) from ..models.config import RAGConfig, IndexStats from ..utils.utils import make_namespace -from .chunking import CodeAwareSplitter +from .semantic_splitter import SemanticCodeSplitter +from .ast_splitter import ASTCodeSplitter from .loader import DocumentLoader from .openrouter_embedding import OpenRouterEmbedding @@ -25,7 +31,6 @@ class RAGIndexManager: def __init__(self, config: RAGConfig): self.config = config - # Qdrant client for vector storage self.qdrant_client = QdrantClient(url=config.qdrant_url) logger.info(f"Connected to Qdrant at {config.qdrant_url}") @@ -45,12 +50,25 @@ def __init__(self, config: RAGConfig): Settings.chunk_size = config.chunk_size Settings.chunk_overlap = config.chunk_overlap - self.splitter = CodeAwareSplitter( - code_chunk_size=config.chunk_size, - code_overlap=config.chunk_overlap, - text_chunk_size=config.text_chunk_size, - text_overlap=config.text_chunk_overlap - ) + # Choose splitter based on environment variable or config + # AST splitter provides better semantic chunking for supported languages + use_ast_splitter = os.environ.get('RAG_USE_AST_SPLITTER', 'true').lower() == 'true' + + if use_ast_splitter: + logger.info("Using ASTCodeSplitter for code chunking (tree-sitter based)") + self.splitter = ASTCodeSplitter( + max_chunk_size=config.chunk_size, + min_chunk_size=min(200, config.chunk_size // 4), + chunk_overlap=config.chunk_overlap, + parser_threshold=10 # Minimum lines for AST parsing + ) + else: + logger.info("Using SemanticCodeSplitter for code chunking (regex-based)") + self.splitter = SemanticCodeSplitter( + max_chunk_size=config.chunk_size, + min_chunk_size=min(200, config.chunk_size // 4), + overlap=config.chunk_overlap + ) self.loader = DocumentLoader(config) @@ -92,10 +110,10 @@ def _get_storage_context(self, workspace: str, project: str, branch: str) -> Sto return StorageContext.from_defaults(vector_store=vector_store) def _get_or_create_index( - self, - workspace: str, - project: str, - branch: str + self, + workspace: str, + project: str, + branch: str ) -> VectorStoreIndex: """Get or create vector index for the given namespace""" namespace = make_namespace(workspace, project, branch) @@ -124,24 +142,47 @@ def _get_or_create_index( return index + def _resolve_alias_to_collection(self, alias_name: str) -> Optional[str]: + """Resolve an alias to its underlying collection name""" + try: + aliases = self.qdrant_client.get_collection_aliases(alias_name) + for alias in aliases.aliases: + if alias.alias_name == alias_name: + return alias.collection_name + except Exception: + pass + return None + + def _find_old_versioned_collection(self, alias_name: str) -> Optional[str]: + """Find the collection currently pointed to by an alias""" + return self._resolve_alias_to_collection(alias_name) + + def _alias_exists(self, alias_name: str) -> bool: + """Check if an alias exists""" + try: + aliases = self.qdrant_client.get_aliases() + return any(a.alias_name == alias_name for a in aliases.aliases) + except Exception: + return False + def estimate_repository_size( - self, - repo_path: str, - exclude_patterns: Optional[List[str]] = None + self, + repo_path: str, + exclude_patterns: Optional[List[str]] = None ) -> tuple[int, int]: """Estimate repository size (file count and chunk count) without actually indexing. - + Args: repo_path: Path to the repository exclude_patterns: Additional patterns to exclude - + Returns: Tuple of (file_count, estimated_chunk_count) """ logger.info(f"Estimating repository size for: {repo_path}") - + repo_path_obj = Path(repo_path) - + # Load documents (but don't embed them) documents = self.loader.load_from_directory( repo_path=repo_path_obj, @@ -151,37 +192,37 @@ def estimate_repository_size( commit="estimate", extra_exclude_patterns=exclude_patterns ) - + file_count = len(documents) logger.info(f"Loaded {file_count} documents for estimation") - + if not documents: return 0, 0 - + # Split into chunks to get accurate count chunks = self.splitter.split_documents(documents) chunk_count = len(chunks) - + logger.info(f"Estimated {chunk_count} chunks from {file_count} files") - + return file_count, chunk_count def index_repository( - self, - repo_path: str, - workspace: str, - project: str, - branch: str, - commit: str, - exclude_patterns: Optional[List[str]] = None + self, + repo_path: str, + workspace: str, + project: str, + branch: str, + commit: str, + exclude_patterns: Optional[List[str]] = None ) -> IndexStats: """Index entire repository. - + This performs a full reindex by: 1. Creating a new temporary collection 2. Indexing all documents into it 3. On success, deleting the old collection and using the new one - + This ensures the old index remains available if indexing fails. """ logger.info(f"Indexing repository: {workspace}/{project}/{branch} from {repo_path}") @@ -208,7 +249,7 @@ def index_repository( # Split into chunks chunks = self.splitter.split_documents(documents) logger.info(f"Created {len(chunks)} chunks") - + # Store counts before we might clear the lists document_count = len(documents) chunk_count = len(chunks) @@ -221,7 +262,7 @@ def index_repository( f"(e.g., node_modules, vendor, dist, generated files). " f"This is a free plan limitation - contact support for extended limits." ) - + if self.config.max_files_per_index > 0 and document_count > self.config.max_files_per_index: raise ValueError( f"Repository exceeds file limit: {document_count} files (max: {self.config.max_files_per_index}). " @@ -230,22 +271,29 @@ def index_repository( f"This is a free plan limitation - contact support for extended limits." ) - # Get collection names - final_collection_name = self._get_collection_name(workspace, project, branch) - temp_collection_name = f"{final_collection_name}_new" - - # Check if old collection exists - collections = self.qdrant_client.get_collections().collections - collection_names = [c.name for c in collections] - old_collection_exists = final_collection_name in collection_names - - # Clean up any leftover temp collection from previous failed attempt - if temp_collection_name in collection_names: - logger.info(f"Cleaning up leftover temp collection: {temp_collection_name}") - try: - self.qdrant_client.delete_collection(temp_collection_name) - except Exception as e: - logger.warning(f"Failed to clean up temp collection: {e}") + # Get collection names (using versioned naming for alias-based swap) + alias_name = self._get_collection_name(workspace, project, branch) + temp_collection_name = f"{alias_name}_v{int(time.time())}" + + # Check if alias or collection exists + old_collection_exists = self._alias_exists(alias_name) + if not old_collection_exists: + collections = self.qdrant_client.get_collections().collections + collection_names = [c.name for c in collections] + old_collection_exists = alias_name in collection_names + else: + collections = self.qdrant_client.get_collections().collections + collection_names = [c.name for c in collections] + + # Clean up any leftover versioned collections from previous failed attempts + for coll_name in collection_names: + if coll_name.startswith(f"{alias_name}_v") and coll_name != temp_collection_name: + if not self._resolve_alias_to_collection(alias_name) == coll_name: + logger.info(f"Cleaning up orphaned versioned collection: {coll_name}") + try: + self.qdrant_client.delete_collection(coll_name) + except Exception as e: + logger.warning(f"Failed to clean up orphaned collection: {e}") # Create new temporary collection logger.info(f"Creating temporary collection: {temp_collection_name}") @@ -266,7 +314,7 @@ def index_repository( batch_size=100 ) storage_context = StorageContext.from_defaults(vector_store=vector_store) - + # Create index with temp collection index = VectorStoreIndex.from_documents( [], @@ -278,39 +326,39 @@ def index_repository( # Insert documents in batches with pre-computed embeddings for efficiency logger.info(f"Inserting {len(chunks)} chunks into temporary collection...") embedding_batch_size = 100 # Batch size for embedding API calls - insert_batch_size = 100 # Batch size for Qdrant inserts + insert_batch_size = 100 # Batch size for Qdrant inserts successful_chunks = 0 failed_chunks = 0 - + # Process in larger batches for embedding, then insert total_batches = (len(chunks) + embedding_batch_size - 1) // embedding_batch_size - + for i in range(0, len(chunks), embedding_batch_size): batch = chunks[i:i + embedding_batch_size] batch_num = i // embedding_batch_size + 1 - + try: # Pre-compute embeddings for the entire batch in one API call texts = [node.get_content() for node in batch] embeddings = self.embed_model._get_text_embeddings(texts) - + # Set embeddings on nodes for node, embedding in zip(batch, embeddings): node.embedding = embedding - + # Now insert nodes (they already have embeddings, so no API call needed) index.insert_nodes(batch) successful_chunks += len(batch) logger.info(f"Inserted batch {batch_num}/{total_batches}: {len(batch)} chunks") - + # Clear batch data to free memory del texts del embeddings - + # Periodic garbage collection every 10 batches to prevent memory buildup if batch_num % 10 == 0: gc.collect() - + except Exception as e: logger.error(f"Failed to process batch {batch_num}: {e}") failed_chunks += len(batch) @@ -323,7 +371,8 @@ def index_repository( successful_chunks += 1 failed_chunks -= 1 except Exception as chunk_error: - logger.warning(f"Failed to insert chunk from {chunk.metadata.get('path', 'unknown')}: {chunk_error}") + logger.warning( + f"Failed to insert chunk from {chunk.metadata.get('path', 'unknown')}: {chunk_error}") logger.info(f"Successfully indexed {successful_chunks}/{chunk_count} chunks ({failed_chunks} failed)") @@ -332,81 +381,52 @@ def index_repository( if temp_collection_info.points_count == 0: raise Exception("Temporary collection is empty after indexing") - # SUCCESS: Now swap collections - logger.info(f"Indexing successful. Swapping collections...") - - # Delete old collection if it exists - if old_collection_exists: - logger.info(f"Deleting old collection: {final_collection_name}") - self.qdrant_client.delete_collection(final_collection_name) - - # Rename temp collection to final name - # Note: Qdrant doesn't have a native rename, so we use collection aliases - # or we can just use the temp collection as is and update the name logic - # For simplicity, we'll create the final collection and copy data - # Actually, Qdrant supports renaming via update_collection_aliases - - # Create alias or just recreate with proper name - # Simplest approach: delete old, create new with final name, copy points - # But copying is expensive. Better: use the temp as final. - - # Qdrant 1.7+ supports collection aliases, let's use a simpler approach: - # Just create the final collection fresh and copy - # Actually, the cleanest way is to just keep using temp_collection - # and update our naming. But that breaks consistency. + # SUCCESS: Atomic alias swap (zero-copy) + logger.info(f"Indexing successful. Performing atomic alias swap...") + + # Check if there's a collection (not alias) with the target name + # This happens when migrating from old direct-collection approach to alias-based approach + collections = self.qdrant_client.get_collections().collections + collection_names = [c.name for c in collections] + is_direct_collection = alias_name in collection_names and not self._alias_exists(alias_name) - # Best approach for Qdrant: recreate final collection from temp - logger.info(f"Creating final collection: {final_collection_name}") - self.qdrant_client.create_collection( - collection_name=final_collection_name, - vectors_config=VectorParams( - size=self.config.embedding_dim, - distance=Distance.COSINE + if is_direct_collection: + # Migration case: delete the old direct collection first + logger.info(f"Migrating from direct collection to alias-based indexing. Deleting old collection: {alias_name}") + try: + self.qdrant_client.delete_collection(alias_name) + except Exception as del_err: + logger.warning(f"Failed to delete old direct collection: {del_err}") + + alias_operations = [] + + if old_collection_exists and not is_direct_collection: + # Only delete alias if it exists (not for direct collections which we already deleted) + alias_operations.append( + DeleteAliasOperation(delete_alias=DeleteAlias(alias_name=alias_name)) ) + + alias_operations.append( + CreateAliasOperation(create_alias=CreateAlias( + alias_name=alias_name, + collection_name=temp_collection_name + )) ) - - # Copy all points from temp to final - logger.info(f"Copying {temp_collection_info.points_count} points to final collection...") - offset = None - copied = 0 - while True: - # Scroll through temp collection - records, offset = self.qdrant_client.scroll( - collection_name=temp_collection_name, - limit=100, - offset=offset, - with_payload=True, - with_vectors=True - ) - - if not records: - break - - # Upsert to final collection - points = [ - PointStruct( - id=record.id, - vector=record.vector, - payload=record.payload - ) - for record in records - ] - self.qdrant_client.upsert( - collection_name=final_collection_name, - points=points - ) - copied += len(points) - - if offset is None: - break - - logger.info(f"Copied {copied} points to final collection") - - # Delete temp collection - logger.info(f"Deleting temporary collection: {temp_collection_name}") - self.qdrant_client.delete_collection(temp_collection_name) - - logger.info(f"Index swap completed successfully") + + self.qdrant_client.update_collection_aliases( + change_aliases_operations=alias_operations + ) + + if old_collection_exists: + old_versioned_name = self._find_old_versioned_collection(alias_name) + if old_versioned_name and old_versioned_name != temp_collection_name: + logger.info(f"Deleting old versioned collection: {old_versioned_name}") + try: + self.qdrant_client.delete_collection(old_versioned_name) + except Exception as del_err: + logger.warning(f"Failed to delete old collection: {del_err}") + + logger.info(f"Alias swap completed successfully") except Exception as e: # FAILURE: Clean up temp collection, keep old one intact @@ -429,7 +449,7 @@ def index_repository( del vector_store if 'storage_context' in locals(): del storage_context - + # Force garbage collection to free memory gc.collect() logger.info("Memory cleanup completed after indexing") @@ -442,15 +462,15 @@ def index_repository( return self._get_index_stats(workspace, project, branch) def _store_metadata( - self, - workspace: str, - project: str, - branch: str, - commit: str, - documents: List[Document], - chunks: List[TextNode], - document_count: Optional[int] = None, - chunk_count: Optional[int] = None + self, + workspace: str, + project: str, + branch: str, + commit: str, + documents: List[Document], + chunks: List[TextNode], + document_count: Optional[int] = None, + chunk_count: Optional[int] = None ): """Store metadata in Qdrant collection payload""" namespace = make_namespace(workspace, project, branch) @@ -585,7 +605,6 @@ def delete_index(self, workspace: str, project: str, branch: str): except Exception as e: logger.warning(f"Failed to delete Qdrant collection: {e}") - def _get_index_stats(self, workspace: str, project: str, branch: str) -> IndexStats: """Get statistics about an index""" namespace = make_namespace(workspace, project, branch) diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/core/semantic_splitter.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/semantic_splitter.py new file mode 100644 index 00000000..349cdf23 --- /dev/null +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/core/semantic_splitter.py @@ -0,0 +1,455 @@ +""" +Semantic Code Splitter - Intelligent code splitting using LangChain's language-aware splitters. + +This module provides smart code chunking that: +1. Uses LangChain's RecursiveCharacterTextSplitter with language-specific separators +2. Supports 25+ programming languages out of the box +3. Enriches metadata with semantic information (function names, imports, etc.) +4. Falls back gracefully for unsupported languages +""" + +import re +import hashlib +import logging +from typing import List, Dict, Any, Optional +from dataclasses import dataclass, field +from enum import Enum + +from langchain_text_splitters import RecursiveCharacterTextSplitter, Language +from llama_index.core.schema import Document, TextNode + +logger = logging.getLogger(__name__) + + +class ChunkType(Enum): + """Type of code chunk for semantic understanding""" + CLASS = "class" + FUNCTION = "function" + METHOD = "method" + INTERFACE = "interface" + MODULE = "module" + IMPORTS = "imports" + CONSTANTS = "constants" + DOCUMENTATION = "documentation" + CONFIG = "config" + MIXED = "mixed" + UNKNOWN = "unknown" + + +@dataclass +class CodeBlock: + """Represents a logical block of code""" + content: str + chunk_type: ChunkType + name: Optional[str] = None + parent_name: Optional[str] = None + start_line: int = 0 + end_line: int = 0 + imports: List[str] = field(default_factory=list) + docstring: Optional[str] = None + signature: Optional[str] = None + + +# Map internal language names to LangChain Language enum +LANGUAGE_MAP: Dict[str, Language] = { + 'python': Language.PYTHON, + 'java': Language.JAVA, + 'kotlin': Language.KOTLIN, + 'javascript': Language.JS, + 'typescript': Language.TS, + 'go': Language.GO, + 'rust': Language.RUST, + 'php': Language.PHP, + 'ruby': Language.RUBY, + 'scala': Language.SCALA, + 'swift': Language.SWIFT, + 'c': Language.C, + 'cpp': Language.CPP, + 'csharp': Language.CSHARP, + 'markdown': Language.MARKDOWN, + 'html': Language.HTML, + 'latex': Language.LATEX, + 'rst': Language.RST, + 'lua': Language.LUA, + 'perl': Language.PERL, + 'haskell': Language.HASKELL, + 'solidity': Language.SOL, + 'proto': Language.PROTO, + 'cobol': Language.COBOL, +} + +# Patterns for metadata extraction +METADATA_PATTERNS = { + 'python': { + 'class': re.compile(r'^class\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'^(?:async\s+)?def\s+(\w+)\s*\(', re.MULTILINE), + 'import': re.compile(r'^(?:from\s+[\w.]+\s+)?import\s+.+$', re.MULTILINE), + 'docstring': re.compile(r'"""[\s\S]*?"""|\'\'\'[\s\S]*?\'\'\''), + }, + 'java': { + 'class': re.compile(r'(?:public\s+|private\s+|protected\s+)?(?:abstract\s+|final\s+)?class\s+(\w+)', re.MULTILINE), + 'interface': re.compile(r'(?:public\s+)?interface\s+(\w+)', re.MULTILINE), + 'method': re.compile(r'(?:public|private|protected)\s+(?:static\s+)?(?:final\s+)?[\w<>,\s]+\s+(\w+)\s*\(', re.MULTILINE), + 'import': re.compile(r'^import\s+[\w.*]+;', re.MULTILINE), + }, + 'javascript': { + 'class': re.compile(r'(?:export\s+)?(?:default\s+)?class\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:export\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*\(', re.MULTILINE), + 'arrow': re.compile(r'(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*=>', re.MULTILINE), + 'import': re.compile(r'^import\s+.*?from\s+[\'"]([^\'"]+)[\'"]', re.MULTILINE), + }, + 'typescript': { + 'class': re.compile(r'(?:export\s+)?(?:default\s+)?class\s+(\w+)', re.MULTILINE), + 'interface': re.compile(r'(?:export\s+)?interface\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:export\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*\(', re.MULTILINE), + 'type': re.compile(r'(?:export\s+)?type\s+(\w+)', re.MULTILINE), + 'import': re.compile(r'^import\s+.*?from\s+[\'"]([^\'"]+)[\'"]', re.MULTILINE), + }, + 'go': { + 'function': re.compile(r'^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(', re.MULTILINE), + 'struct': re.compile(r'^type\s+(\w+)\s+struct\s*\{', re.MULTILINE), + 'interface': re.compile(r'^type\s+(\w+)\s+interface\s*\{', re.MULTILINE), + }, + 'rust': { + 'function': re.compile(r'^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)', re.MULTILINE), + 'struct': re.compile(r'^(?:pub\s+)?struct\s+(\w+)', re.MULTILINE), + 'impl': re.compile(r'^impl(?:<[^>]+>)?\s+(?:\w+\s+for\s+)?(\w+)', re.MULTILINE), + 'trait': re.compile(r'^(?:pub\s+)?trait\s+(\w+)', re.MULTILINE), + }, + 'php': { + 'class': re.compile(r'(?:abstract\s+|final\s+)?class\s+(\w+)', re.MULTILINE), + 'interface': re.compile(r'interface\s+(\w+)', re.MULTILINE), + 'function': re.compile(r'(?:public|private|protected|static|\s)*function\s+(\w+)\s*\(', re.MULTILINE), + }, + 'csharp': { + 'class': re.compile(r'(?:public\s+|private\s+|protected\s+)?(?:abstract\s+|sealed\s+)?class\s+(\w+)', re.MULTILINE), + 'interface': re.compile(r'(?:public\s+)?interface\s+(\w+)', re.MULTILINE), + 'method': re.compile(r'(?:public|private|protected)\s+(?:static\s+)?(?:async\s+)?[\w<>,\s]+\s+(\w+)\s*\(', re.MULTILINE), + }, +} + + +class SemanticCodeSplitter: + """ + Intelligent code splitter using LangChain's language-aware text splitters. + + Features: + - Uses LangChain's RecursiveCharacterTextSplitter with language-specific separators + - Supports 25+ programming languages (Python, Java, JS/TS, Go, Rust, PHP, etc.) + - Enriches chunks with semantic metadata (function names, classes, imports) + - Graceful fallback for unsupported languages + """ + + DEFAULT_CHUNK_SIZE = 1500 + DEFAULT_CHUNK_OVERLAP = 200 + DEFAULT_MIN_CHUNK_SIZE = 100 + + def __init__( + self, + max_chunk_size: int = DEFAULT_CHUNK_SIZE, + min_chunk_size: int = DEFAULT_MIN_CHUNK_SIZE, + overlap: int = DEFAULT_CHUNK_OVERLAP + ): + self.max_chunk_size = max_chunk_size + self.min_chunk_size = min_chunk_size + self.overlap = overlap + + # Cache splitters for reuse + self._splitter_cache: Dict[str, RecursiveCharacterTextSplitter] = {} + + # Default splitter for unknown languages + self._default_splitter = RecursiveCharacterTextSplitter( + chunk_size=max_chunk_size, + chunk_overlap=overlap, + length_function=len, + is_separator_regex=False, + ) + + @staticmethod + def _make_deterministic_id(namespace: str, path: str, chunk_index: int) -> str: + """Generate deterministic chunk ID for idempotent indexing""" + key = f"{namespace}:{path}:{chunk_index}" + return hashlib.sha256(key.encode()).hexdigest()[:32] + + def _get_splitter(self, language: str) -> RecursiveCharacterTextSplitter: + """Get or create a language-specific splitter""" + if language in self._splitter_cache: + return self._splitter_cache[language] + + lang_enum = LANGUAGE_MAP.get(language.lower()) + + if lang_enum: + splitter = RecursiveCharacterTextSplitter.from_language( + language=lang_enum, + chunk_size=self.max_chunk_size, + chunk_overlap=self.overlap, + ) + self._splitter_cache[language] = splitter + return splitter + + return self._default_splitter + + def split_documents(self, documents: List[Document]) -> List[TextNode]: + """Split documents into semantic chunks with enriched metadata""" + return list(self.iter_split_documents(documents)) + + def iter_split_documents(self, documents: List[Document]): + """Generator that yields chunks one at a time for memory efficiency""" + for doc in documents: + language = doc.metadata.get("language", "text") + path = doc.metadata.get("path", "unknown") + + try: + for node in self._split_document(doc, language): + yield node + except Exception as e: + logger.warning(f"Splitting failed for {path}: {e}, using fallback") + for node in self._fallback_split(doc): + yield node + + def _split_document(self, doc: Document, language: str) -> List[TextNode]: + """Split a single document using language-aware splitter""" + text = doc.text + + if not text or not text.strip(): + return [] + + # Get language-specific splitter + splitter = self._get_splitter(language) + + # Split the text + chunks = splitter.split_text(text) + + # Filter empty chunks and convert to nodes with metadata + nodes = [] + text_offset = 0 + + for i, chunk in enumerate(chunks): + if not chunk or not chunk.strip(): + continue + + # Skip very small chunks unless they're standalone + if len(chunk.strip()) < self.min_chunk_size and len(chunks) > 1: + # Try to find and merge with adjacent chunk + continue + + # Calculate approximate line numbers + start_line = text[:text_offset].count('\n') + 1 if text_offset > 0 else 1 + chunk_pos = text.find(chunk, text_offset) + if chunk_pos >= 0: + text_offset = chunk_pos + len(chunk) + end_line = start_line + chunk.count('\n') + + # Extract semantic metadata + metadata = self._extract_metadata(chunk, language, doc.metadata) + metadata.update({ + 'chunk_index': i, + 'total_chunks': len(chunks), + 'start_line': start_line, + 'end_line': end_line, + }) + + chunk_id = self._make_deterministic_id( + metadata.get('namespace', ''), + metadata.get('path', ''), + i + ) + node = TextNode( + id_=chunk_id, + text=chunk, + metadata=metadata + ) + nodes.append(node) + + return nodes + + def _extract_metadata( + self, + chunk: str, + language: str, + base_metadata: Dict[str, Any] + ) -> Dict[str, Any]: + """Extract semantic metadata from a code chunk""" + metadata = dict(base_metadata) + + # Determine chunk type and extract names + chunk_type = ChunkType.MIXED + names = [] + imports = [] + + patterns = METADATA_PATTERNS.get(language.lower(), {}) + + # Check for classes + if 'class' in patterns: + matches = patterns['class'].findall(chunk) + if matches: + chunk_type = ChunkType.CLASS + names.extend(matches) + + # Check for interfaces + if 'interface' in patterns: + matches = patterns['interface'].findall(chunk) + if matches: + chunk_type = ChunkType.INTERFACE + names.extend(matches) + + # Check for functions/methods + if chunk_type == ChunkType.MIXED: + for key in ['function', 'method', 'arrow']: + if key in patterns: + matches = patterns[key].findall(chunk) + if matches: + chunk_type = ChunkType.FUNCTION + names.extend(matches) + break + + # Check for imports + if 'import' in patterns: + import_matches = patterns['import'].findall(chunk) + if import_matches: + imports = import_matches[:10] # Limit + if not names: # Pure import block + chunk_type = ChunkType.IMPORTS + + # Check for documentation files + if language in ('markdown', 'rst', 'text'): + chunk_type = ChunkType.DOCUMENTATION + + # Check for config files + if language in ('json', 'yaml', 'yml', 'toml', 'xml', 'ini'): + chunk_type = ChunkType.CONFIG + + # Extract docstring if present + docstring = self._extract_docstring(chunk, language) + + # Extract function signature + signature = self._extract_signature(chunk, language) + + # Update metadata + metadata['chunk_type'] = chunk_type.value + + if names: + metadata['semantic_names'] = names[:5] # Limit to 5 names + metadata['primary_name'] = names[0] + + if imports: + metadata['imports'] = imports + + if docstring: + metadata['docstring'] = docstring[:500] # Limit size + + if signature: + metadata['signature'] = signature + + return metadata + + def _extract_docstring(self, chunk: str, language: str) -> Optional[str]: + """Extract docstring from code chunk""" + if language == 'python': + # Python docstrings + match = re.search(r'"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\'', chunk) + if match: + return (match.group(1) or match.group(2)).strip() + + elif language in ('javascript', 'typescript', 'java', 'csharp', 'php', 'go'): + # JSDoc / JavaDoc style + match = re.search(r'/\*\*([\s\S]*?)\*/', chunk) + if match: + # Clean up the comment + doc = match.group(1) + doc = re.sub(r'^\s*\*\s?', '', doc, flags=re.MULTILINE) + return doc.strip() + + return None + + def _extract_signature(self, chunk: str, language: str) -> Optional[str]: + """Extract function/method signature from code chunk""" + lines = chunk.split('\n') + + for line in lines[:10]: # Check first 10 lines + line = line.strip() + + if language == 'python': + if line.startswith(('def ', 'async def ')): + # Get full signature including multi-line params + sig = line + if ')' not in sig: + # Multi-line signature + idx = lines.index(line.strip()) if line.strip() in lines else -1 + if idx >= 0: + for next_line in lines[idx+1:idx+5]: + sig += ' ' + next_line.strip() + if ')' in next_line: + break + return sig.split(':')[0] + ':' + + elif language in ('java', 'csharp', 'kotlin'): + if any(kw in line for kw in ['public ', 'private ', 'protected ', 'internal ']): + if '(' in line and not line.startswith('//'): + return line.split('{')[0].strip() + + elif language in ('javascript', 'typescript'): + if line.startswith(('function ', 'async function ')): + return line.split('{')[0].strip() + if '=>' in line and '(' in line: + return line.split('=>')[0].strip() + ' =>' + + elif language == 'go': + if line.startswith('func '): + return line.split('{')[0].strip() + + elif language == 'rust': + if line.startswith(('fn ', 'pub fn ', 'async fn ', 'pub async fn ')): + return line.split('{')[0].strip() + + return None + + def _fallback_split(self, doc: Document) -> List[TextNode]: + """Fallback splitting for problematic documents""" + text = doc.text + + if not text or not text.strip(): + return [] + + # Use default splitter + chunks = self._default_splitter.split_text(text) + + nodes = [] + for i, chunk in enumerate(chunks): + if not chunk or not chunk.strip(): + continue + + # Truncate if too large + if len(chunk) > 30000: + chunk = chunk[:30000] + + metadata = dict(doc.metadata) + metadata['chunk_index'] = i + metadata['total_chunks'] = len(chunks) + metadata['chunk_type'] = 'fallback' + + chunk_id = self._make_deterministic_id( + metadata.get('namespace', ''), + metadata.get('path', ''), + i + ) + nodes.append(TextNode( + id_=chunk_id, + text=chunk, + metadata=metadata + )) + + return nodes + + @staticmethod + def get_supported_languages() -> List[str]: + """Return list of supported languages""" + return list(LANGUAGE_MAP.keys()) + + @staticmethod + def get_separators_for_language(language: str) -> Optional[List[str]]: + """Get the separators used for a specific language""" + lang_enum = LANGUAGE_MAP.get(language.lower()) + if lang_enum: + return RecursiveCharacterTextSplitter.get_separators_for_language(lang_enum) + return None 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 49935b8a..80c5e514 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 @@ -13,7 +13,6 @@ logger = logging.getLogger(__name__) - # File priority patterns for smart RAG HIGH_PRIORITY_PATTERNS = [ 'service', 'controller', 'handler', 'api', 'core', 'auth', 'security', @@ -29,6 +28,15 @@ 'test', 'spec', 'config', 'mock', 'fixture', 'stub' ] +# Content type priorities for AST-based chunks +# functions_classes are more valuable than simplified_code (placeholders) +CONTENT_TYPE_BOOST = { + 'functions_classes': 1.2, # Full function/class definitions - highest value + 'fallback': 1.0, # Regex-based split - normal value + 'oversized_split': 0.95, # Large chunks that were split - slightly lower + 'simplified_code': 0.7, # Code with placeholders - lower value (context only) +} + class RAGQueryService: """Service for querying RAG indices using Qdrant""" @@ -48,20 +56,44 @@ def __init__(self, config: RAGConfig): max_retries=3 ) + def _collection_or_alias_exists(self, name: str) -> bool: + """Check if a collection or alias with the given name exists. + + With alias-based indexing, the collection_name we use is actually an alias + pointing to a versioned collection. QdrantVectorStore can work with aliases + transparently, but we need to check both collections and aliases when + verifying existence. + """ + try: + # Check if it's a direct collection + collections = [c.name for c in self.qdrant_client.get_collections().collections] + if name in collections: + return True + + # Check if it's an alias + aliases = self.qdrant_client.get_aliases() + if any(a.alias_name == name for a in aliases.aliases): + return True + + return False + except Exception as e: + logger.warning(f"Error checking collection/alias existence: {e}") + return False + def _get_collection_name(self, workspace: str, project: str, branch: str) -> str: """Generate collection name""" namespace = make_namespace(workspace, project, branch) return f"{self.config.qdrant_collection_prefix}_{namespace}" def semantic_search( - self, - query: str, - workspace: str, - project: str, - branch: str, - top_k: int = 10, - filter_language: Optional[str] = None, - instruction_type: InstructionType = InstructionType.GENERAL + self, + query: str, + workspace: str, + project: str, + branch: str, + top_k: int = 10, + filter_language: Optional[str] = None, + instruction_type: InstructionType = InstructionType.GENERAL ) -> List[Dict]: """Perform semantic search in the repository""" collection_name = self._get_collection_name(workspace, project, branch) @@ -69,9 +101,8 @@ def semantic_search( logger.info(f"Searching in {collection_name} for: {query[:50]}...") try: - # Check if collection exists - collections = [c.name for c in self.qdrant_client.get_collections().collections] - if collection_name not in collections: + # Check if collection or alias exists + if not self._collection_or_alias_exists(collection_name): logger.warning(f"Collection {collection_name} does not exist") return [] @@ -120,29 +151,30 @@ def semantic_search( return [] def get_context_for_pr( - self, - workspace: str, - project: str, - branch: str, - changed_files: List[str], - diff_snippets: Optional[List[str]] = None, - pr_title: Optional[str] = None, - pr_description: Optional[str] = None, - top_k: int = 15, # Increased default top_k since we filter later - enable_priority_reranking: bool = True, - min_relevance_score: float = 0.7 + self, + workspace: str, + project: str, + branch: str, + changed_files: List[str], + diff_snippets: Optional[List[str]] = None, + pr_title: Optional[str] = None, + pr_description: Optional[str] = None, + top_k: int = 15, # Increased default top_k since we filter later + enable_priority_reranking: bool = True, + min_relevance_score: float = 0.7 ) -> Dict: """ Get relevant context for PR review using Smart RAG (Query Decomposition). Executes multiple targeted queries and merges results with intelligent filtering. - + Lost-in-the-Middle protection features: - Priority-based score boosting for core files - Configurable relevance threshold - Deduplication of similar chunks """ diff_snippets = diff_snippets or [] - logger.info(f"Smart RAG: Decomposing queries for {len(changed_files)} files (priority_reranking={enable_priority_reranking})") + logger.info( + f"Smart RAG: Decomposing queries for {len(changed_files)} files (priority_reranking={enable_priority_reranking})") # 1. Decompose into multiple targeted queries queries = self._decompose_queries( @@ -153,12 +185,12 @@ def get_context_for_pr( ) all_results = [] - + # 2. Execute queries (sequentially for now, could be parallelized) for q_text, q_weight, q_top_k, q_instruction_type in queries: if not q_text.strip(): continue - + results = self.semantic_search( query=q_text, workspace=workspace, @@ -167,19 +199,19 @@ def get_context_for_pr( top_k=q_top_k, instruction_type=q_instruction_type ) - + # Attach weight metadata to results for ranking for r in results: r["_query_weight"] = q_weight - + all_results.extend(results) # 3. Merge, Deduplicate, and Rank (with priority boosting if enabled) final_results = self._merge_and_rank_results( - all_results, + all_results, min_score_threshold=min_relevance_score if enable_priority_reranking else 0.5 ) - + # 4. Fallback if smart filtering was too aggressive if not final_results and all_results: logger.info("Smart RAG: threshold too strict, falling back to top raw results") @@ -189,7 +221,7 @@ def get_context_for_pr( seen = set() unique_fallback = [] for r in raw_sorted: - content_hash = f"{r['metadata'].get('file_path','')}:{r['text']}" + content_hash = f"{r['metadata'].get('file_path', '')}:{r['text']}" if content_hash not in seen: seen.add(content_hash) unique_fallback.append(r) @@ -208,7 +240,7 @@ def get_context_for_pr( if "path" in result["metadata"]: related_files.add(result["metadata"]["path"]) - + logger.info(f"Smart RAG: Final context has {len(relevant_code)} chunks from {len(related_files)} files") return { @@ -218,11 +250,11 @@ def get_context_for_pr( } def _decompose_queries( - self, - pr_title: Optional[str], - pr_description: Optional[str], - diff_snippets: List[str], - changed_files: List[str] + self, + pr_title: Optional[str], + pr_description: Optional[str], + diff_snippets: List[str], + changed_files: List[str] ) -> List[tuple]: """ Generate a list of (query_text, weight, top_k) tuples. @@ -236,7 +268,7 @@ def _decompose_queries( intent_parts = [] if pr_title: intent_parts.append(pr_title) if pr_description: intent_parts.append(pr_description[:500]) - + if intent_parts: queries.append((" ".join(intent_parts), 1.0, 10, InstructionType.GENERAL)) @@ -246,7 +278,7 @@ def _decompose_queries( dir_groups = defaultdict(list) for f in changed_files: # removing filename to get dir - d = os.path.dirname(f) + d = os.path.dirname(f) # if root file, group under 'root' d = d if d else "root" dir_groups[d].append(os.path.basename(f)) @@ -258,16 +290,16 @@ def _decompose_queries( for dir_path, files in sorted_dirs[:5]: # Construct a query for this cluster # "logic related to src/auth involving: Login.tsx, Register.tsx, User.ts..." - + # If too many files in one dir, truncate list to avoid embedding overflow display_files = files[:10] files_str = ", ".join(display_files) if len(files) > 10: files_str += "..." - + clean_path = "root directory" if dir_path == "root" else dir_path q = f"logic in {clean_path} related to {files_str}" - + queries.append((q, 0.8, 5, InstructionType.LOGIC)) # C. Snippet Queries (Low Level) - Weight 1.2 (High precision) @@ -276,7 +308,7 @@ def _decompose_queries( lines = [l.strip() for l in snippet.split('\n') if l.strip() and not l.startswith(('+', '-'))] if lines: # Join first 2-3 significant lines - clean_snippet = " ".join(lines[:3]) + clean_snippet = " ".join(lines[:3]) if len(clean_snippet) > 10: queries.append((clean_snippet, 1.2, 5, InstructionType.DEPENDENCY)) @@ -285,44 +317,70 @@ def _decompose_queries( 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. + + Applies three types of boosting: + 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) """ grouped = {} - + # Deduplicate by file_path + content hash for r in results: key = f"{r['metadata'].get('file_path', 'unknown')}_{hash(r['text'])}" - + # Keep the highest scoring occurrence if key not in grouped: grouped[key] = r else: if r['score'] > grouped[key]['score']: grouped[key] = r - + unique_results = list(grouped.values()) - - # Apply priority-based score boosting + + # Apply multi-factor score boosting for result in unique_results: - file_path = result['metadata'].get('path', result['metadata'].get('file_path', '')).lower() - - # Boost high-priority files + metadata = result.get('metadata', {}) + file_path = metadata.get('path', metadata.get('file_path', '')).lower() + content_type = metadata.get('content_type', 'fallback') + semantic_names = metadata.get('semantic_names', []) + + base_score = result['score'] + + # 1. File path priority boosting if any(p in file_path for p in HIGH_PRIORITY_PATTERNS): - result['score'] = min(1.0, result['score'] * 1.3) + base_score *= 1.3 result['_priority'] = 'HIGH' elif any(p in file_path for p in MEDIUM_PRIORITY_PATTERNS): - result['score'] = min(1.0, result['score'] * 1.1) + base_score *= 1.1 result['_priority'] = 'MEDIUM' elif any(p in file_path for p in LOW_PRIORITY_PATTERNS): - result['score'] = result['score'] * 0.8 # Penalize test/config files + base_score *= 0.8 # Penalize test/config files result['_priority'] = 'LOW' else: result['_priority'] = 'MEDIUM' - + + # 2. Content type boosting (AST-based metadata) + content_boost = CONTENT_TYPE_BOOST.get(content_type, 1.0) + base_score *= content_boost + result['_content_type'] = content_type + + # 3. Semantic name bonus - chunks with extracted names are more valuable + if semantic_names: + base_score *= 1.1 # 10% bonus for having semantic names + result['_has_semantic_names'] = True + + # 4. Docstring bonus - chunks with docstrings provide better context + if metadata.get('docstring'): + base_score *= 1.05 # 5% bonus for having docstring + + result['score'] = min(1.0, base_score) + # Filter by threshold filtered = [r for r in unique_results if r['score'] >= min_score_threshold] - + # Sort by score descending filtered.sort(key=lambda x: x['score'], reverse=True) - + return filtered diff --git a/python-ecosystem/rag-pipeline/src/rag_pipeline/utils/utils.py b/python-ecosystem/rag-pipeline/src/rag_pipeline/utils/utils.py index 781fcafe..0e2fd410 100644 --- a/python-ecosystem/rag-pipeline/src/rag_pipeline/utils/utils.py +++ b/python-ecosystem/rag-pipeline/src/rag_pipeline/utils/utils.py @@ -10,6 +10,7 @@ '.tsx': 'typescript', '.java': 'java', '.kt': 'kotlin', + '.phtml': 'php', '.php': 'php', '.go': 'go', '.rs': 'rust', diff --git a/tools/mcp-testing/mcp.json b/tools/mcp-testing/mcp.json index d5315fcf..645ac34c 100644 --- a/tools/mcp-testing/mcp.json +++ b/tools/mcp-testing/mcp.json @@ -4,7 +4,7 @@ "command": "java", "args": [ "-jar", - "/var/www/html/codecrow/java-bitbucket-mcp/codecrow-mcp-servers/target/codecrow-mcp-servers-1.0.jar" + "/var/www/html/codecrow/vcs-mcp/codecrow-vcs-mcp/target/codecrow-vcs-mcp-1.0.jar" ] } } diff --git a/tools/production-build.sh b/tools/production-build.sh index 4baff2f2..aea5b277 100755 --- a/tools/production-build.sh +++ b/tools/production-build.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -MCP_SERVERS_JAR_PATH="java-ecosystem/mcp-servers/bitbucket-mcp/target/codecrow-mcp-servers-1.0.jar" +MCP_SERVERS_JAR_PATH="java-ecosystem/mcp-servers/vcs-mcp/target/codecrow-vcs-mcp-1.0.jar" PLATFORM_MCP_JAR_PATH="java-ecosystem/mcp-servers/platform-mcp/target/codecrow-platform-mcp-1.0.jar" FRONTEND_REPO_URL="git@github.com:rostilos/CodeCrow-Frontend.git" FRONTEND_DIR="frontend" @@ -38,7 +38,7 @@ echo "--- 3. Building Java Artifacts (mvn clean package) ---" (cd "$JAVA_DIR" && mvn clean package -DskipTests) echo "--- 4. MCP Servers jar update ---" -cp "$MCP_SERVERS_JAR_PATH" python-ecosystem/mcp-client/codecrow-mcp-servers-1.0.jar +cp "$MCP_SERVERS_JAR_PATH" python-ecosystem/mcp-client/codecrow-vcs-mcp-1.0.jar echo "--- 4.1. Platform MCP jar update ---" if [ -f "$PLATFORM_MCP_JAR_PATH" ]; then