task.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615
  1. package cmd
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "github.com/AlecAivazis/survey/v2"
  8. "github.com/olekukonko/tablewriter"
  9. "github.com/urfave/cli/v3"
  10. "gogs.dmsc.dev/arp/arp_cli/client"
  11. )
  12. // TaskCommand returns the task command
  13. func TaskCommand() *cli.Command {
  14. return &cli.Command{
  15. Name: "task",
  16. Usage: "Manage tasks",
  17. Description: `Manage ARP tasks. Tasks are work items that can be assigned to users.
  18. Tasks have statuses, priorities, and due dates. Use this command to
  19. create, list, update, and delete tasks. You can also watch for real-time task updates.`,
  20. Commands: []*cli.Command{
  21. {
  22. Name: "list",
  23. Aliases: []string{"ls"},
  24. Usage: "List all tasks",
  25. Flags: []cli.Flag{
  26. &cli.BoolFlag{
  27. Name: "json",
  28. Aliases: []string{"j"},
  29. Usage: "Output as JSON",
  30. },
  31. },
  32. Action: taskList,
  33. },
  34. {
  35. Name: "get",
  36. Usage: "Get a task by ID",
  37. Flags: []cli.Flag{
  38. &cli.StringFlag{
  39. Name: "id",
  40. Aliases: []string{"i"},
  41. Usage: "Task ID",
  42. Required: true,
  43. },
  44. &cli.BoolFlag{
  45. Name: "json",
  46. Aliases: []string{"j"},
  47. Usage: "Output as JSON",
  48. },
  49. },
  50. Action: taskGet,
  51. },
  52. {
  53. Name: "create",
  54. Usage: "Create a new task",
  55. Action: taskCreate,
  56. Flags: []cli.Flag{
  57. &cli.StringFlag{
  58. Name: "title",
  59. Aliases: []string{"t"},
  60. Usage: "Task title",
  61. },
  62. &cli.StringFlag{
  63. Name: "content",
  64. Aliases: []string{"c"},
  65. Usage: "Task content",
  66. },
  67. &cli.StringFlag{
  68. Name: "created-by",
  69. Aliases: []string{"b"},
  70. Usage: "Creator user ID",
  71. },
  72. &cli.StringFlag{
  73. Name: "assignee",
  74. Aliases: []string{"a"},
  75. Usage: "Assignee user ID",
  76. },
  77. &cli.StringFlag{
  78. Name: "status",
  79. Aliases: []string{"s"},
  80. Usage: "Status ID",
  81. },
  82. &cli.StringFlag{
  83. Name: "due-date",
  84. Aliases: []string{"d"},
  85. Usage: "Due date",
  86. },
  87. &cli.StringFlag{
  88. Name: "priority",
  89. Aliases: []string{"p"},
  90. Usage: "Priority (low, medium, high)",
  91. },
  92. },
  93. },
  94. {
  95. Name: "update",
  96. Usage: "Update a task",
  97. Action: taskUpdate,
  98. Flags: []cli.Flag{
  99. &cli.StringFlag{
  100. Name: "id",
  101. Aliases: []string{"i"},
  102. Usage: "Task ID",
  103. Required: true,
  104. },
  105. &cli.StringFlag{
  106. Name: "title",
  107. Aliases: []string{"t"},
  108. Usage: "Task title",
  109. },
  110. &cli.StringFlag{
  111. Name: "content",
  112. Aliases: []string{"c"},
  113. Usage: "Task content",
  114. },
  115. &cli.StringFlag{
  116. Name: "assignee",
  117. Aliases: []string{"a"},
  118. Usage: "Assignee user ID",
  119. },
  120. &cli.StringFlag{
  121. Name: "status",
  122. Aliases: []string{"s"},
  123. Usage: "Status ID",
  124. },
  125. &cli.StringFlag{
  126. Name: "due-date",
  127. Aliases: []string{"d"},
  128. Usage: "Due date",
  129. },
  130. &cli.StringFlag{
  131. Name: "priority",
  132. Aliases: []string{"p"},
  133. Usage: "Priority",
  134. },
  135. },
  136. },
  137. {
  138. Name: "delete",
  139. Usage: "Delete a task",
  140. Action: taskDelete,
  141. Flags: []cli.Flag{
  142. &cli.StringFlag{
  143. Name: "id",
  144. Aliases: []string{"i"},
  145. Usage: "Task ID",
  146. Required: true,
  147. },
  148. &cli.BoolFlag{
  149. Name: "yes",
  150. Aliases: []string{"y"},
  151. Usage: "Skip confirmation",
  152. },
  153. },
  154. },
  155. {
  156. Name: "watch",
  157. Usage: "Watch for real-time task updates",
  158. Action: taskWatch,
  159. Flags: []cli.Flag{
  160. &cli.StringFlag{
  161. Name: "event",
  162. Aliases: []string{"e"},
  163. Usage: "Event type (created, updated, deleted, all)",
  164. Value: "all",
  165. },
  166. },
  167. },
  168. },
  169. }
  170. }
  171. type Task struct {
  172. ID string `json:"id"`
  173. Title string `json:"title"`
  174. Content string `json:"content"`
  175. CreatedByID string `json:"createdById"`
  176. CreatedBy *User `json:"createdBy"`
  177. UpdatedByID string `json:"updatedById"`
  178. UpdatedBy *User `json:"updatedBy"`
  179. AssigneeID *string `json:"assigneeId"`
  180. Assignee *User `json:"assignee"`
  181. StatusID *string `json:"statusId"`
  182. Status *TaskStatus `json:"status"`
  183. DueDate *string `json:"dueDate"`
  184. Priority string `json:"priority"`
  185. CreatedAt string `json:"createdAt"`
  186. UpdatedAt string `json:"updatedAt"`
  187. }
  188. type TaskStatus struct {
  189. ID string `json:"id"`
  190. Code string `json:"code"`
  191. Label string `json:"label"`
  192. CreatedAt string `json:"createdAt"`
  193. UpdatedAt string `json:"updatedAt"`
  194. }
  195. func taskList(ctx context.Context, cmd *cli.Command) error {
  196. c, cfg, err := GetClient(ctx, cmd)
  197. if err != nil {
  198. return err
  199. }
  200. if err := RequireAuth(cfg); err != nil {
  201. return err
  202. }
  203. query := "query Tasks { tasks { id title priority createdBy { id email } assignee { id email } status { id code label } dueDate createdAt updatedAt } }"
  204. resp, err := c.Query(query, nil)
  205. if err != nil {
  206. return err
  207. }
  208. var result struct {
  209. Tasks []Task `json:"tasks"`
  210. }
  211. if err := json.Unmarshal(resp.Data, &result); err != nil {
  212. return err
  213. }
  214. if cmd.Bool("json") {
  215. enc := json.NewEncoder(os.Stdout)
  216. enc.SetIndent("", " ")
  217. return enc.Encode(result.Tasks)
  218. }
  219. if len(result.Tasks) == 0 {
  220. fmt.Println("No tasks found.")
  221. return nil
  222. }
  223. table := tablewriter.NewWriter(os.Stdout)
  224. table.Header([]string{"ID", "Title", "Priority", "Status", "Assignee", "Due Date"})
  225. for _, t := range result.Tasks {
  226. status := ""
  227. if t.Status != nil {
  228. status = t.Status.Label
  229. }
  230. assignee := ""
  231. if t.Assignee != nil {
  232. assignee = t.Assignee.Email
  233. }
  234. dueDate := ""
  235. if t.DueDate != nil {
  236. dueDate = *t.DueDate
  237. }
  238. table.Append([]string{t.ID, t.Title, t.Priority, status, assignee, dueDate})
  239. }
  240. table.Render()
  241. return nil
  242. }
  243. func taskGet(ctx context.Context, cmd *cli.Command) error {
  244. c, cfg, err := GetClient(ctx, cmd)
  245. if err != nil {
  246. return err
  247. }
  248. if err := RequireAuth(cfg); err != nil {
  249. return err
  250. }
  251. id := cmd.String("id")
  252. query := "query Task($id: ID!) { task(id: $id) { id title content createdBy { id email } assignee { id email } status { id code label } dueDate priority createdAt updatedAt } }"
  253. resp, err := c.Query(query, map[string]interface{}{"id": id})
  254. if err != nil {
  255. return err
  256. }
  257. var result struct {
  258. Task *Task `json:"task"`
  259. }
  260. if err := json.Unmarshal(resp.Data, &result); err != nil {
  261. return err
  262. }
  263. if result.Task == nil {
  264. return fmt.Errorf("task not found")
  265. }
  266. if cmd.Bool("json") {
  267. enc := json.NewEncoder(os.Stdout)
  268. enc.SetIndent("", " ")
  269. return enc.Encode(result.Task)
  270. }
  271. t := result.Task
  272. fmt.Printf("ID: %s\n", t.ID)
  273. fmt.Printf("Title: %s\n", t.Title)
  274. fmt.Printf("Content: %s\n", t.Content)
  275. if t.CreatedBy != nil {
  276. fmt.Printf("Created By: %s\n", t.CreatedBy.Email)
  277. }
  278. if t.Assignee != nil {
  279. fmt.Printf("Assignee: %s\n", t.Assignee.Email)
  280. }
  281. if t.Status != nil {
  282. fmt.Printf("Status: %s (%s)\n", t.Status.Label, t.Status.Code)
  283. }
  284. if t.DueDate != nil {
  285. fmt.Printf("Due Date: %s\n", *t.DueDate)
  286. }
  287. fmt.Printf("Priority: %s\n", t.Priority)
  288. fmt.Printf("Created At: %s\n", t.CreatedAt)
  289. fmt.Printf("Updated At: %s\n", t.UpdatedAt)
  290. return nil
  291. }
  292. func taskCreate(ctx context.Context, cmd *cli.Command) error {
  293. c, cfg, err := GetClient(ctx, cmd)
  294. if err != nil {
  295. return err
  296. }
  297. if err := RequireAuth(cfg); err != nil {
  298. return err
  299. }
  300. title := cmd.String("title")
  301. content := cmd.String("content")
  302. createdBy := cmd.String("created-by")
  303. assignee := cmd.String("assignee")
  304. status := cmd.String("status")
  305. dueDate := cmd.String("due-date")
  306. priority := cmd.String("priority")
  307. if title == "" {
  308. prompt := &survey.Input{Message: "Title:"}
  309. if err := survey.AskOne(prompt, &title, survey.WithValidator(survey.Required)); err != nil {
  310. return err
  311. }
  312. }
  313. if content == "" {
  314. prompt := &survey.Multiline{Message: "Content:"}
  315. if err := survey.AskOne(prompt, &content, survey.WithValidator(survey.Required)); err != nil {
  316. return err
  317. }
  318. }
  319. if createdBy == "" {
  320. prompt := &survey.Input{Message: "Creator user ID:"}
  321. if err := survey.AskOne(prompt, &createdBy, survey.WithValidator(survey.Required)); err != nil {
  322. return err
  323. }
  324. }
  325. if priority == "" {
  326. prompt := &survey.Select{
  327. Message: "Priority:",
  328. Options: []string{"low", "medium", "high"},
  329. Default: "medium",
  330. }
  331. if err := survey.AskOne(prompt, &priority); err != nil {
  332. return err
  333. }
  334. }
  335. // Prompt for assignee (optional)
  336. if assignee == "" {
  337. prompt := &survey.Input{Message: "Assignee user ID (optional, press Enter to skip):"}
  338. if err := survey.AskOne(prompt, &assignee); err != nil {
  339. return err
  340. }
  341. }
  342. // Prompt for status (optional)
  343. if status == "" {
  344. prompt := &survey.Input{Message: "Status ID (optional, press Enter to skip):"}
  345. if err := survey.AskOne(prompt, &status); err != nil {
  346. return err
  347. }
  348. }
  349. // Prompt for due date (optional)
  350. if dueDate == "" {
  351. prompt := &survey.Input{Message: "Due date (optional, press Enter to skip):"}
  352. if err := survey.AskOne(prompt, &dueDate); err != nil {
  353. return err
  354. }
  355. }
  356. mutation := `mutation CreateTask($input: NewTask!) { createTask(input: $input) { id title priority createdBy { id email } assignee { id email } status { id code label } dueDate createdAt updatedAt } }`
  357. input := map[string]interface{}{
  358. "title": title,
  359. "content": content,
  360. "createdById": createdBy,
  361. "priority": priority,
  362. }
  363. if assignee != "" {
  364. input["assigneeId"] = assignee
  365. }
  366. if status != "" {
  367. input["statusId"] = status
  368. }
  369. if dueDate != "" {
  370. input["dueDate"] = dueDate
  371. }
  372. resp, err := c.Mutation(mutation, map[string]interface{}{"input": input})
  373. if err != nil {
  374. return err
  375. }
  376. var result struct {
  377. CreateTask *Task `json:"createTask"`
  378. }
  379. if err := json.Unmarshal(resp.Data, &result); err != nil {
  380. return err
  381. }
  382. if result.CreateTask == nil {
  383. return fmt.Errorf("failed to create task")
  384. }
  385. fmt.Printf("Task created successfully!\n")
  386. fmt.Printf("ID: %s\n", result.CreateTask.ID)
  387. fmt.Printf("Title: %s\n", result.CreateTask.Title)
  388. return nil
  389. }
  390. func taskUpdate(ctx context.Context, cmd *cli.Command) error {
  391. c, cfg, err := GetClient(ctx, cmd)
  392. if err != nil {
  393. return err
  394. }
  395. if err := RequireAuth(cfg); err != nil {
  396. return err
  397. }
  398. id := cmd.String("id")
  399. title := cmd.String("title")
  400. content := cmd.String("content")
  401. assignee := cmd.String("assignee")
  402. status := cmd.String("status")
  403. dueDate := cmd.String("due-date")
  404. priority := cmd.String("priority")
  405. if title == "" && content == "" && assignee == "" && status == "" && dueDate == "" && priority == "" {
  406. fmt.Println("No updates provided. Use flags to specify what to update.")
  407. return nil
  408. }
  409. input := make(map[string]interface{})
  410. if title != "" {
  411. input["title"] = title
  412. }
  413. if content != "" {
  414. input["content"] = content
  415. }
  416. if assignee != "" {
  417. input["assigneeId"] = assignee
  418. }
  419. if status != "" {
  420. input["statusId"] = status
  421. }
  422. if dueDate != "" {
  423. input["dueDate"] = dueDate
  424. }
  425. if priority != "" {
  426. input["priority"] = priority
  427. }
  428. mutation := `mutation UpdateTask($id: ID!, $input: UpdateTaskInput!) { updateTask(id: $id, input: $input) { id title priority status { id code label } createdAt updatedAt } }`
  429. resp, err := c.Mutation(mutation, map[string]interface{}{"id": id, "input": input})
  430. if err != nil {
  431. return err
  432. }
  433. var result struct {
  434. UpdateTask *Task `json:"updateTask"`
  435. }
  436. if err := json.Unmarshal(resp.Data, &result); err != nil {
  437. return err
  438. }
  439. if result.UpdateTask == nil {
  440. return fmt.Errorf("task not found")
  441. }
  442. fmt.Printf("Task updated successfully!\n")
  443. fmt.Printf("ID: %s\n", result.UpdateTask.ID)
  444. fmt.Printf("Title: %s\n", result.UpdateTask.Title)
  445. return nil
  446. }
  447. func taskDelete(ctx context.Context, cmd *cli.Command) error {
  448. c, cfg, err := GetClient(ctx, cmd)
  449. if err != nil {
  450. return err
  451. }
  452. if err := RequireAuth(cfg); err != nil {
  453. return err
  454. }
  455. id := cmd.String("id")
  456. skipConfirm := cmd.Bool("yes")
  457. if !skipConfirm {
  458. confirm := false
  459. prompt := &survey.Confirm{
  460. Message: fmt.Sprintf("Are you sure you want to delete task %s?", id),
  461. Default: false,
  462. }
  463. if err := survey.AskOne(prompt, &confirm); err != nil {
  464. return err
  465. }
  466. if !confirm {
  467. fmt.Println("Deletion cancelled.")
  468. return nil
  469. }
  470. }
  471. mutation := `mutation DeleteTask($id: ID!) { deleteTask(id: $id) }`
  472. resp, err := c.Mutation(mutation, map[string]interface{}{"id": id})
  473. if err != nil {
  474. return err
  475. }
  476. var result struct {
  477. DeleteTask bool `json:"deleteTask"`
  478. }
  479. if err := json.Unmarshal(resp.Data, &result); err != nil {
  480. return err
  481. }
  482. if result.DeleteTask {
  483. fmt.Printf("Task %s deleted successfully.\n", id)
  484. } else {
  485. fmt.Printf("Failed to delete task %s.\n", id)
  486. }
  487. return nil
  488. }
  489. func taskWatch(ctx context.Context, cmd *cli.Command) error {
  490. _, cfg, err := GetClient(ctx, cmd)
  491. if err != nil {
  492. return err
  493. }
  494. if err := RequireAuth(cfg); err != nil {
  495. return err
  496. }
  497. eventType := cmd.String("event")
  498. wsClient := client.NewWebSocketClient(cfg.ServerURL, cfg.Token)
  499. if err := wsClient.Connect(); err != nil {
  500. return fmt.Errorf("failed to connect: %w", err)
  501. }
  502. defer wsClient.Close()
  503. fmt.Printf("Watching for task events (type: %s)...\n", eventType)
  504. fmt.Println("Press Ctrl+C to stop.")
  505. // GraphQL subscriptions only allow one top-level field per subscription
  506. // When watching "all" events, we need to create separate subscriptions
  507. switch eventType {
  508. case "created":
  509. subscription := "subscription { taskCreated { id title priority status { id code label } createdAt } }"
  510. if err := wsClient.Subscribe("1", subscription, nil); err != nil {
  511. return fmt.Errorf("failed to subscribe: %w", err)
  512. }
  513. case "updated":
  514. subscription := "subscription { taskUpdated { id title priority status { id code label } updatedAt } }"
  515. if err := wsClient.Subscribe("1", subscription, nil); err != nil {
  516. return fmt.Errorf("failed to subscribe: %w", err)
  517. }
  518. case "deleted":
  519. subscription := "subscription { taskDeleted { id title } }"
  520. if err := wsClient.Subscribe("1", subscription, nil); err != nil {
  521. return fmt.Errorf("failed to subscribe: %w", err)
  522. }
  523. default:
  524. // Subscribe to all three event types with separate subscriptions
  525. subscriptions := []struct {
  526. id string
  527. query string
  528. }{
  529. {"1", "subscription { taskCreated { id title priority status { id code label } createdAt } }"},
  530. {"2", "subscription { taskUpdated { id title priority status { id code label } updatedAt } }"},
  531. {"3", "subscription { taskDeleted { id title } }"},
  532. }
  533. for _, sub := range subscriptions {
  534. if err := wsClient.Subscribe(sub.id, sub.query, nil); err != nil {
  535. return fmt.Errorf("failed to subscribe to %s: %w", sub.id, err)
  536. }
  537. }
  538. }
  539. for {
  540. select {
  541. case msg := <-wsClient.Messages():
  542. fmt.Printf("Event: %s\n", string(msg))
  543. case err := <-wsClient.Errors():
  544. fmt.Fprintf(os.Stderr, "Error: %v\n", err)
  545. case <-wsClient.Done():
  546. return nil
  547. case <-ctx.Done():
  548. return nil
  549. }
  550. }
  551. }