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.

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 }