aboutsummaryrefslogtreecommitdiffstats
path: root/models
diff options
context:
space:
mode:
authorwxiaoguang <wxiaoguang@gmail.com>2021-10-11 06:40:03 +0800
committerGitHub <noreply@github.com>2021-10-10 18:40:03 -0400
commitc5c88f2f18d99a188357e0bcb837f94c9c41e79a (patch)
treeccdd155112a7ebedc93f0eb0310db860fd38b2d2 /models
parentff9a8a22312a653702342ce0a4073ae8fde2b1d4 (diff)
downloadgitea-c5c88f2f18d99a188357e0bcb837f94c9c41e79a.tar.gz
gitea-c5c88f2f18d99a188357e0bcb837f94c9c41e79a.zip
Save and view issue/comment content history (#16909)
* issue content history * Use timeutil.TimeStampNow() for content history time instead of issue/comment.UpdatedUnix (which are not updated in time) * i18n for frontend * refactor * clean up * fix refactor * re-format * temp refactor * follow db refactor * rename IssueContentHistory to ContentHistory, remove empty model tags * fix html * use avatar refactor to generate avatar url * add unit test, keep at most 20 history revisions. * re-format * syntax nit * Add issue content history table * Update models/migrations/v197.go Co-authored-by: 6543 <6543@obermui.de> * fix merge Co-authored-by: zeripath <art27@cantab.net> Co-authored-by: 6543 <6543@obermui.de> Co-authored-by: Lauris BH <lauris@nix.lv>
Diffstat (limited to 'models')
-rw-r--r--models/db/unit_tests.go4
-rw-r--r--models/issue.go22
-rw-r--r--models/issue_comment.go7
-rw-r--r--models/issues/content_history.go230
-rw-r--r--models/issues/content_history_test.go74
-rw-r--r--models/issues/main_test.go16
-rw-r--r--models/migrations/migrations.go2
-rw-r--r--models/migrations/v198.go33
8 files changed, 385 insertions, 3 deletions
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()
+}