vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
refactor
c5902451905acdaa478f8476844ccd4a510d9ca9 · Keenan Nemetz · 17d ago · parent ec2aba4b
internal/requests/requests.go → metadata/requests.go +41−59
| @@ -1,4 +1,4 @@ | |||
| 1 | // Package requests implements change requests: a proposed update to a branch, | ||
| 1 | // Change requests are a proposed update to a branch, | ||
| 2 | 2 | // plus the approvals that allow it through. | |
| 3 | 3 | // | |
| 4 | 4 | // refs/heads/vole | |
| @@ -17,7 +17,7 @@ | |||
| 17 | 17 | // Merging is a push. The pre-receive hook permits a push to a protected branch | |
| 18 | 18 | // when it carries an approved request, so there is no merge button and no | |
| 19 | 19 | // authenticated write surface to build. | |
| 20 | package requests | ||
| 20 | package metadata | ||
| 21 | 21 | ||
| 22 | 22 | import ( | |
| 23 | 23 | "fmt" | |
| @@ -26,29 +26,21 @@ import ( | |||
| 26 | 26 | "strings" | |
| 27 | 27 | "time" | |
| 28 | 28 | ||
| 29 | "github.com/rodent-software/vole/internal/frontmatter" | ||
| 30 | "github.com/rodent-software/vole/internal/gitx" | ||
| 31 | "github.com/rodent-software/vole/internal/id" | ||
| 32 | "github.com/rodent-software/vole/internal/issues" | ||
| 29 | "github.com/rodent-software/vole/core/git" | ||
| 30 | "github.com/rodent-software/vole/core/id" | ||
| 33 | 31 | ) | |
| 34 | 32 | ||
| 35 | 33 | const ( | |
| 36 | // Dir is the requests subtree on the metadata branch. | ||
| 37 | Dir = "requests" | ||
| 38 | // Ref is the metadata branch, shared with issues. | ||
| 39 | Ref = issues.Ref | ||
| 40 | |||
| 41 | requestName = "request.md" | ||
| 42 | approvalDir = "approvals" | ||
| 43 | commentDir = "comments" | ||
| 44 | maxTitleLen = 200 | ||
| 34 | requestName = "request.md" | ||
| 35 | approvalDir = "approvals" | ||
| 36 | commentDir = "comments" | ||
| 37 | maxRequestTitleLen = 200 | ||
| 45 | 38 | ) | |
| 46 | 39 | ||
| 47 | 40 | var ( | |
| 48 | ulidRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`) | ||
| 49 | shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`) | ||
| 50 | pathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(request\.md)|/approvals/([0-9A-HJKMNP-TV-Z]{26})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | ||
| 51 | branchNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | ||
| 41 | shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`) | ||
| 42 | requestPathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(request\.md)|/approvals/([0-9A-HJKMNP-TV-Z]{26})\.md|/comments/([0-9A-HJKMNP-TV-Z]{26})\.md)$`) | ||
| 43 | branchNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | ||
| 52 | 44 | ) | |
| 53 | 45 | ||
| 54 | 46 | type Request struct { | |
| @@ -68,7 +60,6 @@ type Request struct { | |||
| 68 | 60 | // Comment is a remark on a request. It is the same file shape as a comment on an | |
| 69 | 61 | // issue — author, created, then the body — so one reader and one writer cover | |
| 70 | 62 | // both, and the ownership rule at the push boundary applies to both. | |
| 71 | type Comment = issues.Comment | ||
| 72 | 63 | ||
| 73 | 64 | type Approval struct { | |
| 74 | 65 | Principal string | |
| @@ -94,7 +85,7 @@ func (r *Request) ApprovedBy(principal string) bool { | |||
| 94 | 85 | ||
| 95 | 86 | // meta is the frontmatter head of request.md. Field names are part of the | |
| 96 | 87 | // public format. There is no updated field: the commit carries the time. | |
| 97 | type meta struct { | ||
| 88 | type requestMeta struct { | ||
| 98 | 89 | Title string `yaml:"title"` | |
| 99 | 90 | Author string `yaml:"author"` | |
| 100 | 91 | AuthorID string `yaml:"author_id"` | |
| @@ -105,7 +96,7 @@ type meta struct { | |||
| 105 | 96 | } | |
| 106 | 97 | ||
| 107 | 98 | // Load reads and validates every request on the metadata branch. | |
| 108 | func Load(g *gitx.Repo, ref string) ([]*Request, error) { | ||
| 99 | func LoadRequests(g *git.Repo, ref string) ([]*Request, error) { | ||
| 109 | 100 | commit, err := g.RevParse(ref) | |
| 110 | 101 | if err != nil { | |
| 111 | 102 | return nil, err | |
| @@ -119,7 +110,7 @@ func Load(g *gitx.Repo, ref string) ([]*Request, error) { | |||
| 119 | 110 | } | |
| 120 | 111 | contents := map[string]string{} | |
| 121 | 112 | for _, e := range blobs { | |
| 122 | if !strings.HasPrefix(e.Name, Dir+"/") { | ||
| 113 | if !strings.HasPrefix(e.Name, RequestsDir+"/") { | ||
| 123 | 114 | continue | |
| 124 | 115 | } | |
| 125 | 116 | data, err := g.BlobBySHA(e.SHA) | |
| @@ -128,11 +119,11 @@ func Load(g *gitx.Repo, ref string) ([]*Request, error) { | |||
| 128 | 119 | } | |
| 129 | 120 | contents[e.Name] = string(data) | |
| 130 | 121 | } | |
| 131 | return Parse(contents) | ||
| 122 | return ParseRequests(contents) | ||
| 132 | 123 | } | |
| 133 | 124 | ||
| 134 | 125 | // Parse turns requests paths into requests, validating as it goes. | |
| 135 | func Parse(contents map[string]string) ([]*Request, error) { | ||
| 126 | func ParseRequests(contents map[string]string) ([]*Request, error) { | ||
| 136 | 127 | var problems []string | |
| 137 | 128 | byID := map[string]*Request{} | |
| 138 | 129 | ||
| @@ -143,10 +134,10 @@ func Parse(contents map[string]string) ([]*Request, error) { | |||
| 143 | 134 | sort.Strings(paths) | |
| 144 | 135 | ||
| 145 | 136 | for _, path := range paths { | |
| 146 | if !strings.HasPrefix(path, Dir+"/") { | ||
| 137 | if !strings.HasPrefix(path, RequestsDir+"/") { | ||
| 147 | 138 | continue | |
| 148 | 139 | } | |
| 149 | m := pathRE.FindStringSubmatch(path) | ||
| 140 | m := requestPathRE.FindStringSubmatch(path) | ||
| 150 | 141 | if m == nil { | |
| 151 | 142 | problems = append(problems, fmt.Sprintf( | |
| 152 | 143 | "%s: not part of the request format (expected requests/<ulid>/request.md, approvals/<account>.md or comments/<ulid>.md)", path)) | |
| @@ -171,7 +162,7 @@ func Parse(contents map[string]string) ([]*Request, error) { | |||
| 171 | 162 | case m[4] != "": | |
| 172 | 163 | // A comment on a request is read by the same code as a comment on an | |
| 173 | 164 | // issue: one format, one reader, one set of refusals. | |
| 174 | c, prob := issues.ParseComment(m[4], contents[path], path) | ||
| 165 | c, prob := ParseComment(m[4], contents[path], path) | ||
| 175 | 166 | if prob != "" { | |
| 176 | 167 | problems = append(problems, prob) | |
| 177 | 168 | continue | |
| @@ -183,14 +174,14 @@ func Parse(contents map[string]string) ([]*Request, error) { | |||
| 183 | 174 | out := make([]*Request, 0, len(byID)) | |
| 184 | 175 | for _, req := range byID { | |
| 185 | 176 | if req.Title == "" { | |
| 186 | problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", Dir, req.ID, requestName)) | ||
| 177 | problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", RequestsDir, req.ID, requestName)) | ||
| 187 | 178 | } | |
| 188 | 179 | // Self-approval is not an approval. Catching it here rather than at the | |
| 189 | 180 | // gate means the author learns immediately instead of after a rejection. | |
| 190 | 181 | for _, a := range req.Approvals { | |
| 191 | 182 | if a.Principal == req.AuthorID { | |
| 192 | 183 | problems = append(problems, fmt.Sprintf( | |
| 193 | "%s/%s/%s/%s.md: the author cannot approve their own request", Dir, req.ID, approvalDir, a.Principal)) | ||
| 184 | "%s/%s/%s/%s.md: the author cannot approve their own request", RequestsDir, req.ID, approvalDir, a.Principal)) | ||
| 194 | 185 | } | |
| 195 | 186 | } | |
| 196 | 187 | sort.Slice(req.Approvals, func(i, j int) bool { return req.Approvals[i].Principal < req.Approvals[j].Principal }) | |
| @@ -206,25 +197,25 @@ func Parse(contents map[string]string) ([]*Request, error) { | |||
| 206 | 197 | } | |
| 207 | 198 | ||
| 208 | 199 | func applyRequest(req *Request, raw, path string, problems *[]string) { | |
| 209 | head, body, err := frontmatter.Split([]byte(raw)) | ||
| 200 | head, body, err := Split([]byte(raw)) | ||
| 210 | 201 | if err != nil { | |
| 211 | 202 | *problems = append(*problems, fmt.Sprintf("%s: %v", path, err)) | |
| 212 | 203 | return | |
| 213 | 204 | } | |
| 214 | var m meta | ||
| 215 | if err := frontmatter.Decode(head, &m); err != nil { | ||
| 205 | var m requestMeta | ||
| 206 | if err := Decode(head, &m); err != nil { | ||
| 216 | 207 | *problems = append(*problems, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err)) | |
| 217 | 208 | return | |
| 218 | 209 | } | |
| 219 | if err := frontmatter.Text(head, "title", m.Title); err != nil { | ||
| 210 | if err := Text(head, "title", m.Title); err != nil { | ||
| 220 | 211 | *problems = append(*problems, fmt.Sprintf("%s: title: %v", path, err)) | |
| 221 | 212 | } | |
| 222 | if err := frontmatter.Text(head, "author", m.Author); err != nil { | ||
| 213 | if err := Text(head, "author", m.Author); err != nil { | ||
| 223 | 214 | *problems = append(*problems, fmt.Sprintf("%s: author: %v", path, err)) | |
| 224 | 215 | } | |
| 225 | 216 | req.Title = strings.TrimSpace(m.Title) | |
| 226 | if len(req.Title) > maxTitleLen { | ||
| 227 | *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(req.Title), maxTitleLen)) | ||
| 217 | if len(req.Title) > maxRequestTitleLen { | ||
| 218 | *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(req.Title), maxRequestTitleLen)) | ||
| 228 | 219 | } | |
| 229 | 220 | req.Author = strings.TrimSpace(m.Author) | |
| 230 | 221 | if req.Author == "" { | |
| @@ -267,7 +258,7 @@ func parseApproval(principal, raw, path string) (Approval, string) { | |||
| 267 | 258 | if !id.Valid(principal) { | |
| 268 | 259 | return a, fmt.Sprintf("%s: the file name must be the approver's account id (a 26-character ULID)", path) | |
| 269 | 260 | } | |
| 270 | head, _, err := frontmatter.Split([]byte(raw)) | ||
| 261 | head, _, err := Split([]byte(raw)) | ||
| 271 | 262 | if err != nil { | |
| 272 | 263 | return a, fmt.Sprintf("%s: %v", path, err) | |
| 273 | 264 | } | |
| @@ -276,7 +267,7 @@ func parseApproval(principal, raw, path string) (Approval, string) { | |||
| 276 | 267 | Head string `yaml:"head"` | |
| 277 | 268 | Created string `yaml:"created"` | |
| 278 | 269 | } | |
| 279 | if err := frontmatter.Decode(head, &m); err != nil { | ||
| 270 | if err := Decode(head, &m); err != nil { | ||
| 280 | 271 | return a, fmt.Sprintf("%s: cannot parse frontmatter: %v", path, err) | |
| 281 | 272 | } | |
| 282 | 273 | if !shaRE.MatchString(m.Head) { | |
| @@ -296,38 +287,29 @@ func parseApproval(principal, raw, path string) (Approval, string) { | |||
| 296 | 287 | } | |
| 297 | 288 | ||
| 298 | 289 | // CommentPath is where a comment on a request lives. | |
| 299 | func CommentPath(requestID, commentID string) string { | ||
| 300 | return fmt.Sprintf("%s/%s/%s/%s.md", Dir, requestID, commentDir, commentID) | ||
| 290 | func RequestCommentPath(requestID, commentID string) string { | ||
| 291 | return fmt.Sprintf("%s/%s/%s/%s.md", RequestsDir, requestID, commentDir, commentID) | ||
| 301 | 292 | } | |
| 302 | 293 | ||
| 303 | 294 | // IsCommentPath reports whether path is a comment on a request rather than its | |
| 304 | 295 | // body or an approval. The hook uses it to apply comment authorship. | |
| 305 | func IsCommentPath(path string) bool { | ||
| 306 | m := pathRE.FindStringSubmatch(path) | ||
| 296 | func IsRequestCommentPath(path string) bool { | ||
| 297 | m := requestPathRE.FindStringSubmatch(path) | ||
| 307 | 298 | return m != nil && m[4] != "" | |
| 308 | 299 | } | |
| 309 | 300 | ||
| 310 | 301 | // ApprovalPath returns the principal an approvals/ path belongs to, or "". | |
| 311 | 302 | func ApprovalPath(path string) string { | |
| 312 | m := pathRE.FindStringSubmatch(path) | ||
| 303 | m := requestPathRE.FindStringSubmatch(path) | ||
| 313 | 304 | if m == nil { | |
| 314 | 305 | return "" | |
| 315 | 306 | } | |
| 316 | 307 | return m[3] | |
| 317 | 308 | } | |
| 318 | 309 | ||
| 319 | type ValidationError struct{ Problems []string } | ||
| 320 | |||
| 321 | func (e *ValidationError) Error() string { | ||
| 322 | if len(e.Problems) == 1 { | ||
| 323 | return e.Problems[0] | ||
| 324 | } | ||
| 325 | return fmt.Sprintf("%d problems: %s", len(e.Problems), strings.Join(e.Problems, "; ")) | ||
| 326 | } | ||
| 327 | |||
| 328 | 310 | // Validate checks the requests subtree at commit. | |
| 329 | func Validate(g *gitx.Repo, commit string) []string { | ||
| 330 | if commit == "" || commit == gitx.ZeroSHA { | ||
| 311 | func ValidateRequests(g *git.Repo, commit string) []string { | ||
| 312 | if commit == "" || commit == git.ZeroSHA { | ||
| 331 | 313 | return nil | |
| 332 | 314 | } | |
| 333 | 315 | blobs, err := g.LsTreeR(commit) | |
| @@ -336,7 +318,7 @@ func Validate(g *gitx.Repo, commit string) []string { | |||
| 336 | 318 | } | |
| 337 | 319 | contents := map[string]string{} | |
| 338 | 320 | for _, e := range blobs { | |
| 339 | if !strings.HasPrefix(e.Name, Dir+"/") { | ||
| 321 | if !strings.HasPrefix(e.Name, RequestsDir+"/") { | ||
| 340 | 322 | continue | |
| 341 | 323 | } | |
| 342 | 324 | data, err := g.BlobBySHA(e.SHA) | |
| @@ -345,7 +327,7 @@ func Validate(g *gitx.Repo, commit string) []string { | |||
| 345 | 327 | } | |
| 346 | 328 | contents[e.Name] = string(data) | |
| 347 | 329 | } | |
| 348 | if _, err := Parse(contents); err != nil { | ||
| 330 | if _, err := ParseRequests(contents); err != nil { | ||
| 349 | 331 | if ve, ok := err.(*ValidationError); ok { | |
| 350 | 332 | return ve.Problems | |
| 351 | 333 | } | |
| @@ -386,7 +368,7 @@ type Merge struct { | |||
| 386 | 368 | // Anything else is rejected. In particular "the approved head is an ancestor of | |
| 387 | 369 | // the new tip" is NOT accepted: it would let anyone append unapproved commits to | |
| 388 | 370 | // an approved one and land them together. | |
| 389 | func FindApprovedMatch(g *gitx.Repo, list []*Request, req ApprovalRequirement) (*Merge, error) { | ||
| 371 | func FindApprovedMatch(g *git.Repo, list []*Request, req ApprovalRequirement) (*Merge, error) { | ||
| 390 | 372 | if req.Required <= 0 { | |
| 391 | 373 | return nil, nil | |
| 392 | 374 | } | |
| @@ -399,7 +381,7 @@ func FindApprovedMatch(g *gitx.Repo, list []*Request, req ApprovalRequirement) ( | |||
| 399 | 381 | switch { | |
| 400 | 382 | case r.Head == req.NewTip: | |
| 401 | 383 | matches = true | |
| 402 | case req.OldTip != "" && req.OldTip != gitx.ZeroSHA: | ||
| 384 | case req.OldTip != "" && req.OldTip != git.ZeroSHA: | ||
| 403 | 385 | parents, err := g.CommitParents(req.NewTip) | |
| 404 | 386 | if err != nil { | |
| 405 | 387 | nearest = err | |