cmd/relui: enable creation of mock workflow

This change introduces mock data, multiple templates, and an in-memory
store to the release automation webserver. The goal of this change is to
introduce a basic UI structure. The underlying data infrastructure is
only for mock purposes, and will be replaced in a future CL.

This change enables creation and viewing of an in-memory workflow, with
very minor data stored.

List screenshot:
https://storage.googleapis.com/screen.toothrot.net/pub/2020-07-17-workflow-list.png

New workflow screenshot:
https://storage.googleapis.com/screen.toothrot.net/pub/2020-07-01-11_23_17-workflows-new.png

For golang/go#40279

Change-Id: Id9dfcc01cb2aba1df3e36d7a6301bbf8b47476da
Reviewed-on: https://go-review.googlesource.com/c/build/+/243339
Run-TryBot: Alexander Rakoczy <alex@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Andrew Bonventre <andybons@golang.org>
diff --git a/cmd/relui/index.html b/cmd/relui/index.html
deleted file mode 100644
index 0c7de90..0000000
--- a/cmd/relui/index.html
+++ /dev/null
@@ -1,35 +0,0 @@
-<!--
-    Copyright 2020 The Go Authors. All rights reserved.
-    Use of this source code is governed by a BSD-style
-    license that can be found in the LICENSE file.
--->
-<!DOCTYPE html>
-<html lang="en">
-  <title>Go Releases</title>
-  <meta name="viewport" content="width=device-width, initial-scale=1" />
-  <link rel="stylesheet" href="/styles.css" />
-  <body class="Site">
-    <header class="Site-header">
-      <div class="Header">
-        <h1 class="Header-title">Go Releases</h1>
-      </div>
-    </header>
-    <main class="Site-content">
-      <section class="Workflows">
-        <h2>Workflows</h2>
-        <ul class="WorkflowList">
-          <li class="WorkflowList-item">
-            <h3>Local Release - 20a838ab</h3>
-            <form> </form>
-            <ul class="TaskList">
-              <li class="TaskList-item">
-                <h4>Fetch blob - 20a838ab</h4>
-                Status: created
-              </li>
-            </ul>
-          </li>
-        </ul>
-      </section>
-    </main>
-  </body>
-</html>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
index 57e52bb..e20c6fb 100644
--- a/cmd/relui/main.go
+++ b/cmd/relui/main.go
@@ -11,7 +11,10 @@
 )
 
 func main() {
-	http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(homeHandler)))
+	s := &server{store: &memoryStore{}}
+	http.Handle("/workflows/create", http.HandlerFunc(s.createWorkflowHandler))
+	http.Handle("/workflows/new", http.HandlerFunc(s.newWorkflowHandler))
+	http.Handle("/", fileServerHandler(relativeFile("./static"), http.HandlerFunc(s.homeHandler)))
 	port := os.Getenv("PORT")
 	if port == "" {
 		port = "8080"
diff --git a/cmd/relui/static/styles.css b/cmd/relui/static/styles.css
index 3d6217a..e92e8e2 100644
--- a/cmd/relui/static/styles.css
+++ b/cmd/relui/static/styles.css
@@ -51,3 +51,68 @@
   font-size: 1.5rem;
   margin: 0;
 }
+@media only screen and (min-width: 75rem) {
+  .Workflows,
+  .NewWorkflow {
+    width: 74.75rem;
+  }
+}
+@media only screen and (min-width: 48rem) {
+  .Workflows,
+  .NewWorkflow {
+    margin: 0 auto;
+  }
+}
+.Workflows-header {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+}
+.WorkflowList,
+.TaskList {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+.WorkflowList-sectionTitle {
+  margin-bottom: 0.5rem;
+  font-weight: normal;
+}
+.TaskList {
+  border: 1px solid #d6d6d6;
+  border-radius: 0.25rem;
+}
+.TaskList-item {
+  display: flex;
+  align-items: center;
+  padding: 0.5rem;
+  justify-content: space-between;
+}
+.TaskList-item + .TaskList-item {
+  border-top: 0.0625rem solid #d6d6d6;
+}
+.Button {
+  background: #375eab;
+  border-radius: 0.1875rem;
+  box-shadow: 0 0.1875rem 0.0625rem -0.125rem rgba(0, 0, 0, 0.2),
+    0 0.125rem 0.125rem 0 rgba(0, 0, 0, 0.14),
+    0 0.0625rem 0.3125rem 0 rgba(0, 0, 0, 0.12);
+  color: #fff;
+  font-size: 0.875rem;
+  min-width: 4rem;
+  padding: 0.5rem 1rem;
+  text-decoration: none;
+}
+.Button:hover,
+.Button:focus {
+  background: #3b65b3;
+  box-shadow: 0 0.125rem 0.25rem -0.0625rem rgba(0, 0, 0, 0.2),
+    0 0.25rem 0.3125rem 0 rgba(0, 0, 0, 0.14),
+    0 0.0625rem 0.625rem 0 rgba(0, 0, 0, 0.12);
+}
+.Button:active {
+  background: #4373cc;
+  box-shadow: 0 0.3125rem 0.3125rem -0.1875rem rgba(0, 0, 0, 0.2),
+    0 0.5rem 0.625rem 0.0625rem rgba(0, 0, 0, 0.14),
+    0 0.1875rem 0.875rem 0.125rem rgba(0, 0, 0, 0.12);
+}
diff --git a/cmd/relui/templates/home.html b/cmd/relui/templates/home.html
new file mode 100644
index 0000000..7cd9342
--- /dev/null
+++ b/cmd/relui/templates/home.html
@@ -0,0 +1,33 @@
+<!--
+    Copyright 2020 The Go Authors. All rights reserved.
+    Use of this source code is governed by a BSD-style
+    license that can be found in the LICENSE file.
+-->
+{{define "content"}}
+<section class="Workflows">
+  <div class="Workflows-header">
+    <h2>Workflows</h2>
+    <a href="/workflows/new" class="Button">New</a>
+  </div>
+  <ul class="WorkflowList">
+    {{range $workflow := .Workflows}}
+    <li class="WorkflowList-item">
+      <h3>{{$workflow.Title}}</h3>
+      <h4 class="WorkflowList-sectionTitle">Tasks</h4>
+      <ul class="TaskList">
+        {{range $task := $workflow.Tasks}}
+        <li class="TaskList-item">
+          <span class="TaskList-itemTitle">{{$task.Title}}</span>
+          Status: {{$task.Status}}
+        </li>
+        {{end}}
+        <li class="TaskList-item">
+          <span class="TaskList-itemTitle">Sample Task</span>
+          Status: created
+        </li>
+      </ul>
+    </li>
+    {{end}}
+  </ul>
+</section>
+{{end}}
diff --git a/cmd/relui/templates/layout.html b/cmd/relui/templates/layout.html
new file mode 100644
index 0000000..03b892b
--- /dev/null
+++ b/cmd/relui/templates/layout.html
@@ -0,0 +1,21 @@
+<!--
+    Copyright 2020 The Go Authors. All rights reserved.
+    Use of this source code is governed by a BSD-style
+    license that can be found in the LICENSE file.
+-->
+<!DOCTYPE html>
+<html lang="en">
+  <title>Go Releases</title>
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <link rel="stylesheet" href="/styles.css" />
+  <body class="Site">
+    <header class="Site-header">
+      <div class="Header">
+        <h1 class="Header-title">Go Releases</h1>
+      </div>
+    </header>
+    <main class="Site-content">
+      {{template "content" .}}
+    </main>
+  </body>
+</html>
diff --git a/cmd/relui/templates/new_workflow.html b/cmd/relui/templates/new_workflow.html
new file mode 100644
index 0000000..227e493
--- /dev/null
+++ b/cmd/relui/templates/new_workflow.html
@@ -0,0 +1,17 @@
+<!--
+    Copyright 2020 The Go Authors. All rights reserved.
+    Use of this source code is governed by a BSD-style
+    license that can be found in the LICENSE file.
+-->
+{{define "content"}}
+<section class="NewWorkflow">
+  <h2>New Go Release</h2>
+  <form action="/workflows/create" method="post">
+    <label>
+      Revision
+      <input name="workflow.revision" value="master" />
+    </label>
+    <input name="workflow.create" type="submit" value="Create" />
+  </form>
+</section>
+{{end}}
diff --git a/cmd/relui/web.go b/cmd/relui/web.go
index 40cd773..ec24176 100644
--- a/cmd/relui/web.go
+++ b/cmd/relui/web.go
@@ -5,7 +5,9 @@
 package main
 
 import (
+	"bytes"
 	"html/template"
+	"io"
 	"log"
 	"mime"
 	"net/http"
@@ -14,12 +16,17 @@
 	"path/filepath"
 )
 
+// fileServerHandler returns a http.Handler rooted at root. It will call the next handler provided for requests to "/".
+//
+// The returned handler sets the appropriate Content-Type and Cache-Control headers for the returned file.
 func fileServerHandler(root string, next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		if r.URL.Path == "/" {
 			next.ServeHTTP(w, r)
 			return
 		}
+		// http.FileServer would correctly return a 404, but we need to check that the file exists
+		// before calculating the Content-Type header.
 		if _, err := os.Stat(path.Join(root, r.URL.Path)); os.IsNotExist(err) {
 			http.NotFound(w, r)
 			return
@@ -32,13 +39,60 @@
 	})
 }
 
-var homeTemplate = template.Must(template.ParseFiles(relativeFile("index.html")))
+var (
+	homeTmpl        = template.Must(template.Must(layoutTmpl.Clone()).ParseFiles(relativeFile("templates/home.html")))
+	layoutTmpl      = template.Must(template.ParseFiles(relativeFile("templates/layout.html")))
+	newWorkflowTmpl = template.Must(template.Must(layoutTmpl.Clone()).ParseFiles(relativeFile("templates/new_workflow.html")))
+)
 
-func homeHandler(w http.ResponseWriter, _ *http.Request) {
-	if err := homeTemplate.Execute(w, nil); err != nil {
-		log.Printf("homeHandlerFunc: %v", err)
+// server implements the http handlers for relui.
+type server struct {
+	store store
+}
+
+type homeResponse struct {
+	Workflows []workflow
+}
+
+// homeHandler renders the homepage.
+func (s *server) homeHandler(w http.ResponseWriter, _ *http.Request) {
+	out := bytes.Buffer{}
+	if err := homeTmpl.Execute(&out, homeResponse{Workflows: s.store.GetWorkflows()}); err != nil {
+		log.Printf("homeHandler: %v", err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
 	}
+	io.Copy(w, &out)
+}
+
+// newWorkflowHandler presents a form for creating a new workflow.
+func (s *server) newWorkflowHandler(w http.ResponseWriter, _ *http.Request) {
+	out := bytes.Buffer{}
+	if err := newWorkflowTmpl.Execute(&out, nil); err != nil {
+		log.Printf("newWorkflowHandler: %v", err)
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+	io.Copy(w, &out)
+}
+
+// createWorkflowHandler persists a new workflow in the datastore.
+func (s *server) createWorkflowHandler(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+		return
+	}
+	ref := r.Form.Get("workflow.revision")
+	if ref == "" {
+		// TODO(golang.org/issue/40279) - render a better error in the form.
+		http.Error(w, "workflow revision is required", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.AddWorkflow(newLocalGoRelease(ref)); err != nil {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, "/", http.StatusSeeOther)
 }
 
 // relativeFile returns the path to the provided file or directory,
diff --git a/cmd/relui/web_test.go b/cmd/relui/web_test.go
index 1649e2d..dfb739a 100644
--- a/cmd/relui/web_test.go
+++ b/cmd/relui/web_test.go
@@ -8,21 +8,13 @@
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
+	"net/url"
+	"strings"
 	"testing"
+
+	"github.com/google/go-cmp/cmp"
 )
 
-func TestHomeHandler(t *testing.T) {
-	req := httptest.NewRequest("GET", "/", nil)
-	w := httptest.NewRecorder()
-
-	homeHandler(w, req)
-	resp := w.Result()
-
-	if resp.StatusCode != http.StatusOK {
-		t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
-	}
-}
-
 func TestFileServerHandler(t *testing.T) {
 	h := fileServerHandler("./testing", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Write([]byte("Home"))
@@ -60,7 +52,7 @@
 	}
 	for _, c := range cases {
 		t.Run(c.desc, func(t *testing.T) {
-			req := httptest.NewRequest("GET", c.path, nil)
+			req := httptest.NewRequest(http.MethodGet, c.path, nil)
 			w := httptest.NewRecorder()
 
 			h.ServeHTTP(w, req)
@@ -85,3 +77,84 @@
 		})
 	}
 }
+
+func TestServerHomeHandler(t *testing.T) {
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	w := httptest.NewRecorder()
+
+	s := &server{store: &memoryStore{}}
+	s.homeHandler(w, req)
+	resp := w.Result()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("resp.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+	}
+}
+
+func TestServerNewWorkflowHandler(t *testing.T) {
+	req := httptest.NewRequest(http.MethodGet, "/workflows/new", nil)
+	w := httptest.NewRecorder()
+
+	s := &server{store: &memoryStore{}}
+	s.newWorkflowHandler(w, req)
+	resp := w.Result()
+
+	if resp.StatusCode != http.StatusOK {
+		t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, http.StatusOK)
+	}
+}
+
+func TestServerCreateWorkflowHandler(t *testing.T) {
+	cases := []struct {
+		desc        string
+		params      url.Values
+		wantCode    int
+		wantHeaders map[string]string
+		wantParams  map[string]string
+	}{
+		{
+			desc:     "bad request",
+			wantCode: http.StatusBadRequest,
+		},
+		{
+			desc:     "successful creation",
+			params:   url.Values{"workflow.revision": []string{"abc"}},
+			wantCode: http.StatusSeeOther,
+			wantHeaders: map[string]string{
+				"Location": "/",
+			},
+			wantParams: map[string]string{"GitObject": "abc"},
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.desc, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodPost, "/workflows/create", strings.NewReader(c.params.Encode()))
+			req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+			w := httptest.NewRecorder()
+
+			s := &server{store: &memoryStore{}}
+			s.createWorkflowHandler(w, req)
+			resp := w.Result()
+
+			if resp.StatusCode != c.wantCode {
+				t.Errorf("rep.StatusCode = %d, wanted %d", resp.StatusCode, c.wantCode)
+			}
+			for k, v := range c.wantHeaders {
+				if resp.Header.Get(k) != v {
+					t.Errorf("resp.Header.Get(%q) = %q, wanted %q", k, resp.Header.Get(k), v)
+				}
+			}
+			if len(s.store.GetWorkflows()) != 1 && c.wantParams != nil {
+				t.Fatalf("len(s.store.GetWorkflows()) = %d, wanted %d", len(s.store.GetWorkflows()), 1)
+			} else if len(s.store.GetWorkflows()) != 0 && c.wantParams == nil {
+				t.Fatalf("len(s.store.GetWorkflows()) = %d, wanted %d", len(s.store.GetWorkflows()), 0)
+			}
+			if c.wantParams == nil {
+				return
+			}
+			if diff := cmp.Diff(c.wantParams, s.store.GetWorkflows()[0].Params()); diff != "" {
+				t.Errorf("s.Store.GetWorkflows()[0].Params() mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
diff --git a/cmd/relui/workflow.go b/cmd/relui/workflow.go
new file mode 100644
index 0000000..bc96725
--- /dev/null
+++ b/cmd/relui/workflow.go
@@ -0,0 +1,88 @@
+// Copyright 2020 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"fmt"
+	"sync"
+)
+
+type workflow interface {
+	// Params are the list of parameters given when the workflow was created.
+	Params() map[string]string
+	// Title is a human-readable description of a task.
+	Title() string
+	// Tasks are a list of steps in a workflow.
+	Tasks() []task
+}
+
+type task interface {
+	// Title is a human-readable description of a task.
+	Title() string
+	// Status is the current status of the task.
+	Status() string
+}
+
+// newLocalGoRelease creates a localGoRelease workflow.
+func newLocalGoRelease(revision string) *localGoRelease {
+	return &localGoRelease{GitObject: revision, tasks: []task{&fetchGoSource{gitObject: revision}}}
+}
+
+type localGoRelease struct {
+	GitObject string
+	tasks     []task
+}
+
+func (l *localGoRelease) Params() map[string]string {
+	return map[string]string{"GitObject": l.GitObject}
+}
+
+func (l *localGoRelease) Title() string {
+	return fmt.Sprintf("Local Go release (%s)", l.GitObject)
+}
+
+func (l *localGoRelease) Tasks() []task {
+	return l.tasks
+}
+
+// fetchGoSource is a task for fetching the Go repository at a specific commit reference.
+type fetchGoSource struct {
+	gitObject string
+}
+
+func (f *fetchGoSource) Title() string {
+	return "Fetch Go source at " + f.gitObject
+}
+
+func (f *fetchGoSource) Status() string {
+	return "created"
+}
+
+// store is a persistence adapter for saving data. When running locally, this is implemented by memoryStore.
+type store interface {
+	GetWorkflows() []workflow
+	AddWorkflow(workflow) error
+}
+
+// memoryStore is a non-durable implementation of store that keeps everything in memory.
+type memoryStore struct {
+	sync.Mutex
+	Workflows []workflow
+}
+
+// AddWorkflow adds a workflow to the store.
+func (m *memoryStore) AddWorkflow(w workflow) error {
+	m.Lock()
+	defer m.Unlock()
+	m.Workflows = append(m.Workflows, w)
+	return nil
+}
+
+// GetWorkflows returns all workflows stored.
+func (m *memoryStore) GetWorkflows() []workflow {
+	m.Lock()
+	defer m.Unlock()
+	return m.Workflows
+}