From 6051ff8525381cea36e2f22d496a2ed76370ea75 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 21 Oct 2024 00:38:17 +0800 Subject: [PATCH 01/72] Decouple conversations from issues --- routers/web/repo/issue.go | 2 + routers/web/repo/issue_watch.go | 2 + routers/web/repo/pull.go | 2 + routers/web/repo/pull_review.go | 6 +- templates/repo/commit_page.tmpl | 1 + templates/repo/conversation/comment_form.tmpl | 64 ++++++++ templates/repo/conversation/comment_tab.tmpl | 21 +++ .../comments.tmpl | 21 +-- .../comments_authorlink.tmpl | 0 .../comments_delete_time.tmpl | 0 templates/repo/conversation/conversation.tmpl | 17 +++ .../conversation/issue_header_comment.tmpl | 65 ++++++++ templates/repo/issue/comment_tab.tmpl | 22 +-- templates/repo/issue/view_content.tmpl | 133 +--------------- .../repo/issue/view_content/conversation.tmpl | 143 ------------------ 15 files changed, 192 insertions(+), 307 deletions(-) create mode 100644 templates/repo/conversation/comment_form.tmpl create mode 100644 templates/repo/conversation/comment_tab.tmpl rename templates/repo/{issue/view_content => conversation}/comments.tmpl (97%) rename templates/repo/{issue/view_content => conversation}/comments_authorlink.tmpl (100%) rename templates/repo/{issue/view_content => conversation}/comments_delete_time.tmpl (100%) create mode 100644 templates/repo/conversation/conversation.tmpl create mode 100644 templates/repo/conversation/issue_header_comment.tmpl delete mode 100644 templates/repo/issue/view_content/conversation.tmpl diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 507b5af9d904a..d8b65c1882294 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2037,6 +2037,8 @@ func ViewIssue(ctx *context.Context) { ctx.Data["Participants"] = participants ctx.Data["NumParticipants"] = len(participants) ctx.Data["Issue"] = issue + ctx.Data["IsIssue"] = true + ctx.Data["Comments"] = issue.Comments ctx.Data["Reference"] = issue.Ref ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(ctx.Data["Link"].(string)) ctx.Data["IsIssuePoster"] = ctx.IsSigned && issue.IsPoster(ctx.Doer.ID) diff --git a/routers/web/repo/issue_watch.go b/routers/web/repo/issue_watch.go index 8b033f3b17a80..2fff94720b904 100644 --- a/routers/web/repo/issue_watch.go +++ b/routers/web/repo/issue_watch.go @@ -58,6 +58,8 @@ func IssueWatch(ctx *context.Context) { } ctx.Data["Issue"] = issue + ctx.Data["IsIssue"] = true + ctx.Data["Comments"] = issue.Comments ctx.Data["IssueWatch"] = &issues_model.IssueWatch{IsWatching: watch} ctx.HTML(http.StatusOK, tplWatching) } diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go index 0efe1be76a310..ad92bf1f0c4ca 100644 --- a/routers/web/repo/pull.go +++ b/routers/web/repo/pull.go @@ -128,6 +128,8 @@ func getPullInfo(ctx *context.Context) (issue *issues_model.Issue, ok bool) { } ctx.Data["Title"] = fmt.Sprintf("#%d - %s", issue.Index, emoji.ReplaceAliases(issue.Title)) ctx.Data["Issue"] = issue + ctx.Data["IsIssue"] = true + ctx.Data["Comments"] = issue.Comments if !issue.IsPull { ctx.NotFound("ViewPullCommits", nil) diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 62f6d71c5e5cf..51858724bec08 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -26,7 +26,7 @@ import ( const ( tplDiffConversation base.TplName = "repo/diff/conversation" tplConversationOutdated base.TplName = "repo/diff/conversation_outdated" - tplTimelineConversation base.TplName = "repo/issue/view_content/conversation" + tplTimelineConversation base.TplName = "repo/conversation/conversation" tplNewComment base.TplName = "repo/diff/new_comment" ) @@ -46,6 +46,8 @@ func RenderNewCodeCommentForm(ctx *context.Context) { } ctx.Data["PageIsPullFiles"] = true ctx.Data["Issue"] = issue + ctx.Data["IsIssue"] = true + ctx.Data["Comments"] = issue.Comments ctx.Data["CurrentReview"] = currentReview pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(issue.PullRequest.GetGitRefName()) if err != nil { @@ -193,6 +195,8 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori return } ctx.Data["Issue"] = comment.Issue + ctx.Data["IsIssue"] = true + ctx.Data["Comments"] = comment.Issue.Comments if err = comment.Issue.LoadPullRequest(ctx); err != nil { ctx.ServerError("comment.Issue.LoadPullRequest", err) return diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index b8195ac544983..5399f4c01e8fb 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -281,5 +281,6 @@ {{end}} {{template "repo/diff/box" .}} + {{template "repo/conversation/conversation" .}} {{template "base/footer" .}} diff --git a/templates/repo/conversation/comment_form.tmpl b/templates/repo/conversation/comment_form.tmpl new file mode 100644 index 0000000000000..147597e14f3d0 --- /dev/null +++ b/templates/repo/conversation/comment_form.tmpl @@ -0,0 +1,64 @@ + + + +{{if .IsSigned}} + {{if and (or .IsRepoAdmin .HasIssuesOrPullsWritePermission (not (and .IsIssue .Issue.IsLocked))) (not .Repository.IsArchived)}} +
+ + {{ctx.AvatarUtils.Avatar .SignedUser 40}} + +
+
+
+ {{template "repo/conversation/comment_tab" .}} + {{.CsrfTokenHtml}} + +
+
+
+
+ {{else if .Repository.IsArchived}} +
+ {{if and .IsIssue .Issue.IsPull}} + {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} + {{else}} + {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} + {{end}} +
+ {{end}} +{{else}} {{/* not .IsSigned */}} + {{if .Repository.IsArchived}} +
+ {{if .IsIssue and .Issue.IsPull}} + {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} + {{else}} + {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} + {{end}} +
+ {{else}} +
+ {{ctx.Locale.Tr "repo.issues.sign_in_require_desc" .SignInLink}} +
+ {{end}} +{{end}}{{/* end if: .IsSigned */}} \ No newline at end of file diff --git a/templates/repo/conversation/comment_tab.tmpl b/templates/repo/conversation/comment_tab.tmpl new file mode 100644 index 0000000000000..4197ea4f657f7 --- /dev/null +++ b/templates/repo/conversation/comment_tab.tmpl @@ -0,0 +1,21 @@ +{{$textareaContent := .BodyQuery}} +{{if not $textareaContent}}{{$textareaContent = .IssueTemplate}}{{end}} +{{if not $textareaContent}}{{$textareaContent = .PullRequestTemplate}}{{end}} +{{if not $textareaContent}}{{$textareaContent = .content}}{{end}} + +
+ {{template "shared/combomarkdowneditor" (dict + "MarkdownPreviewUrl" (print .Repository.Link "/markup") + "MarkdownPreviewContext" .RepoLink + "TextareaName" "content" + "TextareaContent" $textareaContent + "TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder") + "DropzoneParentContainer" "form, .ui.form" + )}} +
+ +{{if .IsAttachmentEnabled}} +
+ {{template "repo/upload" .}} +
+{{end}} diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/conversation/comments.tmpl similarity index 97% rename from templates/repo/issue/view_content/comments.tmpl rename to templates/repo/conversation/comments.tmpl index 57abbeb8f7960..11a437c89f7c4 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/conversation/comments.tmpl @@ -1,5 +1,6 @@ + {{template "base/alert"}} -{{range .Issue.Comments}} +{{range .Comments}} {{if call $.ShouldShowCommentType .Type}} {{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} @@ -85,7 +86,7 @@ {{template "shared/user/avatarlink" dict "user" .Poster}} {{end}} - {{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_authorlink" dict "ctxData" $ "comment" .}} {{if .Issue.IsPull}} {{ctx.Locale.Tr "repo.pulls.reopened_at" .EventTag $createdStr}} {{else}} @@ -100,7 +101,7 @@ {{template "shared/user/avatarlink" dict "user" .Poster}} {{end}} - {{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_authorlink" dict "ctxData" $ "comment" .}} {{if .Issue.IsPull}} {{ctx.Locale.Tr "repo.pulls.closed_at" .EventTag $createdStr}} {{else}} @@ -115,7 +116,7 @@ {{template "shared/user/avatarlink" dict "user" .Poster}} {{end}} - {{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_authorlink" dict "ctxData" $ "comment" .}} {{$link := printf "%s/commit/%s" $.Repository.Link ($.Issue.PullRequest.MergedCommitID|PathEscape)}} {{if eq $.Issue.PullRequest.Status 3}} {{ctx.Locale.Tr "repo.issues.comment_manually_pull_merged_at" (HTMLFormat `%[2]s` $link (ShortSha $.Issue.PullRequest.MergedCommitID)) (HTMLFormat "%[1]s" $.BaseTarget) $createdStr}} @@ -251,7 +252,7 @@ {{template "shared/user/authorlink" .Poster}} {{ctx.Locale.Tr "repo.issues.stop_tracking_history" $createdStr}} - {{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_delete_time" dict "ctxData" $ "comment" .}}
{{svg "octicon-clock"}} {{if .RenderedContent}} @@ -270,7 +271,7 @@ {{template "shared/user/authorlink" .Poster}} {{ctx.Locale.Tr "repo.issues.add_time_history" $createdStr}} - {{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_delete_time" dict "ctxData" $ "comment" .}}
{{svg "octicon-clock"}} {{if .RenderedContent}} @@ -384,7 +385,7 @@ {{if .Review}}{{svg (printf "octicon-%s" .Review.Type.Icon)}}{{end}} - {{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_authorlink" dict "ctxData" $ "comment" .}} {{if eq $reviewType 1}} {{ctx.Locale.Tr "repo.issues.review.approve" $createdStr}} {{else if eq $reviewType 2}} @@ -458,7 +459,7 @@
{{range $filename, $lines := .Review.CodeComments}} {{range $line, $comms := $lines}} - {{template "repo/issue/view_content/conversation" dict "." $ "comments" $comms}} + {{template "repo/conversation/conversation" dict "." $ "comments" $comms}} {{end}} {{end}}
@@ -496,7 +497,7 @@ {{template "shared/user/avatarlink" dict "user" .Poster}} {{end}} - {{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_authorlink" dict "ctxData" $ "comment" .}} {{ctx.Locale.Tr "repo.pulls.change_target_branch_at" .OldRef .NewRef $createdStr}}
@@ -685,7 +686,7 @@
{{svg "octicon-git-merge" 16}} - {{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}} + {{template "repo/conversation/comments_authorlink" dict "ctxData" $ "comment" .}} {{if eq .Type 34}}{{ctx.Locale.Tr "repo.pulls.auto_merge_newly_scheduled_comment" $createdStr}} {{else}}{{ctx.Locale.Tr "repo.pulls.auto_merge_canceled_schedule_comment" $createdStr}}{{end}} diff --git a/templates/repo/issue/view_content/comments_authorlink.tmpl b/templates/repo/conversation/comments_authorlink.tmpl similarity index 100% rename from templates/repo/issue/view_content/comments_authorlink.tmpl rename to templates/repo/conversation/comments_authorlink.tmpl diff --git a/templates/repo/issue/view_content/comments_delete_time.tmpl b/templates/repo/conversation/comments_delete_time.tmpl similarity index 100% rename from templates/repo/issue/view_content/comments_delete_time.tmpl rename to templates/repo/conversation/comments_delete_time.tmpl diff --git a/templates/repo/conversation/conversation.tmpl b/templates/repo/conversation/conversation.tmpl new file mode 100644 index 0000000000000..e2ae460825fcf --- /dev/null +++ b/templates/repo/conversation/conversation.tmpl @@ -0,0 +1,17 @@ + + +
+ {{if .IsIssue}} + {{template "repo/conversation/issue_header_comment" .}} + {{end}} + + {{template "repo/conversation/comments" .}} + + {{if .IsIssue}} + {{if and .Issue.IsPull (not $.Repository.IsArchived)}} + {{template "repo/issue/view_content/pull".}} + {{end}} + {{end}} + + {{template "repo/conversation/comment_form" .}} +
\ No newline at end of file diff --git a/templates/repo/conversation/issue_header_comment.tmpl b/templates/repo/conversation/issue_header_comment.tmpl new file mode 100644 index 0000000000000..73802bc6d2032 --- /dev/null +++ b/templates/repo/conversation/issue_header_comment.tmpl @@ -0,0 +1,65 @@ + + +
+ {{$createdStr:= TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} + {{if .Issue.OriginalAuthor}} + + {{ctx.AvatarUtils.Avatar nil 40}} + + {{else}} + + {{ctx.AvatarUtils.Avatar .Issue.Poster 40}} + + {{end}} +
+
+
+ {{if .Issue.OriginalAuthor}} + + {{svg (MigrationIcon .Repository.GetOriginalURLHostname)}} + {{.Issue.OriginalAuthor}} + + + {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} + + + {{if .Repository.OriginalURL}} ({{ctx.Locale.Tr "repo.migrated_from" .Repository.OriginalURL .Repository.GetOriginalURLHostname}}){{end}} + + {{else}} + + {{ctx.AvatarUtils.Avatar .Issue.Poster 24}} + + + {{template "shared/user/authorlink" .Issue.Poster}} + {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} + + {{end}} +
+
+ {{template "repo/issue/view_content/show_role" dict "ShowRole" .Issue.ShowRole "IgnorePoster" true}} + {{if not $.Repository.IsArchived}} + {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}} + {{end}} + {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}} +
+
+
+
+ {{if .Issue.RenderedContent}} + {{.Issue.RenderedContent}} + {{else}} + {{ctx.Locale.Tr "repo.issues.no_content"}} + {{end}} +
+
{{.Issue.Content}}
+
+ {{if .Issue.Attachments}} + {{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}} + {{end}} +
+ {{$reactions := .Issue.Reactions.GroupByType}} + {{if $reactions}} + {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions}} + {{end}} +
+
\ No newline at end of file diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 4197ea4f657f7..e6bae029d231d 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1,21 +1 @@ -{{$textareaContent := .BodyQuery}} -{{if not $textareaContent}}{{$textareaContent = .IssueTemplate}}{{end}} -{{if not $textareaContent}}{{$textareaContent = .PullRequestTemplate}}{{end}} -{{if not $textareaContent}}{{$textareaContent = .content}}{{end}} - -
- {{template "shared/combomarkdowneditor" (dict - "MarkdownPreviewUrl" (print .Repository.Link "/markup") - "MarkdownPreviewContext" .RepoLink - "TextareaName" "content" - "TextareaContent" $textareaContent - "TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder") - "DropzoneParentContainer" "form, .ui.form" - )}} -
- -{{if .IsAttachmentEnabled}} -
- {{template "repo/upload" .}} -
-{{end}} +{{template "repo/conversation/comment_tab" .}} \ No newline at end of file diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index e4213b8fcd6b3..dda29954843b6 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -8,138 +8,7 @@ {{$createdStr:= TimeSinceUnix .Issue.CreatedUnix ctx.Locale}}
-
-
- {{if .Issue.OriginalAuthor}} - - {{ctx.AvatarUtils.Avatar nil 40}} - - {{else}} - - {{ctx.AvatarUtils.Avatar .Issue.Poster 40}} - - {{end}} -
-
-
- {{if .Issue.OriginalAuthor}} - - {{svg (MigrationIcon .Repository.GetOriginalURLHostname)}} - {{.Issue.OriginalAuthor}} - - - {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} - - - {{if .Repository.OriginalURL}} ({{ctx.Locale.Tr "repo.migrated_from" .Repository.OriginalURL .Repository.GetOriginalURLHostname}}){{end}} - - {{else}} - - {{ctx.AvatarUtils.Avatar .Issue.Poster 24}} - - - {{template "shared/user/authorlink" .Issue.Poster}} - {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} - - {{end}} -
-
- {{template "repo/issue/view_content/show_role" dict "ShowRole" .Issue.ShowRole "IgnorePoster" true}} - {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}} - {{end}} - {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}} -
-
-
-
- {{if .Issue.RenderedContent}} - {{.Issue.RenderedContent}} - {{else}} - {{ctx.Locale.Tr "repo.issues.no_content"}} - {{end}} -
-
{{.Issue.Content}}
-
- {{if .Issue.Attachments}} - {{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}} - {{end}} -
- {{$reactions := .Issue.Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions}} - {{end}} -
-
- - {{template "repo/issue/view_content/comments" .}} - - {{if and .Issue.IsPull (not $.Repository.IsArchived)}} - {{template "repo/issue/view_content/pull".}} - {{end}} - - {{if .IsSigned}} - {{if and (or .IsRepoAdmin .HasIssuesOrPullsWritePermission (not .Issue.IsLocked)) (not .Repository.IsArchived)}} -
- - {{ctx.AvatarUtils.Avatar .SignedUser 40}} - -
-
-
- {{template "repo/issue/comment_tab" .}} - {{.CsrfTokenHtml}} - -
-
-
-
- {{else if .Repository.IsArchived}} -
- {{if .Issue.IsPull}} - {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} - {{else}} - {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} - {{end}} -
- {{end}} - {{else}} {{/* not .IsSigned */}} - {{if .Repository.IsArchived}} -
- {{if .Issue.IsPull}} - {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} - {{else}} - {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} - {{end}} -
- {{else}} -
- {{ctx.Locale.Tr "repo.issues.sign_in_require_desc" .SignInLink}} -
- {{end}} - {{end}}{{/* end if: .IsSigned */}} -
+ {{template "repo/conversation/conversation" .}}
{{template "repo/issue/view_content/sidebar" .}} diff --git a/templates/repo/issue/view_content/conversation.tmpl b/templates/repo/issue/view_content/conversation.tmpl deleted file mode 100644 index ccea9b690d7e8..0000000000000 --- a/templates/repo/issue/view_content/conversation.tmpl +++ /dev/null @@ -1,143 +0,0 @@ -{{if len .comments}} - {{$comment := index .comments 0}} - {{$invalid := $comment.Invalidated}} - {{$resolved := $comment.IsResolved}} - {{$resolveDoer := $comment.ResolveDoer}} - {{$hasReview := and $comment.Review}} - {{$isReviewPending := and $hasReview (eq $comment.Review.Type 0)}} -
-
-
- {{$comment.TreePath}} - {{if $invalid}} - - {{ctx.Locale.Tr "repo.issues.review.outdated"}} - - {{end}} -
-
- {{if or $invalid $resolved}} - - - {{end}} -
-
- {{$diff := (CommentMustAsDiff ctx $comment)}} - {{if $diff}} - {{$file := (index $diff.Files 0)}} -
-
-
- - - {{template "repo/diff/section_unified" dict "file" $file "root" $}} - -
-
-
-
- {{end}} -
-
- {{range .comments}} - {{$createdSubStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} -
-
-
-
- {{if not .OriginalAuthor}} - - {{ctx.AvatarUtils.Avatar .Poster 20}} - - {{end}} - - {{if .OriginalAuthor}} - - {{svg (MigrationIcon $.Repository.GetOriginalURLHostname)}} - {{.OriginalAuthor}} - - {{if $.Repository.OriginalURL}} - ({{ctx.Locale.Tr "repo.migrated_from" $.Repository.OriginalURL $.Repository.GetOriginalURLHostname}}) - {{end}} - {{else}} - {{template "shared/user/authorlink" .Poster}} - {{end}} - {{ctx.Locale.Tr "repo.issues.commented_at" .HashTag $createdSubStr}} - -
-
- {{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} - {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} - {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" true "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}} - {{end}} -
-
-
-
- {{if .RenderedContent}} - {{.RenderedContent}} - {{else}} - {{ctx.Locale.Tr "repo.issues.no_content"}} - {{end}} -
-
{{.Content}}
-
- {{if .Attachments}} - {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} - {{end}} -
- {{$reactions := .Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}} - {{end}} -
-
- {{end}} -
-
-
- {{if $resolved}} -
- {{svg "octicon-check" 16 "tw-mr-1"}} - {{$resolveDoer.Name}} {{ctx.Locale.Tr "repo.issues.review.resolved_by"}} -
- {{end}} -
-
- {{if and $.CanMarkConversation $hasReview (not $isReviewPending)}} - - {{end}} - {{if and $.SignedUserID (not $.Repository.IsArchived)}} - - {{end}} -
-
- {{template "repo/diff/comment_form_datahandler" dict "hidden" true "reply" $comment.ReviewID "root" $ "comment" $comment}} -
-
-{{else}} - {{template "repo/diff/conversation_outdated"}} -{{end}} From c511070fa8d566321d079938615b4c74d2472a9e Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 25 Oct 2024 01:35:19 +0800 Subject: [PATCH 02/72] Add backend functionalities to create and display conversations --- models/activities/notification.go | 37 + models/conversations/comment.go | 570 +++++++ models/conversations/comment_list.go | 197 +++ models/conversations/content_history.go | 246 +++ models/conversations/conversation.go | 319 ++++ models/conversations/conversation_list.go | 236 +++ models/conversations/conversation_search.go | 384 +++++ models/conversations/conversation_stat.go | 169 +++ models/conversations/conversation_update.go | 442 ++++++ models/conversations/conversation_user.go | 79 + models/conversations/dependency.go | 222 +++ models/conversations/reaction.go | 373 +++++ models/perm/access/repo_permission.go | 8 + models/repo/attachment.go | 7 + models/repo/repo.go | 59 +- models/unit/unit.go | 1 + modules/conversation/template/template.go | 489 ++++++ modules/indexer/conversations/bleve/bleve.go | 294 ++++ .../indexer/conversations/bleve/bleve_test.go | 18 + modules/indexer/conversations/db/db.go | 113 ++ modules/indexer/conversations/db/options.go | 76 + modules/indexer/conversations/dboptions.go | 105 ++ .../elasticsearch/elasticsearch.go | 290 ++++ .../elasticsearch/elasticsearch_test.go | 48 + modules/indexer/conversations/indexer.go | 315 ++++ modules/indexer/conversations/indexer_test.go | 460 ++++++ .../indexer/conversations/internal/indexer.go | 42 + .../indexer/conversations/internal/model.go | 158 ++ .../conversations/internal/tests/tests.go | 757 ++++++++++ .../conversations/meilisearch/meilisearch.go | 301 ++++ .../meilisearch/meilisearch_test.go | 95 ++ modules/indexer/conversations/util.go | 134 ++ modules/setting/indexer.go | 12 + modules/setting/repository.go | 4 + modules/setting/ui.go | 2 + modules/structs/conversation.go | 218 +++ routers/web/repo/commit.go | 28 + routers/web/repo/conversation.go | 1337 +++++++++++++++++ services/conversation/comments.go | 99 ++ services/conversation/conversation.go | 99 ++ services/conversation/reaction.go | 33 + services/convert/conversation.go | 83 + 42 files changed, 8941 insertions(+), 18 deletions(-) create mode 100644 models/conversations/comment.go create mode 100644 models/conversations/comment_list.go create mode 100644 models/conversations/content_history.go create mode 100644 models/conversations/conversation.go create mode 100644 models/conversations/conversation_list.go create mode 100644 models/conversations/conversation_search.go create mode 100644 models/conversations/conversation_stat.go create mode 100644 models/conversations/conversation_update.go create mode 100644 models/conversations/conversation_user.go create mode 100644 models/conversations/dependency.go create mode 100644 models/conversations/reaction.go create mode 100644 modules/conversation/template/template.go create mode 100644 modules/indexer/conversations/bleve/bleve.go create mode 100644 modules/indexer/conversations/bleve/bleve_test.go create mode 100644 modules/indexer/conversations/db/db.go create mode 100644 modules/indexer/conversations/db/options.go create mode 100644 modules/indexer/conversations/dboptions.go create mode 100644 modules/indexer/conversations/elasticsearch/elasticsearch.go create mode 100644 modules/indexer/conversations/elasticsearch/elasticsearch_test.go create mode 100644 modules/indexer/conversations/indexer.go create mode 100644 modules/indexer/conversations/indexer_test.go create mode 100644 modules/indexer/conversations/internal/indexer.go create mode 100644 modules/indexer/conversations/internal/model.go create mode 100644 modules/indexer/conversations/internal/tests/tests.go create mode 100644 modules/indexer/conversations/meilisearch/meilisearch.go create mode 100644 modules/indexer/conversations/meilisearch/meilisearch_test.go create mode 100644 modules/indexer/conversations/util.go create mode 100644 modules/structs/conversation.go create mode 100644 routers/web/repo/conversation.go create mode 100644 services/conversation/comments.go create mode 100644 services/conversation/conversation.go create mode 100644 services/conversation/reaction.go create mode 100644 services/convert/conversation.go diff --git a/models/activities/notification.go b/models/activities/notification.go index b888adeb60fe5..240434207839f 100644 --- a/models/activities/notification.go +++ b/models/activities/notification.go @@ -9,6 +9,7 @@ import ( "net/url" "strconv" + conversations_model "code.gitea.io/gitea/models/conversations" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/organization" @@ -159,6 +160,16 @@ func GetIssueNotification(ctx context.Context, userID, issueID int64) (*Notifica return notification, err } +// GetConversationNotification return the notification about an conversation +func GetConversationNotification(ctx context.Context, userID, conversationID int64) (*Notification, error) { + notification := new(Notification) + _, err := db.GetEngine(ctx). + Where("user_id = ?", userID). + And("conversation_id = ?", conversationID). + Get(notification) + return notification, err +} + // LoadAttributes load Repo Issue User and Comment if not loaded func (n *Notification) LoadAttributes(ctx context.Context) (err error) { if err = n.loadRepo(ctx); err != nil { @@ -322,6 +333,32 @@ func setIssueNotificationStatusReadIfUnread(ctx context.Context, userID, issueID return err } +// SetConversationReadBy sets conversation to be read by given user. +func SetConversationReadBy(ctx context.Context, conversationID, userID int64) error { + if err := conversations_model.UpdateConversationUserByRead(ctx, userID, conversationID); err != nil { + return err + } + + return setConversationNotificationStatusReadIfUnread(ctx, userID, conversationID) +} + +func setConversationNotificationStatusReadIfUnread(ctx context.Context, userID, conversationID int64) error { + notification, err := GetConversationNotification(ctx, userID, conversationID) + // ignore if not exists + if err != nil { + return nil + } + + if notification.Status != NotificationStatusUnread { + return nil + } + + notification.Status = NotificationStatusRead + + _, err = db.GetEngine(ctx).ID(notification.ID).Cols("status").Update(notification) + return err +} + // SetRepoReadBy sets repo to be visited by given user. func SetRepoReadBy(ctx context.Context, userID, repoID int64) error { _, err := db.GetEngine(ctx).Where(builder.Eq{ diff --git a/models/conversations/comment.go b/models/conversations/comment.go new file mode 100644 index 0000000000000..088a9486443e8 --- /dev/null +++ b/models/conversations/comment.go @@ -0,0 +1,570 @@ +package conversations + +// This comment.go was refactored from issues/comment.go to make it context-agnostic to improve reusability. + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" + + "html/template" + + "xorm.io/builder" +) + +// ErrCommentNotExist represents a "CommentNotExist" kind of error. +type ErrCommentNotExist struct { + ID int64 + ConversationID int64 +} + +// IsErrCommentNotExist checks if an error is a ErrCommentNotExist. +func IsErrCommentNotExist(err error) bool { + _, ok := err.(ErrCommentNotExist) + return ok +} + +func (err ErrCommentNotExist) Error() string { + return fmt.Sprintf("comment does not exist [id: %d, conversation_id: %d]", err.ID, err.ConversationID) +} + +func (err ErrCommentNotExist) Unwrap() error { + return util.ErrNotExist +} + +var ErrCommentAlreadyChanged = util.NewInvalidArgumentErrorf("the comment is already changed") + +// CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. +type CommentType int + +// CommentTypeUndefined is used to search for comments of any type +const CommentTypeUndefined CommentType = -1 + +const ( + CommentTypeComment CommentType = iota // 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0) + + CommentTypeLock // 1 Lock an conversation, giving only collaborators access + CommentTypeUnlock // 2 Unlocks a previously locked conversation + + CommentTypeAddDependency + CommentTypeRemoveDependency +) + +var commentStrings = []string{ + "comment", + "lock", + "unlock", +} + +func (t CommentType) String() string { + return commentStrings[t] +} + +func AsCommentType(typeName string) CommentType { + for index, name := range commentStrings { + if typeName == name { + return CommentType(index) + } + } + return CommentTypeUndefined +} + +func (t CommentType) HasContentSupport() bool { + switch t { + case CommentTypeComment: + return true + } + return false +} + +func (t CommentType) HasAttachmentSupport() bool { + switch t { + case CommentTypeComment: + return true + } + return false +} + +func (t CommentType) HasMailReplySupport() bool { + switch t { + case CommentTypeComment: + return true + } + return false +} + +// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database +type CommentMetaData struct { + ProjectColumnID int64 `json:"project_column_id,omitempty"` + ProjectColumnTitle string `json:"project_column_title,omitempty"` + ProjectTitle string `json:"project_title,omitempty"` +} + +// Comment represents a comment in commit and conversation page. +// Comment struct should not contain any pointers unrelated to Conversation unless absolutely necessary. +// To have pointers outside of conversation, create another comment type (e.g. ConversationComment) and use a converter to load it in. +// The database data for the comments however, for all comment types, are defined here. +type Comment struct { + ID int64 `xorm:"pk autoincr"` + Type CommentType `xorm:"INDEX"` + + PosterID int64 `xorm:"INDEX"` + Poster *user_model.User `xorm:"-"` + + OriginalAuthor string + OriginalAuthorID int64 + + Attachments []*repo_model.Attachment `xorm:"-"` + Reactions ReactionList `xorm:"-"` + + Content string `xorm:"LONGTEXT"` + ContentVersion int `xorm:"NOT NULL DEFAULT 0"` + + ConversationID int64 `xorm:"INDEX"` + Conversation *Conversation + + DependentConversationID int64 `xorm:"INDEX"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + + RenderedContent template.HTML `xorm:"-"` + ShowRole RoleDescriptor `xorm:"-"` +} + +func init() { + db.RegisterModel(new(Comment)) +} + +// LoadPoster loads comment poster +func (c *Comment) LoadPoster(ctx context.Context) (err error) { + if c.Poster != nil { + return nil + } + + c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID) + if err != nil { + if user_model.IsErrUserNotExist(err) { + c.PosterID = user_model.GhostUserID + c.Poster = user_model.NewGhostUser() + } else { + log.Error("getUserByID[%d]: %v", c.ID, err) + } + } + return err +} + +// Creates conversation dependency comment +func createConversationDependencyComment(ctx context.Context, doer *user_model.User, conversation, dependentConversation *Conversation, add bool) (err error) { + cType := CommentTypeAddDependency + if !add { + cType = CommentTypeRemoveDependency + } + if err = conversation.LoadRepo(ctx); err != nil { + return err + } + + // Make two comments, one in each conversation + opts := &CreateCommentOptions{ + Type: cType, + Doer: doer, + Repo: conversation.Repo, + Conversation: conversation, + DependentConversationID: dependentConversation.ID, + } + if _, err = CreateComment(ctx, opts); err != nil { + return err + } + + opts = &CreateCommentOptions{ + Type: cType, + Doer: doer, + Repo: conversation.Repo, + Conversation: dependentConversation, + DependentConversationID: conversation.ID, + } + _, err = CreateComment(ctx, opts) + return err +} + +// LoadReactions loads comment reactions +func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository) (err error) { + if c.Reactions != nil { + return nil + } + c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{ + ConversationID: c.ConversationID, + CommentID: c.ID, + }) + if err != nil { + return err + } + // Load reaction user data + if _, err := c.Reactions.LoadUsers(ctx, repo); err != nil { + return err + } + return nil +} + +// AfterDelete is invoked from XORM after the object is deleted. +func (c *Comment) AfterDelete(ctx context.Context) { + if c.ID <= 0 { + return + } + + _, err := repo_model.DeleteAttachmentsByComment(ctx, c.ID, true) + if err != nil { + log.Info("Could not delete files for comment %d on conversation #%d: %s", c.ID, c.ConversationID, err) + } +} + +// RoleInRepo presents the user's participation in the repo +type RoleInRepo string + +// RoleDescriptor defines comment "role" tags +type RoleDescriptor struct { + IsPoster bool + RoleInRepo RoleInRepo +} + +// Enumerate all the role tags. +const ( + RoleRepoOwner RoleInRepo = "owner" + RoleRepoMember RoleInRepo = "member" + RoleRepoCollaborator RoleInRepo = "collaborator" + RoleRepoFirstTimeContributor RoleInRepo = "first_time_contributor" + RoleRepoContributor RoleInRepo = "contributor" +) + +// LocaleString returns the locale string name of the role +func (r RoleInRepo) LocaleString(lang translation.Locale) string { + return lang.TrString("repo.conversations.role." + string(r)) +} + +// LocaleHelper returns the locale tooltip of the role +func (r RoleInRepo) LocaleHelper(lang translation.Locale) string { + return lang.TrString("repo.conversations.role." + string(r) + "_helper") +} + +// CreateCommentOptions defines options for creating comment +type CreateCommentOptions struct { + Type CommentType + Doer *user_model.User + Repo *repo_model.Repository + Attachments []string // UUIDs of attachments + ConversationID int64 + Conversation *Conversation + Content string + DependentConversationID int64 +} + +// CreateComment creates comment with context +func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + e := db.GetEngine(ctx) + + comment := &Comment{ + Type: opts.Type, + PosterID: opts.Doer.ID, + Poster: opts.Doer, + ConversationID: opts.ConversationID, + } + if _, err = e.Insert(comment); err != nil { + return nil, err + } + + if err = opts.Repo.LoadOwner(ctx); err != nil { + return nil, err + } + + if err = committer.Commit(); err != nil { + return nil, err + } + return comment, nil +} + +// GetCommentByID returns the comment by given ID. +func GetCommentByID(ctx context.Context, id int64) (*Comment, error) { + c := new(Comment) + has, err := db.GetEngine(ctx).ID(id).Get(c) + if err != nil { + return nil, err + } else if !has { + return nil, ErrCommentNotExist{id, 0} + } + return c, nil +} + +// FindCommentsOptions describes the conditions to Find comments +type FindCommentsOptions struct { + db.ListOptions + RepoID int64 + ConversationID int64 + ReviewID int64 + Since int64 + Before int64 + Line int64 + TreePath string + Type CommentType + ConversationIDs []int64 + Invalidated optional.Option[bool] + IsPull optional.Option[bool] +} + +// ToConds implements FindOptions interface +func (opts FindCommentsOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.RepoID > 0 { + cond = cond.And(builder.Eq{"conversation.repo_id": opts.RepoID}) + } + if opts.ConversationID > 0 { + cond = cond.And(builder.Eq{"comment.conversation_id": opts.ConversationID}) + } else if len(opts.ConversationIDs) > 0 { + cond = cond.And(builder.In("comment.conversation_id", opts.ConversationIDs)) + } + if opts.ReviewID > 0 { + cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) + } + if opts.Since > 0 { + cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) + } + if opts.Before > 0 { + cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before}) + } + if opts.Type != CommentTypeUndefined { + cond = cond.And(builder.Eq{"comment.type": opts.Type}) + } + if opts.Line != 0 { + cond = cond.And(builder.Eq{"comment.line": opts.Line}) + } + if len(opts.TreePath) > 0 { + cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) + } + if opts.Invalidated.Has() { + cond = cond.And(builder.Eq{"comment.invalidated": opts.Invalidated.Value()}) + } + if opts.IsPull.Has() { + cond = cond.And(builder.Eq{"conversation.is_pull": opts.IsPull.Value()}) + } + return cond +} + +// FindComments returns all comments according options +func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) { + comments := make([]*Comment, 0, 10) + sess := db.GetEngine(ctx).Where(opts.ToConds()) + if opts.RepoID > 0 || opts.IsPull.Has() { + sess.Join("INNER", "conversation", "conversation.id = comment.conversation_id") + } + + if opts.Page != 0 { + sess = db.SetSessionPagination(sess, opts) + } + + // WARNING: If you change this order you will need to fix createCodeComment + + return comments, sess. + Asc("comment.created_unix"). + Asc("comment.id"). + Find(&comments) +} + +// CountComments count all comments according options by ignoring pagination +func CountComments(ctx context.Context, opts *FindCommentsOptions) (int64, error) { + sess := db.GetEngine(ctx).Where(opts.ToConds()) + if opts.RepoID > 0 { + sess.Join("INNER", "conversation", "conversation.id = comment.conversation_id") + } + return sess.Count(&Comment{}) +} + +// UpdateCommentInvalidate updates comment invalidated column +func UpdateCommentInvalidate(ctx context.Context, c *Comment) error { + _, err := db.GetEngine(ctx).ID(c.ID).Cols("invalidated").Update(c) + return err +} + +// UpdateComment updates information of comment. +func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *user_model.User) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + sess := db.GetEngine(ctx) + + c.ContentVersion = contentVersion + 1 + + affected, err := sess.ID(c.ID).AllCols().Where("content_version = ?", contentVersion).Update(c) + if err != nil { + return err + } + if affected == 0 { + return ErrCommentAlreadyChanged + } + if err := committer.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + + return nil +} + +// DeleteComment deletes the comment +func DeleteComment(ctx context.Context, comment *Comment) error { + e := db.GetEngine(ctx) + if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil { + return err + } + + if _, err := db.DeleteByBean(ctx, &ConversationContentHistory{ + CommentID: comment.ID, + }); err != nil { + return err + } + + if _, err := e.Table("action"). + Where("comment_id = ?", comment.ID). + Update(map[string]any{ + "is_deleted": true, + }); err != nil { + return err + } + + return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID}) +} + +// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id +func UpdateCommentsMigrationsByType(ctx context.Context, tp structs.GitServiceType, originalAuthorID string, posterID int64) error { + _, err := db.GetEngine(ctx).Table("comment"). + Join("INNER", "conversation", "conversation.id = comment.conversation_id"). + Join("INNER", "repository", "conversation.repo_id = repository.id"). + Where("repository.original_service_type = ?", tp). + And("comment.original_author_id = ?", originalAuthorID). + Update(map[string]any{ + "poster_id": posterID, + "original_author": "", + "original_author_id": 0, + }) + return err +} + +func UpdateAttachments(ctx context.Context, opts *CreateCommentOptions, comment *Comment) error { + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) + if err != nil { + return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err) + } + for i := range attachments { + attachments[i].ConversationID = comment.ConversationID + attachments[i].CommentID = comment.ID + // No assign value could be 0, so ignore AllCols(). + if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err) + } + } + comment.Attachments = attachments + return nil +} + +// LoadConversation loads the conversation reference for the comment +func (c *Comment) LoadConversation(ctx context.Context) (err error) { + if c.Conversation != nil { + return nil + } + c.Conversation, err = GetConversationByID(ctx, c.ConversationID) + return err +} + +// LoadAttachments loads attachments (it never returns error, the error during `GetAttachmentsByCommentIDCtx` is ignored) +func (c *Comment) LoadAttachments(ctx context.Context) error { + if len(c.Attachments) > 0 { + return nil + } + + var err error + c.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, c.ID) + if err != nil { + log.Error("getAttachmentsByCommentID[%d]: %v", c.ID, err) + } + return nil +} + +// UpdateAttachments update attachments by UUIDs for the comment +func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids) + if err != nil { + return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err) + } + for i := 0; i < len(attachments); i++ { + attachments[i].ConversationID = c.ConversationID + attachments[i].CommentID = c.ID + if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil { + return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err) + } + } + return committer.Commit() +} + +// HashTag returns unique hash tag for issue. +func (comment *Comment) HashTag() string { + return fmt.Sprintf("comment-%d", comment.ID) +} + +func (c *Comment) hashLink(ctx context.Context) string { + return "#" + c.HashTag() +} + +// HTMLURL formats a URL-string to the conversation-comment +func (c *Comment) HTMLURL(ctx context.Context) string { + err := c.LoadConversation(ctx) + if err != nil { // Silently dropping errors :unamused: + log.Error("LoadConversation(%d): %v", c.ConversationID, err) + return "" + } + err = c.Conversation.LoadRepo(ctx) + if err != nil { // Silently dropping errors :unamused: + log.Error("loadRepo(%d): %v", c.Conversation.RepoID, err) + return "" + } + return c.Conversation.HTMLURL() + c.hashLink(ctx) +} + +// APIURL formats a API-string to the conversation-comment +func (c *Comment) APIURL(ctx context.Context) string { + err := c.LoadConversation(ctx) + if err != nil { // Silently dropping errors :unamused: + log.Error("LoadConversation(%d): %v", c.ConversationID, err) + return "" + } + err = c.Conversation.LoadRepo(ctx) + if err != nil { // Silently dropping errors :unamused: + log.Error("loadRepo(%d): %v", c.Conversation.RepoID, err) + return "" + } + + return fmt.Sprintf("%s/conversations/comments/%d", c.Conversation.Repo.APIURL(), c.ID) +} + +// HasOriginalAuthor returns if a comment was migrated and has an original author. +func (c *Comment) HasOriginalAuthor() bool { + return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 +} diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go new file mode 100644 index 0000000000000..a9b3fef57559b --- /dev/null +++ b/models/conversations/comment_list.go @@ -0,0 +1,197 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/container" +) + +// CommentList defines a list of comments +type CommentList []*Comment + +// LoadPosters loads posters +func (comments CommentList) LoadPosters(ctx context.Context) error { + if len(comments) == 0 { + return nil + } + + posterIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) { + return c.PosterID, c.Poster == nil && c.PosterID > 0 + }) + + posterMaps, err := getPostersByIDs(ctx, posterIDs) + if err != nil { + return err + } + + for _, comment := range comments { + if comment.Poster == nil { + comment.Poster = getPoster(comment.PosterID, posterMaps) + } + } + return nil +} + +// getConversationIDs returns all the conversation ids on this comment list which conversation hasn't been loaded +func (comments CommentList) getConversationIDs() []int64 { + return container.FilterSlice(comments, func(comment *Comment) (int64, bool) { + return comment.ConversationID, comment.Conversation == nil + }) +} + +// Conversations returns all the conversations of comments +func (comments CommentList) Conversations() ConversationList { + conversations := make(map[int64]*Conversation, len(comments)) + for _, comment := range comments { + if comment.Conversation != nil { + if _, ok := conversations[comment.Conversation.ID]; !ok { + conversations[comment.Conversation.ID] = comment.Conversation + } + } + } + + conversationList := make([]*Conversation, 0, len(conversations)) + for _, conversation := range conversations { + conversationList = append(conversationList, conversation) + } + return conversationList +} + +// LoadConversations loads conversations of comments +func (comments CommentList) LoadConversations(ctx context.Context) error { + if len(comments) == 0 { + return nil + } + + conversationIDs := comments.getConversationIDs() + conversations := make(map[int64]*Conversation, len(conversationIDs)) + left := len(conversationIDs) + for left > 0 { + limit := db.DefaultMaxInSize + if left < limit { + limit = left + } + rows, err := db.GetEngine(ctx). + In("id", conversationIDs[:limit]). + Rows(new(Conversation)) + if err != nil { + return err + } + + for rows.Next() { + var conversation Conversation + err = rows.Scan(&conversation) + if err != nil { + rows.Close() + return err + } + + conversations[conversation.ID] = &conversation + } + _ = rows.Close() + + left -= limit + conversationIDs = conversationIDs[limit:] + } + + for _, comment := range comments { + if comment.Conversation == nil { + comment.Conversation = conversations[comment.ConversationID] + } + } + return nil +} + +// getAttachmentCommentIDs only return the comment ids which possibly has attachments +func (comments CommentList) getAttachmentCommentIDs() []int64 { + return container.FilterSlice(comments, func(comment *Comment) (int64, bool) { + return comment.ID, comment.Type.HasAttachmentSupport() + }) +} + +// LoadAttachmentsByConversation loads attachments by conversation id +func (comments CommentList) LoadAttachmentsByConversation(ctx context.Context) error { + if len(comments) == 0 { + return nil + } + + attachments := make([]*repo_model.Attachment, 0, len(comments)/2) + if err := db.GetEngine(ctx).Where("conversation_id=? AND comment_id>0", comments[0].ConversationID).Find(&attachments); err != nil { + return err + } + + commentAttachmentsMap := make(map[int64][]*repo_model.Attachment, len(comments)) + for _, attach := range attachments { + commentAttachmentsMap[attach.CommentID] = append(commentAttachmentsMap[attach.CommentID], attach) + } + + for _, comment := range comments { + comment.Attachments = commentAttachmentsMap[comment.ID] + } + return nil +} + +// LoadAttachments loads attachments +func (comments CommentList) LoadAttachments(ctx context.Context) (err error) { + if len(comments) == 0 { + return nil + } + + attachments := make(map[int64][]*repo_model.Attachment, len(comments)) + commentsIDs := comments.getAttachmentCommentIDs() + left := len(commentsIDs) + for left > 0 { + limit := db.DefaultMaxInSize + if left < limit { + limit = left + } + rows, err := db.GetEngine(ctx). + In("comment_id", commentsIDs[:limit]). + Rows(new(repo_model.Attachment)) + if err != nil { + return err + } + + for rows.Next() { + var attachment repo_model.Attachment + err = rows.Scan(&attachment) + if err != nil { + _ = rows.Close() + return err + } + attachments[attachment.CommentID] = append(attachments[attachment.CommentID], &attachment) + } + + _ = rows.Close() + left -= limit + commentsIDs = commentsIDs[limit:] + } + + for _, comment := range comments { + comment.Attachments = attachments[comment.ID] + } + return nil +} + +// LoadAttributes loads attributes of the comments, except for attachments and +// comments +func (comments CommentList) LoadAttributes(ctx context.Context) (err error) { + if err = comments.LoadPosters(ctx); err != nil { + return err + } + + if err = comments.LoadAttachments(ctx); err != nil { + return err + } + + if err = comments.LoadConversations(ctx); err != nil { + return err + } + + return nil +} diff --git a/models/conversations/content_history.go b/models/conversations/content_history.go new file mode 100644 index 0000000000000..f15f69b4924c9 --- /dev/null +++ b/models/conversations/content_history.go @@ -0,0 +1,246 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +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" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ConversationContentHistory save conversation/comment content history revisions. +type ConversationContentHistory struct { + ID int64 `xorm:"pk autoincr"` + PosterID int64 + ConversationID 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 *ConversationContentHistory) TableName() string { + return "conversation_content_history" +} + +func init() { + db.RegisterModel(new(ConversationContentHistory)) +} + +// SaveConversationContentHistory save history +func SaveConversationContentHistory(ctx context.Context, posterID, conversationID, commentID int64, editTime timeutil.TimeStamp, contentText string, isFirstCreated bool) error { + ch := &ConversationContentHistory{ + PosterID: posterID, + ConversationID: conversationID, + CommentID: commentID, + ContentText: contentText, + EditedUnix: editTime, + IsFirstCreated: isFirstCreated, + } + if err := db.Insert(ctx, ch); err != nil { + log.Error("can not save conversation 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(ctx, conversationID, 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(ctx context.Context, conversationID, commentID int64, limit int) { + type IDEditTime struct { + ID int64 + EditedUnix timeutil.TimeStamp + } + + var res []*IDEditTime + err := db.GetEngine(ctx).Select("id, edited_unix").Table("conversation_content_history"). + Where(builder.Eq{"conversation_id": conversationID, "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) <= 2 { + return + } + + outDatedCount := len(res) - limit + for outDatedCount > 0 { + var indexToDelete int + minEditedInterval := -1 + // find a history revision with minimal edited interval to delete, the first and the last should never be deleted + for i := 1; i < len(res)-1; 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 = db.GetEngine(ctx).Delete(&ConversationContentHistory{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-- + } +} + +// QueryConversationContentHistoryEditedCountMap query related history count of each comment (comment_id = 0 means the main conversation) +// only return the count map for "edited" (history revision count > 1) conversations or comments. +func QueryConversationContentHistoryEditedCountMap(dbCtx context.Context, conversationID 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("conversation_content_history"). + Where(builder.Eq{"conversation_id": conversationID}). + GroupBy("comment_id"). + Having("count(1) > 1"). + Find(&records) + if err != nil { + log.Error("can not query conversation 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 +} + +// ConversationContentListItem the list for web ui +type ConversationContentListItem struct { + UserID int64 + UserName string + UserFullName string + UserAvatarLink string + + HistoryID int64 + EditedUnix timeutil.TimeStamp + IsFirstCreated bool + IsDeleted bool +} + +// FetchConversationContentHistoryList fetch list +func FetchConversationContentHistoryList(dbCtx context.Context, conversationID, commentID int64) ([]*ConversationContentListItem, error) { + res := make([]*ConversationContentListItem, 0) + err := db.GetEngine(dbCtx).Select("u.id as user_id, u.name as user_name, u.full_name as user_full_name,"+ + "h.id as history_id, h.edited_unix, h.is_first_created, h.is_deleted"). + Table([]string{"conversation_content_history", "h"}). + Join("LEFT", []string{"user", "u"}, "h.poster_id = u.id"). + Where(builder.Eq{"conversation_id": conversationID, "comment_id": commentID}). + OrderBy("edited_unix DESC"). + Find(&res) + if err != nil { + log.Error("can not fetch conversation content history list. err=%v", err) + return nil, err + } + + for _, item := range res { + if item.UserID > 0 { + item.UserAvatarLink = avatars.GenerateUserAvatarFastLink(item.UserName, 0) + } else { + item.UserAvatarLink = avatars.DefaultAvatarLink() + } + } + return res, nil +} + +// HasConversationContentHistory check if a ContentHistory entry exists +func HasConversationContentHistory(dbCtx context.Context, conversationID, commentID int64) (bool, error) { + exists, err := db.GetEngine(dbCtx).Where(builder.Eq{"conversation_id": conversationID, "comment_id": commentID}).Exist(&ConversationContentHistory{}) + if err != nil { + return false, fmt.Errorf("can not check conversation content history. err: %w", err) + } + return exists, err +} + +// SoftDeleteConversationContentHistory soft delete +func SoftDeleteConversationContentHistory(dbCtx context.Context, historyID int64) error { + if _, err := db.GetEngine(dbCtx).ID(historyID).Cols("is_deleted", "content_text").Update(&ConversationContentHistory{ + IsDeleted: true, + ContentText: "", + }); err != nil { + log.Error("failed to soft delete conversation content history. err=%v", err) + return err + } + return nil +} + +// ErrConversationContentHistoryNotExist not exist error +type ErrConversationContentHistoryNotExist struct { + ID int64 +} + +// Error error string +func (err ErrConversationContentHistoryNotExist) Error() string { + return fmt.Sprintf("conversation content history does not exist [id: %d]", err.ID) +} + +func (err ErrConversationContentHistoryNotExist) Unwrap() error { + return util.ErrNotExist +} + +// GetConversationContentHistoryByID get conversation content history +func GetConversationContentHistoryByID(dbCtx context.Context, id int64) (*ConversationContentHistory, error) { + h := &ConversationContentHistory{} + has, err := db.GetEngine(dbCtx).ID(id).Get(h) + if err != nil { + return nil, err + } else if !has { + return nil, ErrConversationContentHistoryNotExist{id} + } + return h, nil +} + +// GetConversationContentHistoryAndPrev get a history and the previous non-deleted history (to compare) +func GetConversationContentHistoryAndPrev(dbCtx context.Context, conversationID, id int64) (history, prevHistory *ConversationContentHistory, err error) { + history = &ConversationContentHistory{} + has, err := db.GetEngine(dbCtx).Where("id=? AND conversation_id=?", id, conversationID).Get(history) + if err != nil { + log.Error("failed to get conversation content history %v. err=%v", id, err) + return nil, nil, err + } else if !has { + log.Error("conversation content history does not exist. id=%v. err=%v", id, err) + return nil, nil, &ErrConversationContentHistoryNotExist{id} + } + + prevHistory = &ConversationContentHistory{} + has, err = db.GetEngine(dbCtx).Where(builder.Eq{"conversation_id": history.ConversationID, "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 conversation 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/conversations/conversation.go b/models/conversations/conversation.go new file mode 100644 index 0000000000000..bcd7ffb34ffe3 --- /dev/null +++ b/models/conversations/conversation.go @@ -0,0 +1,319 @@ +package conversations + +// Someone should decouple Comment from issues, and rename it something like ConversationEvent (@RedCocoon, 2024) +// Much of the functions here are reimplemented from models/issues/issue.go but simplified + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/timeutil" + + "xorm.io/builder" +) + +// ErrConversationNotExist represents a "ConversationNotExist" kind of error. +type ErrConversationNotExist struct { + ID int64 + RepoID int64 + Index int64 +} + +// IsErrConversationNotExist checks if an error is a ErrConversationNotExist. +func IsErrConversationNotExist(err error) bool { + _, ok := err.(ErrConversationNotExist) + return ok +} + +func (err ErrConversationNotExist) Error() string { + return fmt.Sprintf("conversation does not exist [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index) +} + +func (err ErrConversationNotExist) Unwrap() error { + return util.ErrNotExist +} + +// ErrConversationIsClosed represents a "ConversationIsClosed" kind of error. +type ErrConversationIsClosed struct { + ID int64 + RepoID int64 + Index int64 +} + +// IsErrConversationIsClosed checks if an error is a ErrConversationNotExist. +func IsErrConversationIsClosed(err error) bool { + _, ok := err.(ErrConversationIsClosed) + return ok +} + +func (err ErrConversationIsClosed) Error() string { + return fmt.Sprintf("conversation is closed [id: %d, repo_id: %d, index: %d]", err.ID, err.RepoID, err.Index) +} + +// ErrNewConversationInsert is used when the INSERT statement in newConversation fails +type ErrNewConversationInsert struct { + OriginalError error +} + +// IsErrNewConversationInsert checks if an error is a ErrNewConversationInsert. +func IsErrNewConversationInsert(err error) bool { + _, ok := err.(ErrNewConversationInsert) + return ok +} + +func (err ErrNewConversationInsert) Error() string { + return err.OriginalError.Error() +} + +// ErrConversationWasClosed is used when close a closed conversation +type ErrConversationWasClosed struct { + ID int64 + Index int64 +} + +// IsErrConversationWasClosed checks if an error is a ErrConversationWasClosed. +func IsErrConversationWasClosed(err error) bool { + _, ok := err.(ErrConversationWasClosed) + return ok +} + +func (err ErrConversationWasClosed) Error() string { + return fmt.Sprintf("Conversation [%d] %d was already closed", err.ID, err.Index) +} + +var ErrConversationAlreadyChanged = util.NewInvalidArgumentErrorf("the conversation is already changed") + +type ConversationType int + +const ( + ConversationTypeCommit ConversationType = iota +) + +// Conversation represents a conversation. +type Conversation struct { + ID int64 `xorm:"pk autoincr"` + Index int64 `xorm:"UNIQUE(repo_index)"` + RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"` + Repo *repo_model.Repository `xorm:"-"` + Type ConversationType + + NumComments int + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + LockedUnix timeutil.TimeStamp `xorm:"INDEX"` + + IsLocked bool `xorm:"-"` + + Comments CommentList `xorm:"-"` + Attachments []*repo_model.Attachment `xorm:"-"` + isAttachmentsLoaded bool `xorm:"-"` + + CommitSha string `xorm:"VARCHAR(64)"` + IsRead bool `xorm:"-"` +} + +// IssueIndex represents the issue index table +type ConversationIndex db.ResourceIndex + +func init() { + db.RegisterModel(new(Conversation)) + db.RegisterModel(new(ConversationIndex)) +} + +// In the future if there are more than one type of conversations +// Add a Type argument to Conversation to differentiate them +func (conversation *Conversation) Link() string { + switch conversation.Type { + default: + return fmt.Sprintf("%s/%s/%s", conversation.Repo.Link(), "commits", conversation.CommitSha) + } +} + +func (conversation *Conversation) loadComments(ctx context.Context) (err error) { + return conversation.loadCommentsByType(ctx, CommentTypeUndefined) +} + +func (conversation *Conversation) loadCommentsByType(ctx context.Context, tp CommentType) (err error) { + if conversation.Comments != nil { + return nil + } + + conversation.Comments, err = FindComments(ctx, &FindCommentsOptions{ + ConversationID: conversation.ID, + Type: tp, + }) + + return err +} + +// GetConversationByID returns an conversation by given ID. +func GetConversationByID(ctx context.Context, id int64) (*Conversation, error) { + conversation := new(Conversation) + has, err := db.GetEngine(ctx).ID(id).Get(conversation) + if err != nil { + return nil, err + } else if !has { + return nil, ErrConversationNotExist{id, 0, 0} + } + return conversation, nil +} + +// GetIssueByIndex returns raw issue without loading attributes by index in a repository. +func GetConversationByIndex(ctx context.Context, repoID, index int64) (*Conversation, error) { + if index < 1 { + return nil, ErrConversationNotExist{} + } + conversation := &Conversation{ + RepoID: repoID, + Index: index, + } + has, err := db.GetEngine(ctx).Get(conversation) + if err != nil { + return nil, err + } else if !has { + return nil, ErrConversationNotExist{0, repoID, index} + } + return conversation, nil +} + +// LoadDiscussComments loads discuss comments +func (conversation *Conversation) LoadDiscussComments(ctx context.Context) error { + return conversation.loadCommentsByType(ctx, CommentTypeComment) +} + +// LoadAttributes loads the attribute of this conversation. +func (conversation *Conversation) LoadAttributes(ctx context.Context) (err error) { + if err = conversation.LoadRepo(ctx); err != nil { + return err + } + + if err = conversation.LoadAttachments(ctx); err != nil { + return err + } + + if err = conversation.loadComments(ctx); err != nil { + return err + } + + if err = conversation.Comments.LoadAttributes(ctx); err != nil { + return err + } + + return nil +} + +// LoadRepo loads conversation's repository +func (conversation *Conversation) LoadRepo(ctx context.Context) (err error) { + if conversation.Repo == nil && conversation.RepoID != 0 { + conversation.Repo, err = repo_model.GetRepositoryByID(ctx, conversation.RepoID) + if err != nil { + return fmt.Errorf("getRepositoryByID [%d]: %w", conversation.RepoID, err) + } + } + return nil +} + +func (conversation *Conversation) LoadAttachments(ctx context.Context) (err error) { + if conversation.isAttachmentsLoaded || conversation.Attachments != nil { + return nil + } + + conversation.Attachments, err = repo_model.GetAttachmentsByConversationID(ctx, conversation.ID) + if err != nil { + return fmt.Errorf("getAttachmentsByConversationID [%d]: %w", conversation.ID, err) + } + conversation.isAttachmentsLoaded = true + return nil +} + +// GetConversationIDsByRepoID returns all conversation ids by repo id +func GetConversationIDsByRepoID(ctx context.Context, repoID int64) ([]int64, error) { + ids := make([]int64, 0, 10) + err := db.GetEngine(ctx).Table("conversation").Cols("id").Where("repo_id = ?", repoID).Find(&ids) + return ids, err +} + +// GetConversationsByIDs return issues with the given IDs. +// If keepOrder is true, the order of the returned Conversations will be the same as the given IDs. +func GetConversationsByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (ConversationList, error) { + issues := make([]*Conversation, 0, len(issueIDs)) + + if err := db.GetEngine(ctx).In("id", issueIDs).Find(&issues); err != nil { + return nil, err + } + + if len(keepOrder) > 0 && keepOrder[0] { + m := make(map[int64]*Conversation, len(issues)) + appended := container.Set[int64]{} + for _, issue := range issues { + m[issue.ID] = issue + } + issues = issues[:0] + for _, id := range issueIDs { + if issue, ok := m[id]; ok && !appended.Contains(id) { // make sure the id is existed and not appended + appended.Add(id) + issues = append(issues, issue) + } + } + } + + return issues, nil +} + +func GetConversationByCommitID(ctx context.Context, commitID string) (*Conversation, error) { + conversation := &Conversation{ + CommitSha: commitID, + } + has, err := db.GetEngine(ctx).Get(conversation) + if err != nil { + return nil, err + } else if !has { + return nil, ErrConversationNotExist{0, 0, 0} + } + return conversation, nil +} + +// GetConversationWithAttrsByIndex returns conversation by index in a repository. +func GetConversationWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Conversation, error) { + conversation, err := GetConversationByIndex(ctx, repoID, index) + if err != nil { + return nil, err + } + return conversation, conversation.LoadAttributes(ctx) +} + +func migratedConversationCond(tp api.GitServiceType) builder.Cond { + return builder.In("conversation_id", + builder.Select("conversation.id"). + From("conversation"). + InnerJoin("repository", "conversation.repo_id = repository.id"). + Where(builder.Eq{ + "repository.original_service_type": tp, + }), + ) +} + +// HTMLURL returns the absolute URL to this conversation. +func (conversation *Conversation) HTMLURL() string { + return fmt.Sprintf("%s/%s/%s", conversation.Repo.HTMLURL(), "commit", conversation.CommitSha) +} + +// APIURL returns the absolute APIURL to this issue. +func (conversation *Conversation) APIURL(ctx context.Context) string { + if conversation.Repo == nil { + err := conversation.LoadRepo(ctx) + if err != nil { + log.Error("Conversation[%d].APIURL(): %v", conversation.ID, err) + return "" + } + } + return fmt.Sprintf("%s/commit/%s", conversation.Repo.APIURL(), conversation.CommitSha) +} diff --git a/models/conversations/conversation_list.go b/models/conversations/conversation_list.go new file mode 100644 index 0000000000000..eb9d0911107c3 --- /dev/null +++ b/models/conversations/conversation_list.go @@ -0,0 +1,236 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + + "xorm.io/builder" +) + +// ConversationList defines a list of conversations +type ConversationList []*Conversation + +// get the repo IDs to be loaded later, these IDs are for conversation.Repo and conversation.PullRequest.HeadRepo +func (conversations ConversationList) getRepoIDs() []int64 { + return container.FilterSlice(conversations, func(conversation *Conversation) (int64, bool) { + if conversation.Repo == nil { + return conversation.RepoID, true + } + return 0, false + }) +} + +// LoadRepositories loads conversations' all repositories +func (conversations ConversationList) LoadRepositories(ctx context.Context) (repo_model.RepositoryList, error) { + if len(conversations) == 0 { + return nil, nil + } + + repoIDs := conversations.getRepoIDs() + repoMaps := make(map[int64]*repo_model.Repository, len(repoIDs)) + left := len(repoIDs) + for left > 0 { + limit := db.DefaultMaxInSize + if left < limit { + limit = left + } + err := db.GetEngine(ctx). + In("id", repoIDs[:limit]). + Find(&repoMaps) + if err != nil { + return nil, fmt.Errorf("find repository: %w", err) + } + left -= limit + repoIDs = repoIDs[limit:] + } + + for _, conversation := range conversations { + if conversation.Repo == nil { + conversation.Repo = repoMaps[conversation.RepoID] + } else { + repoMaps[conversation.RepoID] = conversation.Repo + } + } + return repo_model.ValuesRepository(repoMaps), nil +} + +func (conversations ConversationList) getConversationIDs() []int64 { + ids := make([]int64, 0, len(conversations)) + for _, conversation := range conversations { + ids = append(ids, conversation.ID) + } + return ids +} + +// LoadAttachments loads attachments +func (conversations ConversationList) LoadAttachments(ctx context.Context) (err error) { + if len(conversations) == 0 { + return nil + } + + attachments := make(map[int64][]*repo_model.Attachment, len(conversations)) + conversationsIDs := conversations.getConversationIDs() + left := len(conversationsIDs) + for left > 0 { + limit := db.DefaultMaxInSize + if left < limit { + limit = left + } + rows, err := db.GetEngine(ctx). + In("conversation_id", conversationsIDs[:limit]). + Rows(new(repo_model.Attachment)) + if err != nil { + return err + } + + for rows.Next() { + var attachment repo_model.Attachment + err = rows.Scan(&attachment) + if err != nil { + if err1 := rows.Close(); err1 != nil { + return fmt.Errorf("ConversationList.loadAttachments: Close: %w", err1) + } + return err + } + attachments[attachment.ConversationID] = append(attachments[attachment.ConversationID], &attachment) + } + if err1 := rows.Close(); err1 != nil { + return fmt.Errorf("ConversationList.loadAttachments: Close: %w", err1) + } + left -= limit + conversationsIDs = conversationsIDs[limit:] + } + + for _, conversation := range conversations { + conversation.Attachments = attachments[conversation.ID] + conversation.isAttachmentsLoaded = true + } + return nil +} + +func (conversations ConversationList) loadComments(ctx context.Context, cond builder.Cond) (err error) { + if len(conversations) == 0 { + return nil + } + + comments := make(map[int64][]*Comment, len(conversations)) + conversationsIDs := conversations.getConversationIDs() + left := len(conversationsIDs) + for left > 0 { + limit := db.DefaultMaxInSize + if left < limit { + limit = left + } + rows, err := db.GetEngine(ctx).Table("comment"). + Join("INNER", "conversation", "conversation.id = comment.conversation_id"). + In("conversation.id", conversationsIDs[:limit]). + Where(cond). + NoAutoCondition(). + Rows(new(Comment)) + if err != nil { + return err + } + + for rows.Next() { + var comment Comment + err = rows.Scan(&comment) + if err != nil { + if err1 := rows.Close(); err1 != nil { + return fmt.Errorf("ConversationList.loadComments: Close: %w", err1) + } + return err + } + comments[comment.ConversationID] = append(comments[comment.ConversationID], &comment) + } + if err1 := rows.Close(); err1 != nil { + return fmt.Errorf("ConversationList.loadComments: Close: %w", err1) + } + left -= limit + conversationsIDs = conversationsIDs[limit:] + } + + for _, conversation := range conversations { + conversation.Comments = comments[conversation.ID] + } + return nil +} + +// loadAttributes loads all attributes, expect for attachments and comments +func (conversations ConversationList) LoadAttributes(ctx context.Context) error { + if _, err := conversations.LoadRepositories(ctx); err != nil { + return fmt.Errorf("conversation.loadAttributes: LoadRepositories: %w", err) + } + return nil +} + +// LoadComments loads comments +func (conversations ConversationList) LoadComments(ctx context.Context) error { + return conversations.loadComments(ctx, builder.NewCond()) +} + +// LoadDiscussComments loads discuss comments +func (conversations ConversationList) LoadDiscussComments(ctx context.Context) error { + return conversations.loadComments(ctx, builder.Eq{"comment.type": CommentTypeComment}) +} + +func getPostersByIDs(ctx context.Context, posterIDs []int64) (map[int64]*user_model.User, error) { + posterMaps := make(map[int64]*user_model.User, len(posterIDs)) + left := len(posterIDs) + for left > 0 { + limit := db.DefaultMaxInSize + if left < limit { + limit = left + } + err := db.GetEngine(ctx). + In("id", posterIDs[:limit]). + Find(&posterMaps) + if err != nil { + return nil, err + } + left -= limit + posterIDs = posterIDs[limit:] + } + return posterMaps, nil +} + +func getPoster(posterID int64, posterMaps map[int64]*user_model.User) *user_model.User { + if posterID == user_model.ActionsUserID { + return user_model.NewActionsUser() + } + if posterID <= 0 { + return nil + } + poster, ok := posterMaps[posterID] + if !ok { + return user_model.NewGhostUser() + } + return poster +} + +func (conversations ConversationList) LoadIsRead(ctx context.Context, userID int64) error { + conversationIDs := conversations.getConversationIDs() + conversationUsers := make([]*ConversationUser, 0, len(conversationIDs)) + if err := db.GetEngine(ctx).Where("uid =?", userID). + In("conversation_id"). + Find(&conversationUsers); err != nil { + return err + } + + for _, conversationUser := range conversationUsers { + for _, conversation := range conversations { + if conversation.ID == conversationUser.ConversationID { + conversation.IsRead = conversationUser.IsRead + } + } + } + + return nil +} diff --git a/models/conversations/conversation_search.go b/models/conversations/conversation_search.go new file mode 100644 index 0000000000000..c9469fab5a923 --- /dev/null +++ b/models/conversations/conversation_search.go @@ -0,0 +1,384 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/optional" + + "xorm.io/builder" + "xorm.io/xorm" +) + +// ConversationsOptions represents options of an conversation. +type ConversationsOptions struct { //nolint + Paginator *db.ListOptions + RepoIDs []int64 // overwrites RepoCond if the length is not 0 + AllPublic bool // include also all public repositories + RepoCond builder.Cond + AssigneeID int64 + PosterID int64 + MentionedID int64 + ReviewRequestedID int64 + ReviewedID int64 + SubscriberID int64 + MilestoneIDs []int64 + ProjectID int64 + ProjectColumnID int64 + IsClosed optional.Option[bool] + IsPull optional.Option[bool] + LabelIDs []int64 + IncludedLabelNames []string + ExcludedLabelNames []string + IncludeMilestones []string + SortType string + ConversationIDs []int64 + UpdatedAfterUnix int64 + UpdatedBeforeUnix int64 + // prioritize conversations from this repo + PriorityRepoID int64 + IsArchived optional.Option[bool] + Org *organization.Organization // conversations permission scope + Team *organization.Team // conversations permission scope + User *user_model.User // conversations permission scope +} + +// Copy returns a copy of the options. +// Be careful, it's not a deep copy, so `ConversationsOptions.RepoIDs = {...}` is OK while `ConversationsOptions.RepoIDs[0] = ...` is not. +func (o *ConversationsOptions) Copy(edit ...func(options *ConversationsOptions)) *ConversationsOptions { + if o == nil { + return nil + } + v := *o + for _, e := range edit { + e(&v) + } + return &v +} + +// applySorts sort an conversations-related session based on the provided +// sortType string +func applySorts(sess *xorm.Session, sortType string, priorityRepoID int64) { + switch sortType { + case "oldest": + sess.Asc("conversation.created_unix").Asc("conversation.id") + case "recentupdate": + sess.Desc("conversation.updated_unix").Desc("conversation.created_unix").Desc("conversation.id") + case "leastupdate": + sess.Asc("conversation.updated_unix").Asc("conversation.created_unix").Asc("conversation.id") + case "mostcomment": + sess.Desc("conversation.num_comments").Desc("conversation.created_unix").Desc("conversation.id") + case "leastcomment": + sess.Asc("conversation.num_comments").Desc("conversation.created_unix").Desc("conversation.id") + case "priority": + sess.Desc("conversation.priority").Desc("conversation.created_unix").Desc("conversation.id") + case "nearduedate": + // 253370764800 is 01/01/9999 @ 12:00am (UTC) + sess.Join("LEFT", "milestone", "conversation.milestone_id = milestone.id"). + OrderBy("CASE " + + "WHEN conversation.deadline_unix = 0 AND (milestone.deadline_unix = 0 OR milestone.deadline_unix IS NULL) THEN 253370764800 " + + "WHEN milestone.deadline_unix = 0 OR milestone.deadline_unix IS NULL THEN conversation.deadline_unix " + + "WHEN milestone.deadline_unix < conversation.deadline_unix OR conversation.deadline_unix = 0 THEN milestone.deadline_unix " + + "ELSE conversation.deadline_unix END ASC"). + Desc("conversation.created_unix"). + Desc("conversation.id") + case "farduedate": + sess.Join("LEFT", "milestone", "conversation.milestone_id = milestone.id"). + OrderBy("CASE " + + "WHEN milestone.deadline_unix IS NULL THEN conversation.deadline_unix " + + "WHEN milestone.deadline_unix < conversation.deadline_unix OR conversation.deadline_unix = 0 THEN milestone.deadline_unix " + + "ELSE conversation.deadline_unix END DESC"). + Desc("conversation.created_unix"). + Desc("conversation.id") + case "priorityrepo": + sess.OrderBy("CASE "+ + "WHEN conversation.repo_id = ? THEN 1 "+ + "ELSE 2 END ASC", priorityRepoID). + Desc("conversation.created_unix"). + Desc("conversation.id") + case "project-column-sorting": + sess.Asc("project_conversation.sorting").Desc("conversation.created_unix").Desc("conversation.id") + default: + sess.Desc("conversation.created_unix").Desc("conversation.id") + } +} + +func applyLimit(sess *xorm.Session, opts *ConversationsOptions) { + if opts.Paginator == nil || opts.Paginator.IsListAll() { + return + } + + start := 0 + if opts.Paginator.Page > 1 { + start = (opts.Paginator.Page - 1) * opts.Paginator.PageSize + } + sess.Limit(opts.Paginator.PageSize, start) +} + +func applyLabelsCondition(sess *xorm.Session, opts *ConversationsOptions) { + if len(opts.LabelIDs) > 0 { + if opts.LabelIDs[0] == 0 { + sess.Where("conversation.id NOT IN (SELECT conversation_id FROM conversation_label)") + } else { + // deduplicate the label IDs for inclusion and exclusion + includedLabelIDs := make(container.Set[int64]) + excludedLabelIDs := make(container.Set[int64]) + for _, labelID := range opts.LabelIDs { + if labelID > 0 { + includedLabelIDs.Add(labelID) + } else if labelID < 0 { // 0 is not supported here, so just ignore it + excludedLabelIDs.Add(-labelID) + } + } + // ... and use them in a subquery of the form : + // where (select count(*) from conversation_label where conversation_id=conversation.id and label_id in (2, 4, 6)) = 3 + // This equality is guaranteed thanks to unique index (conversation_id,label_id) on table conversation_label. + if len(includedLabelIDs) > 0 { + subQuery := builder.Select("count(*)").From("conversation_label").Where(builder.Expr("conversation_id = conversation.id")). + And(builder.In("label_id", includedLabelIDs.Values())) + sess.Where(builder.Eq{strconv.Itoa(len(includedLabelIDs)): subQuery}) + } + // or (select count(*)...) = 0 for excluded labels + if len(excludedLabelIDs) > 0 { + subQuery := builder.Select("count(*)").From("conversation_label").Where(builder.Expr("conversation_id = conversation.id")). + And(builder.In("label_id", excludedLabelIDs.Values())) + sess.Where(builder.Eq{"0": subQuery}) + } + } + } +} + +func applyMilestoneCondition(sess *xorm.Session, opts *ConversationsOptions) { + if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID { + sess.And("conversation.milestone_id = 0") + } else if len(opts.MilestoneIDs) > 0 { + sess.In("conversation.milestone_id", opts.MilestoneIDs) + } + + if len(opts.IncludeMilestones) > 0 { + sess.In("conversation.milestone_id", + builder.Select("id"). + From("milestone"). + Where(builder.In("name", opts.IncludeMilestones))) + } +} + +func applyProjectCondition(sess *xorm.Session, opts *ConversationsOptions) { + if opts.ProjectID > 0 { // specific project + sess.Join("INNER", "project_conversation", "conversation.id = project_conversation.conversation_id"). + And("project_conversation.project_id=?", opts.ProjectID) + } else if opts.ProjectID == db.NoConditionID { // show those that are in no project + sess.And(builder.NotIn("conversation.id", builder.Select("conversation_id").From("project_conversation").And(builder.Neq{"project_id": 0}))) + } + // opts.ProjectID == 0 means all projects, + // do not need to apply any condition +} + +func applyProjectColumnCondition(sess *xorm.Session, opts *ConversationsOptions) { + // opts.ProjectColumnID == 0 means all project columns, + // do not need to apply any condition + if opts.ProjectColumnID > 0 { + sess.In("conversation.id", builder.Select("conversation_id").From("project_conversation").Where(builder.Eq{"project_board_id": opts.ProjectColumnID})) + } else if opts.ProjectColumnID == db.NoConditionID { + sess.In("conversation.id", builder.Select("conversation_id").From("project_conversation").Where(builder.Eq{"project_board_id": 0})) + } +} + +func applyRepoConditions(sess *xorm.Session, opts *ConversationsOptions) { + if len(opts.RepoIDs) == 1 { + opts.RepoCond = builder.Eq{"conversation.repo_id": opts.RepoIDs[0]} + } else if len(opts.RepoIDs) > 1 { + opts.RepoCond = builder.In("conversation.repo_id", opts.RepoIDs) + } + if opts.AllPublic { + if opts.RepoCond == nil { + opts.RepoCond = builder.NewCond() + } + opts.RepoCond = opts.RepoCond.Or(builder.In("conversation.repo_id", builder.Select("id").From("repository").Where(builder.Eq{"is_private": false}))) + } + if opts.RepoCond != nil { + sess.And(opts.RepoCond) + } +} + +func applyConditions(sess *xorm.Session, opts *ConversationsOptions) { + if len(opts.ConversationIDs) > 0 { + sess.In("conversation.id", opts.ConversationIDs) + } + + applyRepoConditions(sess, opts) + + if opts.IsClosed.Has() { + sess.And("conversation.is_closed=?", opts.IsClosed.Value()) + } + + if opts.PosterID > 0 { + applyPosterCondition(sess, opts.PosterID) + } + + if opts.MentionedID > 0 { + applyMentionedCondition(sess, opts.MentionedID) + } + + if opts.SubscriberID > 0 { + applySubscribedCondition(sess, opts.SubscriberID) + } + + applyMilestoneCondition(sess, opts) + + if opts.UpdatedAfterUnix != 0 { + sess.And(builder.Gte{"conversation.updated_unix": opts.UpdatedAfterUnix}) + } + if opts.UpdatedBeforeUnix != 0 { + sess.And(builder.Lte{"conversation.updated_unix": opts.UpdatedBeforeUnix}) + } + + applyProjectCondition(sess, opts) + + applyProjectColumnCondition(sess, opts) + + if opts.IsPull.Has() { + sess.And("conversation.is_pull=?", opts.IsPull.Value()) + } + + if opts.IsArchived.Has() { + sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.Value()}) + } + + applyLabelsCondition(sess, opts) +} + +// teamUnitsRepoCond returns query condition for those repo id in the special org team with special units access +func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Type) builder.Cond { + return builder.In(id, + builder.Select("repo_id").From("team_repo").Where( + builder.Eq{ + "team_id": teamID, + }.And( + builder.Or( + // Check if the user is member of the team. + builder.In( + "team_id", builder.Select("team_id").From("team_user").Where( + builder.Eq{ + "uid": userID, + }, + ), + ), + // Check if the user is in the owner team of the organisation. + builder.Exists(builder.Select("team_id").From("team_user"). + Where(builder.Eq{ + "org_id": orgID, + "team_id": builder.Select("id").From("team").Where( + builder.Eq{ + "org_id": orgID, + "lower_name": strings.ToLower(organization.OwnerTeamName), + }), + "uid": userID, + }), + ), + )).And( + builder.In( + "team_id", builder.Select("team_id").From("team_unit").Where( + builder.Eq{ + "`team_unit`.org_id": orgID, + }.And( + builder.In("`team_unit`.type", units), + ), + ), + ), + ), + )) +} + +func applyPosterCondition(sess *xorm.Session, posterID int64) { + sess.And("conversation.poster_id=?", posterID) +} + +func applyMentionedCondition(sess *xorm.Session, mentionedID int64) { + sess.Join("INNER", "conversation_user", "conversation.id = conversation_user.conversation_id"). + And("conversation_user.is_mentioned = ?", true). + And("conversation_user.uid = ?", mentionedID) +} + +func applySubscribedCondition(sess *xorm.Session, subscriberID int64) { + sess.And( + builder. + NotIn("conversation.id", + builder.Select("conversation_id"). + From("conversation_watch"). + Where(builder.Eq{"is_watching": false, "user_id": subscriberID}), + ), + ).And( + builder.Or( + builder.In("conversation.id", builder. + Select("conversation_id"). + From("conversation_watch"). + Where(builder.Eq{"is_watching": true, "user_id": subscriberID}), + ), + builder.In("conversation.id", builder. + Select("conversation_id"). + From("comment"). + Where(builder.Eq{"poster_id": subscriberID}), + ), + builder.Eq{"conversation.poster_id": subscriberID}, + builder.In("conversation.repo_id", builder. + Select("id"). + From("watch"). + Where(builder.And(builder.Eq{"user_id": subscriberID}, + builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))), + ), + ), + ) +} + +// Conversations returns a list of conversations by given conditions. +func Conversations(ctx context.Context, opts *ConversationsOptions) (ConversationList, error) { + sess := db.GetEngine(ctx). + Join("INNER", "repository", "`conversation`.repo_id = `repository`.id") + applyLimit(sess, opts) + applyConditions(sess, opts) + applySorts(sess, opts.SortType, opts.PriorityRepoID) + + conversations := ConversationList{} + if err := sess.Find(&conversations); err != nil { + return nil, fmt.Errorf("unable to query Conversations: %w", err) + } + + if err := conversations.LoadAttributes(ctx); err != nil { + return nil, fmt.Errorf("unable to LoadAttributes for Conversations: %w", err) + } + + return conversations, nil +} + +// ConversationIDs returns a list of conversation ids by given conditions. +func ConversationIDs(ctx context.Context, opts *ConversationsOptions, otherConds ...builder.Cond) ([]int64, int64, error) { + sess := db.GetEngine(ctx). + Join("INNER", "repository", "`conversation`.repo_id = `repository`.id") + applyConditions(sess, opts) + for _, cond := range otherConds { + sess.And(cond) + } + + applyLimit(sess, opts) + applySorts(sess, opts.SortType, opts.PriorityRepoID) + + var res []int64 + total, err := sess.Select("`conversation`.id").Table(&Conversation{}).FindAndCount(&res) + if err != nil { + return nil, 0, err + } + + return res, total, nil +} diff --git a/models/conversations/conversation_stat.go b/models/conversations/conversation_stat.go new file mode 100644 index 0000000000000..8923cf97cf5d6 --- /dev/null +++ b/models/conversations/conversation_stat.go @@ -0,0 +1,169 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + + "xorm.io/builder" + "xorm.io/xorm" +) + +// ConversationStats represents conversation statistic information. +type ConversationStats struct { + OpenCount, ClosedCount int64 + YourRepositoriesCount int64 + AssignCount int64 + CreateCount int64 + MentionCount int64 + ReviewRequestedCount int64 + ReviewedCount int64 +} + +// Filter modes. +const ( + FilterModeAll = iota + FilterModeAssign + FilterModeCreate + FilterModeMention + FilterModeYourRepositories +) + +const ( + // MaxQueryParameters represents the max query parameters + // When queries are broken down in parts because of the number + // of parameters, attempt to break by this amount + MaxQueryParameters = 300 +) + +// CountConversationsByRepo map from repoID to number of conversations matching the options +func CountConversationsByRepo(ctx context.Context, opts *ConversationsOptions) (map[int64]int64, error) { + sess := db.GetEngine(ctx). + Join("INNER", "repository", "`conversation`.repo_id = `repository`.id") + + applyConditions(sess, opts) + + countsSlice := make([]*struct { + RepoID int64 + Count int64 + }, 0, 10) + if err := sess.GroupBy("conversation.repo_id"). + Select("conversation.repo_id AS repo_id, COUNT(*) AS count"). + Table("conversation"). + Find(&countsSlice); err != nil { + return nil, fmt.Errorf("unable to CountConversationsByRepo: %w", err) + } + + countMap := make(map[int64]int64, len(countsSlice)) + for _, c := range countsSlice { + countMap[c.RepoID] = c.Count + } + return countMap, nil +} + +// CountConversations number return of conversations by given conditions. +func CountConversations(ctx context.Context, opts *ConversationsOptions, otherConds ...builder.Cond) (int64, error) { + sess := db.GetEngine(ctx). + Select("COUNT(conversation.id) AS count"). + Table("conversation"). + Join("INNER", "repository", "`conversation`.repo_id = `repository`.id") + applyConditions(sess, opts) + + for _, cond := range otherConds { + sess.And(cond) + } + + return sess.Count() +} + +// GetConversationStats returns conversation statistic information by given conditions. +func GetConversationStats(ctx context.Context, opts *ConversationsOptions) (*ConversationStats, error) { + if len(opts.ConversationIDs) <= MaxQueryParameters { + return getConversationStatsChunk(ctx, opts, opts.ConversationIDs) + } + + // If too long a list of IDs is provided, we get the statistics in + // smaller chunks and get accumulates. Note: this could potentially + // get us invalid results. The alternative is to insert the list of + // ids in a temporary table and join from them. + accum := &ConversationStats{} + for i := 0; i < len(opts.ConversationIDs); { + chunk := i + MaxQueryParameters + if chunk > len(opts.ConversationIDs) { + chunk = len(opts.ConversationIDs) + } + stats, err := getConversationStatsChunk(ctx, opts, opts.ConversationIDs[i:chunk]) + if err != nil { + return nil, err + } + accum.OpenCount += stats.OpenCount + accum.ClosedCount += stats.ClosedCount + accum.YourRepositoriesCount += stats.YourRepositoriesCount + accum.AssignCount += stats.AssignCount + accum.CreateCount += stats.CreateCount + accum.OpenCount += stats.MentionCount + accum.ReviewRequestedCount += stats.ReviewRequestedCount + accum.ReviewedCount += stats.ReviewedCount + i = chunk + } + return accum, nil +} + +func getConversationStatsChunk(ctx context.Context, opts *ConversationsOptions, conversationIDs []int64) (*ConversationStats, error) { + stats := &ConversationStats{} + + sess := db.GetEngine(ctx). + Join("INNER", "repository", "`conversation`.repo_id = `repository`.id") + + var err error + stats.OpenCount, err = applyConversationsOptions(sess, opts, conversationIDs). + And("conversation.is_closed = ?", false). + Count(new(Conversation)) + if err != nil { + return stats, err + } + stats.ClosedCount, err = applyConversationsOptions(sess, opts, conversationIDs). + And("conversation.is_closed = ?", true). + Count(new(Conversation)) + return stats, err +} + +func applyConversationsOptions(sess *xorm.Session, opts *ConversationsOptions, conversationIDs []int64) *xorm.Session { + if len(opts.RepoIDs) > 1 { + sess.In("conversation.repo_id", opts.RepoIDs) + } else if len(opts.RepoIDs) == 1 { + sess.And("conversation.repo_id = ?", opts.RepoIDs[0]) + } + + if len(conversationIDs) > 0 { + sess.In("conversation.id", conversationIDs) + } + + if opts.PosterID > 0 { + applyPosterCondition(sess, opts.PosterID) + } + + if opts.MentionedID > 0 { + applyMentionedCondition(sess, opts.MentionedID) + } + + if opts.IsPull.Has() { + sess.And("conversation.is_pull=?", opts.IsPull.Value()) + } + + return sess +} + +// CountOrphanedConversations count conversations without a repo +func CountOrphanedConversations(ctx context.Context) (int64, error) { + return db.GetEngine(ctx). + Table("conversation"). + Join("LEFT", "repository", "conversation.repo_id=repository.id"). + Where(builder.IsNull{"repository.id"}). + Select("COUNT(`conversation`.`id`)"). + Count() +} diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go new file mode 100644 index 0000000000000..4d303708b5418 --- /dev/null +++ b/models/conversations/conversation_update.go @@ -0,0 +1,442 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + "strings" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/perm" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + system_model "code.gitea.io/gitea/models/system" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/references" + api "code.gitea.io/gitea/modules/structs" + + "xorm.io/builder" +) + +// UpdateConversationCols updates cols of conversation +func UpdateConversationCols(ctx context.Context, conversation *Conversation, cols ...string) error { + if _, err := db.GetEngine(ctx).ID(conversation.ID).Cols(cols...).Update(conversation); err != nil { + return err + } + return nil +} + +// UpdateConversationAttachments update attachments by UUIDs for the conversation +func UpdateConversationAttachments(ctx context.Context, conversationID int64, uuids []string) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids) + if err != nil { + return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err) + } + for i := 0; i < len(attachments); i++ { + attachments[i].ConversationID = conversationID + if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil { + return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err) + } + } + return committer.Commit() +} + +// NewConversationOptions represents the options of a new conversation. +type NewConversationOptions struct { + Repo *repo_model.Repository + Conversation *Conversation + LabelIDs []int64 + Attachments []string // In UUID format. + IsPull bool +} + +// UpdateConversationMentions updates conversation-user relations for mentioned users. +func UpdateConversationMentions(ctx context.Context, conversationID int64, mentions []*user_model.User) error { + if len(mentions) == 0 { + return nil + } + ids := make([]int64, len(mentions)) + for i, u := range mentions { + ids[i] = u.ID + } + if err := UpdateConversationUsersByMentions(ctx, conversationID, ids); err != nil { + return fmt.Errorf("UpdateConversationUsersByMentions: %w", err) + } + return nil +} + +// FindAndUpdateConversationMentions finds users mentioned in the given content string, and saves them in the database. +func FindAndUpdateConversationMentions(ctx context.Context, conversation *Conversation, doer *user_model.User, content string) (mentions []*user_model.User, err error) { + rawMentions := references.FindAllMentionsMarkdown(content) + mentions, err = ResolveConversationMentionsByVisibility(ctx, conversation, doer, rawMentions) + if err != nil { + return nil, fmt.Errorf("UpdateConversationMentions [%d]: %w", conversation.ID, err) + } + + notBlocked := make([]*user_model.User, 0, len(mentions)) + for _, user := range mentions { + if !user_model.IsUserBlockedBy(ctx, doer, user.ID) { + notBlocked = append(notBlocked, user) + } + } + mentions = notBlocked + + if err = UpdateConversationMentions(ctx, conversation.ID, mentions); err != nil { + return nil, fmt.Errorf("UpdateConversationMentions [%d]: %w", conversation.ID, err) + } + return mentions, err +} + +// ResolveConversationMentionsByVisibility returns the users mentioned in an conversation, removing those that +// don't have access to reading it. Teams are expanded into their users, but organizations are ignored. +func ResolveConversationMentionsByVisibility(ctx context.Context, conversation *Conversation, doer *user_model.User, mentions []string) (users []*user_model.User, err error) { + if len(mentions) == 0 { + return nil, nil + } + if err = conversation.LoadRepo(ctx); err != nil { + return nil, err + } + + resolved := make(map[string]bool, 10) + var mentionTeams []string + + if err := conversation.Repo.LoadOwner(ctx); err != nil { + return nil, err + } + + repoOwnerIsOrg := conversation.Repo.Owner.IsOrganization() + if repoOwnerIsOrg { + mentionTeams = make([]string, 0, 5) + } + + resolved[doer.LowerName] = true + for _, name := range mentions { + name := strings.ToLower(name) + if _, ok := resolved[name]; ok { + continue + } + if repoOwnerIsOrg && strings.Contains(name, "/") { + names := strings.Split(name, "/") + if len(names) < 2 || names[0] != conversation.Repo.Owner.LowerName { + continue + } + mentionTeams = append(mentionTeams, names[1]) + resolved[name] = true + } else { + resolved[name] = false + } + } + + if conversation.Repo.Owner.IsOrganization() && len(mentionTeams) > 0 { + teams := make([]*organization.Team, 0, len(mentionTeams)) + if err := db.GetEngine(ctx). + Join("INNER", "team_repo", "team_repo.team_id = team.id"). + Where("team_repo.repo_id=?", conversation.Repo.ID). + In("team.lower_name", mentionTeams). + Find(&teams); err != nil { + return nil, fmt.Errorf("find mentioned teams: %w", err) + } + if len(teams) != 0 { + checked := make([]int64, 0, len(teams)) + unittype := unit.TypeConversations + for _, team := range teams { + if team.AccessMode >= perm.AccessModeAdmin { + checked = append(checked, team.ID) + resolved[conversation.Repo.Owner.LowerName+"/"+team.LowerName] = true + continue + } + has, err := db.GetEngine(ctx).Get(&organization.TeamUnit{OrgID: conversation.Repo.Owner.ID, TeamID: team.ID, Type: unittype}) + if err != nil { + return nil, fmt.Errorf("get team units (%d): %w", team.ID, err) + } + if has { + checked = append(checked, team.ID) + resolved[conversation.Repo.Owner.LowerName+"/"+team.LowerName] = true + } + } + if len(checked) != 0 { + teamusers := make([]*user_model.User, 0, 20) + if err := db.GetEngine(ctx). + Join("INNER", "team_user", "team_user.uid = `user`.id"). + In("`team_user`.team_id", checked). + And("`user`.is_active = ?", true). + And("`user`.prohibit_login = ?", false). + Find(&teamusers); err != nil { + return nil, fmt.Errorf("get teams users: %w", err) + } + if len(teamusers) > 0 { + users = make([]*user_model.User, 0, len(teamusers)) + for _, user := range teamusers { + if already, ok := resolved[user.LowerName]; !ok || !already { + users = append(users, user) + resolved[user.LowerName] = true + } + } + } + } + } + } + + // Remove names already in the list to avoid querying the database if pending names remain + mentionUsers := make([]string, 0, len(resolved)) + for name, already := range resolved { + if !already { + mentionUsers = append(mentionUsers, name) + } + } + if len(mentionUsers) == 0 { + return users, err + } + + if users == nil { + users = make([]*user_model.User, 0, len(mentionUsers)) + } + + unchecked := make([]*user_model.User, 0, len(mentionUsers)) + if err := db.GetEngine(ctx). + Where("`user`.is_active = ?", true). + And("`user`.prohibit_login = ?", false). + In("`user`.lower_name", mentionUsers). + Find(&unchecked); err != nil { + return nil, fmt.Errorf("find mentioned users: %w", err) + } + for _, user := range unchecked { + if already := resolved[user.LowerName]; already || user.IsOrganization() { + continue + } + // Normal users must have read access to the referencing conversation + perm, err := access_model.GetUserRepoPermission(ctx, conversation.Repo, user) + if err != nil { + return nil, fmt.Errorf("GetUserRepoPermission [%d]: %w", user.ID, err) + } + if !perm.CanReadConversations() { + continue + } + users = append(users, user) + } + + return users, err +} + +// UpdateConversationsMigrationsByType updates all migrated repositories' conversations from gitServiceType to replace originalAuthorID to posterID +func UpdateConversationsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, posterID int64) error { + _, err := db.GetEngine(ctx).Table("conversation"). + Where("repo_id IN (SELECT id FROM repository WHERE original_service_type = ?)", gitServiceType). + And("original_author_id = ?", originalAuthorID). + Update(map[string]any{ + "poster_id": posterID, + "original_author": "", + "original_author_id": 0, + }) + return err +} + +// UpdateReactionsMigrationsByType updates all migrated repositories' reactions from gitServiceType to replace originalAuthorID to posterID +func UpdateReactionsMigrationsByType(ctx context.Context, gitServiceType api.GitServiceType, originalAuthorID string, userID int64) error { + _, err := db.GetEngine(ctx).Table("reaction"). + Where("original_author_id = ?", originalAuthorID). + And(migratedConversationCond(gitServiceType)). + Update(map[string]any{ + "user_id": userID, + "original_author": "", + "original_author_id": 0, + }) + return err +} + +// DeleteConversationsByRepoID deletes conversations by repositories id +func DeleteConversationsByRepoID(ctx context.Context, repoID int64) (attachmentPaths []string, err error) { + // MariaDB has a performance bug: https://jira.mariadb.org/browse/MDEV-16289 + // so here it uses "DELETE ... WHERE IN" with pre-queried IDs. + sess := db.GetEngine(ctx) + + for { + conversationIDs := make([]int64, 0, db.DefaultMaxInSize) + + err := sess.Table(&Conversation{}).Where("repo_id = ?", repoID).OrderBy("id").Limit(db.DefaultMaxInSize).Cols("id").Find(&conversationIDs) + if err != nil { + return nil, err + } + + if len(conversationIDs) == 0 { + break + } + + // Delete content histories + _, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationContentHistory{}) + if err != nil { + return nil, err + } + + // Delete comments and attachments + _, err = sess.In("conversation_id", conversationIDs).Delete(&Comment{}) + if err != nil { + return nil, err + } + + // Dependencies for conversations in this repository + _, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationDependency{}) + if err != nil { + return nil, err + } + + // Delete dependencies for conversations in other repositories + _, err = sess.In("dependency_id", conversationIDs).Delete(&ConversationDependency{}) + if err != nil { + return nil, err + } + + _, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationUser{}) + if err != nil { + return nil, err + } + + _, err = sess.In("conversation_id", conversationIDs).Delete(&Reaction{}) + if err != nil { + return nil, err + } + + _, err = sess.In("dependent_conversation_id", conversationIDs).Delete(&Comment{}) + if err != nil { + return nil, err + } + + var attachments []*repo_model.Attachment + err = sess.In("conversation_id", conversationIDs).Find(&attachments) + if err != nil { + return nil, err + } + + for j := range attachments { + attachmentPaths = append(attachmentPaths, attachments[j].RelativePath()) + } + + _, err = sess.In("conversation_id", conversationIDs).Delete(&repo_model.Attachment{}) + if err != nil { + return nil, err + } + + _, err = sess.In("id", conversationIDs).Delete(&Conversation{}) + if err != nil { + return nil, err + } + } + + return attachmentPaths, err +} + +// DeleteOrphanedConversations delete conversations without a repo +func DeleteOrphanedConversations(ctx context.Context) error { + var attachmentPaths []string + err := db.WithTx(ctx, func(ctx context.Context) error { + var ids []int64 + + if err := db.GetEngine(ctx).Table("conversation").Distinct("conversation.repo_id"). + Join("LEFT", "repository", "conversation.repo_id=repository.id"). + Where(builder.IsNull{"repository.id"}).GroupBy("conversation.repo_id"). + Find(&ids); err != nil { + return err + } + + for i := range ids { + paths, err := DeleteConversationsByRepoID(ctx, ids[i]) + if err != nil { + return err + } + attachmentPaths = append(attachmentPaths, paths...) + } + + return nil + }) + if err != nil { + return err + } + + // Remove conversation attachment files. + for i := range attachmentPaths { + system_model.RemoveAllWithNotice(ctx, "Delete conversation attachment", attachmentPaths[i]) + } + return nil +} + +// NewConversationWithIndex creates conversation with given index +func NewConversationWithIndex(ctx context.Context, opts NewConversationOptions) (err error) { + e := db.GetEngine(ctx) + + if opts.Conversation.Index <= 0 { + return fmt.Errorf("no conversation index provided") + } + if opts.Conversation.ID > 0 { + return fmt.Errorf("conversation exist") + } + + if _, err := e.Insert(opts.Conversation); err != nil { + return err + } + + if err := repo_model.UpdateRepoConversationNumbers(ctx, opts.Conversation.RepoID, false); err != nil { + return err + } + + if err = NewConversationUsers(ctx, opts.Repo, opts.Conversation); err != nil { + return err + } + + if len(opts.Attachments) > 0 { + attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) + if err != nil { + return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err) + } + + for i := 0; i < len(attachments); i++ { + attachments[i].ConversationID = opts.Conversation.ID + if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { + return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err) + } + } + } + + return opts.Conversation.LoadAttributes(ctx) +} + +// NewConversation creates new conversation with labels for repository. +func NewConversation(ctx context.Context, repo *repo_model.Repository, conversation *Conversation, uuids []string) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + idx, err := db.GetNextResourceIndex(ctx, "conversation_index", repo.ID) + if err != nil { + return fmt.Errorf("generate conversation index failed: %w", err) + } + + conversation.Index = idx + + if err = NewConversationWithIndex(ctx, NewConversationOptions{ + Repo: repo, + Conversation: conversation, + Attachments: uuids, + }); err != nil { + if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) || IsErrNewConversationInsert(err) { + return err + } + return fmt.Errorf("newConversation: %w", err) + } + + if err = committer.Commit(); err != nil { + return fmt.Errorf("Commit: %w", err) + } + + return nil +} diff --git a/models/conversations/conversation_user.go b/models/conversations/conversation_user.go new file mode 100644 index 0000000000000..b5971d101d723 --- /dev/null +++ b/models/conversations/conversation_user.go @@ -0,0 +1,79 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" +) + +// ConversationUser represents an conversation-user relation. +type ConversationUser struct { + ID int64 `xorm:"pk autoincr"` + UID int64 `xorm:"INDEX unique(uid_to_conversation)"` // User ID. + ConversationID int64 `xorm:"INDEX unique(uid_to_conversation)"` + IsRead bool + IsMentioned bool +} + +func init() { + db.RegisterModel(new(ConversationUser)) +} + +// UpdateConversationUserByRead updates conversation-user relation for reading. +func UpdateConversationUserByRead(ctx context.Context, uid, conversationID int64) error { + _, err := db.GetEngine(ctx).Exec("UPDATE `conversation_user` SET is_read=? WHERE uid=? AND conversation_id=?", true, uid, conversationID) + return err +} + +// UpdateConversationUsersByMentions updates conversation-user pairs by mentioning. +func UpdateConversationUsersByMentions(ctx context.Context, conversationID int64, uids []int64) error { + for _, uid := range uids { + iu := &ConversationUser{ + UID: uid, + ConversationID: conversationID, + } + has, err := db.GetEngine(ctx).Get(iu) + if err != nil { + return err + } + + iu.IsMentioned = true + if has { + _, err = db.GetEngine(ctx).ID(iu.ID).Cols("is_mentioned").Update(iu) + } else { + _, err = db.GetEngine(ctx).Insert(iu) + } + if err != nil { + return err + } + } + return nil +} + +// GetConversationMentionIDs returns all mentioned user IDs of an conversation. +func GetConversationMentionIDs(ctx context.Context, conversationID int64) ([]int64, error) { + var ids []int64 + return ids, db.GetEngine(ctx).Table(ConversationUser{}). + Where("conversation_id=?", conversationID). + And("is_mentioned=?", true). + Select("uid"). + Find(&ids) +} + +// NewConversationUsers inserts an conversation related users +func NewConversationUsers(ctx context.Context, repo *repo_model.Repository, conversation *Conversation) error { + + // Leave a seat for poster itself to append later, but if poster is one of assignee + // and just waste 1 unit is cheaper than re-allocate memory once. + conversationUsers := make([]*ConversationUser, 0, 1) + + conversationUsers = append(conversationUsers, &ConversationUser{ + ConversationID: conversation.ID, + }) + + return db.Insert(ctx, conversationUsers) +} diff --git a/models/conversations/dependency.go b/models/conversations/dependency.go new file mode 100644 index 0000000000000..72aafe96ef93d --- /dev/null +++ b/models/conversations/dependency.go @@ -0,0 +1,222 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" +) + +// ErrDependencyExists represents a "DependencyAlreadyExists" kind of error. +type ErrDependencyExists struct { + ConversationID int64 + DependencyID int64 +} + +// IsErrDependencyExists checks if an error is a ErrDependencyExists. +func IsErrDependencyExists(err error) bool { + _, ok := err.(ErrDependencyExists) + return ok +} + +func (err ErrDependencyExists) Error() string { + return fmt.Sprintf("conversation dependency does already exist [conversation id: %d, dependency id: %d]", err.ConversationID, err.DependencyID) +} + +func (err ErrDependencyExists) Unwrap() error { + return util.ErrAlreadyExist +} + +// ErrDependencyNotExists represents a "DependencyAlreadyExists" kind of error. +type ErrDependencyNotExists struct { + ConversationID int64 + DependencyID int64 +} + +// IsErrDependencyNotExists checks if an error is a ErrDependencyExists. +func IsErrDependencyNotExists(err error) bool { + _, ok := err.(ErrDependencyNotExists) + return ok +} + +func (err ErrDependencyNotExists) Error() string { + return fmt.Sprintf("conversation dependency does not exist [conversation id: %d, dependency id: %d]", err.ConversationID, err.DependencyID) +} + +func (err ErrDependencyNotExists) Unwrap() error { + return util.ErrNotExist +} + +// ErrCircularDependency represents a "DependencyCircular" kind of error. +type ErrCircularDependency struct { + ConversationID int64 + DependencyID int64 +} + +// IsErrCircularDependency checks if an error is a ErrCircularDependency. +func IsErrCircularDependency(err error) bool { + _, ok := err.(ErrCircularDependency) + return ok +} + +func (err ErrCircularDependency) Error() string { + return fmt.Sprintf("circular dependencies exists (two conversations blocking each other) [conversation id: %d, dependency id: %d]", err.ConversationID, err.DependencyID) +} + +// ErrDependenciesLeft represents an error where the conversation you're trying to close still has dependencies left. +type ErrDependenciesLeft struct { + ConversationID int64 +} + +// IsErrDependenciesLeft checks if an error is a ErrDependenciesLeft. +func IsErrDependenciesLeft(err error) bool { + _, ok := err.(ErrDependenciesLeft) + return ok +} + +func (err ErrDependenciesLeft) Error() string { + return fmt.Sprintf("conversation has open dependencies [conversation id: %d]", err.ConversationID) +} + +// ErrUnknownDependencyType represents an error where an unknown dependency type was passed +type ErrUnknownDependencyType struct { + Type DependencyType +} + +// IsErrUnknownDependencyType checks if an error is ErrUnknownDependencyType +func IsErrUnknownDependencyType(err error) bool { + _, ok := err.(ErrUnknownDependencyType) + return ok +} + +func (err ErrUnknownDependencyType) Error() string { + return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) +} + +func (err ErrUnknownDependencyType) Unwrap() error { + return util.ErrInvalidArgument +} + +// ConversationDependency represents an conversation dependency +type ConversationDependency struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + ConversationID int64 `xorm:"UNIQUE(conversation_dependency) NOT NULL"` + DependencyID int64 `xorm:"UNIQUE(conversation_dependency) NOT NULL"` + CreatedUnix timeutil.TimeStamp `xorm:"created"` + UpdatedUnix timeutil.TimeStamp `xorm:"updated"` +} + +func init() { + db.RegisterModel(new(ConversationDependency)) +} + +// DependencyType Defines Dependency Type Constants +type DependencyType int + +// Define Dependency Types +const ( + DependencyTypeBlockedBy DependencyType = iota + DependencyTypeBlocking +) + +// CreateConversationDependency creates a new dependency for an conversation +func CreateConversationDependency(ctx context.Context, user *user_model.User, conversation, dep *Conversation) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + // Check if it already exists + exists, err := conversationDepExists(ctx, conversation.ID, dep.ID) + if err != nil { + return err + } + if exists { + return ErrDependencyExists{conversation.ID, dep.ID} + } + // And if it would be circular + circular, err := conversationDepExists(ctx, dep.ID, conversation.ID) + if err != nil { + return err + } + if circular { + return ErrCircularDependency{conversation.ID, dep.ID} + } + + if err := db.Insert(ctx, &ConversationDependency{ + UserID: user.ID, + ConversationID: conversation.ID, + DependencyID: dep.ID, + }); err != nil { + return err + } + + // Add comment referencing the new dependency + if err = createConversationDependencyComment(ctx, user, conversation, dep, true); err != nil { + return err + } + + return committer.Commit() +} + +// RemoveConversationDependency removes a dependency from an conversation +func RemoveConversationDependency(ctx context.Context, user *user_model.User, conversation, dep *Conversation, depType DependencyType) (err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + var conversationDepToDelete ConversationDependency + + switch depType { + case DependencyTypeBlockedBy: + conversationDepToDelete = ConversationDependency{ConversationID: conversation.ID, DependencyID: dep.ID} + case DependencyTypeBlocking: + conversationDepToDelete = ConversationDependency{ConversationID: dep.ID, DependencyID: conversation.ID} + default: + return ErrUnknownDependencyType{depType} + } + + affected, err := db.GetEngine(ctx).Delete(&conversationDepToDelete) + if err != nil { + return err + } + + // If we deleted nothing, the dependency did not exist + if affected <= 0 { + return ErrDependencyNotExists{conversation.ID, dep.ID} + } + + // Add comment referencing the removed dependency + if err = createConversationDependencyComment(ctx, user, conversation, dep, false); err != nil { + return err + } + return committer.Commit() +} + +// Check if the dependency already exists +func conversationDepExists(ctx context.Context, conversationID, depID int64) (bool, error) { + return db.GetEngine(ctx).Where("(conversation_id = ? AND dependency_id = ?)", conversationID, depID).Exist(&ConversationDependency{}) +} + +// ConversationNoDependenciesLeft checks if conversation can be closed +func ConversationNoDependenciesLeft(ctx context.Context, conversation *Conversation) (bool, error) { + exists, err := db.GetEngine(ctx). + Table("conversation_dependency"). + Select("conversation.*"). + Join("INNER", "conversation", "conversation.id = conversation_dependency.dependency_id"). + Where("conversation_dependency.conversation_id = ?", conversation.ID). + And("conversation.is_closed = ?", "0"). + Exist(&Conversation{}) + + return !exists, err +} diff --git a/models/conversations/reaction.go b/models/conversations/reaction.go new file mode 100644 index 0000000000000..b697826bdc350 --- /dev/null +++ b/models/conversations/reaction.go @@ -0,0 +1,373 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "bytes" + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" + + "xorm.io/builder" +) + +// ErrForbiddenConversationReaction is used when a forbidden reaction was try to created +type ErrForbiddenConversationReaction struct { + Reaction string +} + +// IsErrForbiddenConversationReaction checks if an error is a ErrForbiddenConversationReaction. +func IsErrForbiddenConversationReaction(err error) bool { + _, ok := err.(ErrForbiddenConversationReaction) + return ok +} + +func (err ErrForbiddenConversationReaction) Error() string { + return fmt.Sprintf("'%s' is not an allowed reaction", err.Reaction) +} + +func (err ErrForbiddenConversationReaction) Unwrap() error { + return util.ErrPermissionDenied +} + +// ErrReactionAlreadyExist is used when a existing reaction was try to created +type ErrReactionAlreadyExist struct { + Reaction string +} + +// IsErrReactionAlreadyExist checks if an error is a ErrReactionAlreadyExist. +func IsErrReactionAlreadyExist(err error) bool { + _, ok := err.(ErrReactionAlreadyExist) + return ok +} + +func (err ErrReactionAlreadyExist) Error() string { + return fmt.Sprintf("reaction '%s' already exists", err.Reaction) +} + +func (err ErrReactionAlreadyExist) Unwrap() error { + return util.ErrAlreadyExist +} + +// Reaction represents a reactions on conversations and comments. +type Reaction struct { + ID int64 `xorm:"pk autoincr"` + Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` + ConversationID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + CommentID int64 `xorm:"INDEX UNIQUE(s)"` + UserID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` + OriginalAuthorID int64 `xorm:"INDEX UNIQUE(s) NOT NULL DEFAULT(0)"` + OriginalAuthor string `xorm:"INDEX UNIQUE(s)"` + User *user_model.User `xorm:"-"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` +} + +// LoadUser load user of reaction +func (r *Reaction) LoadUser(ctx context.Context) (*user_model.User, error) { + if r.User != nil { + return r.User, nil + } + user, err := user_model.GetUserByID(ctx, r.UserID) + if err != nil { + return nil, err + } + r.User = user + return user, nil +} + +// RemapExternalUser ExternalUserRemappable interface +func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int64) error { + r.OriginalAuthor = externalName + r.OriginalAuthorID = externalID + r.UserID = userID + return nil +} + +// GetUserID ExternalUserRemappable interface +func (r *Reaction) GetUserID() int64 { return r.UserID } + +// GetExternalName ExternalUserRemappable interface +func (r *Reaction) GetExternalName() string { return r.OriginalAuthor } + +// GetExternalID ExternalUserRemappable interface +func (r *Reaction) GetExternalID() int64 { return r.OriginalAuthorID } + +func init() { + db.RegisterModel(new(Reaction)) +} + +// FindReactionsOptions describes the conditions to Find reactions +type FindReactionsOptions struct { + db.ListOptions + ConversationID int64 + CommentID int64 + UserID int64 + Reaction string +} + +func (opts *FindReactionsOptions) toConds() builder.Cond { + // If Conversation ID is set add to Query + cond := builder.NewCond() + if opts.ConversationID > 0 { + cond = cond.And(builder.Eq{"reaction.conversation_id": opts.ConversationID}) + } + // If CommentID is > 0 add to Query + // If it is 0 Query ignore CommentID to select + // If it is -1 it explicit search of Conversation Reactions where CommentID = 0 + if opts.CommentID > 0 { + cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID}) + } else if opts.CommentID == -1 { + cond = cond.And(builder.Eq{"reaction.comment_id": 0}) + } + if opts.UserID > 0 { + cond = cond.And(builder.Eq{ + "reaction.user_id": opts.UserID, + "reaction.original_author_id": 0, + }) + } + if opts.Reaction != "" { + cond = cond.And(builder.Eq{"reaction.type": opts.Reaction}) + } + + return cond +} + +// FindCommentReactions returns a ReactionList of all reactions from an comment +func FindCommentReactions(ctx context.Context, conversationID, commentID int64) (ReactionList, int64, error) { + return FindReactions(ctx, FindReactionsOptions{ + ConversationID: conversationID, + CommentID: commentID, + }) +} + +// FindConversationReactions returns a ReactionList of all reactions from an conversation +func FindConversationReactions(ctx context.Context, conversationID int64, listOptions db.ListOptions) (ReactionList, int64, error) { + return FindReactions(ctx, FindReactionsOptions{ + ListOptions: listOptions, + ConversationID: conversationID, + CommentID: -1, + }) +} + +// FindReactions returns a ReactionList of all reactions from an conversation or a comment +func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList, int64, error) { + sess := db.GetEngine(ctx). + Where(opts.toConds()). + In("reaction.`type`", setting.UI.Reactions). + Asc("reaction.conversation_id", "reaction.comment_id", "reaction.created_unix", "reaction.id") + if opts.Page != 0 { + sess = db.SetSessionPagination(sess, &opts) + + reactions := make([]*Reaction, 0, opts.PageSize) + count, err := sess.FindAndCount(&reactions) + return reactions, count, err + } + + reactions := make([]*Reaction, 0, 10) + count, err := sess.FindAndCount(&reactions) + return reactions, count, err +} + +func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { + reaction := &Reaction{ + Type: opts.Type, + UserID: opts.DoerID, + ConversationID: opts.ConversationID, + CommentID: opts.CommentID, + } + findOpts := FindReactionsOptions{ + ConversationID: opts.ConversationID, + CommentID: opts.CommentID, + Reaction: opts.Type, + UserID: opts.DoerID, + } + if findOpts.CommentID == 0 { + // explicit search of Conversation Reactions where CommentID = 0 + findOpts.CommentID = -1 + } + + existingR, _, err := FindReactions(ctx, findOpts) + if err != nil { + return nil, err + } + if len(existingR) > 0 { + return existingR[0], ErrReactionAlreadyExist{Reaction: opts.Type} + } + + if err := db.Insert(ctx, reaction); err != nil { + return nil, err + } + + return reaction, nil +} + +// ReactionOptions defines options for creating or deleting reactions +type ReactionOptions struct { + Type string + DoerID int64 + ConversationID int64 + CommentID int64 +} + +// CreateReaction creates reaction for conversation or comment. +func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { + if !setting.UI.ReactionsLookup.Contains(opts.Type) { + return nil, ErrForbiddenConversationReaction{opts.Type} + } + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + reaction, err := createReaction(ctx, opts) + if err != nil { + return reaction, err + } + + if err := committer.Commit(); err != nil { + return nil, err + } + return reaction, nil +} + +// DeleteReaction deletes reaction for conversation or comment. +func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { + reaction := &Reaction{ + Type: opts.Type, + UserID: opts.DoerID, + ConversationID: opts.ConversationID, + CommentID: opts.CommentID, + } + + sess := db.GetEngine(ctx).Where("original_author_id = 0") + if opts.CommentID == -1 { + reaction.CommentID = 0 + sess.MustCols("comment_id") + } + + _, err := sess.Delete(reaction) + return err +} + +// DeleteConversationReaction deletes a reaction on conversation. +func DeleteConversationReaction(ctx context.Context, doerID, conversationID int64, content string) error { + return DeleteReaction(ctx, &ReactionOptions{ + Type: content, + DoerID: doerID, + ConversationID: conversationID, + CommentID: -1, + }) +} + +// DeleteCommentReaction deletes a reaction on comment. +func DeleteCommentReaction(ctx context.Context, doerID, conversationID, commentID int64, content string) error { + return DeleteReaction(ctx, &ReactionOptions{ + Type: content, + DoerID: doerID, + ConversationID: conversationID, + CommentID: commentID, + }) +} + +// ReactionList represents list of reactions +type ReactionList []*Reaction + +// HasUser check if user has reacted +func (list ReactionList) HasUser(userID int64) bool { + if userID == 0 { + return false + } + for _, reaction := range list { + if reaction.OriginalAuthor == "" && reaction.UserID == userID { + return true + } + } + return false +} + +// GroupByType returns reactions grouped by type +func (list ReactionList) GroupByType() map[string]ReactionList { + reactions := make(map[string]ReactionList) + for _, reaction := range list { + reactions[reaction.Type] = append(reactions[reaction.Type], reaction) + } + return reactions +} + +func (list ReactionList) getUserIDs() []int64 { + return container.FilterSlice(list, func(reaction *Reaction) (int64, bool) { + if reaction.OriginalAuthor != "" { + return 0, false + } + return reaction.UserID, true + }) +} + +func valuesUser(m map[int64]*user_model.User) []*user_model.User { + values := make([]*user_model.User, 0, len(m)) + for _, v := range m { + values = append(values, v) + } + return values +} + +// LoadUsers loads reactions' all users +func (list ReactionList) LoadUsers(ctx context.Context, repo *repo_model.Repository) ([]*user_model.User, error) { + if len(list) == 0 { + return nil, nil + } + + userIDs := list.getUserIDs() + userMaps := make(map[int64]*user_model.User, len(userIDs)) + err := db.GetEngine(ctx). + In("id", userIDs). + Find(&userMaps) + if err != nil { + return nil, fmt.Errorf("find user: %w", err) + } + + for _, reaction := range list { + if reaction.OriginalAuthor != "" { + reaction.User = user_model.NewReplaceUser(fmt.Sprintf("%s(%s)", reaction.OriginalAuthor, repo.OriginalServiceType.Name())) + } else if user, ok := userMaps[reaction.UserID]; ok { + reaction.User = user + } else { + reaction.User = user_model.NewGhostUser() + } + } + return valuesUser(userMaps), nil +} + +// GetFirstUsers returns first reacted user display names separated by comma +func (list ReactionList) GetFirstUsers() string { + var buffer bytes.Buffer + rem := setting.UI.ReactionMaxUserNum + for _, reaction := range list { + if buffer.Len() > 0 { + buffer.WriteString(", ") + } + buffer.WriteString(reaction.User.Name) + if rem--; rem == 0 { + break + } + } + return buffer.String() +} + +// GetMoreUserCount returns count of not shown users in reaction tooltip +func (list ReactionList) GetMoreUserCount() int { + if len(list) <= setting.UI.ReactionMaxUserNum { + return 0 + } + return len(list) - setting.UI.ReactionMaxUserNum +} diff --git a/models/perm/access/repo_permission.go b/models/perm/access/repo_permission.go index 0ed116a132465..94d6d1c8120f6 100644 --- a/models/perm/access/repo_permission.go +++ b/models/perm/access/repo_permission.go @@ -127,6 +127,10 @@ func (p *Permission) CanReadIssuesOrPulls(isPull bool) bool { return p.CanRead(unit.TypeIssues) } +func (p *Permission) CanReadConversations() bool { + return p.CanRead(unit.TypeConversations) +} + // CanWrite returns true if user could write to this unit func (p *Permission) CanWrite(unitType unit.Type) bool { return p.CanAccess(perm_model.AccessModeWrite, unitType) @@ -141,6 +145,10 @@ func (p *Permission) CanWriteIssuesOrPulls(isPull bool) bool { return p.CanWrite(unit.TypeIssues) } +func (p *Permission) CanWriteConversations() bool { + return p.CanWrite(unit.TypeConversations) +} + func (p *Permission) ReadableUnitTypes() []unit.Type { types := make([]unit.Type, 0, len(p.units)) for _, u := range p.units { diff --git a/models/repo/attachment.go b/models/repo/attachment.go index fa4f6c47e604a..cb3528c2da470 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -25,6 +25,7 @@ type Attachment struct { UUID string `xorm:"uuid UNIQUE"` RepoID int64 `xorm:"INDEX"` // this should not be zero IssueID int64 `xorm:"INDEX"` // maybe zero when creating + ConversationID int64 `xorm:"INDEX"` // maybe zero when creating ReleaseID int64 `xorm:"INDEX"` // maybe zero when creating UploaderID int64 `xorm:"INDEX DEFAULT 0"` // Notice: will be zero before this column added CommentID int64 `xorm:"INDEX"` @@ -261,3 +262,9 @@ func DeleteOrphanedAttachments(ctx context.Context) error { Delete(new(Attachment)) return err } + +// GetAttachmentsByIssueID returns all attachments of an issue. +func GetAttachmentsByConversationID(ctx context.Context, conversationID int64) ([]*Attachment, error) { + attachments := make([]*Attachment, 0, 10) + return attachments, db.GetEngine(ctx).Where("conversation_id = ? AND comment_id = 0", conversationID).Find(&attachments) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 68f8e16a21d58..1e89bc80004e5 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -139,24 +139,26 @@ type Repository struct { DefaultBranch string DefaultWikiBranch string - NumWatches int - NumStars int - NumForks int - NumIssues int - NumClosedIssues int - NumOpenIssues int `xorm:"-"` - NumPulls int - NumClosedPulls int - NumOpenPulls int `xorm:"-"` - NumMilestones int `xorm:"NOT NULL DEFAULT 0"` - NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` - NumOpenMilestones int `xorm:"-"` - NumProjects int `xorm:"NOT NULL DEFAULT 0"` - NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"` - NumOpenProjects int `xorm:"-"` - NumActionRuns int `xorm:"NOT NULL DEFAULT 0"` - NumClosedActionRuns int `xorm:"NOT NULL DEFAULT 0"` - NumOpenActionRuns int `xorm:"-"` + NumWatches int + NumStars int + NumForks int + NumIssues int + NumClosedIssues int + NumOpenIssues int `xorm:"-"` + NumPulls int + NumClosedPulls int + NumOpenPulls int `xorm:"-"` + NumMilestones int `xorm:"NOT NULL DEFAULT 0"` + NumClosedMilestones int `xorm:"NOT NULL DEFAULT 0"` + NumOpenMilestones int `xorm:"-"` + NumProjects int `xorm:"NOT NULL DEFAULT 0"` + NumClosedProjects int `xorm:"NOT NULL DEFAULT 0"` + NumOpenProjects int `xorm:"-"` + NumActionRuns int `xorm:"NOT NULL DEFAULT 0"` + NumClosedActionRuns int `xorm:"NOT NULL DEFAULT 0"` + NumOpenActionRuns int `xorm:"-"` + NumConversations int `xorm:"NOT NULL DEFAULT 0"` + NumLockedConversations int `xorm:"NOT NULL DEFAULT 0"` IsPrivate bool `xorm:"INDEX"` IsEmpty bool `xorm:"INDEX"` @@ -909,6 +911,27 @@ func UpdateRepoIssueNumbers(ctx context.Context, repoID int64, isPull, isClosed return err } +// UpdateRepoConversationNumbers updates one of a repositories amount of (locked|unlocked) (Conversation) with the current count +func UpdateRepoConversationNumbers(ctx context.Context, repoID int64, isLocked bool) error { + field := "num_" + if isLocked { + field += "locked_" + } + field += "conversations" + + subQuery := builder.Select("count(*)"). + From("conversation").Where(builder.Eq{ + "repo_id": repoID, + }.And(builder.If(isLocked, builder.Eq{"is_locked": isLocked}))) + + // builder.Update(cond) will generate SQL like UPDATE ... SET cond + query := builder.Update(builder.Eq{field: subQuery}). + From("repository"). + Where(builder.Eq{"id": repoID}) + _, err := db.Exec(ctx, query) + return err +} + // CountNullArchivedRepository counts the number of repositories with is_archived is null func CountNullArchivedRepository(ctx context.Context) (int64, error) { return db.GetEngine(ctx).Where(builder.IsNull{"is_archived"}).Count(new(Repository)) diff --git a/models/unit/unit.go b/models/unit/unit.go index 3b62e5f982267..e2a829b36660e 100644 --- a/models/unit/unit.go +++ b/models/unit/unit.go @@ -31,6 +31,7 @@ const ( TypeProjects // 8 Projects TypePackages // 9 Packages TypeActions // 10 Actions + TypeConversations //11 Conversations ) // Value returns integer value for unit type (used by template) diff --git a/modules/conversation/template/template.go b/modules/conversation/template/template.go new file mode 100644 index 0000000000000..70d2ef3730a85 --- /dev/null +++ b/modules/conversation/template/template.go @@ -0,0 +1,489 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package template + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/container" + api "code.gitea.io/gitea/modules/structs" + + "gitea.com/go-chi/binding" +) + +// Validate checks whether an ConversationTemplate is considered valid, and returns the first error +func Validate(template *api.ConversationTemplate) error { + if err := validateMetadata(template); err != nil { + return err + } + if template.Type() == api.ConversationTemplateTypeYaml { + if err := validateYaml(template); err != nil { + return err + } + } + return nil +} + +func validateMetadata(template *api.ConversationTemplate) error { + if strings.TrimSpace(template.Name) == "" { + return fmt.Errorf("'name' is required") + } + if strings.TrimSpace(template.About) == "" { + return fmt.Errorf("'about' is required") + } + return nil +} + +func validateYaml(template *api.ConversationTemplate) error { + if len(template.Fields) == 0 { + return fmt.Errorf("'body' is required") + } + ids := make(container.Set[string]) + for idx, field := range template.Fields { + if err := validateID(field, idx, ids); err != nil { + return err + } + if err := validateLabel(field, idx); err != nil { + return err + } + + position := newErrorPosition(idx, field.Type) + switch field.Type { + case api.ConversationFormFieldTypeMarkdown: + if err := validateStringItem(position, field.Attributes, true, "value"); err != nil { + return err + } + case api.ConversationFormFieldTypeTextarea: + if err := validateStringItem(position, field.Attributes, false, + "description", + "placeholder", + "value", + "render", + ); err != nil { + return err + } + case api.ConversationFormFieldTypeInput: + if err := validateStringItem(position, field.Attributes, false, + "description", + "placeholder", + "value", + ); err != nil { + return err + } + if err := validateBoolItem(position, field.Validations, "is_number"); err != nil { + return err + } + if err := validateStringItem(position, field.Validations, false, "regex"); err != nil { + return err + } + case api.ConversationFormFieldTypeDropdown: + if err := validateStringItem(position, field.Attributes, false, "description"); err != nil { + return err + } + if err := validateBoolItem(position, field.Attributes, "multiple"); err != nil { + return err + } + if err := validateBoolItem(position, field.Attributes, "list"); err != nil { + return err + } + if err := validateOptions(field, idx); err != nil { + return err + } + if err := validateDropdownDefault(position, field.Attributes); err != nil { + return err + } + case api.ConversationFormFieldTypeCheckboxes: + if err := validateStringItem(position, field.Attributes, false, "description"); err != nil { + return err + } + if err := validateOptions(field, idx); err != nil { + return err + } + default: + return position.Errorf("unknown type") + } + + if err := validateRequired(field, idx); err != nil { + return err + } + } + return nil +} + +func validateLabel(field *api.ConversationFormField, idx int) error { + if field.Type == api.ConversationFormFieldTypeMarkdown { + // The label is not required for a markdown field + return nil + } + return validateStringItem(newErrorPosition(idx, field.Type), field.Attributes, true, "label") +} + +func validateRequired(field *api.ConversationFormField, idx int) error { + if field.Type == api.ConversationFormFieldTypeMarkdown || field.Type == api.ConversationFormFieldTypeCheckboxes { + // The label is not required for a markdown or checkboxes field + return nil + } + if err := validateBoolItem(newErrorPosition(idx, field.Type), field.Validations, "required"); err != nil { + return err + } + if required, _ := field.Validations["required"].(bool); required && !field.VisibleOnForm() { + return newErrorPosition(idx, field.Type).Errorf("can not require a hidden field") + } + return nil +} + +func validateID(field *api.ConversationFormField, idx int, ids container.Set[string]) error { + if field.Type == api.ConversationFormFieldTypeMarkdown { + // The ID is not required for a markdown field + return nil + } + + position := newErrorPosition(idx, field.Type) + if field.ID == "" { + // If the ID is empty in yaml, template.Unmarshal will auto autofill it, so it cannot be empty + return position.Errorf("'id' is required") + } + if binding.AlphaDashPattern.MatchString(field.ID) { + return position.Errorf("'id' should contain only alphanumeric, '-' and '_'") + } + if !ids.Add(field.ID) { + return position.Errorf("'id' should be unique") + } + return nil +} + +func validateOptions(field *api.ConversationFormField, idx int) error { + if field.Type != api.ConversationFormFieldTypeDropdown && field.Type != api.ConversationFormFieldTypeCheckboxes { + return nil + } + position := newErrorPosition(idx, field.Type) + + options, ok := field.Attributes["options"].([]any) + if !ok || len(options) == 0 { + return position.Errorf("'options' is required and should be a array") + } + + for optIdx, option := range options { + position := newErrorPosition(idx, field.Type, optIdx) + switch field.Type { + case api.ConversationFormFieldTypeDropdown: + if _, ok := option.(string); !ok { + return position.Errorf("should be a string") + } + case api.ConversationFormFieldTypeCheckboxes: + opt, ok := option.(map[string]any) + if !ok { + return position.Errorf("should be a dictionary") + } + if label, ok := opt["label"].(string); !ok || label == "" { + return position.Errorf("'label' is required and should be a string") + } + + if visibility, ok := opt["visible"]; ok { + visibilityList, ok := visibility.([]any) + if !ok { + return position.Errorf("'visible' should be list") + } + for _, visibleType := range visibilityList { + visibleType, ok := visibleType.(string) + if !ok || !(visibleType == "form" || visibleType == "content") { + return position.Errorf("'visible' list can only contain strings of 'form' and 'content'") + } + } + } + + if required, ok := opt["required"]; ok { + if _, ok := required.(bool); !ok { + return position.Errorf("'required' should be a bool") + } + + // validate if hidden field is required + if visibility, ok := opt["visible"]; ok { + visibilityList, _ := visibility.([]any) + isVisible := false + for _, v := range visibilityList { + if vv, _ := v.(string); vv == "form" { + isVisible = true + break + } + } + if !isVisible { + return position.Errorf("can not require a hidden checkbox") + } + } + } + } + } + return nil +} + +func validateStringItem(position errorPosition, m map[string]any, required bool, names ...string) error { + for _, name := range names { + v, ok := m[name] + if !ok { + if required { + return position.Errorf("'%s' is required", name) + } + return nil + } + attr, ok := v.(string) + if !ok { + return position.Errorf("'%s' should be a string", name) + } + if strings.TrimSpace(attr) == "" && required { + return position.Errorf("'%s' is required", name) + } + } + return nil +} + +func validateBoolItem(position errorPosition, m map[string]any, names ...string) error { + for _, name := range names { + v, ok := m[name] + if !ok { + return nil + } + if _, ok := v.(bool); !ok { + return position.Errorf("'%s' should be a bool", name) + } + } + return nil +} + +func validateDropdownDefault(position errorPosition, attributes map[string]any) error { + v, ok := attributes["default"] + if !ok { + return nil + } + defaultValue, ok := v.(int) + if !ok { + return position.Errorf("'default' should be an int") + } + + options, ok := attributes["options"].([]any) + if !ok { + // should not happen + return position.Errorf("'options' is required and should be a array") + } + if defaultValue < 0 || defaultValue >= len(options) { + return position.Errorf("the value of 'default' is out of range") + } + + return nil +} + +type errorPosition string + +func (p errorPosition) Errorf(format string, a ...any) error { + return fmt.Errorf(string(p)+": "+format, a...) +} + +func newErrorPosition(fieldIdx int, fieldType api.ConversationFormFieldType, optionIndex ...int) errorPosition { + ret := fmt.Sprintf("body[%d](%s)", fieldIdx, fieldType) + if len(optionIndex) > 0 { + ret += fmt.Sprintf(", option[%d]", optionIndex[0]) + } + return errorPosition(ret) +} + +// RenderToMarkdown renders template to markdown with specified values +func RenderToMarkdown(template *api.ConversationTemplate, values url.Values) string { + builder := &strings.Builder{} + + for _, field := range template.Fields { + f := &valuedField{ + ConversationFormField: field, + Values: values, + } + if f.ID == "" || !f.VisibleInContent() { + continue + } + f.WriteTo(builder) + } + + return builder.String() +} + +type valuedField struct { + *api.ConversationFormField + url.Values +} + +func (f *valuedField) WriteTo(builder *strings.Builder) { + // write label + if !f.HideLabel() { + _, _ = fmt.Fprintf(builder, "### %s\n\n", f.Label()) + } + + blankPlaceholder := "_No response_\n" + + // write body + switch f.Type { + case api.ConversationFormFieldTypeCheckboxes: + for _, option := range f.Options() { + if !option.VisibleInContent() { + continue + } + checked := " " + if option.IsChecked() { + checked = "x" + } + _, _ = fmt.Fprintf(builder, "- [%s] %s\n", checked, option.Label()) + } + case api.ConversationFormFieldTypeDropdown: + var checkeds []string + for _, option := range f.Options() { + if option.IsChecked() { + checkeds = append(checkeds, option.Label()) + } + } + if len(checkeds) > 0 { + if list, ok := f.Attributes["list"].(bool); ok && list { + for _, check := range checkeds { + _, _ = fmt.Fprintf(builder, "- %s\n", check) + } + } else { + _, _ = fmt.Fprintf(builder, "%s\n", strings.Join(checkeds, ", ")) + } + } else { + _, _ = fmt.Fprint(builder, blankPlaceholder) + } + case api.ConversationFormFieldTypeInput: + if value := f.Value(); value == "" { + _, _ = fmt.Fprint(builder, blankPlaceholder) + } else { + _, _ = fmt.Fprintf(builder, "%s\n", value) + } + case api.ConversationFormFieldTypeTextarea: + if value := f.Value(); value == "" { + _, _ = fmt.Fprint(builder, blankPlaceholder) + } else if render := f.Render(); render != "" { + quotes := minQuotes(value) + _, _ = fmt.Fprintf(builder, "%s%s\n%s\n%s\n", quotes, f.Render(), value, quotes) + } else { + _, _ = fmt.Fprintf(builder, "%s\n", value) + } + case api.ConversationFormFieldTypeMarkdown: + if value, ok := f.Attributes["value"].(string); ok { + _, _ = fmt.Fprintf(builder, "%s\n", value) + } + } + _, _ = fmt.Fprintln(builder) +} + +func (f *valuedField) Label() string { + if label, ok := f.Attributes["label"].(string); ok { + return label + } + return "" +} + +func (f *valuedField) HideLabel() bool { + if f.Type == api.ConversationFormFieldTypeMarkdown { + return true + } + if label, ok := f.Attributes["hide_label"].(bool); ok { + return label + } + return false +} + +func (f *valuedField) Render() string { + if render, ok := f.Attributes["render"].(string); ok { + return render + } + return "" +} + +func (f *valuedField) Value() string { + return strings.TrimSpace(f.Get(fmt.Sprintf("form-field-%s", f.ID))) +} + +func (f *valuedField) Options() []*valuedOption { + if options, ok := f.Attributes["options"].([]any); ok { + ret := make([]*valuedOption, 0, len(options)) + for i, option := range options { + ret = append(ret, &valuedOption{ + index: i, + data: option, + field: f, + }) + } + return ret + } + return nil +} + +type valuedOption struct { + index int + data any + field *valuedField +} + +func (o *valuedOption) Label() string { + switch o.field.Type { + case api.ConversationFormFieldTypeDropdown: + if label, ok := o.data.(string); ok { + return label + } + case api.ConversationFormFieldTypeCheckboxes: + if vs, ok := o.data.(map[string]any); ok { + if v, ok := vs["label"].(string); ok { + return v + } + } + } + return "" +} + +func (o *valuedOption) IsChecked() bool { + switch o.field.Type { + case api.ConversationFormFieldTypeDropdown: + checks := strings.Split(o.field.Get(fmt.Sprintf("form-field-%s", o.field.ID)), ",") + idx := strconv.Itoa(o.index) + for _, v := range checks { + if v == idx { + return true + } + } + return false + case api.ConversationFormFieldTypeCheckboxes: + return o.field.Get(fmt.Sprintf("form-field-%s-%d", o.field.ID, o.index)) == "on" + } + return false +} + +func (o *valuedOption) VisibleInContent() bool { + if o.field.Type == api.ConversationFormFieldTypeCheckboxes { + if vs, ok := o.data.(map[string]any); ok { + if vl, ok := vs["visible"].([]any); ok { + for _, v := range vl { + if vv, _ := v.(string); vv == "content" { + return true + } + } + return false + } + } + } + return true +} + +var minQuotesRegex = regexp.MustCompilePOSIX("^`{3,}") + +// minQuotes return 3 or more back-quotes. +// If n back-quotes exists, use n+1 back-quotes to quote. +func minQuotes(value string) string { + ret := "```" + for _, v := range minQuotesRegex.FindAllString(value, -1) { + if len(v) >= len(ret) { + ret = v + "`" + } + } + return ret +} diff --git a/modules/indexer/conversations/bleve/bleve.go b/modules/indexer/conversations/bleve/bleve.go new file mode 100644 index 0000000000000..08cfd7d4df53c --- /dev/null +++ b/modules/indexer/conversations/bleve/bleve.go @@ -0,0 +1,294 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package bleve + +import ( + "context" + + "code.gitea.io/gitea/modules/indexer/conversations/internal" + indexer_internal "code.gitea.io/gitea/modules/indexer/internal" + inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/analysis/analyzer/custom" + "github.com/blevesearch/bleve/v2/analysis/token/camelcase" + "github.com/blevesearch/bleve/v2/analysis/token/lowercase" + "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm" + "github.com/blevesearch/bleve/v2/analysis/tokenizer/unicode" + "github.com/blevesearch/bleve/v2/mapping" + "github.com/blevesearch/bleve/v2/search/query" +) + +const ( + conversationIndexerAnalyzer = "conversationIndexer" + conversationIndexerDocType = "conversationIndexerDocType" + conversationIndexerLatestVersion = 4 +) + +const unicodeNormalizeName = "unicodeNormalize" + +func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error { + return m.AddCustomTokenFilter(unicodeNormalizeName, map[string]any{ + "type": unicodenorm.Name, + "form": unicodenorm.NFC, + }) +} + +const maxBatchSize = 16 + +// IndexerData an update to the conversation indexer +type IndexerData internal.IndexerData + +// Type returns the document type, for bleve's mapping.Classifier interface. +func (i *IndexerData) Type() string { + return conversationIndexerDocType +} + +// generateConversationIndexMapping generates the bleve index mapping for conversations +func generateConversationIndexMapping() (mapping.IndexMapping, error) { + mapping := bleve.NewIndexMapping() + docMapping := bleve.NewDocumentMapping() + + numericFieldMapping := bleve.NewNumericFieldMapping() + numericFieldMapping.Store = false + numericFieldMapping.IncludeInAll = false + docMapping.AddFieldMappingsAt("repo_id", numericFieldMapping) + + textFieldMapping := bleve.NewTextFieldMapping() + textFieldMapping.Store = false + textFieldMapping.IncludeInAll = false + + boolFieldMapping := bleve.NewBooleanFieldMapping() + boolFieldMapping.Store = false + boolFieldMapping.IncludeInAll = false + + numberFieldMapping := bleve.NewNumericFieldMapping() + numberFieldMapping.Store = false + numberFieldMapping.IncludeInAll = false + + docMapping.AddFieldMappingsAt("is_public", boolFieldMapping) + + docMapping.AddFieldMappingsAt("title", textFieldMapping) + docMapping.AddFieldMappingsAt("content", textFieldMapping) + docMapping.AddFieldMappingsAt("comments", textFieldMapping) + + docMapping.AddFieldMappingsAt("is_pull", boolFieldMapping) + docMapping.AddFieldMappingsAt("is_closed", boolFieldMapping) + docMapping.AddFieldMappingsAt("label_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("no_label", boolFieldMapping) + docMapping.AddFieldMappingsAt("milestone_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("project_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("project_board_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("poster_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("assignee_id", numberFieldMapping) + docMapping.AddFieldMappingsAt("mention_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("reviewed_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("review_requested_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("subscriber_ids", numberFieldMapping) + docMapping.AddFieldMappingsAt("updated_unix", numberFieldMapping) + + docMapping.AddFieldMappingsAt("created_unix", numberFieldMapping) + docMapping.AddFieldMappingsAt("deadline_unix", numberFieldMapping) + docMapping.AddFieldMappingsAt("comment_count", numberFieldMapping) + + if err := addUnicodeNormalizeTokenFilter(mapping); err != nil { + return nil, err + } else if err = mapping.AddCustomAnalyzer(conversationIndexerAnalyzer, map[string]any{ + "type": custom.Name, + "char_filters": []string{}, + "tokenizer": unicode.Name, + "token_filters": []string{unicodeNormalizeName, camelcase.Name, lowercase.Name}, + }); err != nil { + return nil, err + } + + mapping.DefaultAnalyzer = conversationIndexerAnalyzer + mapping.AddDocumentMapping(conversationIndexerDocType, docMapping) + mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping()) + mapping.DefaultMapping = bleve.NewDocumentDisabledMapping() // disable default mapping, avoid indexing unexpected structs + + return mapping, nil +} + +var _ internal.Indexer = &Indexer{} + +// Indexer implements Indexer interface +type Indexer struct { + inner *inner_bleve.Indexer + indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much +} + +// NewIndexer creates a new bleve local indexer +func NewIndexer(indexDir string) *Indexer { + inner := inner_bleve.NewIndexer(indexDir, conversationIndexerLatestVersion, generateConversationIndexMapping) + return &Indexer{ + Indexer: inner, + inner: inner, + } +} + +// Index will save the index data +func (b *Indexer) Index(_ context.Context, conversations ...*internal.IndexerData) error { + batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize) + for _, conversation := range conversations { + if err := batch.Index(indexer_internal.Base36(conversation.ID), (*IndexerData)(conversation)); err != nil { + return err + } + } + return batch.Flush() +} + +// Delete deletes indexes by ids +func (b *Indexer) Delete(_ context.Context, ids ...int64) error { + batch := inner_bleve.NewFlushingBatch(b.inner.Indexer, maxBatchSize) + for _, id := range ids { + if err := batch.Delete(indexer_internal.Base36(id)); err != nil { + return err + } + } + return batch.Flush() +} + +// Search searches for conversations by given conditions. +// Returns the matching conversation IDs +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + var queries []query.Query + + if options.Keyword != "" { + fuzziness := 0 + if options.IsFuzzyKeyword { + fuzziness = inner_bleve.GuessFuzzinessByKeyword(options.Keyword) + } + + queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{ + inner_bleve.MatchPhraseQuery(options.Keyword, "title", conversationIndexerAnalyzer, fuzziness), + inner_bleve.MatchPhraseQuery(options.Keyword, "content", conversationIndexerAnalyzer, fuzziness), + inner_bleve.MatchPhraseQuery(options.Keyword, "comments", conversationIndexerAnalyzer, fuzziness), + }...)) + } + + if len(options.RepoIDs) > 0 || options.AllPublic { + var repoQueries []query.Query + for _, repoID := range options.RepoIDs { + repoQueries = append(repoQueries, inner_bleve.NumericEqualityQuery(repoID, "repo_id")) + } + if options.AllPublic { + repoQueries = append(repoQueries, inner_bleve.BoolFieldQuery(true, "is_public")) + } + queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...)) + } + + if options.IsPull.Has() { + queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull")) + } + if options.IsClosed.Has() { + queries = append(queries, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed")) + } + + if options.NoLabelOnly { + queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label")) + } else { + if len(options.IncludedLabelIDs) > 0 { + var includeQueries []query.Query + for _, labelID := range options.IncludedLabelIDs { + includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids")) + } + queries = append(queries, bleve.NewConjunctionQuery(includeQueries...)) + } else if len(options.IncludedAnyLabelIDs) > 0 { + var includeQueries []query.Query + for _, labelID := range options.IncludedAnyLabelIDs { + includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids")) + } + queries = append(queries, bleve.NewDisjunctionQuery(includeQueries...)) + } + if len(options.ExcludedLabelIDs) > 0 { + var excludeQueries []query.Query + for _, labelID := range options.ExcludedLabelIDs { + q := bleve.NewBooleanQuery() + q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids")) + excludeQueries = append(excludeQueries, q) + } + queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...)) + } + } + + if len(options.MilestoneIDs) > 0 { + var milestoneQueries []query.Query + for _, milestoneID := range options.MilestoneIDs { + milestoneQueries = append(milestoneQueries, inner_bleve.NumericEqualityQuery(milestoneID, "milestone_id")) + } + queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...)) + } + + if options.ProjectID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id")) + } + if options.ProjectColumnID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) + } + + if options.PosterID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id")) + } + + if options.AssigneeID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id")) + } + + if options.MentionID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.MentionID.Value(), "mention_ids")) + } + + if options.ReviewedID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewedID.Value(), "reviewed_ids")) + } + if options.ReviewRequestedID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewRequestedID.Value(), "review_requested_ids")) + } + + if options.SubscriberID.Has() { + queries = append(queries, inner_bleve.NumericEqualityQuery(options.SubscriberID.Value(), "subscriber_ids")) + } + + if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() { + queries = append(queries, inner_bleve.NumericRangeInclusiveQuery( + options.UpdatedAfterUnix, + options.UpdatedBeforeUnix, + "updated_unix")) + } + + var indexerQuery query.Query = bleve.NewConjunctionQuery(queries...) + if len(queries) == 0 { + indexerQuery = bleve.NewMatchAllQuery() + } + + skip, limit := indexer_internal.ParsePaginator(options.Paginator) + search := bleve.NewSearchRequestOptions(indexerQuery, limit, skip, false) + + if options.SortBy == "" { + options.SortBy = internal.SortByCreatedAsc + } + + search.SortBy([]string{string(options.SortBy), "-_id"}) + + result, err := b.inner.Indexer.SearchInContext(ctx, search) + if err != nil { + return nil, err + } + + ret := &internal.SearchResult{ + Total: int64(result.Total), + Hits: make([]internal.Match, 0, len(result.Hits)), + } + for _, hit := range result.Hits { + id, err := indexer_internal.ParseBase36(hit.ID) + if err != nil { + return nil, err + } + ret.Hits = append(ret.Hits, internal.Match{ + ID: id, + }) + } + return ret, nil +} diff --git a/modules/indexer/conversations/bleve/bleve_test.go b/modules/indexer/conversations/bleve/bleve_test.go new file mode 100644 index 0000000000000..951db77d0987a --- /dev/null +++ b/modules/indexer/conversations/bleve/bleve_test.go @@ -0,0 +1,18 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package bleve + +import ( + "testing" + + "code.gitea.io/gitea/modules/indexer/conversations/internal/tests" +) + +func TestBleveIndexer(t *testing.T) { + dir := t.TempDir() + indexer := NewIndexer(dir) + defer indexer.Close() + + tests.TestIndexer(t, indexer) +} diff --git a/modules/indexer/conversations/db/db.go b/modules/indexer/conversations/db/db.go new file mode 100644 index 0000000000000..9aebb834ee643 --- /dev/null +++ b/modules/indexer/conversations/db/db.go @@ -0,0 +1,113 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + + conversation_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + indexer_internal "code.gitea.io/gitea/modules/indexer/internal" + inner_db "code.gitea.io/gitea/modules/indexer/internal/db" + + "xorm.io/builder" +) + +var _ internal.Indexer = &Indexer{} + +// Indexer implements Indexer interface to use database's like search +type Indexer struct { + indexer_internal.Indexer +} + +func NewIndexer() *Indexer { + return &Indexer{ + Indexer: &inner_db.Indexer{}, + } +} + +// Index dummy function +func (i *Indexer) Index(_ context.Context, _ ...*internal.IndexerData) error { + return nil +} + +// Delete dummy function +func (i *Indexer) Delete(_ context.Context, _ ...int64) error { + return nil +} + +// Search searches for conversations +func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + // FIXME: I tried to avoid importing models here, but it seems to be impossible. + // We can provide a function to register the search function, so models/conversations can register it. + // So models/conversations will import modules/indexer/conversations, it's OK because it's by design. + // But modules/indexer/conversations has already imported models/conversations to do UpdateRepoIndexer and UpdateConversationIndexer. + // And to avoid circular import, we have to move the functions to another package. + // I believe it should be services/indexer, sounds great! + // But the two functions are used in modules/notification/indexer, that means we will import services/indexer in modules/notification/indexer. + // So that's the root problem: + // The notification is defined in modules, but it's using lots of things should be in services. + + cond := builder.NewCond() + + if options.Keyword != "" { + repoCond := builder.In("repo_id", options.RepoIDs) + if len(options.RepoIDs) == 1 { + repoCond = builder.Eq{"repo_id": options.RepoIDs[0]} + } + subQuery := builder.Select("id").From("conversation").Where(repoCond) + + cond = builder.Or( + db.BuildCaseInsensitiveLike("conversation.name", options.Keyword), + db.BuildCaseInsensitiveLike("conversation.content", options.Keyword), + builder.In("conversation.id", builder.Select("conversation_id"). + From("comment"). + Where(builder.And( + builder.Eq{"type": conversation_model.CommentTypeComment}, + builder.In("conversation_id", subQuery), + db.BuildCaseInsensitiveLike("content", options.Keyword), + )), + ), + ) + + if options.IsKeywordNumeric() { + cond = cond.Or( + builder.Eq{"`index`": options.Keyword}, + ) + } + } + + opt, err := ToDBOptions(ctx, options) + if err != nil { + return nil, err + } + + // If pagesize == 0, return total count only. It's a special case for search count. + if options.Paginator != nil && options.Paginator.PageSize == 0 { + total, err := conversation_model.CountConversations(ctx, opt, cond) + if err != nil { + return nil, err + } + return &internal.SearchResult{ + Total: total, + }, nil + } + + ids, total, err := conversation_model.ConversationIDs(ctx, opt, cond) + if err != nil { + return nil, err + } + + hits := make([]internal.Match, 0, len(ids)) + for _, id := range ids { + hits = append(hits, internal.Match{ + ID: id, + }) + } + return &internal.SearchResult{ + Total: total, + Hits: hits, + }, nil +} diff --git a/modules/indexer/conversations/db/options.go b/modules/indexer/conversations/db/options.go new file mode 100644 index 0000000000000..cac58ae9f1dd1 --- /dev/null +++ b/modules/indexer/conversations/db/options.go @@ -0,0 +1,76 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package db + +import ( + "context" + + conversation_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + "code.gitea.io/gitea/modules/optional" +) + +func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*conversation_model.ConversationsOptions, error) { + var sortType string + switch options.SortBy { + case internal.SortByCreatedAsc: + sortType = "oldest" + case internal.SortByUpdatedAsc: + sortType = "leastupdate" + case internal.SortByCommentsAsc: + sortType = "leastcomment" + case internal.SortByCreatedDesc: + sortType = "newest" + case internal.SortByUpdatedDesc: + sortType = "recentupdate" + case internal.SortByCommentsDesc: + sortType = "mostcomment" + default: + sortType = "newest" + } + + // See the comment of conversations_model.SearchOptions for the reason why we need to convert + convertID := func(id optional.Option[int64]) int64 { + if !id.Has() { + return 0 + } + value := id.Value() + if value == 0 { + return db.NoConditionID + } + return value + } + + opts := &conversation_model.ConversationsOptions{ + Paginator: options.Paginator, + RepoIDs: options.RepoIDs, + AllPublic: options.AllPublic, + RepoCond: nil, + AssigneeID: convertID(options.AssigneeID), + PosterID: convertID(options.PosterID), + MentionedID: convertID(options.MentionID), + ReviewRequestedID: convertID(options.ReviewRequestedID), + ReviewedID: convertID(options.ReviewedID), + SubscriberID: convertID(options.SubscriberID), + ProjectID: convertID(options.ProjectID), + ProjectColumnID: convertID(options.ProjectColumnID), + IsClosed: options.IsClosed, + IsPull: options.IsPull, + IncludedLabelNames: nil, + ExcludedLabelNames: nil, + IncludeMilestones: nil, + SortType: sortType, + ConversationIDs: nil, + UpdatedAfterUnix: options.UpdatedAfterUnix.Value(), + UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(), + PriorityRepoID: 0, + IsArchived: optional.None[bool](), + Org: nil, + Team: nil, + User: nil, + } + + return opts, nil +} diff --git a/modules/indexer/conversations/dboptions.go b/modules/indexer/conversations/dboptions.go new file mode 100644 index 0000000000000..fdf735f078299 --- /dev/null +++ b/modules/indexer/conversations/dboptions.go @@ -0,0 +1,105 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/optional" +) + +func ToSearchOptions(keyword string, opts *conversations_model.ConversationsOptions) *SearchOptions { + searchOpt := &SearchOptions{ + Keyword: keyword, + RepoIDs: opts.RepoIDs, + AllPublic: opts.AllPublic, + IsPull: opts.IsPull, + IsClosed: opts.IsClosed, + } + + if len(opts.LabelIDs) == 1 && opts.LabelIDs[0] == 0 { + searchOpt.NoLabelOnly = true + } else { + for _, labelID := range opts.LabelIDs { + if labelID > 0 { + searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) + } else { + searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) + } + } + // opts.IncludedLabelNames and opts.ExcludedLabelNames are not supported here. + // It's not a TO DO, it's just unnecessary. + } + + if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID { + searchOpt.MilestoneIDs = []int64{0} + } else { + searchOpt.MilestoneIDs = opts.MilestoneIDs + } + + if opts.ProjectID > 0 { + searchOpt.ProjectID = optional.Some(opts.ProjectID) + } else if opts.ProjectID == -1 { // FIXME: this is inconsistent from other places + searchOpt.ProjectID = optional.Some[int64](0) // Those conversations with no project(projectid==0) + } + + if opts.AssigneeID > 0 { + searchOpt.AssigneeID = optional.Some(opts.AssigneeID) + } else if opts.AssigneeID == -1 { // FIXME: this is inconsistent from other places + searchOpt.AssigneeID = optional.Some[int64](0) + } + + // See the comment of conversations_model.SearchOptions for the reason why we need to convert + convertID := func(id int64) optional.Option[int64] { + if id > 0 { + return optional.Some(id) + } + if id == db.NoConditionID { + return optional.None[int64]() + } + return nil + } + + searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID) + searchOpt.PosterID = convertID(opts.PosterID) + searchOpt.MentionID = convertID(opts.MentionedID) + searchOpt.ReviewedID = convertID(opts.ReviewedID) + searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID) + searchOpt.SubscriberID = convertID(opts.SubscriberID) + + if opts.UpdatedAfterUnix > 0 { + searchOpt.UpdatedAfterUnix = optional.Some(opts.UpdatedAfterUnix) + } + if opts.UpdatedBeforeUnix > 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(opts.UpdatedBeforeUnix) + } + + searchOpt.Paginator = opts.Paginator + + switch opts.SortType { + case "", "latest": + searchOpt.SortBy = SortByCreatedDesc + case "oldest": + searchOpt.SortBy = SortByCreatedAsc + case "recentupdate": + searchOpt.SortBy = SortByUpdatedDesc + case "leastupdate": + searchOpt.SortBy = SortByUpdatedAsc + case "mostcomment": + searchOpt.SortBy = SortByCommentsDesc + case "leastcomment": + searchOpt.SortBy = SortByCommentsAsc + case "nearduedate": + searchOpt.SortBy = SortByDeadlineAsc + case "farduedate": + searchOpt.SortBy = SortByDeadlineDesc + case "priority", "priorityrepo", "project-column-sorting": + // Unsupported sort type for search + fallthrough + default: + searchOpt.SortBy = SortByUpdatedDesc + } + + return searchOpt +} diff --git a/modules/indexer/conversations/elasticsearch/elasticsearch.go b/modules/indexer/conversations/elasticsearch/elasticsearch.go new file mode 100644 index 0000000000000..a8904a21d7f4a --- /dev/null +++ b/modules/indexer/conversations/elasticsearch/elasticsearch.go @@ -0,0 +1,290 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package elasticsearch + +import ( + "context" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + indexer_internal "code.gitea.io/gitea/modules/indexer/internal" + inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" + + "github.com/olivere/elastic/v7" +) + +const ( + conversationIndexerLatestVersion = 1 + // multi-match-types, currently only 2 types are used + // Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types + esMultiMatchTypeBestFields = "best_fields" + esMultiMatchTypePhrasePrefix = "phrase_prefix" +) + +var _ internal.Indexer = &Indexer{} + +// Indexer implements Indexer interface +type Indexer struct { + inner *inner_elasticsearch.Indexer + indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much +} + +// NewIndexer creates a new elasticsearch indexer +func NewIndexer(url, indexerName string) *Indexer { + inner := inner_elasticsearch.NewIndexer(url, indexerName, conversationIndexerLatestVersion, defaultMapping) + indexer := &Indexer{ + inner: inner, + Indexer: inner, + } + return indexer +} + +const ( + defaultMapping = ` +{ + "mappings": { + "properties": { + "id": { "type": "integer", "index": true }, + "repo_id": { "type": "integer", "index": true }, + "is_public": { "type": "boolean", "index": true }, + + "title": { "type": "text", "index": true }, + "content": { "type": "text", "index": true }, + "comments": { "type" : "text", "index": true }, + + "is_pull": { "type": "boolean", "index": true }, + "is_closed": { "type": "boolean", "index": true }, + "label_ids": { "type": "integer", "index": true }, + "no_label": { "type": "boolean", "index": true }, + "milestone_id": { "type": "integer", "index": true }, + "project_id": { "type": "integer", "index": true }, + "project_board_id": { "type": "integer", "index": true }, + "poster_id": { "type": "integer", "index": true }, + "assignee_id": { "type": "integer", "index": true }, + "mention_ids": { "type": "integer", "index": true }, + "reviewed_ids": { "type": "integer", "index": true }, + "review_requested_ids": { "type": "integer", "index": true }, + "subscriber_ids": { "type": "integer", "index": true }, + "updated_unix": { "type": "integer", "index": true }, + + "created_unix": { "type": "integer", "index": true }, + "deadline_unix": { "type": "integer", "index": true }, + "comment_count": { "type": "integer", "index": true } + } + } +} +` +) + +// Index will save the index data +func (b *Indexer) Index(ctx context.Context, conversations ...*internal.IndexerData) error { + if len(conversations) == 0 { + return nil + } else if len(conversations) == 1 { + conversation := conversations[0] + _, err := b.inner.Client.Index(). + Index(b.inner.VersionedIndexName()). + Id(fmt.Sprintf("%d", conversation.ID)). + BodyJson(conversation). + Do(ctx) + return err + } + + reqs := make([]elastic.BulkableRequest, 0) + for _, conversation := range conversations { + reqs = append(reqs, + elastic.NewBulkIndexRequest(). + Index(b.inner.VersionedIndexName()). + Id(fmt.Sprintf("%d", conversation.ID)). + Doc(conversation), + ) + } + + _, err := b.inner.Client.Bulk(). + Index(b.inner.VersionedIndexName()). + Add(reqs...). + Do(graceful.GetManager().HammerContext()) + return err +} + +// Delete deletes indexes by ids +func (b *Indexer) Delete(ctx context.Context, ids ...int64) error { + if len(ids) == 0 { + return nil + } else if len(ids) == 1 { + _, err := b.inner.Client.Delete(). + Index(b.inner.VersionedIndexName()). + Id(fmt.Sprintf("%d", ids[0])). + Do(ctx) + return err + } + + reqs := make([]elastic.BulkableRequest, 0) + for _, id := range ids { + reqs = append(reqs, + elastic.NewBulkDeleteRequest(). + Index(b.inner.VersionedIndexName()). + Id(fmt.Sprintf("%d", id)), + ) + } + + _, err := b.inner.Client.Bulk(). + Index(b.inner.VersionedIndexName()). + Add(reqs...). + Do(graceful.GetManager().HammerContext()) + return err +} + +// Search searches for conversations by given conditions. +// Returns the matching conversation IDs +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + query := elastic.NewBoolQuery() + + if options.Keyword != "" { + searchType := esMultiMatchTypePhrasePrefix + if options.IsFuzzyKeyword { + searchType = esMultiMatchTypeBestFields + } + + query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(searchType)) + } + + if len(options.RepoIDs) > 0 { + q := elastic.NewBoolQuery() + q.Should(elastic.NewTermsQuery("repo_id", toAnySlice(options.RepoIDs)...)) + if options.AllPublic { + q.Should(elastic.NewTermQuery("is_public", true)) + } + query.Must(q) + } + + if options.IsPull.Has() { + query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value())) + } + if options.IsClosed.Has() { + query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.Value())) + } + + if options.NoLabelOnly { + query.Must(elastic.NewTermQuery("no_label", true)) + } else { + if len(options.IncludedLabelIDs) > 0 { + q := elastic.NewBoolQuery() + for _, labelID := range options.IncludedLabelIDs { + q.Must(elastic.NewTermQuery("label_ids", labelID)) + } + query.Must(q) + } else if len(options.IncludedAnyLabelIDs) > 0 { + query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...)) + } + if len(options.ExcludedLabelIDs) > 0 { + q := elastic.NewBoolQuery() + for _, labelID := range options.ExcludedLabelIDs { + q.MustNot(elastic.NewTermQuery("label_ids", labelID)) + } + query.Must(q) + } + } + + if len(options.MilestoneIDs) > 0 { + query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) + } + + if options.ProjectID.Has() { + query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value())) + } + if options.ProjectColumnID.Has() { + query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) + } + + if options.PosterID.Has() { + query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value())) + } + + if options.AssigneeID.Has() { + query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value())) + } + + if options.MentionID.Has() { + query.Must(elastic.NewTermQuery("mention_ids", options.MentionID.Value())) + } + + if options.ReviewedID.Has() { + query.Must(elastic.NewTermQuery("reviewed_ids", options.ReviewedID.Value())) + } + if options.ReviewRequestedID.Has() { + query.Must(elastic.NewTermQuery("review_requested_ids", options.ReviewRequestedID.Value())) + } + + if options.SubscriberID.Has() { + query.Must(elastic.NewTermQuery("subscriber_ids", options.SubscriberID.Value())) + } + + if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() { + q := elastic.NewRangeQuery("updated_unix") + if options.UpdatedAfterUnix.Has() { + q.Gte(options.UpdatedAfterUnix.Value()) + } + if options.UpdatedBeforeUnix.Has() { + q.Lte(options.UpdatedBeforeUnix.Value()) + } + query.Must(q) + } + + if options.SortBy == "" { + options.SortBy = internal.SortByCreatedAsc + } + sortBy := []elastic.Sorter{ + parseSortBy(options.SortBy), + elastic.NewFieldSort("id").Desc(), + } + + // See https://stackoverflow.com/questions/35206409/elasticsearch-2-1-result-window-is-too-large-index-max-result-window/35221900 + // TODO: make it configurable since it's configurable in elasticsearch + const maxPageSize = 10000 + + skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxPageSize) + searchResult, err := b.inner.Client.Search(). + Index(b.inner.VersionedIndexName()). + Query(query). + SortBy(sortBy...). + From(skip).Size(limit). + Do(ctx) + if err != nil { + return nil, err + } + + hits := make([]internal.Match, 0, limit) + for _, hit := range searchResult.Hits.Hits { + id, _ := strconv.ParseInt(hit.Id, 10, 64) + hits = append(hits, internal.Match{ + ID: id, + }) + } + + return &internal.SearchResult{ + Total: searchResult.TotalHits(), + Hits: hits, + }, nil +} + +func toAnySlice[T any](s []T) []any { + ret := make([]any, 0, len(s)) + for _, item := range s { + ret = append(ret, item) + } + return ret +} + +func parseSortBy(sortBy internal.SortBy) elastic.Sorter { + field := strings.TrimPrefix(string(sortBy), "-") + ret := elastic.NewFieldSort(field) + if strings.HasPrefix(string(sortBy), "-") { + ret.Desc() + } + return ret +} diff --git a/modules/indexer/conversations/elasticsearch/elasticsearch_test.go b/modules/indexer/conversations/elasticsearch/elasticsearch_test.go new file mode 100644 index 0000000000000..c627fe7ece58d --- /dev/null +++ b/modules/indexer/conversations/elasticsearch/elasticsearch_test.go @@ -0,0 +1,48 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package elasticsearch + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "code.gitea.io/gitea/modules/indexer/conversations/internal/tests" +) + +func TestElasticsearchIndexer(t *testing.T) { + // The elasticsearch instance started by pull-db-tests.yml > test-unit > services > elasticsearch + url := "http://elastic:changeme@elasticsearch:9200" + + if os.Getenv("CI") == "" { + // Make it possible to run tests against a local elasticsearch instance + url = os.Getenv("TEST_ELASTICSEARCH_URL") + if url == "" { + t.Skip("TEST_ELASTICSEARCH_URL not set and not running in CI") + return + } + } + + ok := false + for i := 0; i < 60; i++ { + resp, err := http.Get(url) + if err == nil && resp.StatusCode == http.StatusOK { + ok = true + break + } + t.Logf("Waiting for elasticsearch to be up: %v", err) + time.Sleep(time.Second) + } + if !ok { + t.Fatalf("Failed to wait for elasticsearch to be up") + return + } + + indexer := NewIndexer(url, fmt.Sprintf("test_elasticsearch_indexer_%d", time.Now().Unix())) + defer indexer.Close() + + tests.TestIndexer(t, indexer) +} diff --git a/modules/indexer/conversations/indexer.go b/modules/indexer/conversations/indexer.go new file mode 100644 index 0000000000000..dd8e42543dde7 --- /dev/null +++ b/modules/indexer/conversations/indexer.go @@ -0,0 +1,315 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + "os" + "runtime/pprof" + "sync/atomic" + "time" + + db_model "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/graceful" + "code.gitea.io/gitea/modules/indexer/conversations/bleve" + "code.gitea.io/gitea/modules/indexer/conversations/db" + "code.gitea.io/gitea/modules/indexer/conversations/elasticsearch" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + "code.gitea.io/gitea/modules/indexer/conversations/meilisearch" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/process" + "code.gitea.io/gitea/modules/queue" + "code.gitea.io/gitea/modules/setting" +) + +// IndexerMetadata is used to send data to the queue, so it contains only the ids. +// It may look weired, because it has to be compatible with the old queue data format. +// If the IsDelete flag is true, the IDs specify the conversations to delete from the index without querying the database. +// If the IsDelete flag is false, the ID specify the conversation to index, so Indexer will query the database to get the conversation data. +// It should be noted that if the id is not existing in the database, it's index will be deleted too even if IsDelete is false. +// Valid values: +// - IsDelete = true, IDs = [1, 2, 3], and ID will be ignored +// - IsDelete = false, ID = 1, and IDs will be ignored +type IndexerMetadata struct { + ID int64 `json:"id"` + + IsDelete bool `json:"is_delete"` + IDs []int64 `json:"ids"` +} + +var ( + // conversationIndexerQueue queue of conversation ids to be updated + conversationIndexerQueue *queue.WorkerPoolQueue[*IndexerMetadata] + // globalIndexer is the global indexer, it cannot be nil. + // When the real indexer is not ready, it will be a dummy indexer which will return error to explain it's not ready. + // So it's always safe use it as *globalIndexer.Load() and call its methods. + globalIndexer atomic.Pointer[internal.Indexer] + dummyIndexer *internal.Indexer +) + +func init() { + i := internal.NewDummyIndexer() + dummyIndexer = &i + globalIndexer.Store(dummyIndexer) +} + +// InitConversationIndexer initialize conversation indexer, syncReindex is true then reindex until +// all conversation index done. +func InitConversationIndexer(syncReindex bool) { + ctx, _, finished := process.GetManager().AddTypedContext(context.Background(), "Service: ConversationIndexer", process.SystemProcessType, false) + + indexerInitWaitChannel := make(chan time.Duration, 1) + + // Create the Queue + conversationIndexerQueue = queue.CreateUniqueQueue(ctx, "conversation_indexer", getConversationIndexerQueueHandler(ctx)) + + graceful.GetManager().RunAtTerminate(finished) + + // Create the Indexer + go func() { + pprof.SetGoroutineLabels(ctx) + start := time.Now() + log.Info("PID %d: Initializing Conversation Indexer: %s", os.Getpid(), setting.Indexer.ConversationType) + var ( + conversationIndexer internal.Indexer + existed bool + err error + ) + switch setting.Indexer.ConversationType { + case "bleve": + defer func() { + if err := recover(); err != nil { + log.Error("PANIC whilst initializing conversation indexer: %v\nStacktrace: %s", err, log.Stack(2)) + log.Error("The indexer files are likely corrupted and may need to be deleted") + log.Error("You can completely remove the %q directory to make Gitea recreate the indexes", setting.Indexer.ConversationPath) + globalIndexer.Store(dummyIndexer) + log.Fatal("PID: %d Unable to initialize the Bleve Conversation Indexer at path: %s Error: %v", os.Getpid(), setting.Indexer.ConversationPath, err) + } + }() + conversationIndexer = bleve.NewIndexer(setting.Indexer.ConversationPath) + existed, err = conversationIndexer.Init(ctx) + if err != nil { + log.Fatal("Unable to initialize Bleve Conversation Indexer at path: %s Error: %v", setting.Indexer.ConversationPath, err) + } + case "elasticsearch": + conversationIndexer = elasticsearch.NewIndexer(setting.Indexer.ConversationConnStr, setting.Indexer.ConversationIndexerName) + existed, err = conversationIndexer.Init(ctx) + if err != nil { + log.Fatal("Unable to conversationIndexer.Init with connection %s Error: %v", setting.Indexer.ConversationConnStr, err) + } + case "db": + conversationIndexer = db.NewIndexer() + case "meilisearch": + conversationIndexer = meilisearch.NewIndexer(setting.Indexer.ConversationConnStr, setting.Indexer.ConversationConnAuth, setting.Indexer.ConversationIndexerName) + existed, err = conversationIndexer.Init(ctx) + if err != nil { + log.Fatal("Unable to conversationIndexer.Init with connection %s Error: %v", setting.Indexer.ConversationConnStr, err) + } + default: + log.Fatal("Unknown conversation indexer type: %s", setting.Indexer.ConversationType) + } + globalIndexer.Store(&conversationIndexer) + + graceful.GetManager().RunAtTerminate(func() { + log.Debug("Closing conversation indexer") + (*globalIndexer.Load()).Close() + log.Info("PID: %d Conversation Indexer closed", os.Getpid()) + }) + + // Start processing the queue + go graceful.GetManager().RunWithCancel(conversationIndexerQueue) + + // Populate the index + if !existed { + if syncReindex { + graceful.GetManager().RunWithShutdownContext(populateConversationIndexer) + } else { + go graceful.GetManager().RunWithShutdownContext(populateConversationIndexer) + } + } + + indexerInitWaitChannel <- time.Since(start) + close(indexerInitWaitChannel) + }() + + if syncReindex { + select { + case <-indexerInitWaitChannel: + case <-graceful.GetManager().IsShutdown(): + } + } else if setting.Indexer.StartupTimeout > 0 { + go func() { + pprof.SetGoroutineLabels(ctx) + timeout := setting.Indexer.StartupTimeout + if graceful.GetManager().IsChild() && setting.GracefulHammerTime > 0 { + timeout += setting.GracefulHammerTime + } + select { + case duration := <-indexerInitWaitChannel: + log.Info("Conversation Indexer Initialization took %v", duration) + case <-graceful.GetManager().IsShutdown(): + log.Warn("Shutdown occurred before conversation index initialisation was complete") + case <-time.After(timeout): + conversationIndexerQueue.ShutdownWait(5 * time.Second) + log.Fatal("Conversation Indexer Initialization timed-out after: %v", timeout) + } + }() + } +} + +func getConversationIndexerQueueHandler(ctx context.Context) func(items ...*IndexerMetadata) []*IndexerMetadata { + return func(items ...*IndexerMetadata) []*IndexerMetadata { + var unhandled []*IndexerMetadata + + indexer := *globalIndexer.Load() + for _, item := range items { + log.Trace("IndexerMetadata Process: %d %v %t", item.ID, item.IDs, item.IsDelete) + if item.IsDelete { + if err := indexer.Delete(ctx, item.IDs...); err != nil { + log.Error("Conversation indexer handler: failed to from index: %v Error: %v", item.IDs, err) + unhandled = append(unhandled, item) + } + continue + } + data, existed, err := getConversationIndexerData(ctx, item.ID) + if err != nil { + log.Error("Conversation indexer handler: failed to get conversation data of %d: %v", item.ID, err) + unhandled = append(unhandled, item) + continue + } + if !existed { + if err := indexer.Delete(ctx, item.ID); err != nil { + log.Error("Conversation indexer handler: failed to delete conversation %d from index: %v", item.ID, err) + unhandled = append(unhandled, item) + } + continue + } + if err := indexer.Index(ctx, data); err != nil { + log.Error("Conversation indexer handler: failed to index conversation %d: %v", item.ID, err) + unhandled = append(unhandled, item) + continue + } + } + + return unhandled + } +} + +// populateConversationIndexer populate the conversation indexer with conversation data +func populateConversationIndexer(ctx context.Context) { + ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Service: PopulateConversationIndexer", process.SystemProcessType, true) + defer finished() + ctx = contextWithKeepRetry(ctx) // keep retrying since it's a background task + if err := PopulateConversationIndexer(ctx); err != nil { + log.Error("Conversation indexer population failed: %v", err) + } +} + +func PopulateConversationIndexer(ctx context.Context) error { + for page := 1; ; page++ { + select { + case <-ctx.Done(): + return fmt.Errorf("shutdown before completion: %w", ctx.Err()) + default: + } + repos, _, err := repo_model.SearchRepositoryByName(ctx, &repo_model.SearchRepoOptions{ + ListOptions: db_model.ListOptions{Page: page, PageSize: repo_model.RepositoryListDefaultPageSize}, + OrderBy: db_model.SearchOrderByID, + Private: true, + Collaborate: optional.Some(false), + }) + if err != nil { + log.Error("SearchRepositoryByName: %v", err) + continue + } + if len(repos) == 0 { + log.Debug("Conversation Indexer population complete") + return nil + } + + for _, repo := range repos { + if err := updateRepoIndexer(ctx, repo.ID); err != nil { + return fmt.Errorf("populate conversation indexer for repo %d: %v", repo.ID, err) + } + } + } +} + +// UpdateRepoIndexer add/update all conversations of the repositories +func UpdateRepoIndexer(ctx context.Context, repoID int64) { + if err := updateRepoIndexer(ctx, repoID); err != nil { + log.Error("Unable to push repo %d to conversation indexer: %v", repoID, err) + } +} + +// UpdateConversationIndexer add/update an conversation to the conversation indexer +func UpdateConversationIndexer(ctx context.Context, conversationID int64) { + if err := updateConversationIndexer(ctx, conversationID); err != nil { + log.Error("Unable to push conversation %d to conversation indexer: %v", conversationID, err) + } +} + +// DeleteRepoConversationIndexer deletes repo's all conversations indexes +func DeleteRepoConversationIndexer(ctx context.Context, repoID int64) { + if err := deleteRepoConversationIndexer(ctx, repoID); err != nil { + log.Error("Unable to push deleted repo %d to conversation indexer: %v", repoID, err) + } +} + +// IsAvailable checks if conversation indexer is available +func IsAvailable(ctx context.Context) bool { + return (*globalIndexer.Load()).Ping(ctx) == nil +} + +// SearchOptions indicates the options for searching conversations +type SearchOptions = internal.SearchOptions + +const ( + SortByCreatedDesc = internal.SortByCreatedDesc + SortByUpdatedDesc = internal.SortByUpdatedDesc + SortByCommentsDesc = internal.SortByCommentsDesc + SortByDeadlineDesc = internal.SortByDeadlineDesc + SortByCreatedAsc = internal.SortByCreatedAsc + SortByUpdatedAsc = internal.SortByUpdatedAsc + SortByCommentsAsc = internal.SortByCommentsAsc + SortByDeadlineAsc = internal.SortByDeadlineAsc +) + +// SearchConversations search conversations by options. +func SearchConversations(ctx context.Context, opts *SearchOptions) ([]int64, int64, error) { + indexer := *globalIndexer.Load() + + if opts.Keyword == "" || opts.IsKeywordNumeric() { + // This is a conservative shortcut. + // If the keyword is empty or an integer, db has better (at least not worse) performance to filter conversations. + // When the keyword is empty, it tends to listing rather than searching conversations. + // So if the user creates an conversation and list conversations immediately, the conversation may not be listed because the indexer needs time to index the conversation. + // Even worse, the external indexer like elastic search may not be available for a while, + // and the user may not be able to list conversations completely until it is available again. + indexer = db.NewIndexer() + } + + result, err := indexer.Search(ctx, opts) + if err != nil { + return nil, 0, err + } + + ret := make([]int64, 0, len(result.Hits)) + for _, hit := range result.Hits { + ret = append(ret, hit.ID) + } + + return ret, result.Total, nil +} + +// CountConversations counts conversations by options. It is a shortcut of SearchConversations(ctx, opts) but only returns the total count. +func CountConversations(ctx context.Context, opts *SearchOptions) (int64, error) { + opts = opts.Copy(func(options *SearchOptions) { options.Paginator = &db_model.ListOptions{PageSize: 0} }) + + _, total, err := SearchConversations(ctx, opts) + return total, err +} diff --git a/modules/indexer/conversations/indexer_test.go b/modules/indexer/conversations/indexer_test.go new file mode 100644 index 0000000000000..4038d8f6093a8 --- /dev/null +++ b/modules/indexer/conversations/indexer_test.go @@ -0,0 +1,460 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "testing" + + "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} + +func TestDBSearchConversations(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + setting.Indexer.ConversationType = "db" + InitConversationIndexer(true) + + t.Run("search conversations with keyword", searchConversationWithKeyword) + t.Run("search conversations by index", searchConversationByIndex) + t.Run("search conversations in repo", searchConversationInRepo) + t.Run("search conversations by ID", searchConversationByID) + t.Run("search conversations is pr", searchConversationIsPull) + t.Run("search conversations is closed", searchConversationIsClosed) + t.Run("search conversations by milestone", searchConversationByMilestoneID) + t.Run("search conversations by label", searchConversationByLabelID) + t.Run("search conversations by time", searchConversationByTime) + t.Run("search conversations with order", searchConversationWithOrder) + t.Run("search conversations in project", searchConversationInProject) + t.Run("search conversations with paginator", searchConversationWithPaginator) +} + +func searchConversationWithKeyword(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + Keyword: "conversation2", + RepoIDs: []int64{1}, + }, + []int64{2}, + }, + { + SearchOptions{ + Keyword: "first", + RepoIDs: []int64{1}, + }, + []int64{1}, + }, + { + SearchOptions{ + Keyword: "for", + RepoIDs: []int64{1}, + }, + []int64{11, 5, 3, 2, 1}, + }, + { + SearchOptions{ + Keyword: "good", + RepoIDs: []int64{1}, + }, + []int64{1}, + }, + } + + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationByIndex(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + Keyword: "1000", + RepoIDs: []int64{1}, + }, + []int64{}, + }, + { + SearchOptions{ + Keyword: "2", + RepoIDs: []int64{1, 2, 3, 32}, + }, + []int64{17, 12, 7, 2}, + }, + { + SearchOptions{ + Keyword: "1", + RepoIDs: []int64{58}, + }, + []int64{19}, + }, + } + + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationInRepo(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + RepoIDs: []int64{1}, + }, + []int64{11, 5, 3, 2, 1}, + }, + { + SearchOptions{ + RepoIDs: []int64{2}, + }, + []int64{7, 4}, + }, + { + SearchOptions{ + RepoIDs: []int64{3}, + }, + []int64{12, 6}, + }, + { + SearchOptions{ + RepoIDs: []int64{4}, + }, + []int64{}, + }, + { + SearchOptions{ + RepoIDs: []int64{5}, + }, + []int64{15}, + }, + } + + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationByID(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + opts: SearchOptions{ + PosterID: optional.Some(int64(1)), + }, + expectedIDs: []int64{11, 6, 3, 2, 1}, + }, + { + opts: SearchOptions{ + AssigneeID: optional.Some(int64(1)), + }, + expectedIDs: []int64{6, 1}, + }, + { + // NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1. + opts: *ToSearchOptions("", &conversations.ConversationsOptions{AssigneeID: -1}), + expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2}, + }, + { + opts: SearchOptions{ + MentionID: optional.Some(int64(4)), + }, + expectedIDs: []int64{1}, + }, + { + opts: SearchOptions{ + ReviewedID: optional.Some(int64(1)), + }, + expectedIDs: []int64{}, + }, + { + opts: SearchOptions{ + ReviewRequestedID: optional.Some(int64(1)), + }, + expectedIDs: []int64{12}, + }, + { + opts: SearchOptions{ + SubscriberID: optional.Some(int64(1)), + }, + expectedIDs: []int64{11, 6, 5, 3, 2, 1}, + }, + { + // conversation 20 request user 15 and team 5 which user 15 belongs to + // the review request number of conversation 20 should be 1 + opts: SearchOptions{ + ReviewRequestedID: optional.Some(int64(15)), + }, + expectedIDs: []int64{12, 20}, + }, + { + // user 20 approved the conversation 20, so return nothing + opts: SearchOptions{ + ReviewRequestedID: optional.Some(int64(20)), + }, + expectedIDs: []int64{}, + }, + } + + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationIsPull(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + IsPull: optional.Some(false), + }, + []int64{17, 16, 15, 14, 13, 6, 5, 18, 10, 7, 4, 1}, + }, + { + SearchOptions{ + IsPull: optional.Some(true), + }, + []int64{22, 21, 12, 11, 20, 19, 9, 8, 3, 2}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationIsClosed(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + IsClosed: optional.Some(false), + }, + []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 19, 18, 10, 7, 9, 8, 3, 2, 1}, + }, + { + SearchOptions{ + IsClosed: optional.Some(true), + }, + []int64{5, 4}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationByMilestoneID(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + MilestoneIDs: []int64{1}, + }, + []int64{2}, + }, + { + SearchOptions{ + MilestoneIDs: []int64{3}, + }, + []int64{3}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationByLabelID(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + IncludedLabelIDs: []int64{1}, + }, + []int64{2, 1}, + }, + { + SearchOptions{ + IncludedLabelIDs: []int64{4}, + }, + []int64{2}, + }, + { + SearchOptions{ + ExcludedLabelIDs: []int64{1}, + }, + []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationByTime(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + UpdatedAfterUnix: optional.Some(int64(0)), + }, + []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 1}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationWithOrder(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + SortBy: internal.SortByCreatedAsc, + }, + []int64{1, 2, 3, 8, 9, 4, 7, 10, 18, 19, 5, 6, 20, 11, 12, 13, 14, 15, 16, 17, 21, 22}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationInProject(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + }{ + { + SearchOptions{ + ProjectID: optional.Some(int64(1)), + }, + []int64{5, 3, 2, 1}, + }, + { + SearchOptions{ + ProjectColumnID: optional.Some(int64(1)), + }, + []int64{1}, + }, + { + SearchOptions{ + ProjectColumnID: optional.Some(int64(0)), // conversation with in default column + }, + []int64{2}, + }, + } + for _, test := range tests { + conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + } +} + +func searchConversationWithPaginator(t *testing.T) { + tests := []struct { + opts SearchOptions + expectedIDs []int64 + expectedTotal int64 + }{ + { + SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + }, + []int64{22, 21, 17, 16, 15}, + 22, + }, + } + for _, test := range tests { + conversationIDs, total, err := SearchConversations(context.TODO(), &test.opts) + if !assert.NoError(t, err) { + return + } + assert.Equal(t, test.expectedIDs, conversationIDs) + assert.Equal(t, test.expectedTotal, total) + } +} diff --git a/modules/indexer/conversations/internal/indexer.go b/modules/indexer/conversations/internal/indexer.go new file mode 100644 index 0000000000000..903154f29afdb --- /dev/null +++ b/modules/indexer/conversations/internal/indexer.go @@ -0,0 +1,42 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package internal + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/modules/indexer/internal" +) + +// Indexer defines an interface to indexer conversations contents +type Indexer interface { + internal.Indexer + Index(ctx context.Context, conversation ...*IndexerData) error + Delete(ctx context.Context, ids ...int64) error + Search(ctx context.Context, options *SearchOptions) (*SearchResult, error) +} + +// NewDummyIndexer returns a dummy indexer +func NewDummyIndexer() Indexer { + return &dummyIndexer{ + Indexer: internal.NewDummyIndexer(), + } +} + +type dummyIndexer struct { + internal.Indexer +} + +func (d *dummyIndexer) Index(_ context.Context, _ ...*IndexerData) error { + return fmt.Errorf("indexer is not ready") +} + +func (d *dummyIndexer) Delete(_ context.Context, _ ...int64) error { + return fmt.Errorf("indexer is not ready") +} + +func (d *dummyIndexer) Search(_ context.Context, _ *SearchOptions) (*SearchResult, error) { + return nil, fmt.Errorf("indexer is not ready") +} diff --git a/modules/indexer/conversations/internal/model.go b/modules/indexer/conversations/internal/model.go new file mode 100644 index 0000000000000..aa39f9cd6bf5e --- /dev/null +++ b/modules/indexer/conversations/internal/model.go @@ -0,0 +1,158 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package internal + +import ( + "strconv" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/timeutil" +) + +// IndexerData data stored in the conversation indexer +type IndexerData struct { + ID int64 `json:"id"` + RepoID int64 `json:"repo_id"` + IsPublic bool `json:"is_public"` // If the repo is public + + // Fields used for keyword searching + Title string `json:"title"` + Content string `json:"content"` + Comments []string `json:"comments"` + + // Fields used for filtering + IsPull bool `json:"is_pull"` + IsClosed bool `json:"is_closed"` + LabelIDs []int64 `json:"label_ids"` + NoLabel bool `json:"no_label"` // True if LabelIDs is empty + MilestoneID int64 `json:"milestone_id"` + ProjectID int64 `json:"project_id"` + ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible + PosterID int64 `json:"poster_id"` + AssigneeID int64 `json:"assignee_id"` + MentionIDs []int64 `json:"mention_ids"` + ReviewedIDs []int64 `json:"reviewed_ids"` + ReviewRequestedIDs []int64 `json:"review_requested_ids"` + SubscriberIDs []int64 `json:"subscriber_ids"` + UpdatedUnix timeutil.TimeStamp `json:"updated_unix"` + + // Fields used for sorting + // UpdatedUnix is both used for filtering and sorting. + // ID is used for sorting too, to make the sorting stable. + CreatedUnix timeutil.TimeStamp `json:"created_unix"` + DeadlineUnix timeutil.TimeStamp `json:"deadline_unix"` + CommentCount int64 `json:"comment_count"` +} + +// Match represents on search result +type Match struct { + ID int64 `json:"id"` + Score float64 `json:"score"` +} + +// SearchResult represents search results +type SearchResult struct { + Total int64 + Hits []Match +} + +// SearchOptions represents search options. +// +// It has a slightly different design from database query options. +// In database query options, a field is never a pointer, so it could be confusing when it's zero value: +// Do you want to find data with a field value of 0, or do you not specify the field in the options? +// To avoid this confusion, db introduced db.NoConditionID(-1). +// So zero value means the field is not specified in the search options, and db.NoConditionID means "== 0" or "id NOT IN (SELECT id FROM ...)" +// It's still not ideal, it trapped developers many times. +// And sometimes -1 could be a valid value, like conversation ID, negative numbers indicate exclusion. +// Since db.NoConditionID is for "db" (the package name is db), it makes sense not to use it in the indexer: +// Why do bleve/elasticsearch/meilisearch indexers need to know about db.NoConditionID? +// So in SearchOptions, we use pointer for fields which could be not specified, +// and always use the value to filter if it's not nil, even if it's zero or negative. +// It can handle almost all cases, if there is an exception, we can add a new field, like NoLabelOnly. +// Unfortunately, we still use db for the indexer and have to convert between db.NoConditionID and nil for legacy reasons. +type SearchOptions struct { + Keyword string // keyword to search + + IsFuzzyKeyword bool // if false the levenshtein distance is 0 + + RepoIDs []int64 // repository IDs which the conversations belong to + AllPublic bool // if include all public repositories + + IsPull optional.Option[bool] // if the conversations is a pull request + IsClosed optional.Option[bool] // if the conversations is closed + + IncludedLabelIDs []int64 // labels the conversations have + ExcludedLabelIDs []int64 // labels the conversations don't have + IncludedAnyLabelIDs []int64 // labels the conversations have at least one. It will be ignored if IncludedLabelIDs is not empty. It's an uncommon filter, but it has been supported accidentally by conversations.ConversationsOptions.IncludedLabelNames. + NoLabelOnly bool // if the conversations have no label, if true, IncludedLabelIDs and ExcludedLabelIDs, IncludedAnyLabelIDs will be ignored + + MilestoneIDs []int64 // milestones the conversations have + + ProjectID optional.Option[int64] // project the conversations belong to + ProjectColumnID optional.Option[int64] // project column the conversations belong to + + PosterID optional.Option[int64] // poster of the conversations + + AssigneeID optional.Option[int64] // assignee of the conversations, zero means no assignee + + MentionID optional.Option[int64] // mentioned user of the conversations + + ReviewedID optional.Option[int64] // reviewer of the conversations + ReviewRequestedID optional.Option[int64] // requested reviewer of the conversations + + SubscriberID optional.Option[int64] // subscriber of the conversations + + UpdatedAfterUnix optional.Option[int64] + UpdatedBeforeUnix optional.Option[int64] + + Paginator *db.ListOptions + + SortBy SortBy // sort by field +} + +// Copy returns a copy of the options. +// Be careful, it's not a deep copy, so `SearchOptions.RepoIDs = {...}` is OK while `SearchOptions.RepoIDs[0] = ...` is not. +func (o *SearchOptions) Copy(edit ...func(options *SearchOptions)) *SearchOptions { + if o == nil { + return nil + } + v := *o + for _, e := range edit { + e(&v) + } + return &v +} + +// used for optimized conversation index based search +func (o *SearchOptions) IsKeywordNumeric() bool { + _, err := strconv.Atoi(o.Keyword) + return err == nil +} + +type SortBy string + +const ( + SortByCreatedDesc SortBy = "-created_unix" + SortByUpdatedDesc SortBy = "-updated_unix" + SortByCommentsDesc SortBy = "-comment_count" + SortByDeadlineDesc SortBy = "-deadline_unix" + SortByCreatedAsc SortBy = "created_unix" + SortByUpdatedAsc SortBy = "updated_unix" + SortByCommentsAsc SortBy = "comment_count" + SortByDeadlineAsc SortBy = "deadline_unix" + // Unsupported sort types which are supported by conversations.ConversationsOptions.SortType: + // + // - "priorityrepo": + // It's impossible to support it in the indexer. + // It is based on the specified repository in the request, so we cannot add static field to the indexer. + // If we do something like that query the conversations in the specified repository first then append other conversations, + // it will break the pagination. + // + // - "project-column-sorting": + // Although it's possible to support it by adding project.ProjectConversation.Sorting to the indexer, + // but what if the conversation belongs to multiple projects? + // Since it's unsupported to search conversations with keyword in project page, we don't need to support it. +) diff --git a/modules/indexer/conversations/internal/tests/tests.go b/modules/indexer/conversations/internal/tests/tests.go new file mode 100644 index 0000000000000..179d94a19454b --- /dev/null +++ b/modules/indexer/conversations/internal/tests/tests.go @@ -0,0 +1,757 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +// This package contains tests for conversations indexer modules. +// All the code in this package is only used for testing. +// Do not put any production code in this package to avoid it being included in the final binary. + +package tests + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/timeutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIndexer(t *testing.T, indexer internal.Indexer) { + _, err := indexer.Init(context.Background()) + require.NoError(t, err) + + require.NoError(t, indexer.Ping(context.Background())) + + var ( + ids []int64 + data = map[int64]*internal.IndexerData{} + ) + { + d := generateDefaultIndexerData() + for _, v := range d { + ids = append(ids, v.ID) + data[v.ID] = v + } + require.NoError(t, indexer.Index(context.Background(), d...)) + require.NoError(t, waitData(indexer, int64(len(data)))) + } + + defer func() { + require.NoError(t, indexer.Delete(context.Background(), ids...)) + }() + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + if len(c.ExtraData) > 0 { + require.NoError(t, indexer.Index(context.Background(), c.ExtraData...)) + for _, v := range c.ExtraData { + data[v.ID] = v + } + require.NoError(t, waitData(indexer, int64(len(data)))) + defer func() { + for _, v := range c.ExtraData { + require.NoError(t, indexer.Delete(context.Background(), v.ID)) + delete(data, v.ID) + } + require.NoError(t, waitData(indexer, int64(len(data)))) + }() + } + + result, err := indexer.Search(context.Background(), c.SearchOptions) + require.NoError(t, err) + + if c.Expected != nil { + c.Expected(t, data, result) + } else { + ids := make([]int64, 0, len(result.Hits)) + for _, hit := range result.Hits { + ids = append(ids, hit.ID) + } + assert.Equal(t, c.ExpectedIDs, ids) + assert.Equal(t, c.ExpectedTotal, result.Total) + } + + // test counting + c.SearchOptions.Paginator = &db.ListOptions{PageSize: 0} + countResult, err := indexer.Search(context.Background(), c.SearchOptions) + require.NoError(t, err) + assert.Empty(t, countResult.Hits) + assert.Equal(t, result.Total, countResult.Total) + }) + } +} + +var cases = []*testIndexerCase{ + { + Name: "default", + SearchOptions: &internal.SearchOptions{}, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + }, + }, + { + Name: "empty", + SearchOptions: &internal.SearchOptions{ + Keyword: "f1dfac73-fda6-4a6b-b8a4-2408fcb8ef69", + }, + ExpectedIDs: []int64{}, + ExpectedTotal: 0, + }, + { + Name: "with limit", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + }, + }, + { + Name: "Keyword", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hi hello world"}, + {ID: 1001, Content: "hi hello world"}, + {ID: 1002, Comments: []string{"hi", "hello world"}}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + }, + ExpectedIDs: []int64{1002, 1001, 1000}, + ExpectedTotal: 3, + }, + { + Name: "RepoIDs", + ExtraData: []*internal.IndexerData{ + {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true}, + {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, + {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + RepoIDs: []int64{1, 4}, + }, + ExpectedIDs: []int64{1006, 1002, 1001}, + ExpectedTotal: 3, + }, + { + Name: "RepoIDs and AllPublic", + ExtraData: []*internal.IndexerData{ + {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false}, + {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true}, + {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true}, + {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, + {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + RepoIDs: []int64{1, 4}, + AllPublic: true, + }, + ExpectedIDs: []int64{1006, 1005, 1004, 1003, 1002, 1001}, + ExpectedTotal: 6, + }, + { + Name: "conversation only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsPull: optional.Some(false), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.False(t, data[v.ID].IsPull) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsPull }), result.Total) + }, + }, + { + Name: "pull only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsPull: optional.Some(true), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.True(t, data[v.ID].IsPull) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsPull }), result.Total) + }, + }, + { + Name: "opened only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsClosed: optional.Some(false), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.False(t, data[v.ID].IsClosed) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsClosed }), result.Total) + }, + }, + { + Name: "closed only", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + IsClosed: optional.Some(true), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.True(t, data[v.ID].IsClosed) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsClosed }), result.Total) + }, + }, + { + Name: "labels", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}}, + {ID: 1001, Title: "hello b", LabelIDs: []int64{2000, 2001}}, + {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}}, + {ID: 1003, Title: "hello d", LabelIDs: []int64{2000}}, + {ID: 1004, Title: "hello e", LabelIDs: []int64{}}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + IncludedLabelIDs: []int64{2000, 2001}, + ExcludedLabelIDs: []int64{2003}, + }, + ExpectedIDs: []int64{1001, 1000}, + ExpectedTotal: 2, + }, + { + Name: "include any labels", + ExtraData: []*internal.IndexerData{ + {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}}, + {ID: 1001, Title: "hello b", LabelIDs: []int64{2001}}, + {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}}, + {ID: 1003, Title: "hello d", LabelIDs: []int64{2002}}, + {ID: 1004, Title: "hello e", LabelIDs: []int64{}}, + }, + SearchOptions: &internal.SearchOptions{ + Keyword: "hello", + IncludedAnyLabelIDs: []int64{2001, 2002}, + ExcludedLabelIDs: []int64{2003}, + }, + ExpectedIDs: []int64{1003, 1001, 1000}, + ExpectedTotal: 3, + }, + { + Name: "MilestoneIDs", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + MilestoneIDs: []int64{1, 2, 6}, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, []int64{1, 2, 6}, data[v.ID].MilestoneID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.MilestoneID == 1 || v.MilestoneID == 2 || v.MilestoneID == 6 + }), result.Total) + }, + }, + { + Name: "no MilestoneIDs", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + MilestoneIDs: []int64{0}, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].MilestoneID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.MilestoneID == 0 + }), result.Total) + }, + }, + { + Name: "ProjectID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].ProjectID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectID == 1 + }), result.Total) + }, + }, + { + Name: "no ProjectID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectID: optional.Some(int64(0)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].ProjectID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectID == 0 + }), result.Total) + }, + }, + { + Name: "ProjectColumnID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectColumnID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].ProjectColumnID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectColumnID == 1 + }), result.Total) + }, + }, + { + Name: "no ProjectColumnID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ProjectColumnID: optional.Some(int64(0)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].ProjectColumnID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.ProjectColumnID == 0 + }), result.Total) + }, + }, + { + Name: "PosterID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + PosterID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].PosterID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.PosterID == 1 + }), result.Total) + }, + }, + { + Name: "AssigneeID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + AssigneeID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(1), data[v.ID].AssigneeID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.AssigneeID == 1 + }), result.Total) + }, + }, + { + Name: "no AssigneeID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + AssigneeID: optional.Some(int64(0)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Equal(t, int64(0), data[v.ID].AssigneeID) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return v.AssigneeID == 0 + }), result.Total) + }, + }, + { + Name: "MentionID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + MentionID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].MentionIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return slices.Contains(v.MentionIDs, 1) + }), result.Total) + }, + }, + { + Name: "ReviewedID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ReviewedID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].ReviewedIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return slices.Contains(v.ReviewedIDs, 1) + }), result.Total) + }, + }, + { + Name: "ReviewRequestedID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + ReviewRequestedID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].ReviewRequestedIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return slices.Contains(v.ReviewRequestedIDs, 1) + }), result.Total) + }, + }, + { + Name: "SubscriberID", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + SubscriberID: optional.Some(int64(1)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.Contains(t, data[v.ID].SubscriberIDs, int64(1)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return slices.Contains(v.SubscriberIDs, 1) + }), result.Total) + }, + }, + { + Name: "updated", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 5, + }, + UpdatedAfterUnix: optional.Some(int64(20)), + UpdatedBeforeUnix: optional.Some(int64(30)), + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, 5, len(result.Hits)) + for _, v := range result.Hits { + assert.GreaterOrEqual(t, data[v.ID].UpdatedUnix, int64(20)) + assert.LessOrEqual(t, data[v.ID].UpdatedUnix, int64(30)) + } + assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { + return data[v.ID].UpdatedUnix >= 20 && data[v.ID].UpdatedUnix <= 30 + }), result.Total) + }, + }, + { + Name: "SortByCreatedDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByCreatedDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].CreatedUnix, data[result.Hits[i+1].ID].CreatedUnix) + } + } + }, + }, + { + Name: "SortByUpdatedDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByUpdatedDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].UpdatedUnix, data[result.Hits[i+1].ID].UpdatedUnix) + } + } + }, + }, + { + Name: "SortByCommentsDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByCommentsDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].CommentCount, data[result.Hits[i+1].ID].CommentCount) + } + } + }, + }, + { + Name: "SortByDeadlineDesc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByDeadlineDesc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.GreaterOrEqual(t, data[v.ID].DeadlineUnix, data[result.Hits[i+1].ID].DeadlineUnix) + } + } + }, + }, + { + Name: "SortByCreatedAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByCreatedAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].CreatedUnix, data[result.Hits[i+1].ID].CreatedUnix) + } + } + }, + }, + { + Name: "SortByUpdatedAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByUpdatedAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].UpdatedUnix, data[result.Hits[i+1].ID].UpdatedUnix) + } + } + }, + }, + { + Name: "SortByCommentsAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByCommentsAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].CommentCount, data[result.Hits[i+1].ID].CommentCount) + } + } + }, + }, + { + Name: "SortByDeadlineAsc", + SearchOptions: &internal.SearchOptions{ + Paginator: &db.ListOptionsAll, + SortBy: internal.SortByDeadlineAsc, + }, + Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { + assert.Equal(t, len(data), len(result.Hits)) + assert.Equal(t, len(data), int(result.Total)) + for i, v := range result.Hits { + if i < len(result.Hits)-1 { + assert.LessOrEqual(t, data[v.ID].DeadlineUnix, data[result.Hits[i+1].ID].DeadlineUnix) + } + } + }, + }, +} + +type testIndexerCase struct { + Name string + ExtraData []*internal.IndexerData + + SearchOptions *internal.SearchOptions + + Expected func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) // if nil, use ExpectedIDs, ExpectedTotal + ExpectedIDs []int64 + ExpectedTotal int64 +} + +func generateDefaultIndexerData() []*internal.IndexerData { + var id int64 + var data []*internal.IndexerData + for repoID := int64(1); repoID <= 10; repoID++ { + for conversationIndex := int64(1); conversationIndex <= 20; conversationIndex++ { + id++ + + comments := make([]string, id%4) + for i := range comments { + comments[i] = fmt.Sprintf("comment%d", i) + } + + labelIDs := make([]int64, id%5) + for i := range labelIDs { + labelIDs[i] = int64(i) + 1 // LabelID should not be 0 + } + mentionIDs := make([]int64, id%6) + for i := range mentionIDs { + mentionIDs[i] = int64(i) + 1 // MentionID should not be 0 + } + reviewedIDs := make([]int64, id%7) + for i := range reviewedIDs { + reviewedIDs[i] = int64(i) + 1 // ReviewID should not be 0 + } + reviewRequestedIDs := make([]int64, id%8) + for i := range reviewRequestedIDs { + reviewRequestedIDs[i] = int64(i) + 1 // ReviewRequestedID should not be 0 + } + subscriberIDs := make([]int64, id%9) + for i := range subscriberIDs { + subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0 + } + + data = append(data, &internal.IndexerData{ + ID: id, + RepoID: repoID, + IsPublic: repoID%2 == 0, + Title: fmt.Sprintf("conversation%d of repo%d", conversationIndex, repoID), + Content: fmt.Sprintf("content%d", conversationIndex), + Comments: comments, + IsPull: conversationIndex%2 == 0, + IsClosed: conversationIndex%3 == 0, + LabelIDs: labelIDs, + NoLabel: len(labelIDs) == 0, + MilestoneID: conversationIndex % 4, + ProjectID: conversationIndex % 5, + ProjectColumnID: conversationIndex % 6, + PosterID: id%10 + 1, // PosterID should not be 0 + AssigneeID: conversationIndex % 10, + MentionIDs: mentionIDs, + ReviewedIDs: reviewedIDs, + ReviewRequestedIDs: reviewRequestedIDs, + SubscriberIDs: subscriberIDs, + UpdatedUnix: timeutil.TimeStamp(id + conversationIndex), + CreatedUnix: timeutil.TimeStamp(id), + DeadlineUnix: timeutil.TimeStamp(id + conversationIndex + repoID), + CommentCount: int64(len(comments)), + }) + } + } + + return data +} + +func countIndexerData(data map[int64]*internal.IndexerData, f func(v *internal.IndexerData) bool) int64 { + var count int64 + for _, v := range data { + if f(v) { + count++ + } + } + return count +} + +// waitData waits for the indexer to index all data. +// Some engines like Elasticsearch index data asynchronously, so we need to wait for a while. +func waitData(indexer internal.Indexer, total int64) error { + var actual int64 + for i := 0; i < 100; i++ { + result, err := indexer.Search(context.Background(), &internal.SearchOptions{ + Paginator: &db.ListOptions{ + PageSize: 0, + }, + }) + if err != nil { + return err + } + actual = result.Total + if actual == total { + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("waitData: expected %d, actual %d", total, actual) +} diff --git a/modules/indexer/conversations/meilisearch/meilisearch.go b/modules/indexer/conversations/meilisearch/meilisearch.go new file mode 100644 index 0000000000000..9a3d857481fc5 --- /dev/null +++ b/modules/indexer/conversations/meilisearch/meilisearch.go @@ -0,0 +1,301 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package meilisearch + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/indexer/conversations/internal" + indexer_internal "code.gitea.io/gitea/modules/indexer/internal" + inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch" + + "github.com/meilisearch/meilisearch-go" +) + +const ( + conversationIndexerLatestVersion = 3 + + // TODO: make this configurable if necessary + maxTotalHits = 10000 +) + +// ErrMalformedResponse is never expected as we initialize the indexer ourself and so define the types. +var ErrMalformedResponse = errors.New("meilisearch returned unexpected malformed content") + +var _ internal.Indexer = &Indexer{} + +// Indexer implements Indexer interface +type Indexer struct { + inner *inner_meilisearch.Indexer + indexer_internal.Indexer // do not composite inner_meilisearch.Indexer directly to avoid exposing too much +} + +// NewIndexer creates a new meilisearch indexer +func NewIndexer(url, apiKey, indexerName string) *Indexer { + settings := &meilisearch.Settings{ + // The default ranking rules of meilisearch are: ["words", "typo", "proximity", "attribute", "sort", "exactness"] + // So even if we specify the sort order, it could not be respected because the priority of "sort" is so low. + // So we need to specify the ranking rules to make sure the sort order is respected. + // See https://www.meilisearch.com/docs/learn/core_concepts/relevancy + RankingRules: []string{"sort", // make sure "sort" has the highest priority + "words", "typo", "proximity", "attribute", "exactness"}, + + SearchableAttributes: []string{ + "title", + "content", + "comments", + }, + DisplayedAttributes: []string{ + "id", + "title", + "content", + "comments", + }, + FilterableAttributes: []string{ + "repo_id", + "is_public", + "is_pull", + "is_closed", + "label_ids", + "no_label", + "milestone_id", + "project_id", + "project_board_id", + "poster_id", + "assignee_id", + "mention_ids", + "reviewed_ids", + "review_requested_ids", + "subscriber_ids", + "updated_unix", + }, + SortableAttributes: []string{ + "updated_unix", + "created_unix", + "deadline_unix", + "comment_count", + "id", + }, + Pagination: &meilisearch.Pagination{ + MaxTotalHits: maxTotalHits, + }, + } + + inner := inner_meilisearch.NewIndexer(url, apiKey, indexerName, conversationIndexerLatestVersion, settings) + indexer := &Indexer{ + inner: inner, + Indexer: inner, + } + return indexer +} + +// Index will save the index data +func (b *Indexer) Index(_ context.Context, conversations ...*internal.IndexerData) error { + if len(conversations) == 0 { + return nil + } + for _, conversation := range conversations { + _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).AddDocuments(conversation) + if err != nil { + return err + } + } + // TODO: bulk send index data + return nil +} + +// Delete deletes indexes by ids +func (b *Indexer) Delete(_ context.Context, ids ...int64) error { + if len(ids) == 0 { + return nil + } + + for _, id := range ids { + _, err := b.inner.Client.Index(b.inner.VersionedIndexName()).DeleteDocument(strconv.FormatInt(id, 10)) + if err != nil { + return err + } + } + // TODO: bulk send deletes + return nil +} + +// Search searches for conversations by given conditions. +// Returns the matching conversation IDs +func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { + query := inner_meilisearch.FilterAnd{} + + if len(options.RepoIDs) > 0 { + q := &inner_meilisearch.FilterOr{} + q.Or(inner_meilisearch.NewFilterIn("repo_id", options.RepoIDs...)) + if options.AllPublic { + q.Or(inner_meilisearch.NewFilterEq("is_public", true)) + } + query.And(q) + } + + if options.IsPull.Has() { + query.And(inner_meilisearch.NewFilterEq("is_pull", options.IsPull.Value())) + } + if options.IsClosed.Has() { + query.And(inner_meilisearch.NewFilterEq("is_closed", options.IsClosed.Value())) + } + + if options.NoLabelOnly { + query.And(inner_meilisearch.NewFilterEq("no_label", true)) + } else { + if len(options.IncludedLabelIDs) > 0 { + q := &inner_meilisearch.FilterAnd{} + for _, labelID := range options.IncludedLabelIDs { + q.And(inner_meilisearch.NewFilterEq("label_ids", labelID)) + } + query.And(q) + } else if len(options.IncludedAnyLabelIDs) > 0 { + query.And(inner_meilisearch.NewFilterIn("label_ids", options.IncludedAnyLabelIDs...)) + } + if len(options.ExcludedLabelIDs) > 0 { + q := &inner_meilisearch.FilterAnd{} + for _, labelID := range options.ExcludedLabelIDs { + q.And(inner_meilisearch.NewFilterNot(inner_meilisearch.NewFilterEq("label_ids", labelID))) + } + query.And(q) + } + } + + if len(options.MilestoneIDs) > 0 { + query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...)) + } + + if options.ProjectID.Has() { + query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value())) + } + if options.ProjectColumnID.Has() { + query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) + } + + if options.PosterID.Has() { + query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value())) + } + + if options.AssigneeID.Has() { + query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value())) + } + + if options.MentionID.Has() { + query.And(inner_meilisearch.NewFilterEq("mention_ids", options.MentionID.Value())) + } + + if options.ReviewedID.Has() { + query.And(inner_meilisearch.NewFilterEq("reviewed_ids", options.ReviewedID.Value())) + } + if options.ReviewRequestedID.Has() { + query.And(inner_meilisearch.NewFilterEq("review_requested_ids", options.ReviewRequestedID.Value())) + } + + if options.SubscriberID.Has() { + query.And(inner_meilisearch.NewFilterEq("subscriber_ids", options.SubscriberID.Value())) + } + + if options.UpdatedAfterUnix.Has() { + query.And(inner_meilisearch.NewFilterGte("updated_unix", options.UpdatedAfterUnix.Value())) + } + if options.UpdatedBeforeUnix.Has() { + query.And(inner_meilisearch.NewFilterLte("updated_unix", options.UpdatedBeforeUnix.Value())) + } + + if options.SortBy == "" { + options.SortBy = internal.SortByCreatedAsc + } + sortBy := []string{ + parseSortBy(options.SortBy), + "id:desc", + } + + skip, limit := indexer_internal.ParsePaginator(options.Paginator, maxTotalHits) + + counting := limit == 0 + if counting { + // If set limit to 0, it will be 20 by default, and -1 is not allowed. + // See https://www.meilisearch.com/docs/reference/api/search#limit + // So set limit to 1 to make the cost as low as possible, then clear the result before returning. + limit = 1 + } + + keyword := options.Keyword + if !options.IsFuzzyKeyword { + // to make it non fuzzy ("typo tolerance" in meilisearch terms), we have to quote the keyword(s) + // https://www.meilisearch.com/docs/reference/api/search#phrase-search + keyword = doubleQuoteKeyword(keyword) + } + + searchRes, err := b.inner.Client.Index(b.inner.VersionedIndexName()).Search(keyword, &meilisearch.SearchRequest{ + Filter: query.Statement(), + Limit: int64(limit), + Offset: int64(skip), + Sort: sortBy, + MatchingStrategy: "all", + }) + if err != nil { + return nil, err + } + + if counting { + searchRes.Hits = nil + } + + hits, err := convertHits(searchRes) + if err != nil { + return nil, err + } + + return &internal.SearchResult{ + Total: searchRes.EstimatedTotalHits, + Hits: hits, + }, nil +} + +func parseSortBy(sortBy internal.SortBy) string { + field := strings.TrimPrefix(string(sortBy), "-") + if strings.HasPrefix(string(sortBy), "-") { + return field + ":desc" + } + return field + ":asc" +} + +func doubleQuoteKeyword(k string) string { + kp := strings.Split(k, " ") + parts := 0 + for i := range kp { + part := strings.Trim(kp[i], "\"") + if part != "" { + kp[parts] = fmt.Sprintf(`"%s"`, part) + parts++ + } + } + return strings.Join(kp[:parts], " ") +} + +func convertHits(searchRes *meilisearch.SearchResponse) ([]internal.Match, error) { + hits := make([]internal.Match, 0, len(searchRes.Hits)) + for _, hit := range searchRes.Hits { + hit, ok := hit.(map[string]any) + if !ok { + return nil, ErrMalformedResponse + } + + conversationID, ok := hit["id"].(float64) + if !ok { + return nil, ErrMalformedResponse + } + + hits = append(hits, internal.Match{ + ID: int64(conversationID), + }) + } + return hits, nil +} diff --git a/modules/indexer/conversations/meilisearch/meilisearch_test.go b/modules/indexer/conversations/meilisearch/meilisearch_test.go new file mode 100644 index 0000000000000..4cf931ba11438 --- /dev/null +++ b/modules/indexer/conversations/meilisearch/meilisearch_test.go @@ -0,0 +1,95 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package meilisearch + +import ( + "fmt" + "net/http" + "os" + "testing" + "time" + + "code.gitea.io/gitea/modules/indexer/conversations/internal" + "code.gitea.io/gitea/modules/indexer/conversations/internal/tests" + + "github.com/meilisearch/meilisearch-go" + "github.com/stretchr/testify/assert" +) + +func TestMeilisearchIndexer(t *testing.T) { + // The meilisearch instance started by pull-db-tests.yml > test-unit > services > meilisearch + url := "http://meilisearch:7700" + key := "" // auth has been disabled in test environment + + if os.Getenv("CI") == "" { + // Make it possible to run tests against a local meilisearch instance + url = os.Getenv("TEST_MEILISEARCH_URL") + if url == "" { + t.Skip("TEST_MEILISEARCH_URL not set and not running in CI") + return + } + key = os.Getenv("TEST_MEILISEARCH_KEY") + } + + ok := false + for i := 0; i < 60; i++ { + resp, err := http.Get(url) + if err == nil && resp.StatusCode == http.StatusOK { + ok = true + break + } + t.Logf("Waiting for meilisearch to be up: %v", err) + time.Sleep(time.Second) + } + if !ok { + t.Fatalf("Failed to wait for meilisearch to be up") + return + } + + indexer := NewIndexer(url, key, fmt.Sprintf("test_meilisearch_indexer_%d", time.Now().Unix())) + defer indexer.Close() + + tests.TestIndexer(t, indexer) +} + +func TestConvertHits(t *testing.T) { + _, err := convertHits(&meilisearch.SearchResponse{ + Hits: []any{"aa", "bb", "cc", "dd"}, + }) + assert.ErrorIs(t, err, ErrMalformedResponse) + + validResponse := &meilisearch.SearchResponse{ + Hits: []any{ + map[string]any{ + "id": float64(11), + "title": "a title", + "content": "conversation body with no match", + "comments": []any{"hey whats up?", "I'm currently bowling", "nice"}, + }, + map[string]any{ + "id": float64(22), + "title": "Bowling as title", + "content": "", + "comments": []any{}, + }, + map[string]any{ + "id": float64(33), + "title": "Bowl-ing as fuzzy match", + "content": "", + "comments": []any{}, + }, + }, + } + hits, err := convertHits(validResponse) + assert.NoError(t, err) + assert.EqualValues(t, []internal.Match{{ID: 11}, {ID: 22}, {ID: 33}}, hits) +} + +func TestDoubleQuoteKeyword(t *testing.T) { + assert.EqualValues(t, "", doubleQuoteKeyword("")) + assert.EqualValues(t, `"a" "b" "c"`, doubleQuoteKeyword("a b c")) + assert.EqualValues(t, `"a" "d" "g"`, doubleQuoteKeyword("a d g")) + assert.EqualValues(t, `"a" "d" "g"`, doubleQuoteKeyword("a d g")) + assert.EqualValues(t, `"a" "d" "g"`, doubleQuoteKeyword(`a "" "d" """g`)) +} diff --git a/modules/indexer/conversations/util.go b/modules/indexer/conversations/util.go new file mode 100644 index 0000000000000..d68c1e73951f4 --- /dev/null +++ b/modules/indexer/conversations/util.go @@ -0,0 +1,134 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "errors" + "fmt" + + conversation_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/modules/indexer/conversations/internal" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/queue" +) + +// getConversationIndexerData returns the indexer data of an conversation and a bool value indicating whether the conversation exists. +func getConversationIndexerData(ctx context.Context, conversationID int64) (*internal.IndexerData, bool, error) { + conversation, err := conversation_model.GetConversationByID(ctx, conversationID) + if err != nil { + if conversation_model.IsErrConversationNotExist(err) { + return nil, false, nil + } + return nil, false, err + } + + // FIXME: what if users want to search for a review comment of a pull request? + // The comment type is CommentTypeCode or CommentTypeReview. + // But LoadDiscussComments only loads CommentTypeComment. + if err := conversation.LoadDiscussComments(ctx); err != nil { + return nil, false, err + } + + comments := make([]string, 0, len(conversation.Comments)) + for _, comment := range conversation.Comments { + if comment.Content != "" { + // what ever the comment type is, index the content if it is not empty. + comments = append(comments, comment.Content) + } + } + + if err := conversation.LoadAttributes(ctx); err != nil { + return nil, false, err + } + + mentionIDs, err := conversation_model.GetConversationMentionIDs(ctx, conversationID) + if err != nil { + return nil, false, err + } + + return &internal.IndexerData{ + ID: conversation.ID, + RepoID: conversation.RepoID, + IsPublic: !conversation.Repo.IsPrivate, + Comments: comments, + MentionIDs: mentionIDs, + UpdatedUnix: conversation.UpdatedUnix, + CreatedUnix: conversation.CreatedUnix, + CommentCount: int64(len(conversation.Comments)), + }, true, nil +} + +func updateRepoIndexer(ctx context.Context, repoID int64) error { + ids, err := conversation_model.GetConversationIDsByRepoID(ctx, repoID) + if err != nil { + return fmt.Errorf("conversation_model.GetConversationIDsByRepoID: %w", err) + } + for _, id := range ids { + if err := updateConversationIndexer(ctx, id); err != nil { + return err + } + } + return nil +} + +func updateConversationIndexer(ctx context.Context, conversationID int64) error { + return pushConversationIndexerQueue(ctx, &IndexerMetadata{ID: conversationID}) +} + +func deleteRepoConversationIndexer(ctx context.Context, repoID int64) error { + var ids []int64 + ids, err := conversation_model.GetConversationIDsByRepoID(ctx, repoID) + if err != nil { + return fmt.Errorf("conversation_model.GetConversationIDsByRepoID: %w", err) + } + + if len(ids) == 0 { + return nil + } + return pushConversationIndexerQueue(ctx, &IndexerMetadata{ + IDs: ids, + IsDelete: true, + }) +} + +type keepRetryKey struct{} + +// contextWithKeepRetry returns a context with a key indicating that the indexer should keep retrying. +// Please note that it's for background tasks only, and it should not be used for user requests, or it may cause blocking. +func contextWithKeepRetry(ctx context.Context) context.Context { + return context.WithValue(ctx, keepRetryKey{}, true) +} + +func pushConversationIndexerQueue(ctx context.Context, data *IndexerMetadata) error { + if conversationIndexerQueue == nil { + // Some unit tests will trigger indexing, but the queue is not initialized. + // It's OK to ignore it, but log a warning message in case it's not a unit test. + log.Warn("Trying to push %+v to conversation indexer queue, but the queue is not initialized, it's OK if it's a unit test", data) + return nil + } + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + err := conversationIndexerQueue.Push(data) + if errors.Is(err, queue.ErrAlreadyInQueue) { + return nil + } + if errors.Is(err, context.DeadlineExceeded) { // the queue is full + log.Warn("It seems that conversation indexer is slow and the queue is full. Please check the conversation indexer or increase the queue size.") + if ctx.Value(keepRetryKey{}) == nil { + return err + } + // It will be better to increase the queue size instead of retrying, but users may ignore the previous warning message. + // However, even it retries, it may still cause index loss when there's a deadline in the context. + log.Debug("Retry to push %+v to conversation indexer queue", data) + continue + } + return err + } +} diff --git a/modules/setting/indexer.go b/modules/setting/indexer.go index 18585602c3dd2..1c5dd0f57f9d9 100644 --- a/modules/setting/indexer.go +++ b/modules/setting/indexer.go @@ -14,6 +14,12 @@ import ( // Indexer settings var Indexer = struct { + ConversationType string + ConversationPath string + ConversationConnStr string + ConversationConnAuth string + ConversationIndexerName string + IssueType string IssuePath string IssueConnStr string @@ -32,6 +38,12 @@ var Indexer = struct { ExcludePatterns []*GlobMatcher ExcludeVendored bool }{ + ConversationType: "bleve", + ConversationPath: "indexers/conversations.bleve", + ConversationConnStr: "", + ConversationConnAuth: "", + ConversationIndexerName: "gitea_conversations", + IssueType: "bleve", IssuePath: "indexers/issues.bleve", IssueConnStr: "", diff --git a/modules/setting/repository.go b/modules/setting/repository.go index 8656ebc7ecfd0..8afc0433b0f2a 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -94,6 +94,10 @@ var ( MaxPinned int } `ini:"repository.issue"` + Conversation struct { + LockReasons []string + } `ini:"repository.conversation"` + Release struct { AllowedTypes string DefaultPagingNum int diff --git a/modules/setting/ui.go b/modules/setting/ui.go index a8dc11d09713c..2cb30ca7ac755 100644 --- a/modules/setting/ui.go +++ b/modules/setting/ui.go @@ -15,6 +15,7 @@ var UI = struct { ExplorePagingNum int SitemapPagingNum int IssuePagingNum int + ConversationPagingNum int RepoSearchPagingNum int MembersPagingNum int FeedMaxCommitNum int @@ -73,6 +74,7 @@ var UI = struct { ExplorePagingNum: 20, SitemapPagingNum: 20, IssuePagingNum: 20, + ConversationPagingNum: 20, RepoSearchPagingNum: 20, MembersPagingNum: 20, FeedMaxCommitNum: 5, diff --git a/modules/structs/conversation.go b/modules/structs/conversation.go new file mode 100644 index 0000000000000..097b140556059 --- /dev/null +++ b/modules/structs/conversation.go @@ -0,0 +1,218 @@ +// Copyright 2016 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +import ( + "fmt" + "path" + "slices" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Conversation represents an conversation in a repository +// swagger:model +type Conversation struct { + ID int64 `json:"id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Index int64 `json:"number"` + Poster *User `json:"user"` + OriginalAuthor string `json:"original_author"` + OriginalAuthorID int64 `json:"original_author_id"` + Title string `json:"title"` + Body string `json:"body"` + Ref string `json:"ref"` + Attachments []*Attachment `json:"assets"` + Labels []*Label `json:"labels"` + Milestone *Milestone `json:"milestone"` + // deprecated + Assignee *User `json:"assignee"` + Assignees []*User `json:"assignees"` + // Whether the conversation is open or locked + // + // type: string + // enum: open,locked + State StateType `json:"state"` + IsLocked bool `json:"is_locked"` + Comments int `json:"comments"` + // swagger:strfmt date-time + Created time.Time `json:"created_at"` + // swagger:strfmt date-time + Updated time.Time `json:"updated_at"` + // swagger:strfmt date-time + Locked *time.Time `json:"locked_at"` + // swagger:strfmt date-time + Deadline *time.Time `json:"due_date"` + + PullRequest *PullRequestMeta `json:"pull_request"` + Repo *RepositoryMeta `json:"repository"` + + PinOrder int `json:"pin_order"` +} + +// CreateConversationOption options to create one conversation +type CreateConversationOption struct { + // required:true + Title string `json:"title" binding:"Required"` + Body string `json:"body"` + Ref string `json:"ref"` + // deprecated + Assignee string `json:"assignee"` + Assignees []string `json:"assignees"` + // swagger:strfmt date-time + Deadline *time.Time `json:"due_date"` + // milestone id + Milestone int64 `json:"milestone"` + // list of label ids + Labels []int64 `json:"labels"` + Locked bool `json:"locked"` +} + +// EditConversationOption options for editing an conversation +type EditConversationOption struct { + Title string `json:"title"` + Body *string `json:"body"` + Ref *string `json:"ref"` +} + +// ConversationFormFieldType defines conversation form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes" +type ConversationFormFieldType string + +const ( + ConversationFormFieldTypeMarkdown ConversationFormFieldType = "markdown" + ConversationFormFieldTypeTextarea ConversationFormFieldType = "textarea" + ConversationFormFieldTypeInput ConversationFormFieldType = "input" + ConversationFormFieldTypeDropdown ConversationFormFieldType = "dropdown" + ConversationFormFieldTypeCheckboxes ConversationFormFieldType = "checkboxes" +) + +// ConversationFormField represents a form field +// swagger:model +type ConversationFormField struct { + Type ConversationFormFieldType `json:"type" yaml:"type"` + ID string `json:"id" yaml:"id"` + Attributes map[string]any `json:"attributes" yaml:"attributes"` + Validations map[string]any `json:"validations" yaml:"validations"` + Visible []ConversationFormFieldVisible `json:"visible,omitempty"` +} + +func (iff ConversationFormField) VisibleOnForm() bool { + if len(iff.Visible) == 0 { + return true + } + return slices.Contains(iff.Visible, ConversationFormFieldVisibleForm) +} + +func (iff ConversationFormField) VisibleInContent() bool { + if len(iff.Visible) == 0 { + // we have our markdown exception + return iff.Type != ConversationFormFieldTypeMarkdown + } + return slices.Contains(iff.Visible, ConversationFormFieldVisibleContent) +} + +// ConversationFormFieldVisible defines conversation form field visible +// swagger:model +type ConversationFormFieldVisible string + +const ( + ConversationFormFieldVisibleForm ConversationFormFieldVisible = "form" + ConversationFormFieldVisibleContent ConversationFormFieldVisible = "content" +) + +// ConversationTemplate represents an conversation template for a repository +// swagger:model +type ConversationTemplate struct { + Name string `json:"name" yaml:"name"` + Title string `json:"title" yaml:"title"` + About string `json:"about" yaml:"about"` // Using "description" in a template file is compatible + Labels ConversationTemplateStringSlice `json:"labels" yaml:"labels"` + Assignees ConversationTemplateStringSlice `json:"assignees" yaml:"assignees"` + Ref string `json:"ref" yaml:"ref"` + Content string `json:"content" yaml:"-"` + Fields []*ConversationFormField `json:"body" yaml:"body"` + FileName string `json:"file_name" yaml:"-"` +} + +type ConversationTemplateStringSlice []string + +func (l *ConversationTemplateStringSlice) UnmarshalYAML(value *yaml.Node) error { + var labels []string + if value.IsZero() { + *l = labels + return nil + } + switch value.Kind { + case yaml.ScalarNode: + str := "" + err := value.Decode(&str) + if err != nil { + return err + } + for _, v := range strings.Split(str, ",") { + if v = strings.TrimSpace(v); v == "" { + continue + } + labels = append(labels, v) + } + *l = labels + return nil + case yaml.SequenceNode: + if err := value.Decode(&labels); err != nil { + return err + } + *l = labels + return nil + } + return fmt.Errorf("line %d: cannot unmarshal %s into ConversationTemplateStringSlice", value.Line, value.ShortTag()) +} + +type ConversationConfigContactLink struct { + Name string `json:"name" yaml:"name"` + URL string `json:"url" yaml:"url"` + About string `json:"about" yaml:"about"` +} + +type ConversationConfig struct { + BlankConversationsEnabled bool `json:"blank_conversations_enabled" yaml:"blank_conversations_enabled"` + ContactLinks []ConversationConfigContactLink `json:"contact_links" yaml:"contact_links"` +} + +type ConversationConfigValidation struct { + Valid bool `json:"valid"` + Message string `json:"message"` +} + +// ConversationTemplateType defines conversation template type +type ConversationTemplateType string + +const ( + ConversationTemplateTypeMarkdown ConversationTemplateType = "md" + ConversationTemplateTypeYaml ConversationTemplateType = "yaml" +) + +// Type returns the type of ConversationTemplate, can be "md", "yaml" or empty for known +func (it ConversationTemplate) Type() ConversationTemplateType { + if base := path.Base(it.FileName); base == "config.yaml" || base == "config.yml" { + // ignore config.yaml which is a special configuration file + return "" + } + if ext := path.Ext(it.FileName); ext == ".md" { + return ConversationTemplateTypeMarkdown + } else if ext == ".yaml" || ext == ".yml" { + return ConversationTemplateTypeYaml + } + return "" +} + +// ConversationMeta basic conversation information +// swagger:model +type ConversationMeta struct { + Index int64 `json:"index"` + Owner string `json:"owner"` + Name string `json:"repo"` +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 0e4e10bf50944..54bfac198019d 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -13,6 +13,7 @@ import ( "strings" asymkey_model "code.gitea.io/gitea/models/asymkey" + conversation_model "code.gitea.io/gitea/models/conversations" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" @@ -413,6 +414,33 @@ func Diff(ctx *context.Context) { return } + conversation, err := conversation_model.GetConversationByCommitID(ctx, commitID) + if err != nil { + // If failed to get a conversation, generate a new one for this commit + if conversation_model.IsErrConversationNotExist(err) { + err = conversation_model.NewConversation(ctx, ctx.Repo.Repository, &conversation_model.Conversation{ + RepoID: ctx.Repo.Repository.ID, + CommitSha: commitID, + Type: conversation_model.ConversationTypeCommit, + }, []string{}) + if err != nil { + ctx.ServerError("commit.NewConversation", err) + return + } + // And attempt to get it again + conversation, err = conversation_model.GetConversationByCommitID(ctx, commitID) + if err != nil { + ctx.ServerError("commit.GetConversationAfterNew", err) + return + } + } else { + ctx.ServerError("commit.GetConversation", err) + return + } + } + + ctx.Data["Comments"] = conversation.Comments + ctx.HTML(http.StatusOK, tplCommitPage) } diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go new file mode 100644 index 0000000000000..3a9433789a666 --- /dev/null +++ b/routers/web/repo/conversation.go @@ -0,0 +1,1337 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + + activities_model "code.gitea.io/gitea/models/activities" + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + access_model "code.gitea.io/gitea/models/perm/access" + project_model "code.gitea.io/gitea/models/project" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/base" + conversation_indexer "code.gitea.io/gitea/modules/indexer/conversations" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/context/upload" + conversation_service "code.gitea.io/gitea/services/conversation" + "code.gitea.io/gitea/services/convert" + "code.gitea.io/gitea/services/forms" + user_service "code.gitea.io/gitea/services/user" +) + +const ( + tplConversations base.TplName = "repo/conversation/list" + tplConversationNew base.TplName = "repo/conversation/new" + tplConversationChoose base.TplName = "repo/conversation/choose" + tplConversationView base.TplName = "repo/conversation/view" + + conversationTemplateKey = "ConversationTemplate" + conversationTemplateTitleKey = "ConversationTemplateTitle" +) + +// MustAllowUserComment checks to make sure if an conversation is locked. +// If locked and user has permissions to write to the repository, +// then the comment is allowed, else it is blocked +func ConversationMustAllowUserComment(ctx *context.Context) { + conversation := GetActionConversation(ctx) + if ctx.Written() { + return + } + + if conversation.IsLocked && !ctx.Doer.IsAdmin { + ctx.Flash.Error(ctx.Tr("repo.conversations.comment_on_locked")) + ctx.Redirect(conversation.Link()) + return + } +} + +// MustEnableConversations check if repository enable internal conversations +func MustEnableConversations(ctx *context.Context) { + if !ctx.Repo.CanRead(unit.TypeConversations) && + !ctx.Repo.CanRead(unit.TypeExternalTracker) { + ctx.NotFound("MustEnableConversations", nil) + return + } + + unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + if err == nil { + ctx.Redirect(unit.ExternalTrackerConfig().ExternalTrackerURL) + return + } +} + +// MustAllowPulls check if repository enable pull requests and user have right to do that +func ConversationMustAllowPulls(ctx *context.Context) { + if !ctx.Repo.Repository.CanEnablePulls() || !ctx.Repo.CanRead(unit.TypePullRequests) { + ctx.NotFound("MustAllowPulls", nil) + return + } + + // User can send pull request if owns a forked repository. + if ctx.IsSigned && repo_model.HasForkedRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID) { + ctx.Repo.PullRequest.Allowed = true + ctx.Repo.PullRequest.HeadInfoSubURL = url.PathEscape(ctx.Doer.Name) + ":" + util.PathEscapeSegments(ctx.Repo.BranchName) + } +} + +func conversations(ctx *context.Context) { + var err error + viewType := ctx.FormString("type") + sortType := ctx.FormString("sort") + types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested", "reviewed_by"} + if !util.SliceContainsString(types, viewType, true) { + viewType = "all" + } + + var ( + assigneeID = ctx.FormInt64("assignee") + posterID = ctx.FormInt64("poster") + mentionedID int64 + reviewRequestedID int64 + reviewedID int64 + ) + + if ctx.IsSigned { + switch viewType { + case "created_by": + posterID = ctx.Doer.ID + case "mentioned": + mentionedID = ctx.Doer.ID + case "assigned": + assigneeID = ctx.Doer.ID + case "review_requested": + reviewRequestedID = ctx.Doer.ID + case "reviewed_by": + reviewedID = ctx.Doer.ID + } + } + + repo := ctx.Repo.Repository + + keyword := strings.Trim(ctx.FormString("q"), " ") + if bytes.Contains([]byte(keyword), []byte{0x00}) { + keyword = "" + } + + var conversationStats *conversations_model.ConversationStats + statsOpts := &conversations_model.ConversationsOptions{ + RepoIDs: []int64{repo.ID}, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + ConversationIDs: nil, + } + if keyword != "" { + allConversationIDs, err := conversationIDsFromSearch(ctx, keyword, statsOpts) + if err != nil { + if conversation_indexer.IsAvailable(ctx) { + ctx.ServerError("conversationIDsFromSearch", err) + return + } + ctx.Data["ConversationIndexerUnavailable"] = true + return + } + statsOpts.ConversationIDs = allConversationIDs + } + if keyword != "" && len(statsOpts.ConversationIDs) == 0 { + // So it did search with the keyword, but no conversation found. + // Just set conversationStats to empty. + conversationStats = &conversations_model.ConversationStats{} + } else { + // So it did search with the keyword, and found some conversations. It needs to get conversationStats of these conversations. + // Or the keyword is empty, so it doesn't need conversationIDs as filter, just get conversationStats with statsOpts. + conversationStats, err = conversations_model.GetConversationStats(ctx, statsOpts) + if err != nil { + ctx.ServerError("GetConversationStats", err) + return + } + } + + var isShowClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isShowClosed = optional.Some(true) + case "all": + isShowClosed = optional.None[bool]() + default: + isShowClosed = optional.Some(false) + } + // if there are closed conversations and no open conversations, default to showing all conversations + if len(ctx.FormString("state")) == 0 && conversationStats.OpenCount == 0 && conversationStats.ClosedCount != 0 { + isShowClosed = optional.None[bool]() + } + + archived := ctx.FormBool("archived") + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + var total int + switch { + case isShowClosed.Value(): + total = int(conversationStats.ClosedCount) + case !isShowClosed.Has(): + total = int(conversationStats.OpenCount + conversationStats.ClosedCount) + default: + total = int(conversationStats.OpenCount) + } + pager := context.NewPagination(total, setting.UI.ConversationPagingNum, page, 5) + + var conversations conversations_model.ConversationList + { + ids, err := conversationIDsFromSearch(ctx, keyword, &conversations_model.ConversationsOptions{ + Paginator: &db.ListOptions{ + Page: pager.Paginater.Current(), + PageSize: setting.UI.ConversationPagingNum, + }, + RepoIDs: []int64{repo.ID}, + AssigneeID: assigneeID, + PosterID: posterID, + MentionedID: mentionedID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + IsClosed: isShowClosed, + SortType: sortType, + }) + if err != nil { + if conversation_indexer.IsAvailable(ctx) { + ctx.ServerError("conversationIDsFromSearch", err) + return + } + ctx.Data["ConversationIndexerUnavailable"] = true + return + } + conversations, err = conversations_model.GetConversationsByIDs(ctx, ids, true) + if err != nil { + ctx.ServerError("GetConversationsByIDs", err) + return + } + } + + if ctx.IsSigned { + if err := conversations.LoadIsRead(ctx, ctx.Doer.ID); err != nil { + ctx.ServerError("LoadIsRead", err) + return + } + } else { + for i := range conversations { + conversations[i].IsRead = true + } + } + + if err := conversations.LoadAttributes(ctx); err != nil { + ctx.ServerError("conversations.LoadAttributes", err) + return + } + + ctx.Data["Conversations"] = conversations + + handleTeamMentions(ctx) + if ctx.Written() { + return + } + + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["ConversationStats"] = conversationStats + ctx.Data["OpenCount"] = conversationStats.OpenCount + ctx.Data["ClosedCount"] = conversationStats.ClosedCount + ctx.Data["ViewType"] = viewType + ctx.Data["SortType"] = sortType + ctx.Data["AssigneeID"] = assigneeID + ctx.Data["PosterID"] = posterID + ctx.Data["Keyword"] = keyword + ctx.Data["IsShowClosed"] = isShowClosed + switch { + case isShowClosed.Value(): + ctx.Data["State"] = "closed" + case !isShowClosed.Has(): + ctx.Data["State"] = "all" + default: + ctx.Data["State"] = "open" + } + ctx.Data["ShowArchivedLabels"] = archived + + pager.AddParamString("q", keyword) + pager.AddParamString("type", viewType) + pager.AddParamString("sort", sortType) + pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) + pager.AddParamString("assignee", fmt.Sprint(assigneeID)) + pager.AddParamString("poster", fmt.Sprint(posterID)) + pager.AddParamString("archived", fmt.Sprint(archived)) + + ctx.Data["Page"] = pager +} + +func conversationIDsFromSearch(ctx *context.Context, keyword string, opts *conversations_model.ConversationsOptions) ([]int64, error) { + ids, _, err := conversation_indexer.SearchConversations(ctx, conversation_indexer.ToSearchOptions(keyword, opts)) + if err != nil { + return nil, fmt.Errorf("SearchConversations: %w", err) + } + return ids, nil +} + +// Conversations render conversations page +func Conversations(ctx *context.Context) { + + renderMilestones(ctx) + if ctx.Written() { + return + } + + ctx.Data["CanWriteConversations"] = ctx.Repo.CanWriteConversations() + + ctx.HTML(http.StatusOK, tplConversations) +} + +// NewConversation render creating conversation page +func NewConversation(ctx *context.Context) { + + ctx.Data["Title"] = ctx.Tr("repo.conversations.new") + ctx.Data["PageIsConversationList"] = true + ctx.Data["NewConversationChooseTemplate"] = false + ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes + title := ctx.FormString("title") + ctx.Data["TitleQuery"] = title + body := ctx.FormString("body") + ctx.Data["BodyQuery"] = body + + isProjectsEnabled := ctx.Repo.CanRead(unit.TypeProjects) + ctx.Data["IsProjectsEnabled"] = isProjectsEnabled + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + projectID := ctx.FormInt64("project") + if projectID > 0 && isProjectsEnabled { + project, err := project_model.GetProjectByID(ctx, projectID) + if err != nil { + log.Error("GetProjectByID: %d: %v", projectID, err) + } else if project.RepoID != ctx.Repo.Repository.ID { + log.Error("GetProjectByID: %d: %v", projectID, fmt.Errorf("project[%d] not in repo [%d]", project.ID, ctx.Repo.Repository.ID)) + } else { + ctx.Data["project_id"] = projectID + ctx.Data["Project"] = project + } + + if len(ctx.Req.URL.Query().Get("project")) > 0 { + ctx.Data["redirect_after_creation"] = "project" + } + } + + RetrieveRepoMetas(ctx, ctx.Repo.Repository, false) + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + ctx.Data["HasConversationsWritePermission"] = ctx.Repo.CanWrite(unit.TypeConversations) + + ctx.HTML(http.StatusOK, tplConversationNew) +} + +// DeleteConversation deletes an conversation +func DeleteConversation(ctx *context.Context) { + conversation := GetActionConversation(ctx) + if ctx.Written() { + return + } + + if err := conversation_service.DeleteConversation(ctx, ctx.Doer, ctx.Repo.GitRepo, conversation); err != nil { + ctx.ServerError("DeleteConversationByID", err) + return + } + + ctx.Redirect(fmt.Sprintf("%s/conversations", ctx.Repo.Repository.Link()), http.StatusSeeOther) +} + +func conversationGetBranchData(ctx *context.Context, conversation *conversations_model.Conversation) { + ctx.Data["BaseBranch"] = nil + ctx.Data["HeadBranch"] = nil + ctx.Data["HeadUserName"] = nil + ctx.Data["BaseName"] = ctx.Repo.Repository.OwnerName +} + +// ViewConversation render conversation view page +func ViewConversation(ctx *context.Context) { + if ctx.PathParam(":type") == "conversations" { + // If conversation was requested we check if repo has external tracker and redirect + extConversationUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalTracker) + if err == nil && extConversationUnit != nil { + if extConversationUnit.ExternalTrackerConfig().ExternalTrackerStyle == markup.IssueNameStyleNumeric || extConversationUnit.ExternalTrackerConfig().ExternalTrackerStyle == "" { + metas := ctx.Repo.Repository.ComposeMetas(ctx) + metas["index"] = ctx.PathParam(":index") + res, err := vars.Expand(extConversationUnit.ExternalTrackerConfig().ExternalTrackerFormat, metas) + if err != nil { + log.Error("unable to expand template vars for conversation url. conversation: %s, err: %v", metas["index"], err) + ctx.ServerError("Expand", err) + return + } + ctx.Redirect(res) + return + } + } else if err != nil && !repo_model.IsErrUnitTypeNotExist(err) { + ctx.ServerError("GetUnit", err) + return + } + } + + conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + if conversations_model.IsErrConversationNotExist(err) { + ctx.NotFound("GetConversationByIndex", err) + } else { + ctx.ServerError("GetConversationByIndex", err) + } + return + } + if conversation.Repo == nil { + conversation.Repo = ctx.Repo.Repository + } + + ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled + upload.AddUploadContext(ctx, "comment") + + if err = conversation.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return + } + + repo := ctx.Repo.Repository + + if ctx.IsSigned { + // Update conversation-user. + if err = activities_model.SetConversationReadBy(ctx, conversation.ID, ctx.Doer.ID); err != nil { + ctx.ServerError("ReadBy", err) + return + } + } + + var ( + role conversations_model.RoleDescriptor + ok bool + marked = make(map[int64]conversations_model.RoleDescriptor) + comment *conversations_model.Comment + participants = make([]*user_model.User, 1, 10) + latestCloseCommentID int64 + ) + + // Check if the user can use the dependencies + //ctx.Data["CanCreateConversationDependencies"] = ctx.Repo.CanCreateConversationDependencies(ctx, ctx.Doer, conversation.IsPull) + + // check if dependencies can be created across repositories + ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies + + if err := conversation.Comments.LoadAttachmentsByConversation(ctx); err != nil { + ctx.ServerError("LoadAttachmentsByConversation", err) + return + } + if err := conversation.Comments.LoadPosters(ctx); err != nil { + ctx.ServerError("LoadPosters", err) + return + } + + for _, comment = range conversation.Comments { + comment.Conversation = conversation + + if comment.Type == conversations_model.CommentTypeComment { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Repo: ctx.Repo.Repository, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + // Check tag. + role, ok = marked[comment.PosterID] + if ok { + comment.ShowRole = role + continue + } + + comment.ShowRole, err = conversationRoleDescriptor(ctx, repo, comment.Poster, conversation, comment.HasOriginalAuthor()) + if err != nil { + ctx.ServerError("roleDescriptor", err) + return + } + marked[comment.PosterID] = comment.ShowRole + participants = addParticipant(comment.Poster, participants) + } + } + + ctx.Data["LatestCloseCommentID"] = latestCloseCommentID + + ctx.Data["Participants"] = participants + ctx.Data["NumParticipants"] = len(participants) + ctx.Data["Conversation"] = conversation + ctx.Data["IsConversation"] = true + ctx.Data["Comments"] = conversation.Comments + ctx.Data["SignInLink"] = setting.AppSubURL + "/user/login?redirect_to=" + url.QueryEscape(ctx.Data["Link"].(string)) + ctx.Data["HasConversationsOrPullsWritePermission"] = ctx.Repo.CanWriteConversations() + ctx.Data["HasProjectsWritePermission"] = ctx.Repo.CanWrite(unit.TypeProjects) + ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) + ctx.Data["LockReasons"] = setting.Repository.Conversation.LockReasons + + var hiddenCommentTypes *big.Int + if ctx.IsSigned { + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + } + ctx.Data["ShouldShowCommentType"] = func(commentType conversations_model.CommentType) bool { + return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 + } + // For sidebar + PrepareBranchList(ctx) + + if ctx.Written() { + return + } + + tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID) + if err != nil { + ctx.ServerError("GetTagNamesByRepoID", err) + return + } + ctx.Data["Tags"] = tags + + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + + ctx.HTML(http.StatusOK, tplConversationView) +} + +// GetActionConversation will return the conversation which is used in the context. +func GetActionConversation(ctx *context.Context) *conversations_model.Conversation { + conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + ctx.NotFoundOrServerError("GetConversationByIndex", conversations_model.IsErrConversationNotExist, err) + return nil + } + conversation.Repo = ctx.Repo.Repository + checkConversationRights(ctx, conversation) + if ctx.Written() { + return nil + } + if err = conversation.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + return conversation +} + +func checkConversationRights(ctx *context.Context, conversation *conversations_model.Conversation) { + if !ctx.Repo.CanRead(unit.TypeConversations) { + ctx.NotFound("ConversationOrPullRequestUnitNotAllowed", nil) + } +} + +func getActionConversations(ctx *context.Context) conversations_model.ConversationList { + commaSeparatedConversationIDs := ctx.FormString("conversation_ids") + if len(commaSeparatedConversationIDs) == 0 { + return nil + } + conversationIDs := make([]int64, 0, 10) + for _, stringConversationID := range strings.Split(commaSeparatedConversationIDs, ",") { + conversationID, err := strconv.ParseInt(stringConversationID, 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return nil + } + conversationIDs = append(conversationIDs, conversationID) + } + conversations, err := conversations_model.GetConversationsByIDs(ctx, conversationIDs) + if err != nil { + ctx.ServerError("GetConversationsByIDs", err) + return nil + } + // Check access rights for all conversations + conversationUnitEnabled := ctx.Repo.CanRead(unit.TypeConversations) + for _, conversation := range conversations { + if conversation.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("some conversation's RepoID is incorrect", errors.New("some conversation's RepoID is incorrect")) + return nil + } + if !conversationUnitEnabled { + ctx.NotFound("ConversationUnitNotAllowed", nil) + return nil + } + if err = conversation.LoadAttributes(ctx); err != nil { + ctx.ServerError("LoadAttributes", err) + return nil + } + } + return conversations +} + +// GetConversationInfo get an conversation of a repository +func GetConversationInfo(ctx *context.Context) { + conversation, err := conversations_model.GetConversationWithAttrsByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + if conversations_model.IsErrConversationNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.Error(http.StatusInternalServerError, "GetConversationByIndex", err.Error()) + } + return + } + + // Need to check if Conversations are enabled and we can read Conversations + if !ctx.Repo.CanRead(unit.TypeConversations) { + ctx.Error(http.StatusNotFound) + return + } + + ctx.JSON(http.StatusOK, map[string]any{ + "convertedConversation": convert.ToConversation(ctx, ctx.Doer, conversation), + }) +} + +// SearchConversations searches for conversations across the repositories that the user has access to +func SearchConversations(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + return + } + + var isClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isClosed = optional.Some(true) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + var ( + repoIDs []int64 + allPublic bool + ) + { + // find repos user can access (for conversation search) + opts := &repo_model.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: optional.None[bool](), + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return + } + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = optional.Some(false) + } + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + return + } + team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return + } + opts.TeamID = team.ID + } + + if opts.AllPublic { + allPublic = true + opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer + } + repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) + return + } + if len(repoIDs) == 0 { + // no repos found, don't let the indexer return all repos + repoIDs = []int64{0} + } + } + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + + // this api is also used in UI, + // so the default limit is set to fit UI needs + limit := ctx.FormInt("limit") + if limit == 0 { + limit = setting.UI.ConversationPagingNum + } else if limit > setting.API.MaxResponseItems { + limit = setting.API.MaxResponseItems + } + + searchOpt := &conversation_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: limit, + }, + Keyword: keyword, + RepoIDs: repoIDs, + AllPublic: allPublic, + IsClosed: isClosed, + SortBy: conversation_indexer.SortByCreatedDesc, + } + + if since != 0 { + searchOpt.UpdatedAfterUnix = optional.Some(since) + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(before) + } + + if ctx.IsSigned { + ctxUserID := ctx.Doer.ID + if ctx.FormBool("created") { + searchOpt.PosterID = optional.Some(ctxUserID) + } + if ctx.FormBool("assigned") { + searchOpt.AssigneeID = optional.Some(ctxUserID) + } + if ctx.FormBool("mentioned") { + searchOpt.MentionID = optional.Some(ctxUserID) + } + if ctx.FormBool("review_requested") { + searchOpt.ReviewRequestedID = optional.Some(ctxUserID) + } + if ctx.FormBool("reviewed") { + searchOpt.ReviewedID = optional.Some(ctxUserID) + } + } + + // FIXME: It's unsupported to sort by priority repo when searching by indexer, + // it's indeed an regression, but I think it is worth to support filtering by indexer first. + _ = ctx.FormInt64("priority_repo_id") + + ids, total, err := conversation_indexer.SearchConversations(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchConversations", err.Error()) + return + } + conversations, err := conversations_model.GetConversationsByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindConversationsByIDs", err.Error()) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToConversationList(ctx, ctx.Doer, conversations)) +} + +// ListConversations list the conversations of a repository +func ListConversations(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + return + } + + var isClosed optional.Option[bool] + switch ctx.FormString("state") { + case "closed": + isClosed = optional.Some(true) + case "all": + isClosed = optional.None[bool]() + default: + isClosed = optional.Some(false) + } + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + + projectID := optional.None[int64]() + if v := ctx.FormInt64("project"); v > 0 { + projectID = optional.Some(v) + } + + isPull := optional.None[bool]() + switch ctx.FormString("type") { + case "pulls": + isPull = optional.Some(true) + case "conversations": + isPull = optional.Some(false) + } + + // FIXME: we should be more efficient here + createdByID := getUserIDForFilter(ctx, "created_by") + if ctx.Written() { + return + } + assignedByID := getUserIDForFilter(ctx, "assigned_by") + if ctx.Written() { + return + } + mentionedByID := getUserIDForFilter(ctx, "mentioned_by") + if ctx.Written() { + return + } + + searchOpt := &conversation_indexer.SearchOptions{ + Paginator: &db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsPull: isPull, + IsClosed: isClosed, + ProjectID: projectID, + SortBy: conversation_indexer.SortByCreatedDesc, + } + if since != 0 { + searchOpt.UpdatedAfterUnix = optional.Some(since) + } + if before != 0 { + searchOpt.UpdatedBeforeUnix = optional.Some(before) + } + if createdByID > 0 { + searchOpt.PosterID = optional.Some(createdByID) + } + if assignedByID > 0 { + searchOpt.AssigneeID = optional.Some(assignedByID) + } + if mentionedByID > 0 { + searchOpt.MentionID = optional.Some(mentionedByID) + } + + ids, total, err := conversation_indexer.SearchConversations(ctx, searchOpt) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchConversations", err.Error()) + return + } + conversations, err := conversations_model.GetConversationsByIDs(ctx, ids, true) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindConversationsByIDs", err.Error()) + return + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, convert.ToConversationList(ctx, ctx.Doer, conversations)) +} + +func BatchDeleteConversations(ctx *context.Context) { + conversations := getActionConversations(ctx) + if ctx.Written() { + return + } + for _, conversation := range conversations { + if err := conversation_service.DeleteConversation(ctx, ctx.Doer, ctx.Repo.GitRepo, conversation); err != nil { + ctx.ServerError("DeleteConversation", err) + return + } + } + ctx.JSONOK() +} + +// NewComment create a comment for conversation +func NewConversationComment(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.CreateCommentForm) + conversation := GetActionConversation(ctx) + if ctx.Written() { + return + } + + if !ctx.IsSigned || (!ctx.Repo.CanReadConversations()) { + if log.IsTrace() { + if ctx.IsSigned { + conversationType := "conversations" + log.Trace("Permission Denied: User %-v not the Poster (ID: %d) and cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + conversationType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if conversation.IsLocked && !ctx.Repo.CanWriteConversations() && !ctx.Doer.IsAdmin { + ctx.JSONError(ctx.Tr("repo.conversations.comment_on_locked")) + return + } + + var attachments []string + if setting.Attachment.Enabled { + attachments = form.Files + } + + if ctx.HasError() { + ctx.JSONError(ctx.GetErrMsg()) + return + } + + var comment *conversations_model.Comment + defer func() { + + // Redirect to comment hashtag if there is any actual content. + typeName := "commits" + if comment != nil { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha, comment.HashTag())) + } else { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, conversation.CommitSha)) + } + }() + + // Fix #321: Allow empty comments, as long as we have attachments. + if len(form.Content) == 0 && len(attachments) == 0 { + return + } + + comment, err := conversation_service.CreateConversationComment(ctx, ctx.Doer, ctx.Repo.Repository, conversation, form.Content, attachments) + if err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.conversations.comment.blocked_user")) + } else { + ctx.ServerError("CreateConversationComment", err) + } + return + } + + log.Trace("Comment created: %d/%d/%d", ctx.Repo.Repository.ID, conversation.ID, comment.ID) +} + +// UpdateCommentContent change comment of conversation's content +func UpdateConversationCommentContent(ctx *context.Context) { + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadConversation(ctx); err != nil { + ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err) + return + } + + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteConversations()) { + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + oldContent := comment.Content + newContent := ctx.FormString("content") + contentVersion := ctx.FormInt("content_version") + + // allow to save empty content + comment.Content = newContent + if err = conversation_service.UpdateComment(ctx, comment, contentVersion, ctx.Doer, oldContent); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.JSONError(ctx.Tr("repo.conversations.comment.blocked_user")) + } else if errors.Is(err, conversations_model.ErrCommentAlreadyChanged) { + ctx.JSONError(ctx.Tr("repo.comments.edit.already_changed")) + } else { + ctx.ServerError("UpdateComment", err) + } + return + } + + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + + // when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates + if !ctx.FormBool("ignore_attachments") { + if err := updateAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil { + ctx.ServerError("UpdateAttachments", err) + return + } + } + + var renderedContent template.HTML + if comment.Content != "" { + renderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.FormString("context"), // FIXME: <- IS THIS SAFE ? + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Repo: ctx.Repo.Repository, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } else { + contentEmpty := fmt.Sprintf(`%s`, ctx.Tr("repo.conversations.no_content")) + renderedContent = template.HTML(contentEmpty) + } + + ctx.JSON(http.StatusOK, map[string]any{ + "content": renderedContent, + "contentVersion": comment.ContentVersion, + "attachments": attachmentsHTML(ctx, comment.Attachments, comment.Content), + }) +} + +// DeleteComment delete comment of conversation +func DeleteConversationComment(ctx *context.Context) { + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadConversation(ctx); err != nil { + ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err) + return + } + + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanWriteConversations()) { + ctx.Error(http.StatusForbidden) + return + } else if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + if err = conversation_service.DeleteComment(ctx, ctx.Doer, comment); err != nil { + ctx.ServerError("DeleteComment", err) + return + } + + ctx.Status(http.StatusOK) +} + +// ChangeCommentReaction create a reaction for comment +func ChangeConversationCommentReaction(ctx *context.Context) { + form := web.GetForm(ctx).(*forms.ReactionForm) + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadConversation(ctx); err != nil { + ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err) + return + } + + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{}) + return + } + + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadConversations()) { + if log.IsTrace() { + if ctx.IsSigned { + conversationType := "conversations" + log.Trace("Permission Denied: User %-v cannot read %s in Repo %-v.\n"+ + "User in Repo has Permissions: %-+v", + ctx.Doer, + conversationType, + ctx.Repo.Repository, + ctx.Repo.Permission) + } else { + log.Trace("Permission Denied: Not logged in") + } + } + + ctx.Error(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Error(http.StatusNoContent) + return + } + + switch ctx.PathParam(":action") { + case "react": + reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) + if err != nil { + if conversations_model.IsErrForbiddenConversationReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { + ctx.ServerError("ChangeConversationReaction", err) + return + } + log.Info("CreateCommentReaction: %s", err) + break + } + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) + break + } + + log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID, reaction.ID) + case "unreact": + if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Conversation.ID, comment.ID, form.Content); err != nil { + ctx.ServerError("DeleteCommentReaction", err) + return + } + + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) + break + } + + log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID) + default: + ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam(":action")), nil) + return + } + + if len(comment.Reactions) == 0 { + ctx.JSON(http.StatusOK, map[string]any{ + "empty": true, + "html": "", + }) + return + } + + html, err := ctx.RenderToHTML(tplReactions, map[string]any{ + "ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), + "Reactions": comment.Reactions.GroupByType(), + }) + if err != nil { + ctx.ServerError("ChangeCommentReaction.HTMLString", err) + return + } + ctx.JSON(http.StatusOK, map[string]any{ + "html": html, + }) +} + +// GetConversationAttachments returns attachments for the conversation +func GetConversationAttachments(ctx *context.Context) { + conversation := GetActionConversation(ctx) + if ctx.Written() { + return + } + attachments := make([]*api.Attachment, len(conversation.Attachments)) + for i := 0; i < len(conversation.Attachments); i++ { + attachments[i] = convert.ToAttachment(ctx.Repo.Repository, conversation.Attachments[i]) + } + ctx.JSON(http.StatusOK, attachments) +} + +// GetCommentAttachments returns attachments for the comment +func GetConversationCommentAttachments(ctx *context.Context) { + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err) + return + } + + if err := comment.LoadConversation(ctx); err != nil { + ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err) + return + } + + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{}) + return + } + + if !ctx.Repo.Permission.CanReadConversations() { + ctx.NotFound("CanReadConversationsOrPulls", conversations_model.ErrCommentNotExist{}) + return + } + + if !comment.Type.HasAttachmentSupport() { + ctx.ServerError("GetCommentAttachments", fmt.Errorf("comment type %v does not support attachments", comment.Type)) + return + } + + attachments := make([]*api.Attachment, 0) + if err := comment.LoadAttachments(ctx); err != nil { + ctx.ServerError("LoadAttachments", err) + return + } + for i := 0; i < len(comment.Attachments); i++ { + attachments = append(attachments, convert.ToAttachment(ctx.Repo.Repository, comment.Attachments[i])) + } + ctx.JSON(http.StatusOK, attachments) +} + +func updateConversationAttachments(ctx *context.Context, item any, files []string) error { + var attachments []*repo_model.Attachment + switch content := item.(type) { + case *conversations_model.Conversation: + attachments = content.Attachments + case *conversations_model.Comment: + attachments = content.Attachments + default: + return fmt.Errorf("unknown Type: %T", content) + } + for i := 0; i < len(attachments); i++ { + if util.SliceContainsString(files, attachments[i].UUID) { + continue + } + if err := repo_model.DeleteAttachment(ctx, attachments[i], true); err != nil { + return err + } + } + var err error + if len(files) > 0 { + switch content := item.(type) { + case *conversations_model.Conversation: + err = conversations_model.UpdateConversationAttachments(ctx, content.ID, files) + case *conversations_model.Comment: + err = content.UpdateAttachments(ctx, files) + default: + return fmt.Errorf("unknown Type: %T", content) + } + if err != nil { + return err + } + } + switch content := item.(type) { + case *conversations_model.Conversation: + content.Attachments, err = repo_model.GetAttachmentsByConversationID(ctx, content.ID) + case *conversations_model.Comment: + content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID) + default: + return fmt.Errorf("unknown Type: %T", content) + } + return err +} + +// roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and conversation +func conversationRoleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, conversation *conversations_model.Conversation, hasOriginalAuthor bool) (conversations_model.RoleDescriptor, error) { + roleDescriptor := conversations_model.RoleDescriptor{} + + if hasOriginalAuthor { + return roleDescriptor, nil + } + + perm, err := access_model.GetUserRepoPermission(ctx, repo, poster) + if err != nil { + return roleDescriptor, err + } + + // If the poster is the actual poster of the conversation, enable Poster role. + roleDescriptor.IsPoster = false + + // Check if the poster is owner of the repo. + if perm.IsOwner() { + // If the poster isn't an admin, enable the owner role. + if !poster.IsAdmin { + roleDescriptor.RoleInRepo = conversations_model.RoleRepoOwner + return roleDescriptor, nil + } + + // Otherwise check if poster is the real repo admin. + ok, err := access_model.IsUserRealRepoAdmin(ctx, repo, poster) + if err != nil { + return roleDescriptor, err + } + if ok { + roleDescriptor.RoleInRepo = conversations_model.RoleRepoOwner + return roleDescriptor, nil + } + } + + // If repo is organization, check Member role + if err := repo.LoadOwner(ctx); err != nil { + return roleDescriptor, err + } + if repo.Owner.IsOrganization() { + if isMember, err := organization.IsOrganizationMember(ctx, repo.Owner.ID, poster.ID); err != nil { + return roleDescriptor, err + } else if isMember { + roleDescriptor.RoleInRepo = conversations_model.RoleRepoMember + return roleDescriptor, nil + } + } + + // If the poster is the collaborator of the repo + if isCollaborator, err := repo_model.IsCollaborator(ctx, repo.ID, poster.ID); err != nil { + return roleDescriptor, err + } else if isCollaborator { + roleDescriptor.RoleInRepo = conversations_model.RoleRepoCollaborator + return roleDescriptor, nil + } + + return roleDescriptor, nil +} diff --git a/services/conversation/comments.go b/services/conversation/comments.go new file mode 100644 index 0000000000000..15958adf5fac0 --- /dev/null +++ b/services/conversation/comments.go @@ -0,0 +1,99 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversation + +import ( + "context" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/timeutil" +) + +// CreateConversationComment creates a plain conversation comment. +func CreateConversationComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, conversation *conversations_model.Conversation, content string, attachments []string) (*conversations_model.Comment, error) { + if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin { + return nil, user_model.ErrBlockedUser + } + } + + comment, err := conversations_model.CreateComment(ctx, &conversations_model.CreateCommentOptions{ + Type: conversations_model.CommentTypeComment, + Doer: doer, + Repo: repo, + Conversation: conversation, + Content: content, + Attachments: attachments, + }) + if err != nil { + return nil, err + } + + //notify_service.CreateConversationComment(ctx, doer, repo, conversation, comment, mentions) + + return comment, nil +} + +// UpdateComment updates information of comment. +func UpdateComment(ctx context.Context, c *conversations_model.Comment, contentVersion int, doer *user_model.User, oldContent string) error { + if err := c.LoadConversation(ctx); err != nil { + return err + } + if err := c.Conversation.LoadRepo(ctx); err != nil { + return err + } + + if user_model.IsUserBlockedBy(ctx, doer, c.Conversation.Repo.OwnerID) { + if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, c.Conversation.Repo, doer); !isAdmin { + return user_model.ErrBlockedUser + } + } + + needsContentHistory := c.Content != oldContent && c.Type.HasContentSupport() + if needsContentHistory { + hasContentHistory, err := conversations_model.HasConversationContentHistory(ctx, c.ConversationID, c.ID) + if err != nil { + return err + } + if !hasContentHistory { + if err = conversations_model.SaveConversationContentHistory(ctx, c.PosterID, c.ConversationID, c.ID, + c.CreatedUnix, oldContent, true); err != nil { + return err + } + } + } + + if err := conversations_model.UpdateComment(ctx, c, contentVersion, doer); err != nil { + return err + } + + if needsContentHistory { + err := conversations_model.SaveConversationContentHistory(ctx, doer.ID, c.ConversationID, c.ID, timeutil.TimeStampNow(), c.Content, false) + if err != nil { + return err + } + } + + //notify_service.UpdateComment(ctx, doer, c, oldContent) + + return nil +} + +// DeleteComment deletes the comment +func DeleteComment(ctx context.Context, doer *user_model.User, comment *conversations_model.Comment) error { + err := db.WithTx(ctx, func(ctx context.Context) error { + return conversations_model.DeleteComment(ctx, comment) + }) + if err != nil { + return err + } + + //notify_service.DeleteComment(ctx, doer, comment) + + return nil +} diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go new file mode 100644 index 0000000000000..2e83d7a152c89 --- /dev/null +++ b/services/conversation/conversation.go @@ -0,0 +1,99 @@ +// Copyright 2019 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversation + +import ( + "context" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + system_model "code.gitea.io/gitea/models/system" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/storage" +) + +// NewConversation creates new conversation with labels for repository. +func NewConversation(ctx context.Context, repo *repo_model.Repository, uuids []string, conversation *conversations_model.Conversation) error { + + if err := db.WithTx(ctx, func(ctx context.Context) error { + return conversations_model.NewConversation(ctx, repo, conversation, uuids) + }); err != nil { + return err + } + + //notify_service.NewConversation(ctx, conversation, mentions) + + return nil +} + +// DeleteConversation deletes an conversation +func DeleteConversation(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, conversation *conversations_model.Conversation) error { + // load conversation before deleting it + if err := conversation.LoadAttributes(ctx); err != nil { + return err + } + + // delete entries in database + if err := deleteConversation(ctx, conversation); err != nil { + return err + } + + //notify_service.DeleteConversation(ctx, doer, conversation) + + return nil +} + +// deleteConversation deletes the conversation +func deleteConversation(ctx context.Context, conversation *conversations_model.Conversation) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + e := db.GetEngine(ctx) + if _, err := e.ID(conversation.ID).NoAutoCondition().Delete(conversation); err != nil { + return err + } + + // update the total conversation numbers + if err := repo_model.UpdateRepoConversationNumbers(ctx, conversation.RepoID, false); err != nil { + return err + } + // if the conversation is closed, update the closed conversation numbers + if conversation.IsLocked { + if err := repo_model.UpdateRepoConversationNumbers(ctx, conversation.RepoID, true); err != nil { + return err + } + } + + // find attachments related to this conversation and remove them + if err := conversation.LoadAttributes(ctx); err != nil { + return err + } + + for i := range conversation.Attachments { + system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete conversation attachment", conversation.Attachments[i].RelativePath()) + } + + // delete all database data still assigned to this conversation + if err := db.DeleteBeans(ctx, + &conversations_model.ConversationContentHistory{ConversationID: conversation.ID}, + &conversations_model.Comment{ConversationID: conversation.ID}, + &conversations_model.ConversationDependency{ConversationID: conversation.ID}, + &conversations_model.ConversationUser{ConversationID: conversation.ID}, + //&activities_model.Notification{ConversationID: conversation.ID}, + &conversations_model.Reaction{ConversationID: conversation.ID}, + &repo_model.Attachment{ConversationID: conversation.ID}, + &conversations_model.Comment{ConversationID: conversation.ID}, + &conversations_model.ConversationDependency{DependencyID: conversation.ID}, + &conversations_model.Comment{DependentConversationID: conversation.ID}, + ); err != nil { + return err + } + + return committer.Commit() +} diff --git a/services/conversation/reaction.go b/services/conversation/reaction.go new file mode 100644 index 0000000000000..22524281fa74f --- /dev/null +++ b/services/conversation/reaction.go @@ -0,0 +1,33 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversation + +import ( + "context" + + conversations_model "code.gitea.io/gitea/models/conversations" + user_model "code.gitea.io/gitea/models/user" +) + +// CreateCommentReaction creates a reaction on a comment. +func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *conversations_model.Comment, content string) (*conversations_model.Reaction, error) { + if err := comment.LoadConversation(ctx); err != nil { + return nil, err + } + + if err := comment.Conversation.LoadRepo(ctx); err != nil { + return nil, err + } + + if user_model.IsUserBlockedBy(ctx, doer, comment.Conversation.Repo.OwnerID, comment.PosterID) { + return nil, user_model.ErrBlockedUser + } + + return conversations_model.CreateReaction(ctx, &conversations_model.ReactionOptions{ + Type: content, + DoerID: doer.ID, + ConversationID: comment.Conversation.ID, + CommentID: comment.ID, + }) +} diff --git a/services/convert/conversation.go b/services/convert/conversation.go new file mode 100644 index 0000000000000..9e7e963073e6e --- /dev/null +++ b/services/convert/conversation.go @@ -0,0 +1,83 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + conversations_model "code.gitea.io/gitea/models/conversations" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" +) + +func ToConversation(ctx context.Context, doer *user_model.User, conversation *conversations_model.Conversation) *api.Conversation { + return toConversation(ctx, doer, conversation, WebAssetDownloadURL) +} + +// ToAPIConversation converts an Conversation to API format +// it assumes some fields assigned with values: +// Required - Poster, Labels, +// Optional - Milestone, Assignee, PullRequest +func ToAPIConversation(ctx context.Context, doer *user_model.User, conversation *conversations_model.Conversation) *api.Conversation { + return toConversation(ctx, doer, conversation, APIAssetDownloadURL) +} + +func toConversation(ctx context.Context, doer *user_model.User, conversation *conversations_model.Conversation, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Conversation { + if err := conversation.LoadRepo(ctx); err != nil { + return &api.Conversation{} + } + if err := conversation.LoadAttachments(ctx); err != nil { + return &api.Conversation{} + } + + apiConversation := &api.Conversation{ + ID: conversation.ID, + Index: conversation.Index, + Attachments: toAttachments(conversation.Repo, conversation.Attachments, getDownloadURL), + IsLocked: conversation.IsLocked, + Comments: conversation.NumComments, + Created: conversation.CreatedUnix.AsTime(), + Updated: conversation.UpdatedUnix.AsTime(), + } + + if conversation.Repo != nil { + if err := conversation.Repo.LoadOwner(ctx); err != nil { + return &api.Conversation{} + } + apiConversation.URL = conversation.APIURL(ctx) + apiConversation.HTMLURL = conversation.HTMLURL() + + apiConversation.Repo = &api.RepositoryMeta{ + ID: conversation.Repo.ID, + Name: conversation.Repo.Name, + Owner: conversation.Repo.OwnerName, + FullName: conversation.Repo.FullName(), + } + } + + if conversation.LockedUnix != 0 { + apiConversation.Locked = conversation.LockedUnix.AsTimePtr() + } + + return apiConversation +} + +// ToConversationList converts an ConversationList to API format +func ToConversationList(ctx context.Context, doer *user_model.User, il conversations_model.ConversationList) []*api.Conversation { + result := make([]*api.Conversation, len(il)) + for i := range il { + result[i] = ToConversation(ctx, doer, il[i]) + } + return result +} + +// ToAPIConversationList converts an ConversationList to API format +func ToAPIConversationList(ctx context.Context, doer *user_model.User, il conversations_model.ConversationList) []*api.Conversation { + result := make([]*api.Conversation, len(il)) + for i := range il { + result[i] = ToAPIConversation(ctx, doer, il[i]) + } + return result +} From c55ca5df7943ad20ac5c285ee415a6ce3453a52e Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 25 Oct 2024 22:05:56 +0800 Subject: [PATCH 03/72] Allow creation of commit comments --- models/conversations/comment.go | 18 +- models/conversations/conversation.go | 11 +- models/issues/comment.go | 4 +- modules/structs/issue_comment.go | 24 +- routers/api/v1/api.go | 25 + routers/api/v1/repo/conversation_comment.go | 708 ++++++++++++++++++ routers/web/repo/commit.go | 41 + routers/web/repo/conversation.go | 14 +- routers/web/web.go | 8 + services/conversation/comments.go | 13 +- services/convert/conversation_comment.go | 45 ++ services/forms/repo_form.go | 6 + templates/repo/commit_page.tmpl | 2 +- templates/repo/conversation/comment_form.tmpl | 6 +- templates/repo/conversation/comments.tmpl | 2 +- templates/repo/conversation/context_menu.tmpl | 44 ++ 16 files changed, 946 insertions(+), 25 deletions(-) create mode 100644 routers/api/v1/repo/conversation_comment.go create mode 100644 services/convert/conversation_comment.go create mode 100644 templates/repo/conversation/context_menu.tmpl diff --git a/models/conversations/comment.go b/models/conversations/comment.go index 088a9486443e8..1a3e8b3019224 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -281,6 +281,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, Type: opts.Type, PosterID: opts.Doer.ID, Poster: opts.Doer, + Content: opts.Content, ConversationID: opts.ConversationID, } if _, err = e.Insert(comment); err != nil { @@ -367,7 +368,7 @@ func (opts FindCommentsOptions) ToConds() builder.Cond { func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) { comments := make([]*Comment, 0, 10) sess := db.GetEngine(ctx).Where(opts.ToConds()) - if opts.RepoID > 0 || opts.IsPull.Has() { + if opts.RepoID > 0 { sess.Join("INNER", "conversation", "conversation.id = comment.conversation_id") } @@ -568,3 +569,18 @@ func (c *Comment) APIURL(ctx context.Context) string { func (c *Comment) HasOriginalAuthor() bool { return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 } + +func (c *Comment) ConversationURL(ctx context.Context) string { + err := c.LoadConversation(ctx) + if err != nil { // Silently dropping errors :unamused: + log.Error("LoadConversation(%d): %v", c.ConversationID, err) + return "" + } + + err = c.Conversation.LoadRepo(ctx) + if err != nil { // Silently dropping errors :unamused: + log.Error("loadRepo(%d): %v", c.Conversation.RepoID, err) + return "" + } + return c.Conversation.HTMLURL() +} diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index bcd7ffb34ffe3..da41b4e454887 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -138,7 +138,11 @@ func (conversation *Conversation) Link() string { } func (conversation *Conversation) loadComments(ctx context.Context) (err error) { - return conversation.loadCommentsByType(ctx, CommentTypeUndefined) + conversation.Comments, err = FindComments(ctx, &FindCommentsOptions{ + ConversationID: conversation.ID, + }) + + return err } func (conversation *Conversation) loadCommentsByType(ctx context.Context, tp CommentType) (err error) { @@ -278,6 +282,11 @@ func GetConversationByCommitID(ctx context.Context, commitID string) (*Conversat } else if !has { return nil, ErrConversationNotExist{0, 0, 0} } + err = conversation.LoadAttributes(ctx) + if err != nil { + return nil, err + } + return conversation, nil } diff --git a/models/issues/comment.go b/models/issues/comment.go index 48b8e335d48ef..9a769890889fa 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -281,8 +281,8 @@ type Comment struct { CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` - // Reference issue in commit message - CommitSHA string `xorm:"VARCHAR(64)"` + // Reference issue in commit mes `xorm:"VARCHAR(64)"`sage + CommitSHA string Attachments []*repo_model.Attachment `xorm:"-"` Reactions ReactionList `xorm:"-"` diff --git a/modules/structs/issue_comment.go b/modules/structs/issue_comment.go index 9e8f5c4bf3321..5152f6a15cd5d 100644 --- a/modules/structs/issue_comment.go +++ b/modules/structs/issue_comment.go @@ -13,6 +13,7 @@ type Comment struct { HTMLURL string `json:"html_url"` PRURL string `json:"pull_request_url"` IssueURL string `json:"issue_url"` + ConversationURL string `json:"conversation_url"` Poster *User `json:"user"` OriginalAuthor string `json:"original_author"` OriginalAuthorID int64 `json:"original_author_id"` @@ -36,16 +37,29 @@ type EditIssueCommentOption struct { Body string `json:"body" binding:"Required"` } +// CreateIssueCommentOption options for creating a comment on an issue +type CreateConversationCommentOption struct { + // required:true + Body string `json:"body" binding:"Required"` +} + +// EditIssueCommentOption options for editing a comment +type EditConversationCommentOption struct { + // required: true + Body string `json:"body" binding:"Required"` +} + // TimelineComment represents a timeline comment (comment of any type) on a commit or issue type TimelineComment struct { ID int64 `json:"id"` Type string `json:"type"` - HTMLURL string `json:"html_url"` - PRURL string `json:"pull_request_url"` - IssueURL string `json:"issue_url"` - Poster *User `json:"user"` - Body string `json:"body"` + HTMLURL string `json:"html_url"` + PRURL string `json:"pull_request_url"` + IssueURL string `json:"issue_url"` + ConversationURL string `json:"conversation_url"` + Poster *User `json:"user"` + Body string `json:"body"` // swagger:strfmt date-time Created time.Time `json:"created_at"` // swagger:strfmt date-time diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 883e694e44b75..71ce30e8d16b9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -667,6 +667,14 @@ func mustAllowPulls(ctx *context.APIContext) { } } +func mustEnableConversations(ctx *context.APIContext) { + if !ctx.Repo.CanRead(unit.TypeConversations) { + ctx.NotFound() + return + } + +} + func mustEnableIssuesOrPulls(ctx *context.APIContext) { if !ctx.Repo.CanRead(unit.TypeIssues) && !(ctx.Repo.Repository.CanEnablePulls() && ctx.Repo.CanRead(unit.TypePullRequests)) { @@ -1495,6 +1503,23 @@ func Routes() *web.Router { }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue)) + // Conversation + m.Group("/repos", func() { + m.Group("/{username}/{reponame}", func() { + m.Group("/conversations", func() { + m.Group("/{index}", func() { + m.Group("/comments", func() { + m.Combo("").Get(repo.ListConversationComments). + Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueCommentOption{}), repo.CreateIssueComment) + // m.Combo("/{id}", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated). + // Delete(repo.DeleteIssueCommentDeprecated) + }) + + }, mustEnableConversations) + }) + }) + }) + // NOTE: these are Gitea package management API - see packages.CommonRoutes and packages.DockerContainerRoutes for endpoints that implement package manager APIs m.Group("/packages/{username}", func() { m.Group("/{type}/{name}/{version}", func() { diff --git a/routers/api/v1/repo/conversation_comment.go b/routers/api/v1/repo/conversation_comment.go new file mode 100644 index 0000000000000..ba9a910841cb4 --- /dev/null +++ b/routers/api/v1/repo/conversation_comment.go @@ -0,0 +1,708 @@ +// Copyright 2015 The Gogs Authors. All rights reserved. +// Copyright 2020 The Gitea Authors. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "errors" + "net/http" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/optional" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/context" + conversation_service "code.gitea.io/gitea/services/conversation" + "code.gitea.io/gitea/services/convert" +) + +// ListConversationComments list all the comments of an conversation +func ListConversationComments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/conversations/{index}/comments conversation conversationGetComments + // --- + // summary: List all comments on an conversation + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the conversation + // type: integer + // format: int64 + // required: true + // - name: since + // in: query + // description: if provided, only comments updated since the specified time are returned. + // type: string + // format: date-time + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // responses: + // "200": + // "$ref": "#/responses/CommentList" + // "404": + // "$ref": "#/responses/notFound" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRawConversationByIndex", err) + return + } + if !ctx.Repo.CanReadConversations() { + ctx.NotFound() + return + } + + conversation.Repo = ctx.Repo.Repository + + opts := &conversations_model.FindCommentsOptions{ + ConversationID: conversation.ID, + Since: since, + Before: before, + Type: conversations_model.CommentTypeComment, + } + + comments, err := conversations_model.FindComments(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + totalCount, err := conversations_model.CountComments(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + if err := comments.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + if err := comments.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + + apiComments := make([]*api.Comment, len(comments)) + for i, comment := range comments { + comment.Conversation = conversation + apiComments[i] = convert.ConversationToAPIComment(ctx, ctx.Repo.Repository, comments[i]) + } + + ctx.SetTotalCountHeader(totalCount) + ctx.JSON(http.StatusOK, &apiComments) +} + +// ListConversationCommentsAndTimeline list all the comments and events of an conversation +func ListConversationCommentsAndTimeline(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/conversations/{index}/timeline conversation conversationGetCommentsAndTimeline + // --- + // summary: List all comments and events on an conversation + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the conversation + // type: integer + // format: int64 + // required: true + // - name: since + // in: query + // description: if provided, only comments updated since the specified time are returned. + // type: string + // format: date-time + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // responses: + // "200": + // "$ref": "#/responses/TimelineList" + // "404": + // "$ref": "#/responses/notFound" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetRawConversationByIndex", err) + return + } + conversation.Repo = ctx.Repo.Repository + + opts := &conversations_model.FindCommentsOptions{ + ListOptions: utils.GetListOptions(ctx), + ConversationID: conversation.ID, + Since: since, + Before: before, + Type: conversations_model.CommentTypeUndefined, + } + + comments, err := conversations_model.FindComments(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + if err := comments.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + var apiComments []*api.TimelineComment + for _, comment := range comments { + comment.Conversation = conversation + apiComments = append(apiComments, convert.ConversationCommentToTimelineComment(ctx, conversation.Repo, comment, ctx.Doer)) + } + + ctx.SetTotalCountHeader(int64(len(apiComments))) + ctx.JSON(http.StatusOK, &apiComments) +} + +// ListRepoConversationComments returns all conversation-comments for a repo +func ListRepoConversationComments(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/conversations/comments conversation conversationGetRepoComments + // --- + // summary: List all comments in a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: since + // in: query + // description: if provided, only comments updated since the provided time are returned. + // type: string + // format: date-time + // - name: before + // in: query + // description: if provided, only comments updated before the provided time are returned. + // type: string + // format: date-time + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/CommentList" + // "404": + // "$ref": "#/responses/notFound" + + before, since, err := context.GetQueryBeforeSince(ctx.Base) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) + return + } + + var isPull optional.Option[bool] + canReadConversation := ctx.Repo.CanRead(unit.TypeConversations) + canReadPull := ctx.Repo.CanRead(unit.TypePullRequests) + if canReadConversation && canReadPull { + isPull = optional.None[bool]() + } else if canReadConversation { + isPull = optional.Some(false) + } else if canReadPull { + isPull = optional.Some(true) + } else { + ctx.NotFound() + return + } + + opts := &conversations_model.FindCommentsOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + Type: conversations_model.CommentTypeComment, + Since: since, + Before: before, + IsPull: isPull, + } + + comments, err := conversations_model.FindComments(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "FindComments", err) + return + } + + totalCount, err := conversations_model.CountComments(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + if err = comments.LoadPosters(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadPosters", err) + return + } + + apiComments := make([]*api.Comment, len(comments)) + if err := comments.LoadConversations(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadConversations", err) + return + } + if err := comments.LoadAttachments(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadAttachments", err) + return + } + if _, err := comments.Conversations().LoadRepositories(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadRepositories", err) + return + } + for i := range comments { + apiComments[i] = convert.ConversationToAPIComment(ctx, ctx.Repo.Repository, comments[i]) + } + + ctx.SetTotalCountHeader(totalCount) + ctx.JSON(http.StatusOK, &apiComments) +} + +// CreateConversationComment create a comment for an conversation +func CreateConversationComment(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/conversations/{index}/comments conversation conversationCreateComment + // --- + // summary: Add a comment to an conversation + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: index of the conversation + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/CreateConversationCommentOption" + // responses: + // "201": + // "$ref": "#/responses/Comment" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.CreateConversationCommentOption) + conversation, err := conversations_model.GetConversationByIndex(ctx, ctx.Repo.Repository.ID, ctx.PathParamInt64(":index")) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetConversationByIndex", err) + return + } + + if !ctx.Repo.CanReadConversations() { + ctx.NotFound() + return + } + + if conversation.IsLocked && !ctx.Repo.CanWriteConversations() && !ctx.Doer.IsAdmin { + ctx.Error(http.StatusForbidden, "CreateConversationComment", errors.New(ctx.Locale.TrString("repo.conversations.comment_on_locked"))) + return + } + + comment, err := conversation_service.CreateConversationComment(ctx, ctx.Doer, ctx.Repo.Repository, conversation, form.Body, nil) + if err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "CreateConversationComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "CreateConversationComment", err) + } + return + } + + ctx.JSON(http.StatusCreated, convert.ConversationToAPIComment(ctx, ctx.Repo.Repository, comment)) +} + +// GetConversationComment Get a comment by ID +func GetConversationComment(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/conversations/comments/{id} conversation conversationGetComment + // --- + // summary: Get a comment + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Comment" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + if conversations_model.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetCommentByID", err) + } + return + } + + if err = comment.LoadConversation(ctx); err != nil { + ctx.InternalServerError(err) + return + } + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + return + } + + if !ctx.Repo.CanReadConversations() { + ctx.NotFound() + return + } + + if comment.Type != conversations_model.CommentTypeComment { + ctx.Status(http.StatusNoContent) + return + } + + if err := comment.LoadPoster(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "comment.LoadPoster", err) + return + } + + ctx.JSON(http.StatusOK, convert.ConversationToAPIComment(ctx, ctx.Repo.Repository, comment)) +} + +// EditConversationComment modify a comment of an conversation +func EditConversationComment(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/conversations/comments/{id} conversation conversationEditComment + // --- + // summary: Edit a comment + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditConversationCommentOption" + // responses: + // "200": + // "$ref": "#/responses/Comment" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + // "423": + // "$ref": "#/responses/repoArchivedError" + + form := web.GetForm(ctx).(*api.EditConversationCommentOption) + editConversationComment(ctx, *form) +} + +// EditConversationCommentDeprecated modify a comment of an conversation +func EditConversationCommentDeprecated(ctx *context.APIContext) { + // swagger:operation PATCH /repos/{owner}/{repo}/conversations/{index}/comments/{id} conversation conversationEditCommentDeprecated + // --- + // summary: Edit a comment + // deprecated: true + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: this parameter is ignored + // type: integer + // required: true + // - name: id + // in: path + // description: id of the comment to edit + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditConversationCommentOption" + // responses: + // "200": + // "$ref": "#/responses/Comment" + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + form := web.GetForm(ctx).(*api.EditConversationCommentOption) + editConversationComment(ctx, *form) +} + +func editConversationComment(ctx *context.APIContext, form api.EditConversationCommentOption) { + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + if conversations_model.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetCommentByID", err) + } + return + } + + if err := comment.LoadConversation(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadConversation", err) + return + } + + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + return + } + + if !ctx.IsSigned { + ctx.Status(http.StatusForbidden) + return + } + + if !comment.Type.HasContentSupport() { + ctx.Status(http.StatusNoContent) + return + } + + oldContent := comment.Content + comment.Content = form.Body + if err := conversation_service.UpdateComment(ctx, comment, comment.ContentVersion, ctx.Doer, oldContent); err != nil { + if errors.Is(err, user_model.ErrBlockedUser) { + ctx.Error(http.StatusForbidden, "UpdateComment", err) + } else { + ctx.Error(http.StatusInternalServerError, "UpdateComment", err) + } + return + } + + ctx.JSON(http.StatusOK, convert.ConversationToAPIComment(ctx, ctx.Repo.Repository, comment)) +} + +// DeleteConversationComment delete a comment from an conversation +func DeleteConversationComment(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/conversations/comments/{id} conversation conversationDeleteComment + // --- + // summary: Delete a comment + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: id + // in: path + // description: id of comment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + deleteConversationComment(ctx) +} + +// DeleteConversationCommentDeprecated delete a comment from an conversation +func DeleteConversationCommentDeprecated(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/conversations/{index}/comments/{id} conversation conversationDeleteCommentDeprecated + // --- + // summary: Delete a comment + // deprecated: true + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: index + // in: path + // description: this parameter is ignored + // type: integer + // required: true + // - name: id + // in: path + // description: id of comment to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + deleteConversationComment(ctx) +} + +func deleteConversationComment(ctx *context.APIContext) { + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + if err != nil { + if conversations_model.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetCommentByID", err) + } + return + } + + if err := comment.LoadConversation(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "LoadConversation", err) + return + } + + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + return + } + + if !ctx.IsSigned { + ctx.Status(http.StatusForbidden) + return + } else if comment.Type != conversations_model.CommentTypeComment { + ctx.Status(http.StatusNoContent) + return + } + + if err = conversation_service.DeleteComment(ctx, ctx.Doer, comment); err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteCommentByID", err) + return + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 54bfac198019d..2638634ab4b51 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "html/template" + "math/big" "net/http" "path" "strings" @@ -26,11 +27,13 @@ import ( "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" + "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/gitdiff" repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" ) const ( @@ -439,8 +442,46 @@ func Diff(ctx *context.Context) { } } + for _, comment := range conversation.Comments { + comment.Conversation = conversation + + if comment.Type == conversation_model.CommentTypeComment { + comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ + Links: markup.Links{ + Base: ctx.Repo.RepoLink, + }, + Metas: ctx.Repo.Repository.ComposeMetas(ctx), + GitRepo: ctx.Repo.GitRepo, + Repo: ctx.Repo.Repository, + Ctx: ctx, + }, comment.Content) + if err != nil { + ctx.ServerError("RenderString", err) + return + } + } + } + + ctx.Data["Conversation"] = conversation ctx.Data["Comments"] = conversation.Comments + ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { + return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) + } + + var hiddenCommentTypes *big.Int + if ctx.IsSigned { + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + } + ctx.Data["ShouldShowCommentType"] = func(commentType conversation_model.CommentType) bool { + return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 + } + ctx.HTML(http.StatusOK, tplCommitPage) } diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 3a9433789a666..fc8dc6e907b66 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -374,7 +374,7 @@ func DeleteConversation(ctx *context.Context) { ctx.Redirect(fmt.Sprintf("%s/conversations", ctx.Repo.Repository.Link()), http.StatusSeeOther) } -func conversationGetBranchData(ctx *context.Context, conversation *conversations_model.Conversation) { +func conversationGetBranchData(ctx *context.Context) { ctx.Data["BaseBranch"] = nil ctx.Data["HeadBranch"] = nil ctx.Data["HeadUserName"] = nil @@ -548,7 +548,7 @@ func GetActionConversation(ctx *context.Context) *conversations_model.Conversati return nil } conversation.Repo = ctx.Repo.Repository - checkConversationRights(ctx, conversation) + checkConversationRights(ctx) if ctx.Written() { return nil } @@ -559,9 +559,9 @@ func GetActionConversation(ctx *context.Context) *conversations_model.Conversati return conversation } -func checkConversationRights(ctx *context.Context, conversation *conversations_model.Conversation) { +func checkConversationRights(ctx *context.Context) { if !ctx.Repo.CanRead(unit.TypeConversations) { - ctx.NotFound("ConversationOrPullRequestUnitNotAllowed", nil) + ctx.NotFound("ConversationUnitNotAllowed", nil) } } @@ -891,7 +891,7 @@ func BatchDeleteConversations(ctx *context.Context) { // NewComment create a comment for conversation func NewConversationComment(ctx *context.Context) { - form := web.GetForm(ctx).(*forms.CreateCommentForm) + form := web.GetForm(ctx).(*forms.CreateConversationCommentForm) conversation := GetActionConversation(ctx) if ctx.Written() { return @@ -937,9 +937,9 @@ func NewConversationComment(ctx *context.Context) { // Redirect to comment hashtag if there is any actual content. typeName := "commits" if comment != nil { - ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d#%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha, comment.HashTag())) + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s#%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha, comment.HashTag())) } else { - ctx.JSONRedirect(fmt.Sprintf("%s/%s/%d", ctx.Repo.RepoLink, typeName, conversation.CommitSha)) + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha)) } }() diff --git a/routers/web/web.go b/routers/web/web.go index f28ec82c8f0b8..165909b985082 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1276,6 +1276,14 @@ func registerRoutes(m *web.Router) { }, reqSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones + m.Group("/{username}/{reponame}", func() { + m.Group("/conversations", func() { + m.Group("/{index}", func() { + m.Combo("/comments").Post(repo.ConversationMustAllowUserComment, web.Bind(forms.CreateConversationCommentForm{}), repo.NewConversationComment) + }, context.RepoMustNotBeArchived()) + }) + }, reqSignIn, context.RepoAssignment) + m.Group("/{username}/{reponame}", func() { // repo code m.Group("", func() { m.Group("", func() { diff --git a/services/conversation/comments.go b/services/conversation/comments.go index 15958adf5fac0..e94c575a44de5 100644 --- a/services/conversation/comments.go +++ b/services/conversation/comments.go @@ -23,12 +23,13 @@ func CreateConversationComment(ctx context.Context, doer *user_model.User, repo } comment, err := conversations_model.CreateComment(ctx, &conversations_model.CreateCommentOptions{ - Type: conversations_model.CommentTypeComment, - Doer: doer, - Repo: repo, - Conversation: conversation, - Content: content, - Attachments: attachments, + Type: conversations_model.CommentTypeComment, + Doer: doer, + Repo: repo, + Conversation: conversation, + ConversationID: conversation.ID, + Content: content, + Attachments: attachments, }) if err != nil { return nil, err diff --git a/services/convert/conversation_comment.go b/services/convert/conversation_comment.go new file mode 100644 index 0000000000000..b656c951662a7 --- /dev/null +++ b/services/convert/conversation_comment.go @@ -0,0 +1,45 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + conversations_model "code.gitea.io/gitea/models/conversations" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" +) + +// ToAPIComment converts a conversations_model.Comment to the api.Comment format for API usage +func ConversationToAPIComment(ctx context.Context, repo *repo_model.Repository, c *conversations_model.Comment) *api.Comment { + return &api.Comment{ + ID: c.ID, + Poster: ToUser(ctx, c.Poster, nil), + HTMLURL: c.HTMLURL(ctx), + ConversationURL: c.ConversationURL(ctx), + Body: c.Content, + Attachments: ToAPIAttachments(repo, c.Attachments), + Created: c.CreatedUnix.AsTime(), + Updated: c.UpdatedUnix.AsTime(), + } +} + +// ToTimelineComment converts a conversations_model.Comment to the api.TimelineComment format +func ConversationCommentToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *conversations_model.Comment, doer *user_model.User) *api.TimelineComment { + comment := &api.TimelineComment{ + ID: c.ID, + Type: c.Type.String(), + Poster: ToUser(ctx, c.Poster, nil), + HTMLURL: c.HTMLURL(ctx), + ConversationURL: c.ConversationURL(ctx), + Body: c.Content, + Created: c.CreatedUnix.AsTime(), + Updated: c.UpdatedUnix.AsTime(), + + RefCommitSHA: c.Conversation.CommitSha, + } + + return comment +} diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 988e479a48138..cadb48136b021 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -474,6 +474,12 @@ func (f *CreateCommentForm) Validate(req *http.Request, errs binding.Errors) bin return middleware.Validate(errs, ctx.Data, f, ctx.Locale) } +type CreateConversationCommentForm struct { + Content string + Files []string + CommitSha string +} + // ReactionForm form for adding and removing reaction type ReactionForm struct { Content string `binding:"Required"` diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 5399f4c01e8fb..f8de588497a2f 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -280,7 +280,7 @@
{{end}} {{template "repo/diff/box" .}} + {{template "repo/conversation/conversation" .}}
- {{template "repo/conversation/conversation" .}} {{template "base/footer" .}} diff --git a/templates/repo/conversation/comment_form.tmpl b/templates/repo/conversation/comment_form.tmpl index 147597e14f3d0..cc5e42a124b42 100644 --- a/templates/repo/conversation/comment_form.tmpl +++ b/templates/repo/conversation/comment_form.tmpl @@ -9,7 +9,11 @@
-
+ {{if .IsIssue}} + + {{else}} + + {{end}} {{template "repo/conversation/comment_tab" .}} {{.CsrfTokenHtml}}
diff --git a/templates/repo/conversation/context_menu.tmpl b/templates/repo/conversation/context_menu.tmpl new file mode 100644 index 0000000000000..9e38442c369fd --- /dev/null +++ b/templates/repo/conversation/context_menu.tmpl @@ -0,0 +1,44 @@ + From 637788bc2fa45d00fd9ae8d4484a5b8934e7d453 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 28 Oct 2024 16:33:51 +0800 Subject: [PATCH 04/72] Reimplement delete comment for conversations --- models/conversations/conversation_update.go | 2 +- models/conversations/reaction.go | 32 +++++----- options/locale/locale_en-US.ini | 2 + routers/web/web.go | 7 +++ services/conversation/conversation.go | 2 +- services/conversation/reaction.go | 2 +- templates/repo/commit_page.tmpl | 5 +- templates/repo/conversation/comments.tmpl | 3 +- templates/repo/conversation/context_menu.tmpl | 10 +++- web_src/js/features/repo-commit.ts | 58 +++++++++++++++++++ web_src/js/index.ts | 3 +- 11 files changed, 101 insertions(+), 25 deletions(-) diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go index 4d303708b5418..2588f972377dd 100644 --- a/models/conversations/conversation_update.go +++ b/models/conversations/conversation_update.go @@ -300,7 +300,7 @@ func DeleteConversationsByRepoID(ctx context.Context, repoID int64) (attachmentP return nil, err } - _, err = sess.In("conversation_id", conversationIDs).Delete(&Reaction{}) + _, err = sess.In("conversation_id", conversationIDs).Delete(&CommentReaction{}) if err != nil { return nil, err } diff --git a/models/conversations/reaction.go b/models/conversations/reaction.go index b697826bdc350..ca54082b157a3 100644 --- a/models/conversations/reaction.go +++ b/models/conversations/reaction.go @@ -57,8 +57,8 @@ func (err ErrReactionAlreadyExist) Unwrap() error { return util.ErrAlreadyExist } -// Reaction represents a reactions on conversations and comments. -type Reaction struct { +// CommentReaction represents a reactions on conversations and comments. +type CommentReaction struct { ID int64 `xorm:"pk autoincr"` Type string `xorm:"INDEX UNIQUE(s) NOT NULL"` ConversationID int64 `xorm:"INDEX UNIQUE(s) NOT NULL"` @@ -71,7 +71,7 @@ type Reaction struct { } // LoadUser load user of reaction -func (r *Reaction) LoadUser(ctx context.Context) (*user_model.User, error) { +func (r *CommentReaction) LoadUser(ctx context.Context) (*user_model.User, error) { if r.User != nil { return r.User, nil } @@ -84,7 +84,7 @@ func (r *Reaction) LoadUser(ctx context.Context) (*user_model.User, error) { } // RemapExternalUser ExternalUserRemappable interface -func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int64) error { +func (r *CommentReaction) RemapExternalUser(externalName string, externalID, userID int64) error { r.OriginalAuthor = externalName r.OriginalAuthorID = externalID r.UserID = userID @@ -92,16 +92,16 @@ func (r *Reaction) RemapExternalUser(externalName string, externalID, userID int } // GetUserID ExternalUserRemappable interface -func (r *Reaction) GetUserID() int64 { return r.UserID } +func (r *CommentReaction) GetUserID() int64 { return r.UserID } // GetExternalName ExternalUserRemappable interface -func (r *Reaction) GetExternalName() string { return r.OriginalAuthor } +func (r *CommentReaction) GetExternalName() string { return r.OriginalAuthor } // GetExternalID ExternalUserRemappable interface -func (r *Reaction) GetExternalID() int64 { return r.OriginalAuthorID } +func (r *CommentReaction) GetExternalID() int64 { return r.OriginalAuthorID } func init() { - db.RegisterModel(new(Reaction)) + db.RegisterModel(new(CommentReaction)) } // FindReactionsOptions describes the conditions to Find reactions @@ -166,18 +166,18 @@ func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList if opts.Page != 0 { sess = db.SetSessionPagination(sess, &opts) - reactions := make([]*Reaction, 0, opts.PageSize) + reactions := make([]*CommentReaction, 0, opts.PageSize) count, err := sess.FindAndCount(&reactions) return reactions, count, err } - reactions := make([]*Reaction, 0, 10) + reactions := make([]*CommentReaction, 0, 10) count, err := sess.FindAndCount(&reactions) return reactions, count, err } -func createReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { - reaction := &Reaction{ +func createReaction(ctx context.Context, opts *ReactionOptions) (*CommentReaction, error) { + reaction := &CommentReaction{ Type: opts.Type, UserID: opts.DoerID, ConversationID: opts.ConversationID, @@ -218,7 +218,7 @@ type ReactionOptions struct { } // CreateReaction creates reaction for conversation or comment. -func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, error) { +func CreateReaction(ctx context.Context, opts *ReactionOptions) (*CommentReaction, error) { if !setting.UI.ReactionsLookup.Contains(opts.Type) { return nil, ErrForbiddenConversationReaction{opts.Type} } @@ -242,7 +242,7 @@ func CreateReaction(ctx context.Context, opts *ReactionOptions) (*Reaction, erro // DeleteReaction deletes reaction for conversation or comment. func DeleteReaction(ctx context.Context, opts *ReactionOptions) error { - reaction := &Reaction{ + reaction := &CommentReaction{ Type: opts.Type, UserID: opts.DoerID, ConversationID: opts.ConversationID, @@ -280,7 +280,7 @@ func DeleteCommentReaction(ctx context.Context, doerID, conversationID, commentI } // ReactionList represents list of reactions -type ReactionList []*Reaction +type ReactionList []*CommentReaction // HasUser check if user has reacted func (list ReactionList) HasUser(userID int64) bool { @@ -305,7 +305,7 @@ func (list ReactionList) GroupByType() map[string]ReactionList { } func (list ReactionList) getUserIDs() []int64 { - return container.FilterSlice(list, func(reaction *Reaction) (int64, bool) { + return container.FilterSlice(list, func(reaction *CommentReaction) (int64, bool) { if reaction.OriginalAuthor != "" { return 0, false } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index a02d939b79eda..fd601221483e9 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1935,6 +1935,8 @@ pull.agit_documentation = Review documentation about AGit comments.edit.already_changed = Unable to save changes to the comment. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes +conversations.delete_comment_confirm = Are you sure you want to delete this comment? + milestones.new = New Milestone milestones.closed = Closed %s milestones.update_ago = Updated %s diff --git a/routers/web/web.go b/routers/web/web.go index 165909b985082..3566ecc8e1f25 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1281,7 +1281,14 @@ func registerRoutes(m *web.Router) { m.Group("/{index}", func() { m.Combo("/comments").Post(repo.ConversationMustAllowUserComment, web.Bind(forms.CreateConversationCommentForm{}), repo.NewConversationComment) }, context.RepoMustNotBeArchived()) + + m.Group("/comments/{id}", func() { + m.Post("", repo.UpdateConversationCommentContent) + m.Post("/delete", repo.DeleteConversationComment) + m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeConversationCommentReaction) + }, context.RepoMustNotBeArchived()) }) + }, reqSignIn, context.RepoAssignment) m.Group("/{username}/{reponame}", func() { // repo code diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go index 2e83d7a152c89..596245eab89ea 100644 --- a/services/conversation/conversation.go +++ b/services/conversation/conversation.go @@ -86,7 +86,7 @@ func deleteConversation(ctx context.Context, conversation *conversations_model.C &conversations_model.ConversationDependency{ConversationID: conversation.ID}, &conversations_model.ConversationUser{ConversationID: conversation.ID}, //&activities_model.Notification{ConversationID: conversation.ID}, - &conversations_model.Reaction{ConversationID: conversation.ID}, + &conversations_model.CommentReaction{ConversationID: conversation.ID}, &repo_model.Attachment{ConversationID: conversation.ID}, &conversations_model.Comment{ConversationID: conversation.ID}, &conversations_model.ConversationDependency{DependencyID: conversation.ID}, diff --git a/services/conversation/reaction.go b/services/conversation/reaction.go index 22524281fa74f..0ba8041378ab0 100644 --- a/services/conversation/reaction.go +++ b/services/conversation/reaction.go @@ -11,7 +11,7 @@ import ( ) // CreateCommentReaction creates a reaction on a comment. -func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *conversations_model.Comment, content string) (*conversations_model.Reaction, error) { +func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *conversations_model.Comment, content string) (*conversations_model.CommentReaction, error) { if err := comment.LoadConversation(ctx); err != nil { return nil, err } diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index f8de588497a2f..0168b2e9214cc 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -280,7 +280,10 @@
{{end}} {{template "repo/diff/box" .}} - {{template "repo/conversation/conversation" .}} + +
+ {{template "repo/conversation/conversation" .}} +
{{template "base/footer" .}} diff --git a/templates/repo/conversation/comments.tmpl b/templates/repo/conversation/comments.tmpl index 4ed191d66e412..7250813c8774a 100644 --- a/templates/repo/conversation/comments.tmpl +++ b/templates/repo/conversation/comments.tmpl @@ -1,5 +1,6 @@ {{template "base/alert"}} +{{$IsIssue:= .IsIssue}} {{range .Comments}} {{if call $.ShouldShowCommentType .Type}} {{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} @@ -56,7 +57,7 @@ {{if not $.Repository.IsArchived}} {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} {{end}} - {{template "repo/conversation/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}} + {{template "repo/conversation/context_menu" dict "ctxData" $ "item" . "delete" true "issue" $IsIssue "diff" false "IsIssue" $IsIssue "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
diff --git a/templates/repo/conversation/context_menu.tmpl b/templates/repo/conversation/context_menu.tmpl index 9e38442c369fd..79b471871eab3 100644 --- a/templates/repo/conversation/context_menu.tmpl +++ b/templates/repo/conversation/context_menu.tmpl @@ -7,7 +7,7 @@ {{if .issue}} {{$referenceUrl = printf "%s#%s" .ctxData.Issue.Link .item.HashTag}} {{else}} - {{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}} + {{$referenceUrl = printf "%s/files#%s" .ctxData.Conversation.Link .item.HashTag}} {{end}}
{{ctx.Locale.Tr "repo.issues.context.copy_link"}}
{{if .ctxData.IsSigned}} @@ -18,11 +18,15 @@ {{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}
{{end}} - {{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission}} + {{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission .ctxData.HasConversationsWritePermission}}
{{ctx.Locale.Tr "repo.issues.context.edit"}}
{{if .delete}} -
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+ {{if .ctxData.IsIssue}} +
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+ {{else}} +
{{ctx.Locale.Tr "repo.conversations.context.delete"}}
+ {{end}} {{end}} {{end}} {{end}} diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 56493443d9068..08fa693856da4 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -1,5 +1,6 @@ import {createTippy} from '../modules/tippy.ts'; import {toggleElem} from '../utils/dom.ts'; +import {GET, POST} from '../modules/fetch.ts'; export function initRepoEllipsisButton() { for (const button of document.querySelectorAll('.js-toggle-commit-body')) { @@ -25,3 +26,60 @@ export function initCommitStatuses() { }); } } + +export function initRepoConversationCommentDelete() { + // Delete comment + document.addEventListener('click', async (e) => { + if (!e.target.matches('.delete-comment')) return; + e.preventDefault(); + + const deleteButton = e.target; + if (window.confirm(deleteButton.getAttribute('data-locale'))) { + try { + const response = await POST(deleteButton.getAttribute('data-url')); + if (!response.ok) throw new Error('Failed to delete comment'); + + const conversationHolder = deleteButton.closest('.conversation-holder'); + const parentTimelineItem = deleteButton.closest('.timeline-item'); + const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); + + // Check if this was a pending comment. + if (conversationHolder?.querySelector('.pending-label')) { + const counter = document.querySelector('#review-box .review-comments-counter'); + let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0; + num = Math.max(num, 0); + counter.setAttribute('data-pending-comment-number', num); + counter.textContent = String(num); + } + + document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove(); + + if (conversationHolder && !conversationHolder.querySelector('.comment')) { + const path = conversationHolder.getAttribute('data-path'); + const side = conversationHolder.getAttribute('data-side'); + const idx = conversationHolder.getAttribute('data-idx'); + const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type'); + + // the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page + // on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment" + if (lineType) { + if (lineType === 'same') { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible'); + } else { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible'); + } + } + conversationHolder.remove(); + } + + // Check if there is no review content, move the time avatar upward to avoid overlapping the content below. + if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) { + const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar'); + timelineAvatar?.classList.remove('timeline-avatar-offset'); + } + } catch (error) { + console.error(error); + } + } + }); +} \ No newline at end of file diff --git a/web_src/js/index.ts b/web_src/js/index.ts index db678a25ba388..1d936b2e53908 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -34,7 +34,7 @@ import { initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, } from './features/repo-issue.ts'; -import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; +import {initRepoEllipsisButton, initCommitStatuses, initRepoConversationCommentDelete} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; import {initAdminEmails} from './features/admin/emails.ts'; import {initAdminCommon} from './features/admin/common.ts'; @@ -215,6 +215,7 @@ onDomReady(() => { initRepoContributors, initRepoCodeFrequency, initRepoRecentCommits, + initRepoConversationCommentDelete, initCommitStatuses, initCaptcha, From bb943791fb70f7d6d0d6800f0c1f4bb959d8b662 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Tue, 29 Oct 2024 16:48:18 +0800 Subject: [PATCH 05/72] Reimplement reactions and delete, fixed conversation styling css --- models/conversations/conversation.go | 35 ++++- models/conversations/reaction.go | 16 +-- models/unit/unit.go | 12 ++ routers/web/repo/commit.go | 2 + routers/web/repo/conversation.go | 2 +- routers/web/web.go | 3 +- services/context/context.go | 1 + templates/repo/commit_page.tmpl | 5 +- templates/repo/conversation/add_reaction.tmpl | 10 ++ templates/repo/conversation/comments.tmpl | 20 ++- templates/repo/conversation/context_menu.tmpl | 12 +- templates/repo/conversation/conversation.tmpl | 32 +++-- .../conversation/issue_header_comment.tmpl | 2 +- templates/repo/conversation/reactions.tmpl | 17 +++ web_src/css/repo.css | 128 +++++++++--------- web_src/js/features/comp/ReactionSelector.ts | 2 +- web_src/js/features/repo-commit.ts | 57 -------- web_src/js/features/repo-conversation.ts | 59 ++++++++ web_src/js/features/repo-legacy.ts | 10 +- web_src/js/index.ts | 3 +- 20 files changed, 268 insertions(+), 160 deletions(-) create mode 100644 templates/repo/conversation/add_reaction.tmpl create mode 100644 templates/repo/conversation/reactions.tmpl create mode 100644 web_src/js/features/repo-conversation.ts diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index da41b4e454887..8ac237a737944 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -133,7 +133,7 @@ func init() { func (conversation *Conversation) Link() string { switch conversation.Type { default: - return fmt.Sprintf("%s/%s/%s", conversation.Repo.Link(), "commits", conversation.CommitSha) + return fmt.Sprintf("%s/%s/%s", conversation.Repo.Link(), "commit", conversation.CommitSha) } } @@ -207,6 +207,10 @@ func (conversation *Conversation) LoadAttributes(ctx context.Context) (err error return err } + if err = conversation.loadReactions(ctx); err != nil { + return err + } + if err = conversation.Comments.LoadAttributes(ctx); err != nil { return err } @@ -326,3 +330,32 @@ func (conversation *Conversation) APIURL(ctx context.Context) string { } return fmt.Sprintf("%s/commit/%s", conversation.Repo.APIURL(), conversation.CommitSha) } + +func (conversation *Conversation) loadReactions(ctx context.Context) (err error) { + reactions, _, err := FindReactions(ctx, FindReactionsOptions{ + ConversationID: conversation.ID, + }) + if err != nil { + return err + } + if err = conversation.LoadRepo(ctx); err != nil { + return err + } + // Load reaction user data + if _, err := reactions.LoadUsers(ctx, conversation.Repo); err != nil { + return err + } + + // Cache comments to map + comments := make(map[int64]*Comment) + for _, comment := range conversation.Comments { + comments[comment.ID] = comment + } + // Add reactions to comment + for _, react := range reactions { + if comment, ok := comments[react.CommentID]; ok { + comment.Reactions = append(comment.Reactions, react) + } + } + return nil +} diff --git a/models/conversations/reaction.go b/models/conversations/reaction.go index ca54082b157a3..90bcb17334b42 100644 --- a/models/conversations/reaction.go +++ b/models/conversations/reaction.go @@ -117,24 +117,24 @@ func (opts *FindReactionsOptions) toConds() builder.Cond { // If Conversation ID is set add to Query cond := builder.NewCond() if opts.ConversationID > 0 { - cond = cond.And(builder.Eq{"reaction.conversation_id": opts.ConversationID}) + cond = cond.And(builder.Eq{"comment_reaction.conversation_id": opts.ConversationID}) } // If CommentID is > 0 add to Query // If it is 0 Query ignore CommentID to select // If it is -1 it explicit search of Conversation Reactions where CommentID = 0 if opts.CommentID > 0 { - cond = cond.And(builder.Eq{"reaction.comment_id": opts.CommentID}) + cond = cond.And(builder.Eq{"comment_reaction.comment_id": opts.CommentID}) } else if opts.CommentID == -1 { - cond = cond.And(builder.Eq{"reaction.comment_id": 0}) + cond = cond.And(builder.Eq{"comment_reaction.comment_id": 0}) } if opts.UserID > 0 { cond = cond.And(builder.Eq{ - "reaction.user_id": opts.UserID, - "reaction.original_author_id": 0, + "comment_reaction.user_id": opts.UserID, + "comment_reaction.original_author_id": 0, }) } if opts.Reaction != "" { - cond = cond.And(builder.Eq{"reaction.type": opts.Reaction}) + cond = cond.And(builder.Eq{"comment_reaction.type": opts.Reaction}) } return cond @@ -161,8 +161,8 @@ func FindConversationReactions(ctx context.Context, conversationID int64, listOp func FindReactions(ctx context.Context, opts FindReactionsOptions) (ReactionList, int64, error) { sess := db.GetEngine(ctx). Where(opts.toConds()). - In("reaction.`type`", setting.UI.Reactions). - Asc("reaction.conversation_id", "reaction.comment_id", "reaction.created_unix", "reaction.id") + In("comment_reaction.`type`", setting.UI.Reactions). + Asc("comment_reaction.conversation_id", "comment_reaction.comment_id", "comment_reaction.created_unix", "comment_reaction.id") if opts.Page != 0 { sess = db.SetSessionPagination(sess, &opts) diff --git a/models/unit/unit.go b/models/unit/unit.go index e2a829b36660e..feddae3bc5270 100644 --- a/models/unit/unit.go +++ b/models/unit/unit.go @@ -61,6 +61,7 @@ var ( TypeProjects, TypePackages, TypeActions, + TypeConversations, } // DefaultRepoUnits contains the default unit types @@ -73,6 +74,7 @@ var ( TypeProjects, TypePackages, TypeActions, + TypeConversations, } // ForkRepoUnits contains the default unit types for forks @@ -294,6 +296,15 @@ var ( perm.AccessModeOwner, } + UnitConversations = Unit{ + TypeConversations, + "repo.conversations", + "/conversations", + "conversations.unit.desc", + 8, + perm.AccessModeOwner, + } + // Units contains all the units Units = map[Type]Unit{ TypeCode: UnitCode, @@ -306,6 +317,7 @@ var ( TypeProjects: UnitProjects, TypePackages: UnitPackages, TypeActions: UnitActions, + TypeConversations: UnitConversations, } ) diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 2638634ab4b51..230248dc230f4 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -463,7 +463,9 @@ func Diff(ctx *context.Context) { } ctx.Data["Conversation"] = conversation + ctx.Data["ConversationTitle"] = "Comments" ctx.Data["Comments"] = conversation.Comments + ctx.Data["IsCommit"] = true ctx.Data["CanBlockUser"] = func(blocker, blockee *user_model.User) bool { return user_service.CanBlockUser(ctx, ctx.Doer, blocker, blockee) diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index fc8dc6e907b66..49e7c2db3ead8 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -1170,7 +1170,7 @@ func ChangeConversationCommentReaction(ctx *context.Context) { } html, err := ctx.RenderToHTML(tplReactions, map[string]any{ - "ActionURL": fmt.Sprintf("%s/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), + "ActionURL": fmt.Sprintf("%s/conversations/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), "Reactions": comment.Reactions.GroupByType(), }) if err != nil { diff --git a/routers/web/web.go b/routers/web/web.go index 3566ecc8e1f25..c62570e451572 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -815,6 +815,7 @@ func registerRoutes(m *web.Router) { reqRepoPullsReader := context.RequireRepoReader(unit.TypePullRequests) reqRepoIssuesOrPullsWriter := context.RequireRepoWriterOr(unit.TypeIssues, unit.TypePullRequests) reqRepoIssuesOrPullsReader := context.RequireRepoReaderOr(unit.TypeIssues, unit.TypePullRequests) + reqRepoConversationReader := context.RequireRepoReader(unit.TypeConversations) reqRepoProjectsReader := context.RequireRepoReader(unit.TypeProjects) reqRepoProjectsWriter := context.RequireRepoWriter(unit.TypeProjects) reqRepoActionsReader := context.RequireRepoReader(unit.TypeActions) @@ -1289,7 +1290,7 @@ func registerRoutes(m *web.Router) { }, context.RepoMustNotBeArchived()) }) - }, reqSignIn, context.RepoAssignment) + }, reqSignIn, context.RepoAssignment, reqRepoConversationReader) m.Group("/{username}/{reponame}", func() { // repo code m.Group("", func() { diff --git a/services/context/context.go b/services/context/context.go index 42f7c3d9d1d8a..dd42e0d24c8a7 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -112,6 +112,7 @@ func NewTemplateContextForWeb(ctx *Context) TemplateContext { "RepoUnitTypeProjects": unit.TypeProjects, "RepoUnitTypePackages": unit.TypePackages, "RepoUnitTypeActions": unit.TypeActions, + "RepoUnitTypeConversations": unit.TypeConversations, } return tmplCtx } diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 0168b2e9214cc..f8de588497a2f 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -280,10 +280,7 @@
{{end}} {{template "repo/diff/box" .}} - -
- {{template "repo/conversation/conversation" .}} -
+ {{template "repo/conversation/conversation" .}} {{template "base/footer" .}} diff --git a/templates/repo/conversation/add_reaction.tmpl b/templates/repo/conversation/add_reaction.tmpl new file mode 100644 index 0000000000000..6baded8fe9460 --- /dev/null +++ b/templates/repo/conversation/add_reaction.tmpl @@ -0,0 +1,10 @@ +{{if ctx.RootData.IsSigned}} + +{{end}} diff --git a/templates/repo/conversation/comments.tmpl b/templates/repo/conversation/comments.tmpl index 7250813c8774a..eb34213877122 100644 --- a/templates/repo/conversation/comments.tmpl +++ b/templates/repo/conversation/comments.tmpl @@ -1,6 +1,7 @@ {{template "base/alert"}} {{$IsIssue:= .IsIssue}} +{{$IsCommit:= .IsCommit}} {{range .Comments}} {{if call $.ShouldShowCommentType .Type}} {{$createdStr:= TimeSinceUnix .CreatedUnix ctx.Locale}} @@ -55,9 +56,14 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} + {{if $IsIssue}} + {{template "repo/conversation/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} + {{else}} + {{template "repo/conversation/add_reaction" dict "ActionURL" (printf "%s/conversations/comments/%d/reactions" $.RepoLink .ID)}} + {{end}} {{end}} - {{template "repo/conversation/context_menu" dict "ctxData" $ "item" . "delete" true "issue" $IsIssue "diff" false "IsIssue" $IsIssue "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}} + + {{template "repo/conversation/context_menu" dict "ctxData" $ "item" . "delete" true "issue" $IsIssue "pull" ((and $IsIssue .Issue.IsPull)) "commit" $IsCommit "diff" false "IsIssue" $IsIssue "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
@@ -76,7 +82,11 @@
{{$reactions := .Reactions.GroupByType}} {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}} + {{if $IsIssue}} + {{template "repo/conversation/reactions" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}} + {{else}} + {{template "repo/conversation/reactions" dict "ActionURL" (printf "%s/conversations/comments/%d/reactions" $.RepoLink .ID) "Reactions" $reactions}} + {{end}} {{end}} @@ -429,8 +439,8 @@
{{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}} {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} - {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}} + {{template "repo/conversation/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}} + {{template "repo/conversation/context_menu" dict "ctxData" $ "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}} {{end}}
diff --git a/templates/repo/conversation/context_menu.tmpl b/templates/repo/conversation/context_menu.tmpl index 79b471871eab3..6c50ee5037bd9 100644 --- a/templates/repo/conversation/context_menu.tmpl +++ b/templates/repo/conversation/context_menu.tmpl @@ -6,8 +6,14 @@ {{$referenceUrl := ""}} {{if .issue}} {{$referenceUrl = printf "%s#%s" .ctxData.Issue.Link .item.HashTag}} - {{else}} - {{$referenceUrl = printf "%s/files#%s" .ctxData.Conversation.Link .item.HashTag}} + {{end}} + + {{if .pull}} + {{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}} + {{end}} + + {{if .commit}} + {{$referenceUrl = printf "%s#%s" .ctxData.Conversation.Link .item.HashTag}} {{end}}
{{ctx.Locale.Tr "repo.issues.context.copy_link"}}
{{if .ctxData.IsSigned}} @@ -25,7 +31,7 @@ {{if .ctxData.IsIssue}}
{{ctx.Locale.Tr "repo.issues.context.delete"}}
{{else}} -
{{ctx.Locale.Tr "repo.conversations.context.delete"}}
+
{{ctx.Locale.Tr "repo.conversations.context.delete"}}
{{end}} {{end}} {{end}} diff --git a/templates/repo/conversation/conversation.tmpl b/templates/repo/conversation/conversation.tmpl index e2ae460825fcf..2e994e5d67d44 100644 --- a/templates/repo/conversation/conversation.tmpl +++ b/templates/repo/conversation/conversation.tmpl @@ -1,17 +1,27 @@ - -
- {{if .IsIssue}} - {{template "repo/conversation/issue_header_comment" .}} +
+ {{if .ConversationTitle}} +
+

+ {{.ConversationTitle}} +

+
{{end}} +
+
+ {{if .IsIssue}} + {{template "repo/conversation/issue_header_comment" .}} + {{end}} - {{template "repo/conversation/comments" .}} + {{template "repo/conversation/comments" .}} - {{if .IsIssue}} - {{if and .Issue.IsPull (not $.Repository.IsArchived)}} - {{template "repo/issue/view_content/pull".}} - {{end}} - {{end}} + {{if .IsIssue}} + {{if and .Issue.IsPull (not $.Repository.IsArchived)}} + {{template "repo/issue/view_content/pull".}} + {{end}} + {{end}} - {{template "repo/conversation/comment_form" .}} + {{template "repo/conversation/comment_form" .}} +
+
\ No newline at end of file diff --git a/templates/repo/conversation/issue_header_comment.tmpl b/templates/repo/conversation/issue_header_comment.tmpl index 73802bc6d2032..b7b01050bf072 100644 --- a/templates/repo/conversation/issue_header_comment.tmpl +++ b/templates/repo/conversation/issue_header_comment.tmpl @@ -1,5 +1,5 @@ - +
{{$createdStr:= TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} {{if .Issue.OriginalAuthor}} diff --git a/templates/repo/conversation/reactions.tmpl b/templates/repo/conversation/reactions.tmpl new file mode 100644 index 0000000000000..0011efe8b286f --- /dev/null +++ b/templates/repo/conversation/reactions.tmpl @@ -0,0 +1,17 @@ +
+{{range $key, $value := .Reactions}} + {{$hasReacted := $value.HasUser ctx.RootData.SignedUserID}} + + {{ReactionToEmoji $key}} + {{len $value}} + +{{end}} +{{if AllowedReactions}} + {{template "repo/issue/view_content/add_reaction" dict "ActionURL" .ActionURL}} +{{end}} +
diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 85f33f858e2ec..1466cc755bb60 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -699,7 +699,7 @@ td .commit-summary { margin-top: 1.1rem; } -.repository.view.issue .comment-list:not(.prevent-before-timeline)::before { +.conversation-container .comment-list:not(.prevent-before-timeline)::before { display: block; content: ""; position: absolute; @@ -713,14 +713,14 @@ td .commit-summary { z-index: -1; } -.repository.view.issue .comment-list .timeline { +.conversation-container .comment-list .timeline { position: relative; display: block; margin-left: 40px; padding-left: 16px; } -.repository.view.issue .comment-list .timeline::before { /* ciara */ +.conversation-container .comment-list .timeline::before { /* ciara */ display: block; content: ""; position: absolute; @@ -734,49 +734,49 @@ td .commit-summary { z-index: -1; } -.repository.view.issue .comment-list .timeline-item, -.repository.view.issue .comment-list .timeline-item-group { +.conversation-container .comment-list .timeline-item, +.conversation-container .comment-list .timeline-item-group { padding: 16px 0; } -.repository.view.issue .comment-list .timeline-item-group .timeline-item { +.conversation-container .comment-list .timeline-item-group .timeline-item { padding-top: 8px; padding-bottom: 8px; } -.repository.view.issue .comment-list .timeline-avatar-offset { +.conversation-container .comment-list .timeline-avatar-offset { margin-top: 48px; } -.repository.view.issue .comment-list .timeline-item { +.conversation-container .comment-list .timeline-item { margin-left: 16px; position: relative; } -.repository.view.issue .comment-list .timeline-item .timeline-avatar { +.conversation-container .comment-list .timeline-item .timeline-avatar { position: absolute; left: -68px; } /* Don't show the mobile oriented avatar ".inline-timeline-avatar" on desktop. Desktop uses the avatar with class ".timeline-avatar" */ -.repository.view.issue .comment-list .timeline-item .inline-timeline-avatar { +.conversation-container .comment-list .timeline-item .inline-timeline-avatar { display: none; } -.repository.view.issue .comment-list .timeline-item:first-child:not(.commit) { +.conversation-container .comment-list .timeline-item:first-child:not(.commit) { padding-top: 0 !important; } -.repository.view.issue .comment-list .timeline-item:last-child:not(.commit) { +.conversation-container .comment-list .timeline-item:last-child:not(.commit) { padding-bottom: 0 !important; } -.repository.view.issue .comment-list .timeline-item .badge.badge-commit { +.conversation-container .comment-list .timeline-item .badge.badge-commit { border-color: transparent; background: radial-gradient(var(--color-body) 40%, transparent 40%) no-repeat; } -.repository.view.issue .comment-list .timeline-item .badge { +.conversation-container .comment-list .timeline-item .badge { width: 34px; height: 34px; background-color: var(--color-timeline); @@ -790,31 +790,31 @@ td .commit-summary { justify-content: center; } -.repository.view.issue .comment-list .timeline-item .badge .svg { +.conversation-container .comment-list .timeline-item .badge .svg { width: 22px; height: 22px; padding: 3px; } -.repository.view.issue .comment-list .timeline-item .badge .svg.octicon-comment { +.conversation-container .comment-list .timeline-item .badge .svg.octicon-comment { margin-top: 2px; } -.repository.view.issue .comment-list .timeline-item.comment > .content { +.conversation-container .comment-list .timeline-item.comment > .content { margin-left: -16px; } -.repository.view.issue .comment-list .timeline-item.event > .text { +.conversation-container .comment-list .timeline-item.event > .text { line-height: 32px; vertical-align: middle; } -.repository.view.issue .comment-list .timeline-item.commits-list { +.conversation-container .comment-list .timeline-item.commits-list { padding-left: 15px; padding-top: 0; } -.repository.view.issue .comment-list .timeline-item.commits-list .ui.avatar { +.conversation-container .comment-list .timeline-item.commits-list .ui.avatar { margin-right: 0.25em; } @@ -868,71 +868,71 @@ td .commit-summary { background: var(--color-orange-badge-hover-bg) !important; } -.repository.view.issue .comment-list .timeline-item.event > .commit-status-link { +.conversation-container .comment-list .timeline-item.event > .commit-status-link { float: right; margin-right: 8px; margin-top: 4px; } -.repository.view.issue .comment-list .timeline-item .comparebox { +.conversation-container .comment-list .timeline-item .comparebox { line-height: 32px; vertical-align: middle; } -.repository.view.issue .comment-list .timeline-item .comparebox .compare.label { +.conversation-container .comment-list .timeline-item .comparebox .compare.label { font-size: 1rem; margin: 0; border: 1px solid var(--color-light-border); } @media (max-width: 767.98px) { - .repository.view.issue .comment-list .timeline-item .ui.segments { + .conversation-container .comment-list .timeline-item .ui.segments { margin-left: -2rem; } } -.repository.view.issue .comment-list .ui.comments { +.conversation-container .comment-list .ui.comments { max-width: 100%; display: flex; flex-direction: column; gap: 3px; } -.repository.view.issue .comment-list .comment > .content > div:first-child { +.conversation-container .comment-list .comment > .content > div:first-child { border-top-left-radius: 4px; border-top-right-radius: 4px; } -.repository.view.issue .comment-list .comment > .content > div:last-child { +.conversation-container .comment-list .comment > .content > div:last-child { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } -.repository.view.issue .comment-list .comment .comment-container { +.conversation-container .comment-list .comment .comment-container { border: 1px solid var(--color-secondary); border-radius: var(--border-radius); background: var(--color-box-body); } -.repository.view.issue .comment-list .conversation-holder .comment .comment-container { +.conversation-container .comment-list .conversation-holder .comment .comment-container { border: none; } @media (max-width: 767.98px) { - .repository.view.issue .comment-list .comment .content .form .button { + .conversation-container .comment-list .comment .content .form .button { width: 100%; margin: 0; } - .repository.view.issue .comment-list .comment .content .form .button:not(:last-child) { + .conversation-container .comment-list .comment .content .form .button:not(:last-child) { margin-bottom: 1rem; } } -.repository.view.issue .comment-list .comment .merge-section { +.conversation-container .comment-list .comment .merge-section { background-color: var(--color-box-body); } -.repository.view.issue .comment-list .comment .merge-section .item-section { +.conversation-container .comment-list .comment .merge-section .item-section { display: flex; flex-wrap: wrap; align-items: center; @@ -941,13 +941,13 @@ td .commit-summary { gap: 0.5em; } -.repository.view.issue .comment-list .comment .merge-section .divider { +.conversation-container .comment-list .comment .merge-section .divider { margin-left: -1rem; width: calc(100% + 2rem); } -.repository.view.issue .comment-list .comment .merge-section.no-header::before, -.repository.view.issue .comment-list .comment .merge-section.no-header::after { +.conversation-container .comment-list .comment .merge-section.no-header::before, +.conversation-container .comment-list .comment .merge-section.no-header::after { right: 100%; top: 20px; border: solid transparent; @@ -958,13 +958,13 @@ td .commit-summary { pointer-events: none; } -.repository.view.issue .comment-list .comment .merge-section.no-header::before { +.conversation-container .comment-list .comment .merge-section.no-header::before { border-right-color: var(--color-secondary); border-width: 9px; margin-top: -9px; } -.repository.view.issue .comment-list .comment .merge-section.no-header::after { +.conversation-container .comment-list .comment .merge-section.no-header::after { border-right-color: var(--color-box-body); border-width: 8px; margin-top: -8px; @@ -977,75 +977,75 @@ td .commit-summary { background: var(--color-light); } -.repository.view.issue .comment-list .comment .markup { +.conversation-container .comment-list .comment .markup { font-size: 14px; } -.repository.view.issue .comment-list .comment .no-content { +.conversation-container .comment-list .comment .no-content { color: var(--color-text-light-2); font-style: italic; } -.repository.view.issue .comment-list .comment .ui.form .field:first-child { +.conversation-container .comment-list .comment .ui.form .field:first-child { clear: none; } -.repository.view.issue .comment-list .comment .ui.form .field.footer { +.conversation-container .comment-list .comment .ui.form .field.footer { overflow: hidden; } -.repository.view.issue .comment-list .comment .ui.form .field .tab.markup { +.conversation-container .comment-list .comment .ui.form .field .tab.markup { min-height: 5rem; } -.repository.view.issue .comment-list .comment .edit.buttons { +.conversation-container .comment-list .comment .edit.buttons { margin-top: 10px; } -.repository.view.issue .comment-list .code-comment { +.conversation-container .comment-list .code-comment { border: 1px solid transparent; margin: 0; } -.repository.view.issue .comment-list .code-comment .comment-header { +.conversation-container .comment-list .code-comment .comment-header { background: transparent; border-bottom: 0 !important; padding: 0 !important; } -.repository.view.issue .comment-list .code-comment .comment-header::after, -.repository.view.issue .comment-list .code-comment .comment-header::before { +.conversation-container .comment-list .code-comment .comment-header::after, +.conversation-container .comment-list .code-comment .comment-header::before { display: none; } -.repository.view.issue .comment-list .code-comment .comment-content { +.conversation-container .comment-list .code-comment .comment-content { margin-left: 36px; } -.repository.view.issue .comment-list .comment > .avatar { +.conversation-container .comment-list .comment > .avatar { margin-top: 6px; } -.repository.view.issue .comment-list .comment-code-cloud button.comment-form-reply { +.conversation-container .comment-list .comment-code-cloud button.comment-form-reply { margin: 0; } -.repository.view.issue .comment-list .event { +.conversation-container .comment-list .event { padding-left: 15px; } -.repository.view.issue .comment-list .event .detail { +.conversation-container .comment-list .event .detail { margin-top: 4px; margin-left: 15px; } -.repository.view.issue .comment-list .event .detail .text { +.conversation-container .comment-list .event .detail .text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.repository.view.issue .comment-list .event .segments { +.conversation-container .comment-list .event .segments { box-shadow: none; } @@ -2667,30 +2667,30 @@ tbody.commit-list { .repository.file.list #repo-files-table .commit-list tr span.commit-summary { display: none !important; } - .repository.view.issue .comment-list .timeline, - .repository.view.issue .comment-list .timeline-item { + .conversation-container .comment-list .timeline, + .conversation-container .comment-list .timeline-item { margin-left: 0; } - .repository.view.issue .comment-list .timeline::before { + .conversation-container .comment-list .timeline::before { left: 14px; } - .repository.view.issue .comment-list .timeline .inline-timeline-avatar { + .conversation-container .comment-list .timeline .inline-timeline-avatar { display: flex; margin-bottom: auto; margin-left: 6px; margin-right: 2px; } - .repository.view.issue .comment-list .timeline .comment-header { + .conversation-container .comment-list .timeline .comment-header { padding-left: 4px; } - .repository.view.issue .comment-list .timeline .comment-header::before, - .repository.view.issue .comment-list .timeline .comment-header::after { + .conversation-container .comment-list .timeline .comment-header::before, + .conversation-container .comment-list .timeline .comment-header::after { content: unset; } /* Don't show the general avatar, we show the inline avatar on mobile. * And don't show the role labels, there's no place for that. */ - .repository.view.issue .comment-list .timeline .timeline-avatar, - .repository.view.issue .comment-list .timeline .comment-header-right .role-label { + .conversation-container .comment-list .timeline .timeline-avatar, + .conversation-container .comment-list .timeline .comment-header-right .role-label { display: none; } .commit-header-row .ui.horizontal.list { diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts index e1dd84bb14ba4..198c23c1ec24b 100644 --- a/web_src/js/features/comp/ReactionSelector.ts +++ b/web_src/js/features/comp/ReactionSelector.ts @@ -2,7 +2,7 @@ import $ from 'jquery'; import {POST} from '../../modules/fetch.ts'; export function initCompReactionSelector() { - for (const container of document.querySelectorAll('.issue-content, .diff-file-body')) { + for (const container of document.querySelectorAll('.conversation-content, .diff-file-body')) { container.addEventListener('click', async (e) => { // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment const target = e.target.closest('.comment-reaction-button'); diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 08fa693856da4..f6d1ade1e860a 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -26,60 +26,3 @@ export function initCommitStatuses() { }); } } - -export function initRepoConversationCommentDelete() { - // Delete comment - document.addEventListener('click', async (e) => { - if (!e.target.matches('.delete-comment')) return; - e.preventDefault(); - - const deleteButton = e.target; - if (window.confirm(deleteButton.getAttribute('data-locale'))) { - try { - const response = await POST(deleteButton.getAttribute('data-url')); - if (!response.ok) throw new Error('Failed to delete comment'); - - const conversationHolder = deleteButton.closest('.conversation-holder'); - const parentTimelineItem = deleteButton.closest('.timeline-item'); - const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); - - // Check if this was a pending comment. - if (conversationHolder?.querySelector('.pending-label')) { - const counter = document.querySelector('#review-box .review-comments-counter'); - let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0; - num = Math.max(num, 0); - counter.setAttribute('data-pending-comment-number', num); - counter.textContent = String(num); - } - - document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove(); - - if (conversationHolder && !conversationHolder.querySelector('.comment')) { - const path = conversationHolder.getAttribute('data-path'); - const side = conversationHolder.getAttribute('data-side'); - const idx = conversationHolder.getAttribute('data-idx'); - const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type'); - - // the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page - // on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment" - if (lineType) { - if (lineType === 'same') { - document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible'); - } else { - document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible'); - } - } - conversationHolder.remove(); - } - - // Check if there is no review content, move the time avatar upward to avoid overlapping the content below. - if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) { - const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar'); - timelineAvatar?.classList.remove('timeline-avatar-offset'); - } - } catch (error) { - console.error(error); - } - } - }); -} \ No newline at end of file diff --git a/web_src/js/features/repo-conversation.ts b/web_src/js/features/repo-conversation.ts new file mode 100644 index 0000000000000..67dabe1400189 --- /dev/null +++ b/web_src/js/features/repo-conversation.ts @@ -0,0 +1,59 @@ +import {GET, POST} from '../modules/fetch.ts'; + +export function initRepoConversationCommentDelete() { + // Delete comment + document.addEventListener('click', async (e) => { + if (!e.target.matches('.delete-comment')) return; + if (!e.target.matches('.conversation-comment')) return; + e.preventDefault(); + + const deleteButton = e.target; + if (window.confirm(deleteButton.getAttribute('data-locale'))) { + try { + const response = await POST(deleteButton.getAttribute('data-url')); + if (!response.ok) throw new Error('Failed to delete comment'); + + const conversationHolder = deleteButton.closest('.conversation-holder'); + const parentTimelineItem = deleteButton.closest('.timeline-item'); + const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); + + // Check if this was a pending comment. + if (conversationHolder?.querySelector('.pending-label')) { + const counter = document.querySelector('#review-box .review-comments-counter'); + let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0; + num = Math.max(num, 0); + counter.setAttribute('data-pending-comment-number', num); + counter.textContent = String(num); + } + + document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove(); + + if (conversationHolder && !conversationHolder.querySelector('.comment')) { + const path = conversationHolder.getAttribute('data-path'); + const side = conversationHolder.getAttribute('data-side'); + const idx = conversationHolder.getAttribute('data-idx'); + const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type'); + + // the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page + // on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment" + if (lineType) { + if (lineType === 'same') { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible'); + } else { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible'); + } + } + conversationHolder.remove(); + } + + // Check if there is no review content, move the time avatar upward to avoid overlapping the content below. + if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) { + const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar'); + timelineAvatar?.classList.remove('timeline-avatar-offset'); + } + } catch (error) { + console.error(error); + } + } + }); + } \ No newline at end of file diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 5844037770cf3..b9c3c140caa4f 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -5,6 +5,9 @@ import { initRepoIssueTitleEdit, initRepoIssueWipToggle, initRepoPullRequestUpdate, updateIssuesMeta, initIssueTemplateCommentEditors, initSingleCommentEditor, } from './repo-issue.ts'; +import { + initRepoConversationCommentDelete +} from './repo-conversation.ts' import {initUnicodeEscapeButton} from './repo-unicode-escape.ts'; import {svg} from '../svg.ts'; import {htmlEscape} from 'escape-goat'; @@ -398,7 +401,6 @@ export function initRepository() { initRepoIssueDependencyDelete(); initRepoIssueCodeCommentCancel(); initRepoPullRequestUpdate(); - initCompReactionSelector(); initRepoPullRequestMergeForm(); initRepoPullRequestCommitStatus(); @@ -417,5 +419,11 @@ export function initRepository() { }); } + // Conversations + if ($('.conversation-container').length > 0) { + initCompReactionSelector(); + initRepoConversationCommentDelete(); + } + initUnicodeEscapeButton(); } diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 1d936b2e53908..db678a25ba388 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -34,7 +34,7 @@ import { initRepoPullRequestAllowMaintainerEdit, initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, } from './features/repo-issue.ts'; -import {initRepoEllipsisButton, initCommitStatuses, initRepoConversationCommentDelete} from './features/repo-commit.ts'; +import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; import {initAdminEmails} from './features/admin/emails.ts'; import {initAdminCommon} from './features/admin/common.ts'; @@ -215,7 +215,6 @@ onDomReady(() => { initRepoContributors, initRepoCodeFrequency, initRepoRecentCommits, - initRepoConversationCommentDelete, initCommitStatuses, initCaptcha, From 17fb682c18b343af3c2ebe1bc4cb3ddbdda85f92 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Tue, 29 Oct 2024 20:03:40 +0800 Subject: [PATCH 06/72] Reimplement quote reply and translation text --- options/locale/locale_en-US.ini | 6 + routers/web/repo/conversation.go | 2 +- templates/repo/conversation/comments.tmpl | 2 +- templates/repo/conversation/context_menu.tmpl | 12 +- web_src/js/features/repo-conversation-edit.ts | 127 ++++++++++++++++++ web_src/js/features/repo-conversation.ts | 5 +- web_src/js/features/repo-legacy.ts | 6 + 7 files changed, 150 insertions(+), 10 deletions(-) create mode 100644 web_src/js/features/repo-conversation-edit.ts diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index fd601221483e9..f4e46296e02b2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1936,6 +1936,12 @@ pull.agit_documentation = Review documentation about AGit comments.edit.already_changed = Unable to save changes to the comment. It appears the content has already been changed by another user. Please refresh the page and try editing again to avoid overwriting their changes conversations.delete_comment_confirm = Are you sure you want to delete this comment? +conversations.context.copy_link = Copy Link +conversations.context.quote_reply = Quote Reply +conversations.context.reference_issue = Reference in New Issue +conversations.context.edit = Edit +conversations.context.delete = Delete +conversations.no_content = No description provided. milestones.new = New Milestone milestones.closed = Closed %s diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 49e7c2db3ead8..0fa11ae096762 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -935,7 +935,7 @@ func NewConversationComment(ctx *context.Context) { defer func() { // Redirect to comment hashtag if there is any actual content. - typeName := "commits" + typeName := "commit" if comment != nil { ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s#%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha, comment.HashTag())) } else { diff --git a/templates/repo/conversation/comments.tmpl b/templates/repo/conversation/comments.tmpl index eb34213877122..4fc4ab070999e 100644 --- a/templates/repo/conversation/comments.tmpl +++ b/templates/repo/conversation/comments.tmpl @@ -74,7 +74,7 @@ {{ctx.Locale.Tr "repo.issues.no_content"}} {{end}}
-
{{.Content}}
+
{{.Content}}
{{if .Attachments}} {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} diff --git a/templates/repo/conversation/context_menu.tmpl b/templates/repo/conversation/context_menu.tmpl index 6c50ee5037bd9..90542125cf839 100644 --- a/templates/repo/conversation/context_menu.tmpl +++ b/templates/repo/conversation/context_menu.tmpl @@ -15,21 +15,21 @@ {{if .commit}} {{$referenceUrl = printf "%s#%s" .ctxData.Conversation.Link .item.HashTag}} {{end}} -
{{ctx.Locale.Tr "repo.issues.context.copy_link"}}
+
{{ctx.Locale.Tr "repo.conversations.context.copy_link"}}
{{if .ctxData.IsSigned}} {{$needDivider := false}} {{if not .ctxData.Repository.IsArchived}} {{$needDivider = true}} -
{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}
- {{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}} -
{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}
+
{{ctx.Locale.Tr "repo.conversations.context.quote_reply"}}
+ {{if and .issue (not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled)}} +
{{ctx.Locale.Tr "repo.conversations.context.reference_issue"}}
{{end}} {{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission .ctxData.HasConversationsWritePermission}}
-
{{ctx.Locale.Tr "repo.issues.context.edit"}}
+
{{ctx.Locale.Tr "repo.conversations.context.edit"}}
{{if .delete}} {{if .ctxData.IsIssue}} -
{{ctx.Locale.Tr "repo.issues.context.delete"}}
+
{{ctx.Locale.Tr "repo.conversations.context.delete"}}
{{else}}
{{ctx.Locale.Tr "repo.conversations.context.delete"}}
{{end}} diff --git a/web_src/js/features/repo-conversation-edit.ts b/web_src/js/features/repo-conversation-edit.ts new file mode 100644 index 0000000000000..06c8715c4ff01 --- /dev/null +++ b/web_src/js/features/repo-conversation-edit.ts @@ -0,0 +1,127 @@ +import $ from 'jquery'; +import {handleReply} from './repo-issue.ts'; +import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; +import {POST} from '../modules/fetch.ts'; +import {showErrorToast} from '../modules/toast.ts'; +import {hideElem, showElem} from '../utils/dom.ts'; +import {attachRefIssueContextPopup} from './contextpopup.ts'; +import {initCommentContent, initMarkupContent} from '../markup/content.ts'; + +export function initRepoConversationCommentEdit() { + // Edit issue or comment content + $(document).on('click', '.edit-content', onEditContent); + + // Quote reply + $(document).on('click', '.quote-reply', async function (event) { + event.preventDefault(); + const target = this.getAttribute('data-target'); + const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> '); + const content = `> ${quote}\n\n`; + + let editor; + if (this.classList.contains('quote-reply-diff')) { + const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); + editor = await handleReply(replyBtn); + } else { + // for normal issue/comment page + editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); + } + if (editor) { + if (editor.value()) { + editor.value(`${editor.value()}\n\n${content}`); + } else { + editor.value(content); + } + editor.focus(); + editor.moveCursorToEnd(); + } + }); + } + + async function onEditContent(event) { + event.preventDefault(); + + const segment = this.closest('.header').nextElementSibling; + const editContentZone = segment.querySelector('.edit-content-zone'); + const renderContent = segment.querySelector('.render-content'); + const rawContent = segment.querySelector('.raw-content'); + + let comboMarkdownEditor; + + const cancelAndReset = (e) => { + e.preventDefault(); + showElem(renderContent); + hideElem(editContentZone); + comboMarkdownEditor.dropzoneReloadFiles(); + }; + + const saveAndRefresh = async (e) => { + e.preventDefault(); + renderContent.classList.add('is-loading'); + showElem(renderContent); + hideElem(editContentZone); + try { + const params = new URLSearchParams({ + content: comboMarkdownEditor.value(), + context: editContentZone.getAttribute('data-context'), + content_version: editContentZone.getAttribute('data-content-version'), + }); + for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) { + params.append('files[]', file); + } + + const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); + const data = await response.json(); + if (response.status === 400) { + showErrorToast(data.errorMessage); + return; + } + editContentZone.setAttribute('data-content-version', data.contentVersion); + if (!data.content) { + renderContent.innerHTML = document.querySelector('#no-content').innerHTML; + rawContent.textContent = ''; + } else { + renderContent.innerHTML = data.content; + rawContent.textContent = comboMarkdownEditor.value(); + const refIssues = renderContent.querySelectorAll('p .ref-issue'); + attachRefIssueContextPopup(refIssues); + } + const content = segment; + if (!content.querySelector('.dropzone-attachments')) { + if (data.attachments !== '') { + content.insertAdjacentHTML('beforeend', data.attachments); + } + } else if (data.attachments === '') { + content.querySelector('.dropzone-attachments').remove(); + } else { + content.querySelector('.dropzone-attachments').outerHTML = data.attachments; + } + comboMarkdownEditor.dropzoneSubmitReload(); + initMarkupContent(); + initCommentContent(); + } catch (error) { + showErrorToast(`Failed to save the content: ${error}`); + console.error(error); + } finally { + renderContent.classList.remove('is-loading'); + } + }; + + comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); + if (!comboMarkdownEditor) { + editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML; + comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); + editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset); + editContentZone.querySelector('.ui.primary.button').addEventListener('click', saveAndRefresh); + } + + // Show write/preview tab and copy raw content as needed + showElem(editContentZone); + hideElem(renderContent); + // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data + if (!comboMarkdownEditor.value()) { + comboMarkdownEditor.value(rawContent.textContent); + } + comboMarkdownEditor.switchTabToEditor(); + comboMarkdownEditor.focus(); + } \ No newline at end of file diff --git a/web_src/js/features/repo-conversation.ts b/web_src/js/features/repo-conversation.ts index 67dabe1400189..f4f13495cc99b 100644 --- a/web_src/js/features/repo-conversation.ts +++ b/web_src/js/features/repo-conversation.ts @@ -1,4 +1,4 @@ -import {GET, POST} from '../modules/fetch.ts'; +import {POST} from '../modules/fetch.ts'; export function initRepoConversationCommentDelete() { // Delete comment @@ -56,4 +56,5 @@ export function initRepoConversationCommentDelete() { } } }); - } \ No newline at end of file + } + diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index b9c3c140caa4f..9b6fa6724f109 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -8,6 +8,9 @@ import { import { initRepoConversationCommentDelete } from './repo-conversation.ts' +import { + initRepoConversationCommentEdit +} from './repo-conversation-edit.ts' import {initUnicodeEscapeButton} from './repo-unicode-escape.ts'; import {svg} from '../svg.ts'; import {htmlEscape} from 'escape-goat'; @@ -423,6 +426,9 @@ export function initRepository() { if ($('.conversation-container').length > 0) { initCompReactionSelector(); initRepoConversationCommentDelete(); + if ($('.repository.view.issue').length == 0) { + initRepoConversationCommentEdit(); + } } initUnicodeEscapeButton(); From 692f0aacaa630475690fe36313012ea81eb47e25 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Tue, 29 Oct 2024 21:34:24 +0800 Subject: [PATCH 07/72] Reimplement edit comment for conversations --- routers/web/repo/conversation.go | 2 +- templates/repo/conversation/comments.tmpl | 10 +++++++++- web_src/js/features/repo-conversation-edit.ts | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 0fa11ae096762..5ad404f023e35 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -1013,7 +1013,7 @@ func UpdateConversationCommentContent(ctx *context.Context) { // when the update request doesn't intend to update attachments (eg: change checkbox state), ignore attachment updates if !ctx.FormBool("ignore_attachments") { - if err := updateAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil { + if err := updateConversationAttachments(ctx, comment, ctx.FormStrings("files[]")); err != nil { ctx.ServerError("UpdateAttachments", err) return } diff --git a/templates/repo/conversation/comments.tmpl b/templates/repo/conversation/comments.tmpl index 4fc4ab070999e..755ecfc34385c 100644 --- a/templates/repo/conversation/comments.tmpl +++ b/templates/repo/conversation/comments.tmpl @@ -75,7 +75,15 @@ {{end}}
{{.Content}}
-
+ + {{if $IsIssue}} + {{$EditURL:=printf "%s/%s/%d" $.RepoLink "comments" .ID}} +
+ {{else}} + {{$EditURL:=printf "%s/%s/%d" $.RepoLink "conversations/comments" .ID}} +
+ {{end}} + {{if .Attachments}} {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} {{end}} diff --git a/web_src/js/features/repo-conversation-edit.ts b/web_src/js/features/repo-conversation-edit.ts index 06c8715c4ff01..688e705afdd06 100644 --- a/web_src/js/features/repo-conversation-edit.ts +++ b/web_src/js/features/repo-conversation-edit.ts @@ -72,6 +72,7 @@ export function initRepoConversationCommentEdit() { const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); const data = await response.json(); + if (response.status === 400) { showErrorToast(data.errorMessage); return; From bea92f6f8a69b3383178b60a2109f87634c30cc0 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Wed, 30 Oct 2024 01:25:02 +0800 Subject: [PATCH 08/72] Add insertconversationcomments --- models/conversations/comment.go | 43 ++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/models/conversations/comment.go b/models/conversations/comment.go index 1a3e8b3019224..eb867c47f0a3d 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -9,6 +9,7 @@ import ( "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/structs" @@ -525,7 +526,7 @@ func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error { return committer.Commit() } -// HashTag returns unique hash tag for issue. +// HashTag returns unique hash tag for conversation. func (comment *Comment) HashTag() string { return fmt.Sprintf("comment-%d", comment.ID) } @@ -584,3 +585,43 @@ func (c *Comment) ConversationURL(ctx context.Context) string { } return c.Conversation.HTMLURL() } + +// InsertConversationComments inserts many comments of conversations. +func InsertConversationComments(ctx context.Context, comments []*Comment) error { + if len(comments) == 0 { + return nil + } + + conversationIDs := container.FilterSlice(comments, func(comment *Comment) (int64, bool) { + return comment.ConversationID, true + }) + + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + for _, comment := range comments { + if _, err := db.GetEngine(ctx).NoAutoTime().Insert(comment); err != nil { + return err + } + + for _, reaction := range comment.Reactions { + reaction.ConversationID = comment.ConversationID + reaction.CommentID = comment.ID + } + if len(comment.Reactions) > 0 { + if err := db.Insert(ctx, comment.Reactions); err != nil { + return err + } + } + } + + for _, conversationID := range conversationIDs { + if _, err := db.Exec(ctx, "UPDATE conversation set num_comments = (SELECT count(*) FROM comment WHERE conversation_id = ? AND `type`=?) WHERE id = ?", + conversationID, CommentTypeComment, conversationID); err != nil { + return err + } + } + return committer.Commit() +} From 95c57a167704ff869450753008f75b45fa548137 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Wed, 30 Oct 2024 20:37:04 +0800 Subject: [PATCH 09/72] Clean up code based on lint --- models/conversations/comment.go | 11 +- models/conversations/comment_list.go | 6 +- models/conversations/conversation.go | 6 +- models/conversations/conversation_search.go | 44 ---- models/conversations/conversation_user.go | 1 - models/unit/unit.go | 2 +- modules/structs/conversation.go | 48 +--- routers/api/v1/api.go | 2 - routers/web/repo/conversation.go | 225 +----------------- routers/web/web.go | 1 - services/conversation/comments.go | 6 +- services/conversation/conversation.go | 5 +- services/convert/conversation.go | 31 +-- web_src/js/features/repo-commit.ts | 1 - web_src/js/features/repo-conversation-edit.ts | 220 ++++++++--------- web_src/js/features/repo-conversation.ts | 105 ++++---- web_src/js/features/repo-legacy.ts | 10 +- 17 files changed, 204 insertions(+), 520 deletions(-) diff --git a/models/conversations/comment.go b/models/conversations/comment.go index eb867c47f0a3d..9f8aff44d632a 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -5,6 +5,7 @@ package conversations import ( "context" "fmt" + "html/template" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" @@ -17,8 +18,6 @@ import ( "code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/util" - "html/template" - "xorm.io/builder" ) @@ -527,11 +526,11 @@ func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error { } // HashTag returns unique hash tag for conversation. -func (comment *Comment) HashTag() string { - return fmt.Sprintf("comment-%d", comment.ID) +func (c *Comment) HashTag() string { + return fmt.Sprintf("comment-%d", c.ID) } -func (c *Comment) hashLink(ctx context.Context) string { +func (c *Comment) hashLink() string { return "#" + c.HashTag() } @@ -547,7 +546,7 @@ func (c *Comment) HTMLURL(ctx context.Context) string { log.Error("loadRepo(%d): %v", c.Conversation.RepoID, err) return "" } - return c.Conversation.HTMLURL() + c.hashLink(ctx) + return c.Conversation.HTMLURL() + c.hashLink() } // APIURL formats a API-string to the conversation-comment diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go index a9b3fef57559b..4a1799497d1ae 100644 --- a/models/conversations/comment_list.go +++ b/models/conversations/comment_list.go @@ -189,9 +189,5 @@ func (comments CommentList) LoadAttributes(ctx context.Context) (err error) { return err } - if err = comments.LoadConversations(ctx); err != nil { - return err - } - - return nil + return comments.LoadConversations(ctx) } diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index 8ac237a737944..876f9270340f5 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -211,11 +211,7 @@ func (conversation *Conversation) LoadAttributes(ctx context.Context) (err error return err } - if err = conversation.Comments.LoadAttributes(ctx); err != nil { - return err - } - - return nil + return conversation.Comments.LoadAttributes(ctx) } // LoadRepo loads conversation's repository diff --git a/models/conversations/conversation_search.go b/models/conversations/conversation_search.go index c9469fab5a923..798870f5fd686 100644 --- a/models/conversations/conversation_search.go +++ b/models/conversations/conversation_search.go @@ -7,12 +7,10 @@ import ( "context" "fmt" "strconv" - "strings" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" repo_model "code.gitea.io/gitea/models/repo" - "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" @@ -259,48 +257,6 @@ func applyConditions(sess *xorm.Session, opts *ConversationsOptions) { applyLabelsCondition(sess, opts) } -// teamUnitsRepoCond returns query condition for those repo id in the special org team with special units access -func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Type) builder.Cond { - return builder.In(id, - builder.Select("repo_id").From("team_repo").Where( - builder.Eq{ - "team_id": teamID, - }.And( - builder.Or( - // Check if the user is member of the team. - builder.In( - "team_id", builder.Select("team_id").From("team_user").Where( - builder.Eq{ - "uid": userID, - }, - ), - ), - // Check if the user is in the owner team of the organisation. - builder.Exists(builder.Select("team_id").From("team_user"). - Where(builder.Eq{ - "org_id": orgID, - "team_id": builder.Select("id").From("team").Where( - builder.Eq{ - "org_id": orgID, - "lower_name": strings.ToLower(organization.OwnerTeamName), - }), - "uid": userID, - }), - ), - )).And( - builder.In( - "team_id", builder.Select("team_id").From("team_unit").Where( - builder.Eq{ - "`team_unit`.org_id": orgID, - }.And( - builder.In("`team_unit`.type", units), - ), - ), - ), - ), - )) -} - func applyPosterCondition(sess *xorm.Session, posterID int64) { sess.And("conversation.poster_id=?", posterID) } diff --git a/models/conversations/conversation_user.go b/models/conversations/conversation_user.go index b5971d101d723..23ff1265a70de 100644 --- a/models/conversations/conversation_user.go +++ b/models/conversations/conversation_user.go @@ -66,7 +66,6 @@ func GetConversationMentionIDs(ctx context.Context, conversationID int64) ([]int // NewConversationUsers inserts an conversation related users func NewConversationUsers(ctx context.Context, repo *repo_model.Repository, conversation *Conversation) error { - // Leave a seat for poster itself to append later, but if poster is one of assignee // and just waste 1 unit is cheaper than re-allocate memory once. conversationUsers := make([]*ConversationUser, 0, 1) diff --git a/models/unit/unit.go b/models/unit/unit.go index feddae3bc5270..704c9ac08f3b7 100644 --- a/models/unit/unit.go +++ b/models/unit/unit.go @@ -31,7 +31,7 @@ const ( TypeProjects // 8 Projects TypePackages // 9 Packages TypeActions // 10 Actions - TypeConversations //11 Conversations + TypeConversations // 11 Conversations ) // Value returns integer value for unit type (used by template) diff --git a/modules/structs/conversation.go b/modules/structs/conversation.go index 097b140556059..64cadd8b831d4 100644 --- a/modules/structs/conversation.go +++ b/modules/structs/conversation.go @@ -16,22 +16,11 @@ import ( // Conversation represents an conversation in a repository // swagger:model type Conversation struct { - ID int64 `json:"id"` - URL string `json:"url"` - HTMLURL string `json:"html_url"` - Index int64 `json:"number"` - Poster *User `json:"user"` - OriginalAuthor string `json:"original_author"` - OriginalAuthorID int64 `json:"original_author_id"` - Title string `json:"title"` - Body string `json:"body"` - Ref string `json:"ref"` - Attachments []*Attachment `json:"assets"` - Labels []*Label `json:"labels"` - Milestone *Milestone `json:"milestone"` - // deprecated - Assignee *User `json:"assignee"` - Assignees []*User `json:"assignees"` + ID int64 `json:"id"` + URL string `json:"url"` + HTMLURL string `json:"html_url"` + Index int64 `json:"number"` + Ref string `json:"ref"` // Whether the conversation is open or locked // // type: string @@ -48,35 +37,12 @@ type Conversation struct { // swagger:strfmt date-time Deadline *time.Time `json:"due_date"` - PullRequest *PullRequestMeta `json:"pull_request"` - Repo *RepositoryMeta `json:"repository"` - - PinOrder int `json:"pin_order"` + Repo *RepositoryMeta `json:"repository"` } // CreateConversationOption options to create one conversation type CreateConversationOption struct { - // required:true - Title string `json:"title" binding:"Required"` - Body string `json:"body"` - Ref string `json:"ref"` - // deprecated - Assignee string `json:"assignee"` - Assignees []string `json:"assignees"` - // swagger:strfmt date-time - Deadline *time.Time `json:"due_date"` - // milestone id - Milestone int64 `json:"milestone"` - // list of label ids - Labels []int64 `json:"labels"` - Locked bool `json:"locked"` -} - -// EditConversationOption options for editing an conversation -type EditConversationOption struct { - Title string `json:"title"` - Body *string `json:"body"` - Ref *string `json:"ref"` + Locked bool `json:"locked"` } // ConversationFormFieldType defines conversation form field type, can be "markdown", "textarea", "input", "dropdown" or "checkboxes" diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 71ce30e8d16b9..d085d77be6b13 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -672,7 +672,6 @@ func mustEnableConversations(ctx *context.APIContext) { ctx.NotFound() return } - } func mustEnableIssuesOrPulls(ctx *context.APIContext) { @@ -1514,7 +1513,6 @@ func Routes() *web.Router { // m.Combo("/{id}", reqToken()).Patch(bind(api.EditIssueCommentOption{}), repo.EditIssueCommentDeprecated). // Delete(repo.DeleteIssueCommentDeprecated) }) - }, mustEnableConversations) }) }) diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 5ad404f023e35..1feaa7e2546dc 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -5,7 +5,6 @@ package repo import ( - "bytes" "errors" "fmt" "html/template" @@ -48,9 +47,6 @@ const ( tplConversationNew base.TplName = "repo/conversation/new" tplConversationChoose base.TplName = "repo/conversation/choose" tplConversationView base.TplName = "repo/conversation/view" - - conversationTemplateKey = "ConversationTemplate" - conversationTemplateTitleKey = "ConversationTemplateTitle" ) // MustAllowUserComment checks to make sure if an conversation is locked. @@ -98,210 +94,8 @@ func ConversationMustAllowPulls(ctx *context.Context) { } } -func conversations(ctx *context.Context) { - var err error - viewType := ctx.FormString("type") - sortType := ctx.FormString("sort") - types := []string{"all", "your_repositories", "assigned", "created_by", "mentioned", "review_requested", "reviewed_by"} - if !util.SliceContainsString(types, viewType, true) { - viewType = "all" - } - - var ( - assigneeID = ctx.FormInt64("assignee") - posterID = ctx.FormInt64("poster") - mentionedID int64 - reviewRequestedID int64 - reviewedID int64 - ) - - if ctx.IsSigned { - switch viewType { - case "created_by": - posterID = ctx.Doer.ID - case "mentioned": - mentionedID = ctx.Doer.ID - case "assigned": - assigneeID = ctx.Doer.ID - case "review_requested": - reviewRequestedID = ctx.Doer.ID - case "reviewed_by": - reviewedID = ctx.Doer.ID - } - } - - repo := ctx.Repo.Repository - - keyword := strings.Trim(ctx.FormString("q"), " ") - if bytes.Contains([]byte(keyword), []byte{0x00}) { - keyword = "" - } - - var conversationStats *conversations_model.ConversationStats - statsOpts := &conversations_model.ConversationsOptions{ - RepoIDs: []int64{repo.ID}, - AssigneeID: assigneeID, - MentionedID: mentionedID, - PosterID: posterID, - ReviewRequestedID: reviewRequestedID, - ReviewedID: reviewedID, - ConversationIDs: nil, - } - if keyword != "" { - allConversationIDs, err := conversationIDsFromSearch(ctx, keyword, statsOpts) - if err != nil { - if conversation_indexer.IsAvailable(ctx) { - ctx.ServerError("conversationIDsFromSearch", err) - return - } - ctx.Data["ConversationIndexerUnavailable"] = true - return - } - statsOpts.ConversationIDs = allConversationIDs - } - if keyword != "" && len(statsOpts.ConversationIDs) == 0 { - // So it did search with the keyword, but no conversation found. - // Just set conversationStats to empty. - conversationStats = &conversations_model.ConversationStats{} - } else { - // So it did search with the keyword, and found some conversations. It needs to get conversationStats of these conversations. - // Or the keyword is empty, so it doesn't need conversationIDs as filter, just get conversationStats with statsOpts. - conversationStats, err = conversations_model.GetConversationStats(ctx, statsOpts) - if err != nil { - ctx.ServerError("GetConversationStats", err) - return - } - } - - var isShowClosed optional.Option[bool] - switch ctx.FormString("state") { - case "closed": - isShowClosed = optional.Some(true) - case "all": - isShowClosed = optional.None[bool]() - default: - isShowClosed = optional.Some(false) - } - // if there are closed conversations and no open conversations, default to showing all conversations - if len(ctx.FormString("state")) == 0 && conversationStats.OpenCount == 0 && conversationStats.ClosedCount != 0 { - isShowClosed = optional.None[bool]() - } - - archived := ctx.FormBool("archived") - - page := ctx.FormInt("page") - if page <= 1 { - page = 1 - } - - var total int - switch { - case isShowClosed.Value(): - total = int(conversationStats.ClosedCount) - case !isShowClosed.Has(): - total = int(conversationStats.OpenCount + conversationStats.ClosedCount) - default: - total = int(conversationStats.OpenCount) - } - pager := context.NewPagination(total, setting.UI.ConversationPagingNum, page, 5) - - var conversations conversations_model.ConversationList - { - ids, err := conversationIDsFromSearch(ctx, keyword, &conversations_model.ConversationsOptions{ - Paginator: &db.ListOptions{ - Page: pager.Paginater.Current(), - PageSize: setting.UI.ConversationPagingNum, - }, - RepoIDs: []int64{repo.ID}, - AssigneeID: assigneeID, - PosterID: posterID, - MentionedID: mentionedID, - ReviewRequestedID: reviewRequestedID, - ReviewedID: reviewedID, - IsClosed: isShowClosed, - SortType: sortType, - }) - if err != nil { - if conversation_indexer.IsAvailable(ctx) { - ctx.ServerError("conversationIDsFromSearch", err) - return - } - ctx.Data["ConversationIndexerUnavailable"] = true - return - } - conversations, err = conversations_model.GetConversationsByIDs(ctx, ids, true) - if err != nil { - ctx.ServerError("GetConversationsByIDs", err) - return - } - } - - if ctx.IsSigned { - if err := conversations.LoadIsRead(ctx, ctx.Doer.ID); err != nil { - ctx.ServerError("LoadIsRead", err) - return - } - } else { - for i := range conversations { - conversations[i].IsRead = true - } - } - - if err := conversations.LoadAttributes(ctx); err != nil { - ctx.ServerError("conversations.LoadAttributes", err) - return - } - - ctx.Data["Conversations"] = conversations - - handleTeamMentions(ctx) - if ctx.Written() { - return - } - - ctx.Data["IsRepoAdmin"] = ctx.IsSigned && (ctx.Repo.IsAdmin() || ctx.Doer.IsAdmin) - ctx.Data["ConversationStats"] = conversationStats - ctx.Data["OpenCount"] = conversationStats.OpenCount - ctx.Data["ClosedCount"] = conversationStats.ClosedCount - ctx.Data["ViewType"] = viewType - ctx.Data["SortType"] = sortType - ctx.Data["AssigneeID"] = assigneeID - ctx.Data["PosterID"] = posterID - ctx.Data["Keyword"] = keyword - ctx.Data["IsShowClosed"] = isShowClosed - switch { - case isShowClosed.Value(): - ctx.Data["State"] = "closed" - case !isShowClosed.Has(): - ctx.Data["State"] = "all" - default: - ctx.Data["State"] = "open" - } - ctx.Data["ShowArchivedLabels"] = archived - - pager.AddParamString("q", keyword) - pager.AddParamString("type", viewType) - pager.AddParamString("sort", sortType) - pager.AddParamString("state", fmt.Sprint(ctx.Data["State"])) - pager.AddParamString("assignee", fmt.Sprint(assigneeID)) - pager.AddParamString("poster", fmt.Sprint(posterID)) - pager.AddParamString("archived", fmt.Sprint(archived)) - - ctx.Data["Page"] = pager -} - -func conversationIDsFromSearch(ctx *context.Context, keyword string, opts *conversations_model.ConversationsOptions) ([]int64, error) { - ids, _, err := conversation_indexer.SearchConversations(ctx, conversation_indexer.ToSearchOptions(keyword, opts)) - if err != nil { - return nil, fmt.Errorf("SearchConversations: %w", err) - } - return ids, nil -} - // Conversations render conversations page func Conversations(ctx *context.Context) { - - renderMilestones(ctx) if ctx.Written() { return } @@ -313,7 +107,6 @@ func Conversations(ctx *context.Context) { // NewConversation render creating conversation page func NewConversation(ctx *context.Context) { - ctx.Data["Title"] = ctx.Tr("repo.conversations.new") ctx.Data["PageIsConversationList"] = true ctx.Data["NewConversationChooseTemplate"] = false @@ -374,13 +167,6 @@ func DeleteConversation(ctx *context.Context) { ctx.Redirect(fmt.Sprintf("%s/conversations", ctx.Repo.Repository.Link()), http.StatusSeeOther) } -func conversationGetBranchData(ctx *context.Context) { - ctx.Data["BaseBranch"] = nil - ctx.Data["HeadBranch"] = nil - ctx.Data["HeadUserName"] = nil - ctx.Data["BaseName"] = ctx.Repo.Repository.OwnerName -} - // ViewConversation render conversation view page func ViewConversation(ctx *context.Context) { if ctx.PathParam(":type") == "conversations" { @@ -446,7 +232,7 @@ func ViewConversation(ctx *context.Context) { ) // Check if the user can use the dependencies - //ctx.Data["CanCreateConversationDependencies"] = ctx.Repo.CanCreateConversationDependencies(ctx, ctx.Doer, conversation.IsPull) + // ctx.Data["CanCreateConversationDependencies"] = ctx.Repo.CanCreateConversationDependencies(ctx, ctx.Doer, conversation.IsPull) // check if dependencies can be created across repositories ctx.Data["AllowCrossRepositoryDependencies"] = setting.Service.AllowCrossRepositoryDependencies @@ -484,7 +270,7 @@ func ViewConversation(ctx *context.Context) { continue } - comment.ShowRole, err = conversationRoleDescriptor(ctx, repo, comment.Poster, conversation, comment.HasOriginalAuthor()) + comment.ShowRole, err = conversationRoleDescriptor(ctx, repo, comment.Poster, comment.HasOriginalAuthor()) if err != nil { ctx.ServerError("roleDescriptor", err) return @@ -622,7 +408,7 @@ func GetConversationInfo(ctx *context.Context) { } ctx.JSON(http.StatusOK, map[string]any{ - "convertedConversation": convert.ToConversation(ctx, ctx.Doer, conversation), + "convertedConversation": convert.ToConversation(ctx, conversation), }) } @@ -812,8 +598,6 @@ func ListConversations(ctx *context.Context) { isPull := optional.None[bool]() switch ctx.FormString("type") { - case "pulls": - isPull = optional.Some(true) case "conversations": isPull = optional.Some(false) } @@ -933,7 +717,6 @@ func NewConversationComment(ctx *context.Context) { var comment *conversations_model.Comment defer func() { - // Redirect to comment hashtag if there is any actual content. typeName := "commit" if comment != nil { @@ -1278,7 +1061,7 @@ func updateConversationAttachments(ctx *context.Context, item any, files []strin } // roleDescriptor returns the role descriptor for a comment in/with the given repo, poster and conversation -func conversationRoleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, conversation *conversations_model.Conversation, hasOriginalAuthor bool) (conversations_model.RoleDescriptor, error) { +func conversationRoleDescriptor(ctx *context.Context, repo *repo_model.Repository, poster *user_model.User, hasOriginalAuthor bool) (conversations_model.RoleDescriptor, error) { roleDescriptor := conversations_model.RoleDescriptor{} if hasOriginalAuthor { diff --git a/routers/web/web.go b/routers/web/web.go index c62570e451572..162b17f9afd1c 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1289,7 +1289,6 @@ func registerRoutes(m *web.Router) { m.Post("/reactions/{action}", web.Bind(forms.ReactionForm{}), repo.ChangeConversationCommentReaction) }, context.RepoMustNotBeArchived()) }) - }, reqSignIn, context.RepoAssignment, reqRepoConversationReader) m.Group("/{username}/{reponame}", func() { // repo code diff --git a/services/conversation/comments.go b/services/conversation/comments.go index e94c575a44de5..4b8949c537303 100644 --- a/services/conversation/comments.go +++ b/services/conversation/comments.go @@ -35,7 +35,7 @@ func CreateConversationComment(ctx context.Context, doer *user_model.User, repo return nil, err } - //notify_service.CreateConversationComment(ctx, doer, repo, conversation, comment, mentions) + // notify_service.CreateConversationComment(ctx, doer, repo, conversation, comment, mentions) return comment, nil } @@ -80,7 +80,7 @@ func UpdateComment(ctx context.Context, c *conversations_model.Comment, contentV } } - //notify_service.UpdateComment(ctx, doer, c, oldContent) + // notify_service.UpdateComment(ctx, doer, c, oldContent) return nil } @@ -94,7 +94,7 @@ func DeleteComment(ctx context.Context, doer *user_model.User, comment *conversa return err } - //notify_service.DeleteComment(ctx, doer, comment) + // notify_service.DeleteComment(ctx, doer, comment) return nil } diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go index 596245eab89ea..a8b0a384160e3 100644 --- a/services/conversation/conversation.go +++ b/services/conversation/conversation.go @@ -17,14 +17,13 @@ import ( // NewConversation creates new conversation with labels for repository. func NewConversation(ctx context.Context, repo *repo_model.Repository, uuids []string, conversation *conversations_model.Conversation) error { - if err := db.WithTx(ctx, func(ctx context.Context) error { return conversations_model.NewConversation(ctx, repo, conversation, uuids) }); err != nil { return err } - //notify_service.NewConversation(ctx, conversation, mentions) + // notify_service.NewConversation(ctx, conversation, mentions) return nil } @@ -41,7 +40,7 @@ func DeleteConversation(ctx context.Context, doer *user_model.User, gitRepo *git return err } - //notify_service.DeleteConversation(ctx, doer, conversation) + // notify_service.DeleteConversation(ctx, doer, conversation) return nil } diff --git a/services/convert/conversation.go b/services/convert/conversation.go index 9e7e963073e6e..01ed28cf1ca33 100644 --- a/services/convert/conversation.go +++ b/services/convert/conversation.go @@ -7,24 +7,20 @@ import ( "context" conversations_model "code.gitea.io/gitea/models/conversations" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" ) -func ToConversation(ctx context.Context, doer *user_model.User, conversation *conversations_model.Conversation) *api.Conversation { - return toConversation(ctx, doer, conversation, WebAssetDownloadURL) +func ToConversation(ctx context.Context, conversation *conversations_model.Conversation) *api.Conversation { + return toConversation(ctx, conversation) } // ToAPIConversation converts an Conversation to API format -// it assumes some fields assigned with values: -// Required - Poster, Labels, -// Optional - Milestone, Assignee, PullRequest -func ToAPIConversation(ctx context.Context, doer *user_model.User, conversation *conversations_model.Conversation) *api.Conversation { - return toConversation(ctx, doer, conversation, APIAssetDownloadURL) +func ToAPIConversation(ctx context.Context, conversation *conversations_model.Conversation) *api.Conversation { + return toConversation(ctx, conversation) } -func toConversation(ctx context.Context, doer *user_model.User, conversation *conversations_model.Conversation, getDownloadURL func(repo *repo_model.Repository, attach *repo_model.Attachment) string) *api.Conversation { +func toConversation(ctx context.Context, conversation *conversations_model.Conversation) *api.Conversation { if err := conversation.LoadRepo(ctx); err != nil { return &api.Conversation{} } @@ -33,13 +29,12 @@ func toConversation(ctx context.Context, doer *user_model.User, conversation *co } apiConversation := &api.Conversation{ - ID: conversation.ID, - Index: conversation.Index, - Attachments: toAttachments(conversation.Repo, conversation.Attachments, getDownloadURL), - IsLocked: conversation.IsLocked, - Comments: conversation.NumComments, - Created: conversation.CreatedUnix.AsTime(), - Updated: conversation.UpdatedUnix.AsTime(), + ID: conversation.ID, + Index: conversation.Index, + IsLocked: conversation.IsLocked, + Comments: conversation.NumComments, + Created: conversation.CreatedUnix.AsTime(), + Updated: conversation.UpdatedUnix.AsTime(), } if conversation.Repo != nil { @@ -68,7 +63,7 @@ func toConversation(ctx context.Context, doer *user_model.User, conversation *co func ToConversationList(ctx context.Context, doer *user_model.User, il conversations_model.ConversationList) []*api.Conversation { result := make([]*api.Conversation, len(il)) for i := range il { - result[i] = ToConversation(ctx, doer, il[i]) + result[i] = ToConversation(ctx, il[i]) } return result } @@ -77,7 +72,7 @@ func ToConversationList(ctx context.Context, doer *user_model.User, il conversat func ToAPIConversationList(ctx context.Context, doer *user_model.User, il conversations_model.ConversationList) []*api.Conversation { result := make([]*api.Conversation, len(il)) for i := range il { - result[i] = ToAPIConversation(ctx, doer, il[i]) + result[i] = ToAPIConversation(ctx, il[i]) } return result } diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index f6d1ade1e860a..56493443d9068 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -1,6 +1,5 @@ import {createTippy} from '../modules/tippy.ts'; import {toggleElem} from '../utils/dom.ts'; -import {GET, POST} from '../modules/fetch.ts'; export function initRepoEllipsisButton() { for (const button of document.querySelectorAll('.js-toggle-commit-body')) { diff --git a/web_src/js/features/repo-conversation-edit.ts b/web_src/js/features/repo-conversation-edit.ts index 688e705afdd06..c104995b1d872 100644 --- a/web_src/js/features/repo-conversation-edit.ts +++ b/web_src/js/features/repo-conversation-edit.ts @@ -8,121 +8,121 @@ import {attachRefIssueContextPopup} from './contextpopup.ts'; import {initCommentContent, initMarkupContent} from '../markup/content.ts'; export function initRepoConversationCommentEdit() { - // Edit issue or comment content - $(document).on('click', '.edit-content', onEditContent); - - // Quote reply - $(document).on('click', '.quote-reply', async function (event) { - event.preventDefault(); - const target = this.getAttribute('data-target'); - const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> '); - const content = `> ${quote}\n\n`; - - let editor; - if (this.classList.contains('quote-reply-diff')) { - const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); - editor = await handleReply(replyBtn); + // Edit issue or comment content + $(document).on('click', '.edit-content', onEditContent); + + // Quote reply + $(document).on('click', '.quote-reply', async function (event) { + event.preventDefault(); + const target = this.getAttribute('data-target'); + const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> '); + const content = `> ${quote}\n\n`; + + let editor; + if (this.classList.contains('quote-reply-diff')) { + const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); + editor = await handleReply(replyBtn); + } else { + // for normal issue/comment page + editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); + } + if (editor) { + if (editor.value()) { + editor.value(`${editor.value()}\n\n${content}`); } else { - // for normal issue/comment page - editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); + editor.value(content); } - if (editor) { - if (editor.value()) { - editor.value(`${editor.value()}\n\n${content}`); - } else { - editor.value(content); - } - editor.focus(); - editor.moveCursorToEnd(); + editor.focus(); + editor.moveCursorToEnd(); + } + }); +} + +async function onEditContent(event) { + event.preventDefault(); + + const segment = this.closest('.header').nextElementSibling; + const editContentZone = segment.querySelector('.edit-content-zone'); + const renderContent = segment.querySelector('.render-content'); + const rawContent = segment.querySelector('.raw-content'); + + let comboMarkdownEditor; + + const cancelAndReset = (e) => { + e.preventDefault(); + showElem(renderContent); + hideElem(editContentZone); + comboMarkdownEditor.dropzoneReloadFiles(); + }; + + const saveAndRefresh = async (e) => { + e.preventDefault(); + renderContent.classList.add('is-loading'); + showElem(renderContent); + hideElem(editContentZone); + try { + const params = new URLSearchParams({ + content: comboMarkdownEditor.value(), + context: editContentZone.getAttribute('data-context'), + content_version: editContentZone.getAttribute('data-content-version'), + }); + for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) { + params.append('files[]', file); } - }); - } - async function onEditContent(event) { - event.preventDefault(); - - const segment = this.closest('.header').nextElementSibling; - const editContentZone = segment.querySelector('.edit-content-zone'); - const renderContent = segment.querySelector('.render-content'); - const rawContent = segment.querySelector('.raw-content'); - - let comboMarkdownEditor; - - const cancelAndReset = (e) => { - e.preventDefault(); - showElem(renderContent); - hideElem(editContentZone); - comboMarkdownEditor.dropzoneReloadFiles(); - }; - - const saveAndRefresh = async (e) => { - e.preventDefault(); - renderContent.classList.add('is-loading'); - showElem(renderContent); - hideElem(editContentZone); - try { - const params = new URLSearchParams({ - content: comboMarkdownEditor.value(), - context: editContentZone.getAttribute('data-context'), - content_version: editContentZone.getAttribute('data-content-version'), - }); - for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) { - params.append('files[]', file); - } - - const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); - const data = await response.json(); + const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params}); + const data = await response.json(); - if (response.status === 400) { - showErrorToast(data.errorMessage); - return; - } - editContentZone.setAttribute('data-content-version', data.contentVersion); - if (!data.content) { - renderContent.innerHTML = document.querySelector('#no-content').innerHTML; - rawContent.textContent = ''; - } else { - renderContent.innerHTML = data.content; - rawContent.textContent = comboMarkdownEditor.value(); - const refIssues = renderContent.querySelectorAll('p .ref-issue'); - attachRefIssueContextPopup(refIssues); - } - const content = segment; - if (!content.querySelector('.dropzone-attachments')) { - if (data.attachments !== '') { - content.insertAdjacentHTML('beforeend', data.attachments); - } - } else if (data.attachments === '') { - content.querySelector('.dropzone-attachments').remove(); - } else { - content.querySelector('.dropzone-attachments').outerHTML = data.attachments; + if (response.status === 400) { + showErrorToast(data.errorMessage); + return; + } + editContentZone.setAttribute('data-content-version', data.contentVersion); + if (!data.content) { + renderContent.innerHTML = document.querySelector('#no-content').innerHTML; + rawContent.textContent = ''; + } else { + renderContent.innerHTML = data.content; + rawContent.textContent = comboMarkdownEditor.value(); + const refIssues = renderContent.querySelectorAll('p .ref-issue'); + attachRefIssueContextPopup(refIssues); + } + const content = segment; + if (!content.querySelector('.dropzone-attachments')) { + if (data.attachments !== '') { + content.insertAdjacentHTML('beforeend', data.attachments); } - comboMarkdownEditor.dropzoneSubmitReload(); - initMarkupContent(); - initCommentContent(); - } catch (error) { - showErrorToast(`Failed to save the content: ${error}`); - console.error(error); - } finally { - renderContent.classList.remove('is-loading'); + } else if (data.attachments === '') { + content.querySelector('.dropzone-attachments').remove(); + } else { + content.querySelector('.dropzone-attachments').outerHTML = data.attachments; } - }; - - comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); - if (!comboMarkdownEditor) { - editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML; - comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); - editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset); - editContentZone.querySelector('.ui.primary.button').addEventListener('click', saveAndRefresh); - } - - // Show write/preview tab and copy raw content as needed - showElem(editContentZone); - hideElem(renderContent); - // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data - if (!comboMarkdownEditor.value()) { - comboMarkdownEditor.value(rawContent.textContent); + comboMarkdownEditor.dropzoneSubmitReload(); + initMarkupContent(); + initCommentContent(); + } catch (error) { + showErrorToast(`Failed to save the content: ${error}`); + console.error(error); + } finally { + renderContent.classList.remove('is-loading'); } - comboMarkdownEditor.switchTabToEditor(); - comboMarkdownEditor.focus(); - } \ No newline at end of file + }; + + comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); + if (!comboMarkdownEditor) { + editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML; + comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')); + editContentZone.querySelector('.ui.cancel.button').addEventListener('click', cancelAndReset); + editContentZone.querySelector('.ui.primary.button').addEventListener('click', saveAndRefresh); + } + + // Show write/preview tab and copy raw content as needed + showElem(editContentZone); + hideElem(renderContent); + // FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data + if (!comboMarkdownEditor.value()) { + comboMarkdownEditor.value(rawContent.textContent); + } + comboMarkdownEditor.switchTabToEditor(); + comboMarkdownEditor.focus(); +} diff --git a/web_src/js/features/repo-conversation.ts b/web_src/js/features/repo-conversation.ts index f4f13495cc99b..ed643652f0e74 100644 --- a/web_src/js/features/repo-conversation.ts +++ b/web_src/js/features/repo-conversation.ts @@ -1,60 +1,59 @@ import {POST} from '../modules/fetch.ts'; export function initRepoConversationCommentDelete() { - // Delete comment - document.addEventListener('click', async (e) => { - if (!e.target.matches('.delete-comment')) return; - if (!e.target.matches('.conversation-comment')) return; - e.preventDefault(); - - const deleteButton = e.target; - if (window.confirm(deleteButton.getAttribute('data-locale'))) { - try { - const response = await POST(deleteButton.getAttribute('data-url')); - if (!response.ok) throw new Error('Failed to delete comment'); - - const conversationHolder = deleteButton.closest('.conversation-holder'); - const parentTimelineItem = deleteButton.closest('.timeline-item'); - const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); - - // Check if this was a pending comment. - if (conversationHolder?.querySelector('.pending-label')) { - const counter = document.querySelector('#review-box .review-comments-counter'); - let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0; - num = Math.max(num, 0); - counter.setAttribute('data-pending-comment-number', num); - counter.textContent = String(num); - } - - document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove(); - - if (conversationHolder && !conversationHolder.querySelector('.comment')) { - const path = conversationHolder.getAttribute('data-path'); - const side = conversationHolder.getAttribute('data-side'); - const idx = conversationHolder.getAttribute('data-idx'); - const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type'); - - // the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page - // on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment" - if (lineType) { - if (lineType === 'same') { - document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible'); - } else { - document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible'); - } + // Delete comment + document.addEventListener('click', async (e) => { + if (!e.target.matches('.delete-comment')) return; + if (!e.target.matches('.conversation-comment')) return; + e.preventDefault(); + + const deleteButton = e.target; + if (window.confirm(deleteButton.getAttribute('data-locale'))) { + try { + const response = await POST(deleteButton.getAttribute('data-url')); + if (!response.ok) throw new Error('Failed to delete comment'); + + const conversationHolder = deleteButton.closest('.conversation-holder'); + const parentTimelineItem = deleteButton.closest('.timeline-item'); + const parentTimelineGroup = deleteButton.closest('.timeline-item-group'); + + // Check if this was a pending comment. + if (conversationHolder?.querySelector('.pending-label')) { + const counter = document.querySelector('#review-box .review-comments-counter'); + let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0; + num = Math.max(num, 0); + counter.setAttribute('data-pending-comment-number', num); + counter.textContent = String(num); + } + + document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove(); + + if (conversationHolder && !conversationHolder.querySelector('.comment')) { + const path = conversationHolder.getAttribute('data-path'); + const side = conversationHolder.getAttribute('data-side'); + const idx = conversationHolder.getAttribute('data-idx'); + const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type'); + + // the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page + // on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment" + if (lineType) { + if (lineType === 'same') { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible'); + } else { + document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible'); } - conversationHolder.remove(); - } - - // Check if there is no review content, move the time avatar upward to avoid overlapping the content below. - if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) { - const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar'); - timelineAvatar?.classList.remove('timeline-avatar-offset'); } - } catch (error) { - console.error(error); + conversationHolder.remove(); } - } - }); - } + // Check if there is no review content, move the time avatar upward to avoid overlapping the content below. + if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) { + const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar'); + timelineAvatar?.classList.remove('timeline-avatar-offset'); + } + } catch (error) { + console.error(error); + } + } + }); +} diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 9b6fa6724f109..fdb836055e39b 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -6,11 +6,11 @@ import { initRepoPullRequestUpdate, updateIssuesMeta, initIssueTemplateCommentEditors, initSingleCommentEditor, } from './repo-issue.ts'; import { - initRepoConversationCommentDelete -} from './repo-conversation.ts' + initRepoConversationCommentDelete, +} from './repo-conversation.ts'; import { - initRepoConversationCommentEdit -} from './repo-conversation-edit.ts' + initRepoConversationCommentEdit, +} from './repo-conversation-edit.ts'; import {initUnicodeEscapeButton} from './repo-unicode-escape.ts'; import {svg} from '../svg.ts'; import {htmlEscape} from 'escape-goat'; @@ -426,7 +426,7 @@ export function initRepository() { if ($('.conversation-container').length > 0) { initCompReactionSelector(); initRepoConversationCommentDelete(); - if ($('.repository.view.issue').length == 0) { + if ($('.repository.view.issue').length === 0) { initRepoConversationCommentEdit(); } } From 505a23088c5cc39a753597e33a27c96037da5316 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Wed, 30 Oct 2024 20:47:24 +0800 Subject: [PATCH 10/72] Update copyright informations --- models/conversations/comment.go | 3 +++ models/conversations/comment_list.go | 2 +- models/conversations/content_history.go | 2 +- models/conversations/conversation.go | 3 +++ models/conversations/conversation_list.go | 2 +- models/conversations/conversation_search.go | 2 +- models/conversations/conversation_stat.go | 2 +- models/conversations/conversation_update.go | 2 +- models/conversations/conversation_user.go | 2 +- models/conversations/dependency.go | 2 +- models/conversations/reaction.go | 2 +- services/conversation/comments.go | 2 +- services/conversation/conversation.go | 2 +- 13 files changed, 17 insertions(+), 11 deletions(-) diff --git a/models/conversations/comment.go b/models/conversations/comment.go index 9f8aff44d632a..722dd988bf2f4 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -1,3 +1,6 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package conversations // This comment.go was refactored from issues/comment.go to make it context-agnostic to improve reusability. diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go index 4a1799497d1ae..0781ad4ffae99 100644 --- a/models/conversations/comment_list.go +++ b/models/conversations/comment_list.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/content_history.go b/models/conversations/content_history.go index f15f69b4924c9..3896422ffa835 100644 --- a/models/conversations/content_history.go +++ b/models/conversations/content_history.go @@ -1,4 +1,4 @@ -// Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index 876f9270340f5..bf9dd56b8d245 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -1,3 +1,6 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package conversations // Someone should decouple Comment from issues, and rename it something like ConversationEvent (@RedCocoon, 2024) diff --git a/models/conversations/conversation_list.go b/models/conversations/conversation_list.go index eb9d0911107c3..de32b148ba132 100644 --- a/models/conversations/conversation_list.go +++ b/models/conversations/conversation_list.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/conversation_search.go b/models/conversations/conversation_search.go index 798870f5fd686..6dad3fc617317 100644 --- a/models/conversations/conversation_search.go +++ b/models/conversations/conversation_search.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/conversation_stat.go b/models/conversations/conversation_stat.go index 8923cf97cf5d6..bab3e1a502944 100644 --- a/models/conversations/conversation_stat.go +++ b/models/conversations/conversation_stat.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go index 2588f972377dd..209f3d9b3de9b 100644 --- a/models/conversations/conversation_update.go +++ b/models/conversations/conversation_update.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/conversation_user.go b/models/conversations/conversation_user.go index 23ff1265a70de..05f3e9d71d94f 100644 --- a/models/conversations/conversation_user.go +++ b/models/conversations/conversation_user.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/dependency.go b/models/conversations/dependency.go index 72aafe96ef93d..8382ede90eafd 100644 --- a/models/conversations/dependency.go +++ b/models/conversations/dependency.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/models/conversations/reaction.go b/models/conversations/reaction.go index 90bcb17334b42..50e44f1c54087 100644 --- a/models/conversations/reaction.go +++ b/models/conversations/reaction.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/services/conversation/comments.go b/services/conversation/comments.go index 4b8949c537303..c08a9016071f8 100644 --- a/services/conversation/comments.go +++ b/services/conversation/comments.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversation diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go index a8b0a384160e3..1f3bb7d719c05 100644 --- a/services/conversation/conversation.go +++ b/services/conversation/conversation.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversation From 5ba8ae10fcf91384e3b80433dd05ac2fa7439b18 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Wed, 30 Oct 2024 23:49:24 +0800 Subject: [PATCH 11/72] Remove unnecessary data from Conversation --- models/conversations/conversation.go | 51 ++--- models/conversations/conversation_list.go | 5 - models/conversations/conversation_update.go | 12 -- models/conversations/dependency.go | 222 -------------------- routers/web/repo/conversation.go | 17 -- services/conversation/conversation.go | 8 - services/convert/conversation.go | 3 - 7 files changed, 16 insertions(+), 302 deletions(-) delete mode 100644 models/conversations/dependency.go diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index bf9dd56b8d245..cc40e63f23dad 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -115,15 +115,13 @@ type Conversation struct { IsLocked bool `xorm:"-"` - Comments CommentList `xorm:"-"` - Attachments []*repo_model.Attachment `xorm:"-"` - isAttachmentsLoaded bool `xorm:"-"` + Comments CommentList `xorm:"-"` CommitSha string `xorm:"VARCHAR(64)"` IsRead bool `xorm:"-"` } -// IssueIndex represents the issue index table +// ConversationIndex represents the conversation index table type ConversationIndex db.ResourceIndex func init() { @@ -173,7 +171,7 @@ func GetConversationByID(ctx context.Context, id int64) (*Conversation, error) { return conversation, nil } -// GetIssueByIndex returns raw issue without loading attributes by index in a repository. +// GetConversationByIndex returns raw conversation without loading attributes by index in a repository. func GetConversationByIndex(ctx context.Context, repoID, index int64) (*Conversation, error) { if index < 1 { return nil, ErrConversationNotExist{} @@ -202,10 +200,6 @@ func (conversation *Conversation) LoadAttributes(ctx context.Context) (err error return err } - if err = conversation.LoadAttachments(ctx); err != nil { - return err - } - if err = conversation.loadComments(ctx); err != nil { return err } @@ -228,19 +222,6 @@ func (conversation *Conversation) LoadRepo(ctx context.Context) (err error) { return nil } -func (conversation *Conversation) LoadAttachments(ctx context.Context) (err error) { - if conversation.isAttachmentsLoaded || conversation.Attachments != nil { - return nil - } - - conversation.Attachments, err = repo_model.GetAttachmentsByConversationID(ctx, conversation.ID) - if err != nil { - return fmt.Errorf("getAttachmentsByConversationID [%d]: %w", conversation.ID, err) - } - conversation.isAttachmentsLoaded = true - return nil -} - // GetConversationIDsByRepoID returns all conversation ids by repo id func GetConversationIDsByRepoID(ctx context.Context, repoID int64) ([]int64, error) { ids := make([]int64, 0, 10) @@ -248,31 +229,31 @@ func GetConversationIDsByRepoID(ctx context.Context, repoID int64) ([]int64, err return ids, err } -// GetConversationsByIDs return issues with the given IDs. +// GetConversationsByIDs return conversations with the given IDs. // If keepOrder is true, the order of the returned Conversations will be the same as the given IDs. -func GetConversationsByIDs(ctx context.Context, issueIDs []int64, keepOrder ...bool) (ConversationList, error) { - issues := make([]*Conversation, 0, len(issueIDs)) +func GetConversationsByIDs(ctx context.Context, conversationIDs []int64, keepOrder ...bool) (ConversationList, error) { + conversations := make([]*Conversation, 0, len(conversationIDs)) - if err := db.GetEngine(ctx).In("id", issueIDs).Find(&issues); err != nil { + if err := db.GetEngine(ctx).In("id", conversationIDs).Find(&conversations); err != nil { return nil, err } if len(keepOrder) > 0 && keepOrder[0] { - m := make(map[int64]*Conversation, len(issues)) + m := make(map[int64]*Conversation, len(conversations)) appended := container.Set[int64]{} - for _, issue := range issues { - m[issue.ID] = issue + for _, conversation := range conversations { + m[conversation.ID] = conversation } - issues = issues[:0] - for _, id := range issueIDs { - if issue, ok := m[id]; ok && !appended.Contains(id) { // make sure the id is existed and not appended + conversations = conversations[:0] + for _, id := range conversationIDs { + if conversation, ok := m[id]; ok && !appended.Contains(id) { // make sure the id is existed and not appended appended.Add(id) - issues = append(issues, issue) + conversations = append(conversations, conversation) } } } - return issues, nil + return conversations, nil } func GetConversationByCommitID(ctx context.Context, commitID string) (*Conversation, error) { @@ -318,7 +299,7 @@ func (conversation *Conversation) HTMLURL() string { return fmt.Sprintf("%s/%s/%s", conversation.Repo.HTMLURL(), "commit", conversation.CommitSha) } -// APIURL returns the absolute APIURL to this issue. +// APIURL returns the absolute APIURL to this conversation. func (conversation *Conversation) APIURL(ctx context.Context) string { if conversation.Repo == nil { err := conversation.LoadRepo(ctx) diff --git a/models/conversations/conversation_list.go b/models/conversations/conversation_list.go index de32b148ba132..7e080114f6c16 100644 --- a/models/conversations/conversation_list.go +++ b/models/conversations/conversation_list.go @@ -108,11 +108,6 @@ func (conversations ConversationList) LoadAttachments(ctx context.Context) (err left -= limit conversationsIDs = conversationsIDs[limit:] } - - for _, conversation := range conversations { - conversation.Attachments = attachments[conversation.ID] - conversation.isAttachmentsLoaded = true - } return nil } diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go index 209f3d9b3de9b..ee53091043368 100644 --- a/models/conversations/conversation_update.go +++ b/models/conversations/conversation_update.go @@ -283,18 +283,6 @@ func DeleteConversationsByRepoID(ctx context.Context, repoID int64) (attachmentP return nil, err } - // Dependencies for conversations in this repository - _, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationDependency{}) - if err != nil { - return nil, err - } - - // Delete dependencies for conversations in other repositories - _, err = sess.In("dependency_id", conversationIDs).Delete(&ConversationDependency{}) - if err != nil { - return nil, err - } - _, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationUser{}) if err != nil { return nil, err diff --git a/models/conversations/dependency.go b/models/conversations/dependency.go deleted file mode 100644 index 8382ede90eafd..0000000000000 --- a/models/conversations/dependency.go +++ /dev/null @@ -1,222 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package conversations - -import ( - "context" - "fmt" - - "code.gitea.io/gitea/models/db" - user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/timeutil" - "code.gitea.io/gitea/modules/util" -) - -// ErrDependencyExists represents a "DependencyAlreadyExists" kind of error. -type ErrDependencyExists struct { - ConversationID int64 - DependencyID int64 -} - -// IsErrDependencyExists checks if an error is a ErrDependencyExists. -func IsErrDependencyExists(err error) bool { - _, ok := err.(ErrDependencyExists) - return ok -} - -func (err ErrDependencyExists) Error() string { - return fmt.Sprintf("conversation dependency does already exist [conversation id: %d, dependency id: %d]", err.ConversationID, err.DependencyID) -} - -func (err ErrDependencyExists) Unwrap() error { - return util.ErrAlreadyExist -} - -// ErrDependencyNotExists represents a "DependencyAlreadyExists" kind of error. -type ErrDependencyNotExists struct { - ConversationID int64 - DependencyID int64 -} - -// IsErrDependencyNotExists checks if an error is a ErrDependencyExists. -func IsErrDependencyNotExists(err error) bool { - _, ok := err.(ErrDependencyNotExists) - return ok -} - -func (err ErrDependencyNotExists) Error() string { - return fmt.Sprintf("conversation dependency does not exist [conversation id: %d, dependency id: %d]", err.ConversationID, err.DependencyID) -} - -func (err ErrDependencyNotExists) Unwrap() error { - return util.ErrNotExist -} - -// ErrCircularDependency represents a "DependencyCircular" kind of error. -type ErrCircularDependency struct { - ConversationID int64 - DependencyID int64 -} - -// IsErrCircularDependency checks if an error is a ErrCircularDependency. -func IsErrCircularDependency(err error) bool { - _, ok := err.(ErrCircularDependency) - return ok -} - -func (err ErrCircularDependency) Error() string { - return fmt.Sprintf("circular dependencies exists (two conversations blocking each other) [conversation id: %d, dependency id: %d]", err.ConversationID, err.DependencyID) -} - -// ErrDependenciesLeft represents an error where the conversation you're trying to close still has dependencies left. -type ErrDependenciesLeft struct { - ConversationID int64 -} - -// IsErrDependenciesLeft checks if an error is a ErrDependenciesLeft. -func IsErrDependenciesLeft(err error) bool { - _, ok := err.(ErrDependenciesLeft) - return ok -} - -func (err ErrDependenciesLeft) Error() string { - return fmt.Sprintf("conversation has open dependencies [conversation id: %d]", err.ConversationID) -} - -// ErrUnknownDependencyType represents an error where an unknown dependency type was passed -type ErrUnknownDependencyType struct { - Type DependencyType -} - -// IsErrUnknownDependencyType checks if an error is ErrUnknownDependencyType -func IsErrUnknownDependencyType(err error) bool { - _, ok := err.(ErrUnknownDependencyType) - return ok -} - -func (err ErrUnknownDependencyType) Error() string { - return fmt.Sprintf("unknown dependency type [type: %d]", err.Type) -} - -func (err ErrUnknownDependencyType) Unwrap() error { - return util.ErrInvalidArgument -} - -// ConversationDependency represents an conversation dependency -type ConversationDependency struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"NOT NULL"` - ConversationID int64 `xorm:"UNIQUE(conversation_dependency) NOT NULL"` - DependencyID int64 `xorm:"UNIQUE(conversation_dependency) NOT NULL"` - CreatedUnix timeutil.TimeStamp `xorm:"created"` - UpdatedUnix timeutil.TimeStamp `xorm:"updated"` -} - -func init() { - db.RegisterModel(new(ConversationDependency)) -} - -// DependencyType Defines Dependency Type Constants -type DependencyType int - -// Define Dependency Types -const ( - DependencyTypeBlockedBy DependencyType = iota - DependencyTypeBlocking -) - -// CreateConversationDependency creates a new dependency for an conversation -func CreateConversationDependency(ctx context.Context, user *user_model.User, conversation, dep *Conversation) error { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - // Check if it already exists - exists, err := conversationDepExists(ctx, conversation.ID, dep.ID) - if err != nil { - return err - } - if exists { - return ErrDependencyExists{conversation.ID, dep.ID} - } - // And if it would be circular - circular, err := conversationDepExists(ctx, dep.ID, conversation.ID) - if err != nil { - return err - } - if circular { - return ErrCircularDependency{conversation.ID, dep.ID} - } - - if err := db.Insert(ctx, &ConversationDependency{ - UserID: user.ID, - ConversationID: conversation.ID, - DependencyID: dep.ID, - }); err != nil { - return err - } - - // Add comment referencing the new dependency - if err = createConversationDependencyComment(ctx, user, conversation, dep, true); err != nil { - return err - } - - return committer.Commit() -} - -// RemoveConversationDependency removes a dependency from an conversation -func RemoveConversationDependency(ctx context.Context, user *user_model.User, conversation, dep *Conversation, depType DependencyType) (err error) { - ctx, committer, err := db.TxContext(ctx) - if err != nil { - return err - } - defer committer.Close() - - var conversationDepToDelete ConversationDependency - - switch depType { - case DependencyTypeBlockedBy: - conversationDepToDelete = ConversationDependency{ConversationID: conversation.ID, DependencyID: dep.ID} - case DependencyTypeBlocking: - conversationDepToDelete = ConversationDependency{ConversationID: dep.ID, DependencyID: conversation.ID} - default: - return ErrUnknownDependencyType{depType} - } - - affected, err := db.GetEngine(ctx).Delete(&conversationDepToDelete) - if err != nil { - return err - } - - // If we deleted nothing, the dependency did not exist - if affected <= 0 { - return ErrDependencyNotExists{conversation.ID, dep.ID} - } - - // Add comment referencing the removed dependency - if err = createConversationDependencyComment(ctx, user, conversation, dep, false); err != nil { - return err - } - return committer.Commit() -} - -// Check if the dependency already exists -func conversationDepExists(ctx context.Context, conversationID, depID int64) (bool, error) { - return db.GetEngine(ctx).Where("(conversation_id = ? AND dependency_id = ?)", conversationID, depID).Exist(&ConversationDependency{}) -} - -// ConversationNoDependenciesLeft checks if conversation can be closed -func ConversationNoDependenciesLeft(ctx context.Context, conversation *Conversation) (bool, error) { - exists, err := db.GetEngine(ctx). - Table("conversation_dependency"). - Select("conversation.*"). - Join("INNER", "conversation", "conversation.id = conversation_dependency.dependency_id"). - Where("conversation_dependency.conversation_id = ?", conversation.ID). - And("conversation.is_closed = ?", "0"). - Exist(&Conversation{}) - - return !exists, err -} diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 1feaa7e2546dc..612549456525d 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -965,19 +965,6 @@ func ChangeConversationCommentReaction(ctx *context.Context) { }) } -// GetConversationAttachments returns attachments for the conversation -func GetConversationAttachments(ctx *context.Context) { - conversation := GetActionConversation(ctx) - if ctx.Written() { - return - } - attachments := make([]*api.Attachment, len(conversation.Attachments)) - for i := 0; i < len(conversation.Attachments); i++ { - attachments[i] = convert.ToAttachment(ctx.Repo.Repository, conversation.Attachments[i]) - } - ctx.JSON(http.StatusOK, attachments) -} - // GetCommentAttachments returns attachments for the comment func GetConversationCommentAttachments(ctx *context.Context) { comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) @@ -1020,8 +1007,6 @@ func GetConversationCommentAttachments(ctx *context.Context) { func updateConversationAttachments(ctx *context.Context, item any, files []string) error { var attachments []*repo_model.Attachment switch content := item.(type) { - case *conversations_model.Conversation: - attachments = content.Attachments case *conversations_model.Comment: attachments = content.Attachments default: @@ -1050,8 +1035,6 @@ func updateConversationAttachments(ctx *context.Context, item any, files []strin } } switch content := item.(type) { - case *conversations_model.Conversation: - content.Attachments, err = repo_model.GetAttachmentsByConversationID(ctx, content.ID) case *conversations_model.Comment: content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID) default: diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go index 1f3bb7d719c05..8518cbf245aea 100644 --- a/services/conversation/conversation.go +++ b/services/conversation/conversation.go @@ -9,10 +9,8 @@ import ( conversations_model "code.gitea.io/gitea/models/conversations" "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" - system_model "code.gitea.io/gitea/models/system" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/storage" ) // NewConversation creates new conversation with labels for repository. @@ -74,21 +72,15 @@ func deleteConversation(ctx context.Context, conversation *conversations_model.C return err } - for i := range conversation.Attachments { - system_model.RemoveStorageWithNotice(ctx, storage.Attachments, "Delete conversation attachment", conversation.Attachments[i].RelativePath()) - } - // delete all database data still assigned to this conversation if err := db.DeleteBeans(ctx, &conversations_model.ConversationContentHistory{ConversationID: conversation.ID}, &conversations_model.Comment{ConversationID: conversation.ID}, - &conversations_model.ConversationDependency{ConversationID: conversation.ID}, &conversations_model.ConversationUser{ConversationID: conversation.ID}, //&activities_model.Notification{ConversationID: conversation.ID}, &conversations_model.CommentReaction{ConversationID: conversation.ID}, &repo_model.Attachment{ConversationID: conversation.ID}, &conversations_model.Comment{ConversationID: conversation.ID}, - &conversations_model.ConversationDependency{DependencyID: conversation.ID}, &conversations_model.Comment{DependentConversationID: conversation.ID}, ); err != nil { return err diff --git a/services/convert/conversation.go b/services/convert/conversation.go index 01ed28cf1ca33..a3437afe93676 100644 --- a/services/convert/conversation.go +++ b/services/convert/conversation.go @@ -24,9 +24,6 @@ func toConversation(ctx context.Context, conversation *conversations_model.Conve if err := conversation.LoadRepo(ctx); err != nil { return &api.Conversation{} } - if err := conversation.LoadAttachments(ctx); err != nil { - return &api.Conversation{} - } apiConversation := &api.Conversation{ ID: conversation.ID, From fddda21ea2b2a53b661503a0a0bd4493c1564f24 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Thu, 31 Oct 2024 00:08:47 +0800 Subject: [PATCH 12/72] Fix comment form orphan tag issue with lint --- templates/repo/conversation/comment_form.tmpl | 29 +++---------------- .../conversation/comment_form_content.tmpl | 24 +++++++++++++++ 2 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 templates/repo/conversation/comment_form_content.tmpl diff --git a/templates/repo/conversation/comment_form.tmpl b/templates/repo/conversation/comment_form.tmpl index cc5e42a124b42..0e8e3efb91bc2 100644 --- a/templates/repo/conversation/comment_form.tmpl +++ b/templates/repo/conversation/comment_form.tmpl @@ -11,34 +11,13 @@
{{if .IsIssue}} + {{template "repo/conversation/comment_form_content" .}} + {{else}}
+ {{template "repo/conversation/comment_form_content" .}} +
{{end}} - {{template "repo/conversation/comment_tab" .}} - {{.CsrfTokenHtml}} - -
diff --git a/templates/repo/conversation/comment_form_content.tmpl b/templates/repo/conversation/comment_form_content.tmpl new file mode 100644 index 0000000000000..186a2d6a96669 --- /dev/null +++ b/templates/repo/conversation/comment_form_content.tmpl @@ -0,0 +1,24 @@ +{{template "repo/conversation/comment_tab" .}} +{{.CsrfTokenHtml}} + \ No newline at end of file From 1eff5bb38c93754c9dce9fae655e542aaadbeffa Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Thu, 31 Oct 2024 00:10:28 +0800 Subject: [PATCH 13/72] Remove unnecessary data from comment --- models/conversations/comment.go | 35 --------------------------- services/conversation/conversation.go | 1 - 2 files changed, 36 deletions(-) diff --git a/models/conversations/comment.go b/models/conversations/comment.go index 722dd988bf2f4..ab6d18a69280c 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -135,8 +135,6 @@ type Comment struct { ConversationID int64 `xorm:"INDEX"` Conversation *Conversation - DependentConversationID int64 `xorm:"INDEX"` - CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` @@ -166,39 +164,6 @@ func (c *Comment) LoadPoster(ctx context.Context) (err error) { return err } -// Creates conversation dependency comment -func createConversationDependencyComment(ctx context.Context, doer *user_model.User, conversation, dependentConversation *Conversation, add bool) (err error) { - cType := CommentTypeAddDependency - if !add { - cType = CommentTypeRemoveDependency - } - if err = conversation.LoadRepo(ctx); err != nil { - return err - } - - // Make two comments, one in each conversation - opts := &CreateCommentOptions{ - Type: cType, - Doer: doer, - Repo: conversation.Repo, - Conversation: conversation, - DependentConversationID: dependentConversation.ID, - } - if _, err = CreateComment(ctx, opts); err != nil { - return err - } - - opts = &CreateCommentOptions{ - Type: cType, - Doer: doer, - Repo: conversation.Repo, - Conversation: dependentConversation, - DependentConversationID: conversation.ID, - } - _, err = CreateComment(ctx, opts) - return err -} - // LoadReactions loads comment reactions func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository) (err error) { if c.Reactions != nil { diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go index 8518cbf245aea..53fac6e673535 100644 --- a/services/conversation/conversation.go +++ b/services/conversation/conversation.go @@ -81,7 +81,6 @@ func deleteConversation(ctx context.Context, conversation *conversations_model.C &conversations_model.CommentReaction{ConversationID: conversation.ID}, &repo_model.Attachment{ConversationID: conversation.ID}, &conversations_model.Comment{ConversationID: conversation.ID}, - &conversations_model.Comment{DependentConversationID: conversation.ID}, ); err != nil { return err } From ab083c85256712338128860515f1653cdf28c661 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Thu, 31 Oct 2024 21:46:00 +0800 Subject: [PATCH 14/72] Remove unused struct, fix duplicate table name --- models/conversations/comment.go | 104 ++++++++------------ models/conversations/comment_list.go | 8 +- models/conversations/conversation.go | 2 +- models/conversations/conversation_list.go | 10 +- models/conversations/conversation_update.go | 4 +- routers/web/repo/conversation.go | 10 +- services/conversation/comments.go | 6 +- services/conversation/conversation.go | 4 +- services/conversation/reaction.go | 2 +- services/convert/conversation_comment.go | 4 +- 10 files changed, 66 insertions(+), 88 deletions(-) diff --git a/models/conversations/comment.go b/models/conversations/comment.go index ab6d18a69280c..dcb79caa118d0 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -105,18 +105,11 @@ func (t CommentType) HasMailReplySupport() bool { return false } -// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database -type CommentMetaData struct { - ProjectColumnID int64 `json:"project_column_id,omitempty"` - ProjectColumnTitle string `json:"project_column_title,omitempty"` - ProjectTitle string `json:"project_title,omitempty"` -} - -// Comment represents a comment in commit and conversation page. -// Comment struct should not contain any pointers unrelated to Conversation unless absolutely necessary. +// ConversationComment represents a comment in commit and conversation page. +// ConversationComment struct should not contain any pointers unrelated to Conversation unless absolutely necessary. // To have pointers outside of conversation, create another comment type (e.g. ConversationComment) and use a converter to load it in. // The database data for the comments however, for all comment types, are defined here. -type Comment struct { +type ConversationComment struct { ID int64 `xorm:"pk autoincr"` Type CommentType `xorm:"INDEX"` @@ -143,11 +136,11 @@ type Comment struct { } func init() { - db.RegisterModel(new(Comment)) + db.RegisterModel(new(ConversationComment)) } // LoadPoster loads comment poster -func (c *Comment) LoadPoster(ctx context.Context) (err error) { +func (c *ConversationComment) LoadPoster(ctx context.Context) (err error) { if c.Poster != nil { return nil } @@ -165,7 +158,7 @@ func (c *Comment) LoadPoster(ctx context.Context) (err error) { } // LoadReactions loads comment reactions -func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository) (err error) { +func (c *ConversationComment) LoadReactions(ctx context.Context, repo *repo_model.Repository) (err error) { if c.Reactions != nil { return nil } @@ -184,7 +177,7 @@ func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository } // AfterDelete is invoked from XORM after the object is deleted. -func (c *Comment) AfterDelete(ctx context.Context) { +func (c *ConversationComment) AfterDelete(ctx context.Context) { if c.ID <= 0 { return } @@ -236,7 +229,7 @@ type CreateCommentOptions struct { } // CreateComment creates comment with context -func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { +func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *ConversationComment, err error) { ctx, committer, err := db.TxContext(ctx) if err != nil { return nil, err @@ -245,7 +238,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, e := db.GetEngine(ctx) - comment := &Comment{ + comment := &ConversationComment{ Type: opts.Type, PosterID: opts.Doer.ID, Poster: opts.Doer, @@ -267,8 +260,8 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, } // GetCommentByID returns the comment by given ID. -func GetCommentByID(ctx context.Context, id int64) (*Comment, error) { - c := new(Comment) +func GetCommentByID(ctx context.Context, id int64) (*ConversationComment, error) { + c := new(ConversationComment) has, err := db.GetEngine(ctx).ID(id).Get(c) if err != nil { return nil, err @@ -301,43 +294,28 @@ func (opts FindCommentsOptions) ToConds() builder.Cond { cond = cond.And(builder.Eq{"conversation.repo_id": opts.RepoID}) } if opts.ConversationID > 0 { - cond = cond.And(builder.Eq{"comment.conversation_id": opts.ConversationID}) + cond = cond.And(builder.Eq{"conversation_comment.conversation_id": opts.ConversationID}) } else if len(opts.ConversationIDs) > 0 { - cond = cond.And(builder.In("comment.conversation_id", opts.ConversationIDs)) - } - if opts.ReviewID > 0 { - cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) + cond = cond.And(builder.In("conversation_comment.conversation_id", opts.ConversationIDs)) } if opts.Since > 0 { - cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) + cond = cond.And(builder.Gte{"conversation_comment.updated_unix": opts.Since}) } if opts.Before > 0 { - cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before}) + cond = cond.And(builder.Lte{"conversation_comment.updated_unix": opts.Before}) } if opts.Type != CommentTypeUndefined { - cond = cond.And(builder.Eq{"comment.type": opts.Type}) - } - if opts.Line != 0 { - cond = cond.And(builder.Eq{"comment.line": opts.Line}) - } - if len(opts.TreePath) > 0 { - cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) - } - if opts.Invalidated.Has() { - cond = cond.And(builder.Eq{"comment.invalidated": opts.Invalidated.Value()}) - } - if opts.IsPull.Has() { - cond = cond.And(builder.Eq{"conversation.is_pull": opts.IsPull.Value()}) + cond = cond.And(builder.Eq{"conversation_comment.type": opts.Type}) } return cond } // FindComments returns all comments according options func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) { - comments := make([]*Comment, 0, 10) + comments := make([]*ConversationComment, 0, 10) sess := db.GetEngine(ctx).Where(opts.ToConds()) if opts.RepoID > 0 { - sess.Join("INNER", "conversation", "conversation.id = comment.conversation_id") + sess.Join("INNER", "conversation", "conversation.id = conversation_comment.conversation_id") } if opts.Page != 0 { @@ -347,8 +325,8 @@ func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, // WARNING: If you change this order you will need to fix createCodeComment return comments, sess. - Asc("comment.created_unix"). - Asc("comment.id"). + Asc("conversation_comment.created_unix"). + Asc("conversation_comment.id"). Find(&comments) } @@ -356,19 +334,19 @@ func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, func CountComments(ctx context.Context, opts *FindCommentsOptions) (int64, error) { sess := db.GetEngine(ctx).Where(opts.ToConds()) if opts.RepoID > 0 { - sess.Join("INNER", "conversation", "conversation.id = comment.conversation_id") + sess.Join("INNER", "conversation", "conversation.id = conversation_comment.conversation_id") } - return sess.Count(&Comment{}) + return sess.Count(&ConversationComment{}) } // UpdateCommentInvalidate updates comment invalidated column -func UpdateCommentInvalidate(ctx context.Context, c *Comment) error { +func UpdateCommentInvalidate(ctx context.Context, c *ConversationComment) error { _, err := db.GetEngine(ctx).ID(c.ID).Cols("invalidated").Update(c) return err } -// UpdateComment updates information of comment. -func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *user_model.User) error { +// UpdateComment updates information of comment +func UpdateComment(ctx context.Context, c *ConversationComment, contentVersion int, doer *user_model.User) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -393,7 +371,7 @@ func UpdateComment(ctx context.Context, c *Comment, contentVersion int, doer *us } // DeleteComment deletes the comment -func DeleteComment(ctx context.Context, comment *Comment) error { +func DeleteComment(ctx context.Context, comment *ConversationComment) error { e := db.GetEngine(ctx) if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil { return err @@ -418,11 +396,11 @@ func DeleteComment(ctx context.Context, comment *Comment) error { // UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id func UpdateCommentsMigrationsByType(ctx context.Context, tp structs.GitServiceType, originalAuthorID string, posterID int64) error { - _, err := db.GetEngine(ctx).Table("comment"). - Join("INNER", "conversation", "conversation.id = comment.conversation_id"). + _, err := db.GetEngine(ctx).Table("conversation_comment"). + Join("INNER", "conversation", "conversation.id = conversation_comment.conversation_id"). Join("INNER", "repository", "conversation.repo_id = repository.id"). Where("repository.original_service_type = ?", tp). - And("comment.original_author_id = ?", originalAuthorID). + And("conversation_comment.original_author_id = ?", originalAuthorID). Update(map[string]any{ "poster_id": posterID, "original_author": "", @@ -431,7 +409,7 @@ func UpdateCommentsMigrationsByType(ctx context.Context, tp structs.GitServiceTy return err } -func UpdateAttachments(ctx context.Context, opts *CreateCommentOptions, comment *Comment) error { +func UpdateAttachments(ctx context.Context, opts *CreateCommentOptions, comment *ConversationComment) error { attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) if err != nil { return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err) @@ -449,7 +427,7 @@ func UpdateAttachments(ctx context.Context, opts *CreateCommentOptions, comment } // LoadConversation loads the conversation reference for the comment -func (c *Comment) LoadConversation(ctx context.Context) (err error) { +func (c *ConversationComment) LoadConversation(ctx context.Context) (err error) { if c.Conversation != nil { return nil } @@ -458,7 +436,7 @@ func (c *Comment) LoadConversation(ctx context.Context) (err error) { } // LoadAttachments loads attachments (it never returns error, the error during `GetAttachmentsByCommentIDCtx` is ignored) -func (c *Comment) LoadAttachments(ctx context.Context) error { +func (c *ConversationComment) LoadAttachments(ctx context.Context) error { if len(c.Attachments) > 0 { return nil } @@ -472,7 +450,7 @@ func (c *Comment) LoadAttachments(ctx context.Context) error { } // UpdateAttachments update attachments by UUIDs for the comment -func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error { +func (c *ConversationComment) UpdateAttachments(ctx context.Context, uuids []string) error { ctx, committer, err := db.TxContext(ctx) if err != nil { return err @@ -494,16 +472,16 @@ func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error { } // HashTag returns unique hash tag for conversation. -func (c *Comment) HashTag() string { +func (c *ConversationComment) HashTag() string { return fmt.Sprintf("comment-%d", c.ID) } -func (c *Comment) hashLink() string { +func (c *ConversationComment) hashLink() string { return "#" + c.HashTag() } // HTMLURL formats a URL-string to the conversation-comment -func (c *Comment) HTMLURL(ctx context.Context) string { +func (c *ConversationComment) HTMLURL(ctx context.Context) string { err := c.LoadConversation(ctx) if err != nil { // Silently dropping errors :unamused: log.Error("LoadConversation(%d): %v", c.ConversationID, err) @@ -518,7 +496,7 @@ func (c *Comment) HTMLURL(ctx context.Context) string { } // APIURL formats a API-string to the conversation-comment -func (c *Comment) APIURL(ctx context.Context) string { +func (c *ConversationComment) APIURL(ctx context.Context) string { err := c.LoadConversation(ctx) if err != nil { // Silently dropping errors :unamused: log.Error("LoadConversation(%d): %v", c.ConversationID, err) @@ -534,11 +512,11 @@ func (c *Comment) APIURL(ctx context.Context) string { } // HasOriginalAuthor returns if a comment was migrated and has an original author. -func (c *Comment) HasOriginalAuthor() bool { +func (c *ConversationComment) HasOriginalAuthor() bool { return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 } -func (c *Comment) ConversationURL(ctx context.Context) string { +func (c *ConversationComment) ConversationURL(ctx context.Context) string { err := c.LoadConversation(ctx) if err != nil { // Silently dropping errors :unamused: log.Error("LoadConversation(%d): %v", c.ConversationID, err) @@ -554,12 +532,12 @@ func (c *Comment) ConversationURL(ctx context.Context) string { } // InsertConversationComments inserts many comments of conversations. -func InsertConversationComments(ctx context.Context, comments []*Comment) error { +func InsertConversationComments(ctx context.Context, comments []*ConversationComment) error { if len(comments) == 0 { return nil } - conversationIDs := container.FilterSlice(comments, func(comment *Comment) (int64, bool) { + conversationIDs := container.FilterSlice(comments, func(comment *ConversationComment) (int64, bool) { return comment.ConversationID, true }) diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go index 0781ad4ffae99..5acf409c027e2 100644 --- a/models/conversations/comment_list.go +++ b/models/conversations/comment_list.go @@ -12,7 +12,7 @@ import ( ) // CommentList defines a list of comments -type CommentList []*Comment +type CommentList []*ConversationComment // LoadPosters loads posters func (comments CommentList) LoadPosters(ctx context.Context) error { @@ -20,7 +20,7 @@ func (comments CommentList) LoadPosters(ctx context.Context) error { return nil } - posterIDs := container.FilterSlice(comments, func(c *Comment) (int64, bool) { + posterIDs := container.FilterSlice(comments, func(c *ConversationComment) (int64, bool) { return c.PosterID, c.Poster == nil && c.PosterID > 0 }) @@ -39,7 +39,7 @@ func (comments CommentList) LoadPosters(ctx context.Context) error { // getConversationIDs returns all the conversation ids on this comment list which conversation hasn't been loaded func (comments CommentList) getConversationIDs() []int64 { - return container.FilterSlice(comments, func(comment *Comment) (int64, bool) { + return container.FilterSlice(comments, func(comment *ConversationComment) (int64, bool) { return comment.ConversationID, comment.Conversation == nil }) } @@ -109,7 +109,7 @@ func (comments CommentList) LoadConversations(ctx context.Context) error { // getAttachmentCommentIDs only return the comment ids which possibly has attachments func (comments CommentList) getAttachmentCommentIDs() []int64 { - return container.FilterSlice(comments, func(comment *Comment) (int64, bool) { + return container.FilterSlice(comments, func(comment *ConversationComment) (int64, bool) { return comment.ID, comment.Type.HasAttachmentSupport() }) } diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index cc40e63f23dad..042649002eb56 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -327,7 +327,7 @@ func (conversation *Conversation) loadReactions(ctx context.Context) (err error) } // Cache comments to map - comments := make(map[int64]*Comment) + comments := make(map[int64]*ConversationComment) for _, comment := range conversation.Comments { comments[comment.ID] = comment } diff --git a/models/conversations/conversation_list.go b/models/conversations/conversation_list.go index 7e080114f6c16..2e64ef8535f25 100644 --- a/models/conversations/conversation_list.go +++ b/models/conversations/conversation_list.go @@ -116,7 +116,7 @@ func (conversations ConversationList) loadComments(ctx context.Context, cond bui return nil } - comments := make(map[int64][]*Comment, len(conversations)) + comments := make(map[int64][]*ConversationComment, len(conversations)) conversationsIDs := conversations.getConversationIDs() left := len(conversationsIDs) for left > 0 { @@ -124,18 +124,18 @@ func (conversations ConversationList) loadComments(ctx context.Context, cond bui if left < limit { limit = left } - rows, err := db.GetEngine(ctx).Table("comment"). - Join("INNER", "conversation", "conversation.id = comment.conversation_id"). + rows, err := db.GetEngine(ctx).Table("conversation_comment"). + Join("INNER", "conversation", "conversation.id = conversation_comment.conversation_id"). In("conversation.id", conversationsIDs[:limit]). Where(cond). NoAutoCondition(). - Rows(new(Comment)) + Rows(new(ConversationComment)) if err != nil { return err } for rows.Next() { - var comment Comment + var comment ConversationComment err = rows.Scan(&comment) if err != nil { if err1 := rows.Close(); err1 != nil { diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go index ee53091043368..eb12c34594dc2 100644 --- a/models/conversations/conversation_update.go +++ b/models/conversations/conversation_update.go @@ -278,7 +278,7 @@ func DeleteConversationsByRepoID(ctx context.Context, repoID int64) (attachmentP } // Delete comments and attachments - _, err = sess.In("conversation_id", conversationIDs).Delete(&Comment{}) + _, err = sess.In("conversation_id", conversationIDs).Delete(&ConversationComment{}) if err != nil { return nil, err } @@ -293,7 +293,7 @@ func DeleteConversationsByRepoID(ctx context.Context, repoID int64) (attachmentP return nil, err } - _, err = sess.In("dependent_conversation_id", conversationIDs).Delete(&Comment{}) + _, err = sess.In("dependent_conversation_id", conversationIDs).Delete(&ConversationComment{}) if err != nil { return nil, err } diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 612549456525d..93a427a38724c 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -226,7 +226,7 @@ func ViewConversation(ctx *context.Context) { role conversations_model.RoleDescriptor ok bool marked = make(map[int64]conversations_model.RoleDescriptor) - comment *conversations_model.Comment + comment *conversations_model.ConversationComment participants = make([]*user_model.User, 1, 10) latestCloseCommentID int64 ) @@ -715,7 +715,7 @@ func NewConversationComment(ctx *context.Context) { return } - var comment *conversations_model.Comment + var comment *conversations_model.ConversationComment defer func() { // Redirect to comment hashtag if there is any actual content. typeName := "commit" @@ -1007,7 +1007,7 @@ func GetConversationCommentAttachments(ctx *context.Context) { func updateConversationAttachments(ctx *context.Context, item any, files []string) error { var attachments []*repo_model.Attachment switch content := item.(type) { - case *conversations_model.Comment: + case *conversations_model.ConversationComment: attachments = content.Attachments default: return fmt.Errorf("unknown Type: %T", content) @@ -1025,7 +1025,7 @@ func updateConversationAttachments(ctx *context.Context, item any, files []strin switch content := item.(type) { case *conversations_model.Conversation: err = conversations_model.UpdateConversationAttachments(ctx, content.ID, files) - case *conversations_model.Comment: + case *conversations_model.ConversationComment: err = content.UpdateAttachments(ctx, files) default: return fmt.Errorf("unknown Type: %T", content) @@ -1035,7 +1035,7 @@ func updateConversationAttachments(ctx *context.Context, item any, files []strin } } switch content := item.(type) { - case *conversations_model.Comment: + case *conversations_model.ConversationComment: content.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, content.ID) default: return fmt.Errorf("unknown Type: %T", content) diff --git a/services/conversation/comments.go b/services/conversation/comments.go index c08a9016071f8..3b42c5a8cc98d 100644 --- a/services/conversation/comments.go +++ b/services/conversation/comments.go @@ -15,7 +15,7 @@ import ( ) // CreateConversationComment creates a plain conversation comment. -func CreateConversationComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, conversation *conversations_model.Conversation, content string, attachments []string) (*conversations_model.Comment, error) { +func CreateConversationComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, conversation *conversations_model.Conversation, content string, attachments []string) (*conversations_model.ConversationComment, error) { if user_model.IsUserBlockedBy(ctx, doer, repo.OwnerID) { if isAdmin, _ := access_model.IsUserRepoAdmin(ctx, repo, doer); !isAdmin { return nil, user_model.ErrBlockedUser @@ -41,7 +41,7 @@ func CreateConversationComment(ctx context.Context, doer *user_model.User, repo } // UpdateComment updates information of comment. -func UpdateComment(ctx context.Context, c *conversations_model.Comment, contentVersion int, doer *user_model.User, oldContent string) error { +func UpdateComment(ctx context.Context, c *conversations_model.ConversationComment, contentVersion int, doer *user_model.User, oldContent string) error { if err := c.LoadConversation(ctx); err != nil { return err } @@ -86,7 +86,7 @@ func UpdateComment(ctx context.Context, c *conversations_model.Comment, contentV } // DeleteComment deletes the comment -func DeleteComment(ctx context.Context, doer *user_model.User, comment *conversations_model.Comment) error { +func DeleteComment(ctx context.Context, doer *user_model.User, comment *conversations_model.ConversationComment) error { err := db.WithTx(ctx, func(ctx context.Context) error { return conversations_model.DeleteComment(ctx, comment) }) diff --git a/services/conversation/conversation.go b/services/conversation/conversation.go index 53fac6e673535..b89ec3deaf96d 100644 --- a/services/conversation/conversation.go +++ b/services/conversation/conversation.go @@ -75,12 +75,12 @@ func deleteConversation(ctx context.Context, conversation *conversations_model.C // delete all database data still assigned to this conversation if err := db.DeleteBeans(ctx, &conversations_model.ConversationContentHistory{ConversationID: conversation.ID}, - &conversations_model.Comment{ConversationID: conversation.ID}, + &conversations_model.ConversationComment{ConversationID: conversation.ID}, &conversations_model.ConversationUser{ConversationID: conversation.ID}, //&activities_model.Notification{ConversationID: conversation.ID}, &conversations_model.CommentReaction{ConversationID: conversation.ID}, &repo_model.Attachment{ConversationID: conversation.ID}, - &conversations_model.Comment{ConversationID: conversation.ID}, + &conversations_model.ConversationComment{ConversationID: conversation.ID}, ); err != nil { return err } diff --git a/services/conversation/reaction.go b/services/conversation/reaction.go index 0ba8041378ab0..2bb6f9f33adc2 100644 --- a/services/conversation/reaction.go +++ b/services/conversation/reaction.go @@ -11,7 +11,7 @@ import ( ) // CreateCommentReaction creates a reaction on a comment. -func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *conversations_model.Comment, content string) (*conversations_model.CommentReaction, error) { +func CreateCommentReaction(ctx context.Context, doer *user_model.User, comment *conversations_model.ConversationComment, content string) (*conversations_model.CommentReaction, error) { if err := comment.LoadConversation(ctx); err != nil { return nil, err } diff --git a/services/convert/conversation_comment.go b/services/convert/conversation_comment.go index b656c951662a7..3518c65187f8e 100644 --- a/services/convert/conversation_comment.go +++ b/services/convert/conversation_comment.go @@ -13,7 +13,7 @@ import ( ) // ToAPIComment converts a conversations_model.Comment to the api.Comment format for API usage -func ConversationToAPIComment(ctx context.Context, repo *repo_model.Repository, c *conversations_model.Comment) *api.Comment { +func ConversationToAPIComment(ctx context.Context, repo *repo_model.Repository, c *conversations_model.ConversationComment) *api.Comment { return &api.Comment{ ID: c.ID, Poster: ToUser(ctx, c.Poster, nil), @@ -27,7 +27,7 @@ func ConversationToAPIComment(ctx context.Context, repo *repo_model.Repository, } // ToTimelineComment converts a conversations_model.Comment to the api.TimelineComment format -func ConversationCommentToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *conversations_model.Comment, doer *user_model.User) *api.TimelineComment { +func ConversationCommentToTimelineComment(ctx context.Context, repo *repo_model.Repository, c *conversations_model.ConversationComment, doer *user_model.User) *api.TimelineComment { comment := &api.TimelineComment{ ID: c.ID, Type: c.Type.String(), From 60b750ece4f2ac5e87d33271518922d7d29f45d6 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 1 Nov 2024 02:11:43 +0800 Subject: [PATCH 15/72] Remove duplicate functions --- models/conversations/comment.go | 6 - routers/api/v1/repo/conversation_comment.go | 12 +- routers/web/repo/conversation.go | 135 ++++++++++---------- routers/web/repo/issue.go | 67 +--------- 4 files changed, 71 insertions(+), 149 deletions(-) diff --git a/models/conversations/comment.go b/models/conversations/comment.go index dcb79caa118d0..e18434eea23e8 100644 --- a/models/conversations/comment.go +++ b/models/conversations/comment.go @@ -15,7 +15,6 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/translation" @@ -276,15 +275,10 @@ type FindCommentsOptions struct { db.ListOptions RepoID int64 ConversationID int64 - ReviewID int64 Since int64 Before int64 - Line int64 - TreePath string Type CommentType ConversationIDs []int64 - Invalidated optional.Option[bool] - IsPull optional.Option[bool] } // ToConds implements FindOptions interface diff --git a/routers/api/v1/repo/conversation_comment.go b/routers/api/v1/repo/conversation_comment.go index ba9a910841cb4..4ab352b0d6869 100644 --- a/routers/api/v1/repo/conversation_comment.go +++ b/routers/api/v1/repo/conversation_comment.go @@ -11,7 +11,6 @@ import ( conversations_model "code.gitea.io/gitea/models/conversations" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/optional" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/api/v1/utils" @@ -253,16 +252,8 @@ func ListRepoConversationComments(ctx *context.APIContext) { return } - var isPull optional.Option[bool] canReadConversation := ctx.Repo.CanRead(unit.TypeConversations) - canReadPull := ctx.Repo.CanRead(unit.TypePullRequests) - if canReadConversation && canReadPull { - isPull = optional.None[bool]() - } else if canReadConversation { - isPull = optional.Some(false) - } else if canReadPull { - isPull = optional.Some(true) - } else { + if !canReadConversation { ctx.NotFound() return } @@ -273,7 +264,6 @@ func ListRepoConversationComments(ctx *context.APIContext) { Type: conversations_model.CommentTypeComment, Since: since, Before: before, - IsPull: isPull, } comments, err := conversations_model.FindComments(ctx, opts) diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 93a427a38724c..801adfc412b65 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -430,72 +430,7 @@ func SearchConversations(ctx *context.Context) { isClosed = optional.Some(false) } - var ( - repoIDs []int64 - allPublic bool - ) - { - // find repos user can access (for conversation search) - opts := &repo_model.SearchRepoOptions{ - Private: false, - AllPublic: true, - TopicOnly: false, - Collaborate: optional.None[bool](), - // This needs to be a column that is not nil in fixtures or - // MySQL will return different results when sorting by null in some cases - OrderBy: db.SearchOrderByAlphabetically, - Actor: ctx.Doer, - } - if ctx.IsSigned { - opts.Private = true - opts.AllLimited = true - } - if ctx.FormString("owner") != "" { - owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) - if err != nil { - if user_model.IsErrUserNotExist(err) { - ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) - } - return - } - opts.OwnerID = owner.ID - opts.AllLimited = false - opts.AllPublic = false - opts.Collaborate = optional.Some(false) - } - if ctx.FormString("team") != "" { - if ctx.FormString("owner") == "" { - ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") - return - } - team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) - if err != nil { - if organization.IsErrTeamNotExist(err) { - ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) - } - return - } - opts.TeamID = team.ID - } - - if opts.AllPublic { - allPublic = true - opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer - } - repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) - if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) - return - } - if len(repoIDs) == 0 { - // no repos found, don't let the indexer return all repos - repoIDs = []int64{0} - } - } + repoIDs, allPublic := GetUserAccessibleRepo(ctx) keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { @@ -568,6 +503,74 @@ func SearchConversations(ctx *context.Context) { ctx.JSON(http.StatusOK, convert.ToConversationList(ctx, ctx.Doer, conversations)) } +func GetUserAccessibleRepo(ctx *context.Context) ([]int64, bool) { + var ( + repoIDs []int64 + allPublic bool + ) + // find repos user can access (for conversation search) + opts := &repo_model.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: optional.None[bool](), + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return nil, false + } + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = optional.Some(false) + } + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + return nil, false + } + team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return nil, false + } + opts.TeamID = team.ID + } + + if opts.AllPublic { + allPublic = true + opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer + } + repoIDs, _, err := repo_model.SearchRepositoryIDs(ctx, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) + return nil, false + } + if len(repoIDs) == 0 { + // no repos found, don't let the indexer return all repos + repoIDs = []int64{0} + } + return repoIDs, allPublic +} + // ListConversations list the conversations of a repository func ListConversations(ctx *context.Context) { before, since, err := context.GetQueryBeforeSince(ctx.Base) diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index d8b65c1882294..cc787545538e6 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2575,72 +2575,7 @@ func SearchIssues(ctx *context.Context) { isClosed = optional.Some(false) } - var ( - repoIDs []int64 - allPublic bool - ) - { - // find repos user can access (for issue search) - opts := &repo_model.SearchRepoOptions{ - Private: false, - AllPublic: true, - TopicOnly: false, - Collaborate: optional.None[bool](), - // This needs to be a column that is not nil in fixtures or - // MySQL will return different results when sorting by null in some cases - OrderBy: db.SearchOrderByAlphabetically, - Actor: ctx.Doer, - } - if ctx.IsSigned { - opts.Private = true - opts.AllLimited = true - } - if ctx.FormString("owner") != "" { - owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner")) - if err != nil { - if user_model.IsErrUserNotExist(err) { - ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) - } - return - } - opts.OwnerID = owner.ID - opts.AllLimited = false - opts.AllPublic = false - opts.Collaborate = optional.Some(false) - } - if ctx.FormString("team") != "" { - if ctx.FormString("owner") == "" { - ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") - return - } - team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team")) - if err != nil { - if organization.IsErrTeamNotExist(err) { - ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) - } else { - ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) - } - return - } - opts.TeamID = team.ID - } - - if opts.AllPublic { - allPublic = true - opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer - } - repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts) - if err != nil { - ctx.Error(http.StatusInternalServerError, "SearchRepositoryIDs", err.Error()) - return - } - if len(repoIDs) == 0 { - // no repos found, don't let the indexer return all repos - repoIDs = []int64{0} - } - } + repoIDs, allPublic := GetUserAccessibleRepo(ctx) keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { From b1af5cd6be213be5c39b2cf3ba839a8a40d3f0de Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 1 Nov 2024 02:46:55 +0800 Subject: [PATCH 16/72] Rename variables in conversation.go --- routers/web/repo/conversation.go | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 801adfc412b65..5106570012324 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -869,23 +869,23 @@ func DeleteConversationComment(ctx *context.Context) { // ChangeCommentReaction create a reaction for comment func ChangeConversationCommentReaction(ctx *context.Context) { form := web.GetForm(ctx).(*forms.ReactionForm) - comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + conversation_comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) if err != nil { - ctx.NotFoundOrServerError("GetCommentByID", conversations_model.IsErrCommentNotExist, err) + ctx.NotFoundOrServerError("GetConversationCommentByID", conversations_model.IsErrCommentNotExist, err) return } - if err := comment.LoadConversation(ctx); err != nil { + if err := conversation_comment.LoadConversation(ctx); err != nil { ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err) return } - if comment.Conversation.RepoID != ctx.Repo.Repository.ID { + if conversation_comment.Conversation.RepoID != ctx.Repo.Repository.ID { ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{}) return } - if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadConversations()) { + if !ctx.IsSigned || (ctx.Doer.ID != conversation_comment.PosterID && !ctx.Repo.CanReadConversations()) { if log.IsTrace() { if ctx.IsSigned { conversationType := "conversations" @@ -904,50 +904,50 @@ func ChangeConversationCommentReaction(ctx *context.Context) { return } - if !comment.Type.HasContentSupport() { + if !conversation_comment.Type.HasContentSupport() { ctx.Error(http.StatusNoContent) return } switch ctx.PathParam(":action") { case "react": - reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) + reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, conversation_comment, form.Content) if err != nil { if conversations_model.IsErrForbiddenConversationReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.ServerError("ChangeConversationReaction", err) return } - log.Info("CreateCommentReaction: %s", err) + log.Info("CreateConversationCommentReaction: %s", err) break } // Reload new reactions - comment.Reactions = nil - if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { - log.Info("comment.LoadReactions: %s", err) + conversation_comment.Reactions = nil + if err = conversation_comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + log.Info("conversation_comment.LoadReactions: %s", err) break } - log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID, reaction.ID) + log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, conversation_comment.Conversation.ID, conversation_comment.ID, reaction.ID) case "unreact": - if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Conversation.ID, comment.ID, form.Content); err != nil { - ctx.ServerError("DeleteCommentReaction", err) + if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, conversation_comment.Conversation.ID, conversation_comment.ID, form.Content); err != nil { + ctx.ServerError("DeleteConversationCommentReaction", err) return } // Reload new reactions - comment.Reactions = nil - if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + conversation_comment.Reactions = nil + if err = conversation_comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { log.Info("comment.LoadReactions: %s", err) break } - log.Trace("Reaction for comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID) + log.Trace("Reaction for conversation comment removed: %d/%d/%d", ctx.Repo.Repository.ID, conversation_comment.Conversation.ID, conversation_comment.ID) default: ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam(":action")), nil) return } - if len(comment.Reactions) == 0 { + if len(conversation_comment.Reactions) == 0 { ctx.JSON(http.StatusOK, map[string]any{ "empty": true, "html": "", @@ -956,8 +956,8 @@ func ChangeConversationCommentReaction(ctx *context.Context) { } html, err := ctx.RenderToHTML(tplReactions, map[string]any{ - "ActionURL": fmt.Sprintf("%s/conversations/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), - "Reactions": comment.Reactions.GroupByType(), + "ActionURL": fmt.Sprintf("%s/conversations/comments/%d/reactions", ctx.Repo.RepoLink, conversation_comment.ID), + "Reactions": conversation_comment.Reactions.GroupByType(), }) if err != nil { ctx.ServerError("ChangeCommentReaction.HTMLString", err) From 8509c5c2aa3c3fe56c5d0af5341751e84ce10d29 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 1 Nov 2024 02:54:17 +0800 Subject: [PATCH 17/72] Generate Swagger --- templates/swagger/v1_json.tmpl | 530 +++++++++++++++++++++++++++++++++ 1 file changed, 530 insertions(+) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4cbf511aacbe8..791e795a751a3 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5876,6 +5876,528 @@ } } }, + "/repos/{owner}/{repo}/conversations/comments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "List all comments in a repository", + "operationId": "conversationGetRepoComments", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated since the provided time are returned.", + "name": "since", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated before the provided time are returned.", + "name": "before", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/CommentList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/conversations/comments/{id}": { + "get": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "Get a comment", + "operationId": "conversationGetComment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Comment" + }, + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "tags": [ + "conversation" + ], + "summary": "Delete a comment", + "operationId": "conversationDeleteComment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of comment to delete", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "Edit a comment", + "operationId": "conversationEditComment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditConversationCommentOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Comment" + }, + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/conversations/{index}/comments": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "List all comments on an conversation", + "operationId": "conversationGetComments", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of the conversation", + "name": "index", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated since the specified time are returned.", + "name": "since", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated before the provided time are returned.", + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/CommentList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "Add a comment to an conversation", + "operationId": "conversationCreateComment", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of the conversation", + "name": "index", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/CreateConversationCommentOption" + } + } + ], + "responses": { + "201": { + "$ref": "#/responses/Comment" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + }, + "423": { + "$ref": "#/responses/repoArchivedError" + } + } + } + }, + "/repos/{owner}/{repo}/conversations/{index}/comments/{id}": { + "delete": { + "tags": [ + "conversation" + ], + "summary": "Delete a comment", + "operationId": "conversationDeleteCommentDeprecated", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "this parameter is ignored", + "name": "index", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of comment to delete", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "patch": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "Edit a comment", + "operationId": "conversationEditCommentDeprecated", + "deprecated": true, + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "this parameter is ignored", + "name": "index", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "id of the comment to edit", + "name": "id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/EditConversationCommentOption" + } + } + ], + "responses": { + "200": { + "$ref": "#/responses/Comment" + }, + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/conversations/{index}/timeline": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "conversation" + ], + "summary": "List all comments and events on an conversation", + "operationId": "conversationGetCommentsAndTimeline", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "integer", + "format": "int64", + "description": "index of the conversation", + "name": "index", + "in": "path", + "required": true + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated since the specified time are returned.", + "name": "since", + "in": "query" + }, + { + "type": "integer", + "description": "page number of results to return (1-based)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "page size of results", + "name": "limit", + "in": "query" + }, + { + "type": "string", + "format": "date-time", + "description": "if provided, only comments updated before the provided time are returned.", + "name": "before", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/TimelineList" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/diffpatch": { "post": { "consumes": [ @@ -19105,6 +19627,10 @@ "type": "string", "x-go-name": "Body" }, + "conversation_url": { + "type": "string", + "x-go-name": "ConversationURL" + }, "created_at": { "type": "string", "format": "date-time", @@ -24647,6 +25173,10 @@ "type": "string", "x-go-name": "Body" }, + "conversation_url": { + "type": "string", + "x-go-name": "ConversationURL" + }, "created_at": { "type": "string", "format": "date-time", From b03448bc4253902ec5cc5518d4de2c23bcaf585e Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 1 Nov 2024 02:56:21 +0800 Subject: [PATCH 18/72] Revert variable name to adhere to codestyle --- routers/web/repo/conversation.go | 34 ++++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 5106570012324..5fe872dbe7b13 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -869,23 +869,23 @@ func DeleteConversationComment(ctx *context.Context) { // ChangeCommentReaction create a reaction for comment func ChangeConversationCommentReaction(ctx *context.Context) { form := web.GetForm(ctx).(*forms.ReactionForm) - conversation_comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) + comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) if err != nil { ctx.NotFoundOrServerError("GetConversationCommentByID", conversations_model.IsErrCommentNotExist, err) return } - if err := conversation_comment.LoadConversation(ctx); err != nil { + if err := comment.LoadConversation(ctx); err != nil { ctx.NotFoundOrServerError("LoadConversation", conversations_model.IsErrConversationNotExist, err) return } - if conversation_comment.Conversation.RepoID != ctx.Repo.Repository.ID { + if comment.Conversation.RepoID != ctx.Repo.Repository.ID { ctx.NotFound("CompareRepoID", conversations_model.ErrCommentNotExist{}) return } - if !ctx.IsSigned || (ctx.Doer.ID != conversation_comment.PosterID && !ctx.Repo.CanReadConversations()) { + if !ctx.IsSigned || (ctx.Doer.ID != comment.PosterID && !ctx.Repo.CanReadConversations()) { if log.IsTrace() { if ctx.IsSigned { conversationType := "conversations" @@ -904,14 +904,14 @@ func ChangeConversationCommentReaction(ctx *context.Context) { return } - if !conversation_comment.Type.HasContentSupport() { + if !comment.Type.HasContentSupport() { ctx.Error(http.StatusNoContent) return } switch ctx.PathParam(":action") { case "react": - reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, conversation_comment, form.Content) + reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) if err != nil { if conversations_model.IsErrForbiddenConversationReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { ctx.ServerError("ChangeConversationReaction", err) @@ -921,33 +921,33 @@ func ChangeConversationCommentReaction(ctx *context.Context) { break } // Reload new reactions - conversation_comment.Reactions = nil - if err = conversation_comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { - log.Info("conversation_comment.LoadReactions: %s", err) + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) break } - log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, conversation_comment.Conversation.ID, conversation_comment.ID, reaction.ID) + log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID, reaction.ID) case "unreact": - if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, conversation_comment.Conversation.ID, conversation_comment.ID, form.Content); err != nil { + if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Conversation.ID, comment.ID, form.Content); err != nil { ctx.ServerError("DeleteConversationCommentReaction", err) return } // Reload new reactions - conversation_comment.Reactions = nil - if err = conversation_comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { log.Info("comment.LoadReactions: %s", err) break } - log.Trace("Reaction for conversation comment removed: %d/%d/%d", ctx.Repo.Repository.ID, conversation_comment.Conversation.ID, conversation_comment.ID) + log.Trace("Reaction for conversation comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID) default: ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam(":action")), nil) return } - if len(conversation_comment.Reactions) == 0 { + if len(comment.Reactions) == 0 { ctx.JSON(http.StatusOK, map[string]any{ "empty": true, "html": "", @@ -956,8 +956,8 @@ func ChangeConversationCommentReaction(ctx *context.Context) { } html, err := ctx.RenderToHTML(tplReactions, map[string]any{ - "ActionURL": fmt.Sprintf("%s/conversations/comments/%d/reactions", ctx.Repo.RepoLink, conversation_comment.ID), - "Reactions": conversation_comment.Reactions.GroupByType(), + "ActionURL": fmt.Sprintf("%s/conversations/comments/%d/reactions", ctx.Repo.RepoLink, comment.ID), + "Reactions": comment.Reactions.GroupByType(), }) if err != nil { ctx.ServerError("ChangeCommentReaction.HTMLString", err) From 706b919d9fac7756f2599e55bcd1fcff54131f24 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Fri, 1 Nov 2024 03:19:58 +0800 Subject: [PATCH 19/72] make fmt and add option to swagger --- models/conversations/conversation.go | 7 +++---- routers/api/v1/swagger/options.go | 6 ++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index 042649002eb56..408ba57d0c287 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -10,14 +10,13 @@ import ( "context" "fmt" - "code.gitea.io/gitea/modules/container" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" - "code.gitea.io/gitea/models/db" repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/container" + "code.gitea.io/gitea/modules/log" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1de58632d57fa..e2e93f2a8c948 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -205,4 +205,10 @@ type swaggerParameterBodies struct { // in:body UpdateVariableOption api.UpdateVariableOption + + // in:body + CreateConversationCommentOption api.CreateConversationCommentOption + + // in:body + EditConversationCommentOption api.EditConversationCommentOption } From 6976590c909a0f2329124707432fa3479224106a Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sat, 2 Nov 2024 00:41:48 +0800 Subject: [PATCH 20/72] Update v1_json.tmpl --- templates/swagger/v1_json.tmpl | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 791e795a751a3..f7adb399c74b4 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -20162,6 +20162,20 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "CreateConversationCommentOption": { + "description": "CreateIssueCommentOption options for creating a comment on an issue", + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string", + "x-go-name": "Body" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "CreateEmailOption": { "description": "CreateEmailOption options when creating email addresses", "type": "object", @@ -21348,6 +21362,20 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "EditConversationCommentOption": { + "description": "EditIssueCommentOption options for editing a comment", + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string", + "x-go-name": "Body" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "EditDeadlineOption": { "description": "EditDeadlineOption options for creating a deadline", "type": "object", @@ -26893,7 +26921,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/UpdateVariableOption" + "$ref": "#/definitions/EditConversationCommentOption" } }, "redirect": { From 2084a7a5c445be7bab75d563e736c9b01b890d17 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sat, 2 Nov 2024 02:35:21 +0800 Subject: [PATCH 21/72] Remove unneccessary api calls --- routers/api/v1/repo/conversation_comment.go | 89 --------------------- 1 file changed, 89 deletions(-) diff --git a/routers/api/v1/repo/conversation_comment.go b/routers/api/v1/repo/conversation_comment.go index 4ab352b0d6869..04adb35313e11 100644 --- a/routers/api/v1/repo/conversation_comment.go +++ b/routers/api/v1/repo/conversation_comment.go @@ -493,56 +493,6 @@ func EditConversationComment(ctx *context.APIContext) { editConversationComment(ctx, *form) } -// EditConversationCommentDeprecated modify a comment of an conversation -func EditConversationCommentDeprecated(ctx *context.APIContext) { - // swagger:operation PATCH /repos/{owner}/{repo}/conversations/{index}/comments/{id} conversation conversationEditCommentDeprecated - // --- - // summary: Edit a comment - // deprecated: true - // consumes: - // - application/json - // produces: - // - application/json - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: index - // in: path - // description: this parameter is ignored - // type: integer - // required: true - // - name: id - // in: path - // description: id of the comment to edit - // type: integer - // format: int64 - // required: true - // - name: body - // in: body - // schema: - // "$ref": "#/definitions/EditConversationCommentOption" - // responses: - // "200": - // "$ref": "#/responses/Comment" - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - - form := web.GetForm(ctx).(*api.EditConversationCommentOption) - editConversationComment(ctx, *form) -} - func editConversationComment(ctx *context.APIContext, form api.EditConversationCommentOption) { comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) if err != nil { @@ -621,45 +571,6 @@ func DeleteConversationComment(ctx *context.APIContext) { deleteConversationComment(ctx) } -// DeleteConversationCommentDeprecated delete a comment from an conversation -func DeleteConversationCommentDeprecated(ctx *context.APIContext) { - // swagger:operation DELETE /repos/{owner}/{repo}/conversations/{index}/comments/{id} conversation conversationDeleteCommentDeprecated - // --- - // summary: Delete a comment - // deprecated: true - // parameters: - // - name: owner - // in: path - // description: owner of the repo - // type: string - // required: true - // - name: repo - // in: path - // description: name of the repo - // type: string - // required: true - // - name: index - // in: path - // description: this parameter is ignored - // type: integer - // required: true - // - name: id - // in: path - // description: id of comment to delete - // type: integer - // format: int64 - // required: true - // responses: - // "204": - // "$ref": "#/responses/empty" - // "403": - // "$ref": "#/responses/forbidden" - // "404": - // "$ref": "#/responses/notFound" - - deleteConversationComment(ctx) -} - func deleteConversationComment(ctx *context.APIContext) { comment, err := conversations_model.GetCommentByID(ctx, ctx.PathParamInt64(":id")) if err != nil { From 940fa351d9e73b9ac37453fbc8700405d687bd00 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sat, 2 Nov 2024 02:44:04 +0800 Subject: [PATCH 22/72] Change v1_json.tmpl --- templates/swagger/v1_json.tmpl | 118 --------------------------------- 1 file changed, 118 deletions(-) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f7adb399c74b4..df65900a2f6e5 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -6210,124 +6210,6 @@ } } }, - "/repos/{owner}/{repo}/conversations/{index}/comments/{id}": { - "delete": { - "tags": [ - "conversation" - ], - "summary": "Delete a comment", - "operationId": "conversationDeleteCommentDeprecated", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "this parameter is ignored", - "name": "index", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "description": "id of comment to delete", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - }, - "patch": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "conversation" - ], - "summary": "Edit a comment", - "operationId": "conversationEditCommentDeprecated", - "deprecated": true, - "parameters": [ - { - "type": "string", - "description": "owner of the repo", - "name": "owner", - "in": "path", - "required": true - }, - { - "type": "string", - "description": "name of the repo", - "name": "repo", - "in": "path", - "required": true - }, - { - "type": "integer", - "description": "this parameter is ignored", - "name": "index", - "in": "path", - "required": true - }, - { - "type": "integer", - "format": "int64", - "description": "id of the comment to edit", - "name": "id", - "in": "path", - "required": true - }, - { - "name": "body", - "in": "body", - "schema": { - "$ref": "#/definitions/EditConversationCommentOption" - } - } - ], - "responses": { - "200": { - "$ref": "#/responses/Comment" - }, - "204": { - "$ref": "#/responses/empty" - }, - "403": { - "$ref": "#/responses/forbidden" - }, - "404": { - "$ref": "#/responses/notFound" - } - } - } - }, "/repos/{owner}/{repo}/conversations/{index}/timeline": { "get": { "produces": [ From 98baaa2446b7d11c75dab4d00ba48a38aac11306 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sat, 2 Nov 2024 03:14:12 +0800 Subject: [PATCH 23/72] Consolidate reaction web/repo logic --- routers/web/repo/conversation.go | 28 +--------- routers/web/repo/issue.go | 28 +--------- routers/web/repo/reaction.go | 90 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 52 deletions(-) create mode 100644 routers/web/repo/reaction.go diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 5fe872dbe7b13..2e4ccfcc37ffc 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -911,37 +911,13 @@ func ChangeConversationCommentReaction(ctx *context.Context) { switch ctx.PathParam(":action") { case "react": - reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) - if err != nil { - if conversations_model.IsErrForbiddenConversationReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { - ctx.ServerError("ChangeConversationReaction", err) - return - } - log.Info("CreateConversationCommentReaction: %s", err) - break - } - // Reload new reactions - comment.Reactions = nil - if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { - log.Info("comment.LoadReactions: %s", err) + if err = AddReaction(ctx, form, comment, nil); err != nil { break } - - log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID, reaction.ID) case "unreact": - if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Conversation.ID, comment.ID, form.Content); err != nil { - ctx.ServerError("DeleteConversationCommentReaction", err) - return - } - - // Reload new reactions - comment.Reactions = nil - if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { - log.Info("comment.LoadReactions: %s", err) + if err = RemoveReaction(ctx, form, comment, nil); err != nil { break } - - log.Trace("Reaction for conversation comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID) default: ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam(":action")), nil) return diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index cc787545538e6..d96dcb4975318 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -3261,37 +3261,13 @@ func ChangeIssueReaction(ctx *context.Context) { switch ctx.PathParam(":action") { case "react": - reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) - if err != nil { - if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { - ctx.ServerError("ChangeIssueReaction", err) - return - } - log.Info("CreateIssueReaction: %s", err) + if err := AddReaction(ctx, form, nil, issue); err != nil { break } - // Reload new reactions - issue.Reactions = nil - if err = issue.LoadAttributes(ctx); err != nil { - log.Info("issue.LoadAttributes: %s", err) - break - } - - log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) case "unreact": - if err := issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content); err != nil { - ctx.ServerError("DeleteIssueReaction", err) - return - } - - // Reload new reactions - issue.Reactions = nil - if err := issue.LoadAttributes(ctx); err != nil { - log.Info("issue.LoadAttributes: %s", err) + if err := RemoveReaction(ctx, form, nil, issue); err != nil { break } - - log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID) default: ctx.NotFound(fmt.Sprintf("Unknown action %s", ctx.PathParam(":action")), nil) return diff --git a/routers/web/repo/reaction.go b/routers/web/repo/reaction.go new file mode 100644 index 0000000000000..c3062bc49f8aa --- /dev/null +++ b/routers/web/repo/reaction.go @@ -0,0 +1,90 @@ +package repo + +import ( + "errors" + + conversations_model "code.gitea.io/gitea/models/conversations" + issues_model "code.gitea.io/gitea/models/issues" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" + conversation_service "code.gitea.io/gitea/services/conversation" + "code.gitea.io/gitea/services/forms" + issue_service "code.gitea.io/gitea/services/issue" +) + +func AddReaction(ctx *context.Context, form *forms.ReactionForm, comment *conversations_model.ConversationComment, issue *issues_model.Issue) error { + if issue != nil { + reaction, err := issue_service.CreateIssueReaction(ctx, ctx.Doer, issue, form.Content) + if err != nil { + if issues_model.IsErrForbiddenIssueReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { + ctx.ServerError("ChangeIssueReaction", err) + return err + } + log.Info("CreateIssueReaction: %s", err) + return err + } + // Reload new reactions + issue.Reactions = nil + if err = issue.LoadAttributes(ctx); err != nil { + log.Info("issue.LoadAttributes: %s", err) + return err + } + + log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) + } else if comment != nil { + + reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) + if err != nil { + if conversations_model.IsErrForbiddenConversationReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { + ctx.ServerError("ChangeConversationReaction", err) + return err + } + log.Info("CreateConversationCommentReaction: %s", err) + return err + } + // Reload new reactions + comment.Reactions = nil + if err = comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) + return err + } + + log.Trace("Reaction for comment created: %d/%d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID, reaction.ID) + } + + return nil +} + +func RemoveReaction(ctx *context.Context, form *forms.ReactionForm, comment *conversations_model.ConversationComment, issue *issues_model.Issue) error { + if issue != nil { + if err := issues_model.DeleteIssueReaction(ctx, ctx.Doer.ID, issue.ID, form.Content); err != nil { + ctx.ServerError("DeleteIssueReaction", err) + return err + } + + // Reload new reactions + issue.Reactions = nil + if err := issue.LoadAttributes(ctx); err != nil { + log.Info("issue.LoadAttributes: %s", err) + return err + } + + log.Trace("Reaction for issue removed: %d/%d", ctx.Repo.Repository.ID, issue.ID) + } else if comment != nil { + if err := conversations_model.DeleteCommentReaction(ctx, ctx.Doer.ID, comment.Conversation.ID, comment.ID, form.Content); err != nil { + ctx.ServerError("DeleteConversationCommentReaction", err) + return err + } + + // Reload new reactions + comment.Reactions = nil + if err := comment.LoadReactions(ctx, ctx.Repo.Repository); err != nil { + log.Info("comment.LoadReactions: %s", err) + return err + } + + log.Trace("Reaction for conversation comment removed: %d/%d/%d", ctx.Repo.Repository.ID, comment.Conversation.ID, comment.ID) + } + return nil +} From 5a49efbd3eeab30c12fbc495a6705e3f3ce25dce Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sat, 2 Nov 2024 03:21:39 +0800 Subject: [PATCH 24/72] Update reaction.go to conform to lint revive --- routers/web/repo/reaction.go | 1 - 1 file changed, 1 deletion(-) diff --git a/routers/web/repo/reaction.go b/routers/web/repo/reaction.go index c3062bc49f8aa..4bc06e3abe557 100644 --- a/routers/web/repo/reaction.go +++ b/routers/web/repo/reaction.go @@ -33,7 +33,6 @@ func AddReaction(ctx *context.Context, form *forms.ReactionForm, comment *conver log.Trace("Reaction for issue created: %d/%d/%d", ctx.Repo.Repository.ID, issue.ID, reaction.ID) } else if comment != nil { - reaction, err := conversation_service.CreateCommentReaction(ctx, ctx.Doer, comment, form.Content) if err != nil { if conversations_model.IsErrForbiddenConversationReaction(err) || errors.Is(err, user_model.ErrBlockedUser) { From 933b4b48653ba4393a546e8de675ab3405430d87 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sat, 2 Nov 2024 03:43:42 +0800 Subject: [PATCH 25/72] Add copyright information to reaction.go --- routers/web/repo/reaction.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/routers/web/repo/reaction.go b/routers/web/repo/reaction.go index 4bc06e3abe557..c19edc4b8604f 100644 --- a/routers/web/repo/reaction.go +++ b/routers/web/repo/reaction.go @@ -1,3 +1,6 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package repo import ( From e344e371df4fb3b96fb462d3f492bb6a9555c64d Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Sun, 3 Nov 2024 22:29:30 +0800 Subject: [PATCH 26/72] Add unit test for conversations --- models/conversations/conversation.go | 24 ++ models/conversations/conversation_test.go | 267 ++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 models/conversations/conversation_test.go diff --git a/models/conversations/conversation.go b/models/conversations/conversation.go index 408ba57d0c287..43ff19762fc8e 100644 --- a/models/conversations/conversation.go +++ b/models/conversations/conversation.go @@ -338,3 +338,27 @@ func (conversation *Conversation) loadReactions(ctx context.Context) (err error) } return nil } + +// InsertConversations insert issues to database +func InsertConversations(ctx context.Context, conversations ...*Conversation) error { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return err + } + defer committer.Close() + + for _, conversation := range conversations { + if err := insertConversation(ctx, conversation); err != nil { + return err + } + } + return committer.Commit() +} + +func insertConversation(ctx context.Context, conversation *Conversation) error { + sess := db.GetEngine(ctx) + if _, err := sess.NoAutoTime().Insert(conversation); err != nil { + return err + } + return nil +} diff --git a/models/conversations/conversation_test.go b/models/conversations/conversation_test.go new file mode 100644 index 0000000000000..41a76a5b450e9 --- /dev/null +++ b/models/conversations/conversation_test.go @@ -0,0 +1,267 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/setting" + + "github.com/stretchr/testify/assert" + "xorm.io/builder" +) + +func Test_GetConversationIDsByRepoID(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ids, err := conversations_model.GetConversationIDsByRepoID(db.DefaultContext, 1) + assert.NoError(t, err) + assert.Len(t, ids, 5) +} + +func TestConversationAPIURL(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + conversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 1}) + err := conversation.LoadAttributes(db.DefaultContext) + + assert.NoError(t, err) + assert.Equal(t, "https://try.gitea.io/api/v1/repos/user2/repo1/conversations/1", conversation.APIURL(db.DefaultContext)) +} + +func TestGetConversationsByIDs(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + testSuccess := func(expectedConversationIDs, nonExistentConversationIDs []int64) { + conversations, err := conversations_model.GetConversationsByIDs(db.DefaultContext, append(expectedConversationIDs, nonExistentConversationIDs...), true) + assert.NoError(t, err) + actualConversationIDs := make([]int64, len(conversations)) + for i, conversation := range conversations { + actualConversationIDs[i] = conversation.ID + } + assert.Equal(t, expectedConversationIDs, actualConversationIDs) + } + testSuccess([]int64{1, 2, 3}, []int64{}) + testSuccess([]int64{1, 2, 3}, []int64{unittest.NonexistentID}) + testSuccess([]int64{3, 2, 1}, []int64{}) +} + +func TestUpdateConversationCols(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + conversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{}) + + now := time.Now().Unix() + assert.NoError(t, conversations_model.UpdateConversationCols(db.DefaultContext, conversation, "name")) + then := time.Now().Unix() + + updatedConversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: conversation.ID}) + unittest.AssertInt64InRange(t, now, then, int64(updatedConversation.UpdatedUnix)) +} + +func TestConversations(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + for _, test := range []struct { + Opts conversations_model.ConversationsOptions + ExpectedConversationIDs []int64 + }{ + { + conversations_model.ConversationsOptions{ + AssigneeID: 1, + SortType: "oldest", + }, + []int64{1, 6}, + }, + { + conversations_model.ConversationsOptions{ + RepoCond: builder.In("repo_id", 1, 3), + SortType: "oldest", + Paginator: &db.ListOptions{ + Page: 1, + PageSize: 4, + }, + }, + []int64{1, 2, 3, 5}, + }, + { + conversations_model.ConversationsOptions{ + LabelIDs: []int64{1}, + Paginator: &db.ListOptions{ + Page: 1, + PageSize: 4, + }, + }, + []int64{2, 1}, + }, + { + conversations_model.ConversationsOptions{ + LabelIDs: []int64{1, 2}, + Paginator: &db.ListOptions{ + Page: 1, + PageSize: 4, + }, + }, + []int64{}, // conversations with **both** label 1 and 2, none of these conversations matches, TODO: add more tests + }, + { + conversations_model.ConversationsOptions{ + MilestoneIDs: []int64{1}, + }, + []int64{2}, + }, + } { + conversations, err := conversations_model.Conversations(db.DefaultContext, &test.Opts) + assert.NoError(t, err) + if assert.Len(t, conversations, len(test.ExpectedConversationIDs)) { + for i, conversation := range conversations { + assert.EqualValues(t, test.ExpectedConversationIDs[i], conversation.ID) + } + } + } +} + +func TestConversation_InsertConversation(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // there are 5 conversations and max index is 5 on repository 1, so this one should 6 + conversation := testInsertConversation(t, "my conversation1", "special conversation's comments?", 6) + _, err := db.DeleteByID[conversations_model.Conversation](db.DefaultContext, conversation.ID) + assert.NoError(t, err) + + conversation = testInsertConversation(t, `my conversation2, this is my son's love \n \r \ `, "special conversation's '' comments?", 7) + _, err = db.DeleteByID[conversations_model.Conversation](db.DefaultContext, conversation.ID) + assert.NoError(t, err) +} + +func TestResourceIndex(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + testInsertConversation(t, fmt.Sprintf("conversation %d", i+1), "my conversation", 0) + wg.Done() + }(i) + } + wg.Wait() +} + +func TestCorrectConversationStats(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + // Because the condition is to have chunked database look-ups, + // We have to more conversations than `maxQueryParameters`, we will insert. + // maxQueryParameters + 10 conversations into the testDatabase. + // Each new conversations will have a constant description "Bugs are nasty" + // Which will be used later on. + + conversationAmount := conversations_model.MaxQueryParameters + 10 + + var wg sync.WaitGroup + for i := 0; i < conversationAmount; i++ { + wg.Add(1) + go func(i int) { + testInsertConversation(t, fmt.Sprintf("Conversation %d", i+1), "Bugs are nasty", 0) + wg.Done() + }(i) + } + wg.Wait() + + // Now we will get all conversationID's that match the "Bugs are nasty" query. + conversations, err := conversations_model.Conversations(context.TODO(), &conversations_model.ConversationsOptions{ + Paginator: &db.ListOptions{ + PageSize: conversationAmount, + }, + RepoIDs: []int64{1}, + }) + total := int64(len(conversations)) + var ids []int64 + for _, conversation := range conversations { + ids = append(ids, conversation.ID) + } + + // Just to be sure. + assert.NoError(t, err) + assert.EqualValues(t, conversationAmount, total) + + // Now we will call the GetConversationStats with these IDs and if working, + // get the correct stats back. + conversationStats, err := conversations_model.GetConversationStats(db.DefaultContext, &conversations_model.ConversationsOptions{ + RepoIDs: []int64{1}, + ConversationIDs: ids, + }) + + // Now check the values. + assert.NoError(t, err) + assert.EqualValues(t, conversationStats.OpenCount, conversationAmount) +} + +func TestCountConversations(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + count, err := conversations_model.CountConversations(db.DefaultContext, &conversations_model.ConversationsOptions{}) + assert.NoError(t, err) + assert.EqualValues(t, 22, count) +} + +func TestConversationLoadAttributes(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + setting.Service.EnableTimetracking = true + + conversationList := conversations_model.ConversationList{ + unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 1}), + unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 4}), + } + + for _, conversation := range conversationList { + assert.NoError(t, conversation.LoadAttributes(db.DefaultContext)) + assert.EqualValues(t, conversation.RepoID, conversation.Repo.ID) + for _, comment := range conversation.Comments { + assert.EqualValues(t, conversation.ID, comment.ConversationID) + } + } +} + +func assertCreateConversations(t *testing.T, isPull bool) { + assert.NoError(t, unittest.PrepareTestDatabase()) + reponame := "repo1" + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame}) + + conversationID := int64(99) + is := &conversations_model.Conversation{ + RepoID: repo.ID, + Repo: repo, + ID: conversationID, + } + err := conversations_model.InsertConversations(db.DefaultContext, is) + assert.NoError(t, err) + + unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{RepoID: repo.ID, ID: conversationID}) +} + +func testInsertConversation(t *testing.T, title, content string, expectIndex int64) *conversations_model.Conversation { + var newConversation conversations_model.Conversation + t.Run(title, func(t *testing.T) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + conversation := conversations_model.Conversation{ + RepoID: repo.ID, + } + err := conversations_model.NewConversation(db.DefaultContext, repo, &conversation, nil) + assert.NoError(t, err) + + has, err := db.GetEngine(db.DefaultContext).ID(conversation.ID).Get(&newConversation) + assert.NoError(t, err) + assert.True(t, has) + if expectIndex > 0 { + assert.EqualValues(t, expectIndex, newConversation.Index) + } + }) + return &newConversation +} From 5d7dcdf625047a93da4df6d282b4f790ba6aa85c Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 00:15:59 +0800 Subject: [PATCH 27/72] Add unit test for comments --- models/conversations/comment_list.go | 27 -------- models/conversations/comment_test.go | 80 +++++++++++++++++++++++ models/conversations/conversation_test.go | 2 +- models/conversations/main_test.go | 30 +++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 models/conversations/comment_test.go create mode 100644 models/conversations/main_test.go diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go index 5acf409c027e2..6f23950649cbe 100644 --- a/models/conversations/comment_list.go +++ b/models/conversations/comment_list.go @@ -14,29 +14,6 @@ import ( // CommentList defines a list of comments type CommentList []*ConversationComment -// LoadPosters loads posters -func (comments CommentList) LoadPosters(ctx context.Context) error { - if len(comments) == 0 { - return nil - } - - posterIDs := container.FilterSlice(comments, func(c *ConversationComment) (int64, bool) { - return c.PosterID, c.Poster == nil && c.PosterID > 0 - }) - - posterMaps, err := getPostersByIDs(ctx, posterIDs) - if err != nil { - return err - } - - for _, comment := range comments { - if comment.Poster == nil { - comment.Poster = getPoster(comment.PosterID, posterMaps) - } - } - return nil -} - // getConversationIDs returns all the conversation ids on this comment list which conversation hasn't been loaded func (comments CommentList) getConversationIDs() []int64 { return container.FilterSlice(comments, func(comment *ConversationComment) (int64, bool) { @@ -181,10 +158,6 @@ func (comments CommentList) LoadAttachments(ctx context.Context) (err error) { // LoadAttributes loads attributes of the comments, except for attachments and // comments func (comments CommentList) LoadAttributes(ctx context.Context) (err error) { - if err = comments.LoadPosters(ctx); err != nil { - return err - } - if err = comments.LoadAttachments(ctx); err != nil { return err } diff --git a/models/conversations/comment_test.go b/models/conversations/comment_test.go new file mode 100644 index 0000000000000..af5b30081516d --- /dev/null +++ b/models/conversations/comment_test.go @@ -0,0 +1,80 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations_test + +import ( + "testing" + "time" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestCreateComment(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + conversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: conversation.RepoID}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + now := time.Now().Unix() + comment, err := conversations_model.CreateComment(db.DefaultContext, &conversations_model.CreateCommentOptions{ + Type: conversations_model.CommentTypeComment, + Doer: doer, + Repo: repo, + Conversation: conversation, + Content: "Hello", + }) + assert.NoError(t, err) + then := time.Now().Unix() + + assert.EqualValues(t, conversations_model.CommentTypeComment, comment.Type) + assert.EqualValues(t, "Hello", comment.Content) + assert.EqualValues(t, conversation.ID, comment.ConversationID) + assert.EqualValues(t, doer.ID, comment.PosterID) + unittest.AssertInt64InRange(t, now, then, int64(comment.CreatedUnix)) + unittest.AssertExistsAndLoadBean(t, comment) // assert actually added to DB + + updatedConversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: conversation.ID}) + unittest.AssertInt64InRange(t, now, then, int64(updatedConversation.UpdatedUnix)) +} + +func TestAsCommentType(t *testing.T) { + assert.Equal(t, conversations_model.CommentType(0), conversations_model.CommentTypeComment) + assert.Equal(t, conversations_model.CommentTypeUndefined, conversations_model.AsCommentType("")) + assert.Equal(t, conversations_model.CommentTypeUndefined, conversations_model.AsCommentType("nonsense")) + assert.Equal(t, conversations_model.CommentTypeComment, conversations_model.AsCommentType("comment")) +} + +func TestMigrate_InsertConversationComments(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + conversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 1}) + _ = conversation.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: conversation.Repo.OwnerID}) + reaction := &conversations_model.CommentReaction{ + Type: "heart", + UserID: owner.ID, + } + + comment := &conversations_model.ConversationComment{ + PosterID: owner.ID, + Poster: owner, + ConversationID: conversation.ID, + Conversation: conversation, + Reactions: []*conversations_model.CommentReaction{reaction}, + } + + err := conversations_model.InsertConversationComments(db.DefaultContext, []*conversations_model.ConversationComment{comment}) + assert.NoError(t, err) + + conversationModified := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 1}) + assert.EqualValues(t, conversation.NumComments+1, conversationModified.NumComments) + + unittest.CheckConsistencyFor(t, &conversations_model.Conversation{}) +} diff --git a/models/conversations/conversation_test.go b/models/conversations/conversation_test.go index 41a76a5b450e9..cc60a03785c81 100644 --- a/models/conversations/conversation_test.go +++ b/models/conversations/conversation_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations_test diff --git a/models/conversations/main_test.go b/models/conversations/main_test.go new file mode 100644 index 0000000000000..ae47857d4452e --- /dev/null +++ b/models/conversations/main_test.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations_test + +import ( + "testing" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" + _ "code.gitea.io/gitea/models/repo" + _ "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestFixturesAreConsistent(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + unittest.CheckConsistencyFor(t, + &conversations_model.Conversation{}, + ) +} + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} From b1f07f2df0a83fca72e9c904fc6e6db8f8766744 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 00:15:59 +0800 Subject: [PATCH 28/72] Add unit test for comments --- models/conversations/comment_list.go | 27 -------- models/conversations/comment_test.go | 80 +++++++++++++++++++++++ models/conversations/conversation_test.go | 2 +- models/conversations/main_test.go | 30 +++++++++ 4 files changed, 111 insertions(+), 28 deletions(-) create mode 100644 models/conversations/comment_test.go create mode 100644 models/conversations/main_test.go diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go index 5acf409c027e2..6f23950649cbe 100644 --- a/models/conversations/comment_list.go +++ b/models/conversations/comment_list.go @@ -14,29 +14,6 @@ import ( // CommentList defines a list of comments type CommentList []*ConversationComment -// LoadPosters loads posters -func (comments CommentList) LoadPosters(ctx context.Context) error { - if len(comments) == 0 { - return nil - } - - posterIDs := container.FilterSlice(comments, func(c *ConversationComment) (int64, bool) { - return c.PosterID, c.Poster == nil && c.PosterID > 0 - }) - - posterMaps, err := getPostersByIDs(ctx, posterIDs) - if err != nil { - return err - } - - for _, comment := range comments { - if comment.Poster == nil { - comment.Poster = getPoster(comment.PosterID, posterMaps) - } - } - return nil -} - // getConversationIDs returns all the conversation ids on this comment list which conversation hasn't been loaded func (comments CommentList) getConversationIDs() []int64 { return container.FilterSlice(comments, func(comment *ConversationComment) (int64, bool) { @@ -181,10 +158,6 @@ func (comments CommentList) LoadAttachments(ctx context.Context) (err error) { // LoadAttributes loads attributes of the comments, except for attachments and // comments func (comments CommentList) LoadAttributes(ctx context.Context) (err error) { - if err = comments.LoadPosters(ctx); err != nil { - return err - } - if err = comments.LoadAttachments(ctx); err != nil { return err } diff --git a/models/conversations/comment_test.go b/models/conversations/comment_test.go new file mode 100644 index 0000000000000..af5b30081516d --- /dev/null +++ b/models/conversations/comment_test.go @@ -0,0 +1,80 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations_test + +import ( + "testing" + "time" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestCreateComment(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + conversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: conversation.RepoID}) + doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + + now := time.Now().Unix() + comment, err := conversations_model.CreateComment(db.DefaultContext, &conversations_model.CreateCommentOptions{ + Type: conversations_model.CommentTypeComment, + Doer: doer, + Repo: repo, + Conversation: conversation, + Content: "Hello", + }) + assert.NoError(t, err) + then := time.Now().Unix() + + assert.EqualValues(t, conversations_model.CommentTypeComment, comment.Type) + assert.EqualValues(t, "Hello", comment.Content) + assert.EqualValues(t, conversation.ID, comment.ConversationID) + assert.EqualValues(t, doer.ID, comment.PosterID) + unittest.AssertInt64InRange(t, now, then, int64(comment.CreatedUnix)) + unittest.AssertExistsAndLoadBean(t, comment) // assert actually added to DB + + updatedConversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: conversation.ID}) + unittest.AssertInt64InRange(t, now, then, int64(updatedConversation.UpdatedUnix)) +} + +func TestAsCommentType(t *testing.T) { + assert.Equal(t, conversations_model.CommentType(0), conversations_model.CommentTypeComment) + assert.Equal(t, conversations_model.CommentTypeUndefined, conversations_model.AsCommentType("")) + assert.Equal(t, conversations_model.CommentTypeUndefined, conversations_model.AsCommentType("nonsense")) + assert.Equal(t, conversations_model.CommentTypeComment, conversations_model.AsCommentType("comment")) +} + +func TestMigrate_InsertConversationComments(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + conversation := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 1}) + _ = conversation.LoadRepo(db.DefaultContext) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: conversation.Repo.OwnerID}) + reaction := &conversations_model.CommentReaction{ + Type: "heart", + UserID: owner.ID, + } + + comment := &conversations_model.ConversationComment{ + PosterID: owner.ID, + Poster: owner, + ConversationID: conversation.ID, + Conversation: conversation, + Reactions: []*conversations_model.CommentReaction{reaction}, + } + + err := conversations_model.InsertConversationComments(db.DefaultContext, []*conversations_model.ConversationComment{comment}) + assert.NoError(t, err) + + conversationModified := unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{ID: 1}) + assert.EqualValues(t, conversation.NumComments+1, conversationModified.NumComments) + + unittest.CheckConsistencyFor(t, &conversations_model.Conversation{}) +} diff --git a/models/conversations/conversation_test.go b/models/conversations/conversation_test.go index 41a76a5b450e9..cc60a03785c81 100644 --- a/models/conversations/conversation_test.go +++ b/models/conversations/conversation_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations_test diff --git a/models/conversations/main_test.go b/models/conversations/main_test.go new file mode 100644 index 0000000000000..ae47857d4452e --- /dev/null +++ b/models/conversations/main_test.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations_test + +import ( + "testing" + + conversations_model "code.gitea.io/gitea/models/conversations" + "code.gitea.io/gitea/models/unittest" + + _ "code.gitea.io/gitea/models" + _ "code.gitea.io/gitea/models/actions" + _ "code.gitea.io/gitea/models/activities" + _ "code.gitea.io/gitea/models/repo" + _ "code.gitea.io/gitea/models/user" + + "github.com/stretchr/testify/assert" +) + +func TestFixturesAreConsistent(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + unittest.CheckConsistencyFor(t, + &conversations_model.Conversation{}, + ) +} + +func TestMain(m *testing.M) { + unittest.MainTest(m) +} From 56fca5e557a2fbc2dfdefda32d8f6f9f22d4fb76 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 00:28:28 +0800 Subject: [PATCH 29/72] Fix LoadPosters remove from conversation comment list --- models/conversations/comment_list.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go index 6f23950649cbe..5acf409c027e2 100644 --- a/models/conversations/comment_list.go +++ b/models/conversations/comment_list.go @@ -14,6 +14,29 @@ import ( // CommentList defines a list of comments type CommentList []*ConversationComment +// LoadPosters loads posters +func (comments CommentList) LoadPosters(ctx context.Context) error { + if len(comments) == 0 { + return nil + } + + posterIDs := container.FilterSlice(comments, func(c *ConversationComment) (int64, bool) { + return c.PosterID, c.Poster == nil && c.PosterID > 0 + }) + + posterMaps, err := getPostersByIDs(ctx, posterIDs) + if err != nil { + return err + } + + for _, comment := range comments { + if comment.Poster == nil { + comment.Poster = getPoster(comment.PosterID, posterMaps) + } + } + return nil +} + // getConversationIDs returns all the conversation ids on this comment list which conversation hasn't been loaded func (comments CommentList) getConversationIDs() []int64 { return container.FilterSlice(comments, func(comment *ConversationComment) (int64, bool) { @@ -158,6 +181,10 @@ func (comments CommentList) LoadAttachments(ctx context.Context) (err error) { // LoadAttributes loads attributes of the comments, except for attachments and // comments func (comments CommentList) LoadAttributes(ctx context.Context) (err error) { + if err = comments.LoadPosters(ctx); err != nil { + return err + } + if err = comments.LoadAttachments(ctx); err != nil { return err } From 8b68cc8f72032df88d304a401b548d638373cf3d Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 00:34:32 +0800 Subject: [PATCH 30/72] Fix warning lines and remove unused variables --- models/conversations/conversation_test.go | 16 ++++++++++------ models/conversations/conversation_update.go | 2 +- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/models/conversations/conversation_test.go b/models/conversations/conversation_test.go index cc60a03785c81..4673dd860b839 100644 --- a/models/conversations/conversation_test.go +++ b/models/conversations/conversation_test.go @@ -130,11 +130,11 @@ func TestConversation_InsertConversation(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) // there are 5 conversations and max index is 5 on repository 1, so this one should 6 - conversation := testInsertConversation(t, "my conversation1", "special conversation's comments?", 6) + conversation := testInsertConversation(t, "my conversation1", 6) _, err := db.DeleteByID[conversations_model.Conversation](db.DefaultContext, conversation.ID) assert.NoError(t, err) - conversation = testInsertConversation(t, `my conversation2, this is my son's love \n \r \ `, "special conversation's '' comments?", 7) + conversation = testInsertConversation(t, `my conversation2, this is my son's love \n \r \ `, 7) _, err = db.DeleteByID[conversations_model.Conversation](db.DefaultContext, conversation.ID) assert.NoError(t, err) } @@ -146,7 +146,7 @@ func TestResourceIndex(t *testing.T) { for i := 0; i < 100; i++ { wg.Add(1) go func(i int) { - testInsertConversation(t, fmt.Sprintf("conversation %d", i+1), "my conversation", 0) + testInsertConversation(t, fmt.Sprintf("conversation %d", i+1), 0) wg.Done() }(i) } @@ -168,7 +168,7 @@ func TestCorrectConversationStats(t *testing.T) { for i := 0; i < conversationAmount; i++ { wg.Add(1) go func(i int) { - testInsertConversation(t, fmt.Sprintf("Conversation %d", i+1), "Bugs are nasty", 0) + testInsertConversation(t, fmt.Sprintf("Conversation %d", i+1), 0) wg.Done() }(i) } @@ -228,7 +228,11 @@ func TestConversationLoadAttributes(t *testing.T) { } } -func assertCreateConversations(t *testing.T, isPull bool) { +func TestCreateConversation(t *testing.T) { + assertCreateConversations(t) +} + +func assertCreateConversations(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) reponame := "repo1" repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame}) @@ -245,7 +249,7 @@ func assertCreateConversations(t *testing.T, isPull bool) { unittest.AssertExistsAndLoadBean(t, &conversations_model.Conversation{RepoID: repo.ID, ID: conversationID}) } -func testInsertConversation(t *testing.T, title, content string, expectIndex int64) *conversations_model.Conversation { +func testInsertConversation(t *testing.T, title string, expectIndex int64) *conversations_model.Conversation { var newConversation conversations_model.Conversation t.Run(title, func(t *testing.T) { repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go index eb12c34594dc2..92174ec05c298 100644 --- a/models/conversations/conversation_update.go +++ b/models/conversations/conversation_update.go @@ -423,7 +423,7 @@ func NewConversation(ctx context.Context, repo *repo_model.Repository, conversat } if err = committer.Commit(); err != nil { - return fmt.Errorf("Commit: %w", err) + return fmt.Errorf("commit: %w", err) } return nil From ed576c23fb4b4d1e2a2a7be90d676c4161043c45 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 00:50:34 +0800 Subject: [PATCH 31/72] Fix lint backend compliance issues --- templates/repo/conversation/comment_form.tmpl | 84 ++++++------ .../conversation/comment_form_content.tmpl | 42 +++--- templates/repo/conversation/comments.tmpl | 2 +- templates/repo/conversation/context_menu.tmpl | 2 +- templates/repo/conversation/conversation.tmpl | 42 +++--- .../conversation/issue_header_comment.tmpl | 122 +++++++++--------- templates/repo/issue/comment_tab.tmpl | 2 +- 7 files changed, 148 insertions(+), 148 deletions(-) diff --git a/templates/repo/conversation/comment_form.tmpl b/templates/repo/conversation/comment_form.tmpl index 0e8e3efb91bc2..fd42ed3f5da4e 100644 --- a/templates/repo/conversation/comment_form.tmpl +++ b/templates/repo/conversation/comment_form.tmpl @@ -2,46 +2,46 @@ {{if .IsSigned}} - {{if and (or .IsRepoAdmin .HasIssuesOrPullsWritePermission (not (and .IsIssue .Issue.IsLocked))) (not .Repository.IsArchived)}} -
- - {{ctx.AvatarUtils.Avatar .SignedUser 40}} - -
-
- {{if .IsIssue}} -
- {{template "repo/conversation/comment_form_content" .}} -
- {{else}} -
- {{template "repo/conversation/comment_form_content" .}} -
- {{end}} -
-
-
- {{else if .Repository.IsArchived}} -
- {{if and .IsIssue .Issue.IsPull}} - {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} - {{else}} - {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} - {{end}} -
- {{end}} + {{if and (or .IsRepoAdmin .HasIssuesOrPullsWritePermission (not (and .IsIssue .Issue.IsLocked))) (not .Repository.IsArchived)}} +
+ + {{ctx.AvatarUtils.Avatar .SignedUser 40}} + +
+
+ {{if .IsIssue}} +
+ {{template "repo/conversation/comment_form_content" .}} +
+ {{else}} +
+ {{template "repo/conversation/comment_form_content" .}} +
+ {{end}} +
+
+
+ {{else if .Repository.IsArchived}} +
+ {{if and .IsIssue .Issue.IsPull}} + {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} + {{else}} + {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} + {{end}} +
+ {{end}} {{else}} {{/* not .IsSigned */}} - {{if .Repository.IsArchived}} -
- {{if .IsIssue and .Issue.IsPull}} - {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} - {{else}} - {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} - {{end}} -
- {{else}} -
- {{ctx.Locale.Tr "repo.issues.sign_in_require_desc" .SignInLink}} -
- {{end}} -{{end}}{{/* end if: .IsSigned */}} \ No newline at end of file + {{if .Repository.IsArchived}} +
+ {{if .IsIssue and .Issue.IsPull}} + {{ctx.Locale.Tr "repo.archive.pull.nocomment"}} + {{else}} + {{ctx.Locale.Tr "repo.archive.issue.nocomment"}} + {{end}} +
+ {{else}} +
+ {{ctx.Locale.Tr "repo.issues.sign_in_require_desc" .SignInLink}} +
+ {{end}} +{{end}}{{/* end if: .IsSigned */}} diff --git a/templates/repo/conversation/comment_form_content.tmpl b/templates/repo/conversation/comment_form_content.tmpl index 186a2d6a96669..c2c91611ff3cc 100644 --- a/templates/repo/conversation/comment_form_content.tmpl +++ b/templates/repo/conversation/comment_form_content.tmpl @@ -1,24 +1,24 @@ {{template "repo/conversation/comment_tab" .}} {{.CsrfTokenHtml}} \ No newline at end of file +
+ {{if and (or .HasIssuesOrPullsWritePermission .IsIssuePoster) (not .DisableStatusChange)}} + {{if and .IsIssue .Issue.IsClosed}} + + {{else}} + {{$closeTranslationKey := "repo.issues.close"}} + {{if and .IsIssue .Issue.IsPull}} + {{$closeTranslationKey = "repo.pulls.close"}} + {{end}} + + {{end}} + {{end}} + +
+ diff --git a/templates/repo/conversation/comments.tmpl b/templates/repo/conversation/comments.tmpl index a318ab1e0e803..8498f919b6f46 100644 --- a/templates/repo/conversation/comments.tmpl +++ b/templates/repo/conversation/comments.tmpl @@ -83,7 +83,7 @@ {{$EditURL:=printf "%s/%s/%d" $.RepoLink "conversations/comments" .ID}}
{{end}} - + {{if .Attachments}} {{template "repo/issue/view_content/attachments" dict "Attachments" .Attachments "RenderedContent" .RenderedContent}} {{end}} diff --git a/templates/repo/conversation/context_menu.tmpl b/templates/repo/conversation/context_menu.tmpl index 90542125cf839..4cfa095486f40 100644 --- a/templates/repo/conversation/context_menu.tmpl +++ b/templates/repo/conversation/context_menu.tmpl @@ -11,7 +11,7 @@ {{if .pull}} {{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}} {{end}} - + {{if .commit}} {{$referenceUrl = printf "%s#%s" .ctxData.Conversation.Link .item.HashTag}} {{end}} diff --git a/templates/repo/conversation/conversation.tmpl b/templates/repo/conversation/conversation.tmpl index 2e994e5d67d44..1c2d127a32555 100644 --- a/templates/repo/conversation/conversation.tmpl +++ b/templates/repo/conversation/conversation.tmpl @@ -1,27 +1,27 @@
- {{if .ConversationTitle}} -
+ {{if .ConversationTitle}} +

- {{.ConversationTitle}} -

-
- {{end}} -
-
- {{if .IsIssue}} - {{template "repo/conversation/issue_header_comment" .}} - {{end}} + {{.ConversationTitle}} + +
+ {{end}} +
+
+ {{if .IsIssue}} + {{template "repo/conversation/issue_header_comment" .}} + {{end}} - {{template "repo/conversation/comments" .}} + {{template "repo/conversation/comments" .}} - {{if .IsIssue}} - {{if and .Issue.IsPull (not $.Repository.IsArchived)}} - {{template "repo/issue/view_content/pull".}} - {{end}} - {{end}} + {{if .IsIssue}} + {{if and .Issue.IsPull (not $.Repository.IsArchived)}} + {{template "repo/issue/view_content/pull".}} + {{end}} + {{end}} - {{template "repo/conversation/comment_form" .}} -
-
-
\ No newline at end of file + {{template "repo/conversation/comment_form" .}} +
+
+ diff --git a/templates/repo/conversation/issue_header_comment.tmpl b/templates/repo/conversation/issue_header_comment.tmpl index b7b01050bf072..9b71db60c6f4e 100644 --- a/templates/repo/conversation/issue_header_comment.tmpl +++ b/templates/repo/conversation/issue_header_comment.tmpl @@ -2,64 +2,64 @@
{{$createdStr:= TimeSinceUnix .Issue.CreatedUnix ctx.Locale}} - {{if .Issue.OriginalAuthor}} - - {{ctx.AvatarUtils.Avatar nil 40}} - - {{else}} - - {{ctx.AvatarUtils.Avatar .Issue.Poster 40}} - - {{end}} -
-
-
- {{if .Issue.OriginalAuthor}} - - {{svg (MigrationIcon .Repository.GetOriginalURLHostname)}} - {{.Issue.OriginalAuthor}} - - - {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} - - - {{if .Repository.OriginalURL}} ({{ctx.Locale.Tr "repo.migrated_from" .Repository.OriginalURL .Repository.GetOriginalURLHostname}}){{end}} - - {{else}} - - {{ctx.AvatarUtils.Avatar .Issue.Poster 24}} - - - {{template "shared/user/authorlink" .Issue.Poster}} - {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} - - {{end}} -
-
- {{template "repo/issue/view_content/show_role" dict "ShowRole" .Issue.ShowRole "IgnorePoster" true}} - {{if not $.Repository.IsArchived}} - {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}} - {{end}} - {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}} -
-
-
-
- {{if .Issue.RenderedContent}} - {{.Issue.RenderedContent}} - {{else}} - {{ctx.Locale.Tr "repo.issues.no_content"}} - {{end}} -
-
{{.Issue.Content}}
-
- {{if .Issue.Attachments}} - {{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}} - {{end}} -
- {{$reactions := .Issue.Reactions.GroupByType}} - {{if $reactions}} - {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions}} - {{end}} -
-
\ No newline at end of file + {{if .Issue.OriginalAuthor}} + + {{ctx.AvatarUtils.Avatar nil 40}} + + {{else}} + + {{ctx.AvatarUtils.Avatar .Issue.Poster 40}} + + {{end}} +
+
+
+ {{if .Issue.OriginalAuthor}} + + {{svg (MigrationIcon .Repository.GetOriginalURLHostname)}} + {{.Issue.OriginalAuthor}} + + + {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} + + + {{if .Repository.OriginalURL}} ({{ctx.Locale.Tr "repo.migrated_from" .Repository.OriginalURL .Repository.GetOriginalURLHostname}}){{end}} + + {{else}} + + {{ctx.AvatarUtils.Avatar .Issue.Poster 24}} + + + {{template "shared/user/authorlink" .Issue.Poster}} + {{ctx.Locale.Tr "repo.issues.commented_at" .Issue.HashTag $createdStr}} + + {{end}} +
+
+ {{template "repo/issue/view_content/show_role" dict "ShowRole" .Issue.ShowRole "IgnorePoster" true}} + {{if not $.Repository.IsArchived}} + {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}} + {{end}} + {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}} +
+
+
+
+ {{if .Issue.RenderedContent}} + {{.Issue.RenderedContent}} + {{else}} + {{ctx.Locale.Tr "repo.issues.no_content"}} + {{end}} +
+
{{.Issue.Content}}
+
+ {{if .Issue.Attachments}} + {{template "repo/issue/view_content/attachments" dict "Attachments" .Issue.Attachments "RenderedContent" .Issue.RenderedContent}} + {{end}} +
+ {{$reactions := .Issue.Reactions.GroupByType}} + {{if $reactions}} + {{template "repo/issue/view_content/reactions" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index) "Reactions" $reactions}} + {{end}} +
+ diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index e6bae029d231d..fbb698b245cfe 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -1 +1 @@ -{{template "repo/conversation/comment_tab" .}} \ No newline at end of file +{{template "repo/conversation/comment_tab" .}} From 34c006891823a573c4d1f3a3dff450379fd10d2a Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 01:41:36 +0800 Subject: [PATCH 32/72] Update pull_review_test.go to add new data to ctx --- routers/web/repo/pull_review_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/routers/web/repo/pull_review_test.go b/routers/web/repo/pull_review_test.go index 8344ff409113f..3512c1000343b 100644 --- a/routers/web/repo/pull_review_test.go +++ b/routers/web/repo/pull_review_test.go @@ -56,21 +56,25 @@ func TestRenderConversation(t *testing.T) { } run("diff with outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { ctx.Data["ShowOutdatedComments"] = true + ctx.Data["IsIssue"] = true renderConversation(ctx, preparedComment, "diff") assert.Contains(t, resp.Body.String(), `
Date: Mon, 4 Nov 2024 03:57:01 +0800 Subject: [PATCH 33/72] Undo adding unnecessary ctx Data --- routers/web/repo/pull_review_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/routers/web/repo/pull_review_test.go b/routers/web/repo/pull_review_test.go index 3512c1000343b..8344ff409113f 100644 --- a/routers/web/repo/pull_review_test.go +++ b/routers/web/repo/pull_review_test.go @@ -56,25 +56,21 @@ func TestRenderConversation(t *testing.T) { } run("diff with outdated", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { ctx.Data["ShowOutdatedComments"] = true - ctx.Data["IsIssue"] = true renderConversation(ctx, preparedComment, "diff") assert.Contains(t, resp.Body.String(), `
Date: Mon, 4 Nov 2024 14:18:11 +0800 Subject: [PATCH 34/72] Load PR attachments before assigning value --- routers/web/repo/pull_review.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 51858724bec08..305187c14be5a 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -194,13 +194,13 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori ctx.ServerError("CanMarkConversation", err) return } - ctx.Data["Issue"] = comment.Issue - ctx.Data["IsIssue"] = true - ctx.Data["Comments"] = comment.Issue.Comments if err = comment.Issue.LoadPullRequest(ctx); err != nil { ctx.ServerError("comment.Issue.LoadPullRequest", err) return } + ctx.Data["Issue"] = comment.Issue + ctx.Data["IsIssue"] = true + ctx.Data["Comments"] = comment.Issue.Comments pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(comment.Issue.PullRequest.GetGitRefName()) if err != nil { ctx.ServerError("GetRefCommitID", err) From b2a93cdda1e00bad8d871ec8c45b9d9d26a6176f Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 17:10:50 +0800 Subject: [PATCH 35/72] Remove unused data from indexer/search --- models/conversations/conversation_search.go | 172 +------- models/conversations/conversation_test.go | 9 +- modules/indexer/conversations/bleve/bleve.go | 74 +--- .../indexer/conversations/bleve/bleve_test.go | 2 +- modules/indexer/conversations/db/db.go | 2 +- modules/indexer/conversations/db/options.go | 54 +-- modules/indexer/conversations/dboptions.go | 55 +-- .../elasticsearch/elasticsearch.go | 64 +-- .../elasticsearch/elasticsearch_test.go | 2 +- modules/indexer/conversations/indexer.go | 2 +- modules/indexer/conversations/indexer_test.go | 232 +---------- .../indexer/conversations/internal/indexer.go | 2 +- .../indexer/conversations/internal/model.go | 43 +- .../conversations/internal/tests/tests.go | 387 ++---------------- .../conversations/meilisearch/meilisearch.go | 64 +-- .../meilisearch/meilisearch_test.go | 2 +- modules/indexer/conversations/util.go | 8 +- routers/web/repo/conversation.go | 86 +--- 18 files changed, 69 insertions(+), 1191 deletions(-) diff --git a/models/conversations/conversation_search.go b/models/conversations/conversation_search.go index 6dad3fc617317..fcc9c5c0b53d2 100644 --- a/models/conversations/conversation_search.go +++ b/models/conversations/conversation_search.go @@ -6,13 +6,10 @@ package conversations import ( "context" "fmt" - "strconv" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" - repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/optional" "xorm.io/builder" @@ -21,29 +18,14 @@ import ( // ConversationsOptions represents options of an conversation. type ConversationsOptions struct { //nolint - Paginator *db.ListOptions - RepoIDs []int64 // overwrites RepoCond if the length is not 0 - AllPublic bool // include also all public repositories - RepoCond builder.Cond - AssigneeID int64 - PosterID int64 - MentionedID int64 - ReviewRequestedID int64 - ReviewedID int64 - SubscriberID int64 - MilestoneIDs []int64 - ProjectID int64 - ProjectColumnID int64 - IsClosed optional.Option[bool] - IsPull optional.Option[bool] - LabelIDs []int64 - IncludedLabelNames []string - ExcludedLabelNames []string - IncludeMilestones []string - SortType string - ConversationIDs []int64 - UpdatedAfterUnix int64 - UpdatedBeforeUnix int64 + Paginator *db.ListOptions + RepoIDs []int64 // overwrites RepoCond if the length is not 0 + AllPublic bool // include also all public repositories + RepoCond builder.Cond + SortType string + ConversationIDs []int64 + UpdatedAfterUnix int64 + UpdatedBeforeUnix int64 // prioritize conversations from this repo PriorityRepoID int64 IsArchived optional.Option[bool] @@ -124,75 +106,6 @@ func applyLimit(sess *xorm.Session, opts *ConversationsOptions) { sess.Limit(opts.Paginator.PageSize, start) } -func applyLabelsCondition(sess *xorm.Session, opts *ConversationsOptions) { - if len(opts.LabelIDs) > 0 { - if opts.LabelIDs[0] == 0 { - sess.Where("conversation.id NOT IN (SELECT conversation_id FROM conversation_label)") - } else { - // deduplicate the label IDs for inclusion and exclusion - includedLabelIDs := make(container.Set[int64]) - excludedLabelIDs := make(container.Set[int64]) - for _, labelID := range opts.LabelIDs { - if labelID > 0 { - includedLabelIDs.Add(labelID) - } else if labelID < 0 { // 0 is not supported here, so just ignore it - excludedLabelIDs.Add(-labelID) - } - } - // ... and use them in a subquery of the form : - // where (select count(*) from conversation_label where conversation_id=conversation.id and label_id in (2, 4, 6)) = 3 - // This equality is guaranteed thanks to unique index (conversation_id,label_id) on table conversation_label. - if len(includedLabelIDs) > 0 { - subQuery := builder.Select("count(*)").From("conversation_label").Where(builder.Expr("conversation_id = conversation.id")). - And(builder.In("label_id", includedLabelIDs.Values())) - sess.Where(builder.Eq{strconv.Itoa(len(includedLabelIDs)): subQuery}) - } - // or (select count(*)...) = 0 for excluded labels - if len(excludedLabelIDs) > 0 { - subQuery := builder.Select("count(*)").From("conversation_label").Where(builder.Expr("conversation_id = conversation.id")). - And(builder.In("label_id", excludedLabelIDs.Values())) - sess.Where(builder.Eq{"0": subQuery}) - } - } - } -} - -func applyMilestoneCondition(sess *xorm.Session, opts *ConversationsOptions) { - if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID { - sess.And("conversation.milestone_id = 0") - } else if len(opts.MilestoneIDs) > 0 { - sess.In("conversation.milestone_id", opts.MilestoneIDs) - } - - if len(opts.IncludeMilestones) > 0 { - sess.In("conversation.milestone_id", - builder.Select("id"). - From("milestone"). - Where(builder.In("name", opts.IncludeMilestones))) - } -} - -func applyProjectCondition(sess *xorm.Session, opts *ConversationsOptions) { - if opts.ProjectID > 0 { // specific project - sess.Join("INNER", "project_conversation", "conversation.id = project_conversation.conversation_id"). - And("project_conversation.project_id=?", opts.ProjectID) - } else if opts.ProjectID == db.NoConditionID { // show those that are in no project - sess.And(builder.NotIn("conversation.id", builder.Select("conversation_id").From("project_conversation").And(builder.Neq{"project_id": 0}))) - } - // opts.ProjectID == 0 means all projects, - // do not need to apply any condition -} - -func applyProjectColumnCondition(sess *xorm.Session, opts *ConversationsOptions) { - // opts.ProjectColumnID == 0 means all project columns, - // do not need to apply any condition - if opts.ProjectColumnID > 0 { - sess.In("conversation.id", builder.Select("conversation_id").From("project_conversation").Where(builder.Eq{"project_board_id": opts.ProjectColumnID})) - } else if opts.ProjectColumnID == db.NoConditionID { - sess.In("conversation.id", builder.Select("conversation_id").From("project_conversation").Where(builder.Eq{"project_board_id": 0})) - } -} - func applyRepoConditions(sess *xorm.Session, opts *ConversationsOptions) { if len(opts.RepoIDs) == 1 { opts.RepoCond = builder.Eq{"conversation.repo_id": opts.RepoIDs[0]} @@ -217,24 +130,6 @@ func applyConditions(sess *xorm.Session, opts *ConversationsOptions) { applyRepoConditions(sess, opts) - if opts.IsClosed.Has() { - sess.And("conversation.is_closed=?", opts.IsClosed.Value()) - } - - if opts.PosterID > 0 { - applyPosterCondition(sess, opts.PosterID) - } - - if opts.MentionedID > 0 { - applyMentionedCondition(sess, opts.MentionedID) - } - - if opts.SubscriberID > 0 { - applySubscribedCondition(sess, opts.SubscriberID) - } - - applyMilestoneCondition(sess, opts) - if opts.UpdatedAfterUnix != 0 { sess.And(builder.Gte{"conversation.updated_unix": opts.UpdatedAfterUnix}) } @@ -242,60 +137,9 @@ func applyConditions(sess *xorm.Session, opts *ConversationsOptions) { sess.And(builder.Lte{"conversation.updated_unix": opts.UpdatedBeforeUnix}) } - applyProjectCondition(sess, opts) - - applyProjectColumnCondition(sess, opts) - - if opts.IsPull.Has() { - sess.And("conversation.is_pull=?", opts.IsPull.Value()) - } - if opts.IsArchived.Has() { sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.Value()}) } - - applyLabelsCondition(sess, opts) -} - -func applyPosterCondition(sess *xorm.Session, posterID int64) { - sess.And("conversation.poster_id=?", posterID) -} - -func applyMentionedCondition(sess *xorm.Session, mentionedID int64) { - sess.Join("INNER", "conversation_user", "conversation.id = conversation_user.conversation_id"). - And("conversation_user.is_mentioned = ?", true). - And("conversation_user.uid = ?", mentionedID) -} - -func applySubscribedCondition(sess *xorm.Session, subscriberID int64) { - sess.And( - builder. - NotIn("conversation.id", - builder.Select("conversation_id"). - From("conversation_watch"). - Where(builder.Eq{"is_watching": false, "user_id": subscriberID}), - ), - ).And( - builder.Or( - builder.In("conversation.id", builder. - Select("conversation_id"). - From("conversation_watch"). - Where(builder.Eq{"is_watching": true, "user_id": subscriberID}), - ), - builder.In("conversation.id", builder. - Select("conversation_id"). - From("comment"). - Where(builder.Eq{"poster_id": subscriberID}), - ), - builder.Eq{"conversation.poster_id": subscriberID}, - builder.In("conversation.repo_id", builder. - Select("id"). - From("watch"). - Where(builder.And(builder.Eq{"user_id": subscriberID}, - builder.In("mode", repo_model.WatchModeNormal, repo_model.WatchModeAuto))), - ), - ), - ) } // Conversations returns a list of conversations by given conditions. diff --git a/models/conversations/conversation_test.go b/models/conversations/conversation_test.go index 4673dd860b839..198d19c840349 100644 --- a/models/conversations/conversation_test.go +++ b/models/conversations/conversation_test.go @@ -73,8 +73,7 @@ func TestConversations(t *testing.T) { }{ { conversations_model.ConversationsOptions{ - AssigneeID: 1, - SortType: "oldest", + SortType: "oldest", }, []int64{1, 6}, }, @@ -91,7 +90,6 @@ func TestConversations(t *testing.T) { }, { conversations_model.ConversationsOptions{ - LabelIDs: []int64{1}, Paginator: &db.ListOptions{ Page: 1, PageSize: 4, @@ -101,7 +99,6 @@ func TestConversations(t *testing.T) { }, { conversations_model.ConversationsOptions{ - LabelIDs: []int64{1, 2}, Paginator: &db.ListOptions{ Page: 1, PageSize: 4, @@ -110,9 +107,7 @@ func TestConversations(t *testing.T) { []int64{}, // conversations with **both** label 1 and 2, none of these conversations matches, TODO: add more tests }, { - conversations_model.ConversationsOptions{ - MilestoneIDs: []int64{1}, - }, + conversations_model.ConversationsOptions{}, []int64{2}, }, } { diff --git a/modules/indexer/conversations/bleve/bleve.go b/modules/indexer/conversations/bleve/bleve.go index 08cfd7d4df53c..20feb63082263 100644 --- a/modules/indexer/conversations/bleve/bleve.go +++ b/modules/indexer/conversations/bleve/bleve.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package bleve @@ -179,78 +179,6 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( queries = append(queries, bleve.NewDisjunctionQuery(repoQueries...)) } - if options.IsPull.Has() { - queries = append(queries, inner_bleve.BoolFieldQuery(options.IsPull.Value(), "is_pull")) - } - if options.IsClosed.Has() { - queries = append(queries, inner_bleve.BoolFieldQuery(options.IsClosed.Value(), "is_closed")) - } - - if options.NoLabelOnly { - queries = append(queries, inner_bleve.BoolFieldQuery(true, "no_label")) - } else { - if len(options.IncludedLabelIDs) > 0 { - var includeQueries []query.Query - for _, labelID := range options.IncludedLabelIDs { - includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids")) - } - queries = append(queries, bleve.NewConjunctionQuery(includeQueries...)) - } else if len(options.IncludedAnyLabelIDs) > 0 { - var includeQueries []query.Query - for _, labelID := range options.IncludedAnyLabelIDs { - includeQueries = append(includeQueries, inner_bleve.NumericEqualityQuery(labelID, "label_ids")) - } - queries = append(queries, bleve.NewDisjunctionQuery(includeQueries...)) - } - if len(options.ExcludedLabelIDs) > 0 { - var excludeQueries []query.Query - for _, labelID := range options.ExcludedLabelIDs { - q := bleve.NewBooleanQuery() - q.AddMustNot(inner_bleve.NumericEqualityQuery(labelID, "label_ids")) - excludeQueries = append(excludeQueries, q) - } - queries = append(queries, bleve.NewConjunctionQuery(excludeQueries...)) - } - } - - if len(options.MilestoneIDs) > 0 { - var milestoneQueries []query.Query - for _, milestoneID := range options.MilestoneIDs { - milestoneQueries = append(milestoneQueries, inner_bleve.NumericEqualityQuery(milestoneID, "milestone_id")) - } - queries = append(queries, bleve.NewDisjunctionQuery(milestoneQueries...)) - } - - if options.ProjectID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectID.Value(), "project_id")) - } - if options.ProjectColumnID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ProjectColumnID.Value(), "project_board_id")) - } - - if options.PosterID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.PosterID.Value(), "poster_id")) - } - - if options.AssigneeID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.AssigneeID.Value(), "assignee_id")) - } - - if options.MentionID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.MentionID.Value(), "mention_ids")) - } - - if options.ReviewedID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewedID.Value(), "reviewed_ids")) - } - if options.ReviewRequestedID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.ReviewRequestedID.Value(), "review_requested_ids")) - } - - if options.SubscriberID.Has() { - queries = append(queries, inner_bleve.NumericEqualityQuery(options.SubscriberID.Value(), "subscriber_ids")) - } - if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() { queries = append(queries, inner_bleve.NumericRangeInclusiveQuery( options.UpdatedAfterUnix, diff --git a/modules/indexer/conversations/bleve/bleve_test.go b/modules/indexer/conversations/bleve/bleve_test.go index 951db77d0987a..a14bff17ef418 100644 --- a/modules/indexer/conversations/bleve/bleve_test.go +++ b/modules/indexer/conversations/bleve/bleve_test.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package bleve diff --git a/modules/indexer/conversations/db/db.go b/modules/indexer/conversations/db/db.go index 9aebb834ee643..add41c82fd2ae 100644 --- a/modules/indexer/conversations/db/db.go +++ b/modules/indexer/conversations/db/db.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package db diff --git a/modules/indexer/conversations/db/options.go b/modules/indexer/conversations/db/options.go index cac58ae9f1dd1..49109ae5533d3 100644 --- a/modules/indexer/conversations/db/options.go +++ b/modules/indexer/conversations/db/options.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package db @@ -7,7 +7,6 @@ import ( "context" conversation_model "code.gitea.io/gitea/models/conversations" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/indexer/conversations/internal" "code.gitea.io/gitea/modules/optional" ) @@ -31,45 +30,20 @@ func ToDBOptions(ctx context.Context, options *internal.SearchOptions) (*convers sortType = "newest" } - // See the comment of conversations_model.SearchOptions for the reason why we need to convert - convertID := func(id optional.Option[int64]) int64 { - if !id.Has() { - return 0 - } - value := id.Value() - if value == 0 { - return db.NoConditionID - } - return value - } - opts := &conversation_model.ConversationsOptions{ - Paginator: options.Paginator, - RepoIDs: options.RepoIDs, - AllPublic: options.AllPublic, - RepoCond: nil, - AssigneeID: convertID(options.AssigneeID), - PosterID: convertID(options.PosterID), - MentionedID: convertID(options.MentionID), - ReviewRequestedID: convertID(options.ReviewRequestedID), - ReviewedID: convertID(options.ReviewedID), - SubscriberID: convertID(options.SubscriberID), - ProjectID: convertID(options.ProjectID), - ProjectColumnID: convertID(options.ProjectColumnID), - IsClosed: options.IsClosed, - IsPull: options.IsPull, - IncludedLabelNames: nil, - ExcludedLabelNames: nil, - IncludeMilestones: nil, - SortType: sortType, - ConversationIDs: nil, - UpdatedAfterUnix: options.UpdatedAfterUnix.Value(), - UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(), - PriorityRepoID: 0, - IsArchived: optional.None[bool](), - Org: nil, - Team: nil, - User: nil, + Paginator: options.Paginator, + RepoIDs: options.RepoIDs, + AllPublic: options.AllPublic, + RepoCond: nil, + SortType: sortType, + ConversationIDs: nil, + UpdatedAfterUnix: options.UpdatedAfterUnix.Value(), + UpdatedBeforeUnix: options.UpdatedBeforeUnix.Value(), + PriorityRepoID: 0, + IsArchived: optional.None[bool](), + Org: nil, + Team: nil, + User: nil, } return opts, nil diff --git a/modules/indexer/conversations/dboptions.go b/modules/indexer/conversations/dboptions.go index fdf735f078299..b32f4879e28f1 100644 --- a/modules/indexer/conversations/dboptions.go +++ b/modules/indexer/conversations/dboptions.go @@ -1,11 +1,10 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations import ( conversations_model "code.gitea.io/gitea/models/conversations" - "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/modules/optional" ) @@ -14,60 +13,8 @@ func ToSearchOptions(keyword string, opts *conversations_model.ConversationsOpti Keyword: keyword, RepoIDs: opts.RepoIDs, AllPublic: opts.AllPublic, - IsPull: opts.IsPull, - IsClosed: opts.IsClosed, } - if len(opts.LabelIDs) == 1 && opts.LabelIDs[0] == 0 { - searchOpt.NoLabelOnly = true - } else { - for _, labelID := range opts.LabelIDs { - if labelID > 0 { - searchOpt.IncludedLabelIDs = append(searchOpt.IncludedLabelIDs, labelID) - } else { - searchOpt.ExcludedLabelIDs = append(searchOpt.ExcludedLabelIDs, -labelID) - } - } - // opts.IncludedLabelNames and opts.ExcludedLabelNames are not supported here. - // It's not a TO DO, it's just unnecessary. - } - - if len(opts.MilestoneIDs) == 1 && opts.MilestoneIDs[0] == db.NoConditionID { - searchOpt.MilestoneIDs = []int64{0} - } else { - searchOpt.MilestoneIDs = opts.MilestoneIDs - } - - if opts.ProjectID > 0 { - searchOpt.ProjectID = optional.Some(opts.ProjectID) - } else if opts.ProjectID == -1 { // FIXME: this is inconsistent from other places - searchOpt.ProjectID = optional.Some[int64](0) // Those conversations with no project(projectid==0) - } - - if opts.AssigneeID > 0 { - searchOpt.AssigneeID = optional.Some(opts.AssigneeID) - } else if opts.AssigneeID == -1 { // FIXME: this is inconsistent from other places - searchOpt.AssigneeID = optional.Some[int64](0) - } - - // See the comment of conversations_model.SearchOptions for the reason why we need to convert - convertID := func(id int64) optional.Option[int64] { - if id > 0 { - return optional.Some(id) - } - if id == db.NoConditionID { - return optional.None[int64]() - } - return nil - } - - searchOpt.ProjectColumnID = convertID(opts.ProjectColumnID) - searchOpt.PosterID = convertID(opts.PosterID) - searchOpt.MentionID = convertID(opts.MentionedID) - searchOpt.ReviewedID = convertID(opts.ReviewedID) - searchOpt.ReviewRequestedID = convertID(opts.ReviewRequestedID) - searchOpt.SubscriberID = convertID(opts.SubscriberID) - if opts.UpdatedAfterUnix > 0 { searchOpt.UpdatedAfterUnix = optional.Some(opts.UpdatedAfterUnix) } diff --git a/modules/indexer/conversations/elasticsearch/elasticsearch.go b/modules/indexer/conversations/elasticsearch/elasticsearch.go index a8904a21d7f4a..37a6be592c30f 100644 --- a/modules/indexer/conversations/elasticsearch/elasticsearch.go +++ b/modules/indexer/conversations/elasticsearch/elasticsearch.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package elasticsearch @@ -162,68 +162,6 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.Must(q) } - if options.IsPull.Has() { - query.Must(elastic.NewTermQuery("is_pull", options.IsPull.Value())) - } - if options.IsClosed.Has() { - query.Must(elastic.NewTermQuery("is_closed", options.IsClosed.Value())) - } - - if options.NoLabelOnly { - query.Must(elastic.NewTermQuery("no_label", true)) - } else { - if len(options.IncludedLabelIDs) > 0 { - q := elastic.NewBoolQuery() - for _, labelID := range options.IncludedLabelIDs { - q.Must(elastic.NewTermQuery("label_ids", labelID)) - } - query.Must(q) - } else if len(options.IncludedAnyLabelIDs) > 0 { - query.Must(elastic.NewTermsQuery("label_ids", toAnySlice(options.IncludedAnyLabelIDs)...)) - } - if len(options.ExcludedLabelIDs) > 0 { - q := elastic.NewBoolQuery() - for _, labelID := range options.ExcludedLabelIDs { - q.MustNot(elastic.NewTermQuery("label_ids", labelID)) - } - query.Must(q) - } - } - - if len(options.MilestoneIDs) > 0 { - query.Must(elastic.NewTermsQuery("milestone_id", toAnySlice(options.MilestoneIDs)...)) - } - - if options.ProjectID.Has() { - query.Must(elastic.NewTermQuery("project_id", options.ProjectID.Value())) - } - if options.ProjectColumnID.Has() { - query.Must(elastic.NewTermQuery("project_board_id", options.ProjectColumnID.Value())) - } - - if options.PosterID.Has() { - query.Must(elastic.NewTermQuery("poster_id", options.PosterID.Value())) - } - - if options.AssigneeID.Has() { - query.Must(elastic.NewTermQuery("assignee_id", options.AssigneeID.Value())) - } - - if options.MentionID.Has() { - query.Must(elastic.NewTermQuery("mention_ids", options.MentionID.Value())) - } - - if options.ReviewedID.Has() { - query.Must(elastic.NewTermQuery("reviewed_ids", options.ReviewedID.Value())) - } - if options.ReviewRequestedID.Has() { - query.Must(elastic.NewTermQuery("review_requested_ids", options.ReviewRequestedID.Value())) - } - - if options.SubscriberID.Has() { - query.Must(elastic.NewTermQuery("subscriber_ids", options.SubscriberID.Value())) - } - if options.UpdatedAfterUnix.Has() || options.UpdatedBeforeUnix.Has() { q := elastic.NewRangeQuery("updated_unix") if options.UpdatedAfterUnix.Has() { diff --git a/modules/indexer/conversations/elasticsearch/elasticsearch_test.go b/modules/indexer/conversations/elasticsearch/elasticsearch_test.go index c627fe7ece58d..e5823d77d8042 100644 --- a/modules/indexer/conversations/elasticsearch/elasticsearch_test.go +++ b/modules/indexer/conversations/elasticsearch/elasticsearch_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package elasticsearch diff --git a/modules/indexer/conversations/indexer.go b/modules/indexer/conversations/indexer.go index dd8e42543dde7..21dd663c60b00 100644 --- a/modules/indexer/conversations/indexer.go +++ b/modules/indexer/conversations/indexer.go @@ -1,4 +1,4 @@ -// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations diff --git a/modules/indexer/conversations/indexer_test.go b/modules/indexer/conversations/indexer_test.go index 4038d8f6093a8..7272fa37caa75 100644 --- a/modules/indexer/conversations/indexer_test.go +++ b/modules/indexer/conversations/indexer_test.go @@ -1,4 +1,4 @@ -// Copyright 2019 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/modules/indexer/conversations/internal" - "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" _ "code.gitea.io/gitea/models" @@ -35,13 +34,7 @@ func TestDBSearchConversations(t *testing.T) { t.Run("search conversations by index", searchConversationByIndex) t.Run("search conversations in repo", searchConversationInRepo) t.Run("search conversations by ID", searchConversationByID) - t.Run("search conversations is pr", searchConversationIsPull) - t.Run("search conversations is closed", searchConversationIsClosed) - t.Run("search conversations by milestone", searchConversationByMilestoneID) - t.Run("search conversations by label", searchConversationByLabelID) - t.Run("search conversations by time", searchConversationByTime) t.Run("search conversations with order", searchConversationWithOrder) - t.Run("search conversations in project", searchConversationInProject) t.Run("search conversations with paginator", searchConversationWithPaginator) } @@ -178,198 +171,12 @@ func searchConversationByID(t *testing.T) { expectedIDs []int64 }{ { - opts: SearchOptions{ - PosterID: optional.Some(int64(1)), - }, - expectedIDs: []int64{11, 6, 3, 2, 1}, - }, - { - opts: SearchOptions{ - AssigneeID: optional.Some(int64(1)), - }, - expectedIDs: []int64{6, 1}, - }, - { - // NOTE: This tests no assignees filtering and also ToSearchOptions() to ensure it will set AssigneeID to 0 when it is passed as -1. - opts: *ToSearchOptions("", &conversations.ConversationsOptions{AssigneeID: -1}), + // NOTE: This tests no filtering + opts: *ToSearchOptions("", &conversations.ConversationsOptions{}), expectedIDs: []int64{22, 21, 16, 15, 14, 13, 12, 11, 20, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2}, }, - { - opts: SearchOptions{ - MentionID: optional.Some(int64(4)), - }, - expectedIDs: []int64{1}, - }, - { - opts: SearchOptions{ - ReviewedID: optional.Some(int64(1)), - }, - expectedIDs: []int64{}, - }, - { - opts: SearchOptions{ - ReviewRequestedID: optional.Some(int64(1)), - }, - expectedIDs: []int64{12}, - }, - { - opts: SearchOptions{ - SubscriberID: optional.Some(int64(1)), - }, - expectedIDs: []int64{11, 6, 5, 3, 2, 1}, - }, - { - // conversation 20 request user 15 and team 5 which user 15 belongs to - // the review request number of conversation 20 should be 1 - opts: SearchOptions{ - ReviewRequestedID: optional.Some(int64(15)), - }, - expectedIDs: []int64{12, 20}, - }, - { - // user 20 approved the conversation 20, so return nothing - opts: SearchOptions{ - ReviewRequestedID: optional.Some(int64(20)), - }, - expectedIDs: []int64{}, - }, - } - - for _, test := range tests { - conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, test.expectedIDs, conversationIDs) - } -} - -func searchConversationIsPull(t *testing.T) { - tests := []struct { - opts SearchOptions - expectedIDs []int64 - }{ - { - SearchOptions{ - IsPull: optional.Some(false), - }, - []int64{17, 16, 15, 14, 13, 6, 5, 18, 10, 7, 4, 1}, - }, - { - SearchOptions{ - IsPull: optional.Some(true), - }, - []int64{22, 21, 12, 11, 20, 19, 9, 8, 3, 2}, - }, - } - for _, test := range tests { - conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, test.expectedIDs, conversationIDs) - } -} - -func searchConversationIsClosed(t *testing.T) { - tests := []struct { - opts SearchOptions - expectedIDs []int64 - }{ - { - SearchOptions{ - IsClosed: optional.Some(false), - }, - []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 19, 18, 10, 7, 9, 8, 3, 2, 1}, - }, - { - SearchOptions{ - IsClosed: optional.Some(true), - }, - []int64{5, 4}, - }, - } - for _, test := range tests { - conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, test.expectedIDs, conversationIDs) - } -} - -func searchConversationByMilestoneID(t *testing.T) { - tests := []struct { - opts SearchOptions - expectedIDs []int64 - }{ - { - SearchOptions{ - MilestoneIDs: []int64{1}, - }, - []int64{2}, - }, - { - SearchOptions{ - MilestoneIDs: []int64{3}, - }, - []int64{3}, - }, - } - for _, test := range tests { - conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, test.expectedIDs, conversationIDs) - } -} - -func searchConversationByLabelID(t *testing.T) { - tests := []struct { - opts SearchOptions - expectedIDs []int64 - }{ - { - SearchOptions{ - IncludedLabelIDs: []int64{1}, - }, - []int64{2, 1}, - }, - { - SearchOptions{ - IncludedLabelIDs: []int64{4}, - }, - []int64{2}, - }, - { - SearchOptions{ - ExcludedLabelIDs: []int64{1}, - }, - []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3}, - }, - } - for _, test := range tests { - conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, test.expectedIDs, conversationIDs) } -} -func searchConversationByTime(t *testing.T) { - tests := []struct { - opts SearchOptions - expectedIDs []int64 - }{ - { - SearchOptions{ - UpdatedAfterUnix: optional.Some(int64(0)), - }, - []int64{22, 21, 17, 16, 15, 14, 13, 12, 11, 20, 6, 5, 19, 18, 10, 7, 4, 9, 8, 3, 2, 1}, - }, - } for _, test := range tests { conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) if !assert.NoError(t, err) { @@ -400,39 +207,6 @@ func searchConversationWithOrder(t *testing.T) { } } -func searchConversationInProject(t *testing.T) { - tests := []struct { - opts SearchOptions - expectedIDs []int64 - }{ - { - SearchOptions{ - ProjectID: optional.Some(int64(1)), - }, - []int64{5, 3, 2, 1}, - }, - { - SearchOptions{ - ProjectColumnID: optional.Some(int64(1)), - }, - []int64{1}, - }, - { - SearchOptions{ - ProjectColumnID: optional.Some(int64(0)), // conversation with in default column - }, - []int64{2}, - }, - } - for _, test := range tests { - conversationIDs, _, err := SearchConversations(context.TODO(), &test.opts) - if !assert.NoError(t, err) { - return - } - assert.Equal(t, test.expectedIDs, conversationIDs) - } -} - func searchConversationWithPaginator(t *testing.T) { tests := []struct { opts SearchOptions diff --git a/modules/indexer/conversations/internal/indexer.go b/modules/indexer/conversations/internal/indexer.go index 903154f29afdb..c6b197de708df 100644 --- a/modules/indexer/conversations/internal/indexer.go +++ b/modules/indexer/conversations/internal/indexer.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package internal diff --git a/modules/indexer/conversations/internal/model.go b/modules/indexer/conversations/internal/model.go index aa39f9cd6bf5e..a575d46b03be3 100644 --- a/modules/indexer/conversations/internal/model.go +++ b/modules/indexer/conversations/internal/model.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package internal @@ -18,25 +18,10 @@ type IndexerData struct { IsPublic bool `json:"is_public"` // If the repo is public // Fields used for keyword searching - Title string `json:"title"` - Content string `json:"content"` Comments []string `json:"comments"` // Fields used for filtering - IsPull bool `json:"is_pull"` - IsClosed bool `json:"is_closed"` - LabelIDs []int64 `json:"label_ids"` - NoLabel bool `json:"no_label"` // True if LabelIDs is empty - MilestoneID int64 `json:"milestone_id"` - ProjectID int64 `json:"project_id"` - ProjectColumnID int64 `json:"project_board_id"` // the key should be kept as project_board_id to keep compatible - PosterID int64 `json:"poster_id"` - AssigneeID int64 `json:"assignee_id"` - MentionIDs []int64 `json:"mention_ids"` - ReviewedIDs []int64 `json:"reviewed_ids"` - ReviewRequestedIDs []int64 `json:"review_requested_ids"` - SubscriberIDs []int64 `json:"subscriber_ids"` - UpdatedUnix timeutil.TimeStamp `json:"updated_unix"` + UpdatedUnix timeutil.TimeStamp `json:"updated_unix"` // Fields used for sorting // UpdatedUnix is both used for filtering and sorting. @@ -81,30 +66,6 @@ type SearchOptions struct { RepoIDs []int64 // repository IDs which the conversations belong to AllPublic bool // if include all public repositories - IsPull optional.Option[bool] // if the conversations is a pull request - IsClosed optional.Option[bool] // if the conversations is closed - - IncludedLabelIDs []int64 // labels the conversations have - ExcludedLabelIDs []int64 // labels the conversations don't have - IncludedAnyLabelIDs []int64 // labels the conversations have at least one. It will be ignored if IncludedLabelIDs is not empty. It's an uncommon filter, but it has been supported accidentally by conversations.ConversationsOptions.IncludedLabelNames. - NoLabelOnly bool // if the conversations have no label, if true, IncludedLabelIDs and ExcludedLabelIDs, IncludedAnyLabelIDs will be ignored - - MilestoneIDs []int64 // milestones the conversations have - - ProjectID optional.Option[int64] // project the conversations belong to - ProjectColumnID optional.Option[int64] // project column the conversations belong to - - PosterID optional.Option[int64] // poster of the conversations - - AssigneeID optional.Option[int64] // assignee of the conversations, zero means no assignee - - MentionID optional.Option[int64] // mentioned user of the conversations - - ReviewedID optional.Option[int64] // reviewer of the conversations - ReviewRequestedID optional.Option[int64] // requested reviewer of the conversations - - SubscriberID optional.Option[int64] // subscriber of the conversations - UpdatedAfterUnix optional.Option[int64] UpdatedBeforeUnix optional.Option[int64] diff --git a/modules/indexer/conversations/internal/tests/tests.go b/modules/indexer/conversations/internal/tests/tests.go index 179d94a19454b..57585c3c570e8 100644 --- a/modules/indexer/conversations/internal/tests/tests.go +++ b/modules/indexer/conversations/internal/tests/tests.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT // This package contains tests for conversations indexer modules. @@ -10,7 +10,6 @@ package tests import ( "context" "fmt" - "slices" "testing" "time" @@ -120,8 +119,8 @@ var cases = []*testIndexerCase{ { Name: "Keyword", ExtraData: []*internal.IndexerData{ - {ID: 1000, Title: "hi hello world"}, - {ID: 1001, Content: "hi hello world"}, + {ID: 1000}, + {ID: 1001}, {ID: 1002, Comments: []string{"hi", "hello world"}}, }, SearchOptions: &internal.SearchOptions{ @@ -133,13 +132,13 @@ var cases = []*testIndexerCase{ { Name: "RepoIDs", ExtraData: []*internal.IndexerData{ - {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false}, - {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false}, - {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true}, - {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true}, - {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true}, - {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, - {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, + {ID: 1001, RepoID: 1, IsPublic: false}, + {ID: 1002, RepoID: 1, IsPublic: false}, + {ID: 1003, RepoID: 2, IsPublic: true}, + {ID: 1004, RepoID: 2, IsPublic: true}, + {ID: 1005, RepoID: 3, IsPublic: true}, + {ID: 1006, RepoID: 4, IsPublic: false}, + {ID: 1007, RepoID: 5, IsPublic: false}, }, SearchOptions: &internal.SearchOptions{ Keyword: "hello", @@ -151,13 +150,13 @@ var cases = []*testIndexerCase{ { Name: "RepoIDs and AllPublic", ExtraData: []*internal.IndexerData{ - {ID: 1001, Title: "hello world", RepoID: 1, IsPublic: false}, - {ID: 1002, Title: "hello world", RepoID: 1, IsPublic: false}, - {ID: 1003, Title: "hello world", RepoID: 2, IsPublic: true}, - {ID: 1004, Title: "hello world", RepoID: 2, IsPublic: true}, - {ID: 1005, Title: "hello world", RepoID: 3, IsPublic: true}, - {ID: 1006, Title: "hello world", RepoID: 4, IsPublic: false}, - {ID: 1007, Title: "hello world", RepoID: 5, IsPublic: false}, + {ID: 1001, RepoID: 1, IsPublic: false}, + {ID: 1002, RepoID: 1, IsPublic: false}, + {ID: 1003, RepoID: 2, IsPublic: true}, + {ID: 1004, RepoID: 2, IsPublic: true}, + {ID: 1005, RepoID: 3, IsPublic: true}, + {ID: 1006, RepoID: 4, IsPublic: false}, + {ID: 1007, RepoID: 5, IsPublic: false}, }, SearchOptions: &internal.SearchOptions{ Keyword: "hello", @@ -173,330 +172,9 @@ var cases = []*testIndexerCase{ Paginator: &db.ListOptions{ PageSize: 5, }, - IsPull: optional.Some(false), }, Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.False(t, data[v.ID].IsPull) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsPull }), result.Total) - }, - }, - { - Name: "pull only", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - IsPull: optional.Some(true), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.True(t, data[v.ID].IsPull) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsPull }), result.Total) - }, - }, - { - Name: "opened only", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - IsClosed: optional.Some(false), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.False(t, data[v.ID].IsClosed) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return !v.IsClosed }), result.Total) - }, - }, - { - Name: "closed only", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - IsClosed: optional.Some(true), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.True(t, data[v.ID].IsClosed) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { return v.IsClosed }), result.Total) - }, - }, - { - Name: "labels", - ExtraData: []*internal.IndexerData{ - {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}}, - {ID: 1001, Title: "hello b", LabelIDs: []int64{2000, 2001}}, - {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}}, - {ID: 1003, Title: "hello d", LabelIDs: []int64{2000}}, - {ID: 1004, Title: "hello e", LabelIDs: []int64{}}, - }, - SearchOptions: &internal.SearchOptions{ - Keyword: "hello", - IncludedLabelIDs: []int64{2000, 2001}, - ExcludedLabelIDs: []int64{2003}, - }, - ExpectedIDs: []int64{1001, 1000}, - ExpectedTotal: 2, - }, - { - Name: "include any labels", - ExtraData: []*internal.IndexerData{ - {ID: 1000, Title: "hello a", LabelIDs: []int64{2000, 2001, 2002}}, - {ID: 1001, Title: "hello b", LabelIDs: []int64{2001}}, - {ID: 1002, Title: "hello c", LabelIDs: []int64{2000, 2001, 2003}}, - {ID: 1003, Title: "hello d", LabelIDs: []int64{2002}}, - {ID: 1004, Title: "hello e", LabelIDs: []int64{}}, - }, - SearchOptions: &internal.SearchOptions{ - Keyword: "hello", - IncludedAnyLabelIDs: []int64{2001, 2002}, - ExcludedLabelIDs: []int64{2003}, - }, - ExpectedIDs: []int64{1003, 1001, 1000}, - ExpectedTotal: 3, - }, - { - Name: "MilestoneIDs", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - MilestoneIDs: []int64{1, 2, 6}, - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Contains(t, []int64{1, 2, 6}, data[v.ID].MilestoneID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.MilestoneID == 1 || v.MilestoneID == 2 || v.MilestoneID == 6 - }), result.Total) - }, - }, - { - Name: "no MilestoneIDs", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - MilestoneIDs: []int64{0}, - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].MilestoneID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.MilestoneID == 0 - }), result.Total) - }, - }, - { - Name: "ProjectID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ProjectID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].ProjectID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 1 - }), result.Total) - }, - }, - { - Name: "no ProjectID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ProjectID: optional.Some(int64(0)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].ProjectID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectID == 0 - }), result.Total) - }, - }, - { - Name: "ProjectColumnID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ProjectColumnID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].ProjectColumnID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectColumnID == 1 - }), result.Total) - }, - }, - { - Name: "no ProjectColumnID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ProjectColumnID: optional.Some(int64(0)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].ProjectColumnID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.ProjectColumnID == 0 - }), result.Total) - }, - }, - { - Name: "PosterID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - PosterID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].PosterID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.PosterID == 1 - }), result.Total) - }, - }, - { - Name: "AssigneeID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - AssigneeID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(1), data[v.ID].AssigneeID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.AssigneeID == 1 - }), result.Total) - }, - }, - { - Name: "no AssigneeID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - AssigneeID: optional.Some(int64(0)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Equal(t, int64(0), data[v.ID].AssigneeID) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return v.AssigneeID == 0 - }), result.Total) - }, - }, - { - Name: "MentionID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - MentionID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Contains(t, data[v.ID].MentionIDs, int64(1)) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return slices.Contains(v.MentionIDs, 1) - }), result.Total) - }, - }, - { - Name: "ReviewedID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ReviewedID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Contains(t, data[v.ID].ReviewedIDs, int64(1)) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return slices.Contains(v.ReviewedIDs, 1) - }), result.Total) - }, - }, - { - Name: "ReviewRequestedID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - ReviewRequestedID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Contains(t, data[v.ID].ReviewRequestedIDs, int64(1)) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return slices.Contains(v.ReviewRequestedIDs, 1) - }), result.Total) - }, - }, - { - Name: "SubscriberID", - SearchOptions: &internal.SearchOptions{ - Paginator: &db.ListOptions{ - PageSize: 5, - }, - SubscriberID: optional.Some(int64(1)), - }, - Expected: func(t *testing.T, data map[int64]*internal.IndexerData, result *internal.SearchResult) { - assert.Equal(t, 5, len(result.Hits)) - for _, v := range result.Hits { - assert.Contains(t, data[v.ID].SubscriberIDs, int64(1)) - } - assert.Equal(t, countIndexerData(data, func(v *internal.IndexerData) bool { - return slices.Contains(v.SubscriberIDs, 1) - }), result.Total) }, }, { @@ -694,29 +372,14 @@ func generateDefaultIndexerData() []*internal.IndexerData { } data = append(data, &internal.IndexerData{ - ID: id, - RepoID: repoID, - IsPublic: repoID%2 == 0, - Title: fmt.Sprintf("conversation%d of repo%d", conversationIndex, repoID), - Content: fmt.Sprintf("content%d", conversationIndex), - Comments: comments, - IsPull: conversationIndex%2 == 0, - IsClosed: conversationIndex%3 == 0, - LabelIDs: labelIDs, - NoLabel: len(labelIDs) == 0, - MilestoneID: conversationIndex % 4, - ProjectID: conversationIndex % 5, - ProjectColumnID: conversationIndex % 6, - PosterID: id%10 + 1, // PosterID should not be 0 - AssigneeID: conversationIndex % 10, - MentionIDs: mentionIDs, - ReviewedIDs: reviewedIDs, - ReviewRequestedIDs: reviewRequestedIDs, - SubscriberIDs: subscriberIDs, - UpdatedUnix: timeutil.TimeStamp(id + conversationIndex), - CreatedUnix: timeutil.TimeStamp(id), - DeadlineUnix: timeutil.TimeStamp(id + conversationIndex + repoID), - CommentCount: int64(len(comments)), + ID: id, + RepoID: repoID, + IsPublic: repoID%2 == 0, + Comments: comments, + UpdatedUnix: timeutil.TimeStamp(id + conversationIndex), + CreatedUnix: timeutil.TimeStamp(id), + DeadlineUnix: timeutil.TimeStamp(id + conversationIndex + repoID), + CommentCount: int64(len(comments)), }) } } diff --git a/modules/indexer/conversations/meilisearch/meilisearch.go b/modules/indexer/conversations/meilisearch/meilisearch.go index 9a3d857481fc5..5c7c9a7da896d 100644 --- a/modules/indexer/conversations/meilisearch/meilisearch.go +++ b/modules/indexer/conversations/meilisearch/meilisearch.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package meilisearch @@ -139,68 +139,6 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) ( query.And(q) } - if options.IsPull.Has() { - query.And(inner_meilisearch.NewFilterEq("is_pull", options.IsPull.Value())) - } - if options.IsClosed.Has() { - query.And(inner_meilisearch.NewFilterEq("is_closed", options.IsClosed.Value())) - } - - if options.NoLabelOnly { - query.And(inner_meilisearch.NewFilterEq("no_label", true)) - } else { - if len(options.IncludedLabelIDs) > 0 { - q := &inner_meilisearch.FilterAnd{} - for _, labelID := range options.IncludedLabelIDs { - q.And(inner_meilisearch.NewFilterEq("label_ids", labelID)) - } - query.And(q) - } else if len(options.IncludedAnyLabelIDs) > 0 { - query.And(inner_meilisearch.NewFilterIn("label_ids", options.IncludedAnyLabelIDs...)) - } - if len(options.ExcludedLabelIDs) > 0 { - q := &inner_meilisearch.FilterAnd{} - for _, labelID := range options.ExcludedLabelIDs { - q.And(inner_meilisearch.NewFilterNot(inner_meilisearch.NewFilterEq("label_ids", labelID))) - } - query.And(q) - } - } - - if len(options.MilestoneIDs) > 0 { - query.And(inner_meilisearch.NewFilterIn("milestone_id", options.MilestoneIDs...)) - } - - if options.ProjectID.Has() { - query.And(inner_meilisearch.NewFilterEq("project_id", options.ProjectID.Value())) - } - if options.ProjectColumnID.Has() { - query.And(inner_meilisearch.NewFilterEq("project_board_id", options.ProjectColumnID.Value())) - } - - if options.PosterID.Has() { - query.And(inner_meilisearch.NewFilterEq("poster_id", options.PosterID.Value())) - } - - if options.AssigneeID.Has() { - query.And(inner_meilisearch.NewFilterEq("assignee_id", options.AssigneeID.Value())) - } - - if options.MentionID.Has() { - query.And(inner_meilisearch.NewFilterEq("mention_ids", options.MentionID.Value())) - } - - if options.ReviewedID.Has() { - query.And(inner_meilisearch.NewFilterEq("reviewed_ids", options.ReviewedID.Value())) - } - if options.ReviewRequestedID.Has() { - query.And(inner_meilisearch.NewFilterEq("review_requested_ids", options.ReviewRequestedID.Value())) - } - - if options.SubscriberID.Has() { - query.And(inner_meilisearch.NewFilterEq("subscriber_ids", options.SubscriberID.Value())) - } - if options.UpdatedAfterUnix.Has() { query.And(inner_meilisearch.NewFilterGte("updated_unix", options.UpdatedAfterUnix.Value())) } diff --git a/modules/indexer/conversations/meilisearch/meilisearch_test.go b/modules/indexer/conversations/meilisearch/meilisearch_test.go index 4cf931ba11438..fbbc759f89da3 100644 --- a/modules/indexer/conversations/meilisearch/meilisearch_test.go +++ b/modules/indexer/conversations/meilisearch/meilisearch_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package meilisearch diff --git a/modules/indexer/conversations/util.go b/modules/indexer/conversations/util.go index d68c1e73951f4..ffc78c1b2e47a 100644 --- a/modules/indexer/conversations/util.go +++ b/modules/indexer/conversations/util.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package conversations @@ -43,17 +43,11 @@ func getConversationIndexerData(ctx context.Context, conversationID int64) (*int return nil, false, err } - mentionIDs, err := conversation_model.GetConversationMentionIDs(ctx, conversationID) - if err != nil { - return nil, false, err - } - return &internal.IndexerData{ ID: conversation.ID, RepoID: conversation.RepoID, IsPublic: !conversation.Repo.IsPrivate, Comments: comments, - MentionIDs: mentionIDs, UpdatedUnix: conversation.UpdatedUnix, CreatedUnix: conversation.CreatedUnix, CommentCount: int64(len(conversation.Comments)), diff --git a/routers/web/repo/conversation.go b/routers/web/repo/conversation.go index 2e4ccfcc37ffc..384d33ed9909b 100644 --- a/routers/web/repo/conversation.go +++ b/routers/web/repo/conversation.go @@ -1,5 +1,4 @@ -// Copyright 2014 The Gogs Authors. All rights reserved. -// Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Gogs Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -420,16 +419,6 @@ func SearchConversations(ctx *context.Context) { return } - var isClosed optional.Option[bool] - switch ctx.FormString("state") { - case "closed": - isClosed = optional.Some(true) - case "all": - isClosed = optional.None[bool]() - default: - isClosed = optional.Some(false) - } - repoIDs, allPublic := GetUserAccessibleRepo(ctx) keyword := ctx.FormTrim("q") @@ -454,7 +443,6 @@ func SearchConversations(ctx *context.Context) { Keyword: keyword, RepoIDs: repoIDs, AllPublic: allPublic, - IsClosed: isClosed, SortBy: conversation_indexer.SortByCreatedDesc, } @@ -465,25 +453,6 @@ func SearchConversations(ctx *context.Context) { searchOpt.UpdatedBeforeUnix = optional.Some(before) } - if ctx.IsSigned { - ctxUserID := ctx.Doer.ID - if ctx.FormBool("created") { - searchOpt.PosterID = optional.Some(ctxUserID) - } - if ctx.FormBool("assigned") { - searchOpt.AssigneeID = optional.Some(ctxUserID) - } - if ctx.FormBool("mentioned") { - searchOpt.MentionID = optional.Some(ctxUserID) - } - if ctx.FormBool("review_requested") { - searchOpt.ReviewRequestedID = optional.Some(ctxUserID) - } - if ctx.FormBool("reviewed") { - searchOpt.ReviewedID = optional.Some(ctxUserID) - } - } - // FIXME: It's unsupported to sort by priority repo when searching by indexer, // it's indeed an regression, but I think it is worth to support filtering by indexer first. _ = ctx.FormInt64("priority_repo_id") @@ -579,57 +548,19 @@ func ListConversations(ctx *context.Context) { return } - var isClosed optional.Option[bool] - switch ctx.FormString("state") { - case "closed": - isClosed = optional.Some(true) - case "all": - isClosed = optional.None[bool]() - default: - isClosed = optional.Some(false) - } - keyword := ctx.FormTrim("q") if strings.IndexByte(keyword, 0) >= 0 { keyword = "" } - projectID := optional.None[int64]() - if v := ctx.FormInt64("project"); v > 0 { - projectID = optional.Some(v) - } - - isPull := optional.None[bool]() - switch ctx.FormString("type") { - case "conversations": - isPull = optional.Some(false) - } - - // FIXME: we should be more efficient here - createdByID := getUserIDForFilter(ctx, "created_by") - if ctx.Written() { - return - } - assignedByID := getUserIDForFilter(ctx, "assigned_by") - if ctx.Written() { - return - } - mentionedByID := getUserIDForFilter(ctx, "mentioned_by") - if ctx.Written() { - return - } - searchOpt := &conversation_indexer.SearchOptions{ Paginator: &db.ListOptions{ Page: ctx.FormInt("page"), PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), }, - Keyword: keyword, - RepoIDs: []int64{ctx.Repo.Repository.ID}, - IsPull: isPull, - IsClosed: isClosed, - ProjectID: projectID, - SortBy: conversation_indexer.SortByCreatedDesc, + Keyword: keyword, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + SortBy: conversation_indexer.SortByCreatedDesc, } if since != 0 { searchOpt.UpdatedAfterUnix = optional.Some(since) @@ -637,15 +568,6 @@ func ListConversations(ctx *context.Context) { if before != 0 { searchOpt.UpdatedBeforeUnix = optional.Some(before) } - if createdByID > 0 { - searchOpt.PosterID = optional.Some(createdByID) - } - if assignedByID > 0 { - searchOpt.AssigneeID = optional.Some(assignedByID) - } - if mentionedByID > 0 { - searchOpt.MentionID = optional.Some(mentionedByID) - } ids, total, err := conversation_indexer.SearchConversations(ctx, searchOpt) if err != nil { From 6f9647b2b9d4569bade9d2d15a22d4d73e34532e Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 17:13:02 +0800 Subject: [PATCH 36/72] Remove invalid options from conversation_stat.go --- models/conversations/conversation_stat.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/models/conversations/conversation_stat.go b/models/conversations/conversation_stat.go index bab3e1a502944..faa5ca1d80d1e 100644 --- a/models/conversations/conversation_stat.go +++ b/models/conversations/conversation_stat.go @@ -143,18 +143,6 @@ func applyConversationsOptions(sess *xorm.Session, opts *ConversationsOptions, c sess.In("conversation.id", conversationIDs) } - if opts.PosterID > 0 { - applyPosterCondition(sess, opts.PosterID) - } - - if opts.MentionedID > 0 { - applyMentionedCondition(sess, opts.MentionedID) - } - - if opts.IsPull.Has() { - sess.And("conversation.is_pull=?", opts.IsPull.Value()) - } - return sess } From 0d65d7140b044052e63b97b3b07972db68810c65 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 17:47:26 +0800 Subject: [PATCH 37/72] Load issue attributes instead of only loading pullrequest details --- routers/web/repo/pull_review.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index 305187c14be5a..ea3b45a956afa 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -194,8 +194,8 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori ctx.ServerError("CanMarkConversation", err) return } - if err = comment.Issue.LoadPullRequest(ctx); err != nil { - ctx.ServerError("comment.Issue.LoadPullRequest", err) + if err = comment.Issue.LoadAttributes(ctx); err != nil { + ctx.ServerError("comment.Issue.LoadAttributes", err) return } ctx.Data["Issue"] = comment.Issue From 5b9049dec02e8930562470dee7e874a6d67ab7bb Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 19:04:34 +0800 Subject: [PATCH 38/72] Add ShouldShowCommentType function when rendering pull review conversation --- routers/web/repo/pull_review.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/routers/web/repo/pull_review.go b/routers/web/repo/pull_review.go index ea3b45a956afa..abd7f2670e1b4 100644 --- a/routers/web/repo/pull_review.go +++ b/routers/web/repo/pull_review.go @@ -6,6 +6,7 @@ package repo import ( "errors" "fmt" + "math/big" "net/http" issues_model "code.gitea.io/gitea/models/issues" @@ -198,6 +199,19 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori ctx.ServerError("comment.Issue.LoadAttributes", err) return } + + var hiddenCommentTypes *big.Int + if ctx.IsSigned { + val, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyHiddenCommentTypes) + if err != nil { + ctx.ServerError("GetUserSetting", err) + return + } + hiddenCommentTypes, _ = new(big.Int).SetString(val, 10) // we can safely ignore the failed conversion here + } + ctx.Data["ShouldShowCommentType"] = func(commentType issues_model.CommentType) bool { + return hiddenCommentTypes == nil || hiddenCommentTypes.Bit(int(commentType)) == 0 + } ctx.Data["Issue"] = comment.Issue ctx.Data["IsIssue"] = true ctx.Data["Comments"] = comment.Issue.Comments From 8e012ebbb22b0468d746e31ad33463a71f0ef595 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 20:37:52 +0800 Subject: [PATCH 39/72] add comments for testing conversation search --- .../conversations/internal/tests/tests.go | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/modules/indexer/conversations/internal/tests/tests.go b/modules/indexer/conversations/internal/tests/tests.go index 57585c3c570e8..5bb881802a8a1 100644 --- a/modules/indexer/conversations/internal/tests/tests.go +++ b/modules/indexer/conversations/internal/tests/tests.go @@ -120,25 +120,25 @@ var cases = []*testIndexerCase{ Name: "Keyword", ExtraData: []*internal.IndexerData{ {ID: 1000}, - {ID: 1001}, + {ID: 1001, Comments: []string{"hi", "hello world"}}, {ID: 1002, Comments: []string{"hi", "hello world"}}, }, SearchOptions: &internal.SearchOptions{ Keyword: "hello", }, - ExpectedIDs: []int64{1002, 1001, 1000}, - ExpectedTotal: 3, + ExpectedIDs: []int64{1002, 1001}, + ExpectedTotal: 2, }, { Name: "RepoIDs", ExtraData: []*internal.IndexerData{ - {ID: 1001, RepoID: 1, IsPublic: false}, - {ID: 1002, RepoID: 1, IsPublic: false}, - {ID: 1003, RepoID: 2, IsPublic: true}, - {ID: 1004, RepoID: 2, IsPublic: true}, - {ID: 1005, RepoID: 3, IsPublic: true}, - {ID: 1006, RepoID: 4, IsPublic: false}, - {ID: 1007, RepoID: 5, IsPublic: false}, + {ID: 1001, RepoID: 1, IsPublic: false, Comments: []string{"hi", "hello world"}}, + {ID: 1002, RepoID: 1, IsPublic: false, Comments: []string{"hi", "hello world"}}, + {ID: 1003, RepoID: 2, IsPublic: true, Comments: []string{"hi", "hello world"}}, + {ID: 1004, RepoID: 2, IsPublic: true, Comments: []string{"hi", "hello world"}}, + {ID: 1005, RepoID: 3, IsPublic: true, Comments: []string{"hi", "hello world"}}, + {ID: 1006, RepoID: 4, IsPublic: false, Comments: []string{"hi", "hello world"}}, + {ID: 1007, RepoID: 5, IsPublic: false, Comments: []string{"hi", "hello world"}}, }, SearchOptions: &internal.SearchOptions{ Keyword: "hello", @@ -150,13 +150,13 @@ var cases = []*testIndexerCase{ { Name: "RepoIDs and AllPublic", ExtraData: []*internal.IndexerData{ - {ID: 1001, RepoID: 1, IsPublic: false}, - {ID: 1002, RepoID: 1, IsPublic: false}, - {ID: 1003, RepoID: 2, IsPublic: true}, - {ID: 1004, RepoID: 2, IsPublic: true}, - {ID: 1005, RepoID: 3, IsPublic: true}, - {ID: 1006, RepoID: 4, IsPublic: false}, - {ID: 1007, RepoID: 5, IsPublic: false}, + {ID: 1001, RepoID: 1, IsPublic: false, Comments: []string{"hi", "hello world"}}, + {ID: 1002, RepoID: 1, IsPublic: false, Comments: []string{"hi", "hello world"}}, + {ID: 1003, RepoID: 2, IsPublic: true, Comments: []string{"hi", "hello world"}}, + {ID: 1004, RepoID: 2, IsPublic: true, Comments: []string{"hi", "hello world"}}, + {ID: 1005, RepoID: 3, IsPublic: true, Comments: []string{"hi", "hello world"}}, + {ID: 1006, RepoID: 4, IsPublic: false, Comments: []string{"hi", "hello world"}}, + {ID: 1007, RepoID: 5, IsPublic: false, Comments: []string{"hi", "hello world"}}, }, SearchOptions: &internal.SearchOptions{ Keyword: "hello", @@ -350,27 +350,6 @@ func generateDefaultIndexerData() []*internal.IndexerData { comments[i] = fmt.Sprintf("comment%d", i) } - labelIDs := make([]int64, id%5) - for i := range labelIDs { - labelIDs[i] = int64(i) + 1 // LabelID should not be 0 - } - mentionIDs := make([]int64, id%6) - for i := range mentionIDs { - mentionIDs[i] = int64(i) + 1 // MentionID should not be 0 - } - reviewedIDs := make([]int64, id%7) - for i := range reviewedIDs { - reviewedIDs[i] = int64(i) + 1 // ReviewID should not be 0 - } - reviewRequestedIDs := make([]int64, id%8) - for i := range reviewRequestedIDs { - reviewRequestedIDs[i] = int64(i) + 1 // ReviewRequestedID should not be 0 - } - subscriberIDs := make([]int64, id%9) - for i := range subscriberIDs { - subscriberIDs[i] = int64(i) + 1 // SubscriberID should not be 0 - } - data = append(data, &internal.IndexerData{ ID: id, RepoID: repoID, From a3e5eaf91727b68d8711cb4b32835450a47d8d65 Mon Sep 17 00:00:00 2001 From: RedCocoon Date: Mon, 4 Nov 2024 23:38:45 +0800 Subject: [PATCH 40/72] Revert "Merge remote-tracking branch 'upstream/main'" This reverts commit 2585c8b66a454252b0cf265d62f76c33839f6f2c, reversing changes made to 8e012ebbb22b0468d746e31ad33463a71f0ef595. --- custom/conf/app.example.ini | 8 +- go.mod | 2 +- modules/gitrepo/gitrepo.go | 9 +- modules/lfs/http_client.go | 119 ++- modules/lfs/http_client_test.go | 151 ++-- modules/log/event_writer_console.go | 13 +- modules/log/event_writer_file.go | 3 +- modules/markup/html.go | 757 ++++++++++++++++++ modules/markup/html_commit.go | 225 ------ modules/markup/html_email.go | 21 - modules/markup/html_emoji.go | 115 --- modules/markup/html_issue.go | 180 ----- modules/markup/html_link.go | 227 ------ modules/markup/html_mention.go | 54 -- modules/markup/markdown/goldmark.go | 2 +- modules/markup/markdown/toc.go | 10 +- modules/markup/markdown/transform_heading.go | 4 +- modules/markup/render.go | 226 ------ modules/markup/render_helper.go | 21 - modules/markup/render_links.go | 56 -- modules/markup/renderer.go | 301 ++++++- modules/packages/debian/metadata_test.go | 11 +- modules/repository/repo.go | 9 +- modules/setting/lfs.go | 8 +- modules/setting/lfs_test.go | 13 - modules/templates/helper.go | 15 +- modules/templates/util_date.go | 111 +-- modules/templates/util_date_test.go | 23 +- modules/timeutil/datetime.go | 60 ++ modules/timeutil/since.go | 41 +- modules/util/io.go | 6 - options/locale/locale_en-US.ini | 4 - routers/web/repo/blame.go | 3 +- routers/web/repo/issue_content_history.go | 5 +- services/context/context.go | 1 + templates/admin/auth/list.tmpl | 4 +- templates/admin/cron.tmpl | 4 +- templates/admin/notice.tmpl | 2 +- templates/admin/org/list.tmpl | 2 +- templates/admin/packages/list.tmpl | 2 +- templates/admin/repo/list.tmpl | 4 +- templates/admin/stacktrace-row.tmpl | 2 +- templates/admin/user/list.tmpl | 4 +- templates/devtest/gitea-ui.tmpl | 14 +- templates/explore/repo_list.tmpl | 2 +- templates/explore/user_list.tmpl | 2 +- .../package/shared/cleanup_rules/preview.tmpl | 2 +- templates/package/shared/list.tmpl | 2 +- templates/package/shared/versionlist.tmpl | 2 +- templates/package/view.tmpl | 6 +- templates/repo/actions/runs_list.tmpl | 2 +- templates/repo/branch/list.tmpl | 6 +- .../code/recently_pushed_new_branches.tmpl | 2 +- templates/repo/commit_page.tmpl | 4 +- templates/repo/commits_list.tmpl | 4 +- templates/repo/conversation/comments.tmpl | 12 +- templates/repo/diff/comments.tmpl | 2 +- templates/repo/diff/compare.tmpl | 2 +- templates/repo/empty.tmpl | 2 +- templates/repo/graph/commits.tmpl | 2 +- templates/repo/header.tmpl | 2 +- templates/repo/home.tmpl | 2 +- templates/repo/issue/card.tmpl | 2 +- templates/repo/issue/milestone_issues.tmpl | 4 +- templates/repo/issue/milestones.tmpl | 6 +- templates/repo/issue/view_content.tmpl | 2 +- .../repo/issue/view_content/conversation.tmpl | 143 ---- templates/repo/issue/view_content/pull.tmpl | 2 +- .../repo/issue/view_content/sidebar.tmpl | 2 +- templates/repo/issue/view_title.tmpl | 4 +- templates/repo/pulse.tmpl | 14 +- templates/repo/release/list.tmpl | 2 +- templates/repo/settings/deploy_keys.tmpl | 2 +- templates/repo/settings/lfs.tmpl | 2 +- templates/repo/settings/lfs_file_find.tmpl | 2 +- templates/repo/settings/lfs_locks.tmpl | 2 +- templates/repo/settings/options.tmpl | 4 +- templates/repo/settings/webhook/history.tmpl | 2 +- templates/repo/tag/list.tmpl | 2 +- templates/repo/user_cards.tmpl | 2 +- templates/repo/view_file.tmpl | 2 +- templates/repo/view_list.tmpl | 4 +- templates/repo/wiki/pages.tmpl | 2 +- templates/repo/wiki/revision.tmpl | 2 +- templates/repo/wiki/view.tmpl | 2 +- templates/shared/actions/runner_edit.tmpl | 4 +- templates/shared/actions/runner_list.tmpl | 2 +- templates/shared/combomarkdowneditor.tmpl | 15 +- templates/shared/issuelist.tmpl | 4 +- templates/shared/searchbottom.tmpl | 2 +- templates/shared/secrets/add_list.tmpl | 2 +- templates/shared/user/profile_big_avatar.tmpl | 2 +- templates/shared/variables/variable_list.tmpl | 2 +- templates/user/dashboard/feeds.tmpl | 2 +- templates/user/dashboard/milestones.tmpl | 6 +- .../user/notification/notification_div.tmpl | 4 +- templates/user/settings/applications.tmpl | 2 +- templates/user/settings/grants_oauth2.tmpl | 2 +- templates/user/settings/keys_gpg.tmpl | 4 +- templates/user/settings/keys_principal.tmpl | 2 +- templates/user/settings/keys_ssh.tmpl | 2 +- .../user/settings/security/webauthn.tmpl | 2 +- web_src/css/editor/combomarkdowneditor.css | 28 +- web_src/css/modules/comment.css | 1 + .../js/features/comp/ComboMarkdownEditor.ts | 51 +- .../js/features/comp/EditorMarkdown.test.ts | 27 - web_src/js/features/comp/EditorMarkdown.ts | 21 +- web_src/js/features/comp/EditorUpload.ts | 11 +- web_src/js/modules/tippy.ts | 2 +- 109 files changed, 1476 insertions(+), 1845 deletions(-) delete mode 100644 modules/markup/html_commit.go delete mode 100644 modules/markup/html_email.go delete mode 100644 modules/markup/html_emoji.go delete mode 100644 modules/markup/html_issue.go delete mode 100644 modules/markup/html_mention.go delete mode 100644 modules/markup/render.go delete mode 100644 modules/markup/render_helper.go delete mode 100644 modules/markup/render_links.go create mode 100644 modules/timeutil/datetime.go delete mode 100644 templates/repo/issue/view_content/conversation.tmpl delete mode 100644 web_src/js/features/comp/EditorMarkdown.test.ts diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index e080b0be72733..69b57a8c01257 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2642,15 +2642,9 @@ LEVEL = Info ;; override the azure blob base path if storage type is azureblob ;AZURE_BLOB_BASE_PATH = lfs/ -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; settings for Gitea's LFS client (eg: mirroring an upstream lfs endpoint) -;; ;[lfs_client] -;; Limit the number of pointers in each batch request to this number +;; When mirroring an upstream lfs endpoint, limit the number of pointers in each batch request to this number ;BATCH_SIZE = 20 -;; Limit the number of concurrent upload/download operations within a batch -;BATCH_OPERATION_CONCURRENCY = 3 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/go.mod b/go.mod index ff0d612133e36..c98ef9a61bf4a 100644 --- a/go.mod +++ b/go.mod @@ -124,7 +124,6 @@ require ( golang.org/x/image v0.21.0 golang.org/x/net v0.30.0 golang.org/x/oauth2 v0.23.0 - golang.org/x/sync v0.8.0 golang.org/x/sys v0.26.0 golang.org/x/text v0.19.0 golang.org/x/tools v0.26.0 @@ -317,6 +316,7 @@ require ( go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect golang.org/x/mod v0.21.0 // indirect + golang.org/x/sync v0.8.0 // indirect golang.org/x/time v0.7.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/modules/gitrepo/gitrepo.go b/modules/gitrepo/gitrepo.go index 14d809aedbe4a..d89f8f9c0c88c 100644 --- a/modules/gitrepo/gitrepo.go +++ b/modules/gitrepo/gitrepo.go @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" ) type Repository interface { @@ -60,11 +59,15 @@ func repositoryFromContext(ctx context.Context, repo Repository) *git.Repository return nil } +type nopCloser func() + +func (nopCloser) Close() error { return nil } + // RepositoryFromContextOrOpen attempts to get the repository from the context or just opens it func RepositoryFromContextOrOpen(ctx context.Context, repo Repository) (*git.Repository, io.Closer, error) { gitRepo := repositoryFromContext(ctx, repo) if gitRepo != nil { - return gitRepo, util.NopCloser{}, nil + return gitRepo, nopCloser(nil), nil } gitRepo, err := OpenRepository(ctx, repo) @@ -92,7 +95,7 @@ func repositoryFromContextPath(ctx context.Context, path string) *git.Repository func RepositoryFromContextOrOpenPath(ctx context.Context, path string) (*git.Repository, io.Closer, error) { gitRepo := repositoryFromContextPath(ctx, path) if gitRepo != nil { - return gitRepo, util.NopCloser{}, nil + return gitRepo, nopCloser(nil), nil } gitRepo, err := git.OpenRepository(ctx, path) diff --git a/modules/lfs/http_client.go b/modules/lfs/http_client.go index 411c4248c4aa9..aa9e744d72b8f 100644 --- a/modules/lfs/http_client.go +++ b/modules/lfs/http_client.go @@ -17,8 +17,6 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/setting" - - "golang.org/x/sync/errgroup" ) // HTTPClient is used to communicate with the LFS server @@ -115,7 +113,6 @@ func (c *HTTPClient) Upload(ctx context.Context, objects []Pointer, callback Upl return c.performOperation(ctx, objects, nil, callback) } -// performOperation takes a slice of LFS object pointers, batches them, and performs the upload/download operations concurrently in each batch func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc DownloadCallback, uc UploadCallback) error { if len(objects) == 0 { return nil @@ -136,87 +133,71 @@ func (c *HTTPClient) performOperation(ctx context.Context, objects []Pointer, dc return fmt.Errorf("TransferAdapter not found: %s", result.Transfer) } - errGroup, groupCtx := errgroup.WithContext(ctx) - errGroup.SetLimit(setting.LFSClient.BatchOperationConcurrency) for _, object := range result.Objects { - errGroup.Go(func() error { - return performSingleOperation(groupCtx, object, dc, uc, transferAdapter) - }) - } + if object.Error != nil { + log.Trace("Error on object %v: %v", object.Pointer, object.Error) + if uc != nil { + if _, err := uc(object.Pointer, object.Error); err != nil { + return err + } + } else { + if err := dc(object.Pointer, nil, object.Error); err != nil { + return err + } + } + continue + } - // only the first error is returned, preserving legacy behavior before concurrency - return errGroup.Wait() -} + if uc != nil { + if len(object.Actions) == 0 { + log.Trace("%v already present on server", object.Pointer) + continue + } -// performSingleOperation performs an LFS upload or download operation on a single object -func performSingleOperation(ctx context.Context, object *ObjectResponse, dc DownloadCallback, uc UploadCallback, transferAdapter TransferAdapter) error { - // the response from a lfs batch api request for this specific object id contained an error - if object.Error != nil { - log.Trace("Error on object %v: %v", object.Pointer, object.Error) + link, ok := object.Actions["upload"] + if !ok { + log.Debug("%+v", object) + return errors.New("missing action 'upload'") + } - // this was an 'upload' request inside the batch request - if uc != nil { - if _, err := uc(object.Pointer, object.Error); err != nil { + content, err := uc(object.Pointer, nil) + if err != nil { return err } - } else { - // this was NOT an 'upload' request inside the batch request, meaning it must be a 'download' request - if err := dc(object.Pointer, nil, object.Error); err != nil { + + err = transferAdapter.Upload(ctx, link, object.Pointer, content) + if err != nil { return err } - } - // if the callback returns no err, then the error could be ignored, and the operations should continue - return nil - } - - // the response from an lfs batch api request contained necessary upload/download fields to act upon - if uc != nil { - if len(object.Actions) == 0 { - log.Trace("%v already present on server", object.Pointer) - return nil - } - link, ok := object.Actions["upload"] - if !ok { - return errors.New("missing action 'upload'") - } - - content, err := uc(object.Pointer, nil) - if err != nil { - return err - } - - err = transferAdapter.Upload(ctx, link, object.Pointer, content) - if err != nil { - return err - } + link, ok = object.Actions["verify"] + if ok { + if err := transferAdapter.Verify(ctx, link, object.Pointer); err != nil { + return err + } + } + } else { + link, ok := object.Actions["download"] + if !ok { + // no actions block in response, try legacy response schema + link, ok = object.Links["download"] + } + if !ok { + log.Debug("%+v", object) + return errors.New("missing action 'download'") + } - link, ok = object.Actions["verify"] - if ok { - if err := transferAdapter.Verify(ctx, link, object.Pointer); err != nil { + content, err := transferAdapter.Download(ctx, link) + if err != nil { return err } - } - } else { - link, ok := object.Actions["download"] - if !ok { - // no actions block in response, try legacy response schema - link, ok = object.Links["download"] - } - if !ok { - log.Debug("%+v", object) - return errors.New("missing action 'download'") - } - - content, err := transferAdapter.Download(ctx, link) - if err != nil { - return err - } - if err := dc(object.Pointer, content, nil); err != nil { - return err + if err := dc(object.Pointer, content, nil); err != nil { + return err + } } } + return nil } diff --git a/modules/lfs/http_client_test.go b/modules/lfs/http_client_test.go index d22735147a556..ec90f5375d1b9 100644 --- a/modules/lfs/http_client_test.go +++ b/modules/lfs/http_client_test.go @@ -12,8 +12,6 @@ import ( "testing" "code.gitea.io/gitea/modules/json" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/test" "github.com/stretchr/testify/assert" ) @@ -185,84 +183,93 @@ func TestHTTPClientDownload(t *testing.T) { cases := []struct { endpoint string - expectedError string + expectederror string }{ + // case 0 { endpoint: "https://status-not-ok.io", - expectedError: io.ErrUnexpectedEOF.Error(), + expectederror: io.ErrUnexpectedEOF.Error(), }, + // case 1 { endpoint: "https://invalid-json-response.io", - expectedError: "invalid json", + expectederror: "invalid json", }, + // case 2 { endpoint: "https://valid-batch-request-download.io", - expectedError: "", + expectederror: "", }, + // case 3 { endpoint: "https://response-no-objects.io", - expectedError: "", + expectederror: "", }, + // case 4 { endpoint: "https://unknown-transfer-adapter.io", - expectedError: "TransferAdapter not found: ", + expectederror: "TransferAdapter not found: ", }, + // case 5 { endpoint: "https://error-in-response-objects.io", - expectedError: "Object not found", + expectederror: "Object not found", }, + // case 6 { endpoint: "https://empty-actions-map.io", - expectedError: "missing action 'download'", + expectederror: "missing action 'download'", }, + // case 7 { endpoint: "https://download-actions-map.io", - expectedError: "", + expectederror: "", }, + // case 8 { endpoint: "https://upload-actions-map.io", - expectedError: "missing action 'download'", + expectederror: "missing action 'download'", }, + // case 9 { endpoint: "https://verify-actions-map.io", - expectedError: "missing action 'download'", + expectederror: "missing action 'download'", }, + // case 10 { endpoint: "https://unknown-actions-map.io", - expectedError: "missing action 'download'", + expectederror: "missing action 'download'", }, + // case 11 { endpoint: "https://legacy-batch-request-download.io", - expectedError: "", + expectederror: "", }, } - defer test.MockVariableValue(&setting.LFSClient.BatchOperationConcurrency, 3)() - for _, c := range cases { - t.Run(c.endpoint, func(t *testing.T) { - client := &HTTPClient{ - client: hc, - endpoint: c.endpoint, - transfers: map[string]TransferAdapter{ - "dummy": dummy, - }, - } + for n, c := range cases { + client := &HTTPClient{ + client: hc, + endpoint: c.endpoint, + transfers: map[string]TransferAdapter{ + "dummy": dummy, + }, + } - err := client.Download(context.Background(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error { - if objectError != nil { - return objectError - } - b, err := io.ReadAll(content) - assert.NoError(t, err) - assert.Equal(t, []byte("dummy"), b) - return nil - }) - if c.expectedError != "" { - assert.ErrorContains(t, err, c.expectedError) - } else { - assert.NoError(t, err) + err := client.Download(context.Background(), []Pointer{p}, func(p Pointer, content io.ReadCloser, objectError error) error { + if objectError != nil { + return objectError } + b, err := io.ReadAll(content) + assert.NoError(t, err) + assert.Equal(t, []byte("dummy"), b) + return nil }) + if len(c.expectederror) > 0 { + assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror) + } else { + assert.NoError(t, err, "case %d", n) + } } } @@ -289,73 +296,81 @@ func TestHTTPClientUpload(t *testing.T) { cases := []struct { endpoint string - expectedError string + expectederror string }{ + // case 0 { endpoint: "https://status-not-ok.io", - expectedError: io.ErrUnexpectedEOF.Error(), + expectederror: io.ErrUnexpectedEOF.Error(), }, + // case 1 { endpoint: "https://invalid-json-response.io", - expectedError: "invalid json", + expectederror: "invalid json", }, + // case 2 { endpoint: "https://valid-batch-request-upload.io", - expectedError: "", + expectederror: "", }, + // case 3 { endpoint: "https://response-no-objects.io", - expectedError: "", + expectederror: "", }, + // case 4 { endpoint: "https://unknown-transfer-adapter.io", - expectedError: "TransferAdapter not found: ", + expectederror: "TransferAdapter not found: ", }, + // case 5 { endpoint: "https://error-in-response-objects.io", - expectedError: "Object not found", + expectederror: "Object not found", }, + // case 6 { endpoint: "https://empty-actions-map.io", - expectedError: "", + expectederror: "", }, + // case 7 { endpoint: "https://download-actions-map.io", - expectedError: "missing action 'upload'", + expectederror: "missing action 'upload'", }, + // case 8 { endpoint: "https://upload-actions-map.io", - expectedError: "", + expectederror: "", }, + // case 9 { endpoint: "https://verify-actions-map.io", - expectedError: "missing action 'upload'", + expectederror: "missing action 'upload'", }, + // case 10 { endpoint: "https://unknown-actions-map.io", - expectedError: "missing action 'upload'", + expectederror: "missing action 'upload'", }, } - defer test.MockVariableValue(&setting.LFSClient.BatchOperationConcurrency, 3)() - for _, c := range cases { - t.Run(c.endpoint, func(t *testing.T) { - client := &HTTPClient{ - client: hc, - endpoint: c.endpoint, - transfers: map[string]TransferAdapter{ - "dummy": dummy, - }, - } + for n, c := range cases { + client := &HTTPClient{ + client: hc, + endpoint: c.endpoint, + transfers: map[string]TransferAdapter{ + "dummy": dummy, + }, + } - err := client.Upload(context.Background(), []Pointer{p}, func(p Pointer, objectError error) (io.ReadCloser, error) { - return io.NopCloser(new(bytes.Buffer)), objectError - }) - if c.expectedError != "" { - assert.ErrorContains(t, err, c.expectedError) - } else { - assert.NoError(t, err) - } + err := client.Upload(context.Background(), []Pointer{p}, func(p Pointer, objectError error) (io.ReadCloser, error) { + return io.NopCloser(new(bytes.Buffer)), objectError }) + if len(c.expectederror) > 0 { + assert.True(t, strings.Contains(err.Error(), c.expectederror), "case %d: '%s' should contain '%s'", n, err.Error(), c.expectederror) + } else { + assert.NoError(t, err, "case %d", n) + } } } diff --git a/modules/log/event_writer_console.go b/modules/log/event_writer_console.go index e4c409d83e7cc..78183de644baa 100644 --- a/modules/log/event_writer_console.go +++ b/modules/log/event_writer_console.go @@ -4,9 +4,8 @@ package log import ( + "io" "os" - - "code.gitea.io/gitea/modules/util" ) type WriterConsoleOption struct { @@ -19,13 +18,19 @@ type eventWriterConsole struct { var _ EventWriter = (*eventWriterConsole)(nil) +type nopCloser struct { + io.Writer +} + +func (nopCloser) Close() error { return nil } + func NewEventWriterConsole(name string, mode WriterMode) EventWriter { w := &eventWriterConsole{EventWriterBaseImpl: NewEventWriterBase(name, "console", mode)} opt := mode.WriterOption.(WriterConsoleOption) if opt.Stderr { - w.OutputWriteCloser = util.NopCloser{Writer: os.Stderr} + w.OutputWriteCloser = nopCloser{os.Stderr} } else { - w.OutputWriteCloser = util.NopCloser{Writer: os.Stdout} + w.OutputWriteCloser = nopCloser{os.Stdout} } return w } diff --git a/modules/log/event_writer_file.go b/modules/log/event_writer_file.go index f26286498a663..fd73d7d30a04d 100644 --- a/modules/log/event_writer_file.go +++ b/modules/log/event_writer_file.go @@ -6,7 +6,6 @@ package log import ( "io" - "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util/rotatingfilewriter" ) @@ -43,7 +42,7 @@ func NewEventWriterFile(name string, mode WriterMode) EventWriter { // if the log file can't be opened, what should it do? panic/exit? ignore logs? fallback to stderr? // it seems that "fallback to stderr" is slightly better than others .... FallbackErrorf("unable to open log file %q: %v", opt.FileName, err) - w.fileWriter = util.NopCloser{Writer: LoggerToWriter(FallbackErrorf)} + w.fileWriter = nopCloser{Writer: LoggerToWriter(FallbackErrorf)} } w.OutputWriteCloser = w.fileWriter return w diff --git a/modules/markup/html.go b/modules/markup/html.go index a9c3dc9ba289f..8d3327c49eb8b 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -6,12 +6,25 @@ package markup import ( "bytes" "io" + "net/url" + "path" + "path/filepath" "regexp" + "slices" "strings" "sync" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/emoji" + "code.gitea.io/gitea/modules/gitrepo" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/regexplru" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" "golang.org/x/net/html" "golang.org/x/net/html/atom" @@ -438,6 +451,50 @@ func createKeyword(content string) *html.Node { return span } +func createEmoji(content, class, name string) *html.Node { + span := &html.Node{ + Type: html.ElementNode, + Data: atom.Span.String(), + Attr: []html.Attribute{}, + } + if class != "" { + span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class}) + } + if name != "" { + span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name}) + } + + text := &html.Node{ + Type: html.TextNode, + Data: content, + } + + span.AppendChild(text) + return span +} + +func createCustomEmoji(alias string) *html.Node { + span := &html.Node{ + Type: html.ElementNode, + Data: atom.Span.String(), + Attr: []html.Attribute{}, + } + span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"}) + span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias}) + + img := &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Img, + Data: "img", + Attr: []html.Attribute{}, + } + img.Attr = append(img.Attr, html.Attribute{Key: "alt", Val: ":" + alias + ":"}) + img.Attr = append(img.Attr, html.Attribute{Key: "src", Val: setting.StaticURLPrefix + "/assets/img/emoji/" + alias + ".png"}) + + span.AppendChild(img) + return span +} + func createLink(href, content, class string) *html.Node { a := &html.Node{ Type: html.ElementNode, @@ -458,6 +515,33 @@ func createLink(href, content, class string) *html.Node { return a } +func createCodeLink(href, content, class string) *html.Node { + a := &html.Node{ + Type: html.ElementNode, + Data: atom.A.String(), + Attr: []html.Attribute{{Key: "href", Val: href}}, + } + + if class != "" { + a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class}) + } + + text := &html.Node{ + Type: html.TextNode, + Data: content, + } + + code := &html.Node{ + Type: html.ElementNode, + Data: atom.Code.String(), + Attr: []html.Attribute{{Key: "class", Val: "nohighlight"}}, + } + + code.AppendChild(text) + a.AppendChild(code) + return a +} + // replaceContent takes text node, and in its content it replaces a section of // it with the specified newNode. func replaceContent(node *html.Node, i, j int, newNode *html.Node) { @@ -489,3 +573,676 @@ func replaceContentList(node *html.Node, i, j int, newNodes []*html.Node) { }, nextSibling) } } + +func mentionProcessor(ctx *RenderContext, node *html.Node) { + start := 0 + nodeStop := node.NextSibling + for node != nodeStop { + found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:])) + if !found { + node = node.NextSibling + start = 0 + continue + } + loc.Start += start + loc.End += start + mention := node.Data[loc.Start:loc.End] + teams, ok := ctx.Metas["teams"] + // FIXME: util.URLJoin may not be necessary here: + // - setting.AppURL is defined to have a terminal '/' so unless mention[1:] + // is an AppSubURL link we can probably fallback to concatenation. + // team mention should follow @orgName/teamName style + if ok && strings.Contains(mention, "/") { + mentionOrgAndTeam := strings.Split(mention, "/") + if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") { + replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention")) + node = node.NextSibling.NextSibling + start = 0 + continue + } + start = loc.End + continue + } + mentionedUsername := mention[1:] + + if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { + replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention")) + node = node.NextSibling.NextSibling + start = 0 + } else { + start = loc.End + } + } +} + +func shortLinkProcessor(ctx *RenderContext, node *html.Node) { + next := node.NextSibling + for node != nil && node != next { + m := shortLinkPattern.FindStringSubmatchIndex(node.Data) + if m == nil { + return + } + + content := node.Data[m[2]:m[3]] + tail := node.Data[m[4]:m[5]] + props := make(map[string]string) + + // MediaWiki uses [[link|text]], while GitHub uses [[text|link]] + // It makes page handling terrible, but we prefer GitHub syntax + // And fall back to MediaWiki only when it is obvious from the look + // Of text and link contents + sl := strings.Split(content, "|") + for _, v := range sl { + if equalPos := strings.IndexByte(v, '='); equalPos == -1 { + // There is no equal in this argument; this is a mandatory arg + if props["name"] == "" { + if IsFullURLString(v) { + // If we clearly see it is a link, we save it so + + // But first we need to ensure, that if both mandatory args provided + // look like links, we stick to GitHub syntax + if props["link"] != "" { + props["name"] = props["link"] + } + + props["link"] = strings.TrimSpace(v) + } else { + props["name"] = v + } + } else { + props["link"] = strings.TrimSpace(v) + } + } else { + // There is an equal; optional argument. + + sep := strings.IndexByte(v, '=') + key, val := v[:sep], html.UnescapeString(v[sep+1:]) + + // When parsing HTML, x/net/html will change all quotes which are + // not used for syntax into UTF-8 quotes. So checking val[0] won't + // be enough, since that only checks a single byte. + if len(val) > 1 { + if (strings.HasPrefix(val, "“") && strings.HasSuffix(val, "”")) || + (strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) { + const lenQuote = len("‘") + val = val[lenQuote : len(val)-lenQuote] + } else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) || + (strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) { + val = val[1 : len(val)-1] + } else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") { + const lenQuote = len("‘") + val = val[1 : len(val)-lenQuote] + } + } + props[key] = val + } + } + + var name, link string + if props["link"] != "" { + link = props["link"] + } else if props["name"] != "" { + link = props["name"] + } + if props["title"] != "" { + name = props["title"] + } else if props["name"] != "" { + name = props["name"] + } else { + name = link + } + + name += tail + image := false + ext := filepath.Ext(link) + switch ext { + // fast path: empty string, ignore + case "": + // leave image as false + case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg": + image = true + } + + childNode := &html.Node{} + linkNode := &html.Node{ + FirstChild: childNode, + LastChild: childNode, + Type: html.ElementNode, + Data: "a", + DataAtom: atom.A, + } + childNode.Parent = linkNode + absoluteLink := IsFullURLString(link) + if !absoluteLink { + if image { + link = strings.ReplaceAll(link, " ", "+") + } else { + link = strings.ReplaceAll(link, " ", "-") // FIXME: it should support dashes in the link, eg: "the-dash-support.-" + } + if !strings.Contains(link, "/") { + link = url.PathEscape(link) // FIXME: it doesn't seem right and it might cause double-escaping + } + } + if image { + if !absoluteLink { + link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), link) + } + title := props["title"] + if title == "" { + title = props["alt"] + } + if title == "" { + title = path.Base(name) + } + alt := props["alt"] + if alt == "" { + alt = name + } + + // make the childNode an image - if we can, we also place the alt + childNode.Type = html.ElementNode + childNode.Data = "img" + childNode.DataAtom = atom.Img + childNode.Attr = []html.Attribute{ + {Key: "src", Val: link}, + {Key: "title", Val: title}, + {Key: "alt", Val: alt}, + } + if alt == "" { + childNode.Attr = childNode.Attr[:2] + } + } else { + link, _ = ResolveLink(ctx, link, "") + childNode.Type = html.TextNode + childNode.Data = name + } + linkNode.Attr = []html.Attribute{{Key: "href", Val: link}} + replaceContent(node, m[0], m[1], linkNode) + node = node.NextSibling.NextSibling + } +} + +func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) { + if ctx.Metas == nil { + return + } + next := node.NextSibling + for node != nil && node != next { + m := getIssueFullPattern().FindStringSubmatchIndex(node.Data) + if m == nil { + return + } + + mDiffView := getFilesChangedFullPattern().FindStringSubmatchIndex(node.Data) + // leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files + if mDiffView != nil { + return + } + + link := node.Data[m[0]:m[1]] + text := "#" + node.Data[m[2]:m[3]] + // if m[4] and m[5] is not -1, then link is to a comment + // indicate that in the text by appending (comment) + if m[4] != -1 && m[5] != -1 { + if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok { + text += " " + locale.TrString("repo.from_comment") + } else { + text += " (comment)" + } + } + + // extract repo and org name from matched link like + // http://localhost:3000/gituser/myrepo/issues/1 + linkParts := strings.Split(link, "/") + matchOrg := linkParts[len(linkParts)-4] + matchRepo := linkParts[len(linkParts)-3] + + if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] { + replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) + } else { + text = matchOrg + "/" + matchRepo + text + replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) + } + node = node.NextSibling.NextSibling + } +} + +func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) { + if ctx.Metas == nil { + return + } + + // FIXME: the use of "mode" is quite dirty and hacky, for example: what is a "document"? how should it be rendered? + // The "mode" approach should be refactored to some other more clear&reliable way. + crossLinkOnly := ctx.Metas["mode"] == "document" && !ctx.IsWiki + + var ( + found bool + ref *references.RenderizableReference + ) + + next := node.NextSibling + + for node != nil && node != next { + _, hasExtTrackFormat := ctx.Metas["format"] + + // Repos with external issue trackers might still need to reference local PRs + // We need to concern with the first one that shows up in the text, whichever it is + isNumericStyle := ctx.Metas["style"] == "" || ctx.Metas["style"] == IssueNameStyleNumeric + foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly) + + switch ctx.Metas["style"] { + case "", IssueNameStyleNumeric: + found, ref = foundNumeric, refNumeric + case IssueNameStyleAlphanumeric: + found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data) + case IssueNameStyleRegexp: + pattern, err := regexplru.GetCompiled(ctx.Metas["regexp"]) + if err != nil { + return + } + found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern) + } + + // Repos with external issue trackers might still need to reference local PRs + // We need to concern with the first one that shows up in the text, whichever it is + if hasExtTrackFormat && !isNumericStyle && refNumeric != nil { + // If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that + // Allow a free-pass when non-numeric pattern wasn't found. + if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) { + found = foundNumeric + ref = refNumeric + } + } + if !found { + return + } + + var link *html.Node + reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End] + if hasExtTrackFormat && !ref.IsPull { + ctx.Metas["index"] = ref.Issue + + res, err := vars.Expand(ctx.Metas["format"], ctx.Metas) + if err != nil { + // here we could just log the error and continue the rendering + log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err) + } + + link = createLink(res, reftext, "ref-issue ref-external-issue") + } else { + // Path determines the type of link that will be rendered. It's unknown at this point whether + // the linked item is actually a PR or an issue. Luckily it's of no real consequence because + // Gitea will redirect on click as appropriate. + issuePath := util.Iif(ref.IsPull, "pulls", "issues") + if ref.Owner == "" { + link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue") + } else { + link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue") + } + } + + if ref.Action == references.XRefActionNone { + replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) + node = node.NextSibling.NextSibling + continue + } + + // Decorate action keywords if actionable + var keyword *html.Node + if references.IsXrefActionable(ref, hasExtTrackFormat) { + keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End]) + } else { + keyword = &html.Node{ + Type: html.TextNode, + Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End], + } + } + spaces := &html.Node{ + Type: html.TextNode, + Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start], + } + replaceContentList(node, ref.ActionLocation.Start, ref.RefLocation.End, []*html.Node{keyword, spaces, link}) + node = node.NextSibling.NextSibling.NextSibling.NextSibling + } +} + +func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) { + next := node.NextSibling + + for node != nil && node != next { + found, ref := references.FindRenderizableCommitCrossReference(node.Data) + if !found { + return + } + + reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha) + link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit") + + replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) + node = node.NextSibling.NextSibling + } +} + +type anyHashPatternResult struct { + PosStart int + PosEnd int + FullURL string + CommitID string + SubPath string + QueryHash string +} + +func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { + m := anyHashPattern.FindStringSubmatchIndex(s) + if m == nil { + return ret, false + } + + ret.PosStart, ret.PosEnd = m[0], m[1] + ret.FullURL = s[ret.PosStart:ret.PosEnd] + if strings.HasSuffix(ret.FullURL, ".") { + // if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence. + ret.PosEnd-- + ret.FullURL = ret.FullURL[:len(ret.FullURL)-1] + for i := 0; i < len(m); i++ { + m[i] = min(m[i], ret.PosEnd) + } + } + + ret.CommitID = s[m[2]:m[3]] + if m[5] > 0 { + ret.SubPath = s[m[4]:m[5]] + } + + lastStart, lastEnd := m[len(m)-2], m[len(m)-1] + if lastEnd > 0 { + ret.QueryHash = s[lastStart:lastEnd][1:] + } + return ret, true +} + +// fullHashPatternProcessor renders SHA containing URLs +func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) { + if ctx.Metas == nil { + return + } + nodeStop := node.NextSibling + for node != nodeStop { + if node.Type != html.TextNode { + node = node.NextSibling + continue + } + ret, ok := anyHashPatternExtract(node.Data) + if !ok { + node = node.NextSibling + continue + } + text := base.ShortSha(ret.CommitID) + if ret.SubPath != "" { + text += ret.SubPath + } + if ret.QueryHash != "" { + text += " (" + ret.QueryHash + ")" + } + replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit")) + node = node.NextSibling.NextSibling + } +} + +func comparePatternProcessor(ctx *RenderContext, node *html.Node) { + if ctx.Metas == nil { + return + } + nodeStop := node.NextSibling + for node != nodeStop { + if node.Type != html.TextNode { + node = node.NextSibling + continue + } + m := comparePattern.FindStringSubmatchIndex(node.Data) + if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match + node = node.NextSibling + continue + } + + urlFull := node.Data[m[0]:m[1]] + text1 := base.ShortSha(node.Data[m[2]:m[3]]) + textDots := base.ShortSha(node.Data[m[4]:m[5]]) + text2 := base.ShortSha(node.Data[m[6]:m[7]]) + + hash := "" + if m[9] > 0 { + hash = node.Data[m[8]:m[9]][1:] + } + + start := m[0] + end := m[1] + + // If url ends in '.', it's very likely that it is not part of the + // actual url but used to finish a sentence. + if strings.HasSuffix(urlFull, ".") { + end-- + urlFull = urlFull[:len(urlFull)-1] + if hash != "" { + hash = hash[:len(hash)-1] + } else if text2 != "" { + text2 = text2[:len(text2)-1] + } + } + + text := text1 + textDots + text2 + if hash != "" { + text += " (" + hash + ")" + } + replaceContent(node, start, end, createCodeLink(urlFull, text, "compare")) + node = node.NextSibling.NextSibling + } +} + +// emojiShortCodeProcessor for rendering text like :smile: into emoji +func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { + start := 0 + next := node.NextSibling + for node != nil && node != next && start < len(node.Data) { + m := emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:]) + if m == nil { + return + } + m[0] += start + m[1] += start + + start = m[1] + + alias := node.Data[m[0]:m[1]] + alias = strings.ReplaceAll(alias, ":", "") + converted := emoji.FromAlias(alias) + if converted == nil { + // check if this is a custom reaction + if _, exist := setting.UI.CustomEmojisMap[alias]; exist { + replaceContent(node, m[0], m[1], createCustomEmoji(alias)) + node = node.NextSibling.NextSibling + start = 0 + continue + } + continue + } + + replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description)) + node = node.NextSibling.NextSibling + start = 0 + } +} + +// emoji processor to match emoji and add emoji class +func emojiProcessor(ctx *RenderContext, node *html.Node) { + start := 0 + next := node.NextSibling + for node != nil && node != next && start < len(node.Data) { + m := emoji.FindEmojiSubmatchIndex(node.Data[start:]) + if m == nil { + return + } + m[0] += start + m[1] += start + + codepoint := node.Data[m[0]:m[1]] + start = m[1] + val := emoji.FromCode(codepoint) + if val != nil { + replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description)) + node = node.NextSibling.NextSibling + start = 0 + } + } +} + +// hashCurrentPatternProcessor renders SHA1 strings to corresponding links that +// are assumed to be in the same repository. +func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) { + if ctx.Metas == nil || ctx.Metas["user"] == "" || ctx.Metas["repo"] == "" || (ctx.Repo == nil && ctx.GitRepo == nil) { + return + } + + start := 0 + next := node.NextSibling + if ctx.ShaExistCache == nil { + ctx.ShaExistCache = make(map[string]bool) + } + for node != nil && node != next && start < len(node.Data) { + m := hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:]) + if m == nil { + return + } + m[2] += start + m[3] += start + + hash := node.Data[m[2]:m[3]] + // The regex does not lie, it matches the hash pattern. + // However, a regex cannot know if a hash actually exists or not. + // We could assume that a SHA1 hash should probably contain alphas AND numerics + // but that is not always the case. + // Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash + // as used by git and github for linking and thus we have to do similar. + // Because of this, we check to make sure that a matched hash is actually + // a commit in the repository before making it a link. + + // check cache first + exist, inCache := ctx.ShaExistCache[hash] + if !inCache { + if ctx.GitRepo == nil { + var err error + var closer io.Closer + ctx.GitRepo, closer, err = gitrepo.RepositoryFromContextOrOpen(ctx.Ctx, ctx.Repo) + if err != nil { + log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(ctx.Repo), err) + return + } + ctx.AddCancel(func() { + _ = closer.Close() + ctx.GitRepo = nil + }) + } + + // Don't use IsObjectExist since it doesn't support short hashs with gogit edition. + exist = ctx.GitRepo.IsReferenceExist(hash) + ctx.ShaExistCache[hash] = exist + } + + if !exist { + start = m[3] + continue + } + + link := util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], "commit", hash) + replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit")) + start = 0 + node = node.NextSibling.NextSibling + } +} + +// emailAddressProcessor replaces raw email addresses with a mailto: link. +func emailAddressProcessor(ctx *RenderContext, node *html.Node) { + next := node.NextSibling + for node != nil && node != next { + m := emailRegex.FindStringSubmatchIndex(node.Data) + if m == nil { + return + } + + mail := node.Data[m[2]:m[3]] + replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto")) + node = node.NextSibling.NextSibling + } +} + +// linkProcessor creates links for any HTTP or HTTPS URL not captured by +// markdown. +func linkProcessor(ctx *RenderContext, node *html.Node) { + next := node.NextSibling + for node != nil && node != next { + m := common.LinkRegex.FindStringIndex(node.Data) + if m == nil { + return + } + + uri := node.Data[m[0]:m[1]] + replaceContent(node, m[0], m[1], createLink(uri, uri, "link")) + node = node.NextSibling.NextSibling + } +} + +func genDefaultLinkProcessor(defaultLink string) processor { + return func(ctx *RenderContext, node *html.Node) { + ch := &html.Node{ + Parent: node, + Type: html.TextNode, + Data: node.Data, + } + + node.Type = html.ElementNode + node.Data = "a" + node.DataAtom = atom.A + node.Attr = []html.Attribute{ + {Key: "href", Val: defaultLink}, + {Key: "class", Val: "default-link muted"}, + } + node.FirstChild, node.LastChild = ch, ch + } +} + +// descriptionLinkProcessor creates links for DescriptionHTML +func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) { + next := node.NextSibling + for node != nil && node != next { + m := common.LinkRegex.FindStringIndex(node.Data) + if m == nil { + return + } + + uri := node.Data[m[0]:m[1]] + replaceContent(node, m[0], m[1], createDescriptionLink(uri, uri)) + node = node.NextSibling.NextSibling + } +} + +func createDescriptionLink(href, content string) *html.Node { + textNode := &html.Node{ + Type: html.TextNode, + Data: content, + } + linkNode := &html.Node{ + FirstChild: textNode, + LastChild: textNode, + Type: html.ElementNode, + Data: "a", + DataAtom: atom.A, + Attr: []html.Attribute{ + {Key: "href", Val: href}, + {Key: "target", Val: "_blank"}, + {Key: "rel", Val: "noopener noreferrer"}, + }, + } + textNode.Parent = linkNode + return linkNode +} diff --git a/modules/markup/html_commit.go b/modules/markup/html_commit.go deleted file mode 100644 index 86d70746d470c..0000000000000 --- a/modules/markup/html_commit.go +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markup - -import ( - "io" - "slices" - "strings" - - "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/gitrepo" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/util" - - "golang.org/x/net/html" - "golang.org/x/net/html/atom" -) - -type anyHashPatternResult struct { - PosStart int - PosEnd int - FullURL string - CommitID string - SubPath string - QueryHash string -} - -func createCodeLink(href, content, class string) *html.Node { - a := &html.Node{ - Type: html.ElementNode, - Data: atom.A.String(), - Attr: []html.Attribute{{Key: "href", Val: href}}, - } - - if class != "" { - a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: class}) - } - - text := &html.Node{ - Type: html.TextNode, - Data: content, - } - - code := &html.Node{ - Type: html.ElementNode, - Data: atom.Code.String(), - Attr: []html.Attribute{{Key: "class", Val: "nohighlight"}}, - } - - code.AppendChild(text) - a.AppendChild(code) - return a -} - -func anyHashPatternExtract(s string) (ret anyHashPatternResult, ok bool) { - m := anyHashPattern.FindStringSubmatchIndex(s) - if m == nil { - return ret, false - } - - ret.PosStart, ret.PosEnd = m[0], m[1] - ret.FullURL = s[ret.PosStart:ret.PosEnd] - if strings.HasSuffix(ret.FullURL, ".") { - // if url ends in '.', it's very likely that it is not part of the actual url but used to finish a sentence. - ret.PosEnd-- - ret.FullURL = ret.FullURL[:len(ret.FullURL)-1] - for i := 0; i < len(m); i++ { - m[i] = min(m[i], ret.PosEnd) - } - } - - ret.CommitID = s[m[2]:m[3]] - if m[5] > 0 { - ret.SubPath = s[m[4]:m[5]] - } - - lastStart, lastEnd := m[len(m)-2], m[len(m)-1] - if lastEnd > 0 { - ret.QueryHash = s[lastStart:lastEnd][1:] - } - return ret, true -} - -// fullHashPatternProcessor renders SHA containing URLs -func fullHashPatternProcessor(ctx *RenderContext, node *html.Node) { - if ctx.Metas == nil { - return - } - nodeStop := node.NextSibling - for node != nodeStop { - if node.Type != html.TextNode { - node = node.NextSibling - continue - } - ret, ok := anyHashPatternExtract(node.Data) - if !ok { - node = node.NextSibling - continue - } - text := base.ShortSha(ret.CommitID) - if ret.SubPath != "" { - text += ret.SubPath - } - if ret.QueryHash != "" { - text += " (" + ret.QueryHash + ")" - } - replaceContent(node, ret.PosStart, ret.PosEnd, createCodeLink(ret.FullURL, text, "commit")) - node = node.NextSibling.NextSibling - } -} - -func comparePatternProcessor(ctx *RenderContext, node *html.Node) { - if ctx.Metas == nil { - return - } - nodeStop := node.NextSibling - for node != nodeStop { - if node.Type != html.TextNode { - node = node.NextSibling - continue - } - m := comparePattern.FindStringSubmatchIndex(node.Data) - if m == nil || slices.Contains(m[:8], -1) { // ensure that every group (m[0]...m[7]) has a match - node = node.NextSibling - continue - } - - urlFull := node.Data[m[0]:m[1]] - text1 := base.ShortSha(node.Data[m[2]:m[3]]) - textDots := base.ShortSha(node.Data[m[4]:m[5]]) - text2 := base.ShortSha(node.Data[m[6]:m[7]]) - - hash := "" - if m[9] > 0 { - hash = node.Data[m[8]:m[9]][1:] - } - - start := m[0] - end := m[1] - - // If url ends in '.', it's very likely that it is not part of the - // actual url but used to finish a sentence. - if strings.HasSuffix(urlFull, ".") { - end-- - urlFull = urlFull[:len(urlFull)-1] - if hash != "" { - hash = hash[:len(hash)-1] - } else if text2 != "" { - text2 = text2[:len(text2)-1] - } - } - - text := text1 + textDots + text2 - if hash != "" { - text += " (" + hash + ")" - } - replaceContent(node, start, end, createCodeLink(urlFull, text, "compare")) - node = node.NextSibling.NextSibling - } -} - -// hashCurrentPatternProcessor renders SHA1 strings to corresponding links that -// are assumed to be in the same repository. -func hashCurrentPatternProcessor(ctx *RenderContext, node *html.Node) { - if ctx.Metas == nil || ctx.Metas["user"] == "" || ctx.Metas["repo"] == "" || (ctx.Repo == nil && ctx.GitRepo == nil) { - return - } - - start := 0 - next := node.NextSibling - if ctx.ShaExistCache == nil { - ctx.ShaExistCache = make(map[string]bool) - } - for node != nil && node != next && start < len(node.Data) { - m := hashCurrentPattern.FindStringSubmatchIndex(node.Data[start:]) - if m == nil { - return - } - m[2] += start - m[3] += start - - hash := node.Data[m[2]:m[3]] - // The regex does not lie, it matches the hash pattern. - // However, a regex cannot know if a hash actually exists or not. - // We could assume that a SHA1 hash should probably contain alphas AND numerics - // but that is not always the case. - // Although unlikely, deadbeef and 1234567 are valid short forms of SHA1 hash - // as used by git and github for linking and thus we have to do similar. - // Because of this, we check to make sure that a matched hash is actually - // a commit in the repository before making it a link. - - // check cache first - exist, inCache := ctx.ShaExistCache[hash] - if !inCache { - if ctx.GitRepo == nil { - var err error - var closer io.Closer - ctx.GitRepo, closer, err = gitrepo.RepositoryFromContextOrOpen(ctx.Ctx, ctx.Repo) - if err != nil { - log.Error("unable to open repository: %s Error: %v", gitrepo.RepoGitURL(ctx.Repo), err) - return - } - ctx.AddCancel(func() { - _ = closer.Close() - ctx.GitRepo = nil - }) - } - - // Don't use IsObjectExist since it doesn't support short hashs with gogit edition. - exist = ctx.GitRepo.IsReferenceExist(hash) - ctx.ShaExistCache[hash] = exist - } - - if !exist { - start = m[3] - continue - } - - link := util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], "commit", hash) - replaceContent(node, m[2], m[3], createCodeLink(link, base.ShortSha(hash), "commit")) - start = 0 - node = node.NextSibling.NextSibling - } -} diff --git a/modules/markup/html_email.go b/modules/markup/html_email.go deleted file mode 100644 index a062789b35888..0000000000000 --- a/modules/markup/html_email.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markup - -import "golang.org/x/net/html" - -// emailAddressProcessor replaces raw email addresses with a mailto: link. -func emailAddressProcessor(ctx *RenderContext, node *html.Node) { - next := node.NextSibling - for node != nil && node != next { - m := emailRegex.FindStringSubmatchIndex(node.Data) - if m == nil { - return - } - - mail := node.Data[m[2]:m[3]] - replaceContent(node, m[2], m[3], createLink("mailto:"+mail, mail, "mailto")) - node = node.NextSibling.NextSibling - } -} diff --git a/modules/markup/html_emoji.go b/modules/markup/html_emoji.go deleted file mode 100644 index c60d06b823223..0000000000000 --- a/modules/markup/html_emoji.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markup - -import ( - "strings" - - "code.gitea.io/gitea/modules/emoji" - "code.gitea.io/gitea/modules/setting" - - "golang.org/x/net/html" - "golang.org/x/net/html/atom" -) - -func createEmoji(content, class, name string) *html.Node { - span := &html.Node{ - Type: html.ElementNode, - Data: atom.Span.String(), - Attr: []html.Attribute{}, - } - if class != "" { - span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: class}) - } - if name != "" { - span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: name}) - } - - text := &html.Node{ - Type: html.TextNode, - Data: content, - } - - span.AppendChild(text) - return span -} - -func createCustomEmoji(alias string) *html.Node { - span := &html.Node{ - Type: html.ElementNode, - Data: atom.Span.String(), - Attr: []html.Attribute{}, - } - span.Attr = append(span.Attr, html.Attribute{Key: "class", Val: "emoji"}) - span.Attr = append(span.Attr, html.Attribute{Key: "aria-label", Val: alias}) - - img := &html.Node{ - Type: html.ElementNode, - DataAtom: atom.Img, - Data: "img", - Attr: []html.Attribute{}, - } - img.Attr = append(img.Attr, html.Attribute{Key: "alt", Val: ":" + alias + ":"}) - img.Attr = append(img.Attr, html.Attribute{Key: "src", Val: setting.StaticURLPrefix + "/assets/img/emoji/" + alias + ".png"}) - - span.AppendChild(img) - return span -} - -// emojiShortCodeProcessor for rendering text like :smile: into emoji -func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) { - start := 0 - next := node.NextSibling - for node != nil && node != next && start < len(node.Data) { - m := emojiShortCodeRegex.FindStringSubmatchIndex(node.Data[start:]) - if m == nil { - return - } - m[0] += start - m[1] += start - - start = m[1] - - alias := node.Data[m[0]:m[1]] - alias = strings.ReplaceAll(alias, ":", "") - converted := emoji.FromAlias(alias) - if converted == nil { - // check if this is a custom reaction - if _, exist := setting.UI.CustomEmojisMap[alias]; exist { - replaceContent(node, m[0], m[1], createCustomEmoji(alias)) - node = node.NextSibling.NextSibling - start = 0 - continue - } - continue - } - - replaceContent(node, m[0], m[1], createEmoji(converted.Emoji, "emoji", converted.Description)) - node = node.NextSibling.NextSibling - start = 0 - } -} - -// emoji processor to match emoji and add emoji class -func emojiProcessor(ctx *RenderContext, node *html.Node) { - start := 0 - next := node.NextSibling - for node != nil && node != next && start < len(node.Data) { - m := emoji.FindEmojiSubmatchIndex(node.Data[start:]) - if m == nil { - return - } - m[0] += start - m[1] += start - - codepoint := node.Data[m[0]:m[1]] - start = m[1] - val := emoji.FromCode(codepoint) - if val != nil { - replaceContent(node, m[0], m[1], createEmoji(codepoint, "emoji", val.Description)) - node = node.NextSibling.NextSibling - start = 0 - } - } -} diff --git a/modules/markup/html_issue.go b/modules/markup/html_issue.go deleted file mode 100644 index b6d4ed6a8e2a3..0000000000000 --- a/modules/markup/html_issue.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markup - -import ( - "strings" - - "code.gitea.io/gitea/modules/base" - "code.gitea.io/gitea/modules/log" - "code.gitea.io/gitea/modules/references" - "code.gitea.io/gitea/modules/regexplru" - "code.gitea.io/gitea/modules/templates/vars" - "code.gitea.io/gitea/modules/translation" - "code.gitea.io/gitea/modules/util" - - "golang.org/x/net/html" -) - -func fullIssuePatternProcessor(ctx *RenderContext, node *html.Node) { - if ctx.Metas == nil { - return - } - next := node.NextSibling - for node != nil && node != next { - m := getIssueFullPattern().FindStringSubmatchIndex(node.Data) - if m == nil { - return - } - - mDiffView := getFilesChangedFullPattern().FindStringSubmatchIndex(node.Data) - // leave it as it is if the link is from "Files Changed" tab in PR Diff View https://domain/org/repo/pulls/27/files - if mDiffView != nil { - return - } - - link := node.Data[m[0]:m[1]] - text := "#" + node.Data[m[2]:m[3]] - // if m[4] and m[5] is not -1, then link is to a comment - // indicate that in the text by appending (comment) - if m[4] != -1 && m[5] != -1 { - if locale, ok := ctx.Ctx.Value(translation.ContextKey).(translation.Locale); ok { - text += " " + locale.TrString("repo.from_comment") - } else { - text += " (comment)" - } - } - - // extract repo and org name from matched link like - // http://localhost:3000/gituser/myrepo/issues/1 - linkParts := strings.Split(link, "/") - matchOrg := linkParts[len(linkParts)-4] - matchRepo := linkParts[len(linkParts)-3] - - if matchOrg == ctx.Metas["user"] && matchRepo == ctx.Metas["repo"] { - replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) - } else { - text = matchOrg + "/" + matchRepo + text - replaceContent(node, m[0], m[1], createLink(link, text, "ref-issue")) - } - node = node.NextSibling.NextSibling - } -} - -func issueIndexPatternProcessor(ctx *RenderContext, node *html.Node) { - if ctx.Metas == nil { - return - } - - // FIXME: the use of "mode" is quite dirty and hacky, for example: what is a "document"? how should it be rendered? - // The "mode" approach should be refactored to some other more clear&reliable way. - crossLinkOnly := ctx.Metas["mode"] == "document" && !ctx.IsWiki - - var ( - found bool - ref *references.RenderizableReference - ) - - next := node.NextSibling - - for node != nil && node != next { - _, hasExtTrackFormat := ctx.Metas["format"] - - // Repos with external issue trackers might still need to reference local PRs - // We need to concern with the first one that shows up in the text, whichever it is - isNumericStyle := ctx.Metas["style"] == "" || ctx.Metas["style"] == IssueNameStyleNumeric - foundNumeric, refNumeric := references.FindRenderizableReferenceNumeric(node.Data, hasExtTrackFormat && !isNumericStyle, crossLinkOnly) - - switch ctx.Metas["style"] { - case "", IssueNameStyleNumeric: - found, ref = foundNumeric, refNumeric - case IssueNameStyleAlphanumeric: - found, ref = references.FindRenderizableReferenceAlphanumeric(node.Data) - case IssueNameStyleRegexp: - pattern, err := regexplru.GetCompiled(ctx.Metas["regexp"]) - if err != nil { - return - } - found, ref = references.FindRenderizableReferenceRegexp(node.Data, pattern) - } - - // Repos with external issue trackers might still need to reference local PRs - // We need to concern with the first one that shows up in the text, whichever it is - if hasExtTrackFormat && !isNumericStyle && refNumeric != nil { - // If numeric (PR) was found, and it was BEFORE the non-numeric pattern, use that - // Allow a free-pass when non-numeric pattern wasn't found. - if found && (ref == nil || refNumeric.RefLocation.Start < ref.RefLocation.Start) { - found = foundNumeric - ref = refNumeric - } - } - if !found { - return - } - - var link *html.Node - reftext := node.Data[ref.RefLocation.Start:ref.RefLocation.End] - if hasExtTrackFormat && !ref.IsPull { - ctx.Metas["index"] = ref.Issue - - res, err := vars.Expand(ctx.Metas["format"], ctx.Metas) - if err != nil { - // here we could just log the error and continue the rendering - log.Error("unable to expand template vars for ref %s, err: %v", ref.Issue, err) - } - - link = createLink(res, reftext, "ref-issue ref-external-issue") - } else { - // Path determines the type of link that will be rendered. It's unknown at this point whether - // the linked item is actually a PR or an issue. Luckily it's of no real consequence because - // Gitea will redirect on click as appropriate. - issuePath := util.Iif(ref.IsPull, "pulls", "issues") - if ref.Owner == "" { - link = createLink(util.URLJoin(ctx.Links.Prefix(), ctx.Metas["user"], ctx.Metas["repo"], issuePath, ref.Issue), reftext, "ref-issue") - } else { - link = createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, issuePath, ref.Issue), reftext, "ref-issue") - } - } - - if ref.Action == references.XRefActionNone { - replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) - node = node.NextSibling.NextSibling - continue - } - - // Decorate action keywords if actionable - var keyword *html.Node - if references.IsXrefActionable(ref, hasExtTrackFormat) { - keyword = createKeyword(node.Data[ref.ActionLocation.Start:ref.ActionLocation.End]) - } else { - keyword = &html.Node{ - Type: html.TextNode, - Data: node.Data[ref.ActionLocation.Start:ref.ActionLocation.End], - } - } - spaces := &html.Node{ - Type: html.TextNode, - Data: node.Data[ref.ActionLocation.End:ref.RefLocation.Start], - } - replaceContentList(node, ref.ActionLocation.Start, ref.RefLocation.End, []*html.Node{keyword, spaces, link}) - node = node.NextSibling.NextSibling.NextSibling.NextSibling - } -} - -func commitCrossReferencePatternProcessor(ctx *RenderContext, node *html.Node) { - next := node.NextSibling - - for node != nil && node != next { - found, ref := references.FindRenderizableCommitCrossReference(node.Data) - if !found { - return - } - - reftext := ref.Owner + "/" + ref.Name + "@" + base.ShortSha(ref.CommitSha) - link := createLink(util.URLJoin(ctx.Links.Prefix(), ref.Owner, ref.Name, "commit", ref.CommitSha), reftext, "commit") - - replaceContent(node, ref.RefLocation.Start, ref.RefLocation.End, link) - node = node.NextSibling.NextSibling - } -} diff --git a/modules/markup/html_link.go b/modules/markup/html_link.go index 9350634568317..b08613534852d 100644 --- a/modules/markup/html_link.go +++ b/modules/markup/html_link.go @@ -4,16 +4,7 @@ package markup import ( - "net/url" - "path" - "path/filepath" - "strings" - - "code.gitea.io/gitea/modules/markup/common" "code.gitea.io/gitea/modules/util" - - "golang.org/x/net/html" - "golang.org/x/net/html/atom" ) func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (result string, resolved bool) { @@ -36,221 +27,3 @@ func ResolveLink(ctx *RenderContext, link, userContentAnchorPrefix string) (resu } return link, resolved } - -func shortLinkProcessor(ctx *RenderContext, node *html.Node) { - next := node.NextSibling - for node != nil && node != next { - m := shortLinkPattern.FindStringSubmatchIndex(node.Data) - if m == nil { - return - } - - content := node.Data[m[2]:m[3]] - tail := node.Data[m[4]:m[5]] - props := make(map[string]string) - - // MediaWiki uses [[link|text]], while GitHub uses [[text|link]] - // It makes page handling terrible, but we prefer GitHub syntax - // And fall back to MediaWiki only when it is obvious from the look - // Of text and link contents - sl := strings.Split(content, "|") - for _, v := range sl { - if equalPos := strings.IndexByte(v, '='); equalPos == -1 { - // There is no equal in this argument; this is a mandatory arg - if props["name"] == "" { - if IsFullURLString(v) { - // If we clearly see it is a link, we save it so - - // But first we need to ensure, that if both mandatory args provided - // look like links, we stick to GitHub syntax - if props["link"] != "" { - props["name"] = props["link"] - } - - props["link"] = strings.TrimSpace(v) - } else { - props["name"] = v - } - } else { - props["link"] = strings.TrimSpace(v) - } - } else { - // There is an equal; optional argument. - - sep := strings.IndexByte(v, '=') - key, val := v[:sep], html.UnescapeString(v[sep+1:]) - - // When parsing HTML, x/net/html will change all quotes which are - // not used for syntax into UTF-8 quotes. So checking val[0] won't - // be enough, since that only checks a single byte. - if len(val) > 1 { - if (strings.HasPrefix(val, "“") && strings.HasSuffix(val, "”")) || - (strings.HasPrefix(val, "‘") && strings.HasSuffix(val, "’")) { - const lenQuote = len("‘") - val = val[lenQuote : len(val)-lenQuote] - } else if (strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"")) || - (strings.HasPrefix(val, "'") && strings.HasSuffix(val, "'")) { - val = val[1 : len(val)-1] - } else if strings.HasPrefix(val, "'") && strings.HasSuffix(val, "’") { - const lenQuote = len("‘") - val = val[1 : len(val)-lenQuote] - } - } - props[key] = val - } - } - - var name, link string - if props["link"] != "" { - link = props["link"] - } else if props["name"] != "" { - link = props["name"] - } - if props["title"] != "" { - name = props["title"] - } else if props["name"] != "" { - name = props["name"] - } else { - name = link - } - - name += tail - image := false - ext := filepath.Ext(link) - switch ext { - // fast path: empty string, ignore - case "": - // leave image as false - case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".gif", ".bmp", ".ico", ".svg": - image = true - } - - childNode := &html.Node{} - linkNode := &html.Node{ - FirstChild: childNode, - LastChild: childNode, - Type: html.ElementNode, - Data: "a", - DataAtom: atom.A, - } - childNode.Parent = linkNode - absoluteLink := IsFullURLString(link) - if !absoluteLink { - if image { - link = strings.ReplaceAll(link, " ", "+") - } else { - link = strings.ReplaceAll(link, " ", "-") // FIXME: it should support dashes in the link, eg: "the-dash-support.-" - } - if !strings.Contains(link, "/") { - link = url.PathEscape(link) // FIXME: it doesn't seem right and it might cause double-escaping - } - } - if image { - if !absoluteLink { - link = util.URLJoin(ctx.Links.ResolveMediaLink(ctx.IsWiki), link) - } - title := props["title"] - if title == "" { - title = props["alt"] - } - if title == "" { - title = path.Base(name) - } - alt := props["alt"] - if alt == "" { - alt = name - } - - // make the childNode an image - if we can, we also place the alt - childNode.Type = html.ElementNode - childNode.Data = "img" - childNode.DataAtom = atom.Img - childNode.Attr = []html.Attribute{ - {Key: "src", Val: link}, - {Key: "title", Val: title}, - {Key: "alt", Val: alt}, - } - if alt == "" { - childNode.Attr = childNode.Attr[:2] - } - } else { - link, _ = ResolveLink(ctx, link, "") - childNode.Type = html.TextNode - childNode.Data = name - } - linkNode.Attr = []html.Attribute{{Key: "href", Val: link}} - replaceContent(node, m[0], m[1], linkNode) - node = node.NextSibling.NextSibling - } -} - -// linkProcessor creates links for any HTTP or HTTPS URL not captured by -// markdown. -func linkProcessor(ctx *RenderContext, node *html.Node) { - next := node.NextSibling - for node != nil && node != next { - m := common.LinkRegex.FindStringIndex(node.Data) - if m == nil { - return - } - - uri := node.Data[m[0]:m[1]] - replaceContent(node, m[0], m[1], createLink(uri, uri, "link")) - node = node.NextSibling.NextSibling - } -} - -func genDefaultLinkProcessor(defaultLink string) processor { - return func(ctx *RenderContext, node *html.Node) { - ch := &html.Node{ - Parent: node, - Type: html.TextNode, - Data: node.Data, - } - - node.Type = html.ElementNode - node.Data = "a" - node.DataAtom = atom.A - node.Attr = []html.Attribute{ - {Key: "href", Val: defaultLink}, - {Key: "class", Val: "default-link muted"}, - } - node.FirstChild, node.LastChild = ch, ch - } -} - -// descriptionLinkProcessor creates links for DescriptionHTML -func descriptionLinkProcessor(ctx *RenderContext, node *html.Node) { - next := node.NextSibling - for node != nil && node != next { - m := common.LinkRegex.FindStringIndex(node.Data) - if m == nil { - return - } - - uri := node.Data[m[0]:m[1]] - replaceContent(node, m[0], m[1], createDescriptionLink(uri, uri)) - node = node.NextSibling.NextSibling - } -} - -func createDescriptionLink(href, content string) *html.Node { - textNode := &html.Node{ - Type: html.TextNode, - Data: content, - } - linkNode := &html.Node{ - FirstChild: textNode, - LastChild: textNode, - Type: html.ElementNode, - Data: "a", - DataAtom: atom.A, - Attr: []html.Attribute{ - {Key: "href", Val: href}, - {Key: "target", Val: "_blank"}, - {Key: "rel", Val: "noopener noreferrer"}, - }, - } - textNode.Parent = linkNode - return linkNode -} diff --git a/modules/markup/html_mention.go b/modules/markup/html_mention.go deleted file mode 100644 index 3f0692e05f55e..0000000000000 --- a/modules/markup/html_mention.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markup - -import ( - "strings" - - "code.gitea.io/gitea/modules/references" - "code.gitea.io/gitea/modules/util" - - "golang.org/x/net/html" -) - -func mentionProcessor(ctx *RenderContext, node *html.Node) { - start := 0 - nodeStop := node.NextSibling - for node != nodeStop { - found, loc := references.FindFirstMentionBytes(util.UnsafeStringToBytes(node.Data[start:])) - if !found { - node = node.NextSibling - start = 0 - continue - } - loc.Start += start - loc.End += start - mention := node.Data[loc.Start:loc.End] - teams, ok := ctx.Metas["teams"] - // FIXME: util.URLJoin may not be necessary here: - // - setting.AppURL is defined to have a terminal '/' so unless mention[1:] - // is an AppSubURL link we can probably fallback to concatenation. - // team mention should follow @orgName/teamName style - if ok && strings.Contains(mention, "/") { - mentionOrgAndTeam := strings.Split(mention, "/") - if mentionOrgAndTeam[0][1:] == ctx.Metas["org"] && strings.Contains(teams, ","+strings.ToLower(mentionOrgAndTeam[1])+",") { - replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), "org", ctx.Metas["org"], "teams", mentionOrgAndTeam[1]), mention, "mention")) - node = node.NextSibling.NextSibling - start = 0 - continue - } - start = loc.End - continue - } - mentionedUsername := mention[1:] - - if DefaultProcessorHelper.IsUsernameMentionable != nil && DefaultProcessorHelper.IsUsernameMentionable(ctx.Ctx, mentionedUsername) { - replaceContent(node, loc.Start, loc.End, createLink(util.URLJoin(ctx.Links.Prefix(), mentionedUsername), mention, "mention")) - node = node.NextSibling.NextSibling - start = 0 - } else { - start = loc.End - } - } -} diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 0cd9dc5f30c61..515a79578deae 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -45,7 +45,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa ctx := pc.Get(renderContextKey).(*markup.RenderContext) rc := pc.Get(renderConfigKey).(*RenderConfig) - tocList := make([]Header, 0, 20) + tocList := make([]markup.Header, 0, 20) if rc.yamlNode != nil { metaNode := rc.toMetaNode() if metaNode != nil { diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go index ea1af83a3ed1a..38f744a25ff93 100644 --- a/modules/markup/markdown/toc.go +++ b/modules/markup/markdown/toc.go @@ -7,19 +7,13 @@ import ( "fmt" "net/url" + "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/translation" "github.com/yuin/goldmark/ast" ) -// Header holds the data about a header. -type Header struct { - Level int - Text string - ID string -} - -func createTOCNode(toc []Header, lang string, detailsAttrs map[string]string) ast.Node { +func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]string) ast.Node { details := NewDetails() summary := NewSummary() diff --git a/modules/markup/markdown/transform_heading.go b/modules/markup/markdown/transform_heading.go index 5f8a12794dac8..b78720e16dc89 100644 --- a/modules/markup/markdown/transform_heading.go +++ b/modules/markup/markdown/transform_heading.go @@ -13,14 +13,14 @@ import ( "github.com/yuin/goldmark/text" ) -func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]Header) { +func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]markup.Header) { for _, attr := range v.Attributes() { if _, ok := attr.Value.([]byte); !ok { v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value))) } } txt := v.Text(reader.Source()) //nolint:staticcheck - header := Header{ + header := markup.Header{ Text: util.UnsafeBytesToString(txt), Level: v.Level, } diff --git a/modules/markup/render.go b/modules/markup/render.go deleted file mode 100644 index f2ce9229af64d..0000000000000 --- a/modules/markup/render.go +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package markup - -import ( - "context" - "errors" - "fmt" - "io" - "net/url" - "path/filepath" - "strings" - "sync" - - "code.gitea.io/gitea/modules/git" - "code.gitea.io/gitea/modules/gitrepo" - "code.gitea.io/gitea/modules/setting" - "code.gitea.io/gitea/modules/util" - - "github.com/yuin/goldmark/ast" -) - -type RenderMetaMode string - -const ( - RenderMetaAsDetails RenderMetaMode = "details" // default - RenderMetaAsNone RenderMetaMode = "none" - RenderMetaAsTable RenderMetaMode = "table" -) - -// RenderContext represents a render context -type RenderContext struct { - Ctx context.Context - RelativePath string // relative path from tree root of the branch - Type string - IsWiki bool - Links Links - Metas map[string]string // user, repo, mode(comment/document) - DefaultLink string - GitRepo *git.Repository - Repo gitrepo.Repository - ShaExistCache map[string]bool - cancelFn func() - SidebarTocNode ast.Node - RenderMetaAs RenderMetaMode - InStandalonePage bool // used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page -} - -// Cancel runs any cleanup functions that have been registered for this Ctx -func (ctx *RenderContext) Cancel() { - if ctx == nil { - return - } - ctx.ShaExistCache = map[string]bool{} - if ctx.cancelFn == nil { - return - } - ctx.cancelFn() -} - -// AddCancel adds the provided fn as a Cleanup for this Ctx -func (ctx *RenderContext) AddCancel(fn func()) { - if ctx == nil { - return - } - oldCancelFn := ctx.cancelFn - if oldCancelFn == nil { - ctx.cancelFn = fn - return - } - ctx.cancelFn = func() { - defer oldCancelFn() - fn() - } -} - -// Render renders markup file to HTML with all specific handling stuff. -func Render(ctx *RenderContext, input io.Reader, output io.Writer) error { - if ctx.Type != "" { - return renderByType(ctx, input, output) - } else if ctx.RelativePath != "" { - return renderFile(ctx, input, output) - } - return errors.New("render options both filename and type missing") -} - -// RenderString renders Markup string to HTML with all specific handling stuff and return string -func RenderString(ctx *RenderContext, content string) (string, error) { - var buf strings.Builder - if err := Render(ctx, strings.NewReader(content), &buf); err != nil { - return "", err - } - return buf.String(), nil -} - -func renderIFrame(ctx *RenderContext, output io.Writer) error { - // set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight) - // at the moment, only "allow-scripts" is allowed for sandbox mode. - // "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token - // TODO: when using dark theme, if the rendered content doesn't have proper style, the default text color is black, which is not easy to read - _, err := io.WriteString(output, fmt.Sprintf(` -`, - setting.AppSubURL, - url.PathEscape(ctx.Metas["user"]), - url.PathEscape(ctx.Metas["repo"]), - ctx.Metas["BranchNameSubURL"], - url.PathEscape(ctx.RelativePath), - )) - return err -} - -func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { - var wg sync.WaitGroup - var err error - pr, pw := io.Pipe() - defer func() { - _ = pr.Close() - _ = pw.Close() - }() - - var pr2 io.ReadCloser - var pw2 io.WriteCloser - - var sanitizerDisabled bool - if r, ok := renderer.(ExternalRenderer); ok { - sanitizerDisabled = r.SanitizerDisabled() - } - - if !sanitizerDisabled { - pr2, pw2 = io.Pipe() - defer func() { - _ = pr2.Close() - _ = pw2.Close() - }() - - wg.Add(1) - go func() { - err = SanitizeReader(pr2, renderer.Name(), output) - _ = pr2.Close() - wg.Done() - }() - } else { - pw2 = util.NopCloser{Writer: output} - } - - wg.Add(1) - go func() { - if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { - err = PostProcess(ctx, pr, pw2) - } else { - _, err = io.Copy(pw2, pr) - } - _ = pr.Close() - _ = pw2.Close() - wg.Done() - }() - - if err1 := renderer.Render(ctx, input, pw); err1 != nil { - return err1 - } - _ = pw.Close() - - wg.Wait() - return err -} - -func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error { - if renderer, ok := renderers[ctx.Type]; ok { - return render(ctx, renderer, input, output) - } - return fmt.Errorf("unsupported render type: %s", ctx.Type) -} - -// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render -type ErrUnsupportedRenderExtension struct { - Extension string -} - -func IsErrUnsupportedRenderExtension(err error) bool { - _, ok := err.(ErrUnsupportedRenderExtension) - return ok -} - -func (err ErrUnsupportedRenderExtension) Error() string { - return fmt.Sprintf("Unsupported render extension: %s", err.Extension) -} - -func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error { - extension := strings.ToLower(filepath.Ext(ctx.RelativePath)) - if renderer, ok := extRenderers[extension]; ok { - if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() { - if !ctx.InStandalonePage { - // for an external render, it could only output its content in a standalone page - // otherwise, a `, + setting.AppSubURL, + url.PathEscape(ctx.Metas["user"]), + url.PathEscape(ctx.Metas["repo"]), + ctx.Metas["BranchNameSubURL"], + url.PathEscape(ctx.RelativePath), + )) + return err +} + +func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { + var wg sync.WaitGroup + var err error + pr, pw := io.Pipe() + defer func() { + _ = pr.Close() + _ = pw.Close() + }() + + var pr2 io.ReadCloser + var pw2 io.WriteCloser + + var sanitizerDisabled bool + if r, ok := renderer.(ExternalRenderer); ok { + sanitizerDisabled = r.SanitizerDisabled() + } + + if !sanitizerDisabled { + pr2, pw2 = io.Pipe() + defer func() { + _ = pr2.Close() + _ = pw2.Close() + }() + + wg.Add(1) + go func() { + err = SanitizeReader(pr2, renderer.Name(), output) + _ = pr2.Close() + wg.Done() + }() + } else { + pw2 = nopCloser{output} + } + + wg.Add(1) + go func() { + if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { + err = PostProcess(ctx, pr, pw2) + } else { + _, err = io.Copy(pw2, pr) + } + _ = pr.Close() + _ = pw2.Close() + wg.Done() + }() + + if err1 := renderer.Render(ctx, input, pw); err1 != nil { + return err1 + } + _ = pw.Close() + + wg.Wait() + return err +} + +// ErrUnsupportedRenderType represents +type ErrUnsupportedRenderType struct { + Type string +} + +func (err ErrUnsupportedRenderType) Error() string { + return fmt.Sprintf("Unsupported render type: %s", err.Type) +} + +func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error { + if renderer, ok := renderers[ctx.Type]; ok { + return render(ctx, renderer, input, output) + } + return ErrUnsupportedRenderType{ctx.Type} +} + +// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render +type ErrUnsupportedRenderExtension struct { + Extension string +} + +func IsErrUnsupportedRenderExtension(err error) bool { + _, ok := err.(ErrUnsupportedRenderExtension) + return ok +} + +func (err ErrUnsupportedRenderExtension) Error() string { + return fmt.Sprintf("Unsupported render extension: %s", err.Extension) +} + +func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error { + extension := strings.ToLower(filepath.Ext(ctx.RelativePath)) + if renderer, ok := extRenderers[extension]; ok { + if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() { + if !ctx.InStandalonePage { + // for an external render, it could only output its content in a standalone page + // otherwise, a `, + setting.AppSubURL, + url.PathEscape(ctx.Metas["user"]), + url.PathEscape(ctx.Metas["repo"]), + ctx.Metas["BranchNameSubURL"], + url.PathEscape(ctx.RelativePath), + )) + return err +} + +func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { + var wg sync.WaitGroup + var err error + pr, pw := io.Pipe() + defer func() { + _ = pr.Close() + _ = pw.Close() + }() + + var pr2 io.ReadCloser + var pw2 io.WriteCloser + + var sanitizerDisabled bool + if r, ok := renderer.(ExternalRenderer); ok { + sanitizerDisabled = r.SanitizerDisabled() + } + + if !sanitizerDisabled { + pr2, pw2 = io.Pipe() + defer func() { + _ = pr2.Close() + _ = pw2.Close() + }() + + wg.Add(1) + go func() { + err = SanitizeReader(pr2, renderer.Name(), output) + _ = pr2.Close() + wg.Done() + }() + } else { + pw2 = util.NopCloser{Writer: output} + } + + wg.Add(1) + go func() { + if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { + err = PostProcess(ctx, pr, pw2) + } else { + _, err = io.Copy(pw2, pr) + } + _ = pr.Close() + _ = pw2.Close() + wg.Done() + }() + + if err1 := renderer.Render(ctx, input, pw); err1 != nil { + return err1 + } + _ = pw.Close() + + wg.Wait() + return err +} + +func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error { + if renderer, ok := renderers[ctx.Type]; ok { + return render(ctx, renderer, input, output) + } + return fmt.Errorf("unsupported render type: %s", ctx.Type) +} + +// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render +type ErrUnsupportedRenderExtension struct { + Extension string +} + +func IsErrUnsupportedRenderExtension(err error) bool { + _, ok := err.(ErrUnsupportedRenderExtension) + return ok +} + +func (err ErrUnsupportedRenderExtension) Error() string { + return fmt.Sprintf("Unsupported render extension: %s", err.Extension) +} + +func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error { + extension := strings.ToLower(filepath.Ext(ctx.RelativePath)) + if renderer, ok := extRenderers[extension]; ok { + if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() { + if !ctx.InStandalonePage { + // for an external render, it could only output its content in a standalone page + // otherwise, a `, - setting.AppSubURL, - url.PathEscape(ctx.Metas["user"]), - url.PathEscape(ctx.Metas["repo"]), - ctx.Metas["BranchNameSubURL"], - url.PathEscape(ctx.RelativePath), - )) - return err -} - -func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error { - var wg sync.WaitGroup - var err error - pr, pw := io.Pipe() - defer func() { - _ = pr.Close() - _ = pw.Close() - }() - - var pr2 io.ReadCloser - var pw2 io.WriteCloser - - var sanitizerDisabled bool - if r, ok := renderer.(ExternalRenderer); ok { - sanitizerDisabled = r.SanitizerDisabled() - } - - if !sanitizerDisabled { - pr2, pw2 = io.Pipe() - defer func() { - _ = pr2.Close() - _ = pw2.Close() - }() - - wg.Add(1) - go func() { - err = SanitizeReader(pr2, renderer.Name(), output) - _ = pr2.Close() - wg.Done() - }() - } else { - pw2 = nopCloser{output} - } - - wg.Add(1) - go func() { - if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() { - err = PostProcess(ctx, pr, pw2) - } else { - _, err = io.Copy(pw2, pr) - } - _ = pr.Close() - _ = pw2.Close() - wg.Done() - }() - - if err1 := renderer.Render(ctx, input, pw); err1 != nil { - return err1 - } - _ = pw.Close() - - wg.Wait() - return err -} - -// ErrUnsupportedRenderType represents -type ErrUnsupportedRenderType struct { - Type string -} - -func (err ErrUnsupportedRenderType) Error() string { - return fmt.Sprintf("Unsupported render type: %s", err.Type) -} - -func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error { - if renderer, ok := renderers[ctx.Type]; ok { - return render(ctx, renderer, input, output) - } - return ErrUnsupportedRenderType{ctx.Type} -} - -// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render -type ErrUnsupportedRenderExtension struct { - Extension string -} - -func IsErrUnsupportedRenderExtension(err error) bool { - _, ok := err.(ErrUnsupportedRenderExtension) - return ok -} - -func (err ErrUnsupportedRenderExtension) Error() string { - return fmt.Sprintf("Unsupported render extension: %s", err.Extension) -} - -func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error { - extension := strings.ToLower(filepath.Ext(ctx.RelativePath)) - if renderer, ok := extRenderers[extension]; ok { - if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() { - if !ctx.InStandalonePage { - // for an external render, it could only output its content in a standalone page - // otherwise, a