fj/cmd/milestone.go
sid 95da06c003 feat: v0.3.0c — add labels, milestones, wiki, issue dependencies
New commands:
- fgj label list/create/edit/delete
- fgj milestone list/view/create/edit/delete
- fgj wiki list/view/create/edit/delete

Enhanced:
- fgj issue edit --add-dependency/--remove-dependency
2026-03-21 21:50:24 -06:00

437 lines
11 KiB
Go

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 <title-or-id>",
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 <title>",
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
}