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/templates/requests.html +141−46

@@ -75,8 +75,27 @@
75 75 </div>
76 76 {{end}}
77 77
78 {{define "request_landing"}}
79 <section>
80 <h2>{{icon "git-pull-request"}}What would land</h2>
81 {{if .MergeChecked}}{{if .Conflicts}}
82 <p class="conflict">This branch no longer merges into {{.Request.Base}} cleanly:
83 {{range .Conflicts}}<code>{{.}}</code> {{end}}— rebase or merge {{.Request.Base}} before landing it.</p>
84 {{else}}
85 <p class="muted">Merges into {{.Request.Base}} cleanly.</p>
86 {{end}}{{end}}
87 {{if .AlreadyMerged}}
88 <p class="muted">Already merged into {{.Request.Base}}.</p>
89 {{else if .FileCount}}
90 <p class="muted">{{.FileCount}} file(s) changed{{if .Added}} · <span class="adds">+{{.Added}}</span>{{end}}{{if .Removed}} · <span class="dels">−{{.Removed}}</span>{{end}} · <a href="/r/{{.Name}}/requests/{{.Request.ID}}/files">Review files</a></p>
91 {{else}}
92 <p class="muted">No changes, or the head is not reachable from this repository yet.</p>
93 {{end}}
94 </section>
95 {{end}}
96
78 97 {{define "request_sidebar"}}
79 <aside class="thread-side" id="request-sidebar">
98 <div id="request-sidebar">
80 99 {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
81 100 <section>
82 101 <h2>{{icon "git-branch"}}Review</h2>
@@ -120,48 +139,121 @@
120 139 {{else if not .ContributorCI}}
121 140 <p class="muted">No CI run for this commit yet.</p>
122 141 {{end}}
142 <p class="section-link"><a href="/r/{{.Name}}/requests/{{.Request.ID}}/runs">View runs</a></p>
123 143 </section>
124 </aside>
144 </div>
125 145 {{end}}
126 146
127 {{define "request_discussion"}}
128 <div id="request-discussion">
129 {{if .ThreadError}}<p class="error">{{.ThreadError}}</p>{{end}}
130 {{with .CommentTarget}}<p class="anchor-note muted">Commenting on <code>{{.Path}}:{{.Line}}</code> ({{.Side}} side) · <a href="/r/{{$.Name}}/requests/{{$.Request.ID}}#request-comment-form">cancel</a></p>{{end}}
131 <h2>{{icon "message-square"}}Discussion <span class="muted">({{len .Comments}})</span></h2>
132 {{range .Comments}}
133 <article class="comment" id="c-{{.ID}}">
147 {{define "request_inline_comment"}}
148 <tr class="inline-comment">
149 <td colspan="4">
150 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/comments" class="inline-comment-form">
151 <input type="hidden" name="csrf" value="{{.CSRF}}">
152 <input type="hidden" name="path" value="{{.Target.Path}}">
153 <input type="hidden" name="line" value="{{.Target.Line}}">
154 <input type="hidden" name="side" value="{{.Target.Side}}">
155 <div class="inline-comment-card">
156 <div class="inline-comment-head">
157 <span>Comment on <code>{{.Target.Path}}:{{.Target.Line}}</code> <span class="muted">({{.Target.Side}} side)</span></span>
158 <button class="link" type="button" data-inline-comment-cancel>Cancel</button>
159 </div>
160 <textarea name="body" required autofocus placeholder="Leave an inline comment. Markdown is welcome." aria-label="Inline comment"></textarea>
161 </div>
162 <div class="form-actions"><button type="submit">{{icon "message-square"}}Comment</button></div>
163 </form>
164 </td>
165 </tr>
166 {{end}}
167
168 {{define "request_comment_card"}}
169 <article class="comment{{if .Deleted}} deleted{{end}}" id="c-{{.ID}}">
170 {{if .Deleted}}
171 <div class="comment-deleted">{{icon "circle-x"}}Comment deleted</div>
172 {{else}}
134 173 <header class="comment-head">
135 174 <span class="avatar c{{tone .Author}}" aria-hidden="true">{{initial (who .Author)}}</span>
136 175 <span class="comment-author">{{who .Author}}</span>
176 {{if .ReplyTo}}<span class="reply-context">replying to {{if .ReplyToDeleted}}<a href="#c-{{.ReplyTo}}">a deleted comment</a>{{else}}<a href="#c-{{.ReplyTo}}">{{who .ReplyToName}}</a>{{end}}</span>{{end}}
137 177 <span class="muted">{{since .Created.Unix}}</span>
138 {{if .Anchor}}<span class="muted">on <code>{{.Anchor.Path}}:{{.Anchor.Line}}</code> ({{.Anchor.Side}} side){{if not .Current}} · <span class="conflict">the file has changed since this comment</span>{{end}}</span>{{end}}
178 {{if and .Anchor (not .Current)}}<span class="conflict">the file has changed since this comment</span>{{end}}
179 <div class="comment-menu" data-comment-menu>
180 <button type="button" class="comment-menu-toggle" data-comment-menu-toggle aria-haspopup="menu" aria-expanded="false" aria-label="Comment actions">&#8943;</button>
181 <div class="comment-menu-list" data-comment-menu-list role="menu" hidden>
182 {{if .CanReply}}<button type="button" role="menuitem" data-reply-to="{{.ID}}" data-reply-author="{{who .Author}}">Reply</button>{{end}}
183 <button type="button" role="menuitem" data-copy="{{.Source}}">Copy text</button>
184 <button type="button" role="menuitem" data-copy-link="{{.ID}}">Copy link</button>
185 {{if .CanEdit}}<button type="button" role="menuitem" data-comment-edit>Edit</button>{{end}}
186 {{if .CanDelete}}
187 <form method="post" action="{{.DeleteURL}}" data-confirm="Delete this comment?">
188 <input type="hidden" name="csrf" value="{{.CSRF}}">
189 <button type="submit" role="menuitem" class="menu-danger">Delete</button>
190 </form>
191 {{end}}
192 </div>
193 </div>
139 194 </header>
140 195 <div class="comment-body md">{{.Body}}</div>
196 {{if .Snippet}}
197 <details class="comment-snippet">
198 <summary>
199 <span class="snippet-chevron">{{icon "chevron-right"}}</span>
200 <code>{{.Snippet.Path}}:{{.Snippet.Line}}</code>
201 <span class="muted">({{.Snippet.Side}} side)</span>
202 {{if not .Current}}<span class="conflict">original line</span>{{end}}
203 </summary>
204 <div class="snippet-code">{{range .Snippet.Lines}}<div class="snippet-line{{if .Anchor}} anchor{{end}}"><span class="snippet-no">{{.No}}</span><span class="snippet-text">{{.Text}}</span></div>{{end}}</div>
205 <div class="snippet-actions"><a href="{{.Snippet.FilesURL}}">View in Files</a></div>
206 </details>
207 {{end}}
208 {{if .CanEdit}}
209 <form class="comment-edit-form" data-comment-edit-form hidden method="post" action="{{.EditURL}}" hx-post="{{.EditURL}}" hx-target="#request-discussion" hx-swap="outerHTML" hx-disabled-elt="find button[type='submit']">
210 <input type="hidden" name="csrf" value="{{.CSRF}}">
211 <textarea name="body" required>{{.Source}}</textarea>
212 <div class="form-actions">
213 <button class="secondary" type="button" data-comment-edit-cancel>Cancel</button>
214 <button type="submit">Save</button>
215 </div>
216 </form>
217 {{end}}
218 {{end}}
141 219 </article>
142 {{else}}
143 <p class="muted">No discussion yet. From a clone of this repository:
144 <code>vole change comment {{.Request.ID}} -m "..."</code></p>
145 220 {{end}}
146 221
147 {{if .CanDiscuss}}
148 <form id="request-comment-form" method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/comments" class="form-block" hx-post="/r/{{.Name}}/requests/{{.Request.ID}}/comments" hx-target="#request-discussion" hx-swap="outerHTML" hx-push-url="/r/{{.Name}}/requests/{{.Request.ID}}" hx-disabled-elt="find button[type='submit']">
149 <input type="hidden" name="csrf" value="{{.CSRF}}">
150 {{with .CommentTarget}}<input type="hidden" name="path" value="{{.Path}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}
151 <div class="card form-card">
152 <label>Comment <textarea name="body" required placeholder="Leave a comment. Markdown is welcome."{{if .CommentTarget}} autofocus{{end}}></textarea></label>
153 </div>
154 <div class="form-actions"><button type="submit">{{icon "message-square"}}Comment</button></div>
155 </form>
156 {{end}}
222 {{define "request_discussion"}}
223 <div id="request-discussion">
224 <h2>{{icon "message-square"}}Discussion <span class="muted">({{len .Request.Comments}})</span></h2>
225 {{if .ThreadError}}<p class="error">{{.ThreadError}}</p>{{end}}
226 {{with .CommentTarget}}<p class="anchor-note muted">Commenting on <code>{{.Path}}:{{.Line}}</code> ({{.Side}} side) · <a href="/r/{{$.Name}}/requests/{{$.Request.ID}}#request-comment-form">cancel</a></p>{{end}}
227 {{range .Comments}}
228 {{template "request_comment_card" .}}
229 {{if .Replies}}
230 <details class="thread-replies" open>
231 <summary><span class="thread-chevron">{{icon "chevron-right"}}</span>{{len .Replies}} {{if eq (len .Replies) 1}}reply{{else}}replies{{end}}</summary>
232 <div class="comment-replies">{{range .Replies}}{{template "request_comment_card" .}}{{end}}</div>
233 </details>
234 {{end}}
235 {{else}}
236 <p class="muted">No discussion yet. From a clone of this repository:
237 <code>vole change comment {{.Request.ID}} -m "..."</code></p>
238 {{end}}
157 239
240 {{if .CanDiscuss}}
241 <form id="request-comment-form" method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/comments" class="form-block discussion-composer" hx-post="/r/{{.Name}}/requests/{{.Request.ID}}/comments" hx-target="#request-discussion" hx-swap="outerHTML" hx-push-url="/r/{{.Name}}/requests/{{.Request.ID}}" hx-disabled-elt="find button[type='submit']">
242 <input type="hidden" name="csrf" value="{{.CSRF}}">
243 <input type="hidden" name="reply_to" value="" data-reply-input>
244 {{with .CommentTarget}}<input type="hidden" name="path" value="{{.Path}}"><input type="hidden" name="line" value="{{.Line}}"><input type="hidden" name="side" value="{{.Side}}">{{end}}
245 <div class="reply-target" data-reply-target hidden>
246 <span>Replying to <strong data-reply-name></strong></span>
247 <button class="link" type="button" data-reply-clear>Cancel reply</button>
248 </div>
249 <textarea name="body" required placeholder="Leave a comment. Markdown is welcome." aria-label="Comment"{{if .CommentTarget}} autofocus{{end}}></textarea>
250 <div class="form-actions"><button type="submit">{{icon "message-square"}}Comment</button></div>
251 </form>
252 {{end}}
158 253 </div>
159 254 {{end}}
160 255
161 {{define "request"}}
162 {{template "header" .}}
163 {{template "repohead" (nav .Name "requests")}}
164
256 {{define "request_head"}}
165 257 <div class="page-head">
166 258 <div class="page-head-main">
167 259 <h1>{{.Request.Title}}</h1>
@@ -178,25 +270,40 @@
178 270 <input type="hidden" name="csrf" value="{{$.CSRF}}">
179 271 <input type="hidden" name="request" value="{{$.Request.ID}}">
180 272 <input type="hidden" name="ref" value="refs/heads/{{.Ref}}">
181 <button type="submit">{{icon "play"}}Allow CI for this tip</button>
273 <button class="warn" type="submit">{{icon "play"}}Allow CI</button>
182 274 </form>
183 275 {{end}}{{end}}
184 276 {{if .CanApprove}}
185 277 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/approve">
186 278 <input type="hidden" name="csrf" value="{{.CSRF}}">
187 <button type="submit">{{icon "check"}}Approve this head</button>
279 <button class="success" type="submit">{{icon "check"}}Approve</button>
188 280 </form>
189 281 {{end}}
190 282 {{if .CanClose}}
191 283 <form method="post" action="/r/{{.Name}}/requests/{{.Request.ID}}/state">
192 284 <input type="hidden" name="csrf" value="{{.CSRF}}">
193 285 <input type="hidden" name="state" value="{{if eq .Request.State "open"}}closed{{else}}open{{end}}">
194 <button class="secondary" type="submit">{{if eq .Request.State "open"}}{{icon "circle-x"}}Close change{{else}}{{icon "rotate-ccw"}}Reopen change{{end}}</button>
286 <button class="{{if eq .Request.State "open"}}danger{{else}}secondary{{end}}" type="submit">{{if eq .Request.State "open"}}{{icon "circle-x"}}Close{{else}}{{icon "rotate-ccw"}}Reopen{{end}}</button>
195 287 </form>
196 288 {{end}}
197 289 </div>
198 290 {{end}}
199 291 </div>
292 {{end}}
293
294 {{define "request_tabs"}}
295 <nav class="subtabs" aria-label="Change sections">
296 <a class="{{if eq .Tab "conversation"}}active{{end}}" href="/r/{{.Name}}/requests/{{.Request.ID}}"{{if eq .Tab "conversation"}} aria-current="page"{{end}}>{{icon "message-square"}}Conversation</a>
297 <a class="{{if eq .Tab "files"}}active{{end}}" href="/r/{{.Name}}/requests/{{.Request.ID}}/files"{{if eq .Tab "files"}} aria-current="page"{{end}}>{{icon "file"}}Files <span class="chip-count">{{.FileCount}}</span></a>
298 <a class="{{if eq .Tab "runs"}}active{{end}}" href="/r/{{.Name}}/requests/{{.Request.ID}}/runs"{{if eq .Tab "runs"}} aria-current="page"{{end}}>{{icon "play"}}Runs <span class="chip-count">{{len .Checks}}</span></a>
299 </nav>
300 {{end}}
301
302 {{define "request"}}
303 {{template "header" .}}
304 {{template "repohead" (nav .Name "requests")}}
305 {{template "request_head" .}}
306 {{template "request_tabs" .}}
200 307
201 308 <div class="thread">
202 309 <div class="thread-main">
@@ -212,24 +319,12 @@
212 319 </article>
213 320
214 321 {{template "request_discussion" .}}
215
216 <h2>{{icon "git-pull-request"}}What would land</h2>
217 {{if .MergeChecked}}{{if .Conflicts}}
218 <p class="conflict">This branch no longer merges into {{.Request.Base}} cleanly:
219 {{range .Conflicts}}<code>{{.}}</code> {{end}}— rebase or merge {{.Request.Base}} before landing it.</p>
220 {{else}}
221 <p class="muted">Merges into {{.Request.Base}} cleanly.</p>
222 {{end}}{{end}}
223 {{if .AlreadyMerged}}
224 <p class="muted">Already merged into {{.Request.Base}}.</p>
225 {{else if .Files}}
226 {{template "diff" .}}
227 {{else}}
228 <p class="muted">No changes, or the head is not reachable from this repository yet.</p>
229 {{end}}
230 322 </div>
231 323
232 {{template "request_sidebar" .}}
324 <aside class="thread-side">
325 {{template "request_landing" .}}
326 {{template "request_sidebar" .}}
327 </aside>
233 328 </div>
234 329 {{template "footer" .}}
235 330 {{end}}