Skip to content

Commit 8d5f58d

Browse files
jonasfranzlunny
authored andcommitted
Shows total tracked time in issue and milestone list (#3341)
* Show total tracked time in issue and milestone list Show total tracked time at issue page Signed-off-by: Jonas Franz <info@jonasfranz.software> * Optimizing TotalTimes by using SumInt Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fixing wrong total times for milestones caused by a missing JOIN Adding unit tests for total times Signed-off-by: Jonas Franz <info@jonasfranz.software> * Logging error instead of ignoring it Signed-off-by: Jonas Franz <info@jonasfranz.software> * Correcting spelling mistakes Signed-off-by: Jonas Franz <info@jonasfranz.software> * Change error message to a short version Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add error handling to TotalTimes Add variable for totalTimes Signed-off-by: Jonas Franz <info@jonasfranz.de> * Introduce TotalTrackedTimes as variable of issue Load TotalTrackedTimes by loading attributes of IssueList Load TotalTrackedTimes by loading attributes of single issue Add Sec2Time as helper to use it in templates Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fixed test + gofmt Signed-off-by: Jonas Franz <info@jonasfranz.software> * Load TotalTrackedTimes via MilestoneList instead of single requests Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add documentation for MilestoneList Signed-off-by: Jonas Franz <info@jonasfranz.software> * Add documentation for MilestoneList Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix test Signed-off-by: Jonas Franz <info@jonasfranz.software> * Change comment from SQL query to description Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix unit test by using int64 instead of int Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix unit test by using int64 instead of int Signed-off-by: Jonas Franz <info@jonasfranz.software> * Check if timetracker is enabled Signed-off-by: Jonas Franz <info@jonasfranz.software> * Fix test by enabling timetracking Signed-off-by: Jonas Franz <info@jonasfranz.de>
1 parent e3028d1 commit 8d5f58d

File tree

15 files changed

+200
-20
lines changed

15 files changed

+200
-20
lines changed

models/issue.go

+27-3
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,10 @@ type Issue struct {
5151
UpdatedUnix util.TimeStamp `xorm:"INDEX updated"`
5252
ClosedUnix util.TimeStamp `xorm:"INDEX"`
5353

54-
Attachments []*Attachment `xorm:"-"`
55-
Comments []*Comment `xorm:"-"`
56-
Reactions ReactionList `xorm:"-"`
54+
Attachments []*Attachment `xorm:"-"`
55+
Comments []*Comment `xorm:"-"`
56+
Reactions ReactionList `xorm:"-"`
57+
TotalTrackedTime int64 `xorm:"-"`
5758
}
5859

5960
var (
@@ -69,6 +70,15 @@ func init() {
6970
issueTasksDonePat = regexp.MustCompile(issueTasksDoneRegexpStr)
7071
}
7172

73+
func (issue *Issue) loadTotalTimes(e Engine) (err error) {
74+
opts := FindTrackedTimesOptions{IssueID: issue.ID}
75+
issue.TotalTrackedTime, err = opts.ToSession(e).SumInt(&TrackedTime{}, "time")
76+
if err != nil {
77+
return err
78+
}
79+
return nil
80+
}
81+
7282
func (issue *Issue) loadRepo(e Engine) (err error) {
7383
if issue.Repo == nil {
7484
issue.Repo, err = getRepositoryByID(e, issue.RepoID)
@@ -79,6 +89,15 @@ func (issue *Issue) loadRepo(e Engine) (err error) {
7989
return nil
8090
}
8191

92+
// IsTimetrackerEnabled returns true if the repo enables timetracking
93+
func (issue *Issue) IsTimetrackerEnabled() bool {
94+
if err := issue.loadRepo(x); err != nil {
95+
log.Error(4, fmt.Sprintf("loadRepo: %v", err))
96+
return false
97+
}
98+
return issue.Repo.IsTimetrackerEnabled()
99+
}
100+
82101
// GetPullRequest returns the issue pull request
83102
func (issue *Issue) GetPullRequest() (pr *PullRequest, err error) {
84103
if !issue.IsPull {
@@ -225,6 +244,11 @@ func (issue *Issue) loadAttributes(e Engine) (err error) {
225244
if err = issue.loadComments(e); err != nil {
226245
return err
227246
}
247+
if issue.IsTimetrackerEnabled() {
248+
if err = issue.loadTotalTimes(e); err != nil {
249+
return err
250+
}
251+
}
228252

229253
return issue.loadReactions(e)
230254
}

models/issue_list.go

+48
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,50 @@ func (issues IssueList) loadComments(e Engine) (err error) {
290290
return nil
291291
}
292292

293+
func (issues IssueList) loadTotalTrackedTimes(e Engine) (err error) {
294+
type totalTimesByIssue struct {
295+
IssueID int64
296+
Time int64
297+
}
298+
if len(issues) == 0 {
299+
return nil
300+
}
301+
var trackedTimes = make(map[int64]int64, len(issues))
302+
303+
var ids = make([]int64, 0, len(issues))
304+
for _, issue := range issues {
305+
if issue.Repo.IsTimetrackerEnabled() {
306+
ids = append(ids, issue.ID)
307+
}
308+
}
309+
310+
// select issue_id, sum(time) from tracked_time where issue_id in (<issue ids in current page>) group by issue_id
311+
rows, err := e.Table("tracked_time").
312+
Select("issue_id, sum(time) as time").
313+
In("issue_id", ids).
314+
GroupBy("issue_id").
315+
Rows(new(totalTimesByIssue))
316+
if err != nil {
317+
return err
318+
}
319+
320+
defer rows.Close()
321+
322+
for rows.Next() {
323+
var totalTime totalTimesByIssue
324+
err = rows.Scan(&totalTime)
325+
if err != nil {
326+
return err
327+
}
328+
trackedTimes[totalTime.IssueID] = totalTime.Time
329+
}
330+
331+
for _, issue := range issues {
332+
issue.TotalTrackedTime = trackedTimes[issue.ID]
333+
}
334+
return nil
335+
}
336+
293337
// loadAttributes loads all attributes, expect for attachments and comments
294338
func (issues IssueList) loadAttributes(e Engine) (err error) {
295339
if _, err = issues.loadRepositories(e); err != nil {
@@ -316,6 +360,10 @@ func (issues IssueList) loadAttributes(e Engine) (err error) {
316360
return
317361
}
318362

363+
if err = issues.loadTotalTrackedTimes(e); err != nil {
364+
return
365+
}
366+
319367
return nil
320368
}
321369

models/issue_list_test.go

+8-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ package models
77
import (
88
"testing"
99

10+
"code.gitea.io/gitea/modules/setting"
11+
1012
"github.com/stretchr/testify/assert"
1113
)
1214

@@ -29,7 +31,7 @@ func TestIssueList_LoadRepositories(t *testing.T) {
2931

3032
func TestIssueList_LoadAttributes(t *testing.T) {
3133
assert.NoError(t, PrepareTestDatabase())
32-
34+
setting.Service.EnableTimetracking = true
3335
issueList := IssueList{
3436
AssertExistsAndLoadBean(t, &Issue{ID: 1}).(*Issue),
3537
AssertExistsAndLoadBean(t, &Issue{ID: 2}).(*Issue),
@@ -61,5 +63,10 @@ func TestIssueList_LoadAttributes(t *testing.T) {
6163
for _, comment := range issue.Comments {
6264
assert.EqualValues(t, issue.ID, comment.IssueID)
6365
}
66+
if issue.ID == int64(1) {
67+
assert.Equal(t, int64(400), issue.TotalTrackedTime)
68+
} else if issue.ID == int64(2) {
69+
assert.Equal(t, int64(3662), issue.TotalTrackedTime)
70+
}
6471
}
6572
}

models/issue_milestone.go

+59-3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ type Milestone struct {
2929
DeadlineString string `xorm:"-"`
3030
DeadlineUnix util.TimeStamp
3131
ClosedDateUnix util.TimeStamp
32+
33+
TotalTrackedTime int64 `xorm:"-"`
3234
}
3335

3436
// BeforeUpdate is invoked from XORM before updating this object.
@@ -118,14 +120,69 @@ func GetMilestoneByRepoID(repoID, id int64) (*Milestone, error) {
118120
return getMilestoneByRepoID(x, repoID, id)
119121
}
120122

123+
// MilestoneList is a list of milestones offering additional functionality
124+
type MilestoneList []*Milestone
125+
126+
func (milestones MilestoneList) loadTotalTrackedTimes(e Engine) error {
127+
type totalTimesByMilestone struct {
128+
MilestoneID int64
129+
Time int64
130+
}
131+
if len(milestones) == 0 {
132+
return nil
133+
}
134+
var trackedTimes = make(map[int64]int64, len(milestones))
135+
136+
// Get total tracked time by milestone_id
137+
rows, err := e.Table("issue").
138+
Join("INNER", "milestone", "issue.milestone_id = milestone.id").
139+
Join("LEFT", "tracked_time", "tracked_time.issue_id = issue.id").
140+
Select("milestone_id, sum(time) as time").
141+
In("milestone_id", milestones.getMilestoneIDs()).
142+
GroupBy("milestone_id").
143+
Rows(new(totalTimesByMilestone))
144+
if err != nil {
145+
return err
146+
}
147+
148+
defer rows.Close()
149+
150+
for rows.Next() {
151+
var totalTime totalTimesByMilestone
152+
err = rows.Scan(&totalTime)
153+
if err != nil {
154+
return err
155+
}
156+
trackedTimes[totalTime.MilestoneID] = totalTime.Time
157+
}
158+
159+
for _, milestone := range milestones {
160+
milestone.TotalTrackedTime = trackedTimes[milestone.ID]
161+
}
162+
return nil
163+
}
164+
165+
// LoadTotalTrackedTimes loads for every milestone in the list the TotalTrackedTime by a batch request
166+
func (milestones MilestoneList) LoadTotalTrackedTimes() error {
167+
return milestones.loadTotalTrackedTimes(x)
168+
}
169+
170+
func (milestones MilestoneList) getMilestoneIDs() []int64 {
171+
var ids = make([]int64, 0, len(milestones))
172+
for _, ms := range milestones {
173+
ids = append(ids, ms.ID)
174+
}
175+
return ids
176+
}
177+
121178
// GetMilestonesByRepoID returns all milestones of a repository.
122-
func GetMilestonesByRepoID(repoID int64) ([]*Milestone, error) {
179+
func GetMilestonesByRepoID(repoID int64) (MilestoneList, error) {
123180
miles := make([]*Milestone, 0, 10)
124181
return miles, x.Where("repo_id = ?", repoID).Find(&miles)
125182
}
126183

127184
// GetMilestones returns a list of milestones of given repository and status.
128-
func GetMilestones(repoID int64, page int, isClosed bool, sortType string) ([]*Milestone, error) {
185+
func GetMilestones(repoID int64, page int, isClosed bool, sortType string) (MilestoneList, error) {
129186
miles := make([]*Milestone, 0, setting.UI.IssuePagingNum)
130187
sess := x.Where("repo_id = ? AND is_closed = ?", repoID, isClosed)
131188
if page > 0 {
@@ -146,7 +203,6 @@ func GetMilestones(repoID int64, page int, isClosed bool, sortType string) ([]*M
146203
default:
147204
sess.Asc("deadline_unix")
148205
}
149-
150206
return miles, sess.Find(&miles)
151207
}
152208

models/issue_milestone_test.go

+11
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,14 @@ func TestDeleteMilestoneByRepoID(t *testing.T) {
253253

254254
assert.NoError(t, DeleteMilestoneByRepoID(NonexistentID, NonexistentID))
255255
}
256+
257+
func TestMilestoneList_LoadTotalTrackedTimes(t *testing.T) {
258+
assert.NoError(t, PrepareTestDatabase())
259+
miles := MilestoneList{
260+
AssertExistsAndLoadBean(t, &Milestone{ID: 1}).(*Milestone),
261+
}
262+
263+
assert.NoError(t, miles.LoadTotalTrackedTimes())
264+
265+
assert.Equal(t, miles[0].TotalTrackedTime, int64(3662))
266+
}

models/issue_stopwatch.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func CreateOrStopIssueStopwatch(user *User, issue *Issue) error {
6969
Doer: user,
7070
Issue: issue,
7171
Repo: issue.Repo,
72-
Content: secToTime(timediff),
72+
Content: SecToTime(timediff),
7373
Type: CommentTypeStopTracking,
7474
}); err != nil {
7575
return err
@@ -124,7 +124,8 @@ func CancelStopwatch(user *User, issue *Issue) error {
124124
return nil
125125
}
126126

127-
func secToTime(duration int64) string {
127+
// SecToTime converts an amount of seconds to a human-readable string (example: 66s -> 1min 6s)
128+
func SecToTime(duration int64) string {
128129
seconds := duration % 60
129130
minutes := (duration / (60)) % 60
130131
hours := duration / (60 * 60)

models/issue_test.go

+8
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,11 @@ func TestGetUserIssueStats(t *testing.T) {
279279
assert.Equal(t, test.ExpectedIssueStats, *stats)
280280
}
281281
}
282+
283+
func TestIssue_loadTotalTimes(t *testing.T) {
284+
assert.NoError(t, PrepareTestDatabase())
285+
ms, err := GetIssueByID(2)
286+
assert.NoError(t, err)
287+
assert.NoError(t, ms.loadTotalTimes(x))
288+
assert.Equal(t, int64(3662), ms.TotalTrackedTime)
289+
}

models/issue_tracked_time.go

+16-7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
api "code.gitea.io/sdk/gitea"
1212

1313
"github.com/go-xorm/builder"
14+
"github.com/go-xorm/xorm"
1415
)
1516

1617
// TrackedTime represents a time that was spent for a specific issue.
@@ -44,6 +45,7 @@ type FindTrackedTimesOptions struct {
4445
IssueID int64
4546
UserID int64
4647
RepositoryID int64
48+
MilestoneID int64
4749
}
4850

4951
// ToCond will convert each condition into a xorm-Cond
@@ -58,16 +60,23 @@ func (opts *FindTrackedTimesOptions) ToCond() builder.Cond {
5860
if opts.RepositoryID != 0 {
5961
cond = cond.And(builder.Eq{"issue.repo_id": opts.RepositoryID})
6062
}
63+
if opts.MilestoneID != 0 {
64+
cond = cond.And(builder.Eq{"issue.milestone_id": opts.MilestoneID})
65+
}
6166
return cond
6267
}
6368

69+
// ToSession will convert the given options to a xorm Session by using the conditions from ToCond and joining with issue table if required
70+
func (opts *FindTrackedTimesOptions) ToSession(e Engine) *xorm.Session {
71+
if opts.RepositoryID > 0 || opts.MilestoneID > 0 {
72+
return e.Join("INNER", "issue", "issue.id = tracked_time.issue_id").Where(opts.ToCond())
73+
}
74+
return x.Where(opts.ToCond())
75+
}
76+
6477
// GetTrackedTimes returns all tracked times that fit to the given options.
6578
func GetTrackedTimes(options FindTrackedTimesOptions) (trackedTimes []*TrackedTime, err error) {
66-
if options.RepositoryID > 0 {
67-
err = x.Join("INNER", "issue", "issue.id = tracked_time.issue_id").Where(options.ToCond()).Find(&trackedTimes)
68-
return
69-
}
70-
err = x.Where(options.ToCond()).Find(&trackedTimes)
79+
err = options.ToSession(x).Find(&trackedTimes)
7180
return
7281
}
7382

@@ -85,7 +94,7 @@ func AddTime(user *User, issue *Issue, time int64) (*TrackedTime, error) {
8594
Issue: issue,
8695
Repo: issue.Repo,
8796
Doer: user,
88-
Content: secToTime(time),
97+
Content: SecToTime(time),
8998
Type: CommentTypeAddTimeManual,
9099
}); err != nil {
91100
return nil, err
@@ -115,7 +124,7 @@ func TotalTimes(options FindTrackedTimesOptions) (map[*User]string, error) {
115124
}
116125
return nil, err
117126
}
118-
totalTimes[user] = secToTime(total)
127+
totalTimes[user] = SecToTime(total)
119128
}
120129
return totalTimes, nil
121130
}

modules/templates/helper.go

+3-2
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,9 @@ func NewFuncMap() []template.FuncMap {
179179
}
180180
return dict, nil
181181
},
182-
"Printf": fmt.Sprintf,
183-
"Escape": Escape,
182+
"Printf": fmt.Sprintf,
183+
"Escape": Escape,
184+
"Sec2Time": models.SecToTime,
184185
}}
185186
}
186187

options/locale/locale_en-US.ini

+2-1
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,8 @@ issues.add_time_minutes = Minutes
736736
issues.add_time_sum_to_small = No time was entered.
737737
issues.cancel_tracking = Cancel
738738
issues.cancel_tracking_history = `cancelled time tracking %s`
739-
issues.time_spent_total = Total Time Spent
739+
issues.time_spent_from_all_authors = `Total Time Spent: %s`
740+
740741
741742
pulls.desc = Enable merge requests and code reviews.
742743
pulls.new = New Pull Request

routers/repo/issue.go

+6
Original file line numberDiff line numberDiff line change
@@ -1139,6 +1139,12 @@ func Milestones(ctx *context.Context) {
11391139
ctx.ServerError("GetMilestones", err)
11401140
return
11411141
}
1142+
if ctx.Repo.Repository.IsTimetrackerEnabled() {
1143+
if miles.LoadTotalTrackedTimes(); err != nil {
1144+
ctx.ServerError("LoadTotalTrackedTimes", err)
1145+
return
1146+
}
1147+
}
11421148
for _, m := range miles {
11431149
m.RenderedContent = string(markdown.Render([]byte(m.Content), ctx.Repo.RepoLink, ctx.Repo.Repository.ComposeMetas()))
11441150
}

templates/repo/issue/list.tmpl

+4
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@
198198
<span class="comment ui right"><i class="octicon octicon-comment"></i> {{.NumComments}}</span>
199199
{{end}}
200200

201+
{{if .TotalTrackedTime}}
202+
<span class="comment ui right"><i class="octicon octicon-clock"></i> {{.TotalTrackedTime | Sec2Time}}</span>
203+
{{end}}
204+
201205
<p class="desc">
202206
{{$.i18n.Tr "repo.issues.opened_by" $timeStr .Poster.HomeLink .Poster.Name | Safe}}
203207
{{$tasks := .GetTasks}}

0 commit comments

Comments
 (0)