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/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 3: a change request can be discussed

7c6e52e75f3a33be57a81663bc840edd6e14f7f9 · Keenan Nemetz · 17d ago · parent 3c5d11fa

Phase 3: a change request can be discussed

Until now the only ways to say something about a request were editing its body or
approving it. A review thread is now a pushed file like everything else:

	requests/<ulid>/comments/<ulid>.md    author + created, then the body

It is the *same file shape* as a comment on an issue and is read and written by the
same code (`issues.ParseComment`, `issues.RenderComment`, with `requests.Comment` as
an alias), so the thread inherited the validation, the markdown boundary and the
ownership rule instead of reimplementing them. That last one is the part worth
having: `pre-receive` compares the author on the old blob to the pushing account, so
a review comment is its author's to edit or delete, an admin may repair a thread
(audited), and rewriting the author line in the same push does not launder it.

`vole request comment <id> -m` prints the comment's id and the page anchor
(`#c-<ulid>`), `edit-comment` and `rm-comment` are the author's own words, and
`request show` prints the thread so the ids are discoverable at all — an id nobody
can see is an id nobody can use. Adding a comment is free, like adding one to an
issue: a new file's author is a claim, and the boundary has never pretended
otherwise.

The request's own file stays outside the ownership rule, for the reason an issue's
does: state, head and base are shared, and updating them is the author's normal edit.

Verified: TestParseReadsTheDiscussion (a thread plus an approval in one tree,
ordered by id), TestCommentNeedsAnAuthorAndATime and
TestUnknownFileInARequestIsRefused (the subtree stays a closed format, with both
expected shapes named), TestCommentPaths (a request comment is not an issue comment
and not an approval); and end to end through the binary,
TestRequestDiscussionIsPushedAndOwned — the empty state prints the command, the CLI
adds a comment, the page renders it with its anchor and `request show` lists it, a
writer is refused rewriting, adopting and deleting somebody else's comment, may join
the thread and may edit his own — with the issue-comment empty state assertion added
to TestIssuePushedThenRendered earlier. scripts/ci.sh green (gofmt, vet, build,
markdown links, go test -race ./...).

internal/requests/write.go +127

@@ -199,6 +199,133 @@ func SetState(g *gitx.Repo, id, state, actor string) (*Request, error) {
199 199 return out, err
200 200 }
201 201
202 // NewComment builds a comment on a request with a fresh id.
203 func NewComment(body, author string, now time.Time) (*Comment, error) {
204 commentID, err := issues.NewULID(now)
205 if err != nil {
206 return nil, err
207 }
208 c := &Comment{ID: commentID, Author: strings.TrimSpace(author), Created: now.UTC(), Body: body}
209 if err := validateComment(c); err != nil {
210 return nil, err
211 }
212 return c, nil
213 }
214
215 func validateComment(c *Comment) error {
216 if strings.TrimSpace(c.Body) == "" {
217 return fmt.Errorf("requests: a comment needs a body")
218 }
219 if strings.TrimSpace(c.Author) == "" {
220 return fmt.Errorf("requests: a comment needs an author")
221 }
222 return nil
223 }
224
225 // AddComment appends a comment to a request. It is the same file shape a comment
226 // on an issue has, written by the same code, so review discussion needs no format
227 // of its own.
228 func AddComment(g *gitx.Repo, requestID string, c *Comment, actor string) error {
229 if strings.TrimSpace(c.Author) == "" {
230 c.Author = actor
231 }
232 if c.Created.IsZero() {
233 c.Created = time.Now().UTC()
234 }
235 if err := validateComment(c); err != nil {
236 return err
237 }
238 raw, err := issues.RenderComment(c)
239 if err != nil {
240 return err
241 }
242 path := CommentPath(requestID, c.ID)
243 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
244 if _, err := loadFrom(files, requestID); err != nil {
245 return err
246 }
247 if _, exists := files[path]; exists {
248 return fmt.Errorf("requests: comment %s already exists", c.ID)
249 }
250 files[path] = raw
251 return nil
252 }, fmt.Sprintf("request: comment on %s", requestID), c.Author)
253 return err
254 }
255
256 // EditComment rewrites a comment's body in place. The path stays, so the comment
257 // keeps its id and one `git log -p -- <path>` answers what it used to say; there
258 // is no `edited` field, for the reason nothing else in this format has one.
259 func EditComment(g *gitx.Repo, requestID, commentID, body, actor string) (*Comment, error) {
260 c, path, err := findComment(g, requestID, commentID)
261 if err != nil {
262 return nil, err
263 }
264 c.Body = strings.TrimRight(body, "\n")
265 if err := validateComment(c); err != nil {
266 return nil, err
267 }
268 raw, err := issues.RenderComment(c)
269 if err != nil {
270 return nil, err
271 }
272 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
273 if _, ok := files[path]; !ok {
274 return fmt.Errorf("requests: no comment %s", commentID)
275 }
276 files[path] = raw
277 return nil
278 }, fmt.Sprintf("request: edit comment %s on %s", commentID, requestID), actorOr(actor, c.Author))
279 if err != nil {
280 return nil, err
281 }
282 return c, nil
283 }
284
285 // DeleteComment removes a comment's file. The text stays in history: deleting is
286 // not redaction, which is an admin force-push.
287 func DeleteComment(g *gitx.Repo, requestID, commentID, actor string) (*Comment, error) {
288 c, path, err := findComment(g, requestID, commentID)
289 if err != nil {
290 return nil, err
291 }
292 _, err = gitx.CommitFiles(g, Ref, func(files map[string][]byte) error {
293 if _, ok := files[path]; !ok {
294 return fmt.Errorf("requests: no comment %s", commentID)
295 }
296 delete(files, path)
297 return nil
298 }, fmt.Sprintf("request: delete comment %s on %s", commentID, requestID), actorOr(actor, c.Author))
299 if err != nil {
300 return nil, err
301 }
302 return c, nil
303 }
304
305 // findComment locates one comment and the path it lives at.
306 func findComment(g *gitx.Repo, requestID, commentID string) (*Comment, string, error) {
307 r, err := Get(g, requestID)
308 if err != nil {
309 return nil, "", err
310 }
311 for i := range r.Comments {
312 if r.Comments[i].ID == commentID {
313 c := r.Comments[i]
314 return &c, CommentPath(requestID, commentID), nil
315 }
316 }
317 return nil, "", fmt.Errorf("requests: no comment %s on %s", commentID, requestID)
318 }
319
320 // actorOr keeps a commit authored by the person whose words changed, falling back
321 // to whoever is pushing when the record has no name.
322 func actorOr(primary, fallback string) string {
323 if strings.TrimSpace(primary) != "" {
324 return strings.TrimSpace(primary)
325 }
326 return strings.TrimSpace(fallback)
327 }
328
202 329 // loadFrom reads a request out of the tree map a commit is being built from, so
203 330 // mutations always see the tip they are about to modify.
204 331 func loadFrom(files map[string][]byte, id string) (*Request, error) {