Browse Source

Merge pull request #1410 from andreynering/notification/issue-watch

[Notifications Step 6] Per issue/PR watch/unwatch
master
Andrey Nering GitHub 8 years ago
parent
commit
37a34c1a28
10 changed files with 271 additions and 8 deletions
  1. +1
    -0
      cmd/web.go
  2. +15
    -0
      models/fixtures/issue_watch.yml
  3. +96
    -0
      models/issue_watch.go
  4. +51
    -0
      models/issue_watch_test.go
  5. +1
    -0
      models/models.go
  6. +32
    -8
      models/notification.go
  7. +2
    -0
      options/locale/locale_en-US.ini
  8. +14
    -0
      routers/repo/issue.go
  9. +38
    -0
      routers/repo/issue_watch.go
  10. +21
    -0
      templates/repo/issue/view_content/sidebar.tmpl

+ 1
- 0
cmd/web.go View File

@@ -491,6 +491,7 @@ func runWeb(ctx *cli.Context) error {
m.Group("/:index", func() { m.Group("/:index", func() {
m.Post("/title", repo.UpdateIssueTitle) m.Post("/title", repo.UpdateIssueTitle)
m.Post("/content", repo.UpdateIssueContent) m.Post("/content", repo.UpdateIssueContent)
m.Post("/watch", repo.IssueWatch)
m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment) m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
}) })




+ 15
- 0
models/fixtures/issue_watch.yml View File

@@ -0,0 +1,15 @@
-
id: 1
user_id: 1
issue_id: 1
is_watching: true
created_unix: 946684800
updated_unix: 946684800

-
id: 2
user_id: 2
issue_id: 2
is_watching: false
created_unix: 946684800
updated_unix: 946684800

+ 96
- 0
models/issue_watch.go View File

@@ -0,0 +1,96 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package models

import (
"time"
)

// IssueWatch is connection request for receiving issue notification.
type IssueWatch struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"UNIQUE(watch) NOT NULL"`
IssueID int64 `xorm:"UNIQUE(watch) NOT NULL"`
IsWatching bool `xorm:"NOT NULL"`
Created time.Time `xorm:"-"`
CreatedUnix int64 `xorm:"NOT NULL"`
Updated time.Time `xorm:"-"`
UpdatedUnix int64 `xorm:"NOT NULL"`
}

// BeforeInsert is invoked from XORM before inserting an object of this type.
func (iw *IssueWatch) BeforeInsert() {
var (
t = time.Now()
u = t.Unix()
)
iw.Created = t
iw.CreatedUnix = u
iw.Updated = t
iw.UpdatedUnix = u
}

// BeforeUpdate is invoked from XORM before updating an object of this type.
func (iw *IssueWatch) BeforeUpdate() {
var (
t = time.Now()
u = t.Unix()
)
iw.Updated = t
iw.UpdatedUnix = u
}

// CreateOrUpdateIssueWatch set watching for a user and issue
func CreateOrUpdateIssueWatch(userID, issueID int64, isWatching bool) error {
iw, exists, err := getIssueWatch(x, userID, issueID)
if err != nil {
return err
}

if !exists {
iw = &IssueWatch{
UserID: userID,
IssueID: issueID,
IsWatching: isWatching,
}

if _, err := x.Insert(iw); err != nil {
return err
}
} else {
iw.IsWatching = isWatching

if _, err := x.Id(iw.ID).Cols("is_watching", "updated_unix").Update(iw); err != nil {
return err
}
}
return nil
}

// GetIssueWatch returns an issue watch by user and issue
func GetIssueWatch(userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
return getIssueWatch(x, userID, issueID)
}

func getIssueWatch(e Engine, userID, issueID int64) (iw *IssueWatch, exists bool, err error) {
iw = new(IssueWatch)
exists, err = e.
Where("user_id = ?", userID).
And("issue_id = ?", issueID).
Get(iw)
return
}

// GetIssueWatchers returns watchers/unwatchers of a given issue
func GetIssueWatchers(issueID int64) ([]*IssueWatch, error) {
return getIssueWatchers(x, issueID)
}

func getIssueWatchers(e Engine, issueID int64) (watches []*IssueWatch, err error) {
err = e.
Where("issue_id = ?", issueID).
Find(&watches)
return
}

+ 51
- 0
models/issue_watch_test.go View File

@@ -0,0 +1,51 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package models

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestCreateOrUpdateIssueWatch(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

assert.NoError(t, CreateOrUpdateIssueWatch(3, 1, true))
iw := AssertExistsAndLoadBean(t, &IssueWatch{UserID: 3, IssueID: 1}).(*IssueWatch)
assert.Equal(t, true, iw.IsWatching)

assert.NoError(t, CreateOrUpdateIssueWatch(1, 1, false))
iw = AssertExistsAndLoadBean(t, &IssueWatch{UserID: 1, IssueID: 1}).(*IssueWatch)
assert.Equal(t, false, iw.IsWatching)
}

func TestGetIssueWatch(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

_, exists, err := GetIssueWatch(1, 1)
assert.Equal(t, true, exists)
assert.NoError(t, err)

_, exists, err = GetIssueWatch(2, 2)
assert.Equal(t, true, exists)
assert.NoError(t, err)

_, exists, err = GetIssueWatch(3, 1)
assert.Equal(t, false, exists)
assert.NoError(t, err)
}

func TestGetIssueWatchers(t *testing.T) {
assert.NoError(t, PrepareTestDatabase())

iws, err := GetIssueWatchers(1)
assert.NoError(t, err)
assert.Equal(t, 1, len(iws))

iws, err = GetIssueWatchers(5)
assert.NoError(t, err)
assert.Equal(t, 0, len(iws))
}

+ 1
- 0
models/models.go View File

@@ -117,6 +117,7 @@ func init() {
new(ExternalLoginUser), new(ExternalLoginUser),
new(ProtectedBranch), new(ProtectedBranch),
new(UserOpenID), new(UserOpenID),
new(IssueWatch),
) )


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


+ 32
- 8
models/notification.go View File

@@ -96,6 +96,11 @@ func CreateOrUpdateIssueNotifications(issue *Issue, notificationAuthorID int64)
} }


func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthorID int64) error { func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthorID int64) error {
issueWatches, err := getIssueWatchers(e, issue.ID)
if err != nil {
return err
}

watches, err := getWatchers(e, issue.RepoID) watches, err := getWatchers(e, issue.RepoID)
if err != nil { if err != nil {
return err return err
@@ -106,23 +111,42 @@ func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthor
return err return err
} }


for _, watch := range watches {
alreadyNotified := make(map[int64]struct{}, len(issueWatches)+len(watches))

notifyUser := func(userID int64) error {
// do not send notification for the own issuer/commenter // do not send notification for the own issuer/commenter
if watch.UserID == notificationAuthorID {
continue
if userID == notificationAuthorID {
return nil
} }


if notificationExists(notifications, issue.ID, watch.UserID) {
err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID)
} else {
err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID)
if _, ok := alreadyNotified[userID]; ok {
return nil
} }
alreadyNotified[userID] = struct{}{}


if err != nil {
if notificationExists(notifications, issue.ID, userID) {
return updateIssueNotification(e, userID, issue.ID, notificationAuthorID)
}
return createIssueNotification(e, userID, issue, notificationAuthorID)
}

for _, issueWatch := range issueWatches {
// ignore if user unwatched the issue
if !issueWatch.IsWatching {
alreadyNotified[issueWatch.UserID] = struct{}{}
continue
}

if err := notifyUser(issueWatch.UserID); err != nil {
return err return err
} }
} }


for _, watch := range watches {
if err := notifyUser(watch.UserID); err != nil {
return err
}
}
return nil return nil
} }




+ 2
- 0
options/locale/locale_en-US.ini View File

@@ -652,6 +652,8 @@ issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically
issues.num_participants = %d Participants issues.num_participants = %d Participants
issues.attachment.open_tab = `Click to see "%s" in a new tab` issues.attachment.open_tab = `Click to see "%s" in a new tab`
issues.attachment.download = `Click to download "%s"` issues.attachment.download = `Click to download "%s"`
issues.subscribe = Subscribe
issues.unsubscribe = Unsubscribe


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


+ 14
- 0
routers/repo/issue.go View File

@@ -465,6 +465,20 @@ func ViewIssue(ctx *context.Context) {
} }
ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title) ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, issue.Title)


iw, exists, err := models.GetIssueWatch(ctx.User.ID, issue.ID)
if err != nil {
ctx.Handle(500, "GetIssueWatch", err)
return
}
if !exists {
iw = &models.IssueWatch{
UserID: ctx.User.ID,
IssueID: issue.ID,
IsWatching: models.IsWatching(ctx.User.ID, ctx.Repo.Repository.ID),
}
}
ctx.Data["IssueWatch"] = iw

// Make sure type and URL matches. // Make sure type and URL matches.
if ctx.Params(":type") == "issues" && issue.IsPull { if ctx.Params(":type") == "issues" && issue.IsPull {
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index)) ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))


+ 38
- 0
routers/repo/issue_watch.go View File

@@ -0,0 +1,38 @@
// Copyright 2017 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package repo

import (
"fmt"
"net/http"
"strconv"

"code.gitea.io/gitea/models"
"code.gitea.io/gitea/modules/context"
)

// IssueWatch sets issue watching
func IssueWatch(c *context.Context) {
watch, err := strconv.ParseBool(c.Req.PostForm.Get("watch"))
if err != nil {
c.Handle(http.StatusInternalServerError, "watch is not bool", err)
return
}

issueIndex := c.ParamsInt64("index")
issue, err := models.GetIssueByIndex(c.Repo.Repository.ID, issueIndex)
if err != nil {
c.Handle(http.StatusInternalServerError, "GetIssueByIndex", err)
return
}

if err := models.CreateOrUpdateIssueWatch(c.User.ID, issue.ID, watch); err != nil {
c.Handle(http.StatusInternalServerError, "CreateOrUpdateIssueWatch", err)
return
}

url := fmt.Sprintf("%s/issues/%d", c.Repo.RepoLink, issueIndex)
c.Redirect(url, http.StatusSeeOther)
}

+ 21
- 0
templates/repo/issue/view_content/sidebar.tmpl View File

@@ -98,5 +98,26 @@
{{end}} {{end}}
</div> </div>
</div> </div>

<div class="ui divider"></div>

<div class="ui watching">
<span class="text"><strong>{{.i18n.Tr "notification.notifications"}}</strong></span>
<div>
<form method="POST" action="{{$.RepoLink}}/issues/{{.Issue.Index}}/watch">
<input type="hidden" name="watch" value="{{if $.IssueWatch.IsWatching}}0{{else}}1{{end}}" />
{{$.CsrfTokenHtml}}
<button class="fluid ui button">
{{if $.IssueWatch.IsWatching}}
<i class="octicon octicon-mute"></i>
{{.i18n.Tr "repo.issues.unsubscribe"}}
{{else}}
<i class="octicon octicon-unmute"></i>
{{.i18n.Tr "repo.issues.subscribe"}}
{{end}}
</button>
</form>
</div>
</div>
</div> </div>
</div> </div>

Loading…
Cancel
Save