feat: add watch options (#37571)

Adds per-event watch options, so a user can choose which repository
notifications they receive.

The watch button is now a menu with four modes: participating and
mentions (the unwatched state, as on GitHub), all activity, ignore, and
custom, which opens a dialog to pick issues, pull requests and releases.
The same dialog is reachable from the gear icon on the watched
repository list. Options apply to UI notifications and to mail.

The watcher, star and fork counts moved inside their buttons, so all
three share one shape, and the links to those lists moved to the
repository sidebar.

Assignees and requested reviewers now also receive UI notifications
without having commented first.

Buttons and dropdown:

<img width="688" height="398" alt="image"
src="https://github.com/user-attachments/assets/889b567d-086f-4a06-ac67-3a4205fc63b2"
/>

Modal:

<img width="841" height="327" alt="Screenshot 2026-08-07 at 21 10 59"
src="https://github.com/user-attachments/assets/3bedbff5-9011-4f1b-91a9-e8f5c212fb5d"
/>

New entries in sidebar:

<img width="170" height="207" alt="Screenshot 2026-08-07 at 21 13 28"
src="https://github.com/user-attachments/assets/5f5150cd-f42d-4543-bd0b-7334cf4aab0e"
/>

Fixes: https://github.com/go-gitea/gitea/issues/17238
Ref: https://github.com/go-gitea/gitea/issues/35492

---------

Co-authored-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Alexandre Bontems
2026-08-08 07:35:13 +00:00
committed by GitHub
co-authored by bircni silverwind
parent 2087d4a1a5
commit 7dbfed37eb
32 changed files with 467 additions and 83 deletions
+1
View File
@@ -420,6 +420,7 @@ func prepareMigrationTasks() []*migration {
newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob), newMigration(344, "Add deferred-matrix columns to ActionRunJob", v1_28.AddDeferredMatrixColumnsToActionRunJob),
newMigration(345, "Add block on CODEOWNERS reviews branch protection", v1_28.AddBlockOnCodeownerReviews), 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(346, "Add license_path column to repo_license and backfill", v1_28.AddLicensePathToRepoLicense),
newMigration(347, "Add watch options", v1_28.AddWatchOptions),
} }
return preparedMigrations return preparedMigrations
} }
+25
View File
@@ -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
}
+23 -1
View File
@@ -106,7 +106,8 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
} }
toNotify.AddMultiple(issueWatches...) toNotify.AddMultiple(issueWatches...)
if !(issue.IsPull && issues_model.HasWorkInProgressPrefix(issue.Title)) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -117,6 +118,18 @@ func createOrUpdateIssueNotifications(ctx context.Context, issueID, commentID, n
return nil, err return nil, err
} }
toNotify.AddMultiple(issueParticipants...) 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 // don't notify user who cause notification
delete(toNotify, notificationAuthorID) 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 { if err := issue.LoadRepo(ctx); err != nil {
return nil, err return nil, err
} }
+34
View File
@@ -10,6 +10,7 @@ import (
activities_model "gitea.dev/models/activities" activities_model "gitea.dev/models/activities"
"gitea.dev/models/db" "gitea.dev/models/db"
issues_model "gitea.dev/models/issues" issues_model "gitea.dev/models/issues"
repo_model "gitea.dev/models/repo"
"gitea.dev/models/unittest" "gitea.dev/models/unittest"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
@@ -32,6 +33,39 @@ func TestCreateOrUpdateIssueNotifications(t *testing.T) {
assert.Equal(t, activities_model.NotificationStatusUnread, notf.Status) 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) { func TestNotificationsForUser(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+5 -1
View File
@@ -10,6 +10,7 @@ import (
repo_model "gitea.dev/models/repo" repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user" user_model "gitea.dev/models/user"
"gitea.dev/modules/timeutil" "gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
) )
// IssueWatch is connection request for receiving issue notification. // 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 { if err != nil {
return false, err 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 // GetIssueWatchersIDs returns IDs of subscribers or explicit unsubscribers to a given issue id
+13
View File
@@ -1012,3 +1012,16 @@ func GetPullRequestByMergedCommit(ctx context.Context, repoID int64, sha string)
return pr, nil 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)
}
+83 -12
View File
@@ -28,14 +28,26 @@ const (
WatchModeAuto // 3 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. // Watch is connection request for receiving repository notification.
type Watch struct { type Watch struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch)"` UserID int64 `xorm:"UNIQUE(watch)"`
RepoID int64 `xorm:"UNIQUE(watch)"` RepoID int64 `xorm:"UNIQUE(watch)"`
Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"` Mode WatchMode `xorm:"SMALLINT NOT NULL DEFAULT 1"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 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() { func init() {
@@ -48,8 +60,8 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
if err != nil { if err != nil {
return watch, err return watch, err
} }
if watch == nil { if watch == nil { // the dummy record must mirror the column defaults
watch = &Watch{UserID: userID, RepoID: repoID} watch = &Watch{UserID: userID, RepoID: repoID, PullRequests: true, Issues: true, Releases: true}
} }
if !has { if !has {
watch.Mode = WatchModeNone watch.Mode = WatchModeNone
@@ -57,6 +69,11 @@ func GetWatch(ctx context.Context, userID, repoID int64) (*Watch, error) {
return watch, nil 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 // IsWatchMode Decodes watchability of WatchMode
func IsWatchMode(mode WatchMode) bool { func IsWatchMode(mode WatchMode) bool {
return mode != WatchModeNone && mode != WatchModeDont return mode != WatchModeNone && mode != WatchModeDont
@@ -87,15 +104,16 @@ func watchRepoMode(ctx context.Context, watch *Watch, mode WatchMode) (err error
repodiff = -1 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 watch.Mode = mode
if !hadrec && needsrec { if !hadrec && needsrec {
watch.Mode = mode
if err = db.Insert(ctx, watch); err != nil { if err = db.Insert(ctx, watch); err != nil {
return err return err
} }
} else if needsrec { } else if needsrec {
watch.Mode = mode
if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil { if _, err := db.GetEngine(ctx).ID(watch.ID).AllCols().Update(watch); err != nil {
return err return err
} }
@@ -127,6 +145,48 @@ func WatchRepo(ctx context.Context, doer *user_model.User, repo *Repository, doW
return watchRepoMode(ctx, watch, WatchModeNormal) 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. // GetWatchers returns all watchers of given repository.
func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
watches := make([]*Watch, 0, 10) watches := make([]*Watch, 0, 10)
@@ -138,14 +198,25 @@ func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) {
Find(&watches) 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 // but avoids joining with `user` for performance reasons
// User permissions must be verified elsewhere if required // 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) ids := make([]int64, 0, 64)
return ids, db.GetEngine(ctx).Table("watch"). return ids, db.GetEngine(ctx).Table("watch").
Where("watch.repo_id=?", repoID). Where("watch.repo_id=?", repoID).
And("watch.mode<>?", WatchModeDont). And("watch.mode<>?", WatchModeDont).
And(builder.Eq{"watch." + string(watchType): true}).
Select("user_id"). Select("user_id").
Find(&ids) Find(&ids)
} }
+33 -2
View File
@@ -125,16 +125,47 @@ func TestClearRepoWatches(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase()) assert.NoError(t, unittest.PrepareTestDatabase())
const repoID int64 = 1 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.NoError(t, err)
require.NotEmpty(t, watchers) require.NotEmpty(t, watchers)
assert.NoError(t, repo_model.ClearRepoWatches(t.Context(), repoID)) 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.NoError(t, err)
assert.Empty(t, watchers) assert.Empty(t, watchers)
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
assert.Zero(t, repo.NumWatches) 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)
}
+14 -1
View File
@@ -1154,8 +1154,21 @@
"repo.fork_guest_user": "Sign in to fork this repository.", "repo.fork_guest_user": "Sign in to fork this repository.",
"repo.watch_guest_user": "Sign in to watch this repository.", "repo.watch_guest_user": "Sign in to watch this repository.",
"repo.star_guest_user": "Sign in to star this repository.", "repo.star_guest_user": "Sign in to star this repository.",
"repo.unwatch": "Unwatch",
"repo.watch": "Watch", "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.unstar": "Unstar",
"repo.star": "Star", "repo.star": "Star",
"repo.fork": "Fork", "repo.fork": "Fork",
+46 -4
View File
@@ -11,20 +11,62 @@ import (
"gitea.dev/services/context" "gitea.dev/services/context"
) )
const tplWatchUnwatch templates.TplName = "repo/header/watch" const tplWatch templates.TplName = "repo/header/watch"
func ActionWatch(ctx *context.Context) { 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 { if err != nil {
handleActionError(ctx, err) handleActionError(ctx, err)
return 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) ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
if err != nil { if err != nil {
ctx.ServerError("GetRepositoryByName", err) ctx.ServerError("GetRepositoryByName", err)
return 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("")
} }
+7
View File
@@ -399,6 +399,13 @@ func NotificationWatching(ctx *context.Context) {
ctx.Data["Total"] = count ctx.Data["Total"] = count
ctx.Data["Repos"] = repos 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 // redirect to last page if request page is more than total pages
pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5) pager := context.NewPagination(count, setting.UI.User.RepoPagingNum, page, 5)
pager.AddParamFromRequest(ctx.Req) pager.AddParamFromRequest(ctx.Req)
+2 -1
View File
@@ -1743,7 +1743,8 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/watchers", repo.Watchers) m.Get("/watchers", repo.Watchers)
m.Get("/search", reqUnitCodeReader, repo.Search) m.Get("/search", reqUnitCodeReader, repo.Search)
m.Post("/action/{action:star|unstar}", reqSignIn, starsEnabled, repo.ActionStar) 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) m.Post("/action/{action:accept_transfer|reject_transfer}", reqSignIn, repo.ActionTransfer)
}, optSignIn, context.RepoAssignment) }, optSignIn, context.RepoAssignment)
+7 -1
View File
@@ -639,7 +639,13 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
} }
if ctx.IsSigned { 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) ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
} }
+10 -1
View File
@@ -16,6 +16,7 @@ import (
"gitea.dev/modules/container" "gitea.dev/modules/container"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util"
) )
const MailBatchSize = 100 // batch size used in mailIssueCommentBatch const MailBatchSize = 100 // batch size used in mailIssueCommentBatch
@@ -66,7 +67,8 @@ func mailIssueCommentToParticipants(ctx context.Context, comment *mailComment, m
// =========== Repo watchers =========== // =========== Repo watchers ===========
// Make repo watchers last, since it's likely the list with the most users // 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) { 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 { if err != nil {
return fmt.Errorf("GetRepoWatchersIDs(%d): %w", comment.Issue.RepoID, err) 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) 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 // Avoid mailing the doer
if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification { if comment.Doer.EmailNotificationsPreference != user_model.EmailNotificationsAndYourOwn && !comment.ForceDoerNotification {
visited.Add(comment.Doer.ID) visited.Add(comment.Doer.ID)
+1 -1
View File
@@ -35,7 +35,7 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) {
return return
} }
watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID) watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID, repo_model.WatchReleases)
if err != nil { if err != nil {
log.Error("GetRepoWatchersIDs(%d): %v", rel.RepoID, err) log.Error("GetRepoWatchersIDs(%d): %v", rel.RepoID, err)
return return
+4 -4
View File
@@ -76,13 +76,13 @@ func TestMakeRepoPrivateClearsWatches(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo.IsPrivate) 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.NoError(t, err)
require.NotEmpty(t, watchers) require.NotEmpty(t, watchers)
assert.NoError(t, MakeRepoPrivate(t.Context(), repo, true)) 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.NoError(t, err)
assert.Empty(t, watchers) assert.Empty(t, watchers)
@@ -99,14 +99,14 @@ func TestUpdateRepositoryClearsWatchesOnVisibilityChange(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
assert.False(t, repo.IsPrivate) 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.NoError(t, err)
require.NotEmpty(t, watchers) require.NotEmpty(t, watchers)
repo.IsPrivate = true repo.IsPrivate = true
require.NoError(t, updateRepository(t.Context(), repo, 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.NoError(t, err)
assert.Empty(t, watchers) assert.Empty(t, watchers)
+1 -1
View File
@@ -145,7 +145,7 @@ func (ns *notificationService) NewPullRequest(ctx context.Context, pr *issues_mo
return return
} }
toNotify := make(container.Set[int64], 32) 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 { if err != nil {
log.Error("GetRepoWatchersIDs: %v", err) log.Error("GetRepoWatchersIDs: %v", err)
return return
+1
View File
@@ -78,6 +78,7 @@
{{if .IsGenerated}}<div class="secondary-info">{{ctx.Locale.Tr "repo.generated_from"}} <a href="{{(.TemplateRepo ctx).Link}}">{{(.TemplateRepo ctx).FullName}}</a></div>{{end}} {{if .IsGenerated}}<div class="secondary-info">{{ctx.Locale.Tr "repo.generated_from"}} <a href="{{(.TemplateRepo ctx).Link}}">{{(.TemplateRepo ctx).FullName}}</a></div>{{end}}
</div> </div>
{{end}} {{end}}
{{if .IsSigned}}{{template "repo/watch_options_modal"}}{{end}}
<div class="ui container"> <div class="ui container">
<overflow-menu class="ui secondary pointing menu"> <overflow-menu class="ui secondary pointing menu">
+6 -8
View File
@@ -9,20 +9,18 @@
{{else if $canNotForkOwn}} {{else if $canNotForkOwn}}
{{$forkHref = "#"}} {{$forkHref = "#"}}
{{end}} {{end}}
<div class="ui labeled button" <a role="button" class="ui compact small basic button {{if $.ShowForkModal}}show-modal{{end}}" href="{{$forkHref}}"
{{if and $.IsSigned $.ShowForkModal}}data-modal="#fork-repo-modal"{{end}}
{{if not $.IsSigned}} {{if not $.IsSigned}}
data-tooltip-content="{{ctx.Locale.Tr "repo.fork_guest_user"}}" data-tooltip-content="{{ctx.Locale.Tr "repo.fork_guest_user"}}"
{{else if $canNotForkOwn}} {{else if $canNotForkOwn}}
data-tooltip-content="{{ctx.Locale.Tr "repo.fork_from_self"}}" data-tooltip-content="{{ctx.Locale.Tr "repo.fork_from_self"}}"
{{end}} {{end}}
> >
<a role="button" class="ui compact small basic button {{if $.ShowForkModal}}show-modal{{end}}" href="{{$forkHref}}"{{if and $.IsSigned $.ShowForkModal}} data-modal="#fork-repo-modal"{{end}}> {{svg "octicon-repo-forked"}}
{{svg "octicon-repo-forked"}}<span class="text not-mobile">{{ctx.Locale.Tr "repo.fork"}}</span> <span class="not-mobile">{{ctx.Locale.Tr "repo.fork"}}</span>
</a> <span class="ui small label">{{CountFmt $.Repository.NumForks}}</span>
<a class="ui basic label" href="{{$.Repository.Link}}/forks"> </a>
{{CountFmt $.Repository.NumForks}}
</a>
</div>
{{if $.ShowForkModal}} {{if $.ShowForkModal}}
<div class="ui small modal" id="fork-repo-modal"> <div class="ui small modal" id="fork-repo-modal">
<div class="header"> <div class="header">
+14 -19
View File
@@ -1,19 +1,14 @@
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"{{end}}> {{$buttonText := ctx.Locale.Tr (Iif $.IsStaringRepo "repo.unstar" "repo.star")}}
{{$buttonText := ctx.Locale.Tr "repo.star"}} <a role="button" class="ui compact small basic button" aria-label="{{$buttonText}}"
{{if $.IsStaringRepo}}{{$buttonText = ctx.Locale.Tr "repo.unstar"}}{{end}} {{if $.IsSigned}}
<a role="button" class="ui compact small basic button" aria-label="{{$buttonText}}" data-fetch-method="post"
{{if $.IsSigned}} data-fetch-url="{{$.RepoLink}}/action/{{Iif $.IsStaringRepo "unstar" "star"}}"
data-fetch-method="post" {{else}}
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsStaringRepo}}unstar{{else}}star{{end}}" href="{{AppSubUrl}}/user/login"
data-fetch-sync="$closest(.ui.labeled.button)" data-tooltip-content="{{ctx.Locale.Tr "repo.star_guest_user"}}"
{{else}} {{end}}
href="{{AppSubUrl}}/user/login" >
{{end}} {{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}}
> <span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
{{svg (Iif $.IsStaringRepo "octicon-star-fill" "octicon-star")}} <span class="ui small label">{{CountFmt .Repository.NumStars}}</span>
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span> </a>
</a>
<a class="ui basic label" href="{{$.RepoLink}}/stars">
{{CountFmt .Repository.NumStars}}
</a>
</div>
+52 -18
View File
@@ -1,19 +1,53 @@
<div class="ui labeled button" {{if not $.IsSigned}}data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}"{{end}}> {{$isIgnoring := and $.IsSigned $.Watch.IsIgnoring}}
{{$buttonText := ctx.Locale.Tr "repo.watch"}} {{$buttonText := ctx.Locale.Tr (Iif $isIgnoring "repo.ignoring" (Iif $.IsWatchingRepo "repo.watching" "repo.watch"))}}
{{if $.IsWatchingRepo}}{{$buttonText = ctx.Locale.Tr "repo.unwatch"}}{{end}} {{$count := CountFmt .Repository.NumWatches}}
<a role="button" class="ui compact small basic button" aria-label="{{$buttonText}}" <div id="repo-header-watch" class="flex-text-block">
{{if $.IsSigned}} {{if $.IsSigned}}
data-fetch-method="post" {{$isAll := and $.IsWatchingRepo $.Watch.PullRequests $.Watch.Issues $.Watch.Releases}}
data-fetch-url="{{$.RepoLink}}/action/{{if $.IsWatchingRepo}}unwatch{{else}}watch{{end}}" {{$isCustom := and $.IsWatchingRepo (not $isAll)}}
data-fetch-sync="$closest(.ui.labeled.button)" {{$isParticipating := not (or $.IsWatchingRepo $isIgnoring)}}
{{else}} <button class="ui dropdown custom compact small basic button" data-global-init="initRepoWatchMenu" aria-label="{{$buttonText}}">
href="{{AppSubUrl}}/user/login" {{svg (Iif $isIgnoring "octicon-eye-closed" "octicon-eye")}}
{{end}} <span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
> <span class="ui small label">{{$count}}</span>
{{svg "octicon-eye"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span> </button>
</a> <div class="tippy-target">
<a class="ui basic label" href="{{$.RepoLink}}/watchers"> {{$participating := ctx.Locale.Tr "repo.watch.mode.participating"}}
{{CountFmt .Repository.NumWatches}} <a class="item{{if $isParticipating}} active{{end}}" role="menuitem" aria-label="{{$participating}}"
</a> data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/unwatch" data-fetch-sync="$body #repo-header-watch"
>
{{svg "octicon-check" 16 (Iif $isParticipating "" "tw-invisible")}}
<div>{{$participating}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.participating.desc"}}</div></div>
</a>
{{$all := ctx.Locale.Tr "repo.watch.mode.all"}}
<a class="item{{if $isAll}} active{{end}}" role="menuitem" aria-label="{{$all}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/watch" data-fetch-sync="$body #repo-header-watch"
>
{{svg "octicon-check" 16 (Iif $isAll "" "tw-invisible")}}
<div>{{$all}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.all.desc"}}</div></div>
</a>
{{$ignore := ctx.Locale.Tr "repo.watch.mode.ignore"}}
<a class="item{{if $.Watch.IsIgnoring}} active{{end}}" role="menuitem" aria-label="{{$ignore}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/ignore" data-fetch-sync="$body #repo-header-watch"
>
{{svg "octicon-check" 16 (Iif $.Watch.IsIgnoring "" "tw-invisible")}}
<div>{{$ignore}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.ignore.desc"}}</div></div>
</a>
{{$custom := ctx.Locale.Tr "repo.watch.mode.custom"}}
<a class="item{{if $isCustom}} active{{end}}" role="menuitem" aria-label="{{$custom}}"
data-global-click="onRepoWatchOptionsClick" data-url="{{$.RepoLink}}/action/watch/options"
data-issues="{{$.Watch.Issues}}" data-pull-requests="{{$.Watch.PullRequests}}" data-releases="{{$.Watch.Releases}}"
>
{{svg "octicon-check" 16 (Iif $isCustom "" "tw-invisible")}}
<div>{{$custom}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.custom.desc"}}</div></div>
</a>
</div>
{{else}}
<a role="button" class="ui compact small basic button" href="{{AppSubUrl}}/user/login" aria-label="{{$buttonText}}" data-tooltip-content="{{ctx.Locale.Tr "repo.watch_guest_user"}}">
{{svg "octicon-eye"}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
<span class="ui small label">{{$count}}</span>
</a>
{{end}}
</div> </div>
+12
View File
@@ -69,5 +69,17 @@
{{$fileSizeFields := StringUtils.Split $fileSizeFormatted " "}} {{$fileSizeFields := StringUtils.Split $fileSizeFormatted " "}}
{{svg "octicon-database"}} <b>{{ctx.Locale.PrettyNumber (index $fileSizeFields 0)}}</b> {{index $fileSizeFields 1}} {{svg "octicon-database"}} <b>{{ctx.Locale.PrettyNumber (index $fileSizeFields 0)}}</b> {{index $fileSizeFields 1}}
</div> </div>
{{if not .DisableStars}}
<a class="flex-text-block muted" href="{{.RepoLink}}/stars">
{{svg "octicon-star"}} <b>{{CountFmt .Repository.NumStars}}</b> {{ctx.Locale.Tr "repo.stars"}}
</a>
{{end}}
<a class="flex-text-block muted" href="{{.RepoLink}}/watchers">
{{svg "octicon-eye"}} <b>{{CountFmt .Repository.NumWatches}}</b> {{ctx.Locale.Tr "repo.watchers"}}
</a>
<a class="flex-text-block muted" href="{{.RepoLink}}/forks">
{{svg "octicon-repo-forked"}} <b>{{CountFmt .Repository.NumForks}}</b> {{ctx.Locale.Tr "repo.forks"}}
</a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -13,7 +13,7 @@
</h2> </h2>
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui form form-fetch-action" action="{{.Link}}" method="post" data-global-init="initReleaseEditForm" <form class="ui form form-fetch-action" action="{{.Link}}" id="new-release" method="post" data-global-init="initReleaseEditForm"
data-existing-tags="{{JsonUtils.EncodeToString .Tags}}" data-existing-tags="{{JsonUtils.EncodeToString .Tags}}"
data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}" data-tag-helper="{{ctx.Locale.Tr "repo.release.tag_helper"}}"
data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}" data-tag-helper-new="{{ctx.Locale.Tr "repo.release.tag_helper_new"}}"
+15
View File
@@ -0,0 +1,15 @@
<div class="ui small modal" id="repo-watch-options-modal">
<div class="header">{{ctx.Locale.Tr "notifications"}}</div>
<form class="ui form form-fetch-action" method="post">
<div class="content">
{{range $name := StringUtils.Split "issues,pull_requests,releases" ","}}
<div class="field">
<div class="ui checkbox">
<input name="{{$name}}" type="checkbox"><label>{{ctx.Locale.Tr (printf "repo.watch.options.%s" $name)}}</label>
</div>
</div>
{{end}}
</div>
{{template "base/modal_actions_confirm" dict "ModalButtonTypes" "confirm"}}
</form>
</div>
+9
View File
@@ -55,6 +55,14 @@
<span class="tw-contents" aria-label="{{ctx.Locale.Tr "repo.forks"}}">{{svg "octicon-repo-forked" 16}}</span> <span class="tw-contents" aria-label="{{ctx.Locale.Tr "repo.forks"}}">{{svg "octicon-repo-forked" 16}}</span>
<span {{if ge .NumForks 1000}}data-tooltip-content="{{.NumForks}}"{{end}}>{{CountFmt .NumForks}}</span> <span {{if ge .NumForks 1000}}data-tooltip-content="{{.NumForks}}"{{end}}>{{CountFmt .NumForks}}</span>
</a> </a>
{{$watch := and $.Watches (index $.Watches .ID)}}
{{if $watch}}
<button class="btn flex-text-inline" data-global-click="onRepoWatchOptionsClick"
data-url="{{.Link}}/action/watch/options"
data-issues="{{$watch.Issues}}" data-pull-requests="{{$watch.PullRequests}}" data-releases="{{$watch.Releases}}"
data-tooltip-content="{{ctx.Locale.Tr "notifications"}}"
>{{svg "octicon-gear" 16}}</button>
{{end}}
</div> </div>
</div> </div>
{{$description := .DescriptionHTML ctx}} {{$description := .DescriptionHTML ctx}}
@@ -76,4 +84,5 @@
{{ctx.Locale.Tr "search.no_results"}} {{ctx.Locale.Tr "search.no_results"}}
</div> </div>
{{end}} {{end}}
{{if $.Watches}}{{template "repo/watch_options_modal"}}{{end}}
</div> </div>
+3 -2
View File
@@ -11,10 +11,11 @@ test('star and watch a repository', async ({page, request}) => {
]); ]);
await page.goto(`/${owner}/${repoName}`); 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 page.getByRole('button', {name: 'Star', exact: true}).click();
await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible(); await expect(page.getByRole('button', {name: 'Unstar'})).toBeVisible();
await page.getByRole('button', {name: 'Watch', exact: true}).click(); 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();
}); });
+1 -1
View File
@@ -127,7 +127,7 @@ func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body) 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") assert.True(t, exists, "The template has changed")
req = NewRequestWithValues(t, "POST", link, map[string]string{ req = NewRequestWithValues(t, "POST", link, map[string]string{
"title": title, "title": title,
+3 -3
View File
@@ -55,7 +55,7 @@ func testPullCreate(t *testing.T, session *TestSession, user, repo string, toSel
// Submit the form for creating the pull // Submit the form for creating the pull
htmlDoc = NewHTMLParser(t, resp.Body) 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") assert.True(t, exists, "The template has changed")
req = NewRequestWithValues(t, "POST", link, map[string]string{ req = NewRequestWithValues(t, "POST", link, map[string]string{
"title": title, "title": title,
@@ -98,7 +98,7 @@ func testPullCreateDirectly(t *testing.T, session *TestSession, opts createPullR
// Submit the form for creating the pull // Submit the form for creating the pull
htmlDoc := NewHTMLParser(t, resp.Body) 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") assert.True(t, exists, "The template has changed")
params := map[string]string{ params := map[string]string{
"title": opts.Title, "title": opts.Title,
@@ -125,7 +125,7 @@ func testPullCreateFailure(t *testing.T, session *TestSession, baseRepoOwner, ba
// Submit the form for creating the pull // Submit the form for creating the pull
htmlDoc := NewHTMLParser(t, resp.Body) 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") assert.True(t, exists, "The template has changed")
req = NewRequestWithValues(t, "POST", link, map[string]string{ req = NewRequestWithValues(t, "POST", link, map[string]string{
"title": title, "title": title,
+1 -1
View File
@@ -25,7 +25,7 @@ func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title st
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body) 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") assert.True(t, exists, "The template has changed")
postData := map[string]string{ postData := map[string]string{
+5
View File
@@ -21,6 +21,11 @@
grid-row: 2; grid-row: 2;
} }
.repo-home-sidebar-top a:hover,
.repo-home-sidebar-bottom a:hover {
text-decoration-line: none;
}
.repo-home-sidebar-header { .repo-home-sidebar-header {
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
font-size: 16px; font-size: 16px;
+33
View File
@@ -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<HTMLElement>('#repo-watch-options-modal')!;
const form = elModal.querySelector<HTMLFormElement>('form')!;
form.action = btn.getAttribute('data-url')!;
for (const el of form.querySelectorAll<HTMLInputElement>('input[type=checkbox]')) {
el.checked = btn.getAttribute(`data-${el.name.replaceAll('_', '-')}`) === 'true';
}
showFomanticModal(elModal);
});
}
+2
View File
@@ -65,6 +65,7 @@ import {initActionsPermissionsForm} from './features/common-actions-permissions.
import {initRefIssueContextPopup} from './features/ref-issue.ts'; import {initRefIssueContextPopup} from './features/ref-issue.ts';
import {initGlobalShortcut} from './modules/shortcut.ts'; import {initGlobalShortcut} from './modules/shortcut.ts';
import {initDevtest} from './modules/devtest.ts'; import {initDevtest} from './modules/devtest.ts';
import {initRepoWatch} from './features/repo-watch.ts';
const initStartTime = performance.now(); const initStartTime = performance.now();
const initPerformanceTracer = callInitFunctions([ const initPerformanceTracer = callInitFunctions([
@@ -141,6 +142,7 @@ const initPerformanceTracer = callInitFunctions([
initRepoContributors, initRepoContributors,
initRepoCodeFrequency, initRepoCodeFrequency,
initRepoRecentCommits, initRepoRecentCommits,
initRepoWatch,
initCommitStatuses, initCommitStatuses,
initAvatarStackPopup, initAvatarStackPopup,