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 }}