Skip to content

Commit c5c88f2

Browse files
wxiaoguangzeripath6543lafriks
authored
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 <[email protected]> * fix merge Co-authored-by: zeripath <[email protected]> Co-authored-by: 6543 <[email protected]> Co-authored-by: Lauris BH <[email protected]>
1 parent ff9a8a2 commit c5c88f2

File tree

17 files changed

+766
-8
lines changed

17 files changed

+766
-8
lines changed

models/db/unit_tests.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ func MainTest(m *testing.M, pathToGiteaRoot string, fixtureFiles ...string) {
5454
opts.Dir = fixturesDir
5555
} else {
5656
for _, f := range fixtureFiles {
57-
opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
57+
if len(f) != 0 {
58+
opts.Files = append(opts.Files, filepath.Join(fixturesDir, f))
59+
}
5860
}
5961
}
6062

models/issue.go

+20-2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strings"
1515

1616
"code.gitea.io/gitea/models/db"
17+
"code.gitea.io/gitea/models/issues"
1718
"code.gitea.io/gitea/modules/base"
1819
"code.gitea.io/gitea/modules/log"
1920
"code.gitea.io/gitea/modules/references"
@@ -803,8 +804,13 @@ func (issue *Issue) ChangeContent(doer *User, content string) (err error) {
803804
return fmt.Errorf("UpdateIssueCols: %v", err)
804805
}
805806

806-
if err = issue.addCrossReferences(db.GetEngine(ctx), doer, true); err != nil {
807-
return err
807+
if err = issues.SaveIssueContentHistory(db.GetEngine(ctx), issue.PosterID, issue.ID, 0,
808+
timeutil.TimeStampNow(), issue.Content, false); err != nil {
809+
return fmt.Errorf("SaveIssueContentHistory: %v", err)
810+
}
811+
812+
if err = issue.addCrossReferences(ctx.Engine(), doer, true); err != nil {
813+
return fmt.Errorf("addCrossReferences: %v", err)
808814
}
809815

810816
return committer.Commit()
@@ -972,6 +978,12 @@ func newIssue(e db.Engine, doer *User, opts NewIssueOptions) (err error) {
972978
if err = opts.Issue.loadAttributes(e); err != nil {
973979
return err
974980
}
981+
982+
if err = issues.SaveIssueContentHistory(e, opts.Issue.PosterID, opts.Issue.ID, 0,
983+
timeutil.TimeStampNow(), opts.Issue.Content, true); err != nil {
984+
return err
985+
}
986+
975987
return opts.Issue.addCrossReferences(e, doer, false)
976988
}
977989

@@ -2132,6 +2144,12 @@ func UpdateReactionsMigrationsByType(gitServiceType structs.GitServiceType, orig
21322144
func deleteIssuesByRepoID(sess db.Engine, repoID int64) (attachmentPaths []string, err error) {
21332145
deleteCond := builder.Select("id").From("issue").Where(builder.Eq{"issue.repo_id": repoID})
21342146

2147+
// Delete content histories
2148+
if _, err = sess.In("issue_id", deleteCond).
2149+
Delete(&issues.ContentHistory{}); err != nil {
2150+
return
2151+
}
2152+
21352153
// Delete comments and attachments
21362154
if _, err = sess.In("issue_id", deleteCond).
21372155
Delete(&Comment{}); err != nil {

models/issue_comment.go

+7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"unicode/utf8"
1515

1616
"code.gitea.io/gitea/models/db"
17+
"code.gitea.io/gitea/models/issues"
1718
"code.gitea.io/gitea/modules/git"
1819
"code.gitea.io/gitea/modules/json"
1920
"code.gitea.io/gitea/modules/log"
@@ -1083,6 +1084,12 @@ func deleteComment(e db.Engine, comment *Comment) error {
10831084
return err
10841085
}
10851086

1087+
if _, err := e.Delete(&issues.ContentHistory{
1088+
CommentID: comment.ID,
1089+
}); err != nil {
1090+
return err
1091+
}
1092+
10861093
if comment.Type == CommentTypeComment {
10871094
if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil {
10881095
return err

models/issues/content_history.go

+230
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package issues
6+
7+
import (
8+
"context"
9+
"fmt"
10+
11+
"code.gitea.io/gitea/models/avatars"
12+
"code.gitea.io/gitea/models/db"
13+
"code.gitea.io/gitea/modules/log"
14+
"code.gitea.io/gitea/modules/timeutil"
15+
16+
"xorm.io/builder"
17+
)
18+
19+
// ContentHistory save issue/comment content history revisions.
20+
type ContentHistory struct {
21+
ID int64 `xorm:"pk autoincr"`
22+
PosterID int64
23+
IssueID int64 `xorm:"INDEX"`
24+
CommentID int64 `xorm:"INDEX"`
25+
EditedUnix timeutil.TimeStamp `xorm:"INDEX"`
26+
ContentText string `xorm:"LONGTEXT"`
27+
IsFirstCreated bool
28+
IsDeleted bool
29+
}
30+
31+
// TableName provides the real table name
32+
func (m *ContentHistory) TableName() string {
33+
return "issue_content_history"
34+
}
35+
36+
func init() {
37+
db.RegisterModel(new(ContentHistory))
38+
}
39+
40+
// SaveIssueContentHistory save history
41+
func SaveIssueContentHistory(e db.Engine, posterID, issueID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error {
42+
ch := &ContentHistory{
43+
PosterID: posterID,
44+
IssueID: issueID,
45+
CommentID: commentID,
46+
ContentText: contentText,
47+
EditedUnix: editTime,
48+
IsFirstCreated: isFirstCreated,
49+
}
50+
_, err := e.Insert(ch)
51+
if err != nil {
52+
log.Error("can not save issue content history. err=%v", err)
53+
return err
54+
}
55+
// We only keep at most 20 history revisions now. It is enough in most cases.
56+
// If there is a special requirement to keep more, we can consider introducing a new setting option then, but not now.
57+
keepLimitedContentHistory(e, issueID, commentID, 20)
58+
return nil
59+
}
60+
61+
// keepLimitedContentHistory keeps at most `limit` history revisions, it will hard delete out-dated revisions, sorting by revision interval
62+
// we can ignore all errors in this function, so we just log them
63+
func keepLimitedContentHistory(e db.Engine, issueID, commentID int64, limit int) {
64+
type IDEditTime struct {
65+
ID int64
66+
EditedUnix timeutil.TimeStamp
67+
}
68+
69+
var res []*IDEditTime
70+
err := e.Select("id, edited_unix").Table("issue_content_history").
71+
Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
72+
OrderBy("edited_unix ASC").
73+
Find(&res)
74+
if err != nil {
75+
log.Error("can not query content history for deletion, err=%v", err)
76+
return
77+
}
78+
if len(res) <= 1 {
79+
return
80+
}
81+
82+
outDatedCount := len(res) - limit
83+
for outDatedCount > 0 {
84+
var indexToDelete int
85+
minEditedInterval := -1
86+
// find a history revision with minimal edited interval to delete
87+
for i := 1; i < len(res); i++ {
88+
editedInterval := int(res[i].EditedUnix - res[i-1].EditedUnix)
89+
if minEditedInterval == -1 || editedInterval < minEditedInterval {
90+
minEditedInterval = editedInterval
91+
indexToDelete = i
92+
}
93+
}
94+
if indexToDelete == 0 {
95+
break
96+
}
97+
98+
// hard delete the found one
99+
_, err = e.Delete(&ContentHistory{ID: res[indexToDelete].ID})
100+
if err != nil {
101+
log.Error("can not delete out-dated content history, err=%v", err)
102+
break
103+
}
104+
res = append(res[:indexToDelete], res[indexToDelete+1:]...)
105+
outDatedCount--
106+
}
107+
}
108+
109+
// QueryIssueContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main issue)
110+
// only return the count map for "edited" (history revision count > 1) issues or comments.
111+
func QueryIssueContentHistoryEditedCountMap(dbCtx context.Context, issueID int64) (map[int64]int, error) {
112+
type HistoryCountRecord struct {
113+
CommentID int64
114+
HistoryCount int
115+
}
116+
records := make([]*HistoryCountRecord, 0)
117+
118+
err := db.GetEngine(dbCtx).Select("comment_id, COUNT(1) as history_count").
119+
Table("issue_content_history").
120+
Where(builder.Eq{"issue_id": issueID}).
121+
GroupBy("comment_id").
122+
Having("history_count > 1").
123+
Find(&records)
124+
if err != nil {
125+
log.Error("can not query issue content history count map. err=%v", err)
126+
return nil, err
127+
}
128+
129+
res := map[int64]int{}
130+
for _, r := range records {
131+
res[r.CommentID] = r.HistoryCount
132+
}
133+
return res, nil
134+
}
135+
136+
// IssueContentListItem the list for web ui
137+
type IssueContentListItem struct {
138+
UserID int64
139+
UserName string
140+
UserAvatarLink string
141+
142+
HistoryID int64
143+
EditedUnix timeutil.TimeStamp
144+
IsFirstCreated bool
145+
IsDeleted bool
146+
}
147+
148+
// FetchIssueContentHistoryList fetch list
149+
func FetchIssueContentHistoryList(dbCtx context.Context, issueID int64, commentID int64) ([]*IssueContentListItem, error) {
150+
res := make([]*IssueContentListItem, 0)
151+
err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name,"+
152+
"h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted").
153+
Table([]string{"issue_content_history", "h"}).
154+
Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id").
155+
Where(builder.Eq{"issue_id": issueID, "comment_id": commentID}).
156+
OrderBy("edited_unix DESC").
157+
Find(&res)
158+
159+
if err != nil {
160+
log.Error("can not fetch issue content history list. err=%v", err)
161+
return nil, err
162+
}
163+
164+
for _, item := range res {
165+
item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0)
166+
}
167+
return res, nil
168+
}
169+
170+
//SoftDeleteIssueContentHistory soft delete
171+
func SoftDeleteIssueContentHistory(dbCtx context.Context, historyID int64) error {
172+
if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ContentHistory{
173+
IsDeleted: true,
174+
ContentText: "",
175+
}); err != nil {
176+
log.Error("failed to soft delete issue content history. err=%v", err)
177+
return err
178+
}
179+
return nil
180+
}
181+
182+
// ErrIssueContentHistoryNotExist not exist error
183+
type ErrIssueContentHistoryNotExist struct {
184+
ID int64
185+
}
186+
187+
// Error error string
188+
func (err ErrIssueContentHistoryNotExist) Error() string {
189+
return fmt.Sprintf("issue content history does not exist [id: %d]", err.ID)
190+
}
191+
192+
// GetIssueContentHistoryByID get issue content history
193+
func GetIssueContentHistoryByID(dbCtx context.Context, id int64) (*ContentHistory, error) {
194+
h := &ContentHistory{}
195+
has, err := db.GetEngine(dbCtx).ID(id).Get(h)
196+
if err != nil {
197+
return nil, err
198+
} else if !has {
199+
return nil, ErrIssueContentHistoryNotExist{id}
200+
}
201+
return h, nil
202+
}
203+
204+
// GetIssueContentHistoryAndPrev get a history and the previous non-deleted history (to compare)
205+
func GetIssueContentHistoryAndPrev(dbCtx context.Context, id int64) (history, prevHistory *ContentHistory, err error) {
206+
history = &ContentHistory{}
207+
has, err := db.GetEngine(dbCtx).ID(id).Get(history)
208+
if err != nil {
209+
log.Error("failed to get issue content history %v. err=%v", id, err)
210+
return nil, nil, err
211+
} else if !has {
212+
log.Error("issue content history does not exist. id=%v. err=%v", id, err)
213+
return nil, nil, &ErrIssueContentHistoryNotExist{id}
214+
}
215+
216+
prevHistory = &ContentHistory{}
217+
has, err = db.GetEngine(dbCtx).Where(builder.Eq{"issue_id": history.IssueID, "comment_id": history.CommentID, "is_deleted": false}).
218+
And(builder.Lt{"edited_unix": history.EditedUnix}).
219+
OrderBy("edited_unix DESC").Limit(1).
220+
Get(prevHistory)
221+
222+
if err != nil {
223+
log.Error("failed to get issue content history %v. err=%v", id, err)
224+
return nil, nil, err
225+
} else if !has {
226+
return history, nil, nil
227+
}
228+
229+
return history, prevHistory, nil
230+
}

models/issues/content_history_test.go

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2021 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package issues
6+
7+
import (
8+
"testing"
9+
10+
"code.gitea.io/gitea/models/db"
11+
"code.gitea.io/gitea/modules/timeutil"
12+
13+
"github.com/stretchr/testify/assert"
14+
)
15+
16+
func TestContentHistory(t *testing.T) {
17+
assert.NoError(t, db.PrepareTestDatabase())
18+
19+
dbCtx := db.DefaultContext
20+
dbEngine := db.GetEngine(dbCtx)
21+
timeStampNow := timeutil.TimeStampNow()
22+
23+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow, "i-a", true)
24+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow.Add(2), "i-b", false)
25+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 0, timeStampNow.Add(7), "i-c", false)
26+
27+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow, "c-a", true)
28+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(5), "c-b", false)
29+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(20), "c-c", false)
30+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(50), "c-d", false)
31+
_ = SaveIssueContentHistory(dbEngine, 1, 10, 100, timeStampNow.Add(51), "c-e", false)
32+
33+
h1, _ := GetIssueContentHistoryByID(dbCtx, 1)
34+
assert.EqualValues(t, 1, h1.ID)
35+
36+
m, _ := QueryIssueContentHistoryEditedCountMap(dbCtx, 10)
37+
assert.Equal(t, 3, m[0])
38+
assert.Equal(t, 5, m[100])
39+
40+
/*
41+
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
42+
when the refactor of models are done, this test will be possible to be run then with a real `User` model.
43+
*/
44+
type User struct {
45+
ID int64
46+
Name string
47+
}
48+
_ = dbEngine.Sync2(&User{})
49+
50+
list1, _ := FetchIssueContentHistoryList(dbCtx, 10, 0)
51+
assert.Len(t, list1, 3)
52+
list2, _ := FetchIssueContentHistoryList(dbCtx, 10, 100)
53+
assert.Len(t, list2, 5)
54+
55+
h6, h6Prev, _ := GetIssueContentHistoryAndPrev(dbCtx, 6)
56+
assert.EqualValues(t, 6, h6.ID)
57+
assert.EqualValues(t, 5, h6Prev.ID)
58+
59+
// soft-delete
60+
_ = SoftDeleteIssueContentHistory(dbCtx, 5)
61+
h6, h6Prev, _ = GetIssueContentHistoryAndPrev(dbCtx, 6)
62+
assert.EqualValues(t, 6, h6.ID)
63+
assert.EqualValues(t, 4, h6Prev.ID)
64+
65+
// only keep 3 history revisions for comment_id=100
66+
keepLimitedContentHistory(dbEngine, 10, 100, 3)
67+
list1, _ = FetchIssueContentHistoryList(dbCtx, 10, 0)
68+
assert.Len(t, list1, 3)
69+
list2, _ = FetchIssueContentHistoryList(dbCtx, 10, 100)
70+
assert.Len(t, list2, 3)
71+
assert.EqualValues(t, 7, list2[0].HistoryID)
72+
assert.EqualValues(t, 6, list2[1].HistoryID)
73+
assert.EqualValues(t, 4, list2[2].HistoryID)
74+
}

0 commit comments

Comments
 (0)