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  }