package service
import (
"context"
"fmt"
"sort"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// 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)
}
// 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)
}