~bigbes/sr-ht-spec

ref: 128d72963281e275bb1ec3be2bdb8653a2bb0c78 sr-ht-spec/db/proposal.go -rw-r--r-- 14.0 KiB
128d7296 — Eugene Blikh ci: cache Go module and build dirs via cacher 13 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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// BranchPrefix is the ref namespace agents may write, under this package's
// name. It is core's constant: the prefix the INSERT below builds a branch from
// and the prefix gitx enforces on a push are one value, because a row and a ref
// that disagree about a proposal's branch name is a break that surfaces as a
// proposal nobody can find.
const BranchPrefix = core.ProposalPrefix

// ProposalBranch is the branch name for a proposal id: "proposals/42". The row
// stores it verbatim (proposal.branch) because the row, not this function, is
// what the reconciler compares against the refs it finds.
//
// The derivation is core.ProposalBranch, so this and gitx.ProposalBranch cannot
// disagree — and it inherits core's refusal of a non-positive id, which here
// means an unwritten row rather than a proposal.
func ProposalBranch(id int) (string, error) { return core.ProposalBranch(int64(id)) }

// Proposal is a bundle of document edits awaiting review: a branch under
// BranchPrefix plus this row.
//
// BaseRev is the If-Match value the agent sent when the proposal was opened —
// the space's approved-head sha at the time it read the document — and it does
// not move as the proposal accumulates edits. Agent and AgentSession are
// mandatory provenance: one shared token still yields a full audit trail,
// because the identity strings, not the credential, are what say who did what.
//
// Approval and MergedRev are empty until the proposal merges; Resolved is nil
// until it leaves the open state.
type Proposal struct {
	ID           int
	SpaceID      int
	Title        string
	Rationale    string
	BaseRev      string
	Branch       string
	State        core.ProposalState
	Approval     core.Approval
	MergedRev    string
	Agent        string
	AgentSession string
	Created      time.Time
	Resolved     *time.Time
}

// Merge is everything one merge writes to Postgres: the proposal's transition
// to merged, and the new location of every document the merge touched. The two
// are one invariant — a merged proposal whose documents are still registered at
// their old paths would break link resolution and the next staleness check —
// so MergeProposal writes them in a single transaction.
type Merge struct {
	ProposalID int
	SpaceID    int
	Approval   core.Approval
	MergedRev  string
	// Docs is the (id, path) set of the documents the merge landed, at their
	// paths in the merge commit. Empty is legal but unusual: it means the
	// proposal touched nothing the registry tracks.
	Docs []DocRef
}

const proposalSelect = `
SELECT id, space_id, title, COALESCE(rationale, ''), base_rev, branch, state,
       COALESCE(approval, ''), COALESCE(merged_rev, ''), agent, agent_session,
       created, resolved
FROM proposal`

func scanProposal(sc rowScanner) (*Proposal, error) {
	var (
		p        Proposal
		state    string
		approval string
		resolved sql.NullTime
	)
	if err := sc.Scan(&p.ID, &p.SpaceID, &p.Title, &p.Rationale, &p.BaseRev,
		&p.Branch, &state, &approval, &p.MergedRev, &p.Agent, &p.AgentSession,
		&p.Created, &resolved); err != nil {
		return nil, err
	}
	parsedState, err := core.ParseProposalState(state)
	if err != nil {
		return nil, fmt.Errorf("proposal %d: %w", p.ID, err)
	}
	p.State = parsedState
	if approval != "" {
		parsedApproval, err := core.ParseApproval(approval)
		if err != nil {
			return nil, fmt.Errorf("proposal %d: %w", p.ID, err)
		}
		p.Approval = parsedApproval
	}
	if resolved.Valid {
		t := resolved.Time
		p.Resolved = &t
	}
	return &p, nil
}

// OpenProposal inserts a new proposal in the open state and returns it with its
// id, branch and creation time filled in. p.SpaceID, p.Title, p.BaseRev,
// p.Agent and p.AgentSession must be set; State, Approval, MergedRev and
// Resolved are ignored on input — a proposal is always born open.
//
// The branch name derives from the generated id ("proposals/42"), so id and
// branch are allocated in one statement: taking the id in a first round trip
// and writing the branch in a second would leave a window where a crash yields
// a row whose branch names nothing.
func (s *Store) OpenProposal(ctx context.Context, p *Proposal) (*Proposal, error) {
	if p.Agent == "" || p.AgentSession == "" {
		return nil, fmt.Errorf("open proposal: agent identity and session are required provenance")
	}
	if p.BaseRev == "" {
		return nil, fmt.Errorf("open proposal: base rev is required")
	}
	const q = `
WITH next AS (SELECT nextval(pg_get_serial_sequence('proposal', 'id')) AS id)
INSERT INTO proposal (id, space_id, title, rationale, base_rev, branch, state,
                      agent, agent_session, created)
SELECT next.id, $1, $2, $3, $4, $5::text || next.id::text, $6, $7, $8, $9
FROM next
RETURNING id, branch, created`
	out := *p
	out.State = core.StateOpen
	out.Approval = ""
	out.MergedRev = ""
	out.Resolved = nil
	err := s.q.QueryRowContext(ctx, q,
		p.SpaceID, p.Title, nullable(p.Rationale), p.BaseRev, BranchPrefix,
		string(core.StateOpen), p.Agent, p.AgentSession, time.Now().UTC(),
	).Scan(&out.ID, &out.Branch, &out.Created)
	if err != nil {
		return nil, fmt.Errorf("open proposal: %w", err)
	}
	return &out, nil
}

// GetProposal resolves a proposal by id. Returns ErrNotFound if it does not
// exist. Proposal URLs are stable and shareable — a link still resolves after
// merge or rejection, showing the outcome — so this is the same lookup whatever
// the state.
func (s *Store) GetProposal(ctx context.Context, id int) (*Proposal, error) {
	q := proposalSelect + ` WHERE id = $1`
	p, err := scanProposal(s.q.QueryRowContext(ctx, q, id))
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get proposal %d: %w", id, err)
	}
	return p, nil
}

// ListProposalsByState lists proposals in one state, newest first — the inbox
// query ("N proposals waiting on you"), served by ix_proposal_state_created.
// limit <= 0 means no limit.
func (s *Store) ListProposalsByState(ctx context.Context, state core.ProposalState, limit int) ([]*Proposal, error) {
	if _, err := core.ParseProposalState(string(state)); err != nil {
		return nil, err
	}
	q := proposalSelect + ` WHERE state = $1 ORDER BY created DESC, id DESC`
	args := []any{string(state)}
	if limit > 0 {
		q += ` LIMIT $2`
		args = append(args, limit)
	}
	return s.queryProposals(ctx, q, args...)
}

// ListProposalsBySpace lists one space's proposals in one state, newest first —
// the per-space proposal listing the read surfaces serve ("the open proposals
// on ~bigbes/rfcs"). ListProposalsByState is the instance-wide inbox; this is
// its space-scoped counterpart, which is the shape every surface above service/
// asks for, because a proposal is only ever meaningful inside its space.
//
// The (state, created DESC) index still serves this: the extra space_id
// predicate is a filter over a set that is already tiny at this instance's
// volume. limit <= 0 means no limit.
func (s *Store) ListProposalsBySpace(ctx context.Context, spaceID int, state core.ProposalState, limit int) ([]*Proposal, error) {
	if _, err := core.ParseProposalState(string(state)); err != nil {
		return nil, err
	}
	q := proposalSelect + ` WHERE space_id = $1 AND state = $2 ORDER BY created DESC, id DESC`
	args := []any{spaceID, string(state)}
	if limit > 0 {
		q += ` LIMIT $3`
		args = append(args, limit)
	}
	return s.queryProposals(ctx, q, args...)
}

// queryProposals runs a proposalSelect query and scans every row. The two
// listings above differ only in their WHERE and args, so the row loop — the
// part that is easy to get subtly wrong (a missing rows.Err, a leaked cursor) —
// lives in one place.
func (s *Store) queryProposals(ctx context.Context, q string, args ...any) ([]*Proposal, error) {
	rows, err := s.q.QueryContext(ctx, q, args...)
	if err != nil {
		return nil, fmt.Errorf("list proposals: %w", err)
	}
	defer rows.Close()
	var out []*Proposal
	for rows.Next() {
		p, err := scanProposal(rows)
		if err != nil {
			return nil, fmt.Errorf("scan proposal: %w", err)
		}
		out = append(out, p)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate proposals: %w", err)
	}
	return out, nil
}

// MarkProposalMerged transitions a proposal to merged, recording how it was
// authorized (human or policy) and the merge commit. It writes only the row;
// use MergeProposal to update the document registry in the same transaction.
//
// The legality of the transition is enforced in SQL by `WHERE state = 'open'`,
// not by reading the row first: a check-then-write would let two concurrent
// resolutions both pass the check. When the guard bites, the current state is
// read back only to name it in the error.
func (s *Store) MarkProposalMerged(ctx context.Context, id int, approval core.Approval, mergedRev string) error {
	if _, err := core.ParseApproval(string(approval)); err != nil {
		return err
	}
	if mergedRev == "" {
		return fmt.Errorf("merge proposal %d: merged rev is required", id)
	}
	return s.resolveProposal(ctx, id, core.StateMerged, string(approval), mergedRev)
}

// RejectProposal transitions a proposal to rejected. There is no
// request-changes cycle: with one reviewer, a proposal you dislike is rejected
// and the agent proposes again.
func (s *Store) RejectProposal(ctx context.Context, id int) error {
	return s.resolveProposal(ctx, id, core.StateRejected, "", "")
}

// resolveProposal is the shared open->terminal update. next must be a legal
// destination from open; approval and mergedRev are stored as SQL NULL when
// empty, which the ck_proposal_merged constraints require for a rejection.
func (s *Store) resolveProposal(ctx context.Context, id int, next core.ProposalState, approval, mergedRev string) error {
	if err := core.StateOpen.CanTransitionTo(next); err != nil {
		return err
	}
	const q = `
UPDATE proposal
SET state = $2, approval = $3, merged_rev = $4, resolved = $5
WHERE id = $1 AND state = $6`
	res, err := s.q.ExecContext(ctx, q, id, string(next), nullable(approval),
		nullable(mergedRev), time.Now().UTC(), string(core.StateOpen))
	if err != nil {
		return fmt.Errorf("resolve proposal %d as %s: %w", id, next, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("resolve proposal %d: rows affected: %w", id, err)
	}
	if n == 1 {
		return nil
	}

	// Nothing moved: either the proposal is gone, or it is no longer open.
	var current string
	err = s.q.QueryRowContext(ctx, `SELECT state FROM proposal WHERE id = $1`, id).Scan(&current)
	if errors.Is(err, sql.ErrNoRows) {
		return ErrNotFound
	}
	if err != nil {
		return fmt.Errorf("resolve proposal %d: read current state: %w", id, err)
	}
	from, err := core.ParseProposalState(current)
	if err != nil {
		return fmt.Errorf("proposal %d: %w", id, err)
	}
	if err := from.CanTransitionTo(next); err != nil {
		return fmt.Errorf("proposal %d: %w", id, err)
	}
	// The row is open and the transition is legal, yet the guarded UPDATE
	// matched nothing. That cannot happen; refuse rather than report success.
	return fmt.Errorf("proposal %d: guarded update matched no row while state is %s", id, from)
}

// DeleteOpenProposal removes an open proposal row outright.
//
// It is the only delete in this package and it is deliberately narrow: the
// reconciler's repair for a row whose branch never appeared, where the daemon
// died between the row insert and the branch write. Such a row holds no content
// — the agent still has the document it wanted to write and re-proposes — so
// deleting it loses nothing. A resolved proposal is history and is never
// deleted, which is why this is not a general-purpose delete.
//
// The `state = 'open'` guard is in the statement, not in Go: a check-then-write
// would let a merge land in between and delete the row of a proposal that had
// just succeeded. When the guard bites, the current state is read back only to
// name it in the error — ErrNotFound when the row is gone, ErrProposalNotOpen
// when it has been resolved — exactly as resolveProposal does.
func (s *Store) DeleteOpenProposal(ctx context.Context, id int) error {
	const q = `DELETE FROM proposal WHERE id = $1 AND state = $2`
	res, err := s.q.ExecContext(ctx, q, id, string(core.StateOpen))
	if err != nil {
		return fmt.Errorf("delete proposal %d: %w", id, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("delete proposal %d: rows affected: %w", id, err)
	}
	if n == 1 {
		return nil
	}

	// Nothing was deleted: either the proposal is gone, or it is no longer open.
	var current string
	err = s.q.QueryRowContext(ctx, `SELECT state FROM proposal WHERE id = $1`, id).Scan(&current)
	if errors.Is(err, sql.ErrNoRows) {
		return ErrNotFound
	}
	if err != nil {
		return fmt.Errorf("delete proposal %d: read current state: %w", id, err)
	}
	from, err := core.ParseProposalState(current)
	if err != nil {
		return fmt.Errorf("proposal %d: %w", id, err)
	}
	if from == core.StateOpen {
		// The row is open, yet the guarded DELETE matched nothing. That cannot
		// happen; refuse rather than report success.
		return fmt.Errorf("proposal %d: guarded delete matched no row while state is %s", id, from)
	}
	return fmt.Errorf("%w: proposal %d is %s", ErrProposalNotOpen, id, from)
}

// MergeProposal records a merge: the proposal's transition to merged and the
// new registry location of every document it landed, atomically.
//
// Git refs remain the source of truth for whether the merge happened — this row
// is what the reconciler repairs from the ref when a crash lands between the
// two. What the transaction buys is that Postgres never holds the half-state
// where the proposal reads merged but its documents are still registered at
// their pre-merge paths.
func (s *Store) MergeProposal(ctx context.Context, m Merge) error {
	if _, err := core.ParseApproval(string(m.Approval)); err != nil {
		return err
	}
	if m.MergedRev == "" {
		return fmt.Errorf("merge proposal %d: merged rev is required", m.ProposalID)
	}
	return s.InTx(ctx, func(tx *Store) error {
		if err := tx.MarkProposalMerged(ctx, m.ProposalID, m.Approval, m.MergedRev); err != nil {
			return err
		}
		return tx.UpsertDocIDs(ctx, m.SpaceID, m.Docs, m.MergedRev)
	})
}