blob: 48a4465eb051eb6b6b89dd893d6c90137e41c1f4 [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package footer
import (
"regexp"
"strings"
"go.chromium.org/luci/common/data/stringset"
"go.chromium.org/luci/common/data/strpair"
)
var (
// Regexp pattern for git commit message footer.
footerPattern = regexp.MustCompile(`^\s*([\w-]+): *(.*)$`)
// Strings that won't be treated as footer keys.
footerKeyIgnorelist = stringset.NewFromSlice("Http", "Https")
)
// NormalizeKey normalizes a git footer key string.
// It removes leading and trailing spaces and converts each segment (separated
// by `-`) to title case.
func NormalizeKey(footerKey string) string {
segs := strings.Split(strings.TrimSpace(footerKey), "-")
for i, seg := range segs {
segs[i] = strings.Title(strings.ToLower(seg))
}
return strings.Join(segs, "-")
}
// ParseLine tries to extract a git footer from a commit message line.
// Returns a normalized key and value (with surrounding space trimmed) if
// the line represents a valid footer. Returns empty strings otherwise.
func ParseLine(line string) (string, string) {
res := footerPattern.FindStringSubmatch(line)
if len(res) == 3 {
if key := NormalizeKey(res[1]); !footerKeyIgnorelist.Has(key) {
return key, strings.TrimSpace(res[2])
}
}
return "", ""
}
// ParseMessage extracts all footers from the footer lines of given message.
// A shorthand for `SplitLines` + `ParseLines`.
func ParseMessage(message string) strpair.Map {
_, footerLines := SplitLines(message)
return ParseLines(footerLines)
}
// ParseLines extracts all footers from the given lines.
// Returns a multimap as a footer key may map to multiple values. The
// footer in a latter line takes precedence and shows up at the front of the
// value slice. Ideally, this function should be called with the `footerLines`
// part of the return values of `SplitLines`.
func ParseLines(lines []string) strpair.Map {
ret := strpair.Map{}
for i := len(lines) - 1; i >= 0; i-- {
if k, v := ParseLine(lines[i]); k != "" {
ret.Add(k, v)
}
}
return ret
}
// SplitLines splits a commit message to non-footer and footer lines.
//
// Footer lines are all lines in the last paragraph of the message if it:
// - contains at least one valid footer (it may contains lines that are not
// valid footers in the middle).
// - is not the only paragraph in the message.
//
// One exception is that if the last paragraph starts with text then followed
// by valid footers, footer lines will only contain all lines after the first
// valid footer, all the lines above will be included in non-footer lines and
// a new line will be appended to separate them from footer lines.
//
// The leading and trailing whitespaces (including new lines) of the given
// message will be trimmed before splitting.
func SplitLines(message string) (nonFooterLines, footerLines []string) {
lines := strings.Split(strings.TrimSpace(message), "\n")
var maybeFooterLines []string
for i := len(lines) - 1; i >= 0; i-- {
line := lines[i]
if strings.TrimSpace(line) == "" {
break
}
if k, _ := ParseLine(line); k != "" {
footerLines = append(footerLines, maybeFooterLines...)
maybeFooterLines = maybeFooterLines[0:0]
footerLines = append(footerLines, line)
} else {
maybeFooterLines = append(maybeFooterLines, line)
}
}
if len(footerLines)+len(maybeFooterLines) == len(lines) {
// The entire message is consists of footers which means those lines
// are not footers.
return lines, nil
}
nonFooterLines = lines[:len(lines)-len(footerLines)]
reverse(footerLines)
if len(maybeFooterLines) > 0 {
// If there're some malformed lines leftover, add a new line to separate
// them from valid footer lines.
// This mutates `lines` slice but it's okay.
nonFooterLines = append(nonFooterLines, "")
}
return
}
// reverse reverses a slice of strings in place.
func reverse(s []string) {
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i]
}
}