golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gerritbot/internal/rules/run.go (about) 1 // Copyright 2023 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 package rules 6 7 import ( 8 "bufio" 9 "fmt" 10 "regexp" 11 "strings" 12 13 "golang.org/x/exp/slices" 14 ) 15 16 // Change represents a Gerrit CL and/or GitHub PR that we want to check rules against. 17 type Change struct { 18 // Repo is the repository as reported by Gerrit (e.g., "go", "tools", "vscode-go", "website"). 19 Repo string 20 // Title is the commit message first line. 21 Title string 22 // Body is the commit message body (skipping the title on the first line and the blank second line, 23 // and without the footers). 24 Body string 25 26 // TODO: could consider a Footer field, if useful, though I think GerritBot & Gerrit manage those. 27 // TODO: could be useful in future to have CL and PR fields as well, e.g., if we wanted to 28 // spot check for something that looks like test files in the changed file list. 29 } 30 31 func ParseCommitMessage(repo string, text string) (Change, error) { 32 change := Change{Repo: repo} 33 lines := splitLines(text) 34 if len(lines) < 3 { 35 return Change{}, fmt.Errorf("rules: ParseCommitMessage: short commit message: %q", text) 36 } 37 change.Title = lines[0] 38 if lines[1] != "" { 39 return Change{}, fmt.Errorf("rules: ParseCommitMessage: second line is not blank in commit message: %q", text) 40 } 41 42 // Find the body. 43 // Trim the footers, starting at bottom and stopping at first blank line seen after any footers. 44 // It is an error to not have any footers (which likely means we are seeing something not managed 45 // by GerritBot and/or Gerrit). 46 body := lines[2:] 47 sawFooter := false 48 for i := len(body) - 1; i >= 0; i-- { 49 if match(`^[a-zA-Z][^ ]*: `, body[i]) { 50 body = body[:i] 51 sawFooter = true 52 continue 53 } 54 if match(`^\(cherry picked from commit [a-f0-9]+\)$`, body[i]) { 55 // One CL in our corpus (CL 346093) has this intermixed with the footers. 56 continue 57 } 58 if body[i] == "" { 59 if !sawFooter { 60 continue // We leniently skip any blank lines at bottom of commit message. 61 } 62 body = body[:i] 63 break 64 } 65 return Change{}, fmt.Errorf("rules: ParseCommitMessage: found non-footer line at end of commit message. line: %q, commit message: %q", body[i], text) 66 } 67 if !sawFooter { 68 return Change{}, fmt.Errorf("rules: ParseCommitMessage: did not find any footers preceded by blank line for commit message: %q", text) 69 } 70 change.Body = strings.Join(body, "\n") 71 72 return change, nil 73 } 74 75 // Result contains the result of a single rule check against a Change. 76 type Result struct { 77 Name string 78 Finding string 79 Note string 80 } 81 82 // Check runs the defined rules against one Change. 83 func Check(change Change) (results []Result) { 84 for _, group := range ruleGroups { 85 for _, rule := range group { 86 if slices.Contains(rule.skip, change.Repo) || len(rule.only) > 0 && !slices.Contains(rule.only, change.Repo) { 87 continue 88 } 89 finding, advice := rule.f(change) 90 if finding != "" { 91 results = append(results, Result{ 92 Name: rule.name, 93 Finding: finding, 94 Note: advice, 95 }) 96 break // Only report the first finding per rule group. 97 } 98 } 99 } 100 return results 101 } 102 103 // FormatResults returns a string ready to be placed in a CL comment, 104 // formatted as simple markdown. 105 func FormatResults(results []Result) string { 106 if len(results) == 0 { 107 return "" 108 } 109 var b strings.Builder 110 b.WriteString("Possible problems detected:\n") 111 cnt := 1 112 for _, r := range results { 113 fmt.Fprintf(&b, " %d. %s\n", cnt, r.Finding) 114 cnt++ 115 } 116 advice := formatAdvice(results) 117 if advice != "" { 118 b.WriteString("\n" + advice + "\n") 119 } 120 return b.String() 121 } 122 123 // formatAdvice returns a deduplicated string containing all the advice in results. 124 func formatAdvice(results []Result) string { 125 var s []string 126 seen := make(map[string]bool) 127 for _, r := range results { 128 if !seen[r.Note] { 129 s = append(s, r.Note) 130 } 131 seen[r.Note] = true 132 } 133 return strings.Join(s, " ") 134 } 135 136 // match reports whether the regexp pattern matches s, 137 // returning false for a bad regexp after logging the bad regexp. 138 func match(pattern string, s string) bool { 139 re := regexp.MustCompile(pattern) 140 return re.MatchString(s) 141 } 142 143 // matchAny reports whether the regexp pattern matches any string in list, 144 // returning false for a bad regexp after logging the bad regexp. 145 func matchAny(pattern string, list []string) bool { 146 re := regexp.MustCompile(pattern) 147 for _, s := range list { 148 if re.MatchString(s) { 149 return true 150 } 151 } 152 return false 153 } 154 155 // matchCount reports the count of matches for the regexp in s, 156 // returning 0 for a bad regexp after logging the bad regexp. 157 func matchCount(pattern string, s string) int { 158 re := regexp.MustCompile(pattern) 159 return len(re.FindAllString(s, -1)) 160 } 161 162 // splitLines returns s split into lines, without trailing \n. 163 func splitLines(s string) []string { 164 var lines []string 165 scanner := bufio.NewScanner(strings.NewReader(s)) 166 for scanner.Scan() { 167 lines = append(lines, scanner.Text()) 168 } 169 return lines 170 }