mirror of https://github.com/go-gitea/gitea
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>pull/33473/head^2
parent
869f8fdbe4
commit
34692a20b1
@ -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 |
||||
} |
@ -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) |
||||
} |
@ -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" .}} |
@ -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> |
@ -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> |
@ -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> |
@ -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