Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions bitbucket/reviews.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions forges_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ type mockForge struct {
branchService *mockBranchService
deployKeyService *mockDeployKeyService
secretService *mockSecretService
reviewService *mockReviewService
}

func (m *mockForge) Repos() RepoService {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
147 changes: 147 additions & 0 deletions gitea/reviews.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading