diff --git a/models/activities/notification.go b/models/activities/notification.go index 7c794564b6737..d4691f8cc0485 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -213,7 +213,14 @@ 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) + var repoWatches []int64 + + if issue.IsPull { + repoWatches, err = repo_model.GetRepoWatchersEventIDs(ctx, issue.RepoID, repo_model.WatchEventTypePullRequest) + } else { + repoWatches, err = repo_model.GetRepoWatchersEventIDs(ctx, issue.RepoID, repo_model.WatchEventTypeIssue) + } + if err != nil { return err } diff --git a/models/repo/watch.go b/models/repo/watch.go index fba66d6dcb51b..6b1bd7b53396b 100644 --- a/models/repo/watch.go +++ b/models/repo/watch.go @@ -9,7 +9,10 @@ import ( "code.gitea.io/gitea/models/db" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" ) // WatchMode specifies what kind of watch the user has on a repository @@ -24,22 +27,64 @@ const ( WatchModeDont // 2 // WatchModeAuto watch repository (from AutoWatchOnChanges) WatchModeAuto // 3 + // WatchModeCustom watch repository for custom events + WatchModeCustom // 4 +) + +func (mode WatchMode) IsWatchModeNone() bool { + return mode == WatchModeNone +} + +func (mode WatchMode) IsWatchModeNormal() bool { + return mode == WatchModeNormal +} + +func (mode WatchMode) IsWatchModeDont() bool { + return mode == WatchModeDont +} + +func (mode WatchMode) IsWatchModeAuto() bool { + return mode == WatchModeAuto +} + +func (mode WatchMode) IsWatchModeCustom() bool { + return mode == WatchModeCustom +} + +// WatchEventType specifies what kind of event is wanted +type WatchEventType int8 + +const ( + // WatchEventTypeIssue watch issues + WatchEventTypeIssue WatchEventType = iota + // WatchEventTypeIssue watch pull requests + WatchEventTypePullRequest + // WatchEventTypeIssue watch releases + WatchEventTypeRelease ) // 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"` + CustomIssues bool `xorm:"NOT NULL DEFAULT FALSE"` + CustomPullRequests bool `xorm:"NOT NULL DEFAULT FALSE"` + CustomReleases bool `xorm:"NOT NULL DEFAULT FALSE"` } func init() { db.RegisterModel(new(Watch)) } +// IsWatching checks if user has watched the Repo +func (watch Watch) IsWatching() bool { + return IsWatchMode(watch.Mode) +} + // GetWatch gets what kind of subscription a user has on a given repository; returns dummy record if none found func GetWatch(ctx context.Context, userID, repoID int64) (Watch, error) { watch := Watch{UserID: userID, RepoID: repoID} @@ -65,7 +110,7 @@ func IsWatching(ctx context.Context, userID, repoID int64) bool { } func watchRepoMode(ctx context.Context, watch Watch, mode WatchMode) (err error) { - if watch.Mode == mode { + if watch.Mode == mode && mode != WatchModeCustom { return nil } if mode == WatchModeAuto && (watch.Mode == WatchModeDont || IsWatchMode(watch.Mode)) { @@ -85,6 +130,12 @@ func watchRepoMode(ctx context.Context, watch Watch, mode WatchMode) (err error) watch.Mode = mode + if mode != WatchModeCustom { + watch.CustomIssues = false + watch.CustomPullRequests = false + watch.CustomReleases = false + } + e := db.GetEngine(ctx) if !hadrec && needsrec { @@ -131,6 +182,19 @@ func WatchRepo(ctx context.Context, userID, repoID int64, doWatch bool) (err err return err } +func WatchRepoCustom(ctx context.Context, userID, repoID int64, watchTypes api.RepoCustomWatchOptions) error { + watch, err := GetWatch(ctx, userID, repoID) + if err != nil { + return err + } + + watch.CustomIssues = watchTypes.Issues + watch.CustomPullRequests = watchTypes.PullRequests + watch.CustomReleases = watchTypes.Releases + + return watchRepoMode(ctx, watch, WatchModeCustom) +} + // GetWatchers returns all watchers of given repository. func GetWatchers(ctx context.Context, repoID int64) ([]*Watch, error) { watches := make([]*Watch, 0, 10) @@ -170,6 +234,49 @@ func GetRepoWatchers(ctx context.Context, repoID int64, opts db.ListOptions) ([] return users, sess.Find(&users) } +func getRepoWatchEventCond(event WatchEventType) builder.Cond { + cond := builder.NewCond() + + switch event { + case WatchEventTypeIssue: + cond = cond.And(builder.Eq{"`watch`.mode": WatchModeCustom}, builder.Eq{"`watch`.custom_issues": true}) + case WatchEventTypePullRequest: + cond = cond.And(builder.Eq{"`watch`.mode": WatchModeCustom}, builder.Eq{"`watch`.custom_pull_requests": true}) + case WatchEventTypeRelease: + cond = cond.And(builder.Eq{"`watch`.mode": WatchModeCustom}, builder.Eq{"`watch`.custom_releases": true}) + } + + return builder.Or(builder.Eq{"`watch`.mode": WatchModeNormal}, builder.Eq{"`watch`.mode": WatchModeAuto}, cond) +} + +// GetRepoWatchers returns range of users watching the given event in the given repository. +func GetRepoWatchersEvent(repoID int64, event WatchEventType, opts db.ListOptions) ([]*user_model.User, error) { + sess := db.GetEngine(db.DefaultContext).Where("watch.repo_id=?", repoID). + Join("LEFT", "watch", "`user`.id=`watch`.user_id"). + And(getRepoWatchEventCond(event)) + if opts.Page > 0 { + sess = db.SetSessionPagination(sess, &opts) + users := make([]*user_model.User, 0, opts.PageSize) + + return users, sess.Find(&users) + } + + users := make([]*user_model.User, 0, 8) + return users, sess.Find(&users) +} + +// GetRepoWatchersIDs returns IDs of watchers for a given repo ID and the givene Event +// but avoids joining with `user` for performance reasons +// User permissions must be verified elsewhere if required +func GetRepoWatchersEventIDs(ctx context.Context, repoID int64, event WatchEventType) ([]int64, error) { + ids := make([]int64, 0, 64) + return ids, db.GetEngine(ctx).Table("watch"). + Where("watch.repo_id=?", repoID). + And(getRepoWatchEventCond(event)). + Select("user_id"). + Find(&ids) +} + // WatchIfAuto subscribes to repo if AutoWatchOnChanges is set func WatchIfAuto(ctx context.Context, userID, repoID int64, isWrite bool) error { if !isWrite || !setting.Service.AutoWatchOnChanges { diff --git a/models/repo/watch_test.go b/models/repo/watch_test.go index 7aa899291c205..92e8ed659c8d2 100644 --- a/models/repo/watch_test.go +++ b/models/repo/watch_test.go @@ -10,6 +10,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "github.com/stretchr/testify/assert" ) @@ -137,3 +138,43 @@ func TestWatchRepoMode(t *testing.T) { assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, 12, 1, repo_model.WatchModeNone)) unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) } + +func checkRepoWatchersEvent(t *testing.T, event repo_model.WatchEventType, isWatching bool) { + watchers, err := repo_model.GetRepoWatchersEventIDs(db.DefaultContext, 1, event) + assert.NoError(t, err) + + if isWatching { + assert.Len(t, watchers, 1) + assert.Equal(t, int64(12), watchers[0]) + } else { + assert.Len(t, watchers, 0) + } +} + +func TestWatchRepoCustom(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + unittest.AssertCount(t, &repo_model.Watch{UserID: 12, RepoID: 1}, 0) + + // Make sure nobody is watching this repo + watchers, err := repo_model.GetRepoWatchersIDs(db.DefaultContext, 1) + assert.NoError(t, err) + for _, watcher := range watchers { + assert.NoError(t, repo_model.WatchRepoMode(db.DefaultContext, watcher, 1, repo_model.WatchModeNone)) + } + + assert.NoError(t, repo_model.WatchRepoCustom(db.DefaultContext, 12, 1, api.RepoCustomWatchOptions{Issues: true})) + checkRepoWatchersEvent(t, repo_model.WatchEventTypeIssue, true) + checkRepoWatchersEvent(t, repo_model.WatchEventTypePullRequest, false) + checkRepoWatchersEvent(t, repo_model.WatchEventTypeRelease, false) + + assert.NoError(t, repo_model.WatchRepoCustom(db.DefaultContext, 12, 1, api.RepoCustomWatchOptions{PullRequests: true})) + checkRepoWatchersEvent(t, repo_model.WatchEventTypeIssue, false) + checkRepoWatchersEvent(t, repo_model.WatchEventTypePullRequest, true) + checkRepoWatchersEvent(t, repo_model.WatchEventTypeRelease, false) + + assert.NoError(t, repo_model.WatchRepoCustom(db.DefaultContext, 12, 1, api.RepoCustomWatchOptions{Releases: true})) + checkRepoWatchersEvent(t, repo_model.WatchEventTypeIssue, false) + checkRepoWatchersEvent(t, repo_model.WatchEventTypePullRequest, false) + checkRepoWatchersEvent(t, repo_model.WatchEventTypeRelease, true) +} diff --git a/modules/context/repo.go b/modules/context/repo.go index 9efa2ab3c0827..64f3a31f92903 100644 --- a/modules/context/repo.go +++ b/modules/context/repo.go @@ -603,6 +603,12 @@ func RepoAssignment(ctx *Context) context.CancelFunc { if ctx.IsSigned { ctx.Data["IsWatchingRepo"] = repo_model.IsWatching(ctx, ctx.Doer.ID, repo.ID) ctx.Data["IsStaringRepo"] = repo_model.IsStaring(ctx, ctx.Doer.ID, repo.ID) + + ctx.Data["RepoWatchData"], err = repo_model.GetWatch(ctx, ctx.Doer.ID, repo.ID) + if err != nil { + ctx.ServerError("getWatch", err) + return nil + } } if repo.IsFork { diff --git a/modules/structs/repo_watch.go b/modules/structs/repo_watch.go index 0d0b7c4ae0483..9d26d0907f233 100644 --- a/modules/structs/repo_watch.go +++ b/modules/structs/repo_watch.go @@ -7,12 +7,21 @@ import ( "time" ) +// RepoCustomWatchOptions options when watching custom events of a repo +type RepoCustomWatchOptions struct { + Issues bool `json:"issues"` + PullRequests bool `json:"pull_requests"` + Releases bool `json:"releases"` +} + // WatchInfo represents an API watch status of one repository type WatchInfo struct { - Subscribed bool `json:"subscribed"` - Ignored bool `json:"ignored"` - Reason any `json:"reason"` - CreatedAt time.Time `json:"created_at"` - URL string `json:"url"` - RepositoryURL string `json:"repository_url"` + Subscribed bool `json:"subscribed"` + Ignored bool `json:"ignored"` + IsCustom bool `json:"is_custom"` + Reason any `json:"reason"` + CreatedAt time.Time `json:"created_at"` + URL string `json:"url"` + RepositoryURL string `json:"repository_url"` + CustomWatchOptions RepoCustomWatchOptions `json:"custom_watch_options"` } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 53ae8c4d016a0..e71488dc95f9f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1151,10 +1151,12 @@ func Routes() *web.Route { m.Post("/markdown/raw", reqToken(), misc.MarkdownRaw) m.Get("/stargazers", repo.ListStargazers) m.Get("/subscribers", repo.ListSubscribers) + m.Get("/subscribers/{event}", repo.ListSubscribersEvent) m.Group("/subscription", func() { m.Get("", user.IsWatching) m.Put("", reqToken(), user.Watch) m.Delete("", reqToken(), user.Unwatch) + m.Put("/custom", reqToken(), bind(api.RepoCustomWatchOptions{}), user.WatchCustom) }) m.Group("/releases", func() { m.Combo("").Get(repo.ListReleases). diff --git a/routers/api/v1/repo/subscriber.go b/routers/api/v1/repo/subscriber.go index 05509fc4435c9..9061fbbcf619c 100644 --- a/routers/api/v1/repo/subscriber.go +++ b/routers/api/v1/repo/subscriber.go @@ -4,7 +4,9 @@ package repo import ( + "fmt" "net/http" + "strings" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/context" @@ -58,3 +60,66 @@ func ListSubscribers(ctx *context.APIContext) { ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumWatches)) ctx.JSON(http.StatusOK, users) } + +// ListSubscribers list a repo's subscribers (i.e. watchers) for the given event +func ListSubscribersEvent(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/subscribers/{event} repository repoListSubscribersEvent + // --- + // summary: List a repo's watchers for the given event + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: event + // in: path + // description: the event + // type: string + // enum: [issues,pullrequests,releases] + // required: true + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/UserList" + var eventConst repo_model.WatchEventType + + switch strings.ToLower(ctx.Params("event")) { + case "issues": + eventConst = repo_model.WatchEventTypeIssue + case "pullrequests": + eventConst = repo_model.WatchEventTypePullRequest + case "releases": + eventConst = repo_model.WatchEventTypeRelease + default: + ctx.Error(http.StatusBadRequest, "InvalidEvent", fmt.Errorf("%s is not a valid event", ctx.Params("event"))) + return + } + + subscribers, err := repo_model.GetRepoWatchersEvent(ctx.Repo.Repository.ID, eventConst, utils.GetListOptions(ctx)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRepoWatchersEvent", err) + return + } + users := make([]*api.User, len(subscribers)) + for i, subscriber := range subscribers { + users[i] = convert.ToUser(ctx, subscriber, ctx.Doer) + } + + ctx.SetTotalCountHeader(int64(ctx.Repo.Repository.NumWatches)) + ctx.JSON(http.StatusOK, users) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 6f7859df62ed4..ee3d93267b4fa 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -190,4 +190,7 @@ type swaggerParameterBodies struct { // in:body CreateOrUpdateSecretOption api.CreateOrUpdateSecretOption + + // in:body + RepoCustomWatchOptions api.RepoCustomWatchOptions } diff --git a/routers/api/v1/user/watch.go b/routers/api/v1/user/watch.go index 7f531eafaa6be..084dd36fc5c91 100644 --- a/routers/api/v1/user/watch.go +++ b/routers/api/v1/user/watch.go @@ -13,6 +13,7 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/convert" ) @@ -35,6 +36,29 @@ func getWatchedRepos(ctx std_context.Context, user *user_model.User, private boo return repos, total, nil } +// getWatchInfo returns the WatchInfo +func getWatchInfo(ctx *context.APIContext) (*api.WatchInfo, error) { + watch, err := repo_model.GetWatch(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) + if err != nil { + return nil, err + } + + return &api.WatchInfo{ + Subscribed: watch.IsWatching(), + Ignored: watch.Mode.IsWatchModeDont(), + IsCustom: watch.Mode.IsWatchModeCustom(), + Reason: nil, + CreatedAt: ctx.Repo.Repository.CreatedUnix.AsTime(), + URL: subscriptionURL(ctx.Repo.Repository), + RepositoryURL: ctx.Repo.Repository.APIURL(), + CustomWatchOptions: api.RepoCustomWatchOptions{ + Issues: watch.CustomIssues, + PullRequests: watch.CustomPullRequests, + Releases: watch.CustomReleases, + }, + }, nil +} + // GetWatchedRepos returns the repos that the user specified in ctx is watching func GetWatchedRepos(ctx *context.APIContext) { // swagger:operation GET /users/{username}/subscriptions user userListSubscriptions @@ -124,18 +148,18 @@ func IsWatching(ctx *context.APIContext) { // "404": // description: User is not watching this repo or repo do not exist - if repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { - ctx.JSON(http.StatusOK, api.WatchInfo{ - Subscribed: true, - Ignored: false, - Reason: nil, - CreatedAt: ctx.Repo.Repository.CreatedUnix.AsTime(), - URL: subscriptionURL(ctx.Repo.Repository), - RepositoryURL: ctx.Repo.Repository.APIURL(), - }) - } else { + if !repo_model.IsWatching(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { ctx.NotFound() + return + } + + info, err := getWatchInfo(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetWatchInfo", err) + return } + + ctx.JSON(http.StatusOK, info) } // Watch the repo specified in ctx, as the authenticated user @@ -165,14 +189,54 @@ func Watch(ctx *context.APIContext) { ctx.Error(http.StatusInternalServerError, "WatchRepo", err) return } - ctx.JSON(http.StatusOK, api.WatchInfo{ - Subscribed: true, - Ignored: false, - Reason: nil, - CreatedAt: ctx.Repo.Repository.CreatedUnix.AsTime(), - URL: subscriptionURL(ctx.Repo.Repository), - RepositoryURL: ctx.Repo.Repository.APIURL(), - }) + + info, err := getWatchInfo(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetWatchInfo", err) + return + } + + ctx.JSON(http.StatusOK, info) +} + +// Watch the repo specified in ctx, as the authenticated user +func WatchCustom(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/subscription/custom repository userCurrentPutSubscriptionCustom + // --- + // summary: Watch a repo + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/RepoCustomWatchOptions" + // responses: + // "200": + // "$ref": "#/responses/WatchInfo" + opts := *web.GetForm(ctx).(*api.RepoCustomWatchOptions) + + err := repo_model.WatchRepoCustom(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "WatchRepo", err) + return + } + + info, err := getWatchInfo(ctx) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetWatchInfo", err) + return + } + + ctx.JSON(http.StatusOK, info) } // Unwatch the repo specified in ctx, as the authenticated user diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index b3b6b48871a08..8c4d5670e6892 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -700,3 +700,39 @@ func PrepareBranchList(ctx *context.Context) { } ctx.Data["Branches"] = brs } + +func SetWatchMode(ctx *context.Context) { + var mode repo_model.WatchMode + + switch strings.ToLower(ctx.Params("mode")) { + case "none": + mode = repo_model.WatchModeNone + case "normal": + mode = repo_model.WatchModeNormal + case "dont": + mode = repo_model.WatchModeDont + default: + ctx.ServerError("InvalidMode", fmt.Errorf("%s is not a valid mode", ctx.Params("mode"))) + return + } + + err := repo_model.WatchRepoMode(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, mode) + if err != nil { + ctx.ServerError("WatchRepoMode", err) + return + } + + ctx.Redirect(ctx.Repo.RepoLink) +} + +func WatchCustomPost(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.RepoWatchCustomForm) + + err := repo_model.WatchRepoCustom(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, api.RepoCustomWatchOptions{Issues: form.Issues, PullRequests: form.PullRequests, Releases: form.Releases}) + if err != nil { + ctx.ServerError("WatchRepoCustom", err) + return + } + + ctx.Redirect(ctx.Repo.RepoLink) +} diff --git a/routers/web/web.go b/routers/web/web.go index 6449f7716cf75..dc5db1c50dc26 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1214,6 +1214,9 @@ func registerRoutes(m *web.Route) { m.Post("/delete", repo.DeleteBranchPost) m.Post("/restore", repo.RestoreBranchPost) }, context.RepoMustNotBeArchived(), reqRepoCodeWriter, repo.MustBeNotEmpty) + + m.Get("/watch_mode/{mode}", reqSignIn, repo.SetWatchMode) + m.Post("/watch_custom", reqSignIn, web.Bind(forms.RepoWatchCustomForm{}), repo.WatchCustomPost) }, reqSignIn, context.RepoAssignment, context.UnitTypes()) // Tags diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 5df7ec8fd609a..4b051bf806c92 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -903,3 +903,10 @@ func (f *DeadlineForm) Validate(req *http.Request, errs binding.Errors) binding. ctx := context.GetValidateContext(req) return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } + +// RepoWatchCustomForm wtach options for a repo +type RepoWatchCustomForm struct { + Issues bool `binding:"Required"` + PullRequests bool `binding:"Required"` + Releases bool `binding:"Required"` +} diff --git a/services/mailer/mail_issue.go b/services/mailer/mail_issue.go index baa9f1d9abe6d..91d958b981afa 100644 --- a/services/mailer/mail_issue.go +++ b/services/mailer/mail_issue.go @@ -83,7 +83,11 @@ func mailIssueCommentToParticipants(ctx *mailCommentContext, mentions []*user_mo // =========== Repo watchers =========== // Make repo watchers last, since it's likely the list with the most users if !(ctx.Issue.IsPull && ctx.Issue.PullRequest.IsWorkInProgress(ctx) && ctx.ActionType != activities_model.ActionCreatePullRequest) { - ids, err = repo_model.GetRepoWatchersIDs(ctx, ctx.Issue.RepoID) + if ctx.Issue.IsPull { + ids, err = repo_model.GetRepoWatchersEventIDs(ctx, ctx.Issue.RepoID, repo_model.WatchEventTypePullRequest) + } else { + ids, err = repo_model.GetRepoWatchersEventIDs(ctx, ctx.Issue.RepoID, repo_model.WatchEventTypeIssue) + } if err != nil { return fmt.Errorf("GetRepoWatchersIDs(%d): %w", ctx.Issue.RepoID, err) } diff --git a/services/mailer/mail_release.go b/services/mailer/mail_release.go index 7bc3db3fe6c64..aedaba0dcd98a 100644 --- a/services/mailer/mail_release.go +++ b/services/mailer/mail_release.go @@ -29,7 +29,7 @@ func MailNewRelease(ctx context.Context, rel *repo_model.Release) { return } - watcherIDList, err := repo_model.GetRepoWatchersIDs(ctx, rel.RepoID) + watcherIDList, err := repo_model.GetRepoWatchersEventIDs(ctx, rel.RepoID, repo_model.WatchEventTypeRelease) if err != nil { log.Error("GetRepoWatchersIDs(%d): %v", rel.RepoID, err) return diff --git a/services/uinotification/notify.go b/services/uinotification/notify.go index be5f7019a2e11..fdae0e29d746d 100644 --- a/services/uinotification/notify.go +++ b/services/uinotification/notify.go @@ -139,7 +139,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.GetRepoWatchersEventIDs(ctx, pr.Issue.RepoID, repo_model.WatchEventTypePullRequest) if err != nil { log.Error("GetRepoWatchersIDs: %v", err) return diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index 895d18d6fd84f..707f787bb753d 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -55,17 +55,54 @@ {{end}} -
+ {{$.CsrfTokenHtml}}
{{CountFmt .NumWatches}}
+ {{if $.IsSigned}} + + {{end}} + {{if not $.DisableStars}}
{{$.CsrfTokenHtml}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 2389ec3beee33..7d3835e964a1b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -12632,6 +12632,63 @@ } } }, + "/repos/{owner}/{repo}/subscribers/{event}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a repo's watchers for the given event", + "operationId": "repoListSubscribersEvent", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "enum": [ + "issues", + "pullrequests", + "releases" + ], + "type": "string", + "description": "the event", + "name": "event", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/UserList" + } + } + } + }, "/repos/{owner}/{repo}/subscription": { "get": { "tags": [ @@ -12727,6 +12784,43 @@ } } }, + "/repos/{owner}/{repo}/subscription/custom": { + "put": { + "tags": [ + "repository" + ], + "summary": "Watch a repo", + "operationId": "userCurrentPutSubscriptionCustom", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/RepoCustomWatchOptions" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/WatchInfo" + } + } + } + }, "/repos/{owner}/{repo}/tags": { "get": { "produces": [ @@ -21879,6 +21973,25 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "RepoCustomWatchOptions": { + "description": "RepoCustomWatchOptions options when watching custom events of a repo", + "type": "object", + "properties": { + "issues": { + "type": "boolean", + "x-go-name": "Issues" + }, + "pull_requests": { + "type": "boolean", + "x-go-name": "PullRequests" + }, + "releases": { + "type": "boolean", + "x-go-name": "Releases" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "RepoTopicOptions": { "description": "RepoTopicOptions a collection of repo topic names", "type": "object", @@ -22929,10 +23042,17 @@ "format": "date-time", "x-go-name": "CreatedAt" }, + "custom_watch_options": { + "$ref": "#/definitions/RepoCustomWatchOptions" + }, "ignored": { "type": "boolean", "x-go-name": "Ignored" }, + "is_custom": { + "type": "boolean", + "x-go-name": "IsCustom" + }, "reason": { "x-go-name": "Reason" }, @@ -24011,7 +24131,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/CreateOrUpdateSecretOption" + "$ref": "#/definitions/RepoCustomWatchOptions" } }, "redirect": { diff --git a/tests/integration/api_repo_watch_test.go b/tests/integration/api_repo_watch_test.go new file mode 100644 index 0000000000000..4a4e2f4e5bc1f --- /dev/null +++ b/tests/integration/api_repo_watch_test.go @@ -0,0 +1,176 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func getEventWatchers(t *testing.T, repo *repo_model.Repository, event string) []api.User { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/subscribers/%s", repo.OwnerName, repo.Name, event) + + req := NewRequest(t, "GET", urlStr) + resp := MakeRequest(t, req, http.StatusOK) + + var watchers []api.User + DecodeJSON(t, resp, &watchers) + + return watchers +} + +func addIssueComment(t *testing.T, repo *repo_model.Repository, issueIndex int, token, text string) { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments?token=%s", + repo.OwnerName, repo.Name, issueIndex, token) + req := NewRequestWithValues(t, "POST", urlStr, map[string]string{ + "body": text, + }) + MakeRequest(t, req, http.StatusCreated) +} + +func getNotifications(t *testing.T, token string) []api.NotificationThread { + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token)) + resp := MakeRequest(t, req, http.StatusOK) + + var notifications []api.NotificationThread + DecodeJSON(t, resp, ¬ifications) + + return notifications +} + +func TestAPIRepoCustomWatch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + session := loginUser(t, repo.OwnerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteNotification) + + session2 := loginUser(t, "user12") + token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteIssue) + + // make sure nobdy is watching the test repo + _, err := db.GetEngine(db.DefaultContext).Exec("DELETE FROM watch WHERE repo_id = ?", repo.ID) + assert.NoError(t, err) + + assert.Len(t, getEventWatchers(t, repo, "issues"), 0) + assert.Len(t, getEventWatchers(t, repo, "pullrequests"), 0) + assert.Len(t, getEventWatchers(t, repo, "releases"), 0) + + const IssueIndex = 1 + const PullRequestIndex = 5 + + t.Run("Issues", func(t *testing.T) { + // Make sure there are no unread notifications + MakeRequest(t, NewRequest(t, "PUT", fmt.Sprintf("/api/v1/notifications?status-types=unread&status-types=pinned&token=%s", token)), http.StatusResetContent) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/subscription/custom?token=%s", repo.OwnerName, repo.Name, token) + req := NewRequestWithJSON(t, "PUT", urlStr, api.RepoCustomWatchOptions{Issues: true}) + MakeRequest(t, req, http.StatusOK) + + assert.Len(t, getEventWatchers(t, repo, "pullrequests"), 0) + assert.Len(t, getEventWatchers(t, repo, "releases"), 0) + + watchers := getEventWatchers(t, repo, "issues") + assert.Len(t, watchers, 1) + assert.Equal(t, repo.OwnerID, watchers[0].ID) + + addIssueComment(t, repo, IssueIndex, token2, "Hello Issue") + addIssueComment(t, repo, PullRequestIndex, token2, "Hello PR") + + notifications := getNotifications(t, token) + assert.Len(t, notifications, 1) + assert.Equal(t, "issue1", notifications[0].Subject.Title) + }) + + t.Run("PullRequests", func(t *testing.T) { + // Make sure there are no unread notifications + MakeRequest(t, NewRequest(t, "PUT", fmt.Sprintf("/api/v1/notifications?status-types=unread&status-types=pinned&token=%s", token)), http.StatusResetContent) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/subscription/custom?token=%s", repo.OwnerName, repo.Name, token) + req := NewRequestWithJSON(t, "PUT", urlStr, api.RepoCustomWatchOptions{PullRequests: true}) + MakeRequest(t, req, http.StatusOK) + + assert.Len(t, getEventWatchers(t, repo, "issues"), 0) + assert.Len(t, getEventWatchers(t, repo, "releases"), 0) + + watchers := getEventWatchers(t, repo, "pullrequests") + assert.Len(t, watchers, 1) + assert.Equal(t, repo.OwnerID, watchers[0].ID) + + addIssueComment(t, repo, IssueIndex, token2, "Hello Issue") + addIssueComment(t, repo, PullRequestIndex, token2, "Hello PR") + + notifications := getNotifications(t, token) + assert.Len(t, notifications, 1) + assert.Equal(t, "pull5", notifications[0].Subject.Title) + }) + + t.Run("IssuesAndPullRequests", func(t *testing.T) { + // Make sure there are no unread notifications + MakeRequest(t, NewRequest(t, "PUT", fmt.Sprintf("/api/v1/notifications?status-types=unread&status-types=pinned&token=%s", token)), http.StatusResetContent) + + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/subscription/custom?token=%s", repo.OwnerName, repo.Name, token) + req := NewRequestWithJSON(t, "PUT", urlStr, api.RepoCustomWatchOptions{Issues: true, PullRequests: true}) + MakeRequest(t, req, http.StatusOK) + + assert.Len(t, getEventWatchers(t, repo, "releases"), 0) + + issueWatchers := getEventWatchers(t, repo, "issues") + assert.Len(t, issueWatchers, 1) + assert.Equal(t, repo.OwnerID, issueWatchers[0].ID) + + pullRequestWatchers := getEventWatchers(t, repo, "pullrequests") + assert.Len(t, pullRequestWatchers, 1) + assert.Equal(t, repo.OwnerID, pullRequestWatchers[0].ID) + + addIssueComment(t, repo, IssueIndex, token2, "Hello Issue") + addIssueComment(t, repo, PullRequestIndex, token2, "Hello PR") + + notifications := getNotifications(t, token) + assert.Len(t, notifications, 2) + + // The notifications are not always in the same order + if notifications[0].Subject.Title == "issue1" { + assert.Equal(t, "issue1", notifications[0].Subject.Title) + assert.Equal(t, "pull5", notifications[1].Subject.Title) + } else { + assert.Equal(t, "pull5", notifications[0].Subject.Title) + assert.Equal(t, "issue1", notifications[1].Subject.Title) + } + }) + + t.Run("Releases", func(t *testing.T) { + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/subscription/custom?token=%s", repo.OwnerName, repo.Name, token) + req := NewRequestWithJSON(t, "PUT", urlStr, api.RepoCustomWatchOptions{Releases: true}) + MakeRequest(t, req, http.StatusOK) + + assert.Len(t, getEventWatchers(t, repo, "issues"), 0) + assert.Len(t, getEventWatchers(t, repo, "pullrequests"), 0) + + watchers := getEventWatchers(t, repo, "releases") + assert.Len(t, watchers, 1) + assert.Equal(t, repo.OwnerID, watchers[0].ID) + + // Release notifications are currently not shown in the UI, so we can't test that yet + }) +} + +func TestAPIRepoCustomWatchInvalidEventType(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/subscribers/invalid", repo.OwnerName, repo.Name)), http.StatusBadRequest) +} diff --git a/tests/integration/api_user_watch_test.go b/tests/integration/api_user_watch_test.go index c07fd288d109c..046edba01c494 100644 --- a/tests/integration/api_user_watch_test.go +++ b/tests/integration/api_user_watch_test.go @@ -76,4 +76,26 @@ func TestAPIWatch(t *testing.T) { req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/subscription?token=%s", repo, tokenWithRepoScope)) MakeRequest(t, req, http.StatusNoContent) }) + + t.Run("Custom", func(t *testing.T) { + defer tests.PrintCurrentTest(t)() + + urlStr := fmt.Sprintf("/api/v1/repos/%s/subscription/custom?token=%s", repo, tokenWithRepoScope) + req := NewRequestWithJSON(t, "PUT", urlStr, api.RepoCustomWatchOptions{Issues: true, PullRequests: true}) + MakeRequest(t, req, http.StatusOK) + + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription?token=%s", repo, tokenWithRepoScope)) + resp := MakeRequest(t, req, http.StatusOK) + + var info api.WatchInfo + DecodeJSON(t, resp, &info) + + assert.True(t, info.Subscribed) + assert.False(t, info.Ignored) + assert.True(t, info.IsCustom) + + assert.True(t, info.CustomWatchOptions.Issues) + assert.True(t, info.CustomWatchOptions.PullRequests) + assert.False(t, info.CustomWatchOptions.Releases) + }) }