diff --git a/tools/src/cmd/auto-submit/main.go b/tools/src/cmd/auto-submit/main.go new file mode 100644 index 0000000000..d79a76ab00 --- /dev/null +++ b/tools/src/cmd/auto-submit/main.go @@ -0,0 +1,216 @@ +// Copyright 2023 The Tint Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// auto-submit applies the 'Commit-Queue+2' label to Gerrit changes authored by the user +// that are ready to be submitted +package main + +import ( + "flag" + "fmt" + "log" + "os" + "os/exec" + "strings" + "time" + + "dawn.googlesource.com/dawn/tools/src/dawn" + "dawn.googlesource.com/dawn/tools/src/gerrit" + "dawn.googlesource.com/dawn/tools/src/git" +) + +const ( + toolName = "auto-submit" + cqEmailAccount = "dawn-scoped@luci-project-accounts.iam.gserviceaccount.com" +) + +var ( + // See https://dawn-review.googlesource.com/new-password for obtaining + // username and password for gerrit. + gerritUser = flag.String("gerrit-user", "", "gerrit authentication username") + gerritPass = flag.String("gerrit-pass", "", "gerrit authentication password") + repoFlag = flag.String("repo", "dawn", "the repo") + userFlag = flag.String("user", defaultUser(), "user name / email") + verboseFlag = flag.Bool("v", false, "verbose mode") + dryrunFlag = flag.Bool("dry", false, "dry mode. Don't apply any labels") +) + +func defaultUser() string { + if gitExe, err := exec.LookPath("git"); err == nil { + if g, err := git.New(gitExe); err == nil { + if cwd, err := os.Getwd(); err == nil { + if r, err := g.Open(cwd); err == nil { + if cfg, err := r.Config(nil); err == nil { + return cfg["user.email"] + } + } + } + } + } + return "" +} + +func main() { + flag.Usage = func() { + out := flag.CommandLine.Output() + fmt.Fprintf(out, + `%v applies the 'Commit-Queue+2' label to Gerrit changes authored by the user that are ready to be submitted. + +The tool monitors Gerrit changes authored by the user, looking for changes that have the labels +'Kokoro+1', 'Auto-Submit +1' and 'Code-Review +2' and applies the 'Commit-Queue +2' label. +`, toolName) + fmt.Fprintf(out, "\n") + flag.PrintDefaults() + } + flag.Parse() + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + user := *userFlag + if user == "" { + return fmt.Errorf("Missing required 'user' flag") + } + + g, err := gerrit.New(dawn.GerritURL, gerrit.Credentials{ + Username: *gerritUser, Password: *gerritPass, + }) + if err != nil { + return err + } + + app := app{Gerrit: g, user: user} + + log.Println("Monitoring for changes ready to be submitted...") + + for { + err := app.submitReadyChanges() + if err != nil { + fmt.Println("error: ", err) + time.Sleep(time.Minute * 10) + } + time.Sleep(time.Minute * 5) + } +} + +type app struct { + *gerrit.Gerrit + user string // User account of changes to submit +} + +func (a *app) submitReadyChanges() error { + if *verboseFlag { + log.Println("Scanning for changes to submit...") + } + + changes, _, err := a.QueryChangesWith( + gerrit.QueryExtraData{ + Labels: true, + Messages: true, + CurrentRevision: true, + DetailedAccounts: true, + }, + "status:open", + "author:"+a.user, + "-is:wip", + "label:auto-submit", + "label:kokoro", + "repo:"+*repoFlag) + if err != nil { + return fmt.Errorf("failed to Query changes: %w", err) + } + + for _, change := range changes { + // Returns true if the change has the label with the given value + hasLabel := func(name string, value int) bool { + if label, ok := change.Labels[name]; ok { + for _, vote := range label.All { + if vote.Value == value { + return true + } + } + } + return false + } + + isReadyToSubmit := true && + hasLabel("Kokoro", 1) && + hasLabel("Auto-Submit", 1) && + hasLabel("Code-Review", 2) && + !hasLabel("Code-Review", -1) && + !hasLabel("Code-Review", -2) + if !isReadyToSubmit { + // Change does not have all the required labels to submit + continue + } + + if hasLabel("Commit-Queue", 2) { + // Change already in the process of submitting + continue + } + + switch parseCQStatus(change) { + case cqUnknown, cqPassed: + if *dryrunFlag { + log.Printf("Would submit %v: %v... (--dry)\n", change.ChangeID, change.Subject) + continue + } + + log.Printf("Submitting %v: %v...\n", change.ChangeID, change.Subject) + err := a.AddLabel(change.ChangeID, change.CurrentRevision, "Auto submitting change", "Commit-Queue", 2) + if err != nil { + return fmt.Errorf("failed to set Commit-Queue label: %w", err) + } + + case cqFailed: + if *verboseFlag { + log.Printf("Change failed CQ: %v: %v...\n", change.ChangeID, change.Subject) + } + } + } + + return nil +} + +// CQ result status enumerator +type cqStatus int + +// CQ result status enumerator values +const ( + cqUnknown cqStatus = iota + cqPassed + cqFailed +) + +// Attempt to parse the CQ result from the latest patchset's messages from CQ +func parseCQStatus(change gerrit.ChangeInfo) cqStatus { + currentPatchset := change.Revisions[change.CurrentRevision].Number + for _, msg := range change.Messages { + if msg.RevisionNumber != currentPatchset { + continue + } + if msg.Author.Email == cqEmailAccount { + if strings.Contains(msg.Message, "This CL has passed the run") { + return cqPassed + } + if strings.Contains(msg.Message, "This CL has failed the run") { + return cqFailed + } + } + } + return cqUnknown +} diff --git a/tools/src/gerrit/gerrit.go b/tools/src/gerrit/gerrit.go index 228f824b70..119b5c476c 100644 --- a/tools/src/gerrit/gerrit.go +++ b/tools/src/gerrit/gerrit.go @@ -122,15 +122,39 @@ func New(url string, cred Credentials) (*Gerrit, error) { return &Gerrit{client, cred.Username != ""}, nil } +// QueryExtraData holds extra data to query for with QueryChangesWith() +type QueryExtraData struct { + Labels bool + Messages bool + CurrentRevision bool + DetailedAccounts bool +} + // QueryChanges returns the changes that match the given query strings. // See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators -func (g *Gerrit) QueryChanges(querys ...string) (changes []gerrit.ChangeInfo, query string, err error) { +func (g *Gerrit) QueryChangesWith(extras QueryExtraData, queries ...string) (changes []gerrit.ChangeInfo, query string, err error) { changes = []gerrit.ChangeInfo{} - query = strings.Join(querys, "+") + query = strings.Join(queries, "+") + + changeOpts := gerrit.ChangeOptions{} + if extras.Labels { + changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "LABELS") + } + if extras.Messages { + changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "MESSAGES") + } + if extras.CurrentRevision { + changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "CURRENT_REVISION") + } + if extras.DetailedAccounts { + changeOpts.AdditionalFields = append(changeOpts.AdditionalFields, "DETAILED_ACCOUNTS") + } + for { batch, _, err := g.client.Changes.QueryChanges(&gerrit.QueryChangeOptions{ - QueryOptions: gerrit.QueryOptions{Query: []string{query}}, - Skip: len(changes), + QueryOptions: gerrit.QueryOptions{Query: []string{query}}, + Skip: len(changes), + ChangeOptions: changeOpts, }) if err != nil { return nil, "", g.maybeWrapError(err) @@ -144,6 +168,23 @@ func (g *Gerrit) QueryChanges(querys ...string) (changes []gerrit.ChangeInfo, qu return changes, query, nil } +// QueryChanges returns the changes that match the given query strings. +// See: https://gerrit-review.googlesource.com/Documentation/user-search.html#search-operators +func (g *Gerrit) QueryChanges(queries ...string) (changes []gerrit.ChangeInfo, query string, err error) { + return g.QueryChangesWith(QueryExtraData{}, queries...) +} + +func (g *Gerrit) AddLabel(changeID, revisionID, message, label string, value int) error { + _, _, err := g.client.Changes.SetReview(changeID, revisionID, &gerrit.ReviewInput{ + Message: message, + Labels: map[string]string{label: fmt.Sprint(value)}, + }) + if err != nil { + return g.maybeWrapError(err) + } + return nil +} + // Abandon abandons the change with the given changeID. func (g *Gerrit) Abandon(changeID string) error { _, _, err := g.client.Changes.AbandonChange(changeID, &gerrit.AbandonInput{})