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

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

525 lines
14 KiB
Go

package cmd
import (
"fmt"
"net/http"
"os"
"strconv"
"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 issueCmd = &cobra.Command{
Use: "issue",
Short: "Manage issues",
Long: "Create, view, list, and manage issues.",
}
var issueListCmd = &cobra.Command{
Use: "list [flags]",
Short: "List issues",
Long: "List issues in a repository.",
RunE: runIssueList,
}
var issueViewCmd = &cobra.Command{
Use: "view <number>",
Short: "View an issue",
Long: "Display detailed information about an issue.",
Args: cobra.ExactArgs(1),
RunE: runIssueView,
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an issue",
Long: "Create a new issue.",
RunE: runIssueCreate,
}
var issueCommentCmd = &cobra.Command{
Use: "comment <number>",
Short: "Add a comment to an issue",
Long: "Add a comment to an existing issue.",
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
}
var issueCloseCmd = &cobra.Command{
Use: "close <number>",
Short: "Close an issue",
Long: "Close an existing issue.",
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
}
var issueEditCmd = &cobra.Command{
Use: "edit <number>",
Short: "Edit an issue",
Long: "Edit an existing issue's title, body, or state.",
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
}
func init() {
rootCmd.AddCommand(issueCmd)
issueCmd.AddCommand(issueListCmd)
issueCmd.AddCommand(issueViewCmd)
issueCmd.AddCommand(issueCreateCmd)
issueCmd.AddCommand(issueCommentCmd)
issueCmd.AddCommand(issueCloseCmd)
issueCmd.AddCommand(issueEditCmd)
issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
issueListCmd.Flags().Bool("json", false, "Output issues as JSON")
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueViewCmd.Flags().Bool("json", false, "Output issue as JSON")
issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue")
issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)")
issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCommentCmd.Flags().StringP("body", "b", "", "Comment body")
issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing")
issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue")
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 {
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)
}
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
})
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
nonPRIssues := make([]*gitea.Issue, 0, len(issues))
for _, issue := range issues {
if issue.PullRequest == nil {
nonPRIssues = append(nonPRIssues, issue)
}
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(nonPRIssues)
}
if len(nonPRIssues) == 0 {
fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
for _, issue := range nonPRIssues {
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
}
_ = w.Flush()
return nil
}
func runIssueView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
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
}
issue, _, err := client.GetIssue(owner, name, issueNumber)
if err != nil {
return fmt.Errorf("failed to get issue: %w", err)
}
var comments []*gitea.Comment
comments, _, err = client.ListIssueComments(owner, name, issueNumber, gitea.ListIssueCommentOptions{})
if err != nil {
comments = nil
}
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"`
}{
Issue: issue,
Comments: comments,
}
return writeJSON(payload)
}
fmt.Printf("Issue #%d\n", issue.Index)
fmt.Printf("Title: %s\n", issue.Title)
fmt.Printf("State: %s\n", issue.State)
fmt.Printf("Author: %s\n", issue.Poster.UserName)
fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05"))
if issue.Body != "" {
fmt.Printf("\n%s\n", issue.Body)
}
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",
comment.Poster.FullName,
comment.Poster.UserName,
comment.Created.Format("2006-01-02 15:04:05"),
comment.Body)
}
}
return nil
}
func runIssueCreate(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
labelNames, _ := cmd.Flags().GetStringSlice("label")
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
if title == "" {
return fmt.Errorf("title is required")
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
var labelIDs []int64
if len(labelNames) > 0 {
labelIDs, err = resolveLabelIDs(client, owner, name, labelNames)
if err != nil {
return err
}
}
issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
Title: title,
Body: body,
Labels: labelIDs,
})
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
fmt.Printf("Issue created: #%d\n", issue.Index)
fmt.Printf("View at: %s\n", issue.HTMLURL)
return nil
}
func runIssueComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
body, _ := cmd.Flags().GetString("body")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
if body == "" {
return fmt.Errorf("comment body is required")
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: body,
})
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
fmt.Printf("Comment added to issue #%d\n", issueNumber)
fmt.Printf("View at: %s\n", comment.HTMLURL)
return nil
}
func runIssueClose(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
commentBody, _ := cmd.Flags().GetString("comment")
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
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
}
if commentBody != "" {
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: commentBody,
})
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
}
stateClosed := gitea.StateClosed
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateClosed,
})
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
fmt.Printf("Issue #%d closed\n", issueNumber)
return nil
}
func runIssueEdit(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
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 {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
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()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
editOpt := gitea.EditIssueOption{}
if title != "" {
editOpt.Title = title
}
if body != "" {
editOpt.Body = &body
}
if stateStr != "" {
switch strings.ToLower(stateStr) {
case "open":
stateOpen := gitea.StateOpen
editOpt.State = &stateOpen
case "closed":
stateClosed := gitea.StateClosed
editOpt.State = &stateClosed
default:
return fmt.Errorf("invalid state: %s (must be 'open' or 'closed')", stateStr)
}
}
if title != "" || body != "" || stateStr != "" {
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
if err != nil {
return fmt.Errorf("failed to edit issue: %w", err)
}
}
if len(addLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
if err != nil {
return err
}
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
Labels: labelIDs,
})
if err != nil {
return fmt.Errorf("failed to add labels: %w", err)
}
}
if len(removeLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
if err != nil {
return err
}
for _, labelID := range labelIDs {
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
if err != nil {
return fmt.Errorf("failed to remove label %d: %w", labelID, err)
}
}
}
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
}
func resolveLabelIDs(client *api.Client, owner, name string, labelNames []string) ([]int64, error) {
if len(labelNames) == 0 {
return nil, nil
}
labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list labels: %w", err)
}
labelNameToID := make(map[string]int64)
for _, label := range labels {
labelNameToID[label.Name] = label.ID
}
var labelIDs []int64
var missingLabels []string
for _, labelName := range labelNames {
labelID, exists := labelNameToID[labelName]
if !exists {
missingLabels = append(missingLabels, labelName)
continue
}
labelIDs = append(labelIDs, labelID)
}
if len(missingLabels) > 0 {
return nil, fmt.Errorf("labels not found: %s", strings.Join(missingLabels, ", "))
}
return labelIDs, nil
}