coverage~bigbes/sr-ht-spec64cae3afservice/review.go

Coverage
81.0% 51/63 statements
Δ
+0.0
Blob
458ce16
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "sort"
8 "time"
9
10 "sourcecraft.dev/bigbes/sr-ht-spec/core"
11 "sourcecraft.dev/bigbes/sr-ht-spec/db"
12 )
13
14 // ProposalDoc is one document a proposal touches, as the review page needs it:
15 // its path, the approved content it was based on, and the proposed content on
16 // the branch. It is the input to the prose diff, which is the web layer's to
17 // render — this layer reads git and hands over bytes.
18 type ProposalDoc struct {
19 // Path is the document's path on the proposal branch.
20 Path string
21
22 // Base is the document's content at the proposal's base — the approved text
23 // the change was made against. Nil for a document the proposal adds, which
24 // is the signal to render it as wholly new rather than as a diff.
25 Base []byte
26
27 // Proposed is the document's content on the proposal branch.
28 Proposed []byte
29
30 // New reports whether the document did not exist at the base.
31 New bool
32 }
33
34 // ProposalDiff returns every document a proposal changes, each with the base and
35 // proposed content the review page diffs.
36 //
37 // It reads the base and the proposal branch through the normal pinned-revision
38 // path, not the ReadDocumentAtRef bypass: the branch tip is resolved to a commit
39 // sha first, and an object name is a legitimate read whatever it points at. The
40 // bypass exists for reading a branch *by name*; here the review already holds
41 // the proposal and can pin it.
42 //
43 // Only genuinely changed documents are returned — a proposal branch is cut from
44 // the base, so most of its documents are byte-identical to it and are not diffs.
45 // A proposal changes only documents (agents cannot rename or delete), so a
46 // document present at the base is present on the branch; the reverse asymmetry,
47 // a document added by the proposal, is marked New.
48 1 func (s *Service) ProposalDiff(ctx context.Context, p Proposal) ([]ProposalDoc, error) {
49 1 sp, err := s.OpenSpace(ctx, p.Space)
50 1 if err != nil {
51 0 return nil, err
52 0 }
53
54 1 baseDocs, err := s.ListDocuments(ctx, sp, p.BaseRev)
55 1 if err != nil {
56 0 return nil, fmt.Errorf("service: read base %s of proposal %d: %w", short(p.BaseRev), p.ID, err)
57 0 }
58 1 base := make(map[string][]byte, len(baseDocs))
59 2 for _, d := range baseDocs {
60 2 base[d.Path] = d.Data
61 2 }
62
63 1 head, err := sp.Repo.BranchHead(ctx, p.Branch)
64 1 if err != nil {
65 0 return nil, readErr(err, "read head of %s in %s", p.Branch, p.Space)
66 0 }
67 1 branchDocs, err := s.ListDocuments(ctx, sp, head.String())
68 1 if err != nil {
69 0 return nil, fmt.Errorf("service: read proposal branch %s: %w", p.Branch, err)
70 0 }
71
72 1 var out []ProposalDoc
73 3 for _, d := range branchDocs {
74 3 prior, existed := base[d.Path]
75 3 switch {
76 1 case !existed:
77 1 out = append(out, ProposalDoc{Path: d.Path, Proposed: d.Data, New: true})
78 1 case !bytesEqual(prior, d.Data):
79 1 out = append(out, ProposalDoc{Path: d.Path, Base: prior, Proposed: d.Data})
80 }
81 }
82 1 sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
83 1 return out, nil
84 }
85
86 // bytesEqual reports byte equality. It exists so ProposalDiff does not pull in
87 // bytes for a single comparison, and reads as intent at the call site.
88 2 func bytesEqual(a, b []byte) bool {
89 2 if len(a) != len(b) {
90 1 return false
91 1 }
92 58 for i := range a {
93 58 if a[i] != b[i] {
94 0 return false
95 0 }
96 }
97 1 return true
98 }
99
100 // InboxProposals is every open proposal on the instance, newest first — the
101 // reviewer's queue, "N proposals waiting on you". It is instance-wide because
102 // there is one reviewer: a per-space inbox would make them visit each space to
103 // find what a link never reached them about.
104 //
105 // The digest and this share the mapping from a stored proposal's space_id back
106 // to a reference, done once from the space list rather than a lookup per row.
107 1 func (s *Service) InboxProposals(ctx context.Context) ([]Proposal, error) {
108 1 return s.proposalsInState(ctx, core.StateOpen, false, 0)
109 1 }
110
111 // DigestProposals is the recently policy-merged proposals — the firehose
112 // digest. Auto-merged content never stops for review, so this is where a human
113 // sees it after the fact; the design's whole reason for keeping approval=policy
114 // distinct from human is so this list can exist. limit bounds it; <= 0 is a
115 // sane default.
116 1 func (s *Service) DigestProposals(ctx context.Context, limit int) ([]Proposal, error) {
117 1 if limit <= 0 {
118 1 limit = 20
119 1 }
120 1 return s.proposalsInState(ctx, core.StateMerged, true, limit)
121 }
122
123 // DigestMark reports when the owner last marked the digest seen, and whether a
124 // mark exists yet. No mark — the owner has never cleared the digest — is
125 // (zero, false, nil), and on that first view every auto-merge counts as new.
126 //
127 // The digest itself (DigestProposals) stays a pure read; this is the timestamp
128 // the inbox compares each row's merge time against to draw the "new since you
129 // last looked" line, without the render advancing anything.
130 2 func (s *Service) DigestMark(ctx context.Context) (time.Time, bool, error) {
131 2 t, err := s.store.GetDigestMark(ctx, s.cfg.Instance.OwnerName)
132 2 switch {
133 1 case errors.Is(err, db.ErrNotFound):
134 1 return time.Time{}, false, nil
135 0 case err != nil:
136 0 return time.Time{}, false, fmt.Errorf("service: read digest mark: %w", err)
137 }
138 1 return t, true, nil
139 }
140
141 // MarkDigestSeen advances the owner's digest mark to seenAt, so the next inbox
142 // render counts only what auto-merged after this moment as new. Advancing the
143 // mark is the one write the review queue makes, kept behind an explicit action
144 // so the inbox GET stays pure; the caller passes the timestamp so the clock
145 // lives at the edge and the write stays testable.
146 1 func (s *Service) MarkDigestSeen(ctx context.Context, seenAt time.Time) error {
147 1 if err := s.store.SetDigestMark(ctx, s.cfg.Instance.OwnerName, seenAt); err != nil {
148 0 return fmt.Errorf("service: advance digest mark: %w", err)
149 0 }
150 1 return nil
151 }
152
153 // proposalsInState lists proposals in one state instance-wide, optionally
154 // keeping only the policy-approved ones (the digest), and maps each onto its
155 // space reference. A row whose space no longer lists is skipped rather than
156 // errored: a deleted space takes its proposals out of every human-facing view,
157 // and a dangling row is the reconciler's to notice, not this read's to fail on.
158 2 func (s *Service) proposalsInState(ctx context.Context, state core.ProposalState, policyOnly bool, limit int) ([]Proposal, error) {
159 2 spaces, err := s.ListSpaces(ctx)
160 2 if err != nil {
161 0 return nil, err
162 0 }
163 2 refByID := make(map[int]core.SpaceRef, len(spaces))
164 2 for _, sp := range spaces {
165 2 refByID[sp.ID] = sp.Ref
166 2 }
167
168 2 rows, err := s.store.ListProposalsByState(ctx, state, 0)
169 2 if err != nil {
170 0 return nil, fmt.Errorf("service: list %s proposals: %w", state, err)
171 0 }
172 2 out := make([]Proposal, 0, len(rows))
173 2 for _, p := range rows {
174 2 if policyOnly && p.Approval != core.ApprovalPolicy {
175 0 continue
176 }
177 2 ref, ok := refByID[p.SpaceID]
178 2 if !ok {
179 0 continue
180 }
181 2 out = append(out, proposalView(p, ref))
182 2 if limit > 0 && len(out) == limit {
183 0 break
184 }
185 }
186 2 return out, nil
187 }
188
189 // MergeHuman lands a proposal on the owner's approval — the review page's
190 // approve button. It is Merge with the approval kind fixed, so the surface does
191 // not choose it: a browser approve is always human, and a caller that could pass
192 // ApprovalPolicy here would be able to launder a firehose merge as reviewed.
193 1 func (s *Service) MergeHuman(ctx context.Context, ref core.SpaceRef, proposalID int) (Proposal, error) {
194 1 return s.Merge(ctx, ref, proposalID, core.ApprovalHuman)
195 1 }