Платформа ЦРНП "Мирокод" для разработки проектов
https://git.mirocod.ru
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1388 lines
35 KiB
1388 lines
35 KiB
// Copyright 2018 The Gitea Authors. |
|
// Copyright 2016 The Gogs Authors. |
|
// All rights reserved. |
|
// Use of this source code is governed by a MIT-style |
|
// license that can be found in the LICENSE file. |
|
|
|
package models |
|
|
|
import ( |
|
"container/list" |
|
"fmt" |
|
"regexp" |
|
"strconv" |
|
"strings" |
|
"unicode/utf8" |
|
|
|
"code.gitea.io/gitea/modules/git" |
|
"code.gitea.io/gitea/modules/log" |
|
"code.gitea.io/gitea/modules/markup/markdown" |
|
"code.gitea.io/gitea/modules/references" |
|
"code.gitea.io/gitea/modules/structs" |
|
"code.gitea.io/gitea/modules/timeutil" |
|
jsoniter "github.com/json-iterator/go" |
|
|
|
"xorm.io/builder" |
|
"xorm.io/xorm" |
|
) |
|
|
|
// CommentType defines whether a comment is just a simple comment, an action (like close) or a reference. |
|
type CommentType int |
|
|
|
// define unknown comment type |
|
const ( |
|
CommentTypeUnknown CommentType = -1 |
|
) |
|
|
|
// Enumerate all the comment types |
|
const ( |
|
// 0 Plain comment, can be associated with a commit (CommitID > 0) and a line (LineNum > 0) |
|
CommentTypeComment CommentType = iota |
|
CommentTypeReopen // 1 |
|
CommentTypeClose // 2 |
|
|
|
// 3 References. |
|
CommentTypeIssueRef |
|
// 4 Reference from a commit (not part of a pull request) |
|
CommentTypeCommitRef |
|
// 5 Reference from a comment |
|
CommentTypeCommentRef |
|
// 6 Reference from a pull request |
|
CommentTypePullRef |
|
// 7 Labels changed |
|
CommentTypeLabel |
|
// 8 Milestone changed |
|
CommentTypeMilestone |
|
// 9 Assignees changed |
|
CommentTypeAssignees |
|
// 10 Change Title |
|
CommentTypeChangeTitle |
|
// 11 Delete Branch |
|
CommentTypeDeleteBranch |
|
// 12 Start a stopwatch for time tracking |
|
CommentTypeStartTracking |
|
// 13 Stop a stopwatch for time tracking |
|
CommentTypeStopTracking |
|
// 14 Add time manual for time tracking |
|
CommentTypeAddTimeManual |
|
// 15 Cancel a stopwatch for time tracking |
|
CommentTypeCancelTracking |
|
// 16 Added a due date |
|
CommentTypeAddedDeadline |
|
// 17 Modified the due date |
|
CommentTypeModifiedDeadline |
|
// 18 Removed a due date |
|
CommentTypeRemovedDeadline |
|
// 19 Dependency added |
|
CommentTypeAddDependency |
|
// 20 Dependency removed |
|
CommentTypeRemoveDependency |
|
// 21 Comment a line of code |
|
CommentTypeCode |
|
// 22 Reviews a pull request by giving general feedback |
|
CommentTypeReview |
|
// 23 Lock an issue, giving only collaborators access |
|
CommentTypeLock |
|
// 24 Unlocks a previously locked issue |
|
CommentTypeUnlock |
|
// 25 Change pull request's target branch |
|
CommentTypeChangeTargetBranch |
|
// 26 Delete time manual for time tracking |
|
CommentTypeDeleteTimeManual |
|
// 27 add or remove Request from one |
|
CommentTypeReviewRequest |
|
// 28 merge pull request |
|
CommentTypeMergePull |
|
// 29 push to PR head branch |
|
CommentTypePullPush |
|
// 30 Project changed |
|
CommentTypeProject |
|
// 31 Project board changed |
|
CommentTypeProjectBoard |
|
// Dismiss Review |
|
CommentTypeDismissReview |
|
) |
|
|
|
// CommentTag defines comment tag type |
|
type CommentTag int |
|
|
|
// Enumerate all the comment tag types |
|
const ( |
|
CommentTagNone CommentTag = iota |
|
CommentTagPoster |
|
CommentTagWriter |
|
CommentTagOwner |
|
) |
|
|
|
// Comment represents a comment in commit and issue page. |
|
type Comment struct { |
|
ID int64 `xorm:"pk autoincr"` |
|
Type CommentType `xorm:"INDEX"` |
|
PosterID int64 `xorm:"INDEX"` |
|
Poster *User `xorm:"-"` |
|
OriginalAuthor string |
|
OriginalAuthorID int64 |
|
IssueID int64 `xorm:"INDEX"` |
|
Issue *Issue `xorm:"-"` |
|
LabelID int64 |
|
Label *Label `xorm:"-"` |
|
AddedLabels []*Label `xorm:"-"` |
|
RemovedLabels []*Label `xorm:"-"` |
|
OldProjectID int64 |
|
ProjectID int64 |
|
OldProject *Project `xorm:"-"` |
|
Project *Project `xorm:"-"` |
|
OldMilestoneID int64 |
|
MilestoneID int64 |
|
OldMilestone *Milestone `xorm:"-"` |
|
Milestone *Milestone `xorm:"-"` |
|
TimeID int64 |
|
Time *TrackedTime `xorm:"-"` |
|
AssigneeID int64 |
|
RemovedAssignee bool |
|
Assignee *User `xorm:"-"` |
|
AssigneeTeamID int64 `xorm:"NOT NULL DEFAULT 0"` |
|
AssigneeTeam *Team `xorm:"-"` |
|
ResolveDoerID int64 |
|
ResolveDoer *User `xorm:"-"` |
|
OldTitle string |
|
NewTitle string |
|
OldRef string |
|
NewRef string |
|
DependentIssueID int64 |
|
DependentIssue *Issue `xorm:"-"` |
|
|
|
CommitID int64 |
|
Line int64 // - previous line / + proposed line |
|
TreePath string |
|
Content string `xorm:"TEXT"` |
|
RenderedContent string `xorm:"-"` |
|
|
|
// Path represents the 4 lines of code cemented by this comment |
|
Patch string `xorm:"-"` |
|
PatchQuoted string `xorm:"TEXT patch"` |
|
|
|
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` |
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` |
|
|
|
// Reference issue in commit message |
|
CommitSHA string `xorm:"VARCHAR(40)"` |
|
|
|
Attachments []*Attachment `xorm:"-"` |
|
Reactions ReactionList `xorm:"-"` |
|
|
|
// For view issue page. |
|
ShowTag CommentTag `xorm:"-"` |
|
|
|
Review *Review `xorm:"-"` |
|
ReviewID int64 `xorm:"index"` |
|
Invalidated bool |
|
|
|
// Reference an issue or pull from another comment, issue or PR |
|
// All information is about the origin of the reference |
|
RefRepoID int64 `xorm:"index"` // Repo where the referencing |
|
RefIssueID int64 `xorm:"index"` |
|
RefCommentID int64 `xorm:"index"` // 0 if origin is Issue title or content (or PR's) |
|
RefAction references.XRefAction `xorm:"SMALLINT"` // What hapens if RefIssueID resolves |
|
RefIsPull bool |
|
|
|
RefRepo *Repository `xorm:"-"` |
|
RefIssue *Issue `xorm:"-"` |
|
RefComment *Comment `xorm:"-"` |
|
|
|
Commits *list.List `xorm:"-"` |
|
OldCommit string `xorm:"-"` |
|
NewCommit string `xorm:"-"` |
|
CommitsNum int64 `xorm:"-"` |
|
IsForcePush bool `xorm:"-"` |
|
} |
|
|
|
// PushActionContent is content of push pull comment |
|
type PushActionContent struct { |
|
IsForcePush bool `json:"is_force_push"` |
|
CommitIDs []string `json:"commit_ids"` |
|
} |
|
|
|
// LoadIssue loads issue from database |
|
func (c *Comment) LoadIssue() (err error) { |
|
return c.loadIssue(x) |
|
} |
|
|
|
func (c *Comment) loadIssue(e Engine) (err error) { |
|
if c.Issue != nil { |
|
return nil |
|
} |
|
c.Issue, err = getIssueByID(e, c.IssueID) |
|
return |
|
} |
|
|
|
// BeforeInsert will be invoked by XORM before inserting a record |
|
func (c *Comment) BeforeInsert() { |
|
c.PatchQuoted = c.Patch |
|
if !utf8.ValidString(c.Patch) { |
|
c.PatchQuoted = strconv.Quote(c.Patch) |
|
} |
|
} |
|
|
|
// BeforeUpdate will be invoked by XORM before updating a record |
|
func (c *Comment) BeforeUpdate() { |
|
c.PatchQuoted = c.Patch |
|
if !utf8.ValidString(c.Patch) { |
|
c.PatchQuoted = strconv.Quote(c.Patch) |
|
} |
|
} |
|
|
|
// AfterLoad is invoked from XORM after setting the values of all fields of this object. |
|
func (c *Comment) AfterLoad(session *xorm.Session) { |
|
c.Patch = c.PatchQuoted |
|
if len(c.PatchQuoted) > 0 && c.PatchQuoted[0] == '"' { |
|
unquoted, err := strconv.Unquote(c.PatchQuoted) |
|
if err == nil { |
|
c.Patch = unquoted |
|
} |
|
} |
|
} |
|
|
|
func (c *Comment) loadPoster(e Engine) (err error) { |
|
if c.PosterID <= 0 || c.Poster != nil { |
|
return nil |
|
} |
|
|
|
c.Poster, err = getUserByID(e, c.PosterID) |
|
if err != nil { |
|
if IsErrUserNotExist(err) { |
|
c.PosterID = -1 |
|
c.Poster = NewGhostUser() |
|
} else { |
|
log.Error("getUserByID[%d]: %v", c.ID, err) |
|
} |
|
} |
|
return err |
|
} |
|
|
|
// AfterDelete is invoked from XORM after the object is deleted. |
|
func (c *Comment) AfterDelete() { |
|
if c.ID <= 0 { |
|
return |
|
} |
|
|
|
_, err := DeleteAttachmentsByComment(c.ID, true) |
|
if err != nil { |
|
log.Info("Could not delete files for comment %d on issue #%d: %s", c.ID, c.IssueID, err) |
|
} |
|
} |
|
|
|
// HTMLURL formats a URL-string to the issue-comment |
|
func (c *Comment) HTMLURL() string { |
|
err := c.LoadIssue() |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("LoadIssue(%d): %v", c.IssueID, err) |
|
return "" |
|
} |
|
err = c.Issue.loadRepo(x) |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) |
|
return "" |
|
} |
|
if c.Type == CommentTypeCode { |
|
if c.ReviewID == 0 { |
|
return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) |
|
} |
|
if c.Review == nil { |
|
if err := c.LoadReview(); err != nil { |
|
log.Warn("LoadReview(%d): %v", c.ReviewID, err) |
|
return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) |
|
} |
|
} |
|
if c.Review.Type <= ReviewTypePending { |
|
return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) |
|
} |
|
} |
|
return fmt.Sprintf("%s#%s", c.Issue.HTMLURL(), c.HashTag()) |
|
} |
|
|
|
// APIURL formats a API-string to the issue-comment |
|
func (c *Comment) APIURL() string { |
|
err := c.LoadIssue() |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("LoadIssue(%d): %v", c.IssueID, err) |
|
return "" |
|
} |
|
err = c.Issue.loadRepo(x) |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) |
|
return "" |
|
} |
|
|
|
return fmt.Sprintf("%s/issues/comments/%d", c.Issue.Repo.APIURL(), c.ID) |
|
} |
|
|
|
// IssueURL formats a URL-string to the issue |
|
func (c *Comment) IssueURL() string { |
|
err := c.LoadIssue() |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("LoadIssue(%d): %v", c.IssueID, err) |
|
return "" |
|
} |
|
|
|
if c.Issue.IsPull { |
|
return "" |
|
} |
|
|
|
err = c.Issue.loadRepo(x) |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) |
|
return "" |
|
} |
|
return c.Issue.HTMLURL() |
|
} |
|
|
|
// PRURL formats a URL-string to the pull-request |
|
func (c *Comment) PRURL() string { |
|
err := c.LoadIssue() |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("LoadIssue(%d): %v", c.IssueID, err) |
|
return "" |
|
} |
|
|
|
err = c.Issue.loadRepo(x) |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) |
|
return "" |
|
} |
|
|
|
if !c.Issue.IsPull { |
|
return "" |
|
} |
|
return c.Issue.HTMLURL() |
|
} |
|
|
|
// CommentHashTag returns unique hash tag for comment id. |
|
func CommentHashTag(id int64) string { |
|
return fmt.Sprintf("issuecomment-%d", id) |
|
} |
|
|
|
// HashTag returns unique hash tag for comment. |
|
func (c *Comment) HashTag() string { |
|
return CommentHashTag(c.ID) |
|
} |
|
|
|
// EventTag returns unique event hash tag for comment. |
|
func (c *Comment) EventTag() string { |
|
return fmt.Sprintf("event-%d", c.ID) |
|
} |
|
|
|
// LoadLabel if comment.Type is CommentTypeLabel, then load Label |
|
func (c *Comment) LoadLabel() error { |
|
var label Label |
|
has, err := x.ID(c.LabelID).Get(&label) |
|
if err != nil { |
|
return err |
|
} else if has { |
|
c.Label = &label |
|
} else { |
|
// Ignore Label is deleted, but not clear this table |
|
log.Warn("Commit %d cannot load label %d", c.ID, c.LabelID) |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// LoadProject if comment.Type is CommentTypeProject, then load project. |
|
func (c *Comment) LoadProject() error { |
|
if c.OldProjectID > 0 { |
|
var oldProject Project |
|
has, err := x.ID(c.OldProjectID).Get(&oldProject) |
|
if err != nil { |
|
return err |
|
} else if has { |
|
c.OldProject = &oldProject |
|
} |
|
} |
|
|
|
if c.ProjectID > 0 { |
|
var project Project |
|
has, err := x.ID(c.ProjectID).Get(&project) |
|
if err != nil { |
|
return err |
|
} else if has { |
|
c.Project = &project |
|
} |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// LoadMilestone if comment.Type is CommentTypeMilestone, then load milestone |
|
func (c *Comment) LoadMilestone() error { |
|
if c.OldMilestoneID > 0 { |
|
var oldMilestone Milestone |
|
has, err := x.ID(c.OldMilestoneID).Get(&oldMilestone) |
|
if err != nil { |
|
return err |
|
} else if has { |
|
c.OldMilestone = &oldMilestone |
|
} |
|
} |
|
|
|
if c.MilestoneID > 0 { |
|
var milestone Milestone |
|
has, err := x.ID(c.MilestoneID).Get(&milestone) |
|
if err != nil { |
|
return err |
|
} else if has { |
|
c.Milestone = &milestone |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
// LoadPoster loads comment poster |
|
func (c *Comment) LoadPoster() error { |
|
return c.loadPoster(x) |
|
} |
|
|
|
// LoadAttachments loads attachments |
|
func (c *Comment) LoadAttachments() error { |
|
if len(c.Attachments) > 0 { |
|
return nil |
|
} |
|
|
|
var err error |
|
c.Attachments, err = getAttachmentsByCommentID(x, c.ID) |
|
if err != nil { |
|
log.Error("getAttachmentsByCommentID[%d]: %v", c.ID, err) |
|
} |
|
return nil |
|
} |
|
|
|
// UpdateAttachments update attachments by UUIDs for the comment |
|
func (c *Comment) UpdateAttachments(uuids []string) error { |
|
sess := x.NewSession() |
|
defer sess.Close() |
|
if err := sess.Begin(); err != nil { |
|
return err |
|
} |
|
attachments, err := getAttachmentsByUUIDs(sess, uuids) |
|
if err != nil { |
|
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", uuids, err) |
|
} |
|
for i := 0; i < len(attachments); i++ { |
|
attachments[i].IssueID = c.IssueID |
|
attachments[i].CommentID = c.ID |
|
if err := updateAttachment(sess, attachments[i]); err != nil { |
|
return fmt.Errorf("update attachment [id: %d]: %v", attachments[i].ID, err) |
|
} |
|
} |
|
return sess.Commit() |
|
} |
|
|
|
// LoadAssigneeUserAndTeam if comment.Type is CommentTypeAssignees, then load assignees |
|
func (c *Comment) LoadAssigneeUserAndTeam() error { |
|
var err error |
|
|
|
if c.AssigneeID > 0 && c.Assignee == nil { |
|
c.Assignee, err = getUserByID(x, c.AssigneeID) |
|
if err != nil { |
|
if !IsErrUserNotExist(err) { |
|
return err |
|
} |
|
c.Assignee = NewGhostUser() |
|
} |
|
} else if c.AssigneeTeamID > 0 && c.AssigneeTeam == nil { |
|
if err = c.LoadIssue(); err != nil { |
|
return err |
|
} |
|
|
|
if err = c.Issue.LoadRepo(); err != nil { |
|
return err |
|
} |
|
|
|
if err = c.Issue.Repo.GetOwner(); err != nil { |
|
return err |
|
} |
|
|
|
if c.Issue.Repo.Owner.IsOrganization() { |
|
c.AssigneeTeam, err = GetTeamByID(c.AssigneeTeamID) |
|
if err != nil && !IsErrTeamNotExist(err) { |
|
return err |
|
} |
|
} |
|
} |
|
return nil |
|
} |
|
|
|
// LoadResolveDoer if comment.Type is CommentTypeCode and ResolveDoerID not zero, then load resolveDoer |
|
func (c *Comment) LoadResolveDoer() (err error) { |
|
if c.ResolveDoerID == 0 || c.Type != CommentTypeCode { |
|
return nil |
|
} |
|
c.ResolveDoer, err = getUserByID(x, c.ResolveDoerID) |
|
if err != nil { |
|
if IsErrUserNotExist(err) { |
|
c.ResolveDoer = NewGhostUser() |
|
err = nil |
|
} |
|
} |
|
return |
|
} |
|
|
|
// IsResolved check if an code comment is resolved |
|
func (c *Comment) IsResolved() bool { |
|
return c.ResolveDoerID != 0 && c.Type == CommentTypeCode |
|
} |
|
|
|
// LoadDepIssueDetails loads Dependent Issue Details |
|
func (c *Comment) LoadDepIssueDetails() (err error) { |
|
if c.DependentIssueID <= 0 || c.DependentIssue != nil { |
|
return nil |
|
} |
|
c.DependentIssue, err = getIssueByID(x, c.DependentIssueID) |
|
return err |
|
} |
|
|
|
// LoadTime loads the associated time for a CommentTypeAddTimeManual |
|
func (c *Comment) LoadTime() error { |
|
if c.Time != nil || c.TimeID == 0 { |
|
return nil |
|
} |
|
var err error |
|
c.Time, err = GetTrackedTimeByID(c.TimeID) |
|
return err |
|
} |
|
|
|
func (c *Comment) loadReactions(e Engine, repo *Repository) (err error) { |
|
if c.Reactions != nil { |
|
return nil |
|
} |
|
c.Reactions, err = findReactions(e, FindReactionsOptions{ |
|
IssueID: c.IssueID, |
|
CommentID: c.ID, |
|
}) |
|
if err != nil { |
|
return err |
|
} |
|
// Load reaction user data |
|
if _, err := c.Reactions.loadUsers(e, repo); err != nil { |
|
return err |
|
} |
|
return nil |
|
} |
|
|
|
// LoadReactions loads comment reactions |
|
func (c *Comment) LoadReactions(repo *Repository) error { |
|
return c.loadReactions(x, repo) |
|
} |
|
|
|
func (c *Comment) loadReview(e Engine) (err error) { |
|
if c.Review == nil { |
|
if c.Review, err = getReviewByID(e, c.ReviewID); err != nil { |
|
return err |
|
} |
|
} |
|
c.Review.Issue = c.Issue |
|
return nil |
|
} |
|
|
|
// LoadReview loads the associated review |
|
func (c *Comment) LoadReview() error { |
|
return c.loadReview(x) |
|
} |
|
|
|
var notEnoughLines = regexp.MustCompile(`fatal: file .* has only \d+ lines?`) |
|
|
|
func (c *Comment) checkInvalidation(doer *User, repo *git.Repository, branch string) error { |
|
// FIXME differentiate between previous and proposed line |
|
commit, err := repo.LineBlame(branch, repo.Path, c.TreePath, uint(c.UnsignedLine())) |
|
if err != nil && (strings.Contains(err.Error(), "fatal: no such path") || notEnoughLines.MatchString(err.Error())) { |
|
c.Invalidated = true |
|
return UpdateComment(c, doer) |
|
} |
|
if err != nil { |
|
return err |
|
} |
|
if c.CommitSHA != "" && c.CommitSHA != commit.ID.String() { |
|
c.Invalidated = true |
|
return UpdateComment(c, doer) |
|
} |
|
return nil |
|
} |
|
|
|
// CheckInvalidation checks if the line of code comment got changed by another commit. |
|
// If the line got changed the comment is going to be invalidated. |
|
func (c *Comment) CheckInvalidation(repo *git.Repository, doer *User, branch string) error { |
|
return c.checkInvalidation(doer, repo, branch) |
|
} |
|
|
|
// DiffSide returns "previous" if Comment.Line is a LOC of the previous changes and "proposed" if it is a LOC of the proposed changes. |
|
func (c *Comment) DiffSide() string { |
|
if c.Line < 0 { |
|
return "previous" |
|
} |
|
return "proposed" |
|
} |
|
|
|
// UnsignedLine returns the LOC of the code comment without + or - |
|
func (c *Comment) UnsignedLine() uint64 { |
|
if c.Line < 0 { |
|
return uint64(c.Line * -1) |
|
} |
|
return uint64(c.Line) |
|
} |
|
|
|
// CodeCommentURL returns the url to a comment in code |
|
func (c *Comment) CodeCommentURL() string { |
|
err := c.LoadIssue() |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("LoadIssue(%d): %v", c.IssueID, err) |
|
return "" |
|
} |
|
err = c.Issue.loadRepo(x) |
|
if err != nil { // Silently dropping errors :unamused: |
|
log.Error("loadRepo(%d): %v", c.Issue.RepoID, err) |
|
return "" |
|
} |
|
return fmt.Sprintf("%s/files#%s", c.Issue.HTMLURL(), c.HashTag()) |
|
} |
|
|
|
// LoadPushCommits Load push commits |
|
func (c *Comment) LoadPushCommits() (err error) { |
|
if c.Content == "" || c.Commits != nil || c.Type != CommentTypePullPush { |
|
return nil |
|
} |
|
|
|
var data PushActionContent |
|
|
|
json := jsoniter.ConfigCompatibleWithStandardLibrary |
|
err = json.Unmarshal([]byte(c.Content), &data) |
|
if err != nil { |
|
return |
|
} |
|
|
|
c.IsForcePush = data.IsForcePush |
|
|
|
if c.IsForcePush { |
|
if len(data.CommitIDs) != 2 { |
|
return nil |
|
} |
|
c.OldCommit = data.CommitIDs[0] |
|
c.NewCommit = data.CommitIDs[1] |
|
} else { |
|
repoPath := c.Issue.Repo.RepoPath() |
|
gitRepo, err := git.OpenRepository(repoPath) |
|
if err != nil { |
|
return err |
|
} |
|
defer gitRepo.Close() |
|
|
|
c.Commits = gitRepo.GetCommitsFromIDs(data.CommitIDs) |
|
c.CommitsNum = int64(c.Commits.Len()) |
|
if c.CommitsNum > 0 { |
|
c.Commits = ValidateCommitsWithEmails(c.Commits) |
|
c.Commits = ParseCommitsWithSignature(c.Commits, c.Issue.Repo) |
|
c.Commits = ParseCommitsWithStatus(c.Commits, c.Issue.Repo) |
|
} |
|
} |
|
|
|
return err |
|
} |
|
|
|
func createComment(e *xorm.Session, opts *CreateCommentOptions) (_ *Comment, err error) { |
|
var LabelID int64 |
|
if opts.Label != nil { |
|
LabelID = opts.Label.ID |
|
} |
|
|
|
comment := &Comment{ |
|
Type: opts.Type, |
|
PosterID: opts.Doer.ID, |
|
Poster: opts.Doer, |
|
IssueID: opts.Issue.ID, |
|
LabelID: LabelID, |
|
OldMilestoneID: opts.OldMilestoneID, |
|
MilestoneID: opts.MilestoneID, |
|
OldProjectID: opts.OldProjectID, |
|
ProjectID: opts.ProjectID, |
|
TimeID: opts.TimeID, |
|
RemovedAssignee: opts.RemovedAssignee, |
|
AssigneeID: opts.AssigneeID, |
|
AssigneeTeamID: opts.AssigneeTeamID, |
|
CommitID: opts.CommitID, |
|
CommitSHA: opts.CommitSHA, |
|
Line: opts.LineNum, |
|
Content: opts.Content, |
|
OldTitle: opts.OldTitle, |
|
NewTitle: opts.NewTitle, |
|
OldRef: opts.OldRef, |
|
NewRef: opts.NewRef, |
|
DependentIssueID: opts.DependentIssueID, |
|
TreePath: opts.TreePath, |
|
ReviewID: opts.ReviewID, |
|
Patch: opts.Patch, |
|
RefRepoID: opts.RefRepoID, |
|
RefIssueID: opts.RefIssueID, |
|
RefCommentID: opts.RefCommentID, |
|
RefAction: opts.RefAction, |
|
RefIsPull: opts.RefIsPull, |
|
IsForcePush: opts.IsForcePush, |
|
Invalidated: opts.Invalidated, |
|
} |
|
if _, err = e.Insert(comment); err != nil { |
|
return nil, err |
|
} |
|
|
|
if err = opts.Repo.getOwner(e); err != nil { |
|
return nil, err |
|
} |
|
|
|
if err = updateCommentInfos(e, opts, comment); err != nil { |
|
return nil, err |
|
} |
|
|
|
if err = comment.addCrossReferences(e, opts.Doer, false); err != nil { |
|
return nil, err |
|
} |
|
|
|
return comment, nil |
|
} |
|
|
|
func updateCommentInfos(e *xorm.Session, opts *CreateCommentOptions, comment *Comment) (err error) { |
|
// Check comment type. |
|
switch opts.Type { |
|
case CommentTypeCode: |
|
if comment.ReviewID != 0 { |
|
if comment.Review == nil { |
|
if err := comment.loadReview(e); err != nil { |
|
return err |
|
} |
|
} |
|
if comment.Review.Type <= ReviewTypePending { |
|
return nil |
|
} |
|
} |
|
fallthrough |
|
case CommentTypeComment: |
|
if _, err = e.Exec("UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil { |
|
return err |
|
} |
|
|
|
// Check attachments |
|
attachments, err := getAttachmentsByUUIDs(e, opts.Attachments) |
|
if err != nil { |
|
return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %v", opts.Attachments, err) |
|
} |
|
|
|
for i := range attachments { |
|
attachments[i].IssueID = opts.Issue.ID |
|
attachments[i].CommentID = comment.ID |
|
// No assign value could be 0, so ignore AllCols(). |
|
if _, err = e.ID(attachments[i].ID).Update(attachments[i]); err != nil { |
|
return fmt.Errorf("update attachment [%d]: %v", attachments[i].ID, err) |
|
} |
|
} |
|
case CommentTypeReopen, CommentTypeClose: |
|
if err = opts.Issue.updateClosedNum(e); err != nil { |
|
return err |
|
} |
|
} |
|
// update the issue's updated_unix column |
|
return updateIssueCols(e, opts.Issue, "updated_unix") |
|
} |
|
|
|
func createDeadlineComment(e *xorm.Session, doer *User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) { |
|
var content string |
|
var commentType CommentType |
|
|
|
// newDeadline = 0 means deleting |
|
if newDeadlineUnix == 0 { |
|
commentType = CommentTypeRemovedDeadline |
|
content = issue.DeadlineUnix.Format("2006-01-02") |
|
} else if issue.DeadlineUnix == 0 { |
|
// Check if the new date was added or modified |
|
// If the actual deadline is 0 => deadline added |
|
commentType = CommentTypeAddedDeadline |
|
content = newDeadlineUnix.Format("2006-01-02") |
|
} else { // Otherwise modified |
|
commentType = CommentTypeModifiedDeadline |
|
content = newDeadlineUnix.Format("2006-01-02") + "|" + issue.DeadlineUnix.Format("2006-01-02") |
|
} |
|
|
|
if err := issue.loadRepo(e); err != nil { |
|
return nil, err |
|
} |
|
|
|
opts := &CreateCommentOptions{ |
|
Type: commentType, |
|
Doer: doer, |
|
Repo: issue.Repo, |
|
Issue: issue, |
|
Content: content, |
|
} |
|
comment, err := createComment(e, opts) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return comment, nil |
|
} |
|
|
|
// Creates issue dependency comment |
|
func createIssueDependencyComment(e *xorm.Session, doer *User, issue, dependentIssue *Issue, add bool) (err error) { |
|
cType := CommentTypeAddDependency |
|
if !add { |
|
cType = CommentTypeRemoveDependency |
|
} |
|
if err = issue.loadRepo(e); err != nil { |
|
return |
|
} |
|
|
|
// Make two comments, one in each issue |
|
opts := &CreateCommentOptions{ |
|
Type: cType, |
|
Doer: doer, |
|
Repo: issue.Repo, |
|
Issue: issue, |
|
DependentIssueID: dependentIssue.ID, |
|
} |
|
if _, err = createComment(e, opts); err != nil { |
|
return |
|
} |
|
|
|
opts = &CreateCommentOptions{ |
|
Type: cType, |
|
Doer: doer, |
|
Repo: issue.Repo, |
|
Issue: dependentIssue, |
|
DependentIssueID: issue.ID, |
|
} |
|
_, err = createComment(e, opts) |
|
return |
|
} |
|
|
|
// CreateCommentOptions defines options for creating comment |
|
type CreateCommentOptions struct { |
|
Type CommentType |
|
Doer *User |
|
Repo *Repository |
|
Issue *Issue |
|
Label *Label |
|
|
|
DependentIssueID int64 |
|
OldMilestoneID int64 |
|
MilestoneID int64 |
|
OldProjectID int64 |
|
ProjectID int64 |
|
TimeID int64 |
|
AssigneeID int64 |
|
AssigneeTeamID int64 |
|
RemovedAssignee bool |
|
OldTitle string |
|
NewTitle string |
|
OldRef string |
|
NewRef string |
|
CommitID int64 |
|
CommitSHA string |
|
Patch string |
|
LineNum int64 |
|
TreePath string |
|
ReviewID int64 |
|
Content string |
|
Attachments []string // UUIDs of attachments |
|
RefRepoID int64 |
|
RefIssueID int64 |
|
RefCommentID int64 |
|
RefAction references.XRefAction |
|
RefIsPull bool |
|
IsForcePush bool |
|
Invalidated bool |
|
} |
|
|
|
// CreateComment creates comment of issue or commit. |
|
func CreateComment(opts *CreateCommentOptions) (comment *Comment, err error) { |
|
sess := x.NewSession() |
|
defer sess.Close() |
|
if err = sess.Begin(); err != nil { |
|
return nil, err |
|
} |
|
|
|
comment, err = createComment(sess, opts) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
if err = sess.Commit(); err != nil { |
|
return nil, err |
|
} |
|
|
|
return comment, nil |
|
} |
|
|
|
// CreateRefComment creates a commit reference comment to issue. |
|
func CreateRefComment(doer *User, repo *Repository, issue *Issue, content, commitSHA string) error { |
|
if len(commitSHA) == 0 { |
|
return fmt.Errorf("cannot create reference with empty commit SHA") |
|
} |
|
|
|
// Check if same reference from same commit has already existed. |
|
has, err := x.Get(&Comment{ |
|
Type: CommentTypeCommitRef, |
|
IssueID: issue.ID, |
|
CommitSHA: commitSHA, |
|
}) |
|
if err != nil { |
|
return fmt.Errorf("check reference comment: %v", err) |
|
} else if has { |
|
return nil |
|
} |
|
|
|
_, err = CreateComment(&CreateCommentOptions{ |
|
Type: CommentTypeCommitRef, |
|
Doer: doer, |
|
Repo: repo, |
|
Issue: issue, |
|
CommitSHA: commitSHA, |
|
Content: content, |
|
}) |
|
return err |
|
} |
|
|
|
// GetCommentByID returns the comment by given ID. |
|
func GetCommentByID(id int64) (*Comment, error) { |
|
return getCommentByID(x, id) |
|
} |
|
|
|
func getCommentByID(e Engine, id int64) (*Comment, error) { |
|
c := new(Comment) |
|
has, err := e.ID(id).Get(c) |
|
if err != nil { |
|
return nil, err |
|
} else if !has { |
|
return nil, ErrCommentNotExist{id, 0} |
|
} |
|
return c, nil |
|
} |
|
|
|
// FindCommentsOptions describes the conditions to Find comments |
|
type FindCommentsOptions struct { |
|
ListOptions |
|
RepoID int64 |
|
IssueID int64 |
|
ReviewID int64 |
|
Since int64 |
|
Before int64 |
|
Line int64 |
|
TreePath string |
|
Type CommentType |
|
} |
|
|
|
func (opts *FindCommentsOptions) toConds() builder.Cond { |
|
cond := builder.NewCond() |
|
if opts.RepoID > 0 { |
|
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepoID}) |
|
} |
|
if opts.IssueID > 0 { |
|
cond = cond.And(builder.Eq{"comment.issue_id": opts.IssueID}) |
|
} |
|
if opts.ReviewID > 0 { |
|
cond = cond.And(builder.Eq{"comment.review_id": opts.ReviewID}) |
|
} |
|
if opts.Since > 0 { |
|
cond = cond.And(builder.Gte{"comment.updated_unix": opts.Since}) |
|
} |
|
if opts.Before > 0 { |
|
cond = cond.And(builder.Lte{"comment.updated_unix": opts.Before}) |
|
} |
|
if opts.Type != CommentTypeUnknown { |
|
cond = cond.And(builder.Eq{"comment.type": opts.Type}) |
|
} |
|
if opts.Line != 0 { |
|
cond = cond.And(builder.Eq{"comment.line": opts.Line}) |
|
} |
|
if len(opts.TreePath) > 0 { |
|
cond = cond.And(builder.Eq{"comment.tree_path": opts.TreePath}) |
|
} |
|
return cond |
|
} |
|
|
|
func findComments(e Engine, opts FindCommentsOptions) ([]*Comment, error) { |
|
comments := make([]*Comment, 0, 10) |
|
sess := e.Where(opts.toConds()) |
|
if opts.RepoID > 0 { |
|
sess.Join("INNER", "issue", "issue.id = comment.issue_id") |
|
} |
|
|
|
if opts.Page != 0 { |
|
sess = opts.setSessionPagination(sess) |
|
} |
|
|
|
// WARNING: If you change this order you will need to fix createCodeComment |
|
|
|
return comments, sess. |
|
Asc("comment.created_unix"). |
|
Asc("comment.id"). |
|
Find(&comments) |
|
} |
|
|
|
// FindComments returns all comments according options |
|
func FindComments(opts FindCommentsOptions) ([]*Comment, error) { |
|
return findComments(x, opts) |
|
} |
|
|
|
// UpdateComment updates information of comment. |
|
func UpdateComment(c *Comment, doer *User) error { |
|
sess := x.NewSession() |
|
defer sess.Close() |
|
if err := sess.Begin(); err != nil { |
|
return err |
|
} |
|
|
|
if _, err := sess.ID(c.ID).AllCols().Update(c); err != nil { |
|
return err |
|
} |
|
if err := c.loadIssue(sess); err != nil { |
|
return err |
|
} |
|
if err := c.addCrossReferences(sess, doer, true); err != nil { |
|
return err |
|
} |
|
if err := sess.Commit(); err != nil { |
|
return fmt.Errorf("Commit: %v", err) |
|
} |
|
|
|
return nil |
|
} |
|
|
|
// DeleteComment deletes the comment |
|
func DeleteComment(comment *Comment) error { |
|
sess := x.NewSession() |
|
defer sess.Close() |
|
if err := sess.Begin(); err != nil { |
|
return err |
|
} |
|
|
|
if err := deleteComment(sess, comment); err != nil { |
|
return err |
|
} |
|
|
|
return sess.Commit() |
|
} |
|
|
|
func deleteComment(e Engine, comment *Comment) error { |
|
if _, err := e.Delete(&Comment{ |
|
ID: comment.ID, |
|
}); err != nil { |
|
return err |
|
} |
|
|
|
if comment.Type == CommentTypeComment { |
|
if _, err := e.Exec("UPDATE `issue` SET num_comments = num_comments - 1 WHERE id = ?", comment.IssueID); err != nil { |
|
return err |
|
} |
|
} |
|
if _, err := e.Where("comment_id = ?", comment.ID).Cols("is_deleted").Update(&Action{IsDeleted: true}); err != nil { |
|
return err |
|
} |
|
|
|
if err := comment.neuterCrossReferences(e); err != nil { |
|
return err |
|
} |
|
|
|
return deleteReaction(e, &ReactionOptions{Comment: comment}) |
|
} |
|
|
|
// CodeComments represents comments on code by using this structure: FILENAME -> LINE (+ == proposed; - == previous) -> COMMENTS |
|
type CodeComments map[string]map[int64][]*Comment |
|
|
|
func fetchCodeComments(e Engine, issue *Issue, currentUser *User) (CodeComments, error) { |
|
return fetchCodeCommentsByReview(e, issue, currentUser, nil) |
|
} |
|
|
|
func fetchCodeCommentsByReview(e Engine, issue *Issue, currentUser *User, review *Review) (CodeComments, error) { |
|
pathToLineToComment := make(CodeComments) |
|
if review == nil { |
|
review = &Review{ID: 0} |
|
} |
|
opts := FindCommentsOptions{ |
|
Type: CommentTypeCode, |
|
IssueID: issue.ID, |
|
ReviewID: review.ID, |
|
} |
|
|
|
comments, err := findCodeComments(e, opts, issue, currentUser, review) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
for _, comment := range comments { |
|
if pathToLineToComment[comment.TreePath] == nil { |
|
pathToLineToComment[comment.TreePath] = make(map[int64][]*Comment) |
|
} |
|
pathToLineToComment[comment.TreePath][comment.Line] = append(pathToLineToComment[comment.TreePath][comment.Line], comment) |
|
} |
|
return pathToLineToComment, nil |
|
} |
|
|
|
func findCodeComments(e Engine, opts FindCommentsOptions, issue *Issue, currentUser *User, review *Review) ([]*Comment, error) { |
|
var comments []*Comment |
|
if review == nil { |
|
review = &Review{ID: 0} |
|
} |
|
conds := opts.toConds() |
|
if review.ID == 0 { |
|
conds = conds.And(builder.Eq{"invalidated": false}) |
|
} |
|
if err := e.Where(conds). |
|
Asc("comment.created_unix"). |
|
Asc("comment.id"). |
|
Find(&comments); err != nil { |
|
return nil, err |
|
} |
|
|
|
if err := issue.loadRepo(e); err != nil { |
|
return nil, err |
|
} |
|
|
|
if err := CommentList(comments).loadPosters(e); err != nil { |
|
return nil, err |
|
} |
|
|
|
// Find all reviews by ReviewID |
|
reviews := make(map[int64]*Review) |
|
ids := make([]int64, 0, len(comments)) |
|
for _, comment := range comments { |
|
if comment.ReviewID != 0 { |
|
ids = append(ids, comment.ReviewID) |
|
} |
|
} |
|
if err := e.In("id", ids).Find(&reviews); err != nil { |
|
return nil, err |
|
} |
|
|
|
n := 0 |
|
for _, comment := range comments { |
|
if re, ok := reviews[comment.ReviewID]; ok && re != nil { |
|
// If the review is pending only the author can see the comments (except if the review is set) |
|
if review.ID == 0 && re.Type == ReviewTypePending && |
|
(currentUser == nil || currentUser.ID != re.ReviewerID) { |
|
continue |
|
} |
|
comment.Review = re |
|
} |
|
comments[n] = comment |
|
n++ |
|
|
|
if err := comment.LoadResolveDoer(); err != nil { |
|
return nil, err |
|
} |
|
|
|
if err := comment.LoadReactions(issue.Repo); err != nil { |
|
return nil, err |
|
} |
|
|
|
comment.RenderedContent = string(markdown.Render([]byte(comment.Content), issue.Repo.Link(), |
|
issue.Repo.ComposeMetas())) |
|
} |
|
return comments[:n], nil |
|
} |
|
|
|
// FetchCodeCommentsByLine fetches the code comments for a given treePath and line number |
|
func FetchCodeCommentsByLine(issue *Issue, currentUser *User, treePath string, line int64) ([]*Comment, error) { |
|
opts := FindCommentsOptions{ |
|
Type: CommentTypeCode, |
|
IssueID: issue.ID, |
|
TreePath: treePath, |
|
Line: line, |
|
} |
|
return findCodeComments(x, opts, issue, currentUser, nil) |
|
} |
|
|
|
// FetchCodeComments will return a 2d-map: ["Path"]["Line"] = Comments at line |
|
func FetchCodeComments(issue *Issue, currentUser *User) (CodeComments, error) { |
|
return fetchCodeComments(x, issue, currentUser) |
|
} |
|
|
|
// UpdateCommentsMigrationsByType updates comments' migrations information via given git service type and original id and poster id |
|
func UpdateCommentsMigrationsByType(tp structs.GitServiceType, originalAuthorID string, posterID int64) error { |
|
_, err := x.Table("comment"). |
|
Where(builder.In("issue_id", |
|
builder.Select("issue.id"). |
|
From("issue"). |
|
InnerJoin("repository", "issue.repo_id = repository.id"). |
|
Where(builder.Eq{ |
|
"repository.original_service_type": tp, |
|
}), |
|
)). |
|
And("comment.original_author_id = ?", originalAuthorID). |
|
Update(map[string]interface{}{ |
|
"poster_id": posterID, |
|
"original_author": "", |
|
"original_author_id": 0, |
|
}) |
|
return err |
|
} |
|
|
|
// CreatePushPullComment create push code to pull base commend |
|
func CreatePushPullComment(pusher *User, pr *PullRequest, oldCommitID, newCommitID string) (comment *Comment, err error) { |
|
if pr.HasMerged || oldCommitID == "" || newCommitID == "" { |
|
return nil, nil |
|
} |
|
|
|
ops := &CreateCommentOptions{ |
|
Type: CommentTypePullPush, |
|
Doer: pusher, |
|
Repo: pr.BaseRepo, |
|
} |
|
|
|
var data PushActionContent |
|
|
|
data.CommitIDs, data.IsForcePush, err = getCommitIDsFromRepo(pr.BaseRepo, oldCommitID, newCommitID, pr.BaseBranch) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
ops.Issue = pr.Issue |
|
|
|
json := jsoniter.ConfigCompatibleWithStandardLibrary |
|
dataJSON, err := json.Marshal(data) |
|
if err != nil { |
|
return nil, err |
|
} |
|
|
|
ops.Content = string(dataJSON) |
|
|
|
comment, err = CreateComment(ops) |
|
|
|
return |
|
} |
|
|
|
// getCommitsFromRepo get commit IDs from repo in betwern oldCommitID and newCommitID |
|
// isForcePush will be true if oldCommit isn't on the branch |
|
// Commit on baseBranch will skip |
|
func getCommitIDsFromRepo(repo *Repository, oldCommitID, newCommitID, baseBranch string) (commitIDs []string, isForcePush bool, err error) { |
|
repoPath := repo.RepoPath() |
|
gitRepo, err := git.OpenRepository(repoPath) |
|
if err != nil { |
|
return nil, false, err |
|
} |
|
defer gitRepo.Close() |
|
|
|
oldCommit, err := gitRepo.GetCommit(oldCommitID) |
|
if err != nil { |
|
return nil, false, err |
|
} |
|
|
|
if err = oldCommit.LoadBranchName(); err != nil { |
|
return nil, false, err |
|
} |
|
|
|
if len(oldCommit.Branch) == 0 { |
|
commitIDs = make([]string, 2) |
|
commitIDs[0] = oldCommitID |
|
commitIDs[1] = newCommitID |
|
|
|
return commitIDs, true, err |
|
} |
|
|
|
newCommit, err := gitRepo.GetCommit(newCommitID) |
|
if err != nil { |
|
return nil, false, err |
|
} |
|
|
|
var ( |
|
commits *list.List |
|
commitChecks map[string]commitBranchCheckItem |
|
) |
|
commits, err = newCommit.CommitsBeforeUntil(oldCommitID) |
|
if err != nil { |
|
return nil, false, err |
|
} |
|
|
|
commitIDs = make([]string, 0, commits.Len()) |
|
commitChecks = make(map[string]commitBranchCheckItem) |
|
|
|
for e := commits.Front(); e != nil; e = e.Next() { |
|
commitChecks[e.Value.(*git.Commit).ID.String()] = commitBranchCheckItem{ |
|
Commit: e.Value.(*git.Commit), |
|
Checked: false, |
|
} |
|
} |
|
|
|
if err = commitBranchCheck(gitRepo, newCommit, oldCommitID, baseBranch, commitChecks); err != nil { |
|
return |
|
} |
|
|
|
for e := commits.Back(); e != nil; e = e.Prev() { |
|
commitID := e.Value.(*git.Commit).ID.String() |
|
if item, ok := commitChecks[commitID]; ok && item.Checked { |
|
commitIDs = append(commitIDs, commitID) |
|
} |
|
} |
|
|
|
return |
|
} |
|
|
|
type commitBranchCheckItem struct { |
|
Commit *git.Commit |
|
Checked bool |
|
} |
|
|
|
func commitBranchCheck(gitRepo *git.Repository, startCommit *git.Commit, endCommitID, baseBranch string, commitList map[string]commitBranchCheckItem) (err error) { |
|
var ( |
|
item commitBranchCheckItem |
|
ok bool |
|
listItem *list.Element |
|
tmp string |
|
) |
|
|
|
if startCommit.ID.String() == endCommitID { |
|
return |
|
} |
|
|
|
checkStack := list.New() |
|
checkStack.PushBack(startCommit.ID.String()) |
|
listItem = checkStack.Back() |
|
|
|
for listItem != nil { |
|
tmp = listItem.Value.(string) |
|
checkStack.Remove(listItem) |
|
|
|
if item, ok = commitList[tmp]; !ok { |
|
listItem = checkStack.Back() |
|
continue |
|
} |
|
|
|
if item.Commit.ID.String() == endCommitID { |
|
listItem = checkStack.Back() |
|
continue |
|
} |
|
|
|
if err = item.Commit.LoadBranchName(); err != nil { |
|
return |
|
} |
|
|
|
if item.Commit.Branch == baseBranch { |
|
listItem = checkStack.Back() |
|
continue |
|
} |
|
|
|
if item.Checked { |
|
listItem = checkStack.Back() |
|
continue |
|
} |
|
|
|
item.Checked = true |
|
commitList[tmp] = item |
|
|
|
parentNum := item.Commit.ParentCount() |
|
for i := 0; i < parentNum; i++ { |
|
var parentCommit *git.Commit |
|
parentCommit, err = item.Commit.Parent(i) |
|
if err != nil { |
|
return |
|
} |
|
checkStack.PushBack(parentCommit.ID.String()) |
|
} |
|
|
|
listItem = checkStack.Back() |
|
} |
|
return nil |
|
}
|
|
|