github.com/google/trillian-examples@v0.0.0-20240520080811-0d40d35cef0e/internal/github/github.go (about)

     1  // Copyright 2021 Google LLC. All Rights Reserved.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package github contains libraries for using github repositories that
    16  // make serverless operations easy to follow.
    17  package github
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"errors"
    23  	"fmt"
    24  	"net/http"
    25  	"os"
    26  	"strings"
    27  
    28  	"github.com/golang/glog"
    29  	gh_api "github.com/google/go-github/v39/github"
    30  	"golang.org/x/oauth2"
    31  )
    32  
    33  // RepoID identifies a github project, including owner & repo.
    34  type RepoID struct {
    35  	Owner    string
    36  	RepoName string
    37  }
    38  
    39  // String returns "<owner>/<repo>".
    40  func (r RepoID) String() string {
    41  	return fmt.Sprintf("%s/%s", r.Owner, r.RepoName)
    42  }
    43  
    44  // NewRepoID creates a new RepoID struct from an owner/repo fragment.
    45  func NewRepoID(or string) (RepoID, error) {
    46  	s := strings.Split(or, "/")
    47  	if l, want := len(s), 2; l != want {
    48  		return RepoID{}, fmt.Errorf("can't split owner/repo %q, found %d parts want %d", or, l, want)
    49  	}
    50  	return RepoID{
    51  		Owner:    s[0],
    52  		RepoName: s[1],
    53  	}, nil
    54  }
    55  
    56  // NewRepository creates a wrapper around a git repository which has a fork owned by
    57  // the user, and an upstream repository configured that PRs can be proposed against.
    58  func NewRepository(ctx context.Context, c *http.Client, upstream RepoID, upstreamBranch string, fork RepoID, ghUser, ghEmail, ghToken string) (Repository, error) {
    59  	ctx = context.WithValue(ctx, oauth2.HTTPClient, c)
    60  	repo := Repository{
    61  		upstream:       upstream,
    62  		upstreamBranch: upstreamBranch,
    63  		fork:           fork,
    64  		user:           ghUser,
    65  		email:          ghEmail,
    66  	}
    67  	var err error
    68  	repo.ghCli, err = authWithGithub(ctx, ghToken)
    69  	return repo, err
    70  }
    71  
    72  // authWithGithub returns a github client struct which uses the provided OAuth token.
    73  // These tokens can be created using the github -> Settings -> Developers -> Personal Authentication Tokens page.
    74  func authWithGithub(ctx context.Context, ghToken string) (*gh_api.Client, error) {
    75  	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: ghToken})
    76  	tc := oauth2.NewClient(ctx, ts)
    77  	return gh_api.NewClient(tc), nil
    78  }
    79  
    80  // Repository represents a github repository with a working area and an upstream.
    81  // The upstream repo must allow PRs. The working fork is usually owned by the user;
    82  // commits are pushed to branches here and proposed against the upstream repository.
    83  type Repository struct {
    84  	// upstream is the original repository, and fork is the user's clone of it.
    85  	// Changes will be made and pushed to fork, and PRs proposed to upstream.
    86  	upstream, fork RepoID
    87  	// upstreamBranch is the name of the upstreamBranch/main branch that PRs will be proposed against.
    88  	upstreamBranch string
    89  	user, email    string
    90  	ghCli          *gh_api.Client
    91  }
    92  
    93  func (r Repository) String() string {
    94  	return fmt.Sprintf("%s → %s", r.fork, r.upstream)
    95  }
    96  
    97  // CreateOrUpdateBranch attempts to create a new branch on the fork repo if it doesn't already exist, or
    98  // rebase it onto HEAD if it does.
    99  func (r *Repository) CreateOrUpdateBranch(ctx context.Context, branchName string) error {
   100  	baseRef, _, err := r.ghCli.Git.GetRef(ctx, r.upstream.Owner, r.upstream.RepoName, "refs/heads/"+r.upstreamBranch)
   101  	if err != nil {
   102  		return fmt.Errorf("failed to get %s ref: %v", r.upstreamBranch, err)
   103  	}
   104  	branch := "refs/heads/" + branchName
   105  	newRef := &gh_api.Reference{Ref: gh_api.String(branch), Object: baseRef.Object}
   106  
   107  	if _, rsp, err := r.ghCli.Git.GetRef(ctx, r.fork.Owner, r.fork.RepoName, branch); err != nil {
   108  		if rsp == nil || rsp.StatusCode != 404 {
   109  			return fmt.Errorf("failed to check for existing branch %q: %v", branchName, err)
   110  		}
   111  		// Branch doesn't exist, so we'll create it:
   112  		_, _, err = r.ghCli.Git.CreateRef(ctx, r.fork.Owner, r.fork.RepoName, newRef)
   113  		return err
   114  	}
   115  	// The branch already exists, so we'll update it:
   116  	_, _, err = r.ghCli.Git.UpdateRef(ctx, r.fork.Owner, r.fork.RepoName, newRef, true)
   117  	return err
   118  }
   119  
   120  // ReadFile returns the contents of the specified path from the configured upstream repo.
   121  func (r *Repository) ReadFile(ctx context.Context, path string) ([]byte, error) {
   122  	f, _, resp, err := r.ghCli.Repositories.GetContents(ctx, r.upstream.Owner, r.upstream.RepoName, path, nil)
   123  	if err != nil {
   124  		if resp != nil && resp.StatusCode == 404 {
   125  			return nil, os.ErrNotExist
   126  		}
   127  		return nil, fmt.Errorf("failed to GetContents(%q): %v", path, err)
   128  	}
   129  	s, err := f.GetContent()
   130  	return []byte(s), err
   131  }
   132  
   133  // CommitFile creates a commit on a repo's worktree which overwrites the specified file path
   134  // with the provided bytes.
   135  func (r *Repository) CommitFile(ctx context.Context, path string, raw []byte, branch, commitMsg string) error {
   136  	opts := &gh_api.RepositoryContentFileOptions{
   137  		Message: gh_api.String(commitMsg),
   138  		Content: raw,
   139  		Branch:  gh_api.String(branch),
   140  	}
   141  	cRsp, _, err := r.ghCli.Repositories.CreateFile(ctx, r.fork.Owner, r.fork.RepoName, path, opts)
   142  	if err != nil {
   143  		return fmt.Errorf("failed to CreateFile(%q): %v", path, err)
   144  	}
   145  	glog.V(2).Infof("Commit %s updated %q on %s", cRsp.Commit, path, branch)
   146  	return nil
   147  }
   148  
   149  // CreatePR creates a pull request.
   150  // Based on: https://godoc.org/github.com/google/go-github/github#example-PullRequestsService-Create
   151  func (r *Repository) CreatePR(ctx context.Context, title, commitBranch string) error {
   152  	if title == "" {
   153  		return errors.New("missing `title`, won't create PR")
   154  	}
   155  
   156  	newPR := &gh_api.NewPullRequest{
   157  		Title:               gh_api.String(title),
   158  		Head:                gh_api.String(r.fork.Owner + ":" + commitBranch),
   159  		Base:                gh_api.String(r.upstreamBranch),
   160  		MaintainerCanModify: gh_api.Bool(true),
   161  	}
   162  
   163  	prJSON, err := json.Marshal(newPR)
   164  	if err != nil {
   165  		return fmt.Errorf("failed to marshal JSON for new PR request: %v", err)
   166  	}
   167  	glog.V(2).Infof("Creating PR:\n%s", prJSON)
   168  
   169  	pr, _, err := r.ghCli.PullRequests.Create(ctx, r.upstream.Owner, r.upstream.RepoName, newPR)
   170  	if err != nil {
   171  		return err
   172  	}
   173  
   174  	glog.V(1).Infof("PR created: %q %s", title, pr.GetHTMLURL())
   175  	return nil
   176  }