code.gitea.io/gitea@v1.22.3/models/issues/comment.go (about) 1 // Copyright 2018 The Gitea Authors. 2 // Copyright 2016 The Gogs Authors. 3 // All rights reserved. 4 // SPDX-License-Identifier: MIT 5 6 package issues 7 8 import ( 9 "context" 10 "fmt" 11 "html/template" 12 "strconv" 13 "unicode/utf8" 14 15 "code.gitea.io/gitea/models/db" 16 git_model "code.gitea.io/gitea/models/git" 17 "code.gitea.io/gitea/models/organization" 18 project_model "code.gitea.io/gitea/models/project" 19 repo_model "code.gitea.io/gitea/models/repo" 20 user_model "code.gitea.io/gitea/models/user" 21 "code.gitea.io/gitea/modules/container" 22 "code.gitea.io/gitea/modules/gitrepo" 23 "code.gitea.io/gitea/modules/json" 24 "code.gitea.io/gitea/modules/log" 25 "code.gitea.io/gitea/modules/optional" 26 "code.gitea.io/gitea/modules/references" 27 "code.gitea.io/gitea/modules/structs" 28 "code.gitea.io/gitea/modules/timeutil" 29 "code.gitea.io/gitea/modules/translation" 30 "code.gitea.io/gitea/modules/util" 31 32 "xorm.io/builder" 33 ) 34 35 // ErrCommentNotExist represents a "CommentNotExist" kind of error. 36 type ErrCommentNotExist struct { 37 ID int64 38 IssueID int64 39 } 40 41 // IsErrCommentNotExist checks if an error is a ErrCommentNotExist. 42 func IsErrCommentNotExist(err error) bool { 43 _, ok := err.(ErrCommentNotExist) 44 return ok 45 } 46 47 func (err ErrCommentNotExist) Error() string { 48 return fmt.Sprintf("comment does not exist [id: %d, issue_id: %d]", err.ID, err.IssueID) 49 } 50 51 func (err ErrCommentNotExist) Unwrap() error { 52 return util.ErrNotExist 53 } 54 55 // CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. 56 type CommentType int 57 58 // CommentTypeUndefined is used to search for comments of any type 59 const CommentTypeUndefined CommentType = -1 60 61 const ( 62 CommentTypeComment CommentType = iota // 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0) 63 64 CommentTypeReopen // 1 65 CommentTypeClose // 2 66 67 CommentTypeIssueRef // 3 References. 68 CommentTypeCommitRef // 4 Reference from a commit (not part of a pull request) 69 CommentTypeCommentRef // 5 Reference from a comment 70 CommentTypePullRef // 6 Reference from a pull request 71 72 CommentTypeLabel // 7 Labels changed 73 CommentTypeMilestone // 8 Milestone changed 74 CommentTypeAssignees // 9 Assignees changed 75 CommentTypeChangeTitle // 10 Change Title 76 CommentTypeDeleteBranch // 11 Delete Branch 77 78 CommentTypeStartTracking // 12 Start a stopwatch for time tracking 79 CommentTypeStopTracking // 13 Stop a stopwatch for time tracking 80 CommentTypeAddTimeManual // 14 Add time manual for time tracking 81 CommentTypeCancelTracking // 15 Cancel a stopwatch for time tracking 82 CommentTypeAddedDeadline // 16 Added a due date 83 CommentTypeModifiedDeadline // 17 Modified the due date 84 CommentTypeRemovedDeadline // 18 Removed a due date 85 86 CommentTypeAddDependency // 19 Dependency added 87 CommentTypeRemoveDependency // 20 Dependency removed 88 89 CommentTypeCode // 21 Comment a line of code 90 CommentTypeReview // 22 Reviews a pull request by giving general feedback 91 92 CommentTypeLock // 23 Lock an issue, giving only collaborators access 93 CommentTypeUnlock // 24 Unlocks a previously locked issue 94 95 CommentTypeChangeTargetBranch // 25 Change pull request's target branch 96 97 CommentTypeDeleteTimeManual // 26 Delete time manual for time tracking 98 99 CommentTypeReviewRequest // 27 add or remove Request from one 100 CommentTypeMergePull // 28 merge pull request 101 CommentTypePullRequestPush // 29 push to PR head branch 102 103 CommentTypeProject // 30 Project changed 104 CommentTypeProjectBoard // 31 Project board changed 105 106 CommentTypeDismissReview // 32 Dismiss Review 107 108 CommentTypeChangeIssueRef // 33 Change issue ref 109 110 CommentTypePRScheduledToAutoMerge // 34 pr was scheduled to auto merge when checks succeed 111 CommentTypePRUnScheduledToAutoMerge // 35 pr was un scheduled to auto merge when checks succeed 112 113 CommentTypePin // 36 pin Issue 114 CommentTypeUnpin // 37 unpin Issue 115 ) 116 117 var commentStrings = []string{ 118 "comment", 119 "reopen", 120 "close", 121 "issue_ref", 122 "commit_ref", 123 "comment_ref", 124 "pull_ref", 125 "label", 126 "milestone", 127 "assignees", 128 "change_title", 129 "delete_branch", 130 "start_tracking", 131 "stop_tracking", 132 "add_time_manual", 133 "cancel_tracking", 134 "added_deadline", 135 "modified_deadline", 136 "removed_deadline", 137 "add_dependency", 138 "remove_dependency", 139 "code", 140 "review", 141 "lock", 142 "unlock", 143 "change_target_branch", 144 "delete_time_manual", 145 "review_request", 146 "merge_pull", 147 "pull_push", 148 "project", 149 "project_board", 150 "dismiss_review", 151 "change_issue_ref", 152 "pull_scheduled_merge", 153 "pull_cancel_scheduled_merge", 154 "pin", 155 "unpin", 156 } 157 158 func (t CommentType) String() string { 159 return commentStrings[t] 160 } 161 162 func AsCommentType(typeName string) CommentType { 163 for index, name := range commentStrings { 164 if typeName == name { 165 return CommentType(index) 166 } 167 } 168 return CommentTypeUndefined 169 } 170 171 func (t CommentType) HasContentSupport() bool { 172 switch t { 173 case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview: 174 return true 175 } 176 return false 177 } 178 179 func (t CommentType) HasAttachmentSupport() bool { 180 switch t { 181 case CommentTypeComment, CommentTypeCode, CommentTypeReview: 182 return true 183 } 184 return false 185 } 186 187 func (t CommentType) HasMailReplySupport() bool { 188 switch t { 189 case CommentTypeComment, CommentTypeCode, CommentTypeReview, CommentTypeDismissReview, CommentTypeReopen, CommentTypeClose, CommentTypeMergePull, CommentTypeAssignees: 190 return true 191 } 192 return false 193 } 194 195 // RoleInRepo presents the user's participation in the repo 196 type RoleInRepo string 197 198 // RoleDescriptor defines comment "role" tags 199 type RoleDescriptor struct { 200 IsPoster bool 201 RoleInRepo RoleInRepo 202 } 203 204 // Enumerate all the role tags. 205 const ( 206 RoleRepoOwner RoleInRepo = "owner" 207 RoleRepoMember RoleInRepo = "member" 208 RoleRepoCollaborator RoleInRepo = "collaborator" 209 RoleRepoFirstTimeContributor RoleInRepo = "first_time_contributor" 210 RoleRepoContributor RoleInRepo = "contributor" 211 ) 212 213 // LocaleString returns the locale string name of the role 214 func (r RoleInRepo) LocaleString(lang translation.Locale) string { 215 return lang.TrString("repo.issues.role." + string(r)) 216 } 217 218 // LocaleHelper returns the locale tooltip of the role 219 func (r RoleInRepo) LocaleHelper(lang translation.Locale) string { 220 return lang.TrString("repo.issues.role." + string(r) + "_helper") 221 } 222 223 // Comment represents a comment in commit and issue page. 224 type Comment struct { 225 ID int64 `xorm:"pk autoincr"` 226 Type CommentType `xorm:"INDEX"` 227 PosterID int64 `xorm:"INDEX"` 228 Poster *user_model.User `xorm:"-"` 229 OriginalAuthor string 230 OriginalAuthorID int64 231 IssueID int64 `xorm:"INDEX"` 232 Issue *Issue `xorm:"-"` 233 LabelID int64 234 Label *Label `xorm:"-"` 235 AddedLabels []*Label `xorm:"-"` 236 RemovedLabels []*Label `xorm:"-"` 237 OldProjectID int64 238 ProjectID int64 239 OldProject *project_model.Project `xorm:"-"` 240 Project *project_model.Project `xorm:"-"` 241 OldMilestoneID int64 242 MilestoneID int64 243 OldMilestone *Milestone `xorm:"-"` 244 Milestone *Milestone `xorm:"-"` 245 TimeID int64 246 Time *TrackedTime `xorm:"-"` 247 AssigneeID int64 248 RemovedAssignee bool 249 Assignee *user_model.User `xorm:"-"` 250 AssigneeTeamID int64 `xorm:"NOT NULL DEFAULT 0"` 251 AssigneeTeam *organization.Team `xorm:"-"` 252 ResolveDoerID int64 253 ResolveDoer *user_model.User `xorm:"-"` 254 OldTitle string 255 NewTitle string 256 OldRef string 257 NewRef string 258 DependentIssueID int64 `xorm:"index"` // This is used by issue_service.deleteIssue 259 DependentIssue *Issue `xorm:"-"` 260 261 CommitID int64 262 Line int64 // - previous line / + proposed line 263 TreePath string 264 Content string `xorm:"LONGTEXT"` 265 RenderedContent template.HTML `xorm:"-"` 266 267 // Path represents the 4 lines of code cemented by this comment 268 Patch string `xorm:"-"` 269 PatchQuoted string `xorm:"LONGTEXT patch"` 270 271 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 272 UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 273 274 // Reference issue in commit message 275 CommitSHA string `xorm:"VARCHAR(64)"` 276 277 Attachments []*repo_model.Attachment `xorm:"-"` 278 Reactions ReactionList `xorm:"-"` 279 280 // For view issue page. 281 ShowRole RoleDescriptor `xorm:"-"` 282 283 Review *Review `xorm:"-"` 284 ReviewID int64 `xorm:"index"` 285 Invalidated bool 286 287 // Reference an issue or pull from another comment, issue or PR 288 // All information is about the origin of the reference 289 RefRepoID int64 `xorm:"index"` // Repo where the referencing 290 RefIssueID int64 `xorm:"index"` 291 RefCommentID int64 `xorm:"index"` // 0 if origin is Issue title or content (or PR's) 292 RefAction references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves 293 RefIsPull bool 294 295 RefRepo *repo_model.Repository `xorm:"-"` 296 RefIssue *Issue `xorm:"-"` 297 RefComment *Comment `xorm:"-"` 298 299 Commits []*git_model.SignCommitWithStatuses `xorm:"-"` 300 OldCommit string `xorm:"-"` 301 NewCommit string `xorm:"-"` 302 CommitsNum int64 `xorm:"-"` 303 IsForcePush bool `xorm:"-"` 304 } 305 306 func init() { 307 db.RegisterModel(new(Comment)) 308 } 309 310 // PushActionContent is content of push pull comment 311 type PushActionContent struct { 312 IsForcePush bool `json:"is_force_push"` 313 CommitIDs []string `json:"commit_ids"` 314 } 315 316 // LoadIssue loads the issue reference for the comment 317 func (c *Comment) LoadIssue(ctx context.Context) (err error) { 318 if c.Issue != nil { 319 return nil 320 } 321 c.Issue, err = GetIssueByID(ctx, c.IssueID) 322 return err 323 } 324 325 // BeforeInsert will be invoked by XORM before inserting a record 326 func (c *Comment) BeforeInsert() { 327 c.PatchQuoted = c.Patch 328 if !utf8.ValidString(c.Patch) { 329 c.PatchQuoted = strconv.Quote(c.Patch) 330 } 331 } 332 333 // BeforeUpdate will be invoked by XORM before updating a record 334 func (c *Comment) BeforeUpdate() { 335 c.PatchQuoted = c.Patch 336 if !utf8.ValidString(c.Patch) { 337 c.PatchQuoted = strconv.Quote(c.Patch) 338 } 339 } 340 341 // AfterLoad is invoked from XORM after setting the values of all fields of this object. 342 func (c *Comment) AfterLoad() { 343 c.Patch = c.PatchQuoted 344 if len(c.PatchQuoted) > 0 && c.PatchQuoted[0] == '"' { 345 unquoted, err := strconv.Unquote(c.PatchQuoted) 346 if err == nil { 347 c.Patch = unquoted 348 } 349 } 350 } 351 352 // LoadPoster loads comment poster 353 func (c *Comment) LoadPoster(ctx context.Context) (err error) { 354 if c.Poster != nil { 355 return nil 356 } 357 358 c.Poster, err = user_model.GetPossibleUserByID(ctx, c.PosterID) 359 if err != nil { 360 if user_model.IsErrUserNotExist(err) { 361 c.PosterID = user_model.GhostUserID 362 c.Poster = user_model.NewGhostUser() 363 } else { 364 log.Error("getUserByID[%d]: %v", c.ID, err) 365 } 366 } 367 return err 368 } 369 370 // AfterDelete is invoked from XORM after the object is deleted. 371 func (c *Comment) AfterDelete(ctx context.Context) { 372 if c.ID <= 0 { 373 return 374 } 375 376 _, err := repo_model.DeleteAttachmentsByComment(ctx, c.ID, true) 377 if err != nil { 378 log.Info("Could not delete files for comment %d on issue #%d: %s", c.ID, c.IssueID, err) 379 } 380 } 381 382 // HTMLURL formats a URL-string to the issue-comment 383 func (c *Comment) HTMLURL(ctx context.Context) string { 384 err := c.LoadIssue(ctx) 385 if err != nil { // Silently dropping errors :unamused: 386 log.Error("LoadIssue(%d): %v", c.IssueID, err) 387 return "" 388 } 389 err = c.Issue.LoadRepo(ctx) 390 if err != nil { // Silently dropping errors :unamused: 391 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 392 return "" 393 } 394 return c.Issue.HTMLURL() + c.hashLink(ctx) 395 } 396 397 // Link formats a relative URL-string to the issue-comment 398 func (c *Comment) Link(ctx context.Context) string { 399 err := c.LoadIssue(ctx) 400 if err != nil { // Silently dropping errors :unamused: 401 log.Error("LoadIssue(%d): %v", c.IssueID, err) 402 return "" 403 } 404 err = c.Issue.LoadRepo(ctx) 405 if err != nil { // Silently dropping errors :unamused: 406 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 407 return "" 408 } 409 return c.Issue.Link() + c.hashLink(ctx) 410 } 411 412 func (c *Comment) hashLink(ctx context.Context) string { 413 if c.Type == CommentTypeCode { 414 if c.ReviewID == 0 { 415 return "/files#" + c.HashTag() 416 } 417 if c.Review == nil { 418 if err := c.LoadReview(ctx); err != nil { 419 log.Warn("LoadReview(%d): %v", c.ReviewID, err) 420 return "/files#" + c.HashTag() 421 } 422 } 423 if c.Review.Type <= ReviewTypePending { 424 return "/files#" + c.HashTag() 425 } 426 } 427 return "#" + c.HashTag() 428 } 429 430 // APIURL formats a API-string to the issue-comment 431 func (c *Comment) APIURL(ctx context.Context) string { 432 err := c.LoadIssue(ctx) 433 if err != nil { // Silently dropping errors :unamused: 434 log.Error("LoadIssue(%d): %v", c.IssueID, err) 435 return "" 436 } 437 err = c.Issue.LoadRepo(ctx) 438 if err != nil { // Silently dropping errors :unamused: 439 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 440 return "" 441 } 442 443 return fmt.Sprintf("%s/issues/comments/%d", c.Issue.Repo.APIURL(), c.ID) 444 } 445 446 // IssueURL formats a URL-string to the issue 447 func (c *Comment) IssueURL(ctx context.Context) string { 448 err := c.LoadIssue(ctx) 449 if err != nil { // Silently dropping errors :unamused: 450 log.Error("LoadIssue(%d): %v", c.IssueID, err) 451 return "" 452 } 453 454 if c.Issue.IsPull { 455 return "" 456 } 457 458 err = c.Issue.LoadRepo(ctx) 459 if err != nil { // Silently dropping errors :unamused: 460 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 461 return "" 462 } 463 return c.Issue.HTMLURL() 464 } 465 466 // PRURL formats a URL-string to the pull-request 467 func (c *Comment) PRURL(ctx context.Context) string { 468 err := c.LoadIssue(ctx) 469 if err != nil { // Silently dropping errors :unamused: 470 log.Error("LoadIssue(%d): %v", c.IssueID, err) 471 return "" 472 } 473 474 err = c.Issue.LoadRepo(ctx) 475 if err != nil { // Silently dropping errors :unamused: 476 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 477 return "" 478 } 479 480 if !c.Issue.IsPull { 481 return "" 482 } 483 return c.Issue.HTMLURL() 484 } 485 486 // CommentHashTag returns unique hash tag for comment id. 487 func CommentHashTag(id int64) string { 488 return fmt.Sprintf("issuecomment-%d", id) 489 } 490 491 // HashTag returns unique hash tag for comment. 492 func (c *Comment) HashTag() string { 493 return CommentHashTag(c.ID) 494 } 495 496 // EventTag returns unique event hash tag for comment. 497 func (c *Comment) EventTag() string { 498 return fmt.Sprintf("event-%d", c.ID) 499 } 500 501 // LoadLabel if comment.Type is CommentTypeLabel, then load Label 502 func (c *Comment) LoadLabel(ctx context.Context) error { 503 var label Label 504 has, err := db.GetEngine(ctx).ID(c.LabelID).Get(&label) 505 if err != nil { 506 return err 507 } else if has { 508 c.Label = &label 509 } else { 510 // Ignore Label is deleted, but not clear this table 511 log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID) 512 } 513 514 return nil 515 } 516 517 // LoadProject if comment.Type is CommentTypeProject, then load project. 518 func (c *Comment) LoadProject(ctx context.Context) error { 519 if c.OldProjectID > 0 { 520 var oldProject project_model.Project 521 has, err := db.GetEngine(ctx).ID(c.OldProjectID).Get(&oldProject) 522 if err != nil { 523 return err 524 } else if has { 525 c.OldProject = &oldProject 526 } 527 } 528 529 if c.ProjectID > 0 { 530 var project project_model.Project 531 has, err := db.GetEngine(ctx).ID(c.ProjectID).Get(&project) 532 if err != nil { 533 return err 534 } else if has { 535 c.Project = &project 536 } 537 } 538 539 return nil 540 } 541 542 // LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone 543 func (c *Comment) LoadMilestone(ctx context.Context) error { 544 if c.OldMilestoneID > 0 { 545 var oldMilestone Milestone 546 has, err := db.GetEngine(ctx).ID(c.OldMilestoneID).Get(&oldMilestone) 547 if err != nil { 548 return err 549 } else if has { 550 c.OldMilestone = &oldMilestone 551 } 552 } 553 554 if c.MilestoneID > 0 { 555 var milestone Milestone 556 has, err := db.GetEngine(ctx).ID(c.MilestoneID).Get(&milestone) 557 if err != nil { 558 return err 559 } else if has { 560 c.Milestone = &milestone 561 } 562 } 563 return nil 564 } 565 566 // LoadAttachments loads attachments (it never returns error, the error during `GetAttachmentsByCommentIDCtx` is ignored) 567 func (c *Comment) LoadAttachments(ctx context.Context) error { 568 if len(c.Attachments) > 0 { 569 return nil 570 } 571 572 var err error 573 c.Attachments, err = repo_model.GetAttachmentsByCommentID(ctx, c.ID) 574 if err != nil { 575 log.Error("getAttachmentsByCommentID[%d]: %v", c.ID, err) 576 } 577 return nil 578 } 579 580 // UpdateAttachments update attachments by UUIDs for the comment 581 func (c *Comment) UpdateAttachments(ctx context.Context, uuids []string) error { 582 ctx, committer, err := db.TxContext(ctx) 583 if err != nil { 584 return err 585 } 586 defer committer.Close() 587 588 attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids) 589 if err != nil { 590 return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", uuids, err) 591 } 592 for i := 0; i < len(attachments); i++ { 593 attachments[i].IssueID = c.IssueID 594 attachments[i].CommentID = c.ID 595 if err := repo_model.UpdateAttachment(ctx, attachments[i]); err != nil { 596 return fmt.Errorf("update attachment [id: %d]: %w", attachments[i].ID, err) 597 } 598 } 599 return committer.Commit() 600 } 601 602 // LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees 603 func (c *Comment) LoadAssigneeUserAndTeam(ctx context.Context) error { 604 var err error 605 606 if c.AssigneeID > 0 && c.Assignee == nil { 607 c.Assignee, err = user_model.GetUserByID(ctx, c.AssigneeID) 608 if err != nil { 609 if !user_model.IsErrUserNotExist(err) { 610 return err 611 } 612 c.Assignee = user_model.NewGhostUser() 613 } 614 } else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil { 615 if err = c.LoadIssue(ctx); err != nil { 616 return err 617 } 618 619 if err = c.Issue.LoadRepo(ctx); err != nil { 620 return err 621 } 622 623 if err = c.Issue.Repo.LoadOwner(ctx); err != nil { 624 return err 625 } 626 627 if c.Issue.Repo.Owner.IsOrganization() { 628 c.AssigneeTeam, err = organization.GetTeamByID(ctx, c.AssigneeTeamID) 629 if err != nil && !organization.IsErrTeamNotExist(err) { 630 return err 631 } 632 } 633 } 634 return nil 635 } 636 637 // LoadResolveDoer if comment.Type is CommentTypeCode and ResolveDoerID not zero, then load resolveDoer 638 func (c *Comment) LoadResolveDoer(ctx context.Context) (err error) { 639 if c.ResolveDoerID == 0 || c.Type != CommentTypeCode { 640 return nil 641 } 642 c.ResolveDoer, err = user_model.GetUserByID(ctx, c.ResolveDoerID) 643 if err != nil { 644 if user_model.IsErrUserNotExist(err) { 645 c.ResolveDoer = user_model.NewGhostUser() 646 err = nil 647 } 648 } 649 return err 650 } 651 652 // IsResolved check if an code comment is resolved 653 func (c *Comment) IsResolved() bool { 654 return c.ResolveDoerID != 0 && c.Type == CommentTypeCode 655 } 656 657 // LoadDepIssueDetails loads Dependent Issue Details 658 func (c *Comment) LoadDepIssueDetails(ctx context.Context) (err error) { 659 if c.DependentIssueID <= 0 || c.DependentIssue != nil { 660 return nil 661 } 662 c.DependentIssue, err = GetIssueByID(ctx, c.DependentIssueID) 663 return err 664 } 665 666 // LoadTime loads the associated time for a CommentTypeAddTimeManual 667 func (c *Comment) LoadTime(ctx context.Context) error { 668 if c.Time != nil || c.TimeID == 0 { 669 return nil 670 } 671 var err error 672 c.Time, err = GetTrackedTimeByID(ctx, c.TimeID) 673 return err 674 } 675 676 // LoadReactions loads comment reactions 677 func (c *Comment) LoadReactions(ctx context.Context, repo *repo_model.Repository) (err error) { 678 if c.Reactions != nil { 679 return nil 680 } 681 c.Reactions, _, err = FindReactions(ctx, FindReactionsOptions{ 682 IssueID: c.IssueID, 683 CommentID: c.ID, 684 }) 685 if err != nil { 686 return err 687 } 688 // Load reaction user data 689 if _, err := c.Reactions.LoadUsers(ctx, repo); err != nil { 690 return err 691 } 692 return nil 693 } 694 695 func (c *Comment) loadReview(ctx context.Context) (err error) { 696 if c.ReviewID == 0 { 697 return nil 698 } 699 if c.Review == nil { 700 if c.Review, err = GetReviewByID(ctx, c.ReviewID); err != nil { 701 // review request which has been replaced by actual reviews doesn't exist in database anymore, so ignorem them. 702 if c.Type == CommentTypeReviewRequest { 703 return nil 704 } 705 return err 706 } 707 } 708 c.Review.Issue = c.Issue 709 return nil 710 } 711 712 // LoadReview loads the associated review 713 func (c *Comment) LoadReview(ctx context.Context) error { 714 return c.loadReview(ctx) 715 } 716 717 // DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. 718 func (c *Comment) DiffSide() string { 719 if c.Line < 0 { 720 return "previous" 721 } 722 return "proposed" 723 } 724 725 // UnsignedLine returns the LOC of the code comment without + or - 726 func (c *Comment) UnsignedLine() uint64 { 727 if c.Line < 0 { 728 return uint64(c.Line * -1) 729 } 730 return uint64(c.Line) 731 } 732 733 // CodeCommentLink returns the url to a comment in code 734 func (c *Comment) CodeCommentLink(ctx context.Context) string { 735 err := c.LoadIssue(ctx) 736 if err != nil { // Silently dropping errors :unamused: 737 log.Error("LoadIssue(%d): %v", c.IssueID, err) 738 return "" 739 } 740 err = c.Issue.LoadRepo(ctx) 741 if err != nil { // Silently dropping errors :unamused: 742 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 743 return "" 744 } 745 return fmt.Sprintf("%s/files#%s", c.Issue.Link(), c.HashTag()) 746 } 747 748 // LoadPushCommits Load push commits 749 func (c *Comment) LoadPushCommits(ctx context.Context) (err error) { 750 if c.Content == "" || c.Commits != nil || c.Type != CommentTypePullRequestPush { 751 return nil 752 } 753 754 var data PushActionContent 755 756 err = json.Unmarshal([]byte(c.Content), &data) 757 if err != nil { 758 return err 759 } 760 761 c.IsForcePush = data.IsForcePush 762 763 if c.IsForcePush { 764 if len(data.CommitIDs) != 2 { 765 return nil 766 } 767 c.OldCommit = data.CommitIDs[0] 768 c.NewCommit = data.CommitIDs[1] 769 } else { 770 gitRepo, closer, err := gitrepo.RepositoryFromContextOrOpen(ctx, c.Issue.Repo) 771 if err != nil { 772 return err 773 } 774 defer closer.Close() 775 776 c.Commits = git_model.ConvertFromGitCommit(ctx, gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo) 777 c.CommitsNum = int64(len(c.Commits)) 778 } 779 780 return err 781 } 782 783 // CreateComment creates comment with context 784 func CreateComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { 785 ctx, committer, err := db.TxContext(ctx) 786 if err != nil { 787 return nil, err 788 } 789 defer committer.Close() 790 791 e := db.GetEngine(ctx) 792 var LabelID int64 793 if opts.Label != nil { 794 LabelID = opts.Label.ID 795 } 796 797 comment := &Comment{ 798 Type: opts.Type, 799 PosterID: opts.Doer.ID, 800 Poster: opts.Doer, 801 IssueID: opts.Issue.ID, 802 LabelID: LabelID, 803 OldMilestoneID: opts.OldMilestoneID, 804 MilestoneID: opts.MilestoneID, 805 OldProjectID: opts.OldProjectID, 806 ProjectID: opts.ProjectID, 807 TimeID: opts.TimeID, 808 RemovedAssignee: opts.RemovedAssignee, 809 AssigneeID: opts.AssigneeID, 810 AssigneeTeamID: opts.AssigneeTeamID, 811 CommitID: opts.CommitID, 812 CommitSHA: opts.CommitSHA, 813 Line: opts.LineNum, 814 Content: opts.Content, 815 OldTitle: opts.OldTitle, 816 NewTitle: opts.NewTitle, 817 OldRef: opts.OldRef, 818 NewRef: opts.NewRef, 819 DependentIssueID: opts.DependentIssueID, 820 TreePath: opts.TreePath, 821 ReviewID: opts.ReviewID, 822 Patch: opts.Patch, 823 RefRepoID: opts.RefRepoID, 824 RefIssueID: opts.RefIssueID, 825 RefCommentID: opts.RefCommentID, 826 RefAction: opts.RefAction, 827 RefIsPull: opts.RefIsPull, 828 IsForcePush: opts.IsForcePush, 829 Invalidated: opts.Invalidated, 830 } 831 if _, err = e.Insert(comment); err != nil { 832 return nil, err 833 } 834 835 if err = opts.Repo.LoadOwner(ctx); err != nil { 836 return nil, err 837 } 838 839 if err = updateCommentInfos(ctx, opts, comment); err != nil { 840 return nil, err 841 } 842 843 if err = comment.AddCrossReferences(ctx, opts.Doer, false); err != nil { 844 return nil, err 845 } 846 if err = committer.Commit(); err != nil { 847 return nil, err 848 } 849 return comment, nil 850 } 851 852 func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment *Comment) (err error) { 853 // Check comment type. 854 switch opts.Type { 855 case CommentTypeCode: 856 if err = updateAttachments(ctx, opts, comment); err != nil { 857 return err 858 } 859 if comment.ReviewID != 0 { 860 if comment.Review == nil { 861 if err := comment.loadReview(ctx); err != nil { 862 return err 863 } 864 } 865 if comment.Review.Type <= ReviewTypePending { 866 return nil 867 } 868 } 869 fallthrough 870 case CommentTypeComment: 871 if _, err = db.Exec(ctx, "UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { 872 return err 873 } 874 fallthrough 875 case CommentTypeReview: 876 if err = updateAttachments(ctx, opts, comment); err != nil { 877 return err 878 } 879 case CommentTypeReopen, CommentTypeClose: 880 if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil { 881 return err 882 } 883 } 884 // update the issue's updated_unix column 885 return UpdateIssueCols(ctx, opts.Issue, "updated_unix") 886 } 887 888 func updateAttachments(ctx context.Context, opts *CreateCommentOptions, comment *Comment) error { 889 attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) 890 if err != nil { 891 return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err) 892 } 893 for i := range attachments { 894 attachments[i].IssueID = opts.Issue.ID 895 attachments[i].CommentID = comment.ID 896 // No assign value could be 0, so ignore AllCols(). 897 if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil { 898 return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err) 899 } 900 } 901 comment.Attachments = attachments 902 return nil 903 } 904 905 func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) { 906 var content string 907 var commentType CommentType 908 909 // newDeadline = 0 means deleting 910 if newDeadlineUnix == 0 { 911 commentType = CommentTypeRemovedDeadline 912 content = issue.DeadlineUnix.FormatDate() 913 } else if issue.DeadlineUnix == 0 { 914 // Check if the new date was added or modified 915 // If the actual deadline is 0 => deadline added 916 commentType = CommentTypeAddedDeadline 917 content = newDeadlineUnix.FormatDate() 918 } else { // Otherwise modified 919 commentType = CommentTypeModifiedDeadline 920 content = newDeadlineUnix.FormatDate() + "|" + issue.DeadlineUnix.FormatDate() 921 } 922 923 if err := issue.LoadRepo(ctx); err != nil { 924 return nil, err 925 } 926 927 opts := &CreateCommentOptions{ 928 Type: commentType, 929 Doer: doer, 930 Repo: issue.Repo, 931 Issue: issue, 932 Content: content, 933 } 934 comment, err := CreateComment(ctx, opts) 935 if err != nil { 936 return nil, err 937 } 938 return comment, nil 939 } 940 941 // Creates issue dependency comment 942 func createIssueDependencyComment(ctx context.Context, doer *user_model.User, issue, dependentIssue *Issue, add bool) (err error) { 943 cType := CommentTypeAddDependency 944 if !add { 945 cType = CommentTypeRemoveDependency 946 } 947 if err = issue.LoadRepo(ctx); err != nil { 948 return err 949 } 950 951 // Make two comments, one in each issue 952 opts := &CreateCommentOptions{ 953 Type: cType, 954 Doer: doer, 955 Repo: issue.Repo, 956 Issue: issue, 957 DependentIssueID: dependentIssue.ID, 958 } 959 if _, err = CreateComment(ctx, opts); err != nil { 960 return err 961 } 962 963 opts = &CreateCommentOptions{ 964 Type: cType, 965 Doer: doer, 966 Repo: issue.Repo, 967 Issue: dependentIssue, 968 DependentIssueID: issue.ID, 969 } 970 _, err = CreateComment(ctx, opts) 971 return err 972 } 973 974 // CreateCommentOptions defines options for creating comment 975 type CreateCommentOptions struct { 976 Type CommentType 977 Doer *user_model.User 978 Repo *repo_model.Repository 979 Issue *Issue 980 Label *Label 981 982 DependentIssueID int64 983 OldMilestoneID int64 984 MilestoneID int64 985 OldProjectID int64 986 ProjectID int64 987 TimeID int64 988 AssigneeID int64 989 AssigneeTeamID int64 990 RemovedAssignee bool 991 OldTitle string 992 NewTitle string 993 OldRef string 994 NewRef string 995 CommitID int64 996 CommitSHA string 997 Patch string 998 LineNum int64 999 TreePath string 1000 ReviewID int64 1001 Content string 1002 Attachments []string // UUIDs of attachments 1003 RefRepoID int64 1004 RefIssueID int64 1005 RefCommentID int64 1006 RefAction references.XRefAction 1007 RefIsPull bool 1008 IsForcePush bool 1009 Invalidated bool 1010 } 1011 1012 // GetCommentByID returns the comment by given ID. 1013 func GetCommentByID(ctx context.Context, id int64) (*Comment, error) { 1014 c := new(Comment) 1015 has, err := db.GetEngine(ctx).ID(id).Get(c) 1016 if err != nil { 1017 return nil, err 1018 } else if !has { 1019 return nil, ErrCommentNotExist{id, 0} 1020 } 1021 return c, nil 1022 } 1023 1024 // FindCommentsOptions describes the conditions to Find comments 1025 type FindCommentsOptions struct { 1026 db.ListOptions 1027 RepoID int64 1028 IssueID int64 1029 ReviewID int64 1030 Since int64 1031 Before int64 1032 Line int64 1033 TreePath string 1034 Type CommentType 1035 IssueIDs []int64 1036 Invalidated optional.Option[bool] 1037 IsPull optional.Option[bool] 1038 } 1039 1040 // ToConds implements FindOptions interface 1041 func (opts FindCommentsOptions) ToConds() builder.Cond { 1042 cond := builder.NewCond() 1043 if opts.RepoID > 0 { 1044 cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID}) 1045 } 1046 if opts.IssueID > 0 { 1047 cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID}) 1048 } else if len(opts.IssueIDs) > 0 { 1049 cond = cond.And(builder.In("comment.issue_id", opts.IssueIDs)) 1050 } 1051 if opts.ReviewID > 0 { 1052 cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) 1053 } 1054 if opts.Since > 0 { 1055 cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) 1056 } 1057 if opts.Before > 0 { 1058 cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before}) 1059 } 1060 if opts.Type != CommentTypeUndefined { 1061 cond = cond.And(builder.Eq{"comment.type": opts.Type}) 1062 } 1063 if opts.Line != 0 { 1064 cond = cond.And(builder.Eq{"comment.line": opts.Line}) 1065 } 1066 if len(opts.TreePath) > 0 { 1067 cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) 1068 } 1069 if opts.Invalidated.Has() { 1070 cond = cond.And(builder.Eq{"comment.invalidated": opts.Invalidated.Value()}) 1071 } 1072 if opts.IsPull.Has() { 1073 cond = cond.And(builder.Eq{"issue.is_pull": opts.IsPull.Value()}) 1074 } 1075 return cond 1076 } 1077 1078 // FindComments returns all comments according options 1079 func FindComments(ctx context.Context, opts *FindCommentsOptions) (CommentList, error) { 1080 comments := make([]*Comment, 0, 10) 1081 sess := db.GetEngine(ctx).Where(opts.ToConds()) 1082 if opts.RepoID > 0 || opts.IsPull.Has() { 1083 sess.Join("INNER", "issue", "issue.id = comment.issue_id") 1084 } 1085 1086 if opts.Page != 0 { 1087 sess = db.SetSessionPagination(sess, opts) 1088 } 1089 1090 // WARNING: If you change this order you will need to fix createCodeComment 1091 1092 return comments, sess. 1093 Asc("comment.created_unix"). 1094 Asc("comment.id"). 1095 Find(&comments) 1096 } 1097 1098 // CountComments count all comments according options by ignoring pagination 1099 func CountComments(ctx context.Context, opts *FindCommentsOptions) (int64, error) { 1100 sess := db.GetEngine(ctx).Where(opts.ToConds()) 1101 if opts.RepoID > 0 { 1102 sess.Join("INNER", "issue", "issue.id = comment.issue_id") 1103 } 1104 return sess.Count(&Comment{}) 1105 } 1106 1107 // UpdateCommentInvalidate updates comment invalidated column 1108 func UpdateCommentInvalidate(ctx context.Context, c *Comment) error { 1109 _, err := db.GetEngine(ctx).ID(c.ID).Cols("invalidated").Update(c) 1110 return err 1111 } 1112 1113 // UpdateComment updates information of comment. 1114 func UpdateComment(ctx context.Context, c *Comment, doer *user_model.User) error { 1115 ctx, committer, err := db.TxContext(ctx) 1116 if err != nil { 1117 return err 1118 } 1119 defer committer.Close() 1120 sess := db.GetEngine(ctx) 1121 1122 if _, err := sess.ID(c.ID).AllCols().Update(c); err != nil { 1123 return err 1124 } 1125 if err := c.LoadIssue(ctx); err != nil { 1126 return err 1127 } 1128 if err := c.AddCrossReferences(ctx, doer, true); err != nil { 1129 return err 1130 } 1131 if err := committer.Commit(); err != nil { 1132 return fmt.Errorf("Commit: %w", err) 1133 } 1134 1135 return nil 1136 } 1137 1138 // DeleteComment deletes the comment 1139 func DeleteComment(ctx context.Context, comment *Comment) error { 1140 e := db.GetEngine(ctx) 1141 if _, err := e.ID(comment.ID).NoAutoCondition().Delete(comment); err != nil { 1142 return err 1143 } 1144 1145 if _, err := db.DeleteByBean(ctx, &ContentHistory{ 1146 CommentID: comment.ID, 1147 }); err != nil { 1148 return err 1149 } 1150 1151 if comment.Type == CommentTypeComment { 1152 if _, err := e.ID(comment.IssueID).Decr("num_comments").Update(new(Issue)); err != nil { 1153 return err 1154 } 1155 } 1156 if _, err := e.Table("action"). 1157 Where("comment_id = ?", comment.ID). 1158 Update(map[string]any{ 1159 "is_deleted": true, 1160 }); err != nil { 1161 return err 1162 } 1163 1164 if err := comment.neuterCrossReferences(ctx); err != nil { 1165 return err 1166 } 1167 1168 return DeleteReaction(ctx, &ReactionOptions{CommentID: comment.ID}) 1169 } 1170 1171 // UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id 1172 func UpdateCommentsMigrationsByType(ctx context.Context, tp structs.GitServiceType, originalAuthorID string, posterID int64) error { 1173 _, err := db.GetEngine(ctx).Table("comment"). 1174 Join("INNER", "issue", "issue.id = comment.issue_id"). 1175 Join("INNER", "repository", "issue.repo_id = repository.id"). 1176 Where("repository.original_service_type = ?", tp). 1177 And("comment.original_author_id = ?", originalAuthorID). 1178 Update(map[string]any{ 1179 "poster_id": posterID, 1180 "original_author": "", 1181 "original_author_id": 0, 1182 }) 1183 return err 1184 } 1185 1186 // CreateAutoMergeComment is a internal function, only use it for CommentTypePRScheduledToAutoMerge and CommentTypePRUnScheduledToAutoMerge CommentTypes 1187 func CreateAutoMergeComment(ctx context.Context, typ CommentType, pr *PullRequest, doer *user_model.User) (comment *Comment, err error) { 1188 if typ != CommentTypePRScheduledToAutoMerge && typ != CommentTypePRUnScheduledToAutoMerge { 1189 return nil, fmt.Errorf("comment type %d cannot be used to create an auto merge comment", typ) 1190 } 1191 if err = pr.LoadIssue(ctx); err != nil { 1192 return nil, err 1193 } 1194 1195 if err = pr.LoadBaseRepo(ctx); err != nil { 1196 return nil, err 1197 } 1198 1199 comment, err = CreateComment(ctx, &CreateCommentOptions{ 1200 Type: typ, 1201 Doer: doer, 1202 Repo: pr.BaseRepo, 1203 Issue: pr.Issue, 1204 }) 1205 return comment, err 1206 } 1207 1208 // RemapExternalUser ExternalUserRemappable interface 1209 func (c *Comment) RemapExternalUser(externalName string, externalID, userID int64) error { 1210 c.OriginalAuthor = externalName 1211 c.OriginalAuthorID = externalID 1212 c.PosterID = userID 1213 return nil 1214 } 1215 1216 // GetUserID ExternalUserRemappable interface 1217 func (c *Comment) GetUserID() int64 { return c.PosterID } 1218 1219 // GetExternalName ExternalUserRemappable interface 1220 func (c *Comment) GetExternalName() string { return c.OriginalAuthor } 1221 1222 // GetExternalID ExternalUserRemappable interface 1223 func (c *Comment) GetExternalID() int64 { return c.OriginalAuthorID } 1224 1225 // CountCommentTypeLabelWithEmptyLabel count label comments with empty label 1226 func CountCommentTypeLabelWithEmptyLabel(ctx context.Context) (int64, error) { 1227 return db.GetEngine(ctx).Where(builder.Eq{"type": CommentTypeLabel, "label_id": 0}).Count(new(Comment)) 1228 } 1229 1230 // FixCommentTypeLabelWithEmptyLabel count label comments with empty label 1231 func FixCommentTypeLabelWithEmptyLabel(ctx context.Context) (int64, error) { 1232 return db.GetEngine(ctx).Where(builder.Eq{"type": CommentTypeLabel, "label_id": 0}).Delete(new(Comment)) 1233 } 1234 1235 // CountCommentTypeLabelWithOutsideLabels count label comments with outside label 1236 func CountCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) { 1237 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). 1238 Table("comment"). 1239 Join("inner", "label", "label.id = comment.label_id"). 1240 Join("inner", "issue", "issue.id = comment.issue_id "). 1241 Join("inner", "repository", "issue.repo_id = repository.id"). 1242 Count() 1243 } 1244 1245 // FixCommentTypeLabelWithOutsideLabels count label comments with outside label 1246 func FixCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) { 1247 res, err := db.GetEngine(ctx).Exec(`DELETE FROM comment WHERE comment.id IN ( 1248 SELECT il_too.id FROM ( 1249 SELECT com.id 1250 FROM comment AS com 1251 INNER JOIN label ON com.label_id = label.id 1252 INNER JOIN issue on issue.id = com.issue_id 1253 INNER JOIN repository ON issue.repo_id = repository.id 1254 WHERE 1255 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)) 1256 ) AS il_too)`, CommentTypeLabel) 1257 if err != nil { 1258 return 0, err 1259 } 1260 1261 return res.RowsAffected() 1262 } 1263 1264 // HasOriginalAuthor returns if a comment was migrated and has an original author. 1265 func (c *Comment) HasOriginalAuthor() bool { 1266 return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 1267 } 1268 1269 // InsertIssueComments inserts many comments of issues. 1270 func InsertIssueComments(ctx context.Context, comments []*Comment) error { 1271 if len(comments) == 0 { 1272 return nil 1273 } 1274 1275 issueIDs := container.FilterSlice(comments, func(comment *Comment) (int64, bool) { 1276 return comment.IssueID, true 1277 }) 1278 1279 ctx, committer, err := db.TxContext(ctx) 1280 if err != nil { 1281 return err 1282 } 1283 defer committer.Close() 1284 for _, comment := range comments { 1285 if _, err := db.GetEngine(ctx).NoAutoTime().Insert(comment); err != nil { 1286 return err 1287 } 1288 1289 for _, reaction := range comment.Reactions { 1290 reaction.IssueID = comment.IssueID 1291 reaction.CommentID = comment.ID 1292 } 1293 if len(comment.Reactions) > 0 { 1294 if err := db.Insert(ctx, comment.Reactions); err != nil { 1295 return err 1296 } 1297 } 1298 } 1299 1300 for _, issueID := range issueIDs { 1301 if _, err := db.Exec(ctx, "UPDATE issue set num_comments = (SELECT count(*) FROM comment WHERE issue_id = ? AND `type`=?) WHERE id = ?", 1302 issueID, CommentTypeComment, issueID); err != nil { 1303 return err 1304 } 1305 } 1306 return committer.Commit() 1307 }