| 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 |
|
} |