diff --git a/cmd/relui/main.go b/cmd/relui/main.go index 57e52bb79d..e20c6fb80c 100644 --- a/cmd/relui/main.go +++ b/cmd/relui/main.go @@ -11,7 +11,10 @@ import ( ) 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 3d6217a3b9..e92e8e26dc 100644 --- a/cmd/relui/static/styles.css +++ b/cmd/relui/static/styles.css @@ -51,3 +51,68 @@ h6 { 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 0000000000..7cd9342a16 --- /dev/null +++ b/cmd/relui/templates/home.html @@ -0,0 +1,33 @@ + +{{define "content"}} +
+
+

Workflows

+ New +
+ +
+{{end}} diff --git a/cmd/relui/index.html b/cmd/relui/templates/layout.html similarity index 56% rename from cmd/relui/index.html rename to cmd/relui/templates/layout.html index 0c7de905eb..03b892b87d 100644 --- a/cmd/relui/index.html +++ b/cmd/relui/templates/layout.html @@ -15,21 +15,7 @@

Go Releases

-
-

Workflows

- -
+ {{template "content" .}}
diff --git a/cmd/relui/templates/new_workflow.html b/cmd/relui/templates/new_workflow.html new file mode 100644 index 0000000000..227e4935f2 --- /dev/null +++ b/cmd/relui/templates/new_workflow.html @@ -0,0 +1,17 @@ + +{{define "content"}} +
+

New Go Release

+
+ + +
+
+{{end}} diff --git a/cmd/relui/web.go b/cmd/relui/web.go index 40cd773bf8..ec241762cb 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 @@ import ( "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 @@ func fileServerHandler(root string, next http.Handler) http.Handler { }) } -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"))) +) + +// 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) +} -func homeHandler(w http.ResponseWriter, _ *http.Request) { - if err := homeTemplate.Execute(w, nil); err != nil { - log.Printf("homeHandlerFunc: %v", err) +// 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 1649e2d13f..dfb739ac9b 100644 --- a/cmd/relui/web_test.go +++ b/cmd/relui/web_test.go @@ -8,20 +8,12 @@ import ( "io/ioutil" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" -) - -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) - } -} + "github.com/google/go-cmp/cmp" +) func TestFileServerHandler(t *testing.T) { h := fileServerHandler("./testing", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -60,7 +52,7 @@ func TestFileServerHandler(t *testing.T) { } 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 TestFileServerHandler(t *testing.T) { }) } } + +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 0000000000..bc96725e75 --- /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 +}