cmd/vulnreport: add subcommand framework

Adds a new unified framework for vulnreport subcommands. Each subcommand
now implements an interface, command, which has functions for setting up,
running, and tearing down the command.

Change-Id: I7c6ab5cf1b4c19b300dbdef6df0d76e2f1f303ea
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/559955
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/command.go b/cmd/vulnreport/command.go
new file mode 100644
index 0000000..0a03495
--- /dev/null
+++ b/cmd/vulnreport/command.go
@@ -0,0 +1,94 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"golang.org/x/vulndb/cmd/vulnreport/log"
+)
+
+// command represents a subcommand of vulnreport.
+type command interface {
+	// name outputs the string used to invoke the subcommand.
+	name() string
+	// usage outputs strings indicating how to use the subcommand.
+	usage() (args string, desc string)
+	// setup populates any state needed to run the subcommand.
+	setup(context.Context) error
+	// parseArgs takes in the raw args passed to the command line,
+	// and converts them to a representation understood by "run".
+	// This function need not be one-to-one: there may be more
+	// inputs than args or vice-versa.
+	parseArgs(_ context.Context, args []string) (inputs []string, _ error)
+	// run executes the subcommand on the given input.
+	run(_ context.Context, input string) error
+	// close cleans up state and/or completes tasks that should occur
+	// after run is called on all inputs.
+	close() error
+}
+
+// run executes the given command on the given raw arguments.
+func run(ctx context.Context, c command, args []string) error {
+	if err := c.setup(ctx); err != nil {
+		return err
+	}
+	defer c.close()
+
+	inputs, err := c.parseArgs(ctx, args)
+	if err != nil {
+		return err
+	}
+
+	for _, input := range inputs {
+		log.Infof("%s %v", c.name(), input)
+		if err := c.run(ctx, input); err != nil {
+			log.Errf("%s: %s", c.name(), err)
+		}
+	}
+	return nil
+}
+
+const (
+	filenameArgs = "[filename | github-id] ..."
+	ghIssueArgs  = "[github-id] ..."
+)
+
+// filenameParser implements the "parseArgs" function of the command
+// interface, and can be used by commands that operate on YAML filenames.
+type filenameParser bool
+
+func (filenameParser) parseArgs(_ context.Context, args []string) (filenames []string, _ error) {
+	if len(args) == 0 {
+		return nil, fmt.Errorf("no arguments provided")
+	}
+	for _, arg := range args {
+		fname, err := argToFilename(arg)
+		if err != nil {
+			log.Err(err)
+			continue
+		}
+		filenames = append(filenames, fname)
+	}
+	return filenames, nil
+}
+
+func argToFilename(arg string) (string, error) {
+	if _, err := os.Stat(arg); err != nil {
+		// If arg isn't a file, see if it might be an issue ID
+		// with an existing report.
+		for _, padding := range []string{"", "0", "00", "000"} {
+			m, _ := filepath.Glob("data/*/GO-*-" + padding + arg + ".yaml")
+			if len(m) == 1 {
+				return m[0], nil
+			}
+		}
+		return "", fmt.Errorf("%s is not a valid filename or issue ID with existing report: %w", arg, err)
+	}
+	return arg, nil
+}
diff --git a/cmd/vulnreport/commit.go b/cmd/vulnreport/commit.go
index 64db209..1904eab 100644
--- a/cmd/vulnreport/commit.go
+++ b/cmd/vulnreport/commit.go
@@ -11,7 +11,6 @@
 	"strings"
 
 	"golang.org/x/exp/slices"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/ghsa"
 	"golang.org/x/vulndb/internal/proxy"
 	"golang.org/x/vulndb/internal/report"
@@ -21,16 +20,36 @@
 	updateIssue = flag.Bool("up", false, "for commit, create a CL that updates (doesn't fix) the tracking bug")
 )
 
-func commit(ctx context.Context, filename string, ghsaClient *ghsa.Client, pc *proxy.Client, force bool) (err error) {
-	defer derrors.Wrap(&err, "commit(%q)", filename)
+type commit struct {
+	pc *proxy.Client
+	gc *ghsa.Client
 
+	filenameParser
+}
+
+func (commit) name() string { return "commit" }
+
+func (commit) usage() (string, string) {
+	const desc = "creates new commits for YAML reports"
+	return filenameArgs, desc
+}
+
+func (c *commit) setup(ctx context.Context) error {
+	c.pc = proxy.NewDefaultClient()
+	c.gc = ghsa.NewClient(ctx, *githubToken)
+	return nil
+}
+
+func (c *commit) close() error { return nil }
+
+func (c *commit) run(ctx context.Context, filename string) (err error) {
 	// Clean up the report file and lint the result.
 	// Stop if there any problems.
-	if err := fix(ctx, filename, ghsaClient, pc, force); err != nil {
+	r, err := report.ReadAndLint(filename, c.pc)
+	if err != nil {
 		return err
 	}
-	r, err := report.ReadAndLint(filename, pc)
-	if err != nil {
+	if err := fixReport(ctx, r, filename, c.pc, c.gc); err != nil {
 		return err
 	}
 	if hasUnaddressedTodos(r) {
diff --git a/cmd/vulnreport/create.go b/cmd/vulnreport/create.go
index 3a8fad6..c75b8fd 100644
--- a/cmd/vulnreport/create.go
+++ b/cmd/vulnreport/create.go
@@ -15,7 +15,6 @@
 	"golang.org/x/vulndb/cmd/vulnreport/log"
 	"golang.org/x/vulndb/internal/cveclient"
 	"golang.org/x/vulndb/internal/cveschema5"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/genai"
 	"golang.org/x/vulndb/internal/genericosv"
 	"golang.org/x/vulndb/internal/ghsa"
@@ -34,19 +33,75 @@
 	useAI     = flag.Bool("ai", false, "for create, use AI to write draft summary and description when creating report")
 )
 
-func create(ctx context.Context, issueNumber int, cfg *createCfg) (err error) {
-	defer derrors.Wrap(&err, "create(%d)", issueNumber)
-	// Get GitHub issue.
-	iss, err := cfg.issuesClient.Issue(ctx, issueNumber)
+type create struct {
+	gc              *ghsa.Client
+	ic              *issues.Client
+	pc              *proxy.Client
+	ac              *genai.GeminiClient
+	existingByFile  map[string]*report.Report
+	existingByIssue map[int]*report.Report
+	allowClosed     bool
+}
+
+func (create) name() string { return "create" }
+
+func (create) usage() (string, string) {
+	const desc = "creates a new vulnerability YAML report"
+	return ghIssueArgs, desc
+}
+
+func (c *create) setup(ctx context.Context) error {
+	if *githubToken == "" {
+		return fmt.Errorf("githubToken must be provided")
+	}
+	localRepo, err := gitrepo.Open(ctx, ".")
+	if err != nil {
+		return err
+	}
+	existingByIssue, existingByFile, err := report.All(localRepo)
+	if err != nil {
+		return err
+	}
+	owner, repoName, err := gitrepo.ParseGitHubRepo(*issueRepo)
+	if err != nil {
+		return err
+	}
+	var aiClient *genai.GeminiClient
+	if *useAI {
+		aiClient, err = genai.NewGeminiClient(ctx)
+		if err != nil {
+			return err
+		}
+	}
+	c.ic = issues.NewClient(ctx, &issues.Config{Owner: owner, Repo: repoName, Token: *githubToken})
+	c.gc = ghsa.NewClient(ctx, *githubToken)
+	c.pc = proxy.NewDefaultClient()
+	c.existingByFile = existingByFile
+	c.existingByIssue = existingByIssue
+	c.allowClosed = *closedOk
+	c.ac = aiClient
+	return nil
+}
+
+func (*create) close() error { return nil }
+
+func (cfg *create) run(ctx context.Context, issueNumber string) (err error) {
+	n, err := strconv.Atoi(issueNumber)
+	if err != nil {
+		return err
+	}
+	iss, err := cfg.ic.Issue(ctx, n)
 	if err != nil {
 		return err
 	}
 
-	r, err := createReport(ctx, cfg, iss)
+	r, err := createReport(ctx, iss, cfg.pc, cfg.gc, cfg.ac, cfg.allowClosed)
 	if err != nil {
 		return err
 	}
 
+	addTODOs(r)
+
 	filename, err := writeReport(r)
 	if err != nil {
 		return err
@@ -54,7 +109,7 @@
 
 	log.Out(filename)
 
-	xrefs := xref(filename, r, cfg.existingByFile)
+	xrefs := xrefInner(filename, r, cfg.existingByFile)
 	if len(xrefs) != 0 {
 		log.Infof("found cross-references:\n%s", xrefs)
 	}
@@ -62,116 +117,12 @@
 	return nil
 }
 
-func createExcluded(ctx context.Context, cfg *createCfg) (err error) {
-	defer derrors.Wrap(&err, "createExcluded()")
-	isses := []*issues.Issue{}
-	stateOption := "open"
-	if cfg.allowClosed {
-		stateOption = "all"
-	}
-	for _, er := range report.ExcludedReasons {
-		label := er.ToLabel()
-		tempIssues, err :=
-			cfg.issuesClient.Issues(ctx, issues.IssuesOptions{Labels: []string{label}, State: stateOption})
-		if err != nil {
-			return err
-		}
-		log.Infof("found %d issues with label %s\n", len(tempIssues), label)
-		isses = append(isses, tempIssues...)
+func (c *create) parseArgs(ctx context.Context, args []string) ([]string, error) {
+	if len(args) == 0 {
+		return nil, fmt.Errorf("no arguments provided")
 	}
 
-	var created []string
-	for _, iss := range isses {
-		// Don't create a report for an issue that already has a report.
-		if _, ok := cfg.existingByIssue[iss.Number]; ok {
-			log.Infof("skipped issue %d which already has a report\n", iss.Number)
-			continue
-		}
-
-		r, err := createReport(ctx, cfg, iss)
-		if err != nil {
-			log.Errf("skipped issue %d: %v\n", iss.Number, err)
-			continue
-		}
-
-		filename, err := writeReport(r)
-		if err != nil {
-			return err
-		}
-
-		created = append(created, filename)
-	}
-
-	skipped := len(isses) - len(created)
-	if skipped > 0 {
-		log.Infof("skipped %d issue(s)\n", skipped)
-	}
-
-	if len(created) == 0 {
-		log.Infof("no files to commit, exiting")
-		return nil
-	}
-
-	msg, err := excludedCommitMsg(created)
-	if err != nil {
-		return err
-	}
-	if err := gitAdd(created...); err != nil {
-		return err
-	}
-	return gitCommit(msg, created...)
-}
-
-type createCfg struct {
-	ghsaClient      *ghsa.Client
-	issuesClient    *issues.Client
-	proxyClient     *proxy.Client
-	existingByFile  map[string]*report.Report
-	existingByIssue map[int]*report.Report
-	allowClosed     bool
-	aiClient        *genai.GeminiClient
-}
-
-func setupCreate(ctx context.Context, args []string) ([]int, *createCfg, error) {
-	if *githubToken == "" {
-		return nil, nil, fmt.Errorf("githubToken must be provided")
-	}
-	localRepo, err := gitrepo.Open(ctx, ".")
-	if err != nil {
-		return nil, nil, err
-	}
-	existingByIssue, existingByFile, err := report.All(localRepo)
-	if err != nil {
-		return nil, nil, err
-	}
-	githubIDs, err := parseArgsToGithubIDs(args, existingByIssue)
-	if err != nil {
-		return nil, nil, err
-	}
-	owner, repoName, err := gitrepo.ParseGitHubRepo(*issueRepo)
-	if err != nil {
-		return nil, nil, err
-	}
-	var aiClient *genai.GeminiClient
-	if *useAI {
-		aiClient, err = genai.NewGeminiClient(ctx)
-		if err != nil {
-			return nil, nil, err
-		}
-	}
-	return githubIDs, &createCfg{
-		issuesClient:    issues.NewClient(ctx, &issues.Config{Owner: owner, Repo: repoName, Token: *githubToken}),
-		ghsaClient:      ghsa.NewClient(ctx, *githubToken),
-		proxyClient:     proxy.NewDefaultClient(),
-		existingByFile:  existingByFile,
-		existingByIssue: existingByIssue,
-		allowClosed:     *closedOk,
-		aiClient:        aiClient,
-	}, nil
-}
-
-func parseArgsToGithubIDs(args []string, existingByIssue map[int]*report.Report) ([]int, error) {
-	var githubIDs []int
+	var githubIDs []string
 	parseGithubID := func(s string) (int, error) {
 		id, err := strconv.Atoi(s)
 		if err != nil {
@@ -181,11 +132,11 @@
 	}
 	for _, arg := range args {
 		if !strings.Contains(arg, "-") {
-			id, err := parseGithubID(arg)
+			_, err := parseGithubID(arg)
 			if err != nil {
 				return nil, err
 			}
-			githubIDs = append(githubIDs, id)
+			githubIDs = append(githubIDs, arg)
 			continue
 		}
 		from, to, _ := strings.Cut(arg, "-")
@@ -201,25 +152,23 @@
 			return nil, fmt.Errorf("%v > %v", fromID, toID)
 		}
 		for id := fromID; id <= toID; id++ {
-			if existingByIssue[id] != nil {
+			if c.existingByIssue[id] != nil {
 				continue
 			}
-			githubIDs = append(githubIDs, id)
+			githubIDs = append(githubIDs, strconv.Itoa(id))
 		}
 	}
 	return githubIDs, nil
 }
 
-func createReport(ctx context.Context, cfg *createCfg, iss *issues.Issue) (r *report.Report, err error) {
-	defer derrors.Wrap(&err, "createReport(%d)", iss.Number)
-
-	parsed, err := parseGithubIssue(iss, cfg.proxyClient, cfg.allowClosed)
+func createReport(ctx context.Context, iss *issues.Issue, pc *proxy.Client, gc *ghsa.Client, ac *genai.GeminiClient, allowClosed bool) (r *report.Report, err error) {
+	parsed, err := parseGithubIssue(iss, pc, allowClosed)
 	if err != nil {
 		return nil, err
 	}
 
 	r, err = reportFromAliases(ctx, parsed.id, parsed.modulePath, parsed.aliases,
-		cfg.proxyClient, cfg.ghsaClient, cfg.aiClient)
+		pc, gc, ac)
 	if err != nil {
 		return nil, err
 	}
@@ -237,8 +186,6 @@
 			GHSAs:    r.GHSAs,
 		}
 	}
-
-	addTODOs(r)
 	return r, nil
 }
 
@@ -269,7 +216,7 @@
 	addMissingAliases(ctx, r, gc)
 
 	if ac != nil {
-		suggestions, err := suggest(ctx, ac, r, 1)
+		suggestions, err := suggestions(ctx, ac, r, 1)
 		if err != nil {
 			log.Warnf("failed to get AI-generated suggestions for %s: %v\n", r.ID, err)
 		} else if len(suggestions) == 0 {
@@ -338,29 +285,6 @@
 	excluded   report.ExcludedReason
 }
 
-func excludedCommitMsg(fs []string) (string, error) {
-	var issNums []string
-	for _, f := range fs {
-		_, _, iss, err := report.ParseFilepath(f)
-		if err != nil {
-			return "", err
-		}
-		issNums = append(issNums, fmt.Sprintf("Fixes golang/vulndb#%d", iss))
-	}
-
-	return fmt.Sprintf(
-		`%s: batch add %d excluded reports
-
-Adds excluded reports:
-	- %s
-
-%s`,
-		report.ExcludedDir,
-		len(fs),
-		strings.Join(fs, "\n\t- "),
-		strings.Join(issNums, "\n")), nil
-}
-
 // reportFromBestAlias returns a new report created from the "best" alias in the list.
 // For now, it prefers the first GHSA in the list, followed by the first CVE in the list
 // (if no GHSA is present). If no GHSAs or CVEs are present, it returns a new empty Report.
diff --git a/cmd/vulnreport/create_excluded.go b/cmd/vulnreport/create_excluded.go
new file mode 100644
index 0000000..fcbfb06
--- /dev/null
+++ b/cmd/vulnreport/create_excluded.go
@@ -0,0 +1,180 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"golang.org/x/vulndb/cmd/vulnreport/log"
+	"golang.org/x/vulndb/internal/genai"
+	"golang.org/x/vulndb/internal/ghsa"
+	"golang.org/x/vulndb/internal/gitrepo"
+	"golang.org/x/vulndb/internal/issues"
+	"golang.org/x/vulndb/internal/proxy"
+	"golang.org/x/vulndb/internal/report"
+)
+
+var dry = flag.Bool("dry", false, "for create-excluded, do not commit files")
+
+type createExcluded struct {
+	gc              *ghsa.Client
+	ic              *issues.Client
+	pc              *proxy.Client
+	ac              *genai.GeminiClient
+	existingByIssue map[int]*report.Report
+	allowClosed     bool
+
+	isses   map[string]*issues.Issue
+	created []string
+}
+
+func (createExcluded) name() string { return "create-excluded" }
+
+func (createExcluded) usage() (string, string) {
+	const desc = "creates and commits reports for Github issues marked excluded"
+	return "", desc
+}
+
+func (c *createExcluded) close() error {
+	skipped := len(c.isses) - len(c.created)
+	if skipped > 0 {
+		log.Infof("skipped %d issue(s)\n", skipped)
+	}
+
+	if len(c.created) == 0 {
+		log.Infof("no files to commit, exiting")
+		return nil
+	}
+
+	msg, err := excludedCommitMsg(c.created)
+	if err != nil {
+		return err
+	}
+
+	if *dry {
+		log.Outf("create-excluded would commit files:\n\n\t%s\n\nwith message:\n\n%s", strings.Join(c.created, "\n\t"), msg)
+		return nil
+	}
+
+	if err := gitAdd(c.created...); err != nil {
+		return err
+	}
+	return gitCommit(msg, c.created...)
+}
+
+func (c *createExcluded) setup(ctx context.Context) error {
+	if *githubToken == "" {
+		return fmt.Errorf("githubToken must be provided")
+	}
+	localRepo, err := gitrepo.Open(ctx, ".")
+	if err != nil {
+		return err
+	}
+	existingByIssue, _, err := report.All(localRepo)
+	if err != nil {
+		return err
+	}
+	owner, repoName, err := gitrepo.ParseGitHubRepo(*issueRepo)
+	if err != nil {
+		return err
+	}
+	var aiClient *genai.GeminiClient
+	if *useAI {
+		aiClient, err = genai.NewGeminiClient(ctx)
+		if err != nil {
+			return err
+		}
+	}
+
+	c.ic = issues.NewClient(ctx, &issues.Config{Owner: owner, Repo: repoName, Token: *githubToken})
+	c.gc = ghsa.NewClient(ctx, *githubToken)
+	c.pc = proxy.NewDefaultClient()
+	c.existingByIssue = existingByIssue
+	c.allowClosed = *closedOk
+	c.ac = aiClient
+	c.isses = make(map[string]*issues.Issue)
+
+	return nil
+}
+
+func (c *createExcluded) parseArgs(ctx context.Context, args []string) (issNums []string, err error) {
+	if len(args) > 0 {
+		return nil, fmt.Errorf("expected no arguments")
+	}
+
+	stateOption := "open"
+	if c.allowClosed {
+		stateOption = "all"
+	}
+
+	for _, er := range report.ExcludedReasons {
+		label := er.ToLabel()
+		is, err := c.ic.Issues(ctx, issues.IssuesOptions{Labels: []string{label}, State: stateOption})
+		if err != nil {
+			return nil, err
+		}
+		log.Infof("found %d issues with label %s\n", len(is), label)
+
+		for _, iss := range is {
+			if _, ok := c.existingByIssue[iss.Number]; ok {
+				log.Infof("skipping issue %d which already has a report\n", iss.Number)
+				continue
+			}
+
+			n := strconv.Itoa(iss.Number)
+			c.isses[n] = iss
+			issNums = append(issNums, n)
+		}
+	}
+
+	return issNums, nil
+}
+
+func (c *createExcluded) run(ctx context.Context, issNum string) (err error) {
+	iss, ok := c.isses[issNum]
+	if !ok {
+		return fmt.Errorf("BUG: could not find issue %s (this should have been populated in parseArgs)", issNum)
+	}
+
+	r, err := createReport(ctx, iss, c.pc, c.gc, c.ac, c.allowClosed)
+	if err != nil {
+		return err
+	}
+
+	filename, err := writeReport(r)
+	if err != nil {
+		return err
+	}
+
+	c.created = append(c.created, filename)
+	return nil
+}
+
+func excludedCommitMsg(fs []string) (string, error) {
+	var issNums []string
+	for _, f := range fs {
+		_, _, iss, err := report.ParseFilepath(f)
+		if err != nil {
+			return "", err
+		}
+		issNums = append(issNums, fmt.Sprintf("Fixes golang/vulndb#%d", iss))
+	}
+
+	return fmt.Sprintf(
+		`%s: batch add %d excluded reports
+
+Adds excluded reports:
+	- %s
+
+%s`,
+		report.ExcludedDir,
+		len(fs),
+		strings.Join(fs, "\n\t- "),
+		strings.Join(issNums, "\n")), nil
+}
diff --git a/cmd/vulnreport/cve.go b/cmd/vulnreport/cve.go
index ba4cbb2..91f82a8 100644
--- a/cmd/vulnreport/cve.go
+++ b/cmd/vulnreport/cve.go
@@ -9,12 +9,25 @@
 
 	"golang.org/x/vulndb/cmd/vulnreport/log"
 	"golang.org/x/vulndb/internal/database"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/report"
 )
 
-func cveCmd(_ context.Context, filename string) (err error) {
-	defer derrors.Wrap(&err, "cve(%q)", filename)
+type cveCmd struct{ filenameParser }
+
+func (cveCmd) name() string { return "cve" }
+
+func (cveCmd) usage() (string, string) {
+	const desc = "creates and saves CVE 5.0 record from the provided YAML reports"
+	return filenameArgs, desc
+}
+
+func (c *cveCmd) setup(ctx context.Context) error {
+	return nil
+}
+
+func (c *cveCmd) close() error { return nil }
+
+func (c *cveCmd) run(ctx context.Context, filename string) (err error) {
 	r, err := report.Read(filename)
 	if err != nil {
 		return err
diff --git a/cmd/vulnreport/fix.go b/cmd/vulnreport/fix.go
index 5f5ce9d..f6d8420 100644
--- a/cmd/vulnreport/fix.go
+++ b/cmd/vulnreport/fix.go
@@ -15,7 +15,6 @@
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/exp/slices"
 	"golang.org/x/vulndb/cmd/vulnreport/log"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/ghsa"
 	"golang.org/x/vulndb/internal/osvutils"
 	"golang.org/x/vulndb/internal/proxy"
@@ -29,14 +28,38 @@
 	skipSymbols = flag.Bool("skip-symbols", false, "for lint and fix, don't load package for symbols checks")
 )
 
-func fix(ctx context.Context, filename string, ghsaClient *ghsa.Client, pc *proxy.Client, force bool) (err error) {
-	defer derrors.Wrap(&err, "fix(%q)", filename)
-	log.Infof("fix %s\n", filename)
+type fix struct {
+	pc *proxy.Client
+	gc *ghsa.Client
 
+	filenameParser
+}
+
+func (fix) name() string { return "fix" }
+
+func (fix) usage() (string, string) {
+	const desc = "fix a YAML report"
+	return filenameArgs, desc
+}
+
+func (f *fix) setup(ctx context.Context) error {
+	f.pc = proxy.NewDefaultClient()
+	f.gc = ghsa.NewClient(ctx, *githubToken)
+	return nil
+}
+
+func (*fix) close() error { return nil }
+
+func (f *fix) run(ctx context.Context, filename string) (err error) {
 	r, err := report.Read(filename)
 	if err != nil {
 		return err
 	}
+
+	return fixReport(ctx, r, filename, f.pc, f.gc)
+}
+
+func fixReport(ctx context.Context, r *report.Report, filename string, pc *proxy.Client, gc *ghsa.Client) error {
 	if err := r.CheckFilename(filename); err != nil {
 		return err
 	}
@@ -49,7 +72,7 @@
 		}
 	}()
 
-	if lints := r.Lint(pc); force || len(lints) > 0 {
+	if lints := r.Lint(pc); *force || len(lints) > 0 {
 		r.Fix(pc)
 	}
 	if lints := r.Lint(pc); len(lints) > 0 {
@@ -64,7 +87,7 @@
 	}
 	if !*skipAlias {
 		log.Infof("%s: checking for missing GHSAs and CVEs (use -skip-alias to skip this)", r.ID)
-		if added := addMissingAliases(ctx, r, ghsaClient); added > 0 {
+		if added := addMissingAliases(ctx, r, gc); added > 0 {
 			log.Infof("%s: added %d missing aliases", r.ID, added)
 		}
 	}
diff --git a/cmd/vulnreport/lint.go b/cmd/vulnreport/lint.go
index cd203e3..0b319b1 100644
--- a/cmd/vulnreport/lint.go
+++ b/cmd/vulnreport/lint.go
@@ -7,16 +7,31 @@
 import (
 	"context"
 
-	"golang.org/x/vulndb/cmd/vulnreport/log"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/proxy"
 	"golang.org/x/vulndb/internal/report"
 )
 
-func lint(_ context.Context, filename string, pc *proxy.Client) (err error) {
-	defer derrors.Wrap(&err, "lint(%q)", filename)
-	log.Infof("lint %s\n", filename)
+type lint struct {
+	pc *proxy.Client
 
-	_, err = report.ReadAndLint(filename, pc)
+	filenameParser
+}
+
+func (lint) name() string { return "lint" }
+
+func (lint) usage() (string, string) {
+	const desc = "lints vulnerability YAML reports"
+	return filenameArgs, desc
+}
+
+func (l *lint) setup(ctx context.Context) error {
+	l.pc = proxy.NewDefaultClient()
+	return nil
+}
+
+func (l *lint) close() error { return nil }
+
+func (l *lint) run(ctx context.Context, filename string) (err error) {
+	_, err = report.ReadAndLint(filename, l.pc)
 	return err
 }
diff --git a/cmd/vulnreport/main.go b/cmd/vulnreport/main.go
index e8c5b50..f79d88a 100644
--- a/cmd/vulnreport/main.go
+++ b/cmd/vulnreport/main.go
@@ -12,15 +12,10 @@
 	"fmt"
 	"log"
 	"os"
-	"path/filepath"
 	"runtime/pprof"
+	"text/tabwriter"
 
 	vlog "golang.org/x/vulndb/cmd/vulnreport/log"
-	"golang.org/x/vulndb/internal/genai"
-	"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 (
@@ -31,25 +26,40 @@
 
 func init() {
 	vlog.Init(*quiet)
+	out := flag.CommandLine.Output()
+	flag.Usage = func() {
+		fmt.Fprintf(out, "usage: vulnreport [flags] [cmd] [args]\n\n")
+		tw := tabwriter.NewWriter(out, 2, 4, 2, ' ', 0)
+		for _, command := range commands {
+			argUsage, desc := command.usage()
+			fmt.Fprintf(tw, "  %s\t%s\t%s\n", command.name(), argUsage, desc)
+		}
+		tw.Flush()
+		fmt.Fprint(out, "\nsupported flags:\n\n")
+		flag.PrintDefaults()
+	}
+}
+
+// The subcommands supported by vulnreport.
+// To add a new command, implement the command interface and
+// add the command to this list.
+var commands = map[string]command{
+	"create":          &create{},
+	"create-excluded": &createExcluded{},
+	"commit":          &commit{},
+	"cve":             &cveCmd{},
+	"fix":             &fix{},
+	"lint":            &lint{},
+	"set-dates":       &setDates{},
+	"suggest":         &suggest{},
+	"symbols":         &symbolsCmd{},
+	"osv":             &osvCmd{},
+	"unexclude":       &unexclude{},
+	"xref":            &xref{},
 }
 
 func main() {
 	ctx := context.Background()
-	flag.Usage = func() {
-		fmt.Fprintf(flag.CommandLine.Output(), "usage: vulnreport [cmd] [filename.yaml]\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  create [githubIssueNumber]: creates a new vulnerability YAML report\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  create-excluded: creates and commits all open github issues marked as excluded\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  symbols filename.yaml: finds and populates possible vulnerable symbols for a given report\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  lint filename.yaml ...: lints vulnerability YAML reports\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  cve filename.yaml ...: creates and saves CVE 5.0 record from the provided YAML reports\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  fix filename.yaml ...: fixes and reformats YAML reports\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  osv filename.yaml ...: converts YAML reports to OSV JSON and writes to data/osv\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  set-dates filename.yaml ...: sets PublishDate of YAML reports\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  suggest filename.yaml ...: (EXPERIMENTAL) use AI to suggest summary and description for YAML reports\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  commit filename.yaml ...: creates new commits for YAML reports\n")
-		fmt.Fprintf(flag.CommandLine.Output(), "  xref filename.yaml ...: prints cross references for YAML reports\n")
-		flag.PrintDefaults()
-	}
 
 	flag.Parse()
 	if flag.NArg() < 1 {
@@ -61,18 +71,6 @@
 		*githubToken = os.Getenv("VULN_GITHUB_ACCESS_TOKEN")
 	}
 
-	var (
-		args []string
-		cmd  = flag.Arg(0)
-	)
-	if cmd != "create-excluded" {
-		if flag.NArg() < 2 {
-			flag.Usage()
-			log.Fatal("not enough arguments")
-		}
-		args = flag.Args()[1:]
-	}
-
 	// Start CPU profiler.
 	if *cpuprofile != "" {
 		f, err := os.Create(*cpuprofile)
@@ -83,117 +81,16 @@
 		defer pprof.StopCPUProfile()
 	}
 
-	// setupCreate clones the CVEList repo and can be very slow,
-	// so commands that require this functionality are separated from other
-	// commands.
-	if cmd == "create-excluded" || cmd == "create" {
-		githubIDs, cfg, err := setupCreate(ctx, args)
-		if err != nil {
-			log.Fatal(err)
-		}
-		switch cmd {
-		case "create-excluded":
-			if err = createExcluded(ctx, cfg); err != nil {
-				log.Fatal(err)
-			}
-		case "create":
-			// Unlike commands below, create operates on github issue IDs
-			// instead of filenames.
-			for _, githubID := range githubIDs {
-				if err := create(ctx, githubID, cfg); err != nil {
-					vlog.Err(err)
-				}
-			}
-		}
-		return
-	}
+	cmdName := flag.Arg(0)
+	args := flag.Args()[1:]
 
-	ghsaClient := ghsa.NewClient(ctx, *githubToken)
-	pc := proxy.NewDefaultClient()
-	var cmdFunc func(context.Context, string) error
-	switch cmd {
-	case "lint":
-		cmdFunc = func(ctx context.Context, name string) error { return lint(ctx, name, pc) }
-	case "suggest":
-		cmdFunc = func(ctx context.Context, name string) error { return suggestCmd(ctx, name) }
-	case "commit":
-		cmdFunc = func(ctx context.Context, name string) error { return commit(ctx, name, ghsaClient, pc, *force) }
-	case "cve":
-		cmdFunc = func(ctx context.Context, name string) error { return cveCmd(ctx, name) }
-	case "fix":
-		cmdFunc = func(ctx context.Context, name string) error { return fix(ctx, name, ghsaClient, pc, *force) }
-	case "symbols":
-		cmdFunc = func(ctx context.Context, name string) error { return findSymbols(ctx, name) }
-	case "osv":
-		cmdFunc = func(ctx context.Context, name string) error { return osvCmd(ctx, name, pc) }
-	case "set-dates":
-		repo, err := gitrepo.Open(ctx, ".")
-		if err != nil {
-			log.Fatal(err)
-		}
-		commitDates, err := gitrepo.AllCommitDates(repo, gitrepo.MainReference, report.YAMLDir)
-		if err != nil {
-			log.Fatal(err)
-		}
-		cmdFunc = func(ctx context.Context, name string) error { return setDates(ctx, name, commitDates) }
-	case "unexclude":
-		var ac *genai.GeminiClient
-		var err error
-		if *useAI {
-			ac, err = genai.NewGeminiClient(ctx)
-			if err != nil {
-				log.Fatal(err)
-			}
-			defer ac.Close()
-		}
-		cmdFunc = func(ctx context.Context, name string) error { return unexclude(ctx, name, ghsaClient, pc, ac) }
-	case "xref":
-		repo, err := gitrepo.Open(ctx, ".")
-		if err != nil {
-			log.Fatal(err)
-		}
-		_, existingByFile, err := report.All(repo)
-		if err != nil {
-			log.Fatal(err)
-		}
-		cmdFunc = func(ctx context.Context, name string) error {
-			r, err := report.Read(name)
-			if err != nil {
-				return err
-			}
-			vlog.Out(name)
-			vlog.Out(xref(name, r, existingByFile))
-			return nil
-		}
-	default:
+	cmd, ok := commands[cmdName]
+	if !ok {
 		flag.Usage()
-		log.Fatalf("unsupported command: %q", cmd)
+		log.Fatalf("unsupported command: %q", cmdName)
 	}
 
-	// Run the command on each argument.
-	for _, arg := range args {
-		arg, err := argToFilename(arg)
-		if err != nil {
-			vlog.Err(err)
-			continue
-		}
-		if err := cmdFunc(ctx, arg); err != nil {
-			vlog.Err(err)
-		}
+	if err := run(ctx, cmd, args); err != nil {
+		log.Fatalf("%s: %s", cmdName, err)
 	}
 }
-
-func argToFilename(arg string) (string, error) {
-	if _, err := os.Stat(arg); err != nil {
-		// If arg isn't a file, see if it might be an issue ID
-		// with an existing report.
-		for _, padding := range []string{"", "0", "00", "000"} {
-			m, _ := filepath.Glob("data/*/GO-*-" + padding + arg + ".yaml")
-			if len(m) == 1 {
-				return m[0], nil
-			}
-		}
-		return "", fmt.Errorf("%s is not a valid filename or issue ID with existing report: %w", arg, err)
-	}
-	return arg, nil
-}
diff --git a/cmd/vulnreport/osv.go b/cmd/vulnreport/osv.go
index 091df84..fb65616 100644
--- a/cmd/vulnreport/osv.go
+++ b/cmd/vulnreport/osv.go
@@ -10,15 +10,32 @@
 
 	"golang.org/x/vulndb/cmd/vulnreport/log"
 	"golang.org/x/vulndb/internal/database"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/proxy"
 	"golang.org/x/vulndb/internal/report"
 )
 
-func osvCmd(_ context.Context, filename string, pc *proxy.Client) (err error) {
-	defer derrors.Wrap(&err, "osv(%q)", filename)
+type osvCmd struct {
+	pc *proxy.Client
 
-	r, err := report.ReadAndLint(filename, pc)
+	filenameParser
+}
+
+func (osvCmd) name() string { return "osv" }
+
+func (osvCmd) usage() (string, string) {
+	const desc = "converts YAML reports to OSV JSON and writes to data/osv"
+	return filenameArgs, desc
+}
+
+func (o *osvCmd) setup(ctx context.Context) error {
+	o.pc = proxy.NewDefaultClient()
+	return nil
+}
+
+func (o *osvCmd) close() error { return nil }
+
+func (o *osvCmd) run(ctx context.Context, filename string) (err error) {
+	r, err := report.ReadAndLint(filename, o.pc)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/vulnreport/set_dates.go b/cmd/vulnreport/set_dates.go
index f9dc3e1..b7e3e6b 100644
--- a/cmd/vulnreport/set_dates.go
+++ b/cmd/vulnreport/set_dates.go
@@ -8,11 +8,38 @@
 	"context"
 	"fmt"
 
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/gitrepo"
 	"golang.org/x/vulndb/internal/report"
 )
 
+type setDates struct {
+	dates map[string]gitrepo.Dates
+
+	filenameParser
+}
+
+func (setDates) name() string { return "set-dates" }
+
+func (setDates) usage() (string, string) {
+	const desc = "sets PublishDate of YAML reports"
+	return filenameArgs, desc
+}
+
+func (sd *setDates) setup(ctx context.Context) error {
+	repo, err := gitrepo.Open(ctx, ".")
+	if err != nil {
+		return err
+	}
+	dates, err := gitrepo.AllCommitDates(repo, gitrepo.MainReference, report.YAMLDir)
+	if err != nil {
+		return err
+	}
+	sd.dates = dates
+	return nil
+}
+
+func (sd *setDates) close() error { return nil }
+
 // setDates sets the PublishedDate of the report at filename to the oldest
 // commit date in the repo that contains that file. (It may someday also set a
 // last-modified date, hence the plural.) Since it looks at the commits from
@@ -29,9 +56,7 @@
 // date can. Always using the git history as the source of truth for the
 // last-modified date avoids confusion if the report YAML and the git history
 // disagree.
-func setDates(_ context.Context, filename string, dates map[string]gitrepo.Dates) (err error) {
-	defer derrors.Wrap(&err, "setDates(%q)", filename)
-
+func (sd *setDates) run(ctx context.Context, filename string) (err error) {
 	r, err := report.Read(filename)
 	if err != nil {
 		return err
@@ -39,7 +64,7 @@
 	if !r.Published.IsZero() {
 		return nil
 	}
-	d, ok := dates[filename]
+	d, ok := sd.dates[filename]
 	if !ok {
 		return fmt.Errorf("can't find git repo commit dates for %q", filename)
 	}
diff --git a/cmd/vulnreport/suggest.go b/cmd/vulnreport/suggest.go
index f32052f..f063068 100644
--- a/cmd/vulnreport/suggest.go
+++ b/cmd/vulnreport/suggest.go
@@ -10,7 +10,6 @@
 	"fmt"
 
 	"golang.org/x/vulndb/cmd/vulnreport/log"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/genai"
 	"golang.org/x/vulndb/internal/report"
 )
@@ -20,21 +19,43 @@
 	numSuggestions = flag.Int("n", 1, "for suggest, the number of suggestions to generate (>1 can be slow)")
 )
 
-func suggestCmd(ctx context.Context, filename string) (err error) {
-	defer derrors.Wrap(&err, "suggest(%q)", filename)
+type suggest struct {
+	ac *genai.GeminiClient
 
+	filenameParser
+}
+
+func (suggest) name() string { return "suggest" }
+
+func (suggest) usage() (string, string) {
+	const desc = "(EXPERIMENTAL) use AI to suggest summary and description for YAML reports"
+	return filenameArgs, desc
+}
+
+func (s *suggest) setup(ctx context.Context) error {
+	ac, err := genai.NewGeminiClient(ctx)
+	if err != nil {
+		return err
+	}
+	s.ac = ac
+	return nil
+}
+
+func (s *suggest) close() error {
+	if s.ac == nil {
+		return nil
+	}
+	return s.ac.Close()
+}
+
+func (s *suggest) run(ctx context.Context, filename string) (err error) {
 	r, err := report.Read(filename)
 	if err != nil {
 		return err
 	}
 
 	log.Info("contacting the Gemini API...")
-	c, err := genai.NewGeminiClient(ctx)
-	if err != nil {
-		return err
-	}
-
-	suggestions, err := suggest(ctx, c, r, *numSuggestions)
+	suggestions, err := suggestions(ctx, s.ac, r, *numSuggestions)
 	if err != nil {
 		return err
 	}
@@ -79,7 +100,7 @@
 	return nil
 }
 
-func suggest(ctx context.Context, c genai.Client, r *report.Report, max int) (suggestions []*genai.Suggestion, err error) {
+func suggestions(ctx context.Context, c genai.Client, r *report.Report, max int) (suggestions []*genai.Suggestion, err error) {
 	attempts := 0
 	maxAttempts := max + 2
 	for len(suggestions) < max && attempts < maxAttempts {
diff --git a/cmd/vulnreport/symbols.go b/cmd/vulnreport/symbols.go
index 59c85b1..852d397 100644
--- a/cmd/vulnreport/symbols.go
+++ b/cmd/vulnreport/symbols.go
@@ -11,16 +11,25 @@
 	"strings"
 
 	"golang.org/x/vulndb/cmd/vulnreport/log"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/osv"
 	"golang.org/x/vulndb/internal/report"
 	"golang.org/x/vulndb/internal/symbols"
 )
 
-func findSymbols(_ context.Context, filename string) (err error) {
-	defer derrors.Wrap(&err, "findSymbols(%q)", filename)
-	log.Infof("symbols %s\n", filename)
+type symbolsCmd struct{ filenameParser }
 
+func (symbolsCmd) name() string { return "symbols" }
+
+func (symbolsCmd) usage() (string, string) {
+	const desc = "finds and populates possible vulnerable symbols for a given report"
+	return filenameArgs, desc
+}
+
+func (s *symbolsCmd) setup(ctx context.Context) error { return nil }
+
+func (s *symbolsCmd) close() error { return nil }
+
+func (s *symbolsCmd) run(ctx context.Context, filename string) (err error) {
 	r, err := report.Read(filename)
 	if err != nil {
 		return err
diff --git a/cmd/vulnreport/unexclude.go b/cmd/vulnreport/unexclude.go
index 9a4aecb..974b1d4 100644
--- a/cmd/vulnreport/unexclude.go
+++ b/cmd/vulnreport/unexclude.go
@@ -9,19 +9,51 @@
 	"os"
 
 	"golang.org/x/vulndb/cmd/vulnreport/log"
-	"golang.org/x/vulndb/internal/derrors"
 	"golang.org/x/vulndb/internal/genai"
 	"golang.org/x/vulndb/internal/ghsa"
 	"golang.org/x/vulndb/internal/proxy"
 	"golang.org/x/vulndb/internal/report"
 )
 
+type unexclude struct {
+	gc *ghsa.Client
+	pc *proxy.Client
+	ac *genai.GeminiClient
+
+	filenameParser
+}
+
+func (unexclude) name() string { return "unexclude" }
+
+func (unexclude) usage() (string, string) {
+	const desc = "converts excluded YAML reports to regular YAML reports"
+	return filenameArgs, desc
+}
+
+func (u *unexclude) setup(ctx context.Context) error {
+	u.gc = ghsa.NewClient(ctx, *githubToken)
+	u.pc = proxy.NewDefaultClient()
+
+	if *useAI {
+		ac, err := genai.NewGeminiClient(ctx)
+		if err != nil {
+			return err
+		}
+		u.ac = ac
+	}
+
+	return nil
+}
+
+func (u *unexclude) close() error {
+	if u.ac != nil {
+		return u.ac.Close()
+	}
+	return nil
+}
+
 // unexclude converts an excluded report into a regular report.
-func unexclude(ctx context.Context, filename string, gc *ghsa.Client, pc *proxy.Client, ac *genai.GeminiClient) (err error) {
-	defer derrors.Wrap(&err, "unexclude(%s)", filename)
-
-	log.Infof("unexclude %s", filename)
-
+func (u *unexclude) run(ctx context.Context, filename string) (err error) {
 	r, err := report.Read(filename)
 	if err != nil {
 		return err
@@ -49,7 +81,7 @@
 	if len(r.Modules) > 0 {
 		modulePath = r.Modules[0].Module
 	}
-	newR, err := reportFromAliases(ctx, id, modulePath, aliases, pc, gc, ac)
+	newR, err := reportFromAliases(ctx, id, modulePath, aliases, u.pc, u.gc, u.ac)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/vulnreport/xref.go b/cmd/vulnreport/xref.go
index 60c0cd8..a86f70e 100644
--- a/cmd/vulnreport/xref.go
+++ b/cmd/vulnreport/xref.go
@@ -5,21 +5,62 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"golang.org/x/exp/constraints"
 	"golang.org/x/exp/maps"
 	"golang.org/x/exp/slices"
+	vlog "golang.org/x/vulndb/cmd/vulnreport/log"
+	"golang.org/x/vulndb/internal/gitrepo"
 	"golang.org/x/vulndb/internal/report"
 )
 
-// xref returns cross-references for a report: Information about other reports
+type xref struct {
+	existingByFile map[string]*report.Report
+
+	filenameParser
+}
+
+func (xref) name() string { return "xref" }
+
+func (xref) usage() (string, string) {
+	const desc = "prints cross references for YAML reports"
+	return filenameArgs, desc
+}
+
+func (x *xref) setup(ctx context.Context) error {
+	repo, err := gitrepo.Open(ctx, ".")
+	if err != nil {
+		return err
+	}
+	_, existingByFile, err := report.All(repo)
+	if err != nil {
+		return err
+	}
+	x.existingByFile = existingByFile
+	return nil
+}
+
+func (x *xref) close() error { return nil }
+
+// run returns cross-references for a report: Information about other reports
 // for the same CVE, GHSA, or module.
-func xref(rname string, r *report.Report, existingByFile map[string]*report.Report) string {
+func (x *xref) run(ctx context.Context, filename string) (err error) {
+	r, err := report.Read(filename)
+	if err != nil {
+		return err
+	}
+	vlog.Out(filename)
+	vlog.Out(xrefInner(filename, r, x.existingByFile))
+	return nil
+}
+
+func xrefInner(filename string, r *report.Report, existingByFile map[string]*report.Report) string {
 	out := &strings.Builder{}
 	matches := report.XRef(r, existingByFile)
-	delete(matches, rname)
+	delete(matches, filename)
 	// This sorts as CVEs, GHSAs, and then modules.
 	for _, fname := range sorted(maps.Keys(matches)) {
 		for _, id := range sorted(matches[fname]) {