minikube/hack/update/github.go

263 lines
9.2 KiB
Go

/*
Copyright 2020 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package update
import (
"context"
"fmt"
"os"
"strings"
"time"
"golang.org/x/mod/semver"
"golang.org/x/oauth2"
"github.com/google/go-github/v36/github"
"k8s.io/klog/v2"
)
const (
// ghListPerPage uses max value (100) for PerPage to avoid hitting the rate limits.
// (ref: https://godoc.org/github.com/google/go-github/github#hdr-Rate_Limiting)
ghListPerPage = 100
// ghSearchLimit limits the number of searched items to be <= N * ghListPerPage.
ghSearchLimit = 100
)
var (
// GitHub repo data
ghToken = os.Getenv("GITHUB_TOKEN")
ghOwner = "kubernetes"
ghRepo = "minikube"
ghBase = "master" // could be "main" in the near future?
)
// ghCreatePR returns PR created in the GitHub owner/repo, applying the changes to the base head commit fork, as defined by the schema and data.
// Returns any error occurred.
// PR branch will be named by the branch, sufixed by '_' and first 7 characters of the fork commit SHA.
// PR itself will be named by the title and will reference the issue.
func ghCreatePR(ctx context.Context, owner, repo, base, branch, title string, issue int, token string, schema map[string]Item, data interface{}) (*github.PullRequest, error) {
ghc := ghClient(ctx, token)
// get base branch
baseBranch, _, err := ghc.Repositories.GetBranch(ctx, owner, repo, base)
if err != nil {
return nil, fmt.Errorf("unable to get base branch: %w", err)
}
// get base commit
baseCommit, _, err := ghc.Repositories.GetCommit(ctx, owner, repo, *baseBranch.Commit.SHA)
if err != nil {
return nil, fmt.Errorf("unable to get base commit: %w", err)
}
// get base tree
baseTree, _, err := ghc.Git.GetTree(ctx, owner, repo, baseCommit.GetSHA(), true)
if err != nil {
return nil, fmt.Errorf("unable to get base tree: %w", err)
}
// update files
changes, err := ghUpdate(ctx, owner, repo, token, schema, data)
if err != nil {
return nil, fmt.Errorf("unable to update files: %w", err)
}
if changes == nil {
return nil, nil
}
// create fork
fork, resp, err := ghc.Repositories.CreateFork(ctx, owner, repo, nil)
// "This method might return an *AcceptedError and a status code of 202.
// This is because this is the status that GitHub returns to signify that it is now computing creating the fork in a background task.
// In this event, the Repository value will be returned, which includes the details about the pending fork.
// A follow up request, after a delay of a second or so, should result in a successful request."
// (ref: https://pkg.go.dev/github.com/google/go-github/v32@v32.1.0/github#RepositoriesService.CreateFork)
if resp.StatusCode == 202 { // *AcceptedError
time.Sleep(time.Second * 5)
} else if err != nil {
return nil, fmt.Errorf("unable to create fork: %w", err)
}
// create fork tree from base and changed files
forkTree, _, err := ghc.Git.CreateTree(ctx, *fork.Owner.Login, *fork.Name, *baseTree.SHA, changes)
if err != nil {
return nil, fmt.Errorf("unable to create fork tree: %w", err)
}
// create fork commit
forkCommit, _, err := ghc.Git.CreateCommit(ctx, *fork.Owner.Login, *fork.Name, &github.Commit{
Message: github.String(title),
Tree: &github.Tree{SHA: forkTree.SHA},
Parents: []*github.Commit{{SHA: baseCommit.SHA}},
})
if err != nil {
return nil, fmt.Errorf("unable to create fork commit: %w", err)
}
klog.Infof("PR commit '%s' successfully created: %s", forkCommit.GetSHA(), forkCommit.GetHTMLURL())
// create PR branch
prBranch := branch + forkCommit.GetSHA()[:7]
prRef, _, err := ghc.Git.CreateRef(ctx, *fork.Owner.Login, *fork.Name, &github.Reference{
Ref: github.String("refs/heads/" + prBranch),
Object: &github.GitObject{
Type: github.String("commit"),
SHA: forkCommit.SHA,
},
})
if err != nil {
return nil, fmt.Errorf("unable to create PR branch: %w", err)
}
klog.Infof("PR branch '%s' successfully created: %s", prBranch, prRef.GetURL())
// create PR
_, pretty, err := GetPlan(schema, data)
if err != nil {
klog.Fatalf("Unable to parse schema: %v\n%s", err, pretty)
}
modifiable := true
pr, _, err := ghc.PullRequests.Create(ctx, owner, repo, &github.NewPullRequest{
Title: github.String(title),
Head: github.String(*fork.Owner.Login + ":" + prBranch),
Base: github.String(base),
Body: github.String(fmt.Sprintf("fixes: #%d\n\nAutomatically created PR to update repo according to the Plan:\n\n```\n%s\n```", issue, pretty)),
MaintainerCanModify: &modifiable,
})
if err != nil {
return nil, fmt.Errorf("unable to create PR: %w", err)
}
return pr, nil
}
// ghFindPR returns URL of the PR if found in the given GitHub ower/repo base and any error occurred.
func ghFindPR(ctx context.Context, title, owner, repo, base, token string) (url string, err error) {
ghc := ghClient(ctx, token)
// walk through the paginated list of up to ghSearchLimit newest pull requests
opts := &github.PullRequestListOptions{State: "all", Base: base, ListOptions: github.ListOptions{PerPage: ghListPerPage}}
for (opts.Page+1)*ghListPerPage <= ghSearchLimit {
prs, resp, err := ghc.PullRequests.List(ctx, owner, repo, opts)
if err != nil {
return "", err
}
for _, pr := range prs {
if pr.GetTitle() == title {
return pr.GetHTMLURL(), nil
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return "", nil
}
// ghUpdate updates remote GitHub owner/repo tree according to the given token, schema and data.
// Returns resulting changes, and any error occurred.
func ghUpdate(ctx context.Context, owner, repo string, token string, schema map[string]Item, data interface{}) (changes []*github.TreeEntry, err error) {
ghc := ghClient(ctx, token)
// load each schema item content and update it creating new GitHub TreeEntries
for path, item := range schema {
// if the item's content is already set, give it precedence over any current file content
var content string
if item.Content == nil {
file, _, _, err := ghc.Repositories.GetContents(ctx, owner, repo, path, &github.RepositoryContentGetOptions{Ref: ghBase})
if err != nil {
return nil, fmt.Errorf("unable to get file content: %w", err)
}
content, err = file.GetContent()
if err != nil {
return nil, fmt.Errorf("unable to read file content: %w", err)
}
item.Content = []byte(content)
}
if err := item.apply(data); err != nil {
return nil, fmt.Errorf("unable to update file: %w", err)
}
if content != string(item.Content) {
// add github.TreeEntry that will replace original path content with the updated one or add new if one doesn't exist already
// ref: https://developer.github.com/v3/git/trees/#tree-object
rcPath := path // make sure to copy path variable as its reference (not value!) is passed to changes
rcMode := "100644"
rcType := "blob"
changes = append(changes, &github.TreeEntry{
Path: &rcPath,
Mode: &rcMode,
Type: &rcType,
Content: github.String(string(item.Content)),
})
}
}
return changes, nil
}
// GHReleases returns greatest current stable release and greatest latest rc or beta pre-release from GitHub owner/repo repository, and any error occurred.
// If latest pre-release version is lower than the current stable release, then it will return current stable release for both.
func GHReleases(ctx context.Context, owner, repo string) (stable, latest string, err error) {
ghc := ghClient(ctx, ghToken)
// walk through the paginated list of up to ghSearchLimit newest releases
opts := &github.ListOptions{PerPage: ghListPerPage}
for (opts.Page+1)*ghListPerPage <= ghSearchLimit {
rls, resp, err := ghc.Repositories.ListTags(ctx, owner, repo, opts)
if err != nil {
return "", "", err
}
for _, rl := range rls {
ver := *rl.Name
if !semver.IsValid(ver) {
continue
}
// check if ver version is release (ie, 'v1.19.2') or pre-release (ie, 'v1.19.3-rc.0' or 'v1.19.0-beta.2')
prerls := semver.Prerelease(ver)
if prerls == "" {
if semver.Compare(ver, stable) == 1 {
stable = ver
}
} else if strings.HasPrefix(prerls, "-rc") || strings.HasPrefix(prerls, "-beta") {
if semver.Compare(ver, latest) == 1 {
latest = ver
}
}
// make sure that latest >= stable
if semver.Compare(latest, stable) == -1 {
latest = stable
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return stable, latest, nil
}
// ghClient returns GitHub Client with a given context and optional token for authenticated requests.
func ghClient(ctx context.Context, token string) *github.Client {
if token == "" {
return github.NewClient(nil)
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
tc := oauth2.NewClient(ctx, ts)
return github.NewClient(tc)
}