Files
steven.guiheuxandMathieu Fenniak 3dc2b52b5f fix: multiline comment invalidation (#12950)
Found issues during the process of invalidation of a multiline comment (link to #12582):
* Update a line in the middle of the comment
* Update/Delete the last line of the comment

No problem with:
* Deleting a line in the middle of the comment
* Update/Delete the first line of the comment

I added all these cases in the pull_review_test.go

### Tests for Go changes

- I added test coverage for Go changes...
  - [X] in their respective `*_test.go` for unit tests.
- I ran...
  - [X] `make pr-go` before pushing

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12950
Reviewed-by: Mathieu Fenniak <mfenniak@noreply.codeberg.org>
2026-06-09 03:07:56 +02:00

1581 lines
47 KiB
Go

// Copyright 2016 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package issues
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"slices"
"strconv"
"strings"
"unicode/utf8"
"forgejo.org/models/db"
git_model "forgejo.org/models/git"
"forgejo.org/models/organization"
project_model "forgejo.org/models/project"
repo_model "forgejo.org/models/repo"
user_model "forgejo.org/models/user"
"forgejo.org/modules/cache"
"forgejo.org/modules/container"
"forgejo.org/modules/git"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/json"
"forgejo.org/modules/log"
"forgejo.org/modules/optional"
"forgejo.org/modules/references"
"forgejo.org/modules/structs"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/translation"
"forgejo.org/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
}
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)
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
CommentTypeProjectColumn // 31 Project column 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
CommentTypeAggregator // 38 Aggregator of comments
)
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", // FIXME: the name should be project_column
"dismiss_review",
"change_issue_ref",
"pull_scheduled_merge",
"pull_cancel_scheduled_merge",
"pin",
"unpin",
"action_aggregator",
}
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
}
func (t CommentType) CountedAsConversation() bool {
return slices.Contains(ConversationCountedCommentType(), t)
}
// ConversationCountedCommentType returns the comment types that are counted as a conversation
func ConversationCountedCommentType() []CommentType {
return []CommentType{CommentTypeComment, CommentTypeReview}
}
// 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.issues.role." + string(r))
}
// LocaleHelper returns the locale tooltip of the role
func (r RoleInRepo) LocaleHelper(lang translation.Locale) string {
return lang.TrString("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:"-"`
Aggregator *ActionAggregator `xorm:"-"`
AddedLabels []*Label `xorm:"-"`
RemovedLabels []*Label `xorm:"-"`
AddedRequestReview []RequestReviewTarget `xorm:"-"`
RemovedRequestReview []RequestReviewTarget `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
ExtraLinesCount int64 `xorm:"NOT NULL DEFAULT 0"` // number of additional lines after Line (0 = single line)
TreePath string
Content string `xorm:"LONGTEXT"`
ContentVersion int `xorm:"NOT NULL DEFAULT 0"`
RenderedContent template.HTML `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:"-"`
reverseLineBlame *git.ReverseLineBlame `xorm:"-"`
// If you add new fields that might be used to store abusive content (mainly string fields),
// please also add them in the CommentData struct and the corresponding constructor.
}
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()
if err := c.LoadIssue(ctx); err != nil {
return fmt.Errorf("LoadIssue: %w", err)
}
attachments, err := repo_model.FindRepoAttachmentsByUUID(ctx, c.Issue.RepoID, uuids, repo_model.FindAttachmentOptions{})
if err != nil {
return fmt.Errorf("FindRepoAttachmentsByUUID[uuids=%q,repoID=%d]: %w", uuids, c.Issue.RepoID, err)
}
for i := range attachments {
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 {
if !organization.IsErrTeamNotExist(err) {
return err
}
c.AssigneeTeam = organization.NewGhostTeam()
}
}
}
return nil
}
// 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
}
// LoadReactions loads comment reactions
func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository) (err error) {
if c.Reactions != nil {
return nil
}
c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{
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
}
func (c *Comment) loadReview(ctx context.Context) (err error) {
if c.ReviewID == 0 {
return nil
}
if c.Review == nil {
if c.Review, err = GetReviewByID(ctx, c.ReviewID); err != nil {
// review request which has been replaced by actual reviews doesn't exist in database anymore, so ignorem them.
if c.Type == CommentTypeReviewRequest {
return 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)
}
// DisplayLine returns the signed line number where the comment should be displayed
// in the diff view. For multi-line comments, this is the last line of the range.
func (c *Comment) DisplayLine() int64 {
if c.Line < 0 {
return c.Line - c.ExtraLinesCount
}
return c.Line + c.ExtraLinesCount
}
// UnsignedDisplayLine returns the unsigned (absolute) display line number.
// For multi-line comments, this is the last line of the range (UnsignedLine + ExtraLinesCount).
func (c *Comment) UnsignedDisplayLine() uint64 {
return c.UnsignedLine() + uint64(c.ExtraLinesCount)
}
// resolveLineAtHead checks whether a specific line is still present at the given head commit.
// For positive lines (proposed side), uses git blame --reverse.
// For negative lines (previous side), uses diff + FindAdjustedLineNumber.
func (c *Comment) resolveLineAtHead(gitRepo *git.Repository, lineNum uint64, currentHead string) (*git.ReverseLineBlame, error) {
if c.Line > 0 {
blame, err := gitRepo.ReverseLineBlame(c.CommitSHA, c.TreePath, lineNum, currentHead)
if err != nil {
return nil, fmt.Errorf("ReverseLineBlame for line %d: %w", lineNum, err)
}
return blame, nil
}
// For comments on removed lines, diff the commit the line was known to exist in against the head
// being viewed, then locate the line in that diff.
var buffer bytes.Buffer
if err := git.GetRepoRawDiffForFile(gitRepo, c.CommitSHA, currentHead, git.RawDiffNormal, c.TreePath, &buffer); err != nil {
return nil, fmt.Errorf("failed to get diff: %w", err)
}
diff := buffer.String()
adjustedLine, err := git.FindAdjustedLineNumber(c.Patch, int64(lineNum), strings.NewReader(diff))
if err != nil && errors.Is(err, git.ErrLineNotFound) {
return &git.ReverseLineBlame{
CommitID: "", // not currentHead — indicates the line is outdated
LineNumber: lineNum,
FilePath: c.TreePath,
}, nil
} else if err != nil {
return nil, fmt.Errorf("FindAdjustedLineNumber for line %d: %w", lineNum, err)
}
return &git.ReverseLineBlame{
CommitID: currentHead,
LineNumber: uint64(adjustedLine.Left),
FilePath: c.TreePath,
}, nil
}
func (c *Comment) ResolveCurrentLine(ctx context.Context, repo *repo_model.Repository, currentHead string) (*git.ReverseLineBlame, error) {
if c.reverseLineBlame != nil {
return c.reverseLineBlame, nil
}
// When a PR is viewed, the requirement to perform `git blame --reverse...` on every comment is a bit of a
// performance risk. To minimize this risk, cache the results relative to the requested head, so it only needs to be
// recalculated when head changes (or on cache eviction).
//
// Some performance testing was done which showed that a hot cache is much faster than the blame reverse
// operation -- 500-1000x runtime difference:
//
// - cache miss (Forgejo repo) took 7,690,574 ns
// - cache miss (~1000 commit repo) took 1,671,223 ns
// - cache hit (in-memory adapter) took 3,710 ns
// - cache hit (redis adapter) took 77,311 ns
resolveJSON, err := cache.GetString(fmt.Sprintf("comment.Resolve;ID=%d;HEAD=%s", c.ID, currentHead), func() (string, error) {
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return "", fmt.Errorf("failed to open repo: %w", err)
}
defer closer.Close()
// On the previous side the patch is cut around the last line of the range, so resolve that line
// (for single-line comments and the proposed side it equals UnsignedLine).
resolveLine := c.UnsignedLine()
if c.Line < 0 {
resolveLine = c.UnsignedDisplayLine()
}
reverseBlame, err := c.resolveLineAtHead(gitRepo, resolveLine, currentHead)
if err != nil {
return "", err
}
data, err := json.Marshal(reverseBlame)
if err != nil {
return "", err
}
return string(data), nil
})
if err != nil {
return nil, err
}
var reverseBlame *git.ReverseLineBlame
err = json.Unmarshal([]byte(resolveJSON), &reverseBlame)
if err != nil {
return nil, err
}
c.reverseLineBlame = reverseBlame
return c.reverseLineBlame, nil
}
// CheckLineRangeValid reports whether a multi-line comment range can still be placed at the given head.
// Previous (negative) side: only the last line can be located in the cut patch, so only it is checked.
// Proposed (positive) side: modified lines are tolerated; only a line landing at an unexpected offset
// (lines inserted/removed inside the range) invalidates it. Uses the same caching pattern as ResolveCurrentLine.
func (c *Comment) CheckLineRangeValid(ctx context.Context, repo *repo_model.Repository, currentHead string) (bool, error) {
if c.ExtraLinesCount <= 0 {
return true, nil
}
resultJSON, err := cache.GetString(fmt.Sprintf("comment.ResolveRange;ID=%d;HEAD=%s", c.ID, currentHead), func() (string, error) {
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return "", fmt.Errorf("failed to open repo: %w", err)
}
defer closer.Close()
// Previous side: only the last line of the range can be located in the cut patch, so validate it.
if c.Line < 0 {
blame, err := c.resolveLineAtHead(gitRepo, c.UnsignedDisplayLine(), currentHead)
if err != nil {
return "", err
}
if blame.CommitID != currentHead {
return "invalid", nil
}
return "valid", nil
}
// Proposed side: resolve the first line; the others are expected to follow it consecutively.
anchorBlame, err := c.resolveLineAtHead(gitRepo, c.UnsignedLine(), currentHead)
if err != nil {
return "", err
}
if anchorBlame.CommitID != currentHead {
return "invalid", nil
}
anchorResolvedLine := anchorBlame.LineNumber
// Catch a line changed by a later commit by comparing each range line's current content
// to the comment's Patch (its content at creation; lines the PR itself changed already match it).
// The Patch uses the comment's line coordinates, so trust it only
// when the anchor's recorded content equals its current content — otherwise fall back below.
if expected := git.PatchRightSideContent(c.Patch); len(expected) > 0 {
if headLines, ok := c.headFileLines(gitRepo, currentHead, anchorBlame.FilePath); ok {
lineAt := func(n uint64) (string, bool) {
if n >= 1 && n <= uint64(len(headLines)) {
return headLines[n-1], true
}
return "", false
}
anchorExpected, hasAnchor := expected[int64(c.UnsignedLine())]
anchorCurrent, hasCurrent := lineAt(anchorResolvedLine)
if hasAnchor && hasCurrent && anchorExpected == anchorCurrent {
trusted := true
for i := int64(1); i <= c.ExtraLinesCount; i++ {
exp, okExp := expected[int64(c.UnsignedLine())+i]
cur, okCur := lineAt(anchorResolvedLine + uint64(i))
if !okExp || !okCur {
trusted = false // mapping incomplete -> fall back to the offset-only check
break
}
if exp != cur {
return "invalid", nil // a range line was changed after the comment was made
}
}
if trusted {
return "valid", nil
}
}
}
}
// Fallback (offset-only): a line that no longer resolves is treated as "modified but present"
// and tolerated; only a resolved line landing at an unexpected offset (lines inserted/removed
// inside the range) is a break.
startLine := c.UnsignedLine()
for i := int64(1); i <= c.ExtraLinesCount; i++ {
blame, err := c.resolveLineAtHead(gitRepo, startLine+uint64(i), currentHead)
if err != nil {
return "", err
}
if blame.CommitID == currentHead && blame.LineNumber != anchorResolvedLine+uint64(i) {
return "invalid", nil
}
}
return "valid", nil
})
if err != nil {
return false, err
}
return resultJSON == "valid", nil
}
// headFileLines reads the content of treePath at the given commit and returns its lines
// (dropping the trailing empty element produced by a final newline). ok is false when the
// file can't be read at head.
func (c *Comment) headFileLines(gitRepo *git.Repository, head, treePath string) (lines []string, ok bool) {
commit, err := gitRepo.GetCommit(head)
if err != nil {
return nil, false
}
content, err := commit.GetFileContent(treePath, -1)
if err != nil {
return nil, false
}
lines = strings.Split(content, "\n")
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
return lines, true
}
// 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]
}
gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, c.Issue.Repo)
if err != nil {
return err
}
defer closer.Close()
c.Commits = git_model.ParseCommitsWithStatus(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs, c.IsForcePush), 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,
ExtraLinesCount: opts.ExtraLinesCount,
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 err := comment.UpdateAttachments(ctx, opts.Attachments); err != nil {
return err
}
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 := UpdateIssueNumComments(ctx, opts.Issue.ID); err != nil {
return err
}
fallthrough
case CommentTypeReview:
if err := comment.UpdateAttachments(ctx, opts.Attachments); err != nil {
return err
}
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
ExtraLinesCount 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 optional.Option[bool]
IsPull optional.Option[bool]
}
// ToConds implements FindOptions interface
func (opts FindCommentsOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.RepoID > 0 {
cond = cond.And(builder.Eq{"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 has, value := opts.Invalidated.Get(); has {
cond = cond.And(builder.Eq{"comment.invalidated": value})
}
if has, value := opts.IsPull.Get(); has {
cond = cond.And(builder.Eq{"issue.is_pull": value})
}
return cond
}
// FindComments returns all comments according options
func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) {
comments := make([]*Comment, 0, 10)
sess := db.GetEngine(ctx).Where(opts.ToConds())
if opts.RepoID > 0 || opts.IsPull.Has() {
sess.Join("INNER", "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, contentVersion int, doer *user_model.User) error {
ctx, committer, err := db.TxContext(ctx)
if err != nil {
return err
}
defer committer.Close()
// If the comment was reported as abusive, a shadow copy should be created before first update.
if err := IfNeededCreateShadowCopyForComment(ctx, c, true); err != nil {
return err
}
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
}
c.ContentVersion = contentVersion + 1
affected, err := sess.Where("content_version = ?", contentVersion).Update(c)
if err != nil {
return err
}
if affected == 0 {
return ErrCommentAlreadyChanged
}
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 the comment was reported as abusive, a shadow copy should be created before deletion.
if err := IfNeededCreateShadowCopyForComment(ctx, comment, false); err != nil {
return err
}
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.CountedAsConversation() {
if err := UpdateIssueNumComments(ctx, comment.IssueID); err != nil {
return err
}
}
if _, err := e.Table("action").
Where("comment_id = ?", comment.ID).Delete(); 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
}
func UpdateIssueNumCommentsBuilder(issueID int64) *builder.Builder {
subQuery := builder.Select("COUNT(*)").From("`comment`").Where(
builder.Eq{"issue_id": issueID}.And(
builder.In("`type`", ConversationCountedCommentType()),
))
return builder.Update(builder.Eq{"num_comments": subQuery}).
From("`issue`").Where(builder.Eq{"id": issueID})
}
func UpdateIssueNumComments(ctx context.Context, issueID int64) error {
_, err := db.GetEngine(ctx).Exec(UpdateIssueNumCommentsBuilder(issueID))
return err
}
// InsertIssueComments inserts many comments of issues.
func InsertIssueComments(ctx context.Context, comments []*Comment) error {
if len(comments) == 0 {
return nil
}
issueIDs := container.FilterSlice(comments, func(comment *Comment) (int64, bool) {
return comment.IssueID, 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.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 := UpdateIssueNumComments(ctx, issueID); err != nil {
return err
}
}
return committer.Commit()
}