From adbc995c347e158a56264f2488997d7d59a4dd8b Mon Sep 17 00:00:00 2001 From: 6543 Date: Thu, 19 Oct 2023 16:08:31 +0200 Subject: [PATCH] Show total TrackedTime on issue/pull/milestone lists (#26672) TODOs: - [x] write test for `GetIssueTotalTrackedTime` - [x] frontport kitharas template changes and make them mobile-friendly --- ![image](https://github.com/go-gitea/gitea/assets/24977596/6713da97-201f-4217-8588-4c4cec157171) ![image](https://github.com/go-gitea/gitea/assets/24977596/3a45aba8-26b5-4e6a-b97d-68bfc2bf9024) --- *Sponsored by Kithara Software GmbH* --- models/issues/issue_test.go | 6 ++ models/issues/tracked_time.go | 44 ++++++++++++ models/issues/tracked_time_test.go | 12 ++++ options/locale/locale_en-US.ini | 1 + routers/web/repo/issue.go | 78 ++++++++++++---------- templates/repo/issue/filters.tmpl | 9 +++ templates/repo/issue/list.tmpl | 9 +++ templates/repo/issue/milestone_issues.tmpl | 6 ++ 8 files changed, 129 insertions(+), 36 deletions(-) diff --git a/models/issues/issue_test.go b/models/issues/issue_test.go index f5548209648..4393d18bcf4 100644 --- a/models/issues/issue_test.go +++ b/models/issues/issue_test.go @@ -191,6 +191,12 @@ func TestIssues(t *testing.T) { }, []int64{}, // issues with **both** label 1 and 2, none of these issues matches, TODO: add more tests }, + { + issues_model.IssuesOptions{ + MilestoneIDs: []int64{1}, + }, + []int64{2}, + }, } { issues, err := issues_model.Issues(db.DefaultContext, &test.Opts) assert.NoError(t, err) diff --git a/models/issues/tracked_time.go b/models/issues/tracked_time.go index 89c99c5cd13..795bddeb347 100644 --- a/models/issues/tracked_time.go +++ b/models/issues/tracked_time.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/util" "xorm.io/builder" + "xorm.io/xorm" ) // TrackedTime represents a time that was spent for a specific issue. @@ -325,3 +326,46 @@ func GetTrackedTimeByID(ctx context.Context, id int64) (*TrackedTime, error) { } return time, nil } + +// GetIssueTotalTrackedTime returns the total tracked time for issues by given conditions. +func GetIssueTotalTrackedTime(ctx context.Context, opts *IssuesOptions, isClosed 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 := i + MaxQueryParameters + if chunk > len(opts.IssueIDs) { + chunk = 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 bool, issueIDs []int64) (int64, error) { + sumSession := func(opts *IssuesOptions, issueIDs []int64) *xorm.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 + } + + return sumSession(opts, issueIDs). + And("issue.is_closed = ?", isClosed). + SumInt(new(trackedTime), "tracked_time.time") +} diff --git a/models/issues/tracked_time_test.go b/models/issues/tracked_time_test.go index cc2cb918e07..2774234e7bd 100644 --- a/models/issues/tracked_time_test.go +++ b/models/issues/tracked_time_test.go @@ -115,3 +115,15 @@ func TestTotalTimesForEachUser(t *testing.T) { assert.NoError(t, err) assert.Len(t, total, 2) } + +func TestGetIssueTotalTrackedTime(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + + ttt, err := issues_model.GetIssueTotalTrackedTime(db.DefaultContext, &issues_model.IssuesOptions{MilestoneIDs: []int64{1}}, false) + assert.NoError(t, err) + assert.EqualValues(t, 3682, ttt) + + ttt, err = issues_model.GetIssueTotalTrackedTime(db.DefaultContext, &issues_model.IssuesOptions{MilestoneIDs: []int64{1}}, true) + assert.NoError(t, err) + assert.EqualValues(t, 0, ttt) +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 46138fad546..84c457e9e84 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -17,6 +17,7 @@ template = Template language = Language notifications = Notifications active_stopwatch = Active Time Tracker +tracked_time_summary = Summary of tracked time based on filters of issue list create_new = Create… user_profile_and_more = Profile and Settings… signed_in_as = Signed in as diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 3fd25f81fb5..96fce488782 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -198,46 +198,43 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti } var issueStats *issues_model.IssueStats - { - statsOpts := &issues_model.IssuesOptions{ - RepoIDs: []int64{repo.ID}, - LabelIDs: labelIDs, - MilestoneIDs: mileIDs, - ProjectID: projectID, - AssigneeID: assigneeID, - MentionedID: mentionedID, - PosterID: posterID, - ReviewRequestedID: reviewRequestedID, - ReviewedID: reviewedID, - IsPull: isPullOption, - IssueIDs: nil, - } - if keyword != "" { - allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) - if err != nil { - if issue_indexer.IsAvailable(ctx) { - ctx.ServerError("issueIDsFromSearch", err) - return - } - ctx.Data["IssueIndexerUnavailable"] = true + statsOpts := &issues_model.IssuesOptions{ + RepoIDs: []int64{repo.ID}, + LabelIDs: labelIDs, + MilestoneIDs: mileIDs, + ProjectID: projectID, + AssigneeID: assigneeID, + MentionedID: mentionedID, + PosterID: posterID, + ReviewRequestedID: reviewRequestedID, + ReviewedID: reviewedID, + IsPull: isPullOption, + IssueIDs: nil, + } + if keyword != "" { + allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) + if err != nil { + if issue_indexer.IsAvailable(ctx) { + ctx.ServerError("issueIDsFromSearch", err) return } - statsOpts.IssueIDs = allIssueIDs + ctx.Data["IssueIndexerUnavailable"] = true + return } - if keyword != "" && len(statsOpts.IssueIDs) == 0 { - // So it did search with the keyword, but no issue found. - // Just set issueStats to empty. - issueStats = &issues_model.IssueStats{} - } else { - // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. - // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. - issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) - if err != nil { - ctx.ServerError("GetIssueStats", err) - return - } + statsOpts.IssueIDs = allIssueIDs + } + if keyword != "" && len(statsOpts.IssueIDs) == 0 { + // So it did search with the keyword, but no issue found. + // Just set issueStats to empty. + issueStats = &issues_model.IssueStats{} + } else { + // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. + // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. + issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) + if err != nil { + ctx.ServerError("GetIssueStats", err) + return } - } isShowClosed := ctx.FormString("state") == "closed" @@ -246,6 +243,15 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti isShowClosed = true } + if repo.IsTimetrackerEnabled(ctx) { + totalTrackedTime, err := issues_model.GetIssueTotalTrackedTime(ctx, statsOpts, isShowClosed) + if err != nil { + ctx.ServerError("GetIssueTotalTrackedTime", err) + return + } + ctx.Data["TotalTrackedTime"] = totalTrackedTime + } + archived := ctx.FormBool("archived") page := ctx.FormInt("page") diff --git a/templates/repo/issue/filters.tmpl b/templates/repo/issue/filters.tmpl index 8645ff9d507..1d200e23b7a 100644 --- a/templates/repo/issue/filters.tmpl +++ b/templates/repo/issue/filters.tmpl @@ -4,6 +4,15 @@ {{end}} {{template "repo/issue/openclose" .}} + + {{if .TotalTrackedTime}} + + {{end}}