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.

Blame · metadata/issues.go

Line attribution on main · 568 line(s)

c5902451
Keenan Nemetz
16d ago
1 // Issue metadata is a tree of plain files on a
37c01e5a
Keenan Nemetz
17d ago
2 // branch in the repository itself.
3 //
92b37587
Keenan Nemetz
17d ago
4 // refs/heads/vole
307cbc2a
Keenan Nemetz
17d ago
5 // issues/<ulid>/issue.md frontmatter + body
6 // issues/<ulid>/comments/<ulid>.md frontmatter + body
b492b557
Keenan Nemetz
3d ago
7 // changes/... reserved for change requests
92b37587
Keenan Nemetz
17d ago
8 //
307cbc2a
Keenan Nemetz
17d ago
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 //
92b37587
Keenan Nemetz
17d ago
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.
37c01e5a
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
27 package metadata
37c01e5a
Keenan Nemetz
17d ago
28
29 import (
30 "fmt"
31 "regexp"
32 "sort"
33 "strings"
34 "time"
35
d37d301f
Keenan Nemetz
16h ago
36 "rodent.software/vole/core/git"
37 voleid "rodent.software/vole/core/id"
37c01e5a
Keenan Nemetz
17d ago
38 )
39
40 const (
92b37587
Keenan Nemetz
17d ago
41 // Ref is the branch carrying Vole metadata: issues, and later change requests.
42 Ref = "refs/heads/vole"
c5902451
Keenan Nemetz
16d ago
43 // IssuesDir is the issues subtree inside that branch.
44 IssuesDir = "issues"
b492b557
Keenan Nemetz
3d ago
45 // ChangesDir is the change-request subtree.
46 ChangesDir = "changes"
c5902451
Keenan Nemetz
16d ago
47 // ReleasesDir is the release-artifact subtree.
48 ReleasesDir = "releases"
37c01e5a
Keenan Nemetz
17d ago
49
c5902451
Keenan Nemetz
16d ago
50 issueName = "issue.md"
51 commentsDir = "comments"
52 maxIssueTitleLen = 200
53 maxIssuesScan = 20000
37c01e5a
Keenan Nemetz
17d ago
54 )
55
56 var (
c5902451
Keenan Nemetz
16d ago
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}$`)
67dd8854
Keenan Nemetz
17d ago
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}$`)
9cc2f087
Keenan Nemetz
17d ago
64 // blobRE is a full git object id, which is what an anchor records.
65 blobRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
37c01e5a
Keenan Nemetz
17d ago
66 )
67
474a812d
Keenan Nemetz
15d ago
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.
37c01e5a
Keenan Nemetz
17d ago
72 type 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
85 type Comment struct {
86 ID string
87 Author string
88 Created time.Time
89 Body string
f6f4b1db
Keenan Nemetz
15d ago
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
9cc2f087
Keenan Nemetz
17d ago
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.
109 type 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
37c01e5a
Keenan Nemetz
17d ago
116 }
117
118 func (i *Issue) Open() bool { return i.State == "open" }
119
307cbc2a
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
126 type issueMeta struct {
307cbc2a
Keenan Nemetz
17d ago
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"`
37c01e5a
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
137 func LoadIssues(g *git.Repo, ref string) ([]*Issue, error) {
37c01e5a
Keenan Nemetz
17d ago
138 commit, err := g.RevParse(ref)
139 if err != nil {
140 return nil, err
141 }
c9202f7f
Keenan Nemetz
10d ago
142 return LoadIssuesAt(g, commit)
143 }
144
145 // LoadIssuesAt reads every issue at a commit that has already been resolved.
146 func LoadIssuesAt(g *git.Repo, commit string) ([]*Issue, error) {
37c01e5a
Keenan Nemetz
17d ago
147 if commit == "" {
148 return nil, nil
149 }
4581690d
Keenan Nemetz
13d ago
150 contents, err := readMetadata(g, commit, IssuesDir)
37c01e5a
Keenan Nemetz
17d ago
151 if err != nil {
152 return nil, err
153 }
c5902451
Keenan Nemetz
16d ago
154 return ParseIssues(contents)
37c01e5a
Keenan Nemetz
17d ago
155 }
156
34a404b3
Keenan Nemetz
10d ago
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.
160 func 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 }
c9202f7f
Keenan Nemetz
10d ago
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.
174 func LoadIssueAtCommit(g *git.Repo, commit, id string) (*Issue, bool, error) {
175 if !voleid.Valid(id) {
176 return nil, false, nil
177 }
34a404b3
Keenan Nemetz
10d ago
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
37c01e5a
Keenan Nemetz
17d ago
198 // Parse turns a path -> content map into issues, validating the tree.
c5902451
Keenan Nemetz
16d ago
199 func ParseIssues(contents map[string]string) ([]*Issue, error) {
37c01e5a
Keenan Nemetz
17d ago
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 {
c5902451
Keenan Nemetz
16d ago
211 if !strings.HasPrefix(path, IssuesDir+"/") {
37c01e5a
Keenan Nemetz
17d ago
212 continue
213 }
c5902451
Keenan Nemetz
16d ago
214 m := issuePathRE.FindStringSubmatch(path)
37c01e5a
Keenan Nemetz
17d ago
215 if m == nil {
307cbc2a
Keenan Nemetz
17d ago
216 problems = append(problems, fmt.Sprintf("%s: not part of the issue format (expected issues/<ulid>/issue.md or comments/<ulid>.md)", path))
37c01e5a
Keenan Nemetz
17d ago
217 continue
218 }
219 id := m[1]
220 iss := byID[id]
221 if iss == nil {
c5902451
Keenan Nemetz
16d ago
222 iss = &Issue{ID: id, State: "open", Path: IssuesDir + "/" + id}
37c01e5a
Keenan Nemetz
17d ago
223 byID[id] = iss
224 }
225 switch {
307cbc2a
Keenan Nemetz
17d ago
226 case m[2] == issueName:
227 applyIssue(iss, contents[path], path, &problems)
37c01e5a
Keenan Nemetz
17d ago
228 case m[3] != "":
7c6e52e7
Keenan Nemetz
17d ago
229 c, prob := ParseComment(m[3], contents[path], path)
37c01e5a
Keenan Nemetz
17d ago
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 == "" {
307cbc2a
Keenan Nemetz
17d ago
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))
37c01e5a
Keenan Nemetz
17d ago
243 }
244 }
245 sort.Slice(iss.Comments, func(a, b int) bool { return iss.Comments[a].ID < iss.Comments[b].ID })
f6f4b1db
Keenan Nemetz
15d ago
246 problems = append(problems, commentReplyProblems(iss.Comments, iss.Path)...)
37c01e5a
Keenan Nemetz
17d ago
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
307cbc2a
Keenan Nemetz
17d ago
257 func applyIssue(iss *Issue, raw, path string, problems *[]string) {
c5902451
Keenan Nemetz
16d ago
258 head, body, err := Split([]byte(raw))
307cbc2a
Keenan Nemetz
17d ago
259 if err != nil {
260 *problems = append(*problems, fmt.Sprintf("%s: %v", path, err))
261 return
262 }
c5902451
Keenan Nemetz
16d ago
263 var m issueMeta
264 if err := Decode(head, &m); err != nil {
307cbc2a
Keenan Nemetz
17d ago
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.
c5902451
Keenan Nemetz
16d ago
270 if err := Text(head, "title", m.Title); err != nil {
307cbc2a
Keenan Nemetz
17d ago
271 *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err))
272 }
c5902451
Keenan Nemetz
16d ago
273 if err := Text(head, "author", m.Author); err != nil {
307cbc2a
Keenan Nemetz
17d ago
274 *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err))
37c01e5a
Keenan Nemetz
17d ago
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))
c5902451
Keenan Nemetz
16d ago
280 case len(iss.Title) > maxIssueTitleLen:
281 *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(iss.Title), maxIssueTitleLen))
37c01e5a
Keenan Nemetz
17d ago
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 }
67dd8854
Keenan Nemetz
17d ago
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 }
307cbc2a
Keenan Nemetz
17d ago
319 iss.Description = body
4581690d
Keenan Nemetz
13d ago
320 *problems = append(*problems, bodyProblems(path, body)...)
37c01e5a
Keenan Nemetz
17d ago
321 sort.Strings(iss.Labels)
67dd8854
Keenan Nemetz
17d ago
322 sort.Strings(iss.Assignees)
37c01e5a
Keenan Nemetz
17d ago
323 }
324
7c6e52e7
Keenan Nemetz
17d ago
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.
328 func ParseComment(id, raw, path string) (Comment, string) {
37c01e5a
Keenan Nemetz
17d ago
329 c := Comment{ID: id}
c5902451
Keenan Nemetz
16d ago
330 head, body, err := Split([]byte(raw))
307cbc2a
Keenan Nemetz
17d ago
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"`
f6f4b1db
Keenan Nemetz
15d ago
337 ReplyTo string `yaml:"reply_to"`
338 Deleted bool `yaml:"deleted"`
9cc2f087
Keenan Nemetz
17d ago
339 Path string `yaml:"path"`
340 Side string `yaml:"side"`
341 Line int `yaml:"line"`
342 Blob string `yaml:"blob"`
307cbc2a
Keenan Nemetz
17d ago
343 }
c5902451
Keenan Nemetz
16d ago
344 if err := Decode(head, &m); err != nil {
307cbc2a
Keenan Nemetz
17d ago
345 return c, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)
37c01e5a
Keenan Nemetz
17d ago
346 }
c5902451
Keenan Nemetz
16d ago
347 if err := Text(head, "author", m.Author); err != nil {
307cbc2a
Keenan Nemetz
17d ago
348 return c, fmt.Sprintf("%s: author: %v", path, err)
349 }
350 c.Author = strings.TrimSpace(m.Author)
37c01e5a
Keenan Nemetz
17d ago
351 if c.Author == "" {
352 return c, fmt.Sprintf("%s: author is required", path)
353 }
307cbc2a
Keenan Nemetz
17d ago
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)
37c01e5a
Keenan Nemetz
17d ago
357 }
307cbc2a
Keenan Nemetz
17d ago
358 c.Created = t.UTC()
f6f4b1db
Keenan Nemetz
15d ago
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
37c01e5a
Keenan Nemetz
17d ago
364 c.Body = strings.TrimLeft(body, "\n")
4581690d
Keenan Nemetz
13d ago
365 if problems := bodyProblems(path, c.Body); len(problems) > 0 {
366 return c, problems[0]
367 }
f6f4b1db
Keenan Nemetz
15d ago
368 if c.Deleted && strings.TrimSpace(c.Body) != "" {
369 return c, fmt.Sprintf("%s: a deleted comment keeps no body", path)
370 }
9cc2f087
Keenan Nemetz
17d ago
371 if prob := applyAnchor(&c, m.Path, m.Side, m.Line, m.Blob, path); prob != "" {
372 return c, prob
373 }
37c01e5a
Keenan Nemetz
17d ago
374 return c, ""
375 }
376
f6f4b1db
Keenan Nemetz
15d ago
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.
379 func 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.
399 func 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
9cc2f087
Keenan Nemetz
17d ago
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.
433 func 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
37c01e5a
Keenan Nemetz
17d ago
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.
460 type ValidationError struct {
461 Problems []string
462 }
463
464 func (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
92b37587
Keenan Nemetz
17d ago
471 // Validate checks the metadata branch at commit and reports every problem. The
b492b557
Keenan Nemetz
3d ago
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.
c5902451
Keenan Nemetz
16d ago
474 func ValidateIssues(g *git.Repo, commit string) []string {
475 if commit == "" || commit == git.ZeroSHA {
37c01e5a
Keenan Nemetz
17d ago
476 return nil // deleting the branch is allowed
477 }
478 blobs, err := g.LsTreeR(commit)
479 if err != nil {
92b37587
Keenan Nemetz
17d ago
480 return []string{fmt.Sprintf("cannot read the metadata tree: %v", err)}
37c01e5a
Keenan Nemetz
17d ago
481 }
4581690d
Keenan Nemetz
13d ago
482 // Refuse an oversized tree before reading any of it: a rejected push should
483 // cost the receiver nothing either.
92b37587
Keenan Nemetz
17d ago
484 var problems []string
b492b557
Keenan Nemetz
3d ago
485 for _, prefix := range []string{IssuesDir, ChangesDir} {
4581690d
Keenan Nemetz
13d ago
486 paths, shas := metadataPaths(blobs, prefix)
487 problems = append(problems, metadataSizeProblems(paths, shas, g)...)
488 }
489 if len(problems) > 0 {
490 return problems
491 }
5cecc618
Keenan Nemetz
10d ago
492 var paths, shas []string
37c01e5a
Keenan Nemetz
17d ago
493 for _, e := range blobs {
92b37587
Keenan Nemetz
17d ago
494 switch {
c5902451
Keenan Nemetz
16d ago
495 case strings.HasPrefix(e.Name, IssuesDir+"/"):
5cecc618
Keenan Nemetz
10d ago
496 paths = append(paths, e.Name)
497 shas = append(shas, e.SHA)
b492b557
Keenan Nemetz
3d ago
498 case strings.HasPrefix(e.Name, ChangesDir+"/"):
92b37587
Keenan Nemetz
17d ago
499 // Reserved: change requests are validated by their own code when they
500 // land. Accepting the prefix now keeps the layout stable.
c5902451
Keenan Nemetz
16d ago
501 case strings.HasPrefix(e.Name, ReleasesDir+"/"):
85cc89fe
Keenan Nemetz
17d ago
502 // Release artifacts: their own validator runs below, and they are not
503 // part of the entity set this function parses.
504 continue
92b37587
Keenan Nemetz
17d ago
505 default:
506 problems = append(problems, fmt.Sprintf(
59badc63
Keenan Nemetz
10d ago
507 "%s: metadata may only contain %s/, %s/ and %s/",
b492b557
Keenan Nemetz
3d ago
508 e.Name, IssuesDir, ChangesDir, ReleasesDir))
37c01e5a
Keenan Nemetz
17d ago
509 continue
510 }
5cecc618
Keenan Nemetz
10d ago
511 }
512 contents, err := readBlobs(g, paths, shas)
513 if err != nil {
514 return append(problems, err.Error())
37c01e5a
Keenan Nemetz
17d ago
515 }
c5902451
Keenan Nemetz
16d ago
516 problems = append(problems, ReleaseProblems(g, commit)...)
517 if _, err := ParseIssues(contents); err != nil {
37c01e5a
Keenan Nemetz
17d ago
518 if ve, ok := err.(*ValidationError); ok {
92b37587
Keenan Nemetz
17d ago
519 return append(problems, ve.Problems...)
37c01e5a
Keenan Nemetz
17d ago
520 }
92b37587
Keenan Nemetz
17d ago
521 return append(problems, err.Error())
37c01e5a
Keenan Nemetz
17d ago
522 }
92b37587
Keenan Nemetz
17d ago
523 return problems
37c01e5a
Keenan Nemetz
17d ago
524 }
525
79c88d72
Keenan Nemetz
17d ago
526 // CommentPath is where one comment's file lives. One function, so the writer and
527 // the validator cannot disagree about the shape.
c5902451
Keenan Nemetz
16d ago
528 func IssueCommentPath(issueID, commentID string) string {
529 return fmt.Sprintf("%s/%s/%s/%s.md", IssuesDir, issueID, commentsDir, commentID)
79c88d72
Keenan Nemetz
17d ago
530 }
531
34a404b3
Keenan Nemetz
10d ago
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.
535 func 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
79c88d72
Keenan Nemetz
17d ago
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).
c5902451
Keenan Nemetz
16d ago
546 func IsIssueCommentPath(path string) bool {
547 m := issuePathRE.FindStringSubmatch(path)
79c88d72
Keenan Nemetz
17d ago
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.
554 func AuthorOf(raw []byte) (string, error) {
c5902451
Keenan Nemetz
16d ago
555 head, _, err := Split(raw)
79c88d72
Keenan Nemetz
17d ago
556 if err != nil {
557 return "", err
558 }
c5902451
Keenan Nemetz
16d ago
559 author, ok := Value(head, "author")
79c88d72
Keenan Nemetz
17d ago
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 }