package cmd import ( "fmt" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/public/fgj-sid/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/config" "github.com/spf13/cobra" ) var timeCmd = &cobra.Command{ Use: "time", Aliases: []string{"times", "t"}, Short: "Manage tracked time entries", Long: `Manage tracked time entries on issues and pull requests. Time tracking must be enabled on the repository for add/delete/reset to succeed. Durations are parsed with Go's time.ParseDuration, so values like "30m", "1h30m", "2h", or "45s" are all accepted.`, } var timeListCmd = &cobra.Command{ Use: "list [issue-number]", Short: "List tracked time entries", Long: `List tracked time entries. When no issue number is given, shows the authenticated user's tracked times across all repositories on the current host. When an issue number is given, shows the tracked times recorded against that issue in the current repository (or the repository selected with -R/--repo). The issue argument may be a bare number (123), a "#"-prefixed number (#123), or a full issue URL.`, Example: ` # List your tracked times across all repos fgj time list # List tracked times on issue #42 in the current repo fgj time list 42 # List tracked times on issue #42 in a specific repo fgj time list 42 -R owner/repo # Output as JSON fgj time list --json fgj time list 42 --json`, Args: cobra.MaximumNArgs(1), RunE: runTimeList, } var timeAddCmd = &cobra.Command{ Use: "add ", Short: "Add a tracked time entry to an issue", Long: `Add a tracked time entry to an issue or pull request. The duration argument accepts any string that Go's time.ParseDuration understands, for example: 30s thirty seconds 45m forty-five minutes 1h one hour 1h30m ninety minutes 2h15m30s two hours, fifteen minutes, thirty seconds The value is rounded down to whole seconds before being sent to the server. The issue argument may be a bare number, #-prefixed, or an issue URL.`, Example: ` # Add 30 minutes to issue #42 in the current repo fgj time add 42 30m # Add 1h 30m to issue #7 in another repo fgj time add 7 1h30m -R owner/repo # Add just a few seconds (useful for testing) fgj time add #42 45s`, Args: cobra.ExactArgs(2), RunE: runTimeAdd, } var timeDeleteCmd = &cobra.Command{ Use: "delete ", Aliases: []string{"rm"}, Short: "Delete a specific tracked time entry", Long: `Delete a single tracked time entry from an issue by its entry ID. Use "fgj time list " to find the ID of the entry you want to remove. A confirmation prompt is shown unless --yes is passed or stdin is not a TTY.`, Example: ` # Delete tracked time entry 123 from issue #42 fgj time delete 42 123 # Delete without confirmation fgj time delete 42 123 --yes # Target a specific repository fgj time rm 42 123 -R owner/repo`, Args: cobra.ExactArgs(2), RunE: runTimeDelete, } var timeResetCmd = &cobra.Command{ Use: "reset ", Short: "Delete all tracked time entries on an issue", Long: `Reset (delete all) tracked time entries on an issue or pull request. This removes every time entry recorded against the issue, regardless of who logged it. A confirmation prompt is shown unless --yes is passed or stdin is not a TTY.`, Example: ` # Reset tracked times on issue #42 in the current repo fgj time reset 42 # Reset without confirmation fgj time reset 42 --yes # Reset in a specific repo fgj time reset 42 -R owner/repo`, Args: cobra.ExactArgs(1), RunE: runTimeReset, } func init() { rootCmd.AddCommand(timeCmd) timeCmd.AddCommand(timeListCmd) timeCmd.AddCommand(timeAddCmd) timeCmd.AddCommand(timeDeleteCmd) timeCmd.AddCommand(timeResetCmd) addRepoFlags(timeListCmd) addJSONFlags(timeListCmd, "Output as JSON") addRepoFlags(timeAddCmd) addRepoFlags(timeDeleteCmd) timeDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") addRepoFlags(timeResetCmd) timeResetCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } func runTimeList(cmd *cobra.Command, args []string) error { // No issue argument: list the authenticated user's tracked times across // all repos on the current host. This path does not require a repo // context, so we use loadClient. if len(args) == 0 { client, err := loadClient() if err != nil { return err } times, _, err := client.ListMyTrackedTimes(gitea.ListTrackedTimesOptions{}) if err != nil { return fmt.Errorf("failed to list tracked times: %w", err) } if wantJSON(cmd) { return outputJSON(cmd, times) } return renderTimesTable(times, true) } // Issue-scoped list. index, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue %q: %w", args[0], err) } client, owner, repoName, err := newTimeClient(cmd) if err != nil { return err } times, _, err := client.ListIssueTrackedTimes(owner, repoName, index, gitea.ListTrackedTimesOptions{}) if err != nil { return fmt.Errorf("failed to list tracked times for %s/%s#%d: %w", owner, repoName, index, err) } if wantJSON(cmd) { return outputJSON(cmd, times) } return renderTimesTable(times, true) } func runTimeAdd(cmd *cobra.Command, args []string) error { index, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue %q: %w", args[0], err) } dur, err := time.ParseDuration(args[1]) if err != nil { return fmt.Errorf("invalid duration %q (expected a Go duration like 30m, 1h30m, 2h): %w", args[1], err) } seconds := int64(dur.Seconds()) if seconds <= 0 { return fmt.Errorf("duration must be greater than zero seconds") } client, owner, repoName, err := newTimeClient(cmd) if err != nil { return err } tt, _, err := client.AddTime(owner, repoName, index, gitea.AddTimeOption{ Time: seconds, Created: time.Time{}, User: "", }) if err != nil { return fmt.Errorf("failed to add tracked time to %s/%s#%d: %w", owner, repoName, index, err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Added %s to %s/%s#%d (entry %d)\n", cs.SuccessIcon(), formatDurationSeconds(tt.Time), owner, repoName, index, tt.ID) return nil } func runTimeDelete(cmd *cobra.Command, args []string) error { index, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue %q: %w", args[0], err) } timeID, err := parseIssueArg(args[1]) if err != nil { return fmt.Errorf("invalid time id %q: %w", args[1], err) } client, owner, repoName, err := newTimeClient(cmd) if err != nil { return err } skipConfirm, _ := cmd.Flags().GetBool("yes") if !skipConfirm && ios.IsStdinTTY() { answer, err := promptLine(fmt.Sprintf("Delete tracked time entry %d on %s/%s#%d? [y/N]: ", timeID, owner, repoName, index)) if err != nil { return err } if answer != "y" && answer != "Y" && answer != "yes" { fmt.Fprintln(ios.ErrOut, "Cancelled.") return nil } } if _, err := client.DeleteTime(owner, repoName, index, timeID); err != nil { return fmt.Errorf("failed to delete tracked time entry %d on %s/%s#%d: %w", timeID, owner, repoName, index, err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Deleted tracked time entry %d on %s/%s#%d\n", cs.SuccessIcon(), timeID, owner, repoName, index) return nil } func runTimeReset(cmd *cobra.Command, args []string) error { index, err := parseIssueArg(args[0]) if err != nil { return fmt.Errorf("invalid issue %q: %w", args[0], err) } client, owner, repoName, err := newTimeClient(cmd) if err != nil { return err } skipConfirm, _ := cmd.Flags().GetBool("yes") if !skipConfirm && ios.IsStdinTTY() { answer, err := promptLine(fmt.Sprintf("Delete ALL tracked time entries on %s/%s#%d? [y/N]: ", owner, repoName, index)) if err != nil { return err } if answer != "y" && answer != "Y" && answer != "yes" { fmt.Fprintln(ios.ErrOut, "Cancelled.") return nil } } if _, err := client.ResetIssueTime(owner, repoName, index); err != nil { return fmt.Errorf("failed to reset tracked times on %s/%s#%d: %w", owner, repoName, index, err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Reset tracked times on %s/%s#%d\n", cs.SuccessIcon(), owner, repoName, index) return nil } // renderTimesTable prints a table of tracked time entries. When showRepo is // true the ISSUE column includes the owner/repo prefix (useful for the // cross-repo "list my times" view). func renderTimesTable(times []*gitea.TrackedTime, showRepo bool) error { if len(times) == 0 { fmt.Fprintln(ios.Out, "No tracked time entries.") return nil } tp := ios.NewTablePrinter() tp.AddHeader("ID", "DATE", "USER", "ISSUE", "TIME") for _, t := range times { date := "" if !t.Created.IsZero() { date = t.Created.Local().Format("2006-01-02") } issueCol := "" if t.Issue != nil { if showRepo && t.Issue.Repository != nil && t.Issue.Repository.FullName != "" { issueCol = fmt.Sprintf("%s#%d", t.Issue.Repository.FullName, t.Issue.Index) } else { issueCol = fmt.Sprintf("#%d", t.Issue.Index) } } else if t.IssueID != 0 { issueCol = fmt.Sprintf("#%d", t.IssueID) } tp.AddRow( fmt.Sprintf("%d", t.ID), date, t.UserName, issueCol, formatDurationSeconds(t.Time), ) } return tp.Render() } // formatDurationSeconds renders a seconds count as a compact human duration. // Examples: 0 -> "0m", 45 -> "45s", 90 -> "1m 30s", 3600 -> "1h", // 5400 -> "1h 30m", 3661 -> "1h 1m 1s". func formatDurationSeconds(seconds int64) string { if seconds <= 0 { return "0m" } h := seconds / 3600 m := (seconds % 3600) / 60 s := seconds % 60 parts := make([]string, 0, 3) if h > 0 { parts = append(parts, fmt.Sprintf("%dh", h)) } if m > 0 { parts = append(parts, fmt.Sprintf("%dm", m)) } if s > 0 && h == 0 { // Only show seconds when the duration is under an hour; keeps the // table tidy for long entries where the second-level detail is // rarely interesting. parts = append(parts, fmt.Sprintf("%ds", s)) } if len(parts) == 0 { return "0m" } // Join with spaces: "1h 30m". out := parts[0] for _, p := range parts[1:] { out += " " + p } return out } // newTimeClient builds an api.Client plus the resolved owner/repo from the // current -R/--repo flag or the surrounding git context. Used by every // repo-scoped subcommand (add, delete, reset, and issue-scoped list). func newTimeClient(cmd *cobra.Command) (*api.Client, string, string, error) { repo, _ := cmd.Flags().GetString("repo") owner, name, err := parseRepo(repo) if err != nil { return nil, "", "", err } cfg, err := config.Load() if err != nil { return nil, "", "", err } client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return nil, "", "", err } return client, owner, name, nil }