// 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 ( "fmt" "log" "regexp" "sort" "strings" "golang.org/x/build/cmd/watchflakes/internal/script" "rsc.io/github" ) // An Issue is a single GitHub issue in the Test Flakes project: // a plain github.Issue plus our associated data. type Issue struct { *github.Issue ScriptText string // extracted watchflakes script Script *script.Script // compiled script // initialized by readComments Stale bool // issue comments may be stale Comments []*github.IssueComment // all issue comments NewBody bool // issue body (containing script) is newer than last watchflakes comment Mentions map[string]bool // log URLs that have already been posted in watchflakes comments // what to send back to the issue Error string // error message (markdown) to post back to issue Post []*FailurePost // failures to post back to issue } func (i *Issue) String() string { return fmt.Sprintf("#%d", i.Number) } var ( gh *github.Client repo *github.Repo labels map[string]*github.Label testFlakes *github.Project ) var scriptRE = regexp.MustCompile(`(?m)(^( {4}|\t)#!watchflakes\n((( {4}|\t).*)?\n)+|^\x60{3}\n#!watchflakes\n(([^\x60].*)?\n)+\x60{3}\n)`) // readIssues reads the GitHub issues in the Test Flakes project. // It also sets up the repo, labels, and testFlakes variables for // use by other functions below. func readIssues(old []*Issue) ([]*Issue, error) { var err error gh, err = github.Dial("gopherbot") if err != nil { gh, err = github.Dial("") if err != nil { return nil, err } } // Find repo. r, err := gh.Repo("golang", "go") if err != nil { return nil, err } repo = r // Find labels. list, err := gh.SearchLabels("golang", "go", "") if err != nil { return nil, err } labels = make(map[string]*github.Label) for _, label := range list { labels[label.Name] = label } // Find Test Flakes project. ps, err := gh.Projects("golang", "") if err != nil { return nil, err } for _, p := range ps { if p.Title == "Test Flakes" { testFlakes = p break } } if testFlakes == nil { return nil, fmt.Errorf("cannot find Test Flakes project") } cache := make(map[int]*Issue) for _, issue := range old { cache[issue.Number] = issue } // Read all issues in Test Flakes. var issues []*Issue items, err := gh.ProjectItems(testFlakes) if err != nil { return nil, err } for _, item := range items { if item.Issue != nil { issue := &Issue{Issue: item.Issue, NewBody: true, Stale: true} if c := cache[item.Issue.Number]; c != nil { // Carry conservative NewBody, Mentions data forward // to avoid round trips about things we already know. if c.Issue.LastEditedAt.Equal(item.Issue.LastEditedAt) { issue.NewBody = c.NewBody } issue.Mentions = c.Mentions } issues = append(issues, issue) } } sort.Slice(issues, func(i, j int) bool { return issues[i].Number < issues[j].Number }) return issues, nil } // findScripts finds the scripts in the issues, // initializing issue.Script and .ScriptText or else .Error // in each issue. func findScripts(issues []*Issue) { for _, issue := range issues { findScript(issue) } } var noScriptError = ` Sorry, but I can't find a watchflakes script at the start of the issue description. See https://go.dev/wiki/Watchflakes for details. ` var parseScriptError = ` Sorry, but there were parse errors in the watch flakes script. The script I found was: %s And the problems were: %s See https://go.dev/wiki/Watchflakes for details. ` // findScript finds the script in issue and parses it. // If the script is not found or has any parse errors, // issue.Error is filled in. // Otherwise issue.ScriptText and issue.Script are filled in. func findScript(issue *Issue) { // Extract ```-fenced or indented code block at start of issue description (body). body := strings.ReplaceAll(issue.Body, "\r\n", "\n") lines := strings.SplitAfter(body, "\n") for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { lines = lines[1:] } text := "" 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 #!watchflakes so we're sure it is for us. hdr, _, _ := strings.Cut(text, "\n") hdr = strings.TrimSpace(hdr) if hdr != "#!watchflakes" { issue.Error = noScriptError return } // Parse script. issue.ScriptText = text s, errs := script.Parse("script", text, fields) if len(errs) > 0 { var errtext strings.Builder for _, err := range errs { errtext.WriteString(err.Error()) errtext.WriteString("\n") } issue.Error = fmt.Sprintf(parseScriptError, indent("\t", text), indent("\t", errtext.String())) return } issue.Script = s } func postIssueErrors(issues []*Issue) []error { var errors []error for _, issue := range issues { if issue.Error != "" && issue.NewBody { readComments(issue) if issue.NewBody { fmt.Printf(" - #%d script error\n", issue.Number) if *verbose { fmt.Printf("\n%s\n", indent(spaces[:7], issue.Error)) } if *post { if err := postComment(issue, issue.Error); err != nil { errors = append(errors, err) continue } issue.NewBody = false } } } } return errors } // updateText returns the text for the GitHub update on issue. func updateText(issue *Issue) string { if len(issue.Post) == 0 { return "" } var b strings.Builder fmt.Fprintf(&b, "Found new dashboard test flakes for:\n\n%s", indent(spaces[:4], issue.ScriptText)) for _, f := range issue.Post { b.WriteString("\n") _ = f b.WriteString(f.Markdown()) } return b.String() } // reportNew creates and returns a new issue for reporting the failure. // If *post is false, reportNew returns a fake issue with number 0. func reportNew(fp *FailurePost) (*Issue, error) { var pattern, title string if fp.Pkg != "" { pattern = fmt.Sprintf("pkg == %q && test == %q", fp.Pkg, fp.Test) test := fp.Test if test == "" { test = "unrecognized" } title = shortPkg(fp.Pkg) + ": " + test + " failures" } else if fp.Test != "" { pattern = fmt.Sprintf("repo == %q && pkg == %q && test == %q", fp.Repo, "", fp.Test) title = "build: " + fp.Test + " failures" } else if fp.IsBuildFailure() { pattern = fmt.Sprintf("builder == %q && repo == %q && mode == %q", fp.Builder, fp.Repo, "build") title = "build: build failure on " + fp.Builder } else { pattern = fmt.Sprintf("builder == %q && repo == %q && pkg == %q && test == %q", fp.Builder, fp.Repo, "", "") title = "build: unrecognized failures on " + fp.Builder } var msg strings.Builder fmt.Fprintf(&msg, "```\n#!watchflakes\ndefault <- %s\n```\n\n", pattern) fmt.Fprintf(&msg, "Issue created automatically to collect these failures.\n\n") fmt.Fprintf(&msg, "Example ([log](%s)):\n\n%s", fp.URL, indent(spaces[:4], fp.Snippet)) // TODO: for a single test failure, add a link to LUCI history page. fmt.Printf("# new issue: %s\n%s\n%s\n%s\n\n%s\n", title, fp.String(), fp.URL, pattern, fp.Snippet) if *verbose { fmt.Printf("\n%s\n", indent(spaces[:3], msg.String())) } issue := new(Issue) if *post { issue.Issue = newIssue(title, msg.String()) } else { issue.Issue = &github.Issue{Title: title, Body: msg.String()} } findScript(issue) if issue.Error != "" { return nil, fmt.Errorf("cannot find script in generated issue:\nBody:\n%s\n\nError:\n%s", issue.Body, issue.Error) } issue.Post = append(issue.Post, fp) return issue, nil } // signature is the signature we add to the end of every comment or issue body // we post on GitHub. It links to documentation for users, and it also serves as // a way to identify the comments that we posted, since watchflakes can be run // as gopherbot or as an ordinary user. const signature = "\n\n— [watchflakes](https://go.dev/wiki/Watchflakes)\n" // keep in sync with buildURL function in luci.go // An older version reported ci.chromium.org/ui/b instead of ci.chromium.org/b, // match them as well. var buildUrlRE = regexp.MustCompile(`[("']https://ci.chromium.org/(ui/)?b/[0-9]+['")]`) // readComments loads the comments for the given issue, // setting the Comments, NewBody, and Mentions fields. func readComments(issue *Issue) { if issue.Number == 0 || !issue.Stale { return } log.Printf("readComments %d", issue.Number) comments, err := gh.IssueComments(issue.Issue) if err != nil { log.Fatal(err) } issue.Comments = comments mtime := issue.LastEditedAt if mtime.IsZero() { mtime = issue.CreatedAt } issue.Mentions = make(map[string]bool) issue.NewBody = true // until proven otherwise for _, com := range comments { // Only consider comments we signed. if !strings.Contains(com.Body, "\n— watchflakes") && !strings.Contains(com.Body, "\n— [watchflakes](") { continue } if com.CreatedAt.After(issue.LastEditedAt) { issue.NewBody = false } for _, link := range buildUrlRE.FindAllString(com.Body, -1) { l := strings.Trim(link, "()\"'") issue.Mentions[l] = true // An older version reported ci.chromium.org/ui/b instead of ci.chromium.org/b, // match them as well. issue.Mentions[strings.Replace(l, "ci.chromium.org/ui/b/", "ci.chromium.org/b/", 1)] = true } } issue.Stale = false } // newIssue creates a new issue with the given title and body, // setting the NeedsInvestigation label and placing the issue int // the Test Flakes project. // It automatically adds signature to the body. func newIssue(title, body string) *github.Issue { var args []any if lab := labels["NeedsInvestigation"]; lab != nil { args = append(args, lab) } args = append(args, testFlakes) issue, err := gh.CreateIssue(repo, title, body+signature, args...) if err != nil { log.Fatal(err) } return issue } // postComment posts a new comment on the issue. // It automatically adds signature to the comment. func postComment(issue *Issue, body string) error { if len(body) > 50000 { // Apparently GitHub GraphQL API limits comment length to 65536. body = body[:50000] + "\n\n(... long comment truncated ...)\n" } if issue.Issue.Closed { reopen := false for _, p := range issue.Post { _ = p if p.Time.After(issue.ClosedAt) { reopen = true break } } if reopen { if err := gh.ReopenIssue(issue.Issue); err != nil { return err } } } return gh.AddIssueComment(issue.Issue, body+signature) }