blob: 456e8504d26dc5c141199a0ae07b88a148dea586 [file] [log] [blame]
// Copyright 2017 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Logic to interact with a Gerrit server. Gerrit has an entire Git-based
// protocol for fetching metadata about CL's, reviewers, patch comments, which
// is used here - we don't use the x/build/gerrit client, which hits the API.
// TODO: write about Gerrit's Git API.
package maintner
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"log"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/build/internal/envutil"
"golang.org/x/build/maintner/maintpb"
)
// Gerrit holds information about a number of Gerrit projects.
type Gerrit struct {
c *Corpus
projects map[string]*GerritProject // keyed by "go.googlesource.com/build"
clsReferencingGithubIssue map[GitHubIssueRef][]*GerritCL
}
func normalizeGerritServer(server string) string {
u, err := url.Parse(server)
if err == nil && u.Host != "" {
server = u.Host
}
if strings.HasSuffix(server, "-review.googlesource.com") {
// special case: the review site is hosted at a different URL than the
// Git checkout URL.
return strings.Replace(server, "-review.googlesource.com", ".googlesource.com", 1)
}
return server
}
// Project returns the specified Gerrit project if it's known, otherwise
// it returns nil. Server is the Gerrit server's hostname, such as
// "go.googlesource.com".
func (g *Gerrit) Project(server, project string) *GerritProject {
server = normalizeGerritServer(server)
return g.projects[server+"/"+project]
}
// c.mu must be held
func (g *Gerrit) getOrCreateProject(gerritProj string) *GerritProject {
proj, ok := g.projects[gerritProj]
if ok {
return proj
}
proj = &GerritProject{
gerrit: g,
proj: gerritProj,
cls: map[int32]*GerritCL{},
remote: map[gerritCLVersion]GitHash{},
ref: map[string]GitHash{},
commit: map[GitHash]*GitCommit{},
need: map[GitHash]bool{},
}
g.projects[gerritProj] = proj
return proj
}
// ForeachProjectUnsorted calls fn for each known Gerrit project.
// Iteration ends if fn returns a non-nil value.
func (g *Gerrit) ForeachProjectUnsorted(fn func(*GerritProject) error) error {
for _, p := range g.projects {
if err := fn(p); err != nil {
return err
}
}
return nil
}
// GerritProject represents a single Gerrit project.
type GerritProject struct {
gerrit *Gerrit
proj string // "go.googlesource.com/net"
cls map[int32]*GerritCL
remote map[gerritCLVersion]GitHash
need map[GitHash]bool
commit map[GitHash]*GitCommit
numLabelChanges int // incremented (too many times) by meta commits with "Label:" updates
dirtyCL map[*GerritCL]struct{}
// ref are the non-change refs with keys like "HEAD",
// "refs/heads/master", "refs/tags/v0.8.0", etc.
//
// Notably, this excludes the "refs/changes/*" refs matched by
// rxChangeRef. Those are in the remote map.
ref map[string]GitHash
}
// Ref returns a non-change ref, such as "HEAD", "refs/heads/master",
// or "refs/tags/v0.8.0",
// Change refs of the form "refs/changes/*" are not supported.
// The returned hash is the zero value (an empty string) if the ref
// does not exist.
func (gp *GerritProject) Ref(ref string) GitHash {
return gp.ref[ref]
}
func (gp *GerritProject) gitDir() string {
return filepath.Join(gp.gerrit.c.getDataDir(), url.PathEscape(gp.proj))
}
// NumLabelChanges is an inaccurate count the number of times vote labels have
// changed in this project. This number is monotonically increasing.
// This is not guaranteed to be accurate; it definitely overcounts, but it
// at least increments when changes are made.
// It will not undercount.
func (gp *GerritProject) NumLabelChanges() int {
// TODO: rename this method.
return gp.numLabelChanges
}
// ServerSlashProject returns the server and project together, such as
// "go.googlesource.com/build".
func (gp *GerritProject) ServerSlashProject() string { return gp.proj }
// Server returns the Gerrit server, such as "go.googlesource.com".
func (gp *GerritProject) Server() string {
if i := strings.IndexByte(gp.proj, '/'); i != -1 {
return gp.proj[:i]
}
return ""
}
// Project returns the Gerrit project on the server, such as "go" or "crypto".
func (gp *GerritProject) Project() string {
if i := strings.IndexByte(gp.proj, '/'); i != -1 {
return gp.proj[i+1:]
}
return ""
}
// ForeachNonChangeRef calls fn for each git ref on the server that is
// not a change (code review) ref. In general, these correspond to
// submitted changes.
// fn is called serially with sorted ref names.
// Iteration stops with the first non-nil error returned by fn.
func (gp *GerritProject) ForeachNonChangeRef(fn func(ref string, hash GitHash) error) error {
refs := make([]string, 0, len(gp.ref))
for ref := range gp.ref {
refs = append(refs, ref)
}
sort.Strings(refs)
for _, ref := range refs {
if err := fn(ref, gp.ref[ref]); err != nil {
return err
}
}
return nil
}
// ForeachOpenCL calls fn for each open CL in the repo.
//
// If fn returns an error, iteration ends and ForeachOpenCL returns
// with that error.
//
// The fn function is called serially, with increasingly numbered
// CLs.
func (gp *GerritProject) ForeachOpenCL(fn func(*GerritCL) error) error {
var s []*GerritCL
for _, cl := range gp.cls {
if !cl.complete() || cl.Status != "new" || cl.Private {
continue
}
s = append(s, cl)
}
sort.Slice(s, func(i, j int) bool { return s[i].Number < s[j].Number })
for _, cl := range s {
if err := fn(cl); err != nil {
return err
}
}
return nil
}
// ForeachCLUnsorted calls fn for each CL in the repo, in any order.
//
// If fn returns an error, iteration ends and ForeachCLUnsorted returns with
// that error.
func (gp *GerritProject) ForeachCLUnsorted(fn func(*GerritCL) error) error {
for _, cl := range gp.cls {
if !cl.complete() {
continue
}
if err := fn(cl); err != nil {
return err
}
}
return nil
}
// CL returns the GerritCL with the given number, or nil if it is not present.
//
// CL numbers are shared across all projects on a Gerrit server, so you can get
// nil unless you have the GerritProject containing that CL.
func (gp *GerritProject) CL(number int32) *GerritCL {
if cl := gp.cls[number]; cl != nil && cl.complete() {
return cl
}
return nil
}
// GitCommit returns the provided git commit.
func (gp *GerritProject) GitCommit(hash string) (*GitCommit, error) {
if len(hash) != 40 {
// TODO: support prefix lookups. build a trie. But
// for now just avoid panicking in gitHashFromHexStr.
return nil, fmt.Errorf("git hash %q is not 40 characters", hash)
}
var buf [20]byte
_, err := decodeHexStr(buf[:], hash)
if err != nil {
return nil, fmt.Errorf("git hash %q is not a valid hex string: %w", hash, err)
}
c := gp.commit[GitHash(buf[:])]
if c == nil {
// TODO: return an error that the caller can unpack with errors.Is or
// errors.As to distinguish this case.
return nil, fmt.Errorf("git commit %s not found in project", hash)
}
return c, nil
}
func (gp *GerritProject) logf(format string, args ...interface{}) {
log.Printf("gerrit "+gp.proj+": "+format, args...)
}
// gerritCLVersion is a value type used as a map key to store a CL
// number and a patchset version. Its Version field is overloaded
// to reference the "meta" metadata commit if the Version is 0.
type gerritCLVersion struct {
CLNumber int32
Version int32 // version 0 is used for the "meta" ref.
}
// A GerritCL represents a single change in Gerrit.
type GerritCL struct {
// Project is the project this CL is part of.
Project *GerritProject
// Number is the CL number on the Gerrit server (e.g. 1, 2, 3). Gerrit CL
// numbers are sparse (CL N does not guarantee that CL N-1 exists) and
// Gerrit issues CL's out of order - it may issue CL N, then CL (N - 18),
// then CL (N - 40).
Number int32
// Created is the CL creation time.
Created time.Time
// Version is the number of versions of the patchset for this
// CL seen so far. It starts at 1.
Version int32
// Commit is the git commit of the latest version of this CL.
// Previous versions are available via CommitAtVersion.
// Commit is always non-nil.
Commit *GitCommit
// branch is a cache of the latest "Branch: " value seen from
// MetaCommits' commit message values, stripped of any
// "refs/heads/" prefix. It's usually "master".
branch string
// Meta is the head of the most recent Gerrit "meta" commit
// for this CL. This is guaranteed to be a linear history
// back to a CL-specific root commit for this meta branch.
// Meta will always be non-nil.
Meta *GerritMeta
// Metas contains the history of Meta commits, from the oldest (root)
// to the most recent. The last item in the slice is the same
// value as the GerritCL.Meta field.
// The Metas slice will always contain at least 1 element.
Metas []*GerritMeta
// Status will be "merged", "abandoned", "new", or "draft".
Status string
// Private indicates whether this is a private CL.
// Empirically, it seems that one meta commit of private CLs is
// sometimes visible to everybody, even when the rest of the details
// and later meta commits are not. In general, if you see this
// being set to true, treat this CL as if it doesn't exist.
Private bool
// GitHubIssueRefs are parsed references to GitHub issues.
// Multiple references to the same issue are deduplicated.
GitHubIssueRefs []GitHubIssueRef
// Messages contains all of the messages for this CL, in sorted order.
Messages []*GerritMessage
}
// complete reports whether cl is complete.
// A CL is considered complete if its Meta and Commit fields are non-nil,
// and the Metas slice contains at least 1 element.
func (cl *GerritCL) complete() bool {
return cl.Meta != nil &&
len(cl.Metas) >= 1 &&
cl.Commit != nil
}
// GerritMessage is a Gerrit reply that is attached to the CL as a whole, and
// not to a file or line of a patch set.
//
// Maintner does very little parsing or formatting of a Message body. Messages
// are stored the same way they are stored in the API.
type GerritMessage struct {
// Meta is the commit containing the message.
Meta *GitCommit
// Version is the patch set version this message was sent on.
Version int32
// Message is the raw message contents from Gerrit (a subset
// of the raw git commit message), starting with "Patch Set
// nnnn".
Message string
// Date is when this message was stored (the commit time of
// the git commit).
Date time.Time
// Author returns the author of the commit. This takes the form "Gerrit User
// 13437 <13437@62eb7196-b449-3ce5-99f1-c037f21e1705>", where the number
// before the '@' sign is your Gerrit user ID, and the UUID after the '@' sign
// seems to be the same for all commits for the same Gerrit server, across
// projects.
//
// TODO: Merge the *GitPerson object here and for a person's Git commits
// (which use their real email) via the user ID, so they point to the same
// object.
Author *GitPerson
}
// References reports whether cl includes a commit message reference
// to the provided Github issue ref.
func (cl *GerritCL) References(ref GitHubIssueRef) bool {
for _, eref := range cl.GitHubIssueRefs {
if eref == ref {
return true
}
}
return false
}
// Branch returns the CL's branch, with any "refs/heads/" prefix removed.
func (cl *GerritCL) Branch() string { return cl.branch }
func (cl *GerritCL) updateBranch() {
for i := len(cl.Metas) - 1; i >= 0; i-- {
mc := cl.Metas[i]
branch := lineValue(mc.Commit.Msg, "Branch:")
if branch != "" {
cl.branch = strings.TrimPrefix(branch, "refs/heads/")
return
}
}
}
// lineValueOK extracts a value from an RFC 822-style "key: value" series of lines.
// If all is,
//
// foo: bar
// bar: baz
//
// lineValue(all, "foo:") returns "bar". It trims any whitespace.
// The prefix is case sensitive and must include the colon.
// The ok value reports whether a line with such a prefix is found, even if its
// value is empty. If ok is true, the rest value contains the subsequent lines.
func lineValueOK(all, prefix string) (value, rest string, ok bool) {
orig := all
consumed := 0
for {
i := strings.Index(all, prefix)
if i == -1 {
return "", "", false
}
if i > 0 && all[i-1] != '\n' && all[i-1] != '\r' {
all = all[i+len(prefix):]
consumed += i + len(prefix)
continue
}
val := all[i+len(prefix):]
consumed += i + len(prefix)
if nl := strings.IndexByte(val, '\n'); nl != -1 {
consumed += nl + 1
val = val[:nl+1]
} else {
consumed = len(orig)
}
return strings.TrimSpace(val), orig[consumed:], true
}
}
func lineValue(all, prefix string) string {
value, _, _ := lineValueOK(all, prefix)
return value
}
func lineValueRest(all, prefix string) (value, rest string) {
value, rest, _ = lineValueOK(all, prefix)
return
}
// WorkInProgress reports whether the CL has its Work-in-progress bit set, per
// https://gerrit-review.googlesource.com/Documentation/intro-user.html#wip
func (cl *GerritCL) WorkInProgress() bool {
var wip bool
for _, m := range cl.Metas {
switch lineValue(m.Commit.Msg, "Work-in-progress:") {
case "true":
wip = true
case "false":
wip = false
}
}
return wip
}
// ChangeID returns the Gerrit "Change-Id: Ixxxx" line's Ixxxx
// value from the cl.Msg, if any.
func (cl *GerritCL) ChangeID() string {
id := cl.Footer("Change-Id:")
if strings.HasPrefix(id, "I") && len(id) == 41 {
return id
}
return ""
}
// Footer returns the value of a line of the form <key>: value from
// the CL’s commit message. The key is case-sensitive and must end in
// a colon.
// An empty string is returned if there is no value for key.
func (cl *GerritCL) Footer(key string) string {
if len(key) == 0 || key[len(key)-1] != ':' {
panic("Footer key does not end in colon")
}
// TODO: git footers are treated as multimaps. Account for this.
return lineValue(cl.Commit.Msg, key)
}
// OwnerID returns the ID of the CL’s owner. It will return -1 on error.
func (cl *GerritCL) OwnerID() int {
if !cl.complete() {
return -1
}
// Meta commits caused by the owner of a change have an email of the form
// <user id>@<uuid of gerrit server>.
email := cl.Metas[0].Commit.Author.Email()
idx := strings.Index(email, "@")
if idx == -1 {
return -1
}
id, err := strconv.Atoi(email[:idx])
if err != nil {
return -1
}
return id
}
// Owner returns the author of the first commit to the CL. It returns nil on error.
func (cl *GerritCL) Owner() *GitPerson {
// The owner of a change is a numeric ID that can have more than one email
// associated with it, but the email associated with the very first upload is
// designated as the owner of the change by Gerrit.
hash, ok := cl.Project.remote[gerritCLVersion{CLNumber: cl.Number, Version: 1}]
if !ok {
return nil
}
commit, ok := cl.Project.commit[hash]
if !ok {
return nil
}
return commit.Author
}
// Subject returns the subject of the latest commit message.
// The subject is separated from the body by a blank line.
func (cl *GerritCL) Subject() string {
if i := strings.Index(cl.Commit.Msg, "\n\n"); i >= 0 {
return strings.Replace(cl.Commit.Msg[:i], "\n", " ", -1)
}
return strings.Replace(cl.Commit.Msg, "\n", " ", -1)
}
// CommitAtVersion returns the git commit of the specifid version of this CL.
// It returns nil if version is not in the range [1, cl.Version].
func (cl *GerritCL) CommitAtVersion(version int32) *GitCommit {
if version < 1 || version > cl.Version {
return nil
}
hash, ok := cl.Project.remote[gerritCLVersion{CLNumber: cl.Number, Version: version}]
if !ok {
return nil
}
return cl.Project.commit[hash]
}
func (cl *GerritCL) updateGithubIssueRefs() {
gp := cl.Project
gerrit := gp.gerrit
gc := cl.Commit
oldRefs := cl.GitHubIssueRefs
newRefs := gerrit.c.parseGithubRefs(gp.proj, gc.Msg)
cl.GitHubIssueRefs = newRefs
for _, ref := range newRefs {
if !clSliceContains(gerrit.clsReferencingGithubIssue[ref], cl) {
// TODO: make this as small as
// possible? Most will have length
// 1. Care about default capacity of
// 2?
gerrit.clsReferencingGithubIssue[ref] = append(gerrit.clsReferencingGithubIssue[ref], cl)
}
}
for _, ref := range oldRefs {
if !cl.References(ref) {
// TODO: remove ref from gerrit.clsReferencingGithubIssue
// It could be a map of maps I suppose, but not as compact.
// So uses a slice as the second layer, since there will normally
// be one item.
}
}
}
// c.mu must be held
func (c *Corpus) initGerrit() {
if c.gerrit != nil {
return
}
c.gerrit = &Gerrit{
c: c,
projects: map[string]*GerritProject{},
clsReferencingGithubIssue: map[GitHubIssueRef][]*GerritCL{},
}
}
type watchedGerritRepo struct {
project *GerritProject
}
// TrackGerrit registers the Gerrit project with the given project as a project
// to watch and append to the mutation log. Only valid in leader mode.
// The provided string should be of the form "hostname/project", without a scheme
// or trailing slash.
func (c *Corpus) TrackGerrit(gerritProj string) {
if c.mutationLogger == nil {
panic("can't TrackGerrit in non-leader mode")
}
c.mu.Lock()
defer c.mu.Unlock()
if strings.Count(gerritProj, "/") != 1 {
panic(fmt.Sprintf("gerrit project argument %q expected to contain exactly 1 slash", gerritProj))
}
c.initGerrit()
if _, dup := c.gerrit.projects[gerritProj]; dup {
panic("duplicated watched gerrit project " + gerritProj)
}
project := c.gerrit.getOrCreateProject(gerritProj)
if project == nil {
panic("gerrit project not created")
}
c.watchedGerritRepos = append(c.watchedGerritRepos, watchedGerritRepo{
project: project,
})
}
// called with c.mu Locked
func (c *Corpus) processGerritMutation(gm *maintpb.GerritMutation) {
if c.gerrit == nil {
// TODO: option to ignore mutation if user isn't interested.
c.initGerrit()
}
gp, ok := c.gerrit.projects[gm.Project]
if !ok {
// TODO: option to ignore mutation if user isn't interested.
// For now, always process the record.
gp = c.gerrit.getOrCreateProject(gm.Project)
}
gp.processMutation(gm)
}
var statusIndicator = "\nStatus: "
// The Go Gerrit site does not really use the "draft" status much, but if
// you need to test it, create a dummy commit and then run
//
// git push origin HEAD:refs/drafts/master
var statuses = []string{"merged", "abandoned", "draft", "new"}
// getGerritStatus returns a Gerrit status for a commit, or the empty string to
// indicate the commit did not show a status.
//
// getGerritStatus relies on the Gerrit code review convention of amending
// the meta commit to include the current status of the CL. The Gerrit search
// bar allows you to search for changes with the following statuses: "open",
// "reviewed", "closed", "abandoned", "merged", "draft", "pending". The REST API
// returns only "NEW", "DRAFT", "ABANDONED", "MERGED". Gerrit attaches "draft",
// "abandoned", "new", and "merged" statuses to some meta commits; you may have
// to search the current meta commit's parents to find the last good commit.
func getGerritStatus(commit *GitCommit) string {
idx := strings.Index(commit.Msg, statusIndicator)
if idx == -1 {
return ""
}
off := idx + len(statusIndicator)
for _, status := range statuses {
if strings.HasPrefix(commit.Msg[off:], status) {
return status
}
}
return ""
}
var errTooManyParents = errors.New("maintner: too many commit parents")
// foreachCommit walks an entire linear git history, starting at commit itself,
// and iterating over all of its parents. commit must be non-nil.
// f is called for each commit until an error is returned from f, or a commit has no parent.
//
// foreachCommit returns errTooManyParents (and stops processing) if a commit
// has more than one parent.
// An error is returned if a commit has a parent that cannot be found.
//
// Corpus.mu must be held.
func (gp *GerritProject) foreachCommit(commit *GitCommit, f func(*GitCommit) error) error {
c := gp.gerrit.c
for {
if err := f(commit); err != nil {
return err
}
if len(commit.Parents) == 0 {
// No parents, we're at the end of the linear history.
return nil
}
if len(commit.Parents) > 1 {
return errTooManyParents
}
parentHash := commit.Parents[0].Hash // meta tree has no merge commits
commit = c.gitCommit[parentHash]
if commit == nil {
return fmt.Errorf("parent commit %v not found", parentHash)
}
}
}
// getGerritMessage parses a Gerrit comment from the given commit or returns nil
// if there wasn't one.
//
// Corpus.mu must be held.
func (gp *GerritProject) getGerritMessage(commit *GitCommit) *GerritMessage {
const existVerPhrase = "\nPatch Set "
const newVerPhrase = "\nUploaded patch set "
startExist := strings.Index(commit.Msg, existVerPhrase)
startNew := strings.Index(commit.Msg, newVerPhrase)
var start int
var phrase string
switch {
case startExist == -1 && startNew == -1:
return nil
case startExist == -1 || (startNew != -1 && startNew < startExist):
phrase = newVerPhrase
start = startNew
case startNew == -1 || (startExist != -1 && startExist < startNew):
phrase = existVerPhrase
start = startExist
}
numStart := start + len(phrase)
colon := strings.IndexByte(commit.Msg[numStart:], ':')
if colon == -1 {
return nil
}
num := commit.Msg[numStart : numStart+colon]
if strings.Contains(num, "\n") || strings.Contains(num, ".") {
// Spanned lines. Didn't match expected comment form
// we care about (comments with vote changes), like:
//
// Uploaded patch set 5: Some-Vote=+2
//
// For now, treat such meta updates (new uploads only)
// as not comments.
return nil
}
version, err := strconv.ParseInt(num, 10, 32)
if err != nil {
gp.logf("for phrase %q at %d, unexpected patch set number in %s; err: %v, message: %s", phrase, start, commit.Hash, err, commit.Msg)
return nil
}
start++
v := commit.Msg[start:]
l := 0
for {
i := strings.IndexByte(v, '\n')
if i < 0 {
return nil
}
if strings.HasPrefix(v[:i], "Patch-set:") {
// two newlines before the Patch-set message
v = commit.Msg[start : start+l-2]
break
}
v = v[i+1:]
l = l + i + 1
}
return &GerritMessage{
Meta: commit,
Author: commit.Author,
Date: commit.CommitTime,
Message: v,
Version: int32(version),
}
}
func reverseGerritMessages(ss []*GerritMessage) {
for i := len(ss)/2 - 1; i >= 0; i-- {
opp := len(ss) - 1 - i
ss[i], ss[opp] = ss[opp], ss[i]
}
}
func reverseGerritMetas(ss []*GerritMeta) {
for i := len(ss)/2 - 1; i >= 0; i-- {
opp := len(ss) - 1 - i
ss[i], ss[opp] = ss[opp], ss[i]
}
}
// called with c.mu Locked
func (gp *GerritProject) processMutation(gm *maintpb.GerritMutation) {
c := gp.gerrit.c
for _, commitp := range gm.Commits {
gc, err := c.processGitCommit(commitp)
if err != nil {
gp.logf("error processing commit %q: %v", commitp.Sha1, err)
continue
}
gp.commit[gc.Hash] = gc
delete(gp.need, gc.Hash)
for _, p := range gc.Parents {
gp.markNeededCommit(p.Hash)
}
}
for _, refName := range gm.DeletedRefs {
delete(gp.ref, refName)
// TODO: this doesn't delete change refs (from
// gp.remote) yet, mostly because those don't tend to
// ever get deleted and we haven't yet needed it. If
// we ever need it, the mutation generation side would
// also need to be updated.
}
for _, refp := range gm.Refs {
refName := refp.Ref
hash := c.gitHashFromHexStr(refp.Sha1)
m := rxChangeRef.FindStringSubmatch(refName)
if m == nil {
if strings.HasPrefix(refName, "refs/meta/") {
// Some of these slipped in to the data
// before we started ignoring them. So ignore them here.
continue
}
// Misc ref, not a change ref.
if _, ok := c.gitCommit[hash]; !ok {
gp.logf("ERROR: non-change ref %v references unknown hash %v; ignoring", refp, hash)
continue
}
gp.ref[refName] = hash
continue
}
clNum64, err := strconv.ParseInt(m[1], 10, 32)
version, ok := gerritVersionNumber(m[2])
if !ok || err != nil {
continue
}
gc, ok := c.gitCommit[hash]
if !ok {
gp.logf("ERROR: ref %v references unknown hash %v; ignoring", refp, hash)
continue
}
clv := gerritCLVersion{int32(clNum64), version}
gp.remote[clv] = hash
cl := gp.getOrCreateCL(clv.CLNumber)
if clv.Version == 0 { // is a meta commit
cl.Meta = newGerritMeta(gc, cl)
gp.noteDirtyCL(cl) // needs processing at end of sync
} else {
cl.Commit = gc
cl.Version = clv.Version
cl.updateGithubIssueRefs()
}
if c.didInit {
gp.logf("Ref %+v => %v", clv, hash)
}
}
}
// noteDirtyCL notes a CL that needs further processing before the corpus
// is returned to the user.
// cl.Meta must be non-nil.
//
// called with Corpus.mu Locked
func (gp *GerritProject) noteDirtyCL(cl *GerritCL) {
if cl.Meta == nil {
panic("noteDirtyCL given a GerritCL with a nil Meta field")
}
if gp.dirtyCL == nil {
gp.dirtyCL = make(map[*GerritCL]struct{})
}
gp.dirtyCL[cl] = struct{}{}
}
// called with Corpus.mu Locked
func (gp *GerritProject) finishProcessing() {
for cl := range gp.dirtyCL {
// All dirty CLs have non-nil Meta, so it's safe to call finishProcessingCL.
gp.finishProcessingCL(cl)
}
gp.dirtyCL = nil
}
// finishProcessingCL fixes up invariants before the cl can be returned back to the user.
// cl.Meta must be non-nil.
//
// called with Corpus.mu Locked
func (gp *GerritProject) finishProcessingCL(cl *GerritCL) {
c := gp.gerrit.c
mostRecentMetaCommit, ok := c.gitCommit[cl.Meta.Commit.Hash]
if !ok {
log.Printf("WARNING: GerritProject(%q).finishProcessingCL failed to find CL %v hash %s",
gp.ServerSlashProject(), cl.Number, cl.Meta.Commit.Hash)
return
}
foundStatus := ""
// Walk from the newest meta commit backwards, so we store the messages
// in reverse order and then flip the array before setting on the
// GerritCL object.
var backwardMessages []*GerritMessage
var backwardMetas []*GerritMeta
err := gp.foreachCommit(mostRecentMetaCommit, func(gc *GitCommit) error {
if strings.Contains(gc.Msg, "\nLabel: ") {
gp.numLabelChanges++
}
if strings.Contains(gc.Msg, "\nPrivate: true\n") {
cl.Private = true
}
if gc.GerritMeta == nil {
gc.GerritMeta = newGerritMeta(gc, cl)
}
if foundStatus == "" {
foundStatus = getGerritStatus(gc)
}
backwardMetas = append(backwardMetas, gc.GerritMeta)
if message := gp.getGerritMessage(gc); message != nil {
backwardMessages = append(backwardMessages, message)
}
return nil
})
if err != nil {
log.Printf("WARNING: GerritProject(%q).finishProcessingCL failed to walk CL %v meta history: %v",
gp.ServerSlashProject(), cl.Number, err)
return
}
if foundStatus != "" {
cl.Status = foundStatus
} else if cl.Status == "" {
cl.Status = "new"
}
reverseGerritMessages(backwardMessages)
cl.Messages = backwardMessages
reverseGerritMetas(backwardMetas)
cl.Metas = backwardMetas
cl.Created = cl.Metas[0].Commit.CommitTime
cl.updateBranch()
}
// clSliceContains reports whether cls contains cl.
func clSliceContains(cls []*GerritCL, cl *GerritCL) bool {
for _, v := range cls {
if v == cl {
return true
}
}
return false
}
// c.mu must be held
func (gp *GerritProject) markNeededCommit(hash GitHash) {
if _, ok := gp.commit[hash]; ok {
// Already have it.
return
}
gp.need[hash] = true
}
// c.mu must be held
func (gp *GerritProject) getOrCreateCL(num int32) *GerritCL {
cl, ok := gp.cls[num]
if ok {
return cl
}
cl = &GerritCL{
Project: gp,
Number: num,
}
gp.cls[num] = cl
return cl
}
func gerritVersionNumber(s string) (version int32, ok bool) {
if s == "meta" {
return 0, true
}
v, err := strconv.ParseInt(s, 10, 32)
if err != nil {
return 0, false
}
return int32(v), true
}
// rxRemoteRef matches "git ls-remote" lines.
//
// sample row:
// fd1e71f1594ce64941a85428ddef2fbb0ad1023e refs/changes/99/30599/3
//
// Capture values:
//
// $0: whole match
// $1: "fd1e71f1594ce64941a85428ddef2fbb0ad1023e"
// $2: "30599" (CL number)
// $3: "1", "2" (patchset number) or "meta" (a/ special commit
// holding the comments for a commit)
//
// The "99" in the middle covers all CL's that end in "99", so
// refs/changes/99/99/1, refs/changes/99/199/meta.
var rxRemoteRef = regexp.MustCompile(`^([0-9a-f]{40,})\s+refs/changes/[0-9a-f]{2}/([0-9]+)/(.+)$`)
// $1: change num
// $2: version or "meta"
var rxChangeRef = regexp.MustCompile(`^refs/changes/[0-9a-f]{2}/([0-9]+)/(meta|(?:\d+))`)
func (gp *GerritProject) sync(ctx context.Context, loop bool) error {
if err := gp.init(ctx); err != nil {
gp.logf("init: %v", err)
return err
}
activityCh := gp.gerrit.c.activityChan("gerrit:" + gp.proj)
for {
if err := gp.syncOnce(ctx); err != nil {
if ee, ok := err.(*exec.ExitError); ok {
err = fmt.Errorf("%v; stderr=%q", err, ee.Stderr)
}
gp.logf("sync: %v", err)
return err
}
if !loop {
return nil
}
timer := time.NewTimer(5 * time.Minute)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-activityCh:
timer.Stop()
case <-timer.C:
}
}
}
// syncMissingCommits is a cleanup step to fix a previous maintner bug where
// refs were updated without all their reachable commits being indexed and
// recorded in the log. This should only ever run once, and only in Go's history.
// If we restarted the log from the beginning this wouldn't be necessary.
func (gp *GerritProject) syncMissingCommits(ctx context.Context) error {
c := gp.gerrit.c
var hashes []GitHash
c.mu.Lock()
for hash := range gp.need {
hashes = append(hashes, hash)
}
c.mu.Unlock()
if len(hashes) == 0 {
return nil
}
gp.logf("fixing indexing of %d missing commits", len(hashes))
if err := gp.fetchHashes(ctx, hashes); err != nil {
return err
}
n, err := gp.syncCommits(ctx)
if err != nil {
return err
}
gp.logf("%d missing commits indexed", n)
return nil
}
func (gp *GerritProject) syncOnce(ctx context.Context) error {
if err := gp.syncMissingCommits(ctx); err != nil {
return err
}
c := gp.gerrit.c
gitDir := gp.gitDir()
t0 := time.Now()
cmd := exec.CommandContext(ctx, "git", "fetch", "origin")
envutil.SetDir(cmd, gitDir)
// Enable extra Git tracing in case the fetch hangs.
envutil.SetEnv(cmd,
"GIT_TRACE2_EVENT=1",
"GIT_TRACE_CURL_NO_DATA=1",
)
cmd.Stdout = new(bytes.Buffer)
cmd.Stderr = cmd.Stdout
// The 'git fetch' needs a timeout in case it hangs, but to avoid spurious
// timeouts (and live-lock) the timeout should be (at least) an order of
// magnitude longer than we expect the operation to actually take. Moreover,
// exec.CommandContext sends SIGKILL, which may terminate the command without
// giving it a chance to flush useful trace entries, so we'll terminate it
// manually instead (see https://golang.org/issue/22757).
if err := cmd.Start(); err != nil {
return fmt.Errorf("git fetch origin: %v", err)
}
timer := time.AfterFunc(10*time.Minute, func() {
cmd.Process.Signal(os.Interrupt)
})
err := cmd.Wait()
fetchDuration := time.Since(t0).Round(time.Millisecond)
timer.Stop()
if err != nil {
return fmt.Errorf("git fetch origin: %v after %v, %s", err, fetchDuration, cmd.Stdout)
}
gp.logf("ran git fetch origin in %v", fetchDuration)
t0 = time.Now()
cmd = exec.CommandContext(ctx, "git", "ls-remote")
envutil.SetDir(cmd, gitDir)
out, err := cmd.CombinedOutput()
lsRemoteDuration := time.Since(t0).Round(time.Millisecond)
if err != nil {
return fmt.Errorf("git ls-remote in %s: %v after %v, %s", gitDir, err, lsRemoteDuration, out)
}
gp.logf("ran git ls-remote in %v", lsRemoteDuration)
var changedRefs []*maintpb.GitRef
var toFetch []GitHash
bs := bufio.NewScanner(bytes.NewReader(out))
// Take the lock here to access gp.remote and call c.gitHashFromHex.
// It's acceptable to take such a coarse-looking lock because
// it's not actually around I/O: all the input from ls-remote has
// already been slurped into memory.
c.mu.Lock()
refExists := map[string]bool{} // whether ref is this ls-remote fetch
for bs.Scan() {
line := bs.Bytes()
tab := bytes.IndexByte(line, '\t')
if tab == -1 {
if !strings.HasPrefix(bs.Text(), "From ") {
gp.logf("bogus ls-remote line: %q", line)
}
continue
}
sha1 := string(line[:tab])
refName := strings.TrimSpace(string(line[tab+1:]))
refExists[refName] = true
hash := c.gitHashFromHexStr(sha1)
var needFetch bool
m := rxRemoteRef.FindSubmatch(line)
if m != nil {
clNum, err := strconv.ParseInt(string(m[2]), 10, 32)
version, ok := gerritVersionNumber(string(m[3]))
if err != nil || !ok {
continue
}
curHash := gp.remote[gerritCLVersion{int32(clNum), version}]
needFetch = curHash != hash
} else if trackGerritRef(refName) && gp.ref[refName] != hash {
needFetch = true
gp.logf("ref %q = %q", refName, sha1)
}
if needFetch {
toFetch = append(toFetch, hash)
changedRefs = append(changedRefs, &maintpb.GitRef{
Ref: refName,
Sha1: string(sha1),
})
}
}
var deletedRefs []string
for n := range gp.ref {
if !refExists[n] {
gp.logf("ref %q now deleted", n)
deletedRefs = append(deletedRefs, n)
}
}
c.mu.Unlock()
if err := bs.Err(); err != nil {
gp.logf("ls-remote scanning error: %v", err)
return err
}
if len(deletedRefs) > 0 {
c.addMutation(&maintpb.Mutation{
Gerrit: &maintpb.GerritMutation{
Project: gp.proj,
DeletedRefs: deletedRefs,
},
})
}
if len(changedRefs) == 0 {
return nil
}
gp.logf("%d new refs", len(changedRefs))
const batchSize = 250
for len(toFetch) > 0 {
batch := toFetch
if len(batch) > batchSize {
batch = batch[:batchSize]
}
if err := gp.fetchHashes(ctx, batch); err != nil {
return err
}
c.mu.Lock()
for _, hash := range batch {
gp.markNeededCommit(hash)
}
c.mu.Unlock()
n, err := gp.syncCommits(ctx)
if err != nil {
return err
}
toFetch = toFetch[len(batch):]
gp.logf("synced %v commits for %d new hashes, %d hashes remain", n, len(batch), len(toFetch))
c.addMutation(&maintpb.Mutation{
Gerrit: &maintpb.GerritMutation{
Project: gp.proj,
Refs: changedRefs[:len(batch)],
}})
changedRefs = changedRefs[len(batch):]
}
return nil
}
func (gp *GerritProject) syncCommits(ctx context.Context) (n int, err error) {
c := gp.gerrit.c
lastLog := time.Now()
for {
hash := gp.commitToIndex()
if hash == "" {
return n, nil
}
now := time.Now()
if lastLog.Before(now.Add(-1 * time.Second)) {
lastLog = now
gp.logf("parsing commits (%v done)", n)
}
commit, err := parseCommitFromGit(gp.gitDir(), hash)
if err != nil {
return n, err
}
c.addMutation(&maintpb.Mutation{
Gerrit: &maintpb.GerritMutation{
Project: gp.proj,
Commits: []*maintpb.GitCommit{commit},
},
})
n++
}
}
func (gp *GerritProject) commitToIndex() GitHash {
c := gp.gerrit.c
c.mu.RLock()
defer c.mu.RUnlock()
for hash := range gp.need {
return hash
}
return ""
}
var (
statusSpace = []byte("Status: ")
)
func (gp *GerritProject) fetchHashes(ctx context.Context, hashes []GitHash) error {
args := []string{"fetch", "--quiet", "origin"}
for _, hash := range hashes {
args = append(args, hash.String())
}
gp.logf("fetching %v hashes...", len(hashes))
t0 := time.Now()
cmd := exec.CommandContext(ctx, "git", args...)
envutil.SetDir(cmd, gp.gitDir())
out, err := cmd.CombinedOutput()
d := time.Since(t0).Round(time.Millisecond)
if err != nil {
gp.logf("error fetching %d hashes after %v: %s", len(hashes), d, out)
return err
}
gp.logf("fetched %v hashes in %v", len(hashes), d)
return nil
}
func formatExecError(err error) string {
if ee, ok := err.(*exec.ExitError); ok {
return fmt.Sprintf("%v; stderr=%q", err, ee.Stderr)
}
return fmt.Sprint(err)
}
func (gp *GerritProject) init(ctx context.Context) error {
gitDir := gp.gitDir()
if err := os.MkdirAll(gitDir, 0755); err != nil {
return err
}
// try to short circuit a git init error, since the init error matching is
// brittle
if _, err := exec.LookPath("git"); err != nil {
return fmt.Errorf("looking for git binary: %v", err)
}
if _, err := os.Stat(filepath.Join(gitDir, ".git", "config")); err == nil {
cmd := exec.CommandContext(ctx, "git", "remote", "-v")
envutil.SetDir(cmd, gitDir)
remoteBytes, err := cmd.Output()
if err != nil {
return fmt.Errorf("running git remote -v in %v: %v", gitDir, formatExecError(err))
}
if !strings.Contains(string(remoteBytes), "origin") && !strings.Contains(string(remoteBytes), "https://"+gp.proj) {
return fmt.Errorf("didn't find origin & gp.url in remote output %s", string(remoteBytes))
}
gp.logf("git directory exists.")
return nil
}
cmd := exec.CommandContext(ctx, "git", "init")
buf := new(bytes.Buffer)
cmd.Stdout = buf
cmd.Stderr = buf
envutil.SetDir(cmd, gitDir)
if err := cmd.Run(); err != nil {
log.Printf(`Error running "git init": %s`, buf.String())
return err
}
buf.Reset()
cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", "https://"+gp.proj)
cmd.Stdout = buf
cmd.Stderr = buf
envutil.SetDir(cmd, gitDir)
if err := cmd.Run(); err != nil {
log.Printf(`Error running "git remote add origin": %s`, buf.String())
return err
}
return nil
}
// trackGerritRef reports whether we care to record changes about the
// given ref.
func trackGerritRef(ref string) bool {
if strings.HasPrefix(ref, "refs/users/") {
return false
}
if strings.HasPrefix(ref, "refs/meta/") {
return false
}
if strings.HasPrefix(ref, "refs/cache-automerge/") {
return false
}
return true
}
func (g *Gerrit) check() error {
for key, gp := range g.projects {
if err := gp.check(); err != nil {
return fmt.Errorf("%s: %v", key, err)
}
}
return nil
}
// called with its Corpus.mu locked. (called by
// Corpus.finishProcessing; read comment there)
func (g *Gerrit) finishProcessing() {
if g == nil {
return
}
for _, gp := range g.projects {
gp.finishProcessing()
}
}
func (gp *GerritProject) check() error {
if len(gp.need) != 0 {
return fmt.Errorf("%d missing commits", len(gp.need))
}
for hash, gc := range gp.commit {
if gc.Committer == placeholderCommitter {
return fmt.Errorf("git commit for key %q was placeholder", hash)
}
if gc.Hash != hash {
return fmt.Errorf("git commit for key %q had GitCommit.Hash %q", hash, gc.Hash)
}
for _, pc := range gc.Parents {
if _, ok := gp.commit[pc.Hash]; !ok {
return fmt.Errorf("git commit %q exists but its parent %q does not", gc.Hash, pc.Hash)
}
}
}
return nil
}
// GerritMeta represents a Git commit in the Gerrit NoteDb meta
// format.
type GerritMeta struct {
// Commit points up to the git commit for this Gerrit NoteDB meta commit.
Commit *GitCommit
// CL is the Gerrit CL this metadata is for.
CL *GerritCL
flags gerritMetaFlags
}
type gerritMetaFlags uint8
const (
// metaFlagHashtagEdit indicates that the meta commit edits the hashtags on the commit.
metaFlagHashtagEdit gerritMetaFlags = 1 << iota
)
func newGerritMeta(gc *GitCommit, cl *GerritCL) *GerritMeta {
m := &GerritMeta{Commit: gc, CL: cl}
if msg := m.Commit.Msg; strings.Contains(msg, "autogenerated:gerrit:setHashtag") && m.ActionTag() == "autogenerated:gerrit:setHashtag" {
m.flags |= metaFlagHashtagEdit
}
return m
}
// Footer returns the "key: value" lines at the base of the commit.
func (m *GerritMeta) Footer() string {
i := strings.LastIndex(m.Commit.Msg, "\n\n")
if i == -1 {
return ""
}
return m.Commit.Msg[i+2:]
}
// Hashtags returns the set of hashtags on m's CL as of the time of m.
func (m *GerritMeta) Hashtags() GerritHashtags {
// If this GerritMeta set hashtags, use it.
tags, _, ok := lineValueOK(m.Footer(), "Hashtags: ")
if ok {
return GerritHashtags(tags)
}
// Otherwise, look at older metas (from most recent to oldest)
// to find most recent value. Ignore anything that's newer
// than m.
sawThisMeta := false // whether we've seen 'm'
metas := m.CL.Metas
for i := len(metas) - 1; i >= 0; i-- {
mp := metas[i]
if mp.Commit.Hash == m.Commit.Hash {
sawThisMeta = true
continue
}
if !sawThisMeta {
continue
}
if tags, _, ok := lineValueOK(mp.Footer(), "Hashtags: "); ok {
return GerritHashtags(tags)
}
}
return ""
}
// ActionTag returns the Gerrit "Tag" value from the meta commit.
// These are of the form "autogenerated:gerrit:setHashtag".
func (m *GerritMeta) ActionTag() string {
return lineValue(m.Footer(), "Tag: ")
}
// HashtagEdits returns the hashtags added and removed by this meta commit,
// and whether this meta commit actually modified hashtags.
func (m *GerritMeta) HashtagEdits() (added, removed GerritHashtags, ok bool) {
// Return early for the majority of meta commits that don't edit hashtags.
if m.flags&metaFlagHashtagEdit == 0 {
return
}
msg := m.Commit.Msg
// Parse lines of form:
//
// Hashtag removed: bar
// Hashtags removed: foo, bar
// Hashtag added: bar
// Hashtags added: foo, bar
for len(msg) > 0 {
value, rest := lineValueRest(msg, "Hash")
msg = rest
colon := strings.IndexByte(value, ':')
if colon != -1 {
action := value[:colon]
value := GerritHashtags(strings.TrimSpace(value[colon+1:]))
switch action {
case "tag added", "tags added":
added = value
case "tag removed", "tags removed":
removed = value
}
}
}
ok = added != "" || removed != ""
return
}
// HashtagsAdded returns the hashtags added by this meta commit, if any.
func (m *GerritMeta) HashtagsAdded() GerritHashtags {
added, _, _ := m.HashtagEdits()
return added
}
// HashtagsRemoved returns the hashtags removed by this meta commit, if any.
func (m *GerritMeta) HashtagsRemoved() GerritHashtags {
_, removed, _ := m.HashtagEdits()
return removed
}
// LabelVotes returns a map from label name to voter email to their vote.
//
// This is relatively expensive to call compared to other methods in maintner.
// It is not currently cached.
func (m *GerritMeta) LabelVotes() (map[string]map[string]int8, error) {
if m.CL == nil {
panic("GerritMeta has nil CL field")
}
// To calculate votes as the time of the 'm' meta commit,
// we need to consider the meta commits before it.
// Let's see which number in the (linear) meta history
// we are.
ourIndex := -1
for i, mc := range m.CL.Metas {
if mc == m {
ourIndex = i
break
}
}
if ourIndex == -1 {
panic("LabelVotes called on GerritMeta not in its m.CL.Metas slice")
}
labels := map[string]map[string]int8{}
history := m.CL.Metas[:ourIndex+1]
var lastCommit *GitCommit
for _, mc := range history {
footer := mc.Footer()
isNew := strings.Contains(footer, "\nTag: autogenerated:gerrit:newPatchSet\n")
email := mc.Commit.Author.Email()
if isNew {
if commit := lineValue(footer, "Commit: "); commit != "" {
// TODO: implement Gerrit's vote copying. For example,
// label.Label-Name.copyAllScoresIfNoChange defaults to true (as it is with Go's server)
// https://gerrit-review.googlesource.com/Documentation/config-labels.html#label_copyAllScoresIfNoChange
// We don't have the information in Maintner to do this, though.
// One approximation is:
newCommit, err := m.CL.Project.GitCommit(commit)
if err != nil {
return nil, fmt.Errorf("LabelVotes: invalid Commit in footer on CL %v, meta-CL %x: %v", m.CL.Number, mc.Commit.Hash, err)
}
if lastCommit != nil {
if !lastCommit.SameDiffStat(newCommit) {
// TODO: this should really use
// the Gerrit server's project
// config, including the
// All-Projects config, but
// that's not in Maintner
// either.
delete(labels, "Run-TryBot")
delete(labels, "TryBot-Result")
}
}
lastCommit = newCommit
}
}
remain := footer
for len(remain) > 0 {
var labelEqVal string
labelEqVal, remain = lineValueRest(remain, "Label: ")
if labelEqVal != "" {
label, value, whose := parseGerritLabelValue(labelEqVal)
if label != "" {
if whose == "" {
whose = email
}
if label[0] == '-' {
label = label[1:]
if m := labels[label]; m != nil {
delete(m, whose)
}
} else {
m := labels[label]
if m == nil {
m = make(map[string]int8)
labels[label] = m
}
m[whose] = value
}
}
}
}
}
return labels, nil
}
// parseGerritLabelValue parses a Gerrit NoteDb "Label: ..." value.
// It can take forms and return values such as:
//
// "Run-TryBot=+1" => ("Run-TryBot", 1, "")
// "-Run-TryBot" => ("-Run-TryBot", 0, "")
// "-Run-TryBot " => ("-Run-TryBot", 0, "")
// "Run-TryBot=+1 Brad Fitzpatrick <5065@62eb7196-b449-3ce5-99f1-c037f21e1705>" =>
// ("Run-TryBot", 1, "5065@62eb7196-b449-3ce5-99f1-c037f21e1705")
// "-TryBot-Result Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>" =>
// ("-TryBot-Result", 0, "5976@62eb7196-b449-3ce5-99f1-c037f21e1705")
func parseGerritLabelValue(v string) (label string, value int8, whose string) {
space := strings.IndexByte(v, ' ')
if space != -1 {
v, whose = v[:space], v[space+1:]
if i := strings.IndexByte(whose, '<'); i == -1 {
whose = ""
} else {
whose = whose[i+1:]
if i := strings.IndexByte(whose, '>'); i == -1 {
whose = ""
} else {
whose = whose[:i]
}
}
}
v = strings.TrimSpace(v)
if eq := strings.IndexByte(v, '='); eq == -1 {
label = v
} else {
label = v[:eq]
if n, err := strconv.ParseInt(v[eq+1:], 10, 8); err == nil {
value = int8(n)
}
}
return
}
// GerritHashtags represents a set of "hashtags" on a Gerrit CL.
//
// The representation is a comma-separated string, to match Gerrit's
// internal representation in the meta commits. To support both
// forms of Gerrit's internal representation, whitespace is optional
// around the commas.
type GerritHashtags string
// Contains reports whether the hashtag t is in the set of tags s.
func (s GerritHashtags) Contains(t string) bool {
for len(s) > 0 {
comma := strings.IndexByte(string(s), ',')
if comma == -1 {
return strings.TrimSpace(string(s)) == t
}
if strings.TrimSpace(string(s[:comma])) == t {
return true
}
s = s[comma+1:]
}
return false
}
// Foreach calls fn for each tag in the set s.
func (s GerritHashtags) Foreach(fn func(string)) {
for len(s) > 0 {
comma := strings.IndexByte(string(s), ',')
if comma == -1 {
fn(strings.TrimSpace(string(s)))
return
}
fn(strings.TrimSpace(string(s[:comma])))
s = s[comma+1:]
}
}
// Match reports whether fn returns true for any tag in the set s.
// If fn returns true, iteration stops and Match returns true.
func (s GerritHashtags) Match(fn func(string) bool) bool {
for len(s) > 0 {
comma := strings.IndexByte(string(s), ',')
if comma == -1 {
return fn(strings.TrimSpace(string(s)))
}
if fn(strings.TrimSpace(string(s[:comma]))) {
return true
}
s = s[comma+1:]
}
return false
}
// Len returns the number of tags in the set s.
func (s GerritHashtags) Len() int {
if s == "" {
return 0
}
return strings.Count(string(s), ",") + 1
}