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.
Store changes under changes/<ulid>/change.md
b492b557f5cc5633b3dcb21fdbb5e2ad98d0bc16 · Keenan Nemetz · 3d ago · parent 9bce3723
Store changes under changes/<ulid>/change.md The web, the CLI, and the docs call these items changes; only the metadata tree still said requests. Rename the directory and the record file so the format and the product use one word. The tree is a contract, so this is a format change rather than a display change: the top-level layout stays closed, and a request path no longer validates. An existing repository is migrated by rewriting its vole branch, which is an audited admin force-push; the index rebuilds from the new tip and the #N aliases keep their ULIDs because the rewrite preserves commit order. Refs #202 (01M6S3JC3Y2KA6EFJK6APX7A1T).
metadata/requests.go +19−17
| @@ -2,8 +2,8 @@ | |||
| 2 | 2 | // plus the approvals that allow it through. | |
| 3 | 3 | // | |
| 4 | 4 | // refs/heads/vole | |
| 5 | // requests/<ulid>/request.md frontmatter + body | ||
| 6 | // requests/<ulid>/approvals/<principal>.md frontmatter, no body | ||
| 5 | // changes/<ulid>/change.md frontmatter + body | ||
| 6 | // changes/<ulid>/approvals/<principal>.md frontmatter, no body | ||
| 7 | 7 | // | |
| 8 | 8 | // Every file on the metadata branch is frontmatter markdown, so one reader and | |
| 9 | 9 | // one writer cover issues, change requests, comments and approvals. A record | |
| @@ -35,15 +35,17 @@ import ( | |||
| 35 | 35 | ) | |
| 36 | 36 | ||
| 37 | 37 | const ( | |
| 38 | requestName = "request.md" | ||
| 38 | changeName = "change.md" | ||
| 39 | 39 | approvalDir = "approvals" | |
| 40 | 40 | commentDir = "comments" | |
| 41 | 41 | maxRequestTitleLen = 200 | |
| 42 | 42 | ) | |
| 43 | 43 | ||
| 44 | 44 | var ( | |
| 45 | shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`) | ||
| 46 | requestPathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(request\.md)|/approvals/([a-z0-9][a-z0-9_-]{1,31})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | ||
| 45 | shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`) | ||
| 46 | // requestPathRE is the on-disk shape of a change record. The directory comes | ||
| 47 | // from ChangesDir so the parser and the writer cannot drift apart. | ||
| 48 | requestPathRE = regexp.MustCompile(`^` + regexp.QuoteMeta(ChangesDir) + `/([0-9A-HJKMNP-TV-Z]{26})(?:/(change\.md)|/approvals/([a-z0-9][a-z0-9_-]{1,31})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | ||
| 47 | 49 | ) | |
| 48 | 50 | ||
| 49 | 51 | type Request struct { | |
| @@ -109,7 +111,7 @@ func (r *Request) ApprovedBy(principal string) bool { | |||
| 109 | 111 | return false | |
| 110 | 112 | } | |
| 111 | 113 | ||
| 112 | // meta is the frontmatter head of request.md. Field names are part of the | ||
| 114 | // meta is the frontmatter head of change.md. Field names are part of the | ||
| 113 | 115 | // public format. There is no updated field: the commit carries the time. | |
| 114 | 116 | type requestMeta struct { | |
| 115 | 117 | Title string `yaml:"title"` | |
| @@ -142,7 +144,7 @@ func LoadRequestsAt(g *git.Repo, commit string) ([]*Request, error) { | |||
| 142 | 144 | if commit == "" { | |
| 143 | 145 | return nil, nil | |
| 144 | 146 | } | |
| 145 | contents, err := readMetadata(g, commit, RequestsDir) | ||
| 147 | contents, err := readMetadata(g, commit, ChangesDir) | ||
| 146 | 148 | if err != nil { | |
| 147 | 149 | return nil, err | |
| 148 | 150 | } | |
| @@ -160,7 +162,7 @@ func LoadRequestAt(g *git.Repo, commit, id string) (*Request, bool, error) { | |||
| 160 | 162 | if commit == "" { | |
| 161 | 163 | return nil, false, nil | |
| 162 | 164 | } | |
| 163 | contents, err := readMetadata(g, commit, RequestsDir+"/"+id) | ||
| 165 | contents, err := readMetadata(g, commit, ChangesDir+"/"+id) | ||
| 164 | 166 | if err != nil { | |
| 165 | 167 | return nil, false, err | |
| 166 | 168 | } | |
| @@ -235,13 +237,13 @@ func ParseRequests(contents map[string]string) ([]*Request, error) { | |||
| 235 | 237 | sort.Strings(paths) | |
| 236 | 238 | ||
| 237 | 239 | for _, path := range paths { | |
| 238 | if !strings.HasPrefix(path, RequestsDir+"/") { | ||
| 240 | if !strings.HasPrefix(path, ChangesDir+"/") { | ||
| 239 | 241 | continue | |
| 240 | 242 | } | |
| 241 | 243 | m := requestPathRE.FindStringSubmatch(path) | |
| 242 | 244 | if m == nil { | |
| 243 | 245 | problems = append(problems, fmt.Sprintf( | |
| 244 | "%s: not part of the request format (expected requests/<ulid>/request.md, approvals/<account>.md or comments/<ulid>.md)", path)) | ||
| 246 | "%s: not part of the change format (expected %s/<ulid>/%s, approvals/<account>.md or comments/<ulid>.md)", path, ChangesDir, changeName)) | ||
| 245 | 247 | continue | |
| 246 | 248 | } | |
| 247 | 249 | id := m[1] | |
| @@ -251,7 +253,7 @@ func ParseRequests(contents map[string]string) ([]*Request, error) { | |||
| 251 | 253 | byID[id] = req | |
| 252 | 254 | } | |
| 253 | 255 | switch { | |
| 254 | case m[2] == requestName: | ||
| 256 | case m[2] == changeName: | ||
| 255 | 257 | applyRequest(req, contents[path], path, &problems) | |
| 256 | 258 | case m[3] != "": | |
| 257 | 259 | a, prob := parseApproval(m[3], contents[path], path) | |
| @@ -275,19 +277,19 @@ func ParseRequests(contents map[string]string) ([]*Request, error) { | |||
| 275 | 277 | out := make([]*Request, 0, len(byID)) | |
| 276 | 278 | for _, req := range byID { | |
| 277 | 279 | if req.Title == "" { | |
| 278 | problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", RequestsDir, req.ID, requestName)) | ||
| 280 | problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", ChangesDir, req.ID, changeName)) | ||
| 279 | 281 | } | |
| 280 | 282 | // Self-approval is not an approval. Catching it here rather than at the | |
| 281 | 283 | // gate means the author learns immediately instead of after a rejection. | |
| 282 | 284 | for _, a := range req.Approvals { | |
| 283 | 285 | if a.Principal == req.Author { | |
| 284 | 286 | problems = append(problems, fmt.Sprintf( | |
| 285 | "%s/%s/%s/%s.md: the author cannot approve their own request", RequestsDir, req.ID, approvalDir, a.Principal)) | ||
| 287 | "%s/%s/%s/%s.md: the author cannot approve their own request", ChangesDir, req.ID, approvalDir, a.Principal)) | ||
| 286 | 288 | } | |
| 287 | 289 | } | |
| 288 | 290 | sort.Slice(req.Approvals, func(i, j int) bool { return req.Approvals[i].Principal < req.Approvals[j].Principal }) | |
| 289 | 291 | sort.Slice(req.Comments, func(i, j int) bool { return req.Comments[i].ID < req.Comments[j].ID }) | |
| 290 | problems = append(problems, commentReplyProblems(req.Comments, RequestsDir+"/"+req.ID)...) | ||
| 292 | problems = append(problems, commentReplyProblems(req.Comments, ChangesDir+"/"+req.ID)...) | ||
| 291 | 293 | out = append(out, req) | |
| 292 | 294 | } | |
| 293 | 295 | sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) | |
| @@ -483,7 +485,7 @@ func parseApproval(principal, raw, path string) (Approval, string) { | |||
| 483 | 485 | ||
| 484 | 486 | // CommentPath is where a comment on a request lives. | |
| 485 | 487 | func RequestCommentPath(requestID, commentID string) string { | |
| 486 | return fmt.Sprintf("%s/%s/%s/%s.md", RequestsDir, requestID, commentDir, commentID) | ||
| 488 | return fmt.Sprintf("%s/%s/%s/%s.md", ChangesDir, requestID, commentDir, commentID) | ||
| 487 | 489 | } | |
| 488 | 490 | ||
| 489 | 491 | // IsCommentPath reports whether path is a comment on a request rather than its | |
| @@ -509,9 +511,9 @@ func ValidateRequests(g *git.Repo, commit string) []string { | |||
| 509 | 511 | } | |
| 510 | 512 | blobs, err := g.LsTreeR(commit) | |
| 511 | 513 | if err != nil { | |
| 512 | return []string{fmt.Sprintf("cannot read the requests tree: %v", err)} | ||
| 514 | return []string{fmt.Sprintf("cannot read the changes tree: %v", err)} | ||
| 513 | 515 | } | |
| 514 | paths, shas := metadataPaths(blobs, RequestsDir) | ||
| 516 | paths, shas := metadataPaths(blobs, ChangesDir) | ||
| 515 | 517 | if problems := metadataSizeProblems(paths, shas, g); len(problems) > 0 { | |
| 516 | 518 | return problems | |
| 517 | 519 | } | |