fix(time): accept spaced durations and print whole-second times

tea time create 1 1h25m printed 2026-10-11T18:57:49.588823137Z while time
list printed whole seconds, and "1h 30m" or 2d failed with "duration
must be nonzero whole seconds". Durations may now contain spaces, and
errors name the problem: "invalid duration "2d": use h, m and s units
such as 1h25m". Tracked times are whole seconds everywhere.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:14:52 +02:00
1 parent 1749d2f7f6
commit b5fecbde42
4 files changed
+54 -7

No files matched your search

+19 -4
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/clierr"
@@ -17,6 +18,20 @@ import (
func trackedDuration(seconds int64) time.Duration { return time.Duration(seconds) * time.Second }
// parseSpent reads time spent in whole seconds, written like 1h25m or "1h 25m".
func parseSpent(raw string) (int64, error) {
duration, err := time.ParseDuration(strings.Join(strings.Fields(raw), ""))
switch {
case err != nil:
return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid duration %q: use h, m and s units such as 1h25m", raw))
case duration <= 0:
return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid duration %q: time spent must be positive, such as 1h25m", raw))
case duration%time.Second != 0:
return 0, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid duration %q: use whole seconds, such as 1h25m", raw))
}
return int64(duration / time.Second), nil
}
func (a *application) timeCommand() *cobra.Command {
group := &cobra.Command{Use: "time", Short: "Track time spent on issues and pull requests"}
opts := services.TimeOptions{}
@@ -65,15 +80,15 @@ func (a *application) timeCommand() *cobra.Command {
if err != nil {
return err
}
duration, err := time.ParseDuration(args[1])
if err != nil || duration == 0 || duration%time.Second != 0 {
return clierr.Wrap(clierr.Usage, errors.New("duration must be nonzero whole seconds, for example 1h25m"))
seconds, err := parseSpent(args[1])
if err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Times{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), n, int64(duration/time.Second))
item, err := (services.Times{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), n, seconds)
if err != nil {
return err
}
+28
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
@@ -29,3 +30,30 @@ func TestTimeListShowsRepositoryAndDurations(t *testing.T) {
require.NoError(t, Execute(context.Background(), deps, []string{"time", "list", "--mine", "--json=repository,issue_number"}))
require.JSONEq(t, `[{"repository":"alice/project","issue_number":3},{"repository":"bob/tools","issue_number":3}]`, out.String())
}
func TestTimeCreateReadsDurationsAndWholeSecondTimes(t *testing.T) {
var seconds []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "POST /api/v1/repos/alice/project/issues/1/times", r.Method+" "+r.URL.Path)
seconds = append(seconds, payloadField(t, r, "time"))
_, _ = w.Write([]byte(`{"id":5,"time":5400,"created":"2026-10-11T18:57:49.588823137Z","user_name":"alice"}`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
for _, duration := range []string{"1h30m", "1h 30m", " 90m "} {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"time", "create", "1", duration, "-R", "alice/project", "--json=created_at"}))
require.JSONEq(t, `{"created_at":"2026-10-11T18:57:49Z"}`, out.String())
}
require.Equal(t, []string{"5400", "5400", "5400"}, seconds)
for duration, want := range map[string]string{
"2d": `invalid duration "2d": use h, m and s units such as 1h25m`,
"0m": `invalid duration "0m": time spent must be positive`,
"1.5s": `invalid duration "1.5s": use whole seconds`,
} {
err := Execute(context.Background(), deps, []string{"time", "create", "1", duration, "-R", "alice/project"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err), duration)
require.ErrorContains(t, err, want)
}
require.Len(t, seconds, 3)
}
+2 -2
View File
@@ -211,8 +211,8 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"team", "member", "add", "invalid", "alice"}, `invalid team "invalid": use ORG/TEAM, such as acme/developers, or a team ID`},
{[]string{"team", "view", "acme/"}, `invalid team "acme/"`},
{[]string{"team", "repo", "add", "2", "service", "-R", "acme/service"}, "--repo is not used by tea team repo add"},
{[]string{"time", "create", "1", "0s"}, "duration must be nonzero whole seconds, for example 1h25m"},
{[]string{"time", "create", "1", "1ms"}, "duration must be nonzero whole seconds, for example 1h25m"},
{[]string{"time", "create", "1", "0s"}, `invalid duration "0s": time spent must be positive`},
{[]string{"time", "create", "1", "1ms"}, `invalid duration "1ms": use whole seconds`},
{[]string{"time", "list", "--issue", "0"}, "--issue must be positive"},
{[]string{"time", "list", "--mine", "--issue", "1"}, "--mine, --issue and --username select mutually exclusive scopes"},
{[]string{"time", "list", "--from", "2026-10-02", "--until", "2026-10-01"}, "--from must not be later than --until"},