Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save and view issue/comment content history #16909

Merged
merged 33 commits into from
Oct 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5c10ada
issue content history
wxiaoguang Aug 31, 2021
c873f1b
Merge branch 'main' into issue-content-history
wxiaoguang Sep 1, 2021
e3a706f
Use timeutil.TimeStampNow() for content history time instead of issue…
wxiaoguang Sep 1, 2021
c33a9f7
i18n for frontend
wxiaoguang Sep 4, 2021
337293e
Merge branch 'main' into issue-content-history
wxiaoguang Sep 4, 2021
58224f4
refactor
wxiaoguang Sep 5, 2021
f9ab9f5
Merge branch 'issue-content-history' of github.com:wxiaoguang/gitea i…
wxiaoguang Sep 5, 2021
7952ccf
clean up
wxiaoguang Sep 7, 2021
177e3ac
Merge remote-tracking branch 'go-gitea/main' into issue-content-history
wxiaoguang Sep 7, 2021
fd792fd
Merge branch 'main' into issue-content-history
zeripath Sep 14, 2021
d52c9cd
Merge branch 'main' into issue-content-history
wxiaoguang Sep 18, 2021
2afea36
Merge branch 'main' into issue-content-history
wxiaoguang Sep 22, 2021
7f71a28
fix refactor
wxiaoguang Sep 22, 2021
88e81f0
re-format
wxiaoguang Sep 22, 2021
25573a9
temp refactor
wxiaoguang Sep 22, 2021
c7fe79d
Merge branch 'main' into issue-content-history
wxiaoguang Sep 26, 2021
b21a41b
follow db refactor
wxiaoguang Sep 26, 2021
8416fc8
rename IssueContentHistory to ContentHistory, remove empty model tags
wxiaoguang Sep 26, 2021
d8a63af
fix html
wxiaoguang Sep 26, 2021
bdbec82
Merge branch 'main' into issue-content-history
wxiaoguang Sep 27, 2021
7160036
Merge branch 'main' into issue-content-history
wxiaoguang Oct 6, 2021
54ad038
use avatar refactor to generate avatar url
wxiaoguang Oct 6, 2021
9f96c7b
add unit test, keep at most 20 history revisions.
wxiaoguang Oct 8, 2021
3f23204
Merge branch 'main' into issue-content-history
wxiaoguang Oct 8, 2021
a70920d
re-format
wxiaoguang Oct 8, 2021
78cd825
syntax nit
6543 Oct 8, 2021
85d8493
Merge branch 'main' into issue-content-history
lafriks Oct 8, 2021
5e7daaa
Add issue content history table
wxiaoguang Oct 8, 2021
f613828
Merge remote-tracking branch 'go-gitea/main' into issue-content-history
wxiaoguang Oct 8, 2021
f37c287
Update models/migrations/v197.go
wxiaoguang Oct 8, 2021
357a5e5
Merge branch 'main' into issue-content-history
wxiaoguang Oct 8, 2021
a5f551e
fix merge
wxiaoguang Oct 8, 2021
578cc71
Merge branch 'main' into issue-content-history
lafriks Oct 10, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion models/db/unit_tests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}

Expand Down
22 changes: 20 additions & 2 deletions models/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions models/issue_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
230 changes: 230 additions & 0 deletions models/issues/content_history.go
Original file line number Diff line number Diff line change
@@ -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 {
wxiaoguang marked this conversation as resolved.
Show resolved Hide resolved
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
}
74 changes: 74 additions & 0 deletions models/issues/content_history_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading