package service import ( "context" "errors" "fmt" "go.bigb.es/auxilia/culpa" "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. // // The hint rides on the error rather than sitting in this sentence // because the sentence is one wrap away from being buried under // another, while a culpa detail survives every wrap above it and comes // out as its own field wherever this is finally logged. return Proposal{}, culpa.WithHint( culpa.Wrapf(err, "service: proposal %d merged to %s in %s but its row could not be updated", row.ID, short(mergedRev), sp.Ref), "the merge is on the approved branch; the reconciler repairs the row (RepairMarkMerged)") } // 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) } // Everything that is not staleness is a git or database failure: the // surfaces answer it as a 500, and nobody but an operator ever reads it. // culpa.Wrapf and not fmt.Errorf because this is the *outermost* wrap, which // is the one the log sees — it carries a stacktrace and scribe.Err expands // it. The message chain says "merge proposal 7"; the stack says which of the // merge's dozen git calls produced it, which is the question a 500 here // actually raises. errors.Is and errors.As still traverse it, so the // sentinel mapping above and in the surfaces is unaffected. return culpa.Wrapf(err, "service: merge proposal %d in %s", proposalID, ref) } // 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: // A database failure, for the reason mergeErr gives: outermost wrap, // operator-only, so it carries a stack. return culpa.Wrapf(err, "service: reject proposal %d", proposalID) } }