mirror of
https://github.com/go-gitea/gitea.git
synced 2025-02-02 15:09:33 -05:00
Worktime tracking for the organization level (#19808)
Dear Gitea team, first of all, thanks for the great work you're doing with this project. I'm planning to introduce Gitea at a client site, and noticed that while there is time recording, there are no project-manager-friendly reports to actually make use of that data, as were also mentioned by others in #4870 #8684 and #13531. Since I had a little time last weekend, I had put together something that I hope to be a useful contribution to this great project (while of course useful for me too). This PR adds a new "Worktime" tab to the Organisation level. There is a date range selector (by default set to the current month), and there are three possible views: - by repository, - by milestone, and - by team member. Happy to receive any feedback! There are several possible future improvements of course (predefined date ranges, charts, a member time sheet, matrix of repos/members, etc) but I hope that even in this relatively simple state this would be useful to lots of people. <img width="1161" alt="Screen Shot 2022-05-25 at 22 12 58" src="https://user-images.githubusercontent.com/118010/170366976-af00c7af-c4f3-4117-86d7-00356d6797a5.png"> Keep up the good work! Kristof --------- Co-authored-by: user <user@kk-git1> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
869f8fdbe4
commit
34692a20b1
103
models/organization/org_worktime.go
Normal file
103
models/organization/org_worktime.go
Normal file
@ -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
|
||||
}
|
@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap {
|
||||
// time / number / format
|
||||
"FileSize": base.FileSize,
|
||||
"CountFmt": countFmt,
|
||||
"Sec2Time": util.SecToHours,
|
||||
"Sec2Hour": util.SecToHours,
|
||||
|
||||
"TimeEstimateString": timeEstimateString,
|
||||
|
||||
|
@ -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, " ")
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 <strong>all repositories</strong> 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
|
||||
|
74
routers/web/org/worktime.go
Normal file
74
routers/web/org/worktime.go
Normal file
@ -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)
|
||||
}
|
@ -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)
|
||||
|
@ -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...)
|
||||
|
@ -45,6 +45,11 @@
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .IsOrganizationOwner}}
|
||||
<a class="{{if $.PageIsOrgTimes}}active{{end}} item" href="{{$.OrgLink}}/worktime">
|
||||
{{svg "octicon-clock"}} {{ctx.Locale.Tr "org.worktime"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .IsOrganizationOwner}}
|
||||
<span class="item-flex-space"></span>
|
||||
<a class="{{if .PageIsOrgSettings}}active {{end}}item" href="{{.OrgLink}}/settings">
|
||||
{{svg "octicon-tools"}} {{ctx.Locale.Tr "repo.settings"}}
|
||||
|
40
templates/org/worktime.tmpl
Normal file
40
templates/org/worktime.tmpl
Normal file
@ -0,0 +1,40 @@
|
||||
{{template "base/head" .}}
|
||||
<div class="page-content organization times">
|
||||
{{template "org/header" .}}
|
||||
<div class="ui container">
|
||||
<div class="ui grid">
|
||||
<div class="three wide column">
|
||||
<form class="ui form" method="get">
|
||||
<input type="hidden" name="by" value="{{$.WorktimeBy}}">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.worktime.date_range_start"}}</label>
|
||||
<input type="date" name="from" value="{{.RangeFrom}}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "org.worktime.date_range_end"}}</label>
|
||||
<input type="date" name="to" value="{{.RangeTo}}">
|
||||
</div>
|
||||
<button class="ui primary button">{{ctx.Locale.Tr "org.worktime.query"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="thirteen wide column">
|
||||
<div class="ui column">
|
||||
<div class="ui compact small menu">
|
||||
{{$queryParams := QueryBuild "from" .RangeFrom "to" .RangeTo}}
|
||||
<a class="{{Iif .WorktimeByRepos "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=repos&{{$queryParams}}">{{svg "octicon-repo"}} {{ctx.Locale.Tr "org.worktime.by_repositories"}}</a>
|
||||
<a class="{{Iif .WorktimeByMilestones "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=milestones&{{$queryParams}}">{{svg "octicon-milestone"}} {{ctx.Locale.Tr "org.worktime.by_milestones"}}</a>
|
||||
<a class="{{Iif .WorktimeByMembers "active"}} item" href="{{$.Org.OrganisationLink}}/worktime?by=members&{{$queryParams}}">{{svg "octicon-people"}} {{ctx.Locale.Tr "org.worktime.by_members"}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{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}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
16
templates/org/worktime/table_members.tmpl
Normal file
16
templates/org/worktime/table_members.tmpl
Normal file
@ -0,0 +1,16 @@
|
||||
<table class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "org.members.member"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $.WorktimeSumResult}}
|
||||
<tr>
|
||||
<td>{{svg "octicon-person"}} <a href="{{AppSubUrl}}/{{PathEscape .UserName}}">{{.UserName}}</a></td>
|
||||
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
28
templates/org/worktime/table_milestones.tmpl
Normal file
28
templates/org/worktime/table_milestones.tmpl
Normal file
@ -0,0 +1,28 @@
|
||||
<table class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repository"}}</th>
|
||||
<th>{{ctx.Locale.Tr "repo.milestone"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $.WorktimeSumResult}}
|
||||
<tr>
|
||||
<td>
|
||||
{{if not .HideRepoName}}
|
||||
{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .MilestoneName}}
|
||||
{{svg "octicon-milestone"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/milestone/{{.MilestoneID}}">{{.MilestoneName}}</a>
|
||||
{{else}}
|
||||
-
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
16
templates/org/worktime/table_repos.tmpl
Normal file
16
templates/org/worktime/table_repos.tmpl
Normal file
@ -0,0 +1,16 @@
|
||||
<table class="ui table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "repository"}}</th>
|
||||
<th>{{ctx.Locale.Tr "org.worktime.time"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $.WorktimeSumResult}}
|
||||
<tr>
|
||||
<td>{{svg "octicon-repo"}} <a href="{{$.Org.HomeLink}}/{{PathEscape .RepoName}}/issues">{{.RepoName}}</a></td>
|
||||
<td>{{svg "octicon-clock"}} {{.SumTime | Sec2Hour}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
@ -9,7 +9,7 @@
|
||||
<div class="ui compact tiny secondary menu">
|
||||
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||
{{svg "octicon-clock"}}
|
||||
{{.TotalTrackedTime | Sec2Time}}
|
||||
{{.TotalTrackedTime | Sec2Hour}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
@ -40,7 +40,7 @@
|
||||
<div class="ui compact tiny secondary menu">
|
||||
<span class="item" data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||
{{svg "octicon-clock"}}
|
||||
{{.TotalTrackedTime | Sec2Time}}
|
||||
{{.TotalTrackedTime | Sec2Hour}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
@ -50,7 +50,7 @@
|
||||
{{if .TotalTrackedTime}}
|
||||
<div data-tooltip-content='{{ctx.Locale.Tr "tracked_time_summary"}}'>
|
||||
{{svg "octicon-clock"}}
|
||||
{{.TotalTrackedTime | Sec2Time}}
|
||||
{{.TotalTrackedTime | Sec2Hour}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
@ -41,7 +41,7 @@
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="flex-text-block">
|
||||
{{svg "octicon-clock"}}
|
||||
{{.TotalTrackedTime|Sec2Time}}
|
||||
{{.TotalTrackedTime|Sec2Hour}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .UpdatedUnix}}
|
||||
|
@ -72,7 +72,7 @@
|
||||
{{end}}
|
||||
{{if .WorkingUsers}}
|
||||
<div class="ui comments tw-mt-2">
|
||||
{{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)}}
|
||||
<div>
|
||||
{{range $user, $trackedtime := .WorkingUsers}}
|
||||
<div class="comment tw-mt-2">
|
||||
@ -82,7 +82,7 @@
|
||||
<div class="content">
|
||||
{{template "shared/user/authorlink" $user}}
|
||||
<div class="text">
|
||||
{{$trackedtime|Sec2Time}}
|
||||
{{$trackedtime|Sec2Hour}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -252,7 +252,7 @@
|
||||
<span class="text grey muted-links">
|
||||
{{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}}
|
||||
</span>
|
||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||
@ -264,7 +264,7 @@
|
||||
<span class="text grey muted-links">
|
||||
{{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}}
|
||||
</span>
|
||||
{{template "repo/issue/view_content/comments_delete_time" dict "ctxData" $ "comment" .}}
|
||||
@ -506,7 +506,7 @@
|
||||
{{/* compatibility with time comments made before v1.21 */}}
|
||||
<span class="text grey muted-links">{{.RenderedContent}}</span>
|
||||
{{else}}
|
||||
<span class="text grey muted-links">- {{.Content|Sec2Time}}</span>
|
||||
<span class="text grey muted-links">- {{.Content|Sec2Hour}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,7 +28,7 @@
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="text grey flex-text-block">
|
||||
{{svg "octicon-clock" 16}}
|
||||
{{.TotalTrackedTime | Sec2Time}}
|
||||
{{.TotalTrackedTime | Sec2Hour}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
@ -100,7 +100,7 @@
|
||||
{{if .TotalTrackedTime}}
|
||||
<div class="flex-text-block">
|
||||
{{svg "octicon-clock"}}
|
||||
{{.TotalTrackedTime|Sec2Time}}
|
||||
{{.TotalTrackedTime|Sec2Hour}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .UpdatedUnix}}
|
||||
|
293
tests/integration/org_worktime_test.go
Normal file
293
tests/integration/org_worktime_test.go
Normal file
@ -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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user