feat: times, branch protection, release assets, milestone issues, notification states
Five parallel tea-parity additions (~1100 LOC):
- fgj time {list,add,delete,reset} (aliases: times, t)
Tracked time entries. 'list' with no arg uses ListMyTrackedTimes
across all your repos; with an issue number uses ListIssueTrackedTimes.
'add' accepts Go duration strings (30m, 1h30m). 'delete' removes a
single entry by id; 'reset' clears all times on an issue. Confirmation
on delete/reset unless --yes or no TTY.
- fgj branch {protect,unprotect}
Branch protection rules. 'protect' idempotently creates-or-edits via
Get/Create/EditBranchProtection with --require-approvals,
--require-signed-commits, --dismiss-stale-approvals,
--block-on-rejected-reviews, --block-on-outdated-branch,
--push-whitelist, --merge-whitelist, --require-status-checks.
Empty whitelist flags leave existing rule fields untouched.
'unprotect' deletes; 404 is a friendly no-op.
- fgj release asset {list,create,delete} (alias: assets)
Granular attachment management. Resolves the release by tag or
"latest" using the existing helpers in cmd/release.go. 'create'
validates all paths up front then uploads each. 'delete' accepts
numeric ids OR filenames (cross-references the attachment list).
Per-asset confirmation unless --yes.
- fgj milestone issues {add,remove} (alias: i)
Associate/disassociate issues with a milestone. Milestone accepted
as title or numeric id (reuses resolveMilestone from milestone.go).
'remove' passes EditIssueOption{Milestone: &zero} — the Gitea/Forgejo
convention for clearing the association. Continues on per-issue
failure and exits 1 if any failed.
- fgj notification {unread,pin,unpin}
Complement the existing list/read. Factory pattern over
ReadNotification(id, NotifyStatus) with three distinct constants.
All five files are self-contained: each has its own init() attaching
to the existing parent cobra.Command (branchCmd, milestoneCmd,
notificationCmd, releaseCmd) without modifying any other file. Built
by parallel sub-agents; all compile, vet, and test clean.
This commit is contained in:
parent
4eeef2ceca
commit
d15deaf064
5 changed files with 1124 additions and 0 deletions
204
cmd/branch_protect.go
Normal file
204
cmd/branch_protect.go
Normal file
|
|
@ -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 <branch-name>",
|
||||
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 <branch-name>",
|
||||
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")
|
||||
}
|
||||
164
cmd/milestone_issues.go
Normal file
164
cmd/milestone_issues.go
Normal file
|
|
@ -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 <title-or-id> <issue-number>...",
|
||||
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 <title-or-id> <issue-number>...",
|
||||
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
|
||||
}
|
||||
60
cmd/notification_states.go
Normal file
60
cmd/notification_states.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var notificationUnreadCmd = &cobra.Command{
|
||||
Use: "unread <id>",
|
||||
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 <id>",
|
||||
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 <id>",
|
||||
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
|
||||
}
|
||||
}
|
||||
310
cmd/release_assets.go
Normal file
310
cmd/release_assets.go
Normal file
|
|
@ -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 <tag|latest>",
|
||||
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 <tag|latest> <files...>",
|
||||
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 <tag|latest> <asset-id-or-name>...",
|
||||
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])
|
||||
}
|
||||
386
cmd/times.go
Normal file
386
cmd/times.go
Normal file
|
|
@ -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 <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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue