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.

refactor

c5902451905acdaa478f8476844ccd4a510d9ca9 · Keenan Nemetz · 17d ago · parent ec2aba4b

internal/issues/issues.go → metadata/issues.go +48−80

@@ -1,4 +1,4 @@
1 // Package issues implements Vole's issue format: a tree of plain files on a
1 // Issue metadata is a tree of plain files on a
2 2 // branch in the repository itself.
3 3 //
4 4 // refs/heads/vole
@@ -24,40 +24,39 @@
24 24 //
25 25 // IDs are ULIDs, generated by the writer. Nothing is assigned server-side, so
26 26 // concurrent authors never contend and there is no counter to lose.
27 package issues
27 package metadata
28 28
29 29 import (
30 "crypto/rand"
31 30 "fmt"
32 31 "regexp"
33 32 "sort"
34 33 "strings"
35 34 "time"
36 35
37 "github.com/rodent-software/vole/internal/frontmatter"
38 "github.com/rodent-software/vole/internal/gitx"
39 "github.com/rodent-software/vole/internal/releases"
40 "github.com/rodent-software/vole/internal/runs"
36 "github.com/rodent-software/vole/core/git"
41 37 )
42 38
43 39 const (
44 40 // Ref is the branch carrying Vole metadata: issues, and later change requests.
45 41 Ref = "refs/heads/vole"
46 // Dir is the issues subtree inside that branch.
47 Dir = "issues"
48 // RequestsDir is reserved for change requests.
42 // IssuesDir is the issues subtree inside that branch.
43 IssuesDir = "issues"
44 // RequestsDir is the change-request subtree.
49 45 RequestsDir = "requests"
46 // RunsDir is the machine-written run-record subtree.
47 RunsDir = "runs"
48 // ReleasesDir is the release-artifact subtree.
49 ReleasesDir = "releases"
50 50
51 issueName = "issue.md"
52 commentsDir = "comments"
53 maxTitleLen = 200
54 maxIssuesScan = 20000
51 issueName = "issue.md"
52 commentsDir = "comments"
53 maxIssueTitleLen = 200
54 maxIssuesScan = 20000
55 55 )
56 56
57 57 var (
58 ulidRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`)
59 pathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})(?:/(issue\.md)|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`)
60 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
58 issuePathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})(?:/(issue\.md)|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`)
59 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
61 60 // assigneeRE is the roster's account-name rule, not the label rule: an
62 61 // assignee is a person, and a value that could never be an account name is a
63 62 // typo rather than a new kind of label. Kept in step with
@@ -115,7 +114,7 @@ func (i *Issue) Open() bool { return i.State == "open" }
115 114 // There is no updated field. The commit that changed the file already carries
116 115 // the time, `git log -- issues/<id>` answers it per issue, and writing it here
117 116 // cost a changed line on every single edit — more than half the diff volume.
118 type meta struct {
117 type issueMeta struct {
119 118 Title string `yaml:"title"`
120 119 State string `yaml:"state"`
121 120 Author string `yaml:"author"`
@@ -126,7 +125,7 @@ type meta struct {
126 125
127 126 // Load reads every issue at ref. It validates as it goes, so a malformed tree
128 127 // produces an error naming the offending path rather than partial data.
129 func Load(g *gitx.Repo, ref string) ([]*Issue, error) {
128 func LoadIssues(g *git.Repo, ref string) ([]*Issue, error) {
130 129 commit, err := g.RevParse(ref)
131 130 if err != nil {
132 131 return nil, err
@@ -144,7 +143,7 @@ func Load(g *gitx.Repo, ref string) ([]*Issue, error) {
144 143
145 144 contents := map[string]string{}
146 145 for _, e := range blobs {
147 if !strings.HasPrefix(e.Name, Dir+"/") {
146 if !strings.HasPrefix(e.Name, IssuesDir+"/") {
148 147 continue // unrelated files on the issues branch are ignored
149 148 }
150 149 data, err := g.BlobBySHA(e.SHA)
@@ -153,11 +152,11 @@ func Load(g *gitx.Repo, ref string) ([]*Issue, error) {
153 152 }
154 153 contents[e.Name] = string(data)
155 154 }
156 return Parse(contents)
155 return ParseIssues(contents)
157 156 }
158 157
159 158 // Parse turns a path -> content map into issues, validating the tree.
160 func Parse(contents map[string]string) ([]*Issue, error) {
159 func ParseIssues(contents map[string]string) ([]*Issue, error) {
161 160 var problems []string
162 161 byID := map[string]*Issue{}
163 162
@@ -169,10 +168,10 @@ func Parse(contents map[string]string) ([]*Issue, error) {
169 168 sort.Strings(paths)
170 169
171 170 for _, path := range paths {
172 if !strings.HasPrefix(path, Dir+"/") {
171 if !strings.HasPrefix(path, IssuesDir+"/") {
173 172 continue
174 173 }
175 m := pathRE.FindStringSubmatch(path)
174 m := issuePathRE.FindStringSubmatch(path)
176 175 if m == nil {
177 176 problems = append(problems, fmt.Sprintf("%s: not part of the issue format (expected issues/<ulid>/issue.md or comments/<ulid>.md)", path))
178 177 continue
@@ -180,7 +179,7 @@ func Parse(contents map[string]string) ([]*Issue, error) {
180 179 id := m[1]
181 180 iss := byID[id]
182 181 if iss == nil {
183 iss = &Issue{ID: id, State: "open", Path: Dir + "/" + id}
182 iss = &Issue{ID: id, State: "open", Path: IssuesDir + "/" + id}
184 183 byID[id] = iss
185 184 }
186 185 switch {
@@ -215,30 +214,30 @@ func Parse(contents map[string]string) ([]*Issue, error) {
215 214 }
216 215
217 216 func applyIssue(iss *Issue, raw, path string, problems *[]string) {
218 head, body, err := frontmatter.Split([]byte(raw))
217 head, body, err := Split([]byte(raw))
219 218 if err != nil {
220 219 *problems = append(*problems, fmt.Sprintf("%s: %v", path, err))
221 220 return
222 221 }
223 var m meta
224 if err := frontmatter.Decode(head, &m); err != nil {
222 var m issueMeta
223 if err := Decode(head, &m); err != nil {
225 224 *problems = append(*problems, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err))
226 225 return
227 226 }
228 227 // A title that holds " # " truncates to the text before it unless it is
229 228 // quoted, and a truncated title is worse than a rejected push.
230 if err := frontmatter.Text(head, "title", m.Title); err != nil {
229 if err := Text(head, "title", m.Title); err != nil {
231 230 *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err))
232 231 }
233 if err := frontmatter.Text(head, "author", m.Author); err != nil {
232 if err := Text(head, "author", m.Author); err != nil {
234 233 *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err))
235 234 }
236 235 iss.Title = strings.TrimSpace(m.Title)
237 236 switch {
238 237 case iss.Title == "":
239 238 *problems = append(*problems, fmt.Sprintf("%s: title is required", path))
240 case len(iss.Title) > maxTitleLen:
241 *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(iss.Title), maxTitleLen))
239 case len(iss.Title) > maxIssueTitleLen:
240 *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(iss.Title), maxIssueTitleLen))
242 241 }
243 242 if m.State == "" {
244 243 m.State = "open"
@@ -286,7 +285,7 @@ func applyIssue(iss *Issue, raw, path string, problems *[]string) {
286 285 // reader for both: `requests` aliases Comment and calls this.
287 286 func ParseComment(id, raw, path string) (Comment, string) {
288 287 c := Comment{ID: id}
289 head, body, err := frontmatter.Split([]byte(raw))
288 head, body, err := Split([]byte(raw))
290 289 if err != nil {
291 290 return c, fmt.Sprintf("%s: %v (a comment is frontmatter with author and created)", path, err)
292 291 }
@@ -298,10 +297,10 @@ func ParseComment(id, raw, path string) (Comment, string) {
298 297 Line int `yaml:"line"`
299 298 Blob string `yaml:"blob"`
300 299 }
301 if err := frontmatter.Decode(head, &m); err != nil {
300 if err := Decode(head, &m); err != nil {
302 301 return c, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)
303 302 }
304 if err := frontmatter.Text(head, "author", m.Author); err != nil {
303 if err := Text(head, "author", m.Author); err != nil {
305 304 return c, fmt.Sprintf("%s: author: %v", path, err)
306 305 }
307 306 c.Author = strings.TrimSpace(m.Author)
@@ -363,8 +362,8 @@ func (e *ValidationError) Error() string {
363 362 // Validate checks the metadata branch at commit and reports every problem. The
364 363 // top-level layout is closed: only issues/ and requests/ may appear, so a typo in
365 364 // a directory name is caught instead of silently ignored by the web.
366 func Validate(g *gitx.Repo, commit string) []string {
367 if commit == "" || commit == gitx.ZeroSHA {
365 func ValidateIssues(g *git.Repo, commit string) []string {
366 if commit == "" || commit == git.ZeroSHA {
368 367 return nil // deleting the branch is allowed
369 368 }
370 369 blobs, err := g.LsTreeR(commit)
@@ -375,15 +374,15 @@ func Validate(g *gitx.Repo, commit string) []string {
375 374 var problems []string
376 375 for _, e := range blobs {
377 376 switch {
378 case strings.HasPrefix(e.Name, Dir+"/"):
377 case strings.HasPrefix(e.Name, IssuesDir+"/"):
379 378 case strings.HasPrefix(e.Name, RequestsDir+"/"):
380 379 // Reserved: change requests are validated by their own code when they
381 380 // land. Accepting the prefix now keeps the layout stable.
382 case strings.HasPrefix(e.Name, releases.Dir+"/"):
381 case strings.HasPrefix(e.Name, ReleasesDir+"/"):
383 382 // Release artifacts: their own validator runs below, and they are not
384 383 // part of the entity set this function parses.
385 384 continue
386 case strings.HasPrefix(e.Name, runs.Dir+"/"):
385 case strings.HasPrefix(e.Name, RunsDir+"/"):
387 386 // Run records: machine-written. The server validates each one as it
388 387 // writes it, and no client can push here (pre-receive refuses the
389 388 // prefix), so there is nothing at the boundary to check — and reading
@@ -393,7 +392,7 @@ func Validate(g *gitx.Repo, commit string) []string {
393 392 default:
394 393 problems = append(problems, fmt.Sprintf(
395 394 "%s: metadata may only contain %s/, %s/, %s/ and %s/",
396 e.Name, Dir, RequestsDir, releases.Dir, runs.Dir))
395 e.Name, IssuesDir, RequestsDir, ReleasesDir, RunsDir))
397 396 continue
398 397 }
399 398 data, err := g.BlobBySHA(e.SHA)
@@ -402,8 +401,8 @@ func Validate(g *gitx.Repo, commit string) []string {
402 401 }
403 402 contents[e.Name] = string(data)
404 403 }
405 problems = append(problems, releases.Problems(g, commit)...)
406 if _, err := Parse(contents); err != nil {
404 problems = append(problems, ReleaseProblems(g, commit)...)
405 if _, err := ParseIssues(contents); err != nil {
407 406 if ve, ok := err.(*ValidationError); ok {
408 407 return append(problems, ve.Problems...)
409 408 }
@@ -412,48 +411,17 @@ func Validate(g *gitx.Repo, commit string) []string {
412 411 return problems
413 412 }
414 413
415 // NewULID is exported for the CLI: a sortable id with a millisecond timestamp
416 // prefix, so issue lists order sensibly without a server counter.
417 func NewULID(t time.Time) (string, error) {
418 const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
419 var out [26]byte
420 ms := uint64(t.UnixMilli())
421 for i := 9; i >= 0; i-- {
422 out[i] = alphabet[ms&31]
423 ms >>= 5
424 }
425 b := make([]byte, 16)
426 if _, err := rand.Read(b); err != nil {
427 return "", err
428 }
429 var acc uint32
430 var bits uint
431 pos := 10
432 for _, x := range b {
433 acc = acc<<8 | uint32(x)
434 bits += 8
435 for bits >= 5 && pos < 26 {
436 bits -= 5
437 out[pos] = alphabet[(acc>>bits)&31]
438 pos++
439 }
440 }
441 return string(out[:]), nil
442 }
443
444 func ValidULID(s string) bool { return ulidRE.MatchString(s) }
445
446 414 // CommentPath is where one comment's file lives. One function, so the writer and
447 415 // the validator cannot disagree about the shape.
448 func CommentPath(issueID, commentID string) string {
449 return fmt.Sprintf("%s/%s/%s/%s.md", Dir, issueID, commentsDir, commentID)
416 func IssueCommentPath(issueID, commentID string) string {
417 return fmt.Sprintf("%s/%s/%s/%s.md", IssuesDir, issueID, commentsDir, commentID)
450 418 }
451 419
452 420 // IsCommentPath reports whether path is a comment file rather than an issue file.
453 421 // The hook uses it to apply comment authorship to comments and not to the issue
454 422 // file, which carries shared state (state, labels, assignees).
455 func IsCommentPath(path string) bool {
456 m := pathRE.FindStringSubmatch(path)
423 func IsIssueCommentPath(path string) bool {
424 m := issuePathRE.FindStringSubmatch(path)
457 425 return m != nil && m[3] != ""
458 426 }
459 427
@@ -461,11 +429,11 @@ func IsCommentPath(path string) bool {
461 429 // *old* blob of a comment before letting anybody change it: a name in a file is
462 430 // not authentication, but a file that already landed was validated when it did.
463 431 func AuthorOf(raw []byte) (string, error) {
464 head, _, err := frontmatter.Split(raw)
432 head, _, err := Split(raw)
465 433 if err != nil {
466 434 return "", err
467 435 }
468 author, ok := frontmatter.Value(head, "author")
436 author, ok := Value(head, "author")
469 437 if !ok {
470 438 return "", fmt.Errorf("no author field")
471 439 }