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.
Build out change review and threaded discussions
f6f4b1db24292d1df877680714952a9c568e2b78 · Keenan Nemetz · 15d ago · parent 564acd4b
Build out change review and threaded discussions - Add Conversation, Files and Runs tabs to change requests. Files uses the code-browser shape with a changed-file tree and one selected-file diff; Runs lists the head commit's workflow results. - Show anchored review comments beneath the line, with a collapsed excerpt read from the exact blob that was reviewed, and return posted anchors to their line note. - Add comment threads: any comment can be replied to, the UI flattens the descendants under one root, reply context links back to the parent, and reply targets feed a single composer at the foot of the discussion. - Collapse reply groups while keeping the root visible, and scroll to the composer when Reply is selected. - Add comment context menus with Reply, Copy text, Copy link, Edit and Delete. Add web edit/delete routes with author or admin checks; deletion leaves a tombstone so replies keep their thread. - Support --reply-to in the issue and change CLI commands, and treat a missing parent as a tombstone rather than an invalid tree. - Fix the diff comment gutter, action button colours, reply labels, and keep the Files tree and diff pane aligned. Update metadata validation, web templates, CSS, JavaScript, tests, and the CLI/change-request/issue docs.
server/web/issues.go +110−6
| @@ -159,7 +159,19 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) { | |||
| 159 | 159 | // markdown boundary. | |
| 160 | 160 | type issueCommentView struct { | |
| 161 | 161 | metadata.Comment | |
| 162 | Body template.HTML | ||
| 162 | Body template.HTML | ||
| 163 | Replies []issueCommentView | ||
| 164 | // ReplyToName names the account this comment answers. ReplyToDeleted marks a | ||
| 165 | // parent that is a tombstone, so the UI can say so instead of showing no one. | ||
| 166 | ReplyToName string | ||
| 167 | ReplyToDeleted bool | ||
| 168 | CanReply bool | ||
| 169 | CanEdit bool | ||
| 170 | CanDelete bool | ||
| 171 | Source string | ||
| 172 | CSRF string | ||
| 173 | EditURL string | ||
| 174 | DeleteURL string | ||
| 163 | 175 | } | |
| 164 | 176 | ||
| 165 | 177 | // issueSidebarView is the data every sidebar fragment needs. Keeping it one | |
| @@ -220,17 +232,107 @@ type issueThreadView struct { | |||
| 220 | 232 | func (w *Web) issueComments(m *repo.Meta, iss *metadata.Issue) []issueCommentView { | |
| 221 | 233 | comments := make([]issueCommentView, 0, len(iss.Comments)) | |
| 222 | 234 | for _, c := range iss.Comments { | |
| 223 | comments = append(comments, issueCommentView{Comment: c, Body: w.renderMarkdown(m.Name, c.Body)}) | ||
| 235 | comments = append(comments, issueCommentView{Comment: c, Body: w.renderMarkdown(m.Name, c.Body), Source: c.Body}) | ||
| 224 | 236 | } | |
| 225 | return comments | ||
| 237 | return threadIssueComments(comments) | ||
| 238 | } | ||
| 239 | |||
| 240 | // threadIssueComments puts every descendant under its top-level root. The | ||
| 241 | // metadata allows a reply to any comment; the page still shows one level. Missing | ||
| 242 | // parents become tombstones, so replies to a deleted comment keep their thread. | ||
| 243 | func threadIssueComments(comments []issueCommentView) []issueCommentView { | ||
| 244 | nodes := make(map[string]issueCommentView, len(comments)) | ||
| 245 | for _, c := range comments { | ||
| 246 | nodes[c.ID] = c | ||
| 247 | } | ||
| 248 | for _, c := range comments { | ||
| 249 | if c.ReplyTo != "" { | ||
| 250 | if _, ok := nodes[c.ReplyTo]; !ok { | ||
| 251 | nodes[c.ReplyTo] = issueCommentView{Comment: metadata.Comment{ID: c.ReplyTo, Deleted: true}} | ||
| 252 | } | ||
| 253 | } | ||
| 254 | } | ||
| 255 | ids := make([]string, 0, len(nodes)) | ||
| 256 | for id := range nodes { | ||
| 257 | ids = append(ids, id) | ||
| 258 | } | ||
| 259 | sort.Strings(ids) | ||
| 260 | children := make(map[string][]string, len(nodes)) | ||
| 261 | var roots []string | ||
| 262 | for _, id := range ids { | ||
| 263 | c := nodes[id] | ||
| 264 | if c.ReplyTo == "" || c.ReplyTo == id { | ||
| 265 | roots = append(roots, id) | ||
| 266 | continue | ||
| 267 | } | ||
| 268 | if _, ok := nodes[c.ReplyTo]; ok { | ||
| 269 | children[c.ReplyTo] = append(children[c.ReplyTo], id) | ||
| 270 | } else { | ||
| 271 | roots = append(roots, id) | ||
| 272 | } | ||
| 273 | } | ||
| 274 | out := make([]issueCommentView, 0, len(roots)) | ||
| 275 | for _, id := range roots { | ||
| 276 | root := nodes[id] | ||
| 277 | seen := map[string]bool{id: true} | ||
| 278 | var replies []string | ||
| 279 | var walk func(string) | ||
| 280 | walk = func(parent string) { | ||
| 281 | for _, child := range children[parent] { | ||
| 282 | if seen[child] { | ||
| 283 | continue | ||
| 284 | } | ||
| 285 | seen[child] = true | ||
| 286 | replies = append(replies, child) | ||
| 287 | walk(child) | ||
| 288 | } | ||
| 289 | } | ||
| 290 | walk(id) | ||
| 291 | sort.Strings(replies) | ||
| 292 | for _, rid := range replies { | ||
| 293 | reply := nodes[rid] | ||
| 294 | if parent, ok := nodes[reply.ReplyTo]; ok { | ||
| 295 | reply.ReplyToName = parent.Author | ||
| 296 | reply.ReplyToDeleted = parent.Deleted | ||
| 297 | } | ||
| 298 | root.Replies = append(root.Replies, reply) | ||
| 299 | } | ||
| 300 | out = append(out, root) | ||
| 301 | } | ||
| 302 | return out | ||
| 226 | 303 | } | |
| 227 | 304 | ||
| 228 | 305 | func (w *Web) issueThread(r *http.Request, m *repo.Meta, iss *metadata.Issue, errMsg string) issueThreadView { | |
| 229 | 306 | acl, _ := m.ACL() | |
| 307 | canDiscuss := acl != nil && w.canDiscuss(r, acl) | ||
| 308 | comments := w.issueComments(m, iss) | ||
| 309 | w.markIssueCommentActions(r, m, iss.ID, comments, canDiscuss) | ||
| 230 | 310 | return issueThreadView{ | |
| 231 | 311 | Page: w.page(r, ""), Name: m.Name, Issue: iss, | |
| 232 | Comments: w.issueComments(m, iss), CanDiscuss: acl != nil && w.canDiscuss(r, acl), | ||
| 233 | ThreadError: errMsg, | ||
| 312 | Comments: comments, CanDiscuss: canDiscuss, ThreadError: errMsg, | ||
| 313 | } | ||
| 314 | } | ||
| 315 | |||
| 316 | func (w *Web) markIssueCommentActions(r *http.Request, m *repo.Meta, issueID string, comments []issueCommentView, canDiscuss bool) { | ||
| 317 | pr := w.principal(r) | ||
| 318 | acl, _ := m.ACL() | ||
| 319 | admin := pr != nil && acl != nil && acl.RoleFor(pr.account.ID).AtLeast(access.Admin) | ||
| 320 | csrf := "" | ||
| 321 | if pr != nil { | ||
| 322 | csrf = pr.session.CSRF | ||
| 323 | } | ||
| 324 | for i := range comments { | ||
| 325 | c := &comments[i] | ||
| 326 | c.CSRF = csrf | ||
| 327 | c.CanReply = canDiscuss && !c.Deleted | ||
| 328 | c.CanEdit = !c.Deleted && pr != nil && (c.Author == pr.account.Name || admin) | ||
| 329 | c.CanDelete = c.CanEdit | ||
| 330 | if c.CanEdit { | ||
| 331 | base := "/r/" + m.Name + "/issues/" + issueID + "/comments/" + c.ID | ||
| 332 | c.EditURL = base + "/edit" | ||
| 333 | c.DeleteURL = base + "/delete" | ||
| 334 | } | ||
| 335 | w.markIssueCommentActions(r, m, issueID, c.Replies, canDiscuss) | ||
| 234 | 336 | } | |
| 235 | 337 | } | |
| 236 | 338 | ||
| @@ -252,8 +354,10 @@ func (w *Web) IssueView(rw http.ResponseWriter, r *http.Request) { | |||
| 252 | 354 | return | |
| 253 | 355 | } | |
| 254 | 356 | comments := w.issueComments(m, iss) | |
| 357 | sidebar := w.issueSidebar(r, m, iss, m.Name+" · "+iss.Title, "") | ||
| 358 | w.markIssueCommentActions(r, m, iss.ID, comments, sidebar.CanDiscuss) | ||
| 255 | 359 | w.render(rw, "issue", issuePageView{ | |
| 256 | issueSidebarView: w.issueSidebar(r, m, iss, m.Name+" · "+iss.Title, ""), | ||
| 360 | issueSidebarView: sidebar, | ||
| 257 | 361 | Description: w.renderMarkdown(m.Name, iss.Description), | |
| 258 | 362 | Comments: comments, | |
| 259 | 363 | Backlinks: w.backlinks(m, iss.ID), | |