package cmd import ( "fmt" "strconv" "strings" "time" "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 milestoneCmd = &cobra.Command{ Use: "milestone", Short: "Manage milestones", Long: "Create, view, list, edit, and delete milestones.", } var milestoneListCmd = &cobra.Command{ Use: "list [flags]", Short: "List milestones", Long: "List milestones in a repository.", Example: ` # List open milestones fgj milestone list # List all milestones for a specific repo fgj milestone list -R owner/repo --state all # Output as JSON fgj milestone list --json`, RunE: runMilestoneList, } var milestoneViewCmd = &cobra.Command{ Use: "view ", Short: "View a milestone", Long: "Display detailed information about a milestone.", Example: ` # View by ID fgj milestone view 1 # View by title fgj milestone view "v1.0" # Open in browser fgj milestone view "v1.0" --web # Output as JSON fgj milestone view "v1.0" --json`, Args: cobra.ExactArgs(1), RunE: runMilestoneView, } var milestoneCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a milestone", Long: "Create a new milestone.", Example: ` # Create a simple milestone fgj milestone create "v1.0" # Create with description and due date fgj milestone create "v2.0" -d "Second release" --due 2026-06-01 # Output as JSON fgj milestone create "v1.0" --json`, Args: cobra.ExactArgs(1), RunE: runMilestoneCreate, } var milestoneEditCmd = &cobra.Command{ Use: "edit <title-or-id>", Short: "Edit a milestone", Long: "Edit an existing milestone's title, description, due date, or state.", Example: ` # Rename a milestone fgj milestone edit "v1.0" --title "v1.1" # Close a milestone fgj milestone edit "v1.0" --state closed # Update due date fgj milestone edit 1 --due 2026-12-31`, Args: cobra.ExactArgs(1), RunE: runMilestoneEdit, } var milestoneDeleteCmd = &cobra.Command{ Use: "delete <title-or-id>", Short: "Delete a milestone", Long: "Delete an existing milestone.", Example: ` # Delete by title fgj milestone delete "v1.0" # Delete by ID fgj milestone delete 1 # Delete without confirmation fgj milestone delete "v1.0" -y`, Args: cobra.ExactArgs(1), RunE: runMilestoneDelete, } func init() { rootCmd.AddCommand(milestoneCmd) milestoneCmd.AddCommand(milestoneListCmd) milestoneCmd.AddCommand(milestoneViewCmd) milestoneCmd.AddCommand(milestoneCreateCmd) milestoneCmd.AddCommand(milestoneEditCmd) milestoneCmd.AddCommand(milestoneDeleteCmd) milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneListCmd.Flags().String("state", "open", "Filter by state: open, closed, all") addJSONFlags(milestoneListCmd, "Output milestones as JSON") milestoneViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") addJSONFlags(milestoneViewCmd, "Output milestone as JSON") milestoneViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") milestoneCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone") milestoneCreateCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") addJSONFlags(milestoneCreateCmd, "Output created milestone as JSON") milestoneEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneEditCmd.Flags().String("title", "", "New title for the milestone") milestoneEditCmd.Flags().StringP("description", "d", "", "New description for the milestone") milestoneEditCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format") milestoneEditCmd.Flags().String("state", "", "New state: open or closed") addJSONFlags(milestoneEditCmd, "Output updated milestone as JSON") milestoneDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") } // resolveMilestone resolves a title-or-id argument to a milestone. // If the argument is numeric, it fetches by ID. Otherwise, it lists // milestones and finds a match by title. func resolveMilestone(client *api.Client, owner, name, arg string) (*gitea.Milestone, error) { if id, err := strconv.ParseInt(arg, 10, 64); err == nil { ms, _, err := client.GetMilestone(owner, name, id) if err != nil { return nil, fmt.Errorf("failed to get milestone %d: %w", id, err) } return ms, nil } milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{ State: gitea.StateAll, }) if err != nil { return nil, fmt.Errorf("failed to list milestones: %w", err) } for _, ms := range milestones { if strings.EqualFold(ms.Title, arg) { return ms, nil } } return nil, fmt.Errorf("milestone not found: %s", arg) } func parseDueDate(dateStr string) (*time.Time, error) { t, err := time.Parse("2006-01-02", dateStr) if err != nil { return nil, fmt.Errorf("invalid due date %q: expected YYYY-MM-DD format", dateStr) } return &t, nil } func runMilestoneList(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") state, _ := cmd.Flags().GetString("state") 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 } var stateType gitea.StateType switch strings.ToLower(state) { case "open": stateType = gitea.StateOpen case "closed": stateType = gitea.StateClosed case "all": stateType = gitea.StateAll default: return fmt.Errorf("invalid state: %s", state) } ios.StartSpinner("Fetching milestones...") milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{ State: stateType, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to list milestones: %w", err) } if wantJSON(cmd) { return outputJSON(cmd, milestones) } if len(milestones) == 0 { fmt.Fprintf(ios.Out, "No %s milestones in %s/%s\n", state, owner, name) return nil } tp := ios.NewTablePrinter() tp.AddHeader("ID", "TITLE", "STATE", "DUE DATE", "OPEN/CLOSED ISSUES") for _, ms := range milestones { due := "" if ms.Deadline != nil { due = ms.Deadline.Format("2006-01-02") } tp.AddRow( fmt.Sprintf("%d", ms.ID), ms.Title, string(ms.State), due, fmt.Sprintf("%d/%d", ms.OpenIssues, ms.ClosedIssues), ) } return tp.Render() } func runMilestoneView(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 } if web, _ := cmd.Flags().GetBool("web"); web { // Milestones don't have HTMLURL in the API, construct it cfg2, _ := config.Load() host, _ := cfg2.GetHost("", getDetectedHost(), getCwd()) url := fmt.Sprintf("https://%s/%s/%s/milestone/%d", host.Hostname, owner, name, ms.ID) return ios.OpenInBrowser(url) } if wantJSON(cmd) { return outputJSON(cmd, ms) } cs := ios.ColorScheme() isTTY := ios.IsStdoutTTY() fmt.Fprintf(ios.Out, "ID: %d\n", ms.ID) fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(ms.Title)) fmt.Fprintf(ios.Out, "State: %s\n", ms.State) if ms.Description != "" { fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description) } if ms.Deadline != nil { fmt.Fprintf(ios.Out, "Due Date: %s\n", ms.Deadline.Format("2006-01-02")) } fmt.Fprintf(ios.Out, "Open Issues: %d\n", ms.OpenIssues) fmt.Fprintf(ios.Out, "Closed Issues: %d\n", ms.ClosedIssues) fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(ms.Created, isTTY)) if ms.Updated != nil { fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*ms.Updated, isTTY)) } if ms.Closed != nil { fmt.Fprintf(ios.Out, "Closed: %s\n", text.FormatDate(*ms.Closed, isTTY)) } return nil } func runMilestoneCreate(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") description, _ := cmd.Flags().GetString("description") dueStr, _ := cmd.Flags().GetString("due") title := args[0] 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 } opt := gitea.CreateMilestoneOption{ Title: title, Description: description, } if dueStr != "" { deadline, err := parseDueDate(dueStr) if err != nil { return err } opt.Deadline = deadline } ios.StartSpinner("Creating milestone...") ms, _, err := client.CreateMilestone(owner, name, opt) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create milestone: %w", err) } if wantJSON(cmd) { return outputJSON(cmd, ms) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Milestone created: %s\n", cs.SuccessIcon(), ms.Title) return nil } func runMilestoneEdit(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 } opt := gitea.EditMilestoneOption{} changed := false if cmd.Flags().Changed("title") { t, _ := cmd.Flags().GetString("title") opt.Title = t changed = true } if cmd.Flags().Changed("description") { d, _ := cmd.Flags().GetString("description") opt.Description = &d changed = true } if cmd.Flags().Changed("due") { dueStr, _ := cmd.Flags().GetString("due") deadline, err := parseDueDate(dueStr) if err != nil { return err } opt.Deadline = deadline changed = true } if cmd.Flags().Changed("state") { stateStr, _ := cmd.Flags().GetString("state") switch strings.ToLower(stateStr) { case "open": s := gitea.StateOpen opt.State = &s case "closed": s := gitea.StateClosed opt.State = &s default: return fmt.Errorf("invalid state: %s (must be 'open' or 'closed')", stateStr) } changed = true } if !changed { return fmt.Errorf("no changes specified; use flags like --title, --description, --due, or --state") } ios.StartSpinner("Updating milestone...") updated, _, err := client.EditMilestone(owner, name, ms.ID, opt) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to edit milestone: %w", err) } if wantJSON(cmd) { return outputJSON(cmd, updated) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Milestone updated: %s\n", cs.SuccessIcon(), updated.Title) return nil } func runMilestoneDelete(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") yes, _ := cmd.Flags().GetBool("yes") 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 } if !yes { confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete milestone %q?", ms.Title)) if err != nil { return err } if !confirmed { fmt.Fprintln(ios.ErrOut, "Aborted") return nil } } ios.StartSpinner("Deleting milestone...") _, err = client.DeleteMilestone(owner, name, ms.ID) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to delete milestone: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Milestone deleted: %s\n", cs.SuccessIcon(), ms.Title) return nil }