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