[cl-util] Add cl-util tool
cl-util is a tool which provides the ability to create CLs and apply
CQ+1/2 in Gerrit without requiring a local checkout.
Subcommands:
create-cl: Create a CL, with specified file edits.
trigger-cq: Apply CQ+1/2 label, and optionally wait on CQ to finish.
Bug: 51330
Change-Id: Ia848358255bf5f9ab27d3bdf6da488f845032ba9
Reviewed-on: https://fuchsia-review.googlesource.com/c/infra/infra/+/385754
Commit-Queue: Anthony Fandrianto <atyfto@google.com>
Reviewed-by: Marc-Antoine Ruel <maruel@google.com>
diff --git a/cmd/cl-util/common.go b/cmd/cl-util/common.go
new file mode 100644
index 0000000..653db52
--- /dev/null
+++ b/cmd/cl-util/common.go
@@ -0,0 +1,52 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+
+ "github.com/maruel/subcommands"
+
+ "go.chromium.org/luci/auth"
+ "go.chromium.org/luci/auth/client/authcli"
+)
+
+type commonFlags struct {
+ subcommands.CommandRunBase
+ gerritHost string
+ gerritProject string
+ authFlags authcli.Flags
+
+ parsedAuthOpts auth.Options
+}
+
+func (c *commonFlags) Init(authOpts auth.Options) {
+ c.authFlags = authcli.Flags{}
+ c.authFlags.Register(&c.Flags, authOpts)
+ c.Flags.StringVar(&c.gerritHost, "gerrit-host", "", "Gerrit host to use.")
+ c.Flags.StringVar(&c.gerritProject, "gerrit-project", "", "Gerrit project to use.")
+}
+
+func (c *commonFlags) Parse() error {
+ var err error
+ c.parsedAuthOpts, err = c.authFlags.Options()
+ if err != nil {
+ return err
+ }
+ if c.gerritHost == "" {
+ return fmt.Errorf("-gerrit-host is required")
+ }
+ if c.gerritProject == "" {
+ return fmt.Errorf("-gerrit-project is required")
+ }
+ return nil
+}
+
+// newAuthClient returns an authenticated *http.Client.
+func newAuthClient(ctx context.Context, authOpts auth.Options) (*http.Client, error) {
+ return auth.NewAuthenticator(ctx, auth.OptionalLogin, authOpts).Client()
+}
diff --git a/cmd/cl-util/create_cl.go b/cmd/cl-util/create_cl.go
new file mode 100644
index 0000000..086da14
--- /dev/null
+++ b/cmd/cl-util/create_cl.go
@@ -0,0 +1,143 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/maruel/subcommands"
+ "go.chromium.org/luci/auth"
+)
+
+func cmdCreateCL(authOpts auth.Options) *subcommands.Command {
+ return &subcommands.Command{
+ UsageLine: "create-cl -host <gerrit-host> -project <gerrit-project> -subject <cl-subject> -file-edit <filepath1>:<contents1>, ...",
+ ShortDesc: "Create a CL with file edits.",
+ LongDesc: "Create a CL with file edits.",
+ CommandRun: func() subcommands.CommandRun {
+ c := &createCLRun{}
+ c.Init(authOpts)
+ return c
+ },
+ }
+}
+
+type createCLRun struct {
+ commonFlags
+ edits fileEdits
+ subject string
+}
+
+func (c *createCLRun) Init(defaultAuthOpts auth.Options) {
+ c.commonFlags.Init(defaultAuthOpts)
+ c.Flags.StringVar(&c.subject, "subject", "", "CL subject.")
+ c.Flags.Var(&c.edits, "file-edit", "filepath:content pairs. Repeatable.")
+}
+
+func (c *createCLRun) Parse(a subcommands.Application, args []string) error {
+ if err := c.commonFlags.Parse(); err != nil {
+ return err
+ }
+ if c.subject == "" {
+ return fmt.Errorf("-subject is required")
+ }
+ if len(c.edits) == 0 {
+ return fmt.Errorf("at least one -file-edit is required")
+ }
+ return nil
+}
+
+// fileEdit represents a file to edit in a repository.
+type fileEdit struct {
+ filepath string
+ contents string
+}
+
+// String returns a string representation of the file edit.
+func (e *fileEdit) String() string {
+ return fmt.Sprintf("%s:%s", e.filepath, e.contents)
+}
+
+// newFileEdit returns a fileEdit for a filepath:contents string.
+func newFileEdit(editStr string) (*fileEdit, error) {
+ sp := strings.Split(editStr, ":")
+ if len(sp) != 2 {
+ return nil, fmt.Errorf("%q is not of format filepath:contents", editStr)
+ }
+ return &fileEdit{filepath: sp[0], contents: sp[1]}, nil
+}
+
+// fileEdits is a flag.Getter implementation representing a []*fileEdit.
+type fileEdits []*fileEdit
+
+// String returns a comma-separated string representation of the flag file edits.
+func (f fileEdits) String() string {
+ strs := make([]string, len(f))
+ for i, edit := range f {
+ strs[i] = edit.String()
+ }
+ return strings.Join(strs, ", ")
+}
+
+// Set records seeing a flag value.
+func (f *fileEdits) Set(val string) error {
+ fe, err := newFileEdit(val)
+ if err != nil {
+ return err
+ }
+ *f = append(*f, fe)
+ return nil
+}
+
+// Get retrieves the flag value.
+func (f fileEdits) Get() interface{} {
+ return []*fileEdit(f)
+}
+
+func (c *createCLRun) main(a subcommands.Application) error {
+ ctx := context.Background()
+ gitilesClient, err := newGitilesClient(ctx, strings.Replace(c.gerritHost, "-review", "", 1), c.gerritProject, c.parsedAuthOpts)
+ if err != nil {
+ return err
+ }
+ gerritClient, err := newGerritClient(ctx, c.gerritHost, c.gerritProject, c.parsedAuthOpts)
+ if err != nil {
+ return err
+ }
+ // Create the change at refs/heads/master HEAD.
+ commit, err := gitilesClient.getLatestCommit(ctx)
+ if err != nil {
+ return err
+ }
+ changeInfo, err := gerritClient.createChange(ctx, c.subject, commit)
+ if err != nil {
+ return err
+ }
+ for _, edit := range c.edits {
+ if err := gerritClient.editFile(ctx, changeInfo.Number, edit.filepath, edit.contents); err != nil {
+ return err
+ }
+ }
+ if err := gerritClient.publishEdits(ctx, changeInfo.Number); err != nil {
+ return err
+ }
+ fmt.Printf("%d\n", changeInfo.Number)
+ return nil
+}
+
+func (c *createCLRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
+ if err := c.Parse(a, args); err != nil {
+ fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
+ return 1
+ }
+
+ if err := c.main(a); err != nil {
+ fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
+ return 1
+ }
+ return 0
+}
diff --git a/cmd/cl-util/create_cl_test.go b/cmd/cl-util/create_cl_test.go
new file mode 100644
index 0000000..318403d
--- /dev/null
+++ b/cmd/cl-util/create_cl_test.go
@@ -0,0 +1,47 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestNewFileEdit(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ input string
+ expected *fileEdit
+ expectedErr bool
+ }{
+ {
+ input: "path/to/file:test-contents",
+ expected: &fileEdit{
+ filepath: "path/to/file",
+ contents: "test-contents",
+ },
+ expectedErr: false,
+ },
+ {
+ input: "invalid-input",
+ expected: nil,
+ expectedErr: true,
+ },
+ }
+ for _, test := range tests {
+ edit, err := newFileEdit(test.input)
+ if err == nil {
+ if test.expectedErr {
+ t.Errorf("expected error, got nil")
+ }
+ } else if !test.expectedErr {
+ t.Errorf("got unexpected err %v", err)
+ }
+ if diff := cmp.Diff(test.expected, edit, cmp.AllowUnexported(fileEdit{})); diff != "" {
+ t.Fatalf("different (-want +got):\n%s", diff)
+ }
+ }
+}
diff --git a/cmd/cl-util/gerrit.go b/cmd/cl-util/gerrit.go
new file mode 100644
index 0000000..671da7a
--- /dev/null
+++ b/cmd/cl-util/gerrit.go
@@ -0,0 +1,119 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "fmt"
+
+ "go.chromium.org/luci/auth"
+ "go.chromium.org/luci/common/api/gerrit"
+ "go.chromium.org/luci/common/errors"
+ gerritpb "go.chromium.org/luci/common/proto/gerrit"
+ "go.chromium.org/luci/common/retry/transient"
+)
+
+const commitQueueLabel = "Commit-Queue"
+
+// gerritClientWrapper provides utilities for interacting with a Gerrit project.
+type gerritClientWrapper struct {
+ client gerritpb.GerritClient
+ project string
+}
+
+// newGerritClient returns an authenticated gerritClientWrapper.
+func newGerritClient(ctx context.Context, host, project string, authOpts auth.Options) (*gerritClientWrapper, error) {
+ authClient, err := newAuthClient(ctx, authOpts)
+ if err != nil {
+ return nil, err
+ }
+ client, err := gerrit.NewRESTClient(authClient, host, true)
+ if err != nil {
+ return nil, err
+ }
+ return &gerritClientWrapper{client: client, project: project}, nil
+}
+
+// createChange creates an empty Gerrit change.
+func (c *gerritClientWrapper) createChange(ctx context.Context, subject, baseCommit string) (*gerritpb.ChangeInfo, error) {
+ return c.client.CreateChange(ctx, &gerritpb.CreateChangeRequest{
+ Project: c.project,
+ Ref: "refs/heads/master",
+ Subject: subject,
+ BaseCommit: baseCommit,
+ })
+}
+
+// editFile edits a single file for a Gerrit change.
+func (c *gerritClientWrapper) editFile(ctx context.Context, changeNum int64, filepath, content string) error {
+ _, err := c.client.ChangeEditFileContent(ctx, &gerritpb.ChangeEditFileContentRequest{
+ Number: changeNum,
+ Project: c.project,
+ FilePath: filepath,
+ Content: []byte(content),
+ })
+ return err
+}
+
+// publishEdits publishes all pending edits on a Gerrit change.
+func (c *gerritClientWrapper) publishEdits(ctx context.Context, changeNum int64) error {
+ _, err := c.client.ChangeEditPublish(ctx, &gerritpb.ChangeEditPublishRequest{
+ Number: changeNum,
+ Project: c.project,
+ })
+ return err
+}
+
+// setCQLabel sets the CQ+1/2 label on a Gerrit change.
+func (c *gerritClientWrapper) setCQLabel(ctx context.Context, changeNum int64, dryRun bool) error {
+ cqValue := int32(2)
+ if dryRun {
+ cqValue = int32(1)
+ }
+ changeRes, err := c.client.GetChange(ctx, &gerritpb.GetChangeRequest{
+ Number: changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_CURRENT_REVISION},
+ })
+ if err != nil {
+ return err
+ }
+ reviewRes, err := c.client.SetReview(ctx, &gerritpb.SetReviewRequest{
+ Number: changeNum,
+ Project: c.project,
+ RevisionId: changeRes.CurrentRevision,
+ Labels: map[string]int32{commitQueueLabel: cqValue},
+ })
+ if err != nil {
+ return err
+ }
+ setValue, ok := reviewRes.Labels[commitQueueLabel]
+ if !ok {
+ return fmt.Errorf("%s label was rejected", commitQueueLabel)
+ }
+ if setValue != cqValue {
+ return fmt.Errorf("%s label is %d; expected %d", commitQueueLabel, setValue, cqValue)
+ }
+ return nil
+}
+
+// checkCQCompletion checks if a Gerrit change's CQ label is unset.
+func (c *gerritClientWrapper) checkCQCompletion(ctx context.Context, changeNum int64) error {
+ changeInfo, err := c.client.GetChange(ctx, &gerritpb.GetChangeRequest{
+ Number: changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_LABELS},
+ })
+ if err != nil {
+ return err
+ }
+ labelInfo, ok := changeInfo.Labels[commitQueueLabel]
+ if !ok {
+ return fmt.Errorf("%s label was not returned", commitQueueLabel)
+ }
+ // The CQ label will be unset eventually, so tag this error as transient.
+ if labelInfo.Value != 0 {
+ return errors.New(fmt.Sprintf("%s label is still set", commitQueueLabel), transient.Tag)
+ }
+ return nil
+}
diff --git a/cmd/cl-util/gerrit_test.go b/cmd/cl-util/gerrit_test.go
new file mode 100644
index 0000000..087ef80
--- /dev/null
+++ b/cmd/cl-util/gerrit_test.go
@@ -0,0 +1,412 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/golang/protobuf/ptypes/empty"
+ "github.com/google/go-cmp/cmp"
+ gerritpb "go.chromium.org/luci/common/proto/gerrit"
+)
+
+func TestCreateChange(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ subject string
+ commit string
+ expected *gerritpb.ChangeInfo
+ }{
+ {
+ project: "test-project",
+ subject: "dummy change",
+ commit: "foobar",
+ expected: &gerritpb.ChangeInfo{Number: int64(123456)},
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.CreateChangeRequest{
+ Project: test.project,
+ Ref: "refs/heads/master",
+ Subject: test.subject,
+ BaseCommit: test.commit,
+ }
+ // Client returns a successful ChangeInfo response.
+ resp := gerritpb.ChangeInfo{Number: int64(123456)}
+ mockGerritClient.EXPECT().CreateChange(gomock.Any(), &req).Return(&resp, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ changeInfo, err := mockGerritClientWrapper.createChange(ctx, test.subject, test.commit)
+ if err != nil {
+ t.Fatalf("got unexpected err: %v", err)
+ }
+ if diff := cmp.Diff(test.expected, changeInfo); diff != "" {
+ t.Fatalf("different (-want +got):\n%s", diff)
+ }
+ }
+}
+
+func TestFailedCreateChange(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ subject string
+ commit string
+ }{
+ {
+ project: "test-project",
+ subject: "dummy change",
+ commit: "foobar",
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.CreateChangeRequest{
+ Project: test.project,
+ Ref: "refs/heads/master",
+ Subject: test.subject,
+ BaseCommit: test.commit,
+ }
+ // Client returns an error upon CreateChange call.
+ mockGerritClient.EXPECT().CreateChange(gomock.Any(), &req).Return(nil, fmt.Errorf("failed to create change"))
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ changeInfo, err := mockGerritClientWrapper.createChange(ctx, test.subject, test.commit)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ if changeInfo != nil {
+ t.Fatalf("expected nil change info, got %s", changeInfo)
+ }
+ }
+}
+
+func TestEditFile(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ filepath string
+ contents string
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ filepath: "path/to/file",
+ contents: "dummy contents",
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.ChangeEditFileContentRequest{
+ Number: test.changeNum,
+ Project: test.project,
+ FilePath: test.filepath,
+ Content: []byte(test.contents),
+ }
+ // Client returns an empty, non-error response after editing file.
+ mockGerritClient.EXPECT().ChangeEditFileContent(gomock.Any(), &req).Return(&empty.Empty{}, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.editFile(ctx, test.changeNum, test.filepath, test.contents)
+ if err != nil {
+ t.Fatalf("got unexpected err: %v", err)
+ }
+ }
+}
+
+func TestFailedEditFile(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ filepath string
+ contents string
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ filepath: "path/to/file",
+ contents: "dummy contents",
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.ChangeEditFileContentRequest{
+ Number: test.changeNum,
+ Project: test.project,
+ FilePath: test.filepath,
+ Content: []byte(test.contents),
+ }
+ // Client returns an error.
+ mockGerritClient.EXPECT().ChangeEditFileContent(gomock.Any(), &req).Return(nil, fmt.Errorf("failed to edit file"))
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.editFile(ctx, test.changeNum, test.filepath, test.contents)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ }
+}
+
+func TestPublishEdits(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.ChangeEditPublishRequest{
+ Number: test.changeNum,
+ Project: test.project,
+ }
+ // Client returns an empty, non-error response after publishing eidts.
+ mockGerritClient.EXPECT().ChangeEditPublish(gomock.Any(), &req).Return(&empty.Empty{}, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.publishEdits(ctx, test.changeNum)
+ if err != nil {
+ t.Fatalf("got unexpected err: %v", err)
+ }
+ }
+}
+
+func TestSetCQLabel(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ changeReq := gerritpb.GetChangeRequest{
+ Number: test.changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_CURRENT_REVISION},
+ }
+ changeResp := gerritpb.ChangeInfo{CurrentRevision: "foobar"}
+ mockGerritClient.EXPECT().GetChange(gomock.Any(), &changeReq).Return(&changeResp, nil)
+ reviewReq := gerritpb.SetReviewRequest{
+ Number: test.changeNum,
+ Project: test.project,
+ RevisionId: "foobar",
+ Labels: map[string]int32{commitQueueLabel: int32(1)},
+ }
+ reviewResp := gerritpb.ReviewResult{Labels: map[string]int32{commitQueueLabel: int32(1)}}
+ // Client returns a ReviewResult which has CQ+1.
+ mockGerritClient.EXPECT().SetReview(gomock.Any(), &reviewReq).Return(&reviewResp, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.setCQLabel(ctx, test.changeNum, true)
+ if err != nil {
+ t.Fatalf("got unexpected err: %v", err)
+ }
+ }
+}
+
+func TestRejectedSetCQLabel(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ changeReq := gerritpb.GetChangeRequest{
+ Number: test.changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_CURRENT_REVISION},
+ }
+ changeResp := gerritpb.ChangeInfo{CurrentRevision: "foobar"}
+ mockGerritClient.EXPECT().GetChange(gomock.Any(), &changeReq).Return(&changeResp, nil)
+ reviewReq := gerritpb.SetReviewRequest{
+ Number: test.changeNum,
+ Project: test.project,
+ RevisionId: "foobar",
+ Labels: map[string]int32{commitQueueLabel: int32(1)},
+ }
+ reviewResp := gerritpb.ReviewResult{Labels: map[string]int32{}}
+ // Client returns a ReviewResult which has no labels.
+ mockGerritClient.EXPECT().SetReview(gomock.Any(), &reviewReq).Return(&reviewResp, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.setCQLabel(ctx, test.changeNum, true)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ }
+}
+
+func TestInvalidSetCQLabel(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ changeReq := gerritpb.GetChangeRequest{
+ Number: test.changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_CURRENT_REVISION},
+ }
+ changeResp := gerritpb.ChangeInfo{CurrentRevision: "foobar"}
+ mockGerritClient.EXPECT().GetChange(gomock.Any(), &changeReq).Return(&changeResp, nil)
+ reviewReq := gerritpb.SetReviewRequest{
+ Number: test.changeNum,
+ Project: test.project,
+ RevisionId: "foobar",
+ Labels: map[string]int32{commitQueueLabel: int32(1)},
+ }
+ reviewResp := gerritpb.ReviewResult{Labels: map[string]int32{commitQueueLabel: int32(0)}}
+ // Client returns a ReviewResult which has the wrong label value.
+ mockGerritClient.EXPECT().SetReview(gomock.Any(), &reviewReq).Return(&reviewResp, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.setCQLabel(ctx, test.changeNum, true)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ }
+}
+
+func TestCheckCQCompletion(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.GetChangeRequest{
+ Number: test.changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_LABELS},
+ }
+ resp := gerritpb.ChangeInfo{
+ Labels: map[string]*gerritpb.LabelInfo{
+ commitQueueLabel: {Value: int32(0)},
+ },
+ }
+ // Client returns an finished CQ response.
+ mockGerritClient.EXPECT().GetChange(gomock.Any(), &req).Return(&resp, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.checkCQCompletion(ctx, test.changeNum)
+ if err != nil {
+ t.Fatalf("got unexpected err: %v", err)
+ }
+ }
+}
+
+func TestUnfinishedCheckCQCompletion(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ changeNum int64
+ }{
+ {
+ project: "test-project",
+ changeNum: int64(123456),
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGerritClient := gerritpb.NewMockGerritClient(ctrl)
+ req := gerritpb.GetChangeRequest{
+ Number: test.changeNum,
+ Options: []gerritpb.QueryOption{gerritpb.QueryOption_LABELS},
+ }
+ resp := gerritpb.ChangeInfo{
+ Labels: map[string]*gerritpb.LabelInfo{
+ commitQueueLabel: {Value: int32(1)},
+ },
+ }
+ // Client returns an unfinished CQ response.
+ mockGerritClient.EXPECT().GetChange(gomock.Any(), &req).Return(&resp, nil)
+ mockGerritClientWrapper := gerritClientWrapper{
+ client: mockGerritClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ err := mockGerritClientWrapper.checkCQCompletion(ctx, test.changeNum)
+ if err == nil {
+ t.Fatalf("expected error, got nil")
+ }
+ }
+}
diff --git a/cmd/cl-util/gitiles.go b/cmd/cl-util/gitiles.go
new file mode 100644
index 0000000..f6031b4
--- /dev/null
+++ b/cmd/cl-util/gitiles.go
@@ -0,0 +1,49 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "fmt"
+
+ "go.chromium.org/luci/auth"
+ "go.chromium.org/luci/common/api/gitiles"
+ gitilespb "go.chromium.org/luci/common/proto/gitiles"
+)
+
+// gitilesClientWrapper provides utilities for interacting with a Gitiles project.
+type gitilesClientWrapper struct {
+ client gitilespb.GitilesClient
+ project string
+}
+
+// newGitilesClient returns an authenticated gitilesClientWrapper.
+func newGitilesClient(ctx context.Context, host, project string, authOpts auth.Options) (*gitilesClientWrapper, error) {
+ authClient, err := newAuthClient(ctx, authOpts)
+ if err != nil {
+ return nil, err
+ }
+ client, err := gitiles.NewRESTClient(authClient, host, true)
+ if err != nil {
+ return nil, err
+ }
+ return &gitilesClientWrapper{client: client, project: project}, nil
+}
+
+// getLatestCommit returns the revision at refs/heads/master HEAD.
+func (c *gitilesClientWrapper) getLatestCommit(ctx context.Context) (string, error) {
+ resp, err := c.client.Log(ctx, &gitilespb.LogRequest{
+ Project: c.project,
+ Committish: "refs/heads/master",
+ PageSize: 1,
+ })
+ if err != nil {
+ return "", err
+ }
+ if len(resp.Log) == 0 {
+ return "", fmt.Errorf("empty log for project %s", c.project)
+ }
+ return resp.Log[0].GetId(), nil
+}
diff --git a/cmd/cl-util/gitiles_test.go b/cmd/cl-util/gitiles_test.go
new file mode 100644
index 0000000..a88fd9c
--- /dev/null
+++ b/cmd/cl-util/gitiles_test.go
@@ -0,0 +1,54 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "go.chromium.org/luci/common/proto/git"
+ gitilespb "go.chromium.org/luci/common/proto/gitiles"
+)
+
+func TestGetLatestCommit(t *testing.T) {
+ t.Parallel()
+ var tests = []struct {
+ project string
+ expected string
+ }{
+ {
+ project: "test-project",
+ expected: "foobar",
+ },
+ }
+ for _, test := range tests {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGitilesClient := gitilespb.NewMockGitilesClient(ctrl)
+ req := gitilespb.LogRequest{
+ Project: test.project,
+ Committish: "refs/heads/master",
+ PageSize: 1,
+ }
+ // Client returns a successful ChangeInfo response.
+ resp := gitilespb.LogResponse{
+ Log: []*git.Commit{{Id: "foobar"}},
+ }
+ mockGitilesClient.EXPECT().Log(gomock.Any(), &req).Return(&resp, nil)
+ mockGitilesClientWrapper := gitilesClientWrapper{
+ client: mockGitilesClient,
+ project: test.project,
+ }
+ ctx := context.Background()
+ commit, err := mockGitilesClientWrapper.getLatestCommit(ctx)
+ if err != nil {
+ t.Errorf("got unexpected err %v", err)
+ }
+ if test.expected != commit {
+ t.Errorf("expected: %s\nactual: %s\n", test.expected, commit)
+ }
+ }
+}
diff --git a/cmd/cl-util/main.go b/cmd/cl-util/main.go
new file mode 100644
index 0000000..c0525e9
--- /dev/null
+++ b/cmd/cl-util/main.go
@@ -0,0 +1,45 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "log"
+ "os"
+
+ "github.com/maruel/subcommands"
+
+ "go.chromium.org/luci/auth"
+ "go.chromium.org/luci/auth/client/authcli"
+ "go.chromium.org/luci/client/versioncli"
+ "go.chromium.org/luci/common/api/gerrit"
+ "go.chromium.org/luci/common/api/gitiles"
+ "go.chromium.org/luci/hardcoded/chromeinfra"
+)
+
+// Version must be updated on functional change (behavior, arguments, supported commands).
+const version = "0.0.1"
+
+func getApplication(defaultAuthOpts auth.Options) *subcommands.DefaultApplication {
+ defaultAuthOpts.Scopes = []string{gitiles.OAuthScope, gerrit.OAuthScope}
+ return &subcommands.DefaultApplication{
+ Name: "cl-util",
+ Title: "Client for manipulating gerrit CLs.",
+ Commands: []*subcommands.Command{
+ cmdCreateCL(defaultAuthOpts),
+ cmdTriggerCQ(defaultAuthOpts),
+ authcli.SubcommandInfo(defaultAuthOpts, "whoami", false),
+ authcli.SubcommandLogin(defaultAuthOpts, "login", false),
+ authcli.SubcommandLogout(defaultAuthOpts, "logout", false),
+ versioncli.CmdVersion(version),
+ subcommands.CmdHelp,
+ },
+ }
+}
+
+func main() {
+ log.SetFlags(log.Lmicroseconds)
+ app := getApplication(chromeinfra.DefaultAuthOptions())
+ os.Exit(subcommands.Run(app, nil))
+}
diff --git a/cmd/cl-util/trigger_cq.go b/cmd/cl-util/trigger_cq.go
new file mode 100644
index 0000000..3557530
--- /dev/null
+++ b/cmd/cl-util/trigger_cq.go
@@ -0,0 +1,100 @@
+// Copyright 2020 The Fuchsia 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 main
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/maruel/subcommands"
+ "go.chromium.org/luci/auth"
+ "go.chromium.org/luci/common/retry"
+ "go.chromium.org/luci/common/retry/transient"
+)
+
+func cmdTriggerCQ(authOpts auth.Options) *subcommands.Command {
+ return &subcommands.Command{
+ UsageLine: "trigger-cq -host <gerrit-host> -project <gerrit-project> -change-num <change-num> [-wait] [-timeout <timeout>] [-dryrun]",
+ ShortDesc: "Trigger CQ on a CL.",
+ LongDesc: "Trigger CQ on a CL.",
+ CommandRun: func() subcommands.CommandRun {
+ c := &triggerCQRun{}
+ c.Init(authOpts)
+ return c
+ },
+ }
+}
+
+type triggerCQRun struct {
+ commonFlags
+ changeNum int64
+ wait bool
+ timeout time.Duration
+ dryRun bool
+}
+
+func (c *triggerCQRun) Init(defaultAuthOpts auth.Options) {
+ c.commonFlags.Init(defaultAuthOpts)
+ c.Flags.Int64Var(&c.changeNum, "change-num", 0, "Gerrit change number.")
+ c.Flags.BoolVar(&c.wait, "wait", true, "If set, to wait for CQ to complete.")
+ c.Flags.DurationVar(&c.timeout, "timeout", 0, "Wait this long for CQ to finish; indefinite if not set.")
+ c.Flags.BoolVar(&c.dryRun, "dryrun", false, "If set, apply CQ+1; otherwise apply CQ+2.")
+}
+
+func (c *triggerCQRun) Parse(a subcommands.Application, args []string) error {
+ if err := c.commonFlags.Parse(); err != nil {
+ return err
+ }
+ if c.changeNum == 0 {
+ return fmt.Errorf("-change-num is required")
+ }
+ return nil
+}
+
+func (c *triggerCQRun) main(a subcommands.Application) error {
+ ctx := context.Background()
+ client, err := newGerritClient(ctx, c.gerritHost, c.gerritProject, c.parsedAuthOpts)
+ if err != nil {
+ return err
+ }
+ if err := client.setCQLabel(ctx, c.changeNum, c.dryRun); err != nil {
+ return err
+ }
+ if !c.wait {
+ return nil
+ }
+ // Check for CQ completion once a minute.
+ retryPolicy := transient.Only(func() retry.Iterator {
+ return &retry.ExponentialBackoff{
+ Limited: retry.Limited{
+ Delay: time.Minute,
+ Retries: -1,
+ },
+ Multiplier: 1,
+ }
+ })
+ var cancel context.CancelFunc
+ if c.timeout > 0 {
+ ctx, cancel = context.WithTimeout(ctx, c.timeout)
+ defer cancel()
+ }
+ return retry.Retry(ctx, retryPolicy, func() error {
+ return client.checkCQCompletion(ctx, c.changeNum)
+ }, nil)
+}
+
+func (c *triggerCQRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
+ if err := c.Parse(a, args); err != nil {
+ fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
+ return 1
+ }
+
+ if err := c.main(a); err != nil {
+ fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
+ return 1
+ }
+ return 0
+}
diff --git a/go.mod b/go.mod
index 8785a86..6325974 100644
--- a/go.mod
+++ b/go.mod
@@ -9,13 +9,14 @@
github.com/docker/go-connections v0.3.0 // indirect
github.com/docker/go-units v0.3.3 // indirect
github.com/gogo/protobuf v1.1.1 // indirect
+ github.com/golang/mock v1.4.0
github.com/golang/protobuf v1.3.3
github.com/google/go-cmp v0.4.0
github.com/google/subcommands v1.0.1
github.com/google/uuid v1.1.1
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/julienschmidt/httprouter v1.2.0 // indirect
- github.com/maruel/subcommands v0.0.0-20181220013616-967e945be48b // indirect
+ github.com/maruel/subcommands v0.0.0-20181220013616-967e945be48b
github.com/maruel/ut v1.0.0 // indirect
github.com/microsoft/go-winio v0.4.11 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect