package cmd import ( "fmt" "net/http" "strings" "time" "code.gitea.io/sdk/gitea" "forgejo.zerova.net/public/fgj-sid/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/text" "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.", Example: ` # List open issues fgj issue list # List closed issues for a specific repo fgj issue list -s closed -R owner/repo # Output as JSON fgj issue list --json # PRs updated in the last 7 days fgj pr list --since 7d # Issues touched between two dates fgj issue list --since 2026-04-01 --before 2026-04-15`, RunE: runIssueList, } var issueViewCmd = &cobra.Command{ Use: "view ", Short: "View an issue", Long: "Display detailed information about an issue.", Example: ` # View issue #42 fgj issue view 42 # View using URL fgj issue view https://codeberg.org/owner/repo/issues/42 # Open in browser fgj issue view 42 --web # View an issue from a specific repo as JSON fgj issue view 42 -R owner/repo --json`, Args: cobra.ExactArgs(1), RunE: runIssueView, } var issueCreateCmd = &cobra.Command{ Use: "create", Short: "Create an issue", Long: "Create a new issue.", Example: ` # Create an issue with a title fgj issue create -t "Fix login bug" # Create an issue with title, body, and labels fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`, RunE: runIssueCreate, } var issueCommentCmd = &cobra.Command{ Use: "comment ", Short: "Add a comment to an issue", Long: "Add a comment to an existing issue.", Example: ` # Add a comment to issue #42 fgj issue comment 42 -b "This is fixed in the latest release" # Comment on an issue in a specific repo fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`, Args: cobra.ExactArgs(1), RunE: runIssueComment, } var issueCloseCmd = &cobra.Command{ Use: "close ", Short: "Close an issue", Long: "Close an existing issue.", Example: ` # Close issue #42 fgj issue close 42 # Close with a comment fgj issue close 42 -c "Fixed in commit abc1234"`, Args: cobra.ExactArgs(1), RunE: runIssueClose, } var issueReopenCmd = &cobra.Command{ Use: "reopen ", Short: "Reopen an issue", Long: "Reopen a closed issue.", Example: ` # Reopen issue #42 fgj issue reopen 42`, Args: cobra.ExactArgs(1), RunE: runIssueReopen, } var issueDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete an issue", Long: "Delete an issue permanently.", Example: ` # Delete issue #42 fgj issue delete 42 # Delete without confirmation fgj issue delete 42 -y`, Args: cobra.ExactArgs(1), RunE: runIssueDelete, } var issueEditCmd = &cobra.Command{ Use: "edit ", Short: "Edit an issue", Long: "Edit an existing issue's title, body, or state.", Example: ` # Update the title of issue #42 fgj issue edit 42 -t "Updated title" # Reopen a closed issue fgj issue edit 42 -s open # Add and remove labels fgj issue edit 42 --add-label bug --remove-label wontfix # Add a dependency fgj issue edit 42 --add-dependency 10`, 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(issueReopenCmd) issueCmd.AddCommand(issueDeleteCmd) issueCmd.AddCommand(issueEditCmd) issueReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all") issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results") issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username") issueListCmd.Flags().String("author", "", "Filter by author username") issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names") issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter") issueListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)") issueListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)") addJSONFlags(issueListCmd, "Output issues as JSON") issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") addJSONFlags(issueViewCmd, "Output issue as JSON") issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser") 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)") issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their login. Use \"@me\" to self-assign.") issueCreateCmd.Flags().StringP("milestone", "m", "", "Milestone name to associate with the issue") 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") issueDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format") issueDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") 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") limit, _ := cmd.Flags().GetInt("limit") assignee, _ := cmd.Flags().GetString("assignee") author, _ := cmd.Flags().GetString("author") labels, _ := cmd.Flags().GetStringSlice("label") search, _ := cmd.Flags().GetString("search") sinceStr, _ := cmd.Flags().GetString("since") beforeStr, _ := cmd.Flags().GetString("before") var sinceTime, beforeTime time.Time if sinceStr != "" { t, err := parseDateArg(sinceStr) if err != nil { return fmt.Errorf("invalid --since: %w", err) } sinceTime = t } if beforeStr != "" { t, err := parseDateArg(beforeStr) if err != nil { return fmt.Errorf("invalid --before: %w", err) } beforeTime = t } 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(), getCwd()) 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) } ios.StartSpinner("Fetching issues...") issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{ State: stateType, Labels: labels, KeyWord: search, CreatedBy: author, AssignedBy: assignee, Since: sinceTime, Before: beforeTime, ListOptions: gitea.ListOptions{PageSize: limit}, }) ios.StopSpinner() 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 wantJSON(cmd) { return outputJSON(cmd, nonPRIssues) } if len(nonPRIssues) == 0 { fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name) return nil } tp := ios.NewTablePrinter() tp.AddHeader("NUMBER", "TITLE", "STATE") for _, issue := range nonPRIssues { tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State)) } return tp.Render() } func runIssueView(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") issueNumber, err := parseIssueArg(args[0]) 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(), getCwd()) if err != nil { return err } ios.StartSpinner("Fetching issue...") issue, _, err := client.GetIssue(owner, name, issueNumber) if err != nil { ios.StopSpinner() 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 } ios.StopSpinner() if web, _ := cmd.Flags().GetBool("web"); web { return ios.OpenInBrowser(issue.HTMLURL) } if wantJSON(cmd) { payload := struct { Issue *gitea.Issue `json:"issue"` Comments []*gitea.Comment `json:"comments,omitempty"` }{ Issue: issue, Comments: comments, } return outputJSON(cmd, payload) } if err := ios.StartPager(); err != nil { fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err) } defer ios.StopPager() cs := ios.ColorScheme() isTTY := ios.IsStdoutTTY() fmt.Fprintf(ios.Out, "Issue #%d\n", issue.Index) fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(issue.Title)) fmt.Fprintf(ios.Out, "State: %s\n", issue.State) fmt.Fprintf(ios.Out, "Author: %s\n", issue.Poster.UserName) fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(issue.Created, isTTY)) fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(issue.Updated, isTTY)) if issue.Body != "" { fmt.Fprintf(ios.Out, "\n%s\n", issue.Body) } if len(comments) > 0 { fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments)) for _, comment := range comments { fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n", comment.Poster.FullName, comment.Poster.UserName, text.FormatDate(comment.Created, isTTY), 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") assignees, _ := cmd.Flags().GetStringSlice("assignee") milestoneName, _ := cmd.Flags().GetString("milestone") owner, name, err := parseRepo(repo) if err != nil { return err } // Interactive mode: prompt for missing fields when TTY if title == "" && ios.IsStdinTTY() { title, err = promptLine("Title: ") if err != nil { return err } if title == "" { return fmt.Errorf("title is required") } if body == "" { body, _ = promptLine("Body (optional): ") } } else if title == "" { return fmt.Errorf("title is required (use -t flag)") } cfg, err := config.Load() if err != nil { return err } client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd()) if err != nil { return err } var labelIDs []int64 if len(labelNames) > 0 { labelIDs, err = resolveLabelIDs(client, owner, name, labelNames) if err != nil { return err } } // Resolve @me in assignees resolvedAssignees := make([]string, 0, len(assignees)) for _, assignee := range assignees { if assignee == "@me" { user, _, userErr := client.GetMyUserInfo() if userErr != nil { return fmt.Errorf("failed to get current user info: %w", userErr) } resolvedAssignees = append(resolvedAssignees, user.UserName) } else { resolvedAssignees = append(resolvedAssignees, assignee) } } // Resolve milestone name to ID var milestoneID int64 if milestoneName != "" { milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{}) if msErr != nil { return fmt.Errorf("failed to list milestones: %w", msErr) } found := false for _, ms := range milestones { if ms.Title == milestoneName { milestoneID = ms.ID found = true break } } if !found { return fmt.Errorf("milestone not found: %s", milestoneName) } } ios.StartSpinner("Creating issue...") issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{ Title: title, Body: body, Labels: labelIDs, Assignees: resolvedAssignees, Milestone: milestoneID, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create issue: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index) fmt.Fprintf(ios.Out, "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 := parseIssueArg(args[0]) 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(), getCwd()) if err != nil { return err } ios.StartSpinner("Adding comment...") comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ Body: body, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber) fmt.Fprintf(ios.Out, "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 := parseIssueArg(args[0]) 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(), getCwd()) if err != nil { return err } if commentBody != "" { ios.StartSpinner("Adding comment...") _, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{ Body: commentBody, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to create comment: %w", err) } } ios.StartSpinner("Closing issue...") stateClosed := gitea.StateClosed _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ State: &stateClosed, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to close issue: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), 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 := parseIssueArg(args[0]) 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(), getCwd()) 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) } } ios.StartSpinner("Updating issue...") if title != "" || body != "" || stateStr != "" { _, _, err = client.EditIssue(owner, name, issueNumber, editOpt) if err != nil { ios.StopSpinner() return fmt.Errorf("failed to edit issue: %w", err) } } if len(addLabelNames) > 0 { labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames) if err != nil { ios.StopSpinner() return err } _, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{ Labels: labelIDs, }) if err != nil { ios.StopSpinner() return fmt.Errorf("failed to add labels: %w", err) } } if len(removeLabelNames) > 0 { labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames) if err != nil { ios.StopSpinner() return err } for _, labelID := range labelIDs { _, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID) if err != nil { ios.StopSpinner() return fmt.Errorf("failed to remove label %d: %w", labelID, err) } } } ios.StopSpinner() 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.Fprintf(ios.Out, "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.Fprintf(ios.Out, "Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Issue #%d updated\n", cs.SuccessIcon(), issueNumber) return nil } func runIssueDelete(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") yes, _ := cmd.Flags().GetBool("yes") issueNumber, err := parseIssueArg(args[0]) 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(), getCwd()) if err != nil { return err } if !yes { confirmed, confirmErr := ios.ConfirmAction(fmt.Sprintf("Permanently delete issue #%d from %s/%s?", issueNumber, owner, name)) if confirmErr != nil { return confirmErr } if !confirmed { fmt.Fprintln(ios.ErrOut, "Aborted") return nil } } ios.StartSpinner("Deleting issue...") _, err = client.DeleteIssue(owner, name, issueNumber) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to delete issue: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Issue #%d deleted from %s/%s\n", cs.SuccessIcon(), issueNumber, owner, name) return nil } func runIssueReopen(cmd *cobra.Command, args []string) error { repo, _ := cmd.Flags().GetString("repo") issueNumber, err := parseIssueArg(args[0]) 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(), getCwd()) if err != nil { return err } ios.StartSpinner("Reopening issue...") stateOpen := gitea.StateOpen _, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{ State: &stateOpen, }) ios.StopSpinner() if err != nil { return fmt.Errorf("failed to reopen issue: %w", err) } cs := ios.ColorScheme() fmt.Fprintf(ios.Out, "%s Issue #%d reopened\n", cs.SuccessIcon(), 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 }