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