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.

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),