package cmd import ( "fmt" "os" "strconv" "strings" "text/tabwriter" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/sid/fgj-sid/internal/api" "forgejo.zerova.net/sid/fgj-sid/internal/config" "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" # 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`, 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") milestoneListCmd.Flags().Bool("json", false, "Output milestones as JSON") milestoneViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") milestoneViewCmd.Flags().Bool("json", false, "Output milestone as JSON") 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") milestoneCreateCmd.Flags().Bool("json", false, "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") milestoneEditCmd.Flags().Bool("json", false, "Output updated milestone as JSON") milestoneDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") } // 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()) 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) } milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{ State: stateType, }) if err != nil { return fmt.Errorf("failed to list milestones: %w", err) } if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { return writeJSON(milestones) } if len(milestones) == 0 { fmt.Printf("No %s milestones in %s/%s\n", state, owner, name) return nil } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "ID\tTITLE\tSTATE\tDUE DATE\tOPEN/CLOSED ISSUES\n") for _, ms := range milestones { due := "" if ms.Deadline != nil { due = ms.Deadline.Format("2006-01-02") } _, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d/%d\n", ms.ID, ms.Title, ms.State, due, ms.OpenIssues, ms.ClosedIssues) } _ = w.Flush() return nil } 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()) if err != nil { return err } ms, err := resolveMilestone(client, owner, name, args[0]) if err != nil { return err } if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { return writeJSON(ms) } fmt.Printf("ID: %d\n", ms.ID) fmt.Printf("Title: %s\n", ms.Title) fmt.Printf("State: %s\n", ms.State) if ms.Description != "" { fmt.Printf("Description: %s\n", ms.Description) } if ms.Deadline != nil { fmt.Printf("Due Date: %s\n", ms.Deadline.Format("2006-01-02")) } fmt.Printf("Open Issues: %d\n", ms.OpenIssues) fmt.Printf("Closed Issues: %d\n", ms.ClosedIssues) fmt.Printf("Created: %s\n", ms.Created.Format("2006-01-02 15:04:05")) if ms.Updated != nil { fmt.Printf("Updated: %s\n", ms.Updated.Format("2006-01-02 15:04:05")) } if ms.Closed != nil { fmt.Printf("Closed: %s\n", ms.Closed.Format("2006-01-02 15:04:05")) } 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()) 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 } ms, _, err := client.CreateMilestone(owner, name, opt) if err != nil { return fmt.Errorf("failed to create milestone: %w", err) } if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { return writeJSON(ms) } fmt.Printf("Milestone created: %s\n", 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()) if err != nil { return err } ms, err := resolveMilestone(client, owner, name, args[0]) 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") } updated, _, err := client.EditMilestone(owner, name, ms.ID, opt) if err != nil { return fmt.Errorf("failed to edit milestone: %w", err) } if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput { return writeJSON(updated) } fmt.Printf("Milestone updated: %s\n", updated.Title) return nil } func runMilestoneDelete(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()) if err != nil { return err } ms, err := resolveMilestone(client, owner, name, args[0]) if err != nil { return err } _, err = client.DeleteMilestone(owner, name, ms.ID) if err != nil { return fmt.Errorf("failed to delete milestone: %w", err) } fmt.Printf("Milestone deleted: %s\n", ms.Title) return nil }