[bb] Read args from stdin

Update add, get and cancel subcommands to read builds from stdin if no builds
were provided.

Add -id flag to print only build ids.
Incidentally this fixes crbug.com/747322 that is asking for massive
build cancelation:

  bb ls chromium/try/x -status scheduled -id | bb cancel -reason bad

Refactor buildFieldFlags into printRun which is a base command run for
subcommands that print builds, such as add, get, cancel and ls.

R=hinoka@chromium.org, vadimsh@chromium.org

Bug: 747322
Change-Id: I552b55e159d69570dd32960578af3192f3188c5f
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/1551636
Commit-Queue: Nodir Turakulov <nodir@chromium.org>
Reviewed-by: Ryan Tseng <hinoka@chromium.org>
diff --git a/buildbucket/cli/add.go b/buildbucket/cli/add.go
index 6868c1f..87f656b 100644
--- a/buildbucket/cli/add.go
+++ b/buildbucket/cli/add.go
@@ -36,7 +36,10 @@
 		LongDesc: doc(`
 			Add a build for each BUILDER argument.
 
-			A BUILDER must have format "<project>/<bucket>/<builder>", for example "chromium/try/linux-rel".
+			A BUILDER must have format "<project>/<bucket>/<builder>", for
+			example "chromium/try/linux-rel".
+			If no builders were specified on the command line, they are read
+			from stdin.
 
 			Example: add linux-rel and mac-rel builds to chromium/ci bucket using Shell expansion.
 				bb add chromium/ci/{linux-rel,mac-rel}
@@ -44,7 +47,6 @@
 		CommandRun: func() subcommands.CommandRun {
 			r := &addRun{}
 			r.RegisterDefaultFlags(p)
-			r.RegisterJSONFlag()
 
 			r.clsFlag.Register(&r.Flags, doc(`
 				CL URL as input for the builds. Can be specified multiple times.
@@ -91,7 +93,7 @@
 }
 
 type addRun struct {
-	baseCommandRun
+	printRun
 	clsFlag
 	commitFlag
 	tagsFlag
@@ -102,10 +104,6 @@
 }
 
 func (r *addRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
-	if len(args) == 0 {
-		return 0
-	}
-
 	ctx := cli.GetContext(a, r, env)
 	if err := r.initClients(ctx); err != nil {
 		return r.done(ctx, err)
@@ -116,22 +114,19 @@
 		return r.done(ctx, err)
 	}
 
-	req := &pb.BatchRequest{}
-	for i, a := range args {
-		schedReq := proto.Clone(baseReq).(*pb.ScheduleBuildRequest)
-		schedReq.RequestId += fmt.Sprintf("-%d", i)
+	i := 0
+	return r.PrintAndDone(args, func(builder string) (*pb.Build, error) {
+		i++
+		req := proto.Clone(baseReq).(*pb.ScheduleBuildRequest)
+		req.RequestId += fmt.Sprintf("-%d", i)
 
 		var err error
-		schedReq.Builder, err = protoutil.ParseBuilderID(a)
+		req.Builder, err = protoutil.ParseBuilderID(builder)
 		if err != nil {
-			return r.done(ctx, fmt.Errorf("invalid builder %q: %s", a, err))
+			return nil, err
 		}
-		req.Requests = append(req.Requests, &pb.BatchRequest_Request{
-			Request: &pb.BatchRequest_Request_ScheduleBuild{ScheduleBuild: schedReq},
-		})
-	}
-
-	return r.batchAndDone(ctx, req)
+		return r.client.ScheduleBuild(ctx, req, expectedCodeRPCOption)
+	})
 }
 
 func (r *addRun) prepareBaseRequest(ctx context.Context) (*pb.ScheduleBuildRequest, error) {
diff --git a/buildbucket/cli/base_command.go b/buildbucket/cli/baserun.go
similarity index 73%
rename from buildbucket/cli/base_command.go
rename to buildbucket/cli/baserun.go
index 75b0080..46be97f 100644
--- a/buildbucket/cli/base_command.go
+++ b/buildbucket/cli/baserun.go
@@ -18,7 +18,6 @@
 	"context"
 	"fmt"
 	"net/http"
-	"os"
 	"strings"
 
 	"github.com/maruel/subcommands"
@@ -37,10 +36,15 @@
 	pb "go.chromium.org/luci/buildbucket/proto"
 )
 
+var expectedCodeRPCOption = prpc.ExpectedCode(
+	codes.InvalidArgument, codes.NotFound, codes.PermissionDenied)
+
 func doc(doc string) string {
 	return text.Doc(doc)
 }
 
+// baseCommandRun provides common command run functionality.
+// All bb subcommands must embed it directly or indirectly.
 type baseCommandRun struct {
 	subcommands.CommandRunBase
 	authFlags authcli.Flags
@@ -64,9 +68,9 @@
 
 func (r *baseCommandRun) RegisterJSONFlag() {
 	r.Flags.BoolVar(&r.json, "json", false, doc(`
-		Print objects JSON format, one after another (not an array).
+		Print objects in JSON format, one after another (not an array).
 
-		Designed for "jq" tool. If using bb from scripts, consider "batch" subcommand.
+		Intended for "jq" tool. If using bb from scripts, consider "batch" subcommand.
 	`))
 }
 
@@ -106,74 +110,6 @@
 	return nil
 }
 
-// batchAndDone executes req and prints the response.
-func (r *baseCommandRun) batchAndDone(ctx context.Context, req *pb.BatchRequest) int {
-	res, err := r.client.Batch(ctx, req)
-	if err != nil {
-		return r.done(ctx, err)
-	}
-
-	stderr := func(format string, args ...interface{}) {
-		fmt.Fprintf(os.Stderr, format, args...)
-	}
-
-	hasErr := false
-	p := newStdoutPrinter(r.noColor)
-	for i, res := range res.Responses {
-		var build *pb.Build
-		switch res := res.Response.(type) {
-
-		case *pb.BatchResponse_Response_Error:
-			hasErr = true
-
-			// If we have multiple requests, print a request title.
-			if len(req.Requests) > 1 {
-				switch req := req.Requests[i].Request.(type) {
-				case *pb.BatchRequest_Request_GetBuild:
-					r := req.GetBuild
-					if r.Id != 0 {
-						stderr("build %d", r.Id)
-					} else {
-						stderr(`build "%s/%d"`, protoutil.FormatBuilderID(r.Builder), r.BuildNumber)
-					}
-
-				case *pb.BatchRequest_Request_CancelBuild:
-					stderr("build %d", req.CancelBuild.Id)
-
-				default:
-					stderr("request #%d", i)
-				}
-				stderr(": ")
-			}
-
-			stderr("%s\n", res.Error.Message)
-			continue
-
-		case *pb.BatchResponse_Response_GetBuild:
-			build = res.GetBuild
-		case *pb.BatchResponse_Response_CancelBuild:
-			build = res.CancelBuild
-		case *pb.BatchResponse_Response_ScheduleBuild:
-			build = res.ScheduleBuild
-		default:
-			panic("forgot to update batchAndDone()?")
-		}
-
-		if r.json {
-			p.JSONPB(build)
-		} else {
-			if i > 0 {
-				p.f("\n")
-			}
-			p.Build(build)
-		}
-	}
-	if hasErr {
-		return 1
-	}
-	return 0
-}
-
 func (r *baseCommandRun) done(ctx context.Context, err error) int {
 	if err != nil {
 		logging.Errorf(ctx, "%s", err)
@@ -232,3 +168,22 @@
 	}
 	return buildIDs, nil
 }
+
+// retrieveBuildID converts a build string into a build id.
+// May make a GetBuild RPC.
+func (r *baseCommandRun) retrieveBuildID(ctx context.Context, build string) (int64, error) {
+	getBuild, err := protoutil.ParseGetBuildRequest(build)
+	if err != nil {
+		return 0, err
+	}
+
+	if getBuild.Id != 0 {
+		return getBuild.Id, nil
+	}
+
+	res, err := r.client.GetBuild(ctx, getBuild)
+	if err != nil {
+		return 0, err
+	}
+	return res.Id, nil
+}
diff --git a/buildbucket/cli/base_command_test.go b/buildbucket/cli/baserun_test.go
similarity index 100%
rename from buildbucket/cli/base_command_test.go
rename to buildbucket/cli/baserun_test.go
diff --git a/buildbucket/cli/cancel.go b/buildbucket/cli/cancel.go
index 857ad49..bde39d8 100644
--- a/buildbucket/cli/cancel.go
+++ b/buildbucket/cli/cancel.go
@@ -16,6 +16,7 @@
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/maruel/subcommands"
 
@@ -32,13 +33,15 @@
 			Cancel builds.
 
 			Argument BUILD can be an int64 build id or a string
-			<project>/<bucket>/<builder>/<build_number>, e.g. chromium/ci/linux-rel/1
+			<project>/<bucket>/<builder>/<build_number>, e.g.
+			chromium/ci/linux-rel/1.
+			If no builds were specified on the command line, they are read
+			from stdin.
 		`),
 		CommandRun: func() subcommands.CommandRun {
 			r := &cancelRun{}
 			r.RegisterDefaultFlags(p)
-			r.RegisterJSONFlag()
-			r.buildFieldFlags.Register(&r.Flags)
+			r.RegisterFieldFlags()
 			r.Flags.StringVar(&r.reason, "reason", "", doc(`
 				reason of cancelation in Markdown format; required
 			`))
@@ -48,42 +51,37 @@
 }
 
 type cancelRun struct {
-	baseCommandRun
-	buildFieldFlags
+	printRun
 	reason string
 }
 
 func (r *cancelRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
-	if len(args) == 0 {
-		return 0
-	}
 	ctx := cli.GetContext(a, r, env)
 	if err := r.initClients(ctx); err != nil {
 		return r.done(ctx, err)
 	}
 
+	r.reason = strings.TrimSpace(r.reason)
 	if r.reason == "" {
 		return r.done(ctx, fmt.Errorf("-reason is required"))
 	}
 
-	buildIDs, err := r.retrieveBuildIDs(ctx, args)
+	fields, err := r.FieldMask()
 	if err != nil {
 		return r.done(ctx, err)
 	}
 
-	req := &pb.BatchRequest{}
-	fields := r.FieldMask()
-	for _, id := range buildIDs {
-		req.Requests = append(req.Requests, &pb.BatchRequest_Request{
-			Request: &pb.BatchRequest_Request_CancelBuild{
-				CancelBuild: &pb.CancelBuildRequest{
-					Id:              id,
-					SummaryMarkdown: r.reason,
-					Fields:          fields,
-				},
-			},
-		})
-	}
+	return r.PrintAndDone(args, func(arg string) (*pb.Build, error) {
+		id, err := r.retrieveBuildID(ctx, arg)
+		if err != nil {
+			return nil, err
+		}
 
-	return r.batchAndDone(ctx, req)
+		req := &pb.CancelBuildRequest{
+			Id:              id,
+			SummaryMarkdown: r.reason,
+			Fields:          fields,
+		}
+		return r.client.CancelBuild(ctx, req, expectedCodeRPCOption)
+	})
 }
diff --git a/buildbucket/cli/collect.go b/buildbucket/cli/collect.go
index 9eb6274..5967d0d 100644
--- a/buildbucket/cli/collect.go
+++ b/buildbucket/cli/collect.go
@@ -184,6 +184,8 @@
 		return r.done(ctx, err)
 	}
 
+	// TODO(nodir): switch from Batch to concurrent requests.
+	// Delete baseCommandRun.retrieveBuildIDs.
 	buildIDs, err := r.retrieveBuildIDs(ctx, args)
 	if err != nil {
 		return r.done(ctx, err)
diff --git a/buildbucket/cli/fields.go b/buildbucket/cli/fields.go
deleted file mode 100644
index ed10395..0000000
--- a/buildbucket/cli/fields.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// Copyright 2019 The LUCI Authors.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package cli
-
-import (
-	"flag"
-	"reflect"
-	"strings"
-
-	"github.com/golang/protobuf/proto"
-	"google.golang.org/genproto/protobuf/field_mask"
-
-	pb "go.chromium.org/luci/buildbucket/proto"
-)
-
-var completeBuildFieldMask *field_mask.FieldMask
-
-func init() {
-	completeBuildFieldMask = &field_mask.FieldMask{}
-	for _, p := range proto.GetProperties(reflect.TypeOf(pb.Build{})).Prop {
-		if !strings.HasPrefix(p.OrigName, "XXX") {
-			completeBuildFieldMask.Paths = append(completeBuildFieldMask.Paths, p.OrigName)
-		}
-	}
-}
-
-type buildFieldFlags struct {
-	all        bool
-	properties bool
-	steps      bool
-}
-
-func (f *buildFieldFlags) Register(fs *flag.FlagSet) {
-	fs.BoolVar(&f.all, "A", false, "Print build entirely")
-	fs.BoolVar(&f.steps, "steps", false, "Print steps")
-	fs.BoolVar(&f.properties, "p", false, "Print input/output properties")
-}
-
-func (f *buildFieldFlags) FieldMask() *field_mask.FieldMask {
-	if f.all {
-		return proto.Clone(completeBuildFieldMask).(*field_mask.FieldMask)
-	}
-
-	ret := &field_mask.FieldMask{
-		Paths: []string{
-			"builder",
-			"create_time",
-			"created_by",
-			"end_time",
-			"id",
-			"input.experimental",
-			"input.gerrit_changes",
-			"input.gitiles_commit",
-			"number",
-			"start_time",
-			"status",
-			"status_details",
-			"summary_markdown",
-			"tags",
-			"update_time",
-		},
-	}
-
-	if f.properties {
-		ret.Paths = append(ret.Paths, "input.properties", "output.properties")
-	}
-
-	if f.steps {
-		ret.Paths = append(ret.Paths, "steps")
-	}
-
-	return ret
-}
diff --git a/buildbucket/cli/get.go b/buildbucket/cli/get.go
index 98667d0..79a1de1 100644
--- a/buildbucket/cli/get.go
+++ b/buildbucket/cli/get.go
@@ -15,8 +15,6 @@
 package cli
 
 import (
-	"fmt"
-
 	"github.com/maruel/subcommands"
 
 	"go.chromium.org/luci/buildbucket/protoutil"
@@ -33,45 +31,41 @@
 			Print builds.
 
 			Argument BUILD can be an int64 build id or a string
-			<project>/<bucket>/<builder>/<build_number>, e.g. chromium/ci/linux-rel/1
+			<project>/<bucket>/<builder>/<build_number>, e.g.
+			chromium/ci/linux-rel/1.
+			If no builds were specified on the command line, they are read
+			from stdin.
 		`),
 		CommandRun: func() subcommands.CommandRun {
 			r := &getRun{}
 			r.RegisterDefaultFlags(p)
-			r.RegisterJSONFlag()
-			r.buildFieldFlags.Register(&r.Flags)
+			r.RegisterFieldFlags()
 			return r
 		},
 	}
 }
 
 type getRun struct {
-	baseCommandRun
-	buildFieldFlags
+	printRun
 }
 
 func (r *getRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
-	if len(args) == 0 {
-		return 0
-	}
-
 	ctx := cli.GetContext(a, r, env)
 	if err := r.initClients(ctx); err != nil {
 		return r.done(ctx, err)
 	}
 
-	req := &pb.BatchRequest{}
-	fields := r.FieldMask()
-	for _, a := range args {
-		getBuild, err := protoutil.ParseGetBuildRequest(a)
-		if err != nil {
-			return r.done(ctx, fmt.Errorf("invalid build %q: %s", a, err))
-		}
-		getBuild.Fields = fields
-		req.Requests = append(req.Requests, &pb.BatchRequest_Request{
-			Request: &pb.BatchRequest_Request_GetBuild{GetBuild: getBuild},
-		})
+	fields, err := r.FieldMask()
+	if err != nil {
+		return r.done(ctx, err)
 	}
 
-	return r.batchAndDone(ctx, req)
+	return r.PrintAndDone(args, func(arg string) (*pb.Build, error) {
+		req, err := protoutil.ParseGetBuildRequest(arg)
+		if err != nil {
+			return nil, err
+		}
+		req.Fields = fields
+		return r.client.GetBuild(ctx, req, expectedCodeRPCOption)
+	})
 }
diff --git a/buildbucket/cli/log.go b/buildbucket/cli/log.go
index 85a746d..3910d06 100644
--- a/buildbucket/cli/log.go
+++ b/buildbucket/cli/log.go
@@ -241,8 +241,7 @@
 		}()
 	}
 
-	stdout := newPrinter(os.Stdout, r.noColor, time.Now)
-	stderr := newPrinter(os.Stderr, r.noColor, time.Now)
+	stdout, stderr := newStdioPrinters(r.noColor)
 	for {
 		chanIndex, entry, err := m.Next()
 		out := stdout
diff --git a/buildbucket/cli/ls.go b/buildbucket/cli/ls.go
index 99ab4ae..1a94065 100644
--- a/buildbucket/cli/ls.go
+++ b/buildbucket/cli/ls.go
@@ -46,8 +46,8 @@
 		CommandRun: func() subcommands.CommandRun {
 			r := &lsRun{}
 			r.RegisterDefaultFlags(p)
-			r.RegisterJSONFlag()
-			r.buildFieldFlags.Register(&r.Flags)
+			r.RegisterIDFlag()
+			r.RegisterFieldFlags()
 			r.clsFlag.Register(&r.Flags, doc(`
 				CL URLs that builds must be associated with.
 
@@ -72,8 +72,7 @@
 }
 
 type lsRun struct {
-	baseCommandRun
-	buildFieldFlags
+	printRun
 	clsFlag
 	tagsFlag
 
@@ -97,6 +96,8 @@
 		return r.done(ctx, err)
 	}
 
+	// TODO(nodir): switch from Batch request to concurrent searches, streaming
+	// of results with paging.
 	seen := map[int64]struct{}{}
 	var builds []*pb.Build
 	for _, res := range res.Responses {
@@ -115,14 +116,9 @@
 	// Build IDs are monotonically decreasing.
 	sort.Slice(builds, func(i, j int) bool { return builds[i].Id < builds[j].Id })
 
-	p := newStdoutPrinter(r.noColor)
-	for _, b := range builds {
-		if r.json {
-			p.JSONPB(b)
-		} else {
-			p.Build(b)
-			fmt.Println()
-		}
+	stdout, _ := newStdioPrinters(r.noColor)
+	for i, b := range builds {
+		r.printBuild(stdout, b, i == 0)
 	}
 	return 0
 }
@@ -165,14 +161,18 @@
 			Status:              r.status,
 			IncludeExperimental: r.includeExperimental,
 		},
-		Fields: r.FieldMask(),
 	}
 
+	// Prepare a field mask.
+	var err error
+	ret.Fields, err = r.FieldMask()
+	if err != nil {
+		return nil, err
+	}
 	for i, p := range ret.Fields.Paths {
 		ret.Fields.Paths[i] = "builds.*." + p
 	}
 
-	var err error
 	if ret.Predicate.GerritChanges, err = r.clsFlag.retrieveCLs(ctx, r.httpClient); err != nil {
 		return nil, err
 	}
diff --git a/buildbucket/cli/print.go b/buildbucket/cli/print.go
index 71edf8b..6533f52 100644
--- a/buildbucket/cli/print.go
+++ b/buildbucket/cli/print.go
@@ -29,6 +29,7 @@
 	"github.com/golang/protobuf/ptypes"
 	"github.com/golang/protobuf/ptypes/timestamp"
 	"github.com/mgutz/ansi"
+	"google.golang.org/grpc/status"
 
 	"go.chromium.org/luci/common/data/text/color"
 	"go.chromium.org/luci/common/data/text/indented"
@@ -74,11 +75,13 @@
 	return p
 }
 
-func newStdoutPrinter(disableColor bool) *printer {
+func newStdioPrinters(disableColor bool) (stdout, stderr *printer) {
 	if !isatty.IsTerminal(os.Stdout.Fd()) {
 		disableColor = true
 	}
-	return newPrinter(os.Stdout, disableColor, time.Now)
+	stdout = newPrinter(os.Stdout, disableColor, time.Now)
+	stderr = newPrinter(os.Stderr, disableColor, time.Now)
+	return
 }
 
 // f prints a formatted message. Panics if writing fails.
@@ -353,6 +356,13 @@
 	p.f("%s", ansi.Reset)
 }
 
+// Error prints the err. If err is a gRPC error, then prints only the message
+// without the code.
+func (p *printer) Error(err error) {
+	st, _ := status.FromError(err)
+	p.f("%s", st.Message())
+}
+
 // readTimestamp converts ts to time.Time.
 // Returns zero if ts is invalid.
 func readTimestamp(ts *timestamp.Timestamp) time.Time {
diff --git a/buildbucket/cli/printrun.go b/buildbucket/cli/printrun.go
new file mode 100644
index 0000000..ce2096b
--- /dev/null
+++ b/buildbucket/cli/printrun.go
@@ -0,0 +1,244 @@
+// Copyright 2019 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cli
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"os"
+	"reflect"
+	"strings"
+
+	"github.com/golang/protobuf/proto"
+	"google.golang.org/genproto/protobuf/field_mask"
+
+	pb "go.chromium.org/luci/buildbucket/proto"
+)
+
+var completeBuildFieldMask *field_mask.FieldMask
+var idFieldMask = &field_mask.FieldMask{Paths: []string{"id"}}
+
+func init() {
+	completeBuildFieldMask = &field_mask.FieldMask{}
+	for _, p := range proto.GetProperties(reflect.TypeOf(pb.Build{})).Prop {
+		if !strings.HasPrefix(p.OrigName, "XXX") {
+			completeBuildFieldMask.Paths = append(completeBuildFieldMask.Paths, p.OrigName)
+		}
+	}
+}
+
+// printRun is a base command run for subcommands that print
+// builds.
+type printRun struct {
+	baseCommandRun
+	all        bool
+	properties bool
+	steps      bool
+	id         bool
+}
+
+func (r *printRun) RegisterDefaultFlags(p Params) {
+	r.baseCommandRun.RegisterDefaultFlags(p)
+	r.baseCommandRun.RegisterJSONFlag()
+}
+
+// RegisterIDFlag registers -id flag.
+func (r *printRun) RegisterIDFlag() {
+	r.Flags.BoolVar(&r.id, "id", false, doc(`
+		Print only build ids.
+
+		Intended for piping the output into another bb subcommand:
+			bb ls -cl myCL -id | bb cancel
+	`))
+}
+
+// RegisterFieldFlags registers -A, -steps and -p flags.
+func (r *printRun) RegisterFieldFlags() {
+	r.Flags.BoolVar(&r.all, "A", false, doc(`
+		Print builds in their entirety.
+		With -json, prints all build fields.
+		Without -json, implies -steps and -p.
+	`))
+	r.Flags.BoolVar(&r.steps, "steps", false, "Print steps")
+	r.Flags.BoolVar(&r.properties, "p", false, "Print input/output properties")
+}
+
+// FieldMask returns the field mask to use in buildbucket requests.
+func (r *printRun) FieldMask() (*field_mask.FieldMask, error) {
+	if r.id {
+		if r.all || r.properties || r.steps {
+			return nil, fmt.Errorf("-id is mutually exclusive with -A, -p and -steps")
+		}
+		return proto.Clone(idFieldMask).(*field_mask.FieldMask), nil
+	}
+
+	if r.all {
+		if r.properties || r.steps {
+			return nil, fmt.Errorf("-A is mutually exclusive with -p and -steps")
+		}
+		return proto.Clone(completeBuildFieldMask).(*field_mask.FieldMask), nil
+	}
+
+	ret := &field_mask.FieldMask{
+		Paths: []string{
+			"builder",
+			"create_time",
+			"created_by",
+			"end_time",
+			"id",
+			"input.experimental",
+			"input.gerrit_changes",
+			"input.gitiles_commit",
+			"number",
+			"start_time",
+			"status",
+			"status_details",
+			"summary_markdown",
+			"tags",
+			"update_time",
+		},
+	}
+
+	if r.properties {
+		ret.Paths = append(ret.Paths, "input.properties", "output.properties")
+	}
+
+	if r.steps {
+		ret.Paths = append(ret.Paths, "steps")
+	}
+
+	return ret, nil
+}
+
+func (r *printRun) printBuild(p *printer, build *pb.Build, first bool) {
+	if r.json {
+		if r.id {
+			p.f(`{"id": "%d"}`, build.Id)
+			p.f("\n")
+		} else {
+			p.JSONPB(build)
+		}
+	} else {
+		if r.id {
+			p.f("%d\n", build.Id)
+		} else {
+			if !first {
+				// Print a new line so it is easier to differentiate builds.
+				p.f("\n")
+			}
+			p.Build(build)
+		}
+	}
+}
+
+// PrintAndDone calls fn for each argument, prints builds and returns exit code.
+// fn is called concurrently, but builds are printed in the same order
+// as args.
+func (r *printRun) PrintAndDone(args []string, fn func(string) (*pb.Build, error)) int {
+	stdout, stderr := newStdioPrinters(r.noColor)
+	return r.printAndDone(stdout, stderr, args, fn)
+}
+
+func (r *printRun) printAndDone(stdout, stderr *printer, args []string, fn func(string) (*pb.Build, error)) int {
+	// Prepare workspace.
+	type workItem struct {
+		arg   string
+		build *pb.Build
+		done  chan error
+	}
+	work := make(chan *workItem)
+	results := make(chan *workItem, 256)
+
+	// Prepare 16 concurrent workers.
+	for i := 0; i < 16; i++ {
+		go func() {
+			for item := range work {
+				var err error
+				item.build, err = fn(item.arg)
+				item.done <- err
+			}
+		}()
+	}
+
+	// Add work. Close the work space when work is done.
+	go func() {
+		for a := range argChan(args) {
+			item := &workItem{arg: a, done: make(chan error)}
+			work <- item
+			results <- item
+		}
+		close(work)
+		close(results)
+	}()
+
+	// Print the results in the order of args.
+	first := true
+	perfect := true
+	for i := range results {
+		err := <-i.done
+		if err != nil {
+			perfect = false
+			if !first {
+				stderr.f("\n")
+			}
+			stderr.f("arg %q: ", i.arg)
+			stderr.Error(err)
+			stderr.f("\n")
+		} else {
+			r.printBuild(stdout, i.build, first)
+		}
+		first = false
+	}
+	if !perfect {
+		return 1
+	}
+	return 0
+}
+
+// argChan returns a channel of args.
+//
+// If args is empty, reads from stdin. Trims whitespace and skips blank lines.
+// Panics if reading from stdin fails.
+func argChan(args []string) chan string {
+	ret := make(chan string)
+	go func() {
+		defer close(ret)
+
+		if len(args) > 0 {
+			for _, a := range args {
+				ret <- strings.TrimSpace(a)
+			}
+			return
+		}
+
+		reader := bufio.NewReader(os.Stdin)
+		for {
+			line, err := reader.ReadString('\n')
+			line = strings.TrimSpace(line)
+			switch {
+			case err == io.EOF:
+				return
+			case err != nil:
+				panic(err)
+			case len(line) == 0:
+				continue
+			default:
+				ret <- line
+			}
+		}
+	}()
+	return ret
+}
diff --git a/buildbucket/cli/printrun_test.go b/buildbucket/cli/printrun_test.go
new file mode 100644
index 0000000..4840d4d
--- /dev/null
+++ b/buildbucket/cli/printrun_test.go
@@ -0,0 +1,86 @@
+// Copyright 2019 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cli
+
+import (
+	"bytes"
+	"fmt"
+	"strconv"
+	"sync"
+	"testing"
+	"time"
+
+	"go.chromium.org/luci/common/clock/testclock"
+	"go.chromium.org/luci/common/data/stringset"
+
+	pb "go.chromium.org/luci/buildbucket/proto"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestPrintAndDone(t *testing.T) {
+	t.Parallel()
+
+	Convey("printAndDone", t, func(c C) {
+		stdout := &bytes.Buffer{}
+		stderr := &bytes.Buffer{}
+		nowFn := func() time.Time { return testclock.TestRecentTimeUTC }
+		stdoutPrinter := newPrinter(stdout, true, nowFn)
+		stderrPrinter := newPrinter(stderr, true, nowFn)
+
+		call := func(args []string, fn func(string) (*pb.Build, error)) int {
+			run := &printRun{id: true}
+			return run.printAndDone(stdoutPrinter, stderrPrinter, args, fn)
+		}
+
+		Convey("actual args", func() {
+			var m sync.Mutex
+			actualArgs := stringset.New(3)
+			call([]string{"1", "2"}, func(arg string) (*pb.Build, error) {
+				m.Lock()
+				defer m.Unlock()
+				actualArgs.Add(arg)
+				return &pb.Build{SummaryMarkdown: arg}, nil
+			})
+			So(actualArgs, ShouldResemble, stringset.NewFromSlice("1", "2"))
+		})
+
+		Convey("perfect", func() {
+			exitCode := call([]string{"1"}, func(arg string) (*pb.Build, error) {
+				return &pb.Build{SummaryMarkdown: arg}, nil
+			})
+			So(exitCode, ShouldEqual, 0)
+		})
+
+		Convey("imperfect", func() {
+			exitCode := call([]string{"1", "2"}, func(arg string) (*pb.Build, error) {
+				if arg == "2" {
+					return nil, fmt.Errorf("bad")
+				}
+				return &pb.Build{SummaryMarkdown: arg}, nil
+			})
+			So(exitCode, ShouldEqual, 1)
+		})
+
+		Convey("printed", func() {
+			call([]string{"1", "2"}, func(arg string) (*pb.Build, error) {
+				id, err := strconv.ParseInt(arg, 10, 64)
+				c.So(err, ShouldBeNil)
+				return &pb.Build{Id: id}, nil
+			})
+			So(stdout.String(), ShouldEqual, "1\n2\n")
+		})
+	})
+}