code.gitea.io/gitea@v1.21.7/models/issues/milestone.go (about) 1 // Copyright 2017 The Gitea Authors. All rights reserved. 2 // SPDX-License-Identifier: MIT 3 4 package issues 5 6 import ( 7 "context" 8 "fmt" 9 "strings" 10 11 "code.gitea.io/gitea/models/db" 12 repo_model "code.gitea.io/gitea/models/repo" 13 api "code.gitea.io/gitea/modules/structs" 14 "code.gitea.io/gitea/modules/timeutil" 15 "code.gitea.io/gitea/modules/util" 16 17 "xorm.io/builder" 18 ) 19 20 // ErrMilestoneNotExist represents a "MilestoneNotExist" kind of error. 21 type ErrMilestoneNotExist struct { 22 ID int64 23 RepoID int64 24 Name string 25 } 26 27 // IsErrMilestoneNotExist checks if an error is a ErrMilestoneNotExist. 28 func IsErrMilestoneNotExist(err error) bool { 29 _, ok := err.(ErrMilestoneNotExist) 30 return ok 31 } 32 33 func (err ErrMilestoneNotExist) Error() string { 34 if len(err.Name) > 0 { 35 return fmt.Sprintf("milestone does not exist [name: %s, repo_id: %d]", err.Name, err.RepoID) 36 } 37 return fmt.Sprintf("milestone does not exist [id: %d, repo_id: %d]", err.ID, err.RepoID) 38 } 39 40 func (err ErrMilestoneNotExist) Unwrap() error { 41 return util.ErrNotExist 42 } 43 44 // Milestone represents a milestone of repository. 45 type Milestone struct { 46 ID int64 `xorm:"pk autoincr"` 47 RepoID int64 `xorm:"INDEX"` 48 Repo *repo_model.Repository `xorm:"-"` 49 Name string 50 Content string `xorm:"TEXT"` 51 RenderedContent string `xorm:"-"` 52 IsClosed bool 53 NumIssues int 54 NumClosedIssues int 55 NumOpenIssues int `xorm:"-"` 56 Completeness int // Percentage(1-100). 57 IsOverdue bool `xorm:"-"` 58 59 CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` 60 UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` 61 DeadlineUnix timeutil.TimeStamp 62 ClosedDateUnix timeutil.TimeStamp 63 DeadlineString string `xorm:"-"` 64 65 TotalTrackedTime int64 `xorm:"-"` 66 } 67 68 func init() { 69 db.RegisterModel(new(Milestone)) 70 } 71 72 // BeforeUpdate is invoked from XORM before updating this object. 73 func (m *Milestone) BeforeUpdate() { 74 if m.NumIssues > 0 { 75 m.Completeness = m.NumClosedIssues * 100 / m.NumIssues 76 } else { 77 m.Completeness = 0 78 } 79 } 80 81 // AfterLoad is invoked from XORM after setting the value of a field of 82 // this object. 83 func (m *Milestone) AfterLoad() { 84 m.NumOpenIssues = m.NumIssues - m.NumClosedIssues 85 if m.DeadlineUnix.Year() == 9999 { 86 return 87 } 88 89 m.DeadlineString = m.DeadlineUnix.Format("2006-01-02") 90 if m.IsClosed { 91 m.IsOverdue = m.ClosedDateUnix >= m.DeadlineUnix 92 } else { 93 m.IsOverdue = timeutil.TimeStampNow() >= m.DeadlineUnix 94 } 95 } 96 97 // State returns string representation of milestone status. 98 func (m *Milestone) State() api.StateType { 99 if m.IsClosed { 100 return api.StateClosed 101 } 102 return api.StateOpen 103 } 104 105 // NewMilestone creates new milestone of repository. 106 func NewMilestone(ctx context.Context, m *Milestone) (err error) { 107 ctx, committer, err := db.TxContext(ctx) 108 if err != nil { 109 return err 110 } 111 defer committer.Close() 112 113 m.Name = strings.TrimSpace(m.Name) 114 115 if err = db.Insert(ctx, m); err != nil { 116 return err 117 } 118 119 if _, err = db.Exec(ctx, "UPDATE `repository` SET num_milestones = num_milestones + 1 WHERE id = ?", m.RepoID); err != nil { 120 return err 121 } 122 return committer.Commit() 123 } 124 125 // HasMilestoneByRepoID returns if the milestone exists in the repository. 126 func HasMilestoneByRepoID(ctx context.Context, repoID, id int64) (bool, error) { 127 return db.GetEngine(ctx).ID(id).Where("repo_id=?", repoID).Exist(new(Milestone)) 128 } 129 130 // GetMilestoneByRepoID returns the milestone in a repository. 131 func GetMilestoneByRepoID(ctx context.Context, repoID, id int64) (*Milestone, error) { 132 m := new(Milestone) 133 has, err := db.GetEngine(ctx).ID(id).Where("repo_id=?", repoID).Get(m) 134 if err != nil { 135 return nil, err 136 } else if !has { 137 return nil, ErrMilestoneNotExist{ID: id, RepoID: repoID} 138 } 139 return m, nil 140 } 141 142 // GetMilestoneByRepoIDANDName return a milestone if one exist by name and repo 143 func GetMilestoneByRepoIDANDName(ctx context.Context, repoID int64, name string) (*Milestone, error) { 144 var mile Milestone 145 has, err := db.GetEngine(ctx).Where("repo_id=? AND name=?", repoID, name).Get(&mile) 146 if err != nil { 147 return nil, err 148 } 149 if !has { 150 return nil, ErrMilestoneNotExist{Name: name, RepoID: repoID} 151 } 152 return &mile, nil 153 } 154 155 // UpdateMilestone updates information of given milestone. 156 func UpdateMilestone(ctx context.Context, m *Milestone, oldIsClosed bool) error { 157 ctx, committer, err := db.TxContext(ctx) 158 if err != nil { 159 return err 160 } 161 defer committer.Close() 162 163 if m.IsClosed && !oldIsClosed { 164 m.ClosedDateUnix = timeutil.TimeStampNow() 165 } 166 167 if err := updateMilestone(ctx, m); err != nil { 168 return err 169 } 170 171 // if IsClosed changed, update milestone numbers of repository 172 if oldIsClosed != m.IsClosed { 173 if err := updateRepoMilestoneNum(ctx, m.RepoID); err != nil { 174 return err 175 } 176 } 177 178 return committer.Commit() 179 } 180 181 func updateMilestone(ctx context.Context, m *Milestone) error { 182 m.Name = strings.TrimSpace(m.Name) 183 _, err := db.GetEngine(ctx).ID(m.ID).AllCols().Update(m) 184 if err != nil { 185 return err 186 } 187 return UpdateMilestoneCounters(ctx, m.ID) 188 } 189 190 // UpdateMilestoneCounters calculates NumIssues, NumClosesIssues and Completeness 191 func UpdateMilestoneCounters(ctx context.Context, id int64) error { 192 e := db.GetEngine(ctx) 193 _, err := e.ID(id). 194 SetExpr("num_issues", builder.Select("count(*)").From("issue").Where( 195 builder.Eq{"milestone_id": id}, 196 )). 197 SetExpr("num_closed_issues", builder.Select("count(*)").From("issue").Where( 198 builder.Eq{ 199 "milestone_id": id, 200 "is_closed": true, 201 }, 202 )). 203 Update(&Milestone{}) 204 if err != nil { 205 return err 206 } 207 _, err = e.Exec("UPDATE `milestone` SET completeness=100*num_closed_issues/(CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) WHERE id=?", 208 id, 209 ) 210 return err 211 } 212 213 // ChangeMilestoneStatusByRepoIDAndID changes a milestone open/closed status if the milestone ID is in the repo. 214 func ChangeMilestoneStatusByRepoIDAndID(ctx context.Context, repoID, milestoneID int64, isClosed bool) error { 215 ctx, committer, err := db.TxContext(ctx) 216 if err != nil { 217 return err 218 } 219 defer committer.Close() 220 221 m := &Milestone{ 222 ID: milestoneID, 223 RepoID: repoID, 224 } 225 226 has, err := db.GetEngine(ctx).ID(milestoneID).Where("repo_id = ?", repoID).Get(m) 227 if err != nil { 228 return err 229 } else if !has { 230 return ErrMilestoneNotExist{ID: milestoneID, RepoID: repoID} 231 } 232 233 if err := changeMilestoneStatus(ctx, m, isClosed); err != nil { 234 return err 235 } 236 237 return committer.Commit() 238 } 239 240 // ChangeMilestoneStatus changes the milestone open/closed status. 241 func ChangeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) (err error) { 242 ctx, committer, err := db.TxContext(ctx) 243 if err != nil { 244 return err 245 } 246 defer committer.Close() 247 248 if err := changeMilestoneStatus(ctx, m, isClosed); err != nil { 249 return err 250 } 251 252 return committer.Commit() 253 } 254 255 func changeMilestoneStatus(ctx context.Context, m *Milestone, isClosed bool) error { 256 m.IsClosed = isClosed 257 if isClosed { 258 m.ClosedDateUnix = timeutil.TimeStampNow() 259 } 260 261 count, err := db.GetEngine(ctx).ID(m.ID).Where("repo_id = ? AND is_closed = ?", m.RepoID, !isClosed).Cols("is_closed", "closed_date_unix").Update(m) 262 if err != nil { 263 return err 264 } 265 if count < 1 { 266 return nil 267 } 268 return updateRepoMilestoneNum(ctx, m.RepoID) 269 } 270 271 // DeleteMilestoneByRepoID deletes a milestone from a repository. 272 func DeleteMilestoneByRepoID(ctx context.Context, repoID, id int64) error { 273 m, err := GetMilestoneByRepoID(ctx, repoID, id) 274 if err != nil { 275 if IsErrMilestoneNotExist(err) { 276 return nil 277 } 278 return err 279 } 280 281 repo, err := repo_model.GetRepositoryByID(ctx, m.RepoID) 282 if err != nil { 283 return err 284 } 285 286 ctx, committer, err := db.TxContext(ctx) 287 if err != nil { 288 return err 289 } 290 defer committer.Close() 291 292 sess := db.GetEngine(ctx) 293 294 if _, err = sess.ID(m.ID).Delete(new(Milestone)); err != nil { 295 return err 296 } 297 298 numMilestones, err := CountMilestones(ctx, GetMilestonesOption{ 299 RepoID: repo.ID, 300 State: api.StateAll, 301 }) 302 if err != nil { 303 return err 304 } 305 numClosedMilestones, err := CountMilestones(ctx, GetMilestonesOption{ 306 RepoID: repo.ID, 307 State: api.StateClosed, 308 }) 309 if err != nil { 310 return err 311 } 312 repo.NumMilestones = int(numMilestones) 313 repo.NumClosedMilestones = int(numClosedMilestones) 314 315 if _, err = sess.ID(repo.ID).Cols("num_milestones, num_closed_milestones").Update(repo); err != nil { 316 return err 317 } 318 319 if _, err = db.Exec(ctx, "UPDATE `issue` SET milestone_id = 0 WHERE milestone_id = ?", m.ID); err != nil { 320 return err 321 } 322 return committer.Commit() 323 } 324 325 func updateRepoMilestoneNum(ctx context.Context, repoID int64) error { 326 _, err := db.GetEngine(ctx).Exec("UPDATE `repository` SET num_milestones=(SELECT count(*) FROM milestone WHERE repo_id=?),num_closed_milestones=(SELECT count(*) FROM milestone WHERE repo_id=? AND is_closed=?) WHERE id=?", 327 repoID, 328 repoID, 329 true, 330 repoID, 331 ) 332 return err 333 } 334 335 // LoadTotalTrackedTime loads the tracked time for the milestone 336 func (m *Milestone) LoadTotalTrackedTime(ctx context.Context) error { 337 type totalTimesByMilestone struct { 338 MilestoneID int64 339 Time int64 340 } 341 totalTime := &totalTimesByMilestone{MilestoneID: m.ID} 342 has, err := db.GetEngine(ctx).Table("issue"). 343 Join("INNER", "milestone", "issue.milestone_id = milestone.id"). 344 Join("LEFT", "tracked_time", "tracked_time.issue_id = issue.id"). 345 Where("tracked_time.deleted = ?", false). 346 Select("milestone_id, sum(time) as time"). 347 Where("milestone_id = ?", m.ID). 348 GroupBy("milestone_id"). 349 Get(totalTime) 350 if err != nil { 351 return err 352 } else if !has { 353 return nil 354 } 355 m.TotalTrackedTime = totalTime.Time 356 return nil 357 } 358 359 // InsertMilestones creates milestones of repository. 360 func InsertMilestones(ctx context.Context, ms ...*Milestone) (err error) { 361 if len(ms) == 0 { 362 return nil 363 } 364 365 ctx, committer, err := db.TxContext(ctx) 366 if err != nil { 367 return err 368 } 369 defer committer.Close() 370 sess := db.GetEngine(ctx) 371 372 // to return the id, so we should not use batch insert 373 for _, m := range ms { 374 if _, err = sess.NoAutoTime().Insert(m); err != nil { 375 return err 376 } 377 } 378 379 if _, err = db.Exec(ctx, "UPDATE `repository` SET num_milestones = num_milestones + ? WHERE id = ?", len(ms), ms[0].RepoID); err != nil { 380 return err 381 } 382 return committer.Commit() 383 }