internal/reviews: add GerritChanges and CollectChangePreds

These are the building blocks that a particular Gerrit project
needs to build a review dashboard.

Change-Id: Ifb803ff66320470647da234b001e6392f963e485
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/619217
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Auto-Submit: Ian Lance Taylor <iant@golang.org>
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
diff --git a/internal/reviews/collect.go b/internal/reviews/collect.go
new file mode 100644
index 0000000..81d1eeb
--- /dev/null
+++ b/internal/reviews/collect.go
@@ -0,0 +1,108 @@
+// Copyright 2024 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 reviews
+
+import (
+	"cmp"
+	"context"
+	"iter"
+	"log/slog"
+	"runtime"
+	"slices"
+	"strconv"
+	"strings"
+	"sync"
+)
+
+// CollectChangePreds reads [Change] values from an iterator and
+// collects them into a slice of [ChangePreds] values.
+// The slice is sorted by the predicate default values.
+func CollectChangePreds(ctx context.Context, lg *slog.Logger, it iter.Seq[Change]) []ChangePreds {
+	// Applying predicates can be time consuming,
+	// and there can be a lot of changes,
+	// so shard the work.
+	count := runtime.GOMAXPROCS(0)
+	chIn := make(chan Change, count)
+	chOut := make(chan ChangePreds, count)
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+		defer close(chIn)
+		for change := range it {
+			select {
+			case chIn <- change:
+			case <-ctx.Done():
+				return
+			}
+		}
+	}()
+
+	wg.Add(count)
+	for range count {
+		go func() {
+			defer wg.Done()
+			for change := range chIn {
+				cp, ok, err := ApplyPredicates(change)
+				if err != nil {
+					// Errors are assumed to be
+					// non-critical. Just log them.
+					lg.Error("error applying predicate", "change", change.ID(), "err", err)
+				}
+				if !ok {
+					continue
+				}
+
+				select {
+				case chOut <- cp:
+				case <-ctx.Done():
+					return
+				}
+			}
+		}()
+	}
+
+	go func() {
+		wg.Wait()
+		close(chOut)
+	}()
+
+	var cps []ChangePreds
+	for cp := range chOut {
+		cps = append(cps, cp)
+	}
+
+	total := func(cp ChangePreds) int {
+		r := 0
+		for _, p := range cp.Predicates {
+			r += p.Score
+		}
+		return r
+	}
+
+	slices.SortFunc(cps, func(a, b ChangePreds) int {
+		if r := cmp.Compare(total(a), total(b)); r != 0 {
+			return -r // negate to sort in descending order
+		}
+		if r := a.Change.Updated().Compare(b.Change.Updated()); r != 0 {
+			return -r // negate to sort newest to oldest
+		}
+
+		// Sort change IDs numerically if we can,
+		// otherwise lexically.
+		aID := a.Change.ID()
+		bID := b.Change.ID()
+		anum, aerr := strconv.Atoi(aID)
+		bnum, berr := strconv.Atoi(bID)
+		if aerr == nil && berr == nil {
+			return cmp.Compare(anum, bnum)
+		} else {
+			return strings.Compare(aID, bID)
+		}
+	})
+
+	return cps
+}
diff --git a/internal/reviews/collect_test.go b/internal/reviews/collect_test.go
new file mode 100644
index 0000000..a3e65be
--- /dev/null
+++ b/internal/reviews/collect_test.go
@@ -0,0 +1,49 @@
+// Copyright 2024 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 reviews
+
+import (
+	"context"
+	"slices"
+	"testing"
+
+	"golang.org/x/oscar/internal/testutil"
+)
+
+func TestCollectChangePreds(t *testing.T) {
+	gc := testGerritClient(t)
+	testutil.Check(t, gc.Testing().LoadTxtar("testdata/gerritchange.txt"))
+
+	ctx := context.Background()
+
+	// Convert a iter.Seq[*GerritChange] into a iter.Seq[Change].
+	changes := func(yield func(Change) bool) {
+		for gc := range GerritChanges(ctx, gc, []string{"test"}, testAccounts()) {
+			if !yield(gc) {
+				return
+			}
+		}
+	}
+
+	cps := CollectChangePreds(ctx, testutil.Slogger(t), changes)
+	if len(cps) != 1 {
+		t.Errorf("CollectChangePreds returned %d entries, want 1", len(cps))
+	}
+	if len(cps) == 0 {
+		return
+	}
+	cp := cps[0]
+	if got := cp.Change.ID(); got != "1" {
+		t.Errorf("CollectChangePreds returned change %s, want 1", got)
+	}
+	var got []string
+	for _, s := range cp.Predicates {
+		got = append(got, s.Name)
+	}
+	want := []string{"authorReviewer"}
+	if !slices.Equal(got, want) {
+		t.Errorf("CollectChangePreds returned change with predicates %v, want %v", got, want)
+	}
+}
diff --git a/internal/reviews/gerrit.go b/internal/reviews/gerrit.go
index d4c57e3..0256d08 100644
--- a/internal/reviews/gerrit.go
+++ b/internal/reviews/gerrit.go
@@ -5,6 +5,8 @@
 package reviews
 
 import (
+	"context"
+	"iter"
 	"slices"
 	"strconv"
 	"time"
@@ -156,3 +158,26 @@
 		return NeedsReview
 	}
 }
+
+// GerritChanges returns an iterator over the [GerritChange]
+// values for the specific projects.
+func GerritChanges(ctx context.Context, cl *gerrit.Client, projects []string, accounts AccountLookup) iter.Seq[*GerritChange] {
+	return func(yield func(*GerritChange) bool) {
+		for _, project := range projects {
+			grc := &GerritReviewClient{
+				GClient:  cl,
+				Project:  project,
+				Accounts: accounts,
+			}
+			for _, changeFn := range cl.ChangeNumbers(project) {
+				gc := &GerritChange{
+					Client: grc,
+					Change: changeFn(),
+				}
+				if !yield(gc) {
+					return
+				}
+			}
+		}
+	}
+}
diff --git a/internal/reviews/gerrit_test.go b/internal/reviews/gerrit_test.go
index 7f50faa..58b8dad 100644
--- a/internal/reviews/gerrit_test.go
+++ b/internal/reviews/gerrit_test.go
@@ -5,8 +5,10 @@
 package reviews
 
 import (
+	"context"
 	"reflect"
 	"slices"
+	"sync"
 	"testing"
 	"time"
 
@@ -30,28 +32,9 @@
 	testutil.Check(t, tc.LoadTxtar(filename))
 	gch := gc.Change("test", num)
 	grc := &GerritReviewClient{
-		GClient: gc,
-		Project: "test",
-		Accounts: gerritTestAccountLookup{
-			"gopher@golang.org": &gerritTestAccount{
-				name:        "gopher@golang.org",
-				displayName: "gopher",
-				authority:   AuthorityReviewer,
-				commits:     10,
-			},
-			"maintainer@golang.org": &gerritTestAccount{
-				name:        "maintainer@golang.org",
-				displayName: "maintainer",
-				authority:   AuthorityMaintainer,
-				commits:     10,
-			},
-			"commenter@golang.org": &gerritTestAccount{
-				name:        "commenter@golang.org",
-				displayName: "commenter",
-				authority:   AuthorityContributor,
-				commits:     10,
-			},
-		},
+		GClient:  gc,
+		Project:  "test",
+		Accounts: testAccounts(),
 	}
 	change := &GerritChange{
 		Client: grc,
@@ -148,6 +131,19 @@
 			t.Errorf("%s got %v, want %v", test.name, got, test.want)
 		}
 	}
+
+	ctx := context.Background()
+	it := GerritChanges(ctx, gc, []string{"test"}, testAccounts())
+	got := slices.Collect(it)
+	var gotIDs []string
+	for _, g := range got {
+		gotIDs = append(gotIDs, g.ID())
+	}
+	slices.Sort(gotIDs)
+	wantIDs := []string{"1", "2"}
+	if !slices.Equal(gotIDs, wantIDs) {
+		t.Errorf("GerritChanges returned IDs %v, want %v", gotIDs, wantIDs)
+	}
 }
 
 // changeMethod is one of the methods used to retrieve Change values.
@@ -194,3 +190,27 @@
 func (gtal gerritTestAccountLookup) Lookup(name string) Account {
 	return gtal[name]
 }
+
+// testAccounts returns an implementation of [AccountLookup] for testing.
+var testAccounts = sync.OnceValue(func() AccountLookup {
+	return gerritTestAccountLookup{
+		"gopher@golang.org": &gerritTestAccount{
+			name:        "gopher@golang.org",
+			displayName: "gopher",
+			authority:   AuthorityReviewer,
+			commits:     10,
+		},
+		"maintainer@golang.org": &gerritTestAccount{
+			name:        "maintainer@golang.org",
+			displayName: "maintainer",
+			authority:   AuthorityMaintainer,
+			commits:     10,
+		},
+		"commenter@golang.org": &gerritTestAccount{
+			name:        "commenter@golang.org",
+			displayName: "commenter",
+			authority:   AuthorityContributor,
+			commits:     10,
+		},
+	}
+})