mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
365 lines
11 KiB
Go
365 lines
11 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package issues
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/hanzoai/git/models/db"
|
|
user_model "github.com/hanzoai/git/models/user"
|
|
"github.com/hanzoai/git/modules/optional"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
"github.com/hanzoai/git/modules/util"
|
|
|
|
"github.com/hanzoai/builder"
|
|
)
|
|
|
|
// TrackedTime represents a time that was spent for a specific issue.
|
|
type TrackedTime struct {
|
|
ID int64 `xorm:"pk autoincr"`
|
|
IssueID int64 `xorm:"INDEX"`
|
|
Issue *Issue `xorm:"-"`
|
|
UserID int64 `xorm:"INDEX"`
|
|
User *user_model.User `xorm:"-"`
|
|
Created time.Time `xorm:"-"`
|
|
CreatedUnix int64 `xorm:"created"`
|
|
Time int64 `xorm:"NOT NULL"`
|
|
Deleted bool `xorm:"NOT NULL DEFAULT false"`
|
|
}
|
|
|
|
func init() {
|
|
db.RegisterModel(new(TrackedTime))
|
|
}
|
|
|
|
// TrackedTimeList is a List of TrackedTime's
|
|
type TrackedTimeList []*TrackedTime
|
|
|
|
// AfterLoad is invoked from XORM after setting the values of all fields of this object.
|
|
func (t *TrackedTime) AfterLoad() {
|
|
t.Created = time.Unix(t.CreatedUnix, 0).In(setting.DefaultUILocation)
|
|
}
|
|
|
|
// LoadAttributes load Issue, User
|
|
func (t *TrackedTime) LoadAttributes(ctx context.Context) (err error) {
|
|
// Load the issue
|
|
if t.Issue == nil {
|
|
t.Issue, err = GetIssueByID(ctx, t.IssueID)
|
|
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
// Now load the repo for the issue (which we may have just loaded)
|
|
if t.Issue != nil {
|
|
err = t.Issue.LoadRepo(ctx)
|
|
if err != nil && !errors.Is(err, util.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
// Load the user
|
|
if t.User == nil {
|
|
t.User, err = user_model.GetUserByID(ctx, t.UserID)
|
|
if err != nil {
|
|
if !errors.Is(err, util.ErrNotExist) {
|
|
return err
|
|
}
|
|
t.User = user_model.NewGhostUser()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadAttributes load Issue, User
|
|
func (tl TrackedTimeList) LoadAttributes(ctx context.Context) error {
|
|
for _, t := range tl {
|
|
if err := t.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindTrackedTimesOptions represent the filters for tracked times. If an ID is 0 it will be ignored.
|
|
type FindTrackedTimesOptions struct {
|
|
db.ListOptions
|
|
IssueID int64
|
|
UserID int64
|
|
RepositoryID int64
|
|
MilestoneID int64
|
|
CreatedAfterUnix int64
|
|
CreatedBeforeUnix int64
|
|
}
|
|
|
|
// toCond will convert each condition into a xorm-Cond
|
|
func (opts *FindTrackedTimesOptions) ToConds() builder.Cond {
|
|
cond := builder.NewCond().And(builder.Eq{"tracked_time.deleted": false})
|
|
if opts.IssueID != 0 {
|
|
cond = cond.And(builder.Eq{"issue_id": opts.IssueID})
|
|
}
|
|
if opts.UserID != 0 {
|
|
cond = cond.And(builder.Eq{"user_id": opts.UserID})
|
|
}
|
|
if opts.RepositoryID != 0 {
|
|
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
|
|
}
|
|
if opts.MilestoneID != 0 {
|
|
cond = cond.And(builder.Eq{"issue.milestone_id": opts.MilestoneID})
|
|
}
|
|
if opts.CreatedAfterUnix != 0 {
|
|
cond = cond.And(builder.Gte{"tracked_time.created_unix": opts.CreatedAfterUnix})
|
|
}
|
|
if opts.CreatedBeforeUnix != 0 {
|
|
cond = cond.And(builder.Lte{"tracked_time.created_unix": opts.CreatedBeforeUnix})
|
|
}
|
|
return cond
|
|
}
|
|
|
|
func (opts *FindTrackedTimesOptions) ToJoins() []db.JoinFunc {
|
|
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
|
|
return []db.JoinFunc{
|
|
func(e db.Engine) error {
|
|
e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toSession will convert the given options to a xorm Session by using the conditions from toCond and joining with issue table if required
|
|
func (opts *FindTrackedTimesOptions) toSession(e db.Engine) db.Engine {
|
|
sess := e
|
|
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
|
|
sess = e.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
|
|
}
|
|
|
|
sess = sess.Where(opts.ToConds())
|
|
|
|
if opts.Page > 0 {
|
|
db.SetSessionPagination(sess, opts)
|
|
}
|
|
|
|
return sess
|
|
}
|
|
|
|
// GetTrackedTimes returns all tracked times that fit to the given options.
|
|
func GetTrackedTimes(ctx context.Context, options *FindTrackedTimesOptions) (trackedTimes TrackedTimeList, err error) {
|
|
err = options.toSession(db.GetEngine(ctx)).Find(&trackedTimes)
|
|
return trackedTimes, err
|
|
}
|
|
|
|
// CountTrackedTimes returns count of tracked times that fit to the given options.
|
|
func CountTrackedTimes(ctx context.Context, opts *FindTrackedTimesOptions) (int64, error) {
|
|
sess := db.GetEngine(ctx).Where(opts.ToConds())
|
|
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
|
|
sess = sess.Join("INNER", "issue", "issue.id = tracked_time.issue_id")
|
|
}
|
|
return sess.Count(&TrackedTime{})
|
|
}
|
|
|
|
// GetTrackedSeconds return sum of seconds
|
|
func GetTrackedSeconds(ctx context.Context, opts FindTrackedTimesOptions) (trackedSeconds int64, err error) {
|
|
return opts.toSession(db.GetEngine(ctx)).SumInt(&TrackedTime{}, "time")
|
|
}
|
|
|
|
// AddTime will add the given time (in seconds) to the issue
|
|
func AddTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
|
|
return db.WithTx2(ctx, func(ctx context.Context) (*TrackedTime, error) {
|
|
t, err := addTime(ctx, user, issue, amount, created)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := issue.LoadRepo(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := CreateComment(ctx, &CreateCommentOptions{
|
|
Issue: issue,
|
|
Repo: issue.Repo,
|
|
Doer: user,
|
|
// Content before v1.21 did store the formatted string instead of seconds,
|
|
// so use "|" as delimiter to mark the new format
|
|
Content: fmt.Sprintf("|%d", amount),
|
|
Type: CommentTypeAddTimeManual,
|
|
TimeID: t.ID,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return t, nil
|
|
})
|
|
}
|
|
|
|
func addTime(ctx context.Context, user *user_model.User, issue *Issue, amount int64, created time.Time) (*TrackedTime, error) {
|
|
if created.IsZero() {
|
|
created = time.Now()
|
|
}
|
|
tt := &TrackedTime{
|
|
IssueID: issue.ID,
|
|
UserID: user.ID,
|
|
Time: amount,
|
|
Created: created,
|
|
}
|
|
return tt, db.Insert(ctx, tt)
|
|
}
|
|
|
|
// TotalTimesForEachUser returns the spent time in seconds for each user by an issue
|
|
func TotalTimesForEachUser(ctx context.Context, options *FindTrackedTimesOptions) (map[*user_model.User]int64, error) {
|
|
trackedTimes, err := GetTrackedTimes(ctx, options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Adding total time per user ID
|
|
totalTimesByUser := make(map[int64]int64)
|
|
for _, t := range trackedTimes {
|
|
totalTimesByUser[t.UserID] += t.Time
|
|
}
|
|
|
|
totalTimes := make(map[*user_model.User]int64)
|
|
// Fetching User and making time human readable
|
|
for userID, total := range totalTimesByUser {
|
|
user, err := user_model.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
if user_model.IsErrUserNotExist(err) {
|
|
continue
|
|
}
|
|
return nil, err
|
|
}
|
|
totalTimes[user] = total
|
|
}
|
|
return totalTimes, nil
|
|
}
|
|
|
|
// DeleteIssueUserTimes deletes times for issue
|
|
func DeleteIssueUserTimes(ctx context.Context, issue *Issue, user *user_model.User) error {
|
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
|
opts := FindTrackedTimesOptions{
|
|
IssueID: issue.ID,
|
|
UserID: user.ID,
|
|
}
|
|
|
|
removedTime, err := deleteTimes(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if removedTime == 0 {
|
|
return db.ErrNotExist{Resource: "tracked_time"}
|
|
}
|
|
|
|
if err := issue.LoadRepo(ctx); err != nil {
|
|
return err
|
|
}
|
|
_, err = CreateComment(ctx, &CreateCommentOptions{
|
|
Issue: issue,
|
|
Repo: issue.Repo,
|
|
Doer: user,
|
|
// Content before v1.21 did store the formatted string instead of seconds,
|
|
// so use "|" as delimiter to mark the new format
|
|
Content: fmt.Sprintf("|%d", removedTime),
|
|
Type: CommentTypeDeleteTimeManual,
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// DeleteTime delete a specific Time
|
|
func DeleteTime(ctx context.Context, t *TrackedTime) error {
|
|
return db.WithTx(ctx, func(ctx context.Context) error {
|
|
if err := t.LoadAttributes(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := deleteTime(ctx, t); err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err := CreateComment(ctx, &CreateCommentOptions{
|
|
Issue: t.Issue,
|
|
Repo: t.Issue.Repo,
|
|
Doer: t.User,
|
|
// Content before v1.21 did store the formatted string instead of seconds,
|
|
// so use "|" as delimiter to mark the new format
|
|
Content: fmt.Sprintf("|%d", t.Time),
|
|
Type: CommentTypeDeleteTimeManual,
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
func deleteTimes(ctx context.Context, opts FindTrackedTimesOptions) (removedTime int64, err error) {
|
|
removedTime, err = GetTrackedSeconds(ctx, opts)
|
|
if err != nil || removedTime == 0 {
|
|
return removedTime, err
|
|
}
|
|
|
|
_, err = opts.toSession(db.GetEngine(ctx)).Table("tracked_time").Cols("deleted").Update(&TrackedTime{Deleted: true})
|
|
return removedTime, err
|
|
}
|
|
|
|
func deleteTime(ctx context.Context, t *TrackedTime) error {
|
|
if t.Deleted {
|
|
return db.ErrNotExist{Resource: "tracked_time", ID: t.ID}
|
|
}
|
|
t.Deleted = true
|
|
_, err := db.GetEngine(ctx).ID(t.ID).Cols("deleted").Update(t)
|
|
return err
|
|
}
|
|
|
|
// GetTrackedTimeByID returns raw TrackedTime without loading attributes by id
|
|
func GetTrackedTimeByID(ctx context.Context, issueID, trackedTimeID int64) (*TrackedTime, error) {
|
|
time := new(TrackedTime)
|
|
has, err := db.GetEngine(ctx).ID(trackedTimeID).Where("issue_id = ?", issueID).Get(time)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !has {
|
|
return nil, db.ErrNotExist{Resource: "tracked_time", ID: trackedTimeID}
|
|
}
|
|
return time, nil
|
|
}
|
|
|
|
// GetIssueTotalTrackedTime returns the total tracked time for issues by given conditions.
|
|
func GetIssueTotalTrackedTime(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool]) (int64, error) {
|
|
if len(opts.IssueIDs) <= MaxQueryParameters {
|
|
return getIssueTotalTrackedTimeChunk(ctx, opts, isClosed, opts.IssueIDs)
|
|
}
|
|
|
|
// If too long a list of IDs is provided,
|
|
// we get the statistics in smaller chunks and get accumulates
|
|
var accum int64
|
|
for i := 0; i < len(opts.IssueIDs); {
|
|
chunk := min(i+MaxQueryParameters, len(opts.IssueIDs))
|
|
time, err := getIssueTotalTrackedTimeChunk(ctx, opts, isClosed, opts.IssueIDs[i:chunk])
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
accum += time
|
|
i = chunk
|
|
}
|
|
return accum, nil
|
|
}
|
|
|
|
func getIssueTotalTrackedTimeChunk(ctx context.Context, opts *IssuesOptions, isClosed optional.Option[bool], issueIDs []int64) (int64, error) {
|
|
sumSession := func(opts *IssuesOptions, issueIDs []int64) db.Session {
|
|
sess := db.GetEngine(ctx).
|
|
Table("tracked_time").
|
|
Where("tracked_time.deleted = ?", false).
|
|
Join("INNER", "issue", "tracked_time.issue_id = issue.id")
|
|
|
|
return applyIssuesOptions(sess, opts, issueIDs)
|
|
}
|
|
|
|
type trackedTime struct {
|
|
Time int64
|
|
}
|
|
|
|
session := sumSession(opts, issueIDs)
|
|
if isClosed.Has() {
|
|
session.And("issue.is_closed = ?", isClosed.Value())
|
|
}
|
|
return session.SumInt(new(trackedTime), "tracked_time.time")
|
|
}
|