internal/reviews, internal/goreviews: add categories
Split the CL dashboard into categories based on filter expressions.
Use a default set of categories, and also let users change the JSON.
Change-Id: I8651bc00064fd28a36242d37cc7bfc188ae1ea08
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/656575
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
Reviewed-by: Ian Lance Taylor <iant@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Ian Lance Taylor <iant@google.com>
diff --git a/internal/goreviews/changes.go b/internal/goreviews/changes.go
index 1818904..91999ed 100644
--- a/internal/goreviews/changes.go
+++ b/internal/goreviews/changes.go
@@ -9,7 +9,9 @@
"html/template"
"log/slog"
"net/http"
+ "strings"
"sync"
+ "time"
"golang.org/x/oscar/internal/gerrit"
"golang.org/x/oscar/internal/reviews"
@@ -59,5 +61,56 @@
cps := ch.cps
ch.mu.Unlock()
- reviews.Display(ctx, ch.slog, displayDoc, endpoint, cps, w, r)
+ categories := strings.ReplaceAll(categoriesJSON, "$ONEYEARAGO", time.Now().AddDate(-1, 0, 0).Format(`\"2006-01-02\"`))
+
+ reviews.Display(ctx, ch.slog, displayDoc, categories, endpoint, cps, w, r)
}
+
+// categoriesJSON is the default list of ways to categorize issues
+// when displaying them. This is decoded into a []reviews.categoryDef.
+// The string $ONEYEARAGO is replaced before use.
+//
+// Issues are matched against these categories in order, and stop at
+// the first match. That is, an issue that matches "Ready with Comments"
+// will not be compared against "Ready with Merge Conflict".
+// Therefore, the filters do not specifically exclude cases that are
+// handled by earlier categories.
+const categoriesJSON = `
+[
+ {
+ "name": "Ready",
+ "doc": "approved and ready to submit",
+ "filter": "Predicates.Name:hasPlusTwo AND Predicates.Name:trybotsPassed AND NOT Predicates.Name:hasUnresolvedComments AND NOT Predicates.Name:mergeConflict"
+ },
+ {
+ "name": "Ready with Comments",
+ "doc": "approved but has unresolved comments",
+ "filter": "Predicates.Name:hasPlusTwo AND Predicates.Name:trybotsPassed AND NOT Predicates.Name:mergeConflict"
+ },
+ {
+ "name": "Ready with Merge Conflict",
+ "doc": "approved but has a merge conflict",
+ "filter": "Predicates.Name:hasPlusTwo AND Predicates.Name:trybotsPassed"
+ },
+ {
+ "name": "From Maintainer",
+ "doc": "from a maintainer",
+ "filter": "Predicates.Name:authorMaintainer AND Change.Created>$ONEYEARAGO"
+ },
+ {
+ "name": "Older From Maintainer",
+ "doc": "from a maintainer, but created more than a year ago",
+ "filter": "Predicates.Name:authorMaintainer"
+ },
+ {
+ "name": "From Major Contributor",
+ "doc": "from a major contributor",
+ "filter": "Predicates.Name:authorMajorContributor AND Change.Created>$ONEYEARAGO"
+ },
+ {
+ "name": "Older From Major Contributor",
+ "doc": "from a major contributor, but created more than a year ago",
+ "filter": "Predicates.Name:authorMajorContributor"
+ }
+]
+`
diff --git a/internal/reviews/display.go b/internal/reviews/display.go
index e46b925..fa25354 100644
--- a/internal/reviews/display.go
+++ b/internal/reviews/display.go
@@ -5,7 +5,9 @@
package reviews
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
"html/template"
@@ -19,7 +21,7 @@
// Display is an HTTP handler function that displays the
// changes to review.
-func Display(ctx context.Context, lg *slog.Logger, doc template.HTML, endpoint string, cps []ChangePreds, w http.ResponseWriter, r *http.Request) {
+func Display(ctx context.Context, lg *slog.Logger, doc template.HTML, defaultCategoriesJSON, endpoint string, cps []ChangePreds, w http.ResponseWriter, r *http.Request) {
if len(cps) == 0 {
io.WriteString(w, reportNoData)
return
@@ -32,23 +34,84 @@
return
}
- sc := make([]CP, 0, len(cps))
+ categoriesJSON := r.FormValue("categories")
+ if categoriesJSON == "" {
+ categoriesJSON = defaultCategoriesJSON
+ }
+
+ var categoryDefs []categoryDef
+ if categoriesJSON != "" {
+ if err := json.Unmarshal([]byte(categoriesJSON), &categoryDefs); err != nil {
+ http.Error(w, fmt.Sprintf("can't unmarshal JSON categories %q: %v", categoriesJSON, err), http.StatusBadRequest)
+ return
+ }
+ }
+
+ if len(categoryDefs) == 0 {
+ categoryDefs = []categoryDef{
+ {
+ Name: "",
+ Doc: "",
+ Filter: "",
+ },
+ }
+ } else {
+ categoryDefs = append(categoryDefs,
+ categoryDef{
+ Name: "Remainder",
+ Doc: "CLs not matched by earlier categories",
+ Filter: "",
+ },
+ )
+ }
+
+ categoryFilters := make([]func(context.Context, ChangePreds) bool, 0, len(categoryDefs))
+ for _, catDef := range categoryDefs {
+ fn, err := makeFilter(catDef.Filter)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("invalid category filter %q: %v", catDef.Filter, err), http.StatusBadRequest)
+ return
+ }
+ categoryFilters = append(categoryFilters, fn)
+ }
+
+ categories := make([]category, 0, len(categoryDefs))
+ for _, catDef := range categoryDefs {
+ categories = append(categories, category{
+ Name: catDef.Name,
+ Doc: catDef.Doc,
+ })
+ }
+
for _, v := range cps {
if filterFn != nil && !filterFn(ctx, v) {
continue
}
- sc = append(sc, CP{v})
+ for i := range categories {
+ if categoryFilters[i] == nil || categoryFilters[i](ctx, v) {
+ categories[i].Changes = append(categories[i].Changes, CP{v})
+ break
+ }
+ }
+ }
+
+ var jsonBuf bytes.Buffer
+ if len(categoriesJSON) > 0 {
+ if err := json.Indent(&jsonBuf, []byte(categoriesJSON), "", " "); err != nil {
+ lg.Error("reviews.Display: json.Indent failure", "input", categoriesJSON, "err", err)
+ }
}
data := &displayType{
- Endpoint: endpoint,
- Doc: doc,
- Filter: userFilter,
- Changes: sc,
+ Endpoint: endpoint,
+ Doc: doc,
+ Filter: userFilter,
+ Categories: categories,
+ CategoriesJSON: jsonBuf.String(),
}
if err := displayTemplate.Execute(w, data); err != nil {
- lg.Error("template execution failed", "err", err)
+ lg.Error("reviews.Display: template execution failed", "err", err)
}
}
@@ -72,6 +135,14 @@
return fn, nil
}
+// categoryDef is a category definition.
+// This is a name, documentation, and a predicate.
+type categoryDef struct {
+ Name string `json:"name,omitempty"`
+ Doc string `json:"doc,omitempty"`
+ Filter string `json:"filter,omitempty"`
+}
+
// reportNoData is the HTML that we report if we have not finished
// applying predicates to changes.
const reportNoData = `
@@ -93,16 +164,25 @@
// displayTemplate is used to display the changes.
var displayTemplate = template.Must(template.New("display").Parse(displayHTML))
-// displayType is the type expected by displayHTML
+// displayType is the type expected by displayHTML and displayTemplate.
type displayType struct {
- Ctx context.Context
- Endpoint string // Relative URL being served.
- Doc template.HTML // Documentation string.
- Filter string // Filter value.
- Changes []CP // List of changes with predicates.
+ Ctx context.Context
+ Endpoint string // Relative URL being served.
+ Doc template.HTML // Documentation string.
+ Filter string // Filter value.
+ Categories []category // Changes grouped by category.
+ CategoriesJSON string // Category definitions as JSON string.
}
-// CP is the type we pass to the HTML template displayTemplate.
+// category is a group of changes.
+type category struct {
+ Name string // Name of category.
+ Doc string // Documentation for category.
+ Changes []CP // List of changes with predicates.
+}
+
+// CP is a list of changes. This is not just ChangePreds
+// because we give the template a FormattedLastUpdate method.
type CP struct {
ChangePreds
}
@@ -148,6 +228,15 @@
color: #666;
font-size: .9em;
}
+.categoryname {
+ padding: 1em 0;
+ font-size: 1.5em;
+ font-weight: bold;
+}
+.categorydoc {
+ color: #666;
+ font-size: .9em;
+}
.row {
border-bottom: 1px solid #f1f2f3;
display: flex;
@@ -244,40 +333,54 @@
<a href="https://go.googlesource.com/oscar/+/master/internal/goreviews/display.go">Source code</a>
</div>
</header>
- <div class="filter">
<form id="form" action="{{.Endpoint}}" method="GET">
- <span>
- <label for="filter">Filter</label>
- <input id="filter" type="text" size=75 name="filter" value="{{.Filter}}"/>
- </span>
+ <div class="filter">
+ <span>
+ <label for="filter">Filter</label>
+ <input id="filter" type="text" size=75 name="filter" value="{{.Filter}}"/>
+ </span>
+ </div>
+ <div class="header-subtitle">
+ Sample filters: "Russ Cox" / Change.Author.Name:"rsc" / Change.Subject:"runtime" / Predicates.Name:hasUnresolvedComments / Change.Created > "2024-01-01"
+ </div>
+ {{$ctx := .Ctx}}
+ {{range $category := .Categories}}
+ {{if eq (len .Changes) 0}}{{continue}}{{end}}
+ <div class="categoryname">{{.Name}}</div>
+ <div class="categorydoc">{{.Doc}}</div>
+ {{range $change := .Changes}}
+ <div class="row">
+ <span class="date">{{.FormattedLastUpdate $ctx}}</span>
+ <span class="owner">{{with $author := .Change.Author $ctx}}{{$author.DisplayName $ctx}}{{end}}</span>
+ <a class="number" href="https://go.dev/cl/{{.Change.ID $ctx}}" target="_blank">{{.Change.ID $ctx}}</a>
+ <span class="subject">{{.Change.Subject $ctx}}</span>
+ <span class="predicates">
+ {{range $pred := .Predicates}}
+ {{if ge .Score 10}}
+ <span class="highscore">
+ {{else if gt .Score 0}}
+ <span class="posscore">
+ {{else if le .Score -10}}
+ <span class="lowscore">
+ {{else if lt .Score 0}}
+ <span class="negscore">
+ {{else}}
+ <span class="zeroscore">
+ {{end}}
+ {{.Name}}
+ </span>
+ {{end}}
+ </span>
+ </div>
+ {{end}}
+ {{end}}
+ <div class="categories">
+ <span>
+ <label for="categories">Categories</label>
+ <textarea id="categories" name="categories" rows="9", cols="80">{{.CategoriesJSON}}</textarea>
+ </span>
+ </div>
</form>
- </div>
- {{$ctx := .Ctx}}
- {{range $change := .Changes}}
- <div class="row">
- <span class="date">{{.FormattedLastUpdate $ctx}}</span>
- <span class="owner">{{with $author := .Change.Author $ctx}}{{$author.DisplayName $ctx}}{{end}}</span>
- <a class="number" href="https://go.dev/cl/{{.Change.ID $ctx}}" target="_blank">{{.Change.ID $ctx}}</a>
- <span class="subject">{{.Change.Subject $ctx}}</span>
- <span class="predicates">
- {{range $pred := .Predicates}}
- {{if ge .Score 10}}
- <span class="highscore">
- {{else if gt .Score 0}}
- <span class="posscore">
- {{else if le .Score -10}}
- <span class="lowscore">
- {{else if lt .Score 0}}
- <span class="negscore">
- {{else}}
- <span class="zeroscore">
- {{end}}
- {{.Name}}
- </span>
- {{end}}
- </span>
- </div>
- {{end}}
</body>
</html>
`