387 lines
11 KiB
Go
387 lines
11 KiB
Go
|
|
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 <issue-number> <duration>",
|
||
|
|
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 <issue-number> <time-id>",
|
||
|
|
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 <issue-number>" 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 <issue-number>",
|
||
|
|
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
|
||
|
|
}
|