mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-09 22:21:42 +09:00
fix: allow re-requesting uncounted review approvals (#38988)
This commit is contained in:
@@ -223,7 +223,7 @@ func (r *Review) HTMLTypeColorClass() string {
|
||||
case ReviewTypeReject:
|
||||
return "tw-text-red"
|
||||
case ReviewTypeRequest:
|
||||
return "tw-text-yellow"
|
||||
return util.Iif(r.Official, "tw-text-yellow", "tw-text-text-light")
|
||||
}
|
||||
return "tw-text-text-light"
|
||||
}
|
||||
|
||||
@@ -351,13 +351,9 @@ func (d *IssuePageMetaData) retrieveReviewersData(ctx *context.Context) {
|
||||
tmp.ItemID = -review.ReviewerTeamID
|
||||
}
|
||||
|
||||
if data.CanChooseReviewer {
|
||||
// Users who can choose reviewers can also remove review requests
|
||||
tmp.CanChange = true
|
||||
} else if ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID && review.Type == issues_model.ReviewTypeRequest {
|
||||
// A user can refuse review requests
|
||||
tmp.CanChange = true
|
||||
}
|
||||
tmp.CanChange = data.CanChooseReviewer || (ctx.Doer != nil && ctx.Doer.ID == review.ReviewerID &&
|
||||
(review.Type == issues_model.ReviewTypeRequest ||
|
||||
(review.Type == issues_model.ReviewTypeApprove && !isClosed && !repo.IsArchived)))
|
||||
|
||||
pullReviews = append(pullReviews, tmp)
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ func HasAllRequiredCodeownerReviews(ctx context.Context, pb *git_model.Protected
|
||||
// owner lacks write access, or isn't on the approvals whitelist). Unlike the other
|
||||
// branch-protection checks below, this one only cares whether a listed code owner approved.
|
||||
approvingReviews, err := issues_model.FindLatestReviews(ctx, issues_model.FindReviewOptions{
|
||||
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove, issues_model.ReviewTypeReject},
|
||||
Types: []issues_model.ReviewType{issues_model.ReviewTypeApprove, issues_model.ReviewTypeReject, issues_model.ReviewTypeRequest},
|
||||
IssueID: pr.IssueID,
|
||||
OfficialOnly: false,
|
||||
Dismissed: optional.Some(false),
|
||||
|
||||
@@ -103,6 +103,10 @@ func isValidReviewRequest(ctx context.Context, reviewer, doer *user_model.User,
|
||||
return nil
|
||||
}
|
||||
|
||||
if !issue.Repo.IsArchived && doer.ID == reviewer.ID && lastReview != nil && lastReview.Type == issues_model.ReviewTypeApprove {
|
||||
return nil
|
||||
}
|
||||
|
||||
return issues_model.ErrNotValidReviewRequest{
|
||||
Reason: "Doer can't choose reviewer",
|
||||
UserID: doer.ID,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package issue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
issues_model "gitea.dev/models/issues"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReviewRequestRetractOwnApproval(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
reviewType issues_model.ReviewType
|
||||
archived bool
|
||||
allowed bool
|
||||
}{
|
||||
{"approval", issues_model.ReviewTypeApprove, false, true},
|
||||
{"comment", issues_model.ReviewTypeComment, false, false},
|
||||
{"archived repository", issues_model.ReviewTypeApprove, true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
|
||||
pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{IssueID: 2})
|
||||
pull.HasMerged = false
|
||||
require.NoError(t, pull.UpdateCols(t.Context(), "has_merged"))
|
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
|
||||
require.NoError(t, issue.LoadRepo(t.Context()))
|
||||
require.NoError(t, issue.Repo.LoadOwner(t.Context()))
|
||||
reviewer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
|
||||
|
||||
review, _, err := issues_model.SubmitReview(t.Context(), reviewer, issue, tc.reviewType, "review", "", false, nil)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, review.Official)
|
||||
issue.Repo.IsArchived = tc.archived
|
||||
|
||||
_, err = ReviewRequest(t.Context(), issue, reviewer, nil, reviewer, true)
|
||||
if !tc.allowed {
|
||||
assert.True(t, issues_model.IsErrNotValidReviewRequest(err))
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
review, err = issues_model.GetReviewByIssueIDAndUserID(t.Context(), issue.ID, reviewer.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, issues_model.ReviewTypeRequest, review.Type)
|
||||
assert.False(t, review.Official)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@
|
||||
{{if .Review.Stale}}
|
||||
<span data-tooltip-content="{{ctx.Locale.Tr "repo.issues.is_stale"}}">{{svg "octicon-hourglass" 16}}</span>
|
||||
{{end}}
|
||||
{{if and .CanChange $data.CanChooseReviewer}}
|
||||
{{if .CanChange}}
|
||||
{{if .Requested}}
|
||||
<a href="#" class="ui muted icon link-action"
|
||||
data-tooltip-content="{{ctx.Locale.Tr "repo.issues.remove_request_review"}}"
|
||||
|
||||
@@ -1,15 +1,68 @@
|
||||
import {test, expect} from '@playwright/test';
|
||||
import {apiCreateFiles, apiCreatePR, apiCreateRepo, apiCreateReview, apiCreateUser, apiUserHeaders, loginUser, randomString} from './utils.ts';
|
||||
import {
|
||||
apiAddCollaborator,
|
||||
apiCreateFiles,
|
||||
apiCreatePR,
|
||||
apiCreateRepo,
|
||||
apiCreateReview,
|
||||
apiCreateUser,
|
||||
apiUserHeaders,
|
||||
loginUser,
|
||||
randomString,
|
||||
} from './utils.ts';
|
||||
|
||||
test('pr review flow', async ({page, request}) => {
|
||||
const poster = `rv-poster-${randomString(8)}`;
|
||||
const reviewer = `rv-reviewer-${randomString(8)}`;
|
||||
await Promise.all([apiCreateUser(request, poster), apiCreateUser(request, reviewer)]);
|
||||
const officialReviewer = `rv-official-${randomString(8)}`;
|
||||
await Promise.all([
|
||||
apiCreateUser(request, poster),
|
||||
apiCreateUser(request, reviewer),
|
||||
apiCreateUser(request, officialReviewer),
|
||||
]);
|
||||
const posterHeaders = apiUserHeaders(poster);
|
||||
const repoName = `e2e-prreview-${randomString(8)}`;
|
||||
await apiCreateRepo(request, {name: repoName, headers: posterHeaders});
|
||||
await apiCreateFiles(request, poster, repoName, [{path: 'added.txt', content: 'new content\n'}], {branch: 'main', newBranch: 'feat', headers: posterHeaders});
|
||||
await Promise.all([
|
||||
apiAddCollaborator(
|
||||
request,
|
||||
poster,
|
||||
repoName,
|
||||
officialReviewer,
|
||||
{headers: posterHeaders},
|
||||
),
|
||||
apiCreateFiles(
|
||||
request,
|
||||
poster,
|
||||
repoName,
|
||||
[{path: 'added.txt', content: 'new content\n'}],
|
||||
{branch: 'main', newBranch: 'feat', headers: posterHeaders},
|
||||
),
|
||||
]);
|
||||
const prIndex = await apiCreatePR(request, poster, repoName, 'feat', 'main', 'review test', {headers: posterHeaders});
|
||||
const pullUrl = `/${poster}/${repoName}/pulls/${prIndex}`;
|
||||
|
||||
const reRequestAndApprove = async (
|
||||
username: string,
|
||||
approvalTooltip: string,
|
||||
pendingStateClass: string,
|
||||
official: boolean,
|
||||
) => {
|
||||
await page.goto(pullUrl);
|
||||
const reviewerItem = page.locator('.issue-sidebar-combo .ui.relaxed.list > .item', {
|
||||
has: page.locator(`a[href="/${username}"]`),
|
||||
});
|
||||
const reviewState = reviewerItem.locator('span[data-tooltip-content]');
|
||||
await expect(reviewState).toHaveAttribute('data-tooltip-content', approvalTooltip);
|
||||
await reviewerItem.locator('a[data-tooltip-content="Re-request review"]').click();
|
||||
await expect(reviewState).toHaveAttribute('data-tooltip-content', 'Review pending');
|
||||
await expect(reviewState.locator(`.octicon-dot-fill.${pendingStateClass}`)).toBeVisible();
|
||||
|
||||
expect(await apiCreateReview(request, poster, repoName, prIndex, {
|
||||
event: 'APPROVED',
|
||||
headers: apiUserHeaders(username),
|
||||
})).toMatchObject({state: 'APPROVED', official});
|
||||
};
|
||||
|
||||
// reviewer seeds an inline comment via API so the poster's UI reply exercises the reply-to-review path (#35994)
|
||||
await Promise.all([
|
||||
@@ -17,10 +70,14 @@ test('pr review flow', async ({page, request}) => {
|
||||
comments: [{path: 'added.txt', body: 'inline to reply to', new_position: 1}],
|
||||
headers: apiUserHeaders(reviewer),
|
||||
}),
|
||||
apiCreateReview(request, poster, repoName, prIndex, {
|
||||
event: 'APPROVED',
|
||||
headers: apiUserHeaders(officialReviewer),
|
||||
}),
|
||||
loginUser(page, poster),
|
||||
]);
|
||||
|
||||
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
|
||||
await page.goto(`${pullUrl}/files`);
|
||||
|
||||
// diff viewer renders the added file with its header and one added-line row
|
||||
const fileBox = page.locator('.diff-file-box[data-new-filename="added.txt"]');
|
||||
@@ -40,14 +97,18 @@ test('pr review flow', async ({page, request}) => {
|
||||
await replyForm.getByRole('button', {name: 'Reply', exact: true}).click();
|
||||
await expect(conversation.locator('.comment-body')).toContainText(['inline to reply to', 'my reply body']);
|
||||
|
||||
// switch to reviewer and submit an approve review
|
||||
await page.context().clearCookies();
|
||||
await loginUser(page, reviewer);
|
||||
await page.goto(`/${poster}/${repoName}/pulls/${prIndex}/files`);
|
||||
await page.goto(`${pullUrl}/files`);
|
||||
await page.locator('#review-box .js-btn-review').click();
|
||||
const panel = page.locator('.review-box-panel');
|
||||
await panel.locator('textarea[name="content"]').fill('LGTM');
|
||||
await panel.locator('textarea[name="content"]').fill(`First approval from ${reviewer}`);
|
||||
await panel.getByRole('button', {name: 'Approve', exact: true}).click();
|
||||
await expect(page.locator('.timeline-item .octicon-check').first()).toBeVisible();
|
||||
await expect(page.locator('.timeline-item').filter({hasText: 'LGTM'})).toBeVisible();
|
||||
await expect(page.locator('.timeline-item').filter({hasText: `First approval from ${reviewer}`})).toBeVisible();
|
||||
|
||||
await reRequestAndApprove(reviewer, 'Uncounted approval', 'tw-text-text-light', false);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await loginUser(page, officialReviewer);
|
||||
await reRequestAndApprove(officialReviewer, 'Approved', 'tw-text-yellow', true);
|
||||
});
|
||||
|
||||
+18
-14
@@ -1,6 +1,6 @@
|
||||
import {env} from 'node:process';
|
||||
import {expect} from '@playwright/test';
|
||||
import type {APIRequestContext, Locator, Page} from '@playwright/test';
|
||||
import type {APIRequestContext, APIResponse, Locator, Page} from '@playwright/test';
|
||||
|
||||
/** Generate a random alphanumeric string. */
|
||||
export function randomString(length: number): string {
|
||||
@@ -24,11 +24,11 @@ export function apiHeaders() {
|
||||
return apiAuthHeader(env.GITEA_TEST_E2E_USER, env.GITEA_TEST_E2E_PASSWORD);
|
||||
}
|
||||
|
||||
async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => number; text: () => Promise<string>}>, label: string) {
|
||||
async function apiRetry(fn: () => Promise<APIResponse>, label: string): Promise<APIResponse> {
|
||||
const maxAttempts = 5;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const response = await fn();
|
||||
if (response.ok()) return;
|
||||
if (response.ok()) return response;
|
||||
if ([500, 502, 503].includes(response.status()) && attempt < maxAttempts - 1) {
|
||||
const jitter = Math.random() * 500;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1) + jitter));
|
||||
@@ -36,6 +36,7 @@ async function apiRetry(fn: () => Promise<{ok: () => boolean; status: () => numb
|
||||
}
|
||||
throw new Error(`${label} failed: ${response.status()} ${await response.text()}`);
|
||||
}
|
||||
throw new Error(`${label} failed after ${maxAttempts} attempts`);
|
||||
}
|
||||
|
||||
export async function apiCreateRepo(requestContext: APIRequestContext, {name, autoInit = true, headers}: {name: string; autoInit?: boolean; headers?: Record<string, string>}) {
|
||||
@@ -45,6 +46,13 @@ export async function apiCreateRepo(requestContext: APIRequestContext, {name, au
|
||||
}), 'apiCreateRepo');
|
||||
}
|
||||
|
||||
export async function apiAddCollaborator(requestContext: APIRequestContext, owner: string, repo: string, collaborator: string, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.put(`${baseUrl()}/api/v1/repos/${owner}/${repo}/collaborators/${collaborator}`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {permission: 'write'},
|
||||
}), 'apiAddCollaborator');
|
||||
}
|
||||
|
||||
export async function apiCreateOrg(requestContext: APIRequestContext, name: string, {headers}: {headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/orgs`, {
|
||||
headers: headers || apiHeaders(),
|
||||
@@ -91,24 +99,20 @@ export async function apiCloseIssue(requestContext: APIRequestContext, owner: st
|
||||
|
||||
/** Create a PR via API. Returns the PR index for subsequent operations. */
|
||||
export async function apiCreatePR(requestContext: APIRequestContext, owner: string, repo: string, head: string, base: string, title: string, {headers}: {headers?: Record<string, string>} = {}): Promise<number> {
|
||||
let prIndex = 0;
|
||||
await apiRetry(async () => {
|
||||
const response = await requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {head, base, title},
|
||||
});
|
||||
if (response.ok()) prIndex = (await response.json()).number;
|
||||
return response;
|
||||
}, 'apiCreatePR');
|
||||
return prIndex;
|
||||
const response = await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {head, base, title},
|
||||
}), 'apiCreatePR');
|
||||
return (await response.json()).number;
|
||||
}
|
||||
|
||||
/** Create a review on a PR. `event: "COMMENT"` submits immediately without a pending review. */
|
||||
export async function apiCreateReview(requestContext: APIRequestContext, owner: string, repo: string, index: number, {event = 'COMMENT', body, comments = [], headers}: {event?: string; body?: string; comments?: Array<{path: string; body: string; new_position?: number; old_position?: number}>; headers?: Record<string, string>} = {}) {
|
||||
await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, {
|
||||
const response = await apiRetry(() => requestContext.post(`${baseUrl()}/api/v1/repos/${owner}/${repo}/pulls/${index}/reviews`, {
|
||||
headers: headers || apiHeaders(),
|
||||
data: {event, body, comments},
|
||||
}), 'apiCreateReview');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function createProjectColumn(requestContext: APIRequestContext, owner: string, repo: string, projectID: string, title: string) {
|
||||
|
||||
@@ -161,6 +161,11 @@ func TestPullView_CodeOwner(t *testing.T) {
|
||||
hasCodeownerReviews = issue_service.HasAllRequiredCodeownerReviews(t.Context(), &protectBranch, pr)
|
||||
assert.True(t, hasCodeownerReviews)
|
||||
|
||||
_, err = issue_service.ReviewRequest(t.Context(), pr.Issue, user5, nil, user5, true)
|
||||
assert.NoError(t, err)
|
||||
hasCodeownerReviews = issue_service.HasAllRequiredCodeownerReviews(t.Context(), &protectBranch, pr)
|
||||
assert.False(t, hasCodeownerReviews)
|
||||
|
||||
// a later requested-changes review from a required code owner must override
|
||||
// that user's earlier approval and no longer satisfy the requirement
|
||||
_, _, err = issues_model.SubmitReview(t.Context(), user5, pr.Issue, issues_model.ReviewTypeReject, "Needs changes", resp.Commit.SHA, false, make([]string, 0))
|
||||
|
||||
Reference in New Issue
Block a user