diff --git a/cmd/branch_protect.go b/cmd/branch_protect.go new file mode 100644 index 0000000..4878954 --- /dev/null +++ b/cmd/branch_protect.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var branchProtectCmd = &cobra.Command{ + Use: "protect ", + Short: "Protect a branch", + Long: `Create or update a branch protection rule for the given branch. + +If a protection rule already exists for the branch it is updated in place; +otherwise a new one is created. Fields you do not set on the command line +are left at their server-side defaults (or left unchanged when editing).`, + Example: ` # Require 2 approving reviews before merging + fgj branch protect main --require-approvals 2 + + # Dismiss stale approvals and require signed commits + fgj branch protect main --dismiss-stale-approvals --require-signed-commits + + # Allow specific users to push directly, and require CI contexts + fgj branch protect main \ + --push-whitelist alice,bob \ + --require-status-checks ci/build,ci/test`, + Args: cobra.ExactArgs(1), + RunE: runBranchProtect, +} + +var branchUnprotectCmd = &cobra.Command{ + Use: "unprotect ", + Short: "Remove a branch protection rule", + Long: "Remove the protection rule attached to a branch. If the branch has no protection this is a no-op.", + Example: ` # Remove protection interactively + fgj branch unprotect main + + # Remove without confirmation + fgj branch unprotect main -y`, + Args: cobra.ExactArgs(1), + RunE: runBranchUnprotect, +} + +func init() { + branchCmd.AddCommand(branchProtectCmd) + branchCmd.AddCommand(branchUnprotectCmd) + + addRepoFlags(branchProtectCmd) + branchProtectCmd.Flags().Int64("require-approvals", 0, "Minimum number of approving reviews required") + branchProtectCmd.Flags().Bool("dismiss-stale-approvals", false, "Dismiss stale approvals when new commits are pushed") + branchProtectCmd.Flags().Bool("require-signed-commits", false, "Require commits on the branch to be signed") + branchProtectCmd.Flags().Bool("block-on-rejected-reviews", false, "Block merges when a review requests changes") + branchProtectCmd.Flags().Bool("block-on-outdated-branch", false, "Require the PR branch to be up-to-date with the base") + branchProtectCmd.Flags().StringSlice("push-whitelist", nil, "Usernames allowed to push directly (comma-separated or repeatable)") + branchProtectCmd.Flags().StringSlice("merge-whitelist", nil, "Usernames allowed to merge (comma-separated or repeatable)") + branchProtectCmd.Flags().StringSlice("require-status-checks", nil, "CI status contexts that must pass (comma-separated or repeatable)") + addJSONFlags(branchProtectCmd, "Output the resulting protection rule as JSON") + + addRepoFlags(branchUnprotectCmd) + branchUnprotectCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +func runBranchProtect(cmd *cobra.Command, args []string) error { + client, owner, name, err := newBranchClient(cmd) + if err != nil { + return err + } + + branchName := args[0] + + requireApprovals, _ := cmd.Flags().GetInt64("require-approvals") + dismissStale, _ := cmd.Flags().GetBool("dismiss-stale-approvals") + requireSigned, _ := cmd.Flags().GetBool("require-signed-commits") + blockRejected, _ := cmd.Flags().GetBool("block-on-rejected-reviews") + blockOutdated, _ := cmd.Flags().GetBool("block-on-outdated-branch") + pushWhitelist, _ := cmd.Flags().GetStringSlice("push-whitelist") + mergeWhitelist, _ := cmd.Flags().GetStringSlice("merge-whitelist") + statusChecks, _ := cmd.Flags().GetStringSlice("require-status-checks") + + // Check whether a protection rule already exists for this branch. + existing, resp, getErr := client.GetBranchProtection(owner, name, branchName) + exists := getErr == nil && existing != nil + if getErr != nil && !isNotFound(resp, getErr) { + return fmt.Errorf("failed to look up branch protection for %q: %w", branchName, getErr) + } + + var result *gitea.BranchProtection + + if exists { + edit := gitea.EditBranchProtectionOption{ + RequiredApprovals: &requireApprovals, + DismissStaleApprovals: &dismissStale, + RequireSignedCommits: &requireSigned, + BlockOnRejectedReviews: &blockRejected, + BlockOnOutdatedBranch: &blockOutdated, + } + if len(pushWhitelist) > 0 { + enable := true + edit.EnablePushWhitelist = &enable + edit.PushWhitelistUsernames = pushWhitelist + } + if len(mergeWhitelist) > 0 { + enable := true + edit.EnableMergeWhitelist = &enable + edit.MergeWhitelistUsernames = mergeWhitelist + } + if len(statusChecks) > 0 { + enable := true + edit.EnableStatusCheck = &enable + edit.StatusCheckContexts = statusChecks + } + + bp, _, err := client.EditBranchProtection(owner, name, branchName, edit) + if err != nil { + return fmt.Errorf("failed to update branch protection for %q: %w", branchName, err) + } + result = bp + } else { + create := gitea.CreateBranchProtectionOption{ + BranchName: branchName, + RequiredApprovals: requireApprovals, + DismissStaleApprovals: dismissStale, + RequireSignedCommits: requireSigned, + BlockOnRejectedReviews: blockRejected, + BlockOnOutdatedBranch: blockOutdated, + } + if len(pushWhitelist) > 0 { + create.EnablePushWhitelist = true + create.PushWhitelistUsernames = pushWhitelist + } + if len(mergeWhitelist) > 0 { + create.EnableMergeWhitelist = true + create.MergeWhitelistUsernames = mergeWhitelist + } + if len(statusChecks) > 0 { + create.EnableStatusCheck = true + create.StatusCheckContexts = statusChecks + } + + bp, _, err := client.CreateBranchProtection(owner, name, create) + if err != nil { + return fmt.Errorf("failed to create branch protection for %q: %w", branchName, err) + } + result = bp + } + + if wantJSON(cmd) { + return outputJSON(cmd, result) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Protected branch %q\n", cs.SuccessIcon(), branchName) + return nil +} + +func runBranchUnprotect(cmd *cobra.Command, args []string) error { + client, owner, name, err := newBranchClient(cmd) + if err != nil { + return err + } + + branchName := args[0] + skipConfirm, _ := cmd.Flags().GetBool("yes") + + if !skipConfirm && ios.IsStdinTTY() { + answer, err := promptLine(fmt.Sprintf("Remove protection from branch %q in %s/%s? [y/N]: ", branchName, owner, name)) + if err != nil { + return err + } + if answer != "y" && answer != "Y" && answer != "yes" { + fmt.Fprintln(ios.ErrOut, "Cancelled.") + return nil + } + } + + resp, err := client.DeleteBranchProtection(owner, name, branchName) + if err != nil { + if isNotFound(resp, err) { + fmt.Fprintf(ios.Out, "Branch %q has no protection rule; nothing to do.\n", branchName) + return nil + } + return fmt.Errorf("failed to remove branch protection for %q: %w", branchName, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Removed protection from branch %q\n", cs.SuccessIcon(), branchName) + return nil +} + +// isNotFound reports whether a gitea SDK call failed with a 404. The SDK +// sometimes returns a nil Response on transport-level errors, so we fall back +// to a string check on the error message in that case. +func isNotFound(resp *gitea.Response, err error) bool { + if err == nil { + return false + } + if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound { + return true + } + return strings.Contains(err.Error(), "404") +} diff --git a/cmd/milestone_issues.go b/cmd/milestone_issues.go new file mode 100644 index 0000000..ddde3ec --- /dev/null +++ b/cmd/milestone_issues.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "fmt" + + "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 milestoneIssuesCmd = &cobra.Command{ + Use: "issues", + Aliases: []string{"i"}, + Short: "Manage issues associated with a milestone", + Long: "Associate or disassociate issues with a milestone.", +} + +var milestoneIssuesAddCmd = &cobra.Command{ + Use: "add ...", + Short: "Add issues to a milestone", + Long: "Associate one or more issues with a milestone.", + Example: ` # Add issues #5 and #7 to milestone "v1.0" + fgj milestone issues add "v1.0" 5 7 + + # Add issue #12 to milestone with ID 3 in a specific repo + fgj milestone issues add 3 12 -R owner/repo`, + Args: cobra.MinimumNArgs(2), + RunE: runMilestoneIssuesAdd, +} + +var milestoneIssuesRemoveCmd = &cobra.Command{ + Use: "remove ...", + Short: "Remove issues from a milestone", + Long: "Disassociate one or more issues from a milestone.", + Example: ` # Remove issues #5 and #7 from milestone "v1.0" + fgj milestone issues remove "v1.0" 5 7 + + # Remove issue #12 from milestone with ID 3 in a specific repo + fgj milestone issues remove 3 12 -R owner/repo`, + Args: cobra.MinimumNArgs(2), + RunE: runMilestoneIssuesRemove, +} + +func init() { + milestoneCmd.AddCommand(milestoneIssuesCmd) + milestoneIssuesCmd.AddCommand(milestoneIssuesAddCmd) + milestoneIssuesCmd.AddCommand(milestoneIssuesRemoveCmd) + + addRepoFlags(milestoneIssuesAddCmd) + addRepoFlags(milestoneIssuesRemoveCmd) +} + +func runMilestoneIssuesAdd(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching milestone...") + ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() + if err != nil { + return err + } + + milestoneID := ms.ID + cs := ios.ColorScheme() + hadError := false + + for _, arg := range args[1:] { + issueNum, parseErr := parseIssueArg(arg) + if parseErr != nil { + fmt.Fprintf(ios.ErrOut, "invalid issue number %q: %v\n", arg, parseErr) + hadError = true + continue + } + + _, _, editErr := client.EditIssue(owner, name, issueNum, gitea.EditIssueOption{ + Milestone: &milestoneID, + }) + if editErr != nil { + fmt.Fprintf(ios.ErrOut, "failed to add issue #%d to milestone %q: %v\n", issueNum, ms.Title, editErr) + hadError = true + continue + } + + fmt.Fprintf(ios.Out, "%s Added issue #%d to milestone %q\n", cs.SuccessIcon(), issueNum, ms.Title) + } + + if hadError { + return fmt.Errorf("one or more issues could not be added to milestone %q", ms.Title) + } + return nil +} + +func runMilestoneIssuesRemove(cmd *cobra.Command, args []string) error { + repo, _ := cmd.Flags().GetString("repo") + + owner, name, err := parseRepo(repo) + if err != nil { + return err + } + + cfg, err := config.Load() + if err != nil { + return err + } + + client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) + if err != nil { + return err + } + + ios.StartSpinner("Fetching milestone...") + ms, err := resolveMilestone(client, owner, name, args[0]) + ios.StopSpinner() + if err != nil { + return err + } + + cs := ios.ColorScheme() + hadError := false + var zero int64 = 0 + + for _, arg := range args[1:] { + issueNum, parseErr := parseIssueArg(arg) + if parseErr != nil { + fmt.Fprintf(ios.ErrOut, "invalid issue number %q: %v\n", arg, parseErr) + hadError = true + continue + } + + // Setting Milestone to a pointer-to-zero clears the milestone association + // in Gitea's API (PATCH /repos/{owner}/{repo}/issues/{num} with {"milestone": 0}). + _, _, editErr := client.EditIssue(owner, name, issueNum, gitea.EditIssueOption{ + Milestone: &zero, + }) + if editErr != nil { + fmt.Fprintf(ios.ErrOut, "failed to remove issue #%d from milestone %q: %v\n", issueNum, ms.Title, editErr) + hadError = true + continue + } + + fmt.Fprintf(ios.Out, "%s Removed issue #%d from milestone %q\n", cs.SuccessIcon(), issueNum, ms.Title) + } + + if hadError { + return fmt.Errorf("one or more issues could not be removed from milestone %q", ms.Title) + } + return nil +} diff --git a/cmd/notification_states.go b/cmd/notification_states.go new file mode 100644 index 0000000..438a22b --- /dev/null +++ b/cmd/notification_states.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var notificationUnreadCmd = &cobra.Command{ + Use: "unread ", + Short: "Mark a notification as unread", + Long: "Mark a single notification thread as unread by its ID.", + Args: cobra.ExactArgs(1), + RunE: runNotificationState(gitea.NotifyStatusUnread, "unread"), +} + +var notificationPinCmd = &cobra.Command{ + Use: "pin ", + Short: "Pin a notification", + Long: "Mark a single notification thread as pinned by its ID.", + Args: cobra.ExactArgs(1), + RunE: runNotificationState(gitea.NotifyStatusPinned, "pinned"), +} + +var notificationUnpinCmd = &cobra.Command{ + Use: "unpin ", + Short: "Un-pin a notification", + Long: "Un-pin a notification thread (marks it as read).", + Args: cobra.ExactArgs(1), + RunE: runNotificationState(gitea.NotifyStatusRead, "unpinned"), +} + +func init() { + notificationCmd.AddCommand(notificationUnreadCmd) + notificationCmd.AddCommand(notificationPinCmd) + notificationCmd.AddCommand(notificationUnpinCmd) +} + +func runNotificationState(status gitea.NotifyStatus, verb string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + id, err := parseIssueArg(args[0]) + if err != nil { + return fmt.Errorf("invalid notification id %q: %w", args[0], err) + } + + client, err := loadClient() + if err != nil { + return err + } + + if _, _, err := client.ReadNotification(id, status); err != nil { + return fmt.Errorf("failed to mark notification %d as %s: %w", id, verb, err) + } + + cs := ios.ColorScheme() + fmt.Fprintf(ios.Out, "%s Marked notification %d as %s\n", cs.SuccessIcon(), id, verb) + return nil + } +} diff --git a/cmd/release_assets.go b/cmd/release_assets.go new file mode 100644 index 0000000..14532b5 --- /dev/null +++ b/cmd/release_assets.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + + "code.gitea.io/sdk/gitea" + "forgejo.zerova.net/public/fgj-sid/internal/api" + "forgejo.zerova.net/public/fgj-sid/internal/config" + "forgejo.zerova.net/public/fgj-sid/internal/text" + "github.com/spf13/cobra" +) + +var releaseAssetCmd = &cobra.Command{ + Use: "asset", + Aliases: []string{"assets"}, + Short: "Manage release assets", + Long: "List, upload, and delete individual attachments on a release.", +} + +var releaseAssetListCmd = &cobra.Command{ + Use: "list ", + Short: "List release assets", + Long: "List attachments on a release identified by tag name (or \"latest\").", + Example: ` # List assets on a release + fgj release asset list v1.0.0 + + # List assets on the latest release as JSON + fgj release asset list latest --json`, + Args: cobra.ExactArgs(1), + RunE: runReleaseAssetList, +} + +var releaseAssetCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Upload one or more release assets", + Long: "Upload one or more files as attachments to an existing release.", + Example: ` # Upload a single asset + fgj release asset create v1.0.0 dist/app-linux-amd64 + + # Upload multiple assets to the latest release + fgj release asset create latest dist/*.tar.gz + + # Upload a single file under a different name + fgj release asset create v1.0.0 ./build/out --name app-v1.0.0`, + Args: cobra.MinimumNArgs(2), + RunE: runReleaseAssetCreate, +} + +var releaseAssetDeleteCmd = &cobra.Command{ + Use: "delete ...", + Short: "Delete one or more release assets", + Long: "Delete attachments from a release. Each argument may be a numeric asset ID or an asset filename.", + Example: ` # Delete by filename + fgj release asset delete v1.0.0 app-linux-amd64 + + # Delete multiple by ID + fgj release asset delete v1.0.0 42 43 + + # Skip confirmation + fgj release asset delete latest output.zip --yes`, + Args: cobra.MinimumNArgs(2), + RunE: runReleaseAssetDelete, +} + +func init() { + releaseCmd.AddCommand(releaseAssetCmd) + releaseAssetCmd.AddCommand(releaseAssetListCmd) + releaseAssetCmd.AddCommand(releaseAssetCreateCmd) + releaseAssetCmd.AddCommand(releaseAssetDeleteCmd) + + addRepoFlags(releaseAssetListCmd) + addJSONFlags(releaseAssetListCmd, "Output assets as JSON") + + addRepoFlags(releaseAssetCreateCmd) + releaseAssetCreateCmd.Flags().String("name", "", "Override the uploaded filename (single file only)") + + addRepoFlags(releaseAssetDeleteCmd) + releaseAssetDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") +} + +func runReleaseAssetList(cmd *cobra.Command, args []string) error { + tag := args[0] + + client, owner, name, err := newReleaseAssetClient(cmd) + if err != nil { + return err + } + + ios.StartSpinner("Fetching release...") + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + if err != nil { + ios.StopSpinner() + return err + } + + var all []*gitea.Attachment + for page := 1; ; page++ { + attachments, _, err := client.ListReleaseAttachments(owner, name, release.ID, gitea.ListReleaseAttachmentsOptions{ + ListOptions: gitea.ListOptions{Page: page, PageSize: 50}, + }) + if err != nil { + ios.StopSpinner() + return fmt.Errorf("failed to list release assets: %w", err) + } + if len(attachments) == 0 { + break + } + all = append(all, attachments...) + } + ios.StopSpinner() + + if wantJSON(cmd) { + return outputJSON(cmd, all) + } + + if len(all) == 0 { + fmt.Fprintf(ios.Out, "No assets on release %s\n", release.TagName) + return nil + } + + isTTY := ios.IsStdoutTTY() + tp := ios.NewTablePrinter() + tp.AddHeader("ID", "NAME", "SIZE", "DOWNLOADS", "CREATED") + for _, a := range all { + tp.AddRow( + strconv.FormatInt(a.ID, 10), + a.Name, + humanSize(a.Size), + strconv.FormatInt(a.DownloadCount, 10), + text.FormatDate(a.Created, isTTY), + ) + } + return tp.Render() +} + +func runReleaseAssetCreate(cmd *cobra.Command, args []string) error { + tag := args[0] + files := args[1:] + + nameOverride, _ := cmd.Flags().GetString("name") + if nameOverride != "" && len(files) != 1 { + return fmt.Errorf("--name may only be used when uploading a single file") + } + + // Fail early if any file is missing. + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", f, err) + } + if info.IsDir() { + return fmt.Errorf("%s is a directory, not a file", f) + } + } + + client, owner, name, err := newReleaseAssetClient(cmd) + if err != nil { + return err + } + + ios.StartSpinner("Fetching release...") + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + ios.StopSpinner() + if err != nil { + return err + } + + cs := ios.ColorScheme() + for _, file := range files { + filename := filepath.Base(file) + if nameOverride != "" { + filename = nameOverride + } + + handle, err := os.Open(file) + if err != nil { + return fmt.Errorf("failed to open %s: %w", file, err) + } + + attachment, _, uploadErr := client.CreateReleaseAttachment(owner, name, release.ID, handle, filename) + closeErr := handle.Close() + if uploadErr != nil { + return fmt.Errorf("failed to upload %s: %w", file, uploadErr) + } + if closeErr != nil { + return fmt.Errorf("failed to close %s: %w", file, closeErr) + } + + fmt.Fprintf(ios.Out, "%s Uploaded %s (id %d, %s)\n", cs.SuccessIcon(), attachment.Name, attachment.ID, humanSize(attachment.Size)) + } + + return nil +} + +func runReleaseAssetDelete(cmd *cobra.Command, args []string) error { + tag := args[0] + targets := args[1:] + skipConfirm, _ := cmd.Flags().GetBool("yes") + + client, owner, name, err := newReleaseAssetClient(cmd) + if err != nil { + return err + } + + ios.StartSpinner("Fetching release...") + release, err := getReleaseByTagOrLatest(client, owner, name, tag) + ios.StopSpinner() + if err != nil { + return err + } + + // Resolve each target to an attachment (ID + name). List attachments once + // if any target is a non-numeric name so we can match by filename. + var cached []*gitea.Attachment + resolvedIDs := make([]int64, 0, len(targets)) + resolvedNames := make([]string, 0, len(targets)) + + for _, t := range targets { + if id, err := strconv.ParseInt(t, 10, 64); err == nil && id > 0 { + resolvedIDs = append(resolvedIDs, id) + resolvedNames = append(resolvedNames, t) + continue + } + + if cached == nil { + cached, err = listReleaseAttachments(client, owner, name, release.ID) + if err != nil { + return err + } + } + + var matched *gitea.Attachment + for _, a := range cached { + if a.Name == t { + matched = a + break + } + } + if matched == nil { + return fmt.Errorf("no asset named %q on release %s", t, release.TagName) + } + resolvedIDs = append(resolvedIDs, matched.ID) + resolvedNames = append(resolvedNames, matched.Name) + } + + cs := ios.ColorScheme() + for i, id := range resolvedIDs { + display := resolvedNames[i] + + if !skipConfirm && ios.IsStdinTTY() { + answer, err := promptLine(fmt.Sprintf("Delete asset %s (id %d) from release %s? [y/N]: ", display, id, release.TagName)) + if err != nil { + return err + } + if answer != "y" && answer != "Y" && answer != "yes" { + fmt.Fprintf(ios.ErrOut, "Skipped %s\n", display) + continue + } + } + + if _, err := client.DeleteReleaseAttachment(owner, name, release.ID, id); err != nil { + return fmt.Errorf("failed to delete asset %s: %w", display, err) + } + + fmt.Fprintf(ios.Out, "%s Deleted asset %s (id %d)\n", cs.SuccessIcon(), display, id) + } + + return nil +} + +func newReleaseAssetClient(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 +} + +// humanSize formats a byte count using power-of-1024 units. +func humanSize(n int64) string { + const unit = int64(1024) + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := unit, 0 + for v := n / unit; v >= unit; v /= unit { + div *= unit + exp++ + } + suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"} + if exp >= len(suffixes) { + exp = len(suffixes) - 1 + } + return fmt.Sprintf("%.1f %s", float64(n)/float64(div), suffixes[exp]) +} diff --git a/cmd/times.go b/cmd/times.go new file mode 100644 index 0000000..d8fc8d3 --- /dev/null +++ b/cmd/times.go @@ -0,0 +1,386 @@ +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 +}