Skip to content

Commit 37a34c1

Browse files
authored
Merge pull request #1410 from andreynering/notification/issue-watch
[Notifications Step 6] Per issue/PR watch/unwatch
2 parents 88112a5 + f6e5ce6 commit 37a34c1

File tree

10 files changed

+271
-8
lines changed

10 files changed

+271
-8
lines changed

cmd/web.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,7 @@ func runWeb(ctx *cli.Context) error {
491491
m.Group("/:index", func() {
492492
m.Post("/title", repo.UpdateIssueTitle)
493493
m.Post("/content", repo.UpdateIssueContent)
494+
m.Post("/watch", repo.IssueWatch)
494495
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
495496
})
496497

models/fixtures/issue_watch.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-
2+
id: 1
3+
user_id: 1
4+
issue_id: 1
5+
is_watching: true
6+
created_unix: 946684800
7+
updated_unix: 946684800
8+
9+
-
10+
id: 2
11+
user_id: 2
12+
issue_id: 2
13+
is_watching: false
14+
created_unix: 946684800
15+
updated_unix: 946684800

models/issue_watch.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright 2017 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import (
8+
"time"
9+
)
10+
11+
// IssueWatch is connection request for receiving issue notification.
12+
type IssueWatch struct {
13+
ID int64 `xorm:"pk autoincr"`
14+
UserID int64 `xorm:"UNIQUE(watch) NOT NULL"`
15+
IssueID int64 `xorm:"UNIQUE(watch) NOT NULL"`
16+
IsWatching bool `xorm:"NOT NULL"`
17+
Created time.Time `xorm:"-"`
18+
CreatedUnix int64 `xorm:"NOT NULL"`
19+
Updated time.Time `xorm:"-"`
20+
UpdatedUnix int64 `xorm:"NOT NULL"`
21+
}
22+
23+
// BeforeInsert is invoked from XORM before inserting an object of this type.
24+
func (iw *IssueWatch) BeforeInsert() {
25+
var (
26+
t = time.Now()
27+
u = t.Unix()
28+
)
29+
iw.Created = t
30+
iw.CreatedUnix = u
31+
iw.Updated = t
32+
iw.UpdatedUnix = u
33+
}
34+
35+
// BeforeUpdate is invoked from XORM before updating an object of this type.
36+
func (iw *IssueWatch) BeforeUpdate() {
37+
var (
38+
t = time.Now()
39+
u = t.Unix()
40+
)
41+
iw.Updated = t
42+
iw.UpdatedUnix = u
43+
}
44+
45+
// CreateOrUpdateIssueWatch set watching for a user and issue
46+
func CreateOrUpdateIssueWatch(userID, issueID int64, isWatching bool) error {
47+
iw, exists, err := getIssueWatch(x, userID, issueID)
48+
if err != nil {
49+
return err
50+
}
51+
52+
if !exists {
53+
iw = &IssueWatch{
54+
UserID: userID,
55+
IssueID: issueID,
56+
IsWatching: isWatching,
57+
}
58+
59+
if _, err := x.Insert(iw); err != nil {
60+
return err
61+
}
62+
} else {
63+
iw.IsWatching = isWatching
64+
65+
if _, err := x.Id(iw.ID).Cols("is_watching", "updated_unix").Update(iw); err != nil {
66+
return err
67+
}
68+
}
69+
return nil
70+
}
71+
72+
// GetIssueWatch returns an issue watch by user and issue
73+
func GetIssueWatch(userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
74+
return getIssueWatch(x, userID, issueID)
75+
}
76+
77+
func getIssueWatch(e Engine, userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
78+
iw = new(IssueWatch)
79+
exists, err = e.
80+
Where("user_id = ?", userID).
81+
And("issue_id = ?", issueID).
82+
Get(iw)
83+
return
84+
}
85+
86+
// GetIssueWatchers returns watchers/unwatchers of a given issue
87+
func GetIssueWatchers(issueID int64) ([]*IssueWatch, error) {
88+
return getIssueWatchers(x, issueID)
89+
}
90+
91+
func getIssueWatchers(e Engine, issueID int64) (watches []*IssueWatch, err error) {
92+
err = e.
93+
Where("issue_id = ?", issueID).
94+
Find(&watches)
95+
return
96+
}

models/issue_watch_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2017 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package models
6+
7+
import (
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
)
12+
13+
func TestCreateOrUpdateIssueWatch(t *testing.T) {
14+
assert.NoError(t, PrepareTestDatabase())
15+
16+
assert.NoError(t, CreateOrUpdateIssueWatch(3, 1, true))
17+
iw := AssertExistsAndLoadBean(t, &IssueWatch{UserID: 3, IssueID: 1}).(*IssueWatch)
18+
assert.Equal(t, true, iw.IsWatching)
19+
20+
assert.NoError(t, CreateOrUpdateIssueWatch(1, 1, false))
21+
iw = AssertExistsAndLoadBean(t, &IssueWatch{UserID: 1, IssueID: 1}).(*IssueWatch)
22+
assert.Equal(t, false, iw.IsWatching)
23+
}
24+
25+
func TestGetIssueWatch(t *testing.T) {
26+
assert.NoError(t, PrepareTestDatabase())
27+
28+
_, exists, err := GetIssueWatch(1, 1)
29+
assert.Equal(t, true, exists)
30+
assert.NoError(t, err)
31+
32+
_, exists, err = GetIssueWatch(2, 2)
33+
assert.Equal(t, true, exists)
34+
assert.NoError(t, err)
35+
36+
_, exists, err = GetIssueWatch(3, 1)
37+
assert.Equal(t, false, exists)
38+
assert.NoError(t, err)
39+
}
40+
41+
func TestGetIssueWatchers(t *testing.T) {
42+
assert.NoError(t, PrepareTestDatabase())
43+
44+
iws, err := GetIssueWatchers(1)
45+
assert.NoError(t, err)
46+
assert.Equal(t, 1, len(iws))
47+
48+
iws, err = GetIssueWatchers(5)
49+
assert.NoError(t, err)
50+
assert.Equal(t, 0, len(iws))
51+
}

models/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ func init() {
117117
new(ExternalLoginUser),
118118
new(ProtectedBranch),
119119
new(UserOpenID),
120+
new(IssueWatch),
120121
)
121122

122123
gonicNames := []string{"SSL", "UID"}

models/notification.go

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ func CreateOrUpdateIssueNotifications(issue *Issue, notificationAuthorID int64)
9696
}
9797

9898
func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthorID int64) error {
99+
issueWatches, err := getIssueWatchers(e, issue.ID)
100+
if err != nil {
101+
return err
102+
}
103+
99104
watches, err := getWatchers(e, issue.RepoID)
100105
if err != nil {
101106
return err
@@ -106,23 +111,42 @@ func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthor
106111
return err
107112
}
108113

109-
for _, watch := range watches {
114+
alreadyNotified := make(map[int64]struct{}, len(issueWatches)+len(watches))
115+
116+
notifyUser := func(userID int64) error {
110117
// do not send notification for the own issuer/commenter
111-
if watch.UserID == notificationAuthorID {
112-
continue
118+
if userID == notificationAuthorID {
119+
return nil
113120
}
114121

115-
if notificationExists(notifications, issue.ID, watch.UserID) {
116-
err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID)
117-
} else {
118-
err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID)
122+
if _, ok := alreadyNotified[userID]; ok {
123+
return nil
119124
}
125+
alreadyNotified[userID] = struct{}{}
120126

121-
if err != nil {
127+
if notificationExists(notifications, issue.ID, userID) {
128+
return updateIssueNotification(e, userID, issue.ID, notificationAuthorID)
129+
}
130+
return createIssueNotification(e, userID, issue, notificationAuthorID)
131+
}
132+
133+
for _, issueWatch := range issueWatches {
134+
// ignore if user unwatched the issue
135+
if !issueWatch.IsWatching {
136+
alreadyNotified[issueWatch.UserID] = struct{}{}
137+
continue
138+
}
139+
140+
if err := notifyUser(issueWatch.UserID); err != nil {
122141
return err
123142
}
124143
}
125144

145+
for _, watch := range watches {
146+
if err := notifyUser(watch.UserID); err != nil {
147+
return err
148+
}
149+
}
126150
return nil
127151
}
128152

options/locale/locale_en-US.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,8 @@ issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically
652652
issues.num_participants = %d Participants
653653
issues.attachment.open_tab = `Click to see "%s" in a new tab`
654654
issues.attachment.download = `Click to download "%s"`
655+
issues.subscribe = Subscribe
656+
issues.unsubscribe = Unsubscribe
655657

656658
pulls.new = New Pull Request
657659
pulls.compare_changes = Compare Changes

routers/repo/issue.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,20 @@ func ViewIssue(ctx *context.Context) {
465465
}
466466
ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title)
467467

468+
iw, exists, err := models.GetIssueWatch(ctx.User.ID, issue.ID)
469+
if err != nil {
470+
ctx.Handle(500, "GetIssueWatch", err)
471+
return
472+
}
473+
if !exists {
474+
iw = &models.IssueWatch{
475+
UserID: ctx.User.ID,
476+
IssueID: issue.ID,
477+
IsWatching: models.IsWatching(ctx.User.ID, ctx.Repo.Repository.ID),
478+
}
479+
}
480+
ctx.Data["IssueWatch"] = iw
481+
468482
// Make sure type and URL matches.
469483
if ctx.Params(":type") == "issues" && issue.IsPull {
470484
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))

routers/repo/issue_watch.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright 2017 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package repo
6+
7+
import (
8+
"fmt"
9+
"net/http"
10+
"strconv"
11+
12+
"code.gitea.io/gitea/models"
13+
"code.gitea.io/gitea/modules/context"
14+
)
15+
16+
// IssueWatch sets issue watching
17+
func IssueWatch(c *context.Context) {
18+
watch, err := strconv.ParseBool(c.Req.PostForm.Get("watch"))
19+
if err != nil {
20+
c.Handle(http.StatusInternalServerError, "watch is not bool", err)
21+
return
22+
}
23+
24+
issueIndex := c.ParamsInt64("index")
25+
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
26+
if err != nil {
27+
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
28+
return
29+
}
30+
31+
if err := models.CreateOrUpdateIssueWatch(c.User.ID, issue.ID, watch); err != nil {
32+
c.Handle(http.StatusInternalServerError, "CreateOrUpdateIssueWatch", err)
33+
return
34+
}
35+
36+
url := fmt.Sprintf("%s/issues/%d", c.Repo.RepoLink, issueIndex)
37+
c.Redirect(url, http.StatusSeeOther)
38+
}

templates/repo/issue/view_content/sidebar.tmpl

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,26 @@
9898
{{end}}
9999
</div>
100100
</div>
101+
102+
<div class="ui divider"></div>
103+
104+
<div class="ui watching">
105+
<span class="text"><strong>{{.i18n.Tr "notification.notifications"}}</strong></span>
106+
<div>
107+
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/watch">
108+
<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}" />
109+
{{$.CsrfTokenHtml}}
110+
<button class="fluid ui button">
111+
{{if $.IssueWatch.IsWatching}}
112+
<i class="octicon octicon-mute"></i>
113+
{{.i18n.Tr "repo.issues.unsubscribe"}}
114+
{{else}}
115+
<i class="octicon octicon-unmute"></i>
116+
{{.i18n.Tr "repo.issues.subscribe"}}
117+
{{end}}
118+
</button>
119+
</form>
120+
</div>
121+
</div>
101122
</div>
102123
</div>

0 commit comments

Comments
 (0)