Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/comment_writes.go

Line attribution on main · 354 line(s)

c717d4eb
Keenan Nemetz
13d ago
1 package web
2
3 // Comment writes: add, edit, delete.
4 //
5 // The operation is the same on an issue and on a change request, so there is one
6 // implementation of each and a commentTopic says what the two surfaces do
7 // differently: which metadata writer runs, which section is re-rendered, and
8 // where a plain form post lands afterwards. Adding a surface is a new topic, not a
9 // third copy of the validation.
10
11 import (
12 "net/http"
13 "strconv"
14 "strings"
15 "time"
16
d37d301f
Keenan Nemetz
19h ago
17 "rodent.software/vole/forge/index"
18 "rodent.software/vole/forge/repo"
19 "rodent.software/vole/metadata"
c717d4eb
Keenan Nemetz
13d ago
20 )
21
22 // commentTopic is one issue or one change request as a comment write sees it.
23 // Everything here closes over the entity that was loaded for the request, so a
24 // handler never passes one around.
25 type commentTopic struct {
26 // id is the issue or change request the comments belong to.
27 id string
28 // view re-renders the discussion section with a message for the reader.
29 view fragmentFor
30 // reload re-reads the entity after a successful write, so the section shows
31 // the comment that was just stored rather than the state the request began in.
32 reload func() (string, any)
33 // lookup finds one comment for the moderation check.
34 lookup func(commentID string) *metadata.Comment
35 // The writers. Each one is the same metadata function the CLI uses.
36 newComment func(body, author string, now time.Time) (*metadata.Comment, error)
37 add func(c *metadata.Comment, author string) error
38 edit func(commentID, body, author string) error
39 remove func(commentID, author string) error
40 // anchorFor resolves a line anchor; nil on a surface that keeps none (an issue
41 // has no diff to point into).
42 anchorFor func(path string, line int, side string) (*metadata.Anchor, error)
43 // validate re-checks the metadata branch after a write. Problems are logged,
34a404b3
Keenan Nemetz
10d ago
44 // never shown to the browser. It is nil on a surface whose writer already
45 // validated everything it wrote.
c717d4eb
Keenan Nemetz
13d ago
46 validate func() []string
47 // The audit actions, one per write.
48 auditAdd, auditEdit, auditRemove string
49 // afterAdd and afterComment are where a plain form post lands. A new comment
50 // may be anchored into the Files tab, so it is not always "#c-<id>".
51 afterAdd func(c *metadata.Comment) string
52 afterComment func(commentID string) string
53 // target is the diff line the composer was opened from, when it was opened from
54 // a line. The view renders the "Commenting on ..." note from it.
55 target *commentTarget
56 }
57
58 // issueTopic loads the issue named in the path and describes its discussion.
59 func (w *Web) issueTopic(rw http.ResponseWriter, r *http.Request, m *repo.Meta) (*commentTopic, bool) {
60 id := r.PathValue("id")
61 iss, err := index.LoadIssue(m, id)
62 if err != nil {
63 w.notFound(rw, r)
64 return nil, false
65 }
66 return &commentTopic{
67 id: id,
68 view: func(msg string) (string, any) { return "comment_thread", w.issueThread(r, m, iss, msg) },
69 reload: func() (string, any) {
70 updated, err := index.LoadIssue(m, id)
71 if err != nil {
72 // The comment was written; only the re-read failed. Say so in the
73 // section instead of falling back to an empty response.
74 return "comment_thread", w.issueThread(r, m, iss, "the comment was saved, but the thread could not be re-read: "+err.Error())
75 }
76 return "comment_thread", w.issueThread(r, m, updated, "")
77 },
78 lookup: func(commentID string) *metadata.Comment {
79 for i := range iss.Comments {
80 if iss.Comments[i].ID == commentID {
81 return &iss.Comments[i]
82 }
83 }
84 return nil
85 },
86 newComment: metadata.NewIssueComment,
87 add: func(c *metadata.Comment, author string) error {
88 return metadata.AddIssueComment(m.Git(), id, c, author)
89 },
90 edit: func(commentID, body, author string) error {
91 _, err := metadata.EditIssueComment(m.Git(), id, commentID, body, author)
92 return err
93 },
94 remove: func(commentID, author string) error {
95 _, err := metadata.DeleteIssueComment(m.Git(), id, commentID, author)
96 return err
97 },
98 auditAdd: "web.issue.comment",
99 auditEdit: "web.issue.comment.edit",
100 auditRemove: "web.issue.comment.delete",
101 afterAdd: func(c *metadata.Comment) string {
ed03b3d1
Keenan Nemetz
6h ago
102 return "/" + m.Name + "/issues/" + id + "#c-" + c.ID
c717d4eb
Keenan Nemetz
13d ago
103 },
104 afterComment: func(commentID string) string {
ed03b3d1
Keenan Nemetz
6h ago
105 return "/" + m.Name + "/issues/" + id + "#c-" + commentID
c717d4eb
Keenan Nemetz
13d ago
106 },
107 }, true
108 }
109
110 // requestTopic loads the change request named in the path and describes its
111 // discussion. Unlike an issue, a comment here may be anchored to a diff line.
112 func (w *Web) requestTopic(rw http.ResponseWriter, r *http.Request, m *repo.Meta) (*commentTopic, bool) {
113 id := r.PathValue("id")
5d440e37
Keenan Nemetz
10d ago
114 req, err := w.requestAt(memoOf(r), m, id)
c717d4eb
Keenan Nemetz
13d ago
115 if err != nil {
116 w.notFound(rw, r)
117 return nil, false
118 }
ed03b3d1
Keenan Nemetz
6h ago
119 page := "/" + m.Name + "/changes/" + id
c717d4eb
Keenan Nemetz
13d ago
120 t := &commentTopic{
121 id: id,
122 lookup: func(commentID string) *metadata.Comment {
123 for i := range req.Comments {
124 if req.Comments[i].ID == commentID {
125 return &req.Comments[i]
126 }
127 }
128 return nil
129 },
130 newComment: metadata.NewRequestComment,
131 add: func(c *metadata.Comment, author string) error {
132 return metadata.AddRequestComment(m.Git(), id, c, author)
133 },
134 edit: func(commentID, body, author string) error {
135 _, err := metadata.EditRequestComment(m.Git(), id, commentID, body, author)
136 return err
137 },
138 remove: func(commentID, author string) error {
139 _, err := metadata.DeleteRequestComment(m.Git(), id, commentID, author)
140 return err
141 },
142 anchorFor: func(path string, line int, side string) (*metadata.Anchor, error) {
143 return w.commentAnchor(m, req, path, line, side)
144 },
145 validate: func() []string { return metadata.ValidateRequests(m.Git(), metadata.Ref) },
146 auditAdd: "web.request.comment",
147 auditEdit: "web.request.comment.edit",
148 auditRemove: "web.request.comment.delete",
149 afterAdd: func(c *metadata.Comment) string {
150 // An anchored comment belongs to the Files tab, so the reader returns to
151 // the note beneath its line rather than to the conversation.
152 if c.Anchor == nil {
153 return page + "#c-" + c.ID
154 }
155 path := w.reviewPathFor(m, req, c.Anchor.Path)
156 return page + "/files/" + pathURL(path) + "#inline-c-" + c.ID
157 },
158 afterComment: func(commentID string) string { return page + "#c-" + commentID },
159 }
160 // A rejected anchored comment re-renders the note it was written from, so the
161 // reader keeps the line they chose; the success path drops it, because the
162 // comment is no longer waiting to be written.
163 t.view = func(msg string) (string, any) {
164 return "comment_thread", w.requestThread(r, m, req, t.target, msg)
165 }
166 t.reload = func() (string, any) {
5d440e37
Keenan Nemetz
10d ago
167 updated, err := w.requestAt(memoOf(r), m, id)
c717d4eb
Keenan Nemetz
13d ago
168 if err != nil {
169 return "comment_thread", w.requestThread(r, m, req, nil,
170 "the comment was saved, but the discussion could not be re-read: "+err.Error())
171 }
172 return "comment_thread", w.requestThread(r, m, updated, nil, "")
173 }
174 return t, true
175 }
176
177 func (w *Web) AddIssueComment(rw http.ResponseWriter, r *http.Request) {
178 w.addComment(rw, r, w.issueTopic)
179 }
180
181 func (w *Web) AddRequestComment(rw http.ResponseWriter, r *http.Request) {
182 w.addComment(rw, r, w.requestTopic)
183 }
184
185 func (w *Web) EditIssueComment(rw http.ResponseWriter, r *http.Request) {
186 w.editComment(rw, r, w.issueTopic)
187 }
188
189 func (w *Web) EditRequestComment(rw http.ResponseWriter, r *http.Request) {
190 w.editComment(rw, r, w.requestTopic)
191 }
192
193 func (w *Web) DeleteIssueComment(rw http.ResponseWriter, r *http.Request) {
194 w.deleteComment(rw, r, w.issueTopic)
195 }
196
197 func (w *Web) DeleteRequestComment(rw http.ResponseWriter, r *http.Request) {
198 w.deleteComment(rw, r, w.requestTopic)
199 }
200
201 // topicLoader is how a handler names which surface's comments it writes.
202 type topicLoader func(rw http.ResponseWriter, r *http.Request, m *repo.Meta) (*commentTopic, bool)
203
204 // addComment is one implementation of "add a comment": what a comment is, where a
205 // reply and an anchor point, and what the response looks like afterwards.
206 func (w *Web) addComment(rw http.ResponseWriter, r *http.Request, load topicLoader) {
207 ctx, ok := w.beginWrite(rw, r, maxFormBody)
208 if !ok {
209 return
210 }
211 if !ctx.canDiscuss() {
212 w.refuse(rw, r, "you do not have permission to comment here")
213 return
214 }
215 t, ok := load(rw, r, ctx.Repo)
216 if !ok {
217 return
218 }
219 body := strings.TrimSpace(r.PostFormValue("body"))
220 replyTo := strings.TrimSpace(r.PostFormValue("reply_to"))
221 path := strings.TrimSpace(r.PostFormValue("path"))
222 line, _ := strconv.Atoi(r.PostFormValue("line"))
223 side := strings.TrimSpace(r.PostFormValue("side"))
224 if side == "" {
225 side = "new"
226 }
227 if replyTo != "" && path != "" {
228 w.fail(rw, r, http.StatusBadRequest,
229 "a reply belongs to its parent thread, not a line", t.view)
230 return
231 }
232 var anchor *metadata.Anchor
233 if t.anchorFor != nil && path != "" {
234 t.target = &commentTarget{Path: path, Line: line, Side: side}
235 var err error
236 anchor, err = t.anchorFor(path, line, side)
237 if err != nil {
238 w.fail(rw, r, http.StatusBadRequest, err.Error(), t.view)
239 return
240 }
241 }
242 if body == "" {
243 w.fail(rw, r, http.StatusBadRequest, "a comment needs a body", t.view)
244 return
245 }
246 c, err := t.newComment(body, ctx.Name, time.Now().UTC())
247 if err != nil {
248 w.fail(rw, r, http.StatusBadRequest, err.Error(), t.view)
249 return
250 }
251 c.Anchor, c.ReplyTo = anchor, replyTo
252 if err := t.add(c, ctx.Name); err != nil {
253 // Nothing was written, so the anchor the reader was shown is not where the
254 // comment went; drop it before re-rendering.
255 t.target = nil
256 w.fail(rw, r, http.StatusBadRequest, "cannot add the comment: "+err.Error(), t.view)
257 return
258 }
b1499c72
Keenan Nemetz
13d ago
259 w.finishComment(rw, r, ctx, t, t.auditAdd, ctx.Repo.Name, t.id+" "+c.ID, t.afterAdd(c))
c717d4eb
Keenan Nemetz
13d ago
260 }
261
262 // editComment rewrites one comment. Only its author, or an admin repairing a
263 // thread, may do so.
264 func (w *Web) editComment(rw http.ResponseWriter, r *http.Request, load topicLoader) {
265 ctx, ok := w.beginWrite(rw, r, maxFormBody)
266 if !ok {
267 return
268 }
269 t, ok := load(rw, r, ctx.Repo)
270 if !ok {
271 return
272 }
273 commentID := r.PathValue("comment")
274 c := t.lookup(commentID)
275 if c == nil {
276 w.notFound(rw, r)
277 return
278 }
279 if !canModerateComment(ctx.ACL, ctx.Name, c.Author) {
280 w.refuse(rw, r, "only the author or an admin may edit this comment")
281 return
282 }
283 body := strings.TrimSpace(r.PostFormValue("body"))
284 if body == "" {
285 w.fail(rw, r, http.StatusBadRequest, "a comment needs a body", t.view)
286 return
287 }
288 if err := t.edit(commentID, body, ctx.Name); err != nil {
289 w.fail(rw, r, http.StatusBadRequest, "cannot edit the comment: "+err.Error(), t.view)
290 return
291 }
292 w.finishComment(rw, r, ctx, t, t.auditEdit,
b1499c72
Keenan Nemetz
13d ago
293 ctx.Repo.Name, commentAuditDetail(t.id, commentID, c.Author, ctx.Name),
c717d4eb
Keenan Nemetz
13d ago
294 t.afterComment(commentID))
295 }
296
297 // deleteComment replaces a comment with its tombstone, so its replies keep their
298 // thread. It is the same rule as editing: the author, or an admin.
299 func (w *Web) deleteComment(rw http.ResponseWriter, r *http.Request, load topicLoader) {
300 ctx, ok := w.beginWrite(rw, r, maxSmallForm)
301 if !ok {
302 return
303 }
304 t, ok := load(rw, r, ctx.Repo)
305 if !ok {
306 return
307 }
308 commentID := r.PathValue("comment")
309 c := t.lookup(commentID)
310 if c == nil {
311 w.notFound(rw, r)
312 return
313 }
314 if !canModerateComment(ctx.ACL, ctx.Name, c.Author) {
315 w.refuse(rw, r, "only the author or an admin may delete this comment")
316 return
317 }
318 if err := t.remove(commentID, ctx.Name); err != nil {
319 w.fail(rw, r, http.StatusBadRequest, "cannot delete the comment: "+err.Error(), t.view)
320 return
321 }
322 w.finishComment(rw, r, ctx, t, t.auditRemove,
b1499c72
Keenan Nemetz
13d ago
323 ctx.Repo.Name, commentAuditDetail(t.id, commentID, c.Author, ctx.Name),
c717d4eb
Keenan Nemetz
13d ago
324 t.afterComment(commentID))
325 }
326
327 // commentAuditDetail is the audit line for a comment write. An admin acting on
328 // somebody else's comment is recorded as an override, which is the case a reader
329 // of the log is looking for.
b1499c72
Keenan Nemetz
13d ago
330 func commentAuditDetail(topicID, commentID, author, actor string) string {
331 detail := topicID + " " + commentID
c717d4eb
Keenan Nemetz
13d ago
332 if actor != author {
333 detail += " (author " + author + ", admin override)"
334 }
335 return detail
336 }
337
338 // finishComment closes a successful comment write: it records the action, checks
339 // the metadata tree, and answers — the re-rendered section for HTMX, a redirect for
340 // a plain form post.
b1499c72
Keenan Nemetz
13d ago
341 func (w *Web) finishComment(rw http.ResponseWriter, r *http.Request, ctx *writeContext, t *commentTopic, action, subject, detail, next string) {
342 w.audit(action, ctx.Name, subject, detail)
34a404b3
Keenan Nemetz
10d ago
343 if t.validate != nil {
344 if problems := t.validate(); len(problems) > 0 {
345 w.Logger("web: comment write left the metadata tree invalid: %v", problems)
346 }
c717d4eb
Keenan Nemetz
13d ago
347 }
348 if !htmxRequest(r) {
349 http.Redirect(rw, r, next, http.StatusSeeOther)
350 return
351 }
352 name, data := t.reload()
353 w.render(rw, name, data, 0)
354 }