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>
pull/31761/head^2
Lunny Xiao 4 months ago committed by GitHub
parent aa1055fe16
commit 791d7fc76a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 78
      models/issues/comment.go
  2. 1
      models/issues/issue_list.go
  3. 2
      models/migrations/migrations.go
  4. 23
      models/migrations/v1_23/v303.go
  5. 24
      models/project/issue.go
  6. 1
      options/locale/locale_en-US.ini
  7. 3
      routers/web/org/projects.go
  8. 5
      routers/web/repo/issue.go
  9. 3
      routers/web/repo/projects.go
  10. 79
      services/projects/issue.go
  11. 16
      templates/repo/issue/view_content/comments.tmpl

@ -222,6 +222,13 @@ func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
return lang.TrString("repo.issues.role." + string(r) + "_helper") 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. // Comment represents a comment in commit and issue page.
type Comment struct { type Comment struct {
ID int64 `xorm:"pk autoincr"` ID int64 `xorm:"pk autoincr"`
@ -295,6 +302,8 @@ type Comment struct {
RefAction references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves RefAction references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves
RefIsPull bool RefIsPull bool
CommentMetaData *CommentMetaData `xorm:"JSON TEXT"` // put all non-index metadata in a single field
RefRepo *repo_model.Repository `xorm:"-"` RefRepo *repo_model.Repository `xorm:"-"`
RefIssue *Issue `xorm:"-"` RefIssue *Issue `xorm:"-"`
RefComment *Comment `xorm:"-"` RefComment *Comment `xorm:"-"`
@ -797,6 +806,15 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
LabelID = opts.Label.ID LabelID = opts.Label.ID
} }
var commentMetaData *CommentMetaData
if opts.ProjectColumnTitle != "" {
commentMetaData = &CommentMetaData{
ProjectColumnID: opts.ProjectColumnID,
ProjectColumnTitle: opts.ProjectColumnTitle,
ProjectTitle: opts.ProjectTitle,
}
}
comment := &Comment{ comment := &Comment{
Type: opts.Type, Type: opts.Type,
PosterID: opts.Doer.ID, PosterID: opts.Doer.ID,
@ -830,6 +848,7 @@ func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment,
RefIsPull: opts.RefIsPull, RefIsPull: opts.RefIsPull,
IsForcePush: opts.IsForcePush, IsForcePush: opts.IsForcePush,
Invalidated: opts.Invalidated, Invalidated: opts.Invalidated,
CommentMetaData: commentMetaData,
} }
if _, err = e.Insert(comment); err != nil { if _, err = e.Insert(comment); err != nil {
return nil, err return nil, err
@ -982,34 +1001,37 @@ type CreateCommentOptions struct {
Issue *Issue Issue *Issue
Label *Label Label *Label
DependentIssueID int64 DependentIssueID int64
OldMilestoneID int64 OldMilestoneID int64
MilestoneID int64 MilestoneID int64
OldProjectID int64 OldProjectID int64
ProjectID int64 ProjectID int64
TimeID int64 ProjectTitle string
AssigneeID int64 ProjectColumnID int64
AssigneeTeamID int64 ProjectColumnTitle string
RemovedAssignee bool TimeID int64
OldTitle string AssigneeID int64
NewTitle string AssigneeTeamID int64
OldRef string RemovedAssignee bool
NewRef string OldTitle string
CommitID int64 NewTitle string
CommitSHA string OldRef string
Patch string NewRef string
LineNum int64 CommitID int64
TreePath string CommitSHA string
ReviewID int64 Patch string
Content string LineNum int64
Attachments []string // UUIDs of attachments TreePath string
RefRepoID int64 ReviewID int64
RefIssueID int64 Content string
RefCommentID int64 Attachments []string // UUIDs of attachments
RefAction references.XRefAction RefRepoID int64
RefIsPull bool RefIssueID int64
IsForcePush bool RefCommentID int64
Invalidated bool RefAction references.XRefAction
RefIsPull bool
IsForcePush bool
Invalidated bool
} }
// GetCommentByID returns the comment by given ID. // GetCommentByID returns the comment by given ID.

@ -441,6 +441,7 @@ func (issues IssueList) loadComments(ctx context.Context, cond builder.Cond) (er
Join("INNER", "issue", "issue.id = comment.issue_id"). Join("INNER", "issue", "issue.id = comment.issue_id").
In("issue.id", issuesIDs[:limit]). In("issue.id", issuesIDs[:limit]).
Where(cond). Where(cond).
NoAutoCondition().
Rows(new(Comment)) Rows(new(Comment))
if err != nil { if err != nil {
return err return err

@ -597,6 +597,8 @@ var migrations = []Migration{
NewMigration("Add skip_secondary_authorization option to oauth2 application table", v1_23.AddSkipSecondaryAuthColumnToOAuth2ApplicationTable), NewMigration("Add skip_secondary_authorization option to oauth2 application table", v1_23.AddSkipSecondaryAuthColumnToOAuth2ApplicationTable),
// v302 -> v303 // v302 -> v303
NewMigration("Add index to action_task stopped log_expired", v1_23.AddIndexToActionTaskStoppedLogExpired), 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 // GetCurrentDBVersion returns the current db version

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

@ -76,30 +76,6 @@ func (p *Project) NumOpenIssues(ctx context.Context) int {
return int(c) 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 { func (c *Column) moveIssuesToAnotherColumn(ctx context.Context, newColumn *Column) error {
if c.ProjectID != newColumn.ProjectID { if c.ProjectID != newColumn.ProjectID {
return fmt.Errorf("columns have to be in the same project") return fmt.Errorf("columns have to be in the same project")

@ -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_remove_labels = added %s and removed %s labels %s
issues.add_milestone_at = `added this to the <b>%s</b> milestone %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.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_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.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` issues.remove_milestone_at = `removed this from the <b>%s</b> milestone %s`

@ -23,6 +23,7 @@ import (
shared_user "code.gitea.io/gitea/routers/web/shared/user" shared_user "code.gitea.io/gitea/routers/web/shared/user"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
project_service "code.gitea.io/gitea/services/projects"
) )
const ( 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) ctx.ServerError("MoveIssuesOnProjectColumn", err)
return return
} }

@ -1687,6 +1687,11 @@ func ViewIssue(ctx *context.Context) {
if comment.ProjectID > 0 && comment.Project == nil { if comment.ProjectID > 0 && comment.Project == nil {
comment.Project = ghostProject 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 { } else if comment.Type == issues_model.CommentTypeAssignees || comment.Type == issues_model.CommentTypeReviewRequest {
if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil { if err = comment.LoadAssigneeUserAndTeam(ctx); err != nil {
ctx.ServerError("LoadAssigneeUserAndTeam", err) ctx.ServerError("LoadAssigneeUserAndTeam", err)

@ -25,6 +25,7 @@ import (
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
project_service "code.gitea.io/gitea/services/projects"
) )
const ( 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) ctx.ServerError("MoveIssuesOnProjectColumn", err)
return return
} }

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

@ -604,6 +604,22 @@
{{end}} {{end}}
</span> </span>
</div> </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}} {{else if eq .Type 32}}
<div class="timeline-item-group"> <div class="timeline-item-group">
<div class="timeline-item event" id="{{.HashTag}}"> <div class="timeline-item event" id="{{.HashTag}}">

Loading…
Cancel
Save