gopls/internal/lsp/cmd: list bug reports in 'gopls bug'

This change causes gopls bug to print cached bug reports to stdout,
and to summarize them (by file:line) in the GitHub Issue form.
We don't post the bug contents, but we invite the user to copy
them.

Also, move the undocumented bug injection (for testing) from
'gopls stats' to 'gopls bug'.

Updates golang/go#60969

Change-Id: I19648e978dca3b74954d17ac449e22537c5f1e02
Reviewed-on: https://go-review.googlesource.com/c/tools/+/505579
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Run-TryBot: Alan Donovan <adonovan@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/gopls/internal/lsp/cmd/info.go b/gopls/internal/lsp/cmd/info.go
index 68ef40f..d63e24b 100644
--- a/gopls/internal/lsp/cmd/info.go
+++ b/gopls/internal/lsp/cmd/info.go
@@ -12,10 +12,13 @@
 	"fmt"
 	"net/url"
 	"os"
+	"sort"
 	"strings"
 
+	goplsbug "golang.org/x/tools/gopls/internal/bug"
 	"golang.org/x/tools/gopls/internal/lsp/browser"
 	"golang.org/x/tools/gopls/internal/lsp/debug"
+	"golang.org/x/tools/gopls/internal/lsp/filecache"
 	"golang.org/x/tools/gopls/internal/lsp/source"
 	"golang.org/x/tools/internal/tool"
 )
@@ -128,10 +131,50 @@
 // Run collects some basic information and then prepares an issue ready to
 // be reported.
 func (b *bug) Run(ctx context.Context, args ...string) error {
-	buf := &bytes.Buffer{}
-	fmt.Fprint(buf, goplsBugHeader)
-	debug.PrintVersionInfo(ctx, buf, true, debug.Markdown)
-	body := buf.String()
+	// This undocumented environment variable allows
+	// the cmd integration test (and maintainers) to
+	// trigger a call to bug.Report.
+	if msg := os.Getenv("TEST_GOPLS_BUG"); msg != "" {
+		filecache.Start() // register bug handler
+		goplsbug.Report(msg)
+		return nil
+	}
+
+	// Enumerate bug reports, grouped and sorted.
+	_, reports := filecache.BugReports()
+	sort.Slice(reports, func(i, j int) bool {
+		x, y := reports[i], reports[i]
+		if x.Key != y.Key {
+			return x.Key < y.Key // ascending key order
+		}
+		return y.AtTime.Before(x.AtTime) // most recent first
+	})
+	keyDenom := make(map[string]int) // key is "file:line"
+	for _, report := range reports {
+		keyDenom[report.Key]++
+	}
+
+	// Privacy: the content of 'public' will be posted to GitHub
+	// to populate an issue textarea. Even though the user must
+	// submit the form to share the information with the world,
+	// merely populating the form causes us to share the
+	// information with GitHub itself.
+	//
+	// For that reason, we cannot write private information to
+	// public, such as bug reports, which may quote source code.
+	public := &bytes.Buffer{}
+	fmt.Fprint(public, goplsBugHeader)
+	if len(reports) > 0 {
+		fmt.Fprintf(public, "#### Internal errors\n\n")
+		fmt.Fprintf(public, "Gopls detected %d internal errors, %d distinct:\n",
+			len(reports), len(keyDenom))
+		for key, denom := range keyDenom {
+			fmt.Fprintf(public, "- %s (%d)\n", key, denom)
+		}
+		fmt.Fprintf(public, "\nPlease copy the full information printed by `gopls bug` here, if you are comfortable sharing it.\n\n")
+	}
+	debug.PrintVersionInfo(ctx, public, true, debug.Markdown)
+	body := public.String()
 	title := strings.Join(args, " ")
 	if !strings.HasPrefix(title, goplsBugPrefix) {
 		title = goplsBugPrefix + title
@@ -140,6 +183,29 @@
 		fmt.Print("Please file a new issue at golang.org/issue/new using this template:\n\n")
 		fmt.Print(body)
 	}
+
+	// Print bug reports to stdout (not GitHub).
+	keyNum := make(map[string]int)
+	for _, report := range reports {
+		fmt.Printf("-- %v -- \n", report.AtTime)
+
+		// Append seq number (e.g. " (1/2)") for repeated keys.
+		var seq string
+		if denom := keyDenom[report.Key]; denom > 1 {
+			keyNum[report.Key]++
+			seq = fmt.Sprintf(" (%d/%d)", keyNum[report.Key], denom)
+		}
+
+		// Privacy:
+		// - File and Stack may contain the name of the user that built gopls.
+		// - Description may contain names of the user's packages/files/symbols.
+		fmt.Printf("%s:%d: %s%s\n\n", report.File, report.Line, report.Description, seq)
+		fmt.Printf("%s\n\n", report.Stack)
+	}
+	if len(reports) > 0 {
+		fmt.Printf("Please copy the above information into the GitHub issue, if you are comfortable sharing it.\n")
+	}
+
 	return nil
 }
 
diff --git a/gopls/internal/lsp/cmd/stats.go b/gopls/internal/lsp/cmd/stats.go
index a681c5c..4986107 100644
--- a/gopls/internal/lsp/cmd/stats.go
+++ b/gopls/internal/lsp/cmd/stats.go
@@ -57,14 +57,6 @@
 }
 
 func (s *stats) Run(ctx context.Context, args ...string) error {
-	// This undocumented environment variable allows
-	// the cmd integration test to trigger a call to bug.Report.
-	if msg := os.Getenv("TEST_GOPLS_BUG"); msg != "" {
-		filecache.Start() // effect: register bug handler
-		goplsbug.Report(msg)
-		return nil
-	}
-
 	if s.app.Remote != "" {
 		// stats does not work with -remote.
 		// Other sessions on the daemon may interfere with results.
diff --git a/gopls/internal/lsp/cmd/test/integration_test.go b/gopls/internal/lsp/cmd/test/integration_test.go
index 52ecb23..91c214c 100644
--- a/gopls/internal/lsp/cmd/test/integration_test.go
+++ b/gopls/internal/lsp/cmd/test/integration_test.go
@@ -702,7 +702,7 @@
 	oops := fmt.Sprintf("oops-%d", rand.Int())
 	{
 		env := []string{"TEST_GOPLS_BUG=" + oops}
-		res := goplsWithEnv(t, tree, env, "stats")
+		res := goplsWithEnv(t, tree, env, "bug")
 		res.checkExit(true)
 	}
 
@@ -745,8 +745,8 @@
 	{
 		got := fmt.Sprint(stats.BugReports)
 		wants := []string{
-			"cmd/stats.go", // File containing call to bug.Report
-			oops,           // Description
+			"cmd/info.go", // File containing call to bug.Report
+			oops,          // Description
 		}
 		for _, want := range wants {
 			if !strings.Contains(got, want) {
diff --git a/gopls/internal/lsp/filecache/filecache.go b/gopls/internal/lsp/filecache/filecache.go
index 47ee89e..624f872 100644
--- a/gopls/internal/lsp/filecache/filecache.go
+++ b/gopls/internal/lsp/filecache/filecache.go
@@ -552,7 +552,7 @@
 // used by this process (or "" on initialization error).
 func BugReports() (string, []bug.Bug) {
 	// To test this logic, run:
-	// $ TEST_GOPLS_BUG=oops gopls stats   # trigger a bug
+	// $ TEST_GOPLS_BUG=oops gopls bug     # trigger a bug
 	// $ gopls stats                       # list the bugs
 
 	dir, err := getCacheDir()