internal/jobs: add ListJobs

Change-Id: I06878f12210a82766d1d471265debd661e7dd342
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/495797
Reviewed-by: Maceo Thompson <maceothompson@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/internal/jobs/firestore.go b/internal/jobs/firestore.go
index 4b288d5..859c154 100644
--- a/internal/jobs/firestore.go
+++ b/internal/jobs/firestore.go
@@ -7,9 +7,11 @@
 import (
 	"context"
 	"errors"
+	"time"
 
 	"cloud.google.com/go/firestore"
 	"golang.org/x/pkgsite-metrics/internal/derrors"
+	"google.golang.org/api/iterator"
 )
 
 // A DB is a client for a database that stores Jobs.
@@ -89,6 +91,34 @@
 	})
 }
 
+// ListJobs calls f on each job in the DB, most recently started first.
+// f is also passed the time that the job was last updated.
+// If f returns a non-nil error, the iteration stops and returns that error.
+func (d *DB) ListJobs(ctx context.Context, f func(_ *Job, lastUpdate time.Time) error) (err error) {
+	defer derrors.Wrap(&err, "job.DB.ListJobs()")
+
+	q := d.nsDoc.Collection(jobCollection).OrderBy("StartedAt", firestore.Desc)
+	iter := q.Documents(ctx)
+	defer iter.Stop()
+	for {
+		docsnap, err := iter.Next()
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return err
+		}
+		job, err := docsnapToJob(docsnap)
+		if err != nil {
+			return err
+		}
+		if err := f(job, docsnap.UpdateTime); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // jobRef returns the DocumentRef for a job with the given ID.
 func (d *DB) jobRef(id string) *firestore.DocumentRef {
 	return d.nsDoc.Collection(jobCollection).Doc(id)
diff --git a/internal/jobs/firestore_test.go b/internal/jobs/firestore_test.go
index 88d4cbc..3c9bdb0 100644
--- a/internal/jobs/firestore_test.go
+++ b/internal/jobs/firestore_test.go
@@ -69,4 +69,20 @@
 	if !cmp.Equal(got, job) {
 		t.Errorf("got\n%+v\nwant\n%+v", got, job)
 	}
+
+	// Create another job, then list both.
+	job2 := NewJob("user2", tm.Add(24*time.Hour), "url2")
+	must(db.DeleteJob(ctx, job2.ID()))
+	must(db.CreateJob(ctx, job2))
+
+	var got2 []*Job
+	must(db.ListJobs(ctx, func(j *Job, _ time.Time) error {
+		got2 = append(got2, j)
+		return nil
+	}))
+	// Jobs listed in reverse start-time order.
+	want2 := []*Job{job2, job}
+	if diff := cmp.Diff(want2, got2); diff != "" {
+		t.Errorf("mismatch (-want, +got)\n%s", diff)
+	}
 }