diff --git a/models/activities/notification.go b/models/activities/notification.go index 8a830c5aa2..82678e7708 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -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 diff --git a/models/activities/notification_list.go b/models/activities/notification_list.go index d38758bc41..0e5c372591 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -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) diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go index 6f2253c815..ac29dd34dd 100644 --- a/models/activities/notification_test.go +++ b/models/activities/notification_test.go @@ -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) +} diff --git a/models/fixtures/watch.yml b/models/fixtures/watch.yml index b7ee121f24..e676149ec1 100644 --- a/models/fixtures/watch.yml +++ b/models/fixtures/watch.yml @@ -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 diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go index f384e086e5..65f443d72b 100644 --- a/models/issues/issue_watch.go +++ b/models/issues/issue_watch.go @@ -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 diff --git a/models/issues/pull.go b/models/issues/pull.go index 4c8d02a990..bbd8ce14b5 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -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) +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index c3a8f08b5d..e3283ad411 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -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 } diff --git a/models/migrations/v1_27/v332.go b/models/migrations/v1_27/v332.go new file mode 100644 index 0000000000..1362ab4b86 --- /dev/null +++ b/models/migrations/v1_27/v332.go @@ -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 +} diff --git a/models/repo/watch.go b/models/repo/watch.go index 1e63d5c3d2..b84eb998ac 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -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. diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 97576fb787..52c029066b 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -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) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e57..7513499cd2 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go index 616e1ee89c..9f5504869b 100644 --- a/routers/web/repo/watch.go +++ b/routers/web/repo/watch.go @@ -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() +} diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 8133388c5d..45b6bfb932 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -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) diff --git a/routers/web/web.go b/routers/web/web.go index ecd75250d2..20c1c5af56 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/context/repo.go b/services/context/repo.go index 4c31b07b34..7d5c20531a 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -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) } diff --git a/services/feed/feed.go b/services/feed/feed.go index 87ba224c89..0635a5c338 100644 --- a/services/feed/feed.go +++ b/services/feed/feed.go @@ -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 } } diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index b854d61a1a..fbe617b8d9 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -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) } diff --git a/services/mailer/mail_issue_test.go b/services/mailer/mail_issue_test.go new file mode 100644 index 0000000000..b247b41e48 --- /dev/null +++ b/services/mailer/mail_issue_test.go @@ -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}}", "

Issue

")() + + 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) +} diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 1f940f33df..1ad537f859 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -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 diff --git a/services/mailer/mail_release_test.go b/services/mailer/mail_release_test.go index 6fc8587f98..9f24538e25 100644 --- a/services/mailer/mail_release_test.go +++ b/services/mailer/mail_release_test.go @@ -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}}", "

{{.Release.TagName}}

")() + + 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) +} diff --git a/services/repository/repository_test.go b/services/repository/repository_test.go index 5a879a6aa6..169fb21856 100644 --- a/services/repository/repository_test.go +++ b/services/repository/repository_test.go @@ -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) diff --git a/services/uinotification/notify.go b/services/uinotification/notify.go index dd3f1557c6..7386947f61 100644 --- a/services/uinotification/notify.go +++ b/services/uinotification/notify.go @@ -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) diff --git a/templates/repo/header/watch.tmpl b/templates/repo/header/watch.tmpl index a00fd01c80..4a9b965cb6 100644 --- a/templates/repo/header/watch.tmpl +++ b/templates/repo/header/watch.tmpl @@ -1,11 +1,11 @@ -
+
{{$buttonText := ctx.Locale.Tr "repo.watch"}} {{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}} - + {{CountFmt .Repository.NumWatches}} + {{if $.IsWatchingRepo}} + + {{svg "octicon-gear"}} + + {{end}}
+{{template "repo/watch_options_modal" $}} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index cc73bdf13c..06958974e1 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -13,7 +13,7 @@ {{template "base/alert" .}} -
+
{{ctx.Locale.Tr "notifications"}}
+
+

{{ctx.Locale.Tr "repo.watch.options.desc"}}

+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ + +
+
diff --git a/templates/shared/repo/list.tmpl b/templates/shared/repo/list.tmpl index 078e6e67bc..69b263ce61 100644 --- a/templates/shared/repo/list.tmpl +++ b/templates/shared/repo/list.tmpl @@ -1,3 +1,6 @@ +{{/* Template Attributes: +* showWatchOptions: whether to show the watch options button +*/}}
{{range .Repos}}
@@ -37,7 +40,7 @@
{{$description := .DescriptionHTML ctx}} @@ -72,4 +87,5 @@ {{ctx.Locale.Tr "search.no_results"}}
{{end}} + {{template "repo/watch_options_modal"}} diff --git a/templates/user/notification/notification_subscriptions.tmpl b/templates/user/notification/notification_subscriptions.tmpl index 79abcbeb4d..b450b4f488 100644 --- a/templates/user/notification/notification_subscriptions.tmpl +++ b/templates/user/notification/notification_subscriptions.tmpl @@ -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}} diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index ba15f8962e..b98ebb3e32 100644 --- a/tests/integration/issue_test.go +++ b/tests/integration/issue_test.go @@ -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, diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go index 48e8c96d38..c6a24b3a20 100644 --- a/tests/integration/pull_create_test.go +++ b/tests/integration/pull_create_test.go @@ -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, diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go index de86a3f348..c85da74b7d 100644 --- a/tests/integration/release_test.go +++ b/tests/integration/release_test.go @@ -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{ diff --git a/web_src/css/index.css b/web_src/css/index.css index 06f7101af9..5a9a4a31df 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -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"; diff --git a/web_src/css/modules/button.css b/web_src/css/modules/button.css index 9b509f8cd4..2a8fb60dc1 100644 --- a/web_src/css/modules/button.css +++ b/web_src/css/modules/button.css @@ -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); diff --git a/web_src/css/repo/watch.css b/web_src/css/repo/watch.css new file mode 100644 index 0000000000..45a2f8c390 --- /dev/null +++ b/web_src/css/repo/watch.css @@ -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; +} diff --git a/web_src/js/features/repo-watch-options.ts b/web_src/js/features/repo-watch-options.ts new file mode 100644 index 0000000000..0fe7ca8c7c --- /dev/null +++ b/web_src/js/features/repo-watch-options.ts @@ -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('#repo-watch-options-modal'); + if (!elModal) return; + + const elPullRequests = elModal.querySelector('[name="pull_requests"]')!; + const elIssues = elModal.querySelector('[name="issues"]')!; + const elReleases = elModal.querySelector('[name="releases"]')!; + + const showModal = (btn: HTMLElement) => { + const form = elModal.querySelector('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); + }); + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index cb2b56a5bd..a2c61f9103 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -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, diff --git a/web_src/js/modules/observer.ts b/web_src/js/modules/observer.ts index c82d9ce7e0..5abe3033be 100644 --- a/web_src/js/modules/observer.ts +++ b/web_src/js/modules/observer.ts @@ -34,7 +34,7 @@ export function registerGlobalSelectorFunc(selector: string, } } -// It handles the global init functions for all `
` elements. +// It handles the global init functions for all `
` elements. export function registerGlobalInitFunc(name: string, handler: GlobalInitFunc) { globalInitFuncs[name] = handler as GlobalInitFunc; // The "global init" functions are managed internally and called by callGlobalInitFunc