GitAuthoritativeHypothesisReviewService.java

package com.taxonomy.versioning.service;

import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationDefinition;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationIdentity;
import com.taxonomy.model.HypothesisStatus;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandMetadata;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandResult;
import com.taxonomy.relations.model.RelationHypothesis;
import com.taxonomy.relations.service.GitAuthoritativeRelationMutationService;
import com.taxonomy.relations.service.GitAuthoritativeRelationMutationService.MutationResult;
import com.taxonomy.relations.service.GitAuthoritativeRelationMutationService.ProjectionPendingException;
import com.taxonomy.versioning.service.HypothesisReviewStateStore.HypothesisSnapshot;
import com.taxonomy.workspace.service.RepositoryContext;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.Map;
import java.util.Objects;

/**
 * Commits accept/reject/revert decisions to the exact selected Git branch before
 * changing relational hypothesis bookkeeping. The surrounding operation is
 * intentionally non-transactional so a successful authority commit survives a
 * later projection or bookkeeping failure.
 */
@Service
public class GitAuthoritativeHypothesisReviewService {

    private static final String HYPOTHESIS_PROVENANCE = "hypothesis-review";

    private final HypothesisReviewStateStore stateStore;
    private final GitAuthoritativeRelationMutationService mutationService;

    public GitAuthoritativeHypothesisReviewService(
            HypothesisReviewStateStore stateStore,
            GitAuthoritativeRelationMutationService mutationService) {
        this.stateStore = Objects.requireNonNull(stateStore, "stateStore");
        this.mutationService = Objects.requireNonNull(
                mutationService, "mutationService");
    }

    public ReviewResult accept(
            Long hypothesisId,
            RepositoryContext context,
            String expectedHeadCommit,
            CommandMetadata metadata) throws IOException {
        return review(
                hypothesisId,
                context,
                expectedHeadCommit,
                metadata,
                ReviewAction.ACCEPT);
    }

    public ReviewResult reject(
            Long hypothesisId,
            RepositoryContext context,
            String expectedHeadCommit,
            CommandMetadata metadata) throws IOException {
        return review(
                hypothesisId,
                context,
                expectedHeadCommit,
                metadata,
                ReviewAction.REJECT);
    }

    public ReviewResult revert(
            Long hypothesisId,
            RepositoryContext context,
            String expectedHeadCommit,
            CommandMetadata metadata) throws IOException {
        return review(
                hypothesisId,
                context,
                expectedHeadCommit,
                metadata,
                ReviewAction.REVERT);
    }

    /**
     * Fails closed on the exact tenant and lifecycle state before a caller reads
     * branch metadata. This prevents cross-workspace identifiers or terminal
     * states from being masked as a missing Git branch.
     */
    public void requireReviewable(
            Long hypothesisId,
            RepositoryContext context,
            ReviewAction action) {
        requireReviewableSnapshot(hypothesisId, context, action);
    }

    private ReviewResult review(
            Long hypothesisId,
            RepositoryContext context,
            String expectedHeadCommit,
            CommandMetadata metadata,
            ReviewAction action) throws IOException {
        RepositoryContext tenant = HypothesisReviewStateStore
                .requireWritableContext(context);
        Objects.requireNonNull(metadata, "metadata");
        HypothesisSnapshot hypothesis = requireReviewableSnapshot(
                hypothesisId, tenant, action);

        MutationResult mutation;
        try {
            mutation = mutate(
                    hypothesis,
                    tenant,
                    expectedHeadCommit,
                    metadata,
                    action);
        } catch (ProjectionPendingException error) {
            throw HypothesisReviewPendingException.projection(
                    hypothesis.id(),
                    action.targetStatus(),
                    error);
        }

        try {
            RelationHypothesis transitioned = stateStore.transition(
                    hypothesis.id(),
                    tenant,
                    hypothesis.status(),
                    action.targetStatus());
            return new ReviewResult(
                    hypothesis.id(),
                    action,
                    transitioned,
                    mutation);
        } catch (RuntimeException bookkeepingFailure) {
            throw HypothesisReviewPendingException.bookkeeping(
                    hypothesis.id(),
                    action.targetStatus(),
                    mutation.authority(),
                    bookkeepingFailure);
        }
    }

    private HypothesisSnapshot requireReviewableSnapshot(
            Long hypothesisId,
            RepositoryContext context,
            ReviewAction action) {
        RepositoryContext tenant = HypothesisReviewStateStore
                .requireWritableContext(context);
        ReviewAction reviewAction = Objects.requireNonNull(action, "action");
        HypothesisSnapshot hypothesis = stateStore.require(hypothesisId, tenant);
        reviewAction.requireCurrentStatus(
                hypothesis.id(), hypothesis.status());
        return hypothesis;
    }

    private MutationResult mutate(
            HypothesisSnapshot hypothesis,
            RepositoryContext context,
            String expectedHeadCommit,
            CommandMetadata metadata,
            ReviewAction action) throws IOException {
        RelationIdentity identity = new RelationIdentity(
                hypothesis.sourceCode(),
                hypothesis.relationType().name(),
                hypothesis.targetCode());
        if (action == ReviewAction.REVERT) {
            return mutationService.remove(
                    context,
                    expectedHeadCommit,
                    identity,
                    metadata);
        }
        return mutationService.upsert(
                context,
                expectedHeadCommit,
                new RelationDefinition(
                        identity,
                        action.dslStatus(),
                        hypothesis.confidence(),
                        HYPOTHESIS_PROVENANCE,
                        Map.of("x-hypothesis-id", hypothesis.id().toString())),
                metadata);
    }

    public enum ReviewAction {
        ACCEPT(HypothesisStatus.ACCEPTED, "accepted"),
        REJECT(HypothesisStatus.REJECTED, "rejected"),
        REVERT(HypothesisStatus.PROVISIONAL, null);

        private final HypothesisStatus targetStatus;
        private final String dslStatus;

        ReviewAction(HypothesisStatus targetStatus, String dslStatus) {
            this.targetStatus = targetStatus;
            this.dslStatus = dslStatus;
        }

        void requireCurrentStatus(Long hypothesisId, HypothesisStatus current) {
            if (this == REVERT) {
                if (current != HypothesisStatus.ACCEPTED
                        && current != HypothesisStatus.REJECTED) {
                    throw new IllegalStateException(
                            "Hypothesis " + hypothesisId
                                    + " cannot be reverted from " + current);
                }
                return;
            }
            if (current != HypothesisStatus.PROVISIONAL
                    && current != HypothesisStatus.PROPOSED) {
                throw new IllegalStateException(
                        "Hypothesis " + hypothesisId
                                + " cannot be " + dslStatus
                                + " from " + current);
            }
        }

        HypothesisStatus targetStatus() {
            return targetStatus;
        }

        String dslStatus() {
            return dslStatus;
        }
    }

    public record ReviewResult(
            Long hypothesisId,
            ReviewAction action,
            RelationHypothesis hypothesis,
            MutationResult mutation) {
        public ReviewResult {
            hypothesisId = Objects.requireNonNull(hypothesisId, "hypothesisId");
            action = Objects.requireNonNull(action, "action");
            hypothesis = Objects.requireNonNull(hypothesis, "hypothesis");
            mutation = Objects.requireNonNull(mutation, "mutation");
        }
    }

    public static final class HypothesisReviewPendingException
            extends IllegalStateException {
        private final Long hypothesisId;
        private final HypothesisStatus intendedStatus;
        private final CommandResult authority;
        private final PendingPhase phase;

        private HypothesisReviewPendingException(
                Long hypothesisId,
                HypothesisStatus intendedStatus,
                CommandResult authority,
                PendingPhase phase,
                Throwable cause) {
            super("Git hypothesis review succeeded at "
                    + authority.authoritativeCommitId()
                    + ", but " + phase.description + " requires recovery", cause);
            this.hypothesisId = Objects.requireNonNull(
                    hypothesisId, "hypothesisId");
            this.intendedStatus = Objects.requireNonNull(
                    intendedStatus, "intendedStatus");
            this.authority = Objects.requireNonNull(authority, "authority");
            this.phase = Objects.requireNonNull(phase, "phase");
        }

        static HypothesisReviewPendingException projection(
                Long hypothesisId,
                HypothesisStatus intendedStatus,
                ProjectionPendingException error) {
            return new HypothesisReviewPendingException(
                    hypothesisId,
                    intendedStatus,
                    error.getAuthority(),
                    PendingPhase.PROJECTION,
                    error);
        }

        static HypothesisReviewPendingException bookkeeping(
                Long hypothesisId,
                HypothesisStatus intendedStatus,
                CommandResult authority,
                Throwable cause) {
            return new HypothesisReviewPendingException(
                    hypothesisId,
                    intendedStatus,
                    authority,
                    PendingPhase.HYPOTHESIS_BOOKKEEPING,
                    cause);
        }

        public Long getHypothesisId() {
            return hypothesisId;
        }

        public HypothesisStatus getIntendedStatus() {
            return intendedStatus;
        }

        public CommandResult getAuthority() {
            return authority;
        }

        public PendingPhase getPhase() {
            return phase;
        }
    }

    public enum PendingPhase {
        PROJECTION("relation projection"),
        HYPOTHESIS_BOOKKEEPING("hypothesis bookkeeping");

        private final String description;

        PendingPhase(String description) {
            this.description = description;
        }
    }
}