diff --git a/CHANGELOG.md b/CHANGELOG.md index 227ecde..4a61045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0c] - 2026-03-21 + +### Added + +#### Label Management +- `fgj label list` - List repository labels +- `fgj label create` - Create a label with color and description +- `fgj label edit` - Edit label name, color, or description +- `fgj label delete` - Delete a label + +#### Milestone Management +- `fgj milestone list` - List milestones with state filtering +- `fgj milestone view` - View milestone details +- `fgj milestone create` - Create a milestone with description and due date +- `fgj milestone edit` - Edit milestone title, description, due date, or state +- `fgj milestone delete` - Delete a milestone + +#### Wiki Management +- `fgj wiki list` - List wiki pages +- `fgj wiki view` - View wiki page content +- `fgj wiki create` - Create a wiki page from flag or file +- `fgj wiki edit` - Edit a wiki page +- `fgj wiki delete` - Delete a wiki page + +#### Issue Dependencies +- `fgj issue edit --add-dependency ` - Add issue dependency +- `fgj issue edit --remove-dependency ` - Remove issue dependency + ## [0.3.0b] - 2026-03-21 ### Added @@ -175,6 +203,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cobra framework for CLI structure - Viper for configuration management +[0.3.0c]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0c [0.3.0b]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0b [0.3.0a]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0a [0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0 diff --git a/README.md b/README.md index adea186..f8877a2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ - Pull request management (create, list, view, merge, diff, comment, review) - Issue tracking (create, list, view, comment, close, labels) - Repository operations (view, list, create, edit, clone, fork) +- Label management (list, create, edit, delete) +- Milestone management (list, view, create, edit, delete) +- Wiki page management (list, view, create, edit, delete) +- Issue dependencies (`--add-dependency`, `--remove-dependency`) - Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables) - Releases (create, upload, delete) - Raw API access (`fgj api`) for arbitrary REST calls @@ -184,6 +188,68 @@ fgj issue close 456 -c "Fixed in v2.0" # Edit an issue (title, body, state, labels) fgj issue edit 456 -t "New Title" fgj issue edit 456 --add-label priority --remove-label bug + +# Manage issue dependencies +fgj issue edit 456 --add-dependency 123 +fgj issue edit 456 --remove-dependency 123 +``` + +### Labels + +```bash +# List labels +fgj label list + +# Create a label +fgj label create bug --color ff0000 -d "Something isn't working" + +# Edit a label +fgj label edit bug --name bugfix --color ee0000 + +# Delete a label +fgj label delete bug +``` + +### Milestones + +```bash +# List milestones +fgj milestone list +fgj milestone list --state all + +# View a milestone +fgj milestone view "v1.0" + +# Create a milestone with due date +fgj milestone create "v2.0" -d "Next major release" --due 2026-06-01 + +# Edit a milestone +fgj milestone edit "v2.0" --title "v2.0-rc1" --state closed + +# Delete a milestone +fgj milestone delete "v2.0" +``` + +### Wiki + +```bash +# List wiki pages +fgj wiki list + +# View a wiki page +fgj wiki view "Home" + +# Create a wiki page +fgj wiki create "Setup Guide" -b "# Setup\n\nFollow these steps..." + +# Create from file +fgj wiki create "API Docs" --body-file docs/api.md + +# Edit a wiki page +fgj wiki edit "Home" -b "Updated content" + +# Delete a wiki page +fgj wiki delete "Old Page" ``` ### Repositories diff --git a/cmd/issue.go b/cmd/issue.go index 65fe12c..42ef982 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net/http" "os" "strconv" "strings" @@ -98,6 +99,8 @@ func init() { issueEditCmd.Flags().StringP("state", "s", "", "New state for the issue (open or closed)") issueEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (can be specified multiple times)") issueEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (can be specified multiple times)") + issueEditCmd.Flags().Int64Slice("add-dependency", nil, "Issue numbers to add as dependencies (can be specified multiple times)") + issueEditCmd.Flags().Int64Slice("remove-dependency", nil, "Issue numbers to remove as dependencies (can be specified multiple times)") } func runIssueList(cmd *cobra.Command, args []string) error { @@ -372,6 +375,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { stateStr, _ := cmd.Flags().GetString("state") addLabelNames, _ := cmd.Flags().GetStringSlice("add-label") removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label") + addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency") + removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency") issueNumber, err := strconv.ParseInt(args[0], 10, 64) if err != nil { @@ -383,8 +388,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { return err } - if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 { - return fmt.Errorf("at least one of --title, --body, --state, --add-label, or --remove-label must be provided") + if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 && len(addDeps) == 0 && len(removeDeps) == 0 { + return fmt.Errorf("at least one of --title, --body, --state, --add-label, --remove-label, --add-dependency, or --remove-dependency must be provided") } cfg, err := config.Load() @@ -453,6 +458,34 @@ func runIssueEdit(cmd *cobra.Command, args []string) error { } } + for _, depNumber := range addDeps { + depIssue, _, err := client.GetIssue(owner, name, depNumber) + if err != nil { + return fmt.Errorf("failed to get issue #%d: %w", depNumber, err) + } + depBody := map[string]int64{"id": depIssue.ID} + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner, name, issueNumber) + _, err = client.DoJSON(http.MethodPost, path, depBody, nil) + if err != nil { + return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err) + } + fmt.Printf("Added dependency: #%d depends on #%d\n", issueNumber, depNumber) + } + + for _, depNumber := range removeDeps { + depIssue, _, err := client.GetIssue(owner, name, depNumber) + if err != nil { + return fmt.Errorf("failed to get issue #%d: %w", depNumber, err) + } + depBody := map[string]int64{"id": depIssue.ID} + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner, name, issueNumber) + _, err = client.DoJSON(http.MethodDelete, path, depBody, nil) + if err != nil { + return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err) + } + fmt.Printf("Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber) + } + fmt.Printf("Issue #%d updated\n", issueNumber) return nil diff --git a/cmd/label.go b/cmd/label.go new file mode 100644 index 0000000..b5d1205 --- /dev/null +++ b/cmd/label.go @@ -0,0 +1,278 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "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 labelCmd = &cobra.Command{ + Use: "label", + Short: "Manage labels", + Long: "List, create, edit, and delete repository labels.", +} + +var labelListCmd = &cobra.Command{ + Use: "list", + Short: "List labels for a repository", + Long: "List all labels defined in a repository.", + Example: ` # List labels for the current repository + fgj label list + + # List labels for a specific repository + fgj label list -R owner/repo + + # Output as JSON + fgj label list --json`, + RunE: runLabelList, +} + +var labelCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a label", + Long: "Create a new label in a repository.", + Example: ` # Create a label with a color + fgj label create bug -c ff0000 + + # Create a label with color and description + fgj label create feature -c 00ff00 -d "New feature request" + + # Create a label in a specific repository + fgj label create urgent -c ff0000 -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runLabelCreate, +} + +var labelEditCmd = &cobra.Command{ + Use: "edit ", + Short: "Edit a label", + Long: "Edit an existing label in a repository.", + Example: ` # Rename a label + fgj label edit bug --name bugfix + + # Change the color of a label + fgj label edit bug -c 00ff00 + + # Update description + fgj label edit bug -d "Something is broken"`, + Args: cobra.ExactArgs(1), + RunE: runLabelEdit, +} + +var labelDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a label", + Long: "Delete a label from a repository.", + Example: ` # Delete a label + fgj label delete bug + + # Delete a label from a specific repository + fgj label delete bug -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runLabelDelete, +} + +func init() { + rootCmd.AddCommand(labelCmd) + labelCmd.AddCommand(labelListCmd) + labelCmd.AddCommand(labelCreateCmd) + labelCmd.AddCommand(labelEditCmd) + labelCmd.AddCommand(labelDeleteCmd) + + labelListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + labelListCmd.Flags().Bool("json", false, "Output as JSON") + + labelCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + labelCreateCmd.Flags().StringP("color", "c", "", "Label color (hex, e.g. 00ff00)") + labelCreateCmd.Flags().StringP("description", "d", "", "Label description") + labelCreateCmd.Flags().Bool("json", false, "Output as JSON") + + labelEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + labelEditCmd.Flags().String("name", "", "New name for the label") + labelEditCmd.Flags().StringP("color", "c", "", "New color (hex, e.g. 00ff00)") + labelEditCmd.Flags().StringP("description", "d", "", "New description") + labelEditCmd.Flags().Bool("json", false, "Output as JSON") + + labelDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") +} + +func newLabelClient(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()) + if err != nil { + return nil, "", "", err + } + + return client, owner, name, nil +} + +// findLabelByName lists all repo labels and returns the one matching the given name. +func findLabelByName(client *api.Client, owner, repo, labelName string) (*gitea.Label, error) { + labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list labels: %w", err) + } + + for _, l := range labels { + if strings.EqualFold(l.Name, labelName) { + return l, nil + } + } + + return nil, fmt.Errorf("label not found: %s", labelName) +} + +func runLabelList(cmd *cobra.Command, args []string) error { + client, owner, name, err := newLabelClient(cmd) + if err != nil { + return err + } + + labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{}) + if err != nil { + return fmt.Errorf("failed to list labels: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return writeJSON(labels) + } + + if len(labels) == 0 { + fmt.Println("No labels found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintf(w, "NAME\tCOLOR\tDESCRIPTION\n") + for _, l := range labels { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", l.Name, l.Color, l.Description) + } + _ = w.Flush() + + return nil +} + +func runLabelCreate(cmd *cobra.Command, args []string) error { + labelName := args[0] + color, _ := cmd.Flags().GetString("color") + description, _ := cmd.Flags().GetString("description") + + client, owner, name, err := newLabelClient(cmd) + if err != nil { + return err + } + + label, _, err := client.CreateLabel(owner, name, gitea.CreateLabelOption{ + Name: labelName, + Color: color, + Description: description, + }) + if err != nil { + return fmt.Errorf("failed to create label: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return writeJSON(label) + } + + fmt.Printf("Label created: %s\n", label.Name) + return nil +} + +func runLabelEdit(cmd *cobra.Command, args []string) error { + labelName := args[0] + + client, owner, name, err := newLabelClient(cmd) + if err != nil { + return err + } + + existing, err := findLabelByName(client, owner, name, labelName) + if err != nil { + return err + } + + opt := gitea.EditLabelOption{} + changed := false + + if cmd.Flags().Changed("name") { + n, _ := cmd.Flags().GetString("name") + opt.Name = &n + changed = true + } + if cmd.Flags().Changed("color") { + c, _ := cmd.Flags().GetString("color") + opt.Color = &c + changed = true + } + if cmd.Flags().Changed("description") { + d, _ := cmd.Flags().GetString("description") + opt.Description = &d + changed = true + } + + if !changed { + return fmt.Errorf("no changes specified; use flags like --name, --color, or --description") + } + + label, _, err := client.EditLabel(owner, name, existing.ID, opt) + if err != nil { + return fmt.Errorf("failed to edit label: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return writeJSON(label) + } + + fmt.Printf("Label updated: %s\n", label.Name) + return nil +} + +func runLabelDelete(cmd *cobra.Command, args []string) error { + labelName := args[0] + + client, owner, name, err := newLabelClient(cmd) + if err != nil { + return err + } + + existing, err := findLabelByName(client, owner, name, labelName) + if err != nil { + return err + } + + fmt.Printf("Are you sure you want to delete label %q? (y/N): ", labelName) + var confirm string + _, _ = fmt.Scanln(&confirm) + if strings.ToLower(confirm) != "y" { + fmt.Println("Aborted") + return nil + } + + _, err = client.DeleteLabel(owner, name, existing.ID) + if err != nil { + return fmt.Errorf("failed to delete label: %w", err) + } + + fmt.Printf("Label deleted: %s\n", labelName) + return nil +} diff --git a/cmd/milestone.go b/cmd/milestone.go new file mode 100644 index 0000000..b8d156b --- /dev/null +++ b/cmd/milestone.go @@ -0,0 +1,437 @@ +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 +} diff --git a/cmd/root.go b/cmd/root.go index 35d0e59..dab7fb4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,7 +18,7 @@ var rootCmd = &cobra.Command{ Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line", Long: `fgj is a command line tool for Forgejo instances (including Codeberg). It brings pull requests, issues, and other Forgejo concepts to the terminal.`, - Version: "0.3.0b", + Version: "0.3.0c", SilenceErrors: true, } diff --git a/cmd/wiki.go b/cmd/wiki.go new file mode 100644 index 0000000..1e5786a --- /dev/null +++ b/cmd/wiki.go @@ -0,0 +1,360 @@ +package cmd + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "os" + "text/tabwriter" + "time" + + "forgejo.zerova.net/sid/fgj-sid/internal/api" + "forgejo.zerova.net/sid/fgj-sid/internal/config" + "github.com/spf13/cobra" +) + +// Wiki API response types + +type wikiPageMeta struct { + Title string `json:"title"` + HTMLURL string `json:"html_url"` + SubURL string `json:"sub_url"` + LastCommit *wikiCommit `json:"last_commit"` +} + +type wikiCommit struct { + ID string `json:"id"` + Author *wikiUser `json:"author"` + Committer *wikiUser `json:"committer"` + Message string `json:"message"` +} + +type wikiUser struct { + Name string `json:"name"` + Email string `json:"email"` + Date string `json:"date"` +} + +type wikiPage struct { + Title string `json:"title"` + HTMLURL string `json:"html_url"` + SubURL string `json:"sub_url"` + ContentBase64 string `json:"content_base64"` + LastCommit *wikiCommit `json:"last_commit"` + // Decoded content for JSON output + Content string `json:"content,omitempty"` +} + +type wikiCreateRequest struct { + Title string `json:"title"` + ContentBase64 string `json:"content_base64"` +} + +var wikiCmd = &cobra.Command{ + Use: "wiki", + Short: "Manage repository wiki pages", + Long: "View and manage wiki pages for a repository.", +} + +var wikiListCmd = &cobra.Command{ + Use: "list", + Short: "List wiki pages", + Long: "List all wiki pages for a repository.", + Example: ` # List wiki pages for the current repo + fgj wiki list + + # List wiki pages for a specific repo + fgj wiki list -R owner/repo + + # Output as JSON + fgj wiki list --json`, + RunE: runWikiList, +} + +var wikiViewCmd = &cobra.Command{ + Use: "view <title>", + Short: "View a wiki page", + Long: "Display the content of a wiki page.", + Example: ` # View a wiki page + fgj wiki view Home + + # View a wiki page as JSON (includes content) + fgj wiki view Home --json + + # View a wiki page from a specific repo + fgj wiki view "Getting-Started" -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runWikiView, +} + +var wikiCreateCmd = &cobra.Command{ + Use: "create <title>", + Short: "Create a wiki page", + Long: "Create a new wiki page in the repository.", + Example: ` # Create a wiki page with inline content + fgj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide." + + # Create a wiki page from a file + fgj wiki create "Setup Guide" --body-file setup.md + + # Create a wiki page from stdin + echo "# FAQ" | fgj wiki create FAQ --body-file - + + # Output as JSON + fgj wiki create "New Page" -b "Content here" --json`, + Args: cobra.ExactArgs(1), + RunE: runWikiCreate, +} + +var wikiEditCmd = &cobra.Command{ + Use: "edit <title>", + Short: "Edit a wiki page", + Long: "Edit an existing wiki page in the repository.", + Example: ` # Edit a wiki page with new content + fgj wiki edit Home -b "# Updated Home\nNew content here." + + # Edit a wiki page from a file + fgj wiki edit "Setup Guide" --body-file updated-setup.md + + # Edit a wiki page from stdin + cat new-content.md | fgj wiki edit Home --body-file - + + # Output as JSON + fgj wiki edit Home -b "Updated content" --json`, + Args: cobra.ExactArgs(1), + RunE: runWikiEdit, +} + +var wikiDeleteCmd = &cobra.Command{ + Use: "delete <title>", + Short: "Delete a wiki page", + Long: "Delete a wiki page from the repository.", + Example: ` # Delete a wiki page + fgj wiki delete "Old Page" + + # Delete a wiki page from a specific repo + fgj wiki delete "Outdated Guide" -R owner/repo`, + Args: cobra.ExactArgs(1), + RunE: runWikiDelete, +} + +func init() { + rootCmd.AddCommand(wikiCmd) + wikiCmd.AddCommand(wikiListCmd) + wikiCmd.AddCommand(wikiViewCmd) + wikiCmd.AddCommand(wikiCreateCmd) + wikiCmd.AddCommand(wikiEditCmd) + wikiCmd.AddCommand(wikiDeleteCmd) + + wikiListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + wikiListCmd.Flags().Bool("json", false, "Output as JSON") + + wikiViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + wikiViewCmd.Flags().Bool("json", false, "Output as JSON") + + wikiCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + wikiCreateCmd.Flags().StringP("body", "b", "", "Wiki page content") + wikiCreateCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") + wikiCreateCmd.Flags().Bool("json", false, "Output created page as JSON") + + wikiEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") + wikiEditCmd.Flags().StringP("body", "b", "", "Wiki page content") + wikiEditCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)") + wikiEditCmd.Flags().Bool("json", false, "Output updated page as JSON") + + wikiDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") +} + +func newWikiClient(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()) + if err != nil { + return nil, "", "", err + } + + return client, owner, name, nil +} + +func runWikiList(cmd *cobra.Command, args []string) error { + client, owner, name, err := newWikiClient(cmd) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(name)) + + var pages []wikiPageMeta + if err := client.GetJSON(path, &pages); err != nil { + return fmt.Errorf("failed to list wiki pages: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return writeJSON(pages) + } + + if len(pages) == 0 { + fmt.Println("No wiki pages found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintf(w, "TITLE\tLAST UPDATED\n") + for _, p := range pages { + updated := "" + if p.LastCommit != nil && p.LastCommit.Committer != nil && p.LastCommit.Committer.Date != "" { + if t, err := time.Parse(time.RFC3339, p.LastCommit.Committer.Date); err == nil { + updated = t.Format("2006-01-02 15:04:05") + } else { + updated = p.LastCommit.Committer.Date + } + } + _, _ = fmt.Fprintf(w, "%s\t%s\n", p.Title, updated) + } + _ = w.Flush() + + return nil +} + +func runWikiView(cmd *cobra.Command, args []string) error { + title := args[0] + + client, owner, name, err := newWikiClient(cmd) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", + url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + + var page wikiPage + if err := client.GetJSON(path, &page); err != nil { + return fmt.Errorf("failed to get wiki page: %w", err) + } + + content, err := base64.StdEncoding.DecodeString(page.ContentBase64) + if err != nil { + return fmt.Errorf("failed to decode wiki page content: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + page.Content = string(content) + return writeJSON(page) + } + + fmt.Printf("# %s\n\n", page.Title) + fmt.Print(string(content)) + // Ensure trailing newline + if len(content) > 0 && content[len(content)-1] != '\n' { + fmt.Println() + } + + return nil +} + +func runWikiCreate(cmd *cobra.Command, args []string) error { + title := args[0] + + client, owner, name, err := newWikiClient(cmd) + if err != nil { + return err + } + + body, err := readBody(cmd) + if err != nil { + return err + } + if body == "" { + return fmt.Errorf("content is required (use --body or --body-file)") + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", + url.PathEscape(owner), url.PathEscape(name)) + + reqBody := wikiCreateRequest{ + Title: title, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), + } + + var page wikiPage + if _, err := client.DoJSON(http.MethodPost, path, reqBody, &page); err != nil { + return fmt.Errorf("failed to create wiki page: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return writeJSON(page) + } + + fmt.Printf("Wiki page created: %s\n", title) + return nil +} + +func runWikiEdit(cmd *cobra.Command, args []string) error { + title := args[0] + + client, owner, name, err := newWikiClient(cmd) + if err != nil { + return err + } + + body, err := readBody(cmd) + if err != nil { + return err + } + if body == "" { + return fmt.Errorf("content is required (use --body or --body-file)") + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", + url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + + reqBody := wikiCreateRequest{ + Title: title, + ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)), + } + + var page wikiPage + if _, err := client.DoJSON(http.MethodPatch, path, reqBody, &page); err != nil { + return fmt.Errorf("failed to update wiki page: %w", err) + } + + jsonFlag, _ := cmd.Flags().GetBool("json") + if jsonFlag { + return writeJSON(page) + } + + fmt.Printf("Wiki page updated: %s\n", title) + return nil +} + +func runWikiDelete(cmd *cobra.Command, args []string) error { + title := args[0] + + client, owner, name, err := newWikiClient(cmd) + if err != nil { + return err + } + + path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s", + url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title)) + + if _, err := client.DoJSON(http.MethodDelete, path, nil, nil); err != nil { + return fmt.Errorf("failed to delete wiki page: %w", err) + } + + fmt.Printf("Wiki page deleted: %s\n", title) + return nil +}