blob: f3e7fae359b9bf91b1864cf8923743e44142c1b4 [file] [log] [blame]
// Copyright 2023 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.
//go:build linux || darwin
// The stacks command finds all gopls stack traces reported by
// telemetry in the past 7 days, and reports their associated GitHub
// issue, creating new issues as needed.
//
// The association of stacks with GitHub issues (labelled
// gopls/telemetry-wins) is represented in two different ways by the
// body (first comment) of the issue:
//
// 1. Each distinct stack is identified by an ID, 6-digit base64
// string such as "TwtkSg". If a stack's ID appears anywhere
// within the issue body, the stack is associated with the issue.
//
// Some problems are highly deterministic, resulting in many
// field reports of the exact same stack. For such problems, a
// single ID in the issue body suffices to record the
// association. But most problems are exhibited in a variety of
// ways, leading to multiple field reports of similar but
// distinct stacks.
//
// 2. Each GitHub issue body may start with a code block of this form:
//
// ```
// #!stacks
// "runtime.sigpanic" && "golang.hover:+170"
// ```
//
// The first line indicates the purpose of the block; the
// remainder is a predicate that matches stacks.
// It is an expression defined by this grammar:
//
// > expr = "string literal"
// > | ( expr )
// > | ! expr
// > | expr && expr
// > | expr || expr
//
// Each string literal implies a substring match on the stack;
// the other productions are boolean operations.
//
// The stacks command gathers all such predicates out of the
// labelled issues and evaluates each one against each new stack.
// If the predicate for an issue matches, the issue is considered
// to have "claimed" the stack: the stack command appends a
// comment containing the new (variant) stack to the issue, and
// appends the stack's ID to the last line of the issue body.
//
// It is an error if two issues' predicates attempt to claim the
// same stack.
package main
// TODO(adonovan): create a proper package with tests. Much of this
// machinery might find wider use in other x/telemetry clients.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"hash/fnv"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
"unicode"
"golang.org/x/sys/unix"
"golang.org/x/telemetry"
"golang.org/x/tools/gopls/internal/util/browser"
"golang.org/x/tools/gopls/internal/util/moremaps"
)
// flags
var (
daysFlag = flag.Int("days", 7, "number of previous days of telemetry data to read")
authToken string // mandatory GitHub authentication token (for R/W issues access)
)
func main() {
log.SetFlags(0)
log.SetPrefix("stacks: ")
flag.Parse()
// Read GitHub authentication token from $HOME/.stacks.token.
//
// You can create one using the flow at: GitHub > You > Settings >
// Developer Settings > Personal Access Tokens > Fine-grained tokens >
// Generate New Token. Generate the token on behalf of golang/go
// with R/W access to "Issues".
// The token is typically of the form "github_pat_XXX", with 82 hex digits.
// Save it in the file, with mode 0400.
//
// For security, secret tokens should be read from files, not
// command-line flags or environment variables.
{
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
tokenFile := filepath.Join(home, ".stacks.token")
content, err := os.ReadFile(tokenFile)
if err != nil {
if !os.IsNotExist(err) {
log.Fatalf("cannot read GitHub authentication token: %v", err)
}
log.Fatalf("no file %s containing GitHub authentication token.", tokenFile)
}
authToken = string(bytes.TrimSpace(content))
}
// Maps stack text to Info to count.
stacks := make(map[string]map[Info]int64)
var distinctStacks int
// Maps stack to a telemetry URL.
stackToURL := make(map[string]string)
// Read all recent telemetry reports.
t := time.Now()
for i := 0; i < *daysFlag; i++ {
date := t.Add(-time.Duration(i+1) * 24 * time.Hour).Format(time.DateOnly)
url := fmt.Sprintf("https://storage.googleapis.com/prod-telemetry-merged/%s.json", date)
resp, err := http.Get(url)
if err != nil {
log.Fatalf("can't GET %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Fatalf("GET %s returned %d %s", url, resp.StatusCode, resp.Status)
}
dec := json.NewDecoder(resp.Body)
for {
var report telemetry.Report
if err := dec.Decode(&report); err != nil {
if err == io.EOF {
break
}
log.Fatal(err)
}
for _, prog := range report.Programs {
if prog.Program == "golang.org/x/tools/gopls" && len(prog.Stacks) > 0 {
// Include applicable client names (e.g. vscode, eglot).
var clients []string
var clientSuffix string
for key := range prog.Counters {
client := strings.TrimPrefix(key, "gopls/client:")
if client != key {
clients = append(clients, client)
}
}
sort.Strings(clients)
if len(clients) > 0 {
clientSuffix = strings.Join(clients, ",")
}
// Ignore @devel versions as they correspond to
// ephemeral (and often numerous) variations of
// the program as we work on a fix to a bug.
if prog.Version == "devel" {
continue
}
distinctStacks++
info := Info{
Program: prog.Program,
Version: prog.Version,
GoVersion: prog.GoVersion,
GOOS: prog.GOOS,
GOARCH: prog.GOARCH,
Client: clientSuffix,
}
for stack, count := range prog.Stacks {
counts := stacks[stack]
if counts == nil {
counts = make(map[Info]int64)
stacks[stack] = counts
}
counts[info] += count
stackToURL[stack] = url
}
}
}
}
}
// Query GitHub for all existing GitHub issues with label:gopls/telemetry-wins.
//
// TODO(adonovan): by default GitHub returns at most 30
// issues; we have lifted this to 100 using per_page=%d, but
// that won't work forever; use paging.
const query = "is:issue label:gopls/telemetry-wins"
res, err := searchIssues(query)
if err != nil {
log.Fatalf("GitHub issues query %q failed: %v", query, err)
}
// Extract and validate predicate expressions in ```#!stacks...``` code blocks.
// See the package doc comment for the grammar.
for _, issue := range res.Items {
block := findPredicateBlock(issue.Body)
if block != "" {
expr, err := parser.ParseExpr(block)
if err != nil {
log.Printf("invalid predicate in issue #%d: %v\n<<%s>>",
issue.Number, err, block)
continue
}
var validate func(ast.Expr) error
validate = func(e ast.Expr) error {
switch e := e.(type) {
case *ast.UnaryExpr:
if e.Op != token.NOT {
return fmt.Errorf("invalid op: %s", e.Op)
}
return validate(e.X)
case *ast.BinaryExpr:
if e.Op != token.LAND && e.Op != token.LOR {
return fmt.Errorf("invalid op: %s", e.Op)
}
if err := validate(e.X); err != nil {
return err
}
return validate(e.Y)
case *ast.ParenExpr:
return validate(e.X)
case *ast.BasicLit:
if e.Kind != token.STRING {
return fmt.Errorf("invalid literal (%s)", e.Kind)
}
if _, err := strconv.Unquote(e.Value); err != nil {
return err
}
default:
return fmt.Errorf("syntax error (%T)", e)
}
return nil
}
if err := validate(expr); err != nil {
log.Printf("invalid predicate in issue #%d: %v\n<<%s>>",
issue.Number, err, block)
continue
}
issue.predicateText = block
issue.predicate = func(stack string) bool {
var eval func(ast.Expr) bool
eval = func(e ast.Expr) bool {
switch e := e.(type) {
case *ast.UnaryExpr:
return !eval(e.X)
case *ast.BinaryExpr:
if e.Op == token.LAND {
return eval(e.X) && eval(e.Y)
} else {
return eval(e.X) || eval(e.Y)
}
case *ast.ParenExpr:
return eval(e.X)
case *ast.BasicLit:
substr, _ := strconv.Unquote(e.Value)
return strings.Contains(stack, substr)
}
panic("unreachable")
}
return eval(expr)
}
}
}
// Map each stack ID to its issue.
//
// An issue can claim a stack two ways:
//
// 1. if the issue body contains the ID of the stack. Matching
// is a little loose but base64 will rarely produce words
// that appear in the body by chance.
//
// 2. if the issue body contains a ```#!stacks``` predicate
// that matches the stack.
//
// We report an error if two different issues attempt to claim
// the same stack.
//
// This is O(new stacks x existing issues).
claimedBy := make(map[string]*Issue)
for stack := range stacks {
id := stackID(stack)
for _, issue := range res.Items {
byPredicate := false
if strings.Contains(issue.Body, id) {
// nop
} else if issue.predicate != nil && issue.predicate(stack) {
byPredicate = true
} else {
continue
}
if prev := claimedBy[id]; prev != nil && prev != issue {
log.Printf("stack %s is claimed by issues #%d and #%d",
id, prev.Number, issue.Number)
continue
}
if false {
log.Printf("stack %s claimed by issue #%d",
id, issue.Number)
}
claimedBy[id] = issue
if byPredicate {
// The stack ID matched the predicate but was not
// found in the issue body, so this is a new stack.
issue.newStacks = append(issue.newStacks, stack)
}
}
}
// For each stack, show existing issue or create a new one.
// Aggregate stack IDs by issue summary.
var (
// Both vars map the summary line to the stack count.
existingIssues = make(map[string]int64)
newIssues = make(map[string]int64)
)
for stack, counts := range stacks {
id := stackID(stack)
var total int64
for _, count := range counts {
total += count
}
if issue, ok := claimedBy[id]; ok {
// existing issue
summary := fmt.Sprintf("#%d: %s [%s]",
issue.Number, issue.Title, issue.State)
existingIssues[summary] += total
} else {
// new issue
title := newIssue(stack, id, stackToURL[stack], counts)
summary := fmt.Sprintf("%s: %s [%s]", id, title, "new")
newIssues[summary] += total
}
}
// Update existing issues that claimed new stacks by predicate.
for _, issue := range res.Items {
if len(issue.newStacks) == 0 {
continue
}
// Add a comment to the existing issue listing all its new stacks.
// (Save the ID of each stack for the second step.)
comment := new(bytes.Buffer)
var newStackIDs []string
for _, stack := range issue.newStacks {
id := stackID(stack)
newStackIDs = append(newStackIDs, id)
writeStackComment(comment, stack, id, stackToURL[stack], stacks[stack])
}
if err := addIssueComment(issue.Number, comment.String()); err != nil {
log.Println(err)
continue
}
// Append to the "Dups: ID ..." list on last line of issue body.
body := strings.TrimSpace(issue.Body)
lastLineStart := strings.LastIndexByte(body, '\n') + 1
lastLine := body[lastLineStart:]
if !strings.HasPrefix(lastLine, "Dups:") {
body += "\nDups:"
}
body += " " + strings.Join(newStackIDs, " ")
if err := updateIssueBody(issue.Number, body); err != nil {
log.Printf("added comment to issue #%d but failed to update body: %v",
issue.Number, err)
continue
}
log.Printf("added stacks %s to issue #%d", newStackIDs, issue.Number)
}
fmt.Printf("Found %d distinct stacks in last %v days:\n", distinctStacks, *daysFlag)
print := func(caption string, issues map[string]int64) {
// Print items in descending frequency.
keys := moremaps.KeySlice(issues)
sort.Slice(keys, func(i, j int) bool {
return issues[keys[i]] > issues[keys[j]]
})
fmt.Printf("%s issues:\n", caption)
for _, summary := range keys {
count := issues[summary]
// Show closed issues in "white".
if isTerminal(os.Stdout) && strings.Contains(summary, "[closed]") {
// ESC + "[" + n + "m" => change color to n
// (37 = white, 0 = default)
summary = "\x1B[37m" + summary + "\x1B[0m"
}
fmt.Printf("%s (n=%d)\n", summary, count)
}
}
print("Existing", existingIssues)
print("New", newIssues)
}
// Info is used as a key for de-duping and aggregating.
// Do not add detail about particular records (e.g. data, telemetry URL).
type Info struct {
Program string // "golang.org/x/tools/gopls"
Version, GoVersion string // e.g. "gopls/v0.16.1", "go1.23"
GOOS, GOARCH string
Client string // e.g. "vscode"
}
func (info Info) String() string {
return fmt.Sprintf("%s@%s %s %s/%s %s",
info.Program, info.Version,
info.GoVersion, info.GOOS, info.GOARCH,
info.Client)
}
// stackID returns a 32-bit identifier for a stack
// suitable for use in GitHub issue titles.
func stackID(stack string) string {
// Encode it using base64 (6 bytes) for brevity,
// as a single issue's body might contain multiple IDs
// if separate issues with same cause were manually de-duped,
// e.g. "AAAAAA, BBBBBB"
//
// https://hbfs.wordpress.com/2012/03/30/finding-collisions:
// the chance of a collision is 1 - exp(-n(n-1)/2d) where n
// is the number of items and d is the number of distinct values.
// So, even with n=10^4 telemetry-reported stacks each identified
// by a uint32 (d=2^32), we have a 1% chance of a collision,
// which is plenty good enough.
h := fnv.New32()
io.WriteString(h, stack)
return base64.URLEncoding.EncodeToString(h.Sum(nil))[:6]
}
// newIssue creates a browser tab with a populated GitHub "New issue"
// form for the specified stack. (The triage person is expected to
// manually de-dup the issue before deciding whether to submit the form.)
//
// It returns the title.
func newIssue(stack, id string, jsonURL string, counts map[Info]int64) string {
// Use a heuristic to find a suitable symbol to blame
// in the title: the first public function or method
// of a public type, in gopls, to appear in the stack
// trace. We can always refine it later.
//
// TODO(adonovan): include in the issue a source snippet ±5
// lines around the PC in this symbol.
var symbol string
for _, line := range strings.Split(stack, "\n") {
// Look for:
// gopls/.../pkg.Func
// gopls/.../pkg.Type.method
// gopls/.../pkg.(*Type).method
if strings.Contains(line, "internal/util/bug.") {
continue // not interesting
}
if _, rest, ok := strings.Cut(line, "golang.org/x/tools/gopls/"); ok {
if i := strings.IndexByte(rest, '.'); i >= 0 {
rest = rest[i+1:]
rest = strings.TrimPrefix(rest, "(*")
if rest != "" && 'A' <= rest[0] && rest[0] <= 'Z' {
rest, _, _ = strings.Cut(rest, ":")
symbol = " " + rest
break
}
}
}
}
// Populate the form (title, body, label)
title := fmt.Sprintf("x/tools/gopls: bug in %s", symbol)
body := new(bytes.Buffer)
// Add a placeholder ```#!stacks``` block since this is a new issue.
body.WriteString("```" + `
#!stacks
"<insert predicate here>"
` + "```\n")
fmt.Fprintf(body, "Issue created by [stacks](https://pkg.go.dev/golang.org/x/tools/gopls/internal/telemetry/cmd/stacks).\n\n")
writeStackComment(body, stack, id, jsonURL, counts)
const labels = "gopls,Tools,gopls/telemetry-wins,NeedsInvestigation"
// Report it. The user will interactively finish the task,
// since they will typically de-dup it without even creating a new issue
// by expanding the #!stacks predicate of an existing issue.
if !browser.Open("https://github.com/golang/go/issues/new?labels=" + labels + "&title=" + url.QueryEscape(title) + "&body=" + url.QueryEscape(body.String())) {
log.Print("Please file a new issue at golang.org/issue/new using this template:\n\n")
log.Printf("Title: %s\n", title)
log.Printf("Labels: %s\n", labels)
log.Printf("Body: %s\n", body)
}
return title
}
// writeStackComment writes a stack in Markdown form, for a new GitHub
// issue or new comment on an existing one.
func writeStackComment(body *bytes.Buffer, stack, id string, jsonURL string, counts map[Info]int64) {
if len(counts) == 0 {
panic("no counts")
}
var info Info // pick an arbitrary key
for info = range counts {
break
}
fmt.Fprintf(body, "This stack `%s` was [reported by telemetry](%s):\n\n",
id, jsonURL)
// Read the mapping from symbols to file/line.
pclntab, err := readPCLineTable(info)
if err != nil {
log.Fatal(err)
}
// Parse the stack and get the symbol names out.
for _, frame := range strings.Split(stack, "\n") {
if url := frameURL(pclntab, info, frame); url != "" {
fmt.Fprintf(body, "- [`%s`](%s)\n", frame, url)
} else {
fmt.Fprintf(body, "- `%s`\n", frame)
}
}
// Add counts, gopls version, and platform info.
// This isn't very precise but should provide clues.
fmt.Fprintf(body, "```\n")
for info, count := range counts {
fmt.Fprintf(body, "%s (%d)\n", info, count)
}
fmt.Fprintf(body, "```\n\n")
}
// frameURL returns the CodeSearch URL for the stack frame, if known.
func frameURL(pclntab map[string]FileLine, info Info, frame string) string {
// e.g. "golang.org/x/tools/gopls/foo.(*Type).Method.inlined.func3:+5"
symbol, offset, ok := strings.Cut(frame, ":")
if !ok {
// Not a symbol (perhaps stack counter title: "gopls/bug"?)
return ""
}
fileline, ok := pclntab[symbol]
if !ok {
// objdump reports ELF symbol names, which in
// rare cases may be the Go symbols of
// runtime.CallersFrames mangled by (e.g.) the
// addition of .abi0 suffix; see
// https://github.com/golang/go/issues/69390#issuecomment-2343795920
// So this should not be a hard error.
if symbol != "runtime.goexit" {
log.Printf("no pclntab info for symbol: %s", symbol)
}
return ""
}
if offset == "" {
log.Fatalf("missing line offset: %s", frame)
}
if unicode.IsDigit(rune(offset[0])) {
// Fix gopls/v0.14.2 legacy syntax ":%d" -> ":+%d".
offset = "+" + offset
}
offsetNum, err := strconv.Atoi(offset[1:])
if err != nil {
log.Fatalf("invalid line offset: %s", frame)
}
linenum := fileline.line
switch offset[0] {
case '-':
linenum -= offsetNum
case '+':
linenum += offsetNum
case '=':
linenum = offsetNum
}
// Construct CodeSearch URL.
// std module?
firstSegment, _, _ := strings.Cut(fileline.file, "/")
if !strings.Contains(firstSegment, ".") {
// (First segment is a dir beneath GOROOT/src, not a module domain name.)
return fmt.Sprintf("https://cs.opensource.google/go/go/+/%s:src/%s;l=%d",
info.GoVersion, fileline.file, linenum)
}
// x/tools repo (tools or gopls module)?
if rest, ok := strings.CutPrefix(fileline.file, "golang.org/x/tools"); ok {
if rest[0] == '/' {
// "golang.org/x/tools/gopls" -> "gopls"
rest = rest[1:]
} else if rest[0] == '@' {
// "golang.org/x/tools@version/dir/file.go" -> "dir/file.go"
rest = rest[strings.Index(rest, "/")+1:]
}
return fmt.Sprintf("https://cs.opensource.google/go/x/tools/+/%s:%s;l=%d",
"gopls/"+info.Version, rest, linenum)
}
// other x/ module dependency?
// e.g. golang.org/x/sync@v0.8.0/errgroup/errgroup.go
if rest, ok := strings.CutPrefix(fileline.file, "golang.org/x/"); ok {
if modVer, filename, ok := strings.Cut(rest, "/"); ok {
if mod, version, ok := strings.Cut(modVer, "@"); ok {
return fmt.Sprintf("https://cs.opensource.google/go/x/%s/+/%s:%s;l=%d",
mod, version, filename, linenum)
}
}
}
log.Printf("no CodeSearch URL for %q (%s:%d)",
symbol, fileline.file, linenum)
return ""
}
// -- GitHub search --
// searchIssues queries the GitHub issue tracker.
func searchIssues(query string) (*IssuesSearchResult, error) {
q := url.QueryEscape(query)
req, err := http.NewRequest("GET", "https://api.github.com/search/issues?q="+q+"&per_page=100", nil)
if err != nil {
return nil, err
}
req.Header.Add("Authorization", "Bearer "+authToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("search query failed: %s (body: %s)", resp.Status, body)
}
var result IssuesSearchResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
// updateIssueBody updates the body of the numbered issue.
func updateIssueBody(number int, body string) error {
// https://docs.github.com/en/rest/issues/comments#update-an-issue
var payload struct {
Body string `json:"body"`
}
payload.Body = body
data, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://api.github.com/repos/golang/go/issues/%d", number)
req, err := http.NewRequest("PATCH", url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Add("Authorization", "Bearer "+authToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("issue update failed: %s (body: %s)", resp.Status, body)
}
return nil
}
// addIssueComment adds a markdown comment to the numbered issue.
func addIssueComment(number int, comment string) error {
// https://docs.github.com/en/rest/issues/comments#create-an-issue-comment
var payload struct {
Body string `json:"body"`
}
payload.Body = comment
data, err := json.Marshal(payload)
if err != nil {
return err
}
url := fmt.Sprintf("https://api.github.com/repos/golang/go/issues/%d/comments", number)
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Add("Authorization", "Bearer "+authToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to create issue comment: %s (body: %s)", resp.Status, body)
}
return nil
}
// See https://developer.github.com/v3/search/#search-issues.
type IssuesSearchResult struct {
TotalCount int `json:"total_count"`
Items []*Issue
}
type Issue struct {
Number int
HTMLURL string `json:"html_url"`
Title string
State string
User *User
CreatedAt time.Time `json:"created_at"`
Body string // in Markdown format
predicateText string // text of ```#!stacks...``` predicate block
predicate func(string) bool // matching predicate over stack text
newStacks []string // new stacks to add to existing issue (comments and IDs)
}
type User struct {
Login string
HTMLURL string `json:"html_url"`
}
// -- pclntab --
type FileLine struct {
file string // "module@version/dir/file.go" or path relative to $GOROOT/src
line int
}
// readPCLineTable builds the gopls executable specified by info,
// reads its PC-to-line-number table, and returns the file/line of
// each TEXT symbol.
func readPCLineTable(info Info) (map[string]FileLine, error) {
// The stacks dir will be a semi-durable temp directory
// (i.e. lasts for at least hours) holding source trees
// and executables we have built recently.
//
// Each subdir will hold a specific revision.
stacksDir := "/tmp/gopls-stacks"
if err := os.MkdirAll(stacksDir, 0777); err != nil {
return nil, fmt.Errorf("can't create stacks dir: %v", err)
}
// Fetch the source for the tools repo,
// shallow-cloning just the desired revision.
// (Skip if it's already cloned.)
revDir := filepath.Join(stacksDir, info.Version)
if !fileExists(revDir) {
log.Printf("cloning tools@gopls/%s", info.Version)
if err := shallowClone(revDir, "https://go.googlesource.com/tools", "gopls/"+info.Version); err != nil {
_ = os.RemoveAll(revDir) // ignore errors
return nil, fmt.Errorf("clone: %v", err)
}
}
// Build the executable with the correct GOTOOLCHAIN, GOOS, GOARCH.
// Use -trimpath for normalized file names.
// (Skip if it's already built.)
exe := fmt.Sprintf("exe-%s.%s-%s", info.GoVersion, info.GOOS, info.GOARCH)
cmd := exec.Command("go", "build", "-trimpath", "-o", "../"+exe)
cmd.Stderr = os.Stderr
cmd.Dir = filepath.Join(revDir, "gopls")
cmd.Env = append(os.Environ(),
"GOTOOLCHAIN="+info.GoVersion,
"GOOS="+info.GOOS,
"GOARCH="+info.GOARCH,
)
if !fileExists(filepath.Join(revDir, exe)) {
log.Printf("building %s@%s with %s for %s/%s",
info.Program, info.Version, info.GoVersion, info.GOOS, info.GOARCH)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("building: %v (rm -fr /tmp/gopls-stacks?)", err)
}
}
// Read pclntab of executable.
cmd = exec.Command("go", "tool", "objdump", exe)
cmd.Stdout = new(strings.Builder)
cmd.Stderr = os.Stderr
cmd.Dir = revDir
cmd.Env = append(os.Environ(),
"GOTOOLCHAIN="+info.GoVersion,
"GOOS="+info.GOOS,
"GOARCH="+info.GOARCH,
)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("reading pclntab %v", err)
}
pclntab := make(map[string]FileLine)
lines := strings.Split(fmt.Sprint(cmd.Stdout), "\n")
for i, line := range lines {
// Each function is of this form:
//
// TEXT symbol(SB) filename
// basename.go:line instruction
// ...
if !strings.HasPrefix(line, "TEXT ") {
continue
}
fields := strings.Fields(line)
if len(fields) != 3 {
continue // symbol without file (e.g. go:buildid)
}
symbol := strings.TrimSuffix(fields[1], "(SB)")
filename := fields[2]
_, line, ok := strings.Cut(strings.Fields(lines[i+1])[0], ":")
if !ok {
return nil, fmt.Errorf("can't parse 'basename.go:line' from first instruction of %s:\n%s",
symbol, line)
}
linenum, err := strconv.Atoi(line)
if err != nil {
return nil, fmt.Errorf("can't parse line number of %s: %s", symbol, line)
}
pclntab[symbol] = FileLine{filename, linenum}
}
return pclntab, nil
}
// shallowClone performs a shallow clone of repo into dir at the given
// 'commitish' ref (any commit reference understood by git).
//
// The directory dir must not already exist.
func shallowClone(dir, repo, commitish string) error {
if err := os.Mkdir(dir, 0750); err != nil {
return fmt.Errorf("creating dir for %s: %v", repo, err)
}
// Set a timeout for git fetch. If this proves flaky, it can be removed.
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
// Use a shallow fetch to download just the relevant commit.
shInit := fmt.Sprintf("git init && git fetch --depth=1 %q %q && git checkout FETCH_HEAD", repo, commitish)
initCmd := exec.CommandContext(ctx, "/bin/sh", "-c", shInit)
initCmd.Dir = dir
if output, err := initCmd.CombinedOutput(); err != nil {
return fmt.Errorf("checking out %s: %v\n%s", repo, err, output)
}
return nil
}
func fileExists(filename string) bool {
_, err := os.Stat(filename)
return err == nil
}
// findPredicateBlock returns the content (sans "#!stacks") of the
// code block at the start of the issue body.
// Logic plundered from x/build/cmd/watchflakes/github.go.
func findPredicateBlock(body string) string {
// Extract ```-fenced or indented code block at start of issue description (body).
body = strings.ReplaceAll(body, "\r\n", "\n")
lines := strings.SplitAfter(body, "\n")
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
text := ""
// A code quotation is bracketed by sequence of 3+ backticks.
// (More than 3 are permitted so that one can quote 3 backticks.)
if len(lines) > 0 && strings.HasPrefix(lines[0], "```") {
marker := lines[0]
n := 0
for n < len(marker) && marker[n] == '`' {
n++
}
marker = marker[:n]
i := 1
for i := 1; i < len(lines); i++ {
if strings.HasPrefix(lines[i], marker) && strings.TrimSpace(strings.TrimLeft(lines[i], "`")) == "" {
text = strings.Join(lines[1:i], "")
break
}
}
if i < len(lines) {
}
} else if strings.HasPrefix(lines[0], "\t") || strings.HasPrefix(lines[0], " ") {
i := 1
for i < len(lines) && (strings.HasPrefix(lines[i], "\t") || strings.HasPrefix(lines[i], " ")) {
i++
}
text = strings.Join(lines[:i], "")
}
// Must start with #!stacks so we're sure it is for us.
hdr, rest, _ := strings.Cut(text, "\n")
hdr = strings.TrimSpace(hdr)
if hdr != "#!stacks" {
return ""
}
return rest
}
// isTerminal reports whether file is a terminal,
// avoiding a dependency on golang.org/x/term.
func isTerminal(file *os.File) bool {
// Hardwire the constants to avoid the need for build tags.
// The values here are good for our dev machines.
switch runtime.GOOS {
case "darwin":
const TIOCGETA = 0x40487413 // from unix.TIOCGETA
_, err := unix.IoctlGetTermios(int(file.Fd()), TIOCGETA)
return err == nil
case "linux":
const TCGETS = 0x5401 // from unix.TCGETS
_, err := unix.IoctlGetTermios(int(file.Fd()), TCGETS)
return err == nil
}
panic("unreachable")
}