cuelang.org/go@v0.10.1/internal/ci/checks/commit.go (about) 1 // Copyright 2024 CUE Authors 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package main 16 17 import ( 18 "bytes" 19 "fmt" 20 "log" 21 "os" 22 "os/exec" 23 "regexp" 24 "slices" 25 "strings" 26 27 "github.com/yuin/goldmark" 28 mdast "github.com/yuin/goldmark/ast" 29 mdextension "github.com/yuin/goldmark/extension" 30 mdtext "github.com/yuin/goldmark/text" 31 ) 32 33 func main() { 34 wd, err := os.Getwd() 35 if err != nil { 36 log.Fatal(err) 37 } 38 if err := checkCommit(wd); err != nil { 39 log.Fatal(err) 40 } 41 } 42 43 func checkCommit(dir string) error { 44 body, err := runCmd(dir, "git", "log", "-1", "--format=%B", "HEAD") 45 if err != nil { 46 return err 47 } 48 49 // Ensure that commit messages have a blank second line. 50 // We know that a commit message must be longer than a single 51 // line because each commit must be signed-off. 52 lines := strings.Split(body, "\n") 53 if len(lines) > 1 && lines[1] != "" { 54 return fmt.Errorf("The second line of a commit message must be blank") 55 } 56 57 // All authors, including co-authors, must have a signed-off trailer by email. 58 // Note that trailers are in the form "Name <email>", so grab the email with regexp. 59 // For now, we require the sorted lists of author and signer emails to match. 60 // Note that this also fails if a commit isn't signed-off at all. 61 // 62 // In Gerrit we already enable a form of this via https://gerrit-review.googlesource.com/Documentation/project-configuration.html#require-signed-off-by, 63 // but it does not support co-authors nor can it be used when testing GitHub PRs. 64 authorEmail, err := runCmd(dir, "git", "log", "-1", "--format=%ae") 65 if err != nil { 66 return err 67 } 68 coauthorList, err := runCmd(dir, "git", "log", "-1", "--format=%(trailers:key=Co-authored-by,valueonly)") 69 if err != nil { 70 return err 71 } 72 authors := slices.Concat([]string{authorEmail}, extractEmails(coauthorList)) 73 slices.Sort(authors) 74 authors = slices.Compact(authors) 75 76 signerList, err := runCmd(dir, "git", "log", "-1", "--format=%(trailers:key=Signed-off-by,valueonly)") 77 if err != nil { 78 return err 79 } 80 signers := extractEmails(signerList) 81 slices.Sort(signers) 82 signers = slices.Compact(signers) 83 84 if !slices.Equal(authors, signers) { 85 return fmt.Errorf("commit author email addresses %q do not match signed-off-by trailers %q", 86 authors, signers) 87 } 88 89 // Forbid @-mentioning any GitHub usernames in commit messages, 90 // as that will lead to notifications which are likely unintended. 91 // If one must include a similar-looking snippet, like @embed(), 92 // they can use markdown backticks or blockquotes to sidestep the issue. 93 // 94 // Note that we parse the body as markdown including git trailers, but that's okay. 95 // Note that GitHub does not interpret mentions in titles, but we still check them 96 // for the sake of being conservative and consistent. 97 md := goldmark.New( 98 goldmark.WithExtensions(mdextension.GFM), 99 ) 100 docBody := []byte(body) 101 doc := md.Parser().Parse(mdtext.NewReader(docBody)) 102 if err := mdast.Walk(doc, func(node mdast.Node, entering bool) (mdast.WalkStatus, error) { 103 if !entering { 104 return mdast.WalkContinue, nil 105 } 106 // Uncomment for some quick debugging. 107 // fmt.Printf("%T\n%q\n\n", node, node.Text(docBody)) 108 switch node.(type) { 109 case *mdast.CodeSpan: 110 return mdast.WalkSkipChildren, nil 111 case *mdast.Text: 112 text := node.Text(docBody) 113 if m := rxUserMention.FindSubmatch(text); m != nil { 114 return mdast.WalkStop, fmt.Errorf("commit mentions %q; use backquotes or block quoting for code", m[2]) 115 } 116 } 117 return mdast.WalkContinue, nil 118 }); err != nil { 119 return err 120 } 121 return nil 122 } 123 124 func runCmd(dir string, exe string, args ...string) (string, error) { 125 cmd := exec.Command(exe, args...) 126 cmd.Dir = dir 127 out, err := cmd.CombinedOutput() 128 return string(bytes.TrimSpace(out)), err 129 } 130 131 var ( 132 rxExtractEmail = regexp.MustCompile(`.*<(.*)\>$`) 133 rxUserMention = regexp.MustCompile(`(^|\s)(@[a-z0-9][a-z0-9-]*)`) 134 ) 135 136 func extractEmails(list string) []string { 137 lines := strings.Split(list, "\n") 138 var emails []string 139 for _, line := range lines { 140 m := rxExtractEmail.FindStringSubmatch(line) 141 if m == nil { 142 continue // no match; discard this line 143 } 144 emails = append(emails, m[1]) 145 } 146 return emails 147 }