Add PR checks command, iostreams/text packages for colored table output, top-level run/workflow aliases matching gh CLI structure. Enhance actions, issues, PRs, releases, repos, labels, milestones, and wiki commands with improved flags, JSON output, and error handling.
487 lines
12 KiB
Go
487 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.gitea.io/sdk/gitea"
|
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
|
"forgejo.zerova.net/sid/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 <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"
|
|
|
|
# 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 <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
|
|
|
|
# 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())
|
|
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())
|
|
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())
|
|
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())
|
|
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())
|
|
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())
|
|
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
|
|
}
|