about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFlorian Klink <flokli@flokli.de>2019-12-02T09·00+0100
committerFlorian Klink <flokli@flokli.de>2019-12-02T09·00+0100
commit04a24a0c601c28d01e1110fb82f57c7a7c3f5d74 (patch)
treeabe8872d565a82db38167b07b0a4526883031983
parent7bafef7a848cc80c79551441630210e947b2481b (diff)
Use Runner
This revamps code quite a bit. Series handling has been moved into the
gerrit client, it also handles caching.

The Runner logic itself has been greatly simplified.

The runner logic has been moved into the runner.go, submitqueue.go is
gone.

The "per-run result object" concept has been dropped - we instead just
use annotated logs.

Also, we switched to apex/log
-rw-r--r--frontend/frontend.go40
-rw-r--r--gerrit/changeset.go55
-rw-r--r--gerrit/client.go173
-rw-r--r--gerrit/serie.go (renamed from submitqueue/serie.go)14
-rw-r--r--gerrit/series.go (renamed from submitqueue/series.go)35
-rw-r--r--go.mod2
-rw-r--r--go.sum58
-rw-r--r--main.go49
-rw-r--r--public/serie.tmpl.html2
-rw-r--r--public/series.tmpl.html16
-rw-r--r--public/submit-queue.tmpl.html82
-rw-r--r--submitqueue/result.go55
-rw-r--r--submitqueue/runner.go207
-rw-r--r--submitqueue/submitqueue.go231
14 files changed, 484 insertions, 535 deletions
diff --git a/frontend/frontend.go b/frontend/frontend.go
index eef246ea380a..abb5a3db4f70 100644
--- a/frontend/frontend.go
+++ b/frontend/frontend.go
@@ -13,9 +13,9 @@ import (
 	"github.com/tweag/gerrit-queue/gerrit"
 	_ "github.com/tweag/gerrit-queue/statik" // register static assets
 	"github.com/tweag/gerrit-queue/submitqueue"
-)
 
-//TODO: log last update
+	"github.com/apex/log/handlers/memory"
+)
 
 //loadTemplate loads a list of templates, relative to the statikFS root, and a FuncMap, and returns a template object
 func loadTemplate(templateNames []string, funcMap template.FuncMap) (*template.Template, error) {
@@ -47,46 +47,48 @@ func loadTemplate(templateNames []string, funcMap template.FuncMap) (*template.T
 	return tmpl, nil
 }
 
-// MakeFrontend configures the router and returns a new Frontend struct
-func MakeFrontend(runner *submitqueue.Runner) http.Handler {
+// MakeFrontend returns a http.Handler
+func MakeFrontend(memoryHandler *memory.Handler, gerritClient *gerrit.Client, runner *submitqueue.Runner) http.Handler {
 	router := gin.Default()
 
-	router.GET("/submit-queue.json", func(c *gin.Context) {
-		submitQueue, _, _ := runner.GetState()
-		c.JSON(http.StatusOK, submitQueue)
-	})
+	projectName := gerritClient.GetProjectName()
+	branchName := gerritClient.GetBranchName()
 
 	router.GET("/", func(c *gin.Context) {
-		submitQueue, currentlyRunning, results := runner.GetState()
+		var wipSerie *gerrit.Serie = nil
+		HEAD := ""
+		currentlyRunning := runner.IsCurrentlyRunning()
+
+		// don't trigger operations requiring a lock
+		if !currentlyRunning {
+			wipSerie = runner.GetWIPSerie()
+			HEAD = gerritClient.GetHEAD()
+		}
 
 		funcMap := template.FuncMap{
-			"isAutoSubmittable": func(serie *submitqueue.Serie) bool {
-				return submitQueue.IsAutoSubmittable(serie)
-			},
 			"changesetURL": func(changeset *gerrit.Changeset) string {
-				return submitQueue.GetChangesetURL(changeset)
+				return gerritClient.GetChangesetURL(changeset)
 			},
 		}
 
 		tmpl := template.Must(loadTemplate([]string{
 			"submit-queue.tmpl.html",
-			"series.tmpl.html",
 			"serie.tmpl.html",
 			"changeset.tmpl.html",
 		}, funcMap))
 
 		tmpl.ExecuteTemplate(c.Writer, "submit-queue.tmpl.html", gin.H{
 			// Config
-			"projectName": submitQueue.ProjectName,
-			"branchName":  submitQueue.BranchName,
+			"projectName": projectName,
+			"branchName":  branchName,
 
 			// State
 			"currentlyRunning": currentlyRunning,
-			"series":           submitQueue.Series,
-			"HEAD":             submitQueue.HEAD,
+			"wipSerie":         wipSerie,
+			"HEAD":             HEAD,
 
 			// History
-			"results": results,
+			"memory": memoryHandler,
 		})
 	})
 	return router
diff --git a/gerrit/changeset.go b/gerrit/changeset.go
index 38a489ec7dd9..71310a712514 100644
--- a/gerrit/changeset.go
+++ b/gerrit/changeset.go
@@ -5,7 +5,7 @@ import (
 	"fmt"
 
 	goGerrit "github.com/andygrunwald/go-gerrit"
-	log "github.com/sirupsen/logrus"
+	"github.com/apex/log"
 )
 
 // Changeset represents a single changeset
@@ -14,8 +14,8 @@ type Changeset struct {
 	changeInfo      *goGerrit.ChangeInfo
 	ChangeID        string
 	Number          int
-	IsVerified      bool
-	IsCodeReviewed  bool
+	Verified        int
+	CodeReviewed    int
 	HashTags        []string
 	CommitID        string
 	ParentCommitIDs []string
@@ -29,8 +29,8 @@ func MakeChangeset(changeInfo *goGerrit.ChangeInfo) *Changeset {
 		changeInfo:      changeInfo,
 		ChangeID:        changeInfo.ChangeID,
 		Number:          changeInfo.Number,
-		IsVerified:      isVerified(changeInfo),
-		IsCodeReviewed:  isCodeReviewed(changeInfo),
+		Verified:        labelInfoToInt(changeInfo.Labels["Verified"]),
+		CodeReviewed:    labelInfoToInt(changeInfo.Labels["Code-Review"]),
 		HashTags:        changeInfo.Hashtags,
 		CommitID:        changeInfo.CurrentRevision, // yes, this IS the commit ID.
 		ParentCommitIDs: getParentCommitIDs(changeInfo),
@@ -39,12 +39,6 @@ func MakeChangeset(changeInfo *goGerrit.ChangeInfo) *Changeset {
 	}
 }
 
-// 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
@@ -56,6 +50,18 @@ func (c *Changeset) HasTag(tag string) bool {
 	return false
 }
 
+// IsVerified returns true if the changeset passed CI,
+// that's when somebody left the Approved (+1) on the "Verified" label
+func (c *Changeset) IsVerified() bool {
+	return c.Verified == 1
+}
+
+// IsCodeReviewed returns true if the changeset passed code review,
+// that's when somebody left the Recommended (+2) on the "Code-Review" label
+func (c *Changeset) IsCodeReviewed() bool {
+	return c.CodeReviewed == 2
+}
+
 func (c *Changeset) String() string {
 	var b bytes.Buffer
 	b.WriteString("Changeset")
@@ -76,18 +82,21 @@ func FilterChangesets(changesets []*Changeset, f func(*Changeset) bool) []*Chang
 	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
+// labelInfoToInt converts a goGerrit.LabelInfo to -2…+2 int
+func labelInfoToInt(labelInfo goGerrit.LabelInfo) int {
+	if labelInfo.Recommended.AccountID != 0 {
+		return 2
+	}
+	if labelInfo.Approved.AccountID != 0 {
+		return 1
+	}
+	if labelInfo.Disliked.AccountID != 0 {
+		return -1
+	}
+	if labelInfo.Rejected.AccountID != 0 {
+		return -2
+	}
+	return 0
 }
 
 // getParentCommitIDs returns the parent commit IDs of the goGerrit.ChangeInfo
diff --git a/gerrit/client.go b/gerrit/client.go
index c65b5016c1ff..fd8a244151ab 100644
--- a/gerrit/client.go
+++ b/gerrit/client.go
@@ -1,35 +1,52 @@
 package gerrit
 
 import (
+	"fmt"
+
 	goGerrit "github.com/andygrunwald/go-gerrit"
+	"github.com/apex/log"
 
 	"net/url"
 )
 
 // passed to gerrit when retrieving changesets
-var additionalFields = []string{"LABELS", "CURRENT_REVISION", "CURRENT_COMMIT", "DETAILED_ACCOUNTS"}
+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)
+	Refresh() error
+	GetHEAD() string
+	GetBaseURL() string
+	GetChangesetURL(changeset *Changeset) string
 	SubmitChangeset(changeset *Changeset) (*Changeset, error)
 	RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error)
 	RemoveTag(changeset *Changeset, tag string) (*Changeset, error)
-	GetBaseURL() string
+	ChangesetIsRebasedOnHEAD(changeset *Changeset) bool
+	SerieIsRebasedOnHEAD(serie *Serie) bool
+	FilterSeries(filter func(s *Serie) bool) []*Serie
+	FindSerie(filter func(s *Serie) bool) *Serie
 }
 
 var _ IClient = &Client{}
 
 // Client provides some ways to interact with a gerrit instance
 type Client struct {
-	client  *goGerrit.Client
-	baseURL string
+	client      *goGerrit.Client
+	logger      *log.Logger
+	baseURL     string
+	projectName string
+	branchName  string
+	series      []*Serie
+	head        string
 }
 
 // NewClient initializes a new gerrit client
-func NewClient(URL, username, password string) (*Client, error) {
+func NewClient(logger *log.Logger, URL, username, password, projectName, branchName string) (*Client, error) {
 	urlParsed, err := url.Parse(URL)
 	if err != nil {
 		return nil, err
@@ -43,17 +60,58 @@ func NewClient(URL, username, password string) (*Client, error) {
 	return &Client{
 		client:  goGerritClient,
 		baseURL: URL,
+		logger:  logger,
 	}, nil
 }
 
-// SearchChangesets fetches a list of changesets matching a passed query string
-func (gerrit *Client) SearchChangesets(queryString string) (changesets []*Changeset, Error error) {
+// refreshHEAD queries the commit ID of the selected project and branch
+func (c *Client) refreshHEAD() (string, error) {
+	branchInfo, _, err := c.client.Projects.GetBranch(c.projectName, c.branchName)
+	if err != nil {
+		return "", err
+	}
+	return branchInfo.Revision, nil
+}
+
+// GetHEAD returns the internally stored HEAD
+func (c *Client) GetHEAD() string {
+	return c.head
+}
+
+// Refresh causes the client to refresh internal view of gerrit
+func (c *Client) Refresh() error {
+	c.logger.Debug("refreshing from gerrit")
+	HEAD, err := c.refreshHEAD()
+	if err != nil {
+		return err
+	}
+	c.head = HEAD
+
+	var queryString = fmt.Sprintf("status:open project:%s branch:%s", c.projectName, c.branchName)
+	c.logger.Debugf("fetching changesets: %s", queryString)
+	changesets, err := c.fetchChangesets(queryString)
+	if err != nil {
+		return err
+	}
+
+	c.logger.Warnf("assembling series…")
+	series, err := AssembleSeries(changesets, c.logger)
+	if err != nil {
+		return err
+	}
+	series = SortSeries(series)
+	c.series = series
+	return nil
+}
+
+// fetchChangesets fetches a list of changesets matching a passed query string
+func (c *Client) fetchChangesets(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)
+	opt.AdditionalFields = additionalFields
+	changes, _, err := c.client.Changes.QueryChanges(opt)
 	if err != nil {
 		return nil, err
 	}
@@ -66,22 +124,13 @@ func (gerrit *Client) SearchChangesets(queryString string) (changesets []*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
+// fetchChangeset 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) {
+func (c *Client) fetchChangeset(changeID string) (*Changeset, error) {
 	opt := goGerrit.ChangeOptions{}
 	opt.AdditionalFields = []string{"LABELS", "DETAILED_ACCOUNTS"}
-	changeInfo, _, err := gerrit.client.Changes.GetChange(changeID, &opt)
+	changeInfo, _, err := c.client.Changes.GetChange(changeID, &opt)
 	if err != nil {
 		return nil, err
 	}
@@ -89,28 +138,30 @@ func (gerrit *Client) GetChangeset(changeID string) (*Changeset, error) {
 }
 
 // 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{})
+// TODO: update HEAD
+func (c *Client) SubmitChangeset(changeset *Changeset) (*Changeset, error) {
+	changeInfo, _, err := c.client.Changes.SubmitChange(changeset.ChangeID, &goGerrit.SubmitInput{})
 	if err != nil {
 		return nil, err
 	}
-	return gerrit.GetChangeset(changeInfo.ChangeID)
+	return c.fetchChangeset(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{
+// TODO: update HEAD
+func (c *Client) RebaseChangeset(changeset *Changeset, ref string) (*Changeset, error) {
+	changeInfo, _, err := c.client.Changes.RebaseChange(changeset.ChangeID, &goGerrit.RebaseInput{
 		Base: ref,
 	})
 	if err != nil {
 		return changeset, err
 	}
-	return gerrit.GetChangeset(changeInfo.ChangeID)
+	return c.fetchChangeset(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) {
+func (c *Client) RemoveTag(changeset *Changeset, tag string) (*Changeset, error) {
 	hashTags := changeset.HashTags
 	newHashTags := []string{}
 	for _, hashTag := range hashTags {
@@ -118,12 +169,66 @@ func (gerrit *Client) RemoveTag(changeset *Changeset, tag string) (*Changeset, e
 			newHashTags = append(newHashTags, hashTag)
 		}
 	}
-	// TODO: implement set hashtags api in go-gerrit and use here
+	// TODO: implement setting hashtags api in go-gerrit and use here
 	// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#set-hashtags
 	return changeset, nil
 }
 
 // GetBaseURL returns the gerrit base URL
-func (gerrit *Client) GetBaseURL() string {
-	return gerrit.baseURL
+func (c *Client) GetBaseURL() string {
+	return c.baseURL
+}
+
+// GetProjectName returns the configured gerrit project name
+func (c *Client) GetProjectName() string {
+	return c.projectName
+}
+
+// GetBranchName returns the configured gerrit branch name
+func (c *Client) GetBranchName() string {
+	return c.branchName
+}
+
+// GetChangesetURL returns the URL to view a given changeset
+func (c *Client) GetChangesetURL(changeset *Changeset) string {
+	return fmt.Sprintf("%s/c/%s/+/%d", c.GetBaseURL(), c.projectName, changeset.Number)
+}
+
+// ChangesetIsRebasedOnHEAD returns true if the changeset is rebased on the current HEAD
+func (c *Client) ChangesetIsRebasedOnHEAD(changeset *Changeset) bool {
+	if len(changeset.ParentCommitIDs) != 1 {
+		return false
+	}
+	return changeset.ParentCommitIDs[0] == c.head
+}
+
+// SerieIsRebasedOnHEAD returns true if the whole series is rebased on the current HEAD
+// this is already the case if the first changeset in the series is rebased on the current HEAD
+func (c *Client) SerieIsRebasedOnHEAD(serie *Serie) bool {
+	// an empty serie should not exist
+	if len(serie.ChangeSets) == 0 {
+		return false
+	}
+	return c.ChangesetIsRebasedOnHEAD(serie.ChangeSets[0])
+}
+
+// FilterSeries returns a subset of all Series, passing the given filter function
+func (c *Client) FilterSeries(filter func(s *Serie) bool) []*Serie {
+	matchedSeries := []*Serie{}
+	for _, serie := range c.series {
+		if filter(serie) {
+			matchedSeries = append(matchedSeries, serie)
+		}
+	}
+	return matchedSeries
+}
+
+// FindSerie returns the first serie that matches the filter, or nil if none was found
+func (c *Client) FindSerie(filter func(s *Serie) bool) *Serie {
+	for _, serie := range c.series {
+		if filter(serie) {
+			return serie
+		}
+	}
+	return nil
 }
diff --git a/submitqueue/serie.go b/gerrit/serie.go
index d4cd739cdfdd..788cf46f4ea6 100644
--- a/submitqueue/serie.go
+++ b/gerrit/serie.go
@@ -1,18 +1,16 @@
-package submitqueue
+package gerrit
 
 import (
 	"fmt"
 	"strings"
 
-	"github.com/tweag/gerrit-queue/gerrit"
-
-	log "github.com/sirupsen/logrus"
+	"github.com/apex/log"
 )
 
 // Serie represents a list of successive changesets with an unbroken parent -> child relation,
 // starting from the parent.
 type Serie struct {
-	ChangeSets []*gerrit.Changeset
+	ChangeSets []*Changeset
 }
 
 // GetParentCommitIDs returns the parent commit IDs
@@ -33,9 +31,7 @@ func (s *Serie) GetLeafCommitID() (string, error) {
 
 // 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,
-	})
+	logger := log.WithField("serie", s)
 	// an empty serie is invalid
 	if len(s.ChangeSets) == 0 {
 		return fmt.Errorf("An empty serie is invalid")
@@ -71,7 +67,7 @@ func (s *Serie) CheckIntegrity() error {
 
 // FilterAllChangesets applies a filter function on all of the changesets in the series.
 // returns true if it returns true for all changesets, false otherwise
-func (s *Serie) FilterAllChangesets(f func(c *gerrit.Changeset) bool) bool {
+func (s *Serie) FilterAllChangesets(f func(c *Changeset) bool) bool {
 	for _, changeset := range s.ChangeSets {
 		if f(changeset) == false {
 			return false
diff --git a/submitqueue/series.go b/gerrit/series.go
index c607e3ec2135..06d49805d35d 100644
--- a/submitqueue/series.go
+++ b/gerrit/series.go
@@ -1,34 +1,33 @@
-package submitqueue
+package gerrit
 
 import (
 	"sort"
 
-	"github.com/tweag/gerrit-queue/gerrit"
-
-	"github.com/sirupsen/logrus"
+	"github.com/apex/log"
 )
 
 // AssembleSeries consumes a list of `Changeset`, and groups them together to series
 //
+// We initially put every Changeset in its own Serie
+//
 // 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, log *logrus.Logger) ([]*Serie, error) {
+// we maintain a lookup table, mapLeafToSerie,
+// which allows to lookup a serie by its leaf commit id
+// We concat series in a fixpoint approach
+// because both appending and prepending is much more complex.
+// Concatenation moves changesets of the later changeset in the previous one
+// in a cleanup phase, we remove orphaned series (those without any changesets inside)
+// afterwards, we do an integrity check, just to be on the safe side.
+func AssembleSeries(changesets []*Changeset, log *log.Logger) ([]*Serie, error) {
 	series := make([]*Serie, 0)
 	mapLeafToSerie := make(map[string]*Serie, 0)
 
 	for _, changeset := range changesets {
-		logger := log.WithFields(logrus.Fields{
-			"changeset": changeset.String(),
-		})
+		logger := log.WithField("changeset", changeset.String())
 
 		logger.Debug("creating initial serie")
 		serie := &Serie{
-			ChangeSets: []*gerrit.Changeset{changeset},
+			ChangeSets: []*Changeset{changeset},
 		}
 		series = append(series, serie)
 		mapLeafToSerie[changeset.CommitID] = serie
@@ -72,7 +71,7 @@ func AssembleSeries(changesets []*gerrit.Changeset, log *logrus.Logger) ([]*Seri
 				mapLeafToSerie[myLeafCommitID] = otherSerie
 
 				// orphan our serie
-				serie.ChangeSets = []*gerrit.Changeset{}
+				serie.ChangeSets = []*Changeset{}
 				// remove the orphaned serie from the lookup table
 				delete(mapLeafToSerie, myLeafCommitID)
 
@@ -90,9 +89,7 @@ func AssembleSeries(changesets []*gerrit.Changeset, log *logrus.Logger) ([]*Seri
 
 	// Check integrity, just to be on the safe side.
 	for _, serie := range series {
-		logger := log.WithFields(logrus.Fields{
-			"serie": serie.String(),
-		})
+		logger := log.WithField("serie", serie.String())
 		logger.Debugf("checking integrity")
 		err := serie.CheckIntegrity()
 		if err != nil {
diff --git a/go.mod b/go.mod
index c428ff3cb44f..c03e8c43edbd 100644
--- a/go.mod
+++ b/go.mod
@@ -4,9 +4,9 @@ go 1.12
 
 require (
 	github.com/andygrunwald/go-gerrit v0.0.0-20190825170856-5959a9bf9ff8
+	github.com/apex/log v1.1.1
 	github.com/gin-gonic/gin v1.4.0
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/rakyll/statik v0.1.6
-	github.com/sirupsen/logrus v1.4.2
 	github.com/urfave/cli v1.22.1
 )
diff --git a/go.sum b/go.sum
index b5f1dd9f9cc1..12ab50bbd053 100644
--- a/go.sum
+++ b/go.sum
@@ -1,55 +1,87 @@
 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/apex/log v1.1.1 h1:BwhRZ0qbjYtTob0I+2M+smavV0kOC8XgcnGZcyL9liA=
+github.com/apex/log v1.1.1/go.mod h1:Ls949n1HFtXfbDcjiTTFQqkVUrte0puoIBfO3SVgwOA=
+github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE=
+github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=
+github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
+github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 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/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 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/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
+github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
 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/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
+github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
+github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rakyll/statik v0.1.6 h1:uICcfUXpgqtw2VopbIncslhAmE5hwc4g20TEyEENBNs=
 github.com/rakyll/statik v0.1.6/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs=
+github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 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/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 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/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
+github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=
+github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=
 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 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
+github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
+github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
+github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
 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 h1:uOCk1iQW6Vc18bnC13MfzScl+wdKBmM9Y9kU7Z83/lw=
+golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 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/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 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
index 14320276377f..1be379904b29 100644
--- a/main.go
+++ b/main.go
@@ -14,16 +14,13 @@ import (
 
 	"github.com/urfave/cli"
 
-	log "github.com/sirupsen/logrus"
+	"github.com/apex/log"
+	"github.com/apex/log/handlers/memory"
+	"github.com/apex/log/handlers/multi"
+	"github.com/apex/log/handlers/text"
 )
 
 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
 
@@ -81,25 +78,33 @@ func main() {
 		},
 	}
 
+	memoryLogHandler := memory.New()
+	l := &log.Logger{
+		Handler: multi.New(
+			text.New(os.Stderr),
+			memoryLogHandler,
+		),
+		Level: log.DebugLevel,
+	}
+
 	app.Action = func(c *cli.Context) error {
-		gerritClient, err := gerrit.NewClient(URL, username, password)
+		gerrit, err := gerrit.NewClient(l, URL, username, password, projectName, branchName)
 		if err != nil {
 			return err
 		}
-		log.Printf("Successfully connected to gerrit at %s", URL)
+		log.Infof("Successfully connected to gerrit at %s", URL)
 
-		submitQueue := submitqueue.MakeSubmitQueue(gerritClient, projectName, branchName, submitQueueTag)
-		runner := submitqueue.NewRunner(submitQueue)
+		runner := submitqueue.NewRunner(l, gerrit, submitQueueTag)
 
-		handler := frontend.MakeFrontend(runner)
+		handler := frontend.MakeFrontend(memoryLogHandler, gerrit, runner)
 
 		// fetch only on first run
-		runner.Trigger(true)
+		runner.Trigger(fetchOnly)
 
 		// ticker
 		go func() {
 			for {
-				time.Sleep(time.Minute * 10)
+				time.Sleep(time.Minute * 5)
 				runner.Trigger(fetchOnly)
 			}
 		}()
@@ -111,7 +116,7 @@ func main() {
 
 		server.ListenAndServe()
 		if err != nil {
-			log.Fatal(err)
+			log.Fatalf(err.Error())
 		}
 
 		return nil
@@ -119,21 +124,9 @@ func main() {
 
 	err := app.Run(os.Args)
 	if err != nil {
-		log.Fatal(err)
+		log.Fatal(err.Error())
 	}
 
-	// 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/public/serie.tmpl.html b/public/serie.tmpl.html
index 7db666c173ec..8c653946ce7e 100644
--- a/public/serie.tmpl.html
+++ b/public/serie.tmpl.html
@@ -1,6 +1,6 @@
 {{ define "serie" }}
 <tr>
-<td colspan="3" class="{{ if not (. | isAutoSubmittable) }}table-primary{{ else }}table-success{{ end }}">Serie with {{ len .ChangeSets }} changes</td>
+<td colspan="3" class="table-success">Serie with {{ len .ChangeSets }} changes</td>
 </tr>
 {{ range $changeset := .ChangeSets }}
 {{ block "changeset" $changeset }}{{ end }}
diff --git a/public/series.tmpl.html b/public/series.tmpl.html
deleted file mode 100644
index 7e5ffd49c79a..000000000000
--- a/public/series.tmpl.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{{ define "series" }}
-<table class="table table-sm table-hover">
-<thead class="thead-light">
-    <tr>
-    <th scope="col">Owner</th>
-    <th scope="col">Changeset</th>
-    <th scope="col">Flags</th>
-    </tr>
-</thead>
-<tbody>
-    {{ range $serie := . }}
-    {{ block "serie" $serie }}{{ end }}
-    {{ end }}
-</tbody>
-</table>
-{{ end }}
\ No newline at end of file
diff --git a/public/submit-queue.tmpl.html b/public/submit-queue.tmpl.html
index 27d8e9fdc69e..f64cdf24a170 100644
--- a/public/submit-queue.tmpl.html
+++ b/public/submit-queue.tmpl.html
@@ -42,63 +42,37 @@
         </tr>
         <tr>
           <th scope="row">Currently running:</th>
-	  <td>
-	    {{ if .currentlyRunning }}
-	    started at {{ .currentlyRunning.Format "2006-01-02 15:04:05 UTC" }}
-	    {{ else }}
-	    <span class="text-secondary">Not currently running</span>
-	    {{ end }}
-	  </td>
+          <td>
+            {{ if .currentlyRunning }}yes{{ else }}no{{ end }}
+          </td>
+        </tr>
+        <tr>
+          <th scope="row">HEAD:</th>
+          <td>
+            {{ if .currentlyRunning }}{{ .HEAD }}{{ else }}-{{ end }}
+          </td>
         </tr>
       </tbody>
     </table>
-    <h2 id="region-log">Log</h2>
 
-    <div id="history-accordion">
-        {{ range $i, $result := .results }}
-        <div class="card">
-          <div class="card-header">
-            <h5>
-              <button class="btn btn-link" data-toggle="collapse" data-target="#history-collapse-{{ $i }}">
-                Result Item {{ $i }}, {{ $result.StartTime.Format "2006-01-02 15:04:05 UTC"}} - {{ $result.EndTime.Format "2006-01-02 15:04:05 UTC"}}
-              </button>
-            </h5>
-          </div>
-          <div id="history-collapse-{{ $i }}" class="collapse {{ if eq $i 0 }} show{{ end }}" data-parent="#history-accordion">
-            <div class="card-body">
-              <table class="table">
-                <tbody>
-                  <tr>
-                    <th scope="row">HEAD:</th>
-                    <td><code>{{ .HEAD }}</code></td>
-                  </tr>
-                  {{ if $result.Error }}
-                  <tr>
-                    <th scope="row">Error:</th>
-                    <td class="text-danger">{{ $result.Error }}</td>
-                    </td>
-                  </tr>
-                  {{ end }}
-                  <tr>
-                    <th scope="row">Log:</th>
-                    <td class="bg-dark text-white">
-                        {{ range $logEntry := $result.LogEntries }}
-                        <code>{{ $logEntry }}</code><br />
-                        {{ end }}
-                    </td>
-                  </tr>
-                  <tr>
-                    <td colspan="2">
-                      {{ block "series" $result.Series}}{{ end }}
-                    </td>
-                  </tr>
-                </tbody>
-              </table>
-            </div>
-          </div>
-        </div>
-        {{ end }}
-    </div>
-  </div> <!-- .container -->
+    <h2 id="region-wipserie">wip Serie</h2>
+    {{ if .wipSerie }}
+    {{ block "serie" .wipSerie }}{{ end }}
+    {{ else }}
+    - 
+    {{ end }}
+
+    <h2 id="region-log">Log</h2>
+    <table class="table">
+      <tbody>
+        <tr>
+          <td class="bg-dark text-white">
+            {{ range $entry := .memory.Entries }}
+            <code>{{ $entry }}</code><br />
+            {{ end }}
+          </td>
+        </tr>
+      </tbody>
+    </table>
 </body>
 </html>
diff --git a/submitqueue/result.go b/submitqueue/result.go
deleted file mode 100644
index a15c2968f30d..000000000000
--- a/submitqueue/result.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package submitqueue
-
-import (
-	"time"
-
-	"github.com/sirupsen/logrus"
-)
-
-// Problem: no inspection during the run
-// Problem: record the state
-
-// Result contains all data necessary to inspect a previous run
-// This includes the Series from that run, and all Log Entries collected.
-// It also implements the interface required for logrus.Hook.
-type Result struct {
-	LogEntries []*logrus.Entry
-	Series     []Serie
-	Error      error
-	startTime  time.Time
-	HEAD       string
-}
-
-// MakeResult produces a new Result struct,
-// and initializes startTime with the current time.
-func MakeResult() *Result {
-	return &Result{
-		startTime: time.Now(),
-	}
-}
-
-// StartTime returns the startTime
-func (r Result) StartTime() time.Time {
-	return r.startTime
-}
-
-// EndTime returns the time of the latest log entry
-func (r Result) EndTime() time.Time {
-	if len(r.LogEntries) == 0 {
-		return r.startTime
-	}
-	return r.LogEntries[len(r.LogEntries)-1].Time
-}
-
-// Fire is called by logrus on each log event,
-// we collect all log entries in the struct variable
-func (r *Result) Fire(entry *logrus.Entry) error {
-	r.LogEntries = append(r.LogEntries, entry)
-	return nil
-}
-
-// Levels is called by logrus to determine whether to Fire the handler.
-// As we want to collect all log entries, we return logrus.AllLevels
-func (r *Result) Levels() []logrus.Level {
-	return logrus.AllLevels
-}
diff --git a/submitqueue/runner.go b/submitqueue/runner.go
index 4fd9134a0236..d85467b63d5d 100644
--- a/submitqueue/runner.go
+++ b/submitqueue/runner.go
@@ -1,60 +1,203 @@
 package submitqueue
 
 import (
+	"fmt"
 	"sync"
-	"time"
+
+	"github.com/apex/log"
+
+	"github.com/tweag/gerrit-queue/gerrit"
 )
 
-// Runner supervises the submit queue and records historical data about it
+// Runner is a struct existing across the lifetime of a single run of the submit queue
+// it contains a mutex to avoid being run multiple times.
+// In fact, it even cancels runs while another one is still in progress.
+// It contains a Gerrit object facilitating access, a log object, the configured submit queue tag
+// and a `wipSerie` (only populated if waiting for a rebase)
 type Runner struct {
 	mut              sync.Mutex
-	submitQueue      *SubmitQueue
-	currentlyRunning *time.Time
-	results          []*Result
+	currentlyRunning bool
+	wipSerie         *gerrit.Serie
+	logger           *log.Logger
+	gerrit           *gerrit.Client
+	submitQueueTag   string // the tag used to submit something to the submit queue
 }
 
-// NewRunner initializes a new runner object
-func NewRunner(sq *SubmitQueue) *Runner {
+// NewRunner creates a new Runner struct
+func NewRunner(logger *log.Logger, gerrit *gerrit.Client, submitQueueTag string) *Runner {
 	return &Runner{
-		submitQueue: sq,
-		results:     []*Result{},
+		logger:         logger,
+		gerrit:         gerrit,
+		submitQueueTag: submitQueueTag,
 	}
 }
 
-// GetState returns a copy of all the state for the frontend
-func (r *Runner) GetState() (SubmitQueue, *time.Time, []*Result) {
+// isAutoSubmittable determines if something could be autosubmitted, potentially requiring a rebase
+// for this, it needs to:
+//  * have the auto-submit label
+//  * has +2 review
+//  * has +1 CI
+func (r *Runner) isAutoSubmittable(s *gerrit.Serie) bool {
+	for _, c := range s.ChangeSets {
+		if c.Verified != 1 || c.CodeReviewed != 2 || !c.HasTag(r.submitQueueTag) {
+			return false
+		}
+	}
+	return true
+}
+
+// IsCurrentlyRunning returns true if the runner is currently running
+func (r *Runner) IsCurrentlyRunning() bool {
+	return r.currentlyRunning
+}
+
+// GetWIPSerie returns the current wipSerie, if any, nil otherwiese
+// Acquires a lock, so check with IsCurrentlyRunning first
+func (r *Runner) GetWIPSerie() *gerrit.Serie {
 	r.mut.Lock()
-	defer r.mut.Unlock()
-	return *r.submitQueue, r.currentlyRunning, r.results
+	defer func() {
+		r.mut.Unlock()
+	}()
+	return r.wipSerie
 }
 
-// Trigger starts a new batch job
-// TODO: make sure only one batch job is started at the same time
-// if a batch job is already started, ignore the newest request
-// TODO: be more granular in dry-run mode
-func (r *Runner) Trigger(fetchOnly bool) {
+// Trigger gets triggered periodically
+func (r *Runner) Trigger(fetchOnly bool) error {
+	// TODO: If CI fails, remove the auto-submit labels => rules.pl
+	// Only one trigger can run at the same time
 	r.mut.Lock()
-	if r.currentlyRunning != nil {
-		return
+	if r.currentlyRunning {
+		return fmt.Errorf("Already running, skipping")
 	}
-	now := time.Now()
-	r.currentlyRunning = &now
+	r.currentlyRunning = true
 	r.mut.Unlock()
-
 	defer func() {
 		r.mut.Lock()
-		r.currentlyRunning = nil
+		r.currentlyRunning = false
 		r.mut.Unlock()
 	}()
 
-	result := r.submitQueue.Run(fetchOnly)
+	// isReady means a series is auto submittbale and rebased on HEAD
+	isReady := func(s *gerrit.Serie) bool {
+		return r.isAutoSubmittable(s) && r.gerrit.SerieIsRebasedOnHEAD(s)
+	}
 
-	r.mut.Lock()
-	// drop tail if size > 10
-	if len(r.results) > 10 {
-		r.results = append([]*Result{result}, r.results[:9]...)
-	} else {
-		r.results = append([]*Result{result}, r.results...)
+	isAwaitingCI := func(s *gerrit.Serie) bool {
+		for _, c := range s.ChangeSets {
+			if !(c.Verified == 0 && c.CodeReviewed != 2 && c.HasTag(r.submitQueueTag)) {
+				return false
+			}
+		}
+		return true
 	}
-	r.mut.Unlock()
+
+	// Prepare the work by creating a local cache of gerrit state
+	r.gerrit.Refresh()
+
+	// early return if we only want to fetch
+	if fetchOnly {
+		return nil
+	}
+
+	if r.wipSerie != nil {
+		// refresh wipSerie with how it looks like in gerrit now
+		wipSerie := r.gerrit.FindSerie(func(s *gerrit.Serie) bool {
+			// the new wipSerie needs to have the same number of changesets
+			if len(r.wipSerie.ChangeSets) != len(s.ChangeSets) {
+				return false
+			}
+			// … and the same ChangeIDs.
+			for idx, c := range s.ChangeSets {
+				if r.wipSerie.ChangeSets[idx].ChangeID != c.ChangeID {
+					return false
+				}
+			}
+			return true
+		})
+		if wipSerie == nil {
+			r.logger.WithField("wipSerie", r.wipSerie).Warn("wipSerie has disappeared")
+			r.wipSerie = nil
+		} else {
+			r.wipSerie = wipSerie
+		}
+	}
+
+	for {
+		// initialize logger
+		r.logger.Info("Running")
+		if r.wipSerie != nil {
+			// if we have a wipSerie
+			l := r.logger.WithField("wipSerie", r.wipSerie)
+			l.Info("Checking wipSerie")
+
+			if !r.gerrit.SerieIsRebasedOnHEAD(r.wipSerie) {
+				// check for chaos monkeys
+				l.Warnf("HEAD has moved to {} while still waiting for wipSerie, discarding it", r.gerrit.GetHEAD())
+				r.wipSerie = nil
+			} else if isAwaitingCI(r.wipSerie) {
+				// the changeset is still awaiting for CI feedback
+				l.Info("keep waiting for wipSerie")
+
+				// break the loop, take a look at it at the next trigger.
+				break
+			} else if isReady(r.wipSerie) {
+				// if the WIP changeset is ready (auto submittable and rebased on HEAD), submit
+				for _, changeset := range r.wipSerie.ChangeSets {
+					_, err := r.gerrit.SubmitChangeset(changeset)
+					if err != nil {
+						l.WithField("changeset", changeset).Error("error submitting changeset")
+						r.wipSerie = nil
+						return err
+					}
+				}
+				r.wipSerie = nil
+			} else {
+				// should never be reached?!
+			}
+		}
+
+		r.logger.Info("Looking for series ready to submit")
+		// Find serie, that:
+		//  * has the auto-submit label
+		//  * has +2 review
+		//  * has +1 CI
+		//  * is rebased on master
+		serie := r.gerrit.FindSerie(isReady)
+		if serie != nil {
+			r.logger.WithField("serie", serie).Info("Found serie to submit without necessary rebase")
+			r.wipSerie = serie
+			continue
+		}
+
+		// Find serie, that:
+		//  * has the auto-submit label
+		//  * has +2 review
+		//  * has +1 CI
+		//  * is NOT rebased on master
+		serie = r.gerrit.FindSerie(r.isAutoSubmittable)
+		if serie == nil {
+			r.logger.Info("nothing to do, going back to sleep.")
+			break
+		}
+
+		l := r.logger.WithField("serie", serie)
+		l.Info("found serie, which needs a rebase")
+		// TODO: move into Client.RebaseSeries function
+		head := r.gerrit.GetHEAD()
+		for _, changeset := range serie.ChangeSets {
+			changeset, err := r.gerrit.RebaseChangeset(changeset, head)
+			if err != nil {
+				l.Error(err.Error())
+				return err
+			}
+			head = changeset.CommitID
+		}
+		// it doesn't matter this serie isn't in its rebased state,
+		// we'll refetch it on the beginning of the next trigger anyways
+		r.wipSerie = serie
+		break
+	}
+
+	r.logger.Info("Run complete")
+	return nil
 }
diff --git a/submitqueue/submitqueue.go b/submitqueue/submitqueue.go
deleted file mode 100644
index eb622db6832d..000000000000
--- a/submitqueue/submitqueue.go
+++ /dev/null
@@ -1,231 +0,0 @@
-package submitqueue
-
-import (
-	"fmt"
-
-	"github.com/tweag/gerrit-queue/gerrit"
-
-	"github.com/sirupsen/logrus"
-)
-
-// 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
-	URL            string
-}
-
-// 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 changesets, and assembling them to Series.
-func (s *SubmitQueue) LoadSeries(log *logrus.Logger) error {
-	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
-	}
-
-	// Assemble to series
-	series, err := AssembleSeries(changesets, log)
-	if err != nil {
-		return err
-	}
-
-	// Sort by size
-	s.Series = SortSeries(series)
-	return nil
-}
-
-// TODO: clear submit queue tag if missing +1/+2?
-
-// IsAutoSubmittable returns true if a given Serie has all the necessary flags set
-// meaning it would be fine to rebase and/or submit it.
-// This means, every changeset needs to:
-// - have the s.SubmitQueueTag hashtag
-// - be verified (+1 by CI)
-// - be code reviewed (+2 by a human)
-func (s *SubmitQueue) IsAutoSubmittable(serie *Serie) bool {
-	return serie.FilterAllChangesets(func(c *gerrit.Changeset) bool {
-		return c.HasTag(s.SubmitQueueTag) && c.IsVerified && c.IsCodeReviewed
-	})
-}
-
-// GetChangesetURL returns the URL to view a given changeset
-func (s *SubmitQueue) GetChangesetURL(changeset *gerrit.Changeset) string {
-	return fmt.Sprintf("%s/c/%s/+/%d", s.gerrit.GetBaseURL(), s.ProjectName, changeset.Number)
-}
-
-// DoSubmit submits changes that can be submitted,
-// and updates `Series` to contain the remaining ones
-// Also updates `HEAD`.
-func (s *SubmitQueue) DoSubmit(log *logrus.Logger) error {
-	var remainingSeries []*Serie
-
-	// TODO: actually log more!
-
-	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, skipping", serie.String())
-		}
-
-		// if serie is auto-submittable and rebased on top of current master…
-		if s.IsAutoSubmittable(serie) && 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 the next auto-submittable series on top of current HEAD
-// they are still ordered by series size
-// After a DoRebase, consumers are supposed to fetch state again via LoadSeries,
-// as things most likely have changed, and error handling during partially failed rebases
-// is really tricky
-func (s *SubmitQueue) DoRebase(log *logrus.Logger) error {
-	if s.HEAD == "" {
-		return fmt.Errorf("current HEAD is an empty string, bailing out")
-	}
-	for _, serie := range s.Series {
-		logger := log.WithFields(logrus.Fields{
-			"serie": serie,
-		})
-		if !s.IsAutoSubmittable(serie) {
-			logger.Debug("skipping non-auto-submittable series")
-			continue
-		}
-
-		logger.Infof("rebasing on top of %s", s.HEAD)
-		_, err := s.RebaseSerie(serie, s.HEAD)
-		if err != nil {
-			// We skip trivial rebase errors instead of bailing out.
-			// TODO: we might want to remove s.SubmitQueueTag from the changeset,
-			// but even without doing it,
-			// we're merly spanning, and won't get stuck in trying to rebase the same
-			// changeset over and over again, as some other changeset will likely succeed
-			// with rebasing and will be merged by DoSubmit.
-			logger.Warnf("failure while rebasing, continuing with next one: %s", err)
-			continue
-		} else {
-			logger.Info("success rebasing on top of %s", s.HEAD)
-			break
-		}
-	}
-
-	return nil
-}
-
-// Run starts the submit and rebase logic.
-func (s *SubmitQueue) Run(fetchOnly bool) *Result {
-	r := MakeResult()
-	//TODO: log decisions made and add to some ring buffer
-	var err error
-
-	log := logrus.New()
-	log.AddHook(r)
-
-	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)
-		r.Error = err
-		return r
-	}
-	s.HEAD = commitID
-	r.HEAD = commitID
-
-	err = s.LoadSeries(log)
-	if err != nil {
-		r.Error = err
-		return r
-	}
-
-	// copy series to result object
-	for _, serie := range s.Series {
-		r.Series = append(r.Series, *serie)
-	}
-
-	if len(s.Series) == 0 {
-		// Nothing to do!
-		log.Warn("Nothing to do here")
-		return r
-	}
-	if fetchOnly {
-		return r
-	}
-	err = s.DoSubmit(log)
-	if err != nil {
-		r.Error = err
-		return r
-	}
-	err = s.DoRebase(log)
-	if err != nil {
-		r.Error = err
-		return r
-	}
-	return r
-}
-
-// 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
-}