diff --git a/deployment/config/java-shared/application.properties.sample b/deployment/config/java-shared/application.properties.sample index 6fde5e33..b1d29de1 100644 --- a/deployment/config/java-shared/application.properties.sample +++ b/deployment/config/java-shared/application.properties.sample @@ -161,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/docs/architecture/mcp-scaling-strategy.md b/docs/architecture/mcp-scaling-strategy.md deleted file mode 100644 index e69de29b..00000000 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/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 f4005db8..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 ); } 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/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/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__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/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/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 966671f1..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 @@ -53,6 +53,7 @@ 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(); @@ -72,6 +73,15 @@ 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, @@ -173,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); @@ -183,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()); @@ -210,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 @@ -227,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) { @@ -399,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. */ @@ -457,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); }; } @@ -510,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()); } /** @@ -526,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/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/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/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/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 3dc4617a..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); }; } 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 23b61ad3..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 @@ -91,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) { @@ -99,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 } /** @@ -111,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; }; } 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/integration/controller/OAuthCallbackController.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/OAuthCallbackController.java index 639b7faf..32a2afeb 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/OAuthCallbackController.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/controller/OAuthCallbackController.java @@ -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/integration/service/VcsIntegrationService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/VcsIntegrationService.java index e3941267..7491cc2c 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/VcsIntegrationService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/integration/service/VcsIntegrationService.java @@ -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,6 +244,53 @@ 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. */ @@ -236,6 +302,7 @@ public VcsConnectionDTO handleAppCallback(EVcsProvider provider, String code, St 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,8 +605,161 @@ 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) throws IOException { @@ -547,6 +767,29 @@ public VcsRepositoryListDTO listRepositories(Long workspaceId, Long connectionId 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( @@ -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/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/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/vcs/service/VcsConnectionWebService.java b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/service/VcsConnectionWebService.java index e11b391d..c0799562 100644 --- a/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/service/VcsConnectionWebService.java +++ b/java-ecosystem/services/web-server/src/main/java/org/rostilos/codecrow/webserver/vcs/service/VcsConnectionWebService.java @@ -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.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; + } }