forgejo/models/issues/comment.go
Earl Warren c7a389f2b2
[FEAT] allow setting the update date on issues and comments
This field adds the possibility to set the update date when modifying
an issue through the API.

A 'NoAutoDate' in-memory field is added in the Issue struct.
If the update_at field is set, NoAutoDate is set to true and the
Issue's UpdatedUnix field is filled.

That information is passed down to the functions that actually updates
the database, which have been modified to not auto update dates if
requested.

A guard is added to the 'EditIssue' API call, to checks that the
udpate_at date is between the issue's creation date and the current
date (to avoid 'malicious' changes). It also limits the new feature
to project's owners and admins.

(cherry picked from commit c524d33402)

Add a SetIssueUpdateDate() function in services/issue.go

That function is used by some API calls to set the NoAutoDate and
UpdatedUnix fields of an Issue if an updated_at date is provided.

(cherry picked from commit f061caa655)

Add an updated_at field to the API calls related to Issue's Labels.

The update date is applied to the issue's comment created to inform
about the modification of the issue's labels.

(cherry picked from commit ea36cf80f5)

Add an updated_at field to the API call for issue's attachment creation

The update date is applied to the issue's comment created to inform
about the modification of the issue's content, and is set as the
asset creation date.

(cherry picked from commit 96150971ca)

Checking Issue changes, with and without providing an updated_at date

Those unit tests are added:

- TestAPIEditIssueWithAutoDate
- TestAPIEditIssueWithNoAutoDate

- TestAPIAddIssueLabelsWithAutoDate
- TestAPIAddIssueLabelsWithNoAutoDate

- TestAPICreateIssueAttachmentWithAutoDate
- TestAPICreateIssueAttachmentWithNoAutoDate

(cherry picked from commit 4926a5d7a2)

Add an updated_at field to the API call for issue's comment creation

The update date is used as the comment creation date, and is applied to
the issue as the update creation date.

(cherry picked from commit 76c8faecdc)

Add an updated_at field to the API call for issue's comment edition

The update date is used as the comment update date, and is applied to
the issue as an update date.

(cherry picked from commit cf787ad7fd)

Add an updated_at field to the API call for comment's attachment creation

The update date is applied to the comment, and is set as the asset
creation date.

(cherry picked from commit 1e4ff424d3)

Checking Comment changes, with and without providing an updated_at date

Those unit tests are added:

- TestAPICreateCommentWithAutoDate
- TestAPICreateCommentWithNoAutoDate

- TestAPIEditCommentWithAutoDate
- TestAPIEditCommentWithNoAutoDate

- TestAPICreateCommentAttachmentWithAutoDate
- TestAPICreateCommentAttachmentWithNoAutoDate

(cherry picked from commit da932152f1)

Pettier code to set the update time of comments

Now uses sess.AllCols().NoAutoToime().SetExpr("updated_unix", ...)

XORM is smart enough to compose one single SQL UPDATE which all
columns + updated_unix.

(cherry picked from commit 1f6a42808d)

Issue edition: Keep the max of the milestone and issue update dates.

When editing an issue via the API, an updated_at date can be provided.
If the EditIssue call changes the issue's milestone, the milestone's
update date is to be changed accordingly, but only with a greater
value.

This ensures that a milestone's update date is the max of all issue's
update dates.

(cherry picked from commit 8f22ea182e)

Rewrite the 'AutoDate' tests using subtests

Also add a test to check the permissions to set a date, and a test
to check update dates on milestones.

The tests related to 'AutoDate' are:
- TestAPIEditIssueAutoDate
- TestAPIAddIssueLabelsAutoDate
- TestAPIEditIssueMilestoneAutoDate
- TestAPICreateIssueAttachmentAutoDate
- TestAPICreateCommentAutoDate
- TestAPIEditCommentWithDate
- TestAPICreateCommentAttachmentAutoDate

(cherry picked from commit 961fd13c55)
(cherry picked from commit d52f4eea44)
(cherry picked from commit 3540ea2a43)

Conflicts:
	services/issue/issue.go
	https://codeberg.org/forgejo/forgejo/pulls/1415
(cherry picked from commit 56720ade00)

Conflicts:
	routers/api/v1/repo/issue_label.go
	https://codeberg.org/forgejo/forgejo/pulls/1462
(cherry picked from commit 47c78927d6)
(cherry picked from commit 2030f3b965)
(cherry picked from commit f02aeb7698)

Conflicts:
	routers/api/v1/repo/issue_attachment.go
	routers/api/v1/repo/issue_comment_attachment.go
	https://codeberg.org/forgejo/forgejo/pulls/1575
(cherry picked from commit d072525b35)
(cherry picked from commit 8424d0ab3d)
(cherry picked from commit 5cc62caec7)
(cherry picked from commit d6300d5dcd)

[FEAT] allow setting the update date on issues and comments (squash) apply the 'update_at' value to the cross-ref comments (#1676)

[this is a follow-up to PR #764]

When a comment of issue A referencing issue B is added with a forced 'updated_at' date, that date has to be applied to the comment created in issue B.

-----

Comment:

While trying my 'RoundUp migration script', I found that this case was forgotten in PR #764 - my apologies...

I'll try to write a functional test, base on models/issues/issue_xref_test.go

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/1676
Co-authored-by: fluzz <fluzz@freedroid.org>
Co-committed-by: fluzz <fluzz@freedroid.org>
(cherry picked from commit ac4f727f63)
(cherry picked from commit 5110476ee9)
(cherry picked from commit 77ba6be1da)
(cherry picked from commit 9c8337b5c4)
(cherry picked from commit 1d689eb686)
(cherry picked from commit 511c519c87)
(cherry picked from commit 2f0b4a8f61)
(cherry picked from commit fdd4da111c)

[FEAT] allow setting the update date on issues and comments (squash) do not use token= query param

See https://codeberg.org/forgejo/forgejo/commit/33439b733a

(cherry picked from commit c5139a75b9)
(cherry picked from commit c7b572c35d)
(cherry picked from commit aec7503ff6)
(cherry picked from commit 87c65f2a49)
(cherry picked from commit bd47ee33c2)
(cherry picked from commit f3dbd90a74)
2024-02-05 14:44:33 +01:00

1314 lines
37 KiB
Go

// Copyright 2018 The Gitea Authors.
// Copyright 2016 The Gogs Authors.
// All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"context"
"fmt"
"strconv"
"unicode/utf8"
"code.gitea.io/gitea/models/db"
git_model "code.gitea.io/gitea/models/git"
"code.gitea.io/gitea/models/organization"
project_model "code.gitea.io/gitea/models/project"
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/gitrepo"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/references"
"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
IssueID 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, issue_id: %d]", err.ID, err.IssueID)
}
func (err ErrCommentNotExist) Unwrap() error {
return util.ErrNotExist
}
// 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)
CommentTypeReopen // 1
CommentTypeClose // 2
CommentTypeIssueRef // 3 References.
CommentTypeCommitRef // 4 Reference from a commit (not part of a pull request)
CommentTypeCommentRef // 5 Reference from a comment
CommentTypePullRef // 6 Reference from a pull request
CommentTypeLabel // 7 Labels changed
CommentTypeMilestone // 8 Milestone changed
CommentTypeAssignees // 9 Assignees changed
CommentTypeChangeTitle // 10 Change Title
CommentTypeDeleteBranch // 11 Delete Branch
CommentTypeStartTracking // 12 Start a stopwatch for time tracking
CommentTypeStopTracking // 13 Stop a stopwatch for time tracking
CommentTypeAddTimeManual // 14 Add time manual for time tracking
CommentTypeCancelTracking // 15 Cancel a stopwatch for time tracking
CommentTypeAddedDeadline // 16 Added a due date
CommentTypeModifiedDeadline // 17 Modified the due date
CommentTypeRemovedDeadline // 18 Removed a due date
CommentTypeAddDependency // 19 Dependency added
CommentTypeRemoveDependency // 20 Dependency removed
CommentTypeCode // 21 Comment a line of code
CommentTypeReview // 22 Reviews a pull request by giving general feedback
CommentTypeLock // 23 Lock an issue, giving only collaborators access
CommentTypeUnlock // 24 Unlocks a previously locked issue
CommentTypeChangeTargetBranch // 25 Change pull request's target branch
CommentTypeDeleteTimeManual // 26 Delete time manual for time tracking
CommentTypeReviewRequest // 27 add or remove Request from one
CommentTypeMergePull // 28 merge pull request
CommentTypePullRequestPush // 29 push to PR head branch
CommentTypeProject // 30 Project changed
CommentTypeProjectBoard // 31 Project board changed
CommentTypeDismissReview // 32 Dismiss Review
CommentTypeChangeIssueRef // 33 Change issue ref
CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed
CommentTypePRUnScheduledToAutoMerge // 35 pr was un scheduled to auto merge when checks succeed
CommentTypePin // 36 pin Issue
CommentTypeUnpin // 37 unpin Issue
)
var commentStrings = []string{
"comment",
"reopen",
"close",
"issue_ref",
"commit_ref",
"comment_ref",
"pull_ref",
"label",
"milestone",
"assignees",
"change_title",
"delete_branch",
"start_tracking",
"stop_tracking",
"add_time_manual",
"cancel_tracking",
"added_deadline",
"modified_deadline",
"removed_deadline",
"add_dependency",
"remove_dependency",
"code",
"review",
"lock",
"unlock",
"change_target_branch",
"delete_time_manual",
"review_request",
"merge_pull",
"pull_push",
"project",
"project_board",
"dismiss_review",
"change_issue_ref",
"pull_scheduled_merge",
"pull_cancel_scheduled_merge",
"pin",
"unpin",
}
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, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview:
return true
}
return false
}
func (t CommentType) HasAttachmentSupport() bool {
switch t {
case CommentTypeComment, CommentTypeCode, CommentTypeReview:
return true
}
return false
}
func (t CommentType) HasMailReplySupport() bool {
switch t {
case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview, CommentTypeReopen, CommentTypeClose, CommentTypeMergePull, CommentTypeAssignees:
return true
}
return false
}
// 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.Tr("repo.issues.role." + string(r))
}
// LocaleHelper returns the locale tooltip of the role
func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
return lang.Tr("repo.issues.role." + string(r) + "_helper")
}
// Comment represents a comment in commit and issue page.
type Comment struct {
ID int64 `xorm:"pk autoincr"`
Type CommentType `xorm:"INDEX"`
PosterID int64 `xorm:"INDEX"`
Poster *user_model.User `xorm:"-"`
OriginalAuthor string
OriginalAuthorID int64
IssueID int64 `xorm:"INDEX"`
Issue *Issue `xorm:"-"`
LabelID int64
Label *Label `xorm:"-"`
AddedLabels []*Label `xorm:"-"`
RemovedLabels []*Label `xorm:"-"`
OldProjectID int64
ProjectID int64
OldProject *project_model.Project `xorm:"-"`
Project *project_model.Project `xorm:"-"`
OldMilestoneID int64
MilestoneID int64
OldMilestone *Milestone `xorm:"-"`
Milestone *Milestone `xorm:"-"`
TimeID int64
Time *TrackedTime `xorm:"-"`
AssigneeID int64
RemovedAssignee bool
Assignee *user_model.User `xorm:"-"`
AssigneeTeamID int64 `xorm:"NOT NULL DEFAULT 0"`
AssigneeTeam *organization.Team `xorm:"-"`
ResolveDoerID int64
ResolveDoer *user_model.User `xorm:"-"`
OldTitle string
NewTitle string
OldRef string
NewRef string
DependentIssueID int64 `xorm:"index"` // This is used by issue_service.deleteIssue
DependentIssue *Issue `xorm:"-"`
CommitID int64
Line int64 // - previous line / + proposed line
TreePath string
Content string `xorm:"LONGTEXT"`
RenderedContent string `xorm:"-"`
// Path represents the 4 lines of code cemented by this comment
Patch string `xorm:"-"`
PatchQuoted string `xorm:"LONGTEXT patch"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
// Reference issue in commit message
CommitSHA string `xorm:"VARCHAR(64)"`
Attachments []*repo_model.Attachment `xorm:"-"`
Reactions ReactionList `xorm:"-"`
// For view issue page.
ShowRole RoleDescriptor `xorm:"-"`
Review *Review `xorm:"-"`
ReviewID int64 `xorm:"index"`
Invalidated bool
// Reference an issue or pull from another comment, issue or PR
// All information is about the origin of the reference
RefRepoID int64 `xorm:"index"` // Repo where the referencing
RefIssueID int64 `xorm:"index"`
RefCommentID int64 `xorm:"index"` // 0 if origin is Issue title or content (or PR's)
RefAction references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves
RefIsPull bool
RefRepo *repo_model.Repository `xorm:"-"`
RefIssue *Issue `xorm:"-"`
RefComment *Comment `xorm:"-"`
Commits []*git_model.SignCommitWithStatuses `xorm:"-"`
OldCommit string `xorm:"-"`
NewCommit string `xorm:"-"`
CommitsNum int64 `xorm:"-"`
IsForcePush bool `xorm:"-"`
}
func init() {
db.RegisterModel(new(Comment))
}
// PushActionContent is content of push pull comment
type PushActionContent struct {
IsForcePush bool `json:"is_force_push"`
CommitIDs []string `json:"commit_ids"`
}
// LoadIssue loads the issue reference for the comment
func (c *Comment) LoadIssue(ctx context.Context) (err error) {
if c.Issue != nil {
return nil
}
c.Issue, err = GetIssueByID(ctx, c.IssueID)
return err
}
// BeforeInsert will be invoked by XORM before inserting a record
func (c *Comment) BeforeInsert() {
c.PatchQuoted = c.Patch
if !utf8.ValidString(c.Patch) {
c.PatchQuoted = strconv.Quote(c.Patch)
}
}
// BeforeUpdate will be invoked by XORM before updating a record
func (c *Comment) BeforeUpdate() {
c.PatchQuoted = c.Patch
if !utf8.ValidString(c.Patch) {
c.PatchQuoted = strconv.Quote(c.Patch)
}
}
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
func (c *Comment) AfterLoad() {
c.Patch = c.PatchQuoted
if len(c.PatchQuoted) > 0 && c.PatchQuoted[0] == '"' {
unquoted, err := strconv.Unquote(c.PatchQuoted)
if err == nil {
c.Patch = unquoted
}
}
}
// LoadPoster loads comment poster
func (c *Comment) LoadPoster(ctx context.Context) (err error) {
if c.Poster != nil {
return nil
}
c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
c.PosterID = user_model.GhostUserID
c.Poster = user_model.NewGhostUser()
} else {
log.Error("getUserByID[%d]: %v", c.ID, err)
}
}
return err
}
// AfterDelete is invoked from XORM after the object is deleted.
func (c *Comment) AfterDelete(ctx context.Context) {
if c.ID <= 0 {
return
}
_, err := repo_model.DeleteAttachmentsByComment(ctx, c.ID, true)
if err != nil {
log.Info("Could not delete files for comment %d on issue #%d: %s", c.ID, c.IssueID, err)
}
}
// HTMLURL formats a URL-string to the issue-comment
func (c *Comment) HTMLURL(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
return ""
}
err = c.Issue.LoadRepo(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
return ""
}
return c.Issue.HTMLURL() + c.hashLink(ctx)
}
// Link formats a relative URL-string to the issue-comment
func (c *Comment) Link(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
return ""
}
err = c.Issue.LoadRepo(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
return ""
}
return c.Issue.Link() + c.hashLink(ctx)
}
func (c *Comment) hashLink(ctx context.Context) string {
if c.Type == CommentTypeCode {
if c.ReviewID == 0 {
return "/files#" + c.HashTag()
}
if c.Review == nil {
if err := c.LoadReview(ctx); err != nil {
log.Warn("LoadReview(%d): %v", c.ReviewID, err)
return "/files#" + c.HashTag()
}
}
if c.Review.Type <= ReviewTypePending {
return "/files#" + c.HashTag()
}
}
return "#" + c.HashTag()
}
// APIURL formats a API-string to the issue-comment
func (c *Comment) APIURL(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
return ""
}
err = c.Issue.LoadRepo(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
return ""
}
return fmt.Sprintf("%s/issues/comments/%d", c.Issue.Repo.APIURL(), c.ID)
}
// IssueURL formats a URL-string to the issue
func (c *Comment) IssueURL(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
return ""
}
if c.Issue.IsPull {
return ""
}
err = c.Issue.LoadRepo(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
return ""
}
return c.Issue.HTMLURL()
}
// PRURL formats a URL-string to the pull-request
func (c *Comment) PRURL(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
return ""
}
err = c.Issue.LoadRepo(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
return ""
}
if !c.Issue.IsPull {
return ""
}
return c.Issue.HTMLURL()
}
// CommentHashTag returns unique hash tag for comment id.
func CommentHashTag(id int64) string {
return fmt.Sprintf("issuecomment-%d", id)
}
// HashTag returns unique hash tag for comment.
func (c *Comment) HashTag() string {
return CommentHashTag(c.ID)
}
// EventTag returns unique event hash tag for comment.
func (c *Comment) EventTag() string {
return fmt.Sprintf("event-%d", c.ID)
}
// LoadLabel if comment.Type is CommentTypeLabel, then load Label
func (c *Comment) LoadLabel(ctx context.Context) error {
var label Label
has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label)
if err != nil {
return err
} else if has {
c.Label = &label
} else {
// Ignore Label is deleted, but not clear this table
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID)
}
return nil
}
// LoadProject if comment.Type is CommentTypeProject, then load project.
func (c *Comment) LoadProject(ctx context.Context) error {
if c.OldProjectID > 0 {
var oldProject project_model.Project
has, err := db.GetEngine(ctx).ID(c.OldProjectID).Get(&oldProject)
if err != nil {
return err
} else if has {
c.OldProject = &oldProject
}
}
if c.ProjectID > 0 {
var project project_model.Project
has, err := db.GetEngine(ctx).ID(c.ProjectID).Get(&project)
if err != nil {
return err
} else if has {
c.Project = &project
}
}
return nil
}
// LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone
func (c *Comment) LoadMilestone(ctx context.Context) error {
if c.OldMilestoneID > 0 {
var oldMilestone Milestone
has, err := db.GetEngine(ctx).ID(c.OldMilestoneID).Get(&oldMilestone)
if err != nil {
return err
} else if has {
c.OldMilestone = &oldMilestone
}
}
if c.MilestoneID > 0 {
var milestone Milestone
has, err := db.GetEngine(ctx).ID(c.MilestoneID).Get(&milestone)
if err != nil {
return err
} else if has {
c.Milestone = &milestone
}
}
return nil
}
// LoadAttachments loads attachments (it never returns error, the error during `GetAttachmentsByCommentIDCtx` is ignored)
func (c *Comment) LoadAttachments(ctx context.Context) error {
if len(c.Attachments) > 0 {
return nil
}
var err error
c.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, c.ID)
if err != nil {
log.Error("getAttachmentsByCommentID[%d]: %v", c.ID, err)
}
return nil
}
// UpdateAttachments update attachments by UUIDs for the comment
func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids)
if err != nil {
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err)
}
for i := 0; i < len(attachments); i++ {
attachments[i].IssueID = c.IssueID
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()
}
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees
func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error {
var err error
if c.AssigneeID > 0 && c.Assignee == nil {
c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID)
if err != nil {
if !user_model.IsErrUserNotExist(err) {
return err
}
c.Assignee = user_model.NewGhostUser()
}
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil {
if err = c.LoadIssue(ctx); err != nil {
return err
}
if err = c.Issue.LoadRepo(ctx); err != nil {
return err
}
if err = c.Issue.Repo.LoadOwner(ctx); err != nil {
return err
}
if c.Issue.Repo.Owner.IsOrganization() {
c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID)
if err != nil && !organization.IsErrTeamNotExist(err) {
return err
}
}
}
return nil
}
// LoadResolveDoer if comment.Type is CommentTypeCode and ResolveDoerID not zero, then load resolveDoer
func (c *Comment) LoadResolveDoer(ctx context.Context) (err error) {
if c.ResolveDoerID == 0 || c.Type != CommentTypeCode {
return nil
}
c.ResolveDoer, err = user_model.GetUserByID(ctx, c.ResolveDoerID)
if err != nil {
if user_model.IsErrUserNotExist(err) {
c.ResolveDoer = user_model.NewGhostUser()
err = nil
}
}
return err
}
// IsResolved check if an code comment is resolved
func (c *Comment) IsResolved() bool {
return c.ResolveDoerID != 0 && c.Type == CommentTypeCode
}
// LoadDepIssueDetails loads Dependent Issue Details
func (c *Comment) LoadDepIssueDetails(ctx context.Context) (err error) {
if c.DependentIssueID <= 0 || c.DependentIssue != nil {
return nil
}
c.DependentIssue, err = GetIssueByID(ctx, c.DependentIssueID)
return err
}
// LoadTime loads the associated time for a CommentTypeAddTimeManual
func (c *Comment) LoadTime(ctx context.Context) error {
if c.Time != nil || c.TimeID == 0 {
return nil
}
var err error
c.Time, err = GetTrackedTimeByID(ctx, c.TimeID)
return err
}
func (c *Comment) loadReactions(ctx context.Context, repo *repo_model.Repository) (err error) {
if c.Reactions != nil {
return nil
}
c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{
IssueID: c.IssueID,
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
}
// LoadReactions loads comment reactions
func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository) error {
return c.loadReactions(ctx, repo)
}
func (c *Comment) loadReview(ctx context.Context) (err error) {
if c.Review == nil {
if c.Review, err = GetReviewByID(ctx, c.ReviewID); err != nil {
return err
}
}
c.Review.Issue = c.Issue
return nil
}
// LoadReview loads the associated review
func (c *Comment) LoadReview(ctx context.Context) error {
return c.loadReview(ctx)
}
// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes.
func (c *Comment) DiffSide() string {
if c.Line < 0 {
return "previous"
}
return "proposed"
}
// UnsignedLine returns the LOC of the code comment without + or -
func (c *Comment) UnsignedLine() uint64 {
if c.Line < 0 {
return uint64(c.Line * -1)
}
return uint64(c.Line)
}
// CodeCommentLink returns the url to a comment in code
func (c *Comment) CodeCommentLink(ctx context.Context) string {
err := c.LoadIssue(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("LoadIssue(%d): %v", c.IssueID, err)
return ""
}
err = c.Issue.LoadRepo(ctx)
if err != nil { // Silently dropping errors :unamused:
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err)
return ""
}
return fmt.Sprintf("%s/files#%s", c.Issue.Link(), c.HashTag())
}
// LoadPushCommits Load push commits
func (c *Comment) LoadPushCommits(ctx context.Context) (err error) {
if c.Content == "" || c.Commits != nil || c.Type != CommentTypePullRequestPush {
return nil
}
var data PushActionContent
err = json.Unmarshal([]byte(c.Content), &data)
if err != nil {
return err
}
c.IsForcePush = data.IsForcePush
if c.IsForcePush {
if len(data.CommitIDs) != 2 {
return nil
}
c.OldCommit = data.CommitIDs[0]
c.NewCommit = data.CommitIDs[1]
} else {
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, c.Issue.Repo)
if err != nil {
return err
}
defer closer.Close()
c.Commits = git_model.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo)
c.CommitsNum = int64(len(c.Commits))
}
return err
}
// CreateComment creates comment with context
func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return nil, err
}
defer committer.Close()
e := db.GetEngine(ctx)
var LabelID int64
if opts.Label != nil {
LabelID = opts.Label.ID
}
comment := &Comment{
Type: opts.Type,
PosterID: opts.Doer.ID,
Poster: opts.Doer,
IssueID: opts.Issue.ID,
LabelID: LabelID,
OldMilestoneID: opts.OldMilestoneID,
MilestoneID: opts.MilestoneID,
OldProjectID: opts.OldProjectID,
ProjectID: opts.ProjectID,
TimeID: opts.TimeID,
RemovedAssignee: opts.RemovedAssignee,
AssigneeID: opts.AssigneeID,
AssigneeTeamID: opts.AssigneeTeamID,
CommitID: opts.CommitID,
CommitSHA: opts.CommitSHA,
Line: opts.LineNum,
Content: opts.Content,
OldTitle: opts.OldTitle,
NewTitle: opts.NewTitle,
OldRef: opts.OldRef,
NewRef: opts.NewRef,
DependentIssueID: opts.DependentIssueID,
TreePath: opts.TreePath,
ReviewID: opts.ReviewID,
Patch: opts.Patch,
RefRepoID: opts.RefRepoID,
RefIssueID: opts.RefIssueID,
RefCommentID: opts.RefCommentID,
RefAction: opts.RefAction,
RefIsPull: opts.RefIsPull,
IsForcePush: opts.IsForcePush,
Invalidated: opts.Invalidated,
}
if opts.Issue.NoAutoTime {
// Preload the comment with the Issue containing the forced update
// date. This is needed to propagate those data in AddCrossReferences()
comment.Issue = opts.Issue
comment.CreatedUnix = opts.Issue.UpdatedUnix
comment.UpdatedUnix = opts.Issue.UpdatedUnix
e.NoAutoTime()
}
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, comment); err != nil {
return nil, err
}
if err = comment.AddCrossReferences(ctx, opts.Doer, false); err != nil {
return nil, err
}
if err = committer.Commit(); err != nil {
return nil, err
}
return comment, nil
}
func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment *Comment) (err error) {
// Check comment type.
switch opts.Type {
case CommentTypeCode:
if comment.ReviewID != 0 {
if comment.Review == nil {
if err := comment.loadReview(ctx); err != nil {
return err
}
}
if comment.Review.Type <= ReviewTypePending {
return nil
}
}
fallthrough
case CommentTypeComment:
if _, err = db.Exec(ctx, "UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil {
return err
}
fallthrough
case CommentTypeReview:
// Check attachments
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].IssueID = opts.Issue.ID
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
case CommentTypeReopen, CommentTypeClose:
if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil {
return err
}
}
// update the issue's updated_unix column
return UpdateIssueCols(ctx, opts.Issue, "updated_unix")
}
func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) {
var content string
var commentType CommentType
// newDeadline = 0 means deleting
if newDeadlineUnix == 0 {
commentType = CommentTypeRemovedDeadline
content = issue.DeadlineUnix.FormatDate()
} else if issue.DeadlineUnix == 0 {
// Check if the new date was added or modified
// If the actual deadline is 0 => deadline added
commentType = CommentTypeAddedDeadline
content = newDeadlineUnix.FormatDate()
} else { // Otherwise modified
commentType = CommentTypeModifiedDeadline
content = newDeadlineUnix.FormatDate() + "|" + issue.DeadlineUnix.FormatDate()
}
if err := issue.LoadRepo(ctx); err != nil {
return nil, err
}
opts := &CreateCommentOptions{
Type: commentType,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
Content: content,
}
comment, err := CreateComment(ctx, opts)
if err != nil {
return nil, err
}
return comment, nil
}
// Creates issue dependency comment
func createIssueDependencyComment(ctx context.Context, doer *user_model.User, issue, dependentIssue *Issue, add bool) (err error) {
cType := CommentTypeAddDependency
if !add {
cType = CommentTypeRemoveDependency
}
if err = issue.LoadRepo(ctx); err != nil {
return err
}
// Make two comments, one in each issue
opts := &CreateCommentOptions{
Type: cType,
Doer: doer,
Repo: issue.Repo,
Issue: issue,
DependentIssueID: dependentIssue.ID,
}
if _, err = CreateComment(ctx, opts); err != nil {
return err
}
opts = &CreateCommentOptions{
Type: cType,
Doer: doer,
Repo: issue.Repo,
Issue: dependentIssue,
DependentIssueID: issue.ID,
}
_, err = CreateComment(ctx, opts)
return err
}
// CreateCommentOptions defines options for creating comment
type CreateCommentOptions struct {
Type CommentType
Doer *user_model.User
Repo *repo_model.Repository
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
}
// GetCommentByID returns the comment by given ID.
func GetCommentByID(ctx context.Context, id int64) (*Comment, error) {
c := new(Comment)
has, err := db.GetEngine(ctx).ID(id).Get(c)
if err != nil {
return nil, err
} else if !has {
return nil, ErrCommentNotExist{id, 0}
}
return c, nil
}
// FindCommentsOptions describes the conditions to Find comments
type FindCommentsOptions struct {
db.ListOptions
RepoID int64
IssueID int64
ReviewID int64
Since int64
Before int64
Line int64
TreePath string
Type CommentType
IssueIDs []int64
Invalidated util.OptionalBool
IsPull util.OptionalBool
}
// ToConds implements FindOptions interface
func (opts FindCommentsOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID})
}
if opts.IssueID > 0 {
cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID})
} else if len(opts.IssueIDs) > 0 {
cond = cond.And(builder.In("comment.issue_id", opts.IssueIDs))
}
if opts.ReviewID > 0 {
cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID})
}
if opts.Since > 0 {
cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since})
}
if opts.Before > 0 {
cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before})
}
if opts.Type != CommentTypeUndefined {
cond = cond.And(builder.Eq{"comment.type": opts.Type})
}
if opts.Line != 0 {
cond = cond.And(builder.Eq{"comment.line": opts.Line})
}
if len(opts.TreePath) > 0 {
cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath})
}
if !opts.Invalidated.IsNone() {
cond = cond.And(builder.Eq{"comment.invalidated": opts.Invalidated.IsTrue()})
}
if opts.IsPull != util.OptionalBoolNone {
cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull.IsTrue()})
}
return cond
}
// FindComments returns all comments according options
func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) {
comments := make([]*Comment, 0, 10)
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.RepoID > 0 || opts.IsPull != util.OptionalBoolNone {
sess.Join("INNER", "issue", "issue.id = comment.issue_id")
}
if opts.Page != 0 {
sess = db.SetSessionPagination(sess, opts)
}
// WARNING: If you change this order you will need to fix createCodeComment
return comments, sess.
Asc("comment.created_unix").
Asc("comment.id").
Find(&comments)
}
// CountComments count all comments according options by ignoring pagination
func CountComments(ctx context.Context, opts *FindCommentsOptions) (int64, error) {
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.RepoID > 0 {
sess.Join("INNER", "issue", "issue.id = comment.issue_id")
}
return sess.Count(&Comment{})
}
// UpdateCommentInvalidate updates comment invalidated column
func UpdateCommentInvalidate(ctx context.Context, c *Comment) error {
_, err := db.GetEngine(ctx).ID(c.ID).Cols("invalidated").Update(c)
return err
}
// UpdateComment updates information of comment.
func UpdateComment(ctx context.Context, c *Comment, doer *user_model.User) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
if err := c.LoadIssue(ctx); err != nil {
return err
}
sess := db.GetEngine(ctx).ID(c.ID).AllCols()
if c.Issue.NoAutoTime {
// update the DataBase
sess = sess.NoAutoTime().SetExpr("updated_unix", c.Issue.UpdatedUnix)
// the UpdatedUnix value of the Comment also has to be set,
// to return the adequate value
// see https://codeberg.org/forgejo/forgejo/pulls/764#issuecomment-1023801
c.UpdatedUnix = c.Issue.UpdatedUnix
}
if _, err := sess.Update(c); err != nil {
return err
}
if err := c.AddCrossReferences(ctx, doer, true); err != nil {
return err
}
if err := committer.Commit(); err != nil {
return fmt.Errorf("Commit: %w", err)
}
return nil
}
// DeleteComment deletes the comment
func DeleteComment(ctx context.Context, comment *Comment) error {
e := db.GetEngine(ctx)
if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil {
return err
}
if _, err := db.DeleteByBean(ctx, &ContentHistory{
CommentID: comment.ID,
}); err != nil {
return err
}
if comment.Type == CommentTypeComment {
if _, err := e.ID(comment.IssueID).Decr("num_comments").Update(new(Issue)); 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
}
if err := comment.neuterCrossReferences(ctx); err != nil {
return err
}
return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID})
}
// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id
func UpdateCommentsMigrationsByType(ctx context.Context, tp structs.GitServiceType, originalAuthorID string, posterID int64) error {
_, err := db.GetEngine(ctx).Table("comment").
Join("INNER", "issue", "issue.id = comment.issue_id").
Join("INNER", "repository", "issue.repo_id = repository.id").
Where("repository.original_service_type = ?", tp).
And("comment.original_author_id = ?", originalAuthorID).
Update(map[string]any{
"poster_id": posterID,
"original_author": "",
"original_author_id": 0,
})
return err
}
// CreateAutoMergeComment is a internal function, only use it for CommentTypePRScheduledToAutoMerge and CommentTypePRUnScheduledToAutoMerge CommentTypes
func CreateAutoMergeComment(ctx context.Context, typ CommentType, pr *PullRequest, doer *user_model.User) (comment *Comment, err error) {
if typ != CommentTypePRScheduledToAutoMerge && typ != CommentTypePRUnScheduledToAutoMerge {
return nil, fmt.Errorf("comment type %d cannot be used to create an auto merge comment", typ)
}
if err = pr.LoadIssue(ctx); err != nil {
return nil, err
}
if err = pr.LoadBaseRepo(ctx); err != nil {
return nil, err
}
comment, err = CreateComment(ctx, &CreateCommentOptions{
Type: typ,
Doer: doer,
Repo: pr.BaseRepo,
Issue: pr.Issue,
})
return comment, err
}
// RemapExternalUser ExternalUserRemappable interface
func (c *Comment) RemapExternalUser(externalName string, externalID, userID int64) error {
c.OriginalAuthor = externalName
c.OriginalAuthorID = externalID
c.PosterID = userID
return nil
}
// GetUserID ExternalUserRemappable interface
func (c *Comment) GetUserID() int64 { return c.PosterID }
// GetExternalName ExternalUserRemappable interface
func (c *Comment) GetExternalName() string { return c.OriginalAuthor }
// GetExternalID ExternalUserRemappable interface
func (c *Comment) GetExternalID() int64 { return c.OriginalAuthorID }
// CountCommentTypeLabelWithEmptyLabel count label comments with empty label
func CountCommentTypeLabelWithEmptyLabel(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where(builder.Eq{"type": CommentTypeLabel, "label_id": 0}).Count(new(Comment))
}
// FixCommentTypeLabelWithEmptyLabel count label comments with empty label
func FixCommentTypeLabelWithEmptyLabel(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where(builder.Eq{"type": CommentTypeLabel, "label_id": 0}).Delete(new(Comment))
}
// CountCommentTypeLabelWithOutsideLabels count label comments with outside label
func CountCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) {
return db.GetEngine(ctx).Where("comment.type = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id))", CommentTypeLabel).
Table("comment").
Join("inner", "label", "label.id = comment.label_id").
Join("inner", "issue", "issue.id = comment.issue_id ").
Join("inner", "repository", "issue.repo_id = repository.id").
Count()
}
// FixCommentTypeLabelWithOutsideLabels count label comments with outside label
func FixCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) {
res, err := db.GetEngine(ctx).Exec(`DELETE FROM comment WHERE comment.id IN (
SELECT il_too.id FROM (
SELECT com.id
FROM comment AS com
INNER JOIN label ON com.label_id = label.id
INNER JOIN issue on issue.id = com.issue_id
INNER JOIN repository ON issue.repo_id = repository.id
WHERE
com.type = ? AND ((label.org_id = 0 AND issue.repo_id != label.repo_id) OR (label.repo_id = 0 AND label.org_id != repository.owner_id))
) AS il_too)`, CommentTypeLabel)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// HasOriginalAuthor returns if a comment was migrated and has an original author.
func (c *Comment) HasOriginalAuthor() bool {
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
}
// InsertIssueComments inserts many comments of issues.
func InsertIssueComments(ctx context.Context, comments []*Comment) error {
if len(comments) == 0 {
return nil
}
issueIDs := make(container.Set[int64])
for _, comment := range comments {
issueIDs.Add(comment.IssueID)
}
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.IssueID = comment.IssueID
reaction.CommentID = comment.ID
}
if len(comment.Reactions) > 0 {
if err := db.Insert(ctx, comment.Reactions); err != nil {
return err
}
}
}
for issueID := range issueIDs {
if _, err := db.Exec(ctx, "UPDATE issue set num_comments = (SELECT count(*) FROM comment WHERE issue_id = ? AND `type`=?) WHERE id = ?",
issueID, CommentTypeComment, issueID); err != nil {
return err
}
}
return committer.Commit()
}