~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/service/review.go -rw-r--r-- 7.3 KiB
64cae3af — Eugene Blikh graph: accept a meta.sr.ht token, so /query can be federated a day 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
package service

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"time"

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

// ProposalDoc is one document a proposal touches, as the review page needs it:
// its path, the approved content it was based on, and the proposed content on
// the branch. It is the input to the prose diff, which is the web layer's to
// render — this layer reads git and hands over bytes.
type ProposalDoc struct {
	// Path is the document's path on the proposal branch.
	Path string

	// Base is the document's content at the proposal's base — the approved text
	// the change was made against. Nil for a document the proposal adds, which
	// is the signal to render it as wholly new rather than as a diff.
	Base []byte

	// Proposed is the document's content on the proposal branch.
	Proposed []byte

	// New reports whether the document did not exist at the base.
	New bool
}

// ProposalDiff returns every document a proposal changes, each with the base and
// proposed content the review page diffs.
//
// It reads the base and the proposal branch through the normal pinned-revision
// path, not the ReadDocumentAtRef bypass: the branch tip is resolved to a commit
// sha first, and an object name is a legitimate read whatever it points at. The
// bypass exists for reading a branch *by name*; here the review already holds
// the proposal and can pin it.
//
// Only genuinely changed documents are returned — a proposal branch is cut from
// the base, so most of its documents are byte-identical to it and are not diffs.
// A proposal changes only documents (agents cannot rename or delete), so a
// document present at the base is present on the branch; the reverse asymmetry,
// a document added by the proposal, is marked New.
func (s *Service) ProposalDiff(ctx context.Context, p Proposal) ([]ProposalDoc, error) {
	sp, err := s.OpenSpace(ctx, p.Space)
	if err != nil {
		return nil, err
	}

	baseDocs, err := s.ListDocuments(ctx, sp, p.BaseRev)
	if err != nil {
		return nil, fmt.Errorf("service: read base %s of proposal %d: %w", short(p.BaseRev), p.ID, err)
	}
	base := make(map[string][]byte, len(baseDocs))
	for _, d := range baseDocs {
		base[d.Path] = d.Data
	}

	head, err := sp.Repo.BranchHead(ctx, p.Branch)
	if err != nil {
		return nil, readErr(err, "read head of %s in %s", p.Branch, p.Space)
	}
	branchDocs, err := s.ListDocuments(ctx, sp, head.String())
	if err != nil {
		return nil, fmt.Errorf("service: read proposal branch %s: %w", p.Branch, err)
	}

	var out []ProposalDoc
	for _, d := range branchDocs {
		prior, existed := base[d.Path]
		switch {
		case !existed:
			out = append(out, ProposalDoc{Path: d.Path, Proposed: d.Data, New: true})
		case !bytesEqual(prior, d.Data):
			out = append(out, ProposalDoc{Path: d.Path, Base: prior, Proposed: d.Data})
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
	return out, nil
}

// bytesEqual reports byte equality. It exists so ProposalDiff does not pull in
// bytes for a single comparison, and reads as intent at the call site.
func bytesEqual(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// InboxProposals is every open proposal on the instance, newest first — the
// reviewer's queue, "N proposals waiting on you". It is instance-wide because
// there is one reviewer: a per-space inbox would make them visit each space to
// find what a link never reached them about.
//
// The digest and this share the mapping from a stored proposal's space_id back
// to a reference, done once from the space list rather than a lookup per row.
func (s *Service) InboxProposals(ctx context.Context) ([]Proposal, error) {
	return s.proposalsInState(ctx, core.StateOpen, false, 0)
}

// DigestProposals is the recently policy-merged proposals — the firehose
// digest. Auto-merged content never stops for review, so this is where a human
// sees it after the fact; the design's whole reason for keeping approval=policy
// distinct from human is so this list can exist. limit bounds it; <= 0 is a
// sane default.
func (s *Service) DigestProposals(ctx context.Context, limit int) ([]Proposal, error) {
	if limit <= 0 {
		limit = 20
	}
	return s.proposalsInState(ctx, core.StateMerged, true, limit)
}

// DigestMark reports when the owner last marked the digest seen, and whether a
// mark exists yet. No mark — the owner has never cleared the digest — is
// (zero, false, nil), and on that first view every auto-merge counts as new.
//
// The digest itself (DigestProposals) stays a pure read; this is the timestamp
// the inbox compares each row's merge time against to draw the "new since you
// last looked" line, without the render advancing anything.
func (s *Service) DigestMark(ctx context.Context) (time.Time, bool, error) {
	t, err := s.store.GetDigestMark(ctx, s.cfg.Instance.OwnerName)
	switch {
	case errors.Is(err, db.ErrNotFound):
		return time.Time{}, false, nil
	case err != nil:
		return time.Time{}, false, fmt.Errorf("service: read digest mark: %w", err)
	}
	return t, true, nil
}

// MarkDigestSeen advances the owner's digest mark to seenAt, so the next inbox
// render counts only what auto-merged after this moment as new. Advancing the
// mark is the one write the review queue makes, kept behind an explicit action
// so the inbox GET stays pure; the caller passes the timestamp so the clock
// lives at the edge and the write stays testable.
func (s *Service) MarkDigestSeen(ctx context.Context, seenAt time.Time) error {
	if err := s.store.SetDigestMark(ctx, s.cfg.Instance.OwnerName, seenAt); err != nil {
		return fmt.Errorf("service: advance digest mark: %w", err)
	}
	return nil
}

// proposalsInState lists proposals in one state instance-wide, optionally
// keeping only the policy-approved ones (the digest), and maps each onto its
// space reference. A row whose space no longer lists is skipped rather than
// errored: a deleted space takes its proposals out of every human-facing view,
// and a dangling row is the reconciler's to notice, not this read's to fail on.
func (s *Service) proposalsInState(ctx context.Context, state core.ProposalState, policyOnly bool, limit int) ([]Proposal, error) {
	spaces, err := s.ListSpaces(ctx)
	if err != nil {
		return nil, err
	}
	refByID := make(map[int]core.SpaceRef, len(spaces))
	for _, sp := range spaces {
		refByID[sp.ID] = sp.Ref
	}

	rows, err := s.store.ListProposalsByState(ctx, state, 0)
	if err != nil {
		return nil, fmt.Errorf("service: list %s proposals: %w", state, err)
	}
	out := make([]Proposal, 0, len(rows))
	for _, p := range rows {
		if policyOnly && p.Approval != core.ApprovalPolicy {
			continue
		}
		ref, ok := refByID[p.SpaceID]
		if !ok {
			continue
		}
		out = append(out, proposalView(p, ref))
		if limit > 0 && len(out) == limit {
			break
		}
	}
	return out, nil
}

// MergeHuman lands a proposal on the owner's approval — the review page's
// approve button. It is Merge with the approval kind fixed, so the surface does
// not choose it: a browser approve is always human, and a caller that could pass
// ApprovalPolicy here would be able to launder a firehose merge as reviewed.
func (s *Service) MergeHuman(ctx context.Context, ref core.SpaceRef, proposalID int) (Proposal, error) {
	return s.Merge(ctx, ref, proposalID, core.ApprovalHuman)
}