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 }