internal/reviews: new package

This is a step toward a review dashboard.

The reviews package includes a view of changes that is independent
of a particular revision control system. It also includes an
implementation of that view for Gerrit. In practice any specific
Gerrit project, such as the Go project, will need to enhance that
implementation with information specific to that project.

Change-Id: I9aae301a1c52cf1fc77102d0d5b83e38cdfde0c1
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/617760
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Ian Lance Taylor <iant@golang.org>
Reviewed-by: Ian Lance Taylor <iant@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/reviews/gerrit.go b/internal/reviews/gerrit.go
new file mode 100644
index 0000000..d4c57e3
--- /dev/null
+++ b/internal/reviews/gerrit.go
@@ -0,0 +1,158 @@
+// 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 (
+	"slices"
+	"strconv"
+	"time"
+
+	"golang.org/x/oscar/internal/gerrit"
+)
+
+// GerritReviewClient is a [gerrit.Client] with a mapping
+// from account e-mail addresses to [Account] data.
+// We do things this way because a lot of Gerrit change
+// data is more or less the same for any Gerrit project,
+// but account information is not.
+type GerritReviewClient struct {
+	GClient  *gerrit.Client
+	Project  string
+	Accounts AccountLookup
+}
+
+// GerritChange implements [Change] for a Gerrit CL.
+type GerritChange struct {
+	Client *GerritReviewClient
+	Change *gerrit.Change
+}
+
+// ID returns the change ID.
+func (gc *GerritChange) ID() string {
+	return strconv.Itoa(gc.Client.GClient.ChangeNumber(gc.Change))
+}
+
+// Status returns the change status.
+func (gc *GerritChange) Status() Status {
+	switch gc.Client.GClient.ChangeStatus(gc.Change) {
+	case "MERGED":
+		return StatusSubmitted
+	case "ABANDONED":
+		return StatusClosed
+	default:
+		if gc.Client.GClient.ChangeWorkInProgress(gc.Change) {
+			return StatusDoNotReview
+		}
+		return StatusReady
+	}
+}
+
+// Author returns the change author.
+func (gc *GerritChange) Author() Account {
+	gai := gc.Client.GClient.ChangeOwner(gc.Change)
+	return gc.Client.Accounts.Lookup(gai.Email)
+}
+
+// Created returns the time that the change was created.
+func (gc *GerritChange) Created() time.Time {
+	ct := gc.Client.GClient.ChangeTimes(gc.Change)
+	return ct.Created
+}
+
+// Updated returns the time that the change was last updated.
+func (gc *GerritChange) Updated() time.Time {
+	ct := gc.Client.GClient.ChangeTimes(gc.Change)
+	return ct.Updated
+}
+
+// UpdatedByAuthor returns the time that the change was updated by the
+// original author.
+func (gc *GerritChange) UpdatedByAuthor() time.Time {
+	author := gc.Client.GClient.ChangeOwner(gc.Change)
+	revs := gc.Client.GClient.ChangeRevisions(gc.Change)
+	for _, rev := range slices.Backward(revs) {
+		if rev.Uploader.Email == author.Email {
+			return rev.Created.Time()
+		}
+	}
+	return time.Time{}
+}
+
+// Subject returns the change subject.
+func (gc *GerritChange) Subject() string {
+	return gc.Client.GClient.ChangeSubject(gc.Change)
+}
+
+// Description returns the full change description.
+func (gc *GerritChange) Description() string {
+	return gc.Client.GClient.ChangeDescription(gc.Change)
+}
+
+// Reviewers returns the assigned reviewers.
+func (gc *GerritChange) Reviewers() []Account {
+	reviewers := gc.Client.GClient.ChangeReviewers(gc.Change)
+	ret := make([]Account, 0, len(reviewers))
+	for _, rev := range reviewers {
+		ret = append(ret, gc.Client.Accounts.Lookup(rev.Email))
+	}
+	return ret
+}
+
+// Reviewed returns the accounts that have reviewed the change.
+// We treat any account that has sent a message about the change
+// as a reviewer.
+func (gc *GerritChange) Reviewed() []Account {
+	reviewers := make(map[string]bool)
+	msgs := gc.Client.GClient.ChangeMessages(gc.Change)
+	for _, msg := range msgs {
+		if msg.RealAuthor != nil {
+			reviewers[msg.RealAuthor.Email] = true
+		} else if msg.Author != nil {
+			reviewers[msg.Author.Email] = true
+		}
+	}
+
+	num := gc.Client.GClient.ChangeNumber(gc.Change)
+	commentMap := gc.Client.GClient.Comments(gc.Client.Project, num)
+	for _, comments := range commentMap {
+		for _, comment := range comments {
+			if comment.Author != nil {
+				reviewers[comment.Author.Email] = true
+			}
+		}
+	}
+
+	owner := gc.Client.GClient.ChangeOwner(gc.Change)
+	delete(reviewers, owner.Email)
+
+	ret := make([]Account, 0, len(reviewers))
+	for email := range reviewers {
+		ret = append(ret, gc.Client.Accounts.Lookup(email))
+	}
+	return ret
+}
+
+// Needs returns missing requirements for submittal.
+// This implementation does what we can, but most projects will need
+// their own version of this method.
+func (gc *GerritChange) Needs() Needs {
+	hasReview, hasMaintainerReview := false, false
+	for _, review := range gc.Reviewed() {
+		switch review.Authority() {
+		case AuthorityReviewer:
+			hasReview = true
+		case AuthorityMaintainer, AuthorityOwner:
+			hasMaintainerReview = true
+		}
+	}
+	if hasMaintainerReview {
+		// We don't really know if the change can be submitted.
+		return 0
+	} else if hasReview {
+		return NeedsMaintainerReview
+	} else {
+		return NeedsReview
+	}
+}
diff --git a/internal/reviews/gerrit_test.go b/internal/reviews/gerrit_test.go
new file mode 100644
index 0000000..7f50faa
--- /dev/null
+++ b/internal/reviews/gerrit_test.go
@@ -0,0 +1,196 @@
+// 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 (
+	"reflect"
+	"slices"
+	"testing"
+	"time"
+
+	"golang.org/x/oscar/internal/gerrit"
+	"golang.org/x/oscar/internal/secret"
+	"golang.org/x/oscar/internal/storage"
+	"golang.org/x/oscar/internal/testutil"
+)
+
+// testGerritClient returns a [*gerrit.Client] for testing.
+func testGerritClient(t *testing.T) *gerrit.Client {
+	lg := testutil.Slogger(t)
+	db := storage.MemDB()
+	sdb := secret.Empty()
+	return gerrit.New("reviews-test", lg, db, sdb, nil)
+}
+
+// loadTestChange loads a txtar file as a [Change].
+func loadTestChange(t *testing.T, gc *gerrit.Client, filename string, num int) Change {
+	tc := gc.Testing()
+	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,
+			},
+		},
+	}
+	change := &GerritChange{
+		Client: grc,
+		Change: gch,
+	}
+	return change
+}
+
+func TestGerritChange(t *testing.T) {
+	gc := testGerritClient(t)
+	change := loadTestChange(t, gc, "testdata/gerritchange.txt", 1)
+
+	toEmail := func(fn func() []Account) func() []string {
+		return func() []string {
+			var ret []string
+			for _, r := range fn() {
+				ret = append(ret, r.Name())
+			}
+			slices.Sort(ret)
+			return ret
+		}
+	}
+
+	tests := []struct {
+		name   string
+		method func() any
+		want   any
+	}{
+		{
+			"ID",
+			wm(change.ID),
+			"1",
+		},
+		{
+			"Status",
+			wm(change.Status),
+			StatusReady,
+		},
+		{
+			"Author",
+			wm(change.Author().Name),
+			"gopher@golang.org",
+		},
+		{
+			"Created",
+			wm(change.Created),
+			time.Date(2024, 10, 1, 10, 10, 10, 0, time.UTC),
+		},
+		{
+			"Updated",
+			wm(change.Updated),
+			time.Date(2024, 10, 3, 10, 10, 10, 0, time.UTC),
+		},
+		{
+			"UpdatedByAuthor",
+			wm(change.UpdatedByAuthor),
+			time.Date(2024, 10, 2, 10, 10, 10, 0, time.UTC),
+		},
+		{
+			"Subject",
+			wm(change.Subject),
+			"my new change",
+		},
+		{
+			"Description",
+			wm(change.Description),
+			"initial change",
+		},
+		{
+			"Reviewers",
+			wm(toEmail(change.Reviewers)),
+			[]string{
+				"maintainer@golang.org",
+			},
+		},
+		{
+			"Reviewed",
+			wm(toEmail(change.Reviewed)),
+			[]string{
+				"commenter@golang.org",
+				"maintainer@golang.org",
+			},
+		},
+		{
+			"Needs",
+			wm(change.Needs),
+			Needs(0),
+		},
+	}
+
+	for _, test := range tests {
+		got := test.method()
+		if !reflect.DeepEqual(got, test.want) {
+			t.Errorf("%s got %v, want %v", test.name, got, test.want)
+		}
+	}
+}
+
+// changeMethod is one of the methods used to retrieve Change values.
+type changeMethod[T any] func() T
+
+// wm wraps a changeMethod in a function that we can put in a table.
+func wm[T any](fn changeMethod[T]) func() any {
+	return func() any {
+		return fn()
+	}
+}
+
+// gerritTestAccount implements [Account].
+type gerritTestAccount struct {
+	name        string
+	displayName string
+	authority   Authority
+	commits     int
+}
+
+// Name implements Account.Name.
+func (gta *gerritTestAccount) Name() string {
+	return gta.name
+}
+
+// DisplayName implements Account.DisplayName.
+func (gta *gerritTestAccount) DisplayName() string {
+	return gta.displayName
+}
+
+// Authority implements Account.Authority
+func (gta *gerritTestAccount) Authority() Authority {
+	return gta.authority
+}
+
+// Commits implements Account.Commits.
+func (gta *gerritTestAccount) Commits() int {
+	return gta.commits
+}
+
+// gerritTestAccountLookup implements [AccountLookup].
+type gerritTestAccountLookup map[string]Account
+
+func (gtal gerritTestAccountLookup) Lookup(name string) Account {
+	return gtal[name]
+}
diff --git a/internal/reviews/pred.go b/internal/reviews/pred.go
new file mode 100644
index 0000000..9e610e0
--- /dev/null
+++ b/internal/reviews/pred.go
@@ -0,0 +1,208 @@
+// 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 (
+	"fmt"
+	"sync"
+)
+
+// A Predicate is a categorization of a [Change].
+// An example of a Predicate would be "has been approved by a maintainer"
+// or "author is a known contributor" or
+// "waiting for response from author."
+// The dashboard permits filtering and sorting CLs based on the different
+// predicates they satisfy.
+// A Predicate has a default score that indicates how important it is,
+// but the dashboard permits using different sort orders.
+type Predicate struct {
+	Name        string // a short, one-word name
+	Description string // a longer description
+	Score       int    // default scoring value
+
+	// The Applies function reports whether this Predicate
+	// applies to a change.
+	Applies func(Change) (bool, error)
+}
+
+// ChangePreds is a [Change] with a list of predicates that apply
+// to that change.
+type ChangePreds struct {
+	Change     Change
+	Predicates []*Predicate
+}
+
+// A Reject is like a [Predicate], but if the Reject applies to a [Change]
+// then the Change is not put on the dashboard at all.
+type Reject Predicate
+
+// ApplyPredicates takes a [Change] and applies predicates to it.
+// The bool result reports whether the change is reviewable;
+// this will be false if the change should not be reviewed,
+// for example because it has already been committed.
+func ApplyPredicates(change Change) (ChangePreds, bool, error) {
+	predicatesLock.Lock()
+	// The predicates and rejects slices are append-only,
+	// so a top-level copy is safe to use.
+	p := predicates
+	r := rejects
+	predicatesLock.Unlock()
+
+	for i := range r {
+		applies, err := r[i].Applies(change)
+		if err != nil {
+			return ChangePreds{}, false, err
+		}
+		if applies {
+			return ChangePreds{}, false, nil
+		}
+	}
+
+	var preds []*Predicate
+	for i := range p {
+		pred := &p[i]
+		applies, err := pred.Applies(change)
+		if err != nil {
+			return ChangePreds{}, false, err
+		}
+		if applies {
+			preds = append(preds, pred)
+		}
+	}
+
+	cp := ChangePreds{
+		Change:     change,
+		Predicates: preds,
+	}
+
+	return cp, true, nil
+}
+
+// AddPredicates adds more [Predicate] values for a project.
+func AddPredicates(newPreds []Predicate) {
+	predicatesLock.Lock()
+	defer predicatesLock.Unlock()
+	predicates = append(predicates, newPreds...)
+}
+
+// AddRejects adds more [Reject] values for a project.
+func AddRejects(newRejects []Reject) {
+	predicatesLock.Lock()
+	defer predicatesLock.Unlock()
+	rejects = append(rejects, newRejects...)
+}
+
+// Some [Predicate] default scores.
+const (
+	ScoreImportant     = 10  // change is important
+	ScoreSuggested     = 1   // change is worth looking at
+	ScoreUninteresting = -1  // change is not interesting
+	ScoreUnimportant   = -10 // change is not important
+)
+
+// predicatesLock protects predicates and rejectors.
+var predicatesLock sync.Mutex
+
+// predicates is the list of [Predicate] values that we apply to a change.
+var predicates = []Predicate{
+	{
+		Name:        "authorMaintainer",
+		Description: "the change author is a project maintainer",
+		Score:       ScoreImportant,
+		Applies:     authorMaintainer,
+	},
+	{
+		Name:        "authorReviewer",
+		Description: "the change author is a project reviewer",
+		Score:       ScoreSuggested,
+		Applies:     authorReviewer,
+	},
+	{
+		Name:        "authorContributor",
+		Description: "the change author is a project contributor",
+		Score:       ScoreSuggested,
+		Applies:     authorContributor,
+	},
+	{
+		Name:        "authorMajorContributor",
+		Description: "the change author is a major project contributor",
+		Score:       ScoreImportant,
+		Applies:     authorMajorContributor,
+	},
+	{
+		Name:        "noMaintainerReviews",
+		Description: "has no reviews from a project maintainer",
+		Score:       ScoreSuggested,
+		Applies:     noMaintainerReviews,
+	},
+}
+
+// authorMaintainer is a [Predicate] function that reports whether the
+// [Change] author is a project maintainer.
+func authorMaintainer(ch Change) (bool, error) {
+	switch ch.Author().Authority() {
+	case AuthorityMaintainer, AuthorityOwner:
+		return true, nil
+	default:
+		return false, nil
+	}
+}
+
+// authorReviewer is a [Predicate] function that reports whether the
+// [Change] author is a project reviewer.
+func authorReviewer(ch Change) (bool, error) {
+	switch ch.Author().Authority() {
+	case AuthorityReviewer:
+		return true, nil
+	default:
+		return false, nil
+	}
+}
+
+// authorContributor is a [Predicate] function that reports whether the
+// [Change] author is a known contributor: more than 10 changes contributed.
+func authorContributor(ch Change) (bool, error) {
+	return ch.Author().Commits() > 10, nil
+}
+
+// authorMajorContributor is a [Predicate] function that reports whether the
+// [Change] author is a major contributor: more than 50 changes contributed.
+func authorMajorContributor(ch Change) (bool, error) {
+	return ch.Author().Commits() > 50, nil
+}
+
+// noMaintainerReviews is a [Predicate] function that reports whether the
+// [Change] has not been reviewed by a maintainer.
+func noMaintainerReviews(ch Change) (bool, error) {
+	for _, r := range ch.Reviewed() {
+		switch r.Authority() {
+		case AuthorityMaintainer, AuthorityOwner:
+			return false, nil
+		}
+	}
+	return true, nil
+}
+
+// rejects is the list of Reject values that we apply to a change.
+var rejects = []Reject{
+	{
+		Name:        "reviewable",
+		Description: "whether the change is reviewable",
+		Applies:     unreviewable,
+	},
+}
+
+// unreviewable is a [Reject] function that reports whether a
+// [Change] is not reviewable.
+func unreviewable(ch Change) (bool, error) {
+	switch status := ch.Status(); status {
+	case StatusReady:
+		return false, nil
+	case StatusSubmitted, StatusClosed, StatusDoNotReview:
+		return true, nil
+	default:
+		return false, fmt.Errorf("reviewable predicate: change %s: unrecognized status %d", ch.ID(), status)
+	}
+}
diff --git a/internal/reviews/pred_test.go b/internal/reviews/pred_test.go
new file mode 100644
index 0000000..5c13a0c
--- /dev/null
+++ b/internal/reviews/pred_test.go
@@ -0,0 +1,44 @@
+// 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 (
+	"slices"
+	"testing"
+)
+
+func TestPredicates(t *testing.T) {
+	gc := testGerritClient(t)
+	const file = "testdata/gerritchange.txt"
+	const num = 1
+	change := loadTestChange(t, gc, file, 1)
+	sc, ok, err := ApplyPredicates(change)
+	if err != nil {
+		t.Fatalf("%s: %d: ApplyPredicates returned unexpected error %v", file, num, err)
+	} else if !ok {
+		t.Fatalf("%s: %d: ApplyPredicates unexpectedly rejected change", file, num)
+	}
+	var got []string
+	for _, s := range sc.Predicates {
+		got = append(got, s.Name)
+	}
+	want := []string{"authorReviewer"}
+	if !slices.Equal(got, want) {
+		t.Errorf("%s: %d: got %v, want %v", file, num, got, want)
+	}
+}
+
+func TestReject(t *testing.T) {
+	gc := testGerritClient(t)
+	const file = "testdata/gerritchange.txt"
+	const num = 2
+	change := loadTestChange(t, gc, file, num)
+	_, ok, err := ApplyPredicates(change)
+	if err != nil {
+		t.Fatalf("%s: %d: ApplyPredicates returned unexpected error %v", file, num, err)
+	} else if ok {
+		t.Fatalf("%s: %d: ApplyPredicates unexpectedly accepted change", file, num)
+	}
+}
diff --git a/internal/reviews/reviews.go b/internal/reviews/reviews.go
new file mode 100644
index 0000000..29cfe42
--- /dev/null
+++ b/internal/reviews/reviews.go
@@ -0,0 +1,125 @@
+// 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 contains tools for project maintainers to
+// categorize incoming changes, to help them decide what to
+// review next.
+package reviews
+
+import (
+	"time"
+)
+
+// A Change is a change suggested for a project.
+// This is something like a GitHub pull request or a Gerrit change.
+//
+// For example, users of a Gerrit repo will read data using
+// the gerrit package, and produce a [GerritChange] which implements [Change].
+// They can then use the general purpose scoring algorithms to
+// categorize the changes.
+type Change interface {
+	// The change ID, which is unique for a given project.
+	// This is something like a Git revision or a Gerrit change number.
+	ID() string
+	// The change status.
+	Status() Status
+	// The person or agent who wrote the change.
+	Author() Account
+	// When the change was created.
+	Created() time.Time
+	// When the change was last updated.
+	Updated() time.Time
+	// When the change was last updated by the change author.
+	UpdatedByAuthor() time.Time
+	// The change subject: the first line of the description.
+	Subject() string
+	// The complete change description.
+	Description() string
+	// The list of people whose review is requested.
+	Reviewers() []Account
+	// The list of people who have reviewed the change.
+	Reviewed() []Account
+	// What the change needs in order to be submitted.
+	Needs() Needs
+}
+
+// Status is the status of a change.
+type Status int
+
+const (
+	// Change is ready for review. The default.
+	StatusReady Status = iota
+	// Change is submitted.
+	StatusSubmitted
+	// Change is closed or abandoned.
+	StatusClosed
+	// Change is open but not ready for review.
+	StatusDoNotReview
+)
+
+// Needs is a bitmask of missing requirements for a [Change].
+// The requirements may not be comprehensive; it's possible that
+// when all these requirements are satisfied there will be more.
+// If the Needs value is 0 then the change can be submitted.
+type Needs int
+
+const (
+	// Change needs a review by someone.
+	NeedsReview Needs = 1 << iota
+	// Change needs to be approved.
+	NeedsApproval
+	// Change needs maintainer review,
+	// but not necessarily approval.
+	NeedsMaintainerReview
+	// Change needs to resolve a reviewer comment.
+	NeedsResolve
+	// Change needs to resolve a merge conflict.
+	NeedsConflictResolve
+	// Change waiting for tests or other checks to pass.
+	NeedsCheck
+	// Some reviewer has this change on hold,
+	// or change is marked as do not submit by author.
+	NeedsHoldRemoval
+	// Change waiting for next release to open.
+	NeedsRelease
+	// Change not submittable for some other reason.
+	NeedsOther
+)
+
+// An Account describes a person or agent.
+type Account interface {
+	// The unique account name, such as an e-mail address.
+	Name() string
+	// The display name of the account, such as a person's full name.
+	DisplayName() string
+	// The authority of this account in the project.
+	Authority() Authority
+	// Number of commits made by this account to the project.
+	Commits() int
+}
+
+// AccountLookup looks up account information by name.
+// If there is no such account, this returns nil.
+// At least for Gerrit, account information is stored
+// differently by different Gerrit instances,
+// so we need an interface.
+type AccountLookup interface {
+	Lookup(string) Account
+}
+
+// Authority describes what authority a person has in a project.
+type Authority int
+
+const (
+	// Person is unknown or has no particular status.
+	AuthorityUnknown Authority = iota
+	// Person has contributed changes.
+	AuthorityContributor
+	// Person has reviewed changes.
+	AuthorityReviewer
+	// Person is a maintainer who can review and commit patches by others.
+	AuthorityMaintainer
+	// Person is a project owner/admin.
+	AuthorityOwner
+)
diff --git a/internal/reviews/testdata/gerritchange.txt b/internal/reviews/testdata/gerritchange.txt
new file mode 100644
index 0000000..cb17bb9
--- /dev/null
+++ b/internal/reviews/testdata/gerritchange.txt
@@ -0,0 +1,41 @@
+-- change#1 --
+Number: 1
+Project: test
+Status: NEW
+Owner: gopher@golang.org
+Created: "2024-10-01 10:10:10.00000000"
+Updated: "2024-10-03 10:10:10.00000000"
+Subject: my new change
+CurrentRevision: hash2
+CurrentRevisionNumber: 2
+Revisions: hash1
+ Kind: REWORK
+ Number: 1
+ Created: "2024-10-01 10:10:10.00000000"
+ Uploader: gopher@golang.org
+ Commit:
+  Message: initial change
+Revisions: hash2
+ Kind: REWORK
+ Number: 2
+ Created: "2024-10-02 10:10:10.00000000"
+ Uploader: gopher@golang.org
+ Commit:
+  Message: initial change
+Reviewers: REVIEWER
+ AccountID: 1000
+ Email: maintainer@golang.org
+Messages:
+ ID: message hash 1
+ Author: maintainer@golang.org
+ Date: "2024-10-01 11:10:10.00000000"
+ Message: "maintainer review"
+Messages:
+ ID: message hash 2
+ Author: commenter@golang.org
+ Date: "2024-10-01 12:10:10.00000000"
+ Message: "commenter review"
+-- change#2 --
+Number: 2
+Project: test
+Status: MERGED