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
This commit is contained in:
sid 2026-03-21 21:50:24 -06:00
parent 7ee5a61910
commit 95da06c003
7 changed files with 1206 additions and 3 deletions

View file

@ -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 <number>` - Add issue dependency
- `fgj issue edit --remove-dependency <number>` - 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

View file

@ -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

View file

@ -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

278
cmd/label.go Normal file
View file

@ -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 <name>",
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 <name>",
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 <name>",
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
}

437
cmd/milestone.go Normal file
View file

@ -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 <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
}

View file

@ -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,
}

360
cmd/wiki.go Normal file
View file

@ -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
}