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.

metadata/issue_write.go +27−7

@@ -110,16 +110,19 @@ func AddIssueComment(g *git.Repo, id string, c *Comment, actor string) error {
110 110 if err != nil {
111 111 return err
112 112 }
113 found := false
113 var target *Issue
114 114 for _, iss := range list {
115 115 if iss.ID == id {
116 found = true
116 target = iss
117 117 break
118 118 }
119 119 }
120 if !found {
120 if target == nil {
121 121 return fmt.Errorf("issues: no issue %s", id)
122 122 }
123 if prob := checkCommentReply(target.Comments, c); prob != "" {
124 return fmt.Errorf("issues: %s", prob)
125 }
123 126 if strings.TrimSpace(c.Author) == "" {
124 127 c.Author = actor
125 128 }
@@ -158,6 +161,9 @@ func EditIssueComment(g *git.Repo, issueID, commentID, body, actor string) (*Com
158 161 if err != nil {
159 162 return nil, err
160 163 }
164 if c.Deleted {
165 return nil, fmt.Errorf("issues: cannot edit a deleted comment")
166 }
161 167 body = strings.TrimRight(body, "\n")
162 168 if strings.TrimSpace(body) == "" {
163 169 return nil, fmt.Errorf("issues: a comment needs a body")
@@ -180,19 +186,29 @@ func EditIssueComment(g *git.Repo, issueID, commentID, body, actor string) (*Com
180 186 return c, nil
181 187 }
182 188
183 // DeleteComment removes a comment's file. The text stays in history — deleting is
184 // not redaction, which is still an admin force-push — but the thread no longer
185 // shows it. A flat thread keyed by ULID has no replies to orphan.
189 // DeleteComment replaces a comment with a tombstone. The text stays in history —
190 // deleting is not redaction, which is still an admin force-push — and the marker
191 // remains so replies keep the parent they answer.
186 192 func DeleteIssueComment(g *git.Repo, issueID, commentID, actor string) (*Comment, error) {
187 193 c, path, err := findIssueComment(g, issueID, commentID)
188 194 if err != nil {
189 195 return nil, err
190 196 }
197 if c.Deleted {
198 return nil, fmt.Errorf("issues: comment %s is already deleted", commentID)
199 }
200 c.Deleted = true
201 c.Body = ""
202 c.Anchor = nil
203 raw, err := RenderComment(c)
204 if err != nil {
205 return nil, err
206 }
191 207 _, err = git.CommitFiles(g, Ref, func(files map[string][]byte) error {
192 208 if _, ok := files[path]; !ok {
193 209 return fmt.Errorf("issues: no comment %s", commentID)
194 210 }
195 delete(files, path)
211 files[path] = raw
196 212 return nil
197 213 }, fmt.Sprintf("issue: delete comment %s on %s", commentID, issueID), actorOr(actor, c.Author))
198 214 if err != nil {
@@ -229,6 +245,10 @@ func RenderComment(c *Comment) ([]byte, error) {
229 245 d := NewDoc()
230 246 d.Word("author", c.Author)
231 247 d.Word("created", c.Created.UTC().Format(time.RFC3339))
248 d.Word("reply_to", c.ReplyTo)
249 if c.Deleted {
250 d.Word("deleted", "true")
251 }
232 252 if c.Anchor != nil {
233 253 // The anchor goes after the header fields so a reader that only wants the
234 254 // thread still sees author and time first, and one added anchor is one