Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shortcuts/task/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ var CreateTask = common.Shortcut{
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
CreateTask,
GetTask,
UpdateTask,
SetAncestorTask,
CommentTask,
Expand Down
100 changes: 100 additions & 0 deletions shortcuts/task/task_get.go
Original file line number Diff line number Diff line change
@@ -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"})
Comment thread
Zhang-986 marked this conversation as resolved.
},

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)
}
Comment thread
Zhang-986 marked this conversation as resolved.
}
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
},
}
82 changes: 82 additions & 0 deletions shortcuts/task/task_get_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
81 changes: 81 additions & 0 deletions tests/cli_e2e/task/task_get_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -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
}{
Comment thread
Zhang-986 marked this conversation as resolved.
{
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)
}
})
}
}
Loading