Files
teleport/assets/backport/github/github.go
T

324 lines
10 KiB
Go

/*
Copyright 2022 Gravitational, Inc.
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 github
import (
"context"
"fmt"
"github.com/gravitational/trace"
go_github "github.com/google/go-github/v41/github"
"golang.org/x/oauth2"
)
type Client struct {
Client *go_github.Client
c Config
}
type Config struct {
// Token is the Github auth token.
Token string
// Repository is the name of the repository to create
// the backport pull requests in.
Repository string
// Organization is the organization/owner name of the
// repository.
Organization string
}
// New returns a new GitHub client.
func New(ctx context.Context, c *Config) (*Client, error) {
if err := c.Check(); err != nil {
return nil, trace.Wrap(err)
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: c.Token},
)
return &Client{
Client: go_github.NewClient(oauth2.NewClient(ctx, ts)),
c: *c,
}, nil
}
// Check validates config.
func (c *Config) Check() error {
if c.Token == "" {
return trace.BadParameter("missing parameter Token")
}
if c.Organization == "" {
return trace.BadParameter("missing parameter Organization")
}
if c.Repository == "" {
return trace.BadParameter("missing parameter Repository")
}
return nil
}
// Backport backports changes from backportBranchName to a new branch based
// off baseBranchName.
//
// A new branch is created with the name in the format of
// auto-backport/[pull number]-to-[base branch], and
// cherry-picks commits onto the new branch.
func (c *Client) Backport(ctx context.Context, baseBranchName string, pullNumber int) (string, error) {
newBranchName := fmt.Sprintf("auto-backport/%v-to-%s", pullNumber, baseBranchName)
// Create a new branch off of the target branch.
err := c.createBranchFrom(ctx, baseBranchName, newBranchName)
if err != nil {
return "", trace.Wrap(err)
}
fmt.Printf("Created a new branch: %s.\n", newBranchName)
commits, err := c.getPullRequestCommits(ctx, pullNumber)
if err != nil {
return "", trace.Wrap(err)
}
fmt.Printf("Found %v commits. \n", len(commits))
// Cherry pick commits.
err = c.cherryPickCommitsOnBranch(ctx, newBranchName, commits)
if err != nil {
return "", trace.Wrap(err)
}
return newBranchName, nil
}
// CreatePullRequest creates a pull request.
func (c *Client) CreatePullRequest(ctx context.Context, baseBranch string, headBranch string, originalPrNumber int) error {
titleAndBody := fmt.Sprintf("Backport #%v to %s", originalPrNumber, baseBranch)
newPR := &go_github.NewPullRequest{
Title: go_github.String(titleAndBody),
Head: go_github.String(headBranch),
Base: go_github.String(baseBranch),
Body: go_github.String(titleAndBody),
}
_, _, err := c.Client.PullRequests.Create(ctx, c.c.Organization, c.c.Repository, newPR)
if err != nil {
return trace.Wrap(err)
}
return nil
}
// getPullRequestCommits gets the commits for a pull request.
func (c *Client) getPullRequestCommits(ctx context.Context, number int) (commits []string, err error) {
var commitSHAs []string
opts := go_github.ListOptions{
Page: 0,
PerPage: perPage,
}
for {
currCommits, resp, err := c.Client.PullRequests.ListCommits(ctx,
c.c.Organization,
c.c.Repository,
number, &go_github.ListOptions{})
if err != nil {
return nil, trace.Wrap(err)
}
for _, commit := range currCommits {
commitSHAs = append(commitSHAs, commit.GetSHA())
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return commitSHAs, nil
}
// cherryPickCommitsOnBranch cherry picks a list of commits onto the given branch.
func (c *Client) cherryPickCommitsOnBranch(ctx context.Context, branchName string, commits []string) error {
branch, _, err := c.Client.Repositories.GetBranch(ctx, c.c.Organization, c.c.Repository, branchName, true)
if err != nil {
return trace.Wrap(err)
}
// Get the branch's HEAD.
headCommit, _, err := c.Client.Git.GetCommit(ctx,
c.c.Organization,
c.c.Repository,
branch.GetCommit().GetSHA())
if err != nil {
return trace.Wrap(err)
}
for _, commit := range commits {
cherryCommit, _, err := c.Client.Git.GetCommit(ctx, c.c.Organization, c.c.Repository, commit)
if err != nil {
return trace.Wrap(err)
}
// Skip merge commits. The commit to cherry-pick MUST have only 1 parent.
if len(cherryCommit.Parents) != 1 {
fmt.Printf("Skipping merge commit: %s\n", cherryCommit.GetMessage())
continue
}
fmt.Printf("%s %s\n", cherryCommit.GetSHA(), cherryCommit.GetMessage())
tree, sha, err := c.cherryPickCommit(ctx, branchName, cherryCommit, headCommit)
if err != nil {
fmt.Printf("failed to cherry pick commit: %s %s\n", cherryCommit.GetSHA(), cherryCommit.GetMessage())
return trace.Errorf("please manually delete branch %s: %v", branchName, err)
}
headCommit.SHA = &sha
headCommit.Tree = tree
}
return nil
}
// cherryPickCommit cherry picks a single commit on a branch.
func (c *Client) cherryPickCommit(ctx context.Context, branchName string, cherryCommit, headBranchCommit *go_github.Commit) (*go_github.Tree, string, error) {
cherryParent := cherryCommit.Parents[0]
// Temporarily set the parent of the branch HEAD to the parent of the commit
// to cherry-pick so they are siblings.
err := c.createSiblingCommit(ctx, branchName, headBranchCommit, cherryParent)
if err != nil {
return nil, "", trace.Wrap(err)
}
// When git performs the merge, it detects that the parent of the branch commit that is
// being merged onto matches the parent of the cherry pick commit, and merges a tree of size 1.
// The merge commit will contain the delta between the file tree in target branch and the
// commit to cherry-pick.
merge, err := c.merge(ctx, branchName, cherryCommit.GetSHA())
if err != nil {
return nil, "", trace.Wrap(err)
}
mergeTree := merge.GetTree()
updatedCommit, _, err := c.Client.Git.GetCommit(ctx,
c.c.Organization,
c.c.Repository,
headBranchCommit.GetSHA())
if err != nil {
return nil, "", trace.Wrap(err)
}
// Create the actual cherry-pick commit on the target branch containing the merge commit tree.
commit, _, err := c.Client.Git.CreateCommit(ctx, c.c.Organization, c.c.Repository, &go_github.Commit{
Message: cherryCommit.Message,
Tree: mergeTree,
Parents: []*go_github.Commit{
updatedCommit,
},
})
if err != nil {
return nil, "", trace.Wrap(err)
}
// Overwrite the merge commit and its parent on the branch by the newly created commit.
// The result will be equivalent to what would have happened with a fast-forward merge.
sha := commit.GetSHA()
refName := fmt.Sprintf("%s%s", branchRefPrefix, branchName)
_, _, err = c.Client.Git.UpdateRef(ctx, c.c.Organization, c.c.Repository, &go_github.Reference{
Ref: go_github.String(refName),
Object: &go_github.GitObject{
SHA: go_github.String(sha),
},
}, true)
if err != nil {
return nil, "", trace.Wrap(err)
}
return mergeTree, sha, nil
}
// createSiblingCommit creates a commit with the passed in commit's tree and parent
// and updates the passed in branch to point at that commit.
func (c *Client) createSiblingCommit(ctx context.Context, branchName string, branchHeadCommit *go_github.Commit, cherryParent *go_github.Commit) error {
tree := branchHeadCommit.GetTree()
// This sibling commit is temporary commit to later merge for a tree size of 1.
// The commit message does not matter as this commit will not be in the final
// branch.
commit, _, err := c.Client.Git.CreateCommit(ctx, c.c.Organization, c.c.Repository, &go_github.Commit{
Message: go_github.String("field-not-required"),
Tree: tree,
Parents: []*go_github.Commit{
cherryParent,
},
})
if err != nil {
return trace.Wrap(err)
}
sha := commit.GetSHA()
refName := fmt.Sprintf("%s%s", branchRefPrefix, branchName)
_, _, err = c.Client.Git.UpdateRef(ctx, c.c.Organization, c.c.Repository, &go_github.Reference{
Ref: go_github.String(refName),
Object: &go_github.GitObject{
SHA: go_github.String(sha),
},
}, true)
if err != nil {
return trace.Wrap(err)
}
return nil
}
// createBranchFrom creates a new branch pointing at the same commit as the supplied branch.
func (c *Client) createBranchFrom(ctx context.Context, branchFromName string, newBranchName string) error {
baseBranch, _, err := c.Client.Repositories.GetBranch(ctx, c.c.Organization, c.c.Repository, branchFromName, true)
if err != nil {
return trace.Wrap(err)
}
newRefBranchName := fmt.Sprintf("%s%s", branchRefPrefix, newBranchName)
baseBranchSHA := baseBranch.GetCommit().GetSHA()
ref := &go_github.Reference{
Ref: go_github.String(newRefBranchName),
Object: &go_github.GitObject{
SHA: go_github.String(baseBranchSHA), /* SHA to branch from */
},
}
_, _, err = c.Client.Git.CreateRef(ctx, c.c.Organization, c.c.Repository, ref)
if err != nil {
return trace.Wrap(err)
}
return nil
}
// merge merges a branch at `headCommitSHA` into branch `base`
func (c *Client) merge(ctx context.Context, base string, headCommitSHA string) (*go_github.Commit, error) {
merge, _, err := c.Client.Repositories.Merge(ctx, c.c.Organization, c.c.Repository, &go_github.RepositoryMergeRequest{
Base: go_github.String(base),
Head: go_github.String(headCommitSHA),
})
if err != nil {
return nil, trace.Errorf("err: %v. failed to merge %s into %s", err, headCommitSHA, base)
}
mergeCommit, _, err := c.Client.Git.GetCommit(ctx,
c.c.Organization,
c.c.Repository,
merge.GetSHA())
if err != nil {
return nil, trace.Wrap(err)
}
return mergeCommit, nil
}
const (
// perPage is the number of items per page to request.
perPage = 100
// branchRefPrefix is the prefix for a reference that is
// pointing to a branch.
branchRefPrefix = "refs/heads/"
)