* 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 <art27@cantab.net> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>tags/v1.16.0-rc1
@@ -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)) | |||
} |
@@ -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 | |||
@@ -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 | |||
} |
@@ -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 |
@@ -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"` | |||
} |
@@ -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). |
@@ -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 |
@@ -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 { |
@@ -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": { |