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  }