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:
parent
7ee5a61910
commit
95da06c003
7 changed files with 1206 additions and 3 deletions
29
CHANGELOG.md
29
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/),
|
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).
|
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
|
## [0.3.0b] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -175,6 +203,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Cobra framework for CLI structure
|
- Cobra framework for CLI structure
|
||||||
- Viper for configuration management
|
- 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.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.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
|
[0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0
|
||||||
|
|
|
||||||
66
README.md
66
README.md
|
|
@ -13,6 +13,10 @@
|
||||||
- Pull request management (create, list, view, merge, diff, comment, review)
|
- Pull request management (create, list, view, merge, diff, comment, review)
|
||||||
- Issue tracking (create, list, view, comment, close, labels)
|
- Issue tracking (create, list, view, comment, close, labels)
|
||||||
- Repository operations (view, list, create, edit, clone, fork)
|
- 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)
|
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
|
||||||
- Releases (create, upload, delete)
|
- Releases (create, upload, delete)
|
||||||
- Raw API access (`fgj api`) for arbitrary REST calls
|
- 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)
|
# Edit an issue (title, body, state, labels)
|
||||||
fgj issue edit 456 -t "New Title"
|
fgj issue edit 456 -t "New Title"
|
||||||
fgj issue edit 456 --add-label priority --remove-label bug
|
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
|
### Repositories
|
||||||
|
|
|
||||||
37
cmd/issue.go
37
cmd/issue.go
|
|
@ -2,6 +2,7 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -98,6 +99,8 @@ func init() {
|
||||||
issueEditCmd.Flags().StringP("state", "s", "", "New state for the issue (open or closed)")
|
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("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().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 {
|
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")
|
stateStr, _ := cmd.Flags().GetString("state")
|
||||||
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
|
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
|
||||||
removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-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)
|
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -383,8 +388,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 {
|
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, or --remove-label must be provided")
|
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()
|
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)
|
fmt.Printf("Issue #%d updated\n", issueNumber)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
278
cmd/label.go
Normal file
278
cmd/label.go
Normal 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
437
cmd/milestone.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ var rootCmd = &cobra.Command{
|
||||||
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
||||||
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
|
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
|
||||||
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
||||||
Version: "0.3.0b",
|
Version: "0.3.0c",
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
360
cmd/wiki.go
Normal file
360
cmd/wiki.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue