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 }