diff --git a/.gitignore b/.gitignore index 7d7b4a39e..45d80b842 100644 --- a/.gitignore +++ b/.gitignore @@ -174,6 +174,7 @@ application-live-local.yml server/application-server/postgres-data/ server/application-server/postgres-data-temp/ server/application-server/postgres-data-local/ +server/application-server/postgres-data*.tgz server/application-server/logs/ # Private keys @@ -257,4 +258,4 @@ MISSION.md **/http-client.private.env.json # Github Copilot persisted session migrations, see: https://github.com/microsoft/copilot-intellij-feedback/issues/712#issuecomment-3322062215 -**/.idea/**/copilot.data.migration.*.xml \ No newline at end of file +**/.idea/**/copilot.data.migration.*.xml diff --git a/.graphqlrc.yml b/.graphqlrc.yml index f4cdafb67..5b771e4a1 100644 --- a/.graphqlrc.yml +++ b/.graphqlrc.yml @@ -1,5 +1,5 @@ # GraphQL Configuration for Hephaestus -# Enables VSCode IntelliSense for GraphQL operations against GitHub API +# Enables VSCode IntelliSense for GraphQL operations against GitHub and GitLab APIs # Extension: GraphQL.vscode-graphql # Docs: https://the-guild.dev/graphql/config @@ -7,10 +7,33 @@ projects: github: # GitHub GraphQL API schema (downloaded from docs.github.com) schema: "server/application-server/src/main/resources/graphql/github/schema.github.graphql" - - # GraphQL operations (Spring's standard location) - documents: "server/application-server/src/main/resources/graphql-documents/**/*.graphql" - + + # GraphQL operations (queries, mutations, fragments) + documents: + - "server/application-server/src/main/resources/graphql/github/operations/**/*.graphql" + - "server/application-server/src/main/resources/graphql/github/fragments/**/*.graphql" + + # Restrict this project to GitHub files only (prevents cross-schema validation) + include: + - "server/application-server/src/main/resources/graphql/github/**" + + extensions: + languageService: + enableHover: true + enableAutoComplete: true + enableValidation: true + + gitlab: + # GitLab GraphQL API schema + schema: "server/application-server/src/main/resources/graphql/gitlab/schema.gitlab.graphql" + + # GitLab GraphQL operations + documents: "server/application-server/src/main/resources/graphql/gitlab/operations/**/*.graphql" + + # Restrict this project to GitLab files only (prevents cross-schema validation) + include: + - "server/application-server/src/main/resources/graphql/gitlab/**" + extensions: languageService: enableHover: true diff --git a/docs/contributor/erd/schema.mmd b/docs/contributor/erd/schema.mmd index acb0dc4ef..aaed8b913 100644 --- a/docs/contributor/erd/schema.mmd +++ b/docs/contributor/erd/schema.mmd @@ -120,6 +120,8 @@ erDiagram VARCHAR(128) category_id FK BIGINT answer_chosen_by_id FK BIGINT answer_comment_id FK,UK + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } DiscussionCategory { @@ -148,6 +150,8 @@ erDiagram BIGINT discussion_id FK BIGINT author_id FK BIGINT parent_comment_id FK + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } DiscussionLabel { @@ -197,6 +201,13 @@ erDiagram VARCHAR(255) on_behalf_of_login } + GitProvider { + BIGINT id PK + VARCHAR(10) type UK "NOT NULL" + VARCHAR(512) server_url UK "NOT NULL" + TIMESTAMPTZ created_at "NOT NULL" + } + Issue { VARCHAR(31) issue_type "NOT NULL" BIGINT id PK @@ -235,6 +246,8 @@ erDiagram VARCHAR(40) head_ref_oid VARCHAR(255) base_ref_name VARCHAR(40) base_ref_oid + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } IssueAssignee { @@ -256,6 +269,8 @@ erDiagram BIGINT author_id FK BIGINT issue_id FK TEXT body + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } IssueLabel { @@ -300,6 +315,8 @@ erDiagram INTEGER closed_issues_count "NOT NULL" INTEGER open_issues_count "NOT NULL" TIMESTAMPTZ last_sync_at + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } Organization { @@ -307,11 +324,12 @@ erDiagram TIMESTAMPTZ created_at TIMESTAMPTZ updated_at VARCHAR(255) avatar_url - BIGINT github_id UK "NOT NULL" + BIGINT native_id UK "NOT NULL" VARCHAR(255) html_url VARCHAR(255) login UK "NOT NULL" VARCHAR(255) name TIMESTAMPTZ last_sync_at + BIGINT provider_id FK,UK "NOT NULL" } OrganizationMembership { @@ -344,6 +362,8 @@ erDiagram TIMESTAMPTZ status_updates_synced_at TIMESTAMPTZ created_at TIMESTAMPTZ updated_at + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } ProjectField { @@ -381,6 +401,8 @@ erDiagram BIGINT creator_id FK TIMESTAMPTZ created_at TIMESTAMPTZ updated_at + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } ProjectStatusUpdate { @@ -395,6 +417,8 @@ erDiagram BIGINT creator_id FK TIMESTAMPTZ created_at TIMESTAMPTZ updated_at + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } PullRequestBadPractice { @@ -452,6 +476,8 @@ erDiagram TEXT body TEXT diff_hunk BOOLEAN outdated + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } PullRequestReviewThread { @@ -470,6 +496,8 @@ erDiagram BOOLEAN outdated BOOLEAN collapsed BIGINT resolved_by_id FK + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } Repository { @@ -489,6 +517,8 @@ erDiagram BIGINT organization_id FK TIMESTAMPTZ last_sync_at BOOLEAN has_discussions_enabled "NOT NULL" + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } RepositoryCollaborator { @@ -529,6 +559,8 @@ erDiagram BIGINT parent_id VARCHAR(32) privacy TIMESTAMPTZ updated_at + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } TeamMembership { @@ -553,6 +585,8 @@ erDiagram VARCHAR(255) login VARCHAR(255) name VARCHAR(255) type + BIGINT native_id UK "NOT NULL" + BIGINT provider_id FK,UK "NOT NULL" } UserPreference { @@ -637,17 +671,31 @@ erDiagram ChatMessage ||--|| ChatThread : references GitCommit ||--|| CommitContributor : has DiscussionComment ||--|| Discussion : references + GitProvider ||--|| Discussion : references Repository ||--|| Discussion : has Repository ||--|| DiscussionCategory : has + GitProvider ||--|| DiscussionComment : commented_on Repository ||--|| GitCommit : has + GitProvider ||--|| Issue : references Repository ||--|| Issue : has + GitProvider ||--|| IssueComment : commented_on Repository ||--|| Label : labeled + GitProvider ||--|| Milestone : references Repository ||--|| Milestone : has + GitProvider ||--|| Organization : references + GitProvider ||--|| Project : references Project ||--|| ProjectField : has ProjectField ||--|| ProjectFieldValue : has ProjectItem ||--|| ProjectFieldValue : has Project ||--|| ProjectItem : has + GitProvider ||--|| ProjectItem : references + GitProvider ||--|| ProjectStatusUpdate : references + GitProvider ||--|| PullRequestReviewComment : commented_on + GitProvider ||--|| PullRequestReviewThread : reviewed PullRequestReviewComment ||--|| PullRequestReviewThread : reviewed + GitProvider ||--|| Repository : references + GitProvider ||--|| Team : references + GitProvider ||--|| User : references User ||--|| UserPreference : has Organization ||--|| Workspace : has User ||--|| WorkspaceMembership : belongs_to diff --git a/scripts/generate-mermaid-erd.ts b/scripts/generate-mermaid-erd.ts index 4f4ce2d0e..b86655403 100644 --- a/scripts/generate-mermaid-erd.ts +++ b/scripts/generate-mermaid-erd.ts @@ -167,7 +167,7 @@ class MermaidErdGenerator { CASE WHEN uk.column_name IS NOT NULL THEN 'YES' ELSE 'NO' END as is_unique_key FROM information_schema.columns c LEFT JOIN ( - SELECT ku.column_name + SELECT DISTINCT ku.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name @@ -177,7 +177,7 @@ class MermaidErdGenerator { AND tc.constraint_type = 'PRIMARY KEY' ) pk ON c.column_name = pk.column_name LEFT JOIN ( - SELECT ku.column_name + SELECT DISTINCT ku.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name @@ -187,7 +187,7 @@ class MermaidErdGenerator { AND tc.constraint_type = 'FOREIGN KEY' ) fk ON c.column_name = fk.column_name LEFT JOIN ( - SELECT ku.column_name + SELECT DISTINCT ku.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage ku ON tc.constraint_name = ku.constraint_name diff --git a/server/application-server/pom.xml b/server/application-server/pom.xml index f4b05380e..28c6313a2 100644 --- a/server/application-server/pom.xml +++ b/server/application-server/pom.xml @@ -501,7 +501,8 @@ - ${project.build.directory}/generated-sources/graphql + ${project.build.directory}/generated-sources/graphql-github + ${project.build.directory}/generated-sources/graphql-gitlab @@ -533,7 +534,7 @@ - ${project.build.directory}/generated-sources/graphql + ${project.build.directory}/generated-sources/graphql-github de.tum.in.www1.hephaestus.gitprovider.graphql.github de.tum.in.www1.hephaestus.gitprovider.graphql.github.api de.tum.in.www1.hephaestus.gitprovider.graphql.github.model @@ -600,6 +601,104 @@ + + + + generate-gitlab-graphql-client + + generate + + + ${graphql.codegen.skip} + + true + + + + ${project.basedir}/src/main/resources/graphql/gitlab + + + + ${project.build.directory}/generated-sources/graphql-gitlab + de.tum.in.www1.hephaestus.gitprovider.graphql.gitlab + de.tum.in.www1.hephaestus.gitprovider.graphql.gitlab.api + de.tum.in.www1.hephaestus.gitprovider.graphql.gitlab.model + + GL + + + true + false + false + + + + true + true + true + false + false + true + + true + + + JAVA + true + jakarta.annotation.Generated + @jakarta.annotation.Generated("graphql-codegen") + + + + + + java.time.OffsetDateTime + java.time.LocalDate + java.lang.String + java.lang.Object + java.lang.String + java.lang.String + java.math.BigInteger + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + java.lang.String + + + + + Request + Response + ResponseProjection + + + ${project.basedir}/src/main/resources/graphql/templates + + + response_projection.ftl + + + @@ -622,7 +721,7 @@ + dir="${project.build.directory}/generated-sources/graphql-github/de/tum/in/www1/hephaestus/gitprovider/graphql/github/model"> @@ -634,7 +733,7 @@ + dir="${project.build.directory}/generated-sources/graphql-github/de/tum/in/www1/hephaestus/gitprovider/graphql/github/model"> @@ -825,7 +924,8 @@ false - ${project.build.directory}/generated-sources/graphql + ${project.build.directory}/generated-sources/graphql-github + ${project.build.directory}/generated-sources/graphql-gitlab diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/activity/scoring/ExperiencePointCalculator.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/activity/scoring/ExperiencePointCalculator.java index 21e6c09c1..7fbaae86d 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/activity/scoring/ExperiencePointCalculator.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/activity/scoring/ExperiencePointCalculator.java @@ -12,6 +12,7 @@ import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import org.hibernate.Hibernate; import org.springframework.stereotype.Component; /** @@ -342,10 +343,16 @@ public double calculateIssueCommentExperiencePoints(IssueComment issueComment) { return 0; } + // Unwrap Hibernate proxy to get the actual entity subclass. + // When IssueComment.issue is lazy-loaded, Hibernate creates an Issue$HibernateProxy + // that cannot be cast to PullRequest even when the underlying row is a PullRequest + // (SINGLE_TABLE inheritance). Unproxying resolves the real entity type. + issue = (Issue) Hibernate.unproxy(issue); + PullRequest pullRequest; - if (issue.isPullRequest()) { - pullRequest = (PullRequest) issue; + if (issue instanceof PullRequest pr) { + pullRequest = pr; } else { if (issue.getRepository() == null) { log.warn("Skipped XP calculation, issue has no repository: issueId={}", issue.getId()); diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitLabGraphQlConfig.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitLabGraphQlConfig.java index a01eba728..e2b04c9cd 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitLabGraphQlConfig.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/config/GitLabGraphQlConfig.java @@ -21,8 +21,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; import org.springframework.graphql.client.HttpGraphQlClient; -import org.springframework.graphql.support.ResourceDocumentSource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; @@ -95,11 +95,18 @@ public WebClient gitLabGraphQlWebClient() { @Bean @Qualifier("gitLabGraphQlClient") public HttpGraphQlClient gitLabGraphQlClient(@Qualifier("gitLabGraphQlWebClient") WebClient webClient) { - // Load .graphql operation files by name (e.g., documentName("GetGroup")) - // from the classpath. No fragment merging needed — GitLab operations are self-contained. - ResourceDocumentSource documentSource = new ResourceDocumentSource( - List.of(new ClassPathResource("graphql/gitlab/operations/")), - List.of(".graphql", ".gql") + // Operations are loaded from graphql/gitlab/operations/ by name. + // Shared fragments from graphql/gitlab/fragments/GitLabUserFields.graphql are + // selectively appended by FragmentMergingDocumentSource: only fragments that are + // actually referenced (transitively via ...FragmentName spreads) are included. + Resource fragmentFile = new ClassPathResource("graphql/gitlab/fragments/GitLabUserFields.graphql"); + FragmentMergingDocumentSource documentSource = new FragmentMergingDocumentSource( + List.of( + new ClassPathResource("graphql/gitlab/operations/"), + new ClassPathResource("graphql/gitlab/fragments/") + ), + List.of(".graphql", ".gql"), + List.of(fragmentFile) ); return HttpGraphQlClient.builder(webClient).documentSource(documentSource).build(); } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/Commit.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/Commit.java index 9571266e8..84c1d090d 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/Commit.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/Commit.java @@ -25,7 +25,6 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @@ -60,7 +59,6 @@ @Setter @NoArgsConstructor @ToString -@EqualsAndHashCode(onlyExplicitlyIncluded = true) public class Commit { /** @@ -68,7 +66,6 @@ public class Commit { */ @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @EqualsAndHashCode.Include private Long id; /** @@ -376,4 +373,17 @@ public void removeContributor(CommitContributor contributor) { contributor.setCommit(null); } } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Commit that = (Commit) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/CommitAuthorResolver.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/CommitAuthorResolver.java index e1a2f6876..d50260f31 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/CommitAuthorResolver.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/CommitAuthorResolver.java @@ -45,21 +45,25 @@ public class CommitAuthorResolver { /** * Resolve a user's database ID by email, with GitHub noreply fallback. - *

- * First tries a direct email match. If that fails and the email matches a GitHub - * noreply pattern, extracts the login and tries a login-based match. + * Scoped to a specific provider to avoid cross-provider ambiguity. * - * @param email the git author/committer email (from JGit {@code PersonIdent} or webhook payload) + * @param email the git author/committer email + * @param providerId the provider to scope the lookup to * @return the user's database ID, or {@code null} if no match is found */ @Nullable - public Long resolveByEmail(@Nullable String email) { + public Long resolveByEmail(@Nullable String email, @Nullable Long providerId) { if (email == null || email.isBlank()) { return null; } - // Strategy 1: direct email match - Long id = userRepository.findByEmail(email).map(User::getId).orElse(null); + // Strategy 1: direct email match (provider-scoped if available) + Long id; + if (providerId != null) { + id = userRepository.findByEmailAndProviderId(email, providerId).map(User::getId).orElse(null); + } else { + id = userRepository.findByEmail(email).map(User::getId).orElse(null); + } if (id != null) { return id; } @@ -67,6 +71,9 @@ public Long resolveByEmail(@Nullable String email) { // Strategy 2: parse GitHub noreply email → extract login → match by login String login = extractLoginFromNoreply(email); if (login != null) { + if (providerId != null) { + return userRepository.findByLoginAndProviderId(login, providerId).map(User::getId).orElse(null); + } return userRepository.findByLogin(login).map(User::getId).orElse(null); } @@ -74,16 +81,21 @@ public Long resolveByEmail(@Nullable String email) { } /** - * Resolve a user's database ID by GitHub login (username). + * Resolve a user's database ID by login (username). + * Scoped to a specific provider to avoid cross-provider ambiguity. * - * @param login the GitHub username (e.g., from webhook {@code CommitUser.username}) + * @param login the username (e.g., from webhook {@code CommitUser.username}) + * @param providerId the provider to scope the lookup to * @return the user's database ID, or {@code null} if not found */ @Nullable - public Long resolveByLogin(@Nullable String login) { + public Long resolveByLogin(@Nullable String login, @Nullable Long providerId) { if (login == null || login.isBlank()) { return null; } + if (providerId != null) { + return userRepository.findByLoginAndProviderId(login, providerId).map(User::getId).orElse(null); + } return userRepository.findByLogin(login).map(User::getId).orElse(null); } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/CommitAuthorEnrichmentService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/CommitAuthorEnrichmentService.java index 7f64a1245..d75f12fca 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/CommitAuthorEnrichmentService.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/CommitAuthorEnrichmentService.java @@ -109,9 +109,10 @@ public class CommitAuthorEnrichmentService { * @param repositoryId the repository database ID * @param nameWithOwner the repository name with owner (e.g. "owner/repo") * @param scopeId the scope ID for GraphQL client authentication + * @param providerId the provider ID for scoping user lookups * @return the number of commits enriched */ - public int enrichCommitAuthors(Long repositoryId, String nameWithOwner, @Nullable Long scopeId) { + public int enrichCommitAuthors(Long repositoryId, String nameWithOwner, @Nullable Long scopeId, Long providerId) { // Phase 1: Find all distinct unresolved emails from the database List unresolvedAuthorEmails = commitRepository.findDistinctUnresolvedAuthorEmailsByRepositoryId( repositoryId @@ -143,7 +144,12 @@ public int enrichCommitAuthors(Long repositoryId, String nameWithOwner, @Nullabl ); // Phase 2: First pass — try resolving by email using existing DB users - int enrichedByEmail = enrichByEmail(repositoryId, unresolvedAuthorEmails, unresolvedCommitterEmails); + int enrichedByEmail = enrichByEmail( + repositoryId, + unresolvedAuthorEmails, + unresolvedCommitterEmails, + providerId + ); // Phase 3: Re-check which emails are still unresolved after email pass List stillUnresolvedAuthorEmails = commitRepository.findDistinctUnresolvedAuthorEmailsByRepositoryId( @@ -175,7 +181,8 @@ public int enrichCommitAuthors(Long repositoryId, String nameWithOwner, @Nullabl nameWithOwner, scopeId, stillUnresolvedAuthorEmails, - stillUnresolvedCommitterEmails + stillUnresolvedCommitterEmails, + providerId ); int total = enrichedByEmail + enrichedByApi; @@ -197,12 +204,13 @@ public int enrichCommitAuthors(Long repositoryId, String nameWithOwner, @Nullabl private int enrichByEmail( Long repositoryId, List unresolvedAuthorEmails, - List unresolvedCommitterEmails + List unresolvedCommitterEmails, + Long providerId ) { int enriched = 0; for (String email : unresolvedAuthorEmails) { - Long userId = authorResolver.resolveByEmail(email); + Long userId = authorResolver.resolveByEmail(email, providerId); if (userId != null) { int updated = commitRepository.bulkUpdateAuthorIdByEmail(email, repositoryId, userId); enriched += updated; @@ -211,7 +219,7 @@ private int enrichByEmail( } for (String email : unresolvedCommitterEmails) { - Long userId = authorResolver.resolveByEmail(email); + Long userId = authorResolver.resolveByEmail(email, providerId); if (userId != null) { int updated = commitRepository.bulkUpdateCommitterIdByEmail(email, repositoryId, userId); enriched += updated; @@ -232,7 +240,8 @@ private int enrichByGitHubGraphQl( String nameWithOwner, Long scopeId, List unresolvedAuthorEmails, - List unresolvedCommitterEmails + List unresolvedCommitterEmails, + Long providerId ) { // Collect all unique unresolved emails Set allUnresolvedEmails = new HashSet<>(unresolvedAuthorEmails); @@ -280,7 +289,13 @@ private int enrichByGitHubGraphQl( ); // Fetch commit authors in batches via GraphQL - Map emailToLogin = fetchCommitAuthorsBatched(nameWithOwner, scopeId, validShas, shaToEmail); + Map emailToLogin = fetchCommitAuthorsBatched( + nameWithOwner, + scopeId, + validShas, + shaToEmail, + providerId + ); // Bulk update: for each email → login, resolve login → user_id, // then update all commits with that email @@ -292,7 +307,7 @@ private int enrichByGitHubGraphQl( if (login == null) { continue; } - Long userId = authorResolver.resolveByLogin(login); + Long userId = authorResolver.resolveByLogin(login, providerId); if (userId != null) { int updated = commitRepository.bulkUpdateAuthorIdByEmail(email, repositoryId, userId); enriched += updated; @@ -312,7 +327,7 @@ private int enrichByGitHubGraphQl( if (login == null) { continue; } - Long userId = authorResolver.resolveByLogin(login); + Long userId = authorResolver.resolveByLogin(login, providerId); if (userId != null) { int updated = commitRepository.bulkUpdateCommitterIdByEmail(email, repositoryId, userId); enriched += updated; @@ -351,7 +366,8 @@ private Map fetchCommitAuthorsBatched( String nameWithOwner, Long scopeId, List shas, - Map shaToEmail + Map shaToEmail, + Long providerId ) { Map emailToLogin = new HashMap<>(); Map usersToUpsert = new HashMap<>(); @@ -401,7 +417,7 @@ private Map fetchCommitAuthorsBatched( emailToLogin.putAll(batchResult); } - upsertUsers(usersToUpsert, nameWithOwner); + upsertUsers(usersToUpsert, nameWithOwner, providerId); return emailToLogin; } @@ -603,12 +619,12 @@ private void extractLoginsFromResponse( } /** - * Extracts the login from a nested path like {@code author.user.login} or - * {@code committer.user.login} from a GraphQL response field. + * Extracts user info from a nested path like {@code author.user} or + * {@code committer.user} in the GraphQL commit data. * - * @param commitField the commit response field - * @param role "author" or "committer" - * @return the login string, or null if not present + * @param commitData the deserialized commit map from GraphQL + * @param role "author" or "committer" + * @return the user snapshot, or null if not present */ @Nullable private UserSnapshot extractUserSnapshot(Map commitData, String role) { @@ -648,7 +664,7 @@ private UserSnapshot extractUserSnapshot(Map commitData, String } } - private void upsertUsers(Map usersToUpsert, String nameWithOwner) { + private void upsertUsers(Map usersToUpsert, String nameWithOwner, Long providerId) { if (usersToUpsert.isEmpty()) { return; } @@ -658,7 +674,7 @@ private void upsertUsers(Map usersToUpsert, String nameWith continue; } try { - userProcessor.ensureExists(dto); + userProcessor.ensureExists(dto, providerId); processed++; } catch (Exception e) { log.debug( diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubCommitBackfillService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubCommitBackfillService.java index 05e695ae0..9e75d2f54 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubCommitBackfillService.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubCommitBackfillService.java @@ -251,8 +251,9 @@ private boolean processCommitInfo(GitRepositoryManager.CommitInfo info, Reposito } // Resolve author/committer IDs by email (with noreply fallback) - Long authorId = authorResolver.resolveByEmail(info.authorEmail()); - Long committerId = authorResolver.resolveByEmail(info.committerEmail()); + Long providerId = repository.getProvider().getId(); + Long authorId = authorResolver.resolveByEmail(info.authorEmail(), providerId); + Long committerId = authorResolver.resolveByEmail(info.committerEmail(), providerId); // Upsert commit via native SQL (no exception on conflict) // Defense-in-depth: git_commit.message is NOT NULL; default to empty string diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubPushMessageHandler.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubPushMessageHandler.java index 57b903e30..d11a9726d 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubPushMessageHandler.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/commit/github/GitHubPushMessageHandler.java @@ -249,11 +249,14 @@ private void processCommitsViaWebhook(GitHubPushEventDTO event, Repository repos int changedFiles = added + removed + modified; // Resolve author/committer by username + Long providerId = repository.getProvider().getId(); Long authorId = authorResolver.resolveByLogin( - webhookCommit.author() != null ? webhookCommit.author().username() : null + webhookCommit.author() != null ? webhookCommit.author().username() : null, + providerId ); Long committerId = authorResolver.resolveByLogin( - webhookCommit.committer() != null ? webhookCommit.committer().username() : null + webhookCommit.committer() != null ? webhookCommit.committer().username() : null, + providerId ); commitRepository.upsertCommit( @@ -306,8 +309,9 @@ private boolean processCommitInfo(GitRepositoryManager.CommitInfo info, Reposito } // Resolve author/committer IDs by email (with noreply fallback) - Long authorId = authorResolver.resolveByEmail(info.authorEmail()); - Long committerId = authorResolver.resolveByEmail(info.committerEmail()); + Long providerId = repository.getProvider().getId(); + Long authorId = authorResolver.resolveByEmail(info.authorEmail(), providerId); + Long committerId = authorResolver.resolveByEmail(info.committerEmail(), providerId); // Upsert commit via native SQL (no exception on conflict) // Defense-in-depth: git_commit.message is NOT NULL; default to empty string diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/BaseGitServiceEntity.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/BaseGitServiceEntity.java index b07781717..6f03f3398 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/BaseGitServiceEntity.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/BaseGitServiceEntity.java @@ -1,29 +1,72 @@ package de.tum.in.www1.hephaestus.gitprovider.common; +import jakarta.persistence.Column; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.MappedSuperclass; import java.time.Instant; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import lombok.ToString; +/** + * Base class for all git service entities synced from external providers. + *

+ * Provides: + *

    + *
  • {@link #id} — Synthetic auto-generated primary key
  • + *
  • {@link #nativeId} — The provider's original numeric ID (always positive)
  • + *
  • {@link #provider} — FK to {@link GitProvider} identifying the provider instance
  • + *
  • {@link #createdAt} / {@link #updatedAt} — Audit timestamps from the provider
  • + *
+ *

+ * The combination of {@code (provider_id, native_id)} is unique per entity table, + * scoping native IDs to prevent cross-provider collisions. + */ @MappedSuperclass @Getter @Setter @NoArgsConstructor -@AllArgsConstructor @ToString -@EqualsAndHashCode(onlyExplicitlyIncluded = true) public abstract class BaseGitServiceEntity { @Id - @EqualsAndHashCode.Include + @GeneratedValue(strategy = GenerationType.IDENTITY) protected Long id; + /** + * The provider's original numeric ID (always stored as a positive value). + *

+ * Combined with {@link #provider}, this uniquely identifies the entity + * across all provider instances. + */ + @Column(name = "native_id", nullable = false) + protected Long nativeId; + protected Instant createdAt; protected Instant updatedAt; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "provider_id", nullable = false) + @ToString.Exclude + protected GitProvider provider; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BaseGitServiceEntity that = (BaseGitServiceEntity) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProvider.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProvider.java new file mode 100644 index 000000000..806dbcaba --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProvider.java @@ -0,0 +1,74 @@ +package de.tum.in.www1.hephaestus.gitprovider.common; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.CreationTimestamp; + +/** + * Represents a git provider instance (e.g., github.com, gitlab.lrz.de). + *

+ * Each unique combination of provider type and server URL is a distinct provider. + * This supports multiple instances of the same provider type (e.g., GitLab SaaS + * and self-hosted GitLab Enterprise). + *

+ * All git service entities reference a GitProvider via foreign key, scoping + * native IDs to prevent cross-provider collisions. + */ +@Entity +@Table( + name = "git_provider", + uniqueConstraints = { + @UniqueConstraint(name = "uq_git_provider_type_server_url", columnNames = { "type", "server_url" }), + } +) +@Getter +@Setter +@NoArgsConstructor +@ToString +public class GitProvider { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private GitProviderType type; + + @Column(name = "server_url", nullable = false, length = 512) + private String serverUrl; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + public GitProvider(GitProviderType type, String serverUrl) { + this.type = type; + this.serverUrl = serverUrl; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + GitProvider that = (GitProvider) o; + return id != null && id.equals(that.id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProviderRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProviderRepository.java new file mode 100644 index 000000000..31efe4523 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProviderRepository.java @@ -0,0 +1,14 @@ +package de.tum.in.www1.hephaestus.gitprovider.common; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Repository for {@link GitProvider} entities. + *

+ * Git providers are auto-created when workspaces are activated. The + * {@link #findByTypeAndServerUrl} lookup is the primary resolution path. + */ +public interface GitProviderRepository extends JpaRepository { + Optional findByTypeAndServerUrl(GitProviderType type, String serverUrl); +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProviderType.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProviderType.java new file mode 100644 index 000000000..d16641aa3 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/GitProviderType.java @@ -0,0 +1,12 @@ +package de.tum.in.www1.hephaestus.gitprovider.common; + +/** + * High-level git provider identity. + * + *

Used to distinguish provider-specific behavior (API clients, sync engines, UI icons) + * without coupling to the specific authentication mechanism. + */ +public enum GitProviderType { + GITHUB, + GITLAB, +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/ProcessingContext.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/ProcessingContext.java index 705f1fc77..ecddf2413 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/ProcessingContext.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/ProcessingContext.java @@ -6,7 +6,7 @@ import org.springframework.lang.Nullable; /** - * Unified context for processing GitHub data from any source (sync or webhook). + * Unified context for processing git provider data from any source (sync or webhook). *

* This context carries all the information needed to process data consistently, * regardless of whether it came from a scheduled GraphQL sync or a webhook @@ -34,6 +34,7 @@ * * @param scopeId The scope this data belongs to * @param repository The repository being processed (JPA entity - transaction required) + * @param provider The git provider instance (e.g., github.com, gitlab.lrz.de) * @param startedAt When processing started * @param correlationId Unique ID for distributed tracing - correlates all log * entries and events from a single webhook or sync operation @@ -43,11 +44,19 @@ public record ProcessingContext( Long scopeId, Repository repository, + GitProvider provider, Instant startedAt, String correlationId, @Nullable String webhookAction, DataSource source ) { + /** + * Returns the provider's database ID for use in upsert queries. + */ + public Long providerId() { + return provider != null ? provider.getId() : null; + } + /** * Creates a context for scheduled sync operations. */ @@ -55,6 +64,23 @@ public static ProcessingContext forSync(Long scopeId, Repository repository) { return new ProcessingContext( scopeId, repository, + repository != null ? repository.getProvider() : null, + Instant.now(), + UUID.randomUUID().toString(), + null, + DataSource.GRAPHQL_SYNC + ); + } + + /** + * Creates a context for sync operations that are not repository-scoped + * (e.g., organization-level project sync, team sync). + */ + public static ProcessingContext forSync(Long scopeId, GitProvider provider) { + return new ProcessingContext( + scopeId, + null, + provider, Instant.now(), UUID.randomUUID().toString(), null, @@ -69,6 +95,23 @@ public static ProcessingContext forWebhook(Long scopeId, Repository repository, return new ProcessingContext( scopeId, repository, + repository != null ? repository.getProvider() : null, + Instant.now(), + UUID.randomUUID().toString(), + action, + DataSource.WEBHOOK + ); + } + + /** + * Creates a context for webhook events that are not repository-scoped + * (e.g., organization-level project events). + */ + public static ProcessingContext forWebhook(Long scopeId, GitProvider provider, String action) { + return new ProcessingContext( + scopeId, + null, + provider, Instant.now(), UUID.randomUUID().toString(), action, diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/BaseGitHubProcessor.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/BaseGitHubProcessor.java index 1b09736b9..86f4e41e6 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/BaseGitHubProcessor.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/github/BaseGitHubProcessor.java @@ -53,13 +53,16 @@ protected BaseGitHubProcessor( /** * Find an existing user or create a new one from the DTO. *

- * Delegates to {@link GitHubUserProcessor#findOrCreate(GitHubUserDTO)} which handles + * Delegates to {@link GitHubUserProcessor#findOrCreate(GitHubUserDTO, Long)} which handles * login conflicts (uk_user_login constraint) gracefully via INSERT ON CONFLICT DO NOTHING * and automatic login conflict resolution. + * + * @param dto the GitHub user DTO + * @param providerId the git provider ID for multi-provider scoping */ @Nullable - protected User findOrCreateUser(GitHubUserDTO dto) { - return gitHubUserProcessor.findOrCreate(dto); + protected User findOrCreateUser(GitHubUserDTO dto, Long providerId) { + return gitHubUserProcessor.findOrCreate(dto, providerId); } /** @@ -173,8 +176,10 @@ protected Milestone findOrCreateMilestone(GitHubMilestoneDTO dto, Repository rep int openIssuesCount = dto.openIssuesCount() != null ? dto.openIssuesCount() : 0; int closedIssuesCount = dto.closedIssuesCount() != null ? dto.closedIssuesCount() : 0; + Long providerId = repository.getProvider().getId(); int inserted = milestoneRepository.insertIfAbsent( milestoneId, + providerId, dto.number(), title, dto.description(), @@ -193,8 +198,11 @@ protected Milestone findOrCreateMilestone(GitHubMilestoneDTO dto, Repository rep return milestoneRepository.findByNumberAndRepositoryId(dto.number(), repository.getId()).orElse(null); } - // We inserted - fetch the entity to return a managed instance - return milestoneRepository.findById(milestoneId).orElse(null); + // We inserted - fetch the entity to return a managed instance. + // Must look up by natural key (number + repository), not by milestoneId, + // because the table uses auto-generated synthetic PKs (the milestoneId here + // is the native provider ID stored in native_id, not the synthetic PK). + return milestoneRepository.findByNumberAndRepositoryId(dto.number(), repository.getId()).orElse(null); } /** @@ -251,14 +259,18 @@ protected String sanitize(@Nullable String input) { * @param currentAssignees the current assignee set to update (modified in place) * @return true if assignments changed, false otherwise */ - protected boolean updateAssignees(@Nullable List assigneeDtos, Set currentAssignees) { + protected boolean updateAssignees( + @Nullable List assigneeDtos, + Set currentAssignees, + Long providerId + ) { if (assigneeDtos == null) { return false; } Set newAssignees = new HashSet<>(); for (GitHubUserDTO assigneeDto : assigneeDtos) { - User assignee = findOrCreateUser(assigneeDto); + User assignee = findOrCreateUser(assigneeDto, providerId); if (assignee != null) { newAssignees.add(assignee); } @@ -314,14 +326,18 @@ protected boolean updateLabels( * @param currentReviewers the current reviewer set to update (modified in place) * @return true if reviewers changed, false otherwise */ - protected boolean updateRequestedReviewers(@Nullable List reviewerDtos, Set currentReviewers) { + protected boolean updateRequestedReviewers( + @Nullable List reviewerDtos, + Set currentReviewers, + Long providerId + ) { if (reviewerDtos == null) { return false; } Set newReviewers = new HashSet<>(); for (GitHubUserDTO reviewerDto : reviewerDtos) { - User reviewer = findOrCreateUser(reviewerDto); + User reviewer = findOrCreateUser(reviewerDto, providerId); if (reviewer != null) { newReviewers.add(reviewer); } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/BaseGitLabProcessor.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/BaseGitLabProcessor.java new file mode 100644 index 000000000..ee42250ca --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/BaseGitLabProcessor.java @@ -0,0 +1,374 @@ +package de.tum.in.www1.hephaestus.gitprovider.common.gitlab; + +import de.tum.in.www1.hephaestus.gitprovider.common.PostgresStringUtils; +import de.tum.in.www1.hephaestus.gitprovider.common.ProcessingContext; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.dto.GitLabWebhookLabel; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.dto.GitLabWebhookUser; +import de.tum.in.www1.hephaestus.gitprovider.common.spi.RepositoryScopeFilter; +import de.tum.in.www1.hephaestus.gitprovider.common.spi.ScopeIdResolver; +import de.tum.in.www1.hephaestus.gitprovider.label.Label; +import de.tum.in.www1.hephaestus.gitprovider.label.LabelRepository; +import de.tum.in.www1.hephaestus.gitprovider.repository.Repository; +import de.tum.in.www1.hephaestus.gitprovider.repository.RepositoryRepository; +import de.tum.in.www1.hephaestus.gitprovider.user.User; +import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoField; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.lang.Nullable; + +/** + * Base class for GitLab entity processors with shared helper methods. + *

+ * Provides common functionality for finding or creating related entities + * (users, labels) that is shared across GitLab Issue, MR, and Note processors. + *

+ * GitLab entity IDs are stored as {@code nativeId} values alongside a + * {@code provider_id} FK to the git_provider table, preventing cross-provider + * collisions. Synthetic label IDs use negative deterministic hashes to avoid + * colliding with real provider-assigned IDs. + */ +public abstract class BaseGitLabProcessor { + + private static final Logger log = LoggerFactory.getLogger(BaseGitLabProcessor.class); + + /** + * Formatter for GitLab webhook timestamps: {@code "yyyy-MM-dd HH:mm:ss Z"}. + *

+ * GitLab webhooks use a non-ISO timestamp format. Example: {@code "2026-01-31 19:03:35 +0100"}. + * The GraphQL API uses standard ISO-8601. This formatter handles the webhook format + * while {@link OffsetDateTime#parse} handles the ISO-8601 format. + */ + private static final DateTimeFormatter GITLAB_WEBHOOK_TIMESTAMP = new DateTimeFormatterBuilder() + .appendPattern("yyyy-MM-dd HH:mm:ss") + .optionalStart() + .appendPattern(" ") + .appendOffset("+HHmm", "+0000") + .optionalEnd() + .parseDefaulting(ChronoField.OFFSET_SECONDS, 0) + .toFormatter(); + + protected final UserRepository userRepository; + protected final LabelRepository labelRepository; + protected final RepositoryRepository repositoryRepository; + protected final ScopeIdResolver scopeIdResolver; + protected final RepositoryScopeFilter repositoryScopeFilter; + protected final GitLabProperties gitLabProperties; + + protected BaseGitLabProcessor( + UserRepository userRepository, + LabelRepository labelRepository, + RepositoryRepository repositoryRepository, + ScopeIdResolver scopeIdResolver, + RepositoryScopeFilter repositoryScopeFilter, + GitLabProperties gitLabProperties + ) { + this.userRepository = userRepository; + this.labelRepository = labelRepository; + this.repositoryRepository = repositoryRepository; + this.scopeIdResolver = scopeIdResolver; + this.repositoryScopeFilter = repositoryScopeFilter; + this.gitLabProperties = gitLabProperties; + } + + // ======================================================================== + // User Resolution + // ======================================================================== + + /** + * Finds or creates a user from webhook data. + *

+ * Stores the raw GitLab user ID as {@code nativeId} with the given {@code providerId}. + * Constructs HTML URL from the GitLab server URL and username. + */ + @Nullable + protected User findOrCreateUser(@Nullable GitLabWebhookUser dto, Long providerId) { + if (dto == null || dto.id() == null || dto.username() == null) { + return null; + } + + long nativeId = dto.id(); + String login = dto.username(); + String name = dto.name() != null ? dto.name() : login; + String avatarUrl = dto.avatarUrl() != null ? dto.avatarUrl() : ""; + String htmlUrl = gitLabProperties.defaultServerUrl() + "/" + login; + + userRepository.upsertUser( + nativeId, + providerId, + login, + name, + avatarUrl, + htmlUrl, + User.Type.USER.name(), + dto.email(), + null, // createdAt — not in webhook + null // updatedAt — not in webhook + ); + + return userRepository.findByNativeIdAndProviderId(nativeId, providerId).orElse(null); + } + + /** + * Finds or creates a user from GraphQL data (id, username, name, avatarUrl, webUrl). + *

+ * Public because sync services need to resolve users from GraphQL response data. + */ + @Nullable + public User findOrCreateUser( + String globalId, + String username, + @Nullable String name, + @Nullable String avatarUrl, + @Nullable String webUrl, + Long providerId + ) { + if (globalId == null || username == null) { + return null; + } + + long nativeId; + try { + nativeId = GitLabSyncConstants.extractNumericId(globalId); + } catch (IllegalArgumentException e) { + log.warn("Skipped user resolution: reason=invalidGlobalId, gid={}", globalId); + return null; + } + + String resolvedName = name != null ? name : username; + String resolvedAvatarUrl = avatarUrl != null ? avatarUrl : ""; + String resolvedHtmlUrl = webUrl != null ? webUrl : (gitLabProperties.defaultServerUrl() + "/" + username); + + userRepository.upsertUser( + nativeId, + providerId, + username, + resolvedName, + resolvedAvatarUrl, + resolvedHtmlUrl, + User.Type.USER.name(), + null, // email — not available from GraphQL + null, // createdAt — not in GraphQL user data + null // updatedAt — not in GraphQL user data + ); + + return userRepository.findByNativeIdAndProviderId(nativeId, providerId).orElse(null); + } + + // ======================================================================== + // Label Resolution + // ======================================================================== + + /** + * Finds or creates a label from webhook data. + */ + @Nullable + protected Label findOrCreateLabel(@Nullable GitLabWebhookLabel dto, Repository repository) { + if (dto == null || dto.title() == null || dto.title().isBlank()) { + return null; + } + + Optional

+ * Public because sync services need to resolve labels from GraphQL response data. + *

+ * Uses deterministic composite IDs based on (repositoryId, labelName) rather than + * GitLab global IDs. GitLab group-level labels share the same global ID across all + * projects, but labels are stored per-repository in the database. Using the GitLab + * global ID would cause primary key collisions when the same label appears in + * multiple projects. + */ + @Nullable + public Label findOrCreateLabel(@Nullable String title, @Nullable String color, Repository repository) { + if (title == null || title.isBlank()) { + return null; + } + + Optional

+ * Handles both webhook format ({@code "2026-01-31 19:03:35 +0100"}) and + * GraphQL ISO-8601 format ({@code "2026-01-31T19:03:35Z"}). + */ + @Nullable + protected static Instant parseGitLabTimestamp(@Nullable String timestamp) { + if (timestamp == null || timestamp.isBlank()) { + return null; + } + try { + // Try ISO-8601 first (GraphQL format) + return OffsetDateTime.parse(timestamp).toInstant(); + } catch (DateTimeParseException e1) { + try { + // Try webhook format + return OffsetDateTime.parse(timestamp, GITLAB_WEBHOOK_TIMESTAMP).toInstant(); + } catch (DateTimeParseException e2) { + log.warn("Could not parse GitLab timestamp: value={}", timestamp); + return null; + } + } + } + + // ======================================================================== + // Context Resolution + // ======================================================================== + + /** + * Resolves a ProcessingContext from a project's pathWithNamespace for webhook events. + */ + @Nullable + protected ProcessingContext resolveContext(@Nullable String pathWithNamespace, @Nullable String action) { + if (pathWithNamespace == null || pathWithNamespace.isBlank()) { + return null; + } + + if (!repositoryScopeFilter.isRepositoryAllowed(pathWithNamespace)) { + log.debug("Skipped event: reason=repositoryFiltered, repoName={}", pathWithNamespace); + return null; + } + + Repository repository = repositoryRepository + .findByNameWithOwnerWithOrganization(pathWithNamespace) + .orElse(null); + if (repository == null) { + log.debug("Skipped event: reason=repositoryNotFound, repoName={}", pathWithNamespace); + return null; + } + + Long scopeId = resolveScopeId(repository); + return ProcessingContext.forWebhook(scopeId, repository, action); + } + + private Long resolveScopeId(Repository repository) { + if (repository.getOrganization() != null) { + String orgLogin = repository.getOrganization().getLogin(); + Long scopeId = scopeIdResolver.findScopeIdByOrgLogin(orgLogin).orElse(null); + if (scopeId != null) { + return scopeId; + } + } + return scopeIdResolver.findScopeIdByRepositoryName(repository.getNameWithOwner()).orElse(null); + } + + // ======================================================================== + // Sanitization + // ======================================================================== + + /** Strips null bytes and other characters that are invalid in PostgreSQL text columns. */ + @Nullable + protected String sanitize(@Nullable String input) { + return PostgresStringUtils.sanitize(input); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/GitLabSyncConstants.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/GitLabSyncConstants.java index 99d85b8ce..826fb9125 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/GitLabSyncConstants.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/GitLabSyncConstants.java @@ -94,10 +94,12 @@ private GitLabSyncConstants() { /** * Page size for issue sync GraphQL queries. *

- * Reduced from default because GitLab issue queries embed notes, - * labels, assignees, and milestone data per issue. + * Set to 30 as a conservative balance between throughput and query complexity. + * GitLab issue queries embed labels(first:100), assignees(first:20), count fields, + * and author data per issue — higher values risk exceeding GitLab's query complexity limit. + * With only 100 points/minute budget, keeping complexity per query low is critical. */ - public static final int ISSUE_SYNC_PAGE_SIZE = 20; + public static final int ISSUE_SYNC_PAGE_SIZE = 30; /** * Page size for merge request sync GraphQL queries. @@ -149,13 +151,46 @@ private GitLabSyncConstants() { */ private static final Pattern GID_PATTERN = Pattern.compile("^gid://gitlab/[A-Za-z]+/(\\d+)$"); + /** + * Returns the raw GitLab numeric ID as the native entity ID. + *

+ * With the multi-provider architecture, each entity stores its original + * provider ID as {@code nativeId} alongside a {@code provider_id} FK. + * No negation is needed — provider scoping prevents collisions. + * + * @param rawGitLabId the raw GitLab numeric ID (positive) + * @return the same ID (identity function) + */ + public static long toEntityId(long rawGitLabId) { + return rawGitLabId; + } + /** * Extracts the numeric ID from a GitLab Global ID string. *

+ * Convenience method combining {@link #extractNumericId(String)} with + * the identity {@link #toEntityId(long)}. + *

* Example: {@code "gid://gitlab/Project/123"} → {@code 123L} * * @param globalId the GitLab Global ID (e.g., {@code "gid://gitlab/User/42"}) - * @return the numeric database ID + * @return the numeric entity ID (positive) + * @throws IllegalArgumentException if the format is invalid + */ + public static long extractEntityId(String globalId) { + return extractNumericId(globalId); + } + + /** + * Extracts the raw numeric ID from a GitLab Global ID string. + *

+ * Example: {@code "gid://gitlab/Project/123"} → {@code 123L} + *

+ * Note: This returns the raw GitLab ID. Use {@link #extractEntityId(String)} + * or {@link #toEntityId(long)} to convert to a Hephaestus entity ID before persisting. + * + * @param globalId the GitLab Global ID (e.g., {@code "gid://gitlab/User/42"}) + * @return the numeric database ID (positive) * @throws IllegalArgumentException if the format is invalid */ public static long extractNumericId(String globalId) { diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookLabel.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookLabel.java new file mode 100644 index 000000000..dea64a861 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookLabel.java @@ -0,0 +1,15 @@ +package de.tum.in.www1.hephaestus.gitprovider.common.gitlab.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Shared DTO for GitLab webhook label objects. + *

+ * Embedded in issue and merge request webhook events within the {@code labels} array. + * + * @param id the GitLab label database ID + * @param title the label name/title + * @param color the label color (hex string, e.g., {@code "#428BCA"}) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitLabWebhookLabel(Long id, String title, String color) {} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookProject.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookProject.java new file mode 100644 index 000000000..0d14053c9 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookProject.java @@ -0,0 +1,22 @@ +package de.tum.in.www1.hephaestus.gitprovider.common.gitlab.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Shared DTO for GitLab webhook project objects. + *

+ * Embedded in issue, merge request, and note webhook events. + * + * @param id the GitLab project database ID + * @param name the project name + * @param webUrl the project's web URL + * @param pathWithNamespace the full path (e.g., {@code group/project}) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitLabWebhookProject( + Long id, + String name, + @JsonProperty("web_url") String webUrl, + @JsonProperty("path_with_namespace") String pathWithNamespace +) {} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookUser.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookUser.java new file mode 100644 index 000000000..3ccc59c8b --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/common/gitlab/dto/GitLabWebhookUser.java @@ -0,0 +1,25 @@ +package de.tum.in.www1.hephaestus.gitprovider.common.gitlab.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Shared DTO for GitLab webhook user objects. + *

+ * GitLab webhooks embed user data in various event types (issues, merge requests, notes). + * This record captures the common user fields shared across all event types. + * + * @param id the GitLab user database ID + * @param username the user's login name + * @param name the user's display name + * @param avatarUrl the user's avatar URL + * @param email the user's email (may be null depending on privacy settings) + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitLabWebhookUser( + Long id, + String username, + String name, + @JsonProperty("avatar_url") String avatarUrl, + String email +) {} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/Discussion.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/Discussion.java index 9c70ac99b..1beacd44a 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/Discussion.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/Discussion.java @@ -38,7 +38,13 @@ * where comments can be marked as accepted answers. */ @Entity -@Table(name = "discussion", uniqueConstraints = @UniqueConstraint(columnNames = { "repository_id", "number" })) +@Table( + name = "discussion", + uniqueConstraints = { + @UniqueConstraint(columnNames = { "repository_id", "number" }), + @UniqueConstraint(name = "uq_discussion_provider_native_id", columnNames = { "provider_id", "native_id" }), + } +) @Getter @Setter @NoArgsConstructor diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/DiscussionRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/DiscussionRepository.java index d9f965186..7340b233e 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/DiscussionRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/DiscussionRepository.java @@ -60,12 +60,12 @@ Optional findByRepositoryIdAndNumber( @Query( value = """ INSERT INTO discussion ( - id, repository_id, number, title, body, html_url, state, state_reason, + native_id, provider_id, repository_id, number, title, body, html_url, state, state_reason, is_locked, active_lock_reason, closed_at, answer_chosen_at, comment_count, upvote_count, last_sync_at, created_at, updated_at, author_id, category_id, answer_chosen_by_id ) VALUES ( - :id, :repositoryId, :number, :title, :body, :htmlUrl, :state, :stateReason, + :nativeId, :providerId, :repositoryId, :number, :title, :body, :htmlUrl, :state, :stateReason, :isLocked, :activeLockReason, :closedAt, :answerChosenAt, :commentCount, :upvoteCount, :lastSyncAt, :createdAt, :updatedAt, :authorId, :categoryId, :answerChosenById ) @@ -90,7 +90,8 @@ ON CONFLICT (repository_id, number) DO UPDATE SET nativeQuery = true ) int upsertCore( - @Param("id") Long id, + @Param("nativeId") Long nativeId, + @Param("providerId") Long providerId, @Param("repositoryId") Long repositoryId, @Param("number") int number, @Param("title") String title, diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/github/GitHubDiscussionProcessor.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/github/GitHubDiscussionProcessor.java index 64b283fab..f9fa3ead1 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/github/GitHubDiscussionProcessor.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussion/github/GitHubDiscussionProcessor.java @@ -110,8 +110,9 @@ public Discussion process(GitHubDiscussionDTO dto, ProcessingContext context) { } // Resolve related entities BEFORE the upsert - User author = dto.author() != null ? findOrCreateUser(dto.author()) : null; - User answerChosenBy = dto.answerChosenBy() != null ? findOrCreateUser(dto.answerChosenBy()) : null; + User author = dto.author() != null ? findOrCreateUser(dto.author(), context.providerId()) : null; + User answerChosenBy = + dto.answerChosenBy() != null ? findOrCreateUser(dto.answerChosenBy(), context.providerId()) : null; DiscussionCategory category = dto.category() != null ? findOrCreateCategory(dto.category(), repository) : null; // Use atomic upsert to handle concurrent inserts @@ -123,6 +124,7 @@ public Discussion process(GitHubDiscussionDTO dto, ProcessingContext context) { discussionRepository.upsertCore( dbId, + context.providerId(), repository.getId(), dto.number(), sanitize(dto.title()), @@ -195,24 +197,18 @@ public Discussion process(GitHubDiscussionDTO dto, ProcessingContext context) { */ @Transactional public void processDeleted(GitHubDiscussionDTO dto, ProcessingContext context) { - Long dbId = dto.getDatabaseId(); EventContext eventContext = EventContext.from(context); - if (dbId != null) { - discussionRepository.deleteById(dbId); - eventPublisher.publishEvent(new DomainEvent.DiscussionDeleted(dbId, eventContext)); - log.info("Deleted discussion: discussionId={}, discussionNumber={}", dbId, dto.number()); - } else { - // Try to find by repository ID and number if database ID is not available - discussionRepository - .findByRepositoryIdAndNumber(context.repository().getId(), dto.number()) - .ifPresent(discussion -> { - Long discussionId = discussion.getId(); - discussionRepository.delete(discussion); - eventPublisher.publishEvent(new DomainEvent.DiscussionDeleted(discussionId, eventContext)); - log.info("Deleted discussion: discussionId={}, discussionNumber={}", discussionId, dto.number()); - }); - } + // With synthetic PKs, we cannot use deleteById(nativeId) because the PK is + // auto-generated and differs from the native provider ID. Look up by natural key instead. + discussionRepository + .findByRepositoryIdAndNumber(context.repository().getId(), dto.number()) + .ifPresent(discussion -> { + Long discussionId = discussion.getId(); + discussionRepository.delete(discussion); + eventPublisher.publishEvent(new DomainEvent.DiscussionDeleted(discussionId, eventContext)); + log.info("Deleted discussion: discussionId={}, discussionNumber={}", discussionId, dto.number()); + }); } /** diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionComment.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionComment.java index a4fdcb518..8f43adec7 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionComment.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionComment.java @@ -13,6 +13,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -29,7 +30,15 @@ * In Q&A category discussions, a comment can be marked as the accepted answer. */ @Entity -@Table(name = "discussion_comment") +@Table( + name = "discussion_comment", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_discussion_comment_provider_native_id", + columnNames = { "provider_id", "native_id" } + ), + } +) @Getter @Setter @NoArgsConstructor diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionCommentRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionCommentRepository.java index 2a35781a4..1a67ad973 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionCommentRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/DiscussionCommentRepository.java @@ -9,6 +9,7 @@ */ @Repository public interface DiscussionCommentRepository extends JpaRepository { + Optional findByNativeIdAndProviderId(Long nativeId, Long providerId); /** * Find the answer comment for a discussion. * diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/github/GitHubDiscussionCommentProcessor.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/github/GitHubDiscussionCommentProcessor.java index 414917eb6..805ecaf69 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/github/GitHubDiscussionCommentProcessor.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/discussioncomment/github/GitHubDiscussionCommentProcessor.java @@ -70,16 +70,20 @@ public DiscussionComment process(GitHubDiscussionCommentDTO dto, Discussion disc } // Check if this is an update - Optional existingOpt = commentRepository.findById(dbId); + Optional existingOpt = commentRepository.findByNativeIdAndProviderId( + dbId, + context.providerId() + ); boolean isNew = existingOpt.isEmpty(); // Resolve related entities - User author = dto.author() != null ? findOrCreateUser(dto.author()) : null; + User author = dto.author() != null ? findOrCreateUser(dto.author(), context.providerId()) : null; // Get or create the comment DiscussionComment comment = existingOpt.orElseGet(() -> { DiscussionComment c = new DiscussionComment(); - c.setId(dbId); + c.setNativeId(dbId); + c.setProvider(context.provider()); return c; }); @@ -161,7 +165,7 @@ public void processDeleted(GitHubDiscussionCommentDTO commentDto, ProcessingCont } commentRepository - .findById(dbId) + .findByNativeIdAndProviderId(dbId, context.providerId()) .ifPresent(comment -> { Long discussionId = comment.getDiscussion() != null ? comment.getDiscussion().getId() : null; diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationMessageHandler.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationMessageHandler.java index 456e3a919..a394a4dff 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationMessageHandler.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationMessageHandler.java @@ -2,6 +2,8 @@ import static de.tum.in.www1.hephaestus.core.LoggingUtils.sanitizeForLog; +import de.tum.in.www1.hephaestus.gitprovider.common.GitProviderRepository; +import de.tum.in.www1.hephaestus.gitprovider.common.GitProviderType; import de.tum.in.www1.hephaestus.gitprovider.common.NatsMessageDeserializer; import de.tum.in.www1.hephaestus.gitprovider.common.exception.InstallationNotFoundException; import de.tum.in.www1.hephaestus.gitprovider.common.github.GitHubEventAction; @@ -29,14 +31,18 @@ public class GitHubInstallationMessageHandler extends GitHubMessageHandler new IllegalStateException("GitProvider not found for GitHub")) + .getId(); + organizationService.upsertIdentity(account.id(), accountLogin, providerId); } // Handle activation for CREATED events diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationTargetMessageHandler.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationTargetMessageHandler.java index 4b1bef9f8..b09d0028b 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationTargetMessageHandler.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/installation/github/GitHubInstallationTargetMessageHandler.java @@ -2,6 +2,8 @@ import static de.tum.in.www1.hephaestus.core.LoggingUtils.sanitizeForLog; +import de.tum.in.www1.hephaestus.gitprovider.common.GitProviderRepository; +import de.tum.in.www1.hephaestus.gitprovider.common.GitProviderType; import de.tum.in.www1.hephaestus.gitprovider.common.NatsMessageDeserializer; import de.tum.in.www1.hephaestus.gitprovider.common.github.GitHubEventAction; import de.tum.in.www1.hephaestus.gitprovider.common.github.GitHubEventType; @@ -22,18 +24,23 @@ public class GitHubInstallationTargetMessageHandler extends GitHubMessageHandler private static final Logger log = LoggerFactory.getLogger(GitHubInstallationTargetMessageHandler.class); + private static final String GITHUB_SERVER_URL = "https://github.com"; + private final ProvisioningListener provisioningListener; private final OrganizationService organizationService; + private final GitProviderRepository gitProviderRepository; GitHubInstallationTargetMessageHandler( ProvisioningListener provisioningListener, OrganizationService organizationService, + GitProviderRepository gitProviderRepository, NatsMessageDeserializer deserializer, TransactionTemplate transactionTemplate ) { super(GitHubInstallationTargetEventDTO.class, deserializer, transactionTemplate); this.provisioningListener = provisioningListener; this.organizationService = organizationService; + this.gitProviderRepository = gitProviderRepository; } @Override @@ -95,6 +102,10 @@ private void upsertOrganization(GitHubInstallationTargetEventDTO event, long ins return; } - organizationService.upsertIdentity(account.id(), login); + Long providerId = gitProviderRepository + .findByTypeAndServerUrl(GitProviderType.GITHUB, GITHUB_SERVER_URL) + .orElseThrow(() -> new IllegalStateException("GitProvider not found for GitHub")) + .getId(); + organizationService.upsertIdentity(account.id(), login, providerId); } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/Issue.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/Issue.java index e4a755ef4..139d8d530 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/Issue.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/Issue.java @@ -39,6 +39,7 @@ name = "issue", uniqueConstraints = { @UniqueConstraint(name = "uk_issue_repository_number", columnNames = { "repository_id", "number" }), + @UniqueConstraint(name = "uq_issue_provider_native_id", columnNames = { "provider_id", "native_id" }), } ) @Inheritance(strategy = InheritanceType.SINGLE_TABLE) diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/IssueRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/IssueRepository.java index 22a4e1ba0..193fb9e6a 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/IssueRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/IssueRepository.java @@ -114,14 +114,14 @@ public interface IssueRepository extends JpaRepository { @Query( value = """ INSERT INTO issue ( - id, number, title, body, state, state_reason, html_url, is_locked, + native_id, provider_id, number, title, body, state, state_reason, html_url, is_locked, closed_at, comments_count, last_sync_at, created_at, updated_at, author_id, repository_id, milestone_id, issue_type_id, parent_issue_id, sub_issues_total, sub_issues_completed, sub_issues_percent_completed, issue_type ) VALUES ( - :id, :number, :title, :body, :state, :stateReason, :htmlUrl, :isLocked, + :nativeId, :providerId, :number, :title, :body, :state, :stateReason, :htmlUrl, :isLocked, :closedAt, :commentsCount, :lastSyncAt, :createdAt, :updatedAt, :authorId, :repositoryId, :milestoneId, :issueTypeId, :parentIssueId, :subIssuesTotal, :subIssuesCompleted, :subIssuesPercentCompleted, @@ -149,7 +149,8 @@ ON CONFLICT (repository_id, number) DO UPDATE SET nativeQuery = true ) int upsertCore( - @Param("id") Long id, + @Param("nativeId") Long nativeId, + @Param("providerId") Long providerId, @Param("number") int number, @Param("title") String title, @Param("body") String body, diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/github/GitHubIssueProcessor.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/github/GitHubIssueProcessor.java index 982ac6b5c..8656a7c1f 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/github/GitHubIssueProcessor.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/github/GitHubIssueProcessor.java @@ -156,7 +156,7 @@ private Issue processInternal(GitHubIssueDTO dto, ProcessingContext context, boo boolean isNew = existingOpt.isEmpty(); // Resolve related entities BEFORE the upsert - User author = dto.author() != null ? findOrCreateUser(dto.author()) : null; + User author = dto.author() != null ? findOrCreateUser(dto.author(), context.providerId()) : null; Milestone milestone = dto.milestone() != null ? findOrCreateMilestone(dto.milestone(), repository) : null; IssueType issueType = null; if (dto.issueType() != null && repository.getOrganization() != null) { @@ -168,6 +168,7 @@ private Issue processInternal(GitHubIssueDTO dto, ProcessingContext context, boo Instant now = Instant.now(); issueRepository.upsertCore( dbId, + context.providerId(), dto.number(), sanitize(dto.title()), sanitize(dto.body()), @@ -200,7 +201,7 @@ private Issue processInternal(GitHubIssueDTO dto, ProcessingContext context, boo ); // Handle ManyToMany relationships (labels, assignees) - these can't be done in the upsert - boolean relationshipsChanged = updateRelationships(dto, issue, repository); + boolean relationshipsChanged = updateRelationships(dto, issue, repository, context.providerId()); // Save relationship changes if (relationshipsChanged) { @@ -243,8 +244,8 @@ private Issue processInternal(GitHubIssueDTO dto, ProcessingContext context, boo * * @return true if any relationships were changed */ - private boolean updateRelationships(GitHubIssueDTO dto, Issue issue, Repository repository) { - boolean assigneesChanged = updateAssignees(dto.assignees(), issue.getAssignees()); + private boolean updateRelationships(GitHubIssueDTO dto, Issue issue, Repository repository, Long providerId) { + boolean assigneesChanged = updateAssignees(dto.assignees(), issue.getAssignees(), providerId); boolean labelsChanged = updateLabels(dto.labels(), issue.getLabels(), repository); return assigneesChanged || labelsChanged; } @@ -414,12 +415,16 @@ public Issue processUnlabeled(GitHubIssueDTO issueDto, GitHubLabelDTO labelDto, */ @Transactional public void processDeleted(GitHubIssueDTO issueDto, ProcessingContext context) { - Long dbId = issueDto.getDatabaseId(); - if (dbId != null) { - issueRepository.deleteById(dbId); - eventPublisher.publishEvent(new DomainEvent.IssueDeleted(dbId, EventContext.from(context))); - log.info("Deleted issue: issueId={}", dbId); - } + // With synthetic PKs, we cannot use deleteById(nativeId) because the PK is + // auto-generated and differs from the native provider ID. Look up by natural key instead. + issueRepository + .findByRepositoryIdAndNumber(context.repository().getId(), issueDto.number()) + .ifPresent(issue -> { + Long syntheticId = issue.getId(); + issueRepository.delete(issue); + eventPublisher.publishEvent(new DomainEvent.IssueDeleted(syntheticId, EventContext.from(context))); + log.info("Deleted issue: issueId={}, number={}", syntheticId, issueDto.number()); + }); } /** diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/gitlab/GitLabIssueMessageHandler.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/gitlab/GitLabIssueMessageHandler.java new file mode 100644 index 000000000..2998c8a5d --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/gitlab/GitLabIssueMessageHandler.java @@ -0,0 +1,140 @@ +package de.tum.in.www1.hephaestus.gitprovider.issue.gitlab; + +import static de.tum.in.www1.hephaestus.core.LoggingUtils.sanitizeForLog; + +import de.tum.in.www1.hephaestus.gitprovider.common.NatsMessageDeserializer; +import de.tum.in.www1.hephaestus.gitprovider.common.ProcessingContext; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.GitLabEventAction; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.GitLabEventType; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.GitLabMessageHandler; +import de.tum.in.www1.hephaestus.gitprovider.common.spi.RepositoryScopeFilter; +import de.tum.in.www1.hephaestus.gitprovider.common.spi.ScopeIdResolver; +import de.tum.in.www1.hephaestus.gitprovider.issue.gitlab.dto.GitLabIssueEventDTO; +import de.tum.in.www1.hephaestus.gitprovider.repository.Repository; +import de.tum.in.www1.hephaestus.gitprovider.repository.RepositoryRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Handles GitLab issue webhook events. + *

+ * Processes both {@code event_type: "issue"} and {@code event_type: "confidential_issue"} payloads. + * Both arrive on the same NATS subject ({@code object_kind: "issue"}). + *

+ * Confidential issues are skipped entirely — they are never stored in the database. + *

+ * Routes to {@link GitLabIssueProcessor} based on the action: + *

    + *
  • {@code open} / {@code update} → {@link GitLabIssueProcessor#process}
  • + *
  • {@code close} → {@link GitLabIssueProcessor#processClosed}
  • + *
  • {@code reopen} → {@link GitLabIssueProcessor#processReopened}
  • + *
+ */ +@Component +@ConditionalOnProperty(prefix = "hephaestus.gitlab", name = "enabled", havingValue = "true") +public class GitLabIssueMessageHandler extends GitLabMessageHandler { + + private static final Logger log = LoggerFactory.getLogger(GitLabIssueMessageHandler.class); + + private final GitLabIssueProcessor issueProcessor; + private final RepositoryRepository repositoryRepository; + private final RepositoryScopeFilter repositoryScopeFilter; + private final ScopeIdResolver scopeIdResolver; + + GitLabIssueMessageHandler( + GitLabIssueProcessor issueProcessor, + RepositoryRepository repositoryRepository, + RepositoryScopeFilter repositoryScopeFilter, + ScopeIdResolver scopeIdResolver, + NatsMessageDeserializer deserializer, + TransactionTemplate transactionTemplate + ) { + super(GitLabIssueEventDTO.class, deserializer, transactionTemplate); + this.issueProcessor = issueProcessor; + this.repositoryRepository = repositoryRepository; + this.repositoryScopeFilter = repositoryScopeFilter; + this.scopeIdResolver = scopeIdResolver; + } + + @Override + public GitLabEventType getEventType() { + return GitLabEventType.ISSUE; + } + + @Override + protected void handleEvent(GitLabIssueEventDTO event) { + if (event.objectAttributes() == null) { + log.warn("Received issue event with missing object_attributes"); + return; + } + + if (event.project() == null) { + log.warn("Received issue event with missing project data"); + return; + } + + // Skip confidential issues entirely + if (event.isConfidential()) { + log.debug("Skipped confidential issue event: iid={}", event.objectAttributes().iid()); + return; + } + + String projectPath = event.project().pathWithNamespace(); + String safeProjectPath = sanitizeForLog(projectPath); + GitLabEventAction action = event.actionType(); + + log.info( + "Processing issue event: projectPath={}, iid={}, action={}", + safeProjectPath, + event.objectAttributes().iid(), + action + ); + + ProcessingContext context = resolveContext(projectPath, action.getValue()); + if (context == null) { + return; + } + + switch (action) { + case OPEN, UPDATE -> issueProcessor.process(event, context); + case CLOSE -> issueProcessor.processClosed(event, context); + case REOPEN -> issueProcessor.processReopened(event, context); + default -> log.debug("Unhandled issue action: projectPath={}, action={}", safeProjectPath, action); + } + } + + private ProcessingContext resolveContext(String pathWithNamespace, String action) { + String safePath = sanitizeForLog(pathWithNamespace); + + if (!repositoryScopeFilter.isRepositoryAllowed(pathWithNamespace)) { + log.debug("Skipped issue event: reason=repositoryFiltered, repoName={}", safePath); + return null; + } + + Repository repository = repositoryRepository + .findByNameWithOwnerWithOrganization(pathWithNamespace) + .orElse(null); + + if (repository == null) { + log.debug("Skipped issue event: reason=repositoryNotFound, repoName={}", safePath); + return null; + } + + Long scopeId = resolveScopeId(repository); + return ProcessingContext.forWebhook(scopeId, repository, action); + } + + private Long resolveScopeId(Repository repository) { + if (repository.getOrganization() != null) { + String orgLogin = repository.getOrganization().getLogin(); + Long scopeId = scopeIdResolver.findScopeIdByOrgLogin(orgLogin).orElse(null); + if (scopeId != null) { + return scopeId; + } + } + return scopeIdResolver.findScopeIdByRepositoryName(repository.getNameWithOwner()).orElse(null); + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/gitlab/GitLabIssueProcessor.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/gitlab/GitLabIssueProcessor.java new file mode 100644 index 000000000..6ff789ea6 --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/issue/gitlab/GitLabIssueProcessor.java @@ -0,0 +1,474 @@ +package de.tum.in.www1.hephaestus.gitprovider.issue.gitlab; + +import de.tum.in.www1.hephaestus.gitprovider.common.ProcessingContext; +import de.tum.in.www1.hephaestus.gitprovider.common.events.DomainEvent; +import de.tum.in.www1.hephaestus.gitprovider.common.events.EventContext; +import de.tum.in.www1.hephaestus.gitprovider.common.events.EventPayload; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.BaseGitLabProcessor; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.GitLabProperties; +import de.tum.in.www1.hephaestus.gitprovider.common.gitlab.GitLabSyncConstants; +import de.tum.in.www1.hephaestus.gitprovider.common.spi.RepositoryScopeFilter; +import de.tum.in.www1.hephaestus.gitprovider.common.spi.ScopeIdResolver; +import de.tum.in.www1.hephaestus.gitprovider.issue.Issue; +import de.tum.in.www1.hephaestus.gitprovider.issue.IssueRepository; +import de.tum.in.www1.hephaestus.gitprovider.issue.gitlab.dto.GitLabIssueEventDTO; +import de.tum.in.www1.hephaestus.gitprovider.label.Label; +import de.tum.in.www1.hephaestus.gitprovider.label.LabelRepository; +import de.tum.in.www1.hephaestus.gitprovider.repository.Repository; +import de.tum.in.www1.hephaestus.gitprovider.repository.RepositoryRepository; +import de.tum.in.www1.hephaestus.gitprovider.user.User; +import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository; +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Processor for GitLab issues. + *

+ * Handles conversion of GitLab issue data (from webhooks and GraphQL sync) to Issue entities. + * Follows the same patterns as {@link de.tum.in.www1.hephaestus.gitprovider.issue.github.GitHubIssueProcessor}. + *

+ * Confidential issues are skipped entirely (not stored in the database). + */ +@Service +@ConditionalOnProperty(prefix = "hephaestus.gitlab", name = "enabled", havingValue = "true") +public class GitLabIssueProcessor extends BaseGitLabProcessor { + + private static final Logger log = LoggerFactory.getLogger(GitLabIssueProcessor.class); + + private final IssueRepository issueRepository; + private final ApplicationEventPublisher eventPublisher; + + public GitLabIssueProcessor( + IssueRepository issueRepository, + UserRepository userRepository, + LabelRepository labelRepository, + RepositoryRepository repositoryRepository, + ScopeIdResolver scopeIdResolver, + RepositoryScopeFilter repositoryScopeFilter, + GitLabProperties gitLabProperties, + ApplicationEventPublisher eventPublisher + ) { + super( + userRepository, + labelRepository, + repositoryRepository, + scopeIdResolver, + repositoryScopeFilter, + gitLabProperties + ); + this.issueRepository = issueRepository; + this.eventPublisher = eventPublisher; + } + + /** + * Process a GitLab issue webhook event. + */ + @Transactional + @Nullable + public Issue process(GitLabIssueEventDTO event, ProcessingContext context) { + if (event.isConfidential()) { + log.debug("Skipped confidential issue: iid={}", event.objectAttributes().iid()); + return null; + } + + var attrs = event.objectAttributes(); + User author = resolveWebhookAuthor(event, context.providerId()); + Issue issue = upsertIssue( + attrs.id(), + attrs.iid(), + attrs.title(), + attrs.description(), + attrs.state(), + attrs.url(), + attrs.createdAt(), + attrs.updatedAt(), + attrs.closedAt(), + author, + context.repository(), + context + ); + + if (issue == null) return null; + + // Update relationships + boolean changed = updateLabels(event.labels(), issue.getLabels(), context.repository()); + changed |= updateAssignees(event.assignees(), issue.getAssignees(), context.providerId()); + if (changed) { + issue = issueRepository.save(issue); + } + + return issue; + } + + /** + * Label data extracted from GraphQL response for sync processing. + */ + public record SyncLabelData(String globalId, String title, @Nullable String color) {} + + /** + * Assignee data extracted from GraphQL response for sync processing. + */ + public record SyncAssigneeData( + String globalId, + String username, + @Nullable String name, + @Nullable String avatarUrl, + @Nullable String webUrl + ) {} + + /** + * All data needed to sync a single GitLab issue from GraphQL. + */ + public record SyncIssueData( + String globalId, + String iid, + String title, + @Nullable String description, + String state, + boolean confidential, + String webUrl, + @Nullable String createdAt, + @Nullable String updatedAt, + @Nullable String closedAt, + @Nullable String authorGlobalId, + @Nullable String authorUsername, + @Nullable String authorName, + @Nullable String authorAvatarUrl, + @Nullable String authorWebUrl, + int commentsCount, + @Nullable List syncLabels, + @Nullable List syncAssignees + ) {} + + /** + * Process a GitLab issue from GraphQL sync. + *

+ * Labels and assignees are resolved and persisted within this method's + * transaction boundary to avoid detached-entity issues. + */ + @Transactional + @Nullable + public Issue processFromSync(SyncIssueData data, Repository repository, @Nullable Long scopeId) { + if (data.confidential()) { + log.debug("Skipped confidential issue from sync: iid={}", data.iid()); + return null; + } + + long nativeId; + try { + nativeId = GitLabSyncConstants.extractNumericId(data.globalId()); + } catch (IllegalArgumentException e) { + log.warn("Skipped issue processing: reason=invalidGlobalId, gid={}", data.globalId()); + return null; + } + + int issueNumber; + try { + issueNumber = Integer.parseInt(data.iid()); + } catch (NumberFormatException e) { + log.warn("Skipped issue processing: reason=invalidIid, iid={}", data.iid()); + return null; + } + + Long providerId = repository.getProvider().getId(); + + // Check if existing + Optional existingOpt = issueRepository.findByRepositoryIdAndNumber(repository.getId(), issueNumber); + boolean isNew = existingOpt.isEmpty(); + + // Resolve author + User author = findOrCreateUser( + data.authorGlobalId(), + data.authorUsername(), + data.authorName(), + data.authorAvatarUrl(), + data.authorWebUrl(), + providerId + ); + + // State mapping + Issue.State issueState = convertState(data.state()); + + Instant now = Instant.now(); + issueRepository.upsertCore( + nativeId, + providerId, + issueNumber, + sanitize(data.title()), + sanitize(data.description()), + issueState.name(), + null, // stateReason — not available in GitLab + data.webUrl(), + false, // locked — not in query + parseGitLabTimestamp(data.closedAt()), + data.commentsCount(), + now, + parseGitLabTimestamp(data.createdAt()), + parseGitLabTimestamp(data.updatedAt()), + author != null ? author.getId() : null, + repository.getId(), + null, // milestoneId + null, // issueTypeId + null, // parentIssueId + null, // subIssuesTotal + null, // subIssuesCompleted + null // subIssuesPercentCompleted + ); + + Issue issue = issueRepository.findByRepositoryIdAndNumber(repository.getId(), issueNumber).orElse(null); + + if (issue == null) { + return null; + } + + issue.setProvider(repository.getProvider()); + + // Resolve and persist labels/assignees within this transaction + boolean changed = updateSyncLabels(data.syncLabels(), issue.getLabels(), repository); + changed |= updateSyncAssignees(data.syncAssignees(), issue.getAssignees(), providerId); + if (changed) { + issue = issueRepository.save(issue); + } + + if (isNew) { + ProcessingContext ctx = ProcessingContext.forSync(scopeId, repository); + eventPublisher.publishEvent( + new DomainEvent.IssueCreated(EventPayload.IssueData.from(issue), EventContext.from(ctx)) + ); + log.debug("Created issue from sync: issueId={}, iid={}", nativeId, data.iid()); + } + + return issue; + } + + /** + * Process a closed event. + */ + @Transactional + @Nullable + public Issue processClosed(GitLabIssueEventDTO event, ProcessingContext context) { + Issue issue = process(event, context); + if (issue != null) { + eventPublisher.publishEvent( + new DomainEvent.IssueClosed(EventPayload.IssueData.from(issue), "completed", EventContext.from(context)) + ); + log.debug("Closed issue: issueId={}", issue.getId()); + } + return issue; + } + + /** + * Process a reopened event. + */ + @Transactional + @Nullable + public Issue processReopened(GitLabIssueEventDTO event, ProcessingContext context) { + Issue issue = process(event, context); + if (issue != null) { + eventPublisher.publishEvent( + new DomainEvent.IssueReopened(EventPayload.IssueData.from(issue), EventContext.from(context)) + ); + log.debug("Reopened issue: issueId={}", issue.getId()); + } + return issue; + } + + // ======================================================================== + // Private helpers + // ======================================================================== + + /** + * Resolves the issue author from a webhook event. + *

+ * GitLab webhook {@code user} is the actor (who triggered the event), + * not necessarily the issue author. The real author ID is in + * {@code object_attributes.author_id}. + *

+ * Strategy: + *

    + *
  1. If the actor's ID matches {@code author_id}, use the actor DTO + * (has full profile data) to upsert the user.
  2. + *
  3. Otherwise look up the author by native ID from the database + * (works when the author was previously synced).
  4. + *
  5. If not found, return {@code null} — the {@code COALESCE} in the + * upsert SQL will preserve any existing author.
  6. + *
+ */ + @Nullable + private User resolveWebhookAuthor(GitLabIssueEventDTO event, Long providerId) { + Long authorId = event.objectAttributes().authorId(); + if (authorId == null) { + return null; + } + + // If the actor IS the author, we have full profile data — upsert them + if (event.user() != null && authorId.equals(event.user().id())) { + return findOrCreateUser(event.user(), providerId); + } + + // Otherwise, try to find the author by native ID (previously synced) + return userRepository.findByNativeIdAndProviderId(authorId, providerId).orElse(null); + } + + @Nullable + private Issue upsertIssue( + Long rawId, + Integer iid, + String title, + @Nullable String description, + String state, + @Nullable String htmlUrl, + @Nullable String createdAt, + @Nullable String updatedAt, + @Nullable String closedAt, + @Nullable User author, + Repository repository, + ProcessingContext context + ) { + if (rawId == null || iid == null) { + log.warn("Skipped issue processing: reason=missingIdOrIid"); + return null; + } + + long nativeId = rawId; + int issueNumber = iid; + Long providerId = repository.getProvider().getId(); + + Optional existingOpt = issueRepository.findByRepositoryIdAndNumber(repository.getId(), issueNumber); + boolean isNew = existingOpt.isEmpty(); + + Issue.State issueState = convertState(state); + + Instant now = Instant.now(); + issueRepository.upsertCore( + nativeId, + providerId, + issueNumber, + sanitize(title), + sanitize(description), + issueState.name(), + null, // stateReason + htmlUrl, + false, // locked + parseGitLabTimestamp(closedAt), + 0, // commentsCount — not available in webhook + now, + parseGitLabTimestamp(createdAt), + parseGitLabTimestamp(updatedAt), + author != null ? author.getId() : null, + repository.getId(), + null, + null, + null, + null, + null, + null + ); + + Issue issue = issueRepository.findByRepositoryIdAndNumber(repository.getId(), issueNumber).orElse(null); + + if (issue != null) { + issue.setProvider(repository.getProvider()); + + if (isNew) { + eventPublisher.publishEvent( + new DomainEvent.IssueCreated(EventPayload.IssueData.from(issue), EventContext.from(context)) + ); + log.debug("Created issue: nativeId={}, iid={}", nativeId, issueNumber); + } + } + + return issue; + } + + /** + * Maps GitLab issue state string to Issue.State enum. + */ + private static Issue.State convertState(@Nullable String state) { + if (state == null) { + return Issue.State.OPEN; + } + return switch (state.toLowerCase()) { + case "opened" -> Issue.State.OPEN; + case "closed" -> Issue.State.CLOSED; + default -> { + log.warn("Unknown GitLab issue state '{}', defaulting to OPEN", state); + yield Issue.State.OPEN; + } + }; + } + + /** + * Updates labels from GraphQL sync data within the current transaction. + */ + private boolean updateSyncLabels( + @Nullable List syncLabels, + java.util.Collection