1// Copyright 2018 The Gitea Authors. 2// Copyright 2016 The Gogs Authors. 3// All rights reserved. 4// Use of this source code is governed by a MIT-style 5// license that can be found in the LICENSE file. 6 7package models 8 9import ( 10 "context" 11 "fmt" 12 "regexp" 13 "strconv" 14 "strings" 15 "unicode/utf8" 16 17 "code.gitea.io/gitea/models/db" 18 "code.gitea.io/gitea/models/issues" 19 repo_model "code.gitea.io/gitea/models/repo" 20 user_model "code.gitea.io/gitea/models/user" 21 "code.gitea.io/gitea/modules/git" 22 "code.gitea.io/gitea/modules/json" 23 "code.gitea.io/gitea/modules/log" 24 "code.gitea.io/gitea/modules/markup" 25 "code.gitea.io/gitea/modules/markup/markdown" 26 "code.gitea.io/gitea/modules/references" 27 "code.gitea.io/gitea/modules/structs" 28 "code.gitea.io/gitea/modules/timeutil" 29 30 "xorm.io/builder" 31 "xorm.io/xorm" 32) 33 34// CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. 35type CommentType int 36 37// define unknown comment type 38const ( 39 CommentTypeUnknown CommentType = -1 40) 41 42// Enumerate all the comment types 43const ( 44 // 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0) 45 CommentTypeComment CommentType = iota 46 CommentTypeReopen // 1 47 CommentTypeClose // 2 48 49 // 3 References. 50 CommentTypeIssueRef 51 // 4 Reference from a commit (not part of a pull request) 52 CommentTypeCommitRef 53 // 5 Reference from a comment 54 CommentTypeCommentRef 55 // 6 Reference from a pull request 56 CommentTypePullRef 57 // 7 Labels changed 58 CommentTypeLabel 59 // 8 Milestone changed 60 CommentTypeMilestone 61 // 9 Assignees changed 62 CommentTypeAssignees 63 // 10 Change Title 64 CommentTypeChangeTitle 65 // 11 Delete Branch 66 CommentTypeDeleteBranch 67 // 12 Start a stopwatch for time tracking 68 CommentTypeStartTracking 69 // 13 Stop a stopwatch for time tracking 70 CommentTypeStopTracking 71 // 14 Add time manual for time tracking 72 CommentTypeAddTimeManual 73 // 15 Cancel a stopwatch for time tracking 74 CommentTypeCancelTracking 75 // 16 Added a due date 76 CommentTypeAddedDeadline 77 // 17 Modified the due date 78 CommentTypeModifiedDeadline 79 // 18 Removed a due date 80 CommentTypeRemovedDeadline 81 // 19 Dependency added 82 CommentTypeAddDependency 83 // 20 Dependency removed 84 CommentTypeRemoveDependency 85 // 21 Comment a line of code 86 CommentTypeCode 87 // 22 Reviews a pull request by giving general feedback 88 CommentTypeReview 89 // 23 Lock an issue, giving only collaborators access 90 CommentTypeLock 91 // 24 Unlocks a previously locked issue 92 CommentTypeUnlock 93 // 25 Change pull request's target branch 94 CommentTypeChangeTargetBranch 95 // 26 Delete time manual for time tracking 96 CommentTypeDeleteTimeManual 97 // 27 add or remove Request from one 98 CommentTypeReviewRequest 99 // 28 merge pull request 100 CommentTypeMergePull 101 // 29 push to PR head branch 102 CommentTypePullPush 103 // 30 Project changed 104 CommentTypeProject 105 // 31 Project board changed 106 CommentTypeProjectBoard 107 // 32 Dismiss Review 108 CommentTypeDismissReview 109 // 33 Change issue ref 110 CommentTypeChangeIssueRef 111) 112 113var commentStrings = []string{ 114 "comment", 115 "reopen", 116 "close", 117 "issue_ref", 118 "commit_ref", 119 "comment_ref", 120 "pull_ref", 121 "label", 122 "milestone", 123 "assignees", 124 "change_title", 125 "delete_branch", 126 "start_tracking", 127 "stop_tracking", 128 "add_time_manual", 129 "cancel_tracking", 130 "added_deadline", 131 "modified_deadline", 132 "removed_deadline", 133 "add_dependency", 134 "remove_dependency", 135 "code", 136 "review", 137 "lock", 138 "unlock", 139 "change_target_branch", 140 "delete_time_manual", 141 "review_request", 142 "merge_pull", 143 "pull_push", 144 "project", 145 "project_board", 146 "dismiss_review", 147 "change_issue_ref", 148} 149 150func (t CommentType) String() string { 151 return commentStrings[t] 152} 153 154// RoleDescriptor defines comment tag type 155type RoleDescriptor int 156 157// Enumerate all the role tags. 158const ( 159 RoleDescriptorNone RoleDescriptor = iota 160 RoleDescriptorPoster 161 RoleDescriptorWriter 162 RoleDescriptorOwner 163) 164 165// WithRole enable a specific tag on the RoleDescriptor. 166func (rd RoleDescriptor) WithRole(role RoleDescriptor) RoleDescriptor { 167 return rd | (1 << role) 168} 169 170func stringToRoleDescriptor(role string) RoleDescriptor { 171 switch role { 172 case "Poster": 173 return RoleDescriptorPoster 174 case "Writer": 175 return RoleDescriptorWriter 176 case "Owner": 177 return RoleDescriptorOwner 178 default: 179 return RoleDescriptorNone 180 } 181} 182 183// HasRole returns if a certain role is enabled on the RoleDescriptor. 184func (rd RoleDescriptor) HasRole(role string) bool { 185 roleDescriptor := stringToRoleDescriptor(role) 186 bitValue := rd & (1 << roleDescriptor) 187 return (bitValue > 0) 188} 189 190// Comment represents a comment in commit and issue page. 191type Comment struct { 192 ID int64 `xorm:"pk autoincr"` 193 Type CommentType `xorm:"INDEX"` 194 PosterID int64 `xorm:"INDEX"` 195 Poster *user_model.User `xorm:"-"` 196 OriginalAuthor string 197 OriginalAuthorID int64 198 IssueID int64 `xorm:"INDEX"` 199 Issue *Issue `xorm:"-"` 200 LabelID int64 201 Label *Label `xorm:"-"` 202 AddedLabels []*Label `xorm:"-"` 203 RemovedLabels []*Label `xorm:"-"` 204 OldProjectID int64 205 ProjectID int64 206 OldProject *Project `xorm:"-"` 207 Project *Project `xorm:"-"` 208 OldMilestoneID int64 209 MilestoneID int64 210 OldMilestone *Milestone `xorm:"-"` 211 Milestone *Milestone `xorm:"-"` 212 TimeID int64 213 Time *TrackedTime `xorm:"-"` 214 AssigneeID int64 215 RemovedAssignee bool 216 Assignee *user_model.User `xorm:"-"` 217 AssigneeTeamID int64 `xorm:"NOT NULL DEFAULT 0"` 218 AssigneeTeam *Team `xorm:"-"` 219 ResolveDoerID int64 220 ResolveDoer *user_model.User `xorm:"-"` 221 OldTitle string 222 NewTitle string 223 OldRef string 224 NewRef string 225 DependentIssueID int64 226 DependentIssue *Issue `xorm:"-"` 227 228 CommitID int64 229 Line int64 // - previous line / + proposed line 230 TreePath string 231 Content string `xorm:"LONGTEXT"` 232 RenderedContent string `xorm:"-"` 233 234 // Path represents the 4 lines of code cemented by this comment 235 Patch string `xorm:"-"` 236 PatchQuoted string `xorm:"LONGTEXT patch"` 237 238 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 239 UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 240 241 // Reference issue in commit message 242 CommitSHA string `xorm:"VARCHAR(40)"` 243 244 Attachments []*repo_model.Attachment `xorm:"-"` 245 Reactions ReactionList `xorm:"-"` 246 247 // For view issue page. 248 ShowRole RoleDescriptor `xorm:"-"` 249 250 Review *Review `xorm:"-"` 251 ReviewID int64 `xorm:"index"` 252 Invalidated bool 253 254 // Reference an issue or pull from another comment, issue or PR 255 // All information is about the origin of the reference 256 RefRepoID int64 `xorm:"index"` // Repo where the referencing 257 RefIssueID int64 `xorm:"index"` 258 RefCommentID int64 `xorm:"index"` // 0 if origin is Issue title or content (or PR's) 259 RefAction references.XRefAction `xorm:"SMALLINT"` // What happens if RefIssueID resolves 260 RefIsPull bool 261 262 RefRepo *repo_model.Repository `xorm:"-"` 263 RefIssue *Issue `xorm:"-"` 264 RefComment *Comment `xorm:"-"` 265 266 Commits []*SignCommitWithStatuses `xorm:"-"` 267 OldCommit string `xorm:"-"` 268 NewCommit string `xorm:"-"` 269 CommitsNum int64 `xorm:"-"` 270 IsForcePush bool `xorm:"-"` 271} 272 273func init() { 274 db.RegisterModel(new(Comment)) 275} 276 277// PushActionContent is content of push pull comment 278type PushActionContent struct { 279 IsForcePush bool `json:"is_force_push"` 280 CommitIDs []string `json:"commit_ids"` 281} 282 283// LoadIssue loads issue from database 284func (c *Comment) LoadIssue() (err error) { 285 return c.loadIssue(db.GetEngine(db.DefaultContext)) 286} 287 288func (c *Comment) loadIssue(e db.Engine) (err error) { 289 if c.Issue != nil { 290 return nil 291 } 292 c.Issue, err = getIssueByID(e, c.IssueID) 293 return 294} 295 296// BeforeInsert will be invoked by XORM before inserting a record 297func (c *Comment) BeforeInsert() { 298 c.PatchQuoted = c.Patch 299 if !utf8.ValidString(c.Patch) { 300 c.PatchQuoted = strconv.Quote(c.Patch) 301 } 302} 303 304// BeforeUpdate will be invoked by XORM before updating a record 305func (c *Comment) BeforeUpdate() { 306 c.PatchQuoted = c.Patch 307 if !utf8.ValidString(c.Patch) { 308 c.PatchQuoted = strconv.Quote(c.Patch) 309 } 310} 311 312// AfterLoad is invoked from XORM after setting the values of all fields of this object. 313func (c *Comment) AfterLoad(session *xorm.Session) { 314 c.Patch = c.PatchQuoted 315 if len(c.PatchQuoted) > 0 && c.PatchQuoted[0] == '"' { 316 unquoted, err := strconv.Unquote(c.PatchQuoted) 317 if err == nil { 318 c.Patch = unquoted 319 } 320 } 321} 322 323func (c *Comment) loadPoster(e db.Engine) (err error) { 324 if c.PosterID <= 0 || c.Poster != nil { 325 return nil 326 } 327 328 c.Poster, err = user_model.GetUserByIDEngine(e, c.PosterID) 329 if err != nil { 330 if user_model.IsErrUserNotExist(err) { 331 c.PosterID = -1 332 c.Poster = user_model.NewGhostUser() 333 } else { 334 log.Error("getUserByID[%d]: %v", c.ID, err) 335 } 336 } 337 return err 338} 339 340// AfterDelete is invoked from XORM after the object is deleted. 341func (c *Comment) AfterDelete() { 342 if c.ID <= 0 { 343 return 344 } 345 346 _, err := repo_model.DeleteAttachmentsByComment(c.ID, true) 347 if err != nil { 348 log.Info("Could not delete files for comment %d on issue #%d: %s", c.ID, c.IssueID, err) 349 } 350} 351 352// HTMLURL formats a URL-string to the issue-comment 353func (c *Comment) HTMLURL() string { 354 err := c.LoadIssue() 355 if err != nil { // Silently dropping errors :unamused: 356 log.Error("LoadIssue(%d): %v", c.IssueID, err) 357 return "" 358 } 359 err = c.Issue.loadRepo(db.DefaultContext) 360 if err != nil { // Silently dropping errors :unamused: 361 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 362 return "" 363 } 364 if c.Type == CommentTypeCode { 365 if c.ReviewID == 0 { 366 return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) 367 } 368 if c.Review == nil { 369 if err := c.LoadReview(); err != nil { 370 log.Warn("LoadReview(%d): %v", c.ReviewID, err) 371 return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) 372 } 373 } 374 if c.Review.Type <= ReviewTypePending { 375 return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) 376 } 377 } 378 return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag()) 379} 380 381// APIURL formats a API-string to the issue-comment 382func (c *Comment) APIURL() string { 383 err := c.LoadIssue() 384 if err != nil { // Silently dropping errors :unamused: 385 log.Error("LoadIssue(%d): %v", c.IssueID, err) 386 return "" 387 } 388 err = c.Issue.loadRepo(db.DefaultContext) 389 if err != nil { // Silently dropping errors :unamused: 390 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 391 return "" 392 } 393 394 return fmt.Sprintf("%s/issues/comments/%d", c.Issue.Repo.APIURL(), c.ID) 395} 396 397// IssueURL formats a URL-string to the issue 398func (c *Comment) IssueURL() string { 399 err := c.LoadIssue() 400 if err != nil { // Silently dropping errors :unamused: 401 log.Error("LoadIssue(%d): %v", c.IssueID, err) 402 return "" 403 } 404 405 if c.Issue.IsPull { 406 return "" 407 } 408 409 err = c.Issue.loadRepo(db.DefaultContext) 410 if err != nil { // Silently dropping errors :unamused: 411 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 412 return "" 413 } 414 return c.Issue.HTMLURL() 415} 416 417// PRURL formats a URL-string to the pull-request 418func (c *Comment) PRURL() string { 419 err := c.LoadIssue() 420 if err != nil { // Silently dropping errors :unamused: 421 log.Error("LoadIssue(%d): %v", c.IssueID, err) 422 return "" 423 } 424 425 err = c.Issue.loadRepo(db.DefaultContext) 426 if err != nil { // Silently dropping errors :unamused: 427 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 428 return "" 429 } 430 431 if !c.Issue.IsPull { 432 return "" 433 } 434 return c.Issue.HTMLURL() 435} 436 437// CommentHashTag returns unique hash tag for comment id. 438func CommentHashTag(id int64) string { 439 return fmt.Sprintf("issuecomment-%d", id) 440} 441 442// HashTag returns unique hash tag for comment. 443func (c *Comment) HashTag() string { 444 return CommentHashTag(c.ID) 445} 446 447// EventTag returns unique event hash tag for comment. 448func (c *Comment) EventTag() string { 449 return fmt.Sprintf("event-%d", c.ID) 450} 451 452// LoadLabel if comment.Type is CommentTypeLabel, then load Label 453func (c *Comment) LoadLabel() error { 454 var label Label 455 has, err := db.GetEngine(db.DefaultContext).ID(c.LabelID).Get(&label) 456 if err != nil { 457 return err 458 } else if has { 459 c.Label = &label 460 } else { 461 // Ignore Label is deleted, but not clear this table 462 log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID) 463 } 464 465 return nil 466} 467 468// LoadProject if comment.Type is CommentTypeProject, then load project. 469func (c *Comment) LoadProject() error { 470 if c.OldProjectID > 0 { 471 var oldProject Project 472 has, err := db.GetEngine(db.DefaultContext).ID(c.OldProjectID).Get(&oldProject) 473 if err != nil { 474 return err 475 } else if has { 476 c.OldProject = &oldProject 477 } 478 } 479 480 if c.ProjectID > 0 { 481 var project Project 482 has, err := db.GetEngine(db.DefaultContext).ID(c.ProjectID).Get(&project) 483 if err != nil { 484 return err 485 } else if has { 486 c.Project = &project 487 } 488 } 489 490 return nil 491} 492 493// LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone 494func (c *Comment) LoadMilestone() error { 495 if c.OldMilestoneID > 0 { 496 var oldMilestone Milestone 497 has, err := db.GetEngine(db.DefaultContext).ID(c.OldMilestoneID).Get(&oldMilestone) 498 if err != nil { 499 return err 500 } else if has { 501 c.OldMilestone = &oldMilestone 502 } 503 } 504 505 if c.MilestoneID > 0 { 506 var milestone Milestone 507 has, err := db.GetEngine(db.DefaultContext).ID(c.MilestoneID).Get(&milestone) 508 if err != nil { 509 return err 510 } else if has { 511 c.Milestone = &milestone 512 } 513 } 514 return nil 515} 516 517// LoadPoster loads comment poster 518func (c *Comment) LoadPoster() error { 519 return c.loadPoster(db.GetEngine(db.DefaultContext)) 520} 521 522// LoadAttachments loads attachments (it never returns error, the error during `GetAttachmentsByCommentIDCtx` is ignored) 523func (c *Comment) LoadAttachments() error { 524 if len(c.Attachments) > 0 { 525 return nil 526 } 527 528 var err error 529 c.Attachments, err = repo_model.GetAttachmentsByCommentIDCtx(db.DefaultContext, c.ID) 530 if err != nil { 531 log.Error("getAttachmentsByCommentID[%d]: %v", c.ID, err) 532 } 533 return nil 534} 535 536// UpdateAttachments update attachments by UUIDs for the comment 537func (c *Comment) UpdateAttachments(uuids []string) error { 538 ctx, committer, err := db.TxContext() 539 if err != nil { 540 return err 541 } 542 defer committer.Close() 543 544 attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, uuids) 545 if err != nil { 546 return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", uuids, err) 547 } 548 for i := 0; i < len(attachments); i++ { 549 attachments[i].IssueID = c.IssueID 550 attachments[i].CommentID = c.ID 551 if err := repo_model.UpdateAttachmentCtx(ctx, attachments[i]); err != nil { 552 return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err) 553 } 554 } 555 return committer.Commit() 556} 557 558// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees 559func (c *Comment) LoadAssigneeUserAndTeam() error { 560 var err error 561 562 if c.AssigneeID > 0 && c.Assignee == nil { 563 c.Assignee, err = user_model.GetUserByIDCtx(db.DefaultContext, c.AssigneeID) 564 if err != nil { 565 if !user_model.IsErrUserNotExist(err) { 566 return err 567 } 568 c.Assignee = user_model.NewGhostUser() 569 } 570 } else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil { 571 if err = c.LoadIssue(); err != nil { 572 return err 573 } 574 575 if err = c.Issue.LoadRepo(); err != nil { 576 return err 577 } 578 579 if err = c.Issue.Repo.GetOwner(db.DefaultContext); err != nil { 580 return err 581 } 582 583 if c.Issue.Repo.Owner.IsOrganization() { 584 c.AssigneeTeam, err = GetTeamByID(c.AssigneeTeamID) 585 if err != nil && !IsErrTeamNotExist(err) { 586 return err 587 } 588 } 589 } 590 return nil 591} 592 593// LoadResolveDoer if comment.Type is CommentTypeCode and ResolveDoerID not zero, then load resolveDoer 594func (c *Comment) LoadResolveDoer() (err error) { 595 if c.ResolveDoerID == 0 || c.Type != CommentTypeCode { 596 return nil 597 } 598 c.ResolveDoer, err = user_model.GetUserByIDCtx(db.DefaultContext, c.ResolveDoerID) 599 if err != nil { 600 if user_model.IsErrUserNotExist(err) { 601 c.ResolveDoer = user_model.NewGhostUser() 602 err = nil 603 } 604 } 605 return 606} 607 608// IsResolved check if an code comment is resolved 609func (c *Comment) IsResolved() bool { 610 return c.ResolveDoerID != 0 && c.Type == CommentTypeCode 611} 612 613// LoadDepIssueDetails loads Dependent Issue Details 614func (c *Comment) LoadDepIssueDetails() (err error) { 615 if c.DependentIssueID <= 0 || c.DependentIssue != nil { 616 return nil 617 } 618 c.DependentIssue, err = getIssueByID(db.GetEngine(db.DefaultContext), c.DependentIssueID) 619 return err 620} 621 622// LoadTime loads the associated time for a CommentTypeAddTimeManual 623func (c *Comment) LoadTime() error { 624 if c.Time != nil || c.TimeID == 0 { 625 return nil 626 } 627 var err error 628 c.Time, err = GetTrackedTimeByID(c.TimeID) 629 return err 630} 631 632func (c *Comment) loadReactions(e db.Engine, repo *repo_model.Repository) (err error) { 633 if c.Reactions != nil { 634 return nil 635 } 636 c.Reactions, _, err = findReactions(e, FindReactionsOptions{ 637 IssueID: c.IssueID, 638 CommentID: c.ID, 639 }) 640 if err != nil { 641 return err 642 } 643 // Load reaction user data 644 if _, err := c.Reactions.loadUsers(e, repo); err != nil { 645 return err 646 } 647 return nil 648} 649 650// LoadReactions loads comment reactions 651func (c *Comment) LoadReactions(repo *repo_model.Repository) error { 652 return c.loadReactions(db.GetEngine(db.DefaultContext), repo) 653} 654 655func (c *Comment) loadReview(e db.Engine) (err error) { 656 if c.Review == nil { 657 if c.Review, err = getReviewByID(e, c.ReviewID); err != nil { 658 return err 659 } 660 } 661 c.Review.Issue = c.Issue 662 return nil 663} 664 665// LoadReview loads the associated review 666func (c *Comment) LoadReview() error { 667 return c.loadReview(db.GetEngine(db.DefaultContext)) 668} 669 670var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`) 671 672func (c *Comment) checkInvalidation(doer *user_model.User, repo *git.Repository, branch string) error { 673 // FIXME differentiate between previous and proposed line 674 commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine())) 675 if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) { 676 c.Invalidated = true 677 return UpdateComment(c, doer) 678 } 679 if err != nil { 680 return err 681 } 682 if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() { 683 c.Invalidated = true 684 return UpdateComment(c, doer) 685 } 686 return nil 687} 688 689// CheckInvalidation checks if the line of code comment got changed by another commit. 690// If the line got changed the comment is going to be invalidated. 691func (c *Comment) CheckInvalidation(repo *git.Repository, doer *user_model.User, branch string) error { 692 return c.checkInvalidation(doer, repo, branch) 693} 694 695// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. 696func (c *Comment) DiffSide() string { 697 if c.Line < 0 { 698 return "previous" 699 } 700 return "proposed" 701} 702 703// UnsignedLine returns the LOC of the code comment without + or - 704func (c *Comment) UnsignedLine() uint64 { 705 if c.Line < 0 { 706 return uint64(c.Line * -1) 707 } 708 return uint64(c.Line) 709} 710 711// CodeCommentURL returns the url to a comment in code 712func (c *Comment) CodeCommentURL() string { 713 err := c.LoadIssue() 714 if err != nil { // Silently dropping errors :unamused: 715 log.Error("LoadIssue(%d): %v", c.IssueID, err) 716 return "" 717 } 718 err = c.Issue.loadRepo(db.DefaultContext) 719 if err != nil { // Silently dropping errors :unamused: 720 log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) 721 return "" 722 } 723 return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) 724} 725 726// LoadPushCommits Load push commits 727func (c *Comment) LoadPushCommits() (err error) { 728 if c.Content == "" || c.Commits != nil || c.Type != CommentTypePullPush { 729 return nil 730 } 731 732 var data PushActionContent 733 734 err = json.Unmarshal([]byte(c.Content), &data) 735 if err != nil { 736 return 737 } 738 739 c.IsForcePush = data.IsForcePush 740 741 if c.IsForcePush { 742 if len(data.CommitIDs) != 2 { 743 return nil 744 } 745 c.OldCommit = data.CommitIDs[0] 746 c.NewCommit = data.CommitIDs[1] 747 } else { 748 repoPath := c.Issue.Repo.RepoPath() 749 gitRepo, err := git.OpenRepository(repoPath) 750 if err != nil { 751 return err 752 } 753 defer gitRepo.Close() 754 755 c.Commits = ConvertFromGitCommit(gitRepo.GetCommitsFromIDs(data.CommitIDs), c.Issue.Repo) 756 c.CommitsNum = int64(len(c.Commits)) 757 } 758 759 return err 760} 761 762func createComment(ctx context.Context, opts *CreateCommentOptions) (_ *Comment, err error) { 763 e := db.GetEngine(ctx) 764 var LabelID int64 765 if opts.Label != nil { 766 LabelID = opts.Label.ID 767 } 768 769 comment := &Comment{ 770 Type: opts.Type, 771 PosterID: opts.Doer.ID, 772 Poster: opts.Doer, 773 IssueID: opts.Issue.ID, 774 LabelID: LabelID, 775 OldMilestoneID: opts.OldMilestoneID, 776 MilestoneID: opts.MilestoneID, 777 OldProjectID: opts.OldProjectID, 778 ProjectID: opts.ProjectID, 779 TimeID: opts.TimeID, 780 RemovedAssignee: opts.RemovedAssignee, 781 AssigneeID: opts.AssigneeID, 782 AssigneeTeamID: opts.AssigneeTeamID, 783 CommitID: opts.CommitID, 784 CommitSHA: opts.CommitSHA, 785 Line: opts.LineNum, 786 Content: opts.Content, 787 OldTitle: opts.OldTitle, 788 NewTitle: opts.NewTitle, 789 OldRef: opts.OldRef, 790 NewRef: opts.NewRef, 791 DependentIssueID: opts.DependentIssueID, 792 TreePath: opts.TreePath, 793 ReviewID: opts.ReviewID, 794 Patch: opts.Patch, 795 RefRepoID: opts.RefRepoID, 796 RefIssueID: opts.RefIssueID, 797 RefCommentID: opts.RefCommentID, 798 RefAction: opts.RefAction, 799 RefIsPull: opts.RefIsPull, 800 IsForcePush: opts.IsForcePush, 801 Invalidated: opts.Invalidated, 802 } 803 if _, err = e.Insert(comment); err != nil { 804 return nil, err 805 } 806 807 if err = opts.Repo.GetOwner(ctx); err != nil { 808 return nil, err 809 } 810 811 if err = updateCommentInfos(ctx, opts, comment); err != nil { 812 return nil, err 813 } 814 815 if err = comment.addCrossReferences(ctx, opts.Doer, false); err != nil { 816 return nil, err 817 } 818 819 return comment, nil 820} 821 822func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment *Comment) (err error) { 823 e := db.GetEngine(ctx) 824 // Check comment type. 825 switch opts.Type { 826 case CommentTypeCode: 827 if comment.ReviewID != 0 { 828 if comment.Review == nil { 829 if err := comment.loadReview(e); err != nil { 830 return err 831 } 832 } 833 if comment.Review.Type <= ReviewTypePending { 834 return nil 835 } 836 } 837 fallthrough 838 case CommentTypeComment: 839 if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { 840 return err 841 } 842 fallthrough 843 case CommentTypeReview: 844 // Check attachments 845 attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) 846 if err != nil { 847 return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err) 848 } 849 850 for i := range attachments { 851 attachments[i].IssueID = opts.Issue.ID 852 attachments[i].CommentID = comment.ID 853 // No assign value could be 0, so ignore AllCols(). 854 if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { 855 return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) 856 } 857 } 858 case CommentTypeReopen, CommentTypeClose: 859 if err = opts.Issue.updateClosedNum(ctx); err != nil { 860 return err 861 } 862 } 863 // update the issue's updated_unix column 864 return updateIssueCols(ctx, opts.Issue, "updated_unix") 865} 866 867func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) { 868 var content string 869 var commentType CommentType 870 871 // newDeadline = 0 means deleting 872 if newDeadlineUnix == 0 { 873 commentType = CommentTypeRemovedDeadline 874 content = issue.DeadlineUnix.Format("2006-01-02") 875 } else if issue.DeadlineUnix == 0 { 876 // Check if the new date was added or modified 877 // If the actual deadline is 0 => deadline added 878 commentType = CommentTypeAddedDeadline 879 content = newDeadlineUnix.Format("2006-01-02") 880 } else { // Otherwise modified 881 commentType = CommentTypeModifiedDeadline 882 content = newDeadlineUnix.Format("2006-01-02") + "|" + issue.DeadlineUnix.Format("2006-01-02") 883 } 884 885 if err := issue.loadRepo(ctx); err != nil { 886 return nil, err 887 } 888 889 opts := &CreateCommentOptions{ 890 Type: commentType, 891 Doer: doer, 892 Repo: issue.Repo, 893 Issue: issue, 894 Content: content, 895 } 896 comment, err := createComment(ctx, opts) 897 if err != nil { 898 return nil, err 899 } 900 return comment, nil 901} 902 903// Creates issue dependency comment 904func createIssueDependencyComment(ctx context.Context, doer *user_model.User, issue, dependentIssue *Issue, add bool) (err error) { 905 cType := CommentTypeAddDependency 906 if !add { 907 cType = CommentTypeRemoveDependency 908 } 909 if err = issue.loadRepo(ctx); err != nil { 910 return 911 } 912 913 // Make two comments, one in each issue 914 opts := &CreateCommentOptions{ 915 Type: cType, 916 Doer: doer, 917 Repo: issue.Repo, 918 Issue: issue, 919 DependentIssueID: dependentIssue.ID, 920 } 921 if _, err = createComment(ctx, opts); err != nil { 922 return 923 } 924 925 opts = &CreateCommentOptions{ 926 Type: cType, 927 Doer: doer, 928 Repo: issue.Repo, 929 Issue: dependentIssue, 930 DependentIssueID: issue.ID, 931 } 932 _, err = createComment(ctx, opts) 933 return 934} 935 936// CreateCommentOptions defines options for creating comment 937type CreateCommentOptions struct { 938 Type CommentType 939 Doer *user_model.User 940 Repo *repo_model.Repository 941 Issue *Issue 942 Label *Label 943 944 DependentIssueID int64 945 OldMilestoneID int64 946 MilestoneID int64 947 OldProjectID int64 948 ProjectID int64 949 TimeID int64 950 AssigneeID int64 951 AssigneeTeamID int64 952 RemovedAssignee bool 953 OldTitle string 954 NewTitle string 955 OldRef string 956 NewRef string 957 CommitID int64 958 CommitSHA string 959 Patch string 960 LineNum int64 961 TreePath string 962 ReviewID int64 963 Content string 964 Attachments []string // UUIDs of attachments 965 RefRepoID int64 966 RefIssueID int64 967 RefCommentID int64 968 RefAction references.XRefAction 969 RefIsPull bool 970 IsForcePush bool 971 Invalidated bool 972} 973 974// CreateComment creates comment of issue or commit. 975func CreateComment(opts *CreateCommentOptions) (comment *Comment, err error) { 976 ctx, committer, err := db.TxContext() 977 if err != nil { 978 return nil, err 979 } 980 defer committer.Close() 981 982 comment, err = createComment(ctx, opts) 983 if err != nil { 984 return nil, err 985 } 986 987 if err = committer.Commit(); err != nil { 988 return nil, err 989 } 990 991 return comment, nil 992} 993 994// CreateRefComment creates a commit reference comment to issue. 995func CreateRefComment(doer *user_model.User, repo *repo_model.Repository, issue *Issue, content, commitSHA string) error { 996 if len(commitSHA) == 0 { 997 return fmt.Errorf("cannot create reference with empty commit SHA") 998 } 999 1000 // Check if same reference from same commit has already existed. 1001 has, err := db.GetEngine(db.DefaultContext).Get(&Comment{ 1002 Type: CommentTypeCommitRef, 1003 IssueID: issue.ID, 1004 CommitSHA: commitSHA, 1005 }) 1006 if err != nil { 1007 return fmt.Errorf("check reference comment: %v", err) 1008 } else if has { 1009 return nil 1010 } 1011 1012 _, err = CreateComment(&CreateCommentOptions{ 1013 Type: CommentTypeCommitRef, 1014 Doer: doer, 1015 Repo: repo, 1016 Issue: issue, 1017 CommitSHA: commitSHA, 1018 Content: content, 1019 }) 1020 return err 1021} 1022 1023// GetCommentByID returns the comment by given ID. 1024func GetCommentByID(id int64) (*Comment, error) { 1025 return getCommentByID(db.GetEngine(db.DefaultContext), id) 1026} 1027 1028func getCommentByID(e db.Engine, id int64) (*Comment, error) { 1029 c := new(Comment) 1030 has, err := e.ID(id).Get(c) 1031 if err != nil { 1032 return nil, err 1033 } else if !has { 1034 return nil, ErrCommentNotExist{id, 0} 1035 } 1036 return c, nil 1037} 1038 1039// FindCommentsOptions describes the conditions to Find comments 1040type FindCommentsOptions struct { 1041 db.ListOptions 1042 RepoID int64 1043 IssueID int64 1044 ReviewID int64 1045 Since int64 1046 Before int64 1047 Line int64 1048 TreePath string 1049 Type CommentType 1050} 1051 1052func (opts *FindCommentsOptions) toConds() builder.Cond { 1053 cond := builder.NewCond() 1054 if opts.RepoID > 0 { 1055 cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID}) 1056 } 1057 if opts.IssueID > 0 { 1058 cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID}) 1059 } 1060 if opts.ReviewID > 0 { 1061 cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) 1062 } 1063 if opts.Since > 0 { 1064 cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) 1065 } 1066 if opts.Before > 0 { 1067 cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before}) 1068 } 1069 if opts.Type != CommentTypeUnknown { 1070 cond = cond.And(builder.Eq{"comment.type": opts.Type}) 1071 } 1072 if opts.Line != 0 { 1073 cond = cond.And(builder.Eq{"comment.line": opts.Line}) 1074 } 1075 if len(opts.TreePath) > 0 { 1076 cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) 1077 } 1078 return cond 1079} 1080 1081func findComments(e db.Engine, opts *FindCommentsOptions) ([]*Comment, error) { 1082 comments := make([]*Comment, 0, 10) 1083 sess := e.Where(opts.toConds()) 1084 if opts.RepoID > 0 { 1085 sess.Join("INNER", "issue", "issue.id = comment.issue_id") 1086 } 1087 1088 if opts.Page != 0 { 1089 sess = db.SetSessionPagination(sess, opts) 1090 } 1091 1092 // WARNING: If you change this order you will need to fix createCodeComment 1093 1094 return comments, sess. 1095 Asc("comment.created_unix"). 1096 Asc("comment.id"). 1097 Find(&comments) 1098} 1099 1100// FindComments returns all comments according options 1101func FindComments(opts *FindCommentsOptions) ([]*Comment, error) { 1102 return findComments(db.GetEngine(db.DefaultContext), opts) 1103} 1104 1105// CountComments count all comments according options by ignoring pagination 1106func CountComments(opts *FindCommentsOptions) (int64, error) { 1107 sess := db.GetEngine(db.DefaultContext).Where(opts.toConds()) 1108 if opts.RepoID > 0 { 1109 sess.Join("INNER", "issue", "issue.id = comment.issue_id") 1110 } 1111 return sess.Count(&Comment{}) 1112} 1113 1114// UpdateComment updates information of comment. 1115func UpdateComment(c *Comment, doer *user_model.User) error { 1116 ctx, committer, err := db.TxContext() 1117 if err != nil { 1118 return err 1119 } 1120 defer committer.Close() 1121 sess := db.GetEngine(ctx) 1122 1123 if _, err := sess.ID(c.ID).AllCols().Update(c); err != nil { 1124 return err 1125 } 1126 if err := c.loadIssue(sess); err != nil { 1127 return err 1128 } 1129 if err := c.addCrossReferences(ctx, doer, true); err != nil { 1130 return err 1131 } 1132 if err := committer.Commit(); err != nil { 1133 return fmt.Errorf("Commit: %v", err) 1134 } 1135 1136 return nil 1137} 1138 1139// DeleteComment deletes the comment 1140func DeleteComment(comment *Comment) error { 1141 ctx, committer, err := db.TxContext() 1142 if err != nil { 1143 return err 1144 } 1145 defer committer.Close() 1146 1147 if err := deleteComment(db.GetEngine(ctx), comment); err != nil { 1148 return err 1149 } 1150 1151 return committer.Commit() 1152} 1153 1154func deleteComment(e db.Engine, comment *Comment) error { 1155 if _, err := e.Delete(&Comment{ 1156 ID: comment.ID, 1157 }); err != nil { 1158 return err 1159 } 1160 1161 if _, err := e.Delete(&issues.ContentHistory{ 1162 CommentID: comment.ID, 1163 }); err != nil { 1164 return err 1165 } 1166 1167 if comment.Type == CommentTypeComment { 1168 if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil { 1169 return err 1170 } 1171 } 1172 if _, err := e.Where("comment_id = ?", comment.ID).Cols("is_deleted").Update(&Action{IsDeleted: true}); err != nil { 1173 return err 1174 } 1175 1176 if err := comment.neuterCrossReferences(e); err != nil { 1177 return err 1178 } 1179 1180 return deleteReaction(e, &ReactionOptions{Comment: comment}) 1181} 1182 1183// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS 1184type CodeComments map[string]map[int64][]*Comment 1185 1186func fetchCodeComments(ctx context.Context, issue *Issue, currentUser *user_model.User) (CodeComments, error) { 1187 return fetchCodeCommentsByReview(ctx, issue, currentUser, nil) 1188} 1189 1190func fetchCodeCommentsByReview(ctx context.Context, issue *Issue, currentUser *user_model.User, review *Review) (CodeComments, error) { 1191 pathToLineToComment := make(CodeComments) 1192 if review == nil { 1193 review = &Review{ID: 0} 1194 } 1195 opts := FindCommentsOptions{ 1196 Type: CommentTypeCode, 1197 IssueID: issue.ID, 1198 ReviewID: review.ID, 1199 } 1200 1201 comments, err := findCodeComments(ctx, opts, issue, currentUser, review) 1202 if err != nil { 1203 return nil, err 1204 } 1205 1206 for _, comment := range comments { 1207 if pathToLineToComment[comment.TreePath] == nil { 1208 pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment) 1209 } 1210 pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment) 1211 } 1212 return pathToLineToComment, nil 1213} 1214 1215func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issue, currentUser *user_model.User, review *Review) ([]*Comment, error) { 1216 var comments []*Comment 1217 if review == nil { 1218 review = &Review{ID: 0} 1219 } 1220 conds := opts.toConds() 1221 if review.ID == 0 { 1222 conds = conds.And(builder.Eq{"invalidated": false}) 1223 } 1224 e := db.GetEngine(ctx) 1225 if err := e.Where(conds). 1226 Asc("comment.created_unix"). 1227 Asc("comment.id"). 1228 Find(&comments); err != nil { 1229 return nil, err 1230 } 1231 1232 if err := issue.loadRepo(ctx); err != nil { 1233 return nil, err 1234 } 1235 1236 if err := CommentList(comments).loadPosters(e); err != nil { 1237 return nil, err 1238 } 1239 1240 // Find all reviews by ReviewID 1241 reviews := make(map[int64]*Review) 1242 ids := make([]int64, 0, len(comments)) 1243 for _, comment := range comments { 1244 if comment.ReviewID != 0 { 1245 ids = append(ids, comment.ReviewID) 1246 } 1247 } 1248 if err := e.In("id", ids).Find(&reviews); err != nil { 1249 return nil, err 1250 } 1251 1252 n := 0 1253 for _, comment := range comments { 1254 if re, ok := reviews[comment.ReviewID]; ok && re != nil { 1255 // If the review is pending only the author can see the comments (except if the review is set) 1256 if review.ID == 0 && re.Type == ReviewTypePending && 1257 (currentUser == nil || currentUser.ID != re.ReviewerID) { 1258 continue 1259 } 1260 comment.Review = re 1261 } 1262 comments[n] = comment 1263 n++ 1264 1265 if err := comment.LoadResolveDoer(); err != nil { 1266 return nil, err 1267 } 1268 1269 if err := comment.LoadReactions(issue.Repo); err != nil { 1270 return nil, err 1271 } 1272 1273 var err error 1274 if comment.RenderedContent, err = markdown.RenderString(&markup.RenderContext{ 1275 URLPrefix: issue.Repo.Link(), 1276 Metas: issue.Repo.ComposeMetas(), 1277 }, comment.Content); err != nil { 1278 return nil, err 1279 } 1280 } 1281 return comments[:n], nil 1282} 1283 1284// FetchCodeCommentsByLine fetches the code comments for a given treePath and line number 1285func FetchCodeCommentsByLine(issue *Issue, currentUser *user_model.User, treePath string, line int64) ([]*Comment, error) { 1286 opts := FindCommentsOptions{ 1287 Type: CommentTypeCode, 1288 IssueID: issue.ID, 1289 TreePath: treePath, 1290 Line: line, 1291 } 1292 return findCodeComments(db.DefaultContext, opts, issue, currentUser, nil) 1293} 1294 1295// FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line 1296func FetchCodeComments(issue *Issue, currentUser *user_model.User) (CodeComments, error) { 1297 return fetchCodeComments(db.DefaultContext, issue, currentUser) 1298} 1299 1300// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id 1301func UpdateCommentsMigrationsByType(tp structs.GitServiceType, originalAuthorID string, posterID int64) error { 1302 _, err := db.GetEngine(db.DefaultContext).Table("comment"). 1303 Where(builder.In("issue_id", 1304 builder.Select("issue.id"). 1305 From("issue"). 1306 InnerJoin("repository", "issue.repo_id = repository.id"). 1307 Where(builder.Eq{ 1308 "repository.original_service_type": tp, 1309 }), 1310 )). 1311 And("comment.original_author_id = ?", originalAuthorID). 1312 Update(map[string]interface{}{ 1313 "poster_id": posterID, 1314 "original_author": "", 1315 "original_author_id": 0, 1316 }) 1317 return err 1318} 1319 1320// CreatePushPullComment create push code to pull base comment 1321func CreatePushPullComment(pusher *user_model.User, pr *PullRequest, oldCommitID, newCommitID string) (comment *Comment, err error) { 1322 if pr.HasMerged || oldCommitID == "" || newCommitID == "" { 1323 return nil, nil 1324 } 1325 1326 ops := &CreateCommentOptions{ 1327 Type: CommentTypePullPush, 1328 Doer: pusher, 1329 Repo: pr.BaseRepo, 1330 } 1331 1332 var data PushActionContent 1333 1334 data.CommitIDs, data.IsForcePush, err = getCommitIDsFromRepo(pr.BaseRepo, oldCommitID, newCommitID, pr.BaseBranch) 1335 if err != nil { 1336 return nil, err 1337 } 1338 1339 ops.Issue = pr.Issue 1340 1341 dataJSON, err := json.Marshal(data) 1342 if err != nil { 1343 return nil, err 1344 } 1345 1346 ops.Content = string(dataJSON) 1347 1348 comment, err = CreateComment(ops) 1349 1350 return 1351} 1352 1353// getCommitsFromRepo get commit IDs from repo in between oldCommitID and newCommitID 1354// isForcePush will be true if oldCommit isn't on the branch 1355// Commit on baseBranch will skip 1356func getCommitIDsFromRepo(repo *repo_model.Repository, oldCommitID, newCommitID, baseBranch string) (commitIDs []string, isForcePush bool, err error) { 1357 repoPath := repo.RepoPath() 1358 gitRepo, err := git.OpenRepository(repoPath) 1359 if err != nil { 1360 return nil, false, err 1361 } 1362 defer gitRepo.Close() 1363 1364 oldCommit, err := gitRepo.GetCommit(oldCommitID) 1365 if err != nil { 1366 return nil, false, err 1367 } 1368 1369 if err = oldCommit.LoadBranchName(); err != nil { 1370 return nil, false, err 1371 } 1372 1373 if len(oldCommit.Branch) == 0 { 1374 commitIDs = make([]string, 2) 1375 commitIDs[0] = oldCommitID 1376 commitIDs[1] = newCommitID 1377 1378 return commitIDs, true, err 1379 } 1380 1381 newCommit, err := gitRepo.GetCommit(newCommitID) 1382 if err != nil { 1383 return nil, false, err 1384 } 1385 1386 commits, err := newCommit.CommitsBeforeUntil(oldCommitID) 1387 if err != nil { 1388 return nil, false, err 1389 } 1390 1391 commitIDs = make([]string, 0, len(commits)) 1392 commitChecks := make(map[string]*commitBranchCheckItem) 1393 1394 for _, commit := range commits { 1395 commitChecks[commit.ID.String()] = &commitBranchCheckItem{ 1396 Commit: commit, 1397 Checked: false, 1398 } 1399 } 1400 1401 if err = commitBranchCheck(gitRepo, newCommit, oldCommitID, baseBranch, commitChecks); err != nil { 1402 return 1403 } 1404 1405 for i := len(commits) - 1; i >= 0; i-- { 1406 commitID := commits[i].ID.String() 1407 if item, ok := commitChecks[commitID]; ok && item.Checked { 1408 commitIDs = append(commitIDs, commitID) 1409 } 1410 } 1411 1412 return 1413} 1414 1415type commitBranchCheckItem struct { 1416 Commit *git.Commit 1417 Checked bool 1418} 1419 1420func commitBranchCheck(gitRepo *git.Repository, startCommit *git.Commit, endCommitID, baseBranch string, commitList map[string]*commitBranchCheckItem) error { 1421 if startCommit.ID.String() == endCommitID { 1422 return nil 1423 } 1424 1425 checkStack := make([]string, 0, 10) 1426 checkStack = append(checkStack, startCommit.ID.String()) 1427 1428 for len(checkStack) > 0 { 1429 commitID := checkStack[0] 1430 checkStack = checkStack[1:] 1431 1432 item, ok := commitList[commitID] 1433 if !ok { 1434 continue 1435 } 1436 1437 if item.Commit.ID.String() == endCommitID { 1438 continue 1439 } 1440 1441 if err := item.Commit.LoadBranchName(); err != nil { 1442 return err 1443 } 1444 1445 if item.Commit.Branch == baseBranch { 1446 continue 1447 } 1448 1449 if item.Checked { 1450 continue 1451 } 1452 1453 item.Checked = true 1454 1455 parentNum := item.Commit.ParentCount() 1456 for i := 0; i < parentNum; i++ { 1457 parentCommit, err := item.Commit.Parent(i) 1458 if err != nil { 1459 return err 1460 } 1461 checkStack = append(checkStack, parentCommit.ID.String()) 1462 } 1463 } 1464 return nil 1465} 1466