blob: 6bf2e46db51f98aedb9dbdc702dfbf53e9af6217 [file] [log] [blame]
// Copyright 2017 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 (
"bytes"
"context"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"github.com/google/go-github/github"
"golang.org/x/oauth2"
)
const (
projectOwner = "golang"
projectRepo = "go"
)
var githubClient *github.Client
// GitHub personal access token, from https://github.com/settings/applications.
var githubAuthToken string
func loadGithubAuth() {
const short = ".github-issue-token"
filename := filepath.Clean(os.Getenv("HOME") + "/" + short)
shortFilename := filepath.Clean("$HOME/" + short)
data, err := ioutil.ReadFile(filename)
if err != nil {
log.Fatal("reading token: ", err, "\n\n"+
"Please create a personal access token at https://github.com/settings/tokens/new\n"+
"and write it to ", shortFilename, " to use this program.\n"+
"The token only needs the repo scope, or private_repo if you want to\n"+
"view or edit issues for private repositories.\n"+
"The benefit of using a personal access token over using your GitHub\n"+
"password directly is that you can limit its use and revoke it at any time.\n\n")
}
fi, err := os.Stat(filename)
if err != nil {
log.Fatalln("reading token:", err)
}
if fi.Mode()&0077 != 0 {
log.Fatalf("reading token: %s mode is %#o, want %#o", shortFilename, fi.Mode()&0777, fi.Mode()&0700)
}
githubAuthToken = strings.TrimSpace(string(data))
t := &oauth2.Transport{
Source: &tokenSource{AccessToken: githubAuthToken},
}
githubClient = github.NewClient(&http.Client{Transport: t})
}
// releaseStatusTitle returns the title of the release status issue
// for the given milestone.
// If you change this function, releasebot will not be able to find an
// existing tracking issue using the old name and will create a new one.
func releaseStatusTitle(m *github.Milestone) string {
return "all: " + strings.Replace(m.GetTitle(), "Go", "Go ", -1) + " release status"
}
type tokenSource oauth2.Token
func (t *tokenSource) Token() (*oauth2.Token, error) {
return (*oauth2.Token)(t), nil
}
func loadMilestones() ([]*github.Milestone, error) {
// NOTE(rsc): There appears to be no paging possible.
all, _, err := githubClient.Issues.ListMilestones(context.TODO(), projectOwner, projectRepo, &github.MilestoneListOptions{
State: "open",
})
if err != nil {
return nil, err
}
if all == nil {
all = []*github.Milestone{}
}
return all, nil
}
// findIssues finds all the issues for the given milestone and
// categorizes them into approved cherry-picks (w.Picks)
// and other issues (w.OtherIssues).
// It also finds the release summary issue (w.ReleaseIssue).
func (w *Work) findIssues() {
issues, err := listRepoIssues(github.IssueListByRepoOptions{
Milestone: fmt.Sprint(w.Milestone.GetNumber()),
})
if err != nil {
w.log.Panic(err)
}
for _, issue := range issues {
if issue.GetTitle() == releaseStatusTitle(w.Milestone) {
if w.ReleaseIssue != nil {
w.log.Printf("**warning**: multiple release issues: #%d and #%d\n", w.ReleaseIssue.GetNumber(), issue.GetNumber())
continue
}
w.ReleaseIssue = issue
continue
}
if hasLabel(issue, "cherry-pick-approved") {
w.Picks = append(w.Picks, issue)
continue
}
w.OtherIssues = append(w.OtherIssues, issue)
}
sort.Slice(w.Picks, func(i, j int) bool { return w.Picks[i].GetNumber() < w.Picks[j].GetNumber() })
if w.ReleaseIssue == nil {
title := releaseStatusTitle(w.Milestone)
body := wrapStatus(w.Milestone, "Nothing yet.")
req := &github.IssueRequest{
Title: &title,
Body: &body,
Milestone: w.Milestone.Number,
}
issue, _, err := githubClient.Issues.Create(context.TODO(), projectOwner, projectRepo, req)
if err != nil {
w.log.Panic(err)
}
w.ReleaseIssue = issue
}
}
// listRepoIssues wraps Issues.ListByRepo to deal with paging.
func listRepoIssues(opt github.IssueListByRepoOptions) ([]*github.Issue, error) {
var all []*github.Issue
for page := 1; ; {
xopt := opt
xopt.ListOptions = github.ListOptions{
Page: page,
PerPage: 100,
}
list, resp, err := githubClient.Issues.ListByRepo(context.TODO(), projectOwner, projectRepo, &xopt)
all = append(all, list...)
if err != nil {
return all, err
}
if resp.NextPage < page {
break
}
page = resp.NextPage
}
return all, nil
}
// hasLabel reports whether issue has the given label.
func hasLabel(issue *github.Issue, label string) bool {
for _, l := range issue.Labels {
if l.GetName() == label {
return true
}
}
return false
}
var clOK = regexp.MustCompile(`(?i)^CL (\d+) OK(( for Go \d+\.\d+\.\d+)?.*)`)
var afterCL = regexp.MustCompile(`(?i)after CL (\d+)`)
// listIssueComments wraps Issues.ListComments to deal with paging.
func listIssueComments(number int) ([]*github.IssueComment, error) {
var all []*github.IssueComment
for page := 1; ; {
list, resp, err := githubClient.Issues.ListComments(context.TODO(), projectOwner, projectRepo, number, &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{
Page: page,
PerPage: 100,
},
})
all = append(all, list...)
if err != nil {
return all, err
}
if resp.NextPage < page {
break
}
page = resp.NextPage
}
return all, nil
}
func (w *Work) findCLs() {
// Preload all CLs in parallel.
type comments struct {
list []*github.IssueComment
err error
}
preload := make([]comments, len(w.Picks))
var wg sync.WaitGroup
for i, pick := range w.Picks {
i := i
number := pick.GetNumber()
wg.Add(1)
go func() {
defer wg.Done()
list, err := listIssueComments(number)
preload[i] = comments{list, err}
}()
}
wg.Wait()
var cls []*CL
for i, pick := range w.Picks {
number := pick.GetNumber()
fmt.Printf("load #%d\n", number)
found := false
list, err := preload[i].list, preload[i].err
if err != nil {
w.log.Panic(err)
}
var last *CL
for _, com := range list {
user := com.User.GetLogin()
text := com.GetBody()
for _, line := range strings.Split(text, "\n") {
if m := clOK.FindStringSubmatch(line); m != nil {
if m[3] != " for Go "+strings.TrimPrefix(w.Milestone.GetTitle(), "Go") {
w.log.Printf("#%d: %s: wrong milestone: %s\n", number, user, line)
continue
}
if !githubCherryPickApprovers[user] {
w.log.Printf("#%d: %s: not an approver: %s\n", number, user, line)
continue
}
n, err := strconv.Atoi(m[1])
if err != nil {
w.log.Printf("#%d: %s: invalid CL number: %s\n", number, user, line)
continue
}
cl := &CL{Num: n, Approver: user, Issues: []int{number}}
if last != nil {
cl.Prereq = []int{last.Num}
}
for _, am := range afterCL.FindAllStringSubmatch(m[2], -1) {
n, err := strconv.Atoi(am[1])
if err != nil {
w.log.Printf("#%d: %s: invalid after CL number: %s\n", number, user, line)
continue
}
cl.Prereq = append(cl.Prereq, n)
}
cls = append(cls, cl)
found = true
last = cl
}
}
}
if !found {
log.Printf("#%d: has cherry-pick-approved label but no approvals found", number)
}
}
sort.Slice(cls, func(i, j int) bool {
return cls[i].Num < cls[j].Num || cls[i].Num == cls[j].Num && cls[i].Approver < cls[j].Approver
})
out := cls[:0]
var last CL
for _, cl := range cls {
if cl.Num == last.Num {
end := out[len(out)-1]
if cl.Approver != last.Approver {
end.Approver += "," + cl.Approver
}
end.Issues = append(end.Issues, cl.Issues...)
end.Prereq = append(end.Prereq, cl.Prereq...)
} else {
out = append(out, cl)
}
last = *cl
}
w.CLs = out
}
func (w *Work) closeIssues() {
all := append(w.Picks[:len(w.Picks):len(w.Picks)], w.ReleaseIssue)
for _, issue := range all {
if issue.GetState() == "closed" {
continue
}
number := issue.GetNumber()
var md bytes.Buffer
fmt.Fprintf(&md, "%s has been packaged and includes:\n\n", w.Version)
for _, cl := range w.CLs {
match := issue == w.ReleaseIssue
for _, n := range cl.Issues {
if n == number {
match = true
break
}
}
if match {
fmt.Fprintf(&md, " - %s %s\n", mdChangeLink(cl.Num), mdEscape(cl.Title))
}
}
fmt.Fprintf(&md, "\nThe release is posted at [golang.org/dl](https://golang.org/dl).\n")
md.WriteString(signature())
postGithubComment(number, md.String())
closed := "closed"
_, _, err := githubClient.Issues.Edit(context.TODO(), projectOwner, projectRepo, number, &github.IssueRequest{
State: &closed,
})
if err != nil {
w.logError(nil, fmt.Sprintf("closing #%d: %v", number, err))
}
}
}
func (w *Work) closeMilestone() {
closed := "closed"
_, _, err := githubClient.Issues.EditMilestone(context.TODO(), projectOwner, projectRepo, w.Milestone.GetNumber(), &github.Milestone{
State: &closed,
})
if err != nil {
w.logError(nil, fmt.Sprintf("closing milestone: %v", err))
}
}
func findGithubComment(number int, prefix string) *github.IssueComment {
list, _ := listIssueComments(number)
for _, com := range list {
if strings.HasPrefix(com.GetBody(), prefix) {
return com
}
}
return nil
}
func updateGithubComment(com *github.IssueComment, body string) error {
_, _, err := githubClient.Issues.EditComment(context.TODO(), projectOwner, projectRepo, com.GetID(), &github.IssueComment{
Body: &body,
})
return err
}
func postGithubComment(number int, body string) error {
_, _, err := githubClient.Issues.CreateComment(context.TODO(), projectOwner, projectRepo, number, &github.IssueComment{
Body: &body,
})
return err
}