diff --git a/modelmigration/migrations.go b/modelmigration/migrations.go index 9acf58764c3..5a805c17885 100644 --- a/modelmigration/migrations.go +++ b/modelmigration/migrations.go @@ -420,6 +420,7 @@ func prepareMigrationTasks() []*migration { newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob), newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews), newMigration(346, "Add license_path column to repo_license and backfill", v1_28.AddLicensePathToRepoLicense), + newMigration(347, "Add watch options", v1_28.AddWatchOptions), } return preparedMigrations } diff --git a/modelmigration/v1_28/v347.go b/modelmigration/v1_28/v347.go new file mode 100644 index 00000000000..ee8262be703 --- /dev/null +++ b/modelmigration/v1_28/v347.go @@ -0,0 +1,25 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_28 + +import ( + "context" + + "gitea.dev/modelmigration/base" + + "xorm.io/xorm" +) + +func AddWatchOptions(_ context.Context, x base.EngineMigration) error { + type Watch struct { + 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/activities/notification_list.go b/models/activities/notification_list.go index 97170263b20..e344b7f21de 100644 --- a/models/activities/notification_list.go +++ b/models/activities/notification_list.go @@ -106,7 +106,8 @@ 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) + watchType := util.Iif(issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues) + repoWatches, err := repo_model.GetRepoWatchersIDs(ctx, issue.RepoID, watchType) if err != nil { return nil, err } @@ -117,6 +118,18 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n return nil, err } toNotify.AddMultiple(issueParticipants...) + issueAssignees, err := issues_model.GetAssigneeIDsByIssue(ctx, issueID) + if err != nil { + return nil, err + } + toNotify.AddMultiple(issueAssignees...) + if issue.IsPull { + issueReviewers, err := issues_model.GetPullRequestRequestedReviewerIDs(ctx, issueID) + if err != nil { + return nil, err + } + toNotify.AddMultiple(issueReviewers...) + } // don't notify user who cause notification delete(toNotify, notificationAuthorID) @@ -130,6 +143,15 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n } } + // muting the repository outranks every other source, including mentions + ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, issue.RepoID) + if err != nil { + return nil, err + } + for _, id := range ignorers { + toNotify.Remove(id) + } + if err := issue.LoadRepo(ctx); err != nil { return nil, err } diff --git a/models/activities/notification_test.go b/models/activities/notification_test.go index 4f978449e20..72e6bc21c4b 100644 --- a/models/activities/notification_test.go +++ b/models/activities/notification_test.go @@ -10,6 +10,7 @@ import ( activities_model "gitea.dev/models/activities" "gitea.dev/models/db" issues_model "gitea.dev/models/issues" + repo_model "gitea.dev/models/repo" "gitea.dev/models/unittest" user_model "gitea.dev/models/user" @@ -32,6 +33,39 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) { assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status) } +func TestCreateOrUpdateIssueNotificationsForAssigneeAndReviewer(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // user 13 neither watches repo 1 nor participates in PR 3 + assert.NoError(t, db.Insert(t.Context(), &issues_model.IssueAssignees{AssigneeID: 13, IssueID: 3})) + _, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 3, 0, 1, 0) + assert.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 13, IssueID: 3}) + + // user 1 is a requested reviewer of PR 12 and does not participate in it + _, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 12, 0, 2, 0) + assert.NoError(t, err) + unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{UserID: 1, IssueID: 12}) +} + +func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // user 4 watches repo 1 and would be notified about issue 1 + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) + assert.NoError(t, repo_model.IgnoreRepo(t.Context(), user, repo)) + + notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0) + assert.NoError(t, err) + assert.NotContains(t, notified, user.ID) + + // muting outranks a direct receiver too + notified, err = activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, user.ID) + assert.NoError(t, err) + assert.Empty(t, notified) +} + func TestNotificationsForUser(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) diff --git a/models/issues/issue_watch.go b/models/issues/issue_watch.go index 9ac7278f83f..e16621a7ec3 100644 --- a/models/issues/issue_watch.go +++ b/models/issues/issue_watch.go @@ -10,6 +10,7 @@ import ( repo_model "gitea.dev/models/repo" user_model "gitea.dev/models/user" "gitea.dev/modules/timeutil" + "gitea.dev/modules/util" ) // IssueWatch is connection request for receiving issue notification. @@ -81,7 +82,10 @@ 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) && util.Iif(issue.IsPull, w.PullRequests, w.Issues) { + return true, 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 7eb0a5b72db..f7a62a0bf5a 100644 --- a/models/issues/pull.go +++ b/models/issues/pull.go @@ -1012,3 +1012,16 @@ func GetPullRequestByMergedCommit(ctx context.Context, repoID int64, sha string) return pr, nil } + +// GetPullRequestRequestedReviewerIDs returns IDs of reviewers currently 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). + And("type=?", ReviewTypeRequest). + And("reviewer_id > 0"). + Distinct("reviewer_id"). + Find(&userIDs) +} diff --git a/models/repo/watch.go b/models/repo/watch.go index 7b7c331c2ba..fdbcb745131 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -28,14 +28,26 @@ const ( WatchModeAuto // 3 ) +// WatchType is the `watch` column gating one kind of notification +type WatchType string + +const ( + WatchPullRequests WatchType = "pull_requests" + WatchIssues WatchType = "issues" + WatchReleases WatchType = "releases" +) + // 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() { @@ -48,8 +60,8 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) { if err != nil { return watch, err } - if watch == nil { - watch = &Watch{UserID: userID, RepoID: repoID} + if watch == nil { // the dummy record must mirror the column defaults + watch = &Watch{UserID: userID, RepoID: repoID, PullRequests: true, Issues: true, Releases: true} } if !has { watch.Mode = WatchModeNone @@ -57,6 +69,11 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) { return watch, nil } +// IsIgnoring reports whether the user muted the repository entirely +func (w *Watch) IsIgnoring() bool { + return w.Mode == WatchModeDont +} + // IsWatchMode Decodes watchability of WatchMode func IsWatchMode(mode WatchMode) bool { return mode != WatchModeNone && mode != WatchModeDont @@ -87,15 +104,16 @@ func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error repodiff = -1 } + if repodiff == 1 { // starting to watch resets the options, otherwise a custom selection survives + watch.PullRequests, watch.Issues, watch.Releases = true, true, true + } watch.Mode = mode if !hadrec && needsrec { - watch.Mode = mode if err = db.Insert(ctx, watch); err != nil { return err } } else if needsrec { - watch.Mode = mode if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil { return err } @@ -127,6 +145,48 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW return watchRepoMode(ctx, watch, WatchModeNormal) } +// IgnoreRepo mutes the repository, so nothing about it reaches the user. +func IgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error { + watch, err := GetWatch(ctx, doer.ID, repo.ID) + if err != nil { + return err + } + return watchRepoMode(ctx, watch, WatchModeDont) +} + +type WatchOptions struct { + PullRequests bool + Issues bool + Releases bool +} + +// SetWatchOptions updates the per-event options of a watch, callers must run WatchRepo first +func SetWatchOptions(ctx context.Context, userID, repoID int64, opts WatchOptions) error { + _, err := db.GetEngine(ctx).Where("user_id=? AND repo_id=?", userID, repoID). + Cols(string(WatchPullRequests), string(WatchIssues), string(WatchReleases)). + Update(&Watch{PullRequests: opts.PullRequests, Issues: opts.Issues, Releases: opts.Releases}) + return err +} + +// GetUserWatches returns the watches of one user, keyed by repository ID +func GetUserWatches(ctx context.Context, userID int64, repoIDs []int64) (map[int64]*Watch, error) { + if len(repoIDs) == 0 { + return map[int64]*Watch{}, nil + } + watches := make([]*Watch, 0, len(repoIDs)) + if err := db.GetEngine(ctx).Where("user_id=?", userID). + In("repo_id", repoIDs). + And("mode<>?", WatchModeDont). + Find(&watches); err != nil { + return nil, err + } + watchesByRepo := make(map[int64]*Watch, len(watches)) + for _, watch := range watches { + watchesByRepo[watch.RepoID] = watch + } + return watchesByRepo, nil +} + // GetWatchers returns all watchers of given repository. func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { watches := make([]*Watch, 0, 10) @@ -138,14 +198,25 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { Find(&watches) } -// GetRepoWatchersIDs returns IDs of watchers for a given repo ID +// GetRepoIgnorersIDs returns IDs of users who muted the given repo ID +func GetRepoIgnorersIDs(ctx context.Context, repoID int64) ([]int64, error) { + ids := make([]int64, 0, 8) + return ids, db.GetEngine(ctx).Table("watch"). + Where("repo_id=?", repoID). + And("mode=?", WatchModeDont). + Select("user_id"). + Find(&ids) +} + +// GetRepoWatchersIDs returns IDs of watchers for a given repo ID that opted into watchType // 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"). Where("watch.repo_id=?", repoID). And("watch.mode<>?", WatchModeDont). + And(builder.Eq{"watch." + string(watchType): true}). Select("user_id"). Find(&ids) } diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index a0603e38d84..ef4ee3403e0 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -125,16 +125,47 @@ 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 1 is watched by users 1, 4, 9 and 11, all with every event enabled + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + assert.NoError(t, repo_model.SetWatchOptions(t.Context(), user.ID, repo.ID, repo_model.WatchOptions{PullRequests: true})) + + for watchType, expected := range map[repo_model.WatchType][]int64{ + repo_model.WatchPullRequests: {1, 4, 9, 11}, + repo_model.WatchIssues: {4, 9, 11}, + repo_model.WatchReleases: {4, 9, 11}, + } { + ids, err := repo_model.GetRepoWatchersIDs(t.Context(), repo.ID, watchType) + assert.NoError(t, err) + assert.ElementsMatch(t, expected, ids, watchType) + } + + // the options of one user must not show up for another + watches, err := repo_model.GetUserWatches(t.Context(), 4, []int64{repo.ID}) + assert.NoError(t, err) + assert.True(t, watches[repo.ID].Issues) + + // watching again resets a custom selection + assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, false)) + assert.NoError(t, repo_model.WatchRepo(t.Context(), user, repo, true)) + watch, err := repo_model.GetWatch(t.Context(), user.ID, repo.ID) + assert.NoError(t, err) + assert.True(t, watch.PullRequests && watch.Issues && watch.Releases) +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 626aeb65d8f..d00ad1ca266 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -1154,8 +1154,21 @@ "repo.fork_guest_user": "Sign in to fork this repository.", "repo.watch_guest_user": "Sign in to watch this repository.", "repo.star_guest_user": "Sign in to star this repository.", - "repo.unwatch": "Unwatch", "repo.watch": "Watch", + "repo.watching": "Watching", + "repo.ignoring": "Ignoring", + "repo.watch.options.required": "Select at least one event type.", + "repo.watch.options.issues": "Issues", + "repo.watch.options.pull_requests": "Pull Requests", + "repo.watch.options.releases": "Releases", + "repo.watch.mode.participating": "Participating and mentions", + "repo.watch.mode.participating.desc": "Receive notifications from this repository when participating or mentioned.", + "repo.watch.mode.all": "All activity", + "repo.watch.mode.all.desc": "Receive notifications for all events.", + "repo.watch.mode.ignore": "Ignore", + "repo.watch.mode.ignore.desc": "Never receive notifications from this repository.", + "repo.watch.mode.custom": "Custom", + "repo.watch.mode.custom.desc": "Choose the events you want to receive notifications for.", "repo.unstar": "Unstar", "repo.star": "Star", "repo.fork": "Fork", diff --git a/routers/web/repo/watch.go b/routers/web/repo/watch.go index 8d745cb91f8..5e5cce5a126 100644 --- a/routers/web/repo/watch.go +++ b/routers/web/repo/watch.go @@ -11,20 +11,62 @@ import ( "gitea.dev/services/context" ) -const tplWatchUnwatch templates.TplName = "repo/header/watch" +const tplWatch templates.TplName = "repo/header/watch" func ActionWatch(ctx *context.Context) { - err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, ctx.PathParam("action") == "watch") + action := ctx.PathParam("action") + var err error + if action == "ignore" { + err = repo_model.IgnoreRepo(ctx, ctx.Doer, ctx.Repo.Repository) + } else { + err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, action == "watch") + } if err != nil { handleActionError(ctx, err) return } + if action == "watch" { // watching again always restores every event, so "all activity" can undo a custom selection + opts := repo_model.WatchOptions{PullRequests: true, Issues: true, Releases: true} + if err := repo_model.SetWatchOptions(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, opts); err != nil { + ctx.ServerError("SetWatchOptions", err) + return + } + } + + 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["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) 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) + ctx.HTML(http.StatusOK, tplWatch) +} + +// ActionWatchOptions watches the repository with a custom selection of events +func ActionWatchOptions(ctx *context.Context) { + opts := repo_model.WatchOptions{ + PullRequests: ctx.FormBool(string(repo_model.WatchPullRequests)), + Issues: ctx.FormBool(string(repo_model.WatchIssues)), + Releases: ctx.FormBool(string(repo_model.WatchReleases)), + } + if !opts.PullRequests && !opts.Issues && !opts.Releases { + ctx.JSONError(ctx.Tr("repo.watch.options.required")) + return + } + if err := repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, true); err != nil { + handleActionError(ctx, err) + return + } + if err := repo_model.SetWatchOptions(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, opts); err != nil { + ctx.ServerError("SetWatchOptions", err) + return + } + ctx.JSONRedirect("") } diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 6301e525429..11bb5ec3eed 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -399,6 +399,13 @@ func NotificationWatching(ctx *context.Context) { ctx.Data["Total"] = count ctx.Data["Repos"] = repos + watches, err := repo_model.GetUserWatches(ctx, ctx.Doer.ID, repos.IDs()) + if err != nil { + ctx.ServerError("GetUserWatches", 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 99ffc06756c..e2a90c0d733 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1743,7 +1743,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/watchers", repo.Watchers) 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/{action:watch|unwatch|ignore}", 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 cb6964b435c..da5dc689529 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -639,7 +639,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/mailer/mail_issue.go b/services/mailer/mail_issue.go index 482bb3c9aca..c5e0e039f84 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -16,6 +16,7 @@ import ( "gitea.dev/modules/container" "gitea.dev/modules/log" "gitea.dev/modules/setting" + "gitea.dev/modules/util" ) const MailBatchSize = 100 // batch size used in mailIssueCommentBatch @@ -66,7 +67,8 @@ 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 := util.Iif(comment.Issue.IsPull, repo_model.WatchPullRequests, repo_model.WatchIssues) + ids, err = repo_model.GetRepoWatchersIDs(ctx, comment.Issue.RepoID, watchType) if err != nil { return fmt.Errorf("GetRepoWatchersIDs(%d): %w", comment.Issue.RepoID, err) } @@ -75,6 +77,13 @@ func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, m visited := make(container.Set[int64], len(unfiltered)+len(mentions)+1) + // muting the repository outranks every other source, including mentions + ignorers, err := repo_model.GetRepoIgnorersIDs(ctx, comment.Issue.RepoID) + if err != nil { + return fmt.Errorf("GetRepoIgnorersIDs(%d): %w", comment.Issue.RepoID, err) + } + visited.AddMultiple(ignorers...) + // Avoid mailing the doer if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification { visited.Add(comment.Doer.ID) diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index db45c201007..8b19916a069 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/repository/repository_test.go b/services/repository/repository_test.go index 756801c29f3..d7bceae0329 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) @@ -99,14 +99,14 @@ func TestUpdateRepositoryClearsWatchesOnVisibilityChange(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) repo.IsPrivate = true require.NoError(t, updateRepository(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 072f32d0b5e..b0aa8c1ac32 100644 --- a/services/uinotification/notify.go +++ b/services/uinotification/notify.go @@ -145,7 +145,7 @@ 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 diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index c522bf38655..e5426cde462 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -78,6 +78,7 @@ {{if .IsGenerated}}
{{ctx.Locale.Tr "repo.generated_from"}} {{(.TemplateRepo ctx).FullName}}
{{end}} {{end}} + {{if .IsSigned}}{{template "repo/watch_options_modal"}}{{end}}
diff --git a/templates/repo/header/fork.tmpl b/templates/repo/header/fork.tmpl index 3054729d2ae..86480694d3f 100644 --- a/templates/repo/header/fork.tmpl +++ b/templates/repo/header/fork.tmpl @@ -9,20 +9,18 @@ {{else if $canNotForkOwn}} {{$forkHref = "#"}} {{end}} - + {{svg "octicon-repo-forked"}} + {{ctx.Locale.Tr "repo.fork"}} + {{CountFmt $.Repository.NumForks}} + {{if $.ShowForkModal}} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index a930664554e..a14c2710ec5 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -13,7 +13,7 @@ {{template "base/alert" .}} -
+
{{ctx.Locale.Tr "notifications"}}
+ +
+ {{range $name := StringUtils.Split "issues,pull_requests,releases" ","}} +
+
+ +
+
+ {{end}} +
+ {{template "base/modal_actions_confirm" dict "ModalButtonTypes" "confirm"}} +
+
diff --git a/templates/shared/repo/list.tmpl b/templates/shared/repo/list.tmpl index 0b84aa0812e..d574da9be7e 100644 --- a/templates/shared/repo/list.tmpl +++ b/templates/shared/repo/list.tmpl @@ -55,6 +55,14 @@ {{svg "octicon-repo-forked" 16}} {{CountFmt .NumForks}} + {{$watch := and $.Watches (index $.Watches .ID)}} + {{if $watch}} + + {{end}} {{$description := .DescriptionHTML ctx}} @@ -76,4 +84,5 @@ {{ctx.Locale.Tr "search.no_results"}} {{end}} + {{if $.Watches}}{{template "repo/watch_options_modal"}}{{end}} diff --git a/tests/e2e/repo-star-watch.test.ts b/tests/e2e/repo-star-watch.test.ts index d5f84491651..8d3d0b8cade 100644 --- a/tests/e2e/repo-star-watch.test.ts +++ b/tests/e2e/repo-star-watch.test.ts @@ -11,10 +11,11 @@ test('star and watch a repository', async ({page, request}) => { ]); await page.goto(`/${owner}/${repoName}`); - // exact match so "Star"/"Watch" don't also match "Unstar"/"Unwatch" + // exact match so "Star"/"Watch" don't also match "Unstar"/"Watching" await page.getByRole('button', {name: 'Star', exact: true}).click(); await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible(); await page.getByRole('button', {name: 'Watch', exact: true}).click(); - await expect(page.getByRole('button', {name: 'Unwatch'})).toBeVisible(); + await page.getByRole('menuitem', {name: 'All activity', exact: true}).click(); + await expect(page.getByRole('button', {name: 'Watching'})).toBeVisible(); }); diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go index 8b21c96541e..ccc730326e3 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 eaee27652a8..7bc08a2b6dd 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 f495e417d93..7fd9d1f10b5 100644 --- a/tests/integration/release_test.go +++ b/tests/integration/release_test.go @@ -25,7 +25,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/repo/home.css b/web_src/css/repo/home.css index b34fde2b460..3f26fd04ab6 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -21,6 +21,11 @@ grid-row: 2; } +.repo-home-sidebar-top a:hover, +.repo-home-sidebar-bottom a:hover { + text-decoration-line: none; +} + .repo-home-sidebar-header { font-weight: var(--font-weight-semibold); font-size: 16px; diff --git a/web_src/js/features/repo-watch.ts b/web_src/js/features/repo-watch.ts new file mode 100644 index 00000000000..dba04f49b60 --- /dev/null +++ b/web_src/js/features/repo-watch.ts @@ -0,0 +1,33 @@ +import {createTippy} from '../modules/tippy.ts'; +import {showFomanticModal} from '../modules/fomantic/modal.ts'; +import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts'; +import type {Instance} from 'tippy.js'; + +let watchMenuTippy: Instance | null = null; + +export function initRepoWatch() { + registerGlobalInitFunc('initRepoWatchMenu', (btn: HTMLElement) => { + watchMenuTippy?.destroy(); // a watch action replaces the button, orphaning the old menu + const menu = btn.nextElementSibling!; + watchMenuTippy = createTippy(btn, { + content: menu, + theme: 'menu', + maxWidth: 350, + placement: 'bottom-end', + trigger: 'click', + interactive: true, + hideOnClick: true, + }); + menu.addEventListener('click', () => watchMenuTippy!.hide()); + }); + + registerGlobalEventFunc('click', 'onRepoWatchOptionsClick', (btn: HTMLElement) => { + const elModal = document.querySelector('#repo-watch-options-modal')!; + const form = elModal.querySelector('form')!; + form.action = btn.getAttribute('data-url')!; + for (const el of form.querySelectorAll('input[type=checkbox]')) { + el.checked = btn.getAttribute(`data-${el.name.replaceAll('_', '-')}`) === 'true'; + } + showFomanticModal(elModal); + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index ef586d22018..bae6d37ab79 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -65,6 +65,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 {initRepoWatch} from './features/repo-watch.ts'; const initStartTime = performance.now(); const initPerformanceTracer = callInitFunctions([ @@ -141,6 +142,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoContributors, initRepoCodeFrequency, initRepoRecentCommits, + initRepoWatch, initCommitStatuses, initAvatarStackPopup,