fix(pr): review decision ignores comment reviews, paged review list, pending review comments hidden, team request names (follow gh)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 20:07:47 +02:00
1 parent aa181a2b5c
commit efd3383ff9
19 files changed
+258 -50

No files matched your search

+1 -1
View File
@@ -254,7 +254,7 @@ func (s Pulls) DecideAll(ctx context.Context, items []Pull) ([]Pull, error) {
if err != nil {
return err
}
item.ReviewDecision = ReviewDecision(LatestReviews(all), len(item.Reviewers)+len(item.ReviewerTeams) > 0)
item.ReviewDecision = ReviewDecision(all, len(item.Reviewers)+len(item.ReviewerTeams) > 0)
viewed[i] = item
return nil
})
+9 -1
View File
@@ -97,10 +97,15 @@ func (s Reviews) changeRequests(ctx context.Context, n int64, changes ReviewRequ
return nil
}
// CheckRequestTeams verifies that every named team exists in the repository's organization.
// CheckRequestTeams verifies that every named team exists in the repository's organization. Gitea
// lets only organization members list teams, though outside collaborators and the pull request
// author may request reviews; when the lookup is forbidden the server checks the names, as for gh.
func (s Reviews) CheckRequestTeams(ctx context.Context, names []string) error {
owner, _, _ := strings.Cut(s.Repository, "/")
_, isOrg, err := organization(ctx, s.Client, owner)
if IsForbidden(err) {
return nil
}
if err != nil {
return err
}
@@ -108,6 +113,9 @@ func (s Reviews) CheckRequestTeams(ctx context.Context, names []string) error {
return errors.New("team reviews require an organization repository")
}
teams, err := (Teams{Client: s.Client}).ByName(ctx, owner)
if IsForbidden(err) {
return nil
}
if err != nil {
return err
}
+24
View File
@@ -54,6 +54,30 @@ func TestReviewRequestPartialFailureStopsFurtherEdits(t *testing.T) {
require.Equal(t, 2, mutations)
}
func TestReviewerTeamCheckSkippedWhenTeamListingIsForbidden(t *testing.T) {
var posted bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/orgs/owner":
_, _ = w.Write([]byte(`{"id":1}`))
case r.URL.Path == "/api/v1/orgs/owner/teams":
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"Must be an organization member"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/repos/owner/repo/pulls/1/requested_reviewers":
posted = true
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`[]`))
default:
_, _ = w.Write([]byte(`{"number":1}`))
}
}))
defer server.Close()
changes := ReviewRequestChanges{Add: ReviewRequestInput{Teams: []string{"devs"}}}
_, err := (Reviews{Client: transport.New(server.URL, "", false), Repository: "owner/repo"}).ChangeRequests(context.Background(), 1, changes)
require.NoError(t, err)
require.True(t, posted, "the server decides whether the team exists")
}
func TestUnknownReviewerTeamRejectsBeforeUserRemoval(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodGet, r.Method)
+59 -7
View File
@@ -24,6 +24,7 @@ type Review struct {
Body string `json:"body"`
State string `json:"state"`
Author string `json:"author"`
Team string `json:"team"`
URL string `json:"url"`
Commit string `json:"commit"`
SubmittedAt string `json:"submitted_at"`
@@ -31,9 +32,21 @@ type Review struct {
Dismissed bool `json:"dismissed"`
}
// Reviewer names who the review is by, or for a request to a team, "team NAME".
func (r Review) Reviewer() string {
if r.Author == "" && r.Team != "" {
return "team " + r.Team
}
return r.Author
}
func reviewFrom(review *gitea.PullReview) Review {
team := ""
if review.ReviewerTeam != nil {
team = review.ReviewerTeam.Name
}
return Review{
ID: review.ID, Body: review.Body, State: string(review.State), Author: login(review.Reviewer), URL: review.HTMLURL,
ID: review.ID, Body: review.Body, State: string(review.State), Author: login(review.Reviewer), Team: team, URL: review.HTMLURL,
Commit: review.CommitID, SubmittedAt: timestamp(review.Submitted), CommentsCount: review.CodeCommentsCount, Dismissed: review.Dismissed,
}
}
@@ -48,6 +61,7 @@ type ReviewComment struct {
URL string `json:"url"`
Path string `json:"path"`
Line int64 `json:"line"`
OldLine int64 `json:"old_line"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
@@ -55,10 +69,22 @@ type ReviewComment struct {
func reviewCommentFrom(comment *gitea.PullReviewComment) ReviewComment {
return ReviewComment{
ID: comment.ID, ReviewID: comment.ReviewID, Body: comment.Body, Author: login(comment.Reviewer), Resolver: login(comment.Resolver),
URL: comment.HTMLURL, Path: comment.Path, Line: int64(comment.LineNum), CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated),
URL: comment.HTMLURL, Path: comment.Path, Line: int64(comment.LineNum), OldLine: int64(comment.OldLineNum), CreatedAt: timestamp(comment.Created), UpdatedAt: timestamp(comment.Updated),
}
}
// Location names the file and line a comment is on: "app.go:8", or "app.go:4 (old)" for a
// removed line, which has only an old position.
func (c ReviewComment) Location() string {
switch {
case c.Line > 0:
return fmt.Sprintf("%s:%d", c.Path, c.Line)
case c.OldLine > 0:
return fmt.Sprintf("%s:%d (old)", c.Path, c.OldLine)
}
return c.Path
}
// InlineComment supplies an explicit file position for review creation.
type InlineComment struct {
Path string `json:"path"`
@@ -106,6 +132,24 @@ func (s Reviews) List(ctx context.Context, n int64, limit, page int) ([]Review,
return convertAll(reviews, reviewFrom), nil
}
// ListPage lists a window of reviews of pull request n. It reaches past the server's smaller
// page size, which List would silently take as the whole window.
func (s Reviews) ListPage(ctx context.Context, n int64, limit, page int) (Page[Review], error) {
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.PullReview, http.Header, error) {
var headers http.Header
reviews, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.PullReview, *gitea.Response, error) {
reviews, resp, err := c.PullRequests.ListPullReviews(ctx, owner, name, n, gitea.ListPullReviewsOptions{ListOptions: listOptions(limit, page)})
headers = headerOf(resp)
return reviews, resp, err
})
return reviews, headers, err
})
if err != nil {
return Page[Review]{}, err
}
return convertPage(window, reviewFrom), nil
}
// Pending returns the reviews that login started on pull request n and has not submitted yet.
func (s Reviews) Pending(ctx context.Context, n int64, login string) ([]Review, error) {
reviews, err := s.All(ctx, n)
@@ -243,12 +287,19 @@ func LatestReviews(reviews []Review) []Review {
return latest
}
// ReviewDecision summarizes the latest reviews like gh: changes_requested when a reviewer's latest
// ReviewDecision summarizes reviews like gh: changes_requested when a reviewer's latest decisive
// review requests changes, else approved when one approves, else review_required while reviews
// are requested, else "". Dismissed reviews do not count.
func ReviewDecision(latest []Review, requested bool) string {
// are requested, else "". Comment reviews are not decisive (as in gh and Gitea) and dismissed
// reviews do not count.
func ReviewDecision(reviews []Review, requested bool) string {
decisive := make([]Review, 0, len(reviews))
for _, review := range reviews {
if review.State != string(gitea.ReviewStateComment) {
decisive = append(decisive, review)
}
}
decision := ""
for _, review := range latest {
for _, review := range LatestReviews(decisive) {
switch {
case review.Dismissed:
case review.State == string(gitea.ReviewStateRequestChanges):
@@ -268,7 +319,8 @@ func ReviewDecision(latest []Review, requested bool) string {
func (s Reviews) reviewComments(ctx context.Context, n int64, reviews []Review, skipEmpty bool) ([]ReviewComment, error) {
results := []ReviewComment{}
for _, review := range reviews {
if skipEmpty && review.CommentsCount == 0 {
// Pending reviews are drafts that only their author and admins see; an explicit review ID still lists them.
if skipEmpty && (review.CommentsCount == 0 || review.State == string(gitea.ReviewStatePending)) {
continue
}
comments, err := repoCall(ctx, s.Client, s.Repository, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.PullReviewComment, *gitea.Response, error) {
+65
View File
@@ -8,6 +8,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
gitea "gitea.dev/sdk"
@@ -53,6 +55,43 @@ func TestReviewCommentsContinueWhenServerCapsPageSize(t *testing.T) {
require.Equal(t, 2, collections)
}
func TestInlineCommentsSkipPendingReviews(t *testing.T) {
var fetched []string
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fetched = append(fetched, r.URL.Path)
_, _ = w.Write([]byte(`[{"id":5,"pull_request_review_id":1}]`))
}))
reviews := []Review{{ID: 1, State: "COMMENT", CommentsCount: 1}, {ID: 2, State: "PENDING", CommentsCount: 1}}
items, err := (Reviews{Client: client, Repository: "owner/repo"}).Inline(context.Background(), 3, reviews)
require.NoError(t, err)
require.Len(t, items, 1)
require.Equal(t, []string{"/api/v1/repos/owner/repo/pulls/3/reviews/1/comments"}, fetched)
}
func TestReviewListPageReachesPastServerPageCap(t *testing.T) {
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
size, _ := strconv.Atoi(r.URL.Query().Get("limit"))
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
size = min(size, 2) // the server caps its pages at two items
var reviews []string
for id := (page-1)*size + 1; id <= min(page*size, 5); id++ {
reviews = append(reviews, fmt.Sprintf(`{"id":%d}`, id))
}
w.Header().Set("X-Total-Count", "5")
_, _ = w.Write([]byte("[" + strings.Join(reviews, ",") + "]"))
}))
window, err := (Reviews{Client: client, Repository: "owner/repo"}).ListPage(context.Background(), 3, 4, 1)
require.NoError(t, err)
require.Len(t, window.Items, 4)
require.EqualValues(t, 4, window.Items[3].ID)
require.True(t, window.More)
window, err = (Reviews{Client: client, Repository: "owner/repo"}).ListPage(context.Background(), 3, 4, 2)
require.NoError(t, err)
require.Len(t, window.Items, 1)
require.EqualValues(t, 5, window.Items[0].ID)
require.False(t, window.More)
}
func TestReviewDismissalReturnsTheUpdatedReview(t *testing.T) {
for _, dismiss := range []bool{false, true} {
t.Run(fmt.Sprintf("dismiss=%t", dismiss), func(t *testing.T) {
@@ -185,6 +224,17 @@ func TestReviewResolveAndReplyUseCommentEndpoints(t *testing.T) {
}, paths)
}
func TestReviewDecisionIgnoresCommentReviews(t *testing.T) {
reviews := []Review{
{ID: 1, Author: "bob", State: "REQUEST_CHANGES"},
{ID: 2, Author: "bob", State: "COMMENT"},
{ID: 3, Author: "carol", State: "APPROVED"},
}
assert.Equal(t, "changes_requested", ReviewDecision(reviews, false))
assert.Equal(t, "approved", ReviewDecision([]Review{{ID: 1, Author: "carol", State: "APPROVED"}, {ID: 2, Author: "carol", State: "COMMENT"}}, false))
assert.Equal(t, "", ReviewDecision([]Review{{ID: 1, Author: "carol", State: "COMMENT"}}, false))
}
func TestReviewFromMapsServerFields(t *testing.T) {
review := decode[gitea.PullReview](t, `{"id":2,"body":"LGTM","state":"APPROVED","user":{"login":"alice"},"html_url":"https://gitea.test/r/2",
"commit_id":"abc","submitted_at":"2026-10-01T12:00:00Z","comments_count":3,"dismissed":true}`)
@@ -194,6 +244,13 @@ func TestReviewFromMapsServerFields(t *testing.T) {
}, reviewFrom(review))
}
func TestReviewFromNamesRequestedTeam(t *testing.T) {
review := reviewFrom(decode[gitea.PullReview](t, `{"id":251,"state":"REQUEST_REVIEW","user":null,"team":{"name":"devs"}}`))
require.Equal(t, "devs", review.Team)
require.Equal(t, "team devs", review.Reviewer())
require.Equal(t, "alice", Review{Author: "alice", Team: "devs"}.Reviewer())
}
func TestReviewCommentFromMapsServerFields(t *testing.T) {
comment := decode[gitea.PullReviewComment](t, `{"id":5,"pull_request_review_id":1,"body":"Nit","user":{"login":"alice"},
"resolver":{"login":"bob"},"html_url":"https://gitea.test/c/5","path":"main.go","position":12,
@@ -203,3 +260,11 @@ func TestReviewCommentFromMapsServerFields(t *testing.T) {
CreatedAt: "2026-10-01T12:00:00Z", UpdatedAt: "2026-10-02T12:00:00Z",
}, reviewCommentFrom(comment))
}
func TestReviewCommentLocationNamesRemovedLines(t *testing.T) {
removed := reviewCommentFrom(decode[gitea.PullReviewComment](t, `{"id":6,"path":"main.go","position":0,"original_position":4}`))
require.EqualValues(t, 4, removed.OldLine)
require.Equal(t, "main.go:4 (old)", removed.Location())
require.Equal(t, "main.go:12", ReviewComment{Path: "main.go", Line: 12}.Location())
require.Equal(t, "main.go", ReviewComment{Path: "main.go"}.Location())
}