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