From 7db2f110adbd020e70c56497306cfbda8806d109 Mon Sep 17 00:00:00 2001 From: qwerty287 <80460567+qwerty287@users.noreply.github.com> Date: Sat, 1 Jan 2022 15:12:25 +0100 Subject: [PATCH] Add API to get issue/pull comments and events (timeline) (#17403) * Add API to get issue/pull comments and events (timeline) Adds an API to get both comments and events in one endpoint with all required data. Closes go-gitea/gitea#13250 * Fix swagger * Don't show code comments (use review api instead) * fmt * Fix comment * Time -> TrackedTime * Use var directly * Add logger * Fix lint * Fix test * Add comments * fmt * [test] get issue directly by ID * Update test * Add description for changed refs * Fix build issues + lint * Fix build * Use string enums * Update swagger * Support `page` and `limit` params * fmt + swagger * Use global slices Co-authored-by: zeripath Co-authored-by: Lunny Xiao --- integrations/api_comment_test.go | 22 +++ models/issue_comment.go | 41 ++++++ modules/convert/issue_comment.go | 143 +++++++++++++++++++ modules/references/references.go | 11 ++ modules/structs/issue_comment.go | 45 ++++++ routers/api/v1/api.go | 1 + routers/api/v1/repo/issue_comment.go | 111 +++++++++++++++ routers/api/v1/swagger/issue.go | 7 + templates/swagger/v1_json.tmpl | 196 +++++++++++++++++++++++++++ 9 files changed, 577 insertions(+) diff --git a/integrations/api_comment_test.go b/integrations/api_comment_test.go index 0c3ac2ae5b5..4c4c6308eea 100644 --- a/integrations/api_comment_test.go +++ b/integrations/api_comment_test.go @@ -180,3 +180,25 @@ func TestAPIDeleteComment(t *testing.T) { unittest.AssertNotExistsBean(t, &models.Comment{ID: comment.ID}) } + +func TestAPIListIssueTimeline(t *testing.T) { + defer prepareTestEnv(t)() + + // load comment + issue := unittest.AssertExistsAndLoadBean(t, &models.Issue{ID: 1}).(*models.Issue) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}).(*repo_model.Repository) + repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}).(*user_model.User) + + // make request + session := loginUser(t, repoOwner.Name) + req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/timeline", + repoOwner.Name, repo.Name, issue.Index) + resp := session.MakeRequest(t, req, http.StatusOK) + + // check if lens of list returned by API and + // lists extracted directly from DB are the same + var comments []*api.TimelineComment + DecodeJSON(t, resp, &comments) + expectedCount := unittest.GetCount(t, &models.Comment{IssueID: issue.ID}) + assert.EqualValues(t, expectedCount, len(comments)) +} diff --git a/models/issue_comment.go b/models/issue_comment.go index 360a212a23b..9a6f4247b08 100644 --- a/models/issue_comment.go +++ b/models/issue_comment.go @@ -110,6 +110,47 @@ const ( CommentTypeChangeIssueRef ) +var commentStrings = []string{ + "comment", + "reopen", + "close", + "issue_ref", + "commit_ref", + "comment_ref", + "pull_ref", + "label", + "milestone", + "assignees", + "change_title", + "delete_branch", + "start_tracking", + "stop_tracking", + "add_time_manual", + "cancel_tracking", + "added_deadline", + "modified_deadline", + "removed_deadline", + "add_dependency", + "remove_dependency", + "code", + "review", + "lock", + "unlock", + "change_target_branch", + "delete_time_manual", + "review_request", + "merge_pull", + "pull_push", + "project", + "project_board", + "dismiss_review", + "change_issue_ref", +} + +func (t CommentType) String() string { + return commentStrings[t] +} + // RoleDescriptor defines comment tag type type RoleDescriptor int diff --git a/modules/convert/issue_comment.go b/modules/convert/issue_comment.go index 1610b9f0d88..caba2b506e4 100644 --- a/modules/convert/issue_comment.go +++ b/modules/convert/issue_comment.go @@ -6,6 +6,9 @@ package convert import ( "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" ) @@ -22,3 +25,143 @@ func ToComment(c *models.Comment) *api.Comment { Updated: c.UpdatedUnix.AsTime(), } } + +// ToTimelineComment converts a models.Comment to the api.TimelineComment format +func ToTimelineComment(c *models.Comment, doer *user_model.User) *api.TimelineComment { + err := c.LoadMilestone() + if err != nil { + log.Error("LoadMilestone: %v", err) + return nil + } + + err = c.LoadAssigneeUserAndTeam() + if err != nil { + log.Error("LoadAssigneeUserAndTeam: %v", err) + return nil + } + + err = c.LoadResolveDoer() + if err != nil { + log.Error("LoadResolveDoer: %v", err) + return nil + } + + err = c.LoadDepIssueDetails() + if err != nil { + log.Error("LoadDepIssueDetails: %v", err) + return nil + } + + err = c.LoadTime() + if err != nil { + log.Error("LoadTime: %v", err) + return nil + } + + err = c.LoadLabel() + if err != nil { + log.Error("LoadLabel: %v", err) + return nil + } + + comment := &api.TimelineComment{ + ID: c.ID, + Type: c.Type.String(), + Poster: ToUser(c.Poster, nil), + HTMLURL: c.HTMLURL(), + IssueURL: c.IssueURL(), + PRURL: c.PRURL(), + Body: c.Content, + Created: c.CreatedUnix.AsTime(), + Updated: c.UpdatedUnix.AsTime(), + + OldProjectID: c.OldProjectID, + ProjectID: c.ProjectID, + + OldTitle: c.OldTitle, + NewTitle: c.NewTitle, + + OldRef: c.OldRef, + NewRef: c.NewRef, + + RefAction: c.RefAction.String(), + RefCommitSHA: c.CommitSHA, + + ReviewID: c.ReviewID, + + RemovedAssignee: c.RemovedAssignee, + } + + if c.OldMilestone != nil { + comment.OldMilestone = ToAPIMilestone(c.OldMilestone) + } + if c.Milestone != nil { + comment.Milestone = ToAPIMilestone(c.Milestone) + } + + if c.Time != nil { + comment.TrackedTime = ToTrackedTime(c.Time) + } + + if c.RefIssueID != 0 { + issue, err := models.GetIssueByID(c.RefIssueID) + if err != nil { + log.Error("GetIssueByID(%d): %v", c.RefIssueID, err) + return nil + } + comment.RefIssue = ToAPIIssue(issue) + } + + if c.RefCommentID != 0 { + com, err := models.GetCommentByID(c.RefCommentID) + if err != nil { + log.Error("GetCommentByID(%d): %v", c.RefCommentID, err) + return nil + } + err = com.LoadPoster() + if err != nil { + log.Error("LoadPoster: %v", err) + return nil + } + comment.RefComment = ToComment(com) + } + + if c.Label != nil { + var org *user_model.User + var repo *repo_model.Repository + if c.Label.BelongsToOrg() { + var err error + org, err = user_model.GetUserByID(c.Label.OrgID) + if err != nil { + log.Error("GetUserByID(%d): %v", c.Label.OrgID, err) + return nil + } + } + if c.Label.BelongsToRepo() { + var err error + repo, err = repo_model.GetRepositoryByID(c.Label.RepoID) + if err != nil { + log.Error("GetRepositoryByID(%d): %v", c.Label.RepoID, err) + return nil + } + } + comment.Label = ToLabel(c.Label, repo, org) + } + + if c.Assignee != nil { + comment.Assignee = ToUser(c.Assignee, nil) + } + if c.AssigneeTeam != nil { + comment.AssigneeTeam = ToTeam(c.AssigneeTeam) + } + + if c.ResolveDoer != nil { + comment.ResolveDoer = ToUser(c.ResolveDoer, nil) + } + + if c.DependentIssue != nil { + comment.DependentIssue = ToAPIIssue(c.DependentIssue) + } + + return comment +} diff --git a/modules/references/references.go b/modules/references/references.go index cfc01cd4c01..74837b8553f 100644 --- a/modules/references/references.go +++ b/modules/references/references.go @@ -49,6 +49,13 @@ var ( giteaHostInit sync.Once giteaHost string giteaIssuePullPattern *regexp.Regexp + + actionStrings = []string{ + "none", + "closes", + "reopens", + "neutered", + } ) // XRefAction represents the kind of effect a cross reference has once is resolved @@ -65,6 +72,10 @@ const ( XRefActionNeutered // 3 ) +func (a XRefAction) String() string { + return actionStrings[a] +} + // IssueReference contains an unverified cross-reference to a local issue or pull request type IssueReference struct { Index int64 diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go index 0c8ac20017f..e13ec05d018 100644 --- a/modules/structs/issue_comment.go +++ b/modules/structs/issue_comment.go @@ -35,3 +35,48 @@ type EditIssueCommentOption struct { // required: true Body string `json:"body" binding:"Required"` } + +// TimelineComment represents a timeline comment (comment of any type) on a commit or issue +type TimelineComment struct { + ID int64 `json:"id"` + Type string `json:"type"` + + HTMLURL string `json:"html_url"` + PRURL string `json:"pull_request_url"` + IssueURL string `json:"issue_url"` + Poster *User `json:"user"` + Body string `json:"body"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` + + OldProjectID int64 `json:"old_project_id"` + ProjectID int64 `json:"project_id"` + OldMilestone *Milestone `json:"old_milestone"` + Milestone *Milestone `json:"milestone"` + TrackedTime *TrackedTime `json:"tracked_time"` + OldTitle string `json:"old_title"` + NewTitle string `json:"new_title"` + OldRef string `json:"old_ref"` + NewRef string `json:"new_ref"` + + RefIssue *Issue `json:"ref_issue"` + RefComment *Comment `json:"ref_comment"` + RefAction string `json:"ref_action"` + // commit SHA where issue/PR was referenced + RefCommitSHA string `json:"ref_commit_sha"` + + ReviewID int64 `json:"review_id"` + + Label *Label `json:"label"` + + Assignee *User `json:"assignee"` + AssigneeTeam *Team `json:"assignee_team"` + // whether the assignees were removed or added + RemovedAssignee bool `json:"removed_assignee"` + + ResolveDoer *User `json:"resolve_doer"` + + DependentIssue *Issue `json:"dependent_issue"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c587907d4b2..7a2347650a0 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -842,6 +842,7 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route { m.Combo("/{id}", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated). Delete(repo.DeleteIssueCommentDeprecated) }) + m.Get("/timeline", repo.ListIssueCommentsAndTimeline) m.Group("/labels", func() { m.Combo("").Get(repo.ListIssueLabels). Post(reqToken(), bind(api.IssueLabelsOption{}), repo.AddIssueLabels). diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index 13e7de46b1f..b929cec3737 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -10,6 +10,8 @@ import ( "net/http" "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/convert" api "code.gitea.io/gitea/modules/structs" @@ -102,6 +104,115 @@ func ListIssueComments(ctx *context.APIContext) { ctx.JSON(http.StatusOK, &apiComments) } +// ListIssueCommentsAndTimeline list all the comments and events of an issue +func ListIssueCommentsAndTimeline(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/issues/{index}/timeline issue issueGetCommentsAndTimeline + // --- + // summary: List all comments and events on an issue + // 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: since + // in: query + // description: if provided, only comments updated since the specified time are returned. + // type: string + // format: date-time + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // responses: + // "200": + // "$ref": "#/responses/TimelineList" + + before, since, err := utils.GetQueryBeforeSince(ctx) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRawIssueByIndex", err) + return + } + issue.Repo = ctx.Repo.Repository + + opts := &models.FindCommentsOptions{ + ListOptions: utils.GetListOptions(ctx), + IssueID: issue.ID, + Since: since, + Before: before, + Type: models.CommentTypeUnknown, + } + + comments, err := models.FindComments(opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + if err := models.CommentList(comments).LoadPosters(); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + var apiComments []*api.TimelineComment + for _, comment := range comments { + if comment.Type != models.CommentTypeCode && isXRefCommentAccessible(ctx.User, comment, issue.RepoID) { + comment.Issue = issue + apiComments = append(apiComments, convert.ToTimelineComment(comment, ctx.User)) + } + } + + ctx.SetTotalCountHeader(int64(len(apiComments))) + ctx.JSON(http.StatusOK, &apiComments) +} + +func isXRefCommentAccessible(user *user_model.User, c *models.Comment, issueRepoID int64) bool { + // Remove comments that the user has no permissions to see + if models.CommentTypeIsRef(c.Type) && c.RefRepoID != issueRepoID && c.RefRepoID != 0 { + var err error + // Set RefRepo for description in template + c.RefRepo, err = repo_model.GetRepositoryByID(c.RefRepoID) + if err != nil { + return false + } + perm, err := models.GetUserRepoPermission(c.RefRepo, user) + if err != nil { + return false + } + if !perm.CanReadIssuesOrPulls(c.RefIsPull) { + return false + } + } + return true +} + // ListRepoIssueComments returns all issue-comments for a repo func ListRepoIssueComments(ctx *context.APIContext) { // swagger:operation GET /repos/{owner}/{repo}/issues/comments issue issueGetRepoComments diff --git a/routers/api/v1/swagger/issue.go b/routers/api/v1/swagger/issue.go index 0f2f5720208..09e7077b200 100644 --- a/routers/api/v1/swagger/issue.go +++ b/routers/api/v1/swagger/issue.go @@ -36,6 +36,13 @@ type swaggerResponseCommentList struct { Body []api.Comment `json:"body"` } +// TimelineList +// swagger:response TimelineList +type swaggerResponseTimelineList struct { + // in:body + Body []api.TimelineComment `json:"body"` +} + // Label // swagger:response Label type swaggerResponseLabel struct { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 992cdf5bdac..9438c41a29a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6057,6 +6057,73 @@ } } }, + "/repos/{owner}/{repo}/issues/{index}/timeline": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "issue" + ], + "summary": "List all comments and events on an issue", + "operationId": "issueGetCommentsAndTimeline", + "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 + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated since the specified time are returned.", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated before the provided time are returned.", + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/TimelineList" + } + } + } + }, "/repos/{owner}/{repo}/issues/{index}/times": { "get": { "produces": [ @@ -17396,6 +17463,126 @@ "format": "int64", "x-go-package": "code.gitea.io/gitea/modules/timeutil" }, + "TimelineComment": { + "description": "TimelineComment represents a timeline comment (comment of any type) on a commit or issue", + "type": "object", + "properties": { + "assignee": { + "$ref": "#/definitions/User" + }, + "assignee_team": { + "$ref": "#/definitions/Team" + }, + "body": { + "type": "string", + "x-go-name": "Body" + }, + "created_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Created" + }, + "dependent_issue": { + "$ref": "#/definitions/Issue" + }, + "html_url": { + "type": "string", + "x-go-name": "HTMLURL" + }, + "id": { + "type": "integer", + "format": "int64", + "x-go-name": "ID" + }, + "issue_url": { + "type": "string", + "x-go-name": "IssueURL" + }, + "label": { + "$ref": "#/definitions/Label" + }, + "milestone": { + "$ref": "#/definitions/Milestone" + }, + "new_ref": { + "type": "string", + "x-go-name": "NewRef" + }, + "new_title": { + "type": "string", + "x-go-name": "NewTitle" + }, + "old_milestone": { + "$ref": "#/definitions/Milestone" + }, + "old_project_id": { + "type": "integer", + "format": "int64", + "x-go-name": "OldProjectID" + }, + "old_ref": { + "type": "string", + "x-go-name": "OldRef" + }, + "old_title": { + "type": "string", + "x-go-name": "OldTitle" + }, + "project_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ProjectID" + }, + "pull_request_url": { + "type": "string", + "x-go-name": "PRURL" + }, + "ref_action": { + "type": "string", + "x-go-name": "RefAction" + }, + "ref_comment": { + "$ref": "#/definitions/Comment" + }, + "ref_commit_sha": { + "description": "commit SHA where issue/PR was referenced", + "type": "string", + "x-go-name": "RefCommitSHA" + }, + "ref_issue": { + "$ref": "#/definitions/Issue" + }, + "removed_assignee": { + "description": "whether the assignees were removed or added", + "type": "boolean", + "x-go-name": "RemovedAssignee" + }, + "resolve_doer": { + "$ref": "#/definitions/User" + }, + "review_id": { + "type": "integer", + "format": "int64", + "x-go-name": "ReviewID" + }, + "tracked_time": { + "$ref": "#/definitions/TrackedTime" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "x-go-name": "Updated" + }, + "user": { + "$ref": "#/definitions/User" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "TopicName": { "description": "TopicName a list of repo topic names", "type": "object", @@ -18525,6 +18712,15 @@ } } }, + "TimelineList": { + "description": "TimelineList", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/TimelineComment" + } + } + }, "TopicListResponse": { "description": "TopicListResponse", "schema": {