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