// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "errors" "fmt" "strconv" "strings" "time" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) 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 } // 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", 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 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 != "" { return clierr.Wrap(clierr.Usage, errors.New("--total is a human-output summary; use --jq 'map(.seconds) | add // 0' for JSON")) } var err error if opts.Since, opts.Before, err = tracked.parse(a.deps.Now()); err != nil { return err } 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 } 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 } 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) } 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.writeTimes(cmd, page, opts.Page, spec, total) }} 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: firstArg(a.completeIssues("open")), RunE: func(cmd *cobra.Command, args []string) error { seconds, err := parseSpent(args[1]) if err != nil { return err } target, n, err := a.resolveRef(cmd, args[0], issueRef) if err != nil { return err } item, err := (services.Times{Client: target.client, Repository: target.Repo}).Create(cmd.Context(), n, seconds) if err != nil { return err } a.done(cmd, "Logged %s on %s", spent(item.Seconds), strings.TrimSuffix(refLabel(target.Repo, n, item.IssueTitle), " ()")) return a.jsonResult(cmd, item) }} } 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: firstArg(a.completeIssues("all")), 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) return a.deleteConfirmed(cmd, subject, func() error { return service.Delete(cmd.Context(), n, id) }) }} } 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: firstArg(a.completeIssues("all")), 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) return a.deleteConfirmed(cmd, subject, func() error { return (services.Times{Client: target.client, Repository: target.Repo}).Reset(cmd.Context(), n) }) }} }