| Initial import of the rickub CLI as a standalone public project 1a1d430 Olivier Girardot 9h ago | 1 | package cmd |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/spf13/cobra" |
| 9 | ) |
| 10 | |
| 11 | var ( |
| 12 | issueRepo string |
| 13 | issueState string |
| 14 | issueTitle string |
| 15 | issueBody string |
| 16 | issueLabels string |
| 17 | issueMilestone string |
| 18 | issueAssignee string |
| 19 | issueRemove bool |
| 20 | ) |
| 21 | |
| 22 | func init() { |
| 23 | issueCmd := &cobra.Command{ |
| 24 | Use: "issue", |
| 25 | Aliases: []string{"issues"}, |
| 26 | Short: "Work with issues (and labels)", |
| 27 | } |
| 28 | issueCmd.PersistentFlags().StringVarP(&issueRepo, "repo", "R", "", "target repo as owner/repo (default: current git remote)") |
| 29 | |
| 30 | listCmd := &cobra.Command{ |
| 31 | Use: "list", |
| 32 | Short: "List issues", |
| 33 | Args: cobra.NoArgs, |
| 34 | RunE: runIssueList, |
| 35 | } |
| 36 | listCmd.Flags().StringVar(&issueState, "state", "open", "open | closed | all") |
| 37 | addPaging(listCmd) |
| 38 | |
| 39 | viewCmd := &cobra.Command{ |
| 40 | Use: "view <number>", |
| 41 | Short: "Show an issue with its comments", |
| 42 | Args: cobra.ExactArgs(1), |
| 43 | RunE: runIssueView, |
| 44 | } |
| 45 | |
| 46 | createCmd := &cobra.Command{ |
| 47 | Use: "create", |
| 48 | Short: "Open an issue", |
| 49 | Args: cobra.NoArgs, |
| 50 | RunE: runIssueCreate, |
| 51 | } |
| 52 | createCmd.Flags().StringVarP(&issueTitle, "title", "t", "", "title (required)") |
| 53 | createCmd.Flags().StringVarP(&issueBody, "body", "b", "", "description body ('-' reads stdin)") |
| 54 | |
| 55 | closeCmd := &cobra.Command{ |
| 56 | Use: "close <number>", |
| 57 | Short: "Close an issue", |
| 58 | Args: cobra.ExactArgs(1), |
| 59 | RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "closed") }, |
| 60 | } |
| 61 | |
| 62 | reopenCmd := &cobra.Command{ |
| 63 | Use: "reopen <number>", |
| 64 | Short: "Reopen a closed issue", |
| 65 | Args: cobra.ExactArgs(1), |
| 66 | RunE: func(cmd *cobra.Command, args []string) error { return runIssueState(cmd, args, "open") }, |
| 67 | } |
| 68 | |
| 69 | commentCmd := &cobra.Command{ |
| 70 | Use: "comment <number>", |
| 71 | Short: "Comment on an issue", |
| 72 | Args: cobra.ExactArgs(1), |
| 73 | RunE: runIssueComment, |
| 74 | } |
| 75 | commentCmd.Flags().StringVarP(&issueBody, "body", "b", "", "comment body ('-' reads stdin)") |
| 76 | |
| 77 | labelCmd := &cobra.Command{ |
| 78 | Use: "label <number>", |
| 79 | Short: "Replace an issue's labels (by name, comma-separated; --clear empties)", |
| 80 | Args: cobra.ExactArgs(1), |
| 81 | RunE: runIssueLabel, |
| 82 | } |
| 83 | labelCmd.Flags().StringVar(&issueLabels, "labels", "", "comma-separated label names") |
| 84 | labelCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove all labels") |
| 85 | |
| 86 | milestoneCmd := &cobra.Command{ |
| 87 | Use: "milestone <number>", |
| 88 | Short: "Assign an issue to a milestone (by title or id; --clear removes)", |
| 89 | Args: cobra.ExactArgs(1), |
| 90 | RunE: runIssueMilestone, |
| 91 | } |
| 92 | milestoneCmd.Flags().StringVar(&issueMilestone, "milestone", "", "milestone title or id") |
| 93 | milestoneCmd.Flags().BoolVar(&issueRemove, "clear", false, "remove the milestone") |
| 94 | |
| 95 | assignCmd := &cobra.Command{ |
| 96 | Use: "assign <number>", |
| 97 | Short: "Add or remove an assignee (--remove)", |
| 98 | Args: cobra.ExactArgs(1), |
| 99 | RunE: runIssueAssign, |
| 100 | } |
| 101 | assignCmd.Flags().StringVar(&issueAssignee, "user", "", "user handle") |
| 102 | assignCmd.Flags().BoolVar(&issueRemove, "remove", false, "remove instead of add") |
| 103 | |
| 104 | labelsCmd := &cobra.Command{ |
| 105 | Use: "labels", |
| 106 | Short: "List the repo's labels", |
| 107 | Args: cobra.NoArgs, |
| 108 | RunE: runIssueLabelsList, |
| 109 | } |
| 110 | |
| 111 | issueCmd.AddCommand(listCmd, viewCmd, createCmd, closeCmd, reopenCmd, commentCmd, labelCmd, milestoneCmd, assignCmd, labelsCmd) |
| 112 | rootCmd.AddCommand(issueCmd) |
| 113 | } |
| 114 | |
| 115 | // issueNumber parses a positive issue number argument. |
| 116 | func issueNumber(arg string) (int, error) { |
| 117 | n, err := strconv.Atoi(arg) |
| 118 | if err != nil || n <= 0 { |
| 119 | return 0, fmt.Errorf("invalid issue number %q", arg) |
| 120 | } |
| 121 | return n, nil |
| 122 | } |
| 123 | |
| 124 | // labelNames splits a comma-separated --labels value. |
| 125 | func labelNames(s string) []string { |
| 126 | if strings.TrimSpace(s) == "" { |
| 127 | return []string{} |
| 128 | } |
| 129 | parts := strings.Split(s, ",") |
| 130 | out := make([]string, 0, len(parts)) |
| 131 | for _, p := range parts { |
| 132 | if name := strings.TrimSpace(p); name != "" { |
| 133 | out = append(out, name) |
| 134 | } |
| 135 | } |
| 136 | return out |
| 137 | } |
| 138 | |
| 139 | func runIssueList(cmd *cobra.Command, _ []string) error { |
| 140 | client, err := newClient() |
| 141 | if err != nil { |
| 142 | return err |
| 143 | } |
| 144 | owner, repo, err := resolveRepo(issueRepo) |
| 145 | if err != nil { |
| 146 | return err |
| 147 | } |
| 148 | page, err := client.ListIssues(cmd.Context(), owner, repo, issueState, pageFlag, perPageFlag) |
| 149 | if err != nil { |
| 150 | return err |
| 151 | } |
| 152 | if flagJSON { |
| 153 | return printJSON(cmd.OutOrStdout(), page) |
| 154 | } |
| 155 | if len(page.Items) == 0 { |
| 156 | fmt.Fprintln(cmd.OutOrStdout(), "No issues.") |
| 157 | return nil |
| 158 | } |
| 159 | tw := newTabw(cmd.OutOrStdout()) |
| 160 | fmt.Fprintln(tw, "#\tSTATE\tLABELS\tMILESTONE\tTITLE") |
| 161 | for _, i := range page.Items { |
| 162 | var labels, milestone string |
| 163 | if len(i.Labels) > 0 { |
| 164 | names := make([]string, len(i.Labels)) |
| 165 | for j, l := range i.Labels { |
| 166 | names[j] = l.Name |
| 167 | } |
| 168 | labels = strings.Join(names, ",") |
| 169 | } |
| 170 | if i.Milestone != nil { |
| 171 | milestone = i.Milestone.Title |
| 172 | } |
| 173 | fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\n", i.Number, i.State, dash(labels), dash(milestone), i.Title) |
| 174 | } |
| 175 | tw.Flush() |
| 176 | printPageFooter(cmd, page.Page) |
| 177 | return nil |
| 178 | } |
| 179 | |
| 180 | func runIssueView(cmd *cobra.Command, args []string) error { |
| 181 | client, err := newClient() |
| 182 | if err != nil { |
| 183 | return err |
| 184 | } |
| 185 | owner, repo, err := resolveRepo(issueRepo) |
| 186 | if err != nil { |
| 187 | return err |
| 188 | } |
| 189 | n, err := issueNumber(args[0]) |
| 190 | if err != nil { |
| 191 | return err |
| 192 | } |
| 193 | i, err := client.GetIssue(cmd.Context(), owner, repo, n) |
| 194 | if err != nil { |
| 195 | return err |
| 196 | } |
| 197 | if flagJSON { |
| 198 | return printJSON(cmd.OutOrStdout(), i) |
| 199 | } |
| 200 | out := cmd.OutOrStdout() |
| 201 | state := i.State |
| 202 | if i.Milestone != nil { |
| 203 | state += " · " + i.Milestone.Title |
| 204 | } |
| 205 | fmt.Fprintf(out, "Issue #%d %s [%s]\n", i.Number, i.Title, state) |
| 206 | fmt.Fprintf(out, "by %s · %s\n", dash(i.Author), humanTime(i.CreatedAt)) |
| 207 | if len(i.Labels) > 0 { |
| 208 | names := make([]string, len(i.Labels)) |
| 209 | for j, l := range i.Labels { |
| 210 | names[j] = l.Name |
| 211 | } |
| 212 | fmt.Fprintf(out, "labels: %s\n", strings.Join(names, ", ")) |
| 213 | } |
| 214 | if len(i.Assignees) > 0 { |
| 215 | handles := make([]string, len(i.Assignees)) |
| 216 | for j, a := range i.Assignees { |
| 217 | handles[j] = a.Handle |
| 218 | } |
| 219 | fmt.Fprintf(out, "assignees: %s\n", strings.Join(handles, ", ")) |
| 220 | } |
| 221 | fmt.Fprintln(out) |
| 222 | if i.Body != "" { |
| 223 | fmt.Fprintln(out, i.Body) |
| 224 | } |
| 225 | for _, c := range i.Comments { |
| 226 | fmt.Fprintf(out, "\n--- %s (%s) ---\n%s\n", c.Author, humanTime(c.CreatedAt), c.Body) |
| 227 | } |
| 228 | return nil |
| 229 | } |
| 230 | |
| 231 | // bodyOrStdin resolves a --body flag value, reading stdin when it is "-". |
| 232 | func bodyOrStdin(cmd *cobra.Command, body string) (string, error) { |
| 233 | if body != "-" { |
| 234 | return body, nil |
| 235 | } |
| 236 | var sb strings.Builder |
| 237 | buf := make([]byte, 32*1024) |
| 238 | in := cmd.InOrStdin() |
| 239 | for { |
| 240 | n, err := in.Read(buf) |
| 241 | sb.Write(buf[:n]) |
| 242 | if err != nil { |
| 243 | break |
| 244 | } |
| 245 | } |
| 246 | return strings.TrimRight(sb.String(), "\n"), nil |
| 247 | } |
| 248 | |
| 249 | func runIssueCreate(cmd *cobra.Command, _ []string) error { |
| 250 | if issueTitle == "" { |
| 251 | return fmt.Errorf("--title is required") |
| 252 | } |
| 253 | body, err := bodyOrStdin(cmd, issueBody) |
| 254 | if err != nil { |
| 255 | return err |
| 256 | } |
| 257 | client, err := newClient() |
| 258 | if err != nil { |
| 259 | return err |
| 260 | } |
| 261 | owner, repo, err := resolveRepo(issueRepo) |
| 262 | if err != nil { |
| 263 | return err |
| 264 | } |
| 265 | i, err := client.CreateIssue(cmd.Context(), owner, repo, issueTitle, body) |
| 266 | if err != nil { |
| 267 | return err |
| 268 | } |
| 269 | if flagJSON { |
| 270 | return printJSON(cmd.OutOrStdout(), i) |
| 271 | } |
| 272 | fmt.Fprintf(cmd.OutOrStdout(), "Opened issue #%d: %s\n", i.Number, i.Title) |
| 273 | return nil |
| 274 | } |
| 275 | |
| 276 | func runIssueState(cmd *cobra.Command, args []string, state string) error { |
| 277 | client, err := newClient() |
| 278 | if err != nil { |
| 279 | return err |
| 280 | } |
| 281 | owner, repo, err := resolveRepo(issueRepo) |
| 282 | if err != nil { |
| 283 | return err |
| 284 | } |
| 285 | n, err := issueNumber(args[0]) |
| 286 | if err != nil { |
| 287 | return err |
| 288 | } |
| 289 | i, err := client.SetIssueState(cmd.Context(), owner, repo, n, state) |
| 290 | if err != nil { |
| 291 | return err |
| 292 | } |
| 293 | if flagJSON { |
| 294 | return printJSON(cmd.OutOrStdout(), i) |
| 295 | } |
| 296 | fmt.Fprintf(cmd.OutOrStdout(), "Issue #%d is now %s.\n", i.Number, i.State) |
| 297 | return nil |
| 298 | } |
| 299 | |
| 300 | func runIssueComment(cmd *cobra.Command, args []string) error { |
| 301 | body, err := bodyOrStdin(cmd, issueBody) |
| 302 | if err != nil { |
| 303 | return err |
| 304 | } |
| 305 | if strings.TrimSpace(body) == "" { |
| 306 | return fmt.Errorf("--body is required") |
| 307 | } |
| 308 | client, err := newClient() |
| 309 | if err != nil { |
| 310 | return err |
| 311 | } |
| 312 | owner, repo, err := resolveRepo(issueRepo) |
| 313 | if err != nil { |
| 314 | return err |
| 315 | } |
| 316 | n, err := issueNumber(args[0]) |
| 317 | if err != nil { |
| 318 | return err |
| 319 | } |
| 320 | c, err := client.CommentIssue(cmd.Context(), owner, repo, n, body) |
| 321 | if err != nil { |
| 322 | return err |
| 323 | } |
| 324 | if flagJSON { |
| 325 | return printJSON(cmd.OutOrStdout(), c) |
| 326 | } |
| 327 | fmt.Fprintf(cmd.OutOrStdout(), "Commented on issue #%d.\n", n) |
| 328 | return nil |
| 329 | } |
| 330 | |
| 331 | func runIssueLabel(cmd *cobra.Command, args []string) error { |
| 332 | client, err := newClient() |
| 333 | if err != nil { |
| 334 | return err |
| 335 | } |
| 336 | owner, repo, err := resolveRepo(issueRepo) |
| 337 | if err != nil { |
| 338 | return err |
| 339 | } |
| 340 | n, err := issueNumber(args[0]) |
| 341 | if err != nil { |
| 342 | return err |
| 343 | } |
| 344 | names := []string{} |
| 345 | if !issueRemove { |
| 346 | names = labelNames(issueLabels) |
| 347 | } |
| 348 | if err := client.SetIssueLabels(cmd.Context(), owner, repo, n, names); err != nil { |
| 349 | return err |
| 350 | } |
| 351 | if len(names) == 0 { |
| 352 | fmt.Fprintf(cmd.OutOrStdout(), "Cleared labels on issue #%d.\n", n) |
| 353 | } else { |
| 354 | fmt.Fprintf(cmd.OutOrStdout(), "Set labels on issue #%d: %s\n", n, strings.Join(names, ", ")) |
| 355 | } |
| 356 | return nil |
| 357 | } |
| 358 | |
| 359 | func runIssueMilestone(cmd *cobra.Command, args []string) error { |
| 360 | client, err := newClient() |
| 361 | if err != nil { |
| 362 | return err |
| 363 | } |
| 364 | owner, repo, err := resolveRepo(issueRepo) |
| 365 | if err != nil { |
| 366 | return err |
| 367 | } |
| 368 | n, err := issueNumber(args[0]) |
| 369 | if err != nil { |
| 370 | return err |
| 371 | } |
| 372 | ref := issueMilestone |
| 373 | if issueRemove { |
| 374 | ref = "" |
| 375 | } |
| 376 | if err := client.SetIssueMilestone(cmd.Context(), owner, repo, n, ref); err != nil { |
| 377 | return err |
| 378 | } |
| 379 | if ref == "" { |
| 380 | fmt.Fprintf(cmd.OutOrStdout(), "Removed the milestone from issue #%d.\n", n) |
| 381 | } else { |
| 382 | fmt.Fprintf(cmd.OutOrStdout(), "Set issue #%d's milestone to %s.\n", n, ref) |
| 383 | } |
| 384 | return nil |
| 385 | } |
| 386 | |
| 387 | func runIssueAssign(cmd *cobra.Command, args []string) error { |
| 388 | if issueAssignee == "" { |
| 389 | return fmt.Errorf("--user is required") |
| 390 | } |
| 391 | client, err := newClient() |
| 392 | if err != nil { |
| 393 | return err |
| 394 | } |
| 395 | owner, repo, err := resolveRepo(issueRepo) |
| 396 | if err != nil { |
| 397 | return err |
| 398 | } |
| 399 | n, err := issueNumber(args[0]) |
| 400 | if err != nil { |
| 401 | return err |
| 402 | } |
| 403 | op := "add" |
| 404 | if issueRemove { |
| 405 | op = "remove" |
| 406 | } |
| 407 | if err := client.SetIssueAssignee(cmd.Context(), owner, repo, n, op, issueAssignee); err != nil { |
| 408 | return err |
| 409 | } |
| 410 | verb := "Assigned" |
| 411 | if issueRemove { |
| 412 | verb = "Unassigned" |
| 413 | } |
| 414 | fmt.Fprintf(cmd.OutOrStdout(), "%s %s on issue #%d.\n", verb, issueAssignee, n) |
| 415 | return nil |
| 416 | } |
| 417 | |
| 418 | func runIssueLabelsList(cmd *cobra.Command, _ []string) error { |
| 419 | client, err := newClient() |
| 420 | if err != nil { |
| 421 | return err |
| 422 | } |
| 423 | owner, repo, err := resolveRepo(issueRepo) |
| 424 | if err != nil { |
| 425 | return err |
| 426 | } |
| 427 | labels, err := client.ListLabels(cmd.Context(), owner, repo) |
| 428 | if err != nil { |
| 429 | return err |
| 430 | } |
| 431 | if flagJSON { |
| 432 | return printJSON(cmd.OutOrStdout(), labels) |
| 433 | } |
| 434 | if len(labels) == 0 { |
| 435 | fmt.Fprintln(cmd.OutOrStdout(), "No labels.") |
| 436 | return nil |
| 437 | } |
| 438 | tw := newTabw(cmd.OutOrStdout()) |
| 439 | fmt.Fprintln(tw, "NAME\tCOLOR") |
| 440 | for _, l := range labels { |
| 441 | fmt.Fprintf(tw, "%s\t#%s\n", l.Name, l.Color) |
| 442 | } |
| 443 | tw.Flush() |
| 444 | return nil |
| 445 | } |