diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | frontend/frontend.go | 53 | ||||
-rw-r--r-- | gerrit/changeset.go | 107 | ||||
-rw-r--r-- | gerrit/client.go | 119 | ||||
-rw-r--r-- | go.mod | 11 | ||||
-rw-r--r-- | go.sum | 49 | ||||
-rw-r--r-- | main.go | 138 | ||||
-rw-r--r-- | submitqueue/serie.go | 105 | ||||
-rw-r--r-- | submitqueue/series.go | 125 | ||||
-rw-r--r-- | submitqueue/submitqueue.go | 220 | ||||
-rw-r--r-- | templates/submit-queue.tmpl.html | 38 | ||||
-rw-r--r-- | views/index.html | 13 |
12 files changed, 979 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..598ae7928baa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.vscode diff --git a/frontend/frontend.go b/frontend/frontend.go new file mode 100644 index 000000000000..8cd3d9a9b092 --- /dev/null +++ b/frontend/frontend.go @@ -0,0 +1,53 @@ +package frontend + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/tweag/gerrit-queue/submitqueue" +) + +// Frontend holds a gin Engine and the Sergequeue object +type Frontend struct { + Router *gin.Engine + SubmitQueue *submitqueue.SubmitQueue +} + +// MakeFrontend configures the router and returns a new Frontend struct +func MakeFrontend(router *gin.Engine, submitQueue *submitqueue.SubmitQueue) *Frontend { + // FIXME: use go generators and statik + router.LoadHTMLGlob("templates/*") + router.GET("/submit-queue.json", func(c *gin.Context) { + // FIXME: do this periodically + err := submitQueue.UpdateHEAD() + if err != nil { + c.AbortWithError(http.StatusBadGateway, fmt.Errorf("unable to update HEAD")) + } + c.JSON(http.StatusOK, submitQueue) + }) + + router.GET("/", func(c *gin.Context) { + // FIXME: do this periodically + // TODO: add hyperlinks to changesets + err := submitQueue.UpdateHEAD() + if err != nil { + c.AbortWithError(http.StatusBadGateway, fmt.Errorf("unable to update HEAD")) + } + c.HTML(http.StatusOK, "submit-queue.tmpl.html", gin.H{ + "series": submitQueue.Series, + "projectName": submitQueue.ProjectName, + "branchName": submitQueue.BranchName, + "HEAD": submitQueue.HEAD, + }) + }) + return &Frontend{ + Router: router, + SubmitQueue: submitQueue, + } +} + +// Run starts the webserver on a given address +func (f *Frontend) Run(addr string) error { + return f.Router.Run(addr) +} diff --git a/gerrit/changeset.go b/gerrit/changeset.go new file mode 100644 index 000000000000..38a489ec7dd9 --- /dev/null +++ b/gerrit/changeset.go @@ -0,0 +1,107 @@ +package gerrit + +import ( + "bytes" + "fmt" + + goGerrit "github.com/andygrunwald/go-gerrit" + log "github.com/sirupsen/logrus" +) + +// Changeset represents a single changeset +// Relationships between different changesets are described in Series +type Changeset struct { + changeInfo *goGerrit.ChangeInfo + ChangeID string + Number int + IsVerified bool + IsCodeReviewed bool + HashTags []string + CommitID string + ParentCommitIDs []string + OwnerName string + Subject string +} + +// MakeChangeset creates a new Changeset object out of a goGerrit.ChangeInfo object +func MakeChangeset(changeInfo *goGerrit.ChangeInfo) *Changeset { + return &Changeset{ + changeInfo: changeInfo, + ChangeID: changeInfo.ChangeID, + Number: changeInfo.Number, + IsVerified: isVerified(changeInfo), + IsCodeReviewed: isCodeReviewed(changeInfo), + HashTags: changeInfo.Hashtags, + CommitID: changeInfo.CurrentRevision, // yes, this IS the commit ID. + ParentCommitIDs: getParentCommitIDs(changeInfo), + OwnerName: changeInfo.Owner.Name, + Subject: changeInfo.Subject, + } +} + +// MakeMockChangeset creates a mock changeset +// func MakeMockChangeset(isVerified, IsCodeReviewed bool, hashTags []string, commitID string, parentCommitIDs []string, ownerName, subject string) *Changeset { +// //TODO impl +// return nil +//} + +// HasTag returns true if a Changeset has the given tag. +func (c *Changeset) HasTag(tag string) bool { + hashTags := c.HashTags + for _, hashTag := range hashTags { + if hashTag == tag { + return true + } + } + return false +} + +func (c *Changeset) String() string { + var b bytes.Buffer + b.WriteString("Changeset") + b.WriteString(fmt.Sprintf("(commitID: %.7s, author: %s, subject: %s)", c.CommitID, c.OwnerName, c.Subject)) + return b.String() +} + +// FilterChangesets filters a list of Changeset by a given filter function +func FilterChangesets(changesets []*Changeset, f func(*Changeset) bool) []*Changeset { + newChangesets := make([]*Changeset, 0) + for _, changeset := range changesets { + if f(changeset) { + newChangesets = append(newChangesets, changeset) + } else { + log.WithField("changeset", changeset.String()).Debug("dropped by filter") + } + } + return newChangesets +} + +// isVerified returns true if the code passed CI, +// that's when somebody left the Approved (+1) on the "Verified" label +func isVerified(changeInfo *goGerrit.ChangeInfo) bool { + labels := changeInfo.Labels + return labels["Verified"].Approved.AccountID != 0 +} + +// isCodeReviewed returns true if the code passed code review, +// that's when somebody left the Recommended (+2) on the "Code-Review" label +func isCodeReviewed(changeInfo *goGerrit.ChangeInfo) bool { + labels := changeInfo.Labels + return labels["Code-Review"].Recommended.AccountID != 0 +} + +// getParentCommitIDs returns the parent commit IDs of the goGerrit.ChangeInfo +// There is usually only one parent commit ID, except for merge commits. +func getParentCommitIDs(changeInfo *goGerrit.ChangeInfo) []string { + // obtain the RevisionInfo object + revisionInfo := changeInfo.Revisions[changeInfo.CurrentRevision] + + // obtain the Commit object + commit := revisionInfo.Commit + + commitIDs := make([]string, len(commit.Parents)) + for i, commit := range commit.Parents { + commitIDs[i] = commit.Commit + } + return commitIDs +} diff --git a/gerrit/client.go b/gerrit/client.go new file mode 100644 index 000000000000..5a13befe5463 --- /dev/null +++ b/gerrit/client.go @@ -0,0 +1,119 @@ +package gerrit + +import ( + goGerrit "github.com/andygrunwald/go-gerrit" + + "net/url" +) + +// passed to gerrit when retrieving changesets +var additionalFields = []string{"LABELS", "CURRENT_REVISION", "CURRENT_COMMIT", "DETAILED_ACCOUNTS"} + +// IClient defines the gerrit.Client interface +type IClient interface { + SearchChangesets(queryString string) (changesets []*Changeset, Error error) + GetHEAD(projectName string, branchName string) (string, error) + GetChangeset(changeID string) (*Changeset, error) + SubmitChangeset(changeset *Changeset) (*Changeset, error) + RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) + RemoveTag(changeset *Changeset, tag string) (*Changeset, error) +} + +var _ IClient = &Client{} + +// Client provides some ways to interact with a gerrit instance +type Client struct { + client *goGerrit.Client +} + +// NewClient initializes a new gerrit client +func NewClient(URL, username, password string) (*Client, error) { + urlParsed, err := url.Parse(URL) + if err != nil { + return nil, err + } + urlParsed.User = url.UserPassword(username, password) + + goGerritClient, err := goGerrit.NewClient(urlParsed.String(), nil) + if err != nil { + return nil, err + } + return &Client{client: goGerritClient}, nil +} + +// SearchChangesets fetches a list of changesets matching a passed query string +func (gerrit *Client) SearchChangesets(queryString string) (changesets []*Changeset, Error error) { + opt := &goGerrit.QueryChangeOptions{} + opt.Query = []string{ + queryString, + } + opt.AdditionalFields = additionalFields //TODO: check DETAILED_ACCOUNTS is needed + changes, _, err := gerrit.client.Changes.QueryChanges(opt) + if err != nil { + return nil, err + } + + changesets = make([]*Changeset, 0) + for _, change := range *changes { + changesets = append(changesets, MakeChangeset(&change)) + } + + return changesets, nil +} + +// GetHEAD returns the commit ID of a selected branch +func (gerrit *Client) GetHEAD(projectName string, branchName string) (string, error) { + branchInfo, _, err := gerrit.client.Projects.GetBranch(projectName, branchName) + if err != nil { + return "", err + } + return branchInfo.Revision, nil +} + +// GetChangeset downloads an existing Changeset from gerrit, by its ID +// Gerrit's API is a bit sparse, and only returns what you explicitly ask it +// This is used to refresh an existing changeset with more data. +func (gerrit *Client) GetChangeset(changeID string) (*Changeset, error) { + opt := goGerrit.ChangeOptions{} + opt.AdditionalFields = []string{"LABELS", "DETAILED_ACCOUNTS"} + changeInfo, _, err := gerrit.client.Changes.GetChange(changeID, &opt) + if err != nil { + return nil, err + } + return MakeChangeset(changeInfo), nil +} + +// SubmitChangeset submits a given changeset, and returns a changeset afterwards. +func (gerrit *Client) SubmitChangeset(changeset *Changeset) (*Changeset, error) { + changeInfo, _, err := gerrit.client.Changes.SubmitChange(changeset.ChangeID, &goGerrit.SubmitInput{}) + if err != nil { + return nil, err + } + return gerrit.GetChangeset(changeInfo.ChangeID) +} + +// RebaseChangeset rebases a given changeset on top of a given ref +func (gerrit *Client) RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) { + changeInfo, _, err := gerrit.client.Changes.RebaseChange(changeset.ChangeID, &goGerrit.RebaseInput{ + Base: ref, + }) + if err != nil { + return changeset, err + } + return gerrit.GetChangeset(changeInfo.ChangeID) +} + +// RemoveTag removes the submit queue tag from a changeset and updates gerrit +// we never add, that's something users should do in the GUI. +func (gerrit *Client) RemoveTag(changeset *Changeset, tag string) (*Changeset, error) { + hashTags := changeset.HashTags + newHashTags := []string{} + for _, hashTag := range hashTags { + if hashTag != tag { + newHashTags = append(newHashTags, hashTag) + } + } + // TODO: implement set hashtags api in go-gerrit and use here + // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-hashtags + return changeset, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 000000000000..20d2e152cc9f --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/tweag/gerrit-queue + +go 1.12 + +require ( + github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8 + github.com/gin-gonic/gin v1.4.0 + github.com/google/go-querystring v1.0.0 // indirect + github.com/sirupsen/logrus v1.4.2 + github.com/urfave/cli v1.22.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 000000000000..000f431ed4fc --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8 h1:9PvNa6zH6gOW4VVfbAx5rjDLpxunG+RSaXQB+8TEv4w= +github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8/go.mod h1:0iuRQp6WJ44ts+iihy5E/WlPqfg5RNeQxOmzRkxCdtk= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3 h1:t8FVkw33L+wilf2QiWkw0UV77qRpcH/JHPKGpKa2E8g= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0 h1:3tMoCCfM7ppqsR0ptz/wi1impNpT7/9wQtMZ8lr1mCQ= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/ugorji/go v1.1.4 h1:j4s+tAvLfL3bZyefP2SEWmhBzmuIlH/eqNuPdFPgngw= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/urfave/cli v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go new file mode 100644 index 000000000000..0b92a913ae59 --- /dev/null +++ b/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "os" + + "github.com/tweag/gerrit-queue/frontend" + "github.com/tweag/gerrit-queue/gerrit" + "github.com/tweag/gerrit-queue/submitqueue" + + "github.com/gin-gonic/gin" + "github.com/urfave/cli" + + "fmt" + + log "github.com/sirupsen/logrus" +) + +func main() { + // configure logging + log.SetFormatter(&log.TextFormatter{}) + //log.SetFormatter(&log.JSONFormatter{}) + log.SetOutput(os.Stdout) + log.SetLevel(log.DebugLevel) + + var URL, username, password, projectName, branchName, submitQueueTag string + var fetchOnly bool + + app := cli.NewApp() + app.Name = "gerrit-queue" + + app.Flags = []cli.Flag{ + cli.StringFlag{ + Name: "url", + Usage: "URL to the gerrit instance", + EnvVar: "GERRIT_URL", + Destination: &URL, + Required: true, + }, + cli.StringFlag{ + Name: "username", + Usage: "Username to use to login to gerrit", + EnvVar: "GERRIT_USERNAME", + Destination: &username, + Required: true, + }, + cli.StringFlag{ + Name: "password", + Usage: "Password to use to login to gerrit", + EnvVar: "GERRIT_PASSWORD", + Destination: &password, + Required: true, + }, + cli.StringFlag{ + Name: "project", + Usage: "Gerrit project name to run the submit queue for", + EnvVar: "GERRIT_PROJECT", + Destination: &projectName, + Required: true, + }, + cli.StringFlag{ + Name: "branch", + Usage: "Destination branch", + EnvVar: "GERRIT_BRANCH", + Destination: &branchName, + Value: "master", + }, + cli.StringFlag{ + Name: "submit-queue-tag", + Usage: "the tag used to submit something to the submit queue", + EnvVar: "SUBMIT_QUEUE_TAG", + Destination: &submitQueueTag, + Value: "submit_me", + }, + cli.BoolFlag{ + Name: "fetch-only", + Usage: "Only fetch changes and assemble queue, but don't actually write", + Destination: &fetchOnly, + }, + } + + app.Action = func(c *cli.Context) error { + gerritClient, err := gerrit.NewClient(URL, username, password) + if err != nil { + return err + } + log.Printf("Successfully connected to gerrit at %s", URL) + + submitQueue := submitqueue.MakeSubmitQueue(gerritClient, projectName, branchName, submitQueueTag) + + router := gin.Default() + frontend := frontend.MakeFrontend(router, &submitQueue) + + err = submitQueue.LoadSeries() + if err != nil { + log.Errorf("Error loading submit queue: %s", err) + } + + fmt.Println() + fmt.Println() + fmt.Println() + fmt.Println() + for _, serie := range submitQueue.Series { + fmt.Println(fmt.Sprintf("%s", serie)) + for _, changeset := range serie.ChangeSets { + fmt.Println(fmt.Sprintf(" - %s", changeset.String())) + } + fmt.Println() + } + + frontend.Run(":8080") + + if fetchOnly { + //return backlog.Run() + } + + return nil + } + + err := app.Run(os.Args) + if err != nil { + log.Fatal(err) + } + + // mux := http.NewServeMux() + + // options := &gerrit.EventsLogOptions{} + // events, _, _, err := gerritClient.EventsLog.GetEvents(options) + + // TODOS: + // - create submit queue user + // - handle event log, either by accepting webhooks, or by streaming events? + + //n := negroni.Classic() + //n.UseHandler(mux) + + //fmt.Println("Listening on :3000…") + //http.ListenAndServe(":3000", n) +} diff --git a/submitqueue/serie.go b/submitqueue/serie.go new file mode 100644 index 000000000000..b2cf4ef01c55 --- /dev/null +++ b/submitqueue/serie.go @@ -0,0 +1,105 @@ +package submitqueue + +import ( + "fmt" + "strings" + + "github.com/tweag/gerrit-queue/gerrit" + + log "github.com/sirupsen/logrus" +) + +// Serie represents a list of successive changesets with an unbroken parent -> child relation, +// starting from the parent. +type Serie struct { + ChangeSets []*gerrit.Changeset +} + +// GetParentCommitIDs returns the parent commit IDs +func (s *Serie) GetParentCommitIDs() ([]string, error) { + if len(s.ChangeSets) == 0 { + return nil, fmt.Errorf("Can't return parent on a serie with zero ChangeSets") + } + return s.ChangeSets[0].ParentCommitIDs, nil +} + +// GetLeafCommitID returns the commit id of the last commit in ChangeSets +func (s *Serie) GetLeafCommitID() (string, error) { + if len(s.ChangeSets) == 0 { + return "", fmt.Errorf("Can't return leaf on a serie with zero ChangeSets") + } + return s.ChangeSets[len(s.ChangeSets)-1].CommitID, nil +} + +// CheckIntegrity checks that the series contains a properly ordered and connected chain of commits +func (s *Serie) CheckIntegrity() error { + logger := log.WithFields(log.Fields{ + "serie": s, + }) + // an empty serie is invalid + if len(s.ChangeSets) == 0 { + return fmt.Errorf("An empty serie is invalid") + } + + previousCommitID := "" + for i, changeset := range s.ChangeSets { + // we can't really check the parent of the first commit + // so skip verifying that one + logger.WithFields(log.Fields{ + "changeset": changeset.String(), + "previousCommitID": fmt.Sprintf("%.7s", previousCommitID), + }).Debug(" - verifying changeset") + + parentCommitIDs := changeset.ParentCommitIDs + if len(parentCommitIDs) == 0 { + return fmt.Errorf("Changesets without any parent are not supported") + } + // we don't check parents of the first changeset in a series + if i != 0 { + if len(parentCommitIDs) != 1 { + return fmt.Errorf("Merge commits in the middle of a series are not supported (only at the beginning)") + } + if parentCommitIDs[0] != previousCommitID { + return fmt.Errorf("changesets parent commit id doesn't match previous commit id") + } + } + // update previous commit id for the next loop iteration + previousCommitID = changeset.CommitID + } + return nil +} + +func (s *Serie) String() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Serie[%d]", len(s.ChangeSets))) + if len(s.ChangeSets) == 0 { + sb.WriteString("()\n") + return sb.String() + } + parentCommitIDs, err := s.GetParentCommitIDs() + if err == nil { + if len(parentCommitIDs) == 1 { + sb.WriteString(fmt.Sprintf("(parent: %.7s)", parentCommitIDs[0])) + } else { + sb.WriteString("(merge: ") + + for i, parentCommitID := range parentCommitIDs { + sb.WriteString(fmt.Sprintf("%.7s", parentCommitID)) + if i < len(parentCommitIDs) { + sb.WriteString(", ") + } + } + + sb.WriteString(")") + + } + } + sb.WriteString(fmt.Sprintf("(%.7s..%.7s)", + s.ChangeSets[0].CommitID, + s.ChangeSets[len(s.ChangeSets)-1].CommitID)) + return sb.String() +} + +func shortCommitID(commitID string) string { + return commitID[:6] +} diff --git a/submitqueue/series.go b/submitqueue/series.go new file mode 100644 index 000000000000..42abff2d49be --- /dev/null +++ b/submitqueue/series.go @@ -0,0 +1,125 @@ +package submitqueue + +import ( + "sort" + + "github.com/tweag/gerrit-queue/gerrit" + + log "github.com/sirupsen/logrus" +) + +// AssembleSeries consumes a list of `Changeset`, and groups them together to series +// +// As we have no control over the order of the passed changesets, +// we maintain two lookup tables, +// mapLeafToSerie, which allows to lookup a serie by its leaf commit id, +// to append to an existing serie +// and mapParentToSeries, which allows to lookup all series having a certain parent commit id, +// to prepend to any of the existing series +// if we can't find anything, we create a new series +func AssembleSeries(changesets []*gerrit.Changeset) ([]*Serie, error) { + series := make([]*Serie, 0) + mapLeafToSerie := make(map[string]*Serie, 0) + + for _, changeset := range changesets { + logger := log.WithFields(log.Fields{ + "changeset": changeset.String(), + }) + + logger.Debug("creating initial serie") + serie := &Serie{ + ChangeSets: []*gerrit.Changeset{changeset}, + } + series = append(series, serie) + mapLeafToSerie[changeset.CommitID] = serie + } + + // Combine series using a fixpoint approach, with a max iteration count. + log.Debug("glueing together phase") + for i := 1; i < 100; i++ { + didUpdate := false + log.Debugf("at iteration %d", i) + for _, serie := range series { + logger := log.WithField("serie", serie.String()) + parentCommitIDs, err := serie.GetParentCommitIDs() + if err != nil { + return series, err + } + if len(parentCommitIDs) != 1 { + // We can't append merge commits to other series + logger.Infof("No single parent, skipping.") + continue + } + parentCommitID := parentCommitIDs[0] + logger.Debug("Looking for a predecessor.") + // if there's another serie that has this parent as a leaf, glue together + if otherSerie, ok := mapLeafToSerie[parentCommitID]; ok { + if otherSerie == serie { + continue + } + logger := logger.WithField("otherSerie", otherSerie) + + myLeafCommitID, err := serie.GetLeafCommitID() + if err != nil { + return series, err + } + + // append our changesets to the other serie + logger.Debug("Splicing together.") + otherSerie.ChangeSets = append(otherSerie.ChangeSets, serie.ChangeSets...) + + delete(mapLeafToSerie, parentCommitID) + mapLeafToSerie[myLeafCommitID] = otherSerie + + // orphan our serie + serie.ChangeSets = []*gerrit.Changeset{} + // remove the orphaned serie from the lookup table + delete(mapLeafToSerie, myLeafCommitID) + + didUpdate = true + } else { + logger.Debug("Not found.") + } + } + series = removeOrphanedSeries(series) + if !didUpdate { + log.Infof("converged after %d iterations", i) + break + } + } + + // Check integrity, just to be on the safe side. + for _, serie := range series { + logger := log.WithFields(log.Fields{ + "serie": serie.String(), + }) + logger.Debugf("checking integrity") + err := serie.CheckIntegrity() + if err != nil { + logger.Errorf("checking integrity failed: %s", err) + } + } + return series, nil +} + +// removeOrphanedSeries removes all empty series (that contain zero changesets) +func removeOrphanedSeries(series []*Serie) []*Serie { + newSeries := []*Serie{} + for _, serie := range series { + if len(serie.ChangeSets) != 0 { + newSeries = append(newSeries, serie) + } + } + return newSeries +} + +// SortSeries sorts a list of series by the number of changesets in each serie, descending +func SortSeries(series []*Serie) []*Serie { + newSeries := make([]*Serie, len(series)) + copy(newSeries, series) + sort.Slice(newSeries, func(i, j int) bool { + // the weight depends on the amount of changesets series changeset size + return len(series[i].ChangeSets) > len(series[j].ChangeSets) + }) + return newSeries +} diff --git a/submitqueue/submitqueue.go b/submitqueue/submitqueue.go new file mode 100644 index 000000000000..cd765acf0f10 --- /dev/null +++ b/submitqueue/submitqueue.go @@ -0,0 +1,220 @@ +package submitqueue + +import ( + "fmt" + + "github.com/tweag/gerrit-queue/gerrit" + + log "github.com/sirupsen/logrus" +) + +// SubmitQueueTag is the tag used to determine whether something +// should be considered by the submit queue or not +const SubmitQueueTag = "submit_me" + +// SubmitQueue contains a list of series, a gerrit connection, and some project configuration +type SubmitQueue struct { + Series []*Serie + gerrit gerrit.IClient + ProjectName string + BranchName string + HEAD string + SubmitQueueTag string // the tag used to submit something to the submit queue +} + +// MakeSubmitQueue builds a new submit queue +func MakeSubmitQueue(gerritClient gerrit.IClient, projectName string, branchName string, submitQueueTag string) SubmitQueue { + return SubmitQueue{ + Series: make([]*Serie, 0), + gerrit: gerritClient, + ProjectName: projectName, + BranchName: branchName, + SubmitQueueTag: submitQueueTag, + } +} + +// LoadSeries fills Series by searching and filtering changesets, and assembling them to Series. +func (s *SubmitQueue) LoadSeries() error { + // Normally, we'd like to use a queryString like + // "status:open project:myproject branch:mybranch hashtag:submitQueueTag label:verified=+1 label:code-review=+2" + // to avoid filtering client-side + // Due to https://github.com/andygrunwald/go-gerrit/issues/71, + // we need to do this on the client (filterChangesets) + var queryString = fmt.Sprintf("status:open project:%s branch:%s", s.ProjectName, s.BranchName) + log.Debugf("Running query %s", queryString) + + // Download changesets from gerrit + changesets, err := s.gerrit.SearchChangesets(queryString) + if err != nil { + return err + } + // // Filter to contain the SubmitQueueTag + // changesets = gerrit.FilterChangesets(changesets, func(c *gerrit.Changeset) bool { + // return c.HasTag(SubmitQueueTag) + // }) + // Filter to be code reviewed and verified + changesets = gerrit.FilterChangesets(changesets, func(c *gerrit.Changeset) bool { + return c.IsCodeReviewed && c.IsVerified + }) + + // Assemble to series + series, err := AssembleSeries(changesets) + if err != nil { + return err + } + + // Sort by size + s.Series = SortSeries(series) + return nil +} + +// UpdateHEAD updates the HEAD field with the commit ID of the current HEAD +func (s *SubmitQueue) UpdateHEAD() error { + HEAD, err := s.gerrit.GetHEAD(s.ProjectName, s.BranchName) + if err != nil { + return err + } + s.HEAD = HEAD + return nil +} + +// TODO: clear submit queue tag if missing +1/+2? + +// DoSubmit submits changes that can be submitted, +// and updates `Series` to contain the remaining ones +// Also updates `BranchCommitID`. +func (s *SubmitQueue) DoSubmit() error { + var remainingSeries []*Serie + + for _, serie := range s.Series { + serieParentCommitIDs, err := serie.GetParentCommitIDs() + if err != nil { + return err + } + // we can only submit series with a single parent commit (otherwise they're not rebased) + if len(serieParentCommitIDs) != 1 { + return fmt.Errorf("%s has more than one parent commit", serie.String()) + } + // if serie is rebased on top of current master… + if serieParentCommitIDs[0] == s.HEAD { + // submit the last changeset of the series, which submits intermediate ones too + _, err := s.gerrit.SubmitChangeset(serie.ChangeSets[len(serie.ChangeSets)-1]) + if err != nil { + // this might fail, for various reasons: + // - developers could have updated the changeset meanwhile, clearing +1/+2 bits + // - master might have advanced, so this changeset isn't rebased on top of master + // TODO: we currently bail out entirely, but should be fine on the + // next loop. We might later want to improve the logic to be a bit more + // smarter (like log and try with the next one) + return err + } + // advance head to the leaf of the current serie for the next iteration + newHead, err := serie.GetLeafCommitID() + if err != nil { + return err + } + s.HEAD = newHead + } else { + remainingSeries = append(remainingSeries, serie) + } + } + + s.Series = remainingSeries + return nil +} + +// DoRebase rebases all remaining series on top of each other +// they should still be ordered by series size +// TODO: this will produce a very large series on the next run, so we might want to preserve individual series over multiple runs +func (s *SubmitQueue) DoRebase() error { + newSeries := make([]*Serie, len(s.Series)) + futureHEAD := s.HEAD + for _, serie := range s.Series { + //TODO: don't rebase everything, just pick a "good candidate" + + logger := log.WithFields(log.Fields{ + "serie": serie, + }) + logger.Infof("rebasing %s on top of %s", serie, futureHEAD) + newSerie, err := s.RebaseSerie(serie, futureHEAD) + if err != nil { + logger.Warnf("unable to rebase serie %s", err) + // TODO: we want to skip on trivial rebase errors instead of bailing out. + // skip means adding that serie as it is to newSeries, without advancing previousLeafCommitId + + // TODO: we also should talk about when to remove the submit-queue tag + // just because we scheduled a conflicting submit plan, doesn't mean this is not submittable. + // so just removing the submit-queue tag would be unfair + return err + } + newSeries = append(newSeries, newSerie) + + // prepare for next iteration + futureHEAD, err = newSerie.GetLeafCommitID() + if err != nil { + // This should never happen + logger.Errorf("new serie shouldn't be empty: %s", newSerie) + return err + } + + } + s.Series = newSeries + return nil +} + +// Run starts the submit and rebase logic. +func (s *SubmitQueue) Run() error { + //TODO: log decisions made and add to some ring buffer + var err error + + commitID, err := s.gerrit.GetHEAD(s.ProjectName, s.BranchName) + if err != nil { + log.Errorf("Unable to retrieve HEAD of branch %s at project %s: %s", s.BranchName, s.ProjectName, err) + return err + } + s.HEAD = commitID + + err = s.LoadSeries() + if err != nil { + return err + } + if len(s.Series) == 0 { + // Nothing to do! + log.Warn("Nothing to do here") + return nil + } + err = s.DoSubmit() + if err != nil { + return err + } + err = s.DoRebase() + if err != nil { + return err + } + return nil +} + +// RebaseSerie rebases a whole serie on top of a given ref +// TODO: only rebase a single changeset. we don't really want to join disconnected series, by rebasing them on top of each other. +func (s *SubmitQueue) RebaseSerie(serie *Serie, ref string) (*Serie, error) { + newSeries := &Serie{ + ChangeSets: make([]*gerrit.Changeset, len(serie.ChangeSets)), + } + + rebaseOnto := ref + for _, changeset := range serie.ChangeSets { + newChangeset, err := s.gerrit.RebaseChangeset(changeset, rebaseOnto) + + if err != nil { + // uh-oh… + // TODO: think about error handling + // TODO: remove the submit queue tag if the rebase fails (but only then, not on other errors) + return newSeries, err + } + newSeries.ChangeSets = append(newSeries.ChangeSets, newChangeset) + + // the next changeset should be rebased on top of the current commit + rebaseOnto = newChangeset.CommitID + } + return newSeries, nil +} diff --git a/templates/submit-queue.tmpl.html b/templates/submit-queue.tmpl.html new file mode 100644 index 000000000000..f1320a0c2dde --- /dev/null +++ b/templates/submit-queue.tmpl.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html> +<head> + <title>Gerrit Submit Queue</title> + <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha256-CjSoeELFOcH0/uxWu6mC/Vlrc1AARqbm/jiiImDGV3s=" crossorigin="anonymous"></script> + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha256-YLGeXaapI0/5IgZopewRJcFXomhRMlYYjugPLSyNjTY=" crossorigin="anonymous" /> +</head> +<body> + <h1>Gerrit Submit Queue</h1> + <h2>{{ .projectName }}/{{ .branchName }} is at {{ printf "%.7s" .HEAD }}</h2> + <h2>Current Queue:</h2> + {{ range $serie := .series }} + <div class="list-group"> + {{ range $changeset := $serie.ChangeSets}} + <div class="list-group-item"> + <div class="d-flex w-100 justify-content-between"> + <h5>{{ $changeset.Subject }}</h5> + <small>#{{ $changeset.Number }}</small> + </div> + <div class="d-flex w-100 justify-content-between"> + <small>{{ $changeset.OwnerName }}</small> + <span> + {{ if $changeset.IsVerified }}<span class="badge badge-success badge-pill">+1 (CI)</span>{{ end }} + {{ if $changeset.IsCodeReviewed }}<span class="badge badge-info badge-pill">+2 (CR)</span>{{ end }} + </span> + </div> + <div> + <code> + {{ $changeset.CommitID }} + </code> + </div> + </div> + {{ end }} + </div> + <br /> + {{ end }} +</body> +</html> diff --git a/views/index.html b/views/index.html new file mode 100644 index 000000000000..67d9bcbae59b --- /dev/null +++ b/views/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> + +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> + <title>Submit Queue</title> +</head> + +<body> +</body> + +</html> |