| 1 |
|
package web |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"time" |
| 6 |
|
|
| 7 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 8 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 9 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/doc" |
| 10 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/search" |
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 12 |
|
) |
| 13 |
|
|
| 14 |
|
// Snapshot is one space at one *resolved* revision: the addressable document |
| 15 |
|
// set plus every document's bytes. |
| 16 |
|
// |
| 17 |
|
// Rev is always a commit sha, never a branch name, even when the request |
| 18 |
|
// carried no ?rev= at all. The read plane resolves first and renders second, so |
| 19 |
|
// a merge landing mid-request cannot make one page describe two revisions — |
| 20 |
|
// and the sha it resolved to is the value the page offers as its permalink. |
| 21 |
|
// |
| 22 |
|
// Bodies is keyed by tree path, the same key doc.Page.Path carries. It comes |
| 23 |
|
// off the same tree walk as the archive, so a document listed here and a |
| 24 |
|
// document in the archive are the same bytes at the same revision. |
| 25 |
|
type Snapshot struct { |
| 26 |
|
Ref core.SpaceRef |
| 27 |
|
Rev string |
| 28 |
|
Archive *doc.Archive |
| 29 |
|
Bodies map[string][]byte |
| 30 |
|
} |
| 31 |
|
|
| 32 |
|
// Reader is the service surface this package needs. *service.Service provides |
| 33 |
|
// it through NewReader. |
| 34 |
|
// |
| 35 |
|
// It is an interface for the same reason compare.sr.ht's authz.Authorizer is: |
| 36 |
|
// so the handlers can be tested against a document set rather than against a |
| 37 |
|
// Postgres instance and a tree of bare repositories. It is named Reader for the |
| 38 |
|
// read plane it began as; Phase 4's review page adds the proposal reads and the |
| 39 |
|
// two approve/reject writes, because the review page is where this package first |
| 40 |
|
// mutates anything and one seam is simpler than two. |
| 41 |
|
type Reader interface { |
| 42 |
|
// ListSpaces returns every space on the instance. Single-user with no |
| 43 |
|
// visibility levels means there is nothing to filter — the list is the |
| 44 |
|
// whole corpus. |
| 45 |
|
ListSpaces(ctx context.Context) ([]core.SpaceRef, error) |
| 46 |
|
|
| 47 |
|
// Snapshot resolves rev (service.ApprovedRev for the approved head) and |
| 48 |
|
// returns the space at that revision. |
| 49 |
|
Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) |
| 50 |
|
|
| 51 |
|
// ReadDocument reads one document by tree path at a revision. |
| 52 |
|
ReadDocument(ctx context.Context, ref core.SpaceRef, rev, path string) (service.Document, error) |
| 53 |
|
|
| 54 |
|
// GetProposal resolves one proposal by id, in any state — the stable |
| 55 |
|
// proposal URL still resolves after merge or rejection. |
| 56 |
|
GetProposal(ctx context.Context, id int) (service.Proposal, error) |
| 57 |
|
|
| 58 |
|
// ListProposals returns a space's proposals in one state, newest first: the |
| 59 |
|
// inbox (open) and the digest (merged) are the same call with a different |
| 60 |
|
// state. |
| 61 |
|
ListProposals(ctx context.Context, ref core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) |
| 62 |
|
|
| 63 |
|
// ProposalDiff returns each document a proposal changes, with the base and |
| 64 |
|
// proposed content the page diffs. |
| 65 |
|
ProposalDiff(ctx context.Context, p service.Proposal) ([]service.ProposalDoc, error) |
| 66 |
|
|
| 67 |
|
// Approve merges a proposal on the owner's approval — always human, never |
| 68 |
|
// policy. Reject resolves it to rejected. Both return the proposal as it now |
| 69 |
|
// stands. |
| 70 |
|
Approve(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error) |
| 71 |
|
Reject(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error) |
| 72 |
|
|
| 73 |
|
// Threads returns a proposal's review conversations with their replies, and |
| 74 |
|
// the three comment writes the review page makes. |
| 75 |
|
// |
| 76 |
|
// These carry the principal explicitly rather than reading it from the |
| 77 |
|
// context the way the read plane does, because the authority they turn on is |
| 78 |
|
// not the read ACL: only the owner may open or resolve a thread, while owner |
| 79 |
|
// and agent alike may reply. service/ decides that and answers ErrForbidden; |
| 80 |
|
// this package passes the principal through and surfaces the refusal. |
| 81 |
|
Threads(ctx context.Context, p authn.Principal, proposalID int) ([]service.Thread, error) |
| 82 |
|
CommentOn(ctx context.Context, req service.CommentRequest) (service.Thread, error) |
| 83 |
|
ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error) |
| 84 |
|
ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error |
| 85 |
|
|
| 86 |
|
// There is no token surface here. /tokens used to mint, list and revoke |
| 87 |
|
// spec's own agent credential; issuance is tokens.sr.ht's now, so the route |
| 88 |
|
// is a redirect and this interface has nothing to serve it. |
| 89 |
|
|
| 90 |
|
// Inbox is every open proposal on the instance, newest first — the review |
| 91 |
|
// queue. Digest is the recently policy-merged proposals, the firehose a human |
| 92 |
|
// sees after the fact. |
| 93 |
|
Inbox(ctx context.Context) ([]service.Proposal, error) |
| 94 |
|
Digest(ctx context.Context) ([]service.Proposal, error) |
| 95 |
|
|
| 96 |
|
// DigestMark reports when the owner last marked the digest seen, and whether |
| 97 |
|
// a mark exists yet; the inbox reads it to divide new auto-merges from seen |
| 98 |
|
// ones and itself never writes. MarkDigestSeen advances it — the one write |
| 99 |
|
// the review queue makes, behind an explicit POST so the GET stays pure. |
| 100 |
|
DigestMark(ctx context.Context) (time.Time, bool, error) |
| 101 |
|
MarkDigestSeen(ctx context.Context, seenAt time.Time) error |
| 102 |
|
} |
| 103 |
|
|
| 104 |
|
// Searcher is the keyword index. *search.Index satisfies it as declared. |
| 105 |
|
type Searcher interface { |
| 106 |
|
Search(ctx context.Context, q search.Query) (search.Results, error) |
| 107 |
|
} |
| 108 |
|
|
| 109 |
|
// NewReader adapts a *service.Service to Reader. |
| 110 |
0 |
func NewReader(svc *service.Service) Reader { return serviceReader{svc: svc} } |
| 111 |
|
|
| 112 |
|
// serviceReader is the production Reader: everything goes through service/, |
| 113 |
|
// which is the layer that is allowed to touch gitx and db. |
| 114 |
|
type serviceReader struct{ svc *service.Service } |
| 115 |
|
|
| 116 |
0 |
func (r serviceReader) ListSpaces(ctx context.Context) ([]core.SpaceRef, error) { |
| 117 |
0 |
spaces, err := r.svc.ListSpaces(ctx) |
| 118 |
0 |
if err != nil { |
| 119 |
0 |
return nil, err |
| 120 |
0 |
} |
| 121 |
0 |
refs := make([]core.SpaceRef, 0, len(spaces)) |
| 122 |
0 |
for _, sp := range spaces { |
| 123 |
0 |
refs = append(refs, sp.Ref) |
| 124 |
0 |
} |
| 125 |
0 |
return refs, nil |
| 126 |
|
} |
| 127 |
|
|
| 128 |
0 |
func (r serviceReader) Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) { |
| 129 |
0 |
sp, err := r.svc.OpenSpace(ctx, ref) |
| 130 |
0 |
if err != nil { |
| 131 |
0 |
return nil, err |
| 132 |
0 |
} |
| 133 |
|
// One call, one tree walk, and the link graph filled in. This package used |
| 134 |
|
// to scan the git tree itself and then list the documents a second time for |
| 135 |
|
// their bodies — two walks per page view, and a reach past service/ into |
| 136 |
|
// gitx that the layering rule forbids. |
| 137 |
0 |
arc, bodies, err := r.svc.Archive(ctx, sp, rev) |
| 138 |
0 |
if err != nil { |
| 139 |
0 |
return nil, err |
| 140 |
0 |
} |
| 141 |
0 |
return &Snapshot{Ref: ref, Rev: arc.Rev, Archive: arc, Bodies: bodies}, nil |
| 142 |
|
} |
| 143 |
|
|
| 144 |
0 |
func (r serviceReader) ReadDocument(ctx context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) { |
| 145 |
0 |
sp, err := r.svc.OpenSpace(ctx, ref) |
| 146 |
0 |
if err != nil { |
| 147 |
0 |
return service.Document{}, err |
| 148 |
0 |
} |
| 149 |
0 |
return r.svc.ReadDocument(ctx, sp, rev, p) |
| 150 |
|
} |
| 151 |
|
|
| 152 |
0 |
func (r serviceReader) GetProposal(ctx context.Context, id int) (service.Proposal, error) { |
| 153 |
0 |
return r.svc.GetProposal(ctx, id) |
| 154 |
0 |
} |
| 155 |
|
|
| 156 |
0 |
func (r serviceReader) ListProposals(ctx context.Context, ref core.SpaceRef, state core.ProposalState) ([]service.Proposal, error) { |
| 157 |
0 |
return r.svc.ListProposals(ctx, ref, state) |
| 158 |
0 |
} |
| 159 |
|
|
| 160 |
0 |
func (r serviceReader) ProposalDiff(ctx context.Context, p service.Proposal) ([]service.ProposalDoc, error) { |
| 161 |
0 |
return r.svc.ProposalDiff(ctx, p) |
| 162 |
0 |
} |
| 163 |
|
|
| 164 |
0 |
func (r serviceReader) Approve(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error) { |
| 165 |
0 |
return r.svc.MergeHuman(ctx, ref, id) |
| 166 |
0 |
} |
| 167 |
|
|
| 168 |
0 |
func (r serviceReader) Reject(ctx context.Context, ref core.SpaceRef, id int) (service.Proposal, error) { |
| 169 |
0 |
return r.svc.Reject(ctx, ref, id) |
| 170 |
0 |
} |
| 171 |
|
|
| 172 |
0 |
func (r serviceReader) Threads(ctx context.Context, p authn.Principal, proposalID int) ([]service.Thread, error) { |
| 173 |
0 |
return r.svc.Threads(ctx, p, proposalID) |
| 174 |
0 |
} |
| 175 |
|
|
| 176 |
0 |
func (r serviceReader) CommentOn(ctx context.Context, req service.CommentRequest) (service.Thread, error) { |
| 177 |
0 |
return r.svc.CommentOn(ctx, req) |
| 178 |
0 |
} |
| 179 |
|
|
| 180 |
0 |
func (r serviceReader) ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error) { |
| 181 |
0 |
return r.svc.ReplyTo(ctx, p, threadID, body) |
| 182 |
0 |
} |
| 183 |
|
|
| 184 |
0 |
func (r serviceReader) ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error { |
| 185 |
0 |
return r.svc.ResolveThread(ctx, p, threadID, resolved) |
| 186 |
0 |
} |
| 187 |
|
|
| 188 |
0 |
func (r serviceReader) Inbox(ctx context.Context) ([]service.Proposal, error) { |
| 189 |
0 |
return r.svc.InboxProposals(ctx) |
| 190 |
0 |
} |
| 191 |
|
|
| 192 |
0 |
func (r serviceReader) Digest(ctx context.Context) ([]service.Proposal, error) { |
| 193 |
0 |
return r.svc.DigestProposals(ctx, 0) |
| 194 |
0 |
} |
| 195 |
|
|
| 196 |
0 |
func (r serviceReader) DigestMark(ctx context.Context) (time.Time, bool, error) { |
| 197 |
0 |
return r.svc.DigestMark(ctx) |
| 198 |
0 |
} |
| 199 |
|
|
| 200 |
0 |
func (r serviceReader) MarkDigestSeen(ctx context.Context, seenAt time.Time) error { |
| 201 |
0 |
return r.svc.MarkDigestSeen(ctx, seenAt) |
| 202 |
0 |
} |