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:
sid 2026-04-19 22:24:53 -06:00
parent 4eeef2ceca
commit d15deaf064
5 changed files with 1124 additions and 0 deletions

204
cmd/branch_protect.go Normal file
View 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
View 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
}

View 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
View 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
View 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
}