package cmd import ( "fmt" "os" "strconv" "strings" "text/tabwriter" "code.gitea.io/sdk/gitea" "codeberg.org/romaintb/fgj/internal/api" "codeberg.org/romaintb/fgj/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 ", 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 ", 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 ", Short: "Close an issue", Long: "Close an existing issue.", Args: cobra.ExactArgs(1), RunE: runIssueClose, } var issueEditCmd = &cobra.Command{ Use: "edit ", 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)") } 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") 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 { return fmt.Errorf("at least one of --title, --body, --state, --add-label, or --remove-label 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) } } } 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 }