From 6baa5d7588bcf0e1fee8f4e4d77381b39b973363 Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Thu, 9 Jan 2020 12:56:32 +0100
Subject: [PATCH] [API] Add notification endpoint (#9488)

* [API] Add notification endpoints

 * add func GetNotifications(opts FindNotificationOptions)
 * add func (n *Notification) APIFormat()
 * add func (nl NotificationList) APIFormat()
 * add func (n *Notification) APIURL()
 * add func (nl NotificationList) APIFormat()
 * add LoadAttributes functions (loadRepo, loadIssue, loadComment, loadUser)
 * add func (c *Comment) APIURL()
 * add func (issue *Issue) GetLastComment()
 * add endpoint GET /notifications
 * add endpoint PUT /notifications
 * add endpoint GET /repos/{owner}/{repo}/notifications
 * add endpoint PUT /repos/{owner}/{repo}/notifications
 * add endpoint GET /notifications/threads/{id}
 * add endpoint PATCH /notifications/threads/{id}

* Add TEST

* code format

* code format
---
 integrations/api_notification_test.go | 106 +++++++++
 models/fixtures/notification.yml      |  29 ++-
 models/issue.go                       |  14 ++
 models/issue_comment.go               |  17 ++
 models/notification.go                | 218 ++++++++++++++++--
 models/notification_test.go           |   6 +-
 modules/structs/notifications.go      |  28 +++
 routers/api/v1/admin/user.go          |   4 +-
 routers/api/v1/api.go                 |  14 ++
 routers/api/v1/notify/repo.go         | 151 +++++++++++++
 routers/api/v1/notify/threads.go      | 101 +++++++++
 routers/api/v1/notify/user.go         | 129 +++++++++++
 routers/api/v1/repo/pull.go           |   2 +
 routers/api/v1/swagger/notify.go      |  23 ++
 templates/swagger/v1_json.tmpl        | 310 ++++++++++++++++++++++++++
 15 files changed, 1124 insertions(+), 28 deletions(-)
 create mode 100644 integrations/api_notification_test.go
 create mode 100644 modules/structs/notifications.go
 create mode 100644 routers/api/v1/notify/repo.go
 create mode 100644 routers/api/v1/notify/threads.go
 create mode 100644 routers/api/v1/notify/user.go
 create mode 100644 routers/api/v1/swagger/notify.go

diff --git a/integrations/api_notification_test.go b/integrations/api_notification_test.go
new file mode 100644
index 000000000..2c5477dfb
--- /dev/null
+++ b/integrations/api_notification_test.go
@@ -0,0 +1,106 @@
+// Copyright 2020 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 integrations
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"code.gitea.io/gitea/models"
+	api "code.gitea.io/gitea/modules/structs"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAPINotification(t *testing.T) {
+	defer prepareTestEnv(t)()
+
+	user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User)
+	repo1 := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository)
+	thread5 := models.AssertExistsAndLoadBean(t, &models.Notification{ID: 5}).(*models.Notification)
+	assert.NoError(t, thread5.LoadAttributes())
+	session := loginUser(t, user2.Name)
+	token := getTokenForLoggedInUser(t, session)
+
+	// -- GET /notifications --
+	// test filter
+	since := "2000-01-01T00%3A50%3A01%2B00%3A00" //946687801
+	req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?since=%s&token=%s", since, token))
+	resp := session.MakeRequest(t, req, http.StatusOK)
+	var apiNL []api.NotificationThread
+	DecodeJSON(t, resp, &apiNL)
+
+	assert.Len(t, apiNL, 1)
+	assert.EqualValues(t, 5, apiNL[0].ID)
+
+	// test filter
+	before := "2000-01-01T01%3A06%3A59%2B00%3A00" //946688819
+
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?all=%s&before=%s&token=%s", "true", before, token))
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiNL)
+
+	assert.Len(t, apiNL, 3)
+	assert.EqualValues(t, 4, apiNL[0].ID)
+	assert.EqualValues(t, true, apiNL[0].Unread)
+	assert.EqualValues(t, false, apiNL[0].Pinned)
+	assert.EqualValues(t, 3, apiNL[1].ID)
+	assert.EqualValues(t, false, apiNL[1].Unread)
+	assert.EqualValues(t, true, apiNL[1].Pinned)
+	assert.EqualValues(t, 2, apiNL[2].ID)
+	assert.EqualValues(t, false, apiNL[2].Unread)
+	assert.EqualValues(t, false, apiNL[2].Pinned)
+
+	// -- GET /repos/{owner}/{repo}/notifications --
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?token=%s", user2.Name, repo1.Name, token))
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiNL)
+
+	assert.Len(t, apiNL, 1)
+	assert.EqualValues(t, 4, apiNL[0].ID)
+
+	// -- GET /notifications/threads/{id} --
+	// get forbidden
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d?token=%s", 1, token))
+	resp = session.MakeRequest(t, req, http.StatusForbidden)
+
+	// get own
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d?token=%s", thread5.ID, token))
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	var apiN api.NotificationThread
+	DecodeJSON(t, resp, &apiN)
+
+	assert.EqualValues(t, 5, apiN.ID)
+	assert.EqualValues(t, false, apiN.Pinned)
+	assert.EqualValues(t, true, apiN.Unread)
+	assert.EqualValues(t, "issue4", apiN.Subject.Title)
+	assert.EqualValues(t, "Issue", apiN.Subject.Type)
+	assert.EqualValues(t, thread5.Issue.APIURL(), apiN.Subject.URL)
+	assert.EqualValues(t, thread5.Repository.HTMLURL(), apiN.Repository.HTMLURL)
+
+	// -- mark notifications as read --
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token))
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiNL)
+	assert.Len(t, apiNL, 2)
+
+	lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" //946687801 <- only Notification 4 is in this filter ...
+	req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s&token=%s", user2.Name, repo1.Name, lastReadAt, token))
+	resp = session.MakeRequest(t, req, http.StatusResetContent)
+
+	req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?token=%s", token))
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiNL)
+	assert.Len(t, apiNL, 1)
+
+	// -- PATCH /notifications/threads/{id} --
+	req = NewRequest(t, "PATCH", fmt.Sprintf("/api/v1/notifications/threads/%d?token=%s", thread5.ID, token))
+	resp = session.MakeRequest(t, req, http.StatusResetContent)
+
+	assert.Equal(t, models.NotificationStatusUnread, thread5.Status)
+	thread5 = models.AssertExistsAndLoadBean(t, &models.Notification{ID: 5}).(*models.Notification)
+	assert.Equal(t, models.NotificationStatusRead, thread5.Status)
+}
diff --git a/models/fixtures/notification.yml b/models/fixtures/notification.yml
index fe5c47287..bd279d4bb 100644
--- a/models/fixtures/notification.yml
+++ b/models/fixtures/notification.yml
@@ -7,7 +7,7 @@
   updated_by: 2
   issue_id: 1
   created_unix: 946684800
-  updated_unix: 946684800
+  updated_unix: 946684820
 
 -
   id: 2
@@ -17,8 +17,8 @@
   source: 1 # issue
   updated_by: 1
   issue_id: 2
-  created_unix: 946684800
-  updated_unix: 946684800
+  created_unix: 946685800
+  updated_unix: 946685820
 
 -
   id: 3
@@ -27,9 +27,9 @@
   status: 3 # pinned
   source: 1 # issue
   updated_by: 1
-  issue_id: 2
-  created_unix: 946684800
-  updated_unix: 946684800
+  issue_id: 3
+  created_unix: 946686800
+  updated_unix: 946686800
 
 -
   id: 4
@@ -38,6 +38,17 @@
   status: 1 # unread
   source: 1 # issue
   updated_by: 1
-  issue_id: 2
-  created_unix: 946684800
-  updated_unix: 946684800
\ No newline at end of file
+  issue_id: 5
+  created_unix: 946687800
+  updated_unix: 946687800
+
+-
+  id: 5
+  user_id: 2
+  repo_id: 2
+  status: 1 # unread
+  source: 1 # issue
+  updated_by: 5
+  issue_id: 4
+  created_unix: 946688800
+  updated_unix: 946688820
diff --git a/models/issue.go b/models/issue.go
index 3986aeee1..aeeb70d27 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -843,6 +843,20 @@ func (issue *Issue) GetLastEventLabel() string {
 	return "repo.issues.opened_by"
 }
 
+// GetLastComment return last comment for the current issue.
+func (issue *Issue) GetLastComment() (*Comment, error) {
+	var c Comment
+	exist, err := x.Where("type = ?", CommentTypeComment).
+		And("issue_id = ?", issue.ID).Desc("id").Get(&c)
+	if err != nil {
+		return nil, err
+	}
+	if !exist {
+		return nil, nil
+	}
+	return &c, nil
+}
+
 // GetLastEventLabelFake returns the localization label for the current issue without providing a link in the username.
 func (issue *Issue) GetLastEventLabelFake() string {
 	if issue.IsClosed {
diff --git a/models/issue_comment.go b/models/issue_comment.go
index 3ba679021..9caab1dc4 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -8,6 +8,7 @@ package models
 
 import (
 	"fmt"
+	"path"
 	"strings"
 
 	"code.gitea.io/gitea/modules/git"
@@ -235,6 +236,22 @@ func (c *Comment) HTMLURL() string {
 	return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag())
 }
 
+// APIURL formats a API-string to the issue-comment
+func (c *Comment) APIURL() string {
+	err := c.LoadIssue()
+	if err != nil { // Silently dropping errors :unamused:
+		log.Error("LoadIssue(%d): %v", c.IssueID, err)
+		return ""
+	}
+	err = c.Issue.loadRepo(x)
+	if err != nil { // Silently dropping errors :unamused:
+		log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
+		return ""
+	}
+
+	return c.Issue.Repo.APIURL() + "/" + path.Join("issues/comments", fmt.Sprint(c.ID))
+}
+
 // IssueURL formats a URL-string to the issue
 func (c *Comment) IssueURL() string {
 	err := c.LoadIssue()
diff --git a/models/notification.go b/models/notification.go
index 5c03b4925..8e9bca0dc 100644
--- a/models/notification.go
+++ b/models/notification.go
@@ -6,8 +6,14 @@ package models
 
 import (
 	"fmt"
+	"path"
 
+	"code.gitea.io/gitea/modules/setting"
+	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/builder"
+	"xorm.io/xorm"
 )
 
 type (
@@ -47,17 +53,67 @@ type Notification struct {
 	IssueID   int64  `xorm:"INDEX NOT NULL"`
 	CommitID  string `xorm:"INDEX"`
 	CommentID int64
-	Comment   *Comment `xorm:"-"`
 
 	UpdatedBy int64 `xorm:"INDEX NOT NULL"`
 
 	Issue      *Issue      `xorm:"-"`
 	Repository *Repository `xorm:"-"`
+	Comment    *Comment    `xorm:"-"`
+	User       *User       `xorm:"-"`
 
 	CreatedUnix timeutil.TimeStamp `xorm:"created INDEX NOT NULL"`
 	UpdatedUnix timeutil.TimeStamp `xorm:"updated INDEX NOT NULL"`
 }
 
+// FindNotificationOptions represent the filters for notifications. If an ID is 0 it will be ignored.
+type FindNotificationOptions struct {
+	UserID            int64
+	RepoID            int64
+	IssueID           int64
+	Status            NotificationStatus
+	UpdatedAfterUnix  int64
+	UpdatedBeforeUnix int64
+}
+
+// ToCond will convert each condition into a xorm-Cond
+func (opts *FindNotificationOptions) ToCond() builder.Cond {
+	cond := builder.NewCond()
+	if opts.UserID != 0 {
+		cond = cond.And(builder.Eq{"notification.user_id": opts.UserID})
+	}
+	if opts.RepoID != 0 {
+		cond = cond.And(builder.Eq{"notification.repo_id": opts.RepoID})
+	}
+	if opts.IssueID != 0 {
+		cond = cond.And(builder.Eq{"notification.issue_id": opts.IssueID})
+	}
+	if opts.Status != 0 {
+		cond = cond.And(builder.Eq{"notification.status": opts.Status})
+	}
+	if opts.UpdatedAfterUnix != 0 {
+		cond = cond.And(builder.Gte{"notification.updated_unix": opts.UpdatedAfterUnix})
+	}
+	if opts.UpdatedBeforeUnix != 0 {
+		cond = cond.And(builder.Lte{"notification.updated_unix": opts.UpdatedBeforeUnix})
+	}
+	return cond
+}
+
+// ToSession will convert the given options to a xorm Session by using the conditions from ToCond and joining with issue table if required
+func (opts *FindNotificationOptions) ToSession(e Engine) *xorm.Session {
+	return e.Where(opts.ToCond())
+}
+
+func getNotifications(e Engine, options FindNotificationOptions) (nl NotificationList, err error) {
+	err = options.ToSession(e).OrderBy("notification.updated_unix DESC").Find(&nl)
+	return
+}
+
+// GetNotifications returns all notifications that fit to the given options.
+func GetNotifications(opts FindNotificationOptions) (NotificationList, error) {
+	return getNotifications(x, opts)
+}
+
 // CreateOrUpdateIssueNotifications creates an issue notification
 // for each watcher, or updates it if already exists
 func CreateOrUpdateIssueNotifications(issueID, commentID int64, notificationAuthorID int64) error {
@@ -238,22 +294,124 @@ func notificationsForUser(e Engine, user *User, statuses []NotificationStatus, p
 	return
 }
 
+// APIFormat converts a Notification to api.NotificationThread
+func (n *Notification) APIFormat() *api.NotificationThread {
+	result := &api.NotificationThread{
+		ID:        n.ID,
+		Unread:    !(n.Status == NotificationStatusRead || n.Status == NotificationStatusPinned),
+		Pinned:    n.Status == NotificationStatusPinned,
+		UpdatedAt: n.UpdatedUnix.AsTime(),
+		URL:       n.APIURL(),
+	}
+
+	//since user only get notifications when he has access to use minimal access mode
+	if n.Repository != nil {
+		result.Repository = n.Repository.APIFormat(AccessModeRead)
+	}
+
+	//handle Subject
+	switch n.Source {
+	case NotificationSourceIssue:
+		result.Subject = &api.NotificationSubject{Type: "Issue"}
+		if n.Issue != nil {
+			result.Subject.Title = n.Issue.Title
+			result.Subject.URL = n.Issue.APIURL()
+			comment, err := n.Issue.GetLastComment()
+			if err == nil && comment != nil {
+				result.Subject.LatestCommentURL = comment.APIURL()
+			}
+		}
+	case NotificationSourcePullRequest:
+		result.Subject = &api.NotificationSubject{Type: "Pull"}
+		if n.Issue != nil {
+			result.Subject.Title = n.Issue.Title
+			result.Subject.URL = n.Issue.APIURL()
+			comment, err := n.Issue.GetLastComment()
+			if err == nil && comment != nil {
+				result.Subject.LatestCommentURL = comment.APIURL()
+			}
+		}
+	case NotificationSourceCommit:
+		result.Subject = &api.NotificationSubject{
+			Type:  "Commit",
+			Title: n.CommitID,
+		}
+		//unused until now
+	}
+
+	return result
+}
+
+// LoadAttributes load Repo Issue User and Comment if not loaded
+func (n *Notification) LoadAttributes() (err error) {
+	return n.loadAttributes(x)
+}
+
+func (n *Notification) loadAttributes(e Engine) (err error) {
+	if err = n.loadRepo(e); err != nil {
+		return
+	}
+	if err = n.loadIssue(e); err != nil {
+		return
+	}
+	if err = n.loadUser(e); err != nil {
+		return
+	}
+	if err = n.loadComment(e); err != nil {
+		return
+	}
+	return
+}
+
+func (n *Notification) loadRepo(e Engine) (err error) {
+	if n.Repository == nil {
+		n.Repository, err = getRepositoryByID(e, n.RepoID)
+		if err != nil {
+			return fmt.Errorf("getRepositoryByID [%d]: %v", n.RepoID, err)
+		}
+	}
+	return nil
+}
+
+func (n *Notification) loadIssue(e Engine) (err error) {
+	if n.Issue == nil {
+		n.Issue, err = getIssueByID(e, n.IssueID)
+		if err != nil {
+			return fmt.Errorf("getIssueByID [%d]: %v", n.IssueID, err)
+		}
+		return n.Issue.loadAttributes(e)
+	}
+	return nil
+}
+
+func (n *Notification) loadComment(e Engine) (err error) {
+	if n.Comment == nil && n.CommentID > 0 {
+		n.Comment, err = GetCommentByID(n.CommentID)
+		if err != nil {
+			return fmt.Errorf("GetCommentByID [%d]: %v", n.CommentID, err)
+		}
+	}
+	return nil
+}
+
+func (n *Notification) loadUser(e Engine) (err error) {
+	if n.User == nil {
+		n.User, err = getUserByID(e, n.UserID)
+		if err != nil {
+			return fmt.Errorf("getUserByID [%d]: %v", n.UserID, err)
+		}
+	}
+	return nil
+}
+
 // GetRepo returns the repo of the notification
 func (n *Notification) GetRepo() (*Repository, error) {
-	n.Repository = new(Repository)
-	_, err := x.
-		Where("id = ?", n.RepoID).
-		Get(n.Repository)
-	return n.Repository, err
+	return n.Repository, n.loadRepo(x)
 }
 
 // GetIssue returns the issue of the notification
 func (n *Notification) GetIssue() (*Issue, error) {
-	n.Issue = new(Issue)
-	_, err := x.
-		Where("id = ?", n.IssueID).
-		Get(n.Issue)
-	return n.Issue, err
+	return n.Issue, n.loadIssue(x)
 }
 
 // HTMLURL formats a URL-string to the notification
@@ -264,9 +422,34 @@ func (n *Notification) HTMLURL() string {
 	return n.Issue.HTMLURL()
 }
 
+// APIURL formats a URL-string to the notification
+func (n *Notification) APIURL() string {
+	return setting.AppURL + path.Join("api/v1/notifications/threads", fmt.Sprintf("%d", n.ID))
+}
+
 // NotificationList contains a list of notifications
 type NotificationList []*Notification
 
+// APIFormat converts a NotificationList to api.NotificationThread list
+func (nl NotificationList) APIFormat() []*api.NotificationThread {
+	var result = make([]*api.NotificationThread, 0, len(nl))
+	for _, n := range nl {
+		result = append(result, n.APIFormat())
+	}
+	return result
+}
+
+// LoadAttributes load Repo Issue User and Comment if not loaded
+func (nl NotificationList) LoadAttributes() (err error) {
+	for i := 0; i < len(nl); i++ {
+		err = nl[i].LoadAttributes()
+		if err != nil {
+			return
+		}
+	}
+	return
+}
+
 func (nl NotificationList) getPendingRepoIDs() []int64 {
 	var ids = make(map[int64]struct{}, len(nl))
 	for _, notification := range nl {
@@ -486,7 +669,7 @@ func setNotificationStatusReadIfUnread(e Engine, userID, issueID int64) error {
 
 // SetNotificationStatus change the notification status
 func SetNotificationStatus(notificationID int64, user *User, status NotificationStatus) error {
-	notification, err := getNotificationByID(notificationID)
+	notification, err := getNotificationByID(x, notificationID)
 	if err != nil {
 		return err
 	}
@@ -501,9 +684,14 @@ func SetNotificationStatus(notificationID int64, user *User, status Notification
 	return err
 }
 
-func getNotificationByID(notificationID int64) (*Notification, error) {
+// GetNotificationByID return notification by ID
+func GetNotificationByID(notificationID int64) (*Notification, error) {
+	return getNotificationByID(x, notificationID)
+}
+
+func getNotificationByID(e Engine, notificationID int64) (*Notification, error) {
 	notification := new(Notification)
-	ok, err := x.
+	ok, err := e.
 		Where("id = ?", notificationID).
 		Get(notification)
 
@@ -512,7 +700,7 @@ func getNotificationByID(notificationID int64) (*Notification, error) {
 	}
 
 	if !ok {
-		return nil, fmt.Errorf("Notification %d does not exists", notificationID)
+		return nil, ErrNotExist{ID: notificationID}
 	}
 
 	return notification, nil
diff --git a/models/notification_test.go b/models/notification_test.go
index 728be7182..6485f8dc7 100644
--- a/models/notification_test.go
+++ b/models/notification_test.go
@@ -31,11 +31,13 @@ func TestNotificationsForUser(t *testing.T) {
 	statuses := []NotificationStatus{NotificationStatusRead, NotificationStatusUnread}
 	notfs, err := NotificationsForUser(user, statuses, 1, 10)
 	assert.NoError(t, err)
-	if assert.Len(t, notfs, 2) {
-		assert.EqualValues(t, 2, notfs[0].ID)
+	if assert.Len(t, notfs, 3) {
+		assert.EqualValues(t, 5, notfs[0].ID)
 		assert.EqualValues(t, user.ID, notfs[0].UserID)
 		assert.EqualValues(t, 4, notfs[1].ID)
 		assert.EqualValues(t, user.ID, notfs[1].UserID)
+		assert.EqualValues(t, 2, notfs[2].ID)
+		assert.EqualValues(t, user.ID, notfs[2].UserID)
 	}
 }
 
diff --git a/modules/structs/notifications.go b/modules/structs/notifications.go
new file mode 100644
index 000000000..b1e8b7781
--- /dev/null
+++ b/modules/structs/notifications.go
@@ -0,0 +1,28 @@
+// Copyright 2019 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 structs
+
+import (
+	"time"
+)
+
+// NotificationThread expose Notification on API
+type NotificationThread struct {
+	ID         int64                `json:"id"`
+	Repository *Repository          `json:"repository"`
+	Subject    *NotificationSubject `json:"subject"`
+	Unread     bool                 `json:"unread"`
+	Pinned     bool                 `json:"pinned"`
+	UpdatedAt  time.Time            `json:"updated_at"`
+	URL        string               `json:"url"`
+}
+
+// NotificationSubject contains the notification subject (Issue/Pull/Commit)
+type NotificationSubject struct {
+	Title            string `json:"title"`
+	URL              string `json:"url"`
+	LatestCommentURL string `json:"latest_comment_url"`
+	Type             string `json:"type" binding:"In(Issue,Pull,Commit)"`
+}
diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go
index 7387037d3..ebc651516 100644
--- a/routers/api/v1/admin/user.go
+++ b/routers/api/v1/admin/user.go
@@ -56,10 +56,10 @@ func CreateUser(ctx *context.APIContext, form api.CreateUserOption) {
 	// responses:
 	//   "201":
 	//     "$ref": "#/responses/User"
-	//   "403":
-	//     "$ref": "#/responses/forbidden"
 	//   "400":
 	//     "$ref": "#/responses/error"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
 	//   "422":
 	//     "$ref": "#/responses/validationError"
 
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 9f1895189..ccce00e2b 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -70,6 +70,7 @@ import (
 	api "code.gitea.io/gitea/modules/structs"
 	"code.gitea.io/gitea/routers/api/v1/admin"
 	"code.gitea.io/gitea/routers/api/v1/misc"
+	"code.gitea.io/gitea/routers/api/v1/notify"
 	"code.gitea.io/gitea/routers/api/v1/org"
 	"code.gitea.io/gitea/routers/api/v1/repo"
 	_ "code.gitea.io/gitea/routers/api/v1/swagger" // for swagger generation
@@ -512,6 +513,16 @@ func RegisterRoutes(m *macaron.Macaron) {
 		m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)
 		m.Post("/markdown/raw", misc.MarkdownRaw)
 
+		// Notifications
+		m.Group("/notifications", func() {
+			m.Combo("").
+				Get(notify.ListNotifications).
+				Put(notify.ReadNotifications)
+			m.Combo("/threads/:id").
+				Get(notify.GetThread).
+				Patch(notify.ReadThread)
+		}, reqToken())
+
 		// Users
 		m.Group("/users", func() {
 			m.Get("/search", user.Search)
@@ -610,6 +621,9 @@ func RegisterRoutes(m *macaron.Macaron) {
 				m.Combo("").Get(reqAnyRepoReader(), repo.Get).
 					Delete(reqToken(), reqOwner(), repo.Delete).
 					Patch(reqToken(), reqAdmin(), bind(api.EditRepoOption{}), context.RepoRef(), repo.Edit)
+				m.Combo("/notifications").
+					Get(reqToken(), notify.ListRepoNotifications).
+					Put(reqToken(), notify.ReadRepoNotifications)
 				m.Group("/hooks", func() {
 					m.Combo("").Get(repo.ListHooks).
 						Post(bind(api.CreateHookOption{}), repo.CreateHook)
diff --git a/routers/api/v1/notify/repo.go b/routers/api/v1/notify/repo.go
new file mode 100644
index 000000000..b939d90f0
--- /dev/null
+++ b/routers/api/v1/notify/repo.go
@@ -0,0 +1,151 @@
+// Copyright 2020 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 notify
+
+import (
+	"net/http"
+	"strings"
+	"time"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/routers/api/v1/utils"
+)
+
+// ListRepoNotifications list users's notification threads on a specific repo
+func ListRepoNotifications(ctx *context.APIContext) {
+	// swagger:operation GET /repos/{owner}/{repo}/notifications notification notifyGetRepoList
+	// ---
+	// summary: List users's notification threads on a specific repo
+	// consumes:
+	// - application/json
+	// 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: all
+	//   in: query
+	//   description: If true, show notifications marked as read. Default value is false
+	//   type: string
+	//   required: false
+	// - name: since
+	//   in: query
+	//   description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// - name: before
+	//   in: query
+	//   description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/NotificationThreadList"
+
+	before, since, err := utils.GetQueryBeforeSince(ctx)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	opts := models.FindNotificationOptions{
+		UserID:            ctx.User.ID,
+		RepoID:            ctx.Repo.Repository.ID,
+		UpdatedBeforeUnix: before,
+		UpdatedAfterUnix:  since,
+	}
+	qAll := strings.Trim(ctx.Query("all"), " ")
+	if qAll != "true" {
+		opts.Status = models.NotificationStatusUnread
+	}
+	nl, err := models.GetNotifications(opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	err = nl.LoadAttributes()
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, nl.APIFormat())
+}
+
+// ReadRepoNotifications mark notification threads as read on a specific repo
+func ReadRepoNotifications(ctx *context.APIContext) {
+	// swagger:operation PUT /repos/{owner}/{repo}/notifications notification notifyReadRepoList
+	// ---
+	// summary: Mark notification threads as read on a specific repo
+	// consumes:
+	// - application/json
+	// 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: last_read_at
+	//   in: query
+	//   description: Describes the last point that notifications were checked. Anything updated since this time will not be updated.
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// responses:
+	//   "205":
+	//     "$ref": "#/responses/empty"
+
+	lastRead := int64(0)
+	qLastRead := strings.Trim(ctx.Query("last_read_at"), " ")
+	if len(qLastRead) > 0 {
+		tmpLastRead, err := time.Parse(time.RFC3339, qLastRead)
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+		if !tmpLastRead.IsZero() {
+			lastRead = tmpLastRead.Unix()
+		}
+	}
+	opts := models.FindNotificationOptions{
+		UserID:            ctx.User.ID,
+		RepoID:            ctx.Repo.Repository.ID,
+		UpdatedBeforeUnix: lastRead,
+		Status:            models.NotificationStatusUnread,
+	}
+	nl, err := models.GetNotifications(opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	for _, n := range nl {
+		err := models.SetNotificationStatus(n.ID, ctx.User, models.NotificationStatusRead)
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+		ctx.Status(http.StatusResetContent)
+	}
+
+	ctx.Status(http.StatusResetContent)
+}
diff --git a/routers/api/v1/notify/threads.go b/routers/api/v1/notify/threads.go
new file mode 100644
index 000000000..d0119e993
--- /dev/null
+++ b/routers/api/v1/notify/threads.go
@@ -0,0 +1,101 @@
+// Copyright 2020 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 notify
+
+import (
+	"fmt"
+	"net/http"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/context"
+)
+
+// GetThread get notification by ID
+func GetThread(ctx *context.APIContext) {
+	// swagger:operation GET /notifications/threads/{id} notification notifyGetThread
+	// ---
+	// summary: Get notification thread by ID
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: id
+	//   in: path
+	//   description: id of notification thread
+	//   type: string
+	//   required: true
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/NotificationThread"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	n := getThread(ctx)
+	if n == nil {
+		return
+	}
+	if err := n.LoadAttributes(); err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, n.APIFormat())
+}
+
+// ReadThread mark notification as read by ID
+func ReadThread(ctx *context.APIContext) {
+	// swagger:operation PATCH /notifications/threads/{id} notification notifyReadThread
+	// ---
+	// summary: Mark notification thread as read by ID
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: id
+	//   in: path
+	//   description: id of notification thread
+	//   type: string
+	//   required: true
+	// responses:
+	//   "205":
+	//     "$ref": "#/responses/empty"
+	//   "403":
+	//     "$ref": "#/responses/forbidden"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
+
+	n := getThread(ctx)
+	if n == nil {
+		return
+	}
+
+	err := models.SetNotificationStatus(n.ID, ctx.User, models.NotificationStatusRead)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	ctx.Status(http.StatusResetContent)
+}
+
+func getThread(ctx *context.APIContext) *models.Notification {
+	n, err := models.GetNotificationByID(ctx.ParamsInt64(":id"))
+	if err != nil {
+		if models.IsErrNotExist(err) {
+			ctx.Error(http.StatusNotFound, "GetNotificationByID", err)
+		} else {
+			ctx.InternalServerError(err)
+		}
+		return nil
+	}
+	if n.UserID != ctx.User.ID && !ctx.User.IsAdmin {
+		ctx.Error(http.StatusForbidden, "GetNotificationByID", fmt.Errorf("only user itself and admin are allowed to read/change this thread %d", n.ID))
+		return nil
+	}
+	return n
+}
diff --git a/routers/api/v1/notify/user.go b/routers/api/v1/notify/user.go
new file mode 100644
index 000000000..d16e4da0e
--- /dev/null
+++ b/routers/api/v1/notify/user.go
@@ -0,0 +1,129 @@
+// Copyright 2020 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 notify
+
+import (
+	"net/http"
+	"strings"
+	"time"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/routers/api/v1/utils"
+)
+
+// ListNotifications list users's notification threads
+func ListNotifications(ctx *context.APIContext) {
+	// swagger:operation GET /notifications notification notifyGetList
+	// ---
+	// summary: List users's notification threads
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: all
+	//   in: query
+	//   description: If true, show notifications marked as read. Default value is false
+	//   type: string
+	//   required: false
+	// - name: since
+	//   in: query
+	//   description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// - name: before
+	//   in: query
+	//   description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// responses:
+	//   "200":
+	//     "$ref": "#/responses/NotificationThreadList"
+
+	before, since, err := utils.GetQueryBeforeSince(ctx)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	opts := models.FindNotificationOptions{
+		UserID:            ctx.User.ID,
+		UpdatedBeforeUnix: before,
+		UpdatedAfterUnix:  since,
+	}
+	qAll := strings.Trim(ctx.Query("all"), " ")
+	if qAll != "true" {
+		opts.Status = models.NotificationStatusUnread
+	}
+	nl, err := models.GetNotifications(opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+	err = nl.LoadAttributes()
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	ctx.JSON(http.StatusOK, nl.APIFormat())
+}
+
+// ReadNotifications mark notification threads as read
+func ReadNotifications(ctx *context.APIContext) {
+	// swagger:operation PUT /notifications notification notifyReadList
+	// ---
+	// summary: Mark notification threads as read
+	// consumes:
+	// - application/json
+	// produces:
+	// - application/json
+	// parameters:
+	// - name: last_read_at
+	//   in: query
+	//   description: Describes the last point that notifications were checked. Anything updated since this time will not be updated.
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// responses:
+	//   "205":
+	//     "$ref": "#/responses/empty"
+
+	lastRead := int64(0)
+	qLastRead := strings.Trim(ctx.Query("last_read_at"), " ")
+	if len(qLastRead) > 0 {
+		tmpLastRead, err := time.Parse(time.RFC3339, qLastRead)
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+		if !tmpLastRead.IsZero() {
+			lastRead = tmpLastRead.Unix()
+		}
+	}
+	opts := models.FindNotificationOptions{
+		UserID:            ctx.User.ID,
+		UpdatedBeforeUnix: lastRead,
+		Status:            models.NotificationStatusUnread,
+	}
+	nl, err := models.GetNotifications(opts)
+	if err != nil {
+		ctx.InternalServerError(err)
+		return
+	}
+
+	for _, n := range nl {
+		err := models.SetNotificationStatus(n.ID, ctx.User, models.NotificationStatusRead)
+		if err != nil {
+			ctx.InternalServerError(err)
+			return
+		}
+		ctx.Status(http.StatusResetContent)
+	}
+
+	ctx.Status(http.StatusResetContent)
+}
diff --git a/routers/api/v1/repo/pull.go b/routers/api/v1/repo/pull.go
index d0551320f..85ef41978 100644
--- a/routers/api/v1/repo/pull.go
+++ b/routers/api/v1/repo/pull.go
@@ -136,6 +136,8 @@ func GetPullRequest(ctx *context.APIContext) {
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/PullRequest"
+	//   "404":
+	//     "$ref": "#/responses/notFound"
 
 	pr, err := models.GetPullRequestByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index"))
 	if err != nil {
diff --git a/routers/api/v1/swagger/notify.go b/routers/api/v1/swagger/notify.go
new file mode 100644
index 000000000..7d45da0e1
--- /dev/null
+++ b/routers/api/v1/swagger/notify.go
@@ -0,0 +1,23 @@
+// Copyright 2019 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 swagger
+
+import (
+	api "code.gitea.io/gitea/modules/structs"
+)
+
+// NotificationThread
+// swagger:response NotificationThread
+type swaggerNotificationThread struct {
+	// in:body
+	Body api.NotificationThread `json:"body"`
+}
+
+// NotificationThreadList
+// swagger:response NotificationThreadList
+type swaggerNotificationThreadList struct {
+	// in:body
+	Body []api.NotificationThread `json:"body"`
+}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 4e37f65d1..79f760b7a 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -425,6 +425,143 @@
         }
       }
     },
+    "/notifications": {
+      "get": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "notification"
+        ],
+        "summary": "List users's notification threads",
+        "operationId": "notifyGetList",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "If true, show notifications marked as read. Default value is false",
+            "name": "all",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format",
+            "name": "since",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format",
+            "name": "before",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/NotificationThreadList"
+          }
+        }
+      },
+      "put": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "notification"
+        ],
+        "summary": "Mark notification threads as read",
+        "operationId": "notifyReadList",
+        "parameters": [
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Describes the last point that notifications were checked. Anything updated since this time will not be updated.",
+            "name": "last_read_at",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "205": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      }
+    },
+    "/notifications/threads/{id}": {
+      "get": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "notification"
+        ],
+        "summary": "Get notification thread by ID",
+        "operationId": "notifyGetThread",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "id of notification thread",
+            "name": "id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/NotificationThread"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      },
+      "patch": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "notification"
+        ],
+        "summary": "Mark notification thread as read by ID",
+        "operationId": "notifyReadThread",
+        "parameters": [
+          {
+            "type": "string",
+            "description": "id of notification thread",
+            "name": "id",
+            "in": "path",
+            "required": true
+          }
+        ],
+        "responses": {
+          "205": {
+            "$ref": "#/responses/empty"
+          },
+          "403": {
+            "$ref": "#/responses/forbidden"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
+          }
+        }
+      }
+    },
     "/org/{org}/repos": {
       "post": {
         "consumes": [
@@ -5231,6 +5368,103 @@
         }
       }
     },
+    "/repos/{owner}/{repo}/notifications": {
+      "get": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "notification"
+        ],
+        "summary": "List users's notification threads on a specific repo",
+        "operationId": "notifyGetRepoList",
+        "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
+          },
+          {
+            "type": "string",
+            "description": "If true, show notifications marked as read. Default value is false",
+            "name": "all",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format",
+            "name": "since",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format",
+            "name": "before",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "200": {
+            "$ref": "#/responses/NotificationThreadList"
+          }
+        }
+      },
+      "put": {
+        "consumes": [
+          "application/json"
+        ],
+        "produces": [
+          "application/json"
+        ],
+        "tags": [
+          "notification"
+        ],
+        "summary": "Mark notification threads as read on a specific repo",
+        "operationId": "notifyReadRepoList",
+        "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
+          },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Describes the last point that notifications were checked. Anything updated since this time will not be updated.",
+            "name": "last_read_at",
+            "in": "query"
+          }
+        ],
+        "responses": {
+          "205": {
+            "$ref": "#/responses/empty"
+          }
+        }
+      }
+    },
     "/repos/{owner}/{repo}/pulls": {
       "get": {
         "produces": [
@@ -5397,6 +5631,9 @@
         "responses": {
           "200": {
             "$ref": "#/responses/PullRequest"
+          },
+          "404": {
+            "$ref": "#/responses/notFound"
           }
         }
       },
@@ -10584,6 +10821,64 @@
       },
       "x-go-package": "code.gitea.io/gitea/modules/structs"
     },
+    "NotificationSubject": {
+      "description": "NotificationSubject contains the notification subject (Issue/Pull/Commit)",
+      "type": "object",
+      "properties": {
+        "latest_comment_url": {
+          "type": "string",
+          "x-go-name": "LatestCommentURL"
+        },
+        "title": {
+          "type": "string",
+          "x-go-name": "Title"
+        },
+        "type": {
+          "type": "string",
+          "x-go-name": "Type"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
+    "NotificationThread": {
+      "description": "NotificationThread expose Notification on API",
+      "type": "object",
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "int64",
+          "x-go-name": "ID"
+        },
+        "pinned": {
+          "type": "boolean",
+          "x-go-name": "Pinned"
+        },
+        "repository": {
+          "$ref": "#/definitions/Repository"
+        },
+        "subject": {
+          "$ref": "#/definitions/NotificationSubject"
+        },
+        "unread": {
+          "type": "boolean",
+          "x-go-name": "Unread"
+        },
+        "updated_at": {
+          "type": "string",
+          "format": "date-time",
+          "x-go-name": "UpdatedAt"
+        },
+        "url": {
+          "type": "string",
+          "x-go-name": "URL"
+        }
+      },
+      "x-go-package": "code.gitea.io/gitea/modules/structs"
+    },
     "Organization": {
       "description": "Organization represents an organization",
       "type": "object",
@@ -12012,6 +12307,21 @@
         }
       }
     },
+    "NotificationThread": {
+      "description": "NotificationThread",
+      "schema": {
+        "$ref": "#/definitions/NotificationThread"
+      }
+    },
+    "NotificationThreadList": {
+      "description": "NotificationThreadList",
+      "schema": {
+        "type": "array",
+        "items": {
+          "$ref": "#/definitions/NotificationThread"
+        }
+      }
+    },
     "Organization": {
       "description": "Organization",
       "schema": {