diff --git a/integrations/api_issue_reaction_test.go b/integrations/api_issue_reaction_test.go new file mode 100644 index 000000000..f3fcf3946 --- /dev/null +++ b/integrations/api_issue_reaction_test.go @@ -0,0 +1,145 @@ +// 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 integrations + +import ( + "fmt" + "net/http" + "testing" + "time" + + "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" + + "github.com/stretchr/testify/assert" +) + +func TestAPIIssuesReactions(t *testing.T) { + defer prepareTestEnv(t)() + + issue := models.AssertExistsAndLoadBean(t, &models.Issue{ID: 1}).(*models.Issue) + _ = issue.LoadRepo() + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session) + + user1 := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions?token=%s", + owner.Name, issue.Repo.Name, issue.Index, token) + + //Try to add not allowed reaction + req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "wrong", + }) + resp := session.MakeRequest(t, req, http.StatusForbidden) + + //Delete not allowed reaction + req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ + Reaction: "zzz", + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + //Add allowed reaction + req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "rocket", + }) + resp = session.MakeRequest(t, req, http.StatusCreated) + var apiNewReaction api.ReactionResponse + DecodeJSON(t, resp, &apiNewReaction) + + //Add existing reaction + resp = session.MakeRequest(t, req, http.StatusForbidden) + + //Get end result of reaction list of issue #1 + req = NewRequestf(t, "GET", urlStr) + resp = session.MakeRequest(t, req, http.StatusOK) + var apiReactions []*api.ReactionResponse + DecodeJSON(t, resp, &apiReactions) + expectResponse := make(map[int]api.ReactionResponse) + expectResponse[0] = api.ReactionResponse{ + User: user1.APIFormat(), + Reaction: "zzz", + Created: time.Unix(1573248002, 0), + } + expectResponse[1] = api.ReactionResponse{ + User: user2.APIFormat(), + Reaction: "eyes", + Created: time.Unix(1573248003, 0), + } + expectResponse[2] = apiNewReaction + assert.Len(t, apiReactions, 3) + for i, r := range apiReactions { + assert.Equal(t, expectResponse[i].Reaction, r.Reaction) + assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix()) + assert.Equal(t, expectResponse[i].User.ID, r.User.ID) + } +} + +func TestAPICommentReactions(t *testing.T) { + defer prepareTestEnv(t)() + + comment := models.AssertExistsAndLoadBean(t, &models.Comment{ID: 2}).(*models.Comment) + _ = comment.LoadIssue() + issue := comment.Issue + _ = issue.LoadRepo() + owner := models.AssertExistsAndLoadBean(t, &models.User{ID: issue.Repo.OwnerID}).(*models.User) + + session := loginUser(t, owner.Name) + token := getTokenForLoggedInUser(t, session) + + user1 := models.AssertExistsAndLoadBean(t, &models.User{ID: 1}).(*models.User) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions?token=%s", + owner.Name, issue.Repo.Name, comment.ID, token) + + //Try to add not allowed reaction + req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "wrong", + }) + resp := session.MakeRequest(t, req, http.StatusForbidden) + + //Delete none existing reaction + req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{ + Reaction: "eyes", + }) + resp = session.MakeRequest(t, req, http.StatusOK) + + //Add allowed reaction + req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{ + Reaction: "+1", + }) + resp = session.MakeRequest(t, req, http.StatusCreated) + var apiNewReaction api.ReactionResponse + DecodeJSON(t, resp, &apiNewReaction) + + //Add existing reaction + resp = session.MakeRequest(t, req, http.StatusForbidden) + + //Get end result of reaction list of issue #1 + req = NewRequestf(t, "GET", urlStr) + resp = session.MakeRequest(t, req, http.StatusOK) + var apiReactions []*api.ReactionResponse + DecodeJSON(t, resp, &apiReactions) + expectResponse := make(map[int]api.ReactionResponse) + expectResponse[0] = api.ReactionResponse{ + User: user2.APIFormat(), + Reaction: "laugh", + Created: time.Unix(1573248004, 0), + } + expectResponse[1] = api.ReactionResponse{ + User: user1.APIFormat(), + Reaction: "laugh", + Created: time.Unix(1573248005, 0), + } + expectResponse[2] = apiNewReaction + assert.Len(t, apiReactions, 3) + for i, r := range apiReactions { + assert.Equal(t, expectResponse[i].Reaction, r.Reaction) + assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix()) + assert.Equal(t, expectResponse[i].User.ID, r.User.ID) + } +} diff --git a/models/error.go b/models/error.go index 313c36354..16be51213 100644 --- a/models/error.go +++ b/models/error.go @@ -1121,6 +1121,21 @@ func (err ErrNewIssueInsert) Error() string { return err.OriginalError.Error() } +// ErrForbiddenIssueReaction is used when a forbidden reaction was try to created +type ErrForbiddenIssueReaction struct { + Reaction string +} + +// IsErrForbiddenIssueReaction checks if an error is a ErrForbiddenIssueReaction. +func IsErrForbiddenIssueReaction(err error) bool { + _, ok := err.(ErrForbiddenIssueReaction) + return ok +} + +func (err ErrForbiddenIssueReaction) Error() string { + return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction) +} + // __________ .__ .__ __________ __ // \______ \__ __| | | |\______ \ ____ ________ __ ____ _______/ |_ // | ___/ | \ | | | | _// __ \/ ____/ | \_/ __ \ / ___/\ __\ diff --git a/models/fixtures/reaction.yml b/models/fixtures/reaction.yml index ca780a73a..4925935fe 100644 --- a/models/fixtures/reaction.yml +++ b/models/fixtures/reaction.yml @@ -1 +1,39 @@ -[] # empty +- + id: 1 #issue reaction + type: zzz # not allowed reaction (added before allowed reaction list has changed) + issue_id: 1 + comment_id: 0 + user_id: 2 + created_unix: 1573248001 + +- + id: 2 #issue reaction + type: zzz # not allowed reaction (added before allowed reaction list has changed) + issue_id: 1 + comment_id: 0 + user_id: 1 + created_unix: 1573248002 + +- + id: 3 #issue reaction + type: eyes # allowed reaction + issue_id: 1 + comment_id: 0 + user_id: 2 + created_unix: 1573248003 + +- + id: 4 #comment reaction + type: laugh # allowed reaction + issue_id: 1 + comment_id: 2 + user_id: 2 + created_unix: 1573248004 + +- + id: 5 #comment reaction + type: laugh # allowed reaction + issue_id: 1 + comment_id: 2 + user_id: 1 + created_unix: 1573248005 diff --git a/models/issue_reaction.go b/models/issue_reaction.go index 4596d32d0..b4f332a08 100644 --- a/models/issue_reaction.go +++ b/models/issue_reaction.go @@ -33,16 +33,38 @@ type FindReactionsOptions struct { } func (opts *FindReactionsOptions) toConds() builder.Cond { + //If Issue ID is set add to Query var cond = builder.NewCond() if opts.IssueID > 0 { cond = cond.And(builder.Eq{"reaction.issue_id": opts.IssueID}) } + //If CommentID is > 0 add to Query + //If it is 0 Query ignore CommentID to select + //If it is -1 it explicit search of Issue Reactions where CommentID = 0 if opts.CommentID > 0 { cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID}) + } else if opts.CommentID == -1 { + cond = cond.And(builder.Eq{"reaction.comment_id": 0}) } + return cond } +// FindCommentReactions returns a ReactionList of all reactions from an comment +func FindCommentReactions(comment *Comment) (ReactionList, error) { + return findReactions(x, FindReactionsOptions{ + IssueID: comment.IssueID, + CommentID: comment.ID}) +} + +// FindIssueReactions returns a ReactionList of all reactions from an issue +func FindIssueReactions(issue *Issue) (ReactionList, error) { + return findReactions(x, FindReactionsOptions{ + IssueID: issue.ID, + CommentID: -1, + }) +} + func findReactions(e Engine, opts FindReactionsOptions) ([]*Reaction, error) { reactions := make([]*Reaction, 0, 10) sess := e.Where(opts.toConds()) @@ -77,6 +99,10 @@ type ReactionOptions struct { // CreateReaction creates reaction for issue or comment. func CreateReaction(opts *ReactionOptions) (reaction *Reaction, err error) { + if !setting.UI.ReactionsMap[opts.Type] { + return nil, ErrForbiddenIssueReaction{opts.Type} + } + sess := x.NewSession() defer sess.Close() if err = sess.Begin(); err != nil { @@ -160,6 +186,19 @@ func DeleteCommentReaction(doer *User, issue *Issue, comment *Comment, content s }) } +// LoadUser load user of reaction +func (r *Reaction) LoadUser() (*User, error) { + if r.User != nil { + return r.User, nil + } + user, err := getUserByID(x, r.UserID) + if err != nil { + return nil, err + } + r.User = user + return user, nil +} + // ReactionList represents list of reactions type ReactionList []*Reaction diff --git a/models/issue_reaction_test.go b/models/issue_reaction_test.go index bbd8cf29f..1189b389e 100644 --- a/models/issue_reaction_test.go +++ b/models/issue_reaction_test.go @@ -81,22 +81,22 @@ func TestIssueReactionCount(t *testing.T) { user4 := AssertExistsAndLoadBean(t, &User{ID: 4}).(*User) ghost := NewGhostUser() - issue1 := AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue) + issue := AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue) - addReaction(t, user1, issue1, nil, "heart") - addReaction(t, user2, issue1, nil, "heart") - addReaction(t, user3, issue1, nil, "heart") - addReaction(t, user3, issue1, nil, "+1") - addReaction(t, user4, issue1, nil, "+1") - addReaction(t, user4, issue1, nil, "heart") - addReaction(t, ghost, issue1, nil, "-1") - - err := issue1.loadReactions(x) + addReaction(t, user1, issue, nil, "heart") + addReaction(t, user2, issue, nil, "heart") + addReaction(t, user3, issue, nil, "heart") + addReaction(t, user3, issue, nil, "+1") + addReaction(t, user4, issue, nil, "+1") + addReaction(t, user4, issue, nil, "heart") + addReaction(t, ghost, issue, nil, "-1") + + err := issue.loadReactions(x) assert.NoError(t, err) - assert.Len(t, issue1.Reactions, 7) + assert.Len(t, issue.Reactions, 7) - reactions := issue1.Reactions.GroupByType() + reactions := issue.Reactions.GroupByType() assert.Len(t, reactions["heart"], 4) assert.Equal(t, 2, reactions["heart"].GetMoreUserCount()) assert.Equal(t, user1.DisplayName()+", "+user2.DisplayName(), reactions["heart"].GetFirstUsers()) diff --git a/modules/setting/setting.go b/modules/setting/setting.go index c08621df5..f55833a0e 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -171,6 +171,7 @@ var ( DefaultTheme string Themes []string Reactions []string + ReactionsMap map[string]bool SearchRepoDescription bool UseServiceWorker bool @@ -985,6 +986,11 @@ func NewContext() { U2F.AppID = sec.Key("APP_ID").MustString(strings.TrimRight(AppURL, "/")) zip.Verbose = false + + UI.ReactionsMap = make(map[string]bool) + for _, reaction := range UI.Reactions { + UI.ReactionsMap[reaction] = true + } } func loadInternalToken(sec *ini.Section) string { diff --git a/modules/structs/issue_reaction.go b/modules/structs/issue_reaction.go new file mode 100644 index 000000000..9d7174005 --- /dev/null +++ b/modules/structs/issue_reaction.go @@ -0,0 +1,22 @@ +// 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" +) + +// EditReactionOption contain the reaction type +type EditReactionOption struct { + Reaction string `json:"content"` +} + +// ReactionResponse contain one reaction +type ReactionResponse struct { + User *User `json:"user"` + Reaction string `json:"content"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 47c9c95c7..cd5fc1f3e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -657,21 +657,25 @@ func RegisterRoutes(m *macaron.Macaron) { Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}), repo.CreateIssue) m.Group("/comments", func() { m.Get("", repo.ListRepoIssueComments) - m.Combo("/:id", reqToken()). - Patch(mustNotBeArchived, bind(api.EditIssueCommentOption{}), repo.EditIssueComment). - Delete(repo.DeleteIssueComment) + m.Group("/:id", func() { + m.Combo("", reqToken()). + Patch(mustNotBeArchived, bind(api.EditIssueCommentOption{}), repo.EditIssueComment). + Delete(repo.DeleteIssueComment) + m.Combo("/reactions", reqToken()). + Get(repo.GetIssueCommentReactions). + Post(bind(api.EditReactionOption{}), repo.PostIssueCommentReaction). + Delete(bind(api.EditReactionOption{}), repo.DeleteIssueCommentReaction) + }) }) m.Group("/:index", func() { m.Combo("").Get(repo.GetIssue). Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue) - m.Group("/comments", func() { m.Combo("").Get(repo.ListIssueComments). Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) m.Combo("/:id", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated). Delete(repo.DeleteIssueCommentDeprecated) }) - m.Group("/labels", func() { m.Combo("").Get(repo.ListIssueLabels). Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). @@ -679,12 +683,10 @@ func RegisterRoutes(m *macaron.Macaron) { Delete(reqToken(), repo.ClearIssueLabels) m.Delete("/:id", reqToken(), repo.DeleteIssueLabel) }) - m.Group("/times", func() { m.Combo("").Get(repo.ListTrackedTimes). Post(reqToken(), bind(api.AddTimeOption{}), repo.AddTime) }) - m.Combo("/deadline").Post(reqToken(), bind(api.EditDeadlineOption{}), repo.UpdateIssueDeadline) m.Group("/stopwatch", func() { m.Post("/start", reqToken(), repo.StartIssueStopwatch) @@ -695,6 +697,10 @@ func RegisterRoutes(m *macaron.Macaron) { m.Put("/:user", reqToken(), repo.AddIssueSubscription) m.Delete("/:user", reqToken(), repo.DelIssueSubscription) }) + m.Combo("/reactions", reqToken()). + Get(repo.GetIssueReactions). + Post(bind(api.EditReactionOption{}), repo.PostIssueReaction). + Delete(bind(api.EditReactionOption{}), repo.DeleteIssueReaction) }) }, mustEnableIssuesOrPulls) m.Group("/labels", func() { diff --git a/routers/api/v1/repo/issue_reaction.go b/routers/api/v1/repo/issue_reaction.go new file mode 100644 index 000000000..56e12ccdc --- /dev/null +++ b/routers/api/v1/repo/issue_reaction.go @@ -0,0 +1,394 @@ +// 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 repo + +import ( + "errors" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" +) + +// GetIssueCommentReactions list reactions of a issue comment +func GetIssueCommentReactions(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issueGetCommentReactions + // --- + // summary: Get a list reactions of a issue comment + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ReactionResponseList" + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(500, "GetCommentByID", err) + } + return + } + + if !ctx.Repo.CanRead(models.UnitTypeIssues) && !ctx.User.IsAdmin { + ctx.Error(403, "GetIssueCommentReactions", errors.New("no permission to get reactions")) + return + } + + reactions, err := models.FindCommentReactions(comment) + if err != nil { + ctx.Error(500, "FindIssueReactions", err) + return + } + _, err = reactions.LoadUsers() + if err != nil { + ctx.Error(500, "ReactionList.LoadUsers()", err) + return + } + + var result []api.ReactionResponse + for _, r := range reactions { + result = append(result, api.ReactionResponse{ + User: r.User.APIFormat(), + Reaction: r.Type, + Created: r.CreatedUnix.AsTime(), + }) + } + + ctx.JSON(200, result) +} + +// PostIssueCommentReaction add a reaction to a comment of a issue +func PostIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption) { + // swagger:operation POST /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issuePostCommentReaction + // --- + // summary: Add a reaction to a comment of a issue comment + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "201": + // "$ref": "#/responses/ReactionResponse" + changeIssueCommentReaction(ctx, form, true) +} + +// DeleteIssueCommentReaction list reactions of a issue comment +func DeleteIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/comments/{id}/reactions issue issueDeleteCommentReaction + // --- + // summary: Remove a reaction from a comment of a issue comment + // 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: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "200": + // "$ref": "#/responses/empty" + changeIssueCommentReaction(ctx, form, false) +} + +func changeIssueCommentReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) { + comment, err := models.GetCommentByID(ctx.ParamsInt64(":id")) + if err != nil { + if models.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(500, "GetCommentByID", err) + } + return + } + + err = comment.LoadIssue() + if err != nil { + ctx.Error(500, "comment.LoadIssue() failed", err) + } + + if comment.Issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin { + ctx.Error(403, "ChangeIssueCommentReaction", errors.New("no permission to change reaction")) + return + } + + if isCreateType { + // PostIssueCommentReaction part + reaction, err := models.CreateCommentReaction(ctx.User, comment.Issue, comment, form.Reaction) + if err != nil { + if models.IsErrForbiddenIssueReaction(err) { + ctx.Error(403, err.Error(), err) + } else { + ctx.Error(500, "CreateCommentReaction", err) + } + return + } + _, err = reaction.LoadUser() + if err != nil { + ctx.Error(500, "Reaction.LoadUser()", err) + return + } + + ctx.JSON(201, api.ReactionResponse{ + User: reaction.User.APIFormat(), + Reaction: reaction.Type, + Created: reaction.CreatedUnix.AsTime(), + }) + } else { + // DeleteIssueCommentReaction part + err = models.DeleteCommentReaction(ctx.User, comment.Issue, comment, form.Reaction) + if err != nil { + ctx.Error(500, "DeleteCommentReaction", err) + return + } + ctx.Status(200) + } +} + +// GetIssueReactions list reactions of a issue comment +func GetIssueReactions(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/reactions issue issueGetIssueReactions + // --- + // summary: Get a list reactions of a issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/ReactionResponseList" + issue, err := models.GetIssueWithAttrsByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(500, "GetIssueByIndex", err) + } + return + } + + if !ctx.Repo.CanRead(models.UnitTypeIssues) && !ctx.User.IsAdmin { + ctx.Error(403, "GetIssueReactions", errors.New("no permission to get reactions")) + return + } + + reactions, err := models.FindIssueReactions(issue) + if err != nil { + ctx.Error(500, "FindIssueReactions", err) + return + } + _, err = reactions.LoadUsers() + if err != nil { + ctx.Error(500, "ReactionList.LoadUsers()", err) + return + } + + var result []api.ReactionResponse + for _, r := range reactions { + result = append(result, api.ReactionResponse{ + User: r.User.APIFormat(), + Reaction: r.Type, + Created: r.CreatedUnix.AsTime(), + }) + } + + ctx.JSON(200, result) +} + +// PostIssueReaction add a reaction to a comment of a issue +func PostIssueReaction(ctx *context.APIContext, form api.EditReactionOption) { + // swagger:operation POST /repos/{owner}/{repo}/issues/{index}/reactions issue issuePostIssueReaction + // --- + // summary: Add a reaction to a comment of a issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "201": + // "$ref": "#/responses/ReactionResponse" + changeIssueReaction(ctx, form, true) +} + +// DeleteIssueReaction list reactions of a issue comment +func DeleteIssueReaction(ctx *context.APIContext, form api.EditReactionOption) { + // swagger:operation DELETE /repos/{owner}/{repo}/issues/{index}/reactions issue issueDeleteIssueReaction + // --- + // summary: Remove a reaction from a comment of a issue + // 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: index + // in: path + // description: index of the issue + // type: integer + // format: int64 + // required: true + // - name: content + // in: body + // schema: + // "$ref": "#/definitions/EditReactionOption" + // responses: + // "200": + // "$ref": "#/responses/empty" + changeIssueReaction(ctx, form, false) +} + +func changeIssueReaction(ctx *context.APIContext, form api.EditReactionOption, isCreateType bool) { + issue, err := models.GetIssueWithAttrsByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(500, "GetIssueByIndex", err) + } + return + } + + if issue.IsLocked && !ctx.Repo.CanWrite(models.UnitTypeIssues) && !ctx.User.IsAdmin { + ctx.Error(403, "ChangeIssueCommentReaction", errors.New("no permission to change reaction")) + return + } + + if isCreateType { + // PostIssueReaction part + reaction, err := models.CreateIssueReaction(ctx.User, issue, form.Reaction) + if err != nil { + if models.IsErrForbiddenIssueReaction(err) { + ctx.Error(403, err.Error(), err) + } else { + ctx.Error(500, "CreateCommentReaction", err) + } + return + } + _, err = reaction.LoadUser() + if err != nil { + ctx.Error(500, "Reaction.LoadUser()", err) + return + } + + ctx.JSON(201, api.ReactionResponse{ + User: reaction.User.APIFormat(), + Reaction: reaction.Type, + Created: reaction.CreatedUnix.AsTime(), + }) + } else { + // DeleteIssueReaction part + err = models.DeleteIssueReaction(ctx.User, issue, form.Reaction) + if err != nil { + ctx.Error(500, "DeleteIssueReaction", err) + return + } + ctx.Status(200) + } +} diff --git a/routers/api/v1/swagger/issue.go b/routers/api/v1/swagger/issue.go index c06186bf6..a78c2982f 100644 --- a/routers/api/v1/swagger/issue.go +++ b/routers/api/v1/swagger/issue.go @@ -84,3 +84,24 @@ type swaggerIssueDeadline struct { // in:body Body api.IssueDeadline `json:"body"` } + +// EditReactionOption +// swagger:response EditReactionOption +type swaggerEditReactionOption struct { + // in:body + Body api.EditReactionOption `json:"body"` +} + +// ReactionResponse +// swagger:response ReactionResponse +type swaggerReactionResponse struct { + // in:body + Body api.ReactionResponse `json:"body"` +} + +// ReactionResponseList +// swagger:response ReactionResponseList +type swaggerReactionResponseList struct { + // in:body + Body []api.ReactionResponse `json:"body"` +} diff --git a/routers/repo/issue.go b/routers/repo/issue.go index c79ea02e8..5d5aaca25 100644 --- a/routers/repo/issue.go +++ b/routers/repo/issue.go @@ -1463,14 +1463,12 @@ func ChangeIssueReaction(ctx *context.Context, form auth.ReactionForm) { switch ctx.Params(":action") { case "react": - if !util.IsStringInSlice(form.Content, setting.UI.Reactions) { - err := fmt.Errorf("ChangeIssueReaction: '%s' is not an allowed reaction", form.Content) - ctx.ServerError(err.Error(), err) - return - } - reaction, err := models.CreateIssueReaction(ctx.User, issue, form.Content) if err != nil { + if models.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } log.Info("CreateIssueReaction: %s", err) break } @@ -1564,14 +1562,12 @@ func ChangeCommentReaction(ctx *context.Context, form auth.ReactionForm) { switch ctx.Params(":action") { case "react": - if !util.IsStringInSlice(form.Content, setting.UI.Reactions) { - err := fmt.Errorf("ChangeIssueReaction: '%s' is not an allowed reaction", form.Content) - ctx.ServerError(err.Error(), err) - return - } - reaction, err := models.CreateCommentReaction(ctx.User, comment.Issue, comment, form.Content) if err != nil { + if models.IsErrForbiddenIssueReaction(err) { + ctx.ServerError("ChangeIssueReaction", err) + return + } log.Info("CreateCommentReaction: %s", err) break } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 1d8cd3685..9c8db2881 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -3016,6 +3016,148 @@ } } }, + "/repos/{owner}/{repo}/issues/comments/{id}/reactions": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Get a list reactions of a issue comment", + "operationId": "issueGetCommentReactions", + "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": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ReactionResponseList" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Add a reaction to a comment of a issue comment", + "operationId": "issuePostCommentReaction", + "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": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "content", + "in": "body", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ReactionResponse" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Remove a reaction from a comment of a issue comment", + "operationId": "issueDeleteCommentReaction", + "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": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "content", + "in": "body", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/empty" + } + } + } + }, "/repos/{owner}/{repo}/issues/{id}/times": { "get": { "produces": [ @@ -3688,6 +3830,148 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/reactions": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Get a list reactions of a issue", + "operationId": "issueGetIssueReactions", + "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": "integer", + "format": "int64", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ReactionResponseList" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Add a reaction to a comment of a issue", + "operationId": "issuePostIssueReaction", + "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": "integer", + "format": "int64", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "content", + "in": "body", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/ReactionResponse" + } + } + }, + "delete": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "Remove a reaction from a comment of a issue", + "operationId": "issueDeleteIssueReaction", + "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": "integer", + "format": "int64", + "description": "index of the issue", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "content", + "in": "body", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/empty" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/stopwatch/start": { "post": { "consumes": [ @@ -8721,6 +9005,17 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditReactionOption": { + "description": "EditReactionOption contain the reaction type", + "type": "object", + "properties": { + "content": { + "type": "string", + "x-go-name": "Reaction" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditReleaseOption": { "description": "EditReleaseOption options when editing a release", "type": "object", @@ -10095,6 +10390,25 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ReactionResponse": { + "description": "ReactionResponse contain one reaction", + "type": "object", + "properties": { + "content": { + "type": "string", + "x-go-name": "Reaction" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "user": { + "$ref": "#/definitions/User" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "Reference": { "type": "object", "title": "Reference represents a Git reference.", @@ -10960,6 +11274,12 @@ } } }, + "EditReactionOption": { + "description": "EditReactionOption", + "schema": { + "$ref": "#/definitions/EditReactionOption" + } + }, "EmailList": { "description": "EmailList", "schema": { @@ -11146,6 +11466,21 @@ } } }, + "ReactionResponse": { + "description": "ReactionResponse", + "schema": { + "$ref": "#/definitions/ReactionResponse" + } + }, + "ReactionResponseList": { + "description": "ReactionResponseList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/ReactionResponse" + } + } + }, "Reference": { "description": "Reference", "schema": {