cmd/ejobs: support for getting results

Add a results subcommand to fetch results of a job.

This won't work until we add the jobs/results endpoint to the worker,
in a forthcoming CL.

Change-Id: I213ec6d3020ee37ee19896c82b68366f8415f08e
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/511757
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Maceo Thompson <maceothompson@google.com>
diff --git a/cmd/ejobs/main.go b/cmd/ejobs/main.go
index b423569..7277cfa 100644
--- a/cmd/ejobs/main.go
+++ b/cmd/ejobs/main.go
@@ -50,6 +50,10 @@
 
 	waitFlagSet  = flag.NewFlagSet("wait", flag.ContinueOnError)
 	waitInterval = waitFlagSet.Duration("i", 0, "display updates at this interval")
+
+	resultsFlagSet = flag.NewFlagSet("results", flag.ContinueOnError)
+	force          = resultsFlagSet.Bool("f", false, "download even if unfinished")
+	outfile        = resultsFlagSet.String("o", "", "output filename")
 )
 
 var commands = []command{
@@ -62,12 +66,15 @@
 	{"cancel", "JOBID...",
 		"cancel the jobs",
 		doCancel, nil},
-	{"start", "-min [MIN_IMPORTERS] BINARY ARGS...",
+	{"start", "[-min MIN_IMPORTERS] BINARY ARGS...",
 		"start a job",
 		doStart, startFlagSet},
 	{"wait", "JOBID",
 		"do not exit until JOBID is done",
 		doWait, nil},
+	{"results", "[-f] [-o FILE.json] JOBID",
+		"download results as JSON",
+		doResults, nil},
 }
 
 type command struct {
@@ -219,7 +226,7 @@
 		if err != nil {
 			return err
 		}
-		done := job.NumSkipped + job.NumFailed + job.NumErrored + job.NumSucceeded
+		done := job.NumFinished()
 		if done >= job.NumEnqueued {
 			break
 		}
@@ -426,6 +433,44 @@
 	return dest.Close()
 }
 
+func doResults(ctx context.Context, args []string) (err error) {
+	fs := resultsFlagSet
+	if err := fs.Parse(args); err != nil {
+		return err
+	}
+	if fs.NArg() == 0 {
+		return errors.New("wrong number of args: want [-f] [-o FILE.json] JOB_ID")
+	}
+	jobID := fs.Arg(0)
+	ts, err := identityTokenSource(ctx)
+	if err != nil {
+		return err
+	}
+	job, err := requestJSON[jobs.Job](ctx, "jobs/describe?jobid="+jobID, ts)
+	if err != nil {
+		return err
+	}
+	done := job.NumFinished()
+	if !*force && done < job.NumEnqueued {
+		return fmt.Errorf("job not finished (%d/%d completed); use -f for partial results", done, job.NumEnqueued)
+	}
+	results, err := requestJSON[jobs.Results](ctx, "jobs/results?jobid="+jobID, ts)
+	if err != nil {
+		return err
+	}
+	out := os.Stdout
+	if *outfile != "" {
+		out, err = os.Create(*outfile)
+		if err != nil {
+			return err
+		}
+		defer func() { err = errors.Join(err, out.Close()) }()
+	}
+	enc := json.NewEncoder(out)
+	enc.SetIndent("", "\t")
+	return enc.Encode(results)
+}
+
 // requestJSON requests the path from the worker, then reads the returned body
 // and unmarshals it as JSON.
 func requestJSON[T any](ctx context.Context, path string, ts oauth2.TokenSource) (*T, error) {
diff --git a/internal/bigquery/bigquery.go b/internal/bigquery/bigquery.go
index dc503eb..d5ff368 100644
--- a/internal/bigquery/bigquery.go
+++ b/internal/bigquery/bigquery.go
@@ -321,7 +321,7 @@
 }
 
 // PartitionQuery describes a query that returns one row for each distinct value
-// of the partition column in the given table.
+// of the partition columns in the given table.
 //
 // The selected row will be the first one according to the OrderBy clauses.
 //
diff --git a/internal/jobs/job.go b/internal/jobs/job.go
index 2c0169c..5e09912 100644
--- a/internal/jobs/job.go
+++ b/internal/jobs/job.go
@@ -7,6 +7,8 @@
 
 import (
 	"time"
+
+	"golang.org/x/pkgsite-metrics/internal/analysis"
 )
 
 // A Job is a set of related scan tasks enqueued at the same time.
@@ -45,3 +47,14 @@
 func (j *Job) ID() string {
 	return j.User + "-" + j.StartedAt.In(time.UTC).Format(startTimeFormat)
 }
+
+func (j *Job) NumFinished() int {
+	return j.NumSkipped + j.NumFailed + j.NumErrored + j.NumSucceeded
+}
+
+// Results hold the results of a job.
+type Results struct {
+	JobID   string
+	Table   string // bigquery table containing results
+	Results []*analysis.Result
+}