internal/worker: create issues for GitHub security advisories

worker.CreateIssues now creates issues for security advisories
that need them.

Change-Id: I99fa8dc12d6b26681d89e0a861d21cd1ff14f042
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/384334
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Damien Neil <dneil@google.com>
diff --git a/internal/worker/store/fire_store.go b/internal/worker/store/fire_store.go
index 9921b6a..a4c20ff 100644
--- a/internal/worker/store/fire_store.go
+++ b/internal/worker/store/fire_store.go
@@ -280,6 +280,21 @@
 	return tx.t.Set(tx.s.ghsaRecordRef(r.GHSA.ID), r)
 }
 
+// GetGHSARecord implements Transaction.GetGHSARecord.
+func (tx *fsTransaction) GetGHSARecord(id string) (_ *GHSARecord, err error) {
+	defer derrors.Wrap(&err, "GetGHSARecord(%s)", id)
+
+	docsnap, err := tx.t.Get(tx.s.ghsaRecordRef(id))
+	if status.Code(err) == codes.NotFound {
+		return nil, nil
+	}
+	var gr GHSARecord
+	if err := docsnap.DataTo(&gr); err != nil {
+		return nil, err
+	}
+	return &gr, nil
+}
+
 // GetGHSARecords implements Transaction.GetGHSARecords.
 func (tx *fsTransaction) GetGHSARecords() (_ []*GHSARecord, err error) {
 	defer derrors.Wrap(&err, "GetGHSARecords()")
diff --git a/internal/worker/store/mem_store.go b/internal/worker/store/mem_store.go
index a52f1b0..283c092 100644
--- a/internal/worker/store/mem_store.go
+++ b/internal/worker/store/mem_store.go
@@ -170,7 +170,7 @@
 	return nil
 }
 
-// SetGHSARecord implements Transaction.SetGHSARecord.n
+// SetGHSARecord implements Transaction.SetGHSARecord.
 func (tx *memTransaction) SetGHSARecord(r *GHSARecord) error {
 	if _, ok := tx.ms.ghsaRecords[r.GHSA.ID]; !ok {
 		return fmt.Errorf("GHSARecord %s does not exist", r.GHSA.ID)
@@ -179,7 +179,15 @@
 	return nil
 }
 
-// GetGHSARecords returns all the GHSARecords in the database.
+// GetGHSARecord implements Transaction.GetGHSARecord.
+func (tx *memTransaction) GetGHSARecord(id string) (*GHSARecord, error) {
+	if r, ok := tx.ms.ghsaRecords[id]; ok {
+		return r, nil
+	}
+	return nil, fmt.Errorf("GHSARecord %s does not exist", id)
+}
+
+// GetGHSARecords implements Transaction.GetGHSARecords.
 func (tx *memTransaction) GetGHSARecords() ([]*GHSARecord, error) {
 	var recs []*GHSARecord
 	for _, r := range tx.ms.ghsaRecords {
diff --git a/internal/worker/store/store.go b/internal/worker/store/store.go
index 7d05d4a..80440c1 100644
--- a/internal/worker/store/store.go
+++ b/internal/worker/store/store.go
@@ -63,6 +63,12 @@
 	History []*CVERecordSnapshot
 }
 
+func (r *CVERecord) GetID() string                { return r.ID }
+func (r *CVERecord) GetPrettyID() string          { return r.ID }
+func (r *CVERecord) GetUnit() string              { return r.Module }
+func (r *CVERecord) GetIssueReference() string    { return r.IssueReference }
+func (r *CVERecord) GetIssueCreatedAt() time.Time { return r.IssueCreatedAt }
+
 // Validate returns an error if the CVERecord is not valid.
 func (r *CVERecord) Validate() error {
 	if r.ID == "" {
@@ -179,6 +185,26 @@
 	TriageState TriageState
 	// TriageStateReason is an explanation of TriageState.
 	TriageStateReason string
+	// IssueReference is a reference to the GitHub issue that was filed.
+	// E.g. golang/vulndb#12345.
+	// Set only after a GitHub issue has been successfully created.
+	IssueReference string
+	// IssueCreatedAt is the time when the issue was created.
+	// Set only after a GitHub issue has been successfully created.
+	IssueCreatedAt time.Time
+}
+
+func (r *GHSARecord) GetID() string                { return r.GHSA.ID }
+func (r *GHSARecord) GetUnit() string              { return r.GHSA.Vulns[0].Package }
+func (r *GHSARecord) GetIssueReference() string    { return r.IssueReference }
+func (r *GHSARecord) GetIssueCreatedAt() time.Time { return r.IssueCreatedAt }
+
+func (r *GHSARecord) GetPrettyID() string {
+	// The GHSA ID in the Identifiers list is more human-readable.
+	if len(r.GHSA.Identifiers) > 0 {
+		return r.GHSA.Identifiers[0].Value
+	}
+	return r.GHSA.ID
 }
 
 // A Store is a storage system for the CVE database.
@@ -236,6 +262,10 @@
 	// an error if no such record exists.
 	SetGHSARecord(*GHSARecord) error
 
+	// GetGHSARecord returns a single GHSARecord by ID.
+	// If not found, it returns (nil, nil).
+	GetGHSARecord(id string) (*GHSARecord, error)
+
 	// GetGHSARecords returns all the GHSARecords in the database.
 	GetGHSARecords() ([]*GHSARecord, error)
 }
diff --git a/internal/worker/store/store_test.go b/internal/worker/store/store_test.go
index 35dd90b..d21b8e2 100644
--- a/internal/worker/store/store_test.go
+++ b/internal/worker/store/store_test.go
@@ -248,6 +248,20 @@
 	if diff := cmp.Diff(gs, got); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
+
+	// Retrieve one record by ID.
+	var got0 *GHSARecord
+	err = s.RunTransaction(ctx, func(ctx context.Context, tx Transaction) error {
+		var err error
+		got0, err = tx.GetGHSARecord(gs[0].GetID())
+		return err
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if got, want := got0, gs[0]; !cmp.Equal(got, want) {
+		t.Errorf("got %+v, want %+v", got, want)
+	}
 }
 
 func createCVERecords(t *testing.T, ctx context.Context, s Store, crs []*CVERecord) {
diff --git a/internal/worker/update_test.go b/internal/worker/update_test.go
index b3e4c6a..a3bf32d 100644
--- a/internal/worker/update_test.go
+++ b/internal/worker/update_test.go
@@ -353,7 +353,23 @@
 func createCVERecords(t *testing.T, s store.Store, crs []*store.CVERecord) {
 	err := s.RunTransaction(context.Background(), func(_ context.Context, tx store.Transaction) error {
 		for _, cr := range crs {
-			if err := tx.CreateCVERecord(cr); err != nil {
+			copy := *cr
+			if err := tx.CreateCVERecord(&copy); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func createGHSARecords(t *testing.T, s store.Store, grs []*store.GHSARecord) {
+	err := s.RunTransaction(context.Background(), func(_ context.Context, tx store.Transaction) error {
+		for _, gr := range grs {
+			copy := *gr
+			if err := tx.CreateGHSARecord(&copy); err != nil {
 				return err
 			}
 		}
diff --git a/internal/worker/worker.go b/internal/worker/worker.go
index 794cea5..a1049d8 100644
--- a/internal/worker/worker.go
+++ b/internal/worker/worker.go
@@ -215,53 +215,30 @@
 	ctx = event.Start(ctx, "CreateIssues")
 	defer event.End(ctx)
 
+	if err := createCVEIssues(ctx, st, ic, limit); err != nil {
+		return err
+	}
+	return createGHSAIssues(ctx, st, ic, limit)
+}
+
+func createCVEIssues(ctx context.Context, st store.Store, ic issues.Client, limit int) (err error) {
+	defer derrors.Wrap(&err, "createCVEIssues(destination: %s)", ic.Destination())
+
 	needsIssue, err := st.ListCVERecordsWithTriageState(ctx, store.TriageStateNeedsIssue)
 	if err != nil {
 		return err
 	}
-	log.Infof(ctx, "CreateIssues starting; destination: %s, total needing issue: %d",
+	log.Infof(ctx, "createCVEIssues starting; destination: %s, total needing issue: %d",
 		ic.Destination(), len(needsIssue))
-	numCreated := int64(0)
+	numCreated := 0
 	for _, cr := range needsIssue {
-		if limit > 0 && int(numCreated) >= limit {
+		if limit > 0 && numCreated >= limit {
 			break
 		}
-		if cr.IssueReference != "" || !cr.IssueCreatedAt.IsZero() {
-			log.With(
-				"CVE", cr.ID,
-				"IssueReference", cr.IssueReference,
-				"IssueCreatedAt", cr.IssueCreatedAt,
-			).Errorf(ctx, "%s: triage state is NeedsIssue but issue field(s) non-zero; skipping", cr.ID)
-			continue
-		}
-		body, err := newBody(cr)
+		ref, err := createIssue(ctx, cr, ic, newCVEBody)
 		if err != nil {
-			log.With(
-				"CVE", cr.ID,
-				"IssueReference", cr.IssueReference,
-				"IssueCreatedAt", cr.IssueCreatedAt,
-			).Errorf(ctx, "%s: triage state is NeedsIssue but could not generate body; skipping: %v", cr.ID, err)
-			continue
-		}
-
-		// Create the issue.
-		iss := &issues.Issue{
-			Title: fmt.Sprintf("x/vulndb: potential Go vuln in %s: %s", cr.Module, cr.ID),
-			Body:  body,
-		}
-		if err := issueRateLimiter.Wait(ctx); err != nil {
 			return err
 		}
-		num, err := ic.CreateIssue(ctx, iss)
-		if err != nil {
-			return fmt.Errorf("creating issue for %s: %w", cr.ID, err)
-		}
-		// If we crashed here, we would have filed an issue without recording
-		// that fact in the DB. That can lead to duplicate issues, but nothing
-		// worse (we won't miss a CVE).
-		// TODO(https://go.dev/issue/49733): look for the issue title to avoid duplications.
-		ref := ic.Reference(num)
-		log.With("CVE", cr.ID).Infof(ctx, "created issue %s for %s", ref, cr.ID)
 
 		// Update the CVERecord in the DB with issue information.
 		err = st.RunTransaction(ctx, func(ctx context.Context, tx store.Transaction) error {
@@ -280,11 +257,12 @@
 		}
 		numCreated++
 	}
-	log.With("limit", limit).Infof(ctx, "CreateIssues done: %d created", numCreated)
+	log.With("limit", limit).Infof(ctx, "createCVEIssues done: %d created", numCreated)
 	return nil
 }
 
-func newBody(cr *store.CVERecord) (string, error) {
+func newCVEBody(sr storeRecord) (string, error) {
+	cr := sr.(*store.CVERecord)
 	var b strings.Builder
 	if cr.CVE == nil {
 		return "", fmt.Errorf("cannot create body for CVERecord with nil CVE")
@@ -317,6 +295,112 @@
 	return b.String(), nil
 }
 
+func createGHSAIssues(ctx context.Context, st store.Store, ic issues.Client, limit int) (err error) {
+	defer derrors.Wrap(&err, "createGHSAIssues(destination: %s)", ic.Destination())
+
+	sas, err := getGHSARecords(ctx, st)
+	if err != nil {
+		return err
+	}
+	var needsIssue []*store.GHSARecord
+	for _, sa := range sas {
+		if sa.TriageState == store.TriageStateNeedsIssue {
+			needsIssue = append(needsIssue, sa)
+		}
+	}
+
+	log.Infof(ctx, "createGHSAIssues starting; destination: %s, total needing issue: %d",
+		ic.Destination(), len(needsIssue))
+	numCreated := 0
+	for _, gr := range needsIssue {
+		if limit > 0 && numCreated >= limit {
+			break
+		}
+		ref, err := createIssue(ctx, gr, ic, newGHSABody)
+		if err != nil {
+			return err
+		}
+		// Update the GHSARecord in the DB with issue information.
+		err = st.RunTransaction(ctx, func(ctx context.Context, tx store.Transaction) error {
+			r, err := tx.GetGHSARecord(gr.GetID())
+			if err != nil {
+				return err
+			}
+			r.TriageState = store.TriageStateIssueCreated
+			r.IssueReference = ref
+			r.IssueCreatedAt = time.Now()
+			return tx.SetGHSARecord(r)
+		})
+		if err != nil {
+			return err
+		}
+		numCreated++
+	}
+	log.With("limit", limit).Infof(ctx, "createGHSAIssues done: %d created", numCreated)
+	return nil
+}
+
+func newGHSABody(sr storeRecord) (string, error) {
+	sa := sr.(*store.GHSARecord).GHSA
+
+	var b strings.Builder
+	intro := fmt.Sprintf(
+		"In GitHub Security Advisory [%s](%s), there is a vulnerability in the Go package or module %s.",
+		sr.GetPrettyID(), sa.Permalink, sa.Vulns[0].Package)
+	if err := issueTemplate.Execute(&b, issueTemplateData{
+		Intro: intro,
+	}); err != nil {
+		return "", err
+	}
+	return b.String(), nil
+}
+
+type storeRecord interface {
+	GetID() string
+	GetPrettyID() string
+	GetUnit() string
+	GetIssueReference() string
+	GetIssueCreatedAt() time.Time
+}
+
+func createIssue(ctx context.Context, r storeRecord, ic issues.Client, newBody func(storeRecord) (string, error)) (ref string, err error) {
+	id := r.GetID()
+	defer derrors.Wrap(&err, "createIssue(%s)", id)
+
+	if r.GetIssueReference() != "" || !r.GetIssueCreatedAt().IsZero() {
+		log.With(
+			"ID", id,
+			"IssueReference", r.GetIssueReference(),
+			"IssueCreatedAt", r.GetIssueCreatedAt(),
+		).Errorf(ctx, "%s: triage state is NeedsIssue but issue field(s) non-zero; skipping", id)
+		return "", nil
+	}
+	body, err := newBody(r)
+	if err != nil {
+		log.With("ID", id).Errorf(ctx, "%s: triage state is NeedsIssue but could not generate body; skipping: %v", id, err)
+		return "", nil
+	}
+	// Create the issue.
+	iss := &issues.Issue{
+		Title: fmt.Sprintf("x/vulndb: potential Go vuln in %s: %s", r.GetUnit(), r.GetPrettyID()),
+		Body:  body,
+	}
+	if err := issueRateLimiter.Wait(ctx); err != nil {
+		return "", err
+	}
+	num, err := ic.CreateIssue(ctx, iss)
+	if err != nil {
+		return "", fmt.Errorf("creating issue for %s: %w", id, err)
+	}
+	// If we crashed here, we would have filed an issue without recording
+	// that fact in the DB. That can lead to duplicate issues, but nothing
+	// worse (we won't miss a CVE).
+	// TODO(https://go.dev/issue/49733): look for the issue title to avoid duplications.
+	ref = ic.Reference(num)
+	log.With("ID", id).Infof(ctx, "created issue %s for %s", ref, id)
+	return ref, nil
+}
+
 type issueTemplateData struct {
 	Intro  string
 	Report string
@@ -327,9 +411,11 @@
 var issueTemplate = template.Must(template.New("issue").Parse(`
 {{- .Intro}}
 
+{{if (and .Pre .Report) -}}
 {{.Pre}}
 {{.Report}}
 {{.Pre}}
+{{- end}}
 
 See [doc/triage.md](https://github.com/golang/vulndb/blob/master/doc/triage.md) for instructions on how to triage this report.
 `))
diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go
index c6a3fcb..5ed3cd8 100644
--- a/internal/worker/worker_test.go
+++ b/internal/worker/worker_test.go
@@ -9,6 +9,7 @@
 
 import (
 	"context"
+	"fmt"
 	"math"
 	"os"
 	"sort"
@@ -137,32 +138,71 @@
 		},
 	}
 	createCVERecords(t, mstore, crs)
+	grs := []*store.GHSARecord{
+		{
+			GHSA: &ghsa.SecurityAdvisory{
+				ID:    "g1",
+				Vulns: []*ghsa.Vuln{{Package: "p1"}},
+			},
+			TriageState: store.TriageStateNeedsIssue,
+		},
+		{
+			GHSA: &ghsa.SecurityAdvisory{
+				ID:    "g2",
+				Vulns: []*ghsa.Vuln{{Package: "p2"}},
+			},
+			TriageState: store.TriageStateNoActionNeeded,
+		},
+		{
+			GHSA: &ghsa.SecurityAdvisory{
+				ID:    "g3",
+				Vulns: []*ghsa.Vuln{{Package: "p3"}},
+			},
+			TriageState: store.TriageStateIssueCreated,
+		},
+	}
+	createGHSARecords(t, mstore, grs)
 
 	if err := CreateIssues(ctx, mstore, ic, 0); err != nil {
 		t.Fatal(err)
 	}
 
-	var wants []*store.CVERecord
+	var wantCVERecords []*store.CVERecord
 	for _, r := range crs {
 		copy := *r
-		wants = append(wants, &copy)
+		wantCVERecords = append(wantCVERecords, &copy)
 	}
-	wants[0].TriageState = store.TriageStateIssueCreated
-	wants[0].IssueReference = "inMemory#1"
+	wantCVERecords[0].TriageState = store.TriageStateIssueCreated
+	wantCVERecords[0].IssueReference = "inMemory#1"
 
-	gotRecs := mstore.CVERecords()
-	if len(gotRecs) != len(wants) {
-		t.Fatalf("wrong number of records: got %d, want %d", len(gotRecs), len(wants))
+	gotCVERecs := mstore.CVERecords()
+	if len(gotCVERecs) != len(wantCVERecords) {
+		t.Fatalf("wrong number of records: got %d, want %d", len(gotCVERecs), len(wantCVERecords))
 	}
-	for _, want := range wants {
-		got := gotRecs[want.ID]
+	for _, want := range wantCVERecords {
+		got := gotCVERecs[want.ID]
 		if !cmp.Equal(got, want, cmpopts.IgnoreFields(store.CVERecord{}, "IssueCreatedAt")) {
-			t.Errorf("got  %+v\nwant %+v", got, want)
+			t.Errorf("\ngot  %+v\nwant %+v", got, want)
 		}
 	}
+
+	var wantGHSARecs []*store.GHSARecord
+	for _, r := range grs {
+		copy := *r
+		wantGHSARecs = append(wantGHSARecs, &copy)
+	}
+	wantGHSARecs[0].TriageState = store.TriageStateIssueCreated
+	wantGHSARecs[0].IssueReference = "inMemory#2"
+
+	gotGHSARecs := getGHSARecordsSorted(t, mstore)
+	fmt.Printf("%+v\n", gotGHSARecs[0])
+	if diff := cmp.Diff(wantGHSARecs, gotGHSARecs,
+		cmpopts.IgnoreFields(store.GHSARecord{}, "IssueCreatedAt")); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
 }
 
-func TestNewBody(t *testing.T) {
+func TestNewCVEBody(t *testing.T) {
 	r := &store.CVERecord{
 		ID:     "ID1",
 		Module: "a.Module",
@@ -175,7 +215,7 @@
 			},
 		},
 	}
-	got, err := newBody(r)
+	got, err := newCVEBody(r)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -197,6 +237,31 @@
 	}
 }
 
+func TestNewGHSABody(t *testing.T) {
+	r := &store.GHSARecord{
+		GHSA: &ghsa.SecurityAdvisory{
+			ID:          "G1_blah",
+			Identifiers: []ghsa.Identifier{{Type: "GHSA", Value: "G1"}},
+			Permalink:   "https://github.com/permalink/to/G1",
+			Description: "a description",
+			Vulns:       []*ghsa.Vuln{{Package: "aPackage"}},
+		},
+	}
+	got, err := newGHSABody(r)
+	if err != nil {
+		t.Fatal(err)
+	}
+	want := `In GitHub Security Advisory [G1](https://github.com/permalink/to/G1), there is a vulnerability in the Go package or module aPackage.
+
+
+
+See [doc/triage.md](https://github.com/golang/vulndb/blob/master/doc/triage.md) for instructions on how to triage this report.
+`
+	if diff := cmp.Diff(unindent(want), got); diff != "" {
+		t.Errorf("mismatch (-want, +got):\n%s", diff)
+	}
+}
+
 // unindent removes leading whitespace from s.
 // It first finds the line beginning with the fewest space and tab characters.
 // It then removes that many characters from every line.
@@ -258,13 +323,7 @@
 		if gotStats != wantStats {
 			t.Errorf("\ngot  %+v\nwant %+v", gotStats, wantStats)
 		}
-		gotRecords, err := getGHSARecords(ctx, mstore)
-		if err != nil {
-			t.Fatal(err)
-		}
-		sort.Slice(gotRecords, func(i, j int) bool {
-			return gotRecords[i].GHSA.ID < gotRecords[j].GHSA.ID
-		})
+		gotRecords := getGHSARecordsSorted(t, mstore)
 		if diff := cmp.Diff(wantRecords, gotRecords); diff != "" {
 			t.Errorf("mismatch (-want, +got):\n%s", diff)
 		}
@@ -301,6 +360,16 @@
 
 }
 
+func getGHSARecordsSorted(t *testing.T, st store.Store) []*store.GHSARecord {
+	t.Helper()
+	rs, err := getGHSARecords(context.Background(), st)
+	if err != nil {
+		t.Fatal(err)
+	}
+	sort.Slice(rs, func(i, j int) bool { return rs[i].GHSA.ID < rs[j].GHSA.ID })
+	return rs
+}
+
 func fakeListFunc(sas []*ghsa.SecurityAdvisory) GHSAListFunc {
 	return func(_ context.Context, since time.Time) ([]*ghsa.SecurityAdvisory, error) {
 		var rs []*ghsa.SecurityAdvisory