~bigbes/sr-ht-spec

ref: 46048cbc0ada700c621b7c73d8da07deefde2662 sr-ht-spec/mcpsrv/comment.go -rw-r--r-- 8.7 KiB
46048cbc — Eugene Blikh chore(beads): spec-ejq.2 re-index verified against repo.bigb.es 13 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
package mcpsrv

import (
	"context"
	"fmt"
	"strings"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// Commenter is everything spec_comment may reach, and the list is short on
// purpose: read the threads, read the revision they are anchored against, and
// append a reply.
//
// What it leaves out is the load-bearing part. *service.Service also has
// CommentOn and ResolveThread, and both are owner-only there; naming them here
// would be harmless today and a regression the first time somebody relaxed the
// service-side check. An unresolved thread suppresses policy auto-merge, so an
// agent that could open or resolve one would hold the gate that exists to hold
// its own output back. The handler is written against this interface rather
// than against Writer so that "the comment tool cannot resolve a thread" is a
// property of the types, not of the handler remembering not to.
type Commenter interface {
	Threads(ctx context.Context, p authn.Principal, proposalID int) ([]service.Thread, error)
	ReplyTo(ctx context.Context, p authn.Principal, threadID int, body string) (service.Comment, error)
	GetProposal(ctx context.Context, id int) (service.Proposal, error)
	ProposalDiff(ctx context.Context, p service.Proposal) ([]service.ProposalDoc, error)
}

type commentInput struct {
	Proposal int `json:"proposal" jsonschema:"the proposal whose review threads to read, or whose thread to reply to — the id spec_propose returned"`
	// Thread and Body together are the reply; omitting both lists.
	Thread int    `json:"thread,omitempty" jsonschema:"reply to this thread, given as the thread id from a listing. Omit both this and body to list the proposal's threads instead."`
	Body   string `json:"body,omitempty" jsonschema:"the reply text, required when thread is given"`
}

// commentAuthor is one message: a thread's root or a reply. Times are formatted
// strings rather than time.Time because the tool's schema is derived from this
// struct by reflection, and a time.Time would reach the agent as an object with
// no fields.
type commentAuthor struct {
	ID int `json:"id"`
	// Thread is the id of the thread this message belongs to. On a root it is
	// its own id, which is the value to send back as the thread argument.
	Thread int    `json:"thread"`
	Author string `json:"author"`
	// Agent reports whether an agent wrote this, so a reply of one's own is
	// distinguishable from the owner's critique without parsing the name.
	Agent   bool   `json:"agent"`
	Body    string `json:"body"`
	Created string `json:"created"`
}

type commentThread struct {
	commentAuthor
	// Document is the path of the document the thread is on, and DocID the
	// archive key its anchor was written against — the anchor survives a rename,
	// so the two can disagree.
	Document string `json:"document"`
	DocID    string `json:"doc_id,omitempty"`
	// Heading is the enclosing headings of the commented block, outermost
	// first. With Document it is how to find the block being talked about.
	Heading []string `json:"heading,omitempty"`
	// Side is which revision of the block was commented on: "new" for the
	// proposed text, "old" for a block the proposal deletes.
	Side string `json:"side,omitempty"`
	// State is how well the anchor still fits the CURRENT proposal branch:
	// "anchored" (the block is there verbatim), "edited" (the block is at that
	// position but its text has changed since the comment) or "outdated" (the
	// anchor lost its block). Acting on an outdated critique is the failure this
	// field exists to prevent.
	State string `json:"state"`
	// Block is the block's index in the document, or -1 when the anchor is
	// outdated and points at nothing.
	Block int `json:"block"`
	// Open reports whether the thread still awaits the owner. Only the owner can
	// close one; a reply never does.
	Open    bool            `json:"open"`
	Replies []commentAuthor `json:"replies,omitempty"`
}

type commentOutput struct {
	Proposal int `json:"proposal"`
	// Threads is set when listing, and empty when the proposal has no review
	// threads at all — which is a proposal nobody has commented on, not an
	// error.
	Threads []commentThread `json:"threads,omitempty"`
	// Reply is set when replying, and carries the stored reply as it was
	// attributed: an agent's reply comes back under its own agent identity.
	Reply *commentAuthor `json:"reply,omitempty"`
}

func commentHandler(ctx context.Context, c Commenter, in commentInput) (commentOutput, error) {
	if in.Proposal <= 0 {
		return commentOutput{}, fmt.Errorf("proposal must name a proposal id")
	}
	// The mode is chosen on whether body was sent at all, not on whether it has
	// anything in it: a whitespace body is a caller that meant to reply and
	// botched it, and quietly listing instead would look like the reply landed.
	body := strings.TrimSpace(in.Body)
	switch {
	case in.Thread > 0 && body == "":
		return commentOutput{}, fmt.Errorf("a reply to thread %d needs a body", in.Thread)
	case in.Thread <= 0 && in.Body != "":
		return commentOutput{}, fmt.Errorf("body needs the thread it answers; pass thread, " +
			"or omit body to list this proposal's threads")
	}

	// The principal is the one the resolver middleware put on this request.
	// service.Threads and service.ReplyTo apply the ACL — this layer forwards
	// the caller rather than deciding anything, so the read plane has one policy.
	principal := authn.PrincipalFromContext(ctx)

	// Threads runs before anything touches git, in both modes. It is one query,
	// it is where the ACL is enforced, and a proposal with no threads needs no
	// revision read at all.
	threads, err := c.Threads(ctx, principal, in.Proposal)
	if err != nil {
		return commentOutput{}, err
	}

	if in.Thread > 0 {
		return replyToThread(ctx, c, principal, threads, in.Proposal, in.Thread, body)
	}
	return listThreads(ctx, c, threads, in.Proposal)
}

// listThreads resolves every anchor against the proposal branch as it stands
// now and reports the fit.
//
// The anchoring is not optional decoration. A thread's stored anchor says where
// the comment was written, and the branch has moved since — often because this
// very agent revised it. An agent asking "what should I fix" that is not told
// the critique no longer describes any block will go and fix the wrong
// paragraph, so the state travels with every thread.
func listThreads(ctx context.Context, c Commenter, threads []service.Thread, proposalID int) (commentOutput, error) {
	out := commentOutput{Proposal: proposalID}
	if len(threads) == 0 {
		return out, nil
	}

	p, err := c.GetProposal(ctx, proposalID)
	if err != nil {
		return commentOutput{}, err
	}
	docs, err := c.ProposalDiff(ctx, p)
	if err != nil {
		return commentOutput{}, err
	}

	out.Threads = make([]commentThread, 0, len(threads))
	for _, t := range service.AnchorThreads(threads, docs) {
		out.Threads = append(out.Threads, threadEntry(t))
	}
	return out, nil
}

// replyToThread appends the reply, after checking the thread is one of this
// proposal's.
//
// A thread id is a global integer, so a mistyped one names a real thread on
// somebody else's proposal, and service.ReplyTo would accept it: the id is all
// it needs. Requiring the proposal and checking membership here turns that
// typo into an error instead of a reply that lands out of sight of the agent
// that wrote it. The threads were already read for the ACL check, so it costs
// nothing.
func replyToThread(ctx context.Context, c Commenter, p authn.Principal, threads []service.Thread, proposalID, threadID int, body string) (commentOutput, error) {
	found := false
	for _, t := range threads {
		if t.Root.ID == threadID {
			found = true
			break
		}
	}
	if !found {
		return commentOutput{}, fmt.Errorf("proposal %d has no review thread %d; "+
			"call spec_comment with only proposal to list its threads", proposalID, threadID)
	}

	reply, err := c.ReplyTo(ctx, p, threadID, body)
	if err != nil {
		return commentOutput{}, err
	}
	entry := authorEntry(reply)
	return commentOutput{Proposal: proposalID, Reply: &entry}, nil
}

func threadEntry(t service.Thread) commentThread {
	e := commentThread{
		commentAuthor: authorEntry(t.Root),
		Document:      t.DocPath,
		DocID:         t.Anchor.DocID,
		Heading:       t.Anchor.HeadingPath,
		Side:          string(t.Anchor.Side),
		State:         string(t.State),
		Block:         t.Block,
		Open:          t.Open(),
	}
	// A root's thread id is its own id: that is the value spec_comment takes
	// back as the thread argument.
	e.Thread = t.Root.ID
	for _, r := range t.Replies {
		e.Replies = append(e.Replies, authorEntry(r))
	}
	return e
}

func authorEntry(c service.Comment) commentAuthor {
	return commentAuthor{
		ID:      c.ID,
		Thread:  c.ParentID,
		Author:  c.Author,
		Agent:   c.Agent,
		Body:    c.Body,
		Created: c.Created.UTC().Format(time.RFC3339),
	}
}