diff --git a/models/organization/org_worktime.go b/models/organization/org_worktime.go new file mode 100644 index 0000000000000..7b57182a8a748 --- /dev/null +++ b/models/organization/org_worktime.go @@ -0,0 +1,103 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package organization + +import ( + "sort" + + "code.gitea.io/gitea/models/db" + + "xorm.io/builder" +) + +type WorktimeSumByRepos struct { + RepoName string + SumTime int64 +} + +func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByRepos, err error) { + err = db.GetEngine(db.DefaultContext). + Select("repository.name AS repo_name, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unitFrom}). + And(builder.Lte{"tracked_time.created_unix": unixTo}). + GroupBy("repository.name"). + OrderBy("repository.name"). + Find(&results) + return results, err +} + +type WorktimeSumByMilestones struct { + RepoName string + MilestoneName string + MilestoneID int64 + MilestoneDeadline int64 + SumTime int64 + HideRepoName bool +} + +func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) { + err = db.GetEngine(db.DefaultContext). + Select("repository.name AS repo_name, milestone.name AS milestone_name, milestone.id AS milestone_id, milestone.deadline_unix as milestone_deadline, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Join("LEFT", "milestone", "issue.milestone_id = milestone.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unitFrom}). + And(builder.Lte{"tracked_time.created_unix": unixTo}). + GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id"). + OrderBy("repository.name, milestone.deadline_unix, milestone.id"). + Find(&results) + + // TODO: pgsql: NULL values are sorted last in default ascending order, so we need to sort them manually again. + sort.Slice(results, func(i, j int) bool { + if results[i].RepoName != results[j].RepoName { + return results[i].RepoName < results[j].RepoName + } + if results[i].MilestoneDeadline != results[j].MilestoneDeadline { + return results[i].MilestoneDeadline < results[j].MilestoneDeadline + } + return results[i].MilestoneID < results[j].MilestoneID + }) + + // Show only the first RepoName, for nicer output. + prevRepoName := "" + for i := 0; i < len(results); i++ { + res := &results[i] + res.MilestoneDeadline = 0 // clear the deadline because we do not really need it + if prevRepoName == res.RepoName { + res.HideRepoName = true + } + prevRepoName = res.RepoName + } + return results, err +} + +type WorktimeSumByMembers struct { + UserName string + SumTime int64 +} + +func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) { + err = db.GetEngine(db.DefaultContext). + Select("`user`.name AS user_name, SUM(tracked_time.time) AS sum_time"). + Table("tracked_time"). + Join("INNER", "issue", "tracked_time.issue_id = issue.id"). + Join("INNER", "repository", "issue.repo_id = repository.id"). + Join("INNER", "`user`", "tracked_time.user_id = `user`.id"). + Where(builder.Eq{"repository.owner_id": org.ID}). + And(builder.Eq{"tracked_time.deleted": false}). + And(builder.Gte{"tracked_time.created_unix": unitFrom}). + And(builder.Lte{"tracked_time.created_unix": unixTo}). + GroupBy("`user`.name"). + OrderBy("sum_time DESC"). + Find(&results) + return results, err +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a2cc166de9755..c0b0ddc97dd18 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap { // time / number / format "FileSize": base.FileSize, "CountFmt": countFmt, - "Sec2Time": util.SecToHours, + "Sec2Hour": util.SecToHours, "TimeEstimateString": timeEstimateString, diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go index 73667d723ef76..646f33c82a520 100644 --- a/modules/util/sec_to_time.go +++ b/modules/util/sec_to_time.go @@ -11,16 +11,20 @@ import ( // SecToHours converts an amount of seconds to a human-readable hours string. // This is stable for planning and managing timesheets. // Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours. +// If the duration is less than 1 minute, it will be shown as seconds. func SecToHours(durationVal any) string { - duration, _ := ToInt64(durationVal) - hours := duration / 3600 - minutes := (duration / 60) % 60 + seconds, _ := ToInt64(durationVal) + hours := seconds / 3600 + minutes := (seconds / 60) % 60 formattedTime := "" formattedTime = formatTime(hours, "hour", formattedTime) formattedTime = formatTime(minutes, "minute", formattedTime) // The formatTime() function always appends a space at the end. This will be trimmed + if formattedTime == "" && seconds > 0 { + formattedTime = formatTime(seconds, "second", "") + } return strings.TrimRight(formattedTime, " ") } diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go index 71a8801d4f4aa..b67926bbcff9d 100644 --- a/modules/util/sec_to_time_test.go +++ b/modules/util/sec_to_time_test.go @@ -22,4 +22,7 @@ func TestSecToHours(t *testing.T) { assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second)) assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second)) assert.Equal(t, "672 hours", SecToHours(4*7*day)) + assert.Equal(t, "1 second", SecToHours(1)) + assert.Equal(t, "2 seconds", SecToHours(2)) + assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 68b7fa2f9fd97..886628e4ff29a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -54,6 +54,7 @@ webauthn_reload = Reload repository = Repository organization = Organization mirror = Mirror +issue_milestone = Milestone new_repo = New Repository new_migrate = New Migration new_mirror = New Mirror @@ -1253,6 +1254,7 @@ labels = Labels org_labels_desc = Organization level labels that can be used with all repositories under this organization org_labels_desc_manage = manage +milestone = Milestone milestones = Milestones commits = Commits commit = Commit @@ -2876,6 +2878,15 @@ view_as_role = View as: %s view_as_public_hint = You are viewing the README as a public user. view_as_member_hint = You are viewing the README as a member of this organization. +worktime = Worktime +worktime.date_range_start = Start date +worktime.date_range_end = End date +worktime.query = Query +worktime.time = Time +worktime.by_repositories = By repositories +worktime.by_milestones = By milestones +worktime.by_members = By members + [admin] maintenance = Maintenance dashboard = Dashboard diff --git a/routers/web/org/worktime.go b/routers/web/org/worktime.go new file mode 100644 index 0000000000000..23369848254ba --- /dev/null +++ b/routers/web/org/worktime.go @@ -0,0 +1,74 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "net/http" + "time" + + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/services/context" +) + +const tplByRepos templates.TplName = "org/worktime" + +// parseOrgTimes contains functionality that is required in all these functions, +// like parsing the date from the request, setting default dates, etc. +func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) { + rangeFrom := ctx.FormString("from") + rangeTo := ctx.FormString("to") + if rangeFrom == "" { + rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month + } + if rangeTo == "" { + rangeTo = time.Now().Format("2006-01-02") // defaults to today + } + + ctx.Data["RangeFrom"] = rangeFrom + ctx.Data["RangeTo"] = rangeTo + + timeFrom, err := time.Parse("2006-01-02", rangeFrom) + if err != nil { + ctx.ServerError("time.Parse", err) + } + timeTo, err := time.Parse("2006-01-02", rangeTo) + if err != nil { + ctx.ServerError("time.Parse", err) + } + unixFrom = timeFrom.Unix() + unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too + return unixFrom, unixTo +} + +func Worktime(ctx *context.Context) { + ctx.Data["PageIsOrgTimes"] = true + + unixFrom, unixTo := parseOrgTimes(ctx) + if ctx.Written() { + return + } + + worktimeBy := ctx.FormString("by") + ctx.Data["WorktimeBy"] = worktimeBy + + var worktimeSumResult any + var err error + if worktimeBy == "milestones" { + worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByMilestones"] = true + } else if worktimeBy == "members" { + worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByMembers"] = true + } else /* by repos */ { + worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo) + ctx.Data["WorktimeByRepos"] = true + } + if err != nil { + ctx.ServerError("GetWorktime", err) + return + } + ctx.Data["WorktimeSumResult"] = worktimeSumResult + ctx.HTML(http.StatusOK, tplByRepos) +} diff --git a/routers/web/web.go b/routers/web/web.go index bbf257a493de0..daba9887e8f14 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -913,6 +913,8 @@ func registerRoutes(m *web.Router) { m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost) m.Post("/teams/{team}/delete", org.DeleteTeam) + m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime) + m.Group("/settings", func() { m.Combo("").Get(org.Settings). Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost) diff --git a/services/context/org.go b/services/context/org.go index be87cef7a316f..f4597a4ce1572 100644 --- a/services/context/org.go +++ b/services/context/org.go @@ -63,6 +63,7 @@ func GetOrganizationByParams(ctx *Context) { } // HandleOrgAssignment handles organization assignment +// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin func HandleOrgAssignment(ctx *Context, args ...bool) { var ( requireMember bool @@ -269,6 +270,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { } // OrgAssignment returns a middleware to handle organization assignment +// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin func OrgAssignment(args ...bool) func(ctx *Context) { return func(ctx *Context) { HandleOrgAssignment(ctx, args...) diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 4a8aee68a7d37..2d3af2d559791 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -45,6 +45,11 @@ {{end}} {{if .IsOrganizationOwner}} + + {{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}} + + {{end}} + {{if .IsOrganizationOwner}} {{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}} diff --git a/templates/org/worktime.tmpl b/templates/org/worktime.tmpl new file mode 100644 index 0000000000000..5d99998129271 --- /dev/null +++ b/templates/org/worktime.tmpl @@ -0,0 +1,40 @@ +{{template "base/head" .}} +
+ {{template "org/header" .}} +
+
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ + {{if .WorktimeByRepos}} + {{template "org/worktime/table_repos" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}} + {{else if .WorktimeByMilestones}} + {{template "org/worktime/table_milestones" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}} + {{else if .WorktimeByMembers}} + {{template "org/worktime/table_members" dict "Org" .Org "WorktimeSumResult" .WorktimeSumResult}} + {{end}} +
+
+
+
+{{template "base/footer" .}} diff --git a/templates/org/worktime/table_members.tmpl b/templates/org/worktime/table_members.tmpl new file mode 100644 index 0000000000000..a59d1941d8490 --- /dev/null +++ b/templates/org/worktime/table_members.tmpl @@ -0,0 +1,16 @@ + + + + + + + + + {{range $.WorktimeSumResult}} + + + + + {{end}} + +
{{ctx.Locale.Tr "org.members.member"}}{{ctx.Locale.Tr "org.worktime.time"}}
{{svg "octicon-person"}} {{.UserName}}{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}
diff --git a/templates/org/worktime/table_milestones.tmpl b/templates/org/worktime/table_milestones.tmpl new file mode 100644 index 0000000000000..6ef9289e5601e --- /dev/null +++ b/templates/org/worktime/table_milestones.tmpl @@ -0,0 +1,28 @@ + + + + + + + + + + {{range $.WorktimeSumResult}} + + + + + + {{end}} + +
{{ctx.Locale.Tr "repository"}}{{ctx.Locale.Tr "repo.milestone"}}{{ctx.Locale.Tr "org.worktime.time"}}
+ {{if not .HideRepoName}} + {{svg "octicon-repo"}} {{.RepoName}} + {{end}} + + {{if .MilestoneName}} + {{svg "octicon-milestone"}} {{.MilestoneName}} + {{else}} + - + {{end}} + {{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}
diff --git a/templates/org/worktime/table_repos.tmpl b/templates/org/worktime/table_repos.tmpl new file mode 100644 index 0000000000000..eaa085df0c7e6 --- /dev/null +++ b/templates/org/worktime/table_repos.tmpl @@ -0,0 +1,16 @@ + + + + + + + + + {{range $.WorktimeSumResult}} + + + + + {{end}} + +
{{ctx.Locale.Tr "repository"}}{{ctx.Locale.Tr "org.worktime.time"}}
{{svg "octicon-repo"}} {{.RepoName}}{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}
diff --git a/templates/repo/issue/filters.tmpl b/templates/repo/issue/filters.tmpl index 06e7c1aa6c101..409ec876e6c2c 100644 --- a/templates/repo/issue/filters.tmpl +++ b/templates/repo/issue/filters.tmpl @@ -9,7 +9,7 @@ {{end}} diff --git a/templates/repo/issue/list.tmpl b/templates/repo/issue/list.tmpl index 01b610b39db0b..53d0eca171fee 100644 --- a/templates/repo/issue/list.tmpl +++ b/templates/repo/issue/list.tmpl @@ -40,7 +40,7 @@ {{end}} diff --git a/templates/repo/issue/milestone_issues.tmpl b/templates/repo/issue/milestone_issues.tmpl index 4fc60571173d8..abb4e3290da24 100644 --- a/templates/repo/issue/milestone_issues.tmpl +++ b/templates/repo/issue/milestone_issues.tmpl @@ -50,7 +50,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime | Sec2Time}} + {{.TotalTrackedTime | Sec2Hour}}
{{end}} diff --git a/templates/repo/issue/milestones.tmpl b/templates/repo/issue/milestones.tmpl index 9515acfb8e26f..e7dfe08ee0592 100644 --- a/templates/repo/issue/milestones.tmpl +++ b/templates/repo/issue/milestones.tmpl @@ -41,7 +41,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime|Sec2Time}} + {{.TotalTrackedTime|Sec2Hour}}
{{end}} {{if .UpdatedUnix}} diff --git a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl index f107dc5ef5a7a..d5ac6827ba09b 100644 --- a/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl +++ b/templates/repo/issue/sidebar/stopwatch_timetracker.tmpl @@ -72,7 +72,7 @@ {{end}} {{if .WorkingUsers}}
- {{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Time)}} + {{ctx.Locale.Tr "repo.issues.time_spent_from_all_authors" ($.Issue.TotalTrackedTime | Sec2Hour)}}
{{range $user, $trackedtime := .WorkingUsers}}
@@ -82,7 +82,7 @@
{{template "shared/user/authorlink" $user}}
- {{$trackedtime|Sec2Time}} + {{$trackedtime|Sec2Hour}}
diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index c1ad64a118751..f2f3d1c9ccbe1 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -252,7 +252,7 @@ {{template "shared/user/authorlink" .Poster}} {{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} - {{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} + {{if not $timeStr}}{{$timeStr = .Content|Sec2Hour}}{{end}} {{ctx.Locale.Tr "repo.issues.stop_tracking_history" $timeStr $createdStr}} {{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} @@ -264,7 +264,7 @@ {{template "shared/user/authorlink" .Poster}} {{$timeStr := .RenderedContent}} {{/* compatibility with time comments made before v1.21 */}} - {{if not $timeStr}}{{$timeStr = .Content|Sec2Time}}{{end}} + {{if not $timeStr}}{{$timeStr = .Content|Sec2Hour}}{{end}} {{ctx.Locale.Tr "repo.issues.add_time_history" $timeStr $createdStr}} {{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}} @@ -506,7 +506,7 @@ {{/* compatibility with time comments made before v1.21 */}} {{.RenderedContent}} {{else}} - - {{.Content|Sec2Time}} + - {{.Content|Sec2Hour}} {{end}}
diff --git a/templates/shared/issuelist.tmpl b/templates/shared/issuelist.tmpl index e8015b40eacb5..fe7f2fd8bfd61 100644 --- a/templates/shared/issuelist.tmpl +++ b/templates/shared/issuelist.tmpl @@ -28,7 +28,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock" 16}} - {{.TotalTrackedTime | Sec2Time}} + {{.TotalTrackedTime | Sec2Hour}}
{{end}} diff --git a/templates/user/dashboard/milestones.tmpl b/templates/user/dashboard/milestones.tmpl index c0059d3cd4cae..7c1a69a6f592a 100644 --- a/templates/user/dashboard/milestones.tmpl +++ b/templates/user/dashboard/milestones.tmpl @@ -100,7 +100,7 @@ {{if .TotalTrackedTime}}
{{svg "octicon-clock"}} - {{.TotalTrackedTime|Sec2Time}} + {{.TotalTrackedTime|Sec2Hour}}
{{end}} {{if .UpdatedUnix}} diff --git a/tests/integration/org_worktime_test.go b/tests/integration/org_worktime_test.go new file mode 100644 index 0000000000000..fb5216be8d318 --- /dev/null +++ b/tests/integration/org_worktime_test.go @@ -0,0 +1,293 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration_test + +import ( + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/organization" + "code.gitea.io/gitea/models/unittest" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTimesByRepos tests TimesByRepos functionality +func testTimesByRepos(t *testing.T) { + kases := []struct { + name string + unixfrom int64 + unixto int64 + orgname int64 + expected []organization.WorktimeSumByRepos + }{ + { + name: "Full sum for org 1", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 1, + expected: []organization.WorktimeSumByRepos(nil), + }, + { + name: "Full sum for org 2", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 2, + expected: []organization.WorktimeSumByRepos{ + { + RepoName: "repo1", + SumTime: 4083, + }, + { + RepoName: "repo2", + SumTime: 75, + }, + }, + }, + { + name: "Simple time bound", + unixfrom: 946684801, + unixto: 946684802, + orgname: 2, + expected: []organization.WorktimeSumByRepos{ + { + RepoName: "repo1", + SumTime: 3662, + }, + }, + }, + { + name: "Both times inclusive", + unixfrom: 946684801, + unixto: 946684801, + orgname: 2, + expected: []organization.WorktimeSumByRepos{ + { + RepoName: "repo1", + SumTime: 3661, + }, + }, + }, + { + name: "Should ignore deleted", + unixfrom: 947688814, + unixto: 947688815, + orgname: 2, + expected: []organization.WorktimeSumByRepos{ + { + RepoName: "repo2", + SumTime: 71, + }, + }, + }, + } + + // Run test kases + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) + assert.NoError(t, err) + results, err := organization.GetWorktimeByRepos(org, kase.unixfrom, kase.unixto) + assert.NoError(t, err) + assert.Equal(t, kase.expected, results) + }) + } +} + +// TestTimesByMilestones tests TimesByMilestones functionality +func testTimesByMilestones(t *testing.T) { + kases := []struct { + name string + unixfrom int64 + unixto int64 + orgname int64 + expected []organization.WorktimeSumByMilestones + }{ + { + name: "Full sum for org 1", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 1, + expected: []organization.WorktimeSumByMilestones(nil), + }, + { + name: "Full sum for org 2", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 2, + expected: []organization.WorktimeSumByMilestones{ + { + RepoName: "repo1", + MilestoneName: "", + MilestoneID: 0, + SumTime: 401, + HideRepoName: false, + }, + { + RepoName: "repo1", + MilestoneName: "milestone1", + MilestoneID: 1, + SumTime: 3682, + HideRepoName: true, + }, + { + RepoName: "repo2", + MilestoneName: "", + MilestoneID: 0, + SumTime: 75, + HideRepoName: false, + }, + }, + }, + { + name: "Simple time bound", + unixfrom: 946684801, + unixto: 946684802, + orgname: 2, + expected: []organization.WorktimeSumByMilestones{ + { + RepoName: "repo1", + MilestoneName: "milestone1", + MilestoneID: 1, + SumTime: 3662, + HideRepoName: false, + }, + }, + }, + { + name: "Both times inclusive", + unixfrom: 946684801, + unixto: 946684801, + orgname: 2, + expected: []organization.WorktimeSumByMilestones{ + { + RepoName: "repo1", + MilestoneName: "milestone1", + MilestoneID: 1, + SumTime: 3661, + HideRepoName: false, + }, + }, + }, + { + name: "Should ignore deleted", + unixfrom: 947688814, + unixto: 947688815, + orgname: 2, + expected: []organization.WorktimeSumByMilestones{ + { + RepoName: "repo2", + MilestoneName: "", + MilestoneID: 0, + SumTime: 71, + HideRepoName: false, + }, + }, + }, + } + + // Run test kases + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) + require.NoError(t, err) + results, err := organization.GetWorktimeByMilestones(org, kase.unixfrom, kase.unixto) + if assert.NoError(t, err) { + assert.Equal(t, kase.expected, results) + } + }) + } +} + +// TestTimesByMembers tests TimesByMembers functionality +func testTimesByMembers(t *testing.T) { + kases := []struct { + name string + unixfrom int64 + unixto int64 + orgname int64 + expected []organization.WorktimeSumByMembers + }{ + { + name: "Full sum for org 1", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 1, + expected: []organization.WorktimeSumByMembers(nil), + }, + { + // Test case: Sum of times forever in org no. 2 + name: "Full sum for org 2", + unixfrom: 0, + unixto: 9223372036854775807, + orgname: 2, + expected: []organization.WorktimeSumByMembers{ + { + UserName: "user2", + SumTime: 3666, + }, + { + UserName: "user1", + SumTime: 491, + }, + }, + }, + { + name: "Simple time bound", + unixfrom: 946684801, + unixto: 946684802, + orgname: 2, + expected: []organization.WorktimeSumByMembers{ + { + UserName: "user2", + SumTime: 3662, + }, + }, + }, + { + name: "Both times inclusive", + unixfrom: 946684801, + unixto: 946684801, + orgname: 2, + expected: []organization.WorktimeSumByMembers{ + { + UserName: "user2", + SumTime: 3661, + }, + }, + }, + { + name: "Should ignore deleted", + unixfrom: 947688814, + unixto: 947688815, + orgname: 2, + expected: []organization.WorktimeSumByMembers{ + { + UserName: "user1", + SumTime: 71, + }, + }, + }, + } + + // Run test kases + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + org, err := organization.GetOrgByID(db.DefaultContext, kase.orgname) + assert.NoError(t, err) + results, err := organization.GetWorktimeByMembers(org, kase.unixfrom, kase.unixto) + assert.NoError(t, err) + assert.Equal(t, kase.expected, results) + }) + } +} + +func TestOrgWorktime(t *testing.T) { + // we need to run these tests in integration test because there are complex SQL queries + assert.NoError(t, unittest.PrepareTestDatabase()) + t.Run("ByRepos", testTimesByRepos) + t.Run("ByMilestones", testTimesByMilestones) + t.Run("ByMembers", testTimesByMembers) +}