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