internal/gaby: add filter to action log page

Hook up the new internal/filter package to the action log page
to allow filtering.

Fixes golang/oscar#53.

Change-Id: Ie40f791c430e2f79dc78140d3f4be5e1c9c1e380
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/630355
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Ian Lance Taylor <iant@golang.org>
diff --git a/internal/filter/eval.go b/internal/filter/eval.go
index 8806290..501efa5 100644
--- a/internal/filter/eval.go
+++ b/internal/filter/eval.go
@@ -213,7 +213,7 @@
 }
 
 // function returns an evaluator function for a [functionExpr].
-func (ev *eval[T]) function(e *functionExpr) func(T) bool {
+func (ev *eval[T]) function(*functionExpr) func(T) bool {
 	ev.msgs = append(ev.msgs, "function not implemented")
 	return ev.alwaysFalse()
 }
diff --git a/internal/gaby/actionlog.go b/internal/gaby/actionlog.go
index 3880fa6..1852df0 100644
--- a/internal/gaby/actionlog.go
+++ b/internal/gaby/actionlog.go
@@ -10,11 +10,13 @@
 	"fmt"
 	"net/http"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/google/safehtml"
 	"github.com/google/safehtml/template"
 	"golang.org/x/oscar/internal/actions"
+	"golang.org/x/oscar/internal/filter"
 	"golang.org/x/oscar/internal/storage"
 )
 
@@ -24,6 +26,7 @@
 
 	Start, End         endpoint
 	StartTime, EndTime string // formatted times that the endpoints describe
+	Filter             string
 	Entries            []*actions.Entry
 }
 
@@ -48,6 +51,7 @@
 	var page actionLogPage
 
 	// Fill in the endpoint values from the form on the page.
+	page.Filter = r.FormValue("filter")
 	page.Start.formValues(r, "start")
 	page.End.formValues(r, "end")
 
@@ -66,7 +70,11 @@
 
 	// Retrieve and display entries if something was set.
 	if r.FormValue("start") != "" {
-		page.Entries = g.actionsBetween(startTime, endTime)
+		filter, err := newFilter(page.Filter)
+		if err != nil {
+			return nil, http.StatusBadRequest, fmt.Errorf("invalid filter: %v", err)
+		}
+		page.Entries = g.actionsBetween(startTime, endTime, filter)
 		for _, e := range page.Entries {
 			e.Created = e.Created.In(loc)
 			e.Done = e.Done.In(loc)
@@ -206,18 +214,40 @@
 }
 
 // actionsBetween returns the action entries between start and end, inclusive.
-func (g *Gaby) actionsBetween(start, end time.Time) []*actions.Entry {
+func (g *Gaby) actionsBetween(start, end time.Time, filter func(*actions.Entry) bool) []*actions.Entry {
 	var es []*actions.Entry
 	// Scan entries created in [start, end].
 	for e := range actions.ScanAfter(g.slog, g.db, start.Add(-time.Nanosecond), nil) {
 		if e.Created.After(end) {
 			break
 		}
-		es = append(es, e)
+		if filter(e) {
+			es = append(es, e)
+		}
 	}
 	return es
 }
 
+func newFilter(s string) (func(*actions.Entry) bool, error) {
+	if s == "" {
+		return func(*actions.Entry) bool { return true }, nil
+	}
+	expr, err := filter.ParseFilter(s)
+	if err != nil {
+		return nil, err
+	}
+	ev, problems := filter.Evaluator[actions.Entry](expr, nil)
+	if len(problems) > 0 {
+		return nil, errors.New(strings.Join(problems, "\n"))
+	}
+	return func(e *actions.Entry) bool {
+		if e == nil {
+			return false
+		}
+		return ev(*e)
+	}, nil
+}
+
 // fmtTime formats a time for display on the action log page.
 func fmtTime(t time.Time) string {
 	if t.IsZero() {
diff --git a/internal/gaby/actionlog_test.go b/internal/gaby/actionlog_test.go
index a4833bf..9666ed2 100644
--- a/internal/gaby/actionlog_test.go
+++ b/internal/gaby/actionlog_test.go
@@ -6,6 +6,7 @@
 
 import (
 	"context"
+	"slices"
 	"strings"
 	"testing"
 	"time"
@@ -142,8 +143,67 @@
 	time.Sleep(100 * time.Millisecond)
 	before(db, []byte{2}, nil, false)
 
-	got := g.actionsBetween(start, end)
+	got := g.actionsBetween(start, end, func(*actions.Entry) bool { return true })
 	if len(got) != 1 {
 		t.Errorf("got %d entries, want 1", len(got))
 	}
 }
+
+func TestActionFilter(t *testing.T) {
+	entries := []*actions.Entry{
+		{Kind: "a", Error: ""},
+		{Kind: "b", Error: ""},
+		{Kind: "a", Error: "a bad error"},
+		{Kind: "a", Error: "meh"},
+		{Kind: "b", Error: "bad"},
+		{Kind: "a", ApprovalRequired: true},
+		{Kind: "b", ApprovalRequired: true},
+	}
+
+	applyFilter := func(f func(*actions.Entry) bool) []*actions.Entry {
+		var res []*actions.Entry
+		for _, e := range entries {
+			if f(e) {
+				res = append(res, e)
+			}
+		}
+		return res
+	}
+
+	for _, tc := range []struct {
+		in   string
+		want func(*actions.Entry) bool
+	}{
+		{"", func(*actions.Entry) bool { return true }},
+		{
+			"Kind=a",
+			func(e *actions.Entry) bool { return e.Kind == "a" },
+		},
+		{
+			"Kind=a Error:bad",
+			func(e *actions.Entry) bool {
+				return e.Kind == "a" && strings.Contains(e.Error, "bad")
+			},
+		},
+		{
+			"ApprovalRequired=true",
+			func(e *actions.Entry) bool { return e.ApprovalRequired },
+		},
+		{
+			"ApprovalRequired=true OR Kind=b",
+			func(e *actions.Entry) bool {
+				return e.ApprovalRequired || e.Kind == "b"
+			},
+		},
+	} {
+		gotf, err := newFilter(tc.in)
+		if err != nil {
+			t.Fatal(err)
+		}
+		got := applyFilter(gotf)
+		want := applyFilter(tc.want)
+		if !slices.Equal(got, want) {
+			t.Errorf("%q:\ngot  %v\nwant %v", tc.in, got, want)
+		}
+	}
+}
diff --git a/internal/gaby/tmpl/actionlog.tmpl b/internal/gaby/tmpl/actionlog.tmpl
index e4dd08d..7e600ba 100644
--- a/internal/gaby/tmpl/actionlog.tmpl
+++ b/internal/gaby/tmpl/actionlog.tmpl
@@ -33,6 +33,16 @@
   <script>
     document.getElementById("form-tz").setAttribute("value", timezone);
   </script>
+  <span>
+    <label for="filter">Filter</label>
+    <input id="filter" type="text" size=75 name="filter" value="{{.Filter}}"/>
+  </span>
+  <div style="font-size: smaller; margin-top: 0.5rem; margin-bottom: 1rem">
+    Examples: <code>Kind:Fixer</code>, <code>RequiresApproval=true</code><br/>
+    Case matters. Boolean fields must be compared with <code>=true</code> or <code>=false</code>.
+    OR, AND and NOT must be in all caps.<br/>
+    See <a href="https://google.aip.dev/160">AIP 160</a> for more.
+  </div>
   <table>
     <tr>
       <td>