зеркало из https://github.com/golang/build.git
387 строки
11 KiB
Go
387 строки
11 KiB
Go
// 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</details>\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)
|
|
}
|