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 | } | |