From 27f5c87685774afc8fb8c91b08607f2c39f7cec0 Mon Sep 17 00:00:00 2001 From: Zhang-986 <3225483474@qq.com> Date: Mon, 18 May 2026 15:17:34 +0800 Subject: [PATCH] feat: add task +get shortcut for single task detail retrieval --- shortcuts/task/shortcuts.go | 1 + shortcuts/task/task_get.go | 100 +++++++++++++++++++++ shortcuts/task/task_get_test.go | 82 +++++++++++++++++ tests/cli_e2e/task/task_get_dryrun_test.go | 81 +++++++++++++++++ 4 files changed, 264 insertions(+) create mode 100644 shortcuts/task/task_get.go create mode 100644 shortcuts/task/task_get_test.go create mode 100644 tests/cli_e2e/task/task_get_dryrun_test.go diff --git a/shortcuts/task/shortcuts.go b/shortcuts/task/shortcuts.go index caae868f87..8c8153a89f 100644 --- a/shortcuts/task/shortcuts.go +++ b/shortcuts/task/shortcuts.go @@ -248,6 +248,7 @@ var CreateTask = common.Shortcut{ func Shortcuts() []common.Shortcut { return []common.Shortcut{ CreateTask, + GetTask, UpdateTask, SetAncestorTask, CommentTask, diff --git a/shortcuts/task/task_get.go b/shortcuts/task/task_get.go new file mode 100644 index 0000000000..1e357feb5c --- /dev/null +++ b/shortcuts/task/task_get.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "fmt" + "io" + "net/url" + "strconv" + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +var GetTask = common.Shortcut{ + Service: "task", + Command: "+get", + Description: "get a single task by id", + Risk: "read", + Scopes: []string{"task:task:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "task-id", Desc: "task id (guid or applink URL)", Required: true}, + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + taskId := url.PathEscape(extractTaskGuid(runtime.Str("task-id"))) + return common.NewDryRunAPI(). + GET("/open-apis/task/v2/tasks/" + taskId). + Params(map[string]interface{}{"user_id_type": "open_id"}) + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + taskId := extractTaskGuid(runtime.Str("task-id")) + + task, err := getTaskDetail(runtime, taskId) + if err != nil { + return err + } + + guid, _ := task["guid"].(string) + summary, _ := task["summary"].(string) + urlVal, _ := task["url"].(string) + urlVal = truncateTaskURL(urlVal) + + outData := map[string]interface{}{ + "guid": guid, + "summary": summary, + "url": urlVal, + } + if description, ok := task["description"].(string); ok { + outData["description"] = description + } + if dueObj, ok := task["due"].(map[string]interface{}); ok { + if tsStr, ok := dueObj["timestamp"].(string); ok { + if ts, pErr := strconv.ParseInt(tsStr, 10, 64); pErr == nil { + outData["due_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339) + } + } + } + if createdAtStr, ok := task["created_at"].(string); ok { + if ts, pErr := strconv.ParseInt(createdAtStr, 10, 64); pErr == nil { + outData["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339) + } + } + if status, ok := task["status"].(string); ok { + outData["status"] = status + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + fmt.Fprintf(w, "📋 Task Detail\n") + if guid != "" { + fmt.Fprintf(w, " ID: %s\n", guid) + } + if summary != "" { + fmt.Fprintf(w, " Summary: %s\n", summary) + } + if desc, ok := task["description"].(string); ok && desc != "" { + fmt.Fprintf(w, " Description: %s\n", desc) + } + if status, ok := task["status"].(string); ok && status != "" { + fmt.Fprintf(w, " Status: %s\n", status) + } + if dueAt, ok := outData["due_at"].(string); ok { + fmt.Fprintf(w, " Due: %s\n", dueAt) + } + if createdAt, ok := outData["created_at"].(string); ok { + fmt.Fprintf(w, " Created: %s\n", createdAt) + } + if urlVal != "" { + fmt.Fprintf(w, " URL: %s\n", urlVal) + } + }) + return nil + }, +} diff --git a/shortcuts/task/task_get_test.go b/shortcuts/task/task_get_test.go new file mode 100644 index 0000000000..ee4ea42a55 --- /dev/null +++ b/shortcuts/task/task_get_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestGetTask(t *testing.T) { + tests := []struct { + name string + taskId string + formatFlag string + expectedOutput []string + }{ + { + name: "pretty format", + taskId: "task-123", + formatFlag: "pretty", + expectedOutput: []string{ + "📋 Task Detail", + "task-123", + "Buy groceries", + }, + }, + { + name: "json format", + taskId: "task-456", + formatFlag: "json", + expectedOutput: []string{ + `"guid": "task-456"`, + `"summary": "Review PR"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/" + tt.taskId, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": tt.taskId, + "summary": map[string]string{"task-123": "Buy groceries", "task-456": "Review PR"}[tt.taskId], + "description": "task description here", + "status": "in_progress", + "created_at": "1775174400000", + "due": map[string]interface{}{ + "timestamp": "1775347200000", + }, + "url": "https://example.com/" + tt.taskId, + }, + }, + }, + }) + + err := runMountedTaskShortcut(t, GetTask, []string{"+get", "--task-id", tt.taskId, "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + outNorm := strings.ReplaceAll(out, `":"`, `": "`) + + for _, expected := range tt.expectedOutput { + if !strings.Contains(outNorm, expected) && !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} diff --git a/tests/cli_e2e/task/task_get_dryrun_test.go b/tests/cli_e2e/task/task_get_dryrun_test.go new file mode 100644 index 0000000000..7e35c4ae92 --- /dev/null +++ b/tests/cli_e2e/task/task_get_dryrun_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestTask_GetDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "task_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantMethod string + wantURL string + wantTaskID string + }{ + { + name: "get task by guid", + args: []string{ + "task", "+get", + "--task-id", "task-guid-123", + "--dry-run", + }, + wantMethod: "GET", + wantURL: "/open-apis/task/v2/tasks/task-guid-123", + wantTaskID: "task-guid-123", + }, + { + name: "get task by applink URL resolves to guid", + args: []string{ + "task", "+get", + "--task-id", "https://applink.feishu.cn/client/todo/task?guid=task-from-url", + "--dry-run", + }, + wantMethod: "GET", + wantURL: "/open-apis/task/v2/tasks/task-from-url", + wantTaskID: "task-from-url", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if count := gjson.Get(out, "api.#").Int(); count != 1 { + t.Fatalf("expected 1 API call, got %d\nstdout:\n%s", count, out) + } + if method := gjson.Get(out, "api.0.method").String(); method != tt.wantMethod { + t.Fatalf("api[0].method = %q, want %q\nstdout:\n%s", method, tt.wantMethod, out) + } + if url := gjson.Get(out, "api.0.url").String(); url != tt.wantURL { + t.Fatalf("api[0].url = %q, want %q\nstdout:\n%s", url, tt.wantURL, out) + } + if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" { + t.Fatalf("api[0].params.user_id_type = %q, want open_id\nstdout:\n%s", got, out) + } + }) + } +}