~bigbes/sr-ht-spec

ref: c74776077006e81af82c2fd53cb186271b42bee9 sr-ht-spec/service/comment.go -rw-r--r-- 10.7 KiB
c7477607 — Eugene Blikh authn: accept tokens.sr.ht working tokens beside the agent token 10 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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
package service

import (
	"context"
	"errors"
	"fmt"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/db"
	"sourcecraft.dev/bigbes/sr-ht-spec/prosediff"
)

// Comment is one message in a review thread, as the surfaces above this layer
// need it. Agent reports the authoring agent's identity and is empty for the
// owner, which is the only distinction a reader needs — who said it.
type Comment struct {
	ID       int
	ParentID int
	Body     string
	Author   string
	Agent    bool
	Created  time.Time
}

// Thread is one review conversation: a comment anchored to a block of a
// document, plus its replies in order.
//
// State and Block are filled in by [AnchorThreads] against a particular
// revision and are meaningless before it runs — State is the zero AnchorState
// and Block is 0, not -1. That is deliberate: anchor fit is a property of a
// revision, and a Thread that has not been resolved against one has no honest
// answer to give.
type Thread struct {
	Root    Comment
	DocPath string
	Anchor  core.CommentAnchor
	Replies []Comment
	// Resolved is when the owner closed the thread, or nil while it is open.
	// An open thread suppresses policy auto-merge; see [Service.autoMerges].
	Resolved *time.Time

	// State is how well the anchor still fits the revision it was resolved
	// against.
	State core.AnchorState
	// Block is the index into that revision's blocks, or -1 when the anchor did
	// not resolve.
	Block int
}

// Open reports whether the thread still awaits the owner.
func (t Thread) Open() bool { return t.Resolved == nil }

// CommentRequest opens a review thread on one block of one document.
type CommentRequest struct {
	Principal  authn.Principal
	Space      core.SpaceRef
	ProposalID int
	// DocPath is the document's path on the proposal branch.
	DocPath string
	// Anchor names the block. Its DocID is the archive's addressing key for the
	// document, which is what makes the thread survive a later rename.
	Anchor core.CommentAnchor
	Body   string
}

// CommentOn opens a review thread anchored to a block of a proposed document.
//
// Owner-only. An agent may reply to a thread but may not start one: the review
// conversation exists so a human can direct an agent, and an agent opening
// threads on its own proposal would put unresolved threads — which suppress
// policy auto-merge — under the control of the thing the gate exists to hold
// back.
func (s *Service) CommentOn(ctx context.Context, req CommentRequest) (Thread, error) {
	if !req.Principal.IsOwner() {
		return Thread{}, fmt.Errorf("%w: %s may not open a review thread; that is the owner's", ErrForbidden, req.Principal)
	}
	if req.DocPath == "" {
		return Thread{}, fmt.Errorf("%w: a comment must name the document it is on", ErrInvalid)
	}
	if req.Anchor.DocID == "" {
		return Thread{}, fmt.Errorf("%w: a comment must carry the document id it anchors to", ErrInvalid)
	}
	if req.Body == "" {
		return Thread{}, fmt.Errorf("%w: a comment needs a body", ErrInvalid)
	}
	if req.Anchor.Side == "" {
		req.Anchor.Side = core.SideNew
	}

	// The proposal is read through the normal path so a comment on a missing or
	// foreign proposal fails here rather than as a foreign-key violation.
	if _, err := s.GetProposal(ctx, req.ProposalID); err != nil {
		return Thread{}, err
	}

	row, err := s.store.AddComment(ctx, &db.Comment{
		ProposalID: req.ProposalID,
		Anchor:     &req.Anchor,
		DocPath:    req.DocPath,
		Body:       req.Body,
		Author:     req.Principal.Owner,
		Kind:       db.AuthorHuman,
	})
	if err != nil {
		return Thread{}, fmt.Errorf("service: comment on proposal %d: %w", req.ProposalID, err)
	}
	return threadView(row, nil), nil
}

// ReplyTo appends a reply to an existing thread, returning the stored reply.
//
// Both principals may reply: this is the loop's turn-taking — the owner
// critiques, the agent answers and revises. A reply never resolves the thread,
// so an agent answering a critique does not clear the auto-merge gate; only the
// owner accepting the answer does.
func (s *Service) ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (Comment, error) {
	if !p.CanRead() {
		return Comment{}, fmt.Errorf("%w: %s may not comment", ErrForbidden, p)
	}
	if body == "" {
		return Comment{}, fmt.Errorf("%w: a reply needs a body", ErrInvalid)
	}

	reply := &db.Comment{Body: body}
	if p.IsAgent() {
		if p.Agent == "" || p.Session == "" {
			return Comment{}, fmt.Errorf("%w: an agent reply must carry its identity and session", ErrInvalid)
		}
		reply.Author, reply.Kind, reply.Session = p.Agent, db.AuthorAgent, p.Session
	} else {
		reply.Author, reply.Kind = p.Owner, db.AuthorHuman
	}

	row, err := s.store.ReplyComment(ctx, threadID, reply)
	if errors.Is(err, db.ErrNotFound) {
		return Comment{}, fmt.Errorf("%w: no review thread %d", ErrNotFound, threadID)
	}
	if err != nil {
		return Comment{}, fmt.Errorf("service: reply to thread %d: %w", threadID, err)
	}
	return commentView(row), nil
}

// ResolveThread closes a review thread, or reopens it when resolved is false.
//
// Owner-only, and this is the rule the auto-merge gate rests on: an agent that
// could resolve the thread opened against its own proposal could clear the gate
// holding that proposal back, which is the one thing the gate is for.
func (s *Service) ResolveThread(ctx context.Context, p authn.Principal, threadID int, resolved bool) error {
	if !p.IsOwner() {
		return fmt.Errorf("%w: %s may not resolve a review thread; only the owner may", ErrForbidden, p)
	}
	err := s.store.ResolveComment(ctx, threadID, resolved)
	if errors.Is(err, db.ErrNotFound) {
		return fmt.Errorf("%w: no review thread %d", ErrNotFound, threadID)
	}
	if err != nil {
		return fmt.Errorf("service: resolve thread %d: %w", threadID, err)
	}
	return nil
}

// Threads returns a proposal's review conversations, each with its replies in
// order, oldest thread first.
//
// The anchors are not resolved here: fit depends on which revision the caller
// is looking at, so it is [AnchorThreads] that answers it, against the documents
// the caller already read.
func (s *Service) Threads(ctx context.Context, p authn.Principal, proposalID int) ([]Thread, error) {
	if !p.CanRead() {
		return nil, fmt.Errorf("%w: %s may not read review threads", ErrForbidden, p)
	}
	rows, err := s.store.ListComments(ctx, proposalID)
	if err != nil {
		return nil, fmt.Errorf("service: threads of proposal %d: %w", proposalID, err)
	}

	replies := make(map[int][]Comment)
	for _, r := range rows {
		if !r.Root() {
			replies[r.ParentID] = append(replies[r.ParentID], commentView(r))
		}
	}
	var out []Thread
	for _, r := range rows {
		if r.Root() {
			out = append(out, threadView(r, replies[r.ID]))
		}
	}
	return out, nil
}

// AnchorThreads resolves every thread's anchor against a revision's documents,
// filling in State and Block.
//
// It lives here rather than in each surface for the reason [Service.Archive]
// does: the review page and the MCP tool must agree about whether a comment
// still fits, and two surfaces each segmenting and matching would agree only
// until one of them was changed. Each document is segmented at most once per
// side however many threads hang off it.
//
// A thread whose document is not in docs is outdated, not dropped. That happens
// when the agent's revision reverted the document to its base — it is no longer
// a changed document, so the review page never renders it — and a comment that
// silently vanished would look like one that was never made.
func AnchorThreads(threads []Thread, docs []ProposalDoc) []Thread {
	byPath := make(map[string]ProposalDoc, len(docs))
	for _, d := range docs {
		byPath[d.Path] = d
	}
	type key struct {
		path string
		side core.CommentSide
	}
	segmented := make(map[key][]core.AnchorBlock)

	out := make([]Thread, len(threads))
	for i, t := range threads {
		out[i] = t
		out[i].Block, out[i].State = -1, core.AnchorOutdated

		doc, ok := byPath[t.DocPath]
		if !ok {
			continue
		}
		k := key{t.DocPath, t.Anchor.Side}
		blocks, done := segmented[k]
		if !done {
			src := doc.Proposed
			if t.Anchor.Side == core.SideOld {
				src = doc.Base
			}
			blocks = anchorBlocksOf(src)
			segmented[k] = blocks
		}
		out[i].Block, out[i].State = core.ResolveAnchor(t.Anchor, blocks)
	}
	return out
}

// anchorBlocksOf segments a document and reduces it to what anchoring reads.
// A nil source — the base of a document the proposal adds — has no blocks, so
// every anchor against it is outdated, which is the honest answer.
func anchorBlocksOf(src []byte) []core.AnchorBlock {
	if len(src) == 0 {
		return nil
	}
	segs := prosediff.Segment(src)
	hashes := make([]string, len(segs))
	paths := make([][]string, len(segs))
	for i, b := range segs {
		hashes[i], paths[i] = b.Hash, b.HeadingPath
	}
	return core.AnchorBlocks(hashes, paths)
}

// AnchorOf builds the anchor for a block of a document.
//
// A surface offering a "comment on this block" control has a position in
// prosediff's document-global block order; the anchor needs the position within
// the block's own heading path instead. Converting here means the web form and
// the MCP tool cannot each get the numbering subtly different, which would put
// their comments on different blocks of the same document.
//
// It reads that numbering out of [core.AnchorBlocks] — the same call
// [AnchorThreads] resolves against — rather than recomputing it. A second
// implementation of "which block is this within its section" would agree with
// the first only for as long as nobody edited either, and the failure it would
// eventually produce is a comment stored against one block and displayed
// against another.
func AnchorOf(docID string, src []byte, ordinal int, side core.CommentSide) (core.CommentAnchor, error) {
	blocks := anchorBlocksOf(src)
	if ordinal < 0 || ordinal >= len(blocks) {
		return core.CommentAnchor{}, fmt.Errorf("%w: block %d is outside the document's %d blocks",
			ErrInvalid, ordinal, len(blocks))
	}
	b := blocks[ordinal]
	return core.CommentAnchor{
		DocID:       docID,
		HeadingPath: b.HeadingPath,
		Index:       b.Index,
		BlockHash:   b.Hash,
		Side:        side,
	}, nil
}

// commentView maps a stored comment onto the surface shape.
func commentView(c *db.Comment) Comment {
	return Comment{
		ID:       c.ID,
		ParentID: c.ParentID,
		Body:     c.Body,
		Author:   c.Author,
		Agent:    c.Kind == db.AuthorAgent,
		Created:  c.Created,
	}
}

// threadView maps a stored root plus its replies onto the surface shape. Block
// is -1 until AnchorThreads runs: an unresolved anchor points at no block, and
// zero would point at the first one.
func threadView(root *db.Comment, replies []Comment) Thread {
	t := Thread{
		Root:     commentView(root),
		DocPath:  root.DocPath,
		Replies:  replies,
		Resolved: root.Resolved,
		Block:    -1,
	}
	if root.Anchor != nil {
		t.Anchor = *root.Anchor
	}
	return t
}