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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 26m ago
4b8c9e1d
| 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. |
| 27 | package metadata |
| 28 | |
| 29 | import ( |
| 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 | |
| 40 | const ( |
| 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 | |
| 56 | var ( |
| 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. |
| 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 |
| 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. |
| 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 |
| 116 | } |
| 117 | |
| 118 | func (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. |
| 126 | type 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. |
| 137 | func 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. |
| 146 | func 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. |
| 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 | } |
| 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 | } |
| 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. |
| 199 | func 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 | |
| 257 | func 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. |
| 328 | func 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. |
| 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 | |
| 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 | |
| 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 | |
| 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. |
| 474 | func 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. |
| 528 | func 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. |
| 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 | |
| 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). |
| 546 | func 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. |
| 554 | func 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 |