CommitIndexService.java

package com.taxonomy.architecture.service;

import com.taxonomy.architecture.model.ArchitectureCommitIndex;
import com.taxonomy.architecture.repository.ArchitectureCommitIndexRepository;
import com.taxonomy.dsl.parser.DslTokenizer;
import com.taxonomy.dsl.storage.DslCommit;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dto.ElementHistoryAggregation;
import com.taxonomy.workspace.service.RepositoryContext;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.hibernate.search.mapper.orm.Search;
import org.hibernate.search.mapper.orm.session.SearchSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.time.Instant;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;

/**
 * Populates and queries repository-scoped commit-history projections.
 *
 * <p>JGit remains authoritative. Every relational row and every Hibernate Search
 * query uses the exact same {@link RepositoryContext}; a null workspace means
 * central history only inside the selected repository, never a global index.</p>
 */
@Service
public class CommitIndexService {

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

    private final DslGitRepositoryFactory repositoryFactory;
    private final ArchitectureCommitIndexRepository indexRepository;
    private final DslTokenizer tokenizer = new DslTokenizer();

    @PersistenceContext
    private EntityManager entityManager;

    public CommitIndexService(
            DslGitRepositoryFactory repositoryFactory,
            ArchitectureCommitIndexRepository indexRepository) {
        this.repositoryFactory = repositoryFactory;
        this.indexRepository = indexRepository;
    }

    /** Index every commit not yet projected for this exact tenant and branch. */
    @Transactional
    public int indexBranch(String branch, RepositoryContext context) {
        TenantScope scope = TenantScope.from(context);
        String normalizedBranch = requireText(branch, "branch");
        try {
            DslGitRepository repository = repositoryFactory.resolveRepository(context);
            List<DslCommit> commits = repository.getDslHistory(normalizedBranch);
            return indexCommits(repository, commits, normalizedBranch, scope);
        } catch (IOException exception) {
            throw indexingFailure(scope, normalizedBranch, exception);
        }
    }

    /**
     * Rebuild one branch projection without touching another repository,
     * workspace or branch.
     *
     * <p>The authoritative history is opened before the old projection is
     * deleted. Any later JGit failure propagates and rolls back the surrounding
     * transaction, so a failed rebuild cannot commit an empty replacement.</p>
     */
    @Transactional
    public int rebuildBranch(String branch, RepositoryContext context) {
        TenantScope scope = TenantScope.from(context);
        String normalizedBranch = requireText(branch, "branch");
        try {
            DslGitRepository repository = repositoryFactory.resolveRepository(context);
            List<DslCommit> commits = repository.getDslHistory(normalizedBranch);
            List<ArchitectureCommitIndex> existing = indexRepository
                    .findByRepositoryIdAndWorkspaceScopeKeyAndBranchOrderByCommitTimestampDesc(
                            scope.repositoryId(),
                            scope.workspaceScopeKey(),
                            normalizedBranch);
            if (!existing.isEmpty()) {
                indexRepository.deleteAll(existing);
                entityManager.flush();
            }
            return indexCommits(repository, commits, normalizedBranch, scope);
        } catch (IOException exception) {
            throw indexingFailure(scope, normalizedBranch, exception);
        }
    }

    /** Delete only the selected central/workspace history projection. */
    @Transactional
    public int purge(RepositoryContext context) {
        TenantScope scope = TenantScope.from(context);
        List<ArchitectureCommitIndex> existing = indexRepository
                .findByRepositoryIdAndWorkspaceScopeKey(
                        scope.repositoryId(), scope.workspaceScopeKey());
        if (existing.isEmpty()) {
            return 0;
        }
        indexRepository.deleteAll(existing);
        entityManager.flush();
        log.info(
                "Purged {} commit-index row(s) for repository={} workspace={}",
                existing.size(),
                scope.repositoryId(),
                scope.workspaceScopeKey());
        return existing.size();
    }

    @Transactional(readOnly = true)
    public List<ArchitectureCommitIndex> search(
            String query,
            RepositoryContext context) {
        return search(query, 50, context);
    }

    /** Full-text search restricted to the exact selected repository/workspace. */
    @Transactional(readOnly = true)
    public List<ArchitectureCommitIndex> search(
            String query,
            int maxResults,
            RepositoryContext context) {
        if (query == null || query.isBlank() || maxResults <= 0) {
            return Collections.emptyList();
        }
        TenantScope scope = TenantScope.from(context);
        try {
            SearchSession session = Search.session(entityManager);
            String lower = query.toLowerCase(Locale.ROOT);

            return session.search(ArchitectureCommitIndex.class)
                    .where(f -> f.bool()
                            .must(f.match()
                                    .field("repositoryId")
                                    .matching(scope.repositoryId()))
                            .must(f.match()
                                    .field("workspaceScopeKey")
                                    .matching(scope.workspaceScopeKey()))
                            .must(f.bool()
                                    .should(f.match()
                                            .field("tokenizedChangeText")
                                            .matching(lower)
                                            .boost(1.0f))
                                    .should(f.match()
                                            .field("message")
                                            .matching(query)
                                            .boost(0.5f))
                                    .should(f.match()
                                            .field("affectedElementIds")
                                            .matching(lower)
                                            .boost(3.0f))
                                    .should(f.match()
                                            .field("affectedRelationIds")
                                            .matching(lower)
                                            .boost(2.0f))))
                    .sort(f -> f.score())
                    .fetchHits(maxResults);
        } catch (RuntimeException exception) {
            log.error(
                    "Hibernate Search commit search failed for repository={} workspace={}: {}",
                    scope.repositoryId(),
                    scope.workspaceScopeKey(),
                    exception.getClass().getSimpleName());
            return Collections.emptyList();
        }
    }

    @Transactional(readOnly = true)
    public List<ArchitectureCommitIndex> findByElement(
            String elementId,
            RepositoryContext context) {
        if (elementId == null || elementId.isBlank()) {
            return Collections.emptyList();
        }
        TenantScope scope = TenantScope.from(context);
        try {
            SearchSession session = Search.session(entityManager);
            String normalizedElementId = elementId.toLowerCase(Locale.ROOT);
            return session.search(ArchitectureCommitIndex.class)
                    .where(f -> f.bool()
                            .must(f.match()
                                    .field("repositoryId")
                                    .matching(scope.repositoryId()))
                            .must(f.match()
                                    .field("workspaceScopeKey")
                                    .matching(scope.workspaceScopeKey()))
                            .must(f.bool()
                                    .should(f.match()
                                            .field("affectedElementIds")
                                            .matching(normalizedElementId)
                                            .boost(3.0f))
                                    .should(f.match()
                                            .field("tokenizedChangeText")
                                            .matching(normalizedElementId)
                                            .boost(1.0f))))
                    .sort(f -> f.score())
                    .fetchHits(50);
        } catch (RuntimeException exception) {
            log.error(
                    "Hibernate Search element history failed for repository={} workspace={}: {}",
                    scope.repositoryId(),
                    scope.workspaceScopeKey(),
                    exception.getClass().getSimpleName());
            return Collections.emptyList();
        }
    }

    @Transactional(readOnly = true)
    public List<ArchitectureCommitIndex> findByRelation(
            String relationKey,
            RepositoryContext context) {
        if (relationKey == null || relationKey.isBlank()) {
            return Collections.emptyList();
        }
        TenantScope scope = TenantScope.from(context);
        try {
            SearchSession session = Search.session(entityManager);
            String normalizedRelationKey = relationKey.toLowerCase(Locale.ROOT);
            return session.search(ArchitectureCommitIndex.class)
                    .where(f -> f.bool()
                            .must(f.match()
                                    .field("repositoryId")
                                    .matching(scope.repositoryId()))
                            .must(f.match()
                                    .field("workspaceScopeKey")
                                    .matching(scope.workspaceScopeKey()))
                            .must(f.bool()
                                    .should(f.match()
                                            .field("affectedRelationIds")
                                            .matching(normalizedRelationKey)
                                            .boost(3.0f))
                                    .should(f.match()
                                            .field("tokenizedChangeText")
                                            .matching(normalizedRelationKey)
                                            .boost(1.0f))))
                    .sort(f -> f.score())
                    .fetchHits(50);
        } catch (RuntimeException exception) {
            log.error(
                    "Hibernate Search relation history failed for repository={} workspace={}: {}",
                    scope.repositoryId(),
                    scope.workspaceScopeKey(),
                    exception.getClass().getSimpleName());
            return Collections.emptyList();
        }
    }

    /** Aggregate element history and volatility inside one tenant only. */
    @Transactional(readOnly = true)
    public ElementHistoryAggregation aggregateElementHistory(
            String elementId,
            RepositoryContext context) {
        TenantScope scope = TenantScope.from(context);
        List<ArchitectureCommitIndex> commits = findByElement(elementId, context);
        if (commits.isEmpty()) {
            return null;
        }

        long totalCommits = indexRepository.countByRepositoryIdAndWorkspaceScopeKey(
                scope.repositoryId(), scope.workspaceScopeKey());

        Instant firstSeen = commits.stream()
                .map(ArchitectureCommitIndex::getCommitTimestamp)
                .min(Comparator.naturalOrder())
                .orElse(null);
        Instant lastSeen = commits.stream()
                .map(ArchitectureCommitIndex::getCommitTimestamp)
                .max(Comparator.naturalOrder())
                .orElse(null);

        List<String> recentMessages = commits.stream()
                .sorted(Comparator.comparing(
                        ArchitectureCommitIndex::getCommitTimestamp).reversed())
                .limit(5)
                .map(ArchitectureCommitIndex::getMessage)
                .toList();

        double volatility = ElementHistoryAggregation.computeVolatility(
                commits.size(), Math.toIntExact(totalCommits));

        return new ElementHistoryAggregation(
                elementId,
                firstSeen,
                lastSeen,
                commits.size(),
                volatility,
                recentMessages);
    }

    private int indexCommits(
            DslGitRepository repository,
            List<DslCommit> commits,
            String branch,
            TenantScope scope) throws IOException {
        int indexed = 0;
        for (DslCommit commit : commits) {
            if (indexRepository
                    .existsByRepositoryIdAndWorkspaceScopeKeyAndBranchAndCommitId(
                            scope.repositoryId(),
                            scope.workspaceScopeKey(),
                            branch,
                            commit.commitId())) {
                continue;
            }

            String dslText = repository.getDslAtCommit(commit.commitId());
            if (dslText == null) {
                continue;
            }

            ArchitectureCommitIndex entry = new ArchitectureCommitIndex();
            entry.setRepositoryId(scope.repositoryId());
            entry.setWorkspaceId(scope.workspaceId());
            entry.setCommitId(commit.commitId());
            entry.setAuthor(commit.author());
            entry.setCommitTimestamp(commit.timestamp());
            entry.setMessage(commit.message());
            entry.setBranch(branch);
            entry.setChangedFiles("architecture.taxdsl");
            entry.setTokenizedChangeText(tokenizer.tokenize(dslText));

            Set<String> elementIds = tokenizer.extractElementIds(dslText);
            entry.setAffectedElementIds(String.join(",", elementIds));

            Set<String> relationKeys = tokenizer.extractRelationKeys(dslText);
            entry.setAffectedRelationIds(String.join(";", relationKeys));

            indexRepository.save(entry);
            indexed++;
        }

        if (indexed > 0) {
            log.info(
                    "Indexed {} new commit(s) for repository={} workspace={} branch={}",
                    indexed,
                    scope.repositoryId(),
                    scope.workspaceScopeKey(),
                    branch);
        }
        return indexed;
    }

    private IllegalStateException indexingFailure(
            TenantScope scope,
            String branch,
            IOException exception) {
        log.error(
                "Failed to index repository={} workspace={} branch={}",
                scope.repositoryId(),
                scope.workspaceScopeKey(),
                branch,
                exception);
        return new IllegalStateException(
                "Unable to read authoritative JGit history for repository="
                        + scope.repositoryId()
                        + ", workspace=" + scope.workspaceScopeKey()
                        + ", branch=" + branch,
                exception);
    }

    private static String requireText(String value, String field) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " must not be blank");
        }
        return value.strip();
    }

    private record TenantScope(
            String repositoryId,
            String workspaceId,
            String workspaceScopeKey) {

        private static TenantScope from(RepositoryContext context) {
            if (context == null) {
                throw new IllegalArgumentException("RepositoryContext must not be null");
            }
            String repositoryId = requireText(context.repositoryId(), "repositoryId");
            String workspaceId = context.workspaceId() == null
                    || context.workspaceId().isBlank()
                    ? null
                    : context.workspaceId().strip();
            return new TenantScope(
                    repositoryId,
                    workspaceId,
                    ArchitectureCommitIndex.scopeKeyFor(workspaceId));
        }
    }
}