coverage~bigbes/sr-ht-spec3cb1c03dservice/merge.go

Coverage
67.1% 49/73 statements
Δ
Blob
56e848f
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7
8 "go.bigb.es/auxilia/culpa"
9
10 "sourcecraft.dev/bigbes/sr-ht-spec/core"
11 "sourcecraft.dev/bigbes/sr-ht-spec/db"
12 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
13 )
14
15 // Merge lands an open proposal onto the space's approved head and records how it
16 // was authorized.
17 //
18 // This is the state machine the design calls "approve merges immediately":
19 // there is no separate approved-then-merged step, because with one reviewer a
20 // merge is the approval. Phase 4's browser button calls it with ApprovalHuman;
21 // auto-merge policy calls the same path with ApprovalPolicy (see Propose), so
22 // the merge model runs identically whether a human clicked or a pattern
23 // matched — the only difference is the approval kind recorded, which is what a
24 // reader needs to tell reviewed content from firehose.
25 //
26 // The failure modes are the design's: a base that moved under the proposal is
27 // ErrStale (type-assert the chain to *gitx.StaleError for which document), and a
28 // proposal already in the approved history is ErrAlreadyMerged rather than a
29 // confusing staleness 409.
30 6 func (s *Service) Merge(ctx context.Context, ref core.SpaceRef, proposalID int, approval core.Approval) (Proposal, error) {
31 6 sp, row, err := s.openProposalRow(ctx, ref, proposalID)
32 6 if err != nil {
33 0 return Proposal{}, err
34 0 }
35 6 return s.mergeProposal(ctx, sp, row, approval)
36 }
37
38 // Reject resolves an open proposal to rejected. There is no request-changes
39 // cycle — with one reviewer, a proposal you dislike is rejected and the agent
40 // proposes again — so this is the whole of the "do not merge" path. The branch
41 // and row are kept: the proposal URL still resolves and shows the outcome.
42 2 func (s *Service) Reject(ctx context.Context, ref core.SpaceRef, proposalID int) (Proposal, error) {
43 2 _, row, err := s.openProposalRow(ctx, ref, proposalID)
44 2 if err != nil {
45 0 return Proposal{}, err
46 0 }
47 2 if err := s.store.RejectProposal(ctx, row.ID); err != nil {
48 0 return Proposal{}, resolveProposalErr(err, row.ID)
49 0 }
50 2 rejected, err := s.GetProposal(ctx, row.ID)
51 2 if err != nil {
52 0 return Proposal{}, err
53 0 }
54 2 s.emit(EventProposalRejected, rejected)
55 2 return rejected, nil
56 }
57
58 // mergeProposal is the merge itself, shared by the public Merge and by
59 // auto-merge. It assumes row is the proposal's current row and sp its open
60 // space.
61 11 func (s *Service) mergeProposal(ctx context.Context, sp *Space, row *db.Proposal, approval core.Approval) (Proposal, error) {
62 11 if _, err := core.ParseApproval(string(approval)); err != nil {
63 0 return Proposal{}, err
64 0 }
65 11 if row.State != core.StateOpen {
66 1 return Proposal{}, fmt.Errorf("%w: proposal %d is %s", ErrProposalNotOpen, row.ID, row.State)
67 1 }
68
69 10 head, err := sp.Repo.ApprovedHead(ctx)
70 10 if err != nil {
71 0 return Proposal{}, readErr(err, "read approved head of %s", sp.Ref)
72 0 }
73 10 proposalHead, err := sp.Repo.BranchHead(ctx, row.Branch)
74 10 if err != nil {
75 0 return Proposal{}, readErr(err, "read head of %s in %s", row.Branch, sp.Ref)
76 0 }
77 10 base, err := sp.Repo.ResolveRev(ctx, row.BaseRev)
78 10 if err != nil {
79 0 return Proposal{}, readErr(err, "resolve base %s of %s in %s", row.BaseRev, row.Branch, sp.Ref)
80 0 }
81
82 // A branch still sitting on its base carries no commits. It is neither
83 // mergeable nor "already merged": its tip is trivially an ancestor of the
84 // approved head (the branch was cut there), which the ancestry check below
85 // would misread as a completed merge. Say "nothing to merge" first — the
86 // same qualification PlanRepairs makes for exactly this reason.
87 10 if proposalHead == base {
88 0 return Proposal{}, fmt.Errorf("service: proposal %d has no changes to merge: %w",
89 0 row.ID, gitx.ErrUnsupportedChange)
90 0 }
91
92 // Already-merged proposals need an ancestry check, not a staleness check
93 // (design): once the approved head carries the proposal's own blobs, the
94 // blob comparison is trivially "changed" and gitx.Merge would return a
95 // confusing 409. Testing IsAncestor(proposalHead, head) first is the only
96 // thing that tells "landed" from "conflicts".
97 10 already, err := sp.Repo.IsAncestor(ctx, proposalHead, head)
98 10 if err != nil {
99 0 return Proposal{}, readErr(err, "ancestry of %s in %s", row.Branch, sp.Ref)
100 0 }
101 10 if already {
102 0 return Proposal{}, fmt.Errorf("%w: proposal %d (%s)", ErrAlreadyMerged, row.ID, row.Branch)
103 0 }
104
105 // The owner approves and the owner commits, so both identities are the
106 // instance owner. A merge carries no agent trailers: it is a human (or
107 // policy) act, and the agent provenance rides on the proposal's own commits,
108 // which stay visible as the merge commit's second parent.
109 10 sig := s.ownerSignature()
110 10 res, err := sp.Repo.Merge(ctx, gitx.MergeRequest{
111 10 Branch: row.Branch,
112 10 Base: row.BaseRev,
113 10 Meta: gitx.CommitMeta{
114 10 Message: fmt.Sprintf("Merge proposal %d: %s", row.ID, row.Title),
115 10 Author: sig,
116 10 Committer: sig,
117 10 },
118 10 })
119 10 if err != nil {
120 1 return Proposal{}, mergeErr(err, sp.Ref, row.ID)
121 1 }
122
123 // Refs are already truth for the merge; this makes Postgres agree, and does
124 // the row flip and the document-registry move in one transaction so a reader
125 // never sees a merged proposal whose documents are still registered at their
126 // pre-merge paths.
127 9 docs := make([]db.DocRef, 0, len(res.Docs))
128 10 for _, d := range res.Docs {
129 10 id, err := core.ParseDocID(d.DocID)
130 10 if err != nil {
131 0 return Proposal{}, fmt.Errorf("service: merged document %q at %q in %s carries an unusable id: %w",
132 0 d.DocID, d.Path, sp.Ref, err)
133 0 }
134 10 docs = append(docs, db.DocRef{ID: id, Path: d.Path})
135 }
136 9 mergedRev := res.Commit.String()
137 9 if err := s.store.MergeProposal(ctx, db.Merge{
138 9 ProposalID: row.ID,
139 9 SpaceID: sp.ID,
140 9 Approval: approval,
141 9 MergedRev: mergedRev,
142 9 Docs: docs,
143 9 }); err != nil {
144 0 // The ref moved but the row did not: exactly the crash state the
145 0 // reconciler repairs (RepairMarkMerged). Surface it rather than
146 0 // reporting a failed merge — the merge commit is on the approved branch
147 0 // and reads already see it.
148 0 //
149 0 // The hint rides on the error rather than sitting in this sentence
150 0 // because the sentence is one wrap away from being buried under
151 0 // another, while a culpa detail survives every wrap above it and comes
152 0 // out as its own field wherever this is finally logged.
153 0 return Proposal{}, culpa.WithHint(
154 0 culpa.Wrapf(err, "service: proposal %d merged to %s in %s but its row could not be updated",
155 0 row.ID, short(mergedRev), sp.Ref),
156 0 "the merge is on the approved branch; the reconciler repairs the row (RepairMarkMerged)")
157 0 }
158
159 // mergeProposal is the single merge point — both the public Merge and
160 // auto-merge reach it — so PROPOSAL_MERGED fires here, once per merge.
161 9 merged, err := s.GetProposal(ctx, row.ID)
162 9 if err != nil {
163 0 return Proposal{}, err
164 0 }
165 9 s.emit(EventProposalMerged, merged)
166 9 return merged, nil
167 }
168
169 // openProposalRow resolves a proposal by id within a named space, opening the
170 // space's repository. A proposal id that belongs to another space is reported
171 // as not found rather than acted on across the space boundary.
172 8 func (s *Service) openProposalRow(ctx context.Context, ref core.SpaceRef, proposalID int) (*Space, *db.Proposal, error) {
173 8 sp, err := s.OpenSpace(ctx, ref)
174 8 if err != nil {
175 0 return nil, nil, err
176 0 }
177 8 row, err := s.store.GetProposal(ctx, proposalID)
178 8 if err != nil {
179 0 if errors.Is(err, db.ErrNotFound) {
180 0 return nil, nil, fmt.Errorf("%w: proposal %d", ErrNotFound, proposalID)
181 0 }
182 0 return nil, nil, fmt.Errorf("service: look up proposal %d: %w", proposalID, err)
183 }
184 8 if row.SpaceID != sp.ID {
185 0 return nil, nil, fmt.Errorf("%w: proposal %d is not in %s", ErrNotFound, proposalID, ref)
186 0 }
187 8 return sp, row, nil
188 }
189
190 // ownerSignature is the git identity every merge commit carries: the instance
191 // owner, stamped with the reconciler-injectable clock so provenance stays
192 // testable without sleeping.
193 10 func (s *Service) ownerSignature() gitx.Signature {
194 10 o := s.cfg.Instance.OwnerSignature()
195 10 return gitx.Signature{Name: o.Name, Email: o.Email, When: s.now().UTC()}
196 10 }
197
198 // mergeErr maps a gitx merge failure onto this package's sentinels. A staleness
199 // error becomes ErrStale while keeping the *gitx.StaleError in the chain, so a
200 // surface that wants to tell the agent which document went stale type-asserts to
201 // it and one that only needs the 409 matches ErrStale.
202 1 func mergeErr(err error, ref core.SpaceRef, proposalID int) error {
203 1 var stale *gitx.StaleError
204 1 if errors.As(err, &stale) {
205 1 return fmt.Errorf("%w: proposal %d against %s: %w", ErrStale, proposalID, ref, err)
206 1 }
207 // Everything that is not staleness is a git or database failure: the
208 // surfaces answer it as a 500, and nobody but an operator ever reads it.
209 // culpa.Wrapf and not fmt.Errorf because this is the *outermost* wrap, which
210 // is the one the log sees — it carries a stacktrace and scribe.Err expands
211 // it. The message chain says "merge proposal 7"; the stack says which of the
212 // merge's dozen git calls produced it, which is the question a 500 here
213 // actually raises. errors.Is and errors.As still traverse it, so the
214 // sentinel mapping above and in the surfaces is unaffected.
215 0 return culpa.Wrapf(err, "service: merge proposal %d in %s", proposalID, ref)
216 }
217
218 // resolveProposalErr maps a db resolution failure (reject) onto this package's
219 // sentinels: a missing row is ErrNotFound, and a row that has already merged or
220 // been rejected is ErrProposalNotOpen.
221 0 func resolveProposalErr(err error, proposalID int) error {
222 0 switch {
223 0 case errors.Is(err, db.ErrNotFound):
224 0 return fmt.Errorf("%w: proposal %d", ErrNotFound, proposalID)
225 0 case errors.Is(err, db.ErrProposalNotOpen):
226 0 return fmt.Errorf("%w: proposal %d", ErrProposalNotOpen, proposalID)
227 0 default:
228 0 // A database failure, for the reason mergeErr gives: outermost wrap,
229 0 // operator-only, so it carries a stack.
230 0 return culpa.Wrapf(err, "service: reject proposal %d", proposalID)
231 }
232 }