internal/gerrit: support setting any field from a txtar file
Change-Id: I84fb44e45a61231303c3bbb91b95b59a2e477c69
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/617315
Commit-Queue: Ian Lance Taylor <iant@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>
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
diff --git a/internal/gerrit/change.go b/internal/gerrit/change.go
index 59d7d46..78ca091 100644
--- a/internal/gerrit/change.go
+++ b/internal/gerrit/change.go
@@ -109,7 +109,7 @@
}
if abandoned.IsZero() {
c.slog.Error("gerrit change abandoned missing message", "num", ch.num, "data", ch.data)
- c.db.Panic("gerrit change abandoned missing message for %d", ch.num)
+ c.db.Panic("gerrit change abandoned missing message", "num", ch.num)
}
}
@@ -160,7 +160,7 @@
rev, ok := revisions.Revisions[revisions.CurrentRevision]
if !ok {
c.slog.Error("gerrit no revision data for current revision", "num", ch.num, "data", ch.data, "currentRevision", revisions.CurrentRevision)
- c.db.Panic("gerrit no revision data for current revision for %d", ch.num)
+ c.db.Panic("gerrit no revision data for current revision", "num", ch.num)
}
return rev.Commit.Message
diff --git a/internal/gerrit/sync_test.go b/internal/gerrit/sync_test.go
index 6becb42..82ee675 100644
--- a/internal/gerrit/sync_test.go
+++ b/internal/gerrit/sync_test.go
@@ -100,6 +100,14 @@
}
}
+// changeTests is a list of tests to run on a change.
+type changeTests struct {
+ name string
+ accessor func(*Change) any
+ want any
+ eq func(any, any) bool
+}
+
// accessor is one of the accessor methods to retrieve Change values.
type accessor[T any] func(*Change) T
@@ -110,6 +118,23 @@
}
}
+// testChangeTests checks that a [Change] satisfies a list of [changeTests].
+func testChangeTests(t *testing.T, ch *Change, tests []changeTests) {
+ t.Helper()
+ for _, test := range tests {
+ got := test.accessor(ch)
+ var ok bool
+ if test.eq == nil {
+ ok = got == test.want
+ } else {
+ ok = test.eq(got, test.want)
+ }
+ if !ok {
+ t.Errorf("%s got %v, want %v", test.name, got, test.want)
+ }
+ }
+}
+
// checkFirstCL checks the first CL in our saved sync against
// the values we expect. The first CL is https://go.dev/cl/24894.
func checkFirstCL(t *testing.T, c *Client, ch *Change, num int) {
@@ -117,12 +142,7 @@
t.Errorf("got first CL number %d, want 24894", num)
}
- tests := []struct {
- name string
- accessor func(*Change) any
- want any
- eq func(any, any) bool
- }{
+ tests := []changeTests{
{
"ChangeNumber",
wa(c.ChangeNumber),
@@ -310,18 +330,7 @@
},
}
- for _, test := range tests {
- got := test.accessor(ch)
- var ok bool
- if test.eq == nil {
- ok = got == test.want
- } else {
- ok = test.eq(got, test.want)
- }
- if !ok {
- t.Errorf("%s got %v, want %v", test.name, got, test.want)
- }
- }
+ testChangeTests(t, ch, tests)
}
// checkChange verifies that we can unpack CL information, and that it
diff --git a/internal/gerrit/testdata/change.txt b/internal/gerrit/testdata/change.txt
new file mode 100644
index 0000000..0faab16
--- /dev/null
+++ b/internal/gerrit/testdata/change.txt
@@ -0,0 +1,22 @@
+-- change#1 --
+Number: 1
+Labels: Hold
+ Optional: true
+ Description: put change on hold
+ Values: 0: not on hold
+ Values: 1: on hold
+ Votes: 0
+Owner: gopher@golang.org
+CurrentRevision: commitid1
+Revisions: commitid1
+ Number: 1
+ Commit:
+ Subject: patch set 1
+ Message: gerrit: test change
+ Author:
+ Email: gopher@golang.org
+Hashtags: tag1 tag2
+Messages:
+ Message: message 1
+Messages:
+ Message: message 2
diff --git a/internal/gerrit/testing.go b/internal/gerrit/testing.go
index 11ed6b6..c3f49f0 100644
--- a/internal/gerrit/testing.go
+++ b/internal/gerrit/testing.go
@@ -11,6 +11,7 @@
"fmt"
"iter"
"os"
+ "reflect"
"strconv"
"strings"
"testing"
@@ -69,6 +70,12 @@
return err
}
+// timeStampType is the [reflect.Type] of [TimeStamp].
+var timeStampType = reflect.TypeFor[TimeStamp]()
+
+// accountInfoType is the [reflect.Type] of [*AccountInfo].
+var accountInfoType = reflect.TypeFor[*AccountInfo]()
+
// LoadTxtarData loads a change info history from the txtar file content data.
// See [LoadTxtar] for a description of the format.
func (tc *TestingClient) LoadTxtarData(data []byte) error {
@@ -77,48 +84,212 @@
data := string(file.Data)
// Skip the name and proceed to read headers.
c := &ChangeInfo{}
- for {
- line, rest, _ := strings.Cut(data, "\n")
- data = rest
- if line == "" {
- break
- }
- key, val, ok := strings.Cut(line, ":")
- if !ok {
- return fmt.Errorf("%s: invalid header line: %q", file.Name, line)
- }
- val = strings.TrimSpace(val)
- if val == "" {
- continue
- }
- switch key {
- case "Number":
- i, err := strconv.Atoi(val)
- if err != nil {
- return err
- }
- c.Number = i
- case "Updated":
- t, err := timestamp(val)
- if err != nil {
- return err
- }
- c.Updated = t
- case "MetaRevID":
- c.MetaRevID = val
- case "interrupt":
- b, err := strconv.ParseBool(val)
- if err != nil {
- return err
- }
- c.interrupt = b
- }
+ cv := reflect.ValueOf(c).Elem()
+ if _, err := tc.setFields(file.Name, data, 0, cv); err != nil {
+ return err
}
tc.chs = append(tc.chs, c)
}
return nil
}
+// setFields reads field values and sets fields in a struct accordingly.
+// The indent parameter says how many spaces are required on each line;
+// this supports fields that are themselves structs.
+// This returns the remaining data.
+func (tc *TestingClient) setFields(filename, data string, indent int, st reflect.Value) (string, error) {
+ prefix := strings.Repeat(" ", indent)
+ for {
+ line, rest, _ := strings.Cut(data, "\n")
+ if line == "" {
+ data = rest
+ break
+ }
+ line, ok := strings.CutPrefix(line, prefix)
+ if !ok {
+ // Don't change data.
+ break
+ }
+ data = rest
+ rest, err := tc.setField(filename, line, data, indent, st)
+ if err != nil {
+ return "", err
+ }
+ data = rest
+ }
+ return data, nil
+}
+
+// setField takes a struct and a line that sets a scalar field.
+// The line should have the form "key: value",
+// where "key" is the name of a field in the struct and
+// "value is the value we want to set it to.
+// This isn't general, it only handles the cases that arise
+// for Gerrit types.
+// The data argument is the data following the line,
+// used for multi-line values.
+// This returns the remaining data.
+func (tc *TestingClient) setField(filename string, line, data string, indent int, st reflect.Value) (string, error) {
+ key, val, ok := strings.Cut(line, ":")
+ if !ok {
+ return "", fmt.Errorf("%s: invalid line: %q", filename, line)
+ }
+ val = strings.TrimSpace(val)
+
+ field := st.FieldByName(key)
+ if !field.IsValid() {
+ return "", fmt.Errorf("%s: unrecognized field name %q in %s", filename, key, st.Type())
+ }
+
+ var vval reflect.Value
+ switch field.Type().Kind() {
+ case reflect.Bool:
+ b, err := strconv.ParseBool(val)
+ if err != nil {
+ return "", fmt.Errorf("%s: field %q: can't parse %q as bool", filename, key, val)
+ }
+ vval = reflect.ValueOf(b)
+
+ case reflect.Int:
+ i, err := strconv.Atoi(val)
+ if err != nil {
+ return "", fmt.Errorf("%s: field %q: can't parse %q as int", filename, key, val)
+ }
+ vval = reflect.ValueOf(i)
+
+ case reflect.String:
+ vval = reflect.ValueOf(val)
+
+ case reflect.Struct:
+ if field.Type() == timeStampType {
+ t, err := timestamp(val)
+ if err != nil {
+ return "", fmt.Errorf("%s: field %q: can't parse %q as timestamp", filename, key, val)
+ }
+ vval = reflect.ValueOf(TimeStamp(t))
+ break
+ }
+ return "", fmt.Errorf("%s: field %q: unexpected struct type %s", filename, key, field.Type())
+
+ case reflect.Pointer:
+ if field.Type() == accountInfoType {
+ // For an account we just an email address.
+ if val == "" {
+ return "", fmt.Errorf("%s: field %q: missing email address for AccountInfo", filename, key)
+ }
+ vval = reflect.ValueOf(&AccountInfo{Email: val})
+ break
+ }
+ if field.Type().Elem().Kind() != reflect.Struct {
+ return "", fmt.Errorf("%s: field %q: unexpected pointer to %s", filename, key, field.Type().Elem())
+ }
+
+ // For struct types in general we expect the fields
+ // to follow, indented.
+ vval = reflect.New(field.Type().Elem())
+ rest, err := tc.setFields(filename, data, indent+1, vval.Elem())
+ if err != nil {
+ return "", err
+ }
+ data = rest
+ break
+
+ case reflect.Slice:
+ switch field.Type().Elem().Kind() {
+ case reflect.Int:
+ // For ints just put all the values on one line.
+ var ints []int
+ for _, vi := range strings.Fields(val) {
+ i, err := strconv.Atoi(vi)
+ if err != nil {
+ return "", fmt.Errorf("%s: field %q: %v", filename, key, err)
+ }
+ ints = append(ints, i)
+ }
+ vval = reflect.ValueOf(ints)
+
+ case reflect.String:
+ // For strings just put all the values on one line.
+ // Strings are space separated, no quoting.
+ var strs []string
+ for _, vs := range strings.Fields(val) {
+ vs = strings.TrimSpace(vs)
+ if vs == "" {
+ continue
+ }
+ strs = append(strs, vs)
+ }
+ vval = reflect.ValueOf(strs)
+
+ case reflect.Pointer:
+ if field.Type().Elem().Elem().Kind() != reflect.Struct {
+ return "", fmt.Errorf("%s: field %q: pointer not to struct in type %s", filename, key, field.Type())
+ }
+ vval = reflect.New(field.Type().Elem().Elem())
+ rest, err := tc.setFields(filename, data, indent+1, vval.Elem())
+ if err != nil {
+ return "", err
+ }
+ data = rest
+ vval = reflect.Append(field, vval)
+
+ default:
+ return "", fmt.Errorf("%s: field %q: unsupported slice type %s", filename, key, field.Type())
+ }
+
+ case reflect.Map:
+ if field.Type().Key().Kind() != reflect.String {
+ return "", fmt.Errorf("%s: field %q: unsupported map key type in %s", filename, key, field.Type())
+ }
+
+ if field.IsZero() {
+ field.Set(reflect.MakeMap(field.Type()))
+ }
+
+ switch field.Type().Elem().Kind() {
+ case reflect.String:
+ mkey, mval, ok := strings.Cut(val, ":")
+ if !ok {
+ return "", fmt.Errorf("%s: field %q: expected key: val in map to string", filename, key)
+ }
+ mkey = strings.TrimSpace(mkey)
+ mval = strings.TrimSpace(mval)
+ field.SetMapIndex(reflect.ValueOf(mkey), reflect.ValueOf(mval))
+ // Don't fall through to bottom of function.
+ return data, nil
+
+ case reflect.Pointer:
+ if field.Type().Elem().Elem().Kind() != reflect.Struct {
+ return "", fmt.Errorf("%s: field %q: pointer not to struct in map element type in %s", filename, key, field.Type())
+ }
+ vval = reflect.New(field.Type().Elem().Elem())
+ rest, err := tc.setFields(filename, data, indent+1, vval.Elem())
+ if err != nil {
+ return "", err
+ }
+ data = rest
+ field.SetMapIndex(reflect.ValueOf(val), vval)
+ // Don't fall through to bottom of function.
+ return data, nil
+
+ default:
+ return "", fmt.Errorf("%s: field %q: unsupported map element type in %s", filename, key, field.Type())
+ }
+
+ default:
+ return "", fmt.Errorf("%s: field %q: unsupported type %s", filename, key, field.Type())
+ }
+
+ if key == "interrupt" {
+ // Special case for the only unexported field.
+ st.Addr().Interface().(*ChangeInfo).interrupt = vval.Bool()
+ } else {
+ field.Set(vval)
+ }
+
+ return data, nil
+}
+
// changes returns an iterator of change updates in tc.chs that are updated
// in the interval [after, before], in reverse chronological order. First
// skip number of matching change updates are disregarded.
@@ -194,6 +365,19 @@
return ain && bin, nil
}
+// change returns the data for a single testing change.
+func (tc *TestingClient) change(changeNum int) *Change {
+ for _, ch := range tc.chs {
+ if ch.Number == changeNum {
+ return &Change{
+ num: changeNum,
+ data: storage.JSON(ch),
+ }
+ }
+ }
+ return nil
+}
+
func timestamp(gt string) (TimeStamp, error) {
var ts TimeStamp
if err := ts.UnmarshalJSON([]byte(quote(gt))); err != nil {
diff --git a/internal/gerrit/testing_test.go b/internal/gerrit/testing_test.go
index a6418a3..9c12dd8 100644
--- a/internal/gerrit/testing_test.go
+++ b/internal/gerrit/testing_test.go
@@ -6,11 +6,94 @@
import (
"context"
+ "maps"
+ "slices"
"testing"
+ "golang.org/x/oscar/internal/secret"
+ "golang.org/x/oscar/internal/storage"
"golang.org/x/oscar/internal/testutil"
)
+func TestLoadTxtar(t *testing.T) {
+ check := testutil.Checker(t)
+
+ lg := testutil.Slogger(t)
+ db := storage.MemDB()
+ sdb := secret.Empty()
+ c := New("gerrit-test", lg, db, sdb, nil)
+
+ tc := c.Testing()
+ check(tc.LoadTxtar("testdata/change.txt"))
+ ch := tc.change(1)
+ if ch == nil {
+ t.Fatal("could not find loaded change")
+ }
+
+ tests := []changeTests{
+ {
+ "ChangeLabels",
+ wa(c.ChangeLabels),
+ map[string]string{
+ "Hold": "put change on hold",
+ },
+ func(got, want any) bool {
+ g := got.(map[string]*LabelInfo)
+ w := want.(map[string]string)
+ m := make(map[string]string)
+ for k, l := range g {
+ m[k] = l.Description
+ }
+ return maps.Equal(w, m)
+ },
+ },
+ {
+ "ChangeOwner",
+ wa(c.ChangeOwner),
+ "gopher@golang.org",
+ func(got, want any) bool {
+ return got.(*AccountInfo).Email == want
+ },
+ },
+ {
+ "ChangeDescription",
+ wa(c.ChangeDescription),
+ "gerrit: test change",
+ nil,
+ },
+ {
+ "ChangeHashtags",
+ wa(c.ChangeHashtags),
+ []string{"tag1", "tag2"},
+ func(got, want any) bool {
+ g := got.([]string)
+ w := want.([]string)
+ return slices.Equal(g, w)
+ },
+ },
+ {
+ "ChangeMessages",
+ wa(c.ChangeMessages),
+ []string{
+ "message 1",
+ "message 2",
+ },
+ func(got, want any) bool {
+ g := got.([]ChangeMessageInfo)
+ w := want.([]string)
+ for i, m := range g {
+ if m.Message != w[i] {
+ return false
+ }
+ }
+ return true
+ },
+ },
+ }
+
+ testChangeTests(t, ch, tests)
+}
+
func TestTestingChanges(t *testing.T) {
check := testutil.Checker(t)
ctx := context.Background()
diff --git a/internal/gerrit/types.go b/internal/gerrit/types.go
index 05fad19..196b820 100644
--- a/internal/gerrit/types.go
+++ b/internal/gerrit/types.go
@@ -28,13 +28,13 @@
// The map that maps account IDs to AttentionSetInfo of that
// account. Those are all accounts that are currently in the
// attention set.
- AttentionSet map[int]AttentionSetInfo `json:"attention_set,omitempty"`
+ AttentionSet map[int]*AttentionSetInfo `json:"attention_set,omitempty"`
// The map that maps account IDs to AttentionSetInfo of that
// account. Those are all accounts that were in the attention
// set but were removed. The AttentionSetInfo is the latest
// and most recent removal of the account from the attention set.
- RemovedFromAttentionSet map[int]AttentionSetInfo `json:"removed_from_attention_set,omitempty"`
+ RemovedFromAttentionSet map[int]*AttentionSetInfo `json:"removed_from_attention_set,omitempty"`
// List of hashtags that are set on the change.
Hashtags []string `json:"hashtags,omitempty"`
@@ -95,7 +95,7 @@
// List of the [SubmitRecordInfo] containing the submit records
// for the change at the latest patchset.
- SubmitRecords []SubmitRecordInfo `json:"submit_records"`
+ SubmitRecords []*SubmitRecordInfo `json:"submit_records"`
// The labels of the change as a map that maps the label names
// to LabelInfo entries.
@@ -119,7 +119,7 @@
PendingReviewers map[string][]*AccountInfo `json:"pending_reviewers,omitempty"`
// Messages associated with the change.
- Messages []ChangeMessageInfo `json:"messages,omitempty"`
+ Messages []*ChangeMessageInfo `json:"messages,omitempty"`
// The number of the current patch set of this change.
CurrentRevisionNumber int `json:"current_revision_number"`
@@ -129,7 +129,7 @@
// All patch sets of this change as a map that maps the commit
// ID of the patch set to a [RevisionInfo] entity.
- Revisions map[string]RevisionInfo `json:"revisions,omitempty"`
+ Revisions map[string]*RevisionInfo `json:"revisions,omitempty"`
// The SHA-1 of the NoteDb meta ref.
MetaRevID string `json:"meta_rev_id,omitempty"`