[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