go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/git/footer/footer.go (about)

     1  // Copyright 2020 The LUCI 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 footer
    16  
    17  import (
    18  	"regexp"
    19  	"strings"
    20  
    21  	"go.chromium.org/luci/common/data/stringset"
    22  	"go.chromium.org/luci/common/data/strpair"
    23  )
    24  
    25  var (
    26  	// Regexp pattern for git commit message footer.
    27  	footerPattern = regexp.MustCompile(`^\s*([\w-]+): *(.*)$`)
    28  	// Strings that won't be treated as footer keys.
    29  	footerKeyIgnorelist = stringset.NewFromSlice("Http", "Https")
    30  )
    31  
    32  // NormalizeKey normalizes a git footer key string.
    33  // It removes leading and trailing spaces and converts each segment (separated
    34  // by `-`) to title case.
    35  func NormalizeKey(footerKey string) string {
    36  	segs := strings.Split(strings.TrimSpace(footerKey), "-")
    37  	for i, seg := range segs {
    38  		segs[i] = strings.Title(strings.ToLower(seg))
    39  	}
    40  	return strings.Join(segs, "-")
    41  }
    42  
    43  // ParseLine tries to extract a git footer from a commit message line.
    44  // Returns a normalized key and value (with surrounding space trimmed) if
    45  // the line represents a valid footer. Returns empty strings otherwise.
    46  func ParseLine(line string) (string, string) {
    47  	res := footerPattern.FindStringSubmatch(line)
    48  	if len(res) == 3 {
    49  		if key := NormalizeKey(res[1]); !footerKeyIgnorelist.Has(key) {
    50  			return key, strings.TrimSpace(res[2])
    51  		}
    52  	}
    53  	return "", ""
    54  }
    55  
    56  // ParseMessage extracts all footers from the footer lines of given message.
    57  // A shorthand for `SplitLines` + `ParseLines`.
    58  func ParseMessage(message string) strpair.Map {
    59  	_, footerLines := SplitLines(message)
    60  	return ParseLines(footerLines)
    61  }
    62  
    63  // ParseLines extracts all footers from the given lines.
    64  // Returns a multimap as a footer key may map to multiple values. The
    65  // footer in a latter line takes precedence and shows up at the front of the
    66  // value slice. Ideally, this function should be called with the `footerLines`
    67  // part of the return values of `SplitLines`.
    68  func ParseLines(lines []string) strpair.Map {
    69  	ret := strpair.Map{}
    70  	for i := len(lines) - 1; i >= 0; i-- {
    71  		if k, v := ParseLine(lines[i]); k != "" {
    72  			ret.Add(k, v)
    73  		}
    74  	}
    75  	return ret
    76  }
    77  
    78  // SplitLines splits a commit message to non-footer and footer lines.
    79  //
    80  // Footer lines are all lines in the last paragraph of the message if it:
    81  //   - contains at least one valid footer (it may contains lines that are not
    82  //     valid footers in the middle).
    83  //   - is not the only paragraph in the message.
    84  //
    85  // One exception is that if the last paragraph starts with text then followed
    86  // by valid footers, footer lines will only contain all lines after the first
    87  // valid footer, all the lines above will be included in non-footer lines and
    88  // a new line will be appended to separate them from footer lines.
    89  //
    90  // The leading and trailing whitespaces (including new lines) of the given
    91  // message will be trimmed before splitting.
    92  func SplitLines(message string) (nonFooterLines, footerLines []string) {
    93  	lines := strings.Split(strings.TrimSpace(message), "\n")
    94  	var maybeFooterLines []string
    95  	for i := len(lines) - 1; i >= 0; i-- {
    96  		line := lines[i]
    97  		if strings.TrimSpace(line) == "" {
    98  			break
    99  		}
   100  		if k, _ := ParseLine(line); k != "" {
   101  			footerLines = append(footerLines, maybeFooterLines...)
   102  			maybeFooterLines = maybeFooterLines[0:0]
   103  			footerLines = append(footerLines, line)
   104  		} else {
   105  			maybeFooterLines = append(maybeFooterLines, line)
   106  		}
   107  	}
   108  	if len(footerLines)+len(maybeFooterLines) == len(lines) {
   109  		// The entire message is consists of footers which means those lines
   110  		// are not footers.
   111  		return lines, nil
   112  	}
   113  
   114  	nonFooterLines = lines[:len(lines)-len(footerLines)]
   115  	reverse(footerLines)
   116  	if len(maybeFooterLines) > 0 {
   117  		// If there're some malformed lines leftover, add a new line to separate
   118  		// them from valid footer lines.
   119  		// This mutates `lines` slice but it's okay.
   120  		nonFooterLines = append(nonFooterLines, "")
   121  	}
   122  	return
   123  }
   124  
   125  // reverse reverses a slice of strings in place.
   126  func reverse(s []string) {
   127  	for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
   128  		s[i], s[j] = s[j], s[i]
   129  	}
   130  }