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*
This commit is contained in:
6543 2023-10-19 16:08:31 +02:00 committed by GitHub
parent e83f2cbbac
commit adbc995c34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 129 additions and 36 deletions

View file

@ -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 []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) issues, err := issues_model.Issues(db.DefaultContext, &test.Opts)
assert.NoError(t, err) assert.NoError(t, err)

View file

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
"xorm.io/builder" "xorm.io/builder"
"xorm.io/xorm"
) )
// TrackedTime represents a time that was spent for a specific issue. // 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 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")
}

View file

@ -115,3 +115,15 @@ func TestTotalTimesForEachUser(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, total, 2) 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)
}

View file

@ -17,6 +17,7 @@ template = Template
language = Language language = Language
notifications = Notifications notifications = Notifications
active_stopwatch = Active Time Tracker active_stopwatch = Active Time Tracker
tracked_time_summary = Summary of tracked time based on filters of issue list
create_new = Create… create_new = Create…
user_profile_and_more = Profile and Settings… user_profile_and_more = Profile and Settings…
signed_in_as = Signed in as signed_in_as = Signed in as

View file

@ -198,46 +198,43 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
} }
var issueStats *issues_model.IssueStats var issueStats *issues_model.IssueStats
{ statsOpts := &issues_model.IssuesOptions{
statsOpts := &issues_model.IssuesOptions{ RepoIDs: []int64{repo.ID},
RepoIDs: []int64{repo.ID}, LabelIDs: labelIDs,
LabelIDs: labelIDs, MilestoneIDs: mileIDs,
MilestoneIDs: mileIDs, ProjectID: projectID,
ProjectID: projectID, AssigneeID: assigneeID,
AssigneeID: assigneeID, MentionedID: mentionedID,
MentionedID: mentionedID, PosterID: posterID,
PosterID: posterID, ReviewRequestedID: reviewRequestedID,
ReviewRequestedID: reviewRequestedID, ReviewedID: reviewedID,
ReviewedID: reviewedID, IsPull: isPullOption,
IsPull: isPullOption, IssueIDs: nil,
IssueIDs: nil, }
} if keyword != "" {
if keyword != "" { allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts)
allIssueIDs, err := issueIDsFromSearch(ctx, keyword, statsOpts) if err != nil {
if err != nil { if issue_indexer.IsAvailable(ctx) {
if issue_indexer.IsAvailable(ctx) { ctx.ServerError("issueIDsFromSearch", err)
ctx.ServerError("issueIDsFromSearch", err)
return
}
ctx.Data["IssueIndexerUnavailable"] = true
return return
} }
statsOpts.IssueIDs = allIssueIDs ctx.Data["IssueIndexerUnavailable"] = true
return
} }
if keyword != "" && len(statsOpts.IssueIDs) == 0 { statsOpts.IssueIDs = allIssueIDs
// So it did search with the keyword, but no issue found. }
// Just set issueStats to empty. if keyword != "" && len(statsOpts.IssueIDs) == 0 {
issueStats = &issues_model.IssueStats{} // So it did search with the keyword, but no issue found.
} else { // Just set issueStats to empty.
// So it did search with the keyword, and found some issues. It needs to get issueStats of these issues. issueStats = &issues_model.IssueStats{}
// Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts. } else {
issueStats, err = issues_model.GetIssueStats(ctx, statsOpts) // So it did search with the keyword, and found some issues. It needs to get issueStats of these issues.
if err != nil { // Or the keyword is empty, so it doesn't need issueIDs as filter, just get issueStats with statsOpts.
ctx.ServerError("GetIssueStats", err) issueStats, err = issues_model.GetIssueStats(ctx, statsOpts)
return if err != nil {
} ctx.ServerError("GetIssueStats", err)
return
} }
} }
isShowClosed := ctx.FormString("state") == "closed" isShowClosed := ctx.FormString("state") == "closed"
@ -246,6 +243,15 @@ func issues(ctx *context.Context, milestoneID, projectID int64, isPullOption uti
isShowClosed = true 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") archived := ctx.FormBool("archived")
page := ctx.FormInt("page") page := ctx.FormInt("page")

View file

@ -4,6 +4,15 @@
<input type="checkbox" autocomplete="off" class="issue-checkbox-all gt-mr-4" title="{{ctx.Locale.Tr "repo.issues.action_check_all"}}"> <input type="checkbox" autocomplete="off" class="issue-checkbox-all gt-mr-4" title="{{ctx.Locale.Tr "repo.issues.action_check_all"}}">
{{end}} {{end}}
{{template "repo/issue/openclose" .}} {{template "repo/issue/openclose" .}}
<!-- Total Tracked Time -->
{{if .TotalTrackedTime}}
<div class="ui compact tiny secondary menu">
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
{{svg "octicon-clock"}}
{{.TotalTrackedTime | Sec2Time}}
</span>
</div>
{{end}}
</div> </div>
<div class="issue-list-toolbar-right"> <div class="issue-list-toolbar-right">
<div class="ui secondary filter menu labels"> <div class="ui secondary filter menu labels">

View file

@ -34,6 +34,15 @@
<div id="issue-actions" class="issue-list-toolbar gt-hidden"> <div id="issue-actions" class="issue-list-toolbar gt-hidden">
<div class="issue-list-toolbar-left"> <div class="issue-list-toolbar-left">
{{template "repo/issue/openclose" .}} {{template "repo/issue/openclose" .}}
<!-- Total Tracked Time -->
{{if .TotalTrackedTime}}
<div class="ui compact tiny secondary menu">
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
{{svg "octicon-clock"}}
{{.TotalTrackedTime | Sec2Time}}
</span>
</div>
{{end}}
</div> </div>
<div class="issue-list-toolbar-right"> <div class="issue-list-toolbar-right">
{{template "repo/issue/filter_actions" .}} {{template "repo/issue/filter_actions" .}}

View file

@ -46,6 +46,12 @@
{{end}} {{end}}
</div> </div>
<div class="gt-mr-3">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness | Safe}}</div> <div class="gt-mr-3">{{ctx.Locale.Tr "repo.milestones.completeness" .Milestone.Completeness | Safe}}</div>
{{if .TotalTrackedTime}}
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
{{svg "octicon-clock"}}
{{.TotalTrackedTime | Sec2Time}}
</div>
{{end}}
</div> </div>
</div> </div>
<div class="divider"></div> <div class="divider"></div>