From 791d7fc76aa41370860126e861cf14d98efe710e Mon Sep 17 00:00:00 2001
From: Lunny Xiao <xiaolunwen@gmail.com>
Date: Fri, 9 Aug 2024 09:29:02 +0800
Subject: [PATCH] Add issue comment when moving issues from one column to
 another of the project (#29311)

Fix #27278
Replace #27816

This PR adds a meta-comment for an issue when dragging an issue from one
column to another of a project.

<img width="600" alt="image"
src="https://github.com/go-gitea/gitea/assets/81045/5fc1d954-430e-4db0-aaee-a00006fa91f5">

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: yp05327 <576951401@qq.com>
---
 models/issues/comment.go                      | 78 +++++++++++-------
 models/issues/issue_list.go                   |  1 +
 models/migrations/migrations.go               |  2 +
 models/migrations/v1_23/v303.go               | 23 ++++++
 models/project/issue.go                       | 24 ------
 options/locale/locale_en-US.ini               |  1 +
 routers/web/org/projects.go                   |  3 +-
 routers/web/repo/issue.go                     |  5 ++
 routers/web/repo/projects.go                  |  3 +-
 services/projects/issue.go                    | 79 +++++++++++++++++++
 .../repo/issue/view_content/comments.tmpl     | 16 ++++
 11 files changed, 181 insertions(+), 54 deletions(-)
 create mode 100644 models/migrations/v1_23/v303.go
 create mode 100644 services/projects/issue.go

diff --git a/models/issues/comment.go b/models/issues/comment.go
index c6c5dc2432..48b8e335d4 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -222,6 +222,13 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
 	return lang.TrString("repo.issues.role." + string(r) + "_helper")
 }
 
+// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database
+type CommentMetaData struct {
+	ProjectColumnID    int64  `json:"project_column_id,omitempty"`
+	ProjectColumnTitle string `json:"project_column_title,omitempty"`
+	ProjectTitle       string `json:"project_title,omitempty"`
+}
+
 // Comment represents a comment in commit and issue page.
 type Comment struct {
 	ID               int64            `xorm:"pk autoincr"`
@@ -295,6 +302,8 @@ type Comment struct {
 	RefAction    references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves
 	RefIsPull    bool
 
+	CommentMetaData *CommentMetaData `xorm:"JSON TEXT"` // put all non-index metadata in a single field
+
 	RefRepo    *repo_model.Repository `xorm:"-"`
 	RefIssue   *Issue                 `xorm:"-"`
 	RefComment *Comment               `xorm:"-"`
@@ -797,6 +806,15 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
 		LabelID = opts.Label.ID
 	}
 
+	var commentMetaData *CommentMetaData
+	if opts.ProjectColumnTitle != "" {
+		commentMetaData = &CommentMetaData{
+			ProjectColumnID:    opts.ProjectColumnID,
+			ProjectColumnTitle: opts.ProjectColumnTitle,
+			ProjectTitle:       opts.ProjectTitle,
+		}
+	}
+
 	comment := &Comment{
 		Type:             opts.Type,
 		PosterID:         opts.Doer.ID,
@@ -830,6 +848,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
 		RefIsPull:        opts.RefIsPull,
 		IsForcePush:      opts.IsForcePush,
 		Invalidated:      opts.Invalidated,
+		CommentMetaData:  commentMetaData,
 	}
 	if _, err = e.Insert(comment); err != nil {
 		return nil, err
@@ -982,34 +1001,37 @@ type CreateCommentOptions struct {
 	Issue *Issue
 	Label *Label
 
-	DependentIssueID int64
-	OldMilestoneID   int64
-	MilestoneID      int64
-	OldProjectID     int64
-	ProjectID        int64
-	TimeID           int64
-	AssigneeID       int64
-	AssigneeTeamID   int64
-	RemovedAssignee  bool
-	OldTitle         string
-	NewTitle         string
-	OldRef           string
-	NewRef           string
-	CommitID         int64
-	CommitSHA        string
-	Patch            string
-	LineNum          int64
-	TreePath         string
-	ReviewID         int64
-	Content          string
-	Attachments      []string // UUIDs of attachments
-	RefRepoID        int64
-	RefIssueID       int64
-	RefCommentID     int64
-	RefAction        references.XRefAction
-	RefIsPull        bool
-	IsForcePush      bool
-	Invalidated      bool
+	DependentIssueID   int64
+	OldMilestoneID     int64
+	MilestoneID        int64
+	OldProjectID       int64
+	ProjectID          int64
+	ProjectTitle       string
+	ProjectColumnID    int64
+	ProjectColumnTitle string
+	TimeID             int64
+	AssigneeID         int64
+	AssigneeTeamID     int64
+	RemovedAssignee    bool
+	OldTitle           string
+	NewTitle           string
+	OldRef             string
+	NewRef             string
+	CommitID           int64
+	CommitSHA          string
+	Patch              string
+	LineNum            int64
+	TreePath           string
+	ReviewID           int64
+	Content            string
+	Attachments        []string // UUIDs of attachments
+	RefRepoID          int64
+	RefIssueID         int64
+	RefCommentID       int64
+	RefAction          references.XRefAction
+	RefIsPull          bool
+	IsForcePush        bool
+	Invalidated        bool
 }
 
 // GetCommentByID returns the comment by given ID.
diff --git a/models/issues/issue_list.go b/models/issues/issue_list.go
index 2c007c72ec..22a4548adc 100644
--- a/models/issues/issue_list.go
+++ b/models/issues/issue_list.go
@@ -441,6 +441,7 @@ func (issues IssueList) loadComments(ctx context.Context, cond builder.Cond) (er
 			Join("INNER", "issue", "issue.id = comment.issue_id").
 			In("issue.id", issuesIDs[:limit]).
 			Where(cond).
+			NoAutoCondition().
 			Rows(new(Comment))
 		if err != nil {
 			return err
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index a57b4da031..a3264160e5 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -597,6 +597,8 @@ var migrations = []Migration{
 	NewMigration("Add skip_secondary_authorization option to oauth2 application table", v1_23.AddSkipSecondaryAuthColumnToOAuth2ApplicationTable),
 	// v302 -> v303
 	NewMigration("Add index to action_task stopped log_expired", v1_23.AddIndexToActionTaskStoppedLogExpired),
+	// v303 -> v304
+	NewMigration("Add metadata column for comment table", v1_23.AddCommentMetaDataColumn),
 }
 
 // GetCurrentDBVersion returns the current db version
diff --git a/models/migrations/v1_23/v303.go b/models/migrations/v1_23/v303.go
new file mode 100644
index 0000000000..adfe917d3f
--- /dev/null
+++ b/models/migrations/v1_23/v303.go
@@ -0,0 +1,23 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_23 //nolint
+
+import (
+	"xorm.io/xorm"
+)
+
+// CommentMetaData stores metadata for a comment, these data will not be changed once inserted into database
+type CommentMetaData struct {
+	ProjectColumnID    int64  `json:"project_column_id"`
+	ProjectColumnTitle string `json:"project_column_title"`
+	ProjectTitle       string `json:"project_title"`
+}
+
+func AddCommentMetaDataColumn(x *xorm.Engine) error {
+	type Comment struct {
+		CommentMetaData *CommentMetaData `xorm:"JSON TEXT"` // put all non-index metadata in a single field
+	}
+
+	return x.Sync(new(Comment))
+}
diff --git a/models/project/issue.go b/models/project/issue.go
index 3361b533b9..1c31b154ce 100644
--- a/models/project/issue.go
+++ b/models/project/issue.go
@@ -76,30 +76,6 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
 	return int(c)
 }
 
-// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
-func MoveIssuesOnProjectColumn(ctx context.Context, column *Column, sortedIssueIDs map[int64]int64) error {
-	return db.WithTx(ctx, func(ctx context.Context) error {
-		sess := db.GetEngine(ctx)
-		issueIDs := util.ValuesOfMap(sortedIssueIDs)
-
-		count, err := sess.Table(new(ProjectIssue)).Where("project_id=?", column.ProjectID).In("issue_id", issueIDs).Count()
-		if err != nil {
-			return err
-		}
-		if int(count) != len(sortedIssueIDs) {
-			return fmt.Errorf("all issues have to be added to a project first")
-		}
-
-		for sorting, issueID := range sortedIssueIDs {
-			_, err = sess.Exec("UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
-			if err != nil {
-				return err
-			}
-		}
-		return nil
-	})
-}
-
 func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
 	if c.ProjectID != newColumn.ProjectID {
 		return fmt.Errorf("columns have to be in the same project")
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 92f955c78a..cca068a3a2 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1476,6 +1476,7 @@ issues.remove_labels = removed the %s labels %s
 issues.add_remove_labels = added %s and removed %s labels %s
 issues.add_milestone_at = `added this to the <b>%s</b> milestone %s`
 issues.add_project_at = `added this to the <b>%s</b> project %s`
+issues.move_to_column_of_project = `moved this to %s in %s on %s`
 issues.change_milestone_at = `modified the milestone from <b>%s</b> to <b>%s</b> %s`
 issues.change_project_at = `modified the project from <b>%s</b> to <b>%s</b> %s`
 issues.remove_milestone_at = `removed this from the <b>%s</b> milestone %s`
diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go
index eea539f6d9..66760d31db 100644
--- a/routers/web/org/projects.go
+++ b/routers/web/org/projects.go
@@ -23,6 +23,7 @@ import (
 	shared_user "code.gitea.io/gitea/routers/web/shared/user"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
+	project_service "code.gitea.io/gitea/services/projects"
 )
 
 const (
@@ -601,7 +602,7 @@ func MoveIssues(ctx *context.Context) {
 		}
 	}
 
-	if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil {
+	if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
 		ctx.ServerError("MoveIssuesOnProjectColumn", err)
 		return
 	}
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 1018e88f1b..4773cc9adc 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -1687,6 +1687,11 @@ func ViewIssue(ctx *context.Context) {
 			if comment.ProjectID > 0 && comment.Project == nil {
 				comment.Project = ghostProject
 			}
+		} else if comment.Type == issues_model.CommentTypeProjectColumn {
+			if err = comment.LoadProject(ctx); err != nil {
+				ctx.ServerError("LoadProject", err)
+				return
+			}
 		} else if comment.Type == issues_model.CommentTypeAssignees || comment.Type == issues_model.CommentTypeReviewRequest {
 			if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil {
 				ctx.ServerError("LoadAssigneeUserAndTeam", err)
diff --git a/routers/web/repo/projects.go b/routers/web/repo/projects.go
index fdeead5703..aac8997d62 100644
--- a/routers/web/repo/projects.go
+++ b/routers/web/repo/projects.go
@@ -25,6 +25,7 @@ import (
 	"code.gitea.io/gitea/modules/web"
 	"code.gitea.io/gitea/services/context"
 	"code.gitea.io/gitea/services/forms"
+	project_service "code.gitea.io/gitea/services/projects"
 )
 
 const (
@@ -664,7 +665,7 @@ func MoveIssues(ctx *context.Context) {
 		}
 	}
 
-	if err = project_model.MoveIssuesOnProjectColumn(ctx, column, sortedIssueIDs); err != nil {
+	if err = project_service.MoveIssuesOnProjectColumn(ctx, ctx.Doer, column, sortedIssueIDs); err != nil {
 		ctx.ServerError("MoveIssuesOnProjectColumn", err)
 		return
 	}
diff --git a/services/projects/issue.go b/services/projects/issue.go
new file mode 100644
index 0000000000..db1621a39f
--- /dev/null
+++ b/services/projects/issue.go
@@ -0,0 +1,79 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package project
+
+import (
+	"context"
+	"fmt"
+
+	"code.gitea.io/gitea/models/db"
+	issues_model "code.gitea.io/gitea/models/issues"
+	project_model "code.gitea.io/gitea/models/project"
+	user_model "code.gitea.io/gitea/models/user"
+)
+
+// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
+func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
+	return db.WithTx(ctx, func(ctx context.Context) error {
+		issueIDs := make([]int64, 0, len(sortedIssueIDs))
+		for _, issueID := range sortedIssueIDs {
+			issueIDs = append(issueIDs, issueID)
+		}
+		count, err := db.GetEngine(ctx).
+			Where("project_id=?", column.ProjectID).
+			In("issue_id", issueIDs).
+			Count(new(project_model.ProjectIssue))
+		if err != nil {
+			return err
+		}
+		if int(count) != len(sortedIssueIDs) {
+			return fmt.Errorf("all issues have to be added to a project first")
+		}
+
+		issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
+		if err != nil {
+			return err
+		}
+		if _, err := issues.LoadRepositories(ctx); err != nil {
+			return err
+		}
+
+		project, err := project_model.GetProjectByID(ctx, column.ProjectID)
+		if err != nil {
+			return err
+		}
+
+		issuesMap := make(map[int64]*issues_model.Issue, len(issues))
+		for _, issue := range issues {
+			issuesMap[issue.ID] = issue
+		}
+
+		for sorting, issueID := range sortedIssueIDs {
+			curIssue := issuesMap[issueID]
+			if curIssue == nil {
+				continue
+			}
+
+			_, err = db.Exec(ctx, "UPDATE `project_issue` SET project_board_id=?, sorting=? WHERE issue_id=?", column.ID, sorting, issueID)
+			if err != nil {
+				return err
+			}
+
+			// add timeline to issue
+			if _, err := issues_model.CreateComment(ctx, &issues_model.CreateCommentOptions{
+				Type:               issues_model.CommentTypeProjectColumn,
+				Doer:               doer,
+				Repo:               curIssue.Repo,
+				Issue:              curIssue,
+				ProjectID:          column.ProjectID,
+				ProjectTitle:       project.Title,
+				ProjectColumnID:    column.ID,
+				ProjectColumnTitle: column.Title,
+			}); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl
index 804cd6a2f9..1cf9287111 100644
--- a/templates/repo/issue/view_content/comments.tmpl
+++ b/templates/repo/issue/view_content/comments.tmpl
@@ -604,6 +604,22 @@
 					{{end}}
 				</span>
 			</div>
+		{{else if eq .Type 31}}
+			{{if not $.UnitProjectsGlobalDisabled}}
+			<div class="timeline-item event" id="{{.HashTag}}">
+				<span class="badge">{{svg "octicon-project"}}</span>
+				{{template "shared/user/avatarlink" dict "user" .Poster}}
+				<span class="text grey muted-links">
+					{{template "shared/user/authorlink" .Poster}}
+					{{$newProjectDisplay := .CommentMetaData.ProjectTitle}}
+					{{if .Project}}
+						{{$trKey := printf "projects.type-%d.display_name" .Project.Type}}
+						{{$newProjectDisplay = HTMLFormat `%s <a href="%s"><span data-tooltip-content="%s">%s</span></a>` (svg .Project.IconName) (.Project.Link ctx) (ctx.Locale.Tr $trKey) .Project.Title}}
+					{{end}}
+					{{ctx.Locale.Tr "repo.issues.move_to_column_of_project" .CommentMetaData.ProjectColumnTitle $newProjectDisplay $createdStr}}
+				</span>
+			</div>
+			{{end}}
 		{{else if eq .Type 32}}
 			<div class="timeline-item-group">
 				<div class="timeline-item event" id="{{.HashTag}}">