0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-14 04:20:42 +02:00

Merge 4308598c98550ee5ff717aaef4fb3bb425796dd6 into a5d81d9ce230aaa6e1021b6236ca01cb6d2b56c3

This commit is contained in:
Alexandre Bontems 2026-05-08 22:44:11 -04:00 committed by GitHub
commit 53ba749b8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 595 additions and 66 deletions

View File

@ -115,7 +115,7 @@ func init() {
db.RegisterModel(new(Notification))
}
// CreateRepoTransferNotification creates notification for the user a repository was transferred to
// CreateRepoTransferNotification creates notification for the user a repository was transferred to
func CreateRepoTransferNotification(ctx context.Context, doer, newOwner *user_model.User, repo *repo_model.Repository) error {
return db.WithTx(ctx, func(ctx context.Context) error {
var notify []*Notification

View File

@ -101,7 +101,12 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
}
toNotify.AddMultiple(issueWatches...)
if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) {
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID)
repoWatchType := repo_model.WatchIssues
if issue.IsPull {
repoWatchType = repo_model.WatchPullRequests
}
repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID, repoWatchType)
if err != nil {
return err
}
@ -112,6 +117,18 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
return err
}
toNotify.AddMultiple(issueParticipants...)
issueAssignees, err := issues_model.GetAssigneeIDsByIssue(ctx, issueID)
if err != nil {
return err
}
toNotify.AddMultiple(issueAssignees...)
if issue.IsPull {
issueReviewers, err := issues_model.GetPullRequestRequestedReviewerIDs(ctx, issueID)
if err != nil {
return err
}
toNotify.AddMultiple(issueReviewers...)
}
// don't notify user who cause notification
delete(toNotify, notificationAuthorID)

View File

@ -7,9 +7,11 @@ import (
"context"
"testing"
"code.gitea.io/gitea/models/activities"
activities_model "code.gitea.io/gitea/models/activities"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
@ -138,3 +140,69 @@ func TestSetIssueReadBy(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, activities_model.NotificationStatusRead, nt.Status)
}
func TestIssueNotificationWithWatchOptions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
iss := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
assert.NoError(t, issues_model.RemoveIssueWatchersByRepoID(t.Context(), watcher.ID, repo.ID))
assert.NoError(t, repo_model.WatchRepo(t.Context(), watcher, repo, true))
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), watcher, repo, repo_model.WatchOptions{
PullRequests: true,
Issues: false,
Releases: true,
}))
assert.NoError(t, activities_model.CreateOrUpdateIssueNotifications(t.Context(), iss.ID, 0, doer.ID, 0))
notification, err := activities.GetIssueNotification(t.Context(), watcher.ID, iss.ID)
assert.NoError(t, err)
assert.Equal(t, int64(0), notification.IssueID) // No notification found
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), watcher, repo, repo_model.WatchOptions{
PullRequests: true,
Issues: true,
Releases: true,
}))
assert.NoError(t, activities_model.CreateOrUpdateIssueNotifications(t.Context(), iss.ID, 0, doer.ID, 0))
notification, err = activities.GetIssueNotification(t.Context(), watcher.ID, iss.ID)
assert.NoError(t, err)
assert.Equal(t, activities.NotificationStatusUnread, notification.Status)
}
func TestPullRequestNotificationWithWatchOptions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 8})
assert.NoError(t, repo_model.WatchRepo(t.Context(), watcher, repo2, true))
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), watcher, repo2, repo_model.WatchOptions{
PullRequests: false,
Issues: true,
Releases: true,
}))
assert.NoError(t, activities_model.CreateOrUpdateIssueNotifications(t.Context(), pr.ID, 0, doer.ID, 0))
notification, err := activities.GetIssueNotification(t.Context(), watcher.ID, pr.ID)
assert.NoError(t, err)
assert.Equal(t, int64(0), notification.IssueID) // No notification found
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), watcher, repo2, repo_model.WatchOptions{
PullRequests: true,
Issues: true,
Releases: true,
}))
assert.NoError(t, activities_model.CreateOrUpdateIssueNotifications(t.Context(), pr.ID, 0, doer.ID, 0))
notification, err = activities.GetIssueNotification(t.Context(), watcher.ID, pr.ID)
assert.NoError(t, err)
assert.Equal(t, activities.NotificationStatusUnread, notification.Status)
}

View File

@ -1,43 +1,56 @@
-
id: 1
- id: 1
user_id: 1
repo_id: 1
mode: 1 # normal
pull_requests: true
issues: true
releases: true
-
id: 2
- id: 2
user_id: 4
repo_id: 1
mode: 1 # normal
pull_requests: true
issues: true
releases: true
-
id: 3
- id: 3
user_id: 9
repo_id: 1
mode: 1 # normal
pull_requests: true
issues: true
releases: true
-
id: 4
- id: 4
user_id: 8
repo_id: 1
mode: 2 # don't watch
pull_requests: true
issues: true
releases: true
-
id: 5
- id: 5
user_id: 11
repo_id: 1
mode: 3 # auto
pull_requests: true
issues: true
releases: true
-
id: 6
- id: 6
user_id: 10
repo_id: 21
mode: 1 # normal
pull_requests: true
issues: true
releases: true
-
id: 7
- id: 7
user_id: 10
repo_id: 32
mode: 1 # normal
pull_requests: true
issues: true
releases: true
# DO NOT add more test data in the fixtures, test case should prepare their own test data separately and clearly

View File

@ -81,7 +81,14 @@ func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (
if err != nil {
return false, err
}
return repo_model.IsWatchMode(w.Mode) || IsUserParticipantsOfIssue(ctx, user, issue), nil
if repo_model.IsWatchMode(w.Mode) {
if issue.IsPull {
return w.PullRequests, nil
} else {
return w.Issues, nil
}
}
return IsUserParticipantsOfIssue(ctx, user, issue), nil
}
// GetIssueWatchersIDs returns IDs of subscribers or explicit unsubscribers to a given issue id

View File

@ -1008,3 +1008,14 @@ func GetPullRequestByMergedCommit(ctx context.Context, repoID int64, sha string)
return pr, nil
}
// GetPullRequestRequestedReviewerIDs returns IDs of reviewers requested for the given pull request
func GetPullRequestRequestedReviewerIDs(ctx context.Context, issueID int64) ([]int64, error) {
userIDs := make([]int64, 0, 5)
return userIDs, db.GetEngine(ctx).
Table("review").
Cols("reviewer_id").
Where("issue_id=?", issueID).
Distinct("reviewer_id").
Find(&userIDs)
}

View File

@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration {
// Gitea 1.26.0 ends at migration ID number 330 (database version 331)
newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel),
newMigration(332, "Add watch options", v1_27.AddWatchOptions),
}
return preparedMigrations
}

View File

@ -0,0 +1,21 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_27
import (
"xorm.io/xorm"
)
func AddWatchOptions(x *xorm.Engine) error {
type Watch struct { //revive:disable-line:exported
PullRequests bool `xorm:"NOT NULL DEFAULT true"`
Issues bool `xorm:"NOT NULL DEFAULT true"`
Releases bool `xorm:"NOT NULL DEFAULT true"`
}
_, err := x.SyncWithOptions(xorm.SyncOptions{
IgnoreConstrains: true,
IgnoreIndices: true,
}, new(Watch))
return err
}

View File

@ -26,14 +26,25 @@ const (
WatchModeAuto // 3
)
type WatchType int8
const (
WatchPullRequests WatchType = iota // 0
WatchIssues // 1
WatchReleases // 2
)
// Watch is connection request for receiving repository notification.
type Watch struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch)"`
RepoID int64 `xorm:"UNIQUE(watch)"`
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch)"`
RepoID int64 `xorm:"UNIQUE(watch)"`
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
PullRequests bool `xorm:"NOT NULL DEFAULT true"`
Issues bool `xorm:"NOT NULL DEFAULT true"`
Releases bool `xorm:"NOT NULL DEFAULT true"`
}
func init() {
@ -120,11 +131,37 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW
return user_model.ErrBlockedUser
}
watch.PullRequests = true
watch.Issues = true
watch.Releases = true
return watchRepoMode(ctx, watch, WatchModeNormal)
}
// GetWatchers returns all watchers of given repository.
func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
type WatchOptions struct {
PullRequests bool
Issues bool
Releases bool
}
func WatchRepoOptions(ctx context.Context, doer *user_model.User, repo *Repository, opts WatchOptions) error {
watch := Watch{UserID: doer.ID, RepoID: repo.ID}
if _, err := db.GetEngine(ctx).Get(&watch); err != nil {
return err
}
watch.PullRequests = opts.PullRequests
watch.Issues = opts.Issues
watch.Releases = opts.Releases
if _, err := db.GetEngine(ctx).ID(watch.ID).Cols("pull_requests", "issues", "releases").Update(&watch); err != nil {
return err
}
return nil
}
// GetRepoWatches returns all watches of given repository.
func GetRepoWatches(ctx context.Context, repoID int64) ([]*Watch, error) {
watches := make([]*Watch, 0, 10)
return watches, db.GetEngine(ctx).Where("`watch`.repo_id=?", repoID).
And("`watch`.mode<>?", WatchModeDont).
@ -137,13 +174,45 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
// GetRepoWatchersIDs returns IDs of watchers for a given repo ID
// but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required
func GetRepoWatchersIDs(ctx context.Context, repoID int64) ([]int64, error) {
func GetRepoWatchersIDs(ctx context.Context, repoID int64, watchType WatchType) ([]int64, error) {
ids := make([]int64, 0, 64)
return ids, db.GetEngine(ctx).Table("watch").
sess := db.GetEngine(ctx).Table("watch").
Where("watch.repo_id=?", repoID).
And("watch.mode<>?", WatchModeDont)
switch watchType {
case WatchPullRequests:
sess = sess.And("watch.pull_requests=?", true)
case WatchIssues:
sess = sess.And("watch.issues=?", true)
case WatchReleases:
sess = sess.And("watch.releases=?", true)
}
return ids, sess.Select("user_id").Find(&ids)
}
func GetWatches(ctx context.Context, repos []*Repository) (map[int64]*Watch, error) {
repoIDs := make([]int64, 0, len(repos))
for i := range repos {
repoIDs = append(repoIDs, repos[i].ID)
}
var watches []*Watch
err := db.GetEngine(ctx).Table("watch").
In("watch.repo_id", repoIDs).
And("watch.mode<>?", WatchModeDont).
Select("user_id").
Find(&ids)
Find(&watches)
if err != nil {
return nil, err
}
watchesByRepo := make(map[int64]*Watch, len(watches))
for _, w := range watches {
watchesByRepo[w.RepoID] = w
}
return watchesByRepo, nil
}
// GetRepoWatchers returns range of users watching given repository.

View File

@ -32,7 +32,7 @@ func TestGetWatchers(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
watches, err := repo_model.GetWatchers(t.Context(), repo.ID)
watches, err := repo_model.GetRepoWatches(t.Context(), repo.ID)
assert.NoError(t, err)
// One watchers are inactive, thus minus 1
assert.Len(t, watches, repo.NumWatches-1)
@ -40,7 +40,7 @@ func TestGetWatchers(t *testing.T) {
assert.Equal(t, repo.ID, watch.RepoID)
}
watches, err = repo_model.GetWatchers(t.Context(), unittest.NonexistentID)
watches, err = repo_model.GetRepoWatches(t.Context(), unittest.NonexistentID)
assert.NoError(t, err)
assert.Empty(t, watches)
}
@ -125,16 +125,41 @@ func TestClearRepoWatches(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
const repoID int64 = 1
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repoID)
watchers, err := repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1})
require.NoError(t, err)
require.NotEmpty(t, watchers)
assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID))
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repoID)
watchers, err = repo_model.GetRepoWatchers(t.Context(), repoID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Empty(t, watchers)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
assert.Zero(t, repo.NumWatches)
}
func TestWatchOptions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, repo_model.WatchRepo(t.Context(), user5, repo, true))
watch, err := repo_model.GetWatch(t.Context(), user5.ID, repo.ID)
assert.NoError(t, err)
assert.True(t, watch.PullRequests)
assert.True(t, watch.Issues)
assert.True(t, watch.Releases)
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), user5, repo, repo_model.WatchOptions{
PullRequests: true,
Issues: false,
Releases: true,
}))
watch, err = repo_model.GetWatch(t.Context(), user5.ID, repo.ID)
assert.NoError(t, err)
assert.True(t, watch.PullRequests)
assert.False(t, watch.Issues)
assert.True(t, watch.Releases)
}

View File

@ -1154,6 +1154,10 @@
"repo.star_guest_user": "Sign in to star this repository.",
"repo.unwatch": "Unwatch",
"repo.watch": "Watch",
"repo.watch.options.desc": "Select notifications you want to receive in addition to mentions and subscriptions.",
"repo.watch.options.pull_requests": "Pull Requests",
"repo.watch.options.issues": "Issues",
"repo.watch.options.releases": "Releases",
"repo.unstar": "Unstar",
"repo.star": "Star",
"repo.fork": "Fork",

View File

@ -20,11 +20,33 @@ func ActionWatch(ctx *context.Context) {
return
}
ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
watch, err := repo_model.GetWatch(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("GetWatch", err)
return
}
ctx.Data["Watch"] = watch
ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode)
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
if err != nil {
ctx.ServerError("GetRepositoryByName", err)
return
}
ctx.HTML(http.StatusOK, tplWatchUnwatch)
}
func ActionWatchOptions(ctx *context.Context) {
err := repo_model.WatchRepoOptions(ctx, ctx.Doer, ctx.Repo.Repository, repo_model.WatchOptions{
PullRequests: ctx.FormBool("pull_requests"),
Issues: ctx.FormBool("issues"),
Releases: ctx.FormBool("releases"),
})
if err != nil {
handleActionError(ctx, err)
return
}
ctx.JSONOK()
}

View File

@ -373,6 +373,13 @@ func NotificationWatching(ctx *context.Context) {
ctx.Data["Total"] = count
ctx.Data["Repos"] = repos
watches, err := repo_model.GetWatches(ctx, repos)
if err != nil {
ctx.ServerError("GetWatches", err)
return
}
ctx.Data["Watches"] = watches
// redirect to last page if request page is more than total pages
pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req)

View File

@ -1728,6 +1728,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/search", reqUnitCodeReader, repo.Search)
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar)
m.Post("/action/{action:watch|unwatch}", reqSignIn, repo.ActionWatch)
m.Post("/action/watch/options", reqSignIn, repo.ActionWatchOptions)
m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
}, optSignIn, context.RepoAssignment)

View File

@ -651,7 +651,13 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
}
if ctx.IsSigned {
ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, repo.ID)
watch, err := repo_model.GetWatch(ctx, ctx.Doer.ID, repo.ID)
if err != nil {
ctx.ServerError("GetWatch", err)
return
}
ctx.Data["Watch"] = watch
ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode)
ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
}

View File

@ -115,16 +115,16 @@ func NotifyWatchers(ctx context.Context, acts ...*activities_model.Action) error
actUserID := acts[0].ActUserID
// Add feeds for user self and all watchers.
watchers, err := repo_model.GetWatchers(ctx, repoID)
// Add feeds for user self and all watches.
watches, err := repo_model.GetRepoWatches(ctx, repoID)
if err != nil {
return fmt.Errorf("get watchers: %w", err)
}
permCode := make([]bool, len(watchers))
permIssue := make([]bool, len(watchers))
permPR := make([]bool, len(watchers))
for i, watcher := range watchers {
permCode := make([]bool, len(watches))
permIssue := make([]bool, len(watches))
permPR := make([]bool, len(watches))
for i, watcher := range watches {
user, err := user_model.GetUserByID(ctx, watcher.UserID)
if err != nil {
permCode[i] = false
@ -153,7 +153,7 @@ func NotifyWatchers(ctx context.Context, acts ...*activities_model.Action) error
}
act.Repo = repo
if err := notifyWatchers(ctx, act, watchers, permCode, permIssue, permPR); err != nil {
if err := notifyWatchers(ctx, act, watches, permCode, permIssue, permPR); err != nil {
return err
}
}

View File

@ -66,7 +66,12 @@ func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, m
// =========== Repo watchers ===========
// Make repo watchers last, since it's likely the list with the most users
if !(comment.Issue.IsPull && comment.Issue.PullRequest.IsWorkInProgress(ctx) && comment.ActionType != activities_model.ActionCreatePullRequest) {
ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID)
watchType := repo_model.WatchIssues
if comment.Issue.IsPull {
watchType = repo_model.WatchPullRequests
}
ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID, watchType)
if err != nil {
return fmt.Errorf("GetRepoWatchersIDs(%d): %w", comment.Issue.RepoID, err)
}

View File

@ -0,0 +1,86 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mailer
import (
"testing"
"code.gitea.io/gitea/models/activities"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
sender_service "code.gitea.io/gitea/services/mailer/sender"
"github.com/stretchr/testify/assert"
)
func TestMailNewIssueAndPullRequest(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.MailService)()
defer test.MockVariableValue(&setting.Domain)()
defer test.MockVariableValue(&setting.AppName)()
defer test.MockVariableValue(&setting.AppURL)()
setting.MailService = &setting.Mailer{
From: "Gitea",
FromEmail: "noreply@example.com",
}
setting.Domain = "example.com"
setting.AppName = "Gitea"
setting.AppURL = "https://example.com/"
defer mockMailTemplates(string("repo/issue/new"), "{{.Subject}}", "<p>Issue</p>")()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
didSend := false
origSend := SendAsync
SendAsync = func(msgs ...*sender_service.Message) {
for _, msg := range msgs {
if msg.To == watcher.Email {
didSend = true
}
}
}
defer func() {
SendAsync = origSend
}()
iss := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
pr := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
assert.NoError(t, issues_model.RemoveIssueWatchersByRepoID(t.Context(), watcher.ID, repo.ID))
assert.NoError(t, repo_model.WatchRepo(t.Context(), watcher, repo, true))
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), watcher, repo, repo_model.WatchOptions{
PullRequests: false,
Issues: false,
Releases: true,
}))
var mentions []*user_model.User
assert.NoError(t, MailParticipants(t.Context(), iss, doer, activities.ActionCreateIssue, mentions))
assert.False(t, didSend)
didSend = false
assert.NoError(t, MailParticipants(t.Context(), pr, doer, activities.ActionCreatePullRequest, mentions))
assert.False(t, didSend)
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), watcher, repo, repo_model.WatchOptions{
PullRequests: true,
Issues: true,
Releases: true,
}))
didSend = false
assert.NoError(t, MailParticipants(t.Context(), pr, doer, activities.ActionCreatePullRequest, mentions))
assert.True(t, didSend)
didSend = false
assert.NoError(t, MailParticipants(t.Context(), iss, doer, activities.ActionCreateIssue, mentions))
assert.True(t, didSend)
}

View File

@ -35,7 +35,7 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
return
}
watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID)
watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID, repo_model.WatchReleases)
if err != nil {
log.Error("GetRepoWatchersIDs(%d): %v", rel.RepoID, err)
return

View File

@ -62,3 +62,55 @@ func TestMailNewReleaseFiltersUnauthorizedWatchers(t *testing.T) {
assert.Equal(t, admin.EmailTo(), sent[0].To)
assert.NotEqual(t, unauthorized.EmailTo(), sent[0].To)
}
func TestMailNewReleaseWithWatchOptions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
defer test.MockVariableValue(&setting.MailService)()
defer test.MockVariableValue(&setting.Domain)()
defer test.MockVariableValue(&setting.AppName)()
defer test.MockVariableValue(&setting.AppURL)()
setting.MailService = &setting.Mailer{
From: "Gitea",
FromEmail: "noreply@example.com",
}
setting.Domain = "example.com"
setting.AppName = "Gitea"
setting.AppURL = "https://example.com/"
defer mockMailTemplates(string(tplNewReleaseMail), "{{.Subject}}", "<p>{{.Release.TagName}}</p>")()
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true))
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), user, repo, repo_model.WatchOptions{
PullRequests: true,
Issues: true,
Releases: false,
}))
didSend := false
origSend := SendAsync
SendAsync = func(msgs ...*sender_service.Message) {
didSend = true
}
defer func() {
SendAsync = origSend
}()
rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: 11})
rel.Publisher = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: rel.PublisherID})
MailNewRelease(t.Context(), rel)
assert.False(t, didSend)
assert.NoError(t, repo_model.WatchRepoOptions(t.Context(), user, repo, repo_model.WatchOptions{
PullRequests: true,
Issues: true,
Releases: true,
}))
didSend = false
MailNewRelease(t.Context(), rel)
assert.True(t, didSend)
}

View File

@ -76,13 +76,13 @@ func TestMakeRepoPrivateClearsWatches(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo.IsPrivate)
watchers, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
watchers, err := repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
require.NoError(t, err)
require.NotEmpty(t, watchers)
assert.NoError(t, MakeRepoPrivate(t.Context(), repo, true))
watchers, err = repo_model.GetRepoWatchersIDs(t.Context(), repo.ID)
watchers, err = repo_model.GetRepoWatchers(t.Context(), repo.ID, db.ListOptions{Page: 1})
assert.NoError(t, err)
assert.Empty(t, watchers)

View File

@ -139,22 +139,18 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo
return
}
toNotify := make(container.Set[int64], 32)
repoWatchers, err := repo_model.GetRepoWatchersIDs(ctx, pr.Issue.RepoID)
repoWatchers, err := repo_model.GetRepoWatchersIDs(ctx, pr.Issue.RepoID, repo_model.WatchPullRequests)
if err != nil {
log.Error("GetRepoWatchersIDs: %v", err)
return
}
for _, id := range repoWatchers {
toNotify.Add(id)
}
toNotify.AddMultiple(repoWatchers...)
issueParticipants, err := issues_model.GetParticipantsIDsByIssueID(ctx, pr.IssueID)
if err != nil {
log.Error("GetParticipantsIDsByIssueID: %v", err)
return
}
for _, id := range issueParticipants {
toNotify.Add(id)
}
toNotify.AddMultiple(issueParticipants...)
delete(toNotify, pr.Issue.PosterID)
for _, mention := range mentions {
toNotify.Add(mention.ID)

View File

@ -1,11 +1,11 @@
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}>
<div class="ui buttons" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}>
{{$buttonText := ctx.Locale.Tr "repo.watch"}}
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}}
<a role="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
{{if $.IsSigned}}
data-fetch-method="post"
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}"
data-fetch-sync="$closest(.ui.labeled.button)"
data-fetch-sync="$closest(.ui.buttons)"
{{else}}
href="{{AppSubUrl}}/user/login"
{{end}}
@ -13,7 +13,19 @@
{{svg "octicon-eye"}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
</a>
<a class="ui basic label" href="{{$.RepoLink}}/watchers">
<a class="ui basic compact small button watch-label" href="{{$.RepoLink}}/watchers">
{{CountFmt .Repository.NumWatches}}
</a>
{{if $.IsWatchingRepo}}
<a class="ui basic compact small button watch-icon"
data-global-init="initWatchOptions"
data-repo-link="{{$.RepoLink}}"
data-watch-pull-requests="{{$.Watch.PullRequests}}"
data-watch-issues="{{$.Watch.Issues}}"
data-watch-releases="{{$.Watch.Releases}}"
>
{{svg "octicon-gear"}}
</a>
{{end}}
</div>
{{template "repo/watch_options_modal" $}}

View File

@ -13,7 +13,7 @@
</h2>
{{template "base/alert" .}}
<form class="ui form" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm"
<form class="ui form" action="{{.Link}}" id="new-release" method="post" data-global-init="initReleaseEditForm"
data-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"

View File

@ -0,0 +1,33 @@
<div class="ui small modal" id="repo-watch-options-modal">
<div class="header">{{ctx.Locale.Tr "notifications"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "repo.watch.options.desc"}}</p>
<form class="ui form" method="post">
<div class="field">
<div class="ui checkbox">
<input name="pull_requests" type="checkbox"><label>{{ctx.Locale.Tr "repo.watch.options.pull_requests"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="issues" type="checkbox"><label>{{ctx.Locale.Tr "repo.watch.options.issues"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input name="releases" type="checkbox"><label>{{ctx.Locale.Tr "repo.watch.options.releases"}}</label>
</div>
</div>
</form>
</div>
<div class="actions">
<button class="ui small basic cancel button">
{{svg "octicon-x"}}
{{ctx.Locale.Tr "cancel"}}
</button>
<button class="ui primary small approve button">
{{svg "fontawesome-save"}}
{{ctx.Locale.Tr "save"}}
</button>
</div>
</div>

View File

@ -1,3 +1,6 @@
{{/* Template Attributes:
* showWatchOptions: whether to show the watch options button
*/}}
<div class="flex-divided-list items-with-main">
{{range .Repos}}
<div class="item">
@ -37,7 +40,7 @@
<div class="item-trailing muted-links">
{{if .PrimaryLanguage}}
<a class="flex-text-inline" href="?q={{$.Keyword}}&sort={{$.SortType}}&language={{.PrimaryLanguage.Language}}{{if $.TabName}}&tab={{$.TabName}}{{end}}">
<i class="color-icon tw-mr-2" style="background-color: {{.PrimaryLanguage.Color}}"></i>
<i class="color-icon tw-mr-1" style="background-color: {{.PrimaryLanguage.Color}}"></i>
{{.PrimaryLanguage.Language}}
</a>
{{end}}
@ -51,6 +54,18 @@
<span class="tw-contents" aria-label="{{ctx.Locale.Tr "repo.forks"}}">{{svg "octicon-git-branch" 16}}</span>
<span {{if ge .NumForks 1000}}data-tooltip-content="{{.NumForks}}"{{end}}>{{CountFmt .NumForks}}</span>
</a>
{{if $.showWatchOptions}}
{{$watch := index $.Watches .ID}}
<a class="flex-text-inline tw-ml-1"
data-global-init="initWatchOptions"
data-repo-link="{{.Link}}"
data-watch-pull-requests="{{$watch.PullRequests}}"
data-watch-issues="{{$watch.Issues}}"
data-watch-releases="{{$watch.Releases}}"
>
{{svg "octicon-gear"}}
</a>
{{end}}
</div>
</div>
{{$description := .DescriptionHTML ctx}}
@ -72,4 +87,5 @@
{{ctx.Locale.Tr "search.no_results"}}
</div>
{{end}}
{{template "repo/watch_options_modal"}}
</div>

View File

@ -68,7 +68,7 @@
{{end}}
{{else}}
{{template "shared/repo/search" .}}
{{template "shared/repo/list" .}}
{{template "shared/repo/list" dict "." . "showWatchOptions" true}}
{{template "base/paginate" .}}
{{end}}
</div>

View File

@ -127,7 +127,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
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,

View File

@ -55,7 +55,7 @@ func testPullCreate(t *testing.T, session *TestSession, user, repo string, toSel
// Submit the form for creating the pull
htmlDoc = NewHTMLParser(t, resp.Body)
link, exists = htmlDoc.doc.Find("form.ui.form").Attr("action")
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,
@ -98,7 +98,7 @@ func testPullCreateDirectly(t *testing.T, session *TestSession, opts createPullR
// Submit the form for creating the pull
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
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,
@ -125,7 +125,7 @@ func testPullCreateFailure(t *testing.T, session *TestSession, baseRepoOwner, ba
// Submit the form for creating the pull
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
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,

View File

@ -24,7 +24,7 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
link, exists := htmlDoc.doc.Find("form#new-release").Attr("action")
assert.True(t, exists, "The template has changed")
postData := map[string]string{

View File

@ -73,6 +73,7 @@
@import "./repo/clone.css";
@import "./repo/commit-sign.css";
@import "./repo/packages.css";
@import "./repo/watch.css";
@import "./editor/combomarkdowneditor.css";

View File

@ -400,11 +400,21 @@ It needs some tricks to tweak the left/right borders with active state */
border-left: 1px solid var(--color-light-border);
}
.ui.buttons .button:first-child:hover,
.ui.buttons .button.tw-hidden:first-child + .button:hover {
border-left-color: var(--color-secondary-dark-2);
}
.ui.buttons .button:last-child,
.ui.buttons .button:nth-last-child(2):has(+ .button.tw-hidden) {
border-right: 1px solid var(--color-light-border);
}
.ui.buttons .button:last-child:hover,
.ui.buttons .button:nth-last-child(2):has(+ .button.tw-hidden):hover {
border-right-color: var(--color-secondary-dark-2);
}
.ui.buttons .button.active {
border-left: 1px solid var(--color-light-border);
border-right: 1px solid var(--color-light-border);

View File

@ -0,0 +1,9 @@
.ui.buttons .ui.watch-label {
font-size: 1rem !important;
font-weight: var(--font-weight-medium);
padding: 2px 6px;
}
.ui.buttons .ui.watch-icon {
padding: 0.42em;
}

View File

@ -0,0 +1,35 @@
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {submitFormFetchAction} from './common-fetch-action.ts';
export function initRepoWatchOptions() {
const elModal = document.querySelector<HTMLElement>('#repo-watch-options-modal');
if (!elModal) return;
const elPullRequests = elModal.querySelector<HTMLInputElement>('[name="pull_requests"]')!;
const elIssues = elModal.querySelector<HTMLInputElement>('[name="issues"]')!;
const elReleases = elModal.querySelector<HTMLInputElement>('[name="releases"]')!;
const showModal = (btn: HTMLElement) => {
const form = elModal.querySelector<HTMLFormElement>('form')!;
elPullRequests.checked = btn.getAttribute('data-watch-pull-requests') === 'true';
elIssues.checked = btn.getAttribute('data-watch-issues') === 'true';
elReleases.checked = btn.getAttribute('data-watch-releases') === 'true';
form.action = `${btn.getAttribute('data-repo-link')!}/action/watch/options`;
fomanticQuery(elModal).modal({
autofocus: false,
onApprove() {
submitFormFetchAction(form);
return false;
},
}).modal('show');
};
registerGlobalInitFunc('initWatchOptions', (el: HTMLElement) => {
el.addEventListener('click', (e) => {
e.preventDefault();
showModal(el);
});
});
}

View File

@ -66,6 +66,7 @@ import {initActionsPermissionsForm} from './features/common-actions-permissions.
import {initRefIssueContextPopup} from './features/ref-issue.ts';
import {initGlobalShortcut} from './modules/shortcut.ts';
import {initDevtest} from './modules/devtest.ts';
import {initRepoWatchOptions} from './features/repo-watch-options.ts';
const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([
@ -143,6 +144,7 @@ const initPerformanceTracer = callInitFunctions([
initRepoContributors,
initRepoCodeFrequency,
initRepoRecentCommits,
initRepoWatchOptions,
initCommitStatuses,
initCaptcha,

View File

@ -34,7 +34,7 @@ export function registerGlobalSelectorFunc<T extends Element>(selector: string,
}
}
// It handles the global init functions for all `<div data-global-int="initSomeElem"></div>` elements.
// It handles the global init functions for all `<div data-global-init="initSomeElem"></div>` elements.
export function registerGlobalInitFunc<T extends HTMLElement>(name: string, handler: GlobalInitFunc<T>) {
globalInitFuncs[name] = handler as GlobalInitFunc<Element>;
// The "global init" functions are managed internally and called by callGlobalInitFunc