testplan: Parse builds from a file instead of CLI.

- Calls to get-testable with too many builds were causing OS
errors because the arguments were too long. Add an option to
parse builds from a file instead of on the command line.

BUG=b:283907263
TEST=CQ
TEST=Manually ran `testplan get-testable`

Change-Id: I01dea1f23de32e105d283c0d9a6dcf72c53bfff7
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/platform/dev-util/+/4568625
Reviewed-by: Chris Gerber <gerb@google.com>
Commit-Queue: Chris Gerber <gerb@google.com>
Auto-Submit: Andrew Lamb <andrewlamb@chromium.org>
Tested-by: Andrew Lamb <andrewlamb@chromium.org>
diff --git a/src/chromiumos/test/plan/cmd/testplan.go b/src/chromiumos/test/plan/cmd/testplan.go
index 6087d85..6a58744 100644
--- a/src/chromiumos/test/plan/cmd/testplan.go
+++ b/src/chromiumos/test/plan/cmd/testplan.go
@@ -436,6 +436,7 @@
 	subcommands.CommandRunBase
 	planPaths             []string
 	builds                []*bbpb.Build
+	buildsPath            string
 	buildMetadataListPath string
 	dutAttributeListPath  string
 	configBundleListPath  string
@@ -443,12 +444,13 @@
 }
 
 var cmdGetTestable = &subcommands.Command{
-	UsageLine: `get-testable -plan plan1.star [-plan plan2.star] -build BUILD1 [-build BUILD2] -dutattributes PATH -buildmetadata PATH -configbundlelist PATH -builderconfigs PATH`,
+	UsageLine: `get-testable -plan plan1.star [-plan plan2.star] -builds PATH -dutattributes PATH -buildmetadata PATH -configbundlelist PATH -builderconfigs PATH`,
 	ShortDesc: "get a list of builds that could possibly be tested by plans",
 	LongDesc: `Get a list of builds that could possibly be tested by plans.
 
-First compute a set of CoverageRules from plans, then compute which builds in
-GenerateTestPlanRequest could possibly be tested based off the CoverageRules.
+First compute a set of CoverageRules from plans, then compute which builds could
+possibly be tested based off the CoverageRules.
+
 This doesn't take the status, output test artifacts, etc. of builds into
 account, just whether their build target, variant, and profile could be included
 in one of the CoverageRules.
@@ -471,11 +473,20 @@
 		r.Flags.Var(
 			luciflag.MessageSliceFlag(&r.builds),
 			"build",
-			"Buildbucket build protos to analyze, as JSON proto. Each proto must"+
-				"include the `builder.builder` field and the `build_target.name`"+
-				"input property, all other fields will be ignored",
+			"Deprecated, use `-builds` instead. Buildbucket build protos to "+
+				"analyze, as JSON proto. Each proto must include the "+
+				"`builder.builder` field and the `build_target.name` input "+
+				"property, all other fields will be ignored",
 		)
 		r.Flags.StringVar(
+			&r.buildsPath,
+			"builds",
+			"",
+			"Path to a file containing Buildbucket build protos to analyze, with"+
+				"one JSON proto per-line. Each proto must include the "+
+				"`builder.builder` field and the `build_target.name` input "+
+				"property, all other fields will be ignored")
+		r.Flags.StringVar(
 			&r.dutAttributeListPath,
 			"dutattributes",
 			"",
@@ -521,6 +532,20 @@
 		return errors.New("at least one -plan is required")
 	}
 
+	if len(r.builds) > 0 && len(r.buildsPath) > 0 {
+		return errors.New("only one of -build and -builds can be set")
+	}
+
+	// If buildsPath was used, parse the builds into r.builds.
+	if len(r.buildsPath) > 0 {
+		parsedBuilds, err := protoio.ReadJsonl(r.buildsPath, func() *bbpb.Build { return &bbpb.Build{} })
+		if err != nil {
+			return err
+		}
+
+		r.builds = parsedBuilds
+	}
+
 	if len(r.builds) == 0 {
 		return errors.New("at least one -build is required")
 	}
diff --git a/src/chromiumos/test/plan/internal/protoio/protoio.go b/src/chromiumos/test/plan/internal/protoio/protoio.go
index a628375..674932e 100644
--- a/src/chromiumos/test/plan/internal/protoio/protoio.go
+++ b/src/chromiumos/test/plan/internal/protoio/protoio.go
@@ -7,6 +7,7 @@
 package protoio
 
 import (
+	"bufio"
 	"io/ioutil"
 	"os"
 	"path/filepath"
@@ -57,6 +58,31 @@
 	return unmarshalOpts.Unmarshal(b, protov1.MessageV2(m))
 }
 
+// ReadJsonl parses the newline-delimited jsonprotos in inPath. ctor must return
+// a new empty proto to parse each line into.
+func ReadJsonl[M proto.Message](inPath string, ctor func() M) ([]M, error) {
+	f, err := os.Open(inPath)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	messages := make([]M, 0)
+	scanner := bufio.NewScanner(f)
+	unmarshalOpts := protojson.UnmarshalOptions{DiscardUnknown: true}
+
+	for scanner.Scan() {
+		m := ctor()
+		if err := unmarshalOpts.Unmarshal(scanner.Bytes(), m); err != nil {
+			return nil, err
+		}
+
+		messages = append(messages, m)
+	}
+
+	return messages, nil
+}
+
 // WriteJsonl writes a newline-delimited json file containing messages to outPath.
 func WriteJsonl[M proto.Message](messages []M, outPath string) error {
 	outFile, err := os.Create(outPath)
diff --git a/src/chromiumos/test/plan/internal/protoio/protoio_test.go b/src/chromiumos/test/plan/internal/protoio/protoio_test.go
index eb46d17..92c673a 100644
--- a/src/chromiumos/test/plan/internal/protoio/protoio_test.go
+++ b/src/chromiumos/test/plan/internal/protoio/protoio_test.go
@@ -13,6 +13,7 @@
 	protov1 "github.com/golang/protobuf/proto"
 	"github.com/google/go-cmp/cmp"
 	test_api_v1 "go.chromium.org/chromiumos/config/go/test/api/v1"
+	bbpb "go.chromium.org/luci/buildbucket/proto"
 	"google.golang.org/protobuf/testing/protocmp"
 )
 
@@ -81,6 +82,32 @@
 	}
 }
 
+func TestReadJsonl(t *testing.T) {
+	inPath := filepath.Join(t.TempDir(), "in.jsonl")
+	buildsContent := `{"builder": {"builder": "builderA"}}
+{"builder": {"builder": "builderB"}}`
+	if err := os.WriteFile(inPath, []byte(buildsContent), os.ModePerm); err != nil {
+		t.Fatal(err)
+	}
+
+	parsedBuilds, err := protoio.ReadJsonl(inPath, func() *bbpb.Build { return &bbpb.Build{} })
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expectedBuilds := []*bbpb.Build{
+		{
+			Builder: &bbpb.BuilderID{Builder: "builderA"},
+		},
+		{
+			Builder: &bbpb.BuilderID{Builder: "builderB"},
+		},
+	}
+	if diff := cmp.Diff(expectedBuilds, parsedBuilds, protocmp.Transform()); diff != "" {
+		t.Errorf("returned unexpected diff in read message (-want +got):\n%s", diff)
+	}
+}
+
 func TestFilepathAsJsonpb(t *testing.T) {
 	tests := []struct {
 		name, input, expected string