package service
import (
"context"
"errors"
"fmt"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)
// Merge lands an open proposal onto the space's approved head and records how it
// was authorized.
//
// This is the state machine the design calls "approve merges immediately":
// there is no separate approved-then-merged step, because with one reviewer a
// merge is the approval. Phase 4's browser button calls it with ApprovalHuman;
// auto-merge policy calls the same path with ApprovalPolicy (see Propose), so
// the merge model runs identically whether a human clicked or a pattern
// matched — the only difference is the approval kind recorded, which is what a
// reader needs to tell reviewed content from firehose.
//
// The failure modes are the design's: a base that moved under the proposal is
// ErrStale (type-assert the chain to *gitx.StaleError for which document), and a
// proposal already in the approved history is ErrAlreadyMerged rather than a
// confusing staleness 409.
func (s *Service) Merge(ctx context.Context, ref core.SpaceRef, proposalID int, approval core.Approval) (Proposal, error) {
sp, row, err := s.openProposalRow(ctx, ref, proposalID)
if err != nil {
return Proposal{}, err
}
return s.mergeProposal(ctx, sp, row, approval)
}
// Reject resolves an open proposal to rejected. There is no request-changes
// cycle — with one reviewer, a proposal you dislike is rejected and the agent
// proposes again — so this is the whole of the "do not merge" path. The branch
// and row are kept: the proposal URL still resolves and shows the outcome.
func (s *Service) Reject(ctx context.Context, ref core.SpaceRef, proposalID int) (Proposal, error) {
_, row, err := s.openProposalRow(ctx, ref, proposalID)
if err != nil {
return Proposal{}, err
}
if err := s.store.RejectProposal(ctx, row.ID); err != nil {
return Proposal{}, resolveProposalErr(err, row.ID)
}
rejected, err := s.GetProposal(ctx, row.ID)
if err != nil {
return Proposal{}, err
}
s.emit(EventProposalRejected, rejected)
return rejected, nil
}
// mergeProposal is the merge itself, shared by the public Merge and by
// auto-merge. It assumes row is the proposal's current row and sp its open
// space.
func (s *Service) mergeProposal(ctx context.Context, sp *Space, row *db.Proposal, approval core.Approval) (Proposal, error) {
if _, err := core.ParseApproval(string(approval)); err != nil {
return Proposal{}, err
}
if row.State != core.StateOpen {
return Proposal{}, fmt.Errorf("%w: proposal %d is %s", ErrProposalNotOpen, row.ID, row.State)
}
head, err := sp.Repo.ApprovedHead(ctx)
if err != nil {
return Proposal{}, readErr(err, "read approved head of %s", sp.Ref)
}
proposalHead, err := sp.Repo.BranchHead(ctx, row.Branch)
if err != nil {
return Proposal{}, readErr(err, "read head of %s in %s", row.Branch, sp.Ref)
}
base, err := sp.Repo.ResolveRev(ctx, row.BaseRev)
if err != nil {
return Proposal{}, readErr(err, "resolve base %s of %s in %s", row.BaseRev, row.Branch, sp.Ref)
}
// A branch still sitting on its base carries no commits. It is neither
// mergeable nor "already merged": its tip is trivially an ancestor of the
// approved head (the branch was cut there), which the ancestry check below
// would misread as a completed merge. Say "nothing to merge" first — the
// same qualification PlanRepairs makes for exactly this reason.
if proposalHead == base {
return Proposal{}, fmt.Errorf("service: proposal %d has no changes to merge: %w",
row.ID, gitx.ErrUnsupportedChange)
}
// Already-merged proposals need an ancestry check, not a staleness check
// (design): once the approved head carries the proposal's own blobs, the
// blob comparison is trivially "changed" and gitx.Merge would return a
// confusing 409. Testing IsAncestor(proposalHead, head) first is the only
// thing that tells "landed" from "conflicts".
already, err := sp.Repo.IsAncestor(ctx, proposalHead, head)
if err != nil {
return Proposal{}, readErr(err, "ancestry of %s in %s", row.Branch, sp.Ref)
}
if already {
return Proposal{}, fmt.Errorf("%w: proposal %d (%s)", ErrAlreadyMerged, row.ID, row.Branch)
}
// The owner approves and the owner commits, so both identities are the
// instance owner. A merge carries no agent trailers: it is a human (or
// policy) act, and the agent provenance rides on the proposal's own commits,
// which stay visible as the merge commit's second parent.
sig := s.ownerSignature()
res, err := sp.Repo.Merge(ctx, gitx.MergeRequest{
Branch: row.Branch,
Base: row.BaseRev,
Meta: gitx.CommitMeta{
Message: fmt.Sprintf("Merge proposal %d: %s", row.ID, row.Title),
Author: sig,
Committer: sig,
},
})
if err != nil {
return Proposal{}, mergeErr(err, sp.Ref, row.ID)
}
// Refs are already truth for the merge; this makes Postgres agree, and does
// the row flip and the document-registry move in one transaction so a reader
// never sees a merged proposal whose documents are still registered at their
// pre-merge paths.
docs := make([]db.DocRef, 0, len(res.Docs))
for _, d := range res.Docs {
id, err := core.ParseDocID(d.DocID)
if err != nil {
return Proposal{}, fmt.Errorf("service: merged document %q at %q in %s carries an unusable id: %w",
d.DocID, d.Path, sp.Ref, err)
}
docs = append(docs, db.DocRef{ID: id, Path: d.Path})
}
mergedRev := res.Commit.String()
if err := s.store.MergeProposal(ctx, db.Merge{
ProposalID: row.ID,
SpaceID: sp.ID,
Approval: approval,
MergedRev: mergedRev,
Docs: docs,
}); err != nil {
// The ref moved but the row did not: exactly the crash state the
// reconciler repairs (RepairMarkMerged). Surface it rather than
// reporting a failed merge — the merge commit is on the approved branch
// and reads already see it.
return Proposal{}, fmt.Errorf("service: proposal %d merged to %s in %s but its row could not be updated "+
"(the reconciler will repair it): %w", row.ID, short(mergedRev), sp.Ref, err)
}
// mergeProposal is the single merge point — both the public Merge and
// auto-merge reach it — so PROPOSAL_MERGED fires here, once per merge.
merged, err := s.GetProposal(ctx, row.ID)
if err != nil {
return Proposal{}, err
}
s.emit(EventProposalMerged, merged)
return merged, nil
}
// openProposalRow resolves a proposal by id within a named space, opening the
// space's repository. A proposal id that belongs to another space is reported
// as not found rather than acted on across the space boundary.
func (s *Service) openProposalRow(ctx context.Context, ref core.SpaceRef, proposalID int) (*Space, *db.Proposal, error) {
sp, err := s.OpenSpace(ctx, ref)
if err != nil {
return nil, nil, err
}
row, err := s.store.GetProposal(ctx, proposalID)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, nil, fmt.Errorf("%w: proposal %d", ErrNotFound, proposalID)
}
return nil, nil, fmt.Errorf("service: look up proposal %d: %w", proposalID, err)
}
if row.SpaceID != sp.ID {
return nil, nil, fmt.Errorf("%w: proposal %d is not in %s", ErrNotFound, proposalID, ref)
}
return sp, row, nil
}
// ownerSignature is the git identity every merge commit carries: the instance
// owner, stamped with the reconciler-injectable clock so provenance stays
// testable without sleeping.
func (s *Service) ownerSignature() gitx.Signature {
o := s.cfg.Instance.OwnerSignature()
return gitx.Signature{Name: o.Name, Email: o.Email, When: s.now().UTC()}
}
// mergeErr maps a gitx merge failure onto this package's sentinels. A staleness
// error becomes ErrStale while keeping the *gitx.StaleError in the chain, so a
// surface that wants to tell the agent which document went stale type-asserts to
// it and one that only needs the 409 matches ErrStale.
func mergeErr(err error, ref core.SpaceRef, proposalID int) error {
var stale *gitx.StaleError
if errors.As(err, &stale) {
return fmt.Errorf("%w: proposal %d against %s: %w", ErrStale, proposalID, ref, err)
}
return fmt.Errorf("service: merge proposal %d in %s: %w", proposalID, ref, err)
}
// resolveProposalErr maps a db resolution failure (reject) onto this package's
// sentinels: a missing row is ErrNotFound, and a row that has already merged or
// been rejected is ErrProposalNotOpen.
func resolveProposalErr(err error, proposalID int) error {
switch {
case errors.Is(err, db.ErrNotFound):
return fmt.Errorf("%w: proposal %d", ErrNotFound, proposalID)
case errors.Is(err, db.ErrProposalNotOpen):
return fmt.Errorf("%w: proposal %d", ErrProposalNotOpen, proposalID)
default:
return fmt.Errorf("service: reject proposal %d: %w", proposalID, err)
}
}