mirror of
				https://github.com/go-gitea/gitea
				synced 2025-10-30 19:08:37 +00: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.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
									
								
								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(ExternalLoginUser), | ||||||
| 		new(ProtectedBranch), | 		new(ProtectedBranch), | ||||||
| 		new(UserOpenID), | 		new(UserOpenID), | ||||||
|  | 		new(IssueWatch), | ||||||
| 	) | 	) | ||||||
|  |  | ||||||
| 	gonicNames := []string{"SSL", "UID"} | 	gonicNames := []string{"SSL", "UID"} | ||||||
|   | |||||||
| @@ -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 { | 		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 | 			continue | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if notificationExists(notifications, issue.ID, watch.UserID) { | 		if err := notifyUser(issueWatch.UserID); err != nil { | ||||||
| 			err = updateIssueNotification(e, watch.UserID, issue.ID, notificationAuthorID) |  | ||||||
| 		} else { |  | ||||||
| 			err = createIssueNotification(e, watch.UserID, issue, notificationAuthorID) |  | ||||||
| 		} |  | ||||||
|  |  | ||||||
| 		if err != nil { |  | ||||||
| 			return err | 			return err | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	for _, watch := range watches { | ||||||
|  | 		if err := notifyUser(watch.UserID); err != nil { | ||||||
|  | 			return err | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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
									
								
								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}} | 				{{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> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user