зеркало из https://github.com/golang/build.git
internal/task: add milestone tasks
Add tasks that match releasebot's checkReleaseBlockers and pushIssues. They use a mix of GitHub's REST and GraphQL APIs so that we don't need to use maintner, which looked like a headache. For golang/go#51797. Change-Id: I108df115950698ee016b6cbdb3395afc9e0bf844 Reviewed-on: https://go-review.googlesource.com/c/build/+/408295 Reviewed-by: Dmitri Shuralyov <dmitshur@google.com> TryBot-Result: Gopher Robot <gobot@golang.org> Run-TryBot: Heschi Kreinick <heschi@google.com> Auto-Submit: Heschi Kreinick <heschi@google.com> Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
This commit is contained in:
Родитель
33d38b8f07
Коммит
94632bfcf0
2
go.mod
2
go.mod
|
@ -38,6 +38,7 @@ require (
|
|||
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/mattn/go-sqlite3 v1.14.6
|
||||
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
|
||||
go.opencensus.io v0.23.0
|
||||
go4.org v0.0.0-20180809161055-417644f6feb5
|
||||
|
@ -106,6 +107,7 @@ require (
|
|||
github.com/prometheus/common v0.15.0 // indirect
|
||||
github.com/prometheus/procfs v0.2.0 // indirect
|
||||
github.com/prometheus/statsd_exporter v0.20.0 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20220520033453-bdb1221e171e // indirect
|
||||
github.com/sirupsen/logrus v1.8.1 // indirect
|
||||
go.uber.org/atomic v1.6.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
|
|
4
go.sum
4
go.sum
|
@ -786,6 +786,10 @@ github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9Nz
|
|||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 h1:fiFvD4lT0aWjuuAb64LlZ/67v87m+Kc9Qsu5cMFNK0w=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
|
||||
github.com/shurcooL/graphql v0.0.0-20220520033453-bdb1221e171e h1:dmM59/+RIH6bO/gjmUgaJwdyDhAvZkHgA5OJUcoUyGU=
|
||||
github.com/shurcooL/graphql v0.0.0-20220520033453-bdb1221e171e/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
|
|
|
@ -0,0 +1,290 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/shurcooL/githubv4"
|
||||
"golang.org/x/build/internal/workflow"
|
||||
goversion "golang.org/x/build/maintner/maintnerd/maintapi/version"
|
||||
)
|
||||
|
||||
// MilestoneTasks contains the tasks used to check and modify GitHub issues' milestones.
|
||||
type MilestoneTasks struct {
|
||||
Client GitHubClientInterface
|
||||
RepoOwner, RepoName string
|
||||
}
|
||||
|
||||
// ReleaseKind is the type of release being run.
|
||||
type ReleaseKind int
|
||||
|
||||
const (
|
||||
KindUnknown ReleaseKind = iota
|
||||
KindBeta
|
||||
KindRC
|
||||
KindFinal
|
||||
KindMinor
|
||||
)
|
||||
|
||||
type ReleaseMilestones struct {
|
||||
Current, Next int
|
||||
}
|
||||
|
||||
// FetchMilestones returns the milestone numbers for the version currently being
|
||||
// released, and the next version that outstanding issues should be moved to.
|
||||
// If this is a final release, it also creates its first minor release
|
||||
// milestone.
|
||||
func (m *MilestoneTasks) FetchMilestones(ctx *workflow.TaskContext, currentVersion string, kind ReleaseKind) (ReleaseMilestones, error) {
|
||||
x, ok := goversion.Go1PointX(currentVersion)
|
||||
if !ok {
|
||||
return ReleaseMilestones{}, fmt.Errorf("could not parse %q as a Go version", currentVersion)
|
||||
}
|
||||
majorVersion := fmt.Sprintf("go1.%d", x)
|
||||
|
||||
// RCs and betas use the major version's milestone.
|
||||
if kind == KindRC || kind == KindBeta {
|
||||
currentVersion = majorVersion
|
||||
}
|
||||
|
||||
currentMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(currentVersion), false)
|
||||
if err != nil {
|
||||
return ReleaseMilestones{}, err
|
||||
}
|
||||
nextV, err := nextVersion(currentVersion)
|
||||
if err != nil {
|
||||
return ReleaseMilestones{}, err
|
||||
}
|
||||
nextMilestone, err := m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(nextV), true)
|
||||
if err != nil {
|
||||
return ReleaseMilestones{}, err
|
||||
}
|
||||
if kind == KindFinal {
|
||||
// Create the first minor release milestone too.
|
||||
firstMinor := majorVersion + ".1"
|
||||
if err != nil {
|
||||
return ReleaseMilestones{}, err
|
||||
}
|
||||
_, err = m.Client.FetchMilestone(ctx, m.RepoOwner, m.RepoName, uppercaseVersion(firstMinor), true)
|
||||
if err != nil {
|
||||
return ReleaseMilestones{}, err
|
||||
}
|
||||
}
|
||||
return ReleaseMilestones{Current: currentMilestone, Next: nextMilestone}, nil
|
||||
}
|
||||
|
||||
func nextVersion(version string) (string, error) {
|
||||
parts := strings.Split(version, ".")
|
||||
n, err := strconv.Atoi(parts[len(parts)-1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts[len(parts)-1] = strconv.Itoa(n + 1)
|
||||
return strings.Join(parts, "."), nil
|
||||
}
|
||||
|
||||
func uppercaseVersion(version string) string {
|
||||
return strings.Replace(version, "go", "Go", 1)
|
||||
}
|
||||
|
||||
// CheckBlockers returns an error if there are open release blockers in
|
||||
// the current milestone.
|
||||
func (m *MilestoneTasks) CheckBlockers(ctx *workflow.TaskContext, milestones ReleaseMilestones, kind ReleaseKind) (string, error) {
|
||||
issues, err := m.loadMilestoneIssues(ctx, milestones.Current, kind)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var blockers []string
|
||||
for number, blocker := range issues {
|
||||
if blocker {
|
||||
blockers = append(blockers, fmt.Sprintf("https://go.dev/issue/%v", number))
|
||||
}
|
||||
}
|
||||
sort.Strings(blockers)
|
||||
if len(blockers) != 0 {
|
||||
return "", fmt.Errorf("open release blockers:\n%v", strings.Join(blockers, "\n"))
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// loadMilestoneIssues returns all the open issues in milestone and whether
|
||||
// they should block the given kind of release.
|
||||
func (m *MilestoneTasks) loadMilestoneIssues(ctx *workflow.TaskContext, milestoneID int, kind ReleaseKind) (map[int]bool, error) {
|
||||
issues := map[int]bool{}
|
||||
var query struct {
|
||||
Repository struct {
|
||||
Issues struct {
|
||||
PageInfo struct {
|
||||
EndCursor githubv4.String
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
Nodes []struct {
|
||||
Number int
|
||||
ID githubv4.ID
|
||||
Title string
|
||||
Labels struct {
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
}
|
||||
Nodes []struct {
|
||||
Name string
|
||||
}
|
||||
} `graphql:"labels(first:10)"`
|
||||
}
|
||||
} `graphql:"issues(first:100, after:$afterToken, filterBy:{states:OPEN, milestoneNumber:$milestoneNumber})"`
|
||||
} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
|
||||
}
|
||||
var afterToken *githubv4.String
|
||||
more:
|
||||
if err := m.Client.Query(ctx, &query, map[string]interface{}{
|
||||
"repoOwner": githubv4.String(m.RepoOwner),
|
||||
"repoName": githubv4.String(m.RepoName),
|
||||
"milestoneNumber": githubv4.String(fmt.Sprint(milestoneID)),
|
||||
"afterToken": afterToken,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, issue := range query.Repository.Issues.Nodes {
|
||||
if issue.Labels.PageInfo.HasNextPage {
|
||||
return nil, fmt.Errorf("issue %v (#%v) has more than 10 labels", issue.Title, issue.Number)
|
||||
}
|
||||
releaseBlocker := false
|
||||
betaOK := false
|
||||
for _, label := range issue.Labels.Nodes {
|
||||
if label.Name == "release-blocker" {
|
||||
releaseBlocker = true
|
||||
}
|
||||
if label.Name == "okay-after-beta1" {
|
||||
betaOK = true
|
||||
}
|
||||
}
|
||||
if kind == KindBeta && betaOK {
|
||||
releaseBlocker = false
|
||||
}
|
||||
issues[issue.Number] = releaseBlocker
|
||||
}
|
||||
if query.Repository.Issues.PageInfo.HasNextPage {
|
||||
afterToken = &query.Repository.Issues.PageInfo.EndCursor
|
||||
goto more
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// PushIssues moves all the open issues in the current milestone to the next one.
|
||||
func (m *MilestoneTasks) PushIssues(ctx *workflow.TaskContext, milestones ReleaseMilestones) (string, error) {
|
||||
issues, err := m.loadMilestoneIssues(ctx, milestones.Current, KindUnknown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for issueNumber := range issues {
|
||||
_, _, err := m.Client.EditIssue(ctx, m.RepoOwner, m.RepoName, issueNumber, &github.IssueRequest{
|
||||
Milestone: &milestones.Next,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// GitHubClientInterface is a wrapper around the GitHub v3 and v4 APIs, for
|
||||
// testing and dry-run support.
|
||||
type GitHubClientInterface interface {
|
||||
// FetchMilestone returns the number of the requested milestone. If create is true,
|
||||
// and the milestone doesn't exist, it will be created.
|
||||
FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error)
|
||||
|
||||
// See githubv4.Client.Query.
|
||||
Query(ctx context.Context, q interface{}, variables map[string]interface{}) error
|
||||
|
||||
// See github.Client.Issues.Edit.
|
||||
EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error)
|
||||
}
|
||||
|
||||
type GitHubClient struct {
|
||||
V3 *github.Client
|
||||
V4 *githubv4.Client
|
||||
}
|
||||
|
||||
func (c *GitHubClient) Query(ctx context.Context, q interface{}, variables map[string]interface{}) error {
|
||||
return c.V4.Query(ctx, q, variables)
|
||||
}
|
||||
|
||||
func (c *GitHubClient) FetchMilestone(ctx context.Context, owner, repo, name string, create bool) (int, error) {
|
||||
n, found, err := findMilestone(ctx, c.V4, owner, repo, name)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found {
|
||||
return n, nil
|
||||
} else if !create {
|
||||
return 0, fmt.Errorf("no milestone named %q found, and creation was disabled", name)
|
||||
}
|
||||
m, _, createErr := c.V3.Issues.CreateMilestone(ctx, owner, repo, &github.Milestone{
|
||||
Title: github.String(name),
|
||||
})
|
||||
if createErr != nil {
|
||||
return 0, fmt.Errorf("could not find an open milestone named %q and creating it failed: %v", name, createErr)
|
||||
}
|
||||
return *m.Number, nil
|
||||
}
|
||||
|
||||
func findMilestone(ctx context.Context, client *githubv4.Client, owner, repo, name string) (int, bool, error) {
|
||||
var query struct {
|
||||
Repository struct {
|
||||
Milestones struct {
|
||||
Nodes []struct {
|
||||
Title string
|
||||
Number int
|
||||
State string
|
||||
}
|
||||
} `graphql:"milestones(first:10, query: $milestoneName)"`
|
||||
} `graphql:"repository(owner: $repoOwner, name: $repoName)"`
|
||||
}
|
||||
if err := client.Query(ctx, &query, map[string]interface{}{
|
||||
"repoOwner": githubv4.String(owner),
|
||||
"repoName": githubv4.String(repo),
|
||||
"milestoneName": githubv4.String(name),
|
||||
}); err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
// The milestone query is case-insensitive and a partial match; we're okay
|
||||
// with case variations but it needs to be a full match.
|
||||
var open, closed []string
|
||||
milestoneNumber := 0
|
||||
for _, m := range query.Repository.Milestones.Nodes {
|
||||
if strings.ToLower(name) != strings.ToLower(m.Title) {
|
||||
continue
|
||||
}
|
||||
if m.State == "OPEN" {
|
||||
open = append(open, m.Title)
|
||||
milestoneNumber = m.Number
|
||||
} else {
|
||||
closed = append(closed, m.Title)
|
||||
}
|
||||
}
|
||||
// GitHub allows "go" and "Go" to exist at the same time.
|
||||
// If there's any confusion, fail: we expect either one open milestone,
|
||||
// or no matching milestones at all.
|
||||
switch {
|
||||
case len(open) == 1:
|
||||
return milestoneNumber, true, nil
|
||||
case len(open) > 1:
|
||||
return 0, false, fmt.Errorf("multiple open milestones matching %q: %q", name, open)
|
||||
// No open milestones.
|
||||
case len(closed) == 0:
|
||||
return 0, false, nil
|
||||
case len(closed) > 0:
|
||||
return 0, false, fmt.Errorf("no open milestones matching %q, but some closed: %q (re-open or delete?)", name, closed)
|
||||
}
|
||||
// The switch above is exhaustive.
|
||||
panic(fmt.Errorf("unhandled case: open: %q closed: %q", open, closed))
|
||||
}
|
||||
|
||||
func (c *GitHubClient) EditIssue(ctx context.Context, owner string, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) {
|
||||
return c.V3.Issues.Edit(ctx, owner, repo, number, issue)
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-github/github"
|
||||
"github.com/shurcooL/githubv4"
|
||||
"golang.org/x/build/internal/workflow"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
var (
|
||||
flagRun = flag.Bool("run-destructive-milestones-test", false, "Run the milestone test. Requires repository owner and name flags, and GITHUB_TOKEN set in the environment.")
|
||||
flagOwner = flag.String("milestones-github-owner", "", "Owner of testing repository")
|
||||
flagRepo = flag.String("milestones-github-repo", "", "Testing repository")
|
||||
)
|
||||
|
||||
func TestMilestones(t *testing.T) {
|
||||
ctx := &workflow.TaskContext{
|
||||
Context: context.Background(),
|
||||
Logger: &testLogger{t},
|
||||
}
|
||||
|
||||
if !*flagRun {
|
||||
t.Skip("Not enabled by flags")
|
||||
}
|
||||
if *flagOwner == "golang" {
|
||||
t.Fatal("This is a destructive test! Don't run it on a real repository.")
|
||||
}
|
||||
|
||||
src := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: os.Getenv("GITHUB_TOKEN")},
|
||||
)
|
||||
httpClient := oauth2.NewClient(ctx, src)
|
||||
client3 := github.NewClient(httpClient)
|
||||
client4 := githubv4.NewClient(httpClient)
|
||||
|
||||
blocker, err := resetRepo(ctx, client3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tasks := &MilestoneTasks{
|
||||
Client: &GitHubClient{
|
||||
V3: client3,
|
||||
V4: client4,
|
||||
},
|
||||
RepoOwner: *flagOwner,
|
||||
RepoName: *flagRepo,
|
||||
}
|
||||
milestones, err := tasks.FetchMilestones(ctx, "go1.20", KindFinal)
|
||||
if err != nil {
|
||||
t.Fatalf("GetMilestones: %v", err)
|
||||
}
|
||||
_, err = tasks.CheckBlockers(ctx, milestones, KindFinal)
|
||||
if err == nil || !strings.Contains(err.Error(), "open release blockers") {
|
||||
t.Fatalf("CheckBlockers with an open release blocker didn't give expected error: %v", err)
|
||||
}
|
||||
if _, _, err := client3.Issues.Edit(ctx, *flagOwner, *flagRepo, *blocker.Number, &github.IssueRequest{State: github.String("closed")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tasks.CheckBlockers(ctx, milestones, KindFinal); err != nil {
|
||||
t.Fatalf("CheckBlockers with no release blockers failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// resetRepo clears out the test repository and sets it to have:
|
||||
// - a single milestone, Go1.20
|
||||
// - a normal issue in that milestone
|
||||
// - a release blocking issue in that milestone, which is returned.
|
||||
func resetRepo(ctx context.Context, client *github.Client) (*github.Issue, error) {
|
||||
milestones, _, err := client.Issues.ListMilestones(ctx, *flagOwner, *flagRepo, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range milestones {
|
||||
if _, err := client.Issues.DeleteMilestone(ctx, *flagOwner, *flagRepo, *m.Number); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
issues, _, err := client.Issues.ListByRepo(ctx, *flagOwner, *flagRepo, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, i := range issues {
|
||||
if _, _, err := client.Issues.Edit(ctx, *flagOwner, *flagRepo, *i.Number, &github.IssueRequest{
|
||||
State: github.String("CLOSED"),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
currentMilestone, _, err := client.Issues.CreateMilestone(ctx, *flagOwner, *flagRepo, &github.Milestone{Title: github.String("Go1.20")})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, _, err := client.Issues.Create(ctx, *flagOwner, *flagRepo, &github.IssueRequest{
|
||||
Title: github.String("Non-release-blocker"),
|
||||
Milestone: currentMilestone.Number,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocker, _, err := client.Issues.Create(ctx, *flagOwner, *flagRepo, &github.IssueRequest{
|
||||
Title: github.String("Release-blocker"),
|
||||
Milestone: currentMilestone.Number,
|
||||
Labels: &[]string{"release-blocker"},
|
||||
})
|
||||
return blocker, err
|
||||
}
|
||||
|
||||
type testLogger struct {
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (l *testLogger) Printf(format string, v ...interface{}) {
|
||||
l.t.Logf("LOG: %s", fmt.Sprintf(format, v...))
|
||||
}
|
Загрузка…
Ссылка в новой задаче