* [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 formattags/v1.10.5
@@ -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) | |||
} |
@@ -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 | |||
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 |
@@ -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 { |
@@ -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() |
@@ -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 |
@@ -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) | |||
} | |||
} | |||
@@ -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)"` | |||
} |
@@ -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" | |||
@@ -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) |
@@ -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) | |||
} |
@@ -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 | |||
} |
@@ -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) | |||
} |
@@ -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 { |
@@ -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"` | |||
} |
@@ -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": { |