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