diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 46cf1fc0..6d5555f2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -432,14 +432,9 @@ const ( pr attachment view search issue search pr - time create - time delete - time list - time reset ` longsToWrite = ` issue attachment pr attachment - time ` ) diff --git a/internal/app/time.go b/internal/app/time.go index 8b9c1e39..0ab1a530 100644 --- a/internal/app/time.go +++ b/internal/app/time.go @@ -32,16 +32,43 @@ func parseSpent(raw string) (int64, error) { return int64(duration / time.Second), nil } +// spent formats tracked seconds for people: "1h 30m", "15m", "45s". +func spent(seconds int64) string { + d := trackedDuration(seconds) + var parts []string + for _, unit := range []struct { + size time.Duration + suffix string + }{{time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} { + if n := d / unit.size; n > 0 && (unit.size != time.Second || seconds < 3600) { + parts = append(parts, strconv.FormatInt(int64(n), 10)+unit.suffix) + } + d %= unit.size + } + if len(parts) == 0 { + return "0s" + } + return strings.Join(parts, " ") +} + func (a *application) timeCommand() *cobra.Command { - group := &cobra.Command{Use: "time", Short: "Track time spent on issues and pull requests"} + group := &cobra.Command{Use: "time", Short: "Track time spent on issues and pull requests", Long: `Log the time spent on issues and pull requests, list it for a repository, an issue or +yourself across repositories, and delete it.`} + group.AddCommand(a.timeListCommand(), a.timeCreateCommand(), a.timeDeleteCommand(), a.timeResetCommand()) + return group +} + +func (a *application) timeListCommand() *cobra.Command { opts := services.TimeOptions{} var tracked timeRange var total bool - list := &cobra.Command{Use: "list", Short: "List repository or account time records", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - if opts.Issue < 0 || (cmd.Flags().Changed("issue") && opts.Issue == 0) { - return clierr.Wrap(clierr.Usage, errors.New("--issue must be positive")) - } - if (opts.Mine && (cmd.Flags().Changed("issue") || opts.Username != "")) || (opts.Issue != 0 && opts.Username != "") { + var issue string + cmd := &cobra.Command{Use: "list", Short: "List tracked time", Long: `List the time tracked in a repository, on one issue with --issue, or by yourself across +repositories with --mine. On a terminal, the total of the listed time follows the table.`, Example: `$ tea time list +$ tea time list --issue 12 +$ tea time list --mine --since 168h +$ tea time list --username @me --json issue_number,seconds`, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { + if (opts.Mine && (issue != "" || opts.Username != "")) || (issue != "" && opts.Username != "") { return clierr.Wrap(clierr.Usage, errors.New("--mine, --issue and --username select mutually exclusive scopes")) } if total && a.options.json != "" { @@ -51,40 +78,95 @@ func (a *application) timeCommand() *cobra.Command { if opts.Since, opts.Before, err = tracked.parse(a.deps.Now()); err != nil { return err } - target, err := a.resolve(cmd.Context(), !opts.Mine) + var target target + if issue != "" { + target, opts.Issue, err = a.resolveRef(cmd, issue, issueRef) + } else { + target, err = a.resolve(cmd.Context(), !opts.Mine) + } + if errors.Is(err, errRepositoryRequired) { + return clierr.Wrap(clierr.Usage, errors.New("repository required: use --repo owner/name, or --mine for your time across repositories")) + } if err != nil { return err } - items, err := (services.Times{Client: target.client, Repository: target.Repo}).List(cmd.Context(), opts) + opts.Username = target.expandMe(opts.Username) + page, err := (services.Times{Client: target.client, Repository: target.Repo}).ListPage(cmd.Context(), opts) if err != nil { return err } - if !total { - return writeList(a, cmd, items, trackedTimeRow) + where := "in " + target.Repo + switch { + case opts.Mine: + where = "by " + target.login() + case opts.Issue != 0: + where = fmt.Sprintf("on %s#%d", target.Repo, opts.Issue) } - var sum int64 - for _, item := range items { - sum += item.Seconds + spec := listSpec[services.TrackedTime]{ + Columns: []terminal.Column{{Header: "ID"}, {Header: "Issue", Flex: true}, {Header: "User"}, {Header: "Spent"}, {Header: "When"}}, + Row: trackedTimeRow(target.Repo), Empty: "no time tracked " + where, Context: "time records " + where, } - return a.writeHuman(cmd, table(items, trackedTimeRow, a.colorEnabled())+fmt.Sprintf("Total on this page: %s\n", trackedDuration(sum))) + return a.writeTimes(cmd, page, opts.Page, spec, total) }} - list.Flags().BoolVar(&opts.Mine, "mine", false, "List your time across repositories") - list.Flags().Int64Var(&opts.Issue, "issue", 0, "List time on one issue or pull request") - list.Flags().StringVar(&opts.Username, "username", "", "Filter repository time by username") - pageFlags(list, &opts.Limit, &opts.Page) - tracked.register(list, "from", "Only time tracked since a date, timestamp, or duration ago", "until", "Only time tracked before a date, timestamp, or duration ago") - list.Flags().BoolVar(&total, "total", false, "Print the time summed across the returned page") - group.AddCommand(list) - group.AddCommand(&cobra.Command{Use: "create ", Short: "Record spent time, for example 1h25m", Args: argsExact(2), RunE: func(cmd *cobra.Command, args []string) error { - n, err := number(args[0]) - if err != nil { - return err + cmd.Flags().BoolVar(&opts.Mine, "mine", false, "List your time across repositories") + cmd.Flags().StringVar(&issue, "issue", "", "List the time on one issue or pull request, by `number` or URL") + cmd.Flags().StringVar(&opts.Username, "username", "", "List the time of one `user`; @me for yourself") + pageFlags(cmd, &opts.Limit, &opts.Page) + tracked.register(cmd, "since", "Only time tracked since a date, timestamp, or duration ago", "until", "Only time tracked before a date, timestamp, or duration ago") + flagAliases(cmd, map[string]string{"from": "since"}) + cmd.Flags().BoolVar(&total, "total", false, "Print the total of the listed time also when piped") + _ = cmd.RegisterFlagCompletionFunc("issue", a.completeIssues("all")) + _ = cmd.RegisterFlagCompletionFunc("username", a.completeUsers()) + return cmd +} + +// writeTimes writes tracked time like writeTable, followed on a terminal, or with total, by the sum +// of the listed time. +func (a *application) writeTimes(cmd *cobra.Command, page services.Page[services.TrackedTime], number int, spec listSpec[services.TrackedTime], total bool) error { + d := a.display() + if a.options.json != "" || len(page.Items) == 0 || (!d.TTY && !total) { + return writeTable(a, cmd, page.Items, spec, servicePage(page, number)) + } + var sum int64 + rows := make([][]terminal.Cell, len(page.Items)) + for i, item := range page.Items { + sum += item.Seconds + rows[i] = spec.Row(item) + } + text := terminal.Table(spec.Columns, rows, d) + "Total: " + spent(sum) + "\n" + if d.TTY { + text = showing(len(page.Items), servicePage(page, number), spec.Context) + "\n\n" + text + } + return a.writeHuman(cmd, text) +} + +// trackedTimeRow shows a record in repo; records of other repositories name theirs. +func trackedTimeRow(repo string) func(services.TrackedTime) []terminal.Cell { + return func(item services.TrackedTime) []terminal.Cell { + issue := "#" + strconv.FormatInt(item.IssueNumber, 10) + if !strings.EqualFold(item.Repository, repo) { + issue = item.Repository + issue } + return []terminal.Cell{ + {Text: strconv.FormatInt(item.ID, 10)}, + {Text: strings.TrimSpace(issue + " " + item.IssueTitle)}, + {Text: item.User}, + {Text: spent(item.Seconds)}, + {Time: terminal.ParseTime(item.CreatedAt)}, + } + } +} + +func (a *application) timeCreateCommand() *cobra.Command { + return &cobra.Command{Use: "create { | } ", Aliases: []string{"add", "log"}, Short: "Log time spent on an issue", Long: `Log time spent on an issue or pull request. The duration uses h, m and s units, such as +1h25m or "1h 25m".`, Example: `$ tea time create 12 1h25m +$ tea time add 12 "1h 30m" +$ tea time log https://gitea.com/OWNER/REPO/issues/12 45m`, Annotations: map[string]string{"tea.arguments": issueArguments}, Args: argsExact(2), ValidArgsFunction: a.completeIssueArgs("open", false), RunE: func(cmd *cobra.Command, args []string) error { seconds, err := parseSpent(args[1]) if err != nil { return err } - target, err := a.resolve(cmd.Context(), true) + target, n, err := a.resolveRef(cmd, args[0], issueRef) if err != nil { return err } @@ -92,48 +174,56 @@ func (a *application) timeCommand() *cobra.Command { if err != nil { return err } - return writeItem(a, cmd, item, trackedTimeRow) - }}) - for _, verb := range []string{"delete", "reset"} { - use := verb + " " - count := 1 - if verb == "delete" { - use += " " - count = 2 + if a.options.json != "" { + return a.writeJSON(cmd, item) } - group.AddCommand(&cobra.Command{Use: use, Short: titleCase(verb) + " tracked time on an issue", Annotations: textOutput("time " + verb + " does not return a resource"), Args: argsExact(count), RunE: func(cmd *cobra.Command, args []string) error { - n, err := number(args[0]) - if err != nil { - return err - } - var id int64 - if verb == "delete" { - id, err = number(args[1]) - if err != nil { - return err - } - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - subject := target.Repo + " #" + args[0] - if verb == "delete" { - subject = "entry " + args[1] + " on " + subject - } - if err := a.confirm(cmd, verb+" tracked time "+subject); err != nil { - return err - } - service := services.Times{Client: target.client, Repository: target.Repo} - if verb == "delete" { - return service.Delete(cmd.Context(), n, id) - } - return service.Reset(cmd.Context(), n) - }}) - } - return group + a.done(cmd, "Logged %s on %s", spent(item.Seconds), strings.TrimSuffix(refLabel(target.Repo, n, item.IssueTitle), " ()")) + return nil + }} } -func trackedTimeRow(item services.TrackedTime) []terminal.Cell { - return terminal.Row(strconv.FormatInt(item.ID, 10), fmt.Sprintf("%s#%d", item.Repository, item.IssueNumber), item.User, trackedDuration(item.Seconds).String(), item.CreatedAt) +func (a *application) timeDeleteCommand() *cobra.Command { + return &cobra.Command{Use: "delete { | } ", Short: "Delete a time record", Example: `# Find the record with tea time list --issue 12 +$ tea time delete 12 8`, Annotations: map[string]string{"tea.text": "time delete does not return a resource", "tea.confirm": "true", "tea.arguments": issueArguments}, Args: argsExact(2), ValidArgsFunction: a.completeIssueArgs("all", false), RunE: func(cmd *cobra.Command, args []string) error { + id, err := number(args[1]) + if err != nil { + return err + } + target, n, err := a.resolveRef(cmd, args[0], issueRef) + if err != nil { + return err + } + service := services.Times{Client: target.client, Repository: target.Repo} + record, err := service.Find(cmd.Context(), n, id) + if err != nil { + return err + } + subject := fmt.Sprintf("%s logged by %s on %s#%d", spent(record.Seconds), record.User, target.Repo, n) + if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + "?", NonTTY: "delete " + subject}); err != nil { + return err + } + if err := service.Delete(cmd.Context(), n, id); err != nil { + return err + } + a.done(cmd, "Deleted %s", subject) + return nil + }} +} + +func (a *application) timeResetCommand() *cobra.Command { + return &cobra.Command{Use: "reset { | }", Short: "Delete all time logged on an issue", Example: "$ tea time reset 12 --yes", Annotations: map[string]string{"tea.text": "time reset does not return a resource", "tea.confirm": "true", "tea.arguments": issueArguments}, Args: argsExact(1), ValidArgsFunction: a.completeIssueArgs("all", false), RunE: func(cmd *cobra.Command, args []string) error { + target, n, err := a.resolveRef(cmd, args[0], issueRef) + if err != nil { + return err + } + subject := fmt.Sprintf("all time logged on %s#%d", target.Repo, n) + if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: "Delete " + subject + "?", NonTTY: "delete " + subject}); err != nil { + return err + } + if err := (services.Times{Client: target.client, Repository: target.Repo}).Reset(cmd.Context(), n); err != nil { + return err + } + a.done(cmd, "Deleted %s", subject) + return nil + }} } diff --git a/internal/app/time_test.go b/internal/app/time_test.go index 195006be..25b4557f 100644 --- a/internal/app/time_test.go +++ b/internal/app/time_test.go @@ -7,6 +7,7 @@ import ( "context" "net/http" "testing" + "time" "gitea.dev/tea/internal/clierr" "github.com/stretchr/testify/require" @@ -23,9 +24,9 @@ func TestTimeListShowsRepositoryAndDurations(t *testing.T) { defer server.Close() deps, out, _ := serverDeps(t, server.URL) require.NoError(t, Execute(context.Background(), deps, []string{"time", "list", "--mine", "--total"})) - require.Equal(t, "1 alice/project#3 alice 1h25m0s 2026-10-01T10:00:00Z\n"+ - "2 bob/tools#3 alice 15m0s 2026-10-02T10:00:00Z\n"+ - "Total on this page: 1h40m0s\n", out.String()) + require.Equal(t, "1\talice/project#3\talice\t1h 25m\t2026-10-01T10:00:00Z\n"+ + "2\tbob/tools#3\talice\t15m\t2026-10-02T10:00:00Z\n"+ + "Total: 1h 40m\n", out.String()) out.Reset() 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()) @@ -57,3 +58,56 @@ func TestTimeCreateReadsDurationsAndWholeSecondTimes(t *testing.T) { } require.Len(t, seconds, 3) } + +func TestTimeTablesAndDoneLines(t *testing.T) { + var changed []string + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET /api/v1/repos/alice/project/times", "GET /api/v1/repos/alice/project/issues/1/times": + if r.URL.Query().Get("page") != "1" { + _, _ = w.Write([]byte(`[]`)) + return + } + _, _ = w.Write([]byte(`[{"id":8,"time":5400,"created":"2026-10-11T18:00:00Z","user_name":"alice","issue":{"number":1,"title":"Login fails","repository":{"full_name":"alice/project"}}},` + + `{"id":9,"time":900,"created":"2026-10-11T19:00:00Z","user_name":"bob","issue":{"number":2,"title":"Docs"}}]`)) + case "POST /api/v1/repos/alice/project/issues/1/times": + changed = append(changed, r.Method+" "+r.URL.Path) + _, _ = w.Write([]byte(`{"id":10,"time":5100,"user_name":"alice","issue":{"number":1,"title":"Login fails"}}`)) + default: + changed = append(changed, r.Method+" "+r.URL.Path) + w.WriteHeader(http.StatusNoContent) + } + })) + defer server.Close() + deps, out, diagnostics := serverDeps(t, server.URL) + deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) } + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"}) + deps.Streams.OutTTY = true + run := func(args ...string) error { + out.Reset() + diagnostics.Reset() + return Execute(context.Background(), deps, append(args, "-R", "alice/project", "--no-input")) + } + require.NoError(t, run("time", "list")) + require.Equal(t, "Showing 2 of 2 time records in alice/project\n\n"+ + "ID ISSUE USER SPENT WHEN\n"+ + "8 #1 Login fails alice 1h 30m about 2 hours ago\n"+ + "9 #2 Docs bob 15m about 1 hour ago\n"+ + "Total: 1h 45m\n", out.String()) + require.NoError(t, run("time", "add", "#1", "1h 25m")) + require.Equal(t, "✓ Logged 1h 25m on alice/project#1 (Login fails)\n", diagnostics.String()) + require.NoError(t, run("time", "delete", "1", "8", "--yes")) + require.Equal(t, "✓ Deleted 1h 30m logged by alice on alice/project#1\n", diagnostics.String()) + require.NoError(t, run("time", "reset", "1", "--yes")) + require.Equal(t, "✓ Deleted all time logged on alice/project#1\n", diagnostics.String()) + require.ErrorContains(t, run("time", "delete", "1", "77", "--yes"), "tracked time 77 not found in alice/project#1") + require.EqualError(t, run("time", "delete", "1", "8"), "confirmation required to delete 1h 30m logged by alice on alice/project#1; rerun with --yes") + require.Equal(t, []string{ + "POST /api/v1/repos/alice/project/issues/1/times", + "DELETE /api/v1/repos/alice/project/issues/1/times/8", + "DELETE /api/v1/repos/alice/project/issues/1/times", + }, changed) + for seconds, want := range map[int64]string{0: "0s", 45: "45s", 90: "1m 30s", 900: "15m", 3600: "1h", 5400: "1h 30m", 5410: "1h 30m"} { + require.Equal(t, want, spent(seconds)) + } +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index b4e1420e..e8cb3505 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -213,10 +213,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) { {[]string{"team", "repo", "add", "2", "service", "-R", "acme/service"}, "--repo is not used by tea team repo add"}, {[]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", "--issue", "0"}, `invalid issue "0"`}, {[]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"}, - {[]string{"time", "list", "--from", "1h", "--until", "2h"}, "--from must not be later than --until"}, + {[]string{"time", "list", "--from", "2026-10-02", "--until", "2026-10-01"}, "--since must not be later than --until"}, + {[]string{"time", "list", "--since", "1h", "--until", "2h"}, "--since must not be later than --until"}, {[]string{"time", "list", "--total", "--json"}, "--total is a human-output summary"}, {[]string{"variable", "set", "BUILD_MODE", "--value="}, "the value of BUILD_MODE is empty"}, {[]string{"variable", "set", "GITEA_RESERVED", "--value", "text"}, "CI and names starting with GITEA_ or GITHUB_ are reserved"}, @@ -257,7 +257,6 @@ func TestConfirmationsNameResolvedValues(t *testing.T) { want string }{ {[]string{"release", "create"}, map[string]string{"Tag": "v1.0.0"}, "Create release v1.0.0 in alice/project?"}, - {[]string{"time", "delete", "5", "7"}, nil, "delete tracked time entry 7 on alice/project #5"}, } { t.Run(strings.Join(tc.args, " "), func(t *testing.T) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/services/time.go b/internal/services/time.go index 6ec450b0..76d0946f 100644 --- a/internal/services/time.go +++ b/internal/services/time.go @@ -5,6 +5,9 @@ package services import ( "context" + "fmt" + "net/http" + "strconv" "time" gitea "gitea.dev/sdk" @@ -19,6 +22,7 @@ type TrackedTime struct { User string `json:"user"` Repository string `json:"repository"` IssueNumber int64 `json:"issue_number"` + IssueTitle string `json:"issue_title"` IssueURL string `json:"issue_url"` } @@ -30,8 +34,7 @@ func trackedTimeFrom(record *gitea.TrackedTime, repository string) TrackedTime { result.CreatedAt = record.Created.Format(time.RFC3339) } if record.Issue != nil { - result.IssueNumber = record.Issue.Index - result.IssueURL = record.Issue.HTMLURL + result.IssueNumber, result.IssueTitle, result.IssueURL = record.Issue.Index, record.Issue.Title, record.Issue.HTMLURL if record.Issue.Repository != nil { result.Repository = record.Issue.Repository.FullName } @@ -56,30 +59,54 @@ type Times struct { // List returns a non-nil page of tracked time records. func (s Times) List(ctx context.Context, opts TimeOptions) ([]TrackedTime, error) { - query := gitea.ListTrackedTimesOptions{ListOptions: listOptions(opts.Limit, opts.Page), User: opts.Username} + page, err := s.ListPage(ctx, opts) + return page.Items, err +} + +// ListPage is List with the number of records, as the server counts them. +func (s Times) ListPage(ctx context.Context, opts TimeOptions) (Page[TrackedTime], error) { + query := gitea.ListTrackedTimesOptions{User: opts.Username} if opts.Since != nil { query.Since = *opts.Since } if opts.Before != nil { query.Before = *opts.Before } - records, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.TrackedTime, *gitea.Response, error) { - if opts.Mine { - return c.Issues.ListMyTrackedTimes(ctx, query) - } - owner, repo, err := splitRepository(s.Repository) - if err != nil { - return nil, nil, err - } - if opts.Issue != 0 { - return c.Issues.ListIssueTrackedTimes(ctx, owner, repo, opts.Issue, query) - } - return c.Issues.ListRepoTrackedTimes(ctx, owner, repo, query) + records, err := listPage(opts.Limit, opts.Page, func(limit, page int) ([]*gitea.TrackedTime, http.Header, error) { + query.ListOptions = listOptions(limit, page) + records, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.TrackedTime, *gitea.Response, error) { + if opts.Mine { + return c.Issues.ListMyTrackedTimes(ctx, query) + } + owner, repo, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + if opts.Issue != 0 { + return c.Issues.ListIssueTrackedTimes(ctx, owner, repo, opts.Issue, query) + } + return c.Issues.ListRepoTrackedTimes(ctx, owner, repo, query) + }) + return records, headerOf(resp), err + }) + items := convertAll(records.Items, func(record *gitea.TrackedTime) TrackedTime { return trackedTimeFrom(record, s.Repository) }) + return Page[TrackedTime]{Items: items, More: records.More, Total: records.Total, Counted: records.Counted}, err +} + +// Find returns the tracked time record id on issue n. +func (s Times) Find(ctx context.Context, n, id int64) (TrackedTime, error) { + records, err := listAll(func(page int) ([]TrackedTime, error) { + return s.List(ctx, TimeOptions{Issue: n, Limit: 50, Page: page}) }) if err != nil { - return nil, err + return TrackedTime{}, err } - return convertAll(records, func(record *gitea.TrackedTime) TrackedTime { return trackedTimeFrom(record, s.Repository) }), nil + for _, record := range records { + if record.ID == id { + return record, nil + } + } + return TrackedTime{}, NotFound("tracked time", strconv.FormatInt(id, 10), fmt.Sprintf("%s#%d", s.Repository, n)) } // Create records a nonzero number of seconds on one issue.