GitAuthoritativeProposalReviewService.java

package com.taxonomy.relations.service;

import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationDefinition;
import com.taxonomy.dsl.command.ArchitectureRelationDslTransformer.RelationIdentity;
import com.taxonomy.model.ProposalStatus;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandMetadata;
import com.taxonomy.relations.command.ArchitectureRelationGitCommandService.CommandResult;
import com.taxonomy.relations.service.GitAuthoritativeRelationMutationService.MutationResult;
import com.taxonomy.relations.service.GitAuthoritativeRelationMutationService.ProjectionPendingException;
import com.taxonomy.relations.service.ProposalReviewStateStore.ProposalSnapshot;
import com.taxonomy.workspace.service.RepositoryContext;
import org.springframework.stereotype.Service;

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

/**
 * Reviews one proposal by committing the relation decision to Git first and
 * advancing relational proposal bookkeeping only after projection succeeds.
 */
@Service
public class GitAuthoritativeProposalReviewService {

    private static final String PROPOSAL_PROVENANCE = "proposal-review";

    private final ProposalReviewStateStore stateStore;
    private final GitAuthoritativeRelationMutationService mutationService;

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

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

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

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

    private ReviewResult review(
            Long proposalId,
            RepositoryContext context,
            String expectedHeadCommit,
            CommandMetadata metadata,
            ReviewAction action) throws IOException {
        RepositoryContext tenant = ProposalReviewStateStore
                .requireWritableContext(context);
        Objects.requireNonNull(metadata, "metadata");
        ProposalSnapshot proposal = stateStore.require(proposalId, tenant);
        action.requireCurrentStatus(proposal.id(), proposal.status());

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

        try {
            ProposalStatus status = stateStore.transition(
                    proposal.id(),
                    tenant,
                    proposal.status(),
                    action.targetStatus());
            return new ReviewResult(
                    proposal.id(), action, status, mutation);
        } catch (RuntimeException bookkeepingFailure) {
            throw ProposalReviewPendingException.bookkeeping(
                    proposal.id(),
                    action.targetStatus(),
                    mutation.authority(),
                    bookkeepingFailure);
        }
    }

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

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

        private final ProposalStatus requiredStatus;
        private final ProposalStatus targetStatus;
        private final String dslStatus;

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

        void requireCurrentStatus(Long proposalId, ProposalStatus current) {
            if (this == REVERT) {
                if (current == ProposalStatus.PENDING) {
                    throw new IllegalStateException(
                            "Proposal " + proposalId + " is already PENDING");
                }
                return;
            }
            if (current != requiredStatus) {
                throw new IllegalStateException(
                        "Proposal " + proposalId + " is already " + current);
            }
        }

        ProposalStatus targetStatus() {
            return targetStatus;
        }

        String dslStatus() {
            return dslStatus;
        }
    }

    public record ReviewResult(
            Long proposalId,
            ReviewAction action,
            ProposalStatus proposalStatus,
            MutationResult mutation) {
        public ReviewResult {
            proposalId = Objects.requireNonNull(proposalId, "proposalId");
            action = Objects.requireNonNull(action, "action");
            proposalStatus = Objects.requireNonNull(
                    proposalStatus, "proposalStatus");
            mutation = Objects.requireNonNull(mutation, "mutation");
        }
    }

    public static final class ProposalReviewPendingException
            extends IllegalStateException {
        private final Long proposalId;
        private final ProposalStatus intendedStatus;
        private final CommandResult authority;
        private final PendingPhase phase;

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

        static ProposalReviewPendingException projection(
                Long proposalId,
                ProposalStatus intendedStatus,
                ProjectionPendingException error) {
            return new ProposalReviewPendingException(
                    proposalId,
                    intendedStatus,
                    error.getAuthority(),
                    PendingPhase.PROJECTION,
                    error);
        }

        static ProposalReviewPendingException bookkeeping(
                Long proposalId,
                ProposalStatus intendedStatus,
                CommandResult authority,
                Throwable cause) {
            return new ProposalReviewPendingException(
                    proposalId,
                    intendedStatus,
                    authority,
                    PendingPhase.PROPOSAL_BOOKKEEPING,
                    cause);
        }

        public Long getProposalId() {
            return proposalId;
        }

        public ProposalStatus getIntendedStatus() {
            return intendedStatus;
        }

        public CommandResult getAuthority() {
            return authority;
        }

        public PendingPhase getPhase() {
            return phase;
        }
    }

    public enum PendingPhase {
        PROJECTION("relation projection"),
        PROPOSAL_BOOKKEEPING("proposal bookkeeping");

        private final String description;

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