1// Copyright 2020 Google LLC 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 15package git 16 17import ( 18 "context" 19 "errors" 20 "fmt" 21 "io/ioutil" 22 "log" 23 "os" 24 "os/user" 25 "path" 26 "path/filepath" 27 "strings" 28 "time" 29 30 "cloud.google.com/go/internal/gapicgen/execv" 31 "cloud.google.com/go/internal/gapicgen/execv/gocmd" 32 "github.com/google/go-github/v35/github" 33 "github.com/shurcooL/githubv4" 34 "golang.org/x/oauth2" 35) 36 37const ( 38 gocloudBranchName = "regen_gocloud" 39 gocloudCommitTitle = "chore(all): auto-regenerate gapics" 40 gocloudCommitBody = ` 41This is an auto-generated regeneration of the gapic clients by 42cloud.google.com/go/internal/gapicgen. Once the corresponding genproto PR is 43submitted, genbot will update this PR with a newer dependency to the newer 44version of genproto and assign reviewers to this PR. 45 46If you have been assigned to review this PR, please: 47 48- Ensure that the version of genproto in go.mod has been updated. 49- Ensure that CI is passing. If it's failing, it requires your manual attention. 50- Approve and submit this PR if you believe it's ready to ship. 51` 52 53 genprotoBranchName = "regen_genproto" 54 genprotoCommitTitle = "chore(all): auto-regenerate .pb.go files" 55 genprotoCommitBody = ` 56This is an auto-generated regeneration of the .pb.go files by 57cloud.google.com/go/internal/gapicgen. Once this PR is submitted, genbot will 58update the corresponding PR to depend on the newer version of go-genproto, and 59assign reviewers. Whilst this or any regen PR is open in go-genproto, genbot 60will not create any more regeneration PRs. If all regen PRs are closed, 61gapicgen will create a new set of regeneration PRs once per night. 62 63If you have been assigned to review this PR, please: 64 65- Ensure that CI is passing. If it's failing, it requires your manual attention. 66- Approve and submit this PR if you believe it's ready to ship. That will prompt 67genbot to assign reviewers to the google-cloud-go PR. 68` 69) 70 71// PullRequest represents a GitHub pull request. 72type PullRequest struct { 73 Author string 74 Title string 75 URL string 76 Created time.Time 77 IsOpen bool 78 Number int 79 Repo string 80 IsDraft bool 81 NodeID string 82} 83 84// GithubClient is a convenience wrapper around Github clients. 85type GithubClient struct { 86 cV3 *github.Client 87 cV4 *githubv4.Client 88 // Username is the GitHub username. Read-only. 89 Username string 90} 91 92// NewGithubClient creates a new GithubClient. 93func NewGithubClient(ctx context.Context, username, name, email, accessToken string) (*GithubClient, error) { 94 if err := setGitCreds(name, email, username, accessToken); err != nil { 95 return nil, err 96 } 97 98 ts := oauth2.StaticTokenSource( 99 &oauth2.Token{AccessToken: accessToken}, 100 ) 101 tc := oauth2.NewClient(ctx, ts) 102 return &GithubClient{cV3: github.NewClient(tc), cV4: githubv4.NewClient(tc), Username: username}, nil 103} 104 105// setGitCreds configures credentials for GitHub. 106func setGitCreds(githubName, githubEmail, githubUsername, accessToken string) error { 107 u, err := user.Current() 108 if err != nil { 109 return err 110 } 111 gitCredentials := []byte(fmt.Sprintf("https://%s:%s@github.com", githubUsername, accessToken)) 112 if err := ioutil.WriteFile(path.Join(u.HomeDir, ".git-credentials"), gitCredentials, 0644); err != nil { 113 return err 114 } 115 c := execv.Command("git", "config", "--global", "user.name", githubName) 116 c.Env = []string{ 117 fmt.Sprintf("PATH=%s", os.Getenv("PATH")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 118 fmt.Sprintf("HOME=%s", os.Getenv("HOME")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 119 } 120 if err := c.Run(); err != nil { 121 return err 122 } 123 124 c = execv.Command("git", "config", "--global", "user.email", githubEmail) 125 c.Env = []string{ 126 fmt.Sprintf("PATH=%s", os.Getenv("PATH")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 127 fmt.Sprintf("HOME=%s", os.Getenv("HOME")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 128 } 129 return c.Run() 130} 131 132// GetRegenPR finds the first regen pull request with the given status. Accepted 133// statues are: open, closed, or all. 134func (gc *GithubClient) GetRegenPR(ctx context.Context, repo string, status string) (*PullRequest, error) { 135 log.Printf("getting %v pull requests with status %q", repo, status) 136 137 // We don't bother paginating, because it hurts our requests quota and makes 138 // the page slower without a lot of value. 139 opt := &github.PullRequestListOptions{ 140 ListOptions: github.ListOptions{PerPage: 50}, 141 State: status, 142 } 143 prs, _, err := gc.cV3.PullRequests.List(ctx, "googleapis", repo, opt) 144 if err != nil { 145 return nil, err 146 } 147 for _, pr := range prs { 148 if !strings.Contains(pr.GetTitle(), "auto-regenerate") { 149 continue 150 } 151 if pr.GetUser().GetLogin() != gc.Username { 152 continue 153 } 154 return &PullRequest{ 155 Author: pr.GetUser().GetLogin(), 156 Title: pr.GetTitle(), 157 URL: pr.GetHTMLURL(), 158 Created: pr.GetCreatedAt(), 159 IsOpen: pr.GetState() == "open", 160 Number: pr.GetNumber(), 161 Repo: repo, 162 IsDraft: pr.GetDraft(), 163 NodeID: pr.GetNodeID(), 164 }, nil 165 } 166 return nil, nil 167} 168 169// CreateGenprotoPR creates a PR for a given genproto change. 170// 171// hasCorrespondingPR indicates that there is a corresponding google-cloud-go PR. 172func (gc *GithubClient) CreateGenprotoPR(ctx context.Context, genprotoDir string, hasCorrespondingPR bool, changes []*ChangeInfo) (prNumber int, _ error) { 173 log.Println("creating genproto PR") 174 var sb strings.Builder 175 sb.WriteString(genprotoCommitBody) 176 if !hasCorrespondingPR { 177 sb.WriteString("\n\nThere is no corresponding google-cloud-go PR.\n") 178 sb.WriteString(FormatChanges(changes, false)) 179 } 180 body := sb.String() 181 182 c := execv.Command("/bin/bash", "-c", ` 183set -ex 184 185git config credential.helper store # cache creds from ~/.git-credentials 186 187git branch -D $BRANCH_NAME || true 188git push -d origin $BRANCH_NAME || true 189 190git add -A 191git checkout -b $BRANCH_NAME 192git commit -m "$COMMIT_TITLE" -m "$COMMIT_BODY" 193git push origin $BRANCH_NAME 194`) 195 c.Env = []string{ 196 fmt.Sprintf("PATH=%s", os.Getenv("PATH")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 197 fmt.Sprintf("HOME=%s", os.Getenv("HOME")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 198 fmt.Sprintf("COMMIT_TITLE=%s", genprotoCommitTitle), 199 fmt.Sprintf("COMMIT_BODY=%s", body), 200 fmt.Sprintf("BRANCH_NAME=%s", genprotoBranchName), 201 } 202 c.Dir = genprotoDir 203 if err := c.Run(); err != nil { 204 return 0, err 205 } 206 207 head := fmt.Sprintf("googleapis:" + genprotoBranchName) 208 base := "master" 209 t := genprotoCommitTitle // Because we have to take the address. 210 pr, _, err := gc.cV3.PullRequests.Create(ctx, "googleapis", "go-genproto", &github.NewPullRequest{ 211 Title: &t, 212 Body: &body, 213 Head: &head, 214 Base: &base, 215 }) 216 if err != nil { 217 return 0, err 218 } 219 220 log.Printf("creating genproto PR... done %s\n", pr.GetHTMLURL()) 221 222 return pr.GetNumber(), nil 223} 224 225// CreateGocloudPR creates a PR for a given google-cloud-go change. 226func (gc *GithubClient) CreateGocloudPR(ctx context.Context, gocloudDir string, genprotoPRNum int, changes []*ChangeInfo) (prNumber int, _ error) { 227 log.Println("creating google-cloud-go PR") 228 229 var sb strings.Builder 230 var draft bool 231 sb.WriteString(gocloudCommitBody) 232 if genprotoPRNum > 0 { 233 sb.WriteString(fmt.Sprintf("\n\nCorresponding genproto PR: https://github.com/googleapis/go-genproto/pull/%d\n", genprotoPRNum)) 234 draft = true 235 } else { 236 sb.WriteString("\n\nThere is no corresponding genproto PR.\n") 237 } 238 sb.WriteString(FormatChanges(changes, true)) 239 body := sb.String() 240 241 c := execv.Command("/bin/bash", "-c", ` 242set -ex 243 244git config credential.helper store # cache creds from ~/.git-credentials 245 246git branch -D $BRANCH_NAME || true 247git push -d origin $BRANCH_NAME || true 248 249git add -A 250git checkout -b $BRANCH_NAME 251git commit -m "$COMMIT_TITLE" -m "$COMMIT_BODY" 252git push origin $BRANCH_NAME 253`) 254 c.Env = []string{ 255 fmt.Sprintf("PATH=%s", os.Getenv("PATH")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 256 fmt.Sprintf("HOME=%s", os.Getenv("HOME")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 257 fmt.Sprintf("COMMIT_TITLE=%s", gocloudCommitTitle), 258 fmt.Sprintf("COMMIT_BODY=%s", body), 259 fmt.Sprintf("BRANCH_NAME=%s", gocloudBranchName), 260 } 261 c.Dir = gocloudDir 262 if err := c.Run(); err != nil { 263 return 0, err 264 } 265 266 t := gocloudCommitTitle // Because we have to take the address. 267 pr, _, err := gc.cV3.PullRequests.Create(ctx, "googleapis", "google-cloud-go", &github.NewPullRequest{ 268 Title: &t, 269 Body: &body, 270 Head: github.String(fmt.Sprintf("googleapis:" + gocloudBranchName)), 271 Base: github.String("master"), 272 Draft: github.Bool(draft), 273 }) 274 if err != nil { 275 return 0, err 276 } 277 278 log.Printf("creating google-cloud-go PR... done %s\n", pr.GetHTMLURL()) 279 280 return pr.GetNumber(), nil 281} 282 283// AmendGenprotoPR amends the given genproto PR with a link to the given 284// google-cloud-go PR. 285func (gc *GithubClient) AmendGenprotoPR(ctx context.Context, genprotoPRNum int, genprotoDir string, gocloudPRNum int, changes []*ChangeInfo) error { 286 var body strings.Builder 287 body.WriteString(genprotoCommitBody) 288 body.WriteString(fmt.Sprintf("\n\nCorresponding google-cloud-go PR: googleapis/google-cloud-go#%d\n", gocloudPRNum)) 289 body.WriteString(FormatChanges(changes, false)) 290 sBody := body.String() 291 c := execv.Command("/bin/bash", "-c", ` 292set -ex 293 294git config credential.helper store # cache creds from ~/.git-credentials 295 296git checkout $BRANCH_NAME 297git commit --amend -m "$COMMIT_TITLE" -m "$COMMIT_BODY" 298git push -f origin $BRANCH_NAME 299`) 300 c.Env = []string{ 301 fmt.Sprintf("PATH=%s", os.Getenv("PATH")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 302 fmt.Sprintf("HOME=%s", os.Getenv("HOME")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 303 fmt.Sprintf("COMMIT_TITLE=%s", genprotoCommitTitle), 304 fmt.Sprintf("COMMIT_BODY=%s", sBody), 305 fmt.Sprintf("BRANCH_NAME=%s", genprotoBranchName), 306 } 307 c.Dir = genprotoDir 308 if err := c.Run(); err != nil { 309 return err 310 } 311 _, _, err := gc.cV3.PullRequests.Edit(ctx, "googleapis", "go-genproto", genprotoPRNum, &github.PullRequest{ 312 Body: &sBody, 313 }) 314 return err 315} 316 317// MarkPRReadyForReview switches a draft pull request to a reviewable pull 318// request. 319func (gc *GithubClient) MarkPRReadyForReview(ctx context.Context, repo string, nodeID string) error { 320 var m struct { 321 MarkPullRequestReadyForReview struct { 322 PullRequest struct { 323 ID githubv4.ID 324 } 325 } `graphql:"markPullRequestReadyForReview(input: $input)"` 326 } 327 input := githubv4.MarkPullRequestReadyForReviewInput{ 328 PullRequestID: nodeID, 329 } 330 if err := gc.cV4.Mutate(ctx, &m, input, nil); err != nil { 331 return err 332 } 333 return nil 334} 335 336// UpdateGocloudGoMod updates the go.mod to include latest version of genproto 337// for the given gocloud ref. 338func (gc *GithubClient) UpdateGocloudGoMod() error { 339 tmpDir, err := ioutil.TempDir("", "finalize-github-pr") 340 if err != nil { 341 return err 342 } 343 defer os.RemoveAll(tmpDir) 344 345 if err := checkoutCode(tmpDir); err != nil { 346 return err 347 } 348 if err := updateDeps(tmpDir); err != nil { 349 return err 350 } 351 if err := addAndPushCode(tmpDir); err != nil { 352 return err 353 } 354 355 return nil 356} 357 358func checkoutCode(tmpDir string) error { 359 c := execv.Command("/bin/bash", "-c", ` 360set -ex 361 362git init 363git remote add origin https://github.com/googleapis/google-cloud-go 364git fetch --all 365git checkout $BRANCH_NAME 366`) 367 c.Env = []string{ 368 fmt.Sprintf("BRANCH_NAME=%s", gocloudBranchName), 369 } 370 c.Dir = tmpDir 371 return c.Run() 372} 373 374func updateDeps(tmpDir string) error { 375 // Find directories that had code changes. 376 c := execv.Command("git", "diff", "--name-only", "HEAD", "HEAD~1") 377 c.Dir = tmpDir 378 out, err := c.Output() 379 if err != nil { 380 return err 381 } 382 files := strings.Split(string(out), "\n") 383 dirs := map[string]bool{} 384 for _, file := range files { 385 if strings.HasPrefix(file, "internal") { 386 continue 387 } 388 dir := filepath.Dir(file) 389 dirs[filepath.Join(tmpDir, dir)] = true 390 } 391 392 // Find which modules had code changes. 393 updatedModDirs := map[string]bool{} 394 for dir := range dirs { 395 modDir, err := gocmd.ListModDirName(dir) 396 if err != nil { 397 if errors.Is(err, gocmd.ErrBuildConstraint) { 398 continue 399 } 400 return err 401 } 402 updatedModDirs[modDir] = true 403 } 404 405 // Update required modules. 406 for modDir := range updatedModDirs { 407 log.Printf("Updating module dir %q", modDir) 408 c := execv.Command("/bin/bash", "-c", ` 409set -ex 410 411go get -d google.golang.org/api | true # We don't care that there's no files at root. 412go get -d google.golang.org/genproto | true # We don't care that there's no files at root. 413`) 414 c.Dir = modDir 415 if err := c.Run(); err != nil { 416 return err 417 } 418 } 419 420 // Tidy all modules 421 return gocmd.ModTidyAll(tmpDir) 422} 423 424func addAndPushCode(tmpDir string) error { 425 c := execv.Command("/bin/bash", "-c", ` 426set -ex 427 428git add -A 429filesUpdated=$( git status --short | wc -l ) 430if [ $filesUpdated -gt 0 ]; 431then 432 git config credential.helper store # cache creds from ~/.git-credentials 433 git commit --amend --no-edit 434 git push -f origin $BRANCH_NAME 435fi 436`) 437 c.Env = []string{ 438 fmt.Sprintf("BRANCH_NAME=%s", gocloudBranchName), 439 fmt.Sprintf("PATH=%s", os.Getenv("PATH")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 440 fmt.Sprintf("HOME=%s", os.Getenv("HOME")), // TODO(deklerk): Why do we need to do this? Doesn't seem to be necessary in other exec.Commands. 441 } 442 c.Dir = tmpDir 443 return c.Run() 444} 445