From 03527d4c0ce37b43b25e1334ae8176ea2452fa32 Mon Sep 17 00:00:00 2001 From: cw-kajiwara Date: Thu, 8 Apr 2021 16:39:04 +0900 Subject: [PATCH 1/2] support pull request approval --- cmd/review.go | 57 ++++++++++++++++++++++++ pkg/api/review.go | 34 +++++++++++++++ pkg/api/review_test.go | 98 ++++++++++++++++++++++++++++++++++++++++++ pkg/pr/review.go | 57 ++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 cmd/review.go create mode 100644 pkg/api/review.go create mode 100644 pkg/api/review_test.go create mode 100644 pkg/pr/review.go diff --git a/cmd/review.go b/cmd/review.go new file mode 100644 index 0000000..803eefb --- /dev/null +++ b/cmd/review.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/k-kinzal/pr/pkg/pr" + "github.com/spf13/cobra" + "golang.org/x/xerrors" +) + +func ReviewRun(cmd *cobra.Command, args []string) error { + pulls, err := pr.Review(owner, repo, reviewOption) + if err != nil { + if _, ok := err.(*pr.NoMatchError); ok { + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stdout, "[]") + if exitCode { + os.Exit(127) + } + return nil + } + return err + } + + out, err := json.Marshal(pulls) + if err != nil { + return xerrors.Errorf("review: %s", err) + } + fmt.Fprintln(os.Stdout, string(out)) + + return nil +} + +var ( + reviewOption *pr.ReviewOption + reviewCmd = &cobra.Command{ + Use: "review owner/repo", + Short: "Add review to PRs that match rules", + RunE: ReviewRun, + SilenceErrors: true, + SilenceUsage: true, + } +) + +func setReviewFrags(cmd *cobra.Command) *pr.ReviewOption { + opt := &pr.ReviewOption{} + cmd.Flags().StringVar(&opt.Action, "action", "approve", "review action to take. currently, approve is only supported") + return opt +} + +func init() { + reviewOption = setReviewFrags(reviewCmd) + reviewOption.ListOption = setListFrags(reviewCmd) + rootCmd.AddCommand(reviewCmd) +} diff --git a/pkg/api/review.go b/pkg/api/review.go new file mode 100644 index 0000000..299e178 --- /dev/null +++ b/pkg/api/review.go @@ -0,0 +1,34 @@ +package api + +import ( + "context" + "github.com/google/go-github/v28/github" + "golang.org/x/sync/errgroup" +) + +type ReviewOption struct { + Action string +} + +func (c *Client) AddApproval(ctx context.Context, pulls []*PullRequest, opt *ReviewOption) ([]*PullRequest, error) { + eg, ctx := errgroup.WithContext(ctx) + + for _, pull := range pulls { + eg.Go(func(pull *PullRequest) func() error { + return func() error { + pullRequestReviewRequest := &github.PullRequestReviewRequest{Event: github.String(opt.Action)} + pullRequestReview, _, err := c.github.PullRequests.CreateReview(ctx, pull.Owner, pull.Repo, int(pull.Number), pullRequestReviewRequest) + if err != nil { + return err + } + pull.Reviews = append(pull.Reviews, newPullRequestReview(pullRequestReview)) + return nil + } + }(pull)) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + return pulls, nil +} diff --git a/pkg/api/review_test.go b/pkg/api/review_test.go new file mode 100644 index 0000000..8bf3009 --- /dev/null +++ b/pkg/api/review_test.go @@ -0,0 +1,98 @@ +package api_test + +import ( + "context" + "encoding/json" + "fmt" + "github.com/google/go-github/v28/github" + "math" + "net/http" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/k-kinzal/pr/pkg/api" + "github.com/k-kinzal/pr/test/gen" +) + +func TestClient_AddApproval(t *testing.T) { + gen.Reset() + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("POST", "=~^https://api.github.com/repos/octocat/Hello-World/pulls/\\d+/reviews", func(request *http.Request) (response *http.Response, e error) { + var req struct { + Action *string `json:"event"` + } + if err := json.NewDecoder(request.Body).Decode(&req); err != nil { + return nil, err + } + + pullRequestReview, err := gen.PullRequestReview() + if err != nil { + return nil, err + } + + pullRequestReview.State = github.String("APPROVED") + + resp, err := httpmock.NewJsonResponse(200, pullRequestReview) + if err != nil { + return nil, err + } + resp.Header.Add("X-RateLimit-Limit", "5000") + resp.Header.Add("X-RateLimit-Remaining", "4999") + resp.Header.Add("X-RateLimit-Reset", fmt.Sprint(time.Now().Unix())) + resp.Request = request + + return resp, nil + }) + + pulls := []*api.PullRequest{ + { + Id: 1, + Number: 1, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + }, + { + Id: 2, + Number: 2, + State: "open", + Head: &api.PullRequestBranch{ + Sha: "6dcb09b5b57875f334f61aebed695e2e4193db5e", + }, + Owner: "octocat", + Repo: "Hello-World", + }, + } + + ctx := context.Background() + client := api.NewClient(ctx, &api.Options{ + Token: "xxxx", + RateLimit: math.MaxInt32, + }) + opt := &api.ReviewOption{ + Action: "approve", + } + + pulls, err := client.AddApproval(ctx, pulls, opt) + if err != nil { + t.Fatal(err) + } + for i, pull := range pulls { + for j, review := range pull.Reviews { + if review.State != "APPROVED" { + t.Fatalf("pull[`%d`].reviews[`%d`]): expect APPROVED, but actual `%s`", i, j, review.State) + } + } + } + + info := httpmock.GetCallCountInfo() + if info["POST =~^https://api.github.com/repos/octocat/Hello-World/pulls/\\d+/reviews"] != 2 { + t.Fatalf("expect `2`, but actual `%d`: %#v", info["POST =~^https://api.github.com/repos/octocat/Hello-World/pulls/\\d+/reviews"], info) + } +} diff --git a/pkg/pr/review.go b/pkg/pr/review.go new file mode 100644 index 0000000..9fa7605 --- /dev/null +++ b/pkg/pr/review.go @@ -0,0 +1,57 @@ +package pr + +import ( + "context" + "fmt" + "github.com/k-kinzal/pr/pkg/api" + "strings" +) + +const ( + ReviewActionAddApproval = "APPROVE" +) + +type ReviewOption struct { + Action string + *ListOption +} + +func Review(owner string, repo string, opt *ReviewOption) ([]*api.PullRequest, error) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + clientOption := &api.Options{ + Token: token, + RateLimit: opt.Rate, + } + client := api.NewClient(ctx, clientOption) + + pullOption := api.PullsOption{ + EnableComments: opt.EnableComments, + EnableReviews: opt.EnableReviews, + EnableCommits: opt.EnableCommits, + EnableStatuses: opt.EnableStatuses, + EnableChecks: opt.EnableChecks, + Rules: api.NewPullRequestRules(opt.Rules, opt.Limit), + } + pulls, err := client.GetPulls(ctx, owner, repo, pullOption) + if err != nil { + return nil, err + } + if len(pulls) == 0 { + return nil, &NoMatchError{pullOption.Rules} + } + + // GitHub Pull Request Reviews require `event` parameter to be uppercase + // https://docs.github.com/en/rest/reference/pulls#create-a-review-for-a-pull-request + action := strings.ToUpper(opt.Action) + + switch action { + case ReviewActionAddApproval: + reviewOption := &api.ReviewOption{ + Action: action, + } + return client.AddApproval(ctx, pulls, reviewOption) + } + return nil, fmt.Errorf("currently, `approve` is only supported action, but %s was passed", opt.Action) +} From 3e32a71a62373b00d6bd4c49e7e4eaaa0080221d Mon Sep 17 00:00:00 2001 From: cw-kajiwara Date: Thu, 8 Apr 2021 17:20:33 +0900 Subject: [PATCH 2/2] update README with description to pull request review feature --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 4392297..65a3a47 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,12 @@ Usage: pr [command] Available Commands: + assignee Manipulate assignees that match a rule check Check if PR matches the rule and change PR status help Help about any command + label Manipulate labels that match a rule merge Merge PR that matches a rule + review Add review to PRs that match rules show Show PR that matches a rule validate Validate the rules @@ -48,6 +51,17 @@ $ pr merge [owner]/[repo] --with-statuses -l 'state == `"open"`' -l 'length(stat [...] ``` +### Review + +Add a review to PRs that match the rule. + +```bash +$ pr review [owner]/[repo] --action "approve" --with-statuses -l 'state == `"open"`' -l 'length(statuses[?state == `"success"`]) > `3`' +[...] +``` + +`--action "approve"` adds approval to the PR that matches the rule. + ### Label Append/Remove/Replace labels to PRs that match the rule.