feat(time): readable time tables with totals and gh-style feedback

time list printed Go durations (1h30m0s), raw timestamps and no issue
title. It now shows ID ISSUE USER SPENT WHEN with "#1 Login fails",
"1h 30m" and relative times on a terminal, followed by the total of the
listed time, and points at --mine when no repository is selected.
--issue takes issue URLs, --username takes @me, and --since replaces
--from, which still works.

time create (also add and log) reports "✓ Logged 1h 25m on alice/x#1
(title)"; time delete and reset name the time they delete in their
confirmation and ✓ line. Time records gain an issue_title JSON field.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-12 00:17:03 +02:00
1 parent c161a63eb6
commit 0f090654a4
5 files changed
+261 -96

No files matched your search

-5
View File
@@ -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
`
)
+157 -67
View File
@@ -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 <number> <duration>", 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 {<number> | <url>} <duration>", 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 + " <number>"
count := 1
if verb == "delete" {
use += " <time-id>"
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 {<number> | <url>} <time-id>", 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 {<number> | <url>}", 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
}}
}
+57 -3
View File
@@ -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))
}
}
+3 -4
View File
@@ -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) {