| // 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/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, or nil if it's unknown. |
| func (gp *GerritProject) GitCommit(hash string) *GitCommit { |
| if len(hash) != 40 { |
| // TODO: support prefix lookups. build a trie. But |
| // for now just avoid panicking in gitHashFromHexStr. |
| return nil |
| } |
| var buf [20]byte |
| _, err := decodeHexStr(buf[:], hash) |
| if err != nil { |
| return nil |
| } |
| return gp.commit[GitHash(buf[:])] |
| } |
| |
| 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") |
| cmd.Dir = gitDir |
| // Enable extra Git tracing in case the fetch hangs. |
| cmd.Env = append(os.Environ(), |
| "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") |
| cmd.Dir = 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...) |
| cmd.Dir = 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") |
| cmd.Dir = 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 |
| cmd.Dir = 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 |
| cmd.Dir = 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 { |
| if m == nil { |
| panic("nil *GerritMeta") |
| } |
| 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 string |
| 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: |
| if lastCommit != "" { |
| oldCommit := m.CL.Project.GitCommit(lastCommit) |
| newCommit := m.CL.Project.GitCommit(commit) |
| if !oldCommit.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 = commit |
| } |
| } |
| |
| 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 |
| } |
| |
| // 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 |
| } |