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