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..7af579b2054cb --- /dev/null +++ b/models/conversations/comment.go @@ -0,0 +1,589 @@ +// 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. + +import ( + "context" + "fmt" + "html/template" + + "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/structs" + "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" + + "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 +} + +// 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 ConversationComment struct { + ID int64 `xorm:"pk autoincr"` + Type CommentType `xorm:"INDEX"` + + PosterID int64 `xorm:"INDEX"` + Poster *user_model.User `xorm:"-"` + + OriginalAuthor string + OriginalAuthorID int64 `xorm:"INDEX"` + + 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 `xorm:"-"` + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + + RenderedContent template.HTML `xorm:"-"` + ShowRole RoleDescriptor `xorm:"-"` +} + +func init() { + db.RegisterModel(new(ConversationComment)) +} + +// LoadPoster loads comment poster +func (c *ConversationComment) 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 +} + +// LoadReactions loads comment reactions +func (c *ConversationComment) 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 *ConversationComment) 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) (_ *ConversationComment, err error) { + ctx, committer, err := db.TxContext(ctx) + if err != nil { + return nil, err + } + defer committer.Close() + + e := db.GetEngine(ctx) + + comment := &ConversationComment{ + Type: opts.Type, + PosterID: opts.Doer.ID, + Poster: opts.Doer, + Content: opts.Content, + Conversation: opts.Conversation, + ConversationID: opts.Conversation.ID, + } + if _, err = e.Insert(comment); err != nil { + return nil, err + } + + if err = opts.Repo.LoadOwner(ctx); err != nil { + return nil, err + } + + if err = updateCommentInfos(ctx, opts); 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) (*ConversationComment, error) { + c := new(ConversationComment) + 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 + Since int64 + Before int64 + Type CommentType + ConversationIDs []int64 +} + +// 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{"conversation_comment.conversation_id": opts.ConversationID}) + } else if len(opts.ConversationIDs) > 0 { + cond = cond.And(builder.In("conversation_comment.conversation_id", opts.ConversationIDs)) + } + if opts.Since > 0 { + cond = cond.And(builder.Gte{"conversation_comment.updated_unix": opts.Since}) + } + if opts.Before > 0 { + cond = cond.And(builder.Lte{"conversation_comment.updated_unix": opts.Before}) + } + if opts.Type != CommentTypeUndefined { + 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([]*ConversationComment, 0, 10) + sess := db.GetEngine(ctx).Where(opts.ToConds()) + if opts.RepoID > 0 { + sess.Join("INNER", "conversation", "conversation.id = conversation_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("conversation_comment.created_unix"). + Asc("conversation_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 = conversation_comment.conversation_id") + } + return sess.Count(&ConversationComment{}) +} + +// UpdateCommentInvalidate updates comment invalidated column +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 *ConversationComment, 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 *ConversationComment) 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 comment.Type == CommentTypeComment { + if _, err := e.ID(comment.ConversationID).Decr("num_comments").Update(new(Conversation)); 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("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("conversation_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 *ConversationComment) 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 *ConversationComment) 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 *ConversationComment) 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 *ConversationComment) 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 conversation. +func (c *ConversationComment) HashTag() string { + return fmt.Sprintf("comment-%d", c.ID) +} + +func (c *ConversationComment) hashLink() string { + return "#" + c.HashTag() +} + +// HTMLURL formats a URL-string to the conversation-comment +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) + 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() +} + +// APIURL formats a API-string to the conversation-comment +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) + 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 *ConversationComment) HasOriginalAuthor() bool { + return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 +} + +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) + 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() +} + +// InsertConversationComments inserts many comments of conversations. +func InsertConversationComments(ctx context.Context, comments []*ConversationComment) error { + if len(comments) == 0 { + return nil + } + + conversationIDs := container.FilterSlice(comments, func(comment *ConversationComment) (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 conversation_comment WHERE conversation_id = ? AND `type`=?) WHERE id = ?", + conversationID, CommentTypeComment, conversationID); err != nil { + return err + } + } + return committer.Commit() +} + +func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions) (err error) { + // Check comment type. + switch opts.Type { + case CommentTypeComment: + if _, err = db.Exec(ctx, "UPDATE `conversation` SET num_comments=num_comments+1 WHERE id=?", opts.Conversation.ID); err != nil { + return err + } + } + // update the conversation's updated_unix column + return UpdateConversationCols(ctx, opts.Conversation, "updated_unix") +} diff --git a/models/conversations/comment_list.go b/models/conversations/comment_list.go new file mode 100644 index 0000000000000..5acf409c027e2 --- /dev/null +++ b/models/conversations/comment_list.go @@ -0,0 +1,193 @@ +// Copyright 2024 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 []*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) { + 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 *ConversationComment) (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 + } + + return comments.LoadConversations(ctx) +} 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/content_history.go b/models/conversations/content_history.go new file mode 100644 index 0000000000000..3896422ffa835 --- /dev/null +++ b/models/conversations/content_history.go @@ -0,0 +1,246 @@ +// Copyright 2024 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..9059c6cbe41c2 --- /dev/null +++ b/models/conversations/conversation.go @@ -0,0 +1,367 @@ +// 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) +// Much of the functions here are reimplemented from models/issues/issue.go but simplified + +import ( + "context" + "fmt" + + "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" +) + +// 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 + +// CommentTypeUndefined is used to search for comments of any type +const ConversationTypeUndefined CommentType = -1 + +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 `xorm:"INDEX"` + + NumComments int + + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` + LockedUnix timeutil.TimeStamp `xorm:"INDEX"` + + IsLocked bool `xorm:"NOT NULL DEFAULT false"` + + Comments CommentList `xorm:"-"` + + CommitSha string `xorm:"VARCHAR(64)"` + IsRead bool `xorm:"-"` +} + +// ConversationIndex represents the conversation 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(), "commit", conversation.CommitSha) + } +} + +func (conversation *Conversation) loadComments(ctx context.Context) (err error) { + conversation.Comments, err = FindComments(ctx, &FindCommentsOptions{ + ConversationID: conversation.ID, + }) + + return err +} + +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 +} + +// 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{} + } + 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.loadComments(ctx); err != nil { + return err + } + + if err = conversation.loadReactions(ctx); err != nil { + return err + } + + return conversation.Comments.LoadAttributes(ctx) +} + +// 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 +} + +// 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 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, conversationIDs []int64, keepOrder ...bool) (ConversationList, error) { + conversations := make([]*Conversation, 0, len(conversationIDs)) + + 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(conversations)) + appended := container.Set[int64]{} + for _, conversation := range conversations { + m[conversation.ID] = conversation + } + 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) + conversations = append(conversations, conversation) + } + } + } + + return conversations, 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} + } + err = conversation.LoadAttributes(ctx) + if err != nil { + return nil, err + } + + 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 conversation. +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) +} + +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]*ConversationComment) + 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 +} + +// 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_list.go b/models/conversations/conversation_list.go new file mode 100644 index 0000000000000..2e64ef8535f25 --- /dev/null +++ b/models/conversations/conversation_list.go @@ -0,0 +1,231 @@ +// Copyright 2024 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:] + } + return nil +} + +func (conversations ConversationList) loadComments(ctx context.Context, cond builder.Cond) (err error) { + if len(conversations) == 0 { + return nil + } + + comments := make(map[int64][]*ConversationComment, 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("conversation_comment"). + Join("INNER", "conversation", "conversation.id = conversation_comment.conversation_id"). + In("conversation.id", conversationsIDs[:limit]). + Where(cond). + NoAutoCondition(). + Rows(new(ConversationComment)) + if err != nil { + return err + } + + for rows.Next() { + var comment ConversationComment + 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..fcc9c5c0b53d2 --- /dev/null +++ b/models/conversations/conversation_search.go @@ -0,0 +1,184 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package conversations + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + user_model "code.gitea.io/gitea/models/user" + "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 + 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 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.UpdatedAfterUnix != 0 { + sess.And(builder.Gte{"conversation.updated_unix": opts.UpdatedAfterUnix}) + } + if opts.UpdatedBeforeUnix != 0 { + sess.And(builder.Lte{"conversation.updated_unix": opts.UpdatedBeforeUnix}) + } + + if opts.IsArchived.Has() { + sess.And(builder.Eq{"repository.is_archived": opts.IsArchived.Value()}) + } +} + +// 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..99b7e225f6c8b --- /dev/null +++ b/models/conversations/conversation_stat.go @@ -0,0 +1,149 @@ +// Copyright 2024 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, LockedCount int64 + YourRepositoriesCount int64 + CreateCount 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.YourRepositoriesCount += stats.YourRepositoriesCount + accum.CreateCount += stats.CreateCount + accum.OpenCount += stats.OpenCount + accum.LockedCount += stats.LockedCount + 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_locked = ?", false). + Count(new(Conversation)) + if err != nil { + return stats, err + } + stats.LockedCount, err = applyConversationsOptions(sess, opts, conversationIDs). + And("conversation.is_locked = ?", 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) + } + + 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_test.go b/models/conversations/conversation_test.go new file mode 100644 index 0000000000000..0a449c73a8a97 --- /dev/null +++ b/models/conversations/conversation_test.go @@ -0,0 +1,238 @@ +// Copyright 2024 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/commit/", 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{ + RepoCond: builder.In("repo_id", 1, 3), + SortType: "oldest", + Paginator: &db.ListOptions{ + Page: 1, + PageSize: 4, + }, + }, + []int64{1, 2, 3, 5}, + }, + } { + 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", 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 \ `, 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), 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), 0) + wg.Done() + }(i) + } + wg.Wait() + + // Now we will get all conversationID's that match the repo id 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 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}) + + 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 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 +} diff --git a/models/conversations/conversation_update.go b/models/conversations/conversation_update.go new file mode 100644 index 0000000000000..92174ec05c298 --- /dev/null +++ b/models/conversations/conversation_update.go @@ -0,0 +1,430 @@ +// Copyright 2024 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(&ConversationComment{}) + 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(&CommentReaction{}) + if err != nil { + return nil, err + } + + _, err = sess.In("dependent_conversation_id", conversationIDs).Delete(&ConversationComment{}) + 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..05f3e9d71d94f --- /dev/null +++ b/models/conversations/conversation_user.go @@ -0,0 +1,78 @@ +// Copyright 2024 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/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) +} diff --git a/models/conversations/reaction.go b/models/conversations/reaction.go new file mode 100644 index 0000000000000..50e44f1c54087 --- /dev/null +++ b/models/conversations/reaction.go @@ -0,0 +1,373 @@ +// Copyright 2024 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 +} + +// 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"` + 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 *CommentReaction) 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 *CommentReaction) RemapExternalUser(externalName string, externalID, userID int64) error { + r.OriginalAuthor = externalName + r.OriginalAuthorID = externalID + r.UserID = userID + return nil +} + +// GetUserID ExternalUserRemappable interface +func (r *CommentReaction) GetUserID() int64 { return r.UserID } + +// GetExternalName ExternalUserRemappable interface +func (r *CommentReaction) GetExternalName() string { return r.OriginalAuthor } + +// GetExternalID ExternalUserRemappable interface +func (r *CommentReaction) GetExternalID() int64 { return r.OriginalAuthorID } + +func init() { + db.RegisterModel(new(CommentReaction)) +} + +// 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{"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{"comment_reaction.comment_id": opts.CommentID}) + } else if opts.CommentID == -1 { + cond = cond.And(builder.Eq{"comment_reaction.comment_id": 0}) + } + if opts.UserID > 0 { + cond = cond.And(builder.Eq{ + "comment_reaction.user_id": opts.UserID, + "comment_reaction.original_author_id": 0, + }) + } + if opts.Reaction != "" { + cond = cond.And(builder.Eq{"comment_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("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) + + reactions := make([]*CommentReaction, 0, opts.PageSize) + count, err := sess.FindAndCount(&reactions) + return reactions, count, err + } + + reactions := make([]*CommentReaction, 0, 10) + count, err := sess.FindAndCount(&reactions) + return reactions, count, err +} + +func createReaction(ctx context.Context, opts *ReactionOptions) (*CommentReaction, error) { + reaction := &CommentReaction{ + 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) (*CommentReaction, 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 := &CommentReaction{ + 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 []*CommentReaction + +// 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 *CommentReaction) (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/fixtures/comment_reaction.yml b/models/fixtures/comment_reaction.yml new file mode 100644 index 0000000000000..7ee2f7881b9b6 --- /dev/null +++ b/models/fixtures/comment_reaction.yml @@ -0,0 +1,39 @@ +- + id: 1 # conversation reaction + type: zzz # not allowed reaction (added before allowed reaction list has changed) + conversation_id: 1 + comment_id: 0 + user_id: 2 + created_unix: 1573248001 + +- + id: 2 # conversation reaction + type: zzz # not allowed reaction (added before allowed reaction list has changed) + conversation_id: 1 + comment_id: 0 + user_id: 1 + created_unix: 1573248002 + +- + id: 3 # conversation reaction + type: eyes # allowed reaction + conversation_id: 1 + comment_id: 0 + user_id: 2 + created_unix: 1573248003 + +- + id: 4 # comment reaction + type: laugh # allowed reaction + conversation_id: 1 + comment_id: 2 + user_id: 2 + created_unix: 1573248004 + +- + id: 5 # comment reaction + type: laugh # allowed reaction + conversation_id: 1 + comment_id: 2 + user_id: 1 + created_unix: 1573248005 diff --git a/models/fixtures/conversation.yml b/models/fixtures/conversation.yml new file mode 100644 index 0000000000000..6bc1361ad7eec --- /dev/null +++ b/models/fixtures/conversation.yml @@ -0,0 +1,154 @@ +- + created_unix: 946684800 + id: 1 + index: 1 + num_comments: 3 + repo_id: 1 + updated_unix: 978307200 +- + created_unix: 946684810 + id: 2 + index: 2 + num_comments: 1 + repo_id: 1 + updated_unix: 978307190 +- + created_unix: 946684820 + id: 3 + index: 3 + num_comments: 0 + repo_id: 1 + updated_unix: 978307180 +- + created_unix: 946684830 + id: 4 + index: 1 + num_comments: 1 + repo_id: 2 + updated_unix: 978307200 +- + created_unix: 946684840 + id: 5 + index: 4 + num_comments: 0 + repo_id: 1 + updated_unix: 978307200 +- + created_unix: 946684850 + id: 6 + index: 1 + num_comments: 0 + repo_id: 3 + updated_unix: 978307200 +- + created_unix: 946684830 + id: 7 + index: 2 + num_comments: 0 + repo_id: 2 + updated_unix: 978307200 +- + created_unix: 946684820 + id: 8 + index: 1 + num_comments: 0 + repo_id: 10 + updated_unix: 978307180 +- + created_unix: 946684820 + id: 9 + index: 1 + num_comments: 0 + repo_id: 48 + updated_unix: 978307180 +- + created_unix: 946684830 + id: 10 + index: 1 + num_comments: 0 + repo_id: 42 + updated_unix: 999307200 +- + created_unix: 1579194806 + id: 11 + index: 5 + num_comments: 0 + repo_id: 1 + updated_unix: 1579194806 +- + created_unix: 1602935696 + id: 12 + index: 2 + num_comments: 0 + repo_id: 3 + updated_unix: 1602935696 +- + created_unix: 1602935696 + id: 13 + index: 1 + num_comments: 0 + repo_id: 50 + updated_unix: 1602935696 +- + created_unix: 1602935696 + id: 14 + index: 1 + num_comments: 0 + repo_id: 51 + updated_unix: 1602935696 +- + created_unix: 1602935696 + id: 15 + index: 1 + num_comments: 0 + repo_id: 5 + updated_unix: 1602935696 +- + created_unix: 1602935696 + id: 16 + index: 1 + num_comments: 0 + repo_id: 32 + updated_unix: 1602935696 +- + created_unix: 1602935696 + id: 17 + index: 2 + num_comments: 0 + repo_id: 32 + updated_unix: 1602935696 +- + created_unix: 946684830 + id: 18 + index: 1 + num_comments: 0 + repo_id: 55 + updated_unix: 978307200 +- + created_unix: 946684830 + id: 19 + index: 1 + num_comments: 0 + repo_id: 58 + updated_unix: 978307200 +- + created_unix: 978307210 + id: 20 + index: 1 + num_comments: 0 + repo_id: 23 + updated_unix: 978307210 +- + created_unix: 1707270422 + id: 21 + index: 1 + num_comments: 0 + repo_id: 60 + updated_unix: 1707270422 +- + created_unix: 1707270422 + id: 22 + index: 1 + num_comments: 0 + repo_id: 61 + updated_unix: 1707270422 diff --git a/models/fixtures/conversation_comment.yml b/models/fixtures/conversation_comment.yml new file mode 100644 index 0000000000000..9ac0ff9ee1773 --- /dev/null +++ b/models/fixtures/conversation_comment.yml @@ -0,0 +1,39 @@ +- + id: 1 + type: 0 # comment + poster_id: 2 + conversation_id: 1 # in repo_id 1 + content: "1first" + created_unix: 946684810 +- + id: 2 + type: 0 # comment + poster_id: 3 # user not watching (see watch.yml) + conversation_id: 1 # in repo_id 1 + content: "good work!" + created_unix: 946684811 + updated_unix: 946684811 +- + id: 3 + type: 0 # comment + poster_id: 5 # user not watching (see watch.yml) + conversation_id: 1 # in repo_id 1 + content: "meh..." + created_unix: 946684812 + updated_unix: 946684812 +- + id: 4 + type: 0 # comment + poster_id: 2 + conversation_id: 4 # in repo_id 2 + content: "comment in private pository" + created_unix: 946684811 + updated_unix: 946684811 +- + id: 5 + type: 0 # comment + poster_id: 2 + conversation_id: 2 # in repo_id 1 + content: "conversationcomment2" + created_unix: 946684811 + updated_unix: 946684811 diff --git a/models/fixtures/conversation_index.yml b/models/fixtures/conversation_index.yml new file mode 100644 index 0000000000000..5aabc08e388c5 --- /dev/null +++ b/models/fixtures/conversation_index.yml @@ -0,0 +1,35 @@ +- + group_id: 1 + max_index: 5 + +- + group_id: 2 + max_index: 2 + +- + group_id: 3 + max_index: 2 + +- + group_id: 10 + max_index: 1 + +- + group_id: 32 + max_index: 2 + +- + group_id: 48 + max_index: 1 + +- + group_id: 42 + max_index: 1 + +- + group_id: 50 + max_index: 1 + +- + group_id: 51 + max_index: 1 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/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..704c9ac08f3b7 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) @@ -60,6 +61,7 @@ var ( TypeProjects, TypePackages, TypeActions, + TypeConversations, } // DefaultRepoUnits contains the default unit types @@ -72,6 +74,7 @@ var ( TypeProjects, TypePackages, TypeActions, + TypeConversations, } // ForkRepoUnits contains the default unit types for forks @@ -293,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, @@ -305,6 +317,7 @@ var ( TypeProjects: UnitProjects, TypePackages: UnitPackages, TypeActions: UnitActions, + TypeConversations: UnitConversations, } ) diff --git a/models/unit/unit_test.go b/models/unit/unit_test.go index 7bf6326145587..dee9a846c2190 100644 --- a/models/unit/unit_test.go +++ b/models/unit/unit_test.go @@ -89,7 +89,7 @@ func TestLoadUnitConfig(t *testing.T) { setting.Repository.DefaultForkRepoUnits = []string{"repo.releases", "repo.releases"} assert.NoError(t, LoadUnitConfig()) assert.Equal(t, []Type{TypeIssues}, DisabledRepoUnitsGet()) - assert.ElementsMatch(t, []Type{TypeCode, TypePullRequests, TypeReleases, TypeWiki, TypePackages, TypeProjects, TypeActions}, DefaultRepoUnits) + assert.ElementsMatch(t, []Type{TypeCode, TypePullRequests, TypeReleases, TypeWiki, TypePackages, TypeProjects, TypeActions, TypeConversations}, DefaultRepoUnits) assert.Equal(t, []Type{TypeReleases}, DefaultForkRepoUnits) }) } diff --git a/models/unittest/consistency.go b/models/unittest/consistency.go index 71839001be08d..0ecb83656510e 100644 --- a/models/unittest/consistency.go +++ b/models/unittest/consistency.go @@ -179,6 +179,13 @@ func init() { } } + checkForConversationConsistency := func(t assert.TestingT, bean any) { + conversation := reflectionWrap(bean) + typeComment := modelsCommentTypeComment + actual := GetCountByCond(t, "conversation_comment", builder.Eq{"`type`": typeComment, "conversation_id": conversation.int("ID")}) + assert.EqualValues(t, conversation.int("NumComments"), actual, "Unexpected number of comments for conversation id: %d", conversation.int("ID")) + } + consistencyCheckMap["user"] = checkForUserConsistency consistencyCheckMap["repository"] = checkForRepoConsistency consistencyCheckMap["issue"] = checkForIssueConsistency @@ -187,4 +194,5 @@ func init() { consistencyCheckMap["label"] = checkForLabelConsistency consistencyCheckMap["team"] = checkForTeamConsistency consistencyCheckMap["action"] = checkForActionConsistency + consistencyCheckMap["conversation"] = checkForConversationConsistency } 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/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..64cadd8b831d4 --- /dev/null +++ b/modules/structs/conversation.go @@ -0,0 +1,184 @@ +// 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"` + Ref string `json:"ref"` + // 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"` + + Repo *RepositoryMeta `json:"repository"` +} + +// CreateConversationOption options to create one conversation +type CreateConversationOption struct { + Locked bool `json:"locked"` +} + +// 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/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/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 679e64b42415e..11c40108b238c 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1939,6 +1939,14 @@ 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 milestones.update_ago = Updated %s diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 23f466873bad9..14896a3669a28 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -675,6 +675,13 @@ 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)) { @@ -1503,6 +1510,22 @@ 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..04adb35313e11 --- /dev/null +++ b/routers/api/v1/repo/conversation_comment.go @@ -0,0 +1,609 @@ +// 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" + 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 + } + + canReadConversation := ctx.Repo.CanRead(unit.TypeConversations) + if !canReadConversation { + ctx.NotFound() + return + } + + opts := &conversations_model.FindCommentsOptions{ + ListOptions: utils.GetListOptions(ctx), + RepoID: ctx.Repo.Repository.ID, + Type: conversations_model.CommentTypeComment, + Since: since, + Before: before, + } + + 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) +} + +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) +} + +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/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 } diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index d7865e18d63f9..c276bd9031991 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -8,11 +8,13 @@ import ( "errors" "fmt" "html/template" + "math/big" "net/http" "path" "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" @@ -25,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 ( @@ -414,6 +418,73 @@ 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 + } + } + + 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["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) + } + + 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 new file mode 100644 index 0000000000000..91818d07936d6 --- /dev/null +++ b/routers/web/repo/conversation.go @@ -0,0 +1,898 @@ +// Copyright 2024 The Gogs Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "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" + "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" +) + +// 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) + } +} + +// Conversations render conversations page +func Conversations(ctx *context.Context) { + 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) +} + +// 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.ConversationComment + 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, 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) + 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) { + if !ctx.Repo.CanRead(unit.TypeConversations) { + ctx.NotFound("ConversationUnitNotAllowed", 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, conversation), + }) +} + +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 +} + +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.CreateConversationCommentForm) + 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.ConversationComment + defer func() { + // Redirect to comment hashtag if there is any actual content. + typeName := "commit" + if comment != nil { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s#%s", ctx.Repo.RepoLink, typeName, conversation.CommitSha, comment.HashTag())) + } else { + ctx.JSONRedirect(fmt.Sprintf("%s/%s/%s", 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 := updateConversationAttachments(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("GetConversationCommentByID", 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": + if err = AddReaction(ctx, form, comment, nil); err != nil { + break + } + case "unreact": + if err = RemoveReaction(ctx, form, comment, nil); err != nil { + break + } + 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/conversations/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, + }) +} + +// 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.ConversationComment: + 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.ConversationComment: + 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.ConversationComment: + 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, 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/routers/web/repo/issue.go b/routers/web/repo/issue.go index 1ee6e98afbd3d..3bacd04dc0d6e 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -2036,6 +2036,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) @@ -2564,72 +2566,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 { @@ -3315,37 +3252,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/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 cc554a71ff605..04bfcd4bfc8bc 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..cdc0b9ac37cad 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" @@ -46,6 +47,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 { @@ -192,11 +195,26 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori ctx.ServerError("CanMarkConversation", err) return } - ctx.Data["Issue"] = comment.Issue - 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 } + + 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 pullHeadCommitID, err := ctx.Repo.GitRepo.GetRefCommitID(comment.Issue.PullRequest.GetGitRefName()) if err != nil { ctx.ServerError("GetRefCommitID", err) diff --git a/routers/web/repo/reaction.go b/routers/web/repo/reaction.go new file mode 100644 index 0000000000000..c19edc4b8604f --- /dev/null +++ b/routers/web/repo/reaction.go @@ -0,0 +1,92 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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 +} diff --git a/routers/web/web.go b/routers/web/web.go index 29dd8a8edcb66..875e743ca4976 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) @@ -1277,6 +1278,20 @@ func registerRoutes(m *web.Router) { }, reqSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) // end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones + m.Group("/{username}/{reponame}", func() { // conversations/conversation comments + m.Group("/conversations", func() { + 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, reqRepoConversationReader) + m.Group("/{username}/{reponame}", func() { // repo code m.Group("", func() { m.Group("", func() { diff --git a/services/context/context.go b/services/context/context.go index 6c7128ef6866c..22061e338f94d 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -113,6 +113,7 @@ func NewTemplateContextForWeb(ctx *Context) TemplateContext { "RepoUnitTypeProjects": unit.TypeProjects, "RepoUnitTypePackages": unit.TypePackages, "RepoUnitTypeActions": unit.TypeActions, + "RepoUnitTypeConversations": unit.TypeConversations, } return tmplCtx } diff --git a/services/conversation/comments.go b/services/conversation/comments.go new file mode 100644 index 0000000000000..3b42c5a8cc98d --- /dev/null +++ b/services/conversation/comments.go @@ -0,0 +1,100 @@ +// Copyright 2024 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.ConversationComment, 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, + ConversationID: conversation.ID, + 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.ConversationComment, 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.ConversationComment) 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..b89ec3deaf96d --- /dev/null +++ b/services/conversation/conversation.go @@ -0,0 +1,89 @@ +// Copyright 2024 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" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" +) + +// 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 + } + + // delete all database data still assigned to this conversation + if err := db.DeleteBeans(ctx, + &conversations_model.ConversationContentHistory{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.ConversationComment{ConversationID: 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..2bb6f9f33adc2 --- /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.ConversationComment, content string) (*conversations_model.CommentReaction, 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..a3437afe93676 --- /dev/null +++ b/services/convert/conversation.go @@ -0,0 +1,75 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + + conversations_model "code.gitea.io/gitea/models/conversations" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" +) + +func ToConversation(ctx context.Context, conversation *conversations_model.Conversation) *api.Conversation { + return toConversation(ctx, conversation) +} + +// ToAPIConversation converts an Conversation to API format +func ToAPIConversation(ctx context.Context, conversation *conversations_model.Conversation) *api.Conversation { + return toConversation(ctx, conversation) +} + +func toConversation(ctx context.Context, conversation *conversations_model.Conversation) *api.Conversation { + if err := conversation.LoadRepo(ctx); err != nil { + return &api.Conversation{} + } + + apiConversation := &api.Conversation{ + ID: conversation.ID, + Index: conversation.Index, + 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, 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, il[i]) + } + return result +} diff --git a/services/convert/conversation_comment.go b/services/convert/conversation_comment.go new file mode 100644 index 0000000000000..3518c65187f8e --- /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.ConversationComment) *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.ConversationComment, 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 ddd07a64cbf75..ec3a09acb1a8d 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -475,6 +475,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 b9d71917a1d1c..90ba92a0bee3e 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -280,6 +280,7 @@ {{end}} {{template "repo/diff/box" .}} + {{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}} +