cmd/vulnreport: add commit command

The "vulnreport commit reports/GO-XXXX-YYYY.yaml" command runs
"vulnreport fix" on the file and then commits it with a standard
commit message if there are no remaining lint warnings.

Simplifies the workflow of adding new reports by taking responsibility
for setting the commit message, and ensuring fix/lint have been run.

Change-Id: I845ca622c1e53789670c3a6a7e192c6fa2ffcebf
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/412415
Run-TryBot: Damien Neil <dneil@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Julie Qiu <julieqiu@google.com>
diff --git a/cmd/vulnreport/main.go b/cmd/vulnreport/main.go
index 050d4c6..9e57331 100644
--- a/cmd/vulnreport/main.go
+++ b/cmd/vulnreport/main.go
@@ -15,11 +15,13 @@
 	"go/build"
 	"log"
 	"os"
+	"regexp"
 	"sort"
 	"strconv"
 	"strings"
 	"time"
 
+	"github.com/go-git/go-git/v5"
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/vulndb/internal/cvelistrepo"
 	"golang.org/x/vulndb/internal/derrors"
@@ -82,6 +84,15 @@
 		if err := multi(lint, names); err != nil {
 			log.Fatal(err)
 		}
+	case "commit":
+		repo, err := gitrepo.Open(ctx, ".")
+		if err != nil {
+			log.Fatal(err)
+		}
+		f := func(name string) error { return commit(ctx, repo, name, *githubToken) }
+		if err := multi(f, names); err != nil {
+			log.Fatal(err)
+		}
 	case "newcve":
 		if err := multi(newCVE, names); err != nil {
 			log.Fatal(err)
@@ -306,6 +317,83 @@
 	return newslice, nil
 }
 
+var reportRegexp = regexp.MustCompile(`^reports/GO-\d\d\d\d-(\d+)\.yaml$`)
+
+func commit(ctx context.Context, repo *git.Repository, filename, accessToken string) (err error) {
+	defer derrors.Wrap(&err, "commit(%q)", filename)
+	m := reportRegexp.FindStringSubmatch(filename)
+	if len(m) != 2 {
+		return fmt.Errorf("%v: not a report filename", filename)
+	}
+	issueID := m[1]
+
+	// Ignore errors. If anything is really wrong with the report, we'll
+	// detect it on re-linting below.
+	_ = fix(ctx, filename, accessToken)
+
+	r, err := report.Read(filename)
+	if err != nil {
+		return err
+	}
+	if lints := r.Lint(); len(lints) > 0 {
+		fmt.Fprintf(os.Stderr, "%v: contains lint warnings, not committing\n", filename)
+		for _, l := range lints {
+			fmt.Fprintln(os.Stderr, l)
+		}
+		fmt.Fprintln(os.Stderr)
+		return nil
+	}
+
+	tree, err := repo.Worktree()
+	if err != nil {
+		return err
+	}
+	_, err = tree.Add(filename)
+	if err != nil {
+		return err
+	}
+	st, err := tree.Status()
+	if err != nil {
+		return err
+	}
+	if _, ok := st[filename]; !ok {
+		// Trying to commit a file that hasn't changed from HEAD.
+		fmt.Printf("%v: unmodified\n", filename)
+		return nil
+	}
+	msg := fmt.Sprintf("x/vulndb: add %v for %v\n\nFixes golang/vulndb#%v\n",
+		filename, strings.Join(r.CVEs, ", "), issueID)
+	_, err = tree.Commit(msg, &git.CommitOptions{})
+	return err
+}
+
+// Regexp for matching go tags. The groups are:
+// 1  the major.minor version
+// 2  the patch version, or empty if none
+// 3  the entire prerelease, if present
+// 4  the prerelease type ("beta" or "rc")
+// 5  the prerelease number
+var tagRegexp = regexp.MustCompile(`^go(\d+\.\d+)(\.\d+|)((beta|rc)(\d+))?$`)
+
+// versionForTag returns the semantic version for a Go version string,
+// or "" if the version string doesn't correspond to a Go release or beta.
+func semverForGoVersion(v string) report.Version {
+	m := tagRegexp.FindStringSubmatch(v)
+	if m == nil {
+		return ""
+	}
+	version := m[1]
+	if m[2] != "" {
+		version += m[2]
+	} else {
+		version += ".0"
+	}
+	if m[3] != "" {
+		version += "-" + m[4] + "." + m[5]
+	}
+	return report.Version(version)
+}
+
 // loadPackage loads the package at the given import path, with enough
 // information for constructing a call graph.
 func loadPackage(cfg *packages.Config, importPath string) ([]*packages.Package, error) {