aboutsummaryrefslogtreecommitdiffstats
path: root/models/issues
diff options
context:
space:
mode:
Diffstat (limited to 'models/issues')
-rw-r--r--models/issues/comment.go38
-rw-r--r--models/issues/comment_code.go6
-rw-r--r--models/issues/comment_test.go9
-rw-r--r--models/issues/issue.go40
-rw-r--r--models/issues/issue_project.go50
-rw-r--r--models/issues/issue_search.go24
-rw-r--r--models/issues/label.go11
-rw-r--r--models/issues/pull.go4
-rw-r--r--models/issues/review.go6
-rw-r--r--models/issues/review_list.go75
-rw-r--r--models/issues/review_test.go3
-rw-r--r--models/issues/stopwatch.go4
12 files changed, 196 insertions, 74 deletions
diff --git a/models/issues/comment.go b/models/issues/comment.go
index e4537aa872..729fc1b9e5 100644
--- a/models/issues/comment.go
+++ b/models/issues/comment.go
@@ -197,6 +197,20 @@ func (t CommentType) HasMailReplySupport() bool {
return false
}
+func (t CommentType) CountedAsConversation() bool {
+ for _, ct := range ConversationCountedCommentType() {
+ if t == ct {
+ return true
+ }
+ }
+ return false
+}
+
+// ConversationCountedCommentType returns the comment types that are counted as a conversation
+func ConversationCountedCommentType() []CommentType {
+ return []CommentType{CommentTypeComment, CommentTypeReview}
+}
+
// RoleInRepo presents the user's participation in the repo
type RoleInRepo string
@@ -893,7 +907,7 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment
}
fallthrough
case CommentTypeComment:
- if _, err = db.Exec(ctx, "UPDATE `issue` SET num_comments=num_comments+1 WHERE id=?", opts.Issue.ID); err != nil {
+ if err := UpdateIssueNumComments(ctx, opts.Issue.ID); err != nil {
return err
}
fallthrough
@@ -1182,8 +1196,8 @@ func DeleteComment(ctx context.Context, comment *Comment) error {
return err
}
- if comment.Type == CommentTypeComment {
- if _, err := e.ID(comment.IssueID).Decr("num_comments").Update(new(Issue)); err != nil {
+ if comment.Type.CountedAsConversation() {
+ if err := UpdateIssueNumComments(ctx, comment.IssueID); err != nil {
return err
}
}
@@ -1300,6 +1314,21 @@ func (c *Comment) HasOriginalAuthor() bool {
return c.OriginalAuthor != "" && c.OriginalAuthorID != 0
}
+func UpdateIssueNumCommentsBuilder(issueID int64) *builder.Builder {
+ subQuery := builder.Select("COUNT(*)").From("`comment`").Where(
+ builder.Eq{"issue_id": issueID}.And(
+ builder.In("`type`", ConversationCountedCommentType()),
+ ))
+
+ return builder.Update(builder.Eq{"num_comments": subQuery}).
+ From("`issue`").Where(builder.Eq{"id": issueID})
+}
+
+func UpdateIssueNumComments(ctx context.Context, issueID int64) error {
+ _, err := db.GetEngine(ctx).Exec(UpdateIssueNumCommentsBuilder(issueID))
+ return err
+}
+
// InsertIssueComments inserts many comments of issues.
func InsertIssueComments(ctx context.Context, comments []*Comment) error {
if len(comments) == 0 {
@@ -1332,8 +1361,7 @@ func InsertIssueComments(ctx context.Context, comments []*Comment) error {
}
for _, issueID := range issueIDs {
- if _, err := db.Exec(ctx, "UPDATE issue set num_comments = (SELECT count(*) FROM comment WHERE issue_id = ? AND `type`=?) WHERE id = ?",
- issueID, CommentTypeComment, issueID); err != nil {
+ if err := UpdateIssueNumComments(ctx, issueID); err != nil {
return err
}
}
diff --git a/models/issues/comment_code.go b/models/issues/comment_code.go
index 67a77ceb13..b562aab500 100644
--- a/models/issues/comment_code.go
+++ b/models/issues/comment_code.go
@@ -86,8 +86,10 @@ func findCodeComments(ctx context.Context, opts FindCommentsOptions, issue *Issu
ids = append(ids, comment.ReviewID)
}
}
- if err := e.In("id", ids).Find(&reviews); err != nil {
- return nil, err
+ if len(ids) > 0 {
+ if err := e.In("id", ids).Find(&reviews); err != nil {
+ return nil, err
+ }
}
n := 0
diff --git a/models/issues/comment_test.go b/models/issues/comment_test.go
index d81f33f953..cb31a21f92 100644
--- a/models/issues/comment_test.go
+++ b/models/issues/comment_test.go
@@ -97,3 +97,12 @@ func TestMigrate_InsertIssueComments(t *testing.T) {
unittest.CheckConsistencyFor(t, &issues_model.Issue{})
}
+
+func Test_UpdateIssueNumComments(t *testing.T) {
+ assert.NoError(t, unittest.PrepareTestDatabase())
+ issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+
+ assert.NoError(t, issues_model.UpdateIssueNumComments(db.DefaultContext, issue2.ID))
+ issue2 = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+ assert.EqualValues(t, 1, issue2.NumComments)
+}
diff --git a/models/issues/issue.go b/models/issues/issue.go
index fe347c2715..f4b575d804 100644
--- a/models/issues/issue.go
+++ b/models/issues/issue.go
@@ -17,6 +17,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/timeutil"
@@ -531,6 +532,45 @@ func GetIssueByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
return issue, nil
}
+func isPullToCond(isPull optional.Option[bool]) builder.Cond {
+ if isPull.Has() {
+ return builder.Eq{"is_pull": isPull.Value()}
+ }
+ return builder.NewCond()
+}
+
+func FindLatestUpdatedIssues(ctx context.Context, repoID int64, isPull optional.Option[bool], pageSize int) (IssueList, error) {
+ issues := make([]*Issue, 0, pageSize)
+ err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
+ And(isPullToCond(isPull)).
+ OrderBy("updated_unix DESC").
+ Limit(pageSize).
+ Find(&issues)
+ return issues, err
+}
+
+func FindIssuesSuggestionByKeyword(ctx context.Context, repoID int64, keyword string, isPull optional.Option[bool], excludedID int64, pageSize int) (IssueList, error) {
+ cond := builder.NewCond()
+ if excludedID > 0 {
+ cond = cond.And(builder.Neq{"`id`": excludedID})
+ }
+
+ // It seems that GitHub searches both title and content (maybe sorting by the search engine's ranking system?)
+ // The first PR (https://github.com/go-gitea/gitea/pull/32327) uses "search indexer" to search "name(title) + content"
+ // But it seems that searching "content" (especially LIKE by DB engine) generates worse (unusable) results.
+ // So now (https://github.com/go-gitea/gitea/pull/33538) it only searches "name(title)", leave the improvements to the future.
+ cond = cond.And(db.BuildCaseInsensitiveLike("`name`", keyword))
+
+ issues := make([]*Issue, 0, pageSize)
+ err := db.GetEngine(ctx).Where("repo_id = ?", repoID).
+ And(isPullToCond(isPull)).
+ And(cond).
+ OrderBy("updated_unix DESC, `index` DESC").
+ Limit(pageSize).
+ Find(&issues)
+ return issues, err
+}
+
// GetIssueWithAttrsByIndex returns issue by index in a repository.
func GetIssueWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Issue, error) {
issue, err := GetIssueByIndex(ctx, repoID, index)
diff --git a/models/issues/issue_project.go b/models/issues/issue_project.go
index c4515fd898..0185244783 100644
--- a/models/issues/issue_project.go
+++ b/models/issues/issue_project.go
@@ -38,13 +38,30 @@ func (issue *Issue) projectID(ctx context.Context) int64 {
}
// ProjectColumnID return project column id if issue was assigned to one
-func (issue *Issue) ProjectColumnID(ctx context.Context) int64 {
+func (issue *Issue) ProjectColumnID(ctx context.Context) (int64, error) {
var ip project_model.ProjectIssue
has, err := db.GetEngine(ctx).Where("issue_id=?", issue.ID).Get(&ip)
- if err != nil || !has {
- return 0
+ if err != nil {
+ return 0, err
+ } else if !has {
+ return 0, nil
+ }
+ return ip.ProjectColumnID, nil
+}
+
+func LoadProjectIssueColumnMap(ctx context.Context, projectID, defaultColumnID int64) (map[int64]int64, error) {
+ issues := make([]project_model.ProjectIssue, 0)
+ if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&issues); err != nil {
+ return nil, err
+ }
+ result := make(map[int64]int64, len(issues))
+ for _, issue := range issues {
+ if issue.ProjectColumnID == 0 {
+ issue.ProjectColumnID = defaultColumnID
+ }
+ result[issue.IssueID] = issue.ProjectColumnID
}
- return ip.ProjectColumnID
+ return result, nil
}
// LoadIssuesFromColumn load issues assigned to this column
@@ -59,11 +76,11 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
}
if b.Default {
- issues, err := Issues(ctx, &IssuesOptions{
- ProjectColumnID: db.NoConditionID,
- ProjectID: b.ProjectID,
- SortType: "project-column-sorting",
- })
+ issues, err := Issues(ctx, opts.Copy(func(o *IssuesOptions) {
+ o.ProjectColumnID = db.NoConditionID
+ o.ProjectID = b.ProjectID
+ o.SortType = "project-column-sorting"
+ }))
if err != nil {
return nil, err
}
@@ -77,19 +94,6 @@ func LoadIssuesFromColumn(ctx context.Context, b *project_model.Column, opts *Is
return issueList, nil
}
-// LoadIssuesFromColumnList load issues assigned to the columns
-func LoadIssuesFromColumnList(ctx context.Context, bs project_model.ColumnList, opts *IssuesOptions) (map[int64]IssueList, error) {
- issuesMap := make(map[int64]IssueList, len(bs))
- for i := range bs {
- il, err := LoadIssuesFromColumn(ctx, bs[i], opts)
- if err != nil {
- return nil, err
- }
- issuesMap[bs[i].ID] = il
- }
- return issuesMap, nil
-}
-
// IssueAssignOrRemoveProject changes the project associated with an issue
// If newProjectID is 0, the issue is removed from the project
func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_model.User, newProjectID, newColumnID int64) error {
@@ -110,7 +114,7 @@ func IssueAssignOrRemoveProject(ctx context.Context, issue *Issue, doer *user_mo
return util.NewPermissionDeniedErrorf("issue %d can't be accessed by project %d", issue.ID, newProject.ID)
}
if newColumnID == 0 {
- newDefaultColumn, err := newProject.GetDefaultColumn(ctx)
+ newDefaultColumn, err := newProject.MustDefaultColumn(ctx)
if err != nil {
return err
}
diff --git a/models/issues/issue_search.go b/models/issues/issue_search.go
index f1cd125d49..694b918755 100644
--- a/models/issues/issue_search.go
+++ b/models/issues/issue_search.go
@@ -49,9 +49,9 @@ type IssuesOptions struct { //nolint
// prioritize issues from this repo
PriorityRepoID int64
IsArchived optional.Option[bool]
- Org *organization.Organization // issues permission scope
- Team *organization.Team // issues permission scope
- User *user_model.User // issues permission scope
+ Owner *user_model.User // issues permission scope, it could be an organization or a user
+ Team *organization.Team // issues permission scope
+ Doer *user_model.User // issues permission scope
}
// Copy returns a copy of the options.
@@ -273,8 +273,12 @@ func applyConditions(sess *xorm.Session, opts *IssuesOptions) {
applyLabelsCondition(sess, opts)
- if opts.User != nil {
- sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.User.ID, opts.Org, opts.Team, opts.IsPull.Value()))
+ if opts.Owner != nil {
+ sess.And(repo_model.UserOwnedRepoCond(opts.Owner.ID))
+ }
+
+ if opts.Doer != nil && !opts.Doer.IsAdmin {
+ sess.And(issuePullAccessibleRepoCond("issue.repo_id", opts.Doer.ID, opts.Owner, opts.Team, opts.IsPull.Value()))
}
}
@@ -321,20 +325,20 @@ func teamUnitsRepoCond(id string, userID, orgID, teamID int64, units ...unit.Typ
}
// issuePullAccessibleRepoCond userID must not be zero, this condition require join repository table
-func issuePullAccessibleRepoCond(repoIDstr string, userID int64, org *organization.Organization, team *organization.Team, isPull bool) builder.Cond {
+func issuePullAccessibleRepoCond(repoIDstr string, userID int64, owner *user_model.User, team *organization.Team, isPull bool) builder.Cond {
cond := builder.NewCond()
unitType := unit.TypeIssues
if isPull {
unitType = unit.TypePullRequests
}
- if org != nil {
+ if owner != nil && owner.IsOrganization() {
if team != nil {
- cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, org.ID, team.ID, unitType)) // special team member repos
+ cond = cond.And(teamUnitsRepoCond(repoIDstr, userID, owner.ID, team.ID, unitType)) // special team member repos
} else {
cond = cond.And(
builder.Or(
- repo_model.UserOrgUnitRepoCond(repoIDstr, userID, org.ID, unitType), // team member repos
- repo_model.UserOrgPublicUnitRepoCond(userID, org.ID), // user org public non-member repos, TODO: check repo has issues
+ repo_model.UserOrgUnitRepoCond(repoIDstr, userID, owner.ID, unitType), // team member repos
+ repo_model.UserOrgPublicUnitRepoCond(userID, owner.ID), // user org public non-member repos, TODO: check repo has issues
),
)
}
diff --git a/models/issues/label.go b/models/issues/label.go
index d80578193e..b9d24bbe99 100644
--- a/models/issues/label.go
+++ b/models/issues/label.go
@@ -349,6 +349,17 @@ func GetLabelIDsInRepoByNames(ctx context.Context, repoID int64, labelNames []st
Find(&labelIDs)
}
+// GetLabelIDsInOrgByNames returns a list of labelIDs by names in a given org.
+func GetLabelIDsInOrgByNames(ctx context.Context, orgID int64, labelNames []string) ([]int64, error) {
+ labelIDs := make([]int64, 0, len(labelNames))
+ return labelIDs, db.GetEngine(ctx).Table("label").
+ Where("org_id = ?", orgID).
+ In("name", labelNames).
+ Asc("name").
+ Cols("id").
+ Find(&labelIDs)
+}
+
// BuildLabelNamesIssueIDsCondition returns a builder where get issue ids match label names
func BuildLabelNamesIssueIDsCondition(labelNames []string) *builder.Builder {
return builder.Select("issue_label.issue_id").
diff --git a/models/issues/pull.go b/models/issues/pull.go
index 853e2a69e6..319ead5dbd 100644
--- a/models/issues/pull.go
+++ b/models/issues/pull.go
@@ -301,7 +301,7 @@ func (pr *PullRequest) LoadRequestedReviewers(ctx context.Context) error {
return nil
}
- reviews, err := GetReviewsByIssueID(ctx, pr.Issue.ID)
+ reviews, _, err := GetReviewsByIssueID(ctx, pr.Issue.ID)
if err != nil {
return err
}
@@ -320,7 +320,7 @@ func (pr *PullRequest) LoadRequestedReviewers(ctx context.Context) error {
// LoadRequestedReviewersTeams loads the requested reviewers teams.
func (pr *PullRequest) LoadRequestedReviewersTeams(ctx context.Context) error {
- reviews, err := GetReviewsByIssueID(ctx, pr.Issue.ID)
+ reviews, _, err := GetReviewsByIssueID(ctx, pr.Issue.ID)
if err != nil {
return err
}
diff --git a/models/issues/review.go b/models/issues/review.go
index 8b345e5fd8..d8dc539c3b 100644
--- a/models/issues/review.go
+++ b/models/issues/review.go
@@ -639,6 +639,10 @@ func InsertReviews(ctx context.Context, reviews []*Review) error {
return err
}
}
+
+ if err := UpdateIssueNumComments(ctx, review.IssueID); err != nil {
+ return err
+ }
}
return committer.Commit()
@@ -659,7 +663,7 @@ func AddReviewRequest(ctx context.Context, issue *Issue, reviewer, doer *user_mo
}
if review != nil {
- // skip it when reviewer hase been request to review
+ // skip it when reviewer has been request to review
if review.Type == ReviewTypeRequest {
return nil, committer.Commit() // still commit the transaction, or committer.Close() will rollback it, even if it's a reused transaction.
}
diff --git a/models/issues/review_list.go b/models/issues/review_list.go
index bc7d7ec0f0..928f24fb2d 100644
--- a/models/issues/review_list.go
+++ b/models/issues/review_list.go
@@ -5,6 +5,8 @@ package issues
import (
"context"
+ "slices"
+ "sort"
"code.gitea.io/gitea/models/db"
organization_model "code.gitea.io/gitea/models/organization"
@@ -153,43 +155,60 @@ func CountReviews(ctx context.Context, opts FindReviewOptions) (int64, error) {
return db.GetEngine(ctx).Where(opts.toCond()).Count(&Review{})
}
-// GetReviewersFromOriginalAuthorsByIssueID gets the latest review of each original authors for a pull request
-func GetReviewersFromOriginalAuthorsByIssueID(ctx context.Context, issueID int64) (ReviewList, error) {
+// GetReviewsByIssueID gets the latest review of each reviewer for a pull request
+// The first returned parameter is the latest review of each individual reviewer or team
+// The second returned parameter is the latest review of each original author which is migrated from other systems
+// The reviews are sorted by updated time
+func GetReviewsByIssueID(ctx context.Context, issueID int64) (latestReviews, migratedOriginalReviews ReviewList, err error) {
reviews := make([]*Review, 0, 10)
- // Get latest review of each reviewer, sorted in order they were made
- if err := db.GetEngine(ctx).SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = 0 AND type in (?, ?, ?) AND original_author_id <> 0 GROUP BY issue_id, original_author_id) ORDER BY review.updated_unix ASC",
- issueID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest).
- Find(&reviews); err != nil {
- return nil, err
+ // Get all reviews for the issue id
+ if err := db.GetEngine(ctx).Where("issue_id=?", issueID).OrderBy("updated_unix ASC").Find(&reviews); err != nil {
+ return nil, nil, err
}
- return reviews, nil
-}
-
-// GetReviewsByIssueID gets the latest review of each reviewer for a pull request
-func GetReviewsByIssueID(ctx context.Context, issueID int64) (ReviewList, error) {
- reviews := make([]*Review, 0, 10)
-
- sess := db.GetEngine(ctx)
+ // filter them in memory to get the latest review of each reviewer
+ // Since the reviews should not be too many for one issue, less than 100 commonly, it's acceptable to do this in memory
+ // And since there are too less indexes in review table, it will be very slow to filter in the database
+ reviewersMap := make(map[int64][]*Review) // key is reviewer id
+ originalReviewersMap := make(map[int64][]*Review) // key is original author id
+ reviewTeamsMap := make(map[int64][]*Review) // key is reviewer team id
+ countedReivewTypes := []ReviewType{ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest}
+ for _, review := range reviews {
+ if review.ReviewerTeamID == 0 && slices.Contains(countedReivewTypes, review.Type) && !review.Dismissed {
+ if review.OriginalAuthorID != 0 {
+ originalReviewersMap[review.OriginalAuthorID] = append(originalReviewersMap[review.OriginalAuthorID], review)
+ } else {
+ reviewersMap[review.ReviewerID] = append(reviewersMap[review.ReviewerID], review)
+ }
+ } else if review.ReviewerTeamID != 0 && review.OriginalAuthorID == 0 {
+ reviewTeamsMap[review.ReviewerTeamID] = append(reviewTeamsMap[review.ReviewerTeamID], review)
+ }
+ }
- // Get latest review of each reviewer, sorted in order they were made
- if err := sess.SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id = 0 AND type in (?, ?, ?) AND dismissed = ? AND original_author_id = 0 GROUP BY issue_id, reviewer_id) ORDER BY review.updated_unix ASC",
- issueID, ReviewTypeApprove, ReviewTypeReject, ReviewTypeRequest, false).
- Find(&reviews); err != nil {
- return nil, err
+ individualReviews := make([]*Review, 0, 10)
+ for _, reviews := range reviewersMap {
+ individualReviews = append(individualReviews, reviews[len(reviews)-1])
}
+ sort.Slice(individualReviews, func(i, j int) bool {
+ return individualReviews[i].UpdatedUnix < individualReviews[j].UpdatedUnix
+ })
- teamReviewRequests := make([]*Review, 0, 5)
- if err := sess.SQL("SELECT * FROM review WHERE id IN (SELECT max(id) as id FROM review WHERE issue_id = ? AND reviewer_team_id <> 0 AND original_author_id = 0 GROUP BY issue_id, reviewer_team_id) ORDER BY review.updated_unix ASC",
- issueID).
- Find(&teamReviewRequests); err != nil {
- return nil, err
+ originalReviews := make([]*Review, 0, 10)
+ for _, reviews := range originalReviewersMap {
+ originalReviews = append(originalReviews, reviews[len(reviews)-1])
}
+ sort.Slice(originalReviews, func(i, j int) bool {
+ return originalReviews[i].UpdatedUnix < originalReviews[j].UpdatedUnix
+ })
- if len(teamReviewRequests) > 0 {
- reviews = append(reviews, teamReviewRequests...)
+ teamReviewRequests := make([]*Review, 0, 5)
+ for _, reviews := range reviewTeamsMap {
+ teamReviewRequests = append(teamReviewRequests, reviews[len(reviews)-1])
}
+ sort.Slice(teamReviewRequests, func(i, j int) bool {
+ return teamReviewRequests[i].UpdatedUnix < teamReviewRequests[j].UpdatedUnix
+ })
- return reviews, nil
+ return append(individualReviews, teamReviewRequests...), originalReviews, nil
}
diff --git a/models/issues/review_test.go b/models/issues/review_test.go
index 50330e3ff2..2588b8ba41 100644
--- a/models/issues/review_test.go
+++ b/models/issues/review_test.go
@@ -162,8 +162,9 @@ func TestGetReviewersByIssueID(t *testing.T) {
},
)
- allReviews, err := issues_model.GetReviewsByIssueID(db.DefaultContext, issue.ID)
+ allReviews, migratedReviews, err := issues_model.GetReviewsByIssueID(db.DefaultContext, issue.ID)
assert.NoError(t, err)
+ assert.Empty(t, migratedReviews)
for _, review := range allReviews {
assert.NoError(t, review.LoadReviewer(db.DefaultContext))
}
diff --git a/models/issues/stopwatch.go b/models/issues/stopwatch.go
index 629af95b57..baffe04ace 100644
--- a/models/issues/stopwatch.go
+++ b/models/issues/stopwatch.go
@@ -48,7 +48,7 @@ func (s Stopwatch) Seconds() int64 {
// Duration returns a human-readable duration string based on local server time
func (s Stopwatch) Duration() string {
- return util.SecToTime(s.Seconds())
+ return util.SecToHours(s.Seconds())
}
func getStopwatch(ctx context.Context, userID, issueID int64) (sw *Stopwatch, exists bool, err error) {
@@ -201,7 +201,7 @@ func FinishIssueStopwatch(ctx context.Context, user *user_model.User, issue *Iss
Doer: user,
Issue: issue,
Repo: issue.Repo,
- Content: util.SecToTime(timediff),
+ Content: util.SecToHours(timediff),
Type: CommentTypeStopTracking,
TimeID: tt.ID,
}); err != nil {