cmd/vulnreport: make commit message generation more clever

In vulnreport commit, automatically change the commit message
is based on the files in the git staging area. For example, if a report
is being updated, use the word "update" to describe the commit. If an
excluded report is being deleted and a corresponding regular report added,
use the word "unexclude".

Change-Id: I021c4f5e8e6b522a9e4ef0597501dfb8426efec2
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/569598
Reviewed-by: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/cmd/vulnreport/commit.go b/cmd/vulnreport/commit.go
index 4ac21d6..a68efdf 100644
--- a/cmd/vulnreport/commit.go
+++ b/cmd/vulnreport/commit.go
@@ -6,23 +6,22 @@
 
 import (
 	"context"
-	"flag"
 	"fmt"
+	"path/filepath"
 	"strings"
 
+	"github.com/go-git/go-git/v5"
 	"golang.org/x/exp/slices"
 	"golang.org/x/vulndb/internal/ghsa"
+	"golang.org/x/vulndb/internal/gitrepo"
 	"golang.org/x/vulndb/internal/proxy"
 	"golang.org/x/vulndb/internal/report"
 )
 
-var (
-	updateIssue = flag.Bool("up", false, "for commit, create a CL that updates (doesn't fix) the tracking bug")
-)
-
 type commit struct {
-	pc *proxy.Client
-	gc *ghsa.Client
+	pc   *proxy.Client
+	gc   *ghsa.Client
+	repo *git.Repository
 
 	filenameParser
 }
@@ -37,6 +36,12 @@
 func (c *commit) setup(ctx context.Context) error {
 	c.pc = proxy.NewDefaultClient()
 	c.gc = ghsa.NewClient(ctx, *githubToken)
+
+	repo, err := gitrepo.Open(ctx, ".")
+	if err != nil {
+		return err
+	}
+	c.repo = repo
 	return nil
 }
 
@@ -57,21 +62,84 @@
 		return fmt.Errorf("file %q has unaddressed %q fields", filename, "TODO:")
 	}
 
-	// Add all the files related to this report.
+	// Stage all the files related to this report.
 	glob := fmt.Sprintf("*%s*", r.ID)
 	if err := gitAdd(glob); err != nil {
 		return err
 	}
 
-	// Commit the files, allowing the user to edit the default commit message.
-	msg, err := newCommitMsg(r)
+	reportAction, issueAction, err := actionPhrases(c.repo, r)
 	if err != nil {
 		return err
 	}
+
+	msg, err := newCommitMsg(r, reportAction, issueAction)
+	if err != nil {
+		return err
+	}
+
+	// Commit the files, allowing the user to edit the default commit message.
 	return gitCommit(msg, glob)
 }
 
-func newCommitMsg(r *report.Report) (string, error) {
+// actionPhrases determines the action phrases to use to describe what is happening
+// in the commit, based on the status of the git staging area.
+func actionPhrases(repo *git.Repository, r *report.Report) (reportAction, issueAction string, _ error) {
+	const (
+		updateIssueAction = "Updates"
+		fixIssueAction    = "Fixes"
+
+		addReportAction       = "add"
+		deleteReportAction    = "delete"
+		unexcludeReportAction = "unexclude"
+		updateReportAction    = "update"
+	)
+
+	w, err := repo.Worktree()
+	if err != nil {
+		return "", "", err
+	}
+
+	status, err := w.Status()
+	if err != nil {
+		return "", "", err
+	}
+
+	fname, err := r.YAMLFilename()
+	if err != nil {
+		return "", "", err
+	}
+	stat := status.File(fname).Staging
+	switch stat {
+	case git.Deleted:
+		if r.IsExcluded() {
+			// It's theoretically OK to just delete an excluded report,
+			// because they aren't published anywhere.
+			return deleteReportAction, updateIssueAction, nil
+		}
+		// It's not OK to delete a regular report. These can be withdrawn but not deleted.
+		return "", "", fmt.Errorf("cannot delete regular report %s (use withdrawn field instead)", fname)
+	case git.Added:
+		switch {
+		case status.File(filepath.Join(report.ExcludedDir, r.ID+".yaml")).Staging == git.Deleted:
+			// If a corresponding excluded report is being deleted,
+			// this is an unexclude action.
+			return unexcludeReportAction, updateIssueAction, nil
+		case r.CVEMetadata != nil:
+			// Update instead of fixing the issue because we still need
+			// to manually publish the CVE record after submitting the CL.
+			return addReportAction, updateIssueAction, nil
+		default:
+			return addReportAction, fixIssueAction, nil
+		}
+	case git.Modified:
+		return updateReportAction, updateIssueAction, nil
+	}
+
+	return "", "", fmt.Errorf("internal error: could not determine actions for %s", r.ID)
+}
+
+func newCommitMsg(r *report.Report, reportAction, issueAction string) (string, error) {
 	f, err := r.YAMLFilename()
 	if err != nil {
 		return "", err
@@ -82,21 +150,9 @@
 		return "", err
 	}
 
-	issueAction := "Fixes"
-	fileAction := "add"
-	if *updateIssue {
-		fileAction = "update"
-		issueAction = "Updates"
-	}
-	// For now, we need to manually publish the CVE record so the issue
-	// should not be auto-closed on add.
-	if r.CVEMetadata != nil {
-		issueAction = "Updates"
-	}
-
 	return fmt.Sprintf(
 		"%s: %s %s\n\nAliases: %s\n\n%s golang/vulndb#%d",
-		folder, fileAction, filename, strings.Join(r.Aliases(), ", "),
+		folder, reportAction, filename, strings.Join(r.Aliases(), ", "),
 		issueAction, issueID), nil
 }