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/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