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