enhance: refine repo watching (#38835)

Follow-up to https://github.com/go-gitea/gitea/pull/37571.

"Participating and mentions" deleted the watch row, so choosing it
dropped you out of the watcher count. It is a watch like the others, so
it now keeps a row and simply subscribes to no events.

The dashboard feed ignored the per-event options, so a "Custom: issues"
watcher still got pull request activity there. It now gates on the same
options as mail and notifications. That also closes a gap where pull
request reviews bypassed the permission check.

Also, address
https://github.com/go-gitea/gitea/pull/37571#discussion_r3740487363 and
reword a UI text for clarity.

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-08-09 10:54:31 +00:00
committed by GitHub
co-authored by wxiaoguang
parent 76a81b24f9
commit ad6107ab88
14 changed files with 137 additions and 92 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ func TestCreateOrUpdateIssueNotificationsIgnored(t *testing.T) {
// 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))
assert.NoError(t, repo_model.WatchIgnoreRepo(t.Context(), user, repo))
notified, err := activities_model.CreateOrUpdateIssueNotifications(t.Context(), 1, 0, 2, 0)
assert.NoError(t, err)
+37 -3
View File
@@ -74,6 +74,29 @@ func (w *Watch) IsIgnoring() bool {
return w.Mode == WatchModeDont
}
// IsWatching reports whether the watch counts the user as a watcher of the repository
func (w *Watch) IsWatching() bool {
return IsWatchMode(w.Mode)
}
// IsWatchingAll reports whether every event is enabled, which is the "all activity" mode
func (w *Watch) IsWatchingAll() bool {
return w.PullRequests && w.Issues && w.Releases
}
// SelectedMode returns the mode the user picked in the watch menu
func (w *Watch) SelectedMode() string {
switch {
case w.IsIgnoring():
return "ignore"
case !IsWatchMode(w.Mode), !(w.PullRequests || w.Issues || w.Releases):
return "participate" // also the default while there is no watch row
case w.IsWatchingAll():
return "all"
}
return "custom"
}
// IsWatchMode Decodes watchability of WatchMode
func IsWatchMode(mode WatchMode) bool {
return mode != WatchModeNone && mode != WatchModeDont
@@ -145,8 +168,8 @@ 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 {
// WatchIgnoreRepo mutes the repository (unwatch), so nothing about it reaches the user.
func WatchIgnoreRepo(ctx context.Context, doer *user_model.User, repo *Repository) error {
watch, err := GetWatch(ctx, doer.ID, repo.ID)
if err != nil {
return err
@@ -160,6 +183,16 @@ type WatchOptions struct {
Releases bool
}
// WatchRepoWithOptions starts watching the repository and subscribes to the given events
func WatchRepoWithOptions(ctx context.Context, doer *user_model.User, repo *Repository, opts WatchOptions) error {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := WatchRepo(ctx, doer, repo, true); err != nil {
return err
}
return SetWatchOptions(ctx, doer.ID, repo.ID, opts)
})
}
// 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).
@@ -187,11 +220,12 @@ func GetUserWatches(ctx context.Context, userID int64, repoIDs []int64) (map[int
return watchesByRepo, nil
}
// GetWatchers returns all watchers of given repository.
// GetWatchers returns all watchers of given repository, skipping those subscribed to no event.
func GetWatchers(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).
And(builder.Or(builder.Eq{"`watch`.pull_requests": true}, builder.Eq{"`watch`.issues": true}, builder.Eq{"`watch`.releases": true})).
And("`user`.is_active=?", true).
And("`user`.prohibit_login=?", false).
Join("INNER", "`user`", "`user`.id = `watch`.user_id").
+10 -1
View File
@@ -167,5 +167,14 @@ func TestWatchOptions(t *testing.T) {
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)
assert.True(t, watch.IsWatchingAll())
}
func TestWatchSelectedMode(t *testing.T) {
// a user without a watch row gets the dummy record, whose flags are the column defaults
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNone, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
assert.Equal(t, "participate", (&repo_model.Watch{Mode: repo_model.WatchModeNormal}).SelectedMode())
assert.Equal(t, "ignore", (&repo_model.Watch{Mode: repo_model.WatchModeDont}).SelectedMode())
assert.Equal(t, "custom", (&repo_model.Watch{Mode: repo_model.WatchModeNormal, Issues: true}).SelectedMode())
assert.Equal(t, "all", (&repo_model.Watch{Mode: repo_model.WatchModeAuto, PullRequests: true, Issues: true, Releases: true}).SelectedMode())
}
+1 -2
View File
@@ -1157,7 +1157,6 @@
"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",
@@ -1168,7 +1167,7 @@
"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.watch.mode.custom.desc": "Select events you want to be notified of, in addition to participating and mentions.",
"repo.unstar": "Unstar",
"repo.star": "Star",
"repo.fork": "Fork",
+6 -21
View File
@@ -17,29 +17,22 @@ func ActionWatch(ctx *context.Context) {
action := ctx.PathParam("action")
var err error
if action == "ignore" {
err = repo_model.IgnoreRepo(ctx, ctx.Doer, ctx.Repo.Repository)
err = repo_model.WatchIgnoreRepo(ctx, ctx.Doer, ctx.Repo.Repository)
} else {
err = repo_model.WatchRepo(ctx, ctx.Doer, ctx.Repo.Repository, action == "watch")
all := action == "watch" // "participate" is a watch that subscribes to no event on its own
err = repo_model.WatchRepoWithOptions(ctx, ctx.Doer, ctx.Repo.Repository, repo_model.WatchOptions{PullRequests: all, Issues: all, Releases: all})
}
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["RepoWatch"] = watch
ctx.Data["Repository"], err = repo_model.GetRepositoryByName(ctx, ctx.Repo.Repository.OwnerID, ctx.Repo.Repository.Name)
if err != nil {
@@ -51,22 +44,14 @@ func ActionWatch(ctx *context.Context) {
// ActionWatchOptions watches the repository with a custom selection of events
func ActionWatchOptions(ctx *context.Context) {
opts := repo_model.WatchOptions{
opts := repo_model.WatchOptions{ // clearing every event is allowed, it leaves the participating state
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 {
if err := repo_model.WatchRepoWithOptions(ctx, ctx.Doer, ctx.Repo.Repository, opts); 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("")
}
+1 -1
View File
@@ -1739,7 +1739,7 @@ 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|ignore}", reqSignIn, repo.ActionWatch)
m.Post("/action/{action:watch|participate|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)
+1 -2
View File
@@ -644,8 +644,7 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
ctx.ServerError("GetWatch", err)
return
}
ctx.Data["Watch"] = watch
ctx.Data["IsWatchingRepo"] = repo_model.IsWatchMode(watch.Mode)
ctx.Data["RepoWatch"] = watch
ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID)
}
+14 -12
View File
@@ -69,20 +69,23 @@ func notifyWatchers(ctx context.Context, act *activities_model.Action, watchers
act.UserID = watcher.UserID
act.Repo.Units = nil
var allowed bool
switch act.OpType {
case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionPublishRelease, activities_model.ActionDeleteBranch:
if !permCode[i] {
continue
}
case activities_model.ActionCommitRepo, activities_model.ActionPushTag, activities_model.ActionDeleteTag, activities_model.ActionDeleteBranch:
allowed = permCode[i] && watcher.IsWatchingAll()
case activities_model.ActionPublishRelease:
allowed = permCode[i] && watcher.Releases
case activities_model.ActionCreateIssue, activities_model.ActionCommentIssue, activities_model.ActionCloseIssue, activities_model.ActionReopenIssue:
if !permIssue[i] {
continue
}
case activities_model.ActionCreatePullRequest, activities_model.ActionCommentPull, activities_model.ActionMergePullRequest, activities_model.ActionClosePullRequest, activities_model.ActionReopenPullRequest, activities_model.ActionAutoMergePullRequest:
if !permPR[i] {
continue
}
allowed = permIssue[i] && watcher.Issues
case activities_model.ActionCreatePullRequest, activities_model.ActionCommentPull, activities_model.ActionMergePullRequest, activities_model.ActionClosePullRequest,
activities_model.ActionReopenPullRequest, activities_model.ActionAutoMergePullRequest, activities_model.ActionApprovePullRequest,
activities_model.ActionRejectPullRequest, activities_model.ActionPullReviewDismissed, activities_model.ActionPullRequestReadyForReview:
allowed = permPR[i] && watcher.PullRequests
default:
allowed = watcher.IsWatchingAll() // repository events have no watch option of their own
}
if !allowed {
continue
}
if err := db.Insert(ctx, act); err != nil {
@@ -120,7 +123,6 @@ func NotifyWatchers(ctx context.Context, acts ...*activities_model.Action) error
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))
+16
View File
@@ -200,3 +200,19 @@ func TestNotifyWatchers(t *testing.T) {
OpType: action.OpType,
})
}
func TestNotifyWatchersRespectsWatchOptions(t *testing.T) {
assert.NoError(t, unittest.PrepareTestDatabase())
// user 1 watches repo 1 for issues only, user 4 keeps every event
assert.NoError(t, repo_model.SetWatchOptions(t.Context(), 1, 1, repo_model.WatchOptions{Issues: true}))
assert.NoError(t, NotifyWatchers(t.Context(),
&activities_model.Action{ActUserID: 8, RepoID: 1, OpType: activities_model.ActionCreateIssue},
&activities_model.Action{ActUserID: 8, RepoID: 1, OpType: activities_model.ActionApprovePullRequest},
))
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{UserID: 1, RepoID: 1, OpType: activities_model.ActionCreateIssue})
unittest.AssertNotExistsBean(t, &activities_model.Action{UserID: 1, RepoID: 1, OpType: activities_model.ActionApprovePullRequest})
unittest.AssertExistsAndLoadBean(t, &activities_model.Action{UserID: 4, RepoID: 1, OpType: activities_model.ActionApprovePullRequest})
}
+25 -21
View File
@@ -1,11 +1,12 @@
{{$isIgnoring := and $.IsSigned $.Watch.IsIgnoring}}
{{$buttonText := ctx.Locale.Tr (Iif $isIgnoring "repo.ignoring" (Iif $.IsWatchingRepo "repo.watching" "repo.watch"))}}
{{$isIgnoring := and $.IsSigned $.RepoWatch.IsIgnoring}}
{{$buttonText := ctx.Locale.Tr (Iif $isIgnoring "repo.ignoring" (Iif $.RepoWatch.IsWatching "repo.watching" "repo.watch"))}}
{{$count := CountFmt .Repository.NumWatches}}
<div id="repo-header-watch" class="flex-text-block">
{{if $.IsSigned}}
{{$isAll := and $.IsWatchingRepo $.Watch.PullRequests $.Watch.Issues $.Watch.Releases}}
{{$isCustom := and $.IsWatchingRepo (not $isAll)}}
{{$isParticipating := not (or $.IsWatchingRepo $isIgnoring)}}
{{$mode := $.RepoWatch.SelectedMode}}
{{$isAll := eq $mode "all"}}
{{$isCustom := eq $mode "custom"}}
{{$isParticipating := eq $mode "participate"}}
<button class="ui dropdown custom compact small basic button" data-global-init="initRepoWatchMenu" aria-label="{{$buttonText}}">
{{svg (Iif $isIgnoring "octicon-eye-closed" "octicon-eye")}}
<span class="not-mobile" aria-hidden="true">{{$buttonText}}</span>
@@ -13,34 +14,37 @@
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
</button>
<div class="tippy-target">
{{$participating := ctx.Locale.Tr "repo.watch.mode.participating"}}
<a class="item{{if $isParticipating}} active{{end}}" role="menuitem" aria-label="{{$participating}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/unwatch" data-fetch-sync="$body #repo-header-watch"
{{$testParticipating := ctx.Locale.Tr "repo.watch.mode.participating"}}
<a class="item{{if $isParticipating}} active{{end}}" role="menuitem" aria-label="{{$testParticipating}}"
data-fetch-method="post" data-fetch-url="{{$.RepoLink}}/action/participate" 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>
<div>{{$testParticipating}}<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}}"
{{$textAll := ctx.Locale.Tr "repo.watch.mode.all"}}
<a class="item{{if $isAll}} active{{end}}" role="menuitem" aria-label="{{$textAll}}"
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>
<div>{{$textAll}}<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}}"
{{$textIgnore := ctx.Locale.Tr "repo.watch.mode.ignore"}}
<a class="item{{if $isIgnoring}} active{{end}}" role="menuitem" aria-label="{{$textIgnore}}"
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>
{{svg "octicon-check" 16 (Iif $isIgnoring "" "tw-invisible")}}
<div>{{$textIgnore}}<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}}"
{{$textCustom := ctx.Locale.Tr "repo.watch.mode.custom"}}
<a class="item {{if $isCustom}}active{{end}} show-modal" role="menuitem" aria-label="{{$textCustom}}"
data-modal="#repo-watch-options-modal"
data-modal-form.url="{{$.RepoLink}}/action/watch/options"
data-modal-issues="{{$.RepoWatch.Issues}}"
data-modal-pull_requests="{{$.RepoWatch.PullRequests}}"
data-modal-releases="{{$.RepoWatch.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>
<div>{{$textCustom}}<div class="tw-text-12 tw-text-text-light-2">{{ctx.Locale.Tr "repo.watch.mode.custom.desc"}}</div></div>
</a>
</div>
{{else}}
+13 -6
View File
@@ -2,13 +2,20 @@
<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 class="flex-relaxed-list tw-gap-4">
<div class="ui checkbox">
<input name="issues" type="checkbox">
<label>{{ctx.Locale.Tr "repo.watch.options.issues"}}</label>
</div>
{{end}}
<div class="ui checkbox">
<input name="pull_requests" type="checkbox">
<label>{{ctx.Locale.Tr "repo.watch.options.pull_requests"}}</label>
</div>
<div class="ui checkbox">
<input name="releases" type="checkbox">
<label>{{ctx.Locale.Tr "repo.watch.options.releases"}}</label>
</div>
</div>
</div>
{{template "base/modal_actions_confirm" dict "ModalButtonTypes" "confirm"}}
</form>
+6 -3
View File
@@ -57,9 +57,12 @@
</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}}"
<button class="btn flex-text-inline show-modal"
data-modal="#repo-watch-options-modal"
data-modal-form.url="{{.Link}}/action/watch/options"
data-modal-issues="{{$watch.Issues}}"
data-modal-pull_requests="{{$watch.PullRequests}}"
data-modal-releases="{{$watch.Releases}}"
data-tooltip-content="{{ctx.Locale.Tr "notifications"}}"
>{{svg "octicon-gear" 16}}</button>
{{end}}
+3 -1
View File
@@ -111,8 +111,10 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
if (attrTargetProp) {
assignElementProperty(attrTarget, attrTargetProp, attrib.value);
} else if (attrTarget.matches('input[type=checkbox], input[type=radio]')) {
(attrTarget as HTMLInputElement).checked = attrib.value === 'true';
} else if (attrTarget.matches('input, textarea')) {
(attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
(attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value;
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
+3 -18
View File
@@ -1,15 +1,10 @@
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;
import {registerGlobalInitFunc} from '../modules/observer.ts';
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, {
const watchMenuTippy = createTippy(btn, {
content: menu,
theme: 'menu',
maxWidth: 350,
@@ -18,16 +13,6 @@ export function initRepoWatch() {
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);
menu.addEventListener('click', () => watchMenuTippy.hide());
});
}