mirror of
				https://gitee.com/gitea/gitea
				synced 2025-11-04 08:30:25 +08:00 
			
		
		
		
	Merge pull request #1410 from andreynering/notification/issue-watch
[Notifications Step 6] Per issue/PR watch/unwatch
This commit is contained in:
		@@ -491,6 +491,7 @@ func runWeb(ctx *cli.Context) error {
 | 
			
		||||
			m.Group("/:index", func() {
 | 
			
		||||
				m.Post("/title", repo.UpdateIssueTitle)
 | 
			
		||||
				m.Post("/content", repo.UpdateIssueContent)
 | 
			
		||||
				m.Post("/watch", repo.IssueWatch)
 | 
			
		||||
				m.Combo("/comments").Post(bindIgnErr(auth.CreateCommentForm{}), repo.NewComment)
 | 
			
		||||
			})
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								models/fixtures/issue_watch.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								models/fixtures/issue_watch.yml
									
									
									
									
									
										Normal 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
									
								
								models/issue_watch.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								models/issue_watch.go
									
									
									
									
									
										Normal 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
									
								
								models/issue_watch_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								models/issue_watch_test.go
									
									
									
									
									
										Normal 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))
 | 
			
		||||
}
 | 
			
		||||
@@ -117,6 +117,7 @@ func init() {
 | 
			
		||||
		new(ExternalLoginUser),
 | 
			
		||||
		new(ProtectedBranch),
 | 
			
		||||
		new(UserOpenID),
 | 
			
		||||
		new(IssueWatch),
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	gonicNames := []string{"SSL", "UID"}
 | 
			
		||||
 
 | 
			
		||||
@@ -96,6 +96,11 @@ func CreateOrUpdateIssueNotifications(issue *Issue, notificationAuthorID int64)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
@@ -106,23 +111,42 @@ func createOrUpdateIssueNotifications(e Engine, issue *Issue, notificationAuthor
 | 
			
		||||
		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
 | 
			
		||||
		if watch.UserID == notificationAuthorID {
 | 
			
		||||
		if userID == notificationAuthorID {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if _, ok := alreadyNotified[userID]; ok {
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
		alreadyNotified[userID] = struct{}{}
 | 
			
		||||
 | 
			
		||||
		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 notificationExists(notifications, issue.ID, watch.UserID) {
 | 
			
		||||
			err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID)
 | 
			
		||||
		} else {
 | 
			
		||||
			err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err != nil {
 | 
			
		||||
		if err := notifyUser(issueWatch.UserID); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, watch := range watches {
 | 
			
		||||
		if err := notifyUser(watch.UserID); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -652,6 +652,8 @@ issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically
 | 
			
		||||
issues.num_participants = %d Participants
 | 
			
		||||
issues.attachment.open_tab = `Click to see "%s" in a new tab`
 | 
			
		||||
issues.attachment.download = `Click to download "%s"`
 | 
			
		||||
issues.subscribe = Subscribe
 | 
			
		||||
issues.unsubscribe = Unsubscribe
 | 
			
		||||
 | 
			
		||||
pulls.new = New Pull Request
 | 
			
		||||
pulls.compare_changes = Compare Changes
 | 
			
		||||
 
 | 
			
		||||
@@ -465,6 +465,20 @@ func ViewIssue(ctx *context.Context) {
 | 
			
		||||
	}
 | 
			
		||||
	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.
 | 
			
		||||
	if ctx.Params(":type") == "issues" && issue.IsPull {
 | 
			
		||||
		ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + com.ToStr(issue.Index))
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										38
									
								
								routers/repo/issue_watch.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								routers/repo/issue_watch.go
									
									
									
									
									
										Normal 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)
 | 
			
		||||
}
 | 
			
		||||
@@ -98,5 +98,26 @@
 | 
			
		||||
				{{end}}
 | 
			
		||||
			</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>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user