package cmd import ( "fmt" "strconv" "strings" "github.com/spf13/cobra" ) var ( issueRepo string issueState string issueTitle string issueBody string issueLabels string issueMilestone string issueAssignee string issueRemove bool ) func init() { issueCmd := &cobra.Command{ Use: "issue", Aliases: []string{"issues"}, Short: "Work with issues (and labels)", } issueCmd.PersistentFlags().StringVarP(&issueRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") listCmd := &cobra.Command{ Use: "list", Short: "List issues", Args: cobra.NoArgs, RunE: runIssueList, } listCmd.Flags().StringVar(&issueState, "state", "open", "open | closed | all") addPaging(listCmd) viewCmd := &cobra.Command{ Use: "view ", Short: "Show an issue with its comments", Args: cobra.ExactArgs(1), RunE: runIssueView, } createCmd := &cobra.Command{ Use: "create", Short: "Open an issue", Args: cobra.NoArgs, RunE: runIssueCreate, } createCmd.Flags().StringVarP(&issueTitle, "title", "t", "", "title (required)") createCmd.Flags().StringVarP(&issueBody, "body", "b", "", "description body ('-' reads stdin)") closeCmd := &cobra.Command{ Use: "close ", Short: "Close an issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "closed") }, } reopenCmd := &cobra.Command{ Use: "reopen ", Short: "Reopen a closed issue", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "open") }, } commentCmd := &cobra.Command{ Use: "comment ", Short: "Comment on an issue", Args: cobra.ExactArgs(1), RunE: runIssueComment, } commentCmd.Flags().StringVarP(&issueBody, "body", "b", "", "comment body ('-' reads stdin)") labelCmd := &cobra.Command{ Use: "label ", Short: "Replace an issue's labels (by name, comma-separated; --clear empties)", Args: cobra.ExactArgs(1), RunE: runIssueLabel, } labelCmd.Flags().StringVar(&issueLabels, "labels", "", "comma-separated label names") labelCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove all labels") milestoneCmd := &cobra.Command{ Use: "milestone ", Short: "Assign an issue to a milestone (by title or id; --clear removes)", Args: cobra.ExactArgs(1), RunE: runIssueMilestone, } milestoneCmd.Flags().StringVar(&issueMilestone, "milestone", "", "milestone title or id") milestoneCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove the milestone") assignCmd := &cobra.Command{ Use: "assign ", Short: "Add or remove an assignee (--remove)", Args: cobra.ExactArgs(1), RunE: runIssueAssign, } assignCmd.Flags().StringVar(&issueAssignee, "user", "", "user handle") assignCmd.Flags().BoolVar(&issueRemove, "remove", false, "remove instead of add") labelsCmd := &cobra.Command{ Use: "labels", Short: "List the repo's labels", Args: cobra.NoArgs, RunE: runIssueLabelsList, } issueCmd.AddCommand(listCmd, viewCmd, createCmd, closeCmd, reopenCmd, commentCmd, labelCmd, milestoneCmd, assignCmd, labelsCmd) rootCmd.AddCommand(issueCmd) } // issueNumber parses a positive issue number argument. func issueNumber(arg string) (int, error) { n, err := strconv.Atoi(arg) if err != nil || n <= 0 { return 0, fmt.Errorf("invalid issue number %q", arg) } return n, nil } // labelNames splits a comma-separated --labels value. func labelNames(s string) []string { if strings.TrimSpace(s) == "" { return []string{} } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if name := strings.TrimSpace(p); name != "" { out = append(out, name) } } return out } func runIssueList(cmd *cobra.Command, _ []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } page, err := client.ListIssues(cmd.Context(), owner, repo, issueState, pageFlag, perPageFlag) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), page) } if len(page.Items) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No issues.") return nil } tw := newTabw(cmd.OutOrStdout()) fmt.Fprintln(tw, "#\tSTATE\tLABELS\tMILESTONE\tTITLE") for _, i := range page.Items { var labels, milestone string if len(i.Labels) > 0 { names := make([]string, len(i.Labels)) for j, l := range i.Labels { names[j] = l.Name } labels = strings.Join(names, ",") } if i.Milestone != nil { milestone = i.Milestone.Title } fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) } tw.Flush() printPageFooter(cmd, page.Page) return nil } func runIssueView(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } n, err := issueNumber(args[0]) if err != nil { return err } i, err := client.GetIssue(cmd.Context(), owner, repo, n) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), i) } out := cmd.OutOrStdout() state := i.State if i.Milestone != nil { state += " · " + i.Milestone.Title } fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, i.Title, state) fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) if len(i.Labels) > 0 { names := make([]string, len(i.Labels)) for j, l := range i.Labels { names[j] = l.Name } fmt.Fprintf(out, "labels: %s\n", strings.Join(names, ", ")) } if len(i.Assignees) > 0 { handles := make([]string, len(i.Assignees)) for j, a := range i.Assignees { handles[j] = a.Handle } fmt.Fprintf(out, "assignees: %s\n", strings.Join(handles, ", ")) } fmt.Fprintln(out) if i.Body != "" { fmt.Fprintln(out, i.Body) } for _, c := range i.Comments { fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", c.Author, humanTime(c.CreatedAt), c.Body) } return nil } // bodyOrStdin resolves a --body flag value, reading stdin when it is "-". func bodyOrStdin(cmd *cobra.Command, body string) (string, error) { if body != "-" { return body, nil } var sb strings.Builder buf := make([]byte, 32*1024) in := cmd.InOrStdin() for { n, err := in.Read(buf) sb.Write(buf[:n]) if err != nil { break } } return strings.TrimRight(sb.String(), "\n"), nil } func runIssueCreate(cmd *cobra.Command, _ []string) error { if issueTitle == "" { return fmt.Errorf("--title is required") } body, err := bodyOrStdin(cmd, issueBody) if err != nil { return err } client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } i, err := client.CreateIssue(cmd.Context(), owner, repo, issueTitle, body) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), i) } fmt.Fprintf(cmd.OutOrStdout(), "Opened issue #%d: %s\n", i.Number, i.Title) return nil } func runIssueState(cmd *cobra.Command, args []string, state string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } n, err := issueNumber(args[0]) if err != nil { return err } i, err := client.SetIssueState(cmd.Context(), owner, repo, n, state) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), i) } fmt.Fprintf(cmd.OutOrStdout(), "Issue #%d is now %s.\n", i.Number, i.State) return nil } func runIssueComment(cmd *cobra.Command, args []string) error { body, err := bodyOrStdin(cmd, issueBody) if err != nil { return err } if strings.TrimSpace(body) == "" { return fmt.Errorf("--body is required") } client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } n, err := issueNumber(args[0]) if err != nil { return err } c, err := client.CommentIssue(cmd.Context(), owner, repo, n, body) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), c) } fmt.Fprintf(cmd.OutOrStdout(), "Commented on issue #%d.\n", n) return nil } func runIssueLabel(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } n, err := issueNumber(args[0]) if err != nil { return err } names := []string{} if !issueRemove { names = labelNames(issueLabels) } if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil { return err } if len(names) == 0 { fmt.Fprintf(cmd.OutOrStdout(), "Cleared labels on issue #%d.\n", n) } else { fmt.Fprintf(cmd.OutOrStdout(), "Set labels on issue #%d: %s\n", n, strings.Join(names, ", ")) } return nil } func runIssueMilestone(cmd *cobra.Command, args []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } n, err := issueNumber(args[0]) if err != nil { return err } ref := issueMilestone if issueRemove { ref = "" } if err := client.SetIssueMilestone(cmd.Context(), owner, repo, n, ref); err != nil { return err } if ref == "" { fmt.Fprintf(cmd.OutOrStdout(), "Removed the milestone from issue #%d.\n", n) } else { fmt.Fprintf(cmd.OutOrStdout(), "Set issue #%d's milestone to %s.\n", n, ref) } return nil } func runIssueAssign(cmd *cobra.Command, args []string) error { if issueAssignee == "" { return fmt.Errorf("--user is required") } client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } n, err := issueNumber(args[0]) if err != nil { return err } op := "add" if issueRemove { op = "remove" } if err := client.SetIssueAssignee(cmd.Context(), owner, repo, n, op, issueAssignee); err != nil { return err } verb := "Assigned" if issueRemove { verb = "Unassigned" } fmt.Fprintf(cmd.OutOrStdout(), "%s %s on issue #%d.\n", verb, issueAssignee, n) return nil } func runIssueLabelsList(cmd *cobra.Command, _ []string) error { client, err := newClient() if err != nil { return err } owner, repo, err := resolveRepo(issueRepo) if err != nil { return err } labels, err := client.ListLabels(cmd.Context(), owner, repo) if err != nil { return err } if flagJSON { return printJSON(cmd.OutOrStdout(), labels) } if len(labels) == 0 { fmt.Fprintln(cmd.OutOrStdout(), "No labels.") return nil } tw := newTabw(cmd.OutOrStdout()) fmt.Fprintln(tw, "NAME\tCOLOR") for _, l := range labels { fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) } tw.Flush() return nil }