golang.org/x/build@v0.0.0-20240506185731-218518f32b70/internal/task/milestones.go (about) 1 // Copyright 2023 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 task 6 7 import ( 8 "context" 9 "fmt" 10 "sort" 11 "strings" 12 "time" 13 14 "github.com/google/go-github/github" 15 "github.com/shurcooL/githubv4" 16 wf "golang.org/x/build/internal/workflow" 17 goversion "golang.org/x/build/maintner/maintnerd/maintapi/version" 18 ) 19 20 // MilestoneTasks contains the tasks used to check and modify GitHub issues' milestones. 21 type MilestoneTasks struct { 22 Client GitHubClientInterface 23 RepoOwner, RepoName string 24 ApproveAction func(*wf.TaskContext) error 25 } 26 27 // ReleaseKind is the type of release being run. 28 type ReleaseKind int 29 30 const ( 31 KindUnknown ReleaseKind = iota 32 KindBeta 33 KindRC 34 KindMajor 35 KindMinor 36 ) 37 38 func (k ReleaseKind) GoString() string { 39 switch k { 40 case KindUnknown: 41 return "KindUnknown" 42 case KindBeta: 43 return "KindBeta" 44 case KindRC: 45 return "KindRC" 46 case KindMajor: 47 return "KindMajor" 48 case KindMinor: 49 return "KindMinor" 50 default: 51 return fmt.Sprintf("ReleaseKind(%d)", k) 52 } 53 } 54 55 type ReleaseMilestones struct { 56 // Current is the GitHub milestone number for the current Go release. 57 // For example, 279 for the "Go1.21" milestone (https://github.com/golang/go/milestone/279). 58 Current int 59 // Next is the GitHub milestone number for the next Go release of the same kind. 60 Next int 61 } 62 63 // FetchMilestones returns the milestone numbers for the version currently being 64 // released, and the next version that outstanding issues should be moved to. 65 // If this is a major release, it also creates its first minor release 66 // milestone. 67 func (m *MilestoneTasks) FetchMilestones(ctx *wf.TaskContext, currentVersion string, kind ReleaseKind) (ReleaseMilestones, error) { 68 x, ok := goversion.Go1PointX(currentVersion) 69 if !ok { 70 return ReleaseMilestones{}, fmt.Errorf("could not parse %q as a Go version", currentVersion) 71 } 72 majorVersion := fmt.Sprintf("go1.%d", x) 73 74 // Betas, RCs, and major releases use the major version's milestone. 75 if kind == KindBeta || kind == KindRC || kind == KindMajor { 76 currentVersion = majorVersion 77 } 78 79 currentMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(currentVersion), false) 80 if err != nil { 81 return ReleaseMilestones{}, err 82 } 83 nextV, err := nextVersion(currentVersion) 84 if err != nil { 85 return ReleaseMilestones{}, err 86 } 87 nextMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(nextV), true) 88 if err != nil { 89 return ReleaseMilestones{}, err 90 } 91 if kind == KindMajor { 92 // Create the first minor release milestone too. 93 firstMinor := majorVersion + ".1" 94 if err != nil { 95 return ReleaseMilestones{}, err 96 } 97 _, err = m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(firstMinor), true) 98 if err != nil { 99 return ReleaseMilestones{}, err 100 } 101 } 102 return ReleaseMilestones{Current: currentMilestone, Next: nextMilestone}, nil 103 } 104 105 func uppercaseVersion(version string) string { 106 return strings.Replace(version, "go", "Go", 1) 107 } 108 109 // CheckBlockers returns an error if there are open release blockers in 110 // the current milestone. 111 func (m *MilestoneTasks) CheckBlockers(ctx *wf.TaskContext, milestones ReleaseMilestones, version string, kind ReleaseKind) error { 112 issues, err := m.Client.FetchMilestoneIssues(ctx, m.RepoOwner, m.RepoName, milestones.Current) 113 if err != nil { 114 return err 115 } 116 var blockers []string 117 for number, labels := range issues { 118 releaseBlocker := labels["release-blocker"] 119 switch { 120 case kind == KindBeta && strings.HasSuffix(version, "beta1") && labels["okay-after-beta1"], 121 kind == KindRC && strings.HasSuffix(version, "rc1") && labels["okay-after-rc1"]: 122 releaseBlocker = false 123 } 124 if releaseBlocker { 125 blockers = append(blockers, fmt.Sprintf("https://go.dev/issue/%v", number)) 126 } 127 } 128 sort.Strings(blockers) 129 if len(blockers) == 0 { 130 return nil 131 } 132 ctx.Printf("There are open release blockers in https://github.com/golang/go/milestone/%d. Check that they're expected and approve this task:\n%v", 133 milestones.Current, strings.Join(blockers, "\n")) 134 return m.ApproveAction(ctx) 135 } 136 137 // PushIssues updates issues to reflect a finished release. 138 // For major and minor releases, it moves issues to the next milestone and closes the current milestone. 139 // For pre-releases, it cleans up any "okay-after-..." labels in the current milestone that are done serving their purpose. 140 func (m *MilestoneTasks) PushIssues(ctx *wf.TaskContext, milestones ReleaseMilestones, version string, kind ReleaseKind) error { 141 issues, err := m.Client.FetchMilestoneIssues(ctx, m.RepoOwner, m.RepoName, milestones.Current) 142 if err != nil { 143 return err 144 } 145 ctx.Printf("Processing %d open issues in milestone %d.", len(issues), milestones.Current) 146 for issueNumber, labels := range issues { 147 var newLabels *[]string 148 var newMilestone *int 149 var actions []string // A short description of actions taken, for the log line. 150 removeLabel := func(name string) { 151 if !labels[name] { 152 return 153 } 154 newLabels = new([]string) 155 for label := range labels { 156 if label == name { 157 continue 158 } 159 *newLabels = append(*newLabels, label) 160 } 161 actions = append(actions, fmt.Sprintf("removed label %q", name)) 162 } 163 if kind == KindBeta && strings.HasSuffix(version, "beta1") { 164 removeLabel("okay-after-beta1") 165 } else if kind == KindRC && strings.HasSuffix(version, "rc1") { 166 removeLabel("okay-after-rc1") 167 } else if kind == KindMajor || kind == KindMinor { 168 newMilestone = &milestones.Next 169 actions = append(actions, fmt.Sprintf("pushed to milestone %d", milestones.Next)) 170 } 171 if newMilestone == nil && newLabels == nil { 172 ctx.Printf("Nothing to do for issue %d.", issueNumber) 173 continue 174 } 175 _, _, err := m.Client.EditIssue(ctx, m.RepoOwner, m.RepoName, issueNumber, &github.IssueRequest{ 176 Milestone: newMilestone, 177 Labels: newLabels, 178 }) 179 if err != nil { 180 return err 181 } 182 ctx.Printf("Updated issue %d: %s.", issueNumber, strings.Join(actions, ", ")) 183 } 184 if kind == KindMajor || kind == KindMinor { 185 _, _, err := m.Client.EditMilestone(ctx, m.RepoOwner, m.RepoName, milestones.Current, &github.Milestone{ 186 State: github.String("closed"), 187 }) 188 if err != nil { 189 return err 190 } 191 ctx.Printf("Closed milestone %d.", milestones.Current) 192 } 193 return nil 194 } 195 196 // PingEarlyIssues pings early-in-cycle issues in the development major release milestone. 197 // This is done once at the opening of a release cycle, currently via a standalone workflow. 198 // 199 // develVersion is a value like 22 representing that Go 1.22 is the major version whose 200 // development has recently started, and whose early-in-cycle issues are to be pinged. 201 func (m *MilestoneTasks) PingEarlyIssues(ctx *wf.TaskContext, develVersion int, openTreeURL string) (result struct{}, _ error) { 202 milestoneName := fmt.Sprintf("Go1.%d", develVersion) 203 204 gh, ok := m.Client.(*GitHubClient) 205 if !ok || gh.V4 == nil { 206 // TODO(go.dev/issue/58856): Decide if it's worth moving the GraphQL query/mutation 207 // into GitHubClientInterface. That kinda harms readability because GraphQL code is 208 // basically a flexible API call, so it's most readable when close to where they're 209 // used. This also depends on what kind of tests we'll want to use for this. 210 return struct{}{}, fmt.Errorf("no GitHub API v4 client") 211 } 212 213 // Find all open early-in-cycle issues in the development major release milestone. 214 type issue struct { 215 ID githubv4.ID 216 Number int 217 Title string 218 219 TimelineItems struct { 220 Nodes []struct { 221 IssueComment struct { 222 Author struct{ Login string } 223 Body string 224 } `graphql:"...on IssueComment"` 225 } 226 } `graphql:"timelineItems(since: $avoidDupSince, itemTypes: ISSUE_COMMENT, last: 100)"` 227 } 228 var earlyIssues []issue 229 milestoneNumber, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, milestoneName, false) 230 if err != nil { 231 return struct{}{}, err 232 } 233 variables := map[string]interface{}{ 234 "repoOwner": githubv4.String(m.RepoOwner), 235 "repoName": githubv4.String(m.RepoName), 236 "avoidDupSince": githubv4.DateTime{Time: time.Now().Add(-30 * 24 * time.Hour)}, 237 "milestoneNumber": githubv4.String(fmt.Sprint(milestoneNumber)), // For some reason GitHub API v4 uses string type for milestone numbers. 238 "issueCursor": (*githubv4.String)(nil), 239 } 240 for { 241 var q struct { 242 Repository struct { 243 Issues struct { 244 Nodes []issue 245 PageInfo struct { 246 EndCursor githubv4.String 247 HasNextPage bool 248 } 249 } `graphql:"issues(first: 100, after: $issueCursor, filterBy: {states: OPEN, labels: \"early-in-cycle\", milestoneNumber: $milestoneNumber}, orderBy: {field: CREATED_AT, direction: ASC})"` 250 } `graphql:"repository(owner: $repoOwner, name: $repoName)"` 251 } 252 err := gh.V4.Query(ctx, &q, variables) 253 if err != nil { 254 return struct{}{}, err 255 } 256 earlyIssues = append(earlyIssues, q.Repository.Issues.Nodes...) 257 if !q.Repository.Issues.PageInfo.HasNextPage { 258 break 259 } 260 variables["issueCursor"] = githubv4.NewString(q.Repository.Issues.PageInfo.EndCursor) 261 } 262 263 // Ping them. 264 ctx.Printf("Processing %d early-in-cycle issues in %s milestone (milestone number %d).", len(earlyIssues), milestoneName, milestoneNumber) 265 EarlyIssuesLoop: 266 for _, i := range earlyIssues { 267 for _, n := range i.TimelineItems.Nodes { 268 if n.IssueComment.Author.Login == "gopherbot" && strings.Contains(n.IssueComment.Body, "friendly reminder") { 269 ctx.Printf("Skipping issue %d, it was already pinged.", i.Number) 270 continue EarlyIssuesLoop 271 } 272 } 273 274 // Post a comment. 275 const dryRun = false 276 if dryRun { 277 ctx.Printf("[dry run] Would've pinged issue %d (%.32s…).", i.Number, i.Title) 278 continue 279 } 280 err := m.Client.PostComment(ctx, i.ID, fmt.Sprintf("This issue is currently labeled as early-in-cycle for Go 1.%d.\n"+ 281 "That [time is now](%s), so a friendly reminder to look at it again.", develVersion, openTreeURL)) 282 if err != nil { 283 return struct{}{}, err 284 } 285 ctx.Printf("Pinged issue %d (%.32s…).", i.Number, i.Title) 286 time.Sleep(3 * time.Second) // Take a moment between pinging issues to avoid a high rate of addComment mutations. 287 } 288 289 return struct{}{}, nil 290 } 291 292 // GitHubClientInterface is a wrapper around the GitHub v3 and v4 APIs, for 293 // testing and dry-run support. 294 type GitHubClientInterface interface { 295 // FetchMilestone returns the number of the GitHub milestone with the specified name. 296 // If create is true, and the milestone doesn't exist, it will be created. 297 FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error) 298 299 // FetchMilestoneIssues returns all the open issues in the specified milestone 300 // and their labels. 301 FetchMilestoneIssues(ctx context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error) 302 303 // See github.Client.Issues.Edit. 304 EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) 305 306 // See github.Client.Issues.EditMilestone. 307 EditMilestone(ctx context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error) 308 309 // PostComment creates a comment on a GitHub issue or pull request 310 // identified by the given GitHub Node ID. 311 PostComment(_ context.Context, id githubv4.ID, body string) error 312 } 313 314 type GitHubClient struct { 315 V3 *github.Client 316 V4 *githubv4.Client 317 } 318 319 func (c *GitHubClient) FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error) { 320 n, found, err := findMilestone(ctx, c.V4, owner, repo, name) 321 if err != nil { 322 return 0, err 323 } 324 if found { 325 return n, nil 326 } else if !create { 327 return 0, fmt.Errorf("no milestone named %q found, and creation was disabled", name) 328 } 329 m, _, createErr := c.V3.Issues.CreateMilestone(ctx, owner, repo, &github.Milestone{ 330 Title: github.String(name), 331 }) 332 if createErr != nil { 333 return 0, fmt.Errorf("could not find an open milestone named %q and creating it failed: %v", name, createErr) 334 } 335 return *m.Number, nil 336 } 337 338 func findMilestone(ctx context.Context, client *githubv4.Client, owner, repo, name string) (int, bool, error) { 339 var query struct { 340 Repository struct { 341 Milestones struct { 342 Nodes []struct { 343 Title string 344 Number int 345 State string 346 } 347 } `graphql:"milestones(first:10, query: $milestoneName)"` 348 } `graphql:"repository(owner: $repoOwner, name: $repoName)"` 349 } 350 if err := client.Query(ctx, &query, map[string]interface{}{ 351 "repoOwner": githubv4.String(owner), 352 "repoName": githubv4.String(repo), 353 "milestoneName": githubv4.String(name), 354 }); err != nil { 355 return 0, false, err 356 } 357 // The milestone query is case-insensitive and a partial match; we're okay 358 // with case variations but it needs to be a full match. 359 var open, closed []string 360 milestoneNumber := 0 361 for _, m := range query.Repository.Milestones.Nodes { 362 if strings.ToLower(name) != strings.ToLower(m.Title) { 363 continue 364 } 365 if m.State == "OPEN" { 366 open = append(open, m.Title) 367 milestoneNumber = m.Number 368 } else { 369 closed = append(closed, m.Title) 370 } 371 } 372 // GitHub allows "go" and "Go" to exist at the same time. 373 // If there's any confusion, fail: we expect either one open milestone, 374 // or no matching milestones at all. 375 switch { 376 case len(open) == 1: 377 return milestoneNumber, true, nil 378 case len(open) > 1: 379 return 0, false, fmt.Errorf("multiple open milestones matching %q: %q", name, open) 380 // No open milestones. 381 case len(closed) == 0: 382 return 0, false, nil 383 case len(closed) > 0: 384 return 0, false, fmt.Errorf("no open milestones matching %q, but some closed: %q (re-open or delete?)", name, closed) 385 } 386 // The switch above is exhaustive. 387 panic(fmt.Errorf("unhandled case: open: %q closed: %q", open, closed)) 388 } 389 390 func (c *GitHubClient) FetchMilestoneIssues(ctx context.Context, owner, repo string, milestoneID int) (map[int]map[string]bool, error) { 391 issues := map[int]map[string]bool{} 392 var query struct { 393 Repository struct { 394 Issues struct { 395 PageInfo struct { 396 EndCursor githubv4.String 397 HasNextPage bool 398 } 399 400 Nodes []struct { 401 Number int 402 ID githubv4.ID 403 Title string 404 Labels struct { 405 PageInfo struct { 406 HasNextPage bool 407 } 408 Nodes []struct { 409 Name string 410 } 411 } `graphql:"labels(first:10)"` 412 } 413 } `graphql:"issues(first:100, after:$afterToken, filterBy:{states:OPEN, milestoneNumber:$milestoneNumber})"` 414 } `graphql:"repository(owner: $repoOwner, name: $repoName)"` 415 } 416 var afterToken *githubv4.String 417 more: 418 if err := c.V4.Query(ctx, &query, map[string]interface{}{ 419 "repoOwner": githubv4.String(owner), 420 "repoName": githubv4.String(repo), 421 "milestoneNumber": githubv4.String(fmt.Sprint(milestoneID)), 422 "afterToken": afterToken, 423 }); err != nil { 424 return nil, err 425 } 426 for _, issue := range query.Repository.Issues.Nodes { 427 if issue.Labels.PageInfo.HasNextPage { 428 return nil, fmt.Errorf("issue %v (#%v) has more than 10 labels", issue.Title, issue.Number) 429 } 430 labels := map[string]bool{} 431 for _, label := range issue.Labels.Nodes { 432 labels[label.Name] = true 433 } 434 issues[issue.Number] = labels 435 } 436 if query.Repository.Issues.PageInfo.HasNextPage { 437 afterToken = &query.Repository.Issues.PageInfo.EndCursor 438 goto more 439 } 440 return issues, nil 441 } 442 443 func (c *GitHubClient) EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { 444 return c.V3.Issues.Edit(ctx, owner, repo, number, issue) 445 } 446 447 func (c *GitHubClient) EditMilestone(ctx context.Context, owner string, repo string, number int, milestone *github.Milestone) (*github.Milestone, *github.Response, error) { 448 return c.V3.Issues.EditMilestone(ctx, owner, repo, number, milestone) 449 } 450 451 func (c *GitHubClient) PostComment(ctx context.Context, id githubv4.ID, body string) error { 452 return c.V4.Mutate(ctx, new(struct { 453 AddComment struct { 454 ClientMutationID string // Unused; GraphQL doesn't allow for mutations to return nothing. 455 } `graphql:"addComment(input: $input)"` 456 }), githubv4.AddCommentInput{ 457 SubjectID: id, 458 Body: githubv4.String(body), 459 }, nil) 460 }