diff --git a/models/db/unit_tests.go b/models/db/unit_tests.go
index d81610df6b..6f079c8676 100644
--- a/models/db/unit_tests.go
+++ b/models/db/unit_tests.go
@@ -54,7 +54,9 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) {
 		opts.Dir = fixturesDir
 	} else {
 		for _, f := range fixtureFiles {
-			opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
+			if len(f) != 0 {
+				opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
+			}
 		}
 	}
 
diff --git a/models/issue.go b/models/issue.go
index b62394919c..823d82a765 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -14,6 +14,7 @@ import (
 	"strings"
 
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/references"
@@ -803,8 +804,13 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
 		return fmt.Errorf("UpdateIssueCols: %v", err)
 	}
 
-	if err = issue.addCrossReferences(db.GetEngine(ctx), doer, true); err != nil {
-		return err
+	if err = issues.SaveIssueContentHistory(db.GetEngine(ctx), issue.PosterID, issue.ID, 0,
+		timeutil.TimeStampNow(), issue.Content, false); err != nil {
+		return fmt.Errorf("SaveIssueContentHistory: %v", err)
+	}
+
+	if err = issue.addCrossReferences(ctx.Engine(), doer, true); err != nil {
+		return fmt.Errorf("addCrossReferences: %v", err)
 	}
 
 	return committer.Commit()
@@ -972,6 +978,12 @@ func newIssue(e db.Engine, doer *User, opts NewIssueOptions) (err error) {
 	if err = opts.Issue.loadAttributes(e); err != nil {
 		return err
 	}
+
+	if err = issues.SaveIssueContentHistory(e, opts.Issue.PosterID, opts.Issue.ID, 0,
+		timeutil.TimeStampNow(), opts.Issue.Content, true); err != nil {
+		return err
+	}
+
 	return opts.Issue.addCrossReferences(e, doer, false)
 }
 
@@ -2132,6 +2144,12 @@ func UpdateReactionsMigrationsByType(gitServiceType structs.GitServiceType, orig
 func deleteIssuesByRepoID(sess db.Engine, repoID int64) (attachmentPaths []string, err error) {
 	deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"issue.repo_id": repoID})
 
+	// Delete content histories
+	if _, err = sess.In("issue_id", deleteCond).
+		Delete(&issues.ContentHistory{}); err != nil {
+		return
+	}
+
 	// Delete comments and attachments
 	if _, err = sess.In("issue_id", deleteCond).
 		Delete(&Comment{}); err != nil {
diff --git a/models/issue_comment.go b/models/issue_comment.go
index 01e41814a4..0ae9140f0c 100644
--- a/models/issue_comment.go
+++ b/models/issue_comment.go
@@ -14,6 +14,7 @@ import (
 	"unicode/utf8"
 
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/json"
 	"code.gitea.io/gitea/modules/log"
@@ -1083,6 +1084,12 @@ func deleteComment(e db.Engine, comment *Comment) error {
 		return err
 	}
 
+	if _, err := e.Delete(&issues.ContentHistory{
+		CommentID: comment.ID,
+	}); err != nil {
+		return err
+	}
+
 	if comment.Type == CommentTypeComment {
 		if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil {
 			return err
diff --git a/models/issues/content_history.go b/models/issues/content_history.go
new file mode 100644
index 0000000000..697d54b641
--- /dev/null
+++ b/models/issues/content_history.go
@@ -0,0 +1,230 @@
+// Copyright 2021 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 issues
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/avatars"
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/builder"
+)
+
+// ContentHistory save issue/comment content history revisions.
+type ContentHistory struct {
+	ID             int64 `xorm:"pk autoincr"`
+	PosterID       int64
+	IssueID        int64              `xorm:"INDEX"`
+	CommentID      int64              `xorm:"INDEX"`
+	EditedUnix     timeutil.TimeStamp `xorm:"INDEX"`
+	ContentText    string             `xorm:"LONGTEXT"`
+	IsFirstCreated bool
+	IsDeleted      bool
+}
+
+// TableName provides the real table name
+func (m *ContentHistory) TableName() string {
+	return "issue_content_history"
+}
+
+func init() {
+	db.RegisterModel(new(ContentHistory))
+}
+
+// SaveIssueContentHistory save history
+func SaveIssueContentHistory(e db.Engine, posterID, issueID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error {
+	ch := &ContentHistory{
+		PosterID:       posterID,
+		IssueID:        issueID,
+		CommentID:      commentID,
+		ContentText:    contentText,
+		EditedUnix:     editTime,
+		IsFirstCreated: isFirstCreated,
+	}
+	_, err := e.Insert(ch)
+	if err != nil {
+		log.Error("can not save issue content history. err=%v", err)
+		return err
+	}
+	// We only keep at most 20 history revisions now. It is enough in most cases.
+	// If there is a special requirement to keep more, we can consider introducing a new setting option then, but not now.
+	keepLimitedContentHistory(e, issueID, commentID, 20)
+	return nil
+}
+
+// keepLimitedContentHistory keeps at most `limit` history revisions, it will hard delete out-dated revisions, sorting by revision interval
+// we can ignore all errors in this function, so we just log them
+func keepLimitedContentHistory(e db.Engine, issueID, commentID int64, limit int) {
+	type IDEditTime struct {
+		ID         int64
+		EditedUnix timeutil.TimeStamp
+	}
+
+	var res []*IDEditTime
+	err := e.Select("id, edited_unix").Table("issue_content_history").
+		Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
+		OrderBy("edited_unix ASC").
+		Find(&res)
+	if err != nil {
+		log.Error("can not query content history for deletion, err=%v", err)
+		return
+	}
+	if len(res) <= 1 {
+		return
+	}
+
+	outDatedCount := len(res) - limit
+	for outDatedCount > 0 {
+		var indexToDelete int
+		minEditedInterval := -1
+		// find a history revision with minimal edited interval to delete
+		for i := 1; i < len(res); i++ {
+			editedInterval := int(res[i].EditedUnix - res[i-1].EditedUnix)
+			if minEditedInterval == -1 || editedInterval < minEditedInterval {
+				minEditedInterval = editedInterval
+				indexToDelete = i
+			}
+		}
+		if indexToDelete == 0 {
+			break
+		}
+
+		// hard delete the found one
+		_, err = e.Delete(&ContentHistory{ID: res[indexToDelete].ID})
+		if err != nil {
+			log.Error("can not delete out-dated content history, err=%v", err)
+			break
+		}
+		res = append(res[:indexToDelete], res[indexToDelete+1:]...)
+		outDatedCount--
+	}
+}
+
+// QueryIssueContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main issue)
+// only return the count map for "edited" (history revision count > 1) issues or comments.
+func QueryIssueContentHistoryEditedCountMap(dbCtx context.Context, issueID int64) (map[int64]int, error) {
+	type HistoryCountRecord struct {
+		CommentID    int64
+		HistoryCount int
+	}
+	records := make([]*HistoryCountRecord, 0)
+
+	err := db.GetEngine(dbCtx).Select("comment_id, COUNT(1) as history_count").
+		Table("issue_content_history").
+		Where(builder.Eq{"issue_id": issueID}).
+		GroupBy("comment_id").
+		Having("history_count > 1").
+		Find(&records)
+	if err != nil {
+		log.Error("can not query issue content history count map. err=%v", err)
+		return nil, err
+	}
+
+	res := map[int64]int{}
+	for _, r := range records {
+		res[r.CommentID] = r.HistoryCount
+	}
+	return res, nil
+}
+
+// IssueContentListItem the list for web ui
+type IssueContentListItem struct {
+	UserID         int64
+	UserName       string
+	UserAvatarLink string
+
+	HistoryID      int64
+	EditedUnix     timeutil.TimeStamp
+	IsFirstCreated bool
+	IsDeleted      bool
+}
+
+// FetchIssueContentHistoryList fetch list
+func FetchIssueContentHistoryList(dbCtx context.Context, issueID int64, commentID int64) ([]*IssueContentListItem, error) {
+	res := make([]*IssueContentListItem, 0)
+	err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name,"+
+		"h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted").
+		Table([]string{"issue_content_history", "h"}).
+		Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id").
+		Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
+		OrderBy("edited_unix DESC").
+		Find(&res)
+
+	if err != nil {
+		log.Error("can not fetch issue content history list. err=%v", err)
+		return nil, err
+	}
+
+	for _, item := range res {
+		item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0)
+	}
+	return res, nil
+}
+
+//SoftDeleteIssueContentHistory soft delete
+func SoftDeleteIssueContentHistory(dbCtx context.Context, historyID int64) error {
+	if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ContentHistory{
+		IsDeleted:   true,
+		ContentText: "",
+	}); err != nil {
+		log.Error("failed to soft delete issue content history. err=%v", err)
+		return err
+	}
+	return nil
+}
+
+// ErrIssueContentHistoryNotExist not exist error
+type ErrIssueContentHistoryNotExist struct {
+	ID int64
+}
+
+// Error error string
+func (err ErrIssueContentHistoryNotExist) Error() string {
+	return fmt.Sprintf("issue content history does not exist [id: %d]", err.ID)
+}
+
+// GetIssueContentHistoryByID get issue content history
+func GetIssueContentHistoryByID(dbCtx context.Context, id int64) (*ContentHistory, error) {
+	h := &ContentHistory{}
+	has, err := db.GetEngine(dbCtx).ID(id).Get(h)
+	if err != nil {
+		return nil, err
+	} else if !has {
+		return nil, ErrIssueContentHistoryNotExist{id}
+	}
+	return h, nil
+}
+
+// GetIssueContentHistoryAndPrev get a history and the previous non-deleted history (to compare)
+func GetIssueContentHistoryAndPrev(dbCtx context.Context, id int64) (history, prevHistory *ContentHistory, err error) {
+	history = &ContentHistory{}
+	has, err := db.GetEngine(dbCtx).ID(id).Get(history)
+	if err != nil {
+		log.Error("failed to get issue content history %v. err=%v", id, err)
+		return nil, nil, err
+	} else if !has {
+		log.Error("issue content history does not exist. id=%v. err=%v", id, err)
+		return nil, nil, &ErrIssueContentHistoryNotExist{id}
+	}
+
+	prevHistory = &ContentHistory{}
+	has, err = db.GetEngine(dbCtx).Where(builder.Eq{"issue_id": history.IssueID, "comment_id": history.CommentID, "is_deleted": false}).
+		And(builder.Lt{"edited_unix": history.EditedUnix}).
+		OrderBy("edited_unix DESC").Limit(1).
+		Get(prevHistory)
+
+	if err != nil {
+		log.Error("failed to get issue content history %v. err=%v", id, err)
+		return nil, nil, err
+	} else if !has {
+		return history, nil, nil
+	}
+
+	return history, prevHistory, nil
+}
diff --git a/models/issues/content_history_test.go b/models/issues/content_history_test.go
new file mode 100644
index 0000000000..dadeb484b1
--- /dev/null
+++ b/models/issues/content_history_test.go
@@ -0,0 +1,74 @@
+// Copyright 2021 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 issues
+
+import (
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestContentHistory(t *testing.T) {
+	assert.NoError(t, db.PrepareTestDatabase())
+
+	dbCtx := db.DefaultContext
+	dbEngine := db.GetEngine(dbCtx)
+	timeStampNow := timeutil.TimeStampNow()
+
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow, "i-a", true)
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow.Add(2), "i-b", false)
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow.Add(7), "i-c", false)
+
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow, "c-a", true)
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(5), "c-b", false)
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(20), "c-c", false)
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(50), "c-d", false)
+	_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(51), "c-e", false)
+
+	h1, _ := GetIssueContentHistoryByID(dbCtx, 1)
+	assert.EqualValues(t, 1, h1.ID)
+
+	m, _ := QueryIssueContentHistoryEditedCountMap(dbCtx, 10)
+	assert.Equal(t, 3, m[0])
+	assert.Equal(t, 5, m[100])
+
+	/*
+		we can not have this test with real `User` now, because we can not depend on `User` model (circle-import), so there is no `user` table
+		when the refactor of models are done, this test will be possible to be run then with a real `User` model.
+	*/
+	type User struct {
+		ID   int64
+		Name string
+	}
+	_ = dbEngine.Sync2(&User{})
+
+	list1, _ := FetchIssueContentHistoryList(dbCtx, 10, 0)
+	assert.Len(t, list1, 3)
+	list2, _ := FetchIssueContentHistoryList(dbCtx, 10, 100)
+	assert.Len(t, list2, 5)
+
+	h6, h6Prev, _ := GetIssueContentHistoryAndPrev(dbCtx, 6)
+	assert.EqualValues(t, 6, h6.ID)
+	assert.EqualValues(t, 5, h6Prev.ID)
+
+	// soft-delete
+	_ = SoftDeleteIssueContentHistory(dbCtx, 5)
+	h6, h6Prev, _ = GetIssueContentHistoryAndPrev(dbCtx, 6)
+	assert.EqualValues(t, 6, h6.ID)
+	assert.EqualValues(t, 4, h6Prev.ID)
+
+	// only keep 3 history revisions for comment_id=100
+	keepLimitedContentHistory(dbEngine, 10, 100, 3)
+	list1, _ = FetchIssueContentHistoryList(dbCtx, 10, 0)
+	assert.Len(t, list1, 3)
+	list2, _ = FetchIssueContentHistoryList(dbCtx, 10, 100)
+	assert.Len(t, list2, 3)
+	assert.EqualValues(t, 7, list2[0].HistoryID)
+	assert.EqualValues(t, 6, list2[1].HistoryID)
+	assert.EqualValues(t, 4, list2[2].HistoryID)
+}
diff --git a/models/issues/main_test.go b/models/issues/main_test.go
new file mode 100644
index 0000000000..61a15c53b7
--- /dev/null
+++ b/models/issues/main_test.go
@@ -0,0 +1,16 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package issues
+
+import (
+	"path/filepath"
+	"testing"
+
+	"code.gitea.io/gitea/models/db"
+)
+
+func TestMain(m *testing.M) {
+	db.MainTest(m, filepath.Join("..", ".."), "")
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 6f6296dabf..ef0c071417 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -348,6 +348,8 @@ var migrations = []Migration{
 	NewMigration("Add Color to ProjectBoard table", addColorColToProjectBoard),
 	// v197 -> v198
 	NewMigration("Add renamed_branch table", addRenamedBranchTable),
+	// v198 -> v199
+	NewMigration("Add issue content history table", addTableIssueContentHistory),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v198.go b/models/migrations/v198.go
new file mode 100644
index 0000000000..e3c31460a9
--- /dev/null
+++ b/models/migrations/v198.go
@@ -0,0 +1,33 @@
+// Copyright 2021 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 migrations
+
+import (
+	"fmt"
+
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+func addTableIssueContentHistory(x *xorm.Engine) error {
+	type IssueContentHistory struct {
+		ID             int64 `xorm:"pk autoincr"`
+		PosterID       int64
+		IssueID        int64              `xorm:"INDEX"`
+		CommentID      int64              `xorm:"INDEX"`
+		EditedUnix     timeutil.TimeStamp `xorm:"INDEX"`
+		ContentText    string             `xorm:"LONGTEXT"`
+		IsFirstCreated bool
+		IsDeleted      bool
+	}
+
+	sess := x.NewSession()
+	defer sess.Close()
+	if err := sess.Sync2(new(IssueContentHistory)); err != nil {
+		return fmt.Errorf("Sync2: %v", err)
+	}
+	return sess.Commit()
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index d5af933f40..2d522acb98 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1377,6 +1377,12 @@ issues.review.un_resolve_conversation = Unresolve conversation
 issues.review.resolved_by = marked this conversation as resolved
 issues.assignee.error = Not all assignees was added due to an unexpected error.
 issues.reference_issue.body = Body
+issues.content_history.deleted = deleted
+issues.content_history.edited = edited
+issues.content_history.created = created
+issues.content_history.delete_from_history = Delete from history
+issues.content_history.delete_from_history_confirm = Delete from history?
+issues.content_history.options = Options
 
 compare.compare_base = base
 compare.compare_head = compare
diff --git a/routers/web/repo/issue_content_history.go b/routers/web/repo/issue_content_history.go
new file mode 100644
index 0000000000..c0e958203d
--- /dev/null
+++ b/routers/web/repo/issue_content_history.go
@@ -0,0 +1,206 @@
+// Copyright 2021 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 (
+	"bytes"
+	"fmt"
+	"html"
+	"net/http"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/models/db"
+	issuesModel "code.gitea.io/gitea/models/issues"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"github.com/sergi/go-diff/diffmatchpatch"
+	"github.com/unknwon/i18n"
+)
+
+// GetContentHistoryOverview get overview
+func GetContentHistoryOverview(ctx *context.Context) {
+	issue := GetActionIssue(ctx)
+	if issue == nil {
+		return
+	}
+
+	lang := ctx.Data["Lang"].(string)
+	editedHistoryCountMap, _ := issuesModel.QueryIssueContentHistoryEditedCountMap(db.DefaultContext, issue.ID)
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"i18n": map[string]interface{}{
+			"textEdited":                   i18n.Tr(lang, "repo.issues.content_history.edited"),
+			"textDeleteFromHistory":        i18n.Tr(lang, "repo.issues.content_history.delete_from_history"),
+			"textDeleteFromHistoryConfirm": i18n.Tr(lang, "repo.issues.content_history.delete_from_history_confirm"),
+			"textOptions":                  i18n.Tr(lang, "repo.issues.content_history.options"),
+		},
+		"editedHistoryCountMap": editedHistoryCountMap,
+	})
+}
+
+// GetContentHistoryList  get list
+func GetContentHistoryList(ctx *context.Context) {
+	issue := GetActionIssue(ctx)
+	commentID := ctx.FormInt64("comment_id")
+	if issue == nil {
+		return
+	}
+
+	items, _ := issuesModel.FetchIssueContentHistoryList(db.DefaultContext, issue.ID, commentID)
+
+	// render history list to HTML for frontend dropdown items: (name, value)
+	// name is HTML of "avatar + userName + userAction + timeSince"
+	// value is historyId
+	lang := ctx.Data["Lang"].(string)
+	var results []map[string]interface{}
+	for _, item := range items {
+		var actionText string
+		if item.IsDeleted {
+			actionTextDeleted := i18n.Tr(lang, "repo.issues.content_history.deleted")
+			actionText = "" + actionTextDeleted + ""
+		} else if item.IsFirstCreated {
+			actionText = i18n.Tr(lang, "repo.issues.content_history.created")
+		} else {
+			actionText = i18n.Tr(lang, "repo.issues.content_history.edited")
+		}
+		timeSinceText := timeutil.TimeSinceUnix(item.EditedUnix, lang)
+		results = append(results, map[string]interface{}{
+			"name": fmt.Sprintf("%s %s %s",
+				html.EscapeString(item.UserAvatarLink), html.EscapeString(item.UserName), actionText, timeSinceText),
+			"value": item.HistoryID,
+		})
+	}
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"results": results,
+	})
+}
+
+// canSoftDeleteContentHistory checks whether current user can soft-delete a history revision
+// Admins or owners can always delete history revisions. Normal users can only delete own history revisions.
+func canSoftDeleteContentHistory(ctx *context.Context, issue *models.Issue, comment *models.Comment,
+	history *issuesModel.ContentHistory) bool {
+
+	canSoftDelete := false
+	if ctx.Repo.IsOwner() {
+		canSoftDelete = true
+	} else if ctx.Repo.CanWrite(models.UnitTypeIssues) {
+		canSoftDelete = ctx.User.ID == history.PosterID
+		if comment == nil {
+			canSoftDelete = canSoftDelete && (ctx.User.ID == issue.PosterID)
+			canSoftDelete = canSoftDelete && (history.IssueID == issue.ID)
+		} else {
+			canSoftDelete = canSoftDelete && (ctx.User.ID == comment.PosterID)
+			canSoftDelete = canSoftDelete && (history.IssueID == issue.ID)
+			canSoftDelete = canSoftDelete && (history.CommentID == comment.ID)
+		}
+	}
+	return canSoftDelete
+}
+
+//GetContentHistoryDetail get detail
+func GetContentHistoryDetail(ctx *context.Context) {
+	issue := GetActionIssue(ctx)
+	if issue == nil {
+		return
+	}
+
+	historyID := ctx.FormInt64("history_id")
+	history, prevHistory, err := issuesModel.GetIssueContentHistoryAndPrev(db.DefaultContext, historyID)
+	if err != nil {
+		ctx.JSON(http.StatusNotFound, map[string]interface{}{
+			"message": "Can not find the content history",
+		})
+		return
+	}
+
+	// get the related comment if this history revision is for a comment, otherwise the history revision is for an issue.
+	var comment *models.Comment
+	if history.CommentID != 0 {
+		var err error
+		if comment, err = models.GetCommentByID(history.CommentID); err != nil {
+			log.Error("can not get comment for issue content history %v. err=%v", historyID, err)
+			return
+		}
+	}
+
+	// get the previous history revision (if exists)
+	var prevHistoryID int64
+	var prevHistoryContentText string
+	if prevHistory != nil {
+		prevHistoryID = prevHistory.ID
+		prevHistoryContentText = prevHistory.ContentText
+	}
+
+	// compare the current history revision with the previous one
+	dmp := diffmatchpatch.New()
+	diff := dmp.DiffMain(prevHistoryContentText, history.ContentText, true)
+	diff = dmp.DiffCleanupEfficiency(diff)
+
+	// use chroma to render the diff html
+	diffHTMLBuf := bytes.Buffer{}
+	diffHTMLBuf.WriteString("
")
+	for _, it := range diff {
+		if it.Type == diffmatchpatch.DiffInsert {
+			diffHTMLBuf.WriteString("")
+			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
+			diffHTMLBuf.WriteString("")
+		} else if it.Type == diffmatchpatch.DiffDelete {
+			diffHTMLBuf.WriteString("")
+			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
+			diffHTMLBuf.WriteString("")
+		} else {
+			diffHTMLBuf.WriteString(html.EscapeString(it.Text))
+		}
+	}
+	diffHTMLBuf.WriteString("")
+
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"canSoftDelete": canSoftDeleteContentHistory(ctx, issue, comment, history),
+		"historyId":     historyID,
+		"prevHistoryId": prevHistoryID,
+		"diffHtml":      diffHTMLBuf.String(),
+	})
+}
+
+//SoftDeleteContentHistory soft delete
+func SoftDeleteContentHistory(ctx *context.Context) {
+	issue := GetActionIssue(ctx)
+	if issue == nil {
+		return
+	}
+
+	commentID := ctx.FormInt64("comment_id")
+	historyID := ctx.FormInt64("history_id")
+
+	var comment *models.Comment
+	var history *issuesModel.ContentHistory
+	var err error
+	if commentID != 0 {
+		if comment, err = models.GetCommentByID(commentID); err != nil {
+			log.Error("can not get comment for issue content history %v. err=%v", historyID, err)
+			return
+		}
+	}
+	if history, err = issuesModel.GetIssueContentHistoryByID(db.DefaultContext, historyID); err != nil {
+		log.Error("can not get issue content history %v. err=%v", historyID, err)
+		return
+	}
+
+	canSoftDelete := canSoftDeleteContentHistory(ctx, issue, comment, history)
+	if !canSoftDelete {
+		ctx.JSON(http.StatusForbidden, map[string]interface{}{
+			"message": "Can not delete the content history",
+		})
+		return
+	}
+
+	err = issuesModel.SoftDeleteIssueContentHistory(db.DefaultContext, historyID)
+	log.Debug("soft delete issue content history. issue=%d, comment=%d, history=%d", issue.ID, commentID, historyID)
+	ctx.JSON(http.StatusOK, map[string]interface{}{
+		"ok": err == nil,
+	})
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index b4103ccad3..caec067671 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -732,6 +732,9 @@ func RegisterRoutes(m *web.Route) {
 				m.Get("/attachments", repo.GetIssueAttachments)
 				m.Get("/attachments/{uuid}", repo.GetAttachment)
 			})
+			m.Group("/{index}", func() {
+				m.Post("/content-history/soft-delete", repo.SoftDeleteContentHistory)
+			})
 
 			m.Post("/labels", reqRepoIssuesOrPullsWriter, repo.UpdateIssueLabel)
 			m.Post("/milestone", reqRepoIssuesOrPullsWriter, repo.UpdateIssueMilestone)
@@ -853,6 +856,11 @@ func RegisterRoutes(m *web.Route) {
 		m.Group("", func() {
 			m.Get("/{type:issues|pulls}", repo.Issues)
 			m.Get("/{type:issues|pulls}/{index}", repo.ViewIssue)
+			m.Group("/{type:issues|pulls}/{index}/content-history", func() {
+				m.Get("/overview", repo.GetContentHistoryOverview)
+				m.Get("/list", repo.GetContentHistoryList)
+				m.Get("/detail", repo.GetContentHistoryDetail)
+			})
 			m.Get("/labels", reqRepoIssuesOrPullsReader, repo.RetrieveLabels, repo.Labels)
 			m.Get("/milestones", reqRepoIssuesOrPullsReader, repo.Milestones)
 		}, context.RepoRef())
diff --git a/services/comments/comments.go b/services/comments/comments.go
index d65c66aef2..d1e5ea4d88 100644
--- a/services/comments/comments.go
+++ b/services/comments/comments.go
@@ -7,7 +7,9 @@ package comments
 import (
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/issues"
 	"code.gitea.io/gitea/modules/notification"
+	"code.gitea.io/gitea/modules/timeutil"
 )
 
 // CreateIssueComment creates a plain issue comment.
@@ -23,10 +25,16 @@ func CreateIssueComment(doer *models.User, repo *models.Repository, issue *model
 	if err != nil {
 		return nil, err
 	}
+	err = issues.SaveIssueContentHistory(db.GetEngine(db.DefaultContext), doer.ID, issue.ID, comment.ID, timeutil.TimeStampNow(), comment.Content, true)
+	if err != nil {
+		return nil, err
+	}
+
 	mentions, err := issue.FindAndUpdateIssueMentions(db.DefaultContext, doer, comment.Content)
 	if err != nil {
 		return nil, err
 	}
+
 	notification.NotifyCreateIssueComment(doer, repo, issue, comment, mentions)
 
 	return comment, nil
@@ -38,6 +46,13 @@ func UpdateComment(c *models.Comment, doer *models.User, oldContent string) erro
 		return err
 	}
 
+	if c.Type == models.CommentTypeComment && c.Content != oldContent {
+		err := issues.SaveIssueContentHistory(db.GetEngine(db.DefaultContext), doer.ID, c.IssueID, c.ID, timeutil.TimeStampNow(), c.Content, false)
+		if err != nil {
+			return err
+		}
+	}
+
 	notification.NotifyUpdateComment(doer, c, oldContent)
 
 	return nil
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl
index 872c3b620b..95c9296174 100644
--- a/templates/repo/issue/view_content.tmpl
+++ b/templates/repo/issue/view_content.tmpl
@@ -8,6 +8,13 @@
 		{{template "repo/issue/view_title" .}}
 	{{end}}
 
+	
+	
+	
+	
+	
+	
+
 	{{ $createdStr:= TimeSinceUnix .Issue.CreatedUnix $.Lang }}