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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 26m ago 4b8c9e1d
history blame raw 19.4 KiB
1// Issue metadata is a tree of plain files on a
2// branch in the repository itself.
3//
4// refs/heads/vole
5// issues/<ulid>/issue.md frontmatter + body
6// issues/<ulid>/comments/<ulid>.md frontmatter + body
7// changes/... reserved for change requests
8//
9// Metadata is frontmatter markdown rather than JSON because the diffs are the
10// interface: one field per line, one label per line, so closing an issue changes
11// one line and adding a label adds one. A JSON array rewrites its neighbour to
12// add a comma, which is how a one-word change becomes a four-line diff.
13//
14// Issues and change requests share one branch because they are both human-authored
15// and low-frequency, and because a single ref means one push, one fetch and one
16// validation path. Anything machine-written (CI runs, statuses) must NOT go here:
17// a high-frequency writer on this ref would collide with every human edit.
18//
19// The format is the contract, not any particular tool. Someone with an editor and
20// a push can file a bug without installing Vole; `vole issue` is sugar over the
21// same tree. Issues live on a branch (not a custom ref) so that a plain
22// `git clone` carries them, which is what "issues travel with the repo" means in
23// practice.
24//
25// IDs are ULIDs, generated by the writer. Nothing is assigned server-side, so
26// concurrent authors never contend and there is no counter to lose.
27package metadata
28
29import (
30 "fmt"
31 "regexp"
32 "sort"
33 "strings"
34 "time"
35
36 "rodent.software/vole/core/git"
37 voleid "rodent.software/vole/core/id"
38)
39
40const (
41 // Ref is the branch carrying Vole metadata: issues, and later change requests.
42 Ref = "refs/heads/vole"
43 // IssuesDir is the issues subtree inside that branch.
44 IssuesDir = "issues"
45 // ChangesDir is the change-request subtree.
46 ChangesDir = "changes"
47 // ReleasesDir is the release-artifact subtree.
48 ReleasesDir = "releases"
49
50 issueName = "issue.md"
51 commentsDir = "comments"
52 maxIssueTitleLen = 200
53 maxIssuesScan = 20000
54)
55
56var (
57 issuePathRE = regexp.MustCompile(`^issues/([0-9A-HJKMNP-TV-Z]{26})(?:/(issue\.md)|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`)
58 labelRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,31}$`)
59 // assigneeRE is the roster's account-name rule, not the label rule: an
60 // assignee is a person, and a value that could never be an account name is a
61 // typo rather than a new kind of label. Kept in step with
62 // identity.ValidateName by TestAssigneeRuleMatchesAccountNames.
63 assigneeRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`)
64 // blobRE is a full git object id, which is what an anchor records.
65 blobRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
66)
67
68// Issue is one file on the metadata branch. Author is the account name at the
69// time of writing: it is load-bearing, because the push boundary resolves it to
70// decide who may edit a comment. Display labels are resolved for rendering and
71// never written here.
72type Issue struct {
73 ID string
74 Title string
75 State string
76 Author string
77 Created time.Time
78 Labels []string
79 Assignees []string
80 Description string
81 Comments []Comment
82 Path string // relative path of the issue directory
83}
84
85type Comment struct {
86 ID string
87 Author string
88 Created time.Time
89 Body string
90 // ReplyTo names the comment this one answers. Any comment may be replied to;
91 // the web renders one level of nesting by collecting a root's descendants.
92 ReplyTo string
93 // Deleted marks a tombstone left in place when a comment is removed, so its
94 // replies keep their thread and can still say what they were answering.
95 Deleted bool
96 // Anchor, when set, says which line of which file the comment is about. It is
97 // only meaningful on a review thread — the issue page ignores it — but it is
98 // part of the comment's file so that a comment and its subject travel together
99 // and one reader covers both.
100 Anchor *Anchor
101}
102
103// Anchor points a comment at one line of one file, as that file stood when the
104// comment was written. Blob is the id of the content reviewed, which is what makes
105// "has this changed since?" answerable without a second index: the same file at a
106// later head has the same blob id, so the comment stays current while the file is
107// untouched and is marked stale the moment it changes — rather than sliding to a
108// line that now says something else.
109type Anchor struct {
110 Path string
111 // Side is "new" for the head's copy of the file, "old" for the merge-base's:
112 // commenting on a deleted line is the point of reviewing a deletion.
113 Side string
114 Line int
115 Blob string
116}
117
118func (i *Issue) Open() bool { return i.State == "open" }
119
120// meta is the frontmatter head of issue.md. Field names are part of the public
121// format.
122//
123// There is no updated field. The commit that changed the file already carries
124// the time, `git log -- issues/<id>` answers it per issue, and writing it here
125// cost a changed line on every single edit — more than half the diff volume.
126type issueMeta struct {
127 Title string `yaml:"title"`
128 State string `yaml:"state"`
129 Author string `yaml:"author"`
130 Created string `yaml:"created"`
131 Labels []string `yaml:"labels,omitempty"`
132 Assignees []string `yaml:"assignees,omitempty"`
133}
134
135// Load reads every issue at ref. It validates as it goes, so a malformed tree
136// produces an error naming the offending path rather than partial data.
137func LoadIssues(g *git.Repo, ref string) ([]*Issue, error) {
138 commit, err := g.RevParse(ref)
139 if err != nil {
140 return nil, err
141 }
142 return LoadIssuesAt(g, commit)
143}
144
145// LoadIssuesAt reads every issue at a commit that has already been resolved.
146func LoadIssuesAt(g *git.Repo, commit string) ([]*Issue, error) {
147 if commit == "" {
148 return nil, nil
149 }
150 contents, err := readMetadata(g, commit, IssuesDir)
151 if err != nil {
152 return nil, err
153 }
154 return ParseIssues(contents)
155}
156
157// LoadIssueAt reads one issue and its comments at ref, without reading the rest
158// of the tree. The bool separates "no issue with that id" from a read error, so
159// an incremental refresh can delete the rows for an issue that is gone.
160func LoadIssueAt(g *git.Repo, ref, id string) (*Issue, bool, error) {
161 if !voleid.Valid(id) {
162 return nil, false, nil
163 }
164 commit, err := g.RevParse(ref)
165 if err != nil {
166 return nil, false, err
167 }
168 return LoadIssueAtCommit(g, commit, id)
169}
170
171// LoadIssueAtCommit reads one issue and its comments at a commit that has
172// already been resolved. The index has the commit and must not resolve it again
173// per changed issue.
174func LoadIssueAtCommit(g *git.Repo, commit, id string) (*Issue, bool, error) {
175 if !voleid.Valid(id) {
176 return nil, false, nil
177 }
178 if commit == "" {
179 return nil, false, nil
180 }
181 contents, err := readMetadata(g, commit, IssuesDir+"/"+id)
182 if err != nil {
183 return nil, false, err
184 }
185 if len(contents) == 0 {
186 return nil, false, nil
187 }
188 list, err := ParseIssues(contents)
189 if err != nil {
190 return nil, false, err
191 }
192 if len(list) == 0 {
193 return nil, false, nil
194 }
195 return list[0], true, nil
196}
197
198// Parse turns a path -> content map into issues, validating the tree.
199func ParseIssues(contents map[string]string) ([]*Issue, error) {
200 var problems []string
201 byID := map[string]*Issue{}
202
203 // Deterministic order for stable error output.
204 paths := make([]string, 0, len(contents))
205 for p := range contents {
206 paths = append(paths, p)
207 }
208 sort.Strings(paths)
209
210 for _, path := range paths {
211 if !strings.HasPrefix(path, IssuesDir+"/") {
212 continue
213 }
214 m := issuePathRE.FindStringSubmatch(path)
215 if m == nil {
216 problems = append(problems, fmt.Sprintf("%s: not part of the issue format (expected issues/<ulid>/issue.md or comments/<ulid>.md)", path))
217 continue
218 }
219 id := m[1]
220 iss := byID[id]
221 if iss == nil {
222 iss = &Issue{ID: id, State: "open", Path: IssuesDir + "/" + id}
223 byID[id] = iss
224 }
225 switch {
226 case m[2] == issueName:
227 applyIssue(iss, contents[path], path, &problems)
228 case m[3] != "":
229 c, prob := ParseComment(m[3], contents[path], path)
230 if prob != "" {
231 problems = append(problems, prob)
232 continue
233 }
234 iss.Comments = append(iss.Comments, c)
235 }
236 }
237
238 out := make([]*Issue, 0, len(byID))
239 for _, iss := range byID {
240 if iss.Title == "" {
241 if _, ok := contents[iss.Path+"/"+issueName]; !ok {
242 problems = append(problems, fmt.Sprintf("%s: missing %s (an issue needs a title)", iss.Path, issueName))
243 }
244 }
245 sort.Slice(iss.Comments, func(a, b int) bool { return iss.Comments[a].ID < iss.Comments[b].ID })
246 problems = append(problems, commentReplyProblems(iss.Comments, iss.Path)...)
247 out = append(out, iss)
248 }
249 sort.Slice(out, func(a, b int) bool { return out[a].ID < out[b].ID })
250
251 if len(problems) > 0 {
252 return nil, &ValidationError{Problems: problems}
253 }
254 return out, nil
255}
256
257func applyIssue(iss *Issue, raw, path string, problems *[]string) {
258 head, body, err := Split([]byte(raw))
259 if err != nil {
260 *problems = append(*problems, fmt.Sprintf("%s: %v", path, err))
261 return
262 }
263 var m issueMeta
264 if err := Decode(head, &m); err != nil {
265 *problems = append(*problems, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err))
266 return
267 }
268 // A title that holds " # " truncates to the text before it unless it is
269 // quoted, and a truncated title is worse than a rejected push.
270 if err := Text(head, "title", m.Title); err != nil {
271 *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err))
272 }
273 if err := Text(head, "author", m.Author); err != nil {
274 *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err))
275 }
276 iss.Title = strings.TrimSpace(m.Title)
277 switch {
278 case iss.Title == "":
279 *problems = append(*problems, fmt.Sprintf("%s: title is required", path))
280 case len(iss.Title) > maxIssueTitleLen:
281 *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(iss.Title), maxIssueTitleLen))
282 }
283 if m.State == "" {
284 m.State = "open"
285 }
286 if m.State != "open" && m.State != "closed" {
287 *problems = append(*problems, fmt.Sprintf("%s: state must be \"open\" or \"closed\", got %q", path, m.State))
288 }
289 iss.State = m.State
290 iss.Author = strings.TrimSpace(m.Author)
291 if iss.Author == "" {
292 *problems = append(*problems, fmt.Sprintf("%s: author is required", path))
293 }
294 if m.Created == "" {
295 *problems = append(*problems, fmt.Sprintf("%s: created is required", path))
296 } else if t, err := time.Parse(time.RFC3339, m.Created); err != nil {
297 *problems = append(*problems, fmt.Sprintf("%s: created must be RFC3339: %v", path, err))
298 } else {
299 iss.Created = t.UTC()
300 }
301 for _, l := range m.Labels {
302 if !labelRE.MatchString(l) {
303 *problems = append(*problems, fmt.Sprintf("%s: invalid label %q (lowercase letters, digits, dot, dash, underscore)", path, l))
304 continue
305 }
306 iss.Labels = append(iss.Labels, l)
307 }
308 // An assignee names an account, so a value that could never be one is
309 // reported here rather than rendering as a filter link to nothing. Stored as
310 // written: names are never reissued, so an assignment keeps resolving to the
311 // same person after a rename, exactly as the author field does.
312 for _, a := range m.Assignees {
313 if !assigneeRE.MatchString(a) {
314 *problems = append(*problems, fmt.Sprintf("%s: invalid assignee %q (an account name: lowercase letters, digits, dash, underscore)", path, a))
315 continue
316 }
317 iss.Assignees = append(iss.Assignees, a)
318 }
319 iss.Description = body
320 *problems = append(*problems, bodyProblems(path, body)...)
321 sort.Strings(iss.Labels)
322 sort.Strings(iss.Assignees)
323}
324
325// ParseComment reads the frontmatter block a comment carries. A comment on a
326// change request has the same shape as a comment on an issue, so this is the one
327// reader for both: `requests` aliases Comment and calls this.
328func ParseComment(id, raw, path string) (Comment, string) {
329 c := Comment{ID: id}
330 head, body, err := Split([]byte(raw))
331 if err != nil {
332 return c, fmt.Sprintf("%s: %v (a comment is frontmatter with author and created)", path, err)
333 }
334 var m struct {
335 Author string `yaml:"author"`
336 Created string `yaml:"created"`
337 ReplyTo string `yaml:"reply_to"`
338 Deleted bool `yaml:"deleted"`
339 Path string `yaml:"path"`
340 Side string `yaml:"side"`
341 Line int `yaml:"line"`
342 Blob string `yaml:"blob"`
343 }
344 if err := Decode(head, &m); err != nil {
345 return c, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)
346 }
347 if err := Text(head, "author", m.Author); err != nil {
348 return c, fmt.Sprintf("%s: author: %v", path, err)
349 }
350 c.Author = strings.TrimSpace(m.Author)
351 if c.Author == "" {
352 return c, fmt.Sprintf("%s: author is required", path)
353 }
354 t, err := time.Parse(time.RFC3339, m.Created)
355 if err != nil {
356 return c, fmt.Sprintf("%s: created is required and must be RFC3339: %v", path, err)
357 }
358 c.Created = t.UTC()
359 c.ReplyTo = strings.TrimSpace(m.ReplyTo)
360 if c.ReplyTo != "" && !voleid.Valid(c.ReplyTo) {
361 return c, fmt.Sprintf("%s: reply_to must be the id of the comment being answered", path)
362 }
363 c.Deleted = m.Deleted
364 c.Body = strings.TrimLeft(body, "\n")
365 if problems := bodyProblems(path, c.Body); len(problems) > 0 {
366 return c, problems[0]
367 }
368 if c.Deleted && strings.TrimSpace(c.Body) != "" {
369 return c, fmt.Sprintf("%s: a deleted comment keeps no body", path)
370 }
371 if prob := applyAnchor(&c, m.Path, m.Side, m.Line, m.Blob, path); prob != "" {
372 return c, prob
373 }
374 return c, ""
375}
376
377// checkCommentReply answers whether c may be a reply when it is written. Any
378// comment can be a parent, including one that already has replies.
379func checkCommentReply(comments []Comment, c *Comment) string {
380 if c.ReplyTo == "" {
381 return ""
382 }
383 if c.ReplyTo == c.ID {
384 return "a comment cannot reply to itself"
385 }
386 for i := range comments {
387 if comments[i].ID == c.ReplyTo {
388 if comments[i].Deleted {
389 return fmt.Sprintf("reply_to %s is a deleted comment", c.ReplyTo)
390 }
391 return ""
392 }
393 }
394 return fmt.Sprintf("reply_to %s is not a comment in this thread", c.ReplyTo)
395}
396
397// commentReplyProblems refuses self-replies and cycles. A missing parent is not
398// an error: the parent may have been deleted after these replies were written.
399func commentReplyProblems(comments []Comment, where string) []string {
400 byID := make(map[string]Comment, len(comments))
401 for _, c := range comments {
402 byID[c.ID] = c
403 }
404 var problems []string
405 for i := range comments {
406 c := &comments[i]
407 if c.ReplyTo == "" {
408 continue
409 }
410 if c.ReplyTo == c.ID {
411 problems = append(problems, fmt.Sprintf("%s: comment %s cannot reply to itself", where, c.ID))
412 continue
413 }
414 seen := map[string]bool{c.ID: true}
415 for next := c.ReplyTo; next != ""; {
416 if seen[next] {
417 problems = append(problems, fmt.Sprintf("%s: comment %s is in a reply cycle", where, c.ID))
418 break
419 }
420 seen[next] = true
421 parent, ok := byID[next]
422 if !ok {
423 break
424 }
425 next = parent.ReplyTo
426 }
427 }
428 return problems
429}
430
431// applyAnchor validates the four anchor fields as a set: an anchor is all of them
432// or none, and a half-written one is a mistake rather than a comment on line zero.
433func applyAnchor(c *Comment, path, side string, line int, blob, file string) string {
434 if path == "" {
435 if side != "" || line != 0 || blob != "" {
436 return fmt.Sprintf("%s: an anchor needs a path (path, side, line and blob go together)", file)
437 }
438 return ""
439 }
440 if strings.HasPrefix(path, "/") || strings.Contains(path, "..") || path != strings.TrimSpace(path) {
441 return fmt.Sprintf("%s: anchor path %q must be relative to the repository root", file, path)
442 }
443 switch side {
444 case "new", "old":
445 default:
446 return fmt.Sprintf("%s: anchor side must be \"new\" (the head) or \"old\" (the merge base), got %q", file, side)
447 }
448 if line < 1 {
449 return fmt.Sprintf("%s: anchor line must be 1 or more, got %d", file, line)
450 }
451 if !blobRE.MatchString(blob) {
452 return fmt.Sprintf("%s: anchor blob must be the full 40-character blob id of the content reviewed", file)
453 }
454 c.Anchor = &Anchor{Path: path, Side: side, Line: line, Blob: blob}
455 return ""
456}
457
458// ValidationError carries every problem found in one tree, so a push is rejected
459// with the whole list rather than one error per attempt.
460type ValidationError struct {
461 Problems []string
462}
463
464func (e *ValidationError) Error() string {
465 if len(e.Problems) == 1 {
466 return e.Problems[0]
467 }
468 return fmt.Sprintf("%d problems: %s", len(e.Problems), strings.Join(e.Problems, "; "))
469}
470
471// Validate checks the metadata branch at commit and reports every problem. The
472// top-level layout is closed: only issues/, changes/ and releases/ may appear, so
473// a typo in a directory name is caught instead of silently ignored by the web.
474func ValidateIssues(g *git.Repo, commit string) []string {
475 if commit == "" || commit == git.ZeroSHA {
476 return nil // deleting the branch is allowed
477 }
478 blobs, err := g.LsTreeR(commit)
479 if err != nil {
480 return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)}
481 }
482 // Refuse an oversized tree before reading any of it: a rejected push should
483 // cost the receiver nothing either.
484 var problems []string
485 for _, prefix := range []string{IssuesDir, ChangesDir} {
486 paths, shas := metadataPaths(blobs, prefix)
487 problems = append(problems, metadataSizeProblems(paths, shas, g)...)
488 }
489 if len(problems) > 0 {
490 return problems
491 }
492 var paths, shas []string
493 for _, e := range blobs {
494 switch {
495 case strings.HasPrefix(e.Name, IssuesDir+"/"):
496 paths = append(paths, e.Name)
497 shas = append(shas, e.SHA)
498 case strings.HasPrefix(e.Name, ChangesDir+"/"):
499 // Reserved: change requests are validated by their own code when they
500 // land. Accepting the prefix now keeps the layout stable.
501 case strings.HasPrefix(e.Name, ReleasesDir+"/"):
502 // Release artifacts: their own validator runs below, and they are not
503 // part of the entity set this function parses.
504 continue
505 default:
506 problems = append(problems, fmt.Sprintf(
507 "%s: metadata may only contain %s/, %s/ and %s/",
508 e.Name, IssuesDir, ChangesDir, ReleasesDir))
509 continue
510 }
511 }
512 contents, err := readBlobs(g, paths, shas)
513 if err != nil {
514 return append(problems, err.Error())
515 }
516 problems = append(problems, ReleaseProblems(g, commit)...)
517 if _, err := ParseIssues(contents); err != nil {
518 if ve, ok := err.(*ValidationError); ok {
519 return append(problems, ve.Problems...)
520 }
521 return append(problems, err.Error())
522 }
523 return problems
524}
525
526// CommentPath is where one comment's file lives. One function, so the writer and
527// the validator cannot disagree about the shape.
528func IssueCommentPath(issueID, commentID string) string {
529 return fmt.Sprintf("%s/%s/%s/%s.md", IssuesDir, issueID, commentsDir, commentID)
530}
531
532// IssueIDFromPath returns the issue a file under issues/ belongs to. It matches
533// only the files the format defines, so a stray path is not mistaken for an
534// issue an incremental refresh should reload.
535func IssueIDFromPath(path string) (string, bool) {
536 m := issuePathRE.FindStringSubmatch(path)
537 if m == nil {
538 return "", false
539 }
540 return m[1], true
541}
542
543// IsCommentPath reports whether path is a comment file rather than an issue file.
544// The hook uses it to apply comment authorship to comments and not to the issue
545// file, which carries shared state (state, labels, assignees).
546func IsIssueCommentPath(path string) bool {
547 m := issuePathRE.FindStringSubmatch(path)
548 return m != nil && m[3] != ""
549}
550
551// AuthorOf reads the author field from a metadata file. The hook calls it on the
552// *old* blob of a comment before letting anybody change it: a name in a file is
553// not authentication, but a file that already landed was validated when it did.
554func AuthorOf(raw []byte) (string, error) {
555 head, _, err := Split(raw)
556 if err != nil {
557 return "", err
558 }
559 author, ok := Value(head, "author")
560 if !ok {
561 return "", fmt.Errorf("no author field")
562 }
563 author = strings.TrimSpace(author)
564 if author == "" {
565 return "", fmt.Errorf("no author field")
566 }
567 return author, nil
568}
569