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