about summary refs log tree commit diff
diff options
context:
space:
mode:
-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
-}