golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/watchflakes/github.go (about)

     1  // Copyright 2024 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"fmt"
     9  	"log"
    10  	"regexp"
    11  	"sort"
    12  	"strings"
    13  
    14  	"golang.org/x/build/cmd/watchflakes/internal/script"
    15  	"rsc.io/github"
    16  )
    17  
    18  // An Issue is a single GitHub issue in the Test Flakes project:
    19  // a plain github.Issue plus our associated data.
    20  type Issue struct {
    21  	*github.Issue
    22  	ScriptText string         // extracted watchflakes script
    23  	Script     *script.Script // compiled script
    24  
    25  	// initialized by readComments
    26  	Stale    bool                   // issue comments may be stale
    27  	Comments []*github.IssueComment // all issue comments
    28  	NewBody  bool                   // issue body (containing script) is newer than last watchflakes comment
    29  	Mentions map[string]bool        // log URLs that have already been posted in watchflakes comments
    30  
    31  	// what to send back to the issue
    32  	Error string         // error message (markdown) to post back to issue
    33  	Post  []*FailurePost // failures to post back to issue
    34  }
    35  
    36  func (i *Issue) String() string { return fmt.Sprintf("#%d", i.Number) }
    37  
    38  var (
    39  	gh         *github.Client
    40  	repo       *github.Repo
    41  	labels     map[string]*github.Label
    42  	testFlakes *github.Project
    43  )
    44  
    45  var scriptRE = regexp.MustCompile(`(?m)(^( {4}|\t)#!watchflakes\n((( {4}|\t).*)?\n)+|^\x60{3}\n#!watchflakes\n(([^\x60].*)?\n)+\x60{3}\n)`)
    46  
    47  // readIssues reads the GitHub issues in the Test Flakes project.
    48  // It also sets up the repo, labels, and testFlakes variables for
    49  // use by other functions below.
    50  func readIssues(old []*Issue) ([]*Issue, error) {
    51  	// Find repo.
    52  	r, err := gh.Repo("golang", "go")
    53  	if err != nil {
    54  		return nil, err
    55  	}
    56  	repo = r
    57  
    58  	// Find labels.
    59  	list, err := gh.SearchLabels("golang", "go", "")
    60  	if err != nil {
    61  		return nil, err
    62  	}
    63  	labels = make(map[string]*github.Label)
    64  	for _, label := range list {
    65  		labels[label.Name] = label
    66  	}
    67  
    68  	// Find Test Flakes project.
    69  	ps, err := gh.Projects("golang", "")
    70  	if err != nil {
    71  		return nil, err
    72  	}
    73  	for _, p := range ps {
    74  		if p.Title == "Test Flakes" {
    75  			testFlakes = p
    76  			break
    77  		}
    78  	}
    79  	if testFlakes == nil {
    80  		return nil, fmt.Errorf("cannot find Test Flakes project")
    81  	}
    82  
    83  	cache := make(map[int]*Issue)
    84  	for _, issue := range old {
    85  		cache[issue.Number] = issue
    86  	}
    87  	// Read all issues in Test Flakes.
    88  	var issues []*Issue
    89  	items, err := gh.ProjectItems(testFlakes)
    90  	if err != nil {
    91  		return nil, err
    92  	}
    93  	for _, item := range items {
    94  		if item.Issue != nil {
    95  			issue := &Issue{Issue: item.Issue, NewBody: true, Stale: true}
    96  			if c := cache[item.Issue.Number]; c != nil {
    97  				// Carry conservative NewBody, Mentions data forward
    98  				// to avoid round trips about things we already know.
    99  				if c.Issue.LastEditedAt.Equal(item.Issue.LastEditedAt) {
   100  					issue.NewBody = c.NewBody
   101  				}
   102  				issue.Mentions = c.Mentions
   103  			}
   104  			issues = append(issues, issue)
   105  		}
   106  	}
   107  	sort.Slice(issues, func(i, j int) bool {
   108  		return issues[i].Number < issues[j].Number
   109  	})
   110  
   111  	return issues, nil
   112  }
   113  
   114  // findScripts finds the scripts in the issues,
   115  // initializing issue.Script and .ScriptText or else .Error
   116  // in each issue.
   117  func findScripts(issues []*Issue) {
   118  	for _, issue := range issues {
   119  		findScript(issue)
   120  	}
   121  }
   122  
   123  var noScriptError = `
   124  Sorry, but I can't find a watchflakes script at the start of the issue description.
   125  See https://go.dev/wiki/Watchflakes for details.
   126  `
   127  
   128  var parseScriptError = `
   129  Sorry, but there were parse errors in the watch flakes script.
   130  The script I found was:
   131  
   132  %s
   133  
   134  And the problems were:
   135  
   136  %s
   137  
   138  See https://go.dev/wiki/Watchflakes for details.
   139  `
   140  
   141  // findScript finds the script in issue and parses it.
   142  // If the script is not found or has any parse errors,
   143  // issue.Error is filled in.
   144  // Otherwise issue.ScriptText and issue.Script are filled in.
   145  func findScript(issue *Issue) {
   146  	// Extract ```-fenced or indented code block at start of issue description (body).
   147  	body := strings.ReplaceAll(issue.Body, "\r\n", "\n")
   148  	lines := strings.SplitAfter(body, "\n")
   149  	for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
   150  		lines = lines[1:]
   151  	}
   152  	text := ""
   153  	if len(lines) > 0 && strings.HasPrefix(lines[0], "```") {
   154  		marker := lines[0]
   155  		n := 0
   156  		for n < len(marker) && marker[n] == '`' {
   157  			n++
   158  		}
   159  		marker = marker[:n]
   160  		i := 1
   161  		for i := 1; i < len(lines); i++ {
   162  			if strings.HasPrefix(lines[i], marker) && strings.TrimSpace(strings.TrimLeft(lines[i], "`")) == "" {
   163  				text = strings.Join(lines[1:i], "")
   164  				break
   165  			}
   166  		}
   167  		if i < len(lines) {
   168  		}
   169  	} else if strings.HasPrefix(lines[0], "\t") || strings.HasPrefix(lines[0], "    ") {
   170  		i := 1
   171  		for i < len(lines) && (strings.HasPrefix(lines[i], "\t") || strings.HasPrefix(lines[i], "    ")) {
   172  			i++
   173  		}
   174  		text = strings.Join(lines[:i], "")
   175  	}
   176  
   177  	// Must start with #!watchflakes so we're sure it is for us.
   178  	hdr, _, _ := strings.Cut(text, "\n")
   179  	hdr = strings.TrimSpace(hdr)
   180  	if hdr != "#!watchflakes" {
   181  		issue.Error = noScriptError
   182  		return
   183  	}
   184  
   185  	// Parse script.
   186  	issue.ScriptText = text
   187  	s, errs := script.Parse("script", text, fields)
   188  	if len(errs) > 0 {
   189  		var errtext strings.Builder
   190  		for _, err := range errs {
   191  			errtext.WriteString(err.Error())
   192  			errtext.WriteString("\n")
   193  		}
   194  		issue.Error = fmt.Sprintf(parseScriptError, indent("\t", text), indent("\t", errtext.String()))
   195  		return
   196  	}
   197  
   198  	issue.Script = s
   199  }
   200  
   201  func postIssueErrors(issues []*Issue) []error {
   202  	var errors []error
   203  	for _, issue := range issues {
   204  		if issue.Error != "" && issue.NewBody {
   205  			readComments(issue)
   206  			if issue.NewBody {
   207  				fmt.Printf(" - #%d script error\n", issue.Number)
   208  				if *verbose {
   209  					fmt.Printf("\n%s\n", indent(spaces[:7], issue.Error))
   210  				}
   211  				if *post {
   212  					if err := postComment(issue, issue.Error); err != nil {
   213  						errors = append(errors, err)
   214  						continue
   215  					}
   216  					issue.NewBody = false
   217  				}
   218  			}
   219  		}
   220  	}
   221  	return errors
   222  }
   223  
   224  // updateText returns the text for the GitHub update on issue.
   225  func updateText(issue *Issue) string {
   226  	if len(issue.Post) == 0 {
   227  		return ""
   228  	}
   229  
   230  	var b strings.Builder
   231  	fmt.Fprintf(&b, "Found new dashboard test flakes for:\n\n%s", indent(spaces[:4], issue.ScriptText))
   232  	for _, f := range issue.Post {
   233  		b.WriteString("\n")
   234  		_ = f
   235  		b.WriteString(f.Markdown())
   236  	}
   237  	return b.String()
   238  }
   239  
   240  // reportNew creates and returns a new issue for reporting the failure.
   241  // If *post is false, reportNew returns a fake issue with number 0.
   242  func reportNew(fp *FailurePost) (*Issue, error) {
   243  	var pattern, title string
   244  	if fp.Pkg != "" {
   245  		pattern = fmt.Sprintf("pkg == %q && test == %q", fp.Pkg, fp.Test)
   246  		test := fp.Test
   247  		if test == "" {
   248  			test = "unrecognized"
   249  		}
   250  		title = shortPkg(fp.Pkg) + ": " + test + " failures"
   251  	} else if fp.Test != "" {
   252  		pattern = fmt.Sprintf("repo == %q && pkg == %q && test == %q", fp.Repo, "", fp.Test)
   253  		title = "build: " + fp.Test + " failures"
   254  	} else if fp.IsBuildFailure() {
   255  		pattern = fmt.Sprintf("builder == %q && repo == %q && mode == %q", fp.Builder, fp.Repo, "build")
   256  		title = "build: build failure on " + fp.Builder
   257  	} else {
   258  		pattern = fmt.Sprintf("builder == %q && repo == %q && pkg == %q && test == %q", fp.Builder, fp.Repo, "", "")
   259  		title = "build: unrecognized failures on " + fp.Builder
   260  	}
   261  
   262  	var msg strings.Builder
   263  	fmt.Fprintf(&msg, "```\n#!watchflakes\ndefault <- %s\n```\n\n", pattern)
   264  	fmt.Fprintf(&msg, "Issue created automatically to collect these failures.\n\n")
   265  	fmt.Fprintf(&msg, "Example ([log](%s)):\n\n%s", fp.URL, indent(spaces[:4], fp.Snippet))
   266  
   267  	// TODO: for a single test failure, add a link to LUCI history page.
   268  
   269  	fmt.Printf("# new issue: %s\n%s\n%s\n%s\n\n%s\n", title, fp.String(), fp.URL, pattern, fp.Snippet)
   270  	if *verbose {
   271  		fmt.Printf("\n%s\n", indent(spaces[:3], msg.String()))
   272  	}
   273  
   274  	issue := new(Issue)
   275  	if *post {
   276  		issue.Issue = newIssue(title, msg.String())
   277  	} else {
   278  		issue.Issue = &github.Issue{Title: title, Body: msg.String()}
   279  	}
   280  	findScript(issue)
   281  	if issue.Error != "" {
   282  		return nil, fmt.Errorf("cannot find script in generated issue:\nBody:\n%s\n\nError:\n%s", issue.Body, issue.Error)
   283  	}
   284  	issue.Post = append(issue.Post, fp)
   285  	return issue, nil
   286  }
   287  
   288  // signature is the signature we add to the end of every comment or issue body
   289  // we post on GitHub. It links to documentation for users, and it also serves as
   290  // a way to identify the comments that we posted, since watchflakes can be run
   291  // as gopherbot or as an ordinary user.
   292  const signature = "\n\n— [watchflakes](https://go.dev/wiki/Watchflakes)\n"
   293  
   294  // keep in sync with buildURL function in luci.go
   295  // An older version reported ci.chromium.org/ui/b instead of ci.chromium.org/b,
   296  // match them as well.
   297  var buildUrlRE = regexp.MustCompile(`[("']https://ci.chromium.org/(ui/)?b/[0-9]+['")]`)
   298  
   299  // readComments loads the comments for the given issue,
   300  // setting the Comments, NewBody, and Mentions fields.
   301  func readComments(issue *Issue) {
   302  	if issue.Number == 0 || !issue.Stale {
   303  		return
   304  	}
   305  	log.Printf("readComments %d", issue.Number)
   306  	comments, err := gh.IssueComments(issue.Issue)
   307  	if err != nil {
   308  		log.Fatal(err)
   309  	}
   310  	issue.Comments = comments
   311  	mtime := issue.LastEditedAt
   312  	if mtime.IsZero() {
   313  		mtime = issue.CreatedAt
   314  	}
   315  	issue.Mentions = make(map[string]bool)
   316  	issue.NewBody = true // until proven otherwise
   317  	for _, com := range comments {
   318  		// Only consider comments we signed.
   319  		if !strings.Contains(com.Body, "\n— watchflakes") && !strings.Contains(com.Body, "\n— [watchflakes](") {
   320  			continue
   321  		}
   322  		if com.CreatedAt.After(issue.LastEditedAt) {
   323  			issue.NewBody = false
   324  		}
   325  		for _, link := range buildUrlRE.FindAllString(com.Body, -1) {
   326  			l := strings.Trim(link, "()\"'")
   327  			issue.Mentions[l] = true
   328  			// An older version reported ci.chromium.org/ui/b instead of ci.chromium.org/b,
   329  			// match them as well.
   330  			issue.Mentions[strings.Replace(l, "ci.chromium.org/ui/b/", "ci.chromium.org/b/", 1)] = true
   331  		}
   332  	}
   333  	issue.Stale = false
   334  }
   335  
   336  // newIssue creates a new issue with the given title and body,
   337  // setting the NeedsInvestigation label and placing the issue int
   338  // the Test Flakes project.
   339  // It automatically adds signature to the body.
   340  func newIssue(title, body string) *github.Issue {
   341  	var args []any
   342  	if lab := labels["NeedsInvestigation"]; lab != nil {
   343  		args = append(args, lab)
   344  	}
   345  	args = append(args, testFlakes)
   346  
   347  	issue, err := gh.CreateIssue(repo, title, body+signature, args...)
   348  	if err != nil {
   349  		log.Fatal(err)
   350  	}
   351  	return issue
   352  }
   353  
   354  // postComment posts a new comment on the issue.
   355  // It automatically adds signature to the comment.
   356  func postComment(issue *Issue, body string) error {
   357  	if len(body) > 50000 {
   358  		// Apparently GitHub GraphQL API limits comment length to 65536.
   359  		body = body[:50000] + "\n</details>\n(... long comment truncated ...)\n"
   360  	}
   361  	if issue.Issue.Closed {
   362  		reopen := false
   363  		for _, p := range issue.Post {
   364  			_ = p
   365  			if p.Time.After(issue.ClosedAt) {
   366  				reopen = true
   367  				break
   368  			}
   369  		}
   370  		if reopen {
   371  			if err := gh.ReopenIssue(issue.Issue); err != nil {
   372  				return err
   373  			}
   374  		}
   375  	}
   376  	return gh.AddIssueComment(issue.Issue, body+signature)
   377  }