~bigbes/sr-ht-spec

ref: 8255ff90741113fd85e6f15706127bb5c30ca3f5 sr-ht-spec/web/reader.go -rw-r--r-- 8.4 KiB
8255ff90 — Eugene Blikh ci: export the version instead of sed-ing a tracked APKBUILD 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
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

	// There is no token surface here. /tokens used to mint, list and revoke
	// spec's own agent credential; issuance is tokens.sr.ht's now, so the route
	// is a redirect and this interface has nothing to serve it.

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