mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-09 05:38:54 +09:00
7dbfed37eb
Adds per-event watch options, so a user can choose which repository notifications they receive. The watch button is now a menu with four modes: participating and mentions (the unwatched state, as on GitHub), all activity, ignore, and custom, which opens a dialog to pick issues, pull requests and releases. The same dialog is reachable from the gear icon on the watched repository list. Options apply to UI notifications and to mail. The watcher, star and fork counts moved inside their buttons, so all three share one shape, and the links to those lists moved to the repository sidebar. Assignees and requested reviewers now also receive UI notifications without having commented first. Buttons and dropdown: <img width="688" height="398" alt="image" src="https://github.com/user-attachments/assets/889b567d-086f-4a06-ac67-3a4205fc63b2" /> Modal: <img width="841" height="327" alt="Screenshot 2026-08-07 at 21 10 59" src="https://github.com/user-attachments/assets/3bedbff5-9011-4f1b-91a9-e8f5c212fb5d" /> New entries in sidebar: <img width="170" height="207" alt="Screenshot 2026-08-07 at 21 13 28" src="https://github.com/user-attachments/assets/5f5150cd-f42d-4543-bd0b-7334cf4aab0e" /> Fixes: https://github.com/go-gitea/gitea/issues/17238 Ref: https://github.com/go-gitea/gitea/issues/35492 --------- Co-authored-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
456 lines
16 KiB
Go
456 lines
16 KiB
Go
// Copyright 2017 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package integration
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
auth_model "gitea.dev/models/auth"
|
|
repo_model "gitea.dev/models/repo"
|
|
"gitea.dev/models/unittest"
|
|
"gitea.dev/modules/git/gitcmd"
|
|
api "gitea.dev/modules/structs"
|
|
"gitea.dev/modules/test"
|
|
"gitea.dev/tests"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func testPullCreate(t *testing.T, session *TestSession, user, repo string, toSelf bool, targetBranch, sourceBranch, title string) *httptest.ResponseRecorder {
|
|
req := NewRequest(t, "GET", "/"+path.Join(user, repo))
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// Click the PR button to create a pull
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
link, exists := htmlDoc.doc.Find("#new-pull-request").Attr("href")
|
|
assert.True(t, exists, "The template has changed")
|
|
|
|
targetUser := strings.Split(link, "/")[1]
|
|
if toSelf && targetUser != user {
|
|
link = strings.Replace(link, targetUser, user, 1)
|
|
}
|
|
|
|
if targetBranch != "master" {
|
|
link = strings.Replace(link, "master...", targetBranch+"...", 1)
|
|
}
|
|
if sourceBranch != "master" {
|
|
if targetUser == user {
|
|
link = strings.Replace(link, "...master", "..."+sourceBranch, 1)
|
|
} else {
|
|
link = strings.Replace(link, ":master", ":"+sourceBranch, 1)
|
|
}
|
|
}
|
|
|
|
req = NewRequest(t, "GET", link)
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// Submit the form for creating the pull
|
|
htmlDoc = NewHTMLParser(t, resp.Body)
|
|
link, exists = htmlDoc.doc.Find("form#new-issue").Attr("action")
|
|
assert.True(t, exists, "The template has changed")
|
|
req = NewRequestWithValues(t, "POST", link, map[string]string{
|
|
"title": title,
|
|
})
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
return resp
|
|
}
|
|
|
|
type createPullRequestOptions struct {
|
|
BaseRepoOwner string
|
|
BaseRepoName string
|
|
BaseBranch string
|
|
HeadRepoOwner string
|
|
HeadRepoName string
|
|
HeadBranch string
|
|
Title string
|
|
ReviewerIDs string // comma-separated list of user IDs
|
|
}
|
|
|
|
func (opts createPullRequestOptions) IsValid() bool {
|
|
return opts.BaseRepoOwner != "" && opts.BaseRepoName != "" && opts.BaseBranch != "" &&
|
|
opts.HeadBranch != "" && opts.Title != ""
|
|
}
|
|
|
|
func testPullCreateDirectly(t *testing.T, session *TestSession, opts createPullRequestOptions) *httptest.ResponseRecorder {
|
|
if !opts.IsValid() {
|
|
t.Fatal("Invalid pull request options")
|
|
}
|
|
|
|
headCompare := opts.HeadBranch
|
|
if opts.HeadRepoOwner != "" {
|
|
if opts.HeadRepoName != "" {
|
|
headCompare = fmt.Sprintf("%s/%s:%s", opts.HeadRepoOwner, opts.HeadRepoName, opts.HeadBranch)
|
|
} else {
|
|
headCompare = fmt.Sprintf("%s:%s", opts.HeadRepoOwner, opts.HeadBranch)
|
|
}
|
|
}
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/compare/%s...%s", opts.BaseRepoOwner, opts.BaseRepoName, opts.BaseBranch, headCompare))
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// Submit the form for creating the pull
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action")
|
|
assert.True(t, exists, "The template has changed")
|
|
params := map[string]string{
|
|
"title": opts.Title,
|
|
}
|
|
if opts.ReviewerIDs != "" {
|
|
params["reviewer_ids"] = opts.ReviewerIDs
|
|
}
|
|
req = NewRequestWithValues(t, "POST", link, params)
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
return resp
|
|
}
|
|
|
|
func testPullCreateFailure(t *testing.T, session *TestSession, baseRepoOwner, baseRepoName, baseBranch, headRepoOwner, headRepoName, headBranch, title string) *httptest.ResponseRecorder {
|
|
headCompare := headBranch
|
|
if headRepoOwner != "" {
|
|
if headRepoName != "" {
|
|
headCompare = fmt.Sprintf("%s/%s:%s", headRepoOwner, headRepoName, headBranch)
|
|
} else {
|
|
headCompare = fmt.Sprintf("%s:%s", headRepoOwner, headBranch)
|
|
}
|
|
}
|
|
req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/compare/%s...%s", baseRepoOwner, baseRepoName, baseBranch, headCompare))
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// Submit the form for creating the pull
|
|
htmlDoc := NewHTMLParser(t, resp.Body)
|
|
link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action")
|
|
assert.True(t, exists, "The template has changed")
|
|
req = NewRequestWithValues(t, "POST", link, map[string]string{
|
|
"title": title,
|
|
})
|
|
resp = session.MakeRequest(t, req, http.StatusBadRequest)
|
|
return resp
|
|
}
|
|
|
|
func TestPullCreate(t *testing.T) {
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
session := loginUser(t, "user1")
|
|
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
|
|
testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
|
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
|
|
assert.Equal(t, 3, repo1.NumPulls)
|
|
assert.Equal(t, 3, repo1.NumOpenPulls)
|
|
resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
|
|
|
|
repo1 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
|
|
assert.Equal(t, 4, repo1.NumPulls)
|
|
assert.Equal(t, 4, repo1.NumOpenPulls)
|
|
|
|
// check the redirected URL
|
|
url := test.RedirectURL(resp)
|
|
assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url)
|
|
|
|
// test create the pull request again and it should fail now
|
|
link := "/user2/repo1/compare/master...user1/repo1:master"
|
|
req := NewRequestWithValues(t, "POST", link, map[string]string{
|
|
"title": "This is a pull title",
|
|
})
|
|
session.MakeRequest(t, req, http.StatusBadRequest)
|
|
|
|
// check .diff can be accessed and matches performed change
|
|
req = NewRequest(t, "GET", url+".diff")
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
assert.Regexp(t, `\+Hello, World \(Edited\)`, resp.Body)
|
|
assert.Regexp(t, "^diff", resp.Body)
|
|
assert.NotRegexp(t, "diff.*diff", resp.Body) // not two diffs, just one
|
|
|
|
// check .patch can be accessed and matches performed change
|
|
req = NewRequest(t, "GET", url+".patch")
|
|
resp = session.MakeRequest(t, req, http.StatusOK)
|
|
assert.Regexp(t, `\+Hello, World \(Edited\)`, resp.Body)
|
|
assert.Regexp(t, "diff", resp.Body)
|
|
assert.Regexp(t, `Subject: \[PATCH\] Update README.md`, resp.Body)
|
|
assert.NotRegexp(t, "diff.*diff", resp.Body) // not two diffs, just one
|
|
})
|
|
}
|
|
|
|
func testUIDeleteBranch(t *testing.T, session *TestSession, ownerName, repoName, branchName string) {
|
|
relURL := "/" + path.Join(ownerName, repoName, "branches")
|
|
req := NewRequestWithValues(t, "POST", relURL+"/delete", map[string]string{
|
|
"name": branchName,
|
|
})
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
}
|
|
|
|
func testDeleteRepository(t *testing.T, session *TestSession, ownerName, repoName string) {
|
|
relURL := "/" + path.Join(ownerName, repoName, "settings")
|
|
req := NewRequestWithValues(t, "POST", relURL+"?action=delete", map[string]string{
|
|
"repo_name": repoName,
|
|
})
|
|
resp := session.MakeRequest(t, req, http.StatusOK)
|
|
assert.NotNil(t, test.ParseJSONRedirect(resp.Body.Bytes()).Redirect)
|
|
}
|
|
|
|
func TestPullBranchDelete(t *testing.T) {
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
defer tests.PrepareTestEnv(t)()
|
|
|
|
session := loginUser(t, "user1")
|
|
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
|
|
testCreateBranch(t, session, "user1", "repo1", "branch/master", "master1", http.StatusSeeOther)
|
|
testEditFile(t, session, "user1", "repo1", "master1", "README.md", "Hello, World (Edited)\n")
|
|
resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master1", "This is a pull title")
|
|
|
|
// check the redirected URL
|
|
url := test.RedirectURL(resp)
|
|
assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url)
|
|
req := NewRequest(t, "GET", url)
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// delete head branch and confirm pull page is ok
|
|
testUIDeleteBranch(t, session, "user1", "repo1", "master1")
|
|
req = NewRequest(t, "GET", url)
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
|
|
// delete head repository and confirm pull page is ok
|
|
testDeleteRepository(t, session, "user1", "repo1")
|
|
req = NewRequest(t, "GET", url)
|
|
session.MakeRequest(t, req, http.StatusOK)
|
|
})
|
|
}
|
|
|
|
/*
|
|
Setup:
|
|
The base repository is: user2/repo1
|
|
Fork repository to: user1/repo1
|
|
Push extra commit to: user2/repo1, which changes README.md
|
|
Create a PR on user1/repo1
|
|
|
|
Test checks:
|
|
Check if pull request can be created from base to the fork repository.
|
|
*/
|
|
func TestPullCreatePrFromBaseToFork(t *testing.T) {
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
sessionFork := loginUser(t, "user1")
|
|
testRepoFork(t, sessionFork, "user2", "repo1", "user1", "repo1", "")
|
|
|
|
// Edit base repository
|
|
sessionBase := loginUser(t, "user2")
|
|
testEditFile(t, sessionBase, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n")
|
|
|
|
// Create a PR
|
|
resp := testPullCreateDirectly(t, sessionFork, createPullRequestOptions{
|
|
BaseRepoOwner: "user1",
|
|
BaseRepoName: "repo1",
|
|
BaseBranch: "master",
|
|
HeadRepoOwner: "user2",
|
|
HeadRepoName: "repo1",
|
|
HeadBranch: "master",
|
|
Title: "This is a pull title",
|
|
})
|
|
// check the redirected URL
|
|
url := test.RedirectURL(resp)
|
|
assert.Regexp(t, "^/user1/repo1/pulls/[0-9]*$", url)
|
|
})
|
|
}
|
|
|
|
func TestCreatePullRequestFromNestedOrgForks(t *testing.T) {
|
|
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
|
|
session := loginUser(t, "user1")
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
|
|
|
|
const (
|
|
baseOrg = "test-fork-org1"
|
|
midForkOrg = "test-fork-org2"
|
|
leafForkOrg = "test-fork-org3"
|
|
repoName = "test-fork-repo"
|
|
patchBranch = "teabot-patch-1"
|
|
)
|
|
|
|
createOrg := func(name string) {
|
|
req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{
|
|
UserName: name,
|
|
Visibility: "public",
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
}
|
|
|
|
createOrg(baseOrg)
|
|
createOrg(midForkOrg)
|
|
createOrg(leafForkOrg)
|
|
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", baseOrg), &api.CreateRepoOption{
|
|
Name: repoName,
|
|
AutoInit: true,
|
|
DefaultBranch: "main",
|
|
Private: false,
|
|
Readme: "Default",
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
|
baseRepo := DecodeJSON(t, resp, &api.Repository{})
|
|
assert.Equal(t, "main", baseRepo.DefaultBranch)
|
|
|
|
forkIntoOrg := func(srcOrg, dstOrg string) api.Repository {
|
|
req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", srcOrg, repoName), &api.CreateForkOption{
|
|
Organization: new(dstOrg),
|
|
}).AddTokenAuth(token)
|
|
resp := MakeRequest(t, req, http.StatusAccepted)
|
|
forkRepo := DecodeJSON(t, resp, &api.Repository{})
|
|
assert.NotNil(t, forkRepo.Owner)
|
|
if forkRepo.Owner != nil {
|
|
assert.Equal(t, dstOrg, forkRepo.Owner.UserName)
|
|
}
|
|
return *forkRepo
|
|
}
|
|
|
|
forkIntoOrg(baseOrg, midForkOrg)
|
|
forkIntoOrg(midForkOrg, leafForkOrg)
|
|
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", leafForkOrg, repoName, "patch-from-org3.txt"), &api.CreateFileOptions{
|
|
FileOptions: api.FileOptions{
|
|
BranchName: "main",
|
|
NewBranchName: patchBranch,
|
|
Message: "create patch from org3",
|
|
},
|
|
ContentBase64: base64.StdEncoding.EncodeToString([]byte("patch content")),
|
|
}).AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusCreated)
|
|
|
|
prPayload := map[string]string{
|
|
"head": fmt.Sprintf("%s:%s", leafForkOrg, patchBranch),
|
|
"base": "main",
|
|
"title": "test creating pull from test-fork-org3 to test-fork-org1",
|
|
}
|
|
req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls", baseOrg, repoName), prPayload).AddTokenAuth(token)
|
|
resp = MakeRequest(t, req, http.StatusCreated)
|
|
pr := DecodeJSON(t, resp, &api.PullRequest{})
|
|
assert.Equal(t, prPayload["title"], pr.Title)
|
|
if assert.NotNil(t, pr.Head) {
|
|
assert.Equal(t, patchBranch, pr.Head.Ref)
|
|
if assert.NotNil(t, pr.Head.Repository) {
|
|
assert.Equal(t, fmt.Sprintf("%s/%s", leafForkOrg, repoName), pr.Head.Repository.FullName)
|
|
}
|
|
}
|
|
if assert.NotNil(t, pr.Base) {
|
|
assert.Equal(t, "main", pr.Base.Ref)
|
|
if assert.NotNil(t, pr.Base.Repository) {
|
|
assert.Equal(t, fmt.Sprintf("%s/%s", baseOrg, repoName), pr.Base.Repository.FullName)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPullCreateParallel(t *testing.T) {
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
sessionFork := loginUser(t, "user1")
|
|
testRepoFork(t, sessionFork, "user2", "repo1", "user1", "repo1", "")
|
|
|
|
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
|
|
assert.Equal(t, 3, repo1.NumPulls)
|
|
assert.Equal(t, 3, repo1.NumOpenPulls)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := range 5 {
|
|
wg.Go(func() {
|
|
branchName := fmt.Sprintf("new-branch-%d", i)
|
|
testEditFileToNewBranch(t, sessionFork, "user1", "repo1", "master", branchName, "README.md", fmt.Sprintf("Hello, World (Edited) %d\n", i))
|
|
|
|
// Create a PR
|
|
resp := testPullCreateDirectly(t, sessionFork, createPullRequestOptions{
|
|
BaseRepoOwner: "user2",
|
|
BaseRepoName: "repo1",
|
|
BaseBranch: "master",
|
|
HeadRepoOwner: "user1",
|
|
HeadRepoName: "repo1",
|
|
HeadBranch: branchName,
|
|
Title: fmt.Sprintf("This is a pull title %d", i),
|
|
})
|
|
// check the redirected URL
|
|
url := test.RedirectURL(resp)
|
|
assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
|
|
repo1 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
|
|
assert.Equal(t, 8, repo1.NumPulls)
|
|
assert.Equal(t, 8, repo1.NumOpenPulls)
|
|
})
|
|
}
|
|
|
|
func TestCreateAgitPullWithReadPermission(t *testing.T) {
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
dstPath := t.TempDir()
|
|
|
|
u.Path = "user2/repo1.git"
|
|
u.User = url.UserPassword("user4", userPassword)
|
|
|
|
doGitClone(dstPath, u)(t)
|
|
doGitCheckoutWriteFileCommit(localGitAddCommitOptions{
|
|
LocalRepoPath: dstPath,
|
|
CheckoutBranch: "master",
|
|
TreeFilePath: "new-file-for-agit.txt",
|
|
TreeFileContent: "temp content",
|
|
})(t)
|
|
|
|
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
|
|
assert.Equal(t, 3, repo.NumPulls)
|
|
assert.Equal(t, 3, repo.NumOpenPulls)
|
|
|
|
err := gitcmd.NewCommand("push", "origin", "HEAD:refs/for/master", "-o").
|
|
AddDynamicArguments("topic=test-topic").
|
|
WithDir(dstPath).
|
|
Run(t.Context())
|
|
assert.NoError(t, err)
|
|
|
|
repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
|
|
assert.Equal(t, 4, repo.NumPulls)
|
|
assert.Equal(t, 4, repo.NumOpenPulls)
|
|
})
|
|
}
|
|
|
|
/*
|
|
Setup: user2 has repository, user1 forks it
|
|
---
|
|
|
|
1. User2 blocks User1
|
|
2. User1 adds changes to fork
|
|
3. User1 attempts to create a pull request
|
|
4. User1 sees alert that the action is not allowed because of the block
|
|
*/
|
|
func TestCreatePullWhenBlocked(t *testing.T) {
|
|
RepoOwner := "user2"
|
|
ForkOwner := "user16"
|
|
onGiteaRun(t, func(t *testing.T, u *url.URL) {
|
|
// Setup
|
|
// User1 forks repo1 from User2
|
|
sessionFork := loginUser(t, ForkOwner)
|
|
testRepoFork(t, sessionFork, RepoOwner, "repo1", ForkOwner, "forkrepo1", "")
|
|
|
|
// 1. User2 blocks user1
|
|
// sessionBase := loginUser(t, "user2")
|
|
token := getUserToken(t, RepoOwner, auth_model.AccessTokenScopeWriteUser)
|
|
|
|
req := NewRequest(t, "GET", "/api/v1/user/blocks/"+ForkOwner).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNotFound)
|
|
req = NewRequest(t, "PUT", "/api/v1/user/blocks/"+ForkOwner).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
|
|
// 2. User1 adds changes to fork
|
|
testEditFile(t, sessionFork, ForkOwner, "forkrepo1", "master", "README.md", "Hello, World (Edited)\n")
|
|
|
|
// 3. User1 attempts to create a pull request
|
|
testPullCreateFailure(t, sessionFork, RepoOwner, "repo1", "master", ForkOwner, "forkrepo1", "master", "This is a pull title")
|
|
|
|
// Teardown
|
|
// Unblock user
|
|
req = NewRequest(t, "DELETE", "/api/v1/user/blocks/"+ForkOwner).
|
|
AddTokenAuth(token)
|
|
MakeRequest(t, req, http.StatusNoContent)
|
|
})
|
|
}
|