diff --git a/integrations/api_comment_test.go b/integrations/api_comment_test.go
index 0c3ac2ae5b..4c4c6308ee 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 360a212a23..9a6f4247b0 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 1610b9f0d8..caba2b506e 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 cfc01cd4c0..74837b8553 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 0c8ac20017..e13ec05d01 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 c587907d4b..7a2347650a 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 13e7de46b1..b929cec373 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 0f2f572020..09e7077b20 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 992cdf5bda..9438c41a29 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": {