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  }