package web
import (
"context"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/doc"
"sourcecraft.dev/bigbes/sr-ht-spec/search"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// Snapshot is one space at one *resolved* revision: the addressable document
// set plus every document's bytes.
//
// Rev is always a commit sha, never a branch name, even when the request
// carried no ?rev= at all. The read plane resolves first and renders second, so
// a merge landing mid-request cannot make one page describe two revisions —
// and the sha it resolved to is the value the page offers as its permalink.
//
// Bodies is keyed by tree path, the same key doc.Page.Path carries. It comes
// off the same tree walk as the archive, so a document listed here and a
// document in the archive are the same bytes at the same revision.
type Snapshot struct {
Ref core.SpaceRef
Rev string
Archive *doc.Archive
Bodies map[string][]byte
}
// Reader is the service surface this package needs. *service.Service provides
// it through NewReader.
//
// It is an interface for the same reason compare.sr.ht's authz.Authorizer is:
// so the handlers can be tested against a document set rather than against a
// Postgres instance and a tree of bare repositories. It is named Reader for the
// read plane it began as; Phase 4's review page adds the proposal reads and the
// two approve/reject writes, because the review page is where this package first
// mutates anything and one seam is simpler than two.
type Reader interface {
// ListSpaces returns every space on the instance. Single-user with no
// visibility levels means there is nothing to filter — the list is the
// whole corpus.
ListSpaces(ctx context.Context) ([]core.SpaceRef, error)
// Snapshot resolves rev (service.ApprovedRev for the approved head) and
// returns the space at that revision.
Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error)
// ReadDocument reads one document by tree path at a revision.
ReadDocument(ctx context.Context, ref core.SpaceRef, rev, path string) (service.Document, error)
// GetProposal resolves one proposal by id, in any state — the stable
// proposal URL still resolves after merge or rejection.
GetProposal(ctx context.Context, id int) (service.Proposal, error)
// ListProposals returns a space's proposals in one state, newest first: the
// inbox (open) and the digest (merged) are the same call with a different
// state.
ListProposals(ctx context.Context, ref core.SpaceRef, state core.ProposalState) ([]service.Proposal, error)
// ProposalDiff returns each document a proposal changes, with the base and
// proposed content the page diffs.
ProposalDiff(ctx context.Context, p service.Proposal) ([]service.ProposalDoc, error)
// Approve merges a proposal on the owner's approval — always human, never
// policy. Reject resolves it to rejected. Both return the proposal as it now
// stands.
Approve(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error)
Reject(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error)
// Threads returns a proposal's review conversations with their replies, and
// the three comment writes the review page makes.
//
// These carry the principal explicitly rather than reading it from the
// context the way the read plane does, because the authority they turn on is
// not the read ACL: only the owner may open or resolve a thread, while owner
// and agent alike may reply. service/ decides that and answers ErrForbidden;
// this package passes the principal through and surfaces the refusal.
Threads(ctx context.Context, p authn.Principal, proposalID int) ([]service.Thread, error)
CommentOn(ctx context.Context, req service.CommentRequest) (service.Thread, error)
ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error)
ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error
// Inbox is every open proposal on the instance, newest first — the review
// queue. Digest is the recently policy-merged proposals, the firehose a human
// sees after the fact.
Inbox(ctx context.Context) ([]service.Proposal, error)
Digest(ctx context.Context) ([]service.Proposal, error)
// DigestMark reports when the owner last marked the digest seen, and whether
// a mark exists yet; the inbox reads it to divide new auto-merges from seen
// ones and itself never writes. MarkDigestSeen advances it — the one write
// the review queue makes, behind an explicit POST so the GET stays pure.
DigestMark(ctx context.Context) (time.Time, bool, error)
MarkDigestSeen(ctx context.Context, seenAt time.Time) error
}
// Searcher is the keyword index. *search.Index satisfies it as declared.
type Searcher interface {
Search(ctx context.Context, q search.Query) (search.Results, error)
}
// NewReader adapts a *service.Service to Reader.
func NewReader(svc *service.Service) Reader { return serviceReader{svc: svc} }
// serviceReader is the production Reader: everything goes through service/,
// which is the layer that is allowed to touch gitx and db.
type serviceReader struct{ svc *service.Service }
func (r serviceReader) ListSpaces(ctx context.Context) ([]core.SpaceRef, error) {
spaces, err := r.svc.ListSpaces(ctx)
if err != nil {
return nil, err
}
refs := make([]core.SpaceRef, 0, len(spaces))
for _, sp := range spaces {
refs = append(refs, sp.Ref)
}
return refs, nil
}
func (r serviceReader) Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) {
sp, err := r.svc.OpenSpace(ctx, ref)
if err != nil {
return nil, err
}
// One call, one tree walk, and the link graph filled in. This package used
// to scan the git tree itself and then list the documents a second time for
// their bodies — two walks per page view, and a reach past service/ into
// gitx that the layering rule forbids.
arc, bodies, err := r.svc.Archive(ctx, sp, rev)
if err != nil {
return nil, err
}
return &Snapshot{Ref: ref, Rev: arc.Rev, Archive: arc, Bodies: bodies}, nil
}
func (r serviceReader) ReadDocument(ctx context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) {
sp, err := r.svc.OpenSpace(ctx, ref)
if err != nil {
return service.Document{}, err
}
return r.svc.ReadDocument(ctx, sp, rev, p)
}
func (r serviceReader) GetProposal(ctx context.Context, id int) (service.Proposal, error) {
return r.svc.GetProposal(ctx, id)
}
func (r serviceReader) ListProposals(ctx context.Context, ref core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) {
return r.svc.ListProposals(ctx, ref, state)
}
func (r serviceReader) ProposalDiff(ctx context.Context, p service.Proposal) ([]service.ProposalDoc, error) {
return r.svc.ProposalDiff(ctx, p)
}
func (r serviceReader) Approve(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error) {
return r.svc.MergeHuman(ctx, ref, id)
}
func (r serviceReader) Reject(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error) {
return r.svc.Reject(ctx, ref, id)
}
func (r serviceReader) Threads(ctx context.Context, p authn.Principal, proposalID int) ([]service.Thread, error) {
return r.svc.Threads(ctx, p, proposalID)
}
func (r serviceReader) CommentOn(ctx context.Context, req service.CommentRequest) (service.Thread, error) {
return r.svc.CommentOn(ctx, req)
}
func (r serviceReader) ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error) {
return r.svc.ReplyTo(ctx, p, threadID, body)
}
func (r serviceReader) ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error {
return r.svc.ResolveThread(ctx, p, threadID, resolved)
}
func (r serviceReader) Inbox(ctx context.Context) ([]service.Proposal, error) {
return r.svc.InboxProposals(ctx)
}
func (r serviceReader) Digest(ctx context.Context) ([]service.Proposal, error) {
return r.svc.DigestProposals(ctx, 0)
}
func (r serviceReader) DigestMark(ctx context.Context) (time.Time, bool, error) {
return r.svc.DigestMark(ctx)
}
func (r serviceReader) MarkDigestSeen(ctx context.Context, seenAt time.Time) error {
return r.svc.MarkDigestSeen(ctx, seenAt)
}