[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")
+ })
+ })
+}