~bigbes/sr-ht-spec

ref: b643b0be64833bb1c989b70b57a5a7ded7142904 sr-ht-spec/service/merge.go -rw-r--r-- 9.6 KiB
b643b0be — Eugene Blikh bearer: refuse through the shared table and challenge 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
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)
	}
}