diff --git a/bitbucket/reviews.go b/bitbucket/reviews.go new file mode 100644 index 0000000..b507cbb --- /dev/null +++ b/bitbucket/reviews.go @@ -0,0 +1,134 @@ +package bitbucket + +import ( + "context" + "fmt" + forge "github.com/git-pkgs/forge" + "net/http" +) + +type bitbucketReviewService struct { + token string + httpClient *http.Client +} + +func (f *bitbucketForge) Reviews() forge.ReviewService { + return &bitbucketReviewService{token: f.token, httpClient: f.httpClient} +} + +func (s *bitbucketReviewService) doJSON(ctx context.Context, method, url string, body any, v any) error { + rs := &bitbucketRepoService{token: s.token, httpClient: s.httpClient} + return rs.doJSON(ctx, method, url, body, v) +} + +type bbParticipant struct { + User struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + } `json:"user"` + Role string `json:"role"` + Approved bool `json:"approved"` +} + +type bbPRDetail struct { + Participants []bbParticipant `json:"participants"` +} + +func (s *bitbucketReviewService) List(ctx context.Context, owner, repo string, number int, opts forge.ListReviewOpts) ([]forge.Review, error) { + url := fmt.Sprintf("%s/repositories/%s/%s/pullrequests/%d", bitbucketAPI, owner, repo, number) + var bb bbPRDetail + if err := s.doJSON(ctx, http.MethodGet, url, nil, &bb); err != nil { + return nil, err + } + + var reviews []forge.Review + for _, p := range bb.Participants { + if p.Role != "REVIEWER" { + continue + } + state := forge.ReviewCommented + if p.Approved { + state = forge.ReviewApproved + } + reviews = append(reviews, forge.Review{ + State: state, + Author: forge.User{ + Login: p.User.Username, + }, + }) + } + + return reviews, nil +} + +func (s *bitbucketReviewService) Submit(ctx context.Context, owner, repo string, number int, opts forge.SubmitReviewOpts) (*forge.Review, error) { + switch opts.State { + case forge.ReviewApproved: + url := fmt.Sprintf("%s/repositories/%s/%s/pullrequests/%d/approve", bitbucketAPI, owner, repo, number) + if err := s.doJSON(ctx, http.MethodPost, url, nil, nil); err != nil { + return nil, err + } + return &forge.Review{State: forge.ReviewApproved}, nil + + case forge.ReviewChangesRequested: + return nil, fmt.Errorf("requesting changes: %w", forge.ErrNotSupported) + + default: + // Post a comment as the review + reqBody := map[string]any{ + "content": map[string]string{"raw": opts.Body}, + } + url := fmt.Sprintf("%s/repositories/%s/%s/pullrequests/%d/comments", bitbucketAPI, owner, repo, number) + if err := s.doJSON(ctx, http.MethodPost, url, reqBody, nil); err != nil { + return nil, err + } + return &forge.Review{State: forge.ReviewCommented, Body: opts.Body}, nil + } +} + +func (s *bitbucketReviewService) RequestReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + // Bitbucket sets reviewers on the PR body. Get current PR, add reviewers, update. + url := fmt.Sprintf("%s/repositories/%s/%s/pullrequests/%d", bitbucketAPI, owner, repo, number) + var bb bbPullRequest + if err := s.doJSON(ctx, http.MethodGet, url, nil, &bb); err != nil { + return err + } + + existing := make(map[string]bool) + var reviewers []map[string]string + for _, r := range bb.Reviewers { + existing[r.Username] = true + reviewers = append(reviewers, map[string]string{"username": r.Username}) + } + for _, u := range users { + if !existing[u] { + reviewers = append(reviewers, map[string]string{"username": u}) + } + } + + body := map[string]any{"reviewers": reviewers} + return s.doJSON(ctx, http.MethodPut, url, body, nil) +} + +func (s *bitbucketReviewService) RemoveReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + url := fmt.Sprintf("%s/repositories/%s/%s/pullrequests/%d", bitbucketAPI, owner, repo, number) + var bb bbPullRequest + if err := s.doJSON(ctx, http.MethodGet, url, nil, &bb); err != nil { + return err + } + + removeSet := make(map[string]bool) + for _, u := range users { + removeSet[u] = true + } + + var reviewers []map[string]string + for _, r := range bb.Reviewers { + if !removeSet[r.Username] { + reviewers = append(reviewers, map[string]string{"username": r.Username}) + } + } + + body := map[string]any{"reviewers": reviewers} + return s.doJSON(ctx, http.MethodPut, url, body, nil) +} diff --git a/forge.go b/forge.go index ebb3cbd..e6f5f01 100644 --- a/forge.go +++ b/forge.go @@ -43,6 +43,7 @@ type Forge interface { Branches() BranchService DeployKeys() DeployKeyService Secrets() SecretService + Reviews() ReviewService } // Client routes requests to the appropriate Forge based on the URL domain. diff --git a/forges_test.go b/forges_test.go index b155c46..df17677 100644 --- a/forges_test.go +++ b/forges_test.go @@ -384,6 +384,7 @@ type mockForge struct { branchService *mockBranchService deployKeyService *mockDeployKeyService secretService *mockSecretService + reviewService *mockReviewService } func (m *mockForge) Repos() RepoService { @@ -453,6 +454,13 @@ func (m *mockForge) Secrets() SecretService { return &mockSecretService{} } +func (m *mockForge) Reviews() ReviewService { + if m.reviewService != nil { + return m.reviewService + } + return &mockReviewService{} +} + type mockRepoService struct { repo *Repository repos []Repository @@ -946,3 +954,39 @@ func (m *mockSecretService) Delete(_ context.Context, owner, repo, name string) m.lastName = name return nil } + +type mockReviewService struct { + review *Review + reviews []Review + lastOwner string + lastRepo string + lastNumber int +} + +func (m *mockReviewService) List(_ context.Context, owner, repo string, number int, opts ListReviewOpts) ([]Review, error) { + m.lastOwner = owner + m.lastRepo = repo + m.lastNumber = number + return m.reviews, nil +} + +func (m *mockReviewService) Submit(_ context.Context, owner, repo string, number int, opts SubmitReviewOpts) (*Review, error) { + m.lastOwner = owner + m.lastRepo = repo + m.lastNumber = number + return m.review, nil +} + +func (m *mockReviewService) RequestReviewers(_ context.Context, owner, repo string, number int, users []string) error { + m.lastOwner = owner + m.lastRepo = repo + m.lastNumber = number + return nil +} + +func (m *mockReviewService) RemoveReviewers(_ context.Context, owner, repo string, number int, users []string) error { + m.lastOwner = owner + m.lastRepo = repo + m.lastNumber = number + return nil +} diff --git a/gitea/reviews.go b/gitea/reviews.go new file mode 100644 index 0000000..da5189b --- /dev/null +++ b/gitea/reviews.go @@ -0,0 +1,147 @@ +package gitea + +import ( + "context" + forge "github.com/git-pkgs/forge" + "net/http" + "strings" + + "code.gitea.io/sdk/gitea" +) + +type giteaReviewService struct { + client *gitea.Client +} + +func (f *giteaForge) Reviews() forge.ReviewService { + return &giteaReviewService{client: f.client} +} + +func convertGiteaReviewState(s gitea.ReviewStateType) forge.ReviewState { + switch s { + case gitea.ReviewStateApproved: + return forge.ReviewApproved + case gitea.ReviewStateRequestChanges: + return forge.ReviewChangesRequested + case gitea.ReviewStateComment: + return forge.ReviewCommented + case gitea.ReviewStateRequestReview: + return forge.ReviewPending + default: + return forge.ReviewState(strings.ToLower(string(s))) + } +} + +func convertGiteaReview(r *gitea.PullReview) forge.Review { + result := forge.Review{ + ID: r.ID, + State: convertGiteaReviewState(r.State), + Body: r.Body, + } + + if r.Reviewer != nil { + result.Author = forge.User{ + Login: r.Reviewer.UserName, + AvatarURL: r.Reviewer.AvatarURL, + } + } + + if r.HTMLURL != "" { + result.HTMLURL = r.HTMLURL + } + + if !r.Submitted.IsZero() { + result.SubmittedAt = r.Submitted + } + + return result +} + +func (s *giteaReviewService) List(ctx context.Context, owner, repo string, number int, opts forge.ListReviewOpts) ([]forge.Review, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 30 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + var all []forge.Review + for { + reviews, resp, err := s.client.ListPullReviews(owner, repo, int64(number), gitea.ListPullReviewsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: perPage}, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, r := range reviews { + all = append(all, convertGiteaReview(r)) + } + if len(reviews) < perPage || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + page++ + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func forgeStateToGiteaType(state forge.ReviewState) gitea.ReviewStateType { + switch state { + case forge.ReviewApproved: + return gitea.ReviewStateApproved + case forge.ReviewChangesRequested: + return gitea.ReviewStateRequestChanges + default: + return gitea.ReviewStateComment + } +} + +func (s *giteaReviewService) Submit(ctx context.Context, owner, repo string, number int, opts forge.SubmitReviewOpts) (*forge.Review, error) { + review, resp, err := s.client.CreatePullReview(owner, repo, int64(number), gitea.CreatePullReviewOptions{ + State: forgeStateToGiteaType(opts.State), + Body: opts.Body, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGiteaReview(review) + return &result, nil +} + +func (s *giteaReviewService) RequestReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + resp, err := s.client.CreateReviewRequests(owner, repo, int64(number), gitea.PullReviewRequestOptions{ + Reviewers: users, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} + +func (s *giteaReviewService) RemoveReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + resp, err := s.client.DeleteReviewRequests(owner, repo, int64(number), gitea.PullReviewRequestOptions{ + Reviewers: users, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} diff --git a/github/reviews.go b/github/reviews.go new file mode 100644 index 0000000..d9f61b9 --- /dev/null +++ b/github/reviews.go @@ -0,0 +1,151 @@ +package github + +import ( + "context" + "net/http" + "strings" + + forge "github.com/git-pkgs/forge" + + "github.com/google/go-github/v82/github" +) + +type gitHubReviewService struct { + client *github.Client +} + +func (f *gitHubForge) Reviews() forge.ReviewService { + return &gitHubReviewService{client: f.client} +} + +func convertGitHubReviewState(s string) forge.ReviewState { + switch strings.ToUpper(s) { + case "APPROVED": + return forge.ReviewApproved + case "CHANGES_REQUESTED": + return forge.ReviewChangesRequested + case "COMMENTED": + return forge.ReviewCommented + case "DISMISSED": + return forge.ReviewDismissed + case "PENDING": + return forge.ReviewPending + default: + return forge.ReviewState(strings.ToLower(s)) + } +} + +func convertGitHubReview(r *github.PullRequestReview) forge.Review { + result := forge.Review{ + ID: r.GetID(), + State: convertGitHubReviewState(r.GetState()), + Body: r.GetBody(), + HTMLURL: r.GetHTMLURL(), + } + + if u := r.GetUser(); u != nil { + result.Author = forge.User{ + Login: u.GetLogin(), + AvatarURL: u.GetAvatarURL(), + HTMLURL: u.GetHTMLURL(), + } + } + + if t := r.GetSubmittedAt(); !t.IsZero() { + result.SubmittedAt = t.Time + } + + return result +} + +func (s *gitHubReviewService) List(ctx context.Context, owner, repo string, number int, opts forge.ListReviewOpts) ([]forge.Review, error) { + perPage := opts.PerPage + if perPage <= 0 { + perPage = 30 + } + page := opts.Page + if page <= 0 { + page = 1 + } + + ghOpts := &github.ListOptions{PerPage: perPage, Page: page} + + var all []forge.Review + for { + reviews, resp, err := s.client.PullRequests.ListReviews(ctx, owner, repo, number, ghOpts) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + for _, r := range reviews { + all = append(all, convertGitHubReview(r)) + } + if resp.NextPage == 0 || (opts.Limit > 0 && len(all) >= opts.Limit) { + break + } + ghOpts.Page = resp.NextPage + } + + if opts.Limit > 0 && len(all) > opts.Limit { + all = all[:opts.Limit] + } + + return all, nil +} + +func forgeStateToGitHubEvent(state forge.ReviewState) string { + switch state { + case forge.ReviewApproved: + return "APPROVE" + case forge.ReviewChangesRequested: + return "REQUEST_CHANGES" + default: + return "COMMENT" + } +} + +func (s *gitHubReviewService) Submit(ctx context.Context, owner, repo string, number int, opts forge.SubmitReviewOpts) (*forge.Review, error) { + event := forgeStateToGitHubEvent(opts.State) + req := &github.PullRequestReviewRequest{ + Body: &opts.Body, + Event: &event, + } + + review, resp, err := s.client.PullRequests.CreateReview(ctx, owner, repo, number, req) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := convertGitHubReview(review) + return &result, nil +} + +func (s *gitHubReviewService) RequestReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + _, resp, err := s.client.PullRequests.RequestReviewers(ctx, owner, repo, number, github.ReviewersRequest{ + Reviewers: users, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} + +func (s *gitHubReviewService) RemoveReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + resp, err := s.client.PullRequests.RemoveReviewers(ctx, owner, repo, number, github.ReviewersRequest{ + Reviewers: users, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} diff --git a/github/reviews_test.go b/github/reviews_test.go new file mode 100644 index 0000000..26b9a01 --- /dev/null +++ b/github/reviews_test.go @@ -0,0 +1,173 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" + "github.com/google/go-github/v82/github" +) + +func newTestGitHubReviewService(srv *httptest.Server) *gitHubReviewService { + c := github.NewClient(nil) + c, _ = c.WithEnterpriseURLs(srv.URL+"/api/v3", srv.URL+"/api/v3") + return &gitHubReviewService{client: c} +} + +func TestGitHubListReviews(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/repos/octocat/hello-world/pulls/1/reviews", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]*github.PullRequestReview{ + { + ID: ptrInt64(100), + State: ptr("APPROVED"), + Body: ptr("Looks good!"), + User: &github.User{Login: ptr("alice")}, + SubmittedAt: &github.Timestamp{Time: parseTime("2024-01-15T10:00:00Z")}, + }, + { + ID: ptrInt64(101), + State: ptr("CHANGES_REQUESTED"), + Body: ptr("Please fix the tests"), + User: &github.User{Login: ptr("bob")}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubReviewService(srv) + reviews, err := s.List(context.Background(), "octocat", "hello-world", 1, forge.ListReviewOpts{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(reviews) != 2 { + t.Fatalf("expected 2 reviews, got %d", len(reviews)) + } + + assertEqual(t, "reviews[0].State", "approved", string(reviews[0].State)) + assertEqual(t, "reviews[0].Body", "Looks good!", reviews[0].Body) + assertEqual(t, "reviews[0].Author.Login", "alice", reviews[0].Author.Login) + + assertEqual(t, "reviews[1].State", "changes_requested", string(reviews[1].State)) + assertEqual(t, "reviews[1].Author.Login", "bob", reviews[1].Author.Login) +} + +func TestGitHubListReviewsNotFound(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/repos/octocat/hello-world/pulls/999/reviews", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubReviewService(srv) + _, err := s.List(context.Background(), "octocat", "hello-world", 999, forge.ListReviewOpts{}) + if err != forge.ErrNotFound { + t.Fatalf("expected forge.ErrNotFound, got %v", err) + } +} + +func TestGitHubSubmitReviewApprove(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v3/repos/octocat/hello-world/pulls/1/reviews", func(w http.ResponseWriter, r *http.Request) { + var req github.PullRequestReviewRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.GetEvent() != "APPROVE" { + t.Errorf("expected event APPROVE, got %s", req.GetEvent()) + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(github.PullRequestReview{ + ID: ptrInt64(200), + State: ptr("APPROVED"), + Body: ptr("LGTM"), + User: &github.User{Login: ptr("reviewer")}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubReviewService(srv) + review, err := s.Submit(context.Background(), "octocat", "hello-world", 1, forge.SubmitReviewOpts{ + State: forge.ReviewApproved, + Body: "LGTM", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "State", "approved", string(review.State)) + assertEqual(t, "Author.Login", "reviewer", review.Author.Login) +} + +func TestGitHubSubmitReviewRequestChanges(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v3/repos/octocat/hello-world/pulls/1/reviews", func(w http.ResponseWriter, r *http.Request) { + var req github.PullRequestReviewRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.GetEvent() != "REQUEST_CHANGES" { + t.Errorf("expected event REQUEST_CHANGES, got %s", req.GetEvent()) + } + _ = json.NewEncoder(w).Encode(github.PullRequestReview{ + ID: ptrInt64(201), + State: ptr("CHANGES_REQUESTED"), + Body: ptr("Fix the tests"), + User: &github.User{Login: ptr("reviewer")}, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubReviewService(srv) + review, err := s.Submit(context.Background(), "octocat", "hello-world", 1, forge.SubmitReviewOpts{ + State: forge.ReviewChangesRequested, + Body: "Fix the tests", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertEqual(t, "State", "changes_requested", string(review.State)) +} + +func TestGitHubRequestReviewers(t *testing.T) { + var requested github.ReviewersRequest + mux := http.NewServeMux() + mux.HandleFunc("POST /api/v3/repos/octocat/hello-world/pulls/1/requested_reviewers", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&requested) + _ = json.NewEncoder(w).Encode(github.PullRequest{ + Number: ptrInt(1), + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubReviewService(srv) + err := s.RequestReviewers(context.Background(), "octocat", "hello-world", 1, []string{"alice", "bob"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertSliceEqual(t, "Reviewers", []string{"alice", "bob"}, requested.Reviewers) +} + +func TestGitHubRemoveReviewers(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("DELETE /api/v3/repos/octocat/hello-world/pulls/1/requested_reviewers", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + s := newTestGitHubReviewService(srv) + err := s.RemoveReviewers(context.Background(), "octocat", "hello-world", 1, []string{"alice"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/gitlab/reviews.go b/gitlab/reviews.go new file mode 100644 index 0000000..6e33b2d --- /dev/null +++ b/gitlab/reviews.go @@ -0,0 +1,169 @@ +package gitlab + +import ( + "context" + "fmt" + forge "github.com/git-pkgs/forge" + "net/http" + + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +type gitLabReviewService struct { + client *gitlab.Client +} + +func (f *gitLabForge) Reviews() forge.ReviewService { + return &gitLabReviewService{client: f.client} +} + +func (s *gitLabReviewService) List(ctx context.Context, owner, repo string, number int, opts forge.ListReviewOpts) ([]forge.Review, error) { + pid := owner + "/" + repo + + approvals, resp, err := s.client.MergeRequestApprovals.GetConfiguration(pid, int64(number)) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + + var reviews []forge.Review + for _, a := range approvals.ApprovedBy { + if a.User == nil { + continue + } + reviews = append(reviews, forge.Review{ + State: forge.ReviewApproved, + Author: forge.User{ + Login: a.User.Username, + Name: a.User.Name, + AvatarURL: a.User.AvatarURL, + HTMLURL: a.User.WebURL, + }, + }) + } + + return reviews, nil +} + +func (s *gitLabReviewService) Submit(ctx context.Context, owner, repo string, number int, opts forge.SubmitReviewOpts) (*forge.Review, error) { + pid := owner + "/" + repo + + switch opts.State { + case forge.ReviewApproved: + _, resp, err := s.client.MergeRequestApprovals.ApproveMergeRequest(pid, int64(number), nil) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := &forge.Review{State: forge.ReviewApproved} + return result, nil + + case forge.ReviewChangesRequested: + return nil, fmt.Errorf("requesting changes: %w", forge.ErrNotSupported) + + default: + // For comment-only reviews, add a note to the MR + n, resp, err := s.client.Notes.CreateMergeRequestNote(pid, int64(number), &gitlab.CreateMergeRequestNoteOptions{ + Body: gitlab.Ptr(opts.Body), + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil, forge.ErrNotFound + } + return nil, err + } + result := &forge.Review{ + ID: int64(n.ID), + State: forge.ReviewCommented, + Body: n.Body, + Author: forge.User{ + Login: n.Author.Username, + Name: n.Author.Name, + AvatarURL: n.Author.AvatarURL, + HTMLURL: n.Author.WebURL, + }, + } + if n.CreatedAt != nil { + result.SubmittedAt = *n.CreatedAt + } + return result, nil + } +} + +func (s *gitLabReviewService) RequestReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + pid := owner + "/" + repo + + // GitLab requires user IDs, not usernames. Resolve them. + ids, err := s.resolveUserIDs(users) + if err != nil { + return err + } + + _, resp, err := s.client.MergeRequests.UpdateMergeRequest(pid, int64(number), &gitlab.UpdateMergeRequestOptions{ + ReviewerIDs: &ids, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} + +func (s *gitLabReviewService) RemoveReviewers(ctx context.Context, owner, repo string, number int, users []string) error { + pid := owner + "/" + repo + + // Get current reviewers + mr, resp, err := s.client.MergeRequests.GetMergeRequest(pid, int64(number), nil) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + + removeSet := make(map[string]bool) + for _, u := range users { + removeSet[u] = true + } + + var remaining []int64 + for _, r := range mr.Reviewers { + if !removeSet[r.Username] { + remaining = append(remaining, int64(r.ID)) + } + } + + _, resp, err = s.client.MergeRequests.UpdateMergeRequest(pid, int64(number), &gitlab.UpdateMergeRequestOptions{ + ReviewerIDs: &remaining, + }) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return forge.ErrNotFound + } + return err + } + return nil +} + +func (s *gitLabReviewService) resolveUserIDs(usernames []string) ([]int64, error) { + ids := make([]int64, 0, len(usernames)) + for _, username := range usernames { + users, _, err := s.client.Users.ListUsers(&gitlab.ListUsersOptions{ + Username: gitlab.Ptr(username), + }) + if err != nil { + return nil, fmt.Errorf("looking up user %q: %w", username, err) + } + if len(users) == 0 { + return nil, fmt.Errorf("user %q not found", username) + } + ids = append(ids, int64(users[0].ID)) + } + return ids, nil +} diff --git a/internal/cli/review.go b/internal/cli/review.go new file mode 100644 index 0000000..b7ea4b2 --- /dev/null +++ b/internal/cli/review.go @@ -0,0 +1,229 @@ +package cli + +import ( + "fmt" + "os" + "strconv" + + forges "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/internal/output" + "github.com/git-pkgs/forge/internal/resolve" + "github.com/spf13/cobra" +) + +var reviewCmd = &cobra.Command{ + Use: "review", + Short: "Manage pull request reviews", +} + +var reviewerCmd = &cobra.Command{ + Use: "reviewer", + Short: "Manage pull request reviewers", +} + +func init() { + prCmd.AddCommand(reviewCmd) + prCmd.AddCommand(reviewerCmd) + reviewCmd.AddCommand(reviewListCmd()) + reviewCmd.AddCommand(reviewApproveCmd()) + reviewCmd.AddCommand(reviewRejectCmd()) + reviewerCmd.AddCommand(reviewerRequestCmd()) + reviewerCmd.AddCommand(reviewerRemoveCmd()) +} + +func reviewListCmd() *cobra.Command { + var flagLimit int + + cmd := &cobra.Command{ + Use: "list ", + Short: "List reviews on a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + reviews, err := forge.Reviews().List(cmd.Context(), owner, repoName, number, forges.ListReviewOpts{ + Limit: flagLimit, + }) + if err != nil { + return notSupported(err, "PR reviews") + } + + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(reviews) + } + + if p.Format == output.Plain { + lines := make([]string, len(reviews)) + for i, r := range reviews { + lines[i] = fmt.Sprintf("%s\t%s", r.Author.Login, r.State) + } + p.PrintPlain(lines) + return nil + } + + headers := []string{"AUTHOR", "STATE", "BODY"} + rows := make([][]string, len(reviews)) + for i, r := range reviews { + body := r.Body + if len(body) > 60 { + body = body[:57] + "..." + } + rows[i] = []string{ + r.Author.Login, + string(r.State), + body, + } + } + p.PrintTable(headers, rows) + return nil + }, + } + + cmd.Flags().IntVarP(&flagLimit, "limit", "L", 30, "Maximum number of reviews") + return cmd +} + +func reviewApproveCmd() *cobra.Command { + var flagBody string + + cmd := &cobra.Command{ + Use: "approve ", + Short: "Approve a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + review, err := forge.Reviews().Submit(cmd.Context(), owner, repoName, number, forges.SubmitReviewOpts{ + State: forges.ReviewApproved, + Body: flagBody, + }) + if err != nil { + return notSupported(err, "PR approval") + } + + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(review) + } + + _, _ = fmt.Fprintf(os.Stdout, "Approved #%d\n", number) + return nil + }, + } + + cmd.Flags().StringVarP(&flagBody, "body", "b", "", "Review body") + return cmd +} + +func reviewRejectCmd() *cobra.Command { + var flagBody string + + cmd := &cobra.Command{ + Use: "reject ", + Short: "Request changes on a pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + + if flagBody == "" { + return fmt.Errorf("--body is required when requesting changes") + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + review, err := forge.Reviews().Submit(cmd.Context(), owner, repoName, number, forges.SubmitReviewOpts{ + State: forges.ReviewChangesRequested, + Body: flagBody, + }) + if err != nil { + return notSupported(err, "requesting changes") + } + + p := printer() + if p.Format == output.JSON { + return p.PrintJSON(review) + } + + _, _ = fmt.Fprintf(os.Stdout, "Requested changes on #%d\n", number) + return nil + }, + } + + cmd.Flags().StringVarP(&flagBody, "body", "b", "", "Review body") + return cmd +} + +func reviewerRequestCmd() *cobra.Command { + return &cobra.Command{ + Use: "request ", + Short: "Request reviewers on a pull request", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + if err := forge.Reviews().RequestReviewers(cmd.Context(), owner, repoName, number, args[1:]); err != nil { + return notSupported(err, "requesting reviewers") + } + + _, _ = fmt.Fprintf(os.Stdout, "Requested reviewers on #%d\n", number) + return nil + }, + } +} + +func reviewerRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove reviewer requests from a pull request", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + number, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid PR number: %s", args[0]) + } + + forge, owner, repoName, _, err := resolve.Repo(flagRepo, flagForgeType) + if err != nil { + return err + } + + if err := forge.Reviews().RemoveReviewers(cmd.Context(), owner, repoName, number, args[1:]); err != nil { + return notSupported(err, "removing reviewers") + } + + _, _ = fmt.Fprintf(os.Stdout, "Removed reviewers from #%d\n", number) + return nil + }, + } +} diff --git a/internal/cli/review_test.go b/internal/cli/review_test.go new file mode 100644 index 0000000..72bd9fb --- /dev/null +++ b/internal/cli/review_test.go @@ -0,0 +1,69 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestReviewCmdStructure(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "review list requires number", + args: []string{"pr", "review", "list"}, + want: "accepts 1 arg", + }, + { + name: "review approve requires number", + args: []string{"pr", "review", "approve"}, + want: "accepts 1 arg", + }, + { + name: "review reject requires number", + args: []string{"pr", "review", "reject"}, + want: "accepts 1 arg", + }, + { + name: "reviewer request requires number and users", + args: []string{"pr", "reviewer", "request"}, + want: "requires at least 2 arg", + }, + { + name: "reviewer request requires users", + args: []string{"pr", "reviewer", "request", "1"}, + want: "requires at least 2 arg", + }, + { + name: "reviewer remove requires number and users", + args: []string{"pr", "reviewer", "remove"}, + want: "requires at least 2 arg", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd.SetArgs(tt.args) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Errorf("expected error containing %q, got %q", tt.want, err.Error()) + } + }) + } +} + +func TestReviewRejectRequiresBody(t *testing.T) { + rootCmd.SetArgs([]string{"pr", "review", "reject", "1"}) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--body is required") { + t.Errorf("expected error about --body, got %q", err.Error()) + } +} diff --git a/services.go b/services.go index c5ab628..75beaf2 100644 --- a/services.go +++ b/services.go @@ -96,6 +96,14 @@ type SecretService interface { Delete(ctx context.Context, owner, repo, name string) error } +// ReviewService provides operations on pull request reviews. +type ReviewService interface { + List(ctx context.Context, owner, repo string, number int, opts ListReviewOpts) ([]Review, error) + Submit(ctx context.Context, owner, repo string, number int, opts SubmitReviewOpts) (*Review, error) + RequestReviewers(ctx context.Context, owner, repo string, number int, users []string) error + RemoveReviewers(ctx context.Context, owner, repo string, number int, users []string) error +} + // IssueService provides operations on issues. type IssueService interface { Get(ctx context.Context, owner, repo string, number int) (*Issue, error) diff --git a/types.go b/types.go index 97e5f89..3708abd 100644 --- a/types.go +++ b/types.go @@ -499,3 +499,37 @@ type ListSecretOpts struct { Page int // starting page; 0 or 1 = first page PerPage int // results per API request; 0 = default } + +// ReviewState represents the state of a pull request review. +type ReviewState string + +const ( + ReviewApproved ReviewState = "approved" + ReviewChangesRequested ReviewState = "changes_requested" + ReviewCommented ReviewState = "commented" + ReviewDismissed ReviewState = "dismissed" + ReviewPending ReviewState = "pending" +) + +// Review holds normalized metadata about a pull request review. +type Review struct { + ID int64 `json:"id"` + State ReviewState `json:"state"` + Body string `json:"body,omitempty"` + Author User `json:"author"` + HTMLURL string `json:"html_url,omitempty"` + SubmittedAt time.Time `json:"submitted_at,omitzero"` +} + +// ListReviewOpts holds options for listing reviews. +type ListReviewOpts struct { + Limit int // max total results; 0 = unlimited + Page int // starting page; 0 or 1 = first page + PerPage int // results per API request; 0 = default +} + +// SubmitReviewOpts holds options for submitting a review. +type SubmitReviewOpts struct { + State ReviewState // approved, changes_requested, or commented + Body string +}