From b5fecbde4297f4d4c62946b7595ec813d57c33f4 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:16:55 +0200 Subject: [PATCH] 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 --- internal/app/time.go | 23 +++++++++++++++++++---- internal/app/time_test.go | 28 ++++++++++++++++++++++++++++ internal/app/validation_test.go | 4 ++-- internal/services/time.go | 6 +++++- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/internal/app/time.go b/internal/app/time.go index ea4e8f22..8b9c1e39 100644 --- a/internal/app/time.go +++ b/internal/app/time.go @@ -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 } diff --git a/internal/app/time_test.go b/internal/app/time_test.go index 465e801e..195006be 100644 --- a/internal/app/time_test.go +++ b/internal/app/time_test.go @@ -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) +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index b6dcafad..2ce4fc91 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -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"}, diff --git a/internal/services/time.go b/internal/services/time.go index 8dcdf0be..6ec450b0 100644 --- a/internal/services/time.go +++ b/internal/services/time.go @@ -24,7 +24,11 @@ type TrackedTime struct { // trackedTimeFrom falls back to repository when the record does not name its issue's repository. func trackedTimeFrom(record *gitea.TrackedTime, repository string) TrackedTime { - result := TrackedTime{ID: record.ID, Seconds: record.Time, CreatedAt: timestamp(record.Created), User: record.UserName, Repository: repository} + result := TrackedTime{ID: record.ID, Seconds: record.Time, User: record.UserName, Repository: repository} + if !record.Created.IsZero() { + // Gitea returns the creation time of a new record with nanoseconds, but whole seconds in listings. + result.CreatedAt = record.Created.Format(time.RFC3339) + } if record.Issue != nil { result.IssueNumber = record.Issue.Index result.IssueURL = record.Issue.HTMLURL