about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--frontend/frontend.go53
-rw-r--r--gerrit/changeset.go107
-rw-r--r--gerrit/client.go119
-rw-r--r--go.mod11
-rw-r--r--go.sum49
-rw-r--r--main.go138
-rw-r--r--submitqueue/serie.go105
-rw-r--r--submitqueue/series.go125
-rw-r--r--submitqueue/submitqueue.go220
-rw-r--r--templates/submit-queue.tmpl.html38
-rw-r--r--views/index.html13
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>