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 @@
+
+
+
+ {{ctx.Locale.Tr "org.members.member"}} |
+ {{ctx.Locale.Tr "org.worktime.time"}} |
+
+
+
+ {{range $.WorktimeSumResult}}
+
+ {{svg "octicon-person"}} {{.UserName}} |
+ {{svg "octicon-clock"}} {{.SumTime | Sec2Hour}} |
+
+ {{end}}
+
+
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 @@
+
+
+
+ {{ctx.Locale.Tr "repository"}} |
+ {{ctx.Locale.Tr "repo.milestone"}} |
+ {{ctx.Locale.Tr "org.worktime.time"}} |
+
+
+
+ {{range $.WorktimeSumResult}}
+
+
+ {{if not .HideRepoName}}
+ {{svg "octicon-repo"}} {{.RepoName}}
+ {{end}}
+ |
+
+ {{if .MilestoneName}}
+ {{svg "octicon-milestone"}} {{.MilestoneName}}
+ {{else}}
+ -
+ {{end}}
+ |
+ {{svg "octicon-clock"}} {{.SumTime | Sec2Hour}} |
+
+ {{end}}
+
+
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 @@
+
+
+
+ {{ctx.Locale.Tr "repository"}} |
+ {{ctx.Locale.Tr "org.worktime.time"}} |
+
+
+
+ {{range $.WorktimeSumResult}}
+
+ {{svg "octicon-repo"}} {{.RepoName}} |
+ {{svg "octicon-clock"}} {{.SumTime | Sec2Hour}} |
+
+ {{end}}
+
+
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}}
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)
+}