Skip to content

Commit 5d5d9af

Browse files
authored
Merge branch 'main' into fix-todo
2 parents e9bd90f + 34692a2 commit 5d5d9af

File tree

24 files changed

+628
-27
lines changed

24 files changed

+628
-27
lines changed

.changelog.yml

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,25 @@ groups:
2222
name: FEATURES
2323
labels:
2424
- type/feature
25-
-
26-
name: API
27-
labels:
28-
- modifies/api
2925
-
3026
name: ENHANCEMENTS
3127
labels:
3228
- type/enhancement
33-
- type/refactoring
34-
- topic/ui
29+
-
30+
name: PERFORMANCE
31+
labels:
32+
- performance/memory
33+
- performance/speed
34+
- performance/bigrepo
35+
- performance/cpu
3536
-
3637
name: BUGFIXES
3738
labels:
3839
- type/bug
40+
-
41+
name: API
42+
labels:
43+
- modifies/api
3944
-
4045
name: TESTING
4146
labels:
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package organization
5+
6+
import (
7+
"sort"
8+
9+
"code.gitea.io/gitea/models/db"
10+
11+
"xorm.io/builder"
12+
)
13+
14+
type WorktimeSumByRepos struct {
15+
RepoName string
16+
SumTime int64
17+
}
18+
19+
func GetWorktimeByRepos(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByRepos, err error) {
20+
err = db.GetEngine(db.DefaultContext).
21+
Select("repository.name AS repo_name, SUM(tracked_time.time) AS sum_time").
22+
Table("tracked_time").
23+
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
24+
Join("INNER", "repository", "issue.repo_id = repository.id").
25+
Where(builder.Eq{"repository.owner_id": org.ID}).
26+
And(builder.Eq{"tracked_time.deleted": false}).
27+
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
28+
And(builder.Lte{"tracked_time.created_unix": unixTo}).
29+
GroupBy("repository.name").
30+
OrderBy("repository.name").
31+
Find(&results)
32+
return results, err
33+
}
34+
35+
type WorktimeSumByMilestones struct {
36+
RepoName string
37+
MilestoneName string
38+
MilestoneID int64
39+
MilestoneDeadline int64
40+
SumTime int64
41+
HideRepoName bool
42+
}
43+
44+
func GetWorktimeByMilestones(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMilestones, err error) {
45+
err = db.GetEngine(db.DefaultContext).
46+
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").
47+
Table("tracked_time").
48+
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
49+
Join("INNER", "repository", "issue.repo_id = repository.id").
50+
Join("LEFT", "milestone", "issue.milestone_id = milestone.id").
51+
Where(builder.Eq{"repository.owner_id": org.ID}).
52+
And(builder.Eq{"tracked_time.deleted": false}).
53+
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
54+
And(builder.Lte{"tracked_time.created_unix": unixTo}).
55+
GroupBy("repository.name, milestone.name, milestone.deadline_unix, milestone.id").
56+
OrderBy("repository.name, milestone.deadline_unix, milestone.id").
57+
Find(&results)
58+
59+
// TODO: pgsql: NULL values are sorted last in default ascending order, so we need to sort them manually again.
60+
sort.Slice(results, func(i, j int) bool {
61+
if results[i].RepoName != results[j].RepoName {
62+
return results[i].RepoName < results[j].RepoName
63+
}
64+
if results[i].MilestoneDeadline != results[j].MilestoneDeadline {
65+
return results[i].MilestoneDeadline < results[j].MilestoneDeadline
66+
}
67+
return results[i].MilestoneID < results[j].MilestoneID
68+
})
69+
70+
// Show only the first RepoName, for nicer output.
71+
prevRepoName := ""
72+
for i := 0; i < len(results); i++ {
73+
res := &results[i]
74+
res.MilestoneDeadline = 0 // clear the deadline because we do not really need it
75+
if prevRepoName == res.RepoName {
76+
res.HideRepoName = true
77+
}
78+
prevRepoName = res.RepoName
79+
}
80+
return results, err
81+
}
82+
83+
type WorktimeSumByMembers struct {
84+
UserName string
85+
SumTime int64
86+
}
87+
88+
func GetWorktimeByMembers(org *Organization, unitFrom, unixTo int64) (results []WorktimeSumByMembers, err error) {
89+
err = db.GetEngine(db.DefaultContext).
90+
Select("`user`.name AS user_name, SUM(tracked_time.time) AS sum_time").
91+
Table("tracked_time").
92+
Join("INNER", "issue", "tracked_time.issue_id = issue.id").
93+
Join("INNER", "repository", "issue.repo_id = repository.id").
94+
Join("INNER", "`user`", "tracked_time.user_id = `user`.id").
95+
Where(builder.Eq{"repository.owner_id": org.ID}).
96+
And(builder.Eq{"tracked_time.deleted": false}).
97+
And(builder.Gte{"tracked_time.created_unix": unitFrom}).
98+
And(builder.Lte{"tracked_time.created_unix": unixTo}).
99+
GroupBy("`user`.name").
100+
OrderBy("sum_time DESC").
101+
Find(&results)
102+
return results, err
103+
}

modules/templates/helper.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func NewFuncMap() template.FuncMap {
6969
// time / number / format
7070
"FileSize": base.FileSize,
7171
"CountFmt": countFmt,
72-
"Sec2Time": util.SecToHours,
72+
"Sec2Hour": util.SecToHours,
7373

7474
"TimeEstimateString": timeEstimateString,
7575

modules/util/sec_to_time.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,20 @@ import (
1111
// SecToHours converts an amount of seconds to a human-readable hours string.
1212
// This is stable for planning and managing timesheets.
1313
// Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours.
14+
// If the duration is less than 1 minute, it will be shown as seconds.
1415
func SecToHours(durationVal any) string {
15-
duration, _ := ToInt64(durationVal)
16-
hours := duration / 3600
17-
minutes := (duration / 60) % 60
16+
seconds, _ := ToInt64(durationVal)
17+
hours := seconds / 3600
18+
minutes := (seconds / 60) % 60
1819

1920
formattedTime := ""
2021
formattedTime = formatTime(hours, "hour", formattedTime)
2122
formattedTime = formatTime(minutes, "minute", formattedTime)
2223

2324
// The formatTime() function always appends a space at the end. This will be trimmed
25+
if formattedTime == "" && seconds > 0 {
26+
formattedTime = formatTime(seconds, "second", "")
27+
}
2428
return strings.TrimRight(formattedTime, " ")
2529
}
2630

modules/util/sec_to_time_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ func TestSecToHours(t *testing.T) {
2222
assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second))
2323
assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second))
2424
assert.Equal(t, "672 hours", SecToHours(4*7*day))
25+
assert.Equal(t, "1 second", SecToHours(1))
26+
assert.Equal(t, "2 seconds", SecToHours(2))
27+
assert.Equal(t, "", SecToHours(nil)) // old behavior, empty means no output
2528
}

options/locale/locale_en-US.ini

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ webauthn_reload = Reload
5454
repository = Repository
5555
organization = Organization
5656
mirror = Mirror
57+
issue_milestone = Milestone
5758
new_repo = New Repository
5859
new_migrate = New Migration
5960
new_mirror = New Mirror
@@ -1253,6 +1254,7 @@ labels = Labels
12531254
org_labels_desc = Organization level labels that can be used with <strong>all repositories</strong> under this organization
12541255
org_labels_desc_manage = manage
12551256
1257+
milestone = Milestone
12561258
milestones = Milestones
12571259
commits = Commits
12581260
commit = Commit
@@ -2876,6 +2878,15 @@ view_as_role = View as: %s
28762878
view_as_public_hint = You are viewing the README as a public user.
28772879
view_as_member_hint = You are viewing the README as a member of this organization.
28782880
2881+
worktime = Worktime
2882+
worktime.date_range_start = Start date
2883+
worktime.date_range_end = End date
2884+
worktime.query = Query
2885+
worktime.time = Time
2886+
worktime.by_repositories = By repositories
2887+
worktime.by_milestones = By milestones
2888+
worktime.by_members = By members
2889+
28792890
[admin]
28802891
maintenance = Maintenance
28812892
dashboard = Dashboard

routers/web/org/worktime.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package org
5+
6+
import (
7+
"net/http"
8+
"time"
9+
10+
"code.gitea.io/gitea/models/organization"
11+
"code.gitea.io/gitea/modules/templates"
12+
"code.gitea.io/gitea/services/context"
13+
)
14+
15+
const tplByRepos templates.TplName = "org/worktime"
16+
17+
// parseOrgTimes contains functionality that is required in all these functions,
18+
// like parsing the date from the request, setting default dates, etc.
19+
func parseOrgTimes(ctx *context.Context) (unixFrom, unixTo int64) {
20+
rangeFrom := ctx.FormString("from")
21+
rangeTo := ctx.FormString("to")
22+
if rangeFrom == "" {
23+
rangeFrom = time.Now().Format("2006-01") + "-01" // defaults to start of current month
24+
}
25+
if rangeTo == "" {
26+
rangeTo = time.Now().Format("2006-01-02") // defaults to today
27+
}
28+
29+
ctx.Data["RangeFrom"] = rangeFrom
30+
ctx.Data["RangeTo"] = rangeTo
31+
32+
timeFrom, err := time.Parse("2006-01-02", rangeFrom)
33+
if err != nil {
34+
ctx.ServerError("time.Parse", err)
35+
}
36+
timeTo, err := time.Parse("2006-01-02", rangeTo)
37+
if err != nil {
38+
ctx.ServerError("time.Parse", err)
39+
}
40+
unixFrom = timeFrom.Unix()
41+
unixTo = timeTo.Add(1440*time.Minute - 1*time.Second).Unix() // humans expect that we include the ending day too
42+
return unixFrom, unixTo
43+
}
44+
45+
func Worktime(ctx *context.Context) {
46+
ctx.Data["PageIsOrgTimes"] = true
47+
48+
unixFrom, unixTo := parseOrgTimes(ctx)
49+
if ctx.Written() {
50+
return
51+
}
52+
53+
worktimeBy := ctx.FormString("by")
54+
ctx.Data["WorktimeBy"] = worktimeBy
55+
56+
var worktimeSumResult any
57+
var err error
58+
if worktimeBy == "milestones" {
59+
worktimeSumResult, err = organization.GetWorktimeByMilestones(ctx.Org.Organization, unixFrom, unixTo)
60+
ctx.Data["WorktimeByMilestones"] = true
61+
} else if worktimeBy == "members" {
62+
worktimeSumResult, err = organization.GetWorktimeByMembers(ctx.Org.Organization, unixFrom, unixTo)
63+
ctx.Data["WorktimeByMembers"] = true
64+
} else /* by repos */ {
65+
worktimeSumResult, err = organization.GetWorktimeByRepos(ctx.Org.Organization, unixFrom, unixTo)
66+
ctx.Data["WorktimeByRepos"] = true
67+
}
68+
if err != nil {
69+
ctx.ServerError("GetWorktime", err)
70+
return
71+
}
72+
ctx.Data["WorktimeSumResult"] = worktimeSumResult
73+
ctx.HTML(http.StatusOK, tplByRepos)
74+
}

routers/web/web.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,8 @@ func registerRoutes(m *web.Router) {
913913
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
914914
m.Post("/teams/{team}/delete", org.DeleteTeam)
915915

916+
m.Get("/worktime", context.OrgAssignment(false, true), org.Worktime)
917+
916918
m.Group("/settings", func() {
917919
m.Combo("").Get(org.Settings).
918920
Post(web.Bind(forms.UpdateOrgSettingForm{}), org.SettingsPost)

services/actions/cleanup.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ func cleanExpiredArtifacts(taskCtx context.Context) error {
5252
}
5353
if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
5454
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
55-
continue
55+
// go on
5656
}
57-
log.Info("Artifact %d set expired", artifact.ID)
57+
log.Info("Artifact %d is deleted (due to expiration)", artifact.ID)
5858
}
5959
return nil
6060
}
@@ -76,9 +76,9 @@ func cleanNeedDeleteArtifacts(taskCtx context.Context) error {
7676
}
7777
if err := storage.ActionsArtifacts.Delete(artifact.StoragePath); err != nil {
7878
log.Error("Cannot delete artifact %d: %v", artifact.ID, err)
79-
continue
79+
// go on
8080
}
81-
log.Info("Artifact %d set deleted", artifact.ID)
81+
log.Info("Artifact %d is deleted (due to pending deletion)", artifact.ID)
8282
}
8383
if len(artifacts) < deleteArtifactBatchSize {
8484
log.Debug("No more artifacts pending deletion")
@@ -103,8 +103,7 @@ func CleanupLogs(ctx context.Context) error {
103103
for _, task := range tasks {
104104
if err := actions_module.RemoveLogs(ctx, task.LogInStorage, task.LogFilename); err != nil {
105105
log.Error("Failed to remove log %s (in storage %v) of task %v: %v", task.LogFilename, task.LogInStorage, task.ID, err)
106-
// do not return error here, continue to next task
107-
continue
106+
// do not return error here, go on
108107
}
109108
task.LogIndexes = nil // clear log indexes since it's a heavy field
110109
task.LogExpired = true

services/context/org.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ func GetOrganizationByParams(ctx *Context) {
6363
}
6464

6565
// HandleOrgAssignment handles organization assignment
66+
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
6667
func HandleOrgAssignment(ctx *Context, args ...bool) {
6768
var (
6869
requireMember bool
@@ -269,6 +270,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) {
269270
}
270271

271272
// OrgAssignment returns a middleware to handle organization assignment
273+
// args: requireMember, requireOwner, requireTeamMember, requireTeamAdmin
272274
func OrgAssignment(args ...bool) func(ctx *Context) {
273275
return func(ctx *Context) {
274276
HandleOrgAssignment(ctx, args...)

0 commit comments

Comments
 (0)