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.
| 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 | |
| 17 | "rodent.software/vole/forge/index" |
| 18 | "rodent.software/vole/forge/repo" |
| 19 | "rodent.software/vole/metadata" |
| 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, |
| 44 | // never shown to the browser. It is nil on a surface whose writer already |
| 45 | // validated everything it wrote. |
| 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 { |
| 102 | return "/" + m.Name + "/issues/" + id + "#c-" + c.ID |
| 103 | }, |
| 104 | afterComment: func(commentID string) string { |
| 105 | return "/" + m.Name + "/issues/" + id + "#c-" + commentID |
| 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") |
| 114 | req, err := w.requestAt(memoOf(r), m, id) |
| 115 | if err != nil { |
| 116 | w.notFound(rw, r) |
| 117 | return nil, false |
| 118 | } |
| 119 | page := "/" + m.Name + "/changes/" + id |
| 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) { |
| 167 | updated, err := w.requestAt(memoOf(r), m, id) |
| 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 | } |
| 259 | w.finishComment(rw, r, ctx, t, t.auditAdd, ctx.Repo.Name, t.id+" "+c.ID, t.afterAdd(c)) |
| 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, |
| 293 | ctx.Repo.Name, commentAuditDetail(t.id, commentID, c.Author, ctx.Name), |
| 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, |
| 323 | ctx.Repo.Name, commentAuditDetail(t.id, commentID, c.Author, ctx.Name), |
| 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. |
| 330 | func commentAuditDetail(topicID, commentID, author, actor string) string { |
| 331 | detail := topicID + " " + commentID |
| 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. |
| 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) |
| 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 | } |
| 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 | } |
| 355 |