chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func inferTaskMemberType(id string) string {
if strings.HasPrefix(strings.TrimSpace(id), "cli_") {
return "app"
}
return "user"
}
func buildTaskMember(id, role string) map[string]interface{} {
return map[string]interface{}{
"id": id,
"role": role,
"type": inferTaskMemberType(id),
}
}
// parseTaskTime converts a flexible time string into the Task API due/start object format.
func parseTaskTime(timeStr string) (map[string]interface{}, error) {
var msTs string
timeStr = strings.TrimSpace(timeStr)
// snapDay aligns to start-of-day or end-of-day based on hint.
snapDay := func(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
if isRelativeTime(timeStr) {
t, err := parseRelativeTime(timeStr)
if err != nil {
return nil, err
}
if strings.HasSuffix(timeStr, "d") || strings.HasSuffix(timeStr, "w") {
msTs = fmt.Sprintf("%d", snapDay(t).Unix()*1000)
} else {
msTs = fmt.Sprintf("%d", t.Unix()*1000)
}
} else {
parsedTs, err := common.ParseTime(timeStr)
if err != nil {
return nil, err
}
var sec int64
fmt.Sscanf(parsedTs, "%d", &sec)
msTs = fmt.Sprintf("%d", sec*1000)
}
// Determine if it's an all-day event based on the input format
isAllDay := false
// YYYY-MM-DD or relative like +2d typically mean all-day
if len(timeStr) == 10 && strings.Count(timeStr, "-") == 2 {
isAllDay = true
} else if strings.HasPrefix(timeStr, "+") && (strings.HasSuffix(timeStr, "d") || strings.HasSuffix(timeStr, "w")) {
isAllDay = true
}
return map[string]interface{}{
"timestamp": msTs,
"is_all_day": isAllDay,
}, nil
}
// extractTasklistGuid extracts the GUID from an applink URL or returns the string if it's already an ID.
func extractTasklistGuid(input string) string {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "http") {
u, err := url.Parse(input)
if err == nil {
guid := u.Query().Get("guid")
if guid != "" {
return guid
}
}
}
return input
}
// extractTaskGuid extracts a task GUID from either a raw GUID or a Feishu task
// applink URL (e.g. ".../client/todo/task?guid=..."). The URL query parameter
// is always named "guid" for both tasks and tasklists, so we delegate to the
// shared parsing logic.
func extractTaskGuid(input string) string {
return extractTasklistGuid(input)
}
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := make(map[string]interface{})
// Handle generic JSON payload if provided
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
}
}
// Explicit flags override generic data
if summary := runtime.Str("summary"); summary != "" {
body["summary"] = summary
}
if desc := runtime.Str("description"); desc != "" {
body["description"] = desc
}
var members []map[string]interface{}
if assignee := runtime.Str("assignee"); assignee != "" {
members = append(members, buildTaskMember(assignee, "assignee"))
}
if follower := runtime.Str("follower"); follower != "" {
members = append(members, buildTaskMember(follower, "follower"))
}
if len(members) > 0 {
body["members"] = members
}
if tasklistId := runtime.Str("tasklist-id"); tasklistId != "" {
guid := extractTasklistGuid(tasklistId)
body["tasklists"] = []map[string]interface{}{
{
"tasklist_guid": guid,
},
}
}
if dueStr := runtime.Str("due"); dueStr != "" {
dueObj, err := parseTaskTime(dueStr)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
}
body["due"] = dueObj
}
if idempotencyKey := runtime.Str("idempotency-key"); idempotencyKey != "" {
body["client_token"] = idempotencyKey
}
summary, _ := body["summary"].(string)
if strings.TrimSpace(summary) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
}
return body, nil
}
var CreateTask = common.Shortcut{
Service: "task",
Command: "+create",
Description: "create a task",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "assignee", Desc: "task assignee id added during create; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "follower", Desc: "task follower id added during create; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "tasklist-id", Desc: "tasklist id or applink URL"},
{Name: "idempotency-key", Desc: "client token for idempotency"},
{Name: "data", Desc: "JSON payload for creating task"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskCreateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskCreateBody(runtime)
if err != nil {
return err
}
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, body)
if err != nil {
return err
}
task, _ := data["task"].(map[string]interface{})
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task created successfully!\n")
fmt.Fprintf(w, "Summary: %s\n", body["summary"])
if guid != "" {
fmt.Fprintf(w, "Task ID: %s\n", guid)
}
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
})
return nil
},
}
// Shortcuts returns all shortcuts for task and tasklist domain.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
CreateTask,
UpdateTask,
SetAncestorTask,
CommentTask,
CompleteTask,
ReopenTask,
AssignTask,
FollowersTask,
ReminderTask,
GetMyTasks,
GetRelatedTasks,
SearchTask,
UploadAttachmentTask,
CreateTasklist,
SearchTasklist,
AddTaskToTasklist,
MembersTasklist,
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"testing"
"github.com/smartystreets/goconvey/convey"
)
func TestShortcutsRegistration(t *testing.T) {
convey.Convey("Shortcuts() returns all commands", t, func() {
list := Shortcuts()
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
})
}
+131
View File
@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
var AssignTask = common.Shortcut{
Service: "task",
Command: "+assign",
Description: "assign or remove task members",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "add", Desc: "comma-separated assignee IDs to add; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "remove", Desc: "comma-separated assignee IDs to remove; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
taskId := url.PathEscape(runtime.Str("task-id"))
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "assignee", "")
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
params := map[string]interface{}{"user_id_type": "open_id"}
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
if err != nil {
return err
}
lastData = data
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildMembersBody(removeStr, "assignee", "")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
if err != nil {
return err
}
lastData = data
}
task, _ := lastData["task"].(map[string]interface{})
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": taskId,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task assignes updated successfully!\n")
fmt.Fprintf(w, "Task ID: %s\n", taskId)
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
if members, ok := task["members"].([]interface{}); ok {
fmt.Fprintf(w, "Current Assignes: %d\n", len(members))
}
})
return nil
},
}
func buildMembersBody(idsStr, role, clientToken string) map[string]interface{} {
ids := strings.Split(idsStr, ",")
var members []map[string]interface{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
members = append(members, buildTaskMember(id, role))
}
body := map[string]interface{}{
"members": members,
}
if clientToken != "" {
body["client_token"] = clientToken
}
return body
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
// TestAssignTask_RequiresAddOrRemove covers the Validate guard: neither --add
// nor --remove yields a typed validation error (exit 2) before any API call.
func TestAssignTask_RequiresAddOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
// TestAssignTask_MalformedResponse covers the Execute parse-response arm: a
// 200 with an unparseable body surfaces a typed internal invalid_response
// error (exit 5).
func TestAssignTask_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_members",
Status: 200,
RawBody: []byte("{not-json"),
})
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--add", "ou_user_1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
// TestAssignTask_MalformedResponse_RemoveArm covers the Execute remove-members
// parse arm: with only --remove set, the add arm is skipped and the
// remove_members POST returns a 200 with an unparseable body, which must
// surface a typed internal invalid_response error (exit 5).
func TestAssignTask_MalformedResponse_RemoveArm(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/remove_members",
Status: 200,
RawBody: []byte("{not-json"),
})
s := AssignTask
args := []string{"+assign", "--task-id", "task-1", "--remove", "ou_user_1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
func TestBuildMembersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildMembersBody("u1, u2 , ", "assignee", "token1")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(body["client_token"], convey.ShouldEqual, "token1")
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
convey.So(members[0]["type"], convey.ShouldEqual, "user")
})
convey.Convey("Build infers app assignee members from cli prefix", t, func() {
body := buildMembersBody("cli_bot_1", "assignee", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 1)
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
convey.So(members[0]["type"], convey.ShouldEqual, "app")
})
convey.Convey("Build infers mixed member types in one list", t, func() {
body := buildMembersBody("ou_user_1, cli_bot_1", "assignee", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(members[0]["type"], convey.ShouldEqual, "user")
convey.So(members[1]["type"], convey.ShouldEqual, "app")
})
}
func TestBuildTaskCreateBodySupportsAssigneeAndFollower(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("summary", "", "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("tasklist-id", "", "")
cmd.Flags().String("idempotency-key", "", "")
cmd.Flags().String("data", "", "")
_ = cmd.Flags().Set("summary", "bot task")
_ = cmd.Flags().Set("assignee", "cli_bot_xxx")
_ = cmd.Flags().Set("follower", "ou_follower_xxx")
runtime := &common.RuntimeContext{Cmd: cmd}
body, err := buildTaskCreateBody(runtime)
if err != nil {
t.Fatalf("buildTaskCreateBody() error = %v", err)
}
members := body["members"].([]map[string]interface{})
if len(members) != 2 {
t.Fatalf("members len = %d, want 2", len(members))
}
if got := members[0]["type"]; got != "app" {
t.Fatalf("member[0] type = %v, want app", got)
}
if got := members[0]["role"]; got != "assignee" {
t.Fatalf("member[0] role = %v, want assignee", got)
}
if got := members[1]["type"]; got != "user" {
t.Fatalf("member[1] type = %v, want user", got)
}
if got := members[1]["role"]; got != "follower" {
t.Fatalf("member[1] role = %v, want follower", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
tests := []struct {
name string
data string
summary string
due string
wantSubstr string
}{
{
name: "invalid JSON data returns validation error",
data: "not-json",
summary: "test",
wantSubstr: "--data must be a valid JSON object",
},
{
name: "missing summary returns validation error",
data: "",
summary: "",
wantSubstr: "task summary is required",
},
{
name: "invalid due time returns validation error",
data: "",
summary: "test task",
due: "not-a-valid-time",
wantSubstr: "failed to parse due time",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("data", tt.data, "")
cmd.Flags().String("summary", tt.summary, "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().String("due", tt.due, "")
cmd.Flags().String("tasklist-id", "", "")
cmd.Flags().String("idempotency-key", "", "")
runtime := &common.RuntimeContext{Cmd: cmd}
_, err := buildTaskCreateBody(runtime)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) returned !ok", err)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), tt.wantSubstr) {
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
}
})
}
}
func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
tests := []struct {
name string
data string
summary string
due string
wantSubstr string
}{
{
name: "invalid JSON data returns validation error",
data: "not-json",
summary: "",
due: "",
wantSubstr: "--data must be a valid JSON object",
},
{
name: "no fields to update returns validation error",
data: "",
summary: "",
due: "",
wantSubstr: "no fields to update",
},
{
name: "invalid due time returns validation error",
data: "",
summary: "",
due: "not-a-valid-time",
wantSubstr: "failed to parse due time",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("data", tt.data, "")
cmd.Flags().String("summary", tt.summary, "")
cmd.Flags().String("description", "", "")
cmd.Flags().String("due", tt.due, "")
runtime := &common.RuntimeContext{Cmd: cmd}
_, err := buildTaskUpdateBody(runtime)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(%T) returned !ok", err)
}
if p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), tt.wantSubstr) {
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
}
})
}
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"github.com/larksuite/cli/shortcuts/common"
)
var CommentTask = common.Shortcut{
Service: "task",
Command: "+comment",
Description: "add a comment to a task",
Risk: "write",
Scopes: []string{"task:comment:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "content", Desc: "comment content", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{
"content": runtime.Str("content"),
"resource_id": runtime.Str("task-id"),
"resource_type": "task",
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/comments").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := map[string]interface{}{
"content": runtime.Str("content"),
"resource_id": runtime.Str("task-id"),
"resource_type": "task",
}
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/comments", params, body)
if err != nil {
return err
}
comment, _ := data["comment"].(map[string]interface{})
id, _ := comment["id"].(string)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"id": id,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Comment added successfully!\n")
if id != "" {
fmt.Fprintf(w, "Comment ID: %s\n", id)
}
})
return nil
},
}
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/larksuite/cli/shortcuts/common"
)
// CompleteTask marks a task as complete and skips the PATCH call if already completed.
var CompleteTask = common.Shortcut{
Service: "task",
Command: "+complete",
Description: "mark a task as complete",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildCompleteBody()
taskId := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
GET("/open-apis/task/v2/tasks/" + taskId).
Desc("get current task status").
Params(map[string]interface{}{"user_id_type": "open_id"}).
PATCH("/open-apis/task/v2/tasks/" + taskId).
Desc("complete task if not completed").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
params := map[string]interface{}{"user_id_type": "open_id"}
var data map[string]interface{}
// 1. Get current task status
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
taskData, _ := getData["task"].(map[string]interface{})
completedAtStr, _ := taskData["completed_at"].(string)
// 2. If already completed, directly return success
if completedAtStr != "" && completedAtStr != "0" {
data = getData
} else {
// 3. Complete the task
body := buildCompleteBody()
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}
}
task, _ := data["task"].(map[string]interface{})
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
summary, _ := task["summary"].(string)
fmt.Fprintf(w, "✅ Task completed successfully!\n")
if guid != "" {
fmt.Fprintf(w, "Task ID: %s\n", guid)
}
if summary != "" {
fmt.Fprintf(w, "Summary: %s\n", summary)
}
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
})
return nil
},
}
func buildCompleteBody() map[string]interface{} {
completedAt := fmt.Sprintf("%d", time.Now().Unix()*1000)
return map[string]interface{}{
"task": map[string]interface{}{
"completed_at": completedAt,
},
"update_fields": []string{"completed_at"},
}
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestCompleteTask(t *testing.T) {
tests := []struct {
name string
taskId string
isCompleted bool
formatFlag string
expectedOutput []string
}{
{
name: "task already completed",
taskId: "task-123",
isCompleted: true,
formatFlag: "pretty",
expectedOutput: []string{
"✅ Task completed successfully!",
"Task ID: task-123",
},
},
{
name: "task not completed",
taskId: "task-456",
isCompleted: false,
formatFlag: "pretty",
expectedOutput: []string{
"✅ Task completed successfully!",
"Task ID: task-456",
},
},
{
name: "task not completed json format",
taskId: "task-789",
isCompleted: false,
formatFlag: "json",
expectedOutput: []string{
`"guid": "task-789"`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
completedAt := "0"
if tt.isCompleted {
completedAt = "1775174400000"
}
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": "Test Task " + tt.taskId,
"completed_at": completedAt,
"url": "https://example.com/" + tt.taskId,
},
},
},
})
if !tt.isCompleted {
reg.Register(&httpmock.Stub{
Method: "PATCH",
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": "Test Task " + tt.taskId,
"completed_at": "1775174400000",
"url": "https://example.com/" + tt.taskId,
},
},
},
})
}
err := runMountedTaskShortcut(t, CompleteTask, []string{"+complete", "--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)
}
}
})
}
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)
// wrapTaskNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving its subtype / code / log_id from the runtime boundary),
// and only wraps a raw, unclassified error as a transport-level network error.
func wrapTaskNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
// taskInputStatError maps a FileIO.Stat/Open error for input file validation
// to a typed validation error:
// - Path validation failures → "unsafe file path: ..."
// - Other errors → readMsg prefix (default "cannot read file")
//
// param names the input flag/path field that failed (for example "--file").
// Pass an optional readMsg to override the non-path-validation message prefix,
// mirroring the shared input-stat helper so call-site context is preserved.
func taskInputStatError(err error, param string, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
if param != "" {
validationErr = validationErr.WithParam(param)
}
return validationErr
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
if param != "" {
validationErr = validationErr.WithParam(param)
}
return validationErr
}
+103
View File
@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
func TestTaskInputStatError(t *testing.T) {
t.Run("nil error returns nil", func(t *testing.T) {
if err := taskInputStatError(nil, "--file"); err != nil {
t.Errorf("taskInputStatError(nil) = %v, want nil", err)
}
})
t.Run("path validation failure maps to unsafe file path", func(t *testing.T) {
err := taskInputStatError(fmt.Errorf("bad: %w", fileio.ErrPathValidation), "--file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(err.Error(), "unsafe file path") {
t.Errorf("message = %q, want 'unsafe file path'", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
t.Run("generic error uses readMsg prefix", func(t *testing.T) {
err := taskInputStatError(errors.New("permission denied"), "--file", "cannot access file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), "cannot access file") {
t.Errorf("message = %q, want 'cannot access file' prefix", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
t.Run("default prefix when no readMsg", func(t *testing.T) {
err := taskInputStatError(errors.New("boom"), "--file")
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("message = %q, want default 'cannot read file'", err.Error())
}
if ve.Param != "--file" {
t.Errorf("param = %q, want --file", ve.Param)
}
})
}
func TestWrapTaskNetworkErr(t *testing.T) {
// wrapTaskNetworkErr is only ever called inside an `if err != nil` guard
// (DoAPIStream failure), mirroring drive's wrapDriveNetworkErr, so it does
// not special-case a nil cause.
t.Run("untyped cause becomes typed network error wrapping the cause", func(t *testing.T) {
cause := errors.New("dial timeout")
err := wrapTaskNetworkErr(cause, "upload failed")
var ne *errs.NetworkError
if !errors.As(err, &ne) {
t.Fatalf("err = %T, want *errs.NetworkError", err)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(err, cause) {
t.Error("expected the original cause to be wrapped (errors.Is)")
}
})
t.Run("already-typed cause is passed through unchanged", func(t *testing.T) {
typed := errs.NewAPIError(errs.SubtypeNotFound, "missing")
err := wrapTaskNetworkErr(typed, "upload failed")
var ae *errs.APIError
if !errors.As(err, &ae) {
t.Fatalf("err = %T, want the original *errs.APIError passed through", err)
}
if ae.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want %q (not re-wrapped as network)", ae.Subtype, errs.SubtypeNotFound)
}
})
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
var FollowersTask = common.Shortcut{
Service: "task",
Command: "+followers",
Description: "manage task followers",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "add", Desc: "comma-separated follower IDs to add; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "remove", Desc: "comma-separated follower IDs to remove; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
taskId := url.PathEscape(runtime.Str("task-id"))
if addStr := runtime.Str("add"); addStr != "" {
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildFollowersBody(removeStr, "")
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
params := map[string]interface{}{"user_id_type": "open_id"}
var lastData map[string]interface{}
if addStr := runtime.Str("add"); addStr != "" {
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
if err != nil {
return err
}
lastData = data
}
if removeStr := runtime.Str("remove"); removeStr != "" {
body := buildFollowersBody(removeStr, "")
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
if err != nil {
return err
}
lastData = data
}
task, _ := lastData["task"].(map[string]interface{})
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": taskId,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task followers updated successfully!\n")
fmt.Fprintf(w, "Task ID: %s\n", taskId)
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
})
return nil
},
}
func buildFollowersBody(idsStr string, clientToken string) map[string]interface{} {
ids := strings.Split(idsStr, ",")
var members []map[string]interface{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
members = append(members, buildTaskMember(id, "follower"))
}
body := map[string]interface{}{
"members": members,
}
if clientToken != "" {
body["client_token"] = clientToken
}
return body
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/smartystreets/goconvey/convey"
)
// TestFollowersTask_RequiresAddOrRemove covers the Validate guard: neither
// --add nor --remove yields a typed validation error (exit 2) before any API
// call.
func TestFollowersTask_RequiresAddOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := FollowersTask
args := []string{"+followers", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
func TestBuildFollowersBody(t *testing.T) {
convey.Convey("Build with ids and token", t, func() {
body := buildFollowersBody("u1, u2 , ", "token1")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(body["client_token"], convey.ShouldEqual, "token1")
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
convey.So(members[0]["type"], convey.ShouldEqual, "user")
})
convey.Convey("Build infers app followers", t, func() {
body := buildFollowersBody("cli_bot_1", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 1)
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
convey.So(members[0]["type"], convey.ShouldEqual, "app")
})
convey.Convey("Build infers mixed follower types in one list", t, func() {
body := buildFollowersBody("ou_user_1, cli_bot_1", "")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
convey.So(members[0]["type"], convey.ShouldEqual, "user")
convey.So(members[1]["type"], convey.ShouldEqual, "app")
})
}
+302
View File
@@ -0,0 +1,302 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// GetMyTasks lists tasks assigned to the current user.
var GetMyTasks = common.Shortcut{
Service: "task",
Command: "+get-my-tasks",
Description: "List tasks assigned to me",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search for tasks by summary (exact match first, then partial match)"},
{Name: "complete", Type: "bool", Desc: "if true, query completed tasks;if false, query incompleted tasks; if not provided, both completed and incompleted tasks are queried."},
{Name: "created_at", Desc: "query tasks created after this time (date/relative/ms)"},
{Name: "due-start", Desc: "query tasks with due date after this time (date/relative/ms)"},
{Name: "due-end", Desc: "query tasks with due date before this time (date/relative/ms)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40 with --page-all)"},
{Name: "page-token", Desc: "start from the specified page token"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
params := map[string]interface{}{
"type": "my_tasks",
"user_id_type": "open_id",
"page_size": 50,
}
if runtime.Cmd.Flags().Changed("complete") {
params["completed"] = runtime.Bool("complete")
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return d.GET("/open-apis/task/v2/tasks").Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime := time.Now()
params := map[string]interface{}{
"type": "my_tasks",
"user_id_type": "open_id",
"page_size": 50,
}
if runtime.Cmd.Flags().Changed("complete") {
if runtime.Bool("complete") {
params["completed"] = "true"
} else {
params["completed"] = "false"
}
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
// parse time flags to ms timestamp if provided
var createdAfterMs, dueStartMs, dueEndMs int64
if createdStr := runtime.Str("created_at"); createdStr != "" {
tStr, err := parseTimeFlagSec(createdStr, "start")
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid created_at: %v", err).WithParam("--created_at")
}
createdAfterMs, _ = strconv.ParseInt(tStr, 10, 64)
createdAfterMs *= 1000 // Convert sec to ms
}
if dueStartStr := runtime.Str("due-start"); dueStartStr != "" {
tStr, err := parseTimeFlagSec(dueStartStr, "start")
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-start: %v", err).WithParam("--due-start")
}
dueStartMs, _ = strconv.ParseInt(tStr, 10, 64)
dueStartMs *= 1000
}
if dueEndStr := runtime.Str("due-end"); dueEndStr != "" {
tStr, err := parseTimeFlagSec(dueEndStr, "end")
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-end: %v", err).WithParam("--due-end")
}
dueEndMs, _ = strconv.ParseInt(tStr, 10, 64)
dueEndMs *= 1000
}
var allItems []interface{}
var lastPageToken string
var lastHasMore bool
pageCount := 0
pageLimit := runtime.Int("page-limit")
if runtime.Bool("page-all") {
pageLimit = 40
}
for {
pageCount++
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks", params, nil)
if err != nil {
return err
}
itemsRaw, _ := data["items"].([]interface{})
allItems = append(allItems, itemsRaw...)
hasMore, _ := data["has_more"].(bool)
lastHasMore = hasMore
lastPageToken, _ = data["page_token"].(string)
if !hasMore || lastPageToken == "" {
break
}
if pageCount >= pageLimit {
break
}
// Set page_token for next iteration
params["page_token"] = lastPageToken
}
var filteredItems []map[string]interface{}
for _, itemRaw := range allItems {
item, ok := itemRaw.(map[string]interface{})
if !ok {
continue
}
// Apply client-side filtering for created_at, due-start, due-end
// because the API might not support these filters natively in GET /v2/tasks
if createdAfterMs > 0 {
createdAtStr, _ := item["created_at"].(string)
createdAtMs, _ := strconv.ParseInt(createdAtStr, 10, 64)
if createdAtMs < createdAfterMs {
continue
}
}
if dueStartMs > 0 || dueEndMs > 0 {
dueObj, _ := item["due"].(map[string]interface{})
if dueObj == nil {
// If due filtering is requested but task has no due date, filter it out
continue
}
dueTimeStr, _ := dueObj["timestamp"].(string)
dueTimeMs, _ := strconv.ParseInt(dueTimeStr, 10, 64)
if dueStartMs > 0 && dueTimeMs < dueStartMs {
continue
}
if dueEndMs > 0 && dueTimeMs > dueEndMs {
continue
}
}
filteredItems = append(filteredItems, item)
}
// Apply query filtering if provided
if query := runtime.Str("query"); query != "" {
var exactMatches []map[string]interface{}
var partialMatches []map[string]interface{}
for _, item := range filteredItems {
summary, _ := item["summary"].(string)
if summary == query {
exactMatches = append(exactMatches, item)
} else if strings.Contains(summary, query) {
partialMatches = append(partialMatches, item)
}
}
if len(exactMatches) > 0 {
filteredItems = exactMatches
} else {
filteredItems = partialMatches
}
}
var outputItems []interface{}
for _, item := range filteredItems {
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
outputItem := map[string]interface{}{
"guid": item["guid"],
"summary": item["summary"],
"url": urlVal,
"completed": completed,
}
if createdAtStr, ok := item["created_at"].(string); ok {
if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil {
outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
}
}
if !completedAt.IsZero() {
outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339)
}
if dueObj, ok := item["due"].(map[string]interface{}); ok {
if tsStr, ok := dueObj["timestamp"].(string); ok {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
outputItem["due_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
}
}
}
outputItems = append(outputItems, outputItem)
}
outData := map[string]interface{}{
"items": outputItems,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(filteredItems) == 0 {
fmt.Fprintln(w, "No tasks found.")
return
}
for i, item := range filteredItems {
guid, _ := item["guid"].(string)
summary, _ := item["summary"].(string)
urlVal, _ := item["url"].(string)
urlVal = truncateTaskURL(urlVal)
completed, completedAt := taskCompletionState(item)
var dueTimeStr string
if dueObj, ok := item["due"].(map[string]interface{}); ok {
if tsStr, ok := dueObj["timestamp"].(string); ok {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
dueTimeStr = time.UnixMilli(ts).Local().Format("2006-01-02 15:04")
}
}
}
var createdDateStr string
if createdStr, ok := item["created_at"].(string); ok {
if ts, err := strconv.ParseInt(createdStr, 10, 64); err == nil {
createdDateStr = time.UnixMilli(ts).Local().Format("2006-01-02")
}
}
fmt.Fprintf(w, "[%d] %s\n", i+1, summary)
fmt.Fprintf(w, " ID: %s\n", guid)
if urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintf(w, " Completed: %t\n", completed)
if !completedAt.IsZero() {
fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04"))
}
if dueTimeStr != "" {
fmt.Fprintf(w, " Due: %s\n", dueTimeStr)
}
if createdDateStr != "" {
fmt.Fprintf(w, " Created: %s\n", createdDateStr)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" && !runtime.Cmd.Flags().Changed("page-limit") && !runtime.Cmd.Flags().Changed("page-all") {
fmt.Fprintf(w, "\n[Warning] Too many tasks! Stopped after fetching %d pages.\n", pageLimit)
}
fmt.Fprintf(w, "\nTotal execution time: %v\n", time.Since(startTime))
})
return nil
},
}
func taskCompletionState(item map[string]interface{}) (bool, time.Time) {
completedAtStr, _ := item["completed_at"].(string)
if completedAtStr == "" || completedAtStr == "0" {
return false, time.Time{}
}
ts, err := strconv.ParseInt(completedAtStr, 10, 64)
if err != nil {
return false, time.Time{}
}
return true, time.UnixMilli(ts)
}
+270
View File
@@ -0,0 +1,270 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strconv"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedDueTimeStr := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
expectedCreatedDateStr := time.UnixMilli(tsMs).Local().Format("2006-01-02")
expectedRFC3339 := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
tests := []struct {
name string
formatFlag string
pageToken string
stubURL string
expectedOutput []string
}{
{
name: "pretty format",
formatFlag: "pretty",
stubURL: "/open-apis/task/v2/tasks",
expectedOutput: []string{
"Due: " + expectedDueTimeStr,
"Created: " + expectedCreatedDateStr,
},
},
{
name: "json format",
formatFlag: "json",
stubURL: "/open-apis/task/v2/tasks",
expectedOutput: []string{
`"due_at": "` + expectedRFC3339 + `"`,
`"created_at": "` + expectedRFC3339 + `"`,
},
},
{
name: "start from page token",
formatFlag: "json",
pageToken: "pt_001",
stubURL: "page_token=pt_001",
expectedOutput: []string{
`"guid": "task-123"`,
},
},
}
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: tt.stubURL,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-123",
"summary": "Test Task",
"created_at": tsStr,
"due": map[string]interface{}{
"timestamp": tsStr,
},
"url": "https://example.com/task-123",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
args := []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}
if tt.pageToken != "" {
args = append(args, "--page-token", tt.pageToken)
}
err := runMountedTaskShortcut(t, s, args, 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)
}
}
})
}
}
func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`)
for _, expected := range []string{
`"guid": "task-open"`,
`"completed": false`,
`"guid": "task-done"`,
`"completed": true`,
`"completed_at": "` + expectedCompletedAt + `"`,
} {
if !strings.Contains(outNorm, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String())
}
}
}
func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) {
tsMs := int64(1775174400000)
tsStr := strconv.FormatInt(tsMs, 10)
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "task-open",
"summary": "Open Task",
"completed_at": "0",
"url": "https://example.com/task-open",
},
map[string]interface{}{
"guid": "task-done",
"summary": "Done Task",
"completed_at": tsStr,
"url": "https://example.com/task-done",
},
},
"has_more": false,
"page_token": "",
},
},
})
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
for _, expected := range []string{
"[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n",
"[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n",
} {
if !strings.Contains(out, expected) {
t.Fatalf("output missing expected string (%s), got: %s", expected, out)
}
}
if count := strings.Count(out, "Completed At:"); count != 1 {
t.Fatalf("Completed At count = %d, want 1; output: %s", count, out)
}
}
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
// Execute (--created_at / --due-start / --due-end). The parse runs before any
// API call, so a malformed value deterministically surfaces a typed
// *errs.ValidationError (exit 2) regardless of credentials — the command runs
// as user with a throwaway token. Each error carries the corresponding --flag
// param so the caller can point at the offending input.
func TestGetMyTasks_InvalidTimeFlags(t *testing.T) {
tests := []struct {
name string
flag string
wantParam string
}{
{name: "created_at", flag: "--created_at", wantParam: "--created_at"},
{name: "due-start", flag: "--due-start", wantParam: "--due-start"},
{name: "due-end", flag: "--due-end", wantParam: "--due-end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := GetMyTasks
s.AuthTypes = []string{"bot", "user"}
args := []string{"+get-my-tasks", tt.flag, "not-a-time", "--as", "user"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatalf("expected validation error for %s, got nil", tt.flag)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != tt.wantParam {
t.Errorf("param = %q, want %q", ve.Param, tt.wantParam)
}
})
}
}
+142
View File
@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
relatedTasksDefaultPageLimit = 20
relatedTasksMaxPageLimit = 40
relatedTasksPageSize = 100
)
var GetRelatedTasks = common.Shortcut{
Service: "task",
Command: "+get-related-tasks",
Description: "list tasks related to me",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
params["completed"] = false
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return common.NewDryRunAPI().
GET("/open-apis/task/v2/task_v2/list_related_task").
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
params["completed"] = "false"
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = relatedTasksDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = relatedTasksMaxPageLimit
}
if pageLimit > relatedTasksMaxPageLimit {
pageLimit = relatedTasksMaxPageLimit
}
var allItems []interface{}
var lastPageToken string
var lastHasMore bool
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/task_v2/list_related_task", params, nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
params["page_token"] = lastPageToken
}
userOpenID := runtime.UserOpenId()
filtered := make([]map[string]interface{}, 0, len(allItems))
for _, item := range allItems {
task, ok := item.(map[string]interface{})
if !ok {
continue
}
if runtime.Bool("created-by-me") {
creator, _ := task["creator"].(map[string]interface{})
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
continue
}
}
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
continue
}
filtered = append(filtered, outputRelatedTask(task))
}
outData := map[string]interface{}{
"items": filtered,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
if len(filtered) == 0 {
fmt.Fprintln(w, "No related tasks found.")
return
}
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
})
return nil
},
}
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
members, _ := task["members"].([]interface{})
for _, member := range members {
memberObj, _ := member.(map[string]interface{})
role, _ := memberObj["role"].(string)
id, _ := memberObj["id"].(string)
if strings.EqualFold(role, "follower") && id == userOpenID {
return true
}
}
return false
}
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestTaskFollowedBy(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
userOpenID string
want bool
}{
{
name: "contains follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
map[string]interface{}{"id": "ou_2", "role": "follower"},
},
},
userOpenID: "ou_2",
want: true,
},
{
name: "missing follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
},
},
userOpenID: "ou_3",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := taskFollowedBy(tt.task, tt.userOpenID)
if got != tt.want {
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetRelatedTasks_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "with page token and incomplete filter",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("include-complete", "false")
_ = cmd.Flags().Set("page-token", "pt_001")
},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
},
{
name: "default query params",
setup: func(cmd *cobra.Command) {},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Bool("include-complete", true, "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
out := GetRelatedTasks.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestGetRelatedTasks_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json created by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "done",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123",
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
},
},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
},
{
name: "pretty pagination followed by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_2",
"items": []interface{}{
map[string]interface{}{
"guid": "task-1",
"summary": "Task One",
"url": "https://example.com/task-1",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "page_token=pt_2",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-2",
"summary": "Task Two",
"url": "https://example.com/task-2",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
},
wantParts: []string{"Task One", "Task Two"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := GetRelatedTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}
+249
View File
@@ -0,0 +1,249 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
)
func splitAndTrimCSV(input string) []string {
parts := strings.Split(input, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startMillis, endMillis string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
hasStart = true
startMillis = startSec + "000"
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
hasEnd = true
endMillis = endSec + "000"
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
}
return startMillis, endMillis, nil
}
func parseTimeRangeRFC3339(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startTime, endTime string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
}
hasStart = true
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
}
hasEnd = true
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
}
return startTime, endTime, nil
}
func formatTaskDateTimeMillis(msStr string) string {
if msStr == "" || msStr == "0" {
return ""
}
ms, err := strconv.ParseInt(msStr, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(ms).Local().Format(time.DateTime)
}
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"url": urlVal,
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
out["updated_at"] = updated
}
}
if dueObj, ok := task["due"].(map[string]interface{}); ok {
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
out["due_at"] = dueAt
}
}
}
return out
}
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"description": task["description"],
"status": task["status"],
"source": task["source"],
"mode": task["mode"],
"subtask_count": task["subtask_count"],
"tasklists": task["tasklists"],
"url": urlVal,
}
if creator, ok := task["creator"].(map[string]interface{}); ok {
out["creator"] = creator
}
if members, ok := task["members"].([]interface{}); ok {
out["members"] = members
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
return out
}
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
timeRange := map[string]interface{}{}
if start != "" {
timeRange["start_time"] = start
}
if end != "" {
timeRange["end_time"] = end
}
if len(timeRange) == 0 {
return nil
}
return map[string]interface{}{key: timeRange}
}
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
for k, v := range src {
dst[k] = v
}
}
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
if strings.TrimSpace(query) != "" {
return nil
}
if len(filter) > 0 {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: query is empty and no filter is provided", action)
}
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
var b strings.Builder
for i, item := range items {
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
if status, _ := item["status"].(string); status != "" {
fmt.Fprintf(&b, " Status: %s\n", status)
}
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(&b, " Created: %s\n", created)
}
if completed, _ := item["completed_at"].(string); completed != "" {
fmt.Fprintf(&b, " Completed: %s\n", completed)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(&b, " URL: %s\n", urlVal)
}
b.WriteString("\n")
}
if hasMore && pageToken != "" {
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
}
return b.String()
}
+318
View File
@@ -0,0 +1,318 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
func TestSplitAndTrimCSV(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
{name: "empty input", input: "", want: []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitAndTrimCSV(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
}{
{
name: "with timestamps and due",
task: map[string]interface{}{
"guid": "task-123",
"summary": "summary",
"url": "https://example.com/task-123&suite_entity_num=t1",
"created_at": "1775174400000",
"due": map[string]interface{}{
"timestamp": "1775174400000",
},
},
},
{
name: "with completed and updated",
task: map[string]interface{}{
"guid": "task-456",
"summary": "done",
"url": "https://example.com/task-456",
"completed_at": "1775174400000",
"updated_at": "1775174400000",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := outputTaskSummary(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected summary output: %#v", got)
}
if got["url"] == "" {
t.Fatalf("expected url in output, got %#v", got)
}
})
}
}
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run("parse:"+tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeMillis(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
}
if tt.name == "reversed range fails fast" {
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
}
if tt.wantStart == "" && start != "" {
t.Fatalf("start = %q, want empty", start)
}
if tt.wantEnd == "" && end != "" {
t.Fatalf("end = %q, want empty", end)
}
if tt.wantStart == "non-empty" && start == "" {
t.Fatalf("start should not be empty")
}
if tt.wantEnd == "non-empty" && end == "" {
t.Fatalf("end should not be empty")
}
})
}
filterTests := []struct {
name string
query string
filter map[string]interface{}
wantErr bool
}{
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
}
for _, tt := range filterTests {
t.Run("filter:"+tt.name, func(t *testing.T) {
err := requireSearchFilter(tt.query, tt.filter, "search")
if tt.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
outputTests := []struct {
name string
task map[string]interface{}
}{
{
name: "full related task",
task: map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "todo",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123&suite_entity_num=t1",
"creator": map[string]interface{}{"id": "ou_1"},
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
"created_at": "1775174400000",
"completed_at": "1775174400000",
},
},
{
name: "minimal related task",
task: map[string]interface{}{
"guid": "task-456",
"summary": "Minimal",
"url": "https://example.com/task-456",
},
},
}
for _, tt := range outputTests {
t.Run("output:"+tt.name, func(t *testing.T) {
got := outputRelatedTask(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected related task output: %#v", got)
}
})
}
rangeTests := []struct {
name string
start string
end string
wantNil bool
}{
{name: "empty range", start: "", end: "", wantNil: true},
{name: "full range", start: "1", end: "2", wantNil: false},
}
for _, tt := range rangeTests {
t.Run("range:"+tt.name, func(t *testing.T) {
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
if tt.wantNil && got != nil {
t.Fatalf("expected nil, got %#v", got)
}
if !tt.wantNil && got == nil {
t.Fatalf("expected range filter, got nil")
}
})
}
}
func TestRenderRelatedTasksPretty(t *testing.T) {
tests := []struct {
name string
items []map[string]interface{}
hasMore bool
pageToken string
wantParts []string
}{
{
name: "includes next token",
items: []map[string]interface{}{
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
},
hasMore: true,
pageToken: "pt_123",
wantParts: []string{"Related Task", "Next page token: pt_123"},
},
{
name: "without next token",
items: []map[string]interface{}{
{"guid": "task-456", "summary": "Another Task"},
},
hasMore: false,
pageToken: "",
wantParts: []string{"Another Task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeRFC3339(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if tt.name == "reversed range fails fast" {
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
}
if tt.wantStart == "rfc3339" {
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
t.Fatalf("expected RFC3339 start, got %q", start)
}
} else if start != tt.wantStart {
t.Fatalf("unexpected start: %q", start)
}
if tt.wantEnd == "rfc3339" {
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
t.Fatalf("expected RFC3339 end, got %q", end)
}
} else if end != tt.wantEnd {
t.Fatalf("unexpected end: %q", end)
}
})
}
})
}
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
var ReminderTask = common.Shortcut{
Service: "task",
Command: "+reminder",
Description: "manage task reminders",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
{Name: "set", Desc: "relative fire minutes to set (e.g. 15m, 1h, 1d)"},
{Name: "remove", Type: "bool", Desc: "removes all existing reminders"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("set") == "" && !runtime.Bool("remove") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --set or --remove")
}
if runtime.Str("set") != "" && runtime.Bool("remove") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot specify both --set and --remove")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
taskId := url.PathEscape(runtime.Str("task-id"))
if runtime.Bool("remove") {
d.Desc("1. GET task to find existing reminder IDs").
GET("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Desc("2. POST to remove_reminders with found IDs")
} else if setStr := runtime.Str("set"); setStr != "" {
d.Desc("1. GET task to check existing reminders").
GET("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Desc("2. POST to remove_reminders if any exist").
Desc("3. POST to add_reminders").
POST("/open-apis/task/v2/tasks/" + taskId + "/add_reminders")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
params := map[string]interface{}{"user_id_type": "open_id"}
// First, get the task to find existing reminders
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
if err != nil {
return err
}
taskObj, _ := data["task"].(map[string]interface{})
reminders, _ := taskObj["reminders"].([]interface{})
if runtime.Bool("remove") {
if len(reminders) == 0 {
runtime.OutFormat(map[string]interface{}{"guid": taskId}, nil, func(w io.Writer) {
fmt.Fprintln(w, "No existing reminders to remove.")
})
return nil
}
var reminderIds []string
for _, r := range reminders {
if rMap, ok := r.(map[string]interface{}); ok {
if id, ok := rMap["id"].(string); ok {
reminderIds = append(reminderIds, id)
}
}
}
if len(reminderIds) > 0 {
body := map[string]interface{}{
"reminder_ids": reminderIds,
}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
return err
}
}
} else if setStr := runtime.Str("set"); setStr != "" {
// Parse relative time string (e.g. 15m, 1h, 1d, or plain 30)
var minutes int
var parseErr error
if strings.HasSuffix(setStr, "m") {
minutes, parseErr = strconv.Atoi(strings.TrimSuffix(setStr, "m"))
} else if strings.HasSuffix(setStr, "h") {
h, e := strconv.Atoi(strings.TrimSuffix(setStr, "h"))
if e == nil {
minutes = h * 60
}
parseErr = e
} else if strings.HasSuffix(setStr, "d") {
d, e := strconv.Atoi(strings.TrimSuffix(setStr, "d"))
if e == nil {
minutes = d * 24 * 60
}
parseErr = e
} else {
// Default to minutes if no suffix
minutes, parseErr = strconv.Atoi(setStr)
}
if parseErr != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", parseErr)
}
// If any reminders exist, remove them first
if len(reminders) > 0 {
var reminderIds []string
for _, r := range reminders {
if rMap, ok := r.(map[string]interface{}); ok {
if id, ok := rMap["id"].(string); ok {
reminderIds = append(reminderIds, id)
}
}
}
if len(reminderIds) > 0 {
body := map[string]interface{}{
"reminder_ids": reminderIds,
}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
return err
}
}
}
body := map[string]interface{}{
"reminders": []map[string]interface{}{
{
"relative_fire_minute": minutes,
},
},
}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_reminders", params, body); err != nil {
return err
}
}
urlVal, _ := taskObj["url"].(string)
urlVal = truncateTaskURL(urlVal)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": taskId,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task reminders updated successfully!\n")
fmt.Fprintf(w, "Task ID: %s\n", taskId)
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
})
return nil
},
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
)
// TestReminderTask_RequiresSetOrRemove covers the first Validate guard: neither
// --set nor --remove yields a typed validation error (exit 2) before any API
// call.
func TestReminderTask_RequiresSetOrRemove(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := ReminderTask
args := []string{"+reminder", "--task-id", "task-1", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
// TestReminderTask_CannotSpecifyBoth covers the second Validate guard: passing
// both --set and --remove yields a typed validation error (exit 2).
func TestReminderTask_CannotSpecifyBoth(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := ReminderTask
args := []string{"+reminder", "--task-id", "task-1", "--set", "15m", "--remove", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"github.com/larksuite/cli/shortcuts/common"
)
var ReopenTask = common.Shortcut{
Service: "task",
Command: "+reopen",
Description: "reopen a completed task",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id", Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildReopenBody()
taskId := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
PATCH("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskId := url.PathEscape(runtime.Str("task-id"))
body := buildReopenBody()
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
if err != nil {
return err
}
task, _ := data["task"].(map[string]interface{})
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": guid,
"url": urlVal,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
summary, _ := task["summary"].(string)
fmt.Fprintf(w, "✅ Task reopened successfully!\n")
if guid != "" {
fmt.Fprintf(w, "Task ID: %s\n", guid)
}
if summary != "" {
fmt.Fprintf(w, "Summary: %s\n", summary)
}
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
})
return nil
},
}
func buildReopenBody() map[string]interface{} {
return map[string]interface{}{
"task": map[string]interface{}{
"completed_at": "0",
},
"update_fields": []string{"completed_at"},
}
}
+204
View File
@@ -0,0 +1,204 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
taskSearchDefaultPageLimit = 20
taskSearchMaxPageLimit = 40
)
var SearchTask = common.Shortcut{
Service: "task",
Command: "+search",
Description: "search tasks",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
{Name: "follower", Desc: "follower open_ids, comma-separated"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTaskSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = taskSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = taskSearchMaxPageLimit
}
if pageLimit > taskSearchMaxPageLimit {
pageLimit = taskSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
if err != nil {
return err
}
if notice == "" {
notice, _ = data["notice"].(string)
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
taskID, _ := itemMap["id"].(string)
if taskID == "" {
continue
}
task, err := getTaskDetail(runtime, taskID)
if err != nil {
metaData, _ := itemMap["meta_data"].(map[string]interface{})
appLink, _ := metaData["app_link"].(string)
enriched = append(enriched, map[string]interface{}{
"guid": taskID,
"url": truncateTaskURL(appLink),
})
continue
}
enriched = append(enriched, outputTaskSummary(task))
}
outData := map[string]interface{}{
"items": enriched,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No tasks found.")
return
}
for i, item := range enriched {
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(w, " Created: %s\n", created)
}
if dueAt, _ := item["due_at"].(string); dueAt != "" {
fmt.Fprintf(w, " Due: %s\n", dueAt)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["creator_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
filter["assignee_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
filter["follower_ids"] = ids
}
if runtime.Cmd.Flags().Changed("completed") {
filter["is_completed"] = runtime.Bool("completed")
}
if dueRange := runtime.Str("due"); dueRange != "" {
start, end, err := parseTimeRangeRFC3339(dueRange)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due: %v", err).WithParam("--due")
}
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
mergeIntoFilter(filter, dueFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, nil)
if err != nil {
return nil, err
}
task, _ := data["task"].(map[string]interface{})
if task == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "task detail response missing task object")
}
return task, nil
}
+433
View File
@@ -0,0 +1,433 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTaskSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "query creator due and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "release")
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
_ = cmd.Flags().Set("completed", "true")
_ = cmd.Flags().Set("due", "-1d,+1d")
_ = cmd.Flags().Set("page-token", "pt_123")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" || body["page_token"] != "pt_123" {
t.Fatalf("unexpected body: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := dueTime["start_time"].(string)
endTime, _ := dueTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected due_time: %#v", dueTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
{
name: "assignee follower and incomplete",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("assignee", "ou_assignee")
_ = cmd.Flags().Set("follower", "ou_follower")
_ = cmd.Flags().Set("completed", "false")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
t.Fatalf("unexpected filter: %#v", filter)
}
if filter["is_completed"] != false {
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTaskSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTaskSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
},
{
name: "dry run error on invalid due",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("due", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTask.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
// TestSearchTask_Execute verifies task search output, enrichment, and notices.
func TestSearchTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
},
{
name: "fallback to app link",
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-999",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
},
{
name: "empty pretty with pagination",
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasks found."},
},
{
name: "pretty with next page token",
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_next",
"items": []interface{}{
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-321",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
},
},
})
},
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}
// TestSearchTask_InvalidDue_Validation drives the --due validation arm through
// the mounted command. buildTaskSearchBody runs before any API call, so a
// malformed range deterministically surfaces a typed *errs.ValidationError
// (invalid_argument, exit 2) carrying the --due param.
func TestSearchTask_InvalidDue_Validation(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
args := []string{"+search", "--query", "release", "--due", "not-a-time", "--as", "user"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatal("expected validation error for malformed --due, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != "--due" {
t.Errorf("param = %q, want %q", ve.Param, "--due")
}
}
// TestSearchTask_MalformedSearchResponse covers the search raw-body parse arm:
// the SDK returns a 200 with a non-JSON body and nil error, so the shortcut's
// own json.Unmarshal fails and must surface a typed *errs.InternalError
// (invalid_response, exit 5).
func TestSearchTask_MalformedSearchResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
RawBody: []byte("{not-json"),
})
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
args := []string{"+search", "--query", "release", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err == nil {
t.Fatal("expected internal error for malformed response, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
// TestGetTaskDetail_MalformedResponse exercises getTaskDetail directly. In the
// +search Execute loop a detail-fetch error is intentionally swallowed (the hit
// falls back to its app_link), so the only way to lock the helper's two
// internal arms — a non-JSON body and a code-0 response missing the task object
// — is to call it directly. Both must surface a typed *errs.InternalError
// (invalid_response, exit 5).
func TestGetTaskDetail_MalformedResponse(t *testing.T) {
tests := []struct {
name string
stub *httpmock.Stub
}{
{
name: "body not json",
stub: &httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
RawBody: []byte("{not-json"),
},
},
{
name: "missing task object",
stub: &httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(tt.stub)
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, taskTestConfig(t), f, core.AsBot)
_, err := getTaskDetail(runtime, "task-123")
if err == nil {
t.Fatal("expected internal error, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
})
}
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"github.com/larksuite/cli/shortcuts/common"
)
var SetAncestorTask = common.Shortcut{
Service: "task",
Command: "+set-ancestor",
Description: "set or clear a task ancestor",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task guid to update", Required: true},
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
taskID := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
params := map[string]interface{}{"user_id_type": "open_id"}
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID)+"/set_ancestor_task", params, buildSetAncestorBody(runtime.Str("ancestor-id"))); err != nil {
return err
}
outData := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{
"guid": taskID,
},
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
} else {
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
}
})
return nil
},
}
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
if ancestorID == "" {
return map[string]interface{}{}
}
return map[string]interface{}{
"ancestor_guid": ancestorID,
}
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildSetAncestorBody(t *testing.T) {
tests := []struct {
name string
ancestorID string
want map[string]interface{}
}{
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildSetAncestorBody(tt.ancestorID)
if len(got) != len(tt.want) {
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
}
for k, want := range tt.want {
if got[k] != want {
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
}
}
})
}
}
func TestSetAncestorTask_DryRun(t *testing.T) {
tests := []struct {
name string
taskID string
ancestor string
wantParts []string
}{
{
name: "with ancestor",
taskID: "task-123",
ancestor: "task-456",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
},
{
name: "clear ancestor",
taskID: "task-123",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("ancestor-id", "", "")
_ = cmd.Flags().Set("task-id", tt.taskID)
if tt.ancestor != "" {
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
out := SetAncestorTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSetAncestorTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "json output with ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"guid": "task-123"`},
},
{
name: "pretty output clears ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
},
{
name: "api-level error (code!=0) returns error",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 10003,
"msg": "permission denied",
},
})
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if out := stdout.String(); out != "" {
t.Fatalf("expected empty stdout on error, got: %s", out)
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"bytes"
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func taskTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
suffix := strings.NewReplacer("/", "-", " ", "-", ":", "-", "\t", "-").Replace(t.Name())
return &core.CliConfig{
AppID: "test-app-" + suffix,
AppSecret: "test-secret-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: "ou_testuser",
UserName: "Test User",
}
}
func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) {
t.Helper()
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/test/v1/warm",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{},
},
})
s := common.Shortcut{
Service: "test",
Command: "+warm-token",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
return err
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+warm-token", "--as", "bot"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("warm tenant token: %v", err)
}
}
func taskShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
return cmdutil.TestFactory(t, taskTestConfig(t))
}
func runMountedTaskShortcut(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "test"}
shortcut.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
+191
View File
@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
tasklistSearchDefaultPageLimit = 20
tasklistSearchMaxPageLimit = 40
)
var SearchTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-search",
Description: "search tasklists",
Risk: "read",
Scopes: []string{"task:tasklist:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTasklistSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = tasklistSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = tasklistSearchMaxPageLimit
}
if pageLimit > tasklistSearchMaxPageLimit {
pageLimit = tasklistSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
if err != nil {
return err
}
if notice == "" {
notice, _ = data["notice"].(string)
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
tasklistID, _ := itemMap["id"].(string)
if tasklistID == "" {
continue
}
tasklist, err := getTasklistDetail(runtime, tasklistID)
if err != nil {
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklistID,
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
})
continue
}
urlVal, _ := tasklist["url"].(string)
urlVal = truncateTaskURL(urlVal)
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklist["guid"],
"name": tasklist["name"],
"url": urlVal,
"creator": tasklist["creator"],
})
}
outData := map[string]interface{}{
"items": tasklists,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
if len(tasklists) == 0 {
fmt.Fprintln(w, "No tasklists found.")
return
}
for i, tasklist := range tasklists {
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["user_id"] = ids
}
if createTime := runtime.Str("create-time"); createTime != "" {
start, end, err := parseTimeRangeRFC3339(createTime)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid create-time: %v", err).WithParam("--create-time")
}
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
mergeIntoFilter(filter, timeFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+url.PathEscape(tasklistID), params, nil)
if err != nil {
return nil, err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
if tasklist == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "tasklist detail response missing tasklist object")
}
return tasklist, nil
}
+300
View File
@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTasklistSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "creator create-time and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("creator", "ou_creator")
_ = cmd.Flags().Set("create-time", "-7d,+0d")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := createTime["start_time"].(string)
endTime, _ := createTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected create_time: %#v", createTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTasklistSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTasklistSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("create-time", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTasklist.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTasklist.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
// TestSearchTasklist_Execute verifies tasklist search output, enrichment, and notices.
func TestSearchTasklist_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
},
},
})
},
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
},
{
name: "fallback on detail error",
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "tl-fallback"`},
},
{
name: "pretty fallback avoids nil name",
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
},
{
name: "empty pretty with pagination",
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasklists found."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}
// TestSearchTasklist_MalformedResponse covers the search parse arm: a 200 with
// an unparseable search body surfaces a typed internal invalid_response error
// (exit 5). The detail parse arm is swallowed into the fallback path, so only
// the top-level search parse propagates.
func TestSearchTasklist_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Status: 200,
RawBody: []byte("{not-json"),
})
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
var UpdateTask = common.Shortcut{
Service: "task",
Command: "+update",
Description: "update task attributes",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "summary", Desc: "task title"},
{Name: "description", Desc: "task description"},
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
{Name: "data", Desc: "JSON payload for task object"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
return common.NewDryRunAPI().
PATCH("/open-apis/task/v2/tasks/" + taskId).
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskUpdateBody(runtime)
if err != nil {
// buildTaskUpdateBody already returns a typed validation error;
// propagate it directly instead of re-wrapping as an API error.
return err
}
taskIds := strings.Split(runtime.Str("task-id"), ",")
var updatedTasks []map[string]interface{}
for _, taskId := range taskIds {
taskId = strings.TrimSpace(taskId)
if taskId == "" {
continue
}
params := map[string]interface{}{"user_id_type": "open_id"}
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
if err != nil {
return err
}
taskObj, _ := data["task"].(map[string]interface{})
if taskObj != nil {
updatedTasks = append(updatedTasks, taskObj)
}
}
var tasks []map[string]interface{}
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
tasks = append(tasks, map[string]interface{}{
"guid": guid,
"url": urlVal,
})
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"tasks": tasks,
}
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
for _, task := range updatedTasks {
guid, _ := task["guid"].(string)
summary, _ := task["summary"].(string)
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
fmt.Fprintf(w, "✅ Task updated successfully!\n")
fmt.Fprintf(w, "Task ID: %s\n", guid)
if summary != "" {
fmt.Fprintf(w, "Summary: %s\n", summary)
}
if urlVal != "" {
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
}
fmt.Fprintln(w, strings.Repeat("-", 20))
}
})
return nil
},
}
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
taskObj := make(map[string]interface{})
var updateFields []string
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &taskObj); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
}
// If data is provided, assume keys are update fields
for k := range taskObj {
updateFields = append(updateFields, k)
}
}
if summary := runtime.Str("summary"); summary != "" {
taskObj["summary"] = summary
if !contains(updateFields, "summary") {
updateFields = append(updateFields, "summary")
}
}
if desc := runtime.Str("description"); desc != "" {
taskObj["description"] = desc
if !contains(updateFields, "description") {
updateFields = append(updateFields, "description")
}
}
if dueStr := runtime.Str("due"); dueStr != "" {
dueObj, err := parseTaskTime(dueStr)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
}
taskObj["due"] = dueObj
if !contains(updateFields, "due") {
updateFields = append(updateFields, "due")
}
}
if len(updateFields) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no fields to update")
}
return map[string]interface{}{
"task": taskObj,
"update_fields": updateFields,
}, nil
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/shortcuts/common"
)
// taskAttachmentUploadMaxSize is the upper bound on a single attachment upload
// to the Task service (50MB, as documented by the open API).
const taskAttachmentUploadMaxSize int64 = 50 * 1024 * 1024
// taskAttachmentUploadPath is the Task open-api endpoint that accepts a single
// multipart/form-data upload per call.
const taskAttachmentUploadPath = "/open-apis/task/v2/attachments/upload"
// defaultTaskAttachmentResourceType is used when the caller does not pass an
// explicit --resource-type flag. Task is the only resource type documented for
// this endpoint today, but the flag is kept open so that future resource types
// can be targeted without a client upgrade.
const defaultTaskAttachmentResourceType = "task"
// UploadAttachmentTask uploads a single local file as an attachment to a task
// (or any other resource type accepted by the Task attachment endpoint).
var UploadAttachmentTask = common.Shortcut{
Service: "task",
Command: "+upload-attachment",
Description: "upload a local file as an attachment to a task",
Risk: "write",
Scopes: []string{"task:attachment:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "resource-id", Desc: "task guid (or task applink URL)", Required: true},
{Name: "file", Desc: "local file path (single file, <= 50MB)", Required: true},
{Name: "resource-type", Desc: "owning resource type (default: task); use task_delivery when uploading to task agents", Default: defaultTaskAttachmentResourceType},
{Name: "user-id-type", Desc: "user id type (default: open_id)", Default: "open_id"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
resourceType := runtime.Str("resource-type")
if resourceType == "" {
resourceType = defaultTaskAttachmentResourceType
}
resourceID := extractTaskGuid(runtime.Str("resource-id"))
filePath := runtime.Str("file")
userIDType := runtime.Str("user-id-type")
if userIDType == "" {
userIDType = "open_id"
}
return common.NewDryRunAPI().
POST(taskAttachmentUploadPath).
Params(map[string]interface{}{"user_id_type": userIDType}).
Body(map[string]interface{}{
"resource_type": resourceType,
"resource_id": resourceID,
"file": map[string]string{
"field": "file",
"path": filePath,
"name": filepath.Base(filePath),
},
})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
resourceType := runtime.Str("resource-type")
if resourceType == "" {
resourceType = defaultTaskAttachmentResourceType
}
resourceID := extractTaskGuid(runtime.Str("resource-id"))
filePath := runtime.Str("file")
userIDType := runtime.Str("user-id-type")
if userIDType == "" {
userIDType = "open_id"
}
fio := runtime.FileIO()
if fio == nil {
// A nil FileIO is a runtime wiring fault, not user input.
return errs.NewInternalError(errs.SubtypeUnknown, "file operations require a FileIO provider")
}
stat, err := fio.Stat(filePath)
if err != nil {
return taskInputStatError(err, "--file", "cannot access file")
}
if !stat.Mode().IsRegular() {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
}
if stat.Size() > taskAttachmentUploadMaxSize {
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"attachment %s exceeds the 50MB per-file limit",
common.FormatSize(stat.Size()),
).WithParam("--file")
}
fileName := filepath.Base(filePath)
// Observability: input parsed.
fmt.Fprintf(
runtime.IO().ErrOut,
"[+upload-attachment] input parsed: resource_type=%s resource_id=%s file=%s size=%s\n",
resourceType, resourceID, filePath, common.FormatSize(stat.Size()),
)
f, err := fio.Open(filePath)
if err != nil {
return taskInputStatError(err, "--file", "cannot open file")
}
defer f.Close()
// Build the multipart body manually so the real filename is preserved
// in the `file` part's Content-Disposition. The SDK's Formdata.AddFile
// hardcodes the filename to "unknown-file" (see oapi-sdk-go
// core/reqtranslator.go), which is what was showing up in the Task UI.
var bodyBuf bytes.Buffer
mw := common.NewMultipartWriter(&bodyBuf)
if err := mw.WriteField("resource_type", resourceType); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
}
if err := mw.WriteField("resource_id", resourceID); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
}
filePart, err := mw.CreateFormFile("file", fileName)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
}
if _, err := io.Copy(filePart, f); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "write file to multipart body: %s", err)
}
if err := mw.Close(); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "finalize multipart body: %s", err)
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", userIDType)
// Observability: HTTP call about to start.
fmt.Fprintf(
runtime.IO().ErrOut,
"[+upload-attachment] http call: POST %s user_id_type=%s\n",
taskAttachmentUploadPath, userIDType,
)
headers := http.Header{}
headers.Set("Content-Type", mw.FormDataContentType())
httpResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "POST",
ApiPath: taskAttachmentUploadPath,
QueryParams: queryParams,
Body: &bodyBuf,
}, client.WithHeaders(headers))
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: error=%v\n", err)
return wrapTaskNetworkErr(err, "upload attachment request failed")
}
defer httpResp.Body.Close()
rawBody, readErr := io.ReadAll(httpResp.Body)
if readErr != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: read_error=%v\n", readErr)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to read response: %v", readErr)
}
var result map[string]interface{}
if parseErr := json.Unmarshal(rawBody, &result); parseErr != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: parse_error=%v\n", parseErr)
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
}
data, err := HandleTaskApiResultWithContext(result, nil, "upload task attachment", runtime.APIClassifyContext())
if err != nil {
code, _ := result["code"]
msg, _ := result["msg"].(string)
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: code=%v msg=%q error=%v\n",
code, msg, err)
return err
}
// The Task attachment upload endpoint returns `data.items` containing
// the freshly created attachment records. Since this shortcut uploads
// exactly one file per call, we surface the single record directly as
// the output envelope — all fields returned by the API (guid, name,
// size, url, resource_type, uploader, ...) are preserved verbatim.
items, _ := data["items"].([]interface{})
var first map[string]interface{}
if len(items) > 0 {
first, _ = items[0].(map[string]interface{})
}
if first == nil {
first = map[string]interface{}{}
}
guid, _ := first["guid"].(string)
code, _ := result["code"]
msg, _ := result["msg"].(string)
fmt.Fprintf(runtime.IO().ErrOut,
"[+upload-attachment] http response: code=%v msg=%q attachment_guid=%s\n",
code, msg, guid)
runtime.OutFormat(first, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Attachment uploaded successfully!\n")
fmt.Fprintf(w, "Resource: %s/%s\n", resourceType, resourceID)
name, _ := first["name"].(string)
if name == "" {
name = fileName
}
fmt.Fprintf(w, "File: %s (%s)\n", name, common.FormatSize(stat.Size()))
if guid != "" {
fmt.Fprintf(w, "Attachment GUID: %s\n", guid)
}
})
return nil
},
}
@@ -0,0 +1,591 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"mime/multipart"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// writeTestFile creates a file at name (relative to cwd) with size bytes of
// ASCII data and returns the relative path it wrote.
func writeTestFile(t *testing.T, name string, size int) string {
t.Helper()
if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error: %v", name, err)
}
return name
}
// writeSparseTestFile produces a sparse file of the requested size without
// allocating real disk space, useful for exercising the 50MB validation path.
func writeSparseTestFile(t *testing.T, name string, size int64) string {
t.Helper()
fh, err := os.Create(name)
if err != nil {
t.Fatalf("Create(%q) error: %v", name, err)
}
if err := fh.Truncate(size); err != nil {
t.Fatalf("Truncate(%q, %d) error: %v", name, size, err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close(%q) error: %v", name, err)
}
return name
}
func TestUploadAttachmentTask_Success(t *testing.T) {
for _, tt := range []struct {
name string
format string
contains []string
}{
{
name: "pretty format",
format: "pretty",
contains: []string{
"✅ Attachment uploaded successfully!",
"Attachment GUID: att-guid-1",
},
},
{
name: "json format",
format: "json",
contains: []string{
`"guid": "att-guid-1"`,
`"name": "note.txt"`,
},
},
} {
t.Run(tt.name, func(t *testing.T) {
f, stdout, stderr, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 12)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"guid": "att-guid-1",
"name": "note.txt",
"size": 12,
},
},
},
},
}
reg.Register(uploadStub)
args := []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", tt.format,
}
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Normalize JSON whitespace so that both compact and indented forms match.
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.contains {
if !strings.Contains(outNorm, want) && !strings.Contains(out, want) {
t.Errorf("stdout missing %q; got:\n%s", want, out)
}
}
// Verify multipart body structure.
body := decodeTaskAttachmentMultipart(t, uploadStub)
if got := body.Fields["resource_type"]; got != "task" {
t.Errorf("resource_type = %q, want %q", got, "task")
}
if got := body.Fields["resource_id"]; got != "task-guid-123" {
t.Errorf("resource_id = %q, want %q", got, "task-guid-123")
}
if got, ok := body.Files["file"]; !ok {
t.Errorf("multipart missing file part")
} else if len(got) != 12 {
t.Errorf("file size = %d, want 12", len(got))
}
if got := body.FileNames["file"]; got != "note.txt" {
t.Errorf("multipart file filename = %q, want %q", got, "note.txt")
}
// Verify key observability logs on stderr.
errOut := stderr.String()
for _, log := range []string{
"input parsed",
"http call: POST /open-apis/task/v2/attachments/upload",
"http response",
"att-guid-1",
} {
if !strings.Contains(errOut, log) {
t.Errorf("stderr missing log %q; got:\n%s", log, errOut)
}
}
})
}
}
func TestUploadAttachmentTask_ExplicitResourceTypePassthrough(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 5)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"guid": "att-guid-2"}},
},
},
}
reg.Register(uploadStub)
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--resource-type", "custom_type",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeTaskAttachmentMultipart(t, uploadStub)
if got := body.Fields["resource_type"]; got != "custom_type" {
t.Fatalf("resource_type = %q, want custom_type", got)
}
}
func TestUploadAttachmentTask_ResourceIDFromApplink(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 5)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"guid": "att-guid-3"}},
},
},
}
reg.Register(uploadStub)
applink := "https://applink.feishu.cn/client/todo/task?guid=task-from-url"
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", applink,
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeTaskAttachmentMultipart(t, uploadStub)
if got := body.Fields["resource_id"]; got != "task-from-url" {
t.Fatalf("resource_id = %q, want task-from-url", got)
}
}
func TestUploadAttachmentTask_SizeLimit(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// 50MB + 1 byte; no HTTP stub registered — we must fail before any call.
filePath := writeSparseTestFile(t, "big.bin", 50*1024*1024+1)
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "50MB") {
t.Fatalf("error message should mention 50MB limit, got: %v", err)
}
}
func TestUploadAttachmentTask_FileMissing(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "does-not-exist.bin",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if ve.Param != "--file" {
t.Fatalf("param = %q, want %q", ve.Param, "--file")
}
}
func TestUploadAttachmentTask_NotRegularFile(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A directory stats fine but is not a regular file; we must reject it as
// invalid --file input before any HTTP call (no stub registered).
if err := os.Mkdir("a-dir", 0o755); err != nil {
t.Fatalf("Mkdir error: %v", err)
}
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "a-dir",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--file" {
t.Fatalf("param = %q, want %q", ve.Param, "--file")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "regular file") {
t.Fatalf("error message should mention regular file, got: %v", err)
}
}
func TestUploadAttachmentTask_StatErrorMessage(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A missing path fails Stat → taskInputStatError surfaces "cannot access
// file" (no longer "file not found"). No HTTP stub: must fail before any call.
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "missing.bin",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "cannot access file") {
t.Fatalf("error message should contain %q, got: %v", "cannot access file", err)
}
}
func TestUploadAttachmentTask_MalformedResponse(t *testing.T) {
f, stdout, stderr, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 4)
// A 200 response whose body is not valid JSON: the parse failure must
// surface a typed internal invalid_response error (exit 5), not a panic
// or a silent success.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
RawBody: []byte("this is not json{"),
})
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
}
if !strings.Contains(err.Error(), "parse response") {
t.Fatalf("error message should mention parse response, got: %v", err)
}
// The parse-error observability log should be emitted on stderr.
if errOut := stderr.String(); !strings.Contains(errOut, "parse_error") {
t.Errorf("stderr missing parse_error log; got:\n%s", errOut)
}
}
func TestUploadAttachmentTask_APIError(t *testing.T) {
f, stdout, stderr, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
filePath := writeTestFile(t, "note.txt", 3)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/attachments/upload",
Body: map[string]interface{}{
"code": ErrCodeTaskPermissionDenied,
"msg": "no permission",
},
})
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", filePath,
"--as", "bot",
"--format", "json",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf(err) = !ok, want typed errs.* error; err = %v", err)
}
if p.Subtype != errs.SubtypePermissionDenied {
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
}
if p.Code != ErrCodeTaskPermissionDenied {
t.Fatalf("code = %d, want %d", p.Code, ErrCodeTaskPermissionDenied)
}
// permission_denied maps to CategoryAuthorization → exit 3 (was exit 1 under legacy).
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d", got, output.ExitAuth)
}
// Key-path log should still be emitted on failure.
errOut := stderr.String()
for _, log := range []string{"input parsed", "http call", "http response"} {
if !strings.Contains(errOut, log) {
t.Errorf("stderr missing failure log %q; got:\n%s", log, errOut)
}
}
}
func TestUploadAttachmentTask_DryRun(t *testing.T) {
for _, tt := range []struct {
name string
extraArgs []string
wantResourceType string
}{
{
name: "default resource type",
extraArgs: nil,
wantResourceType: "task",
},
{
name: "explicit resource type",
extraArgs: []string{"--resource-type", "custom_type"},
wantResourceType: "custom_type",
},
} {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := taskShortcutTestFactory(t)
args := []string{
"+upload-attachment",
"--resource-id", "task-guid-123",
"--file", "./some.pdf",
"--as", "bot",
"--format", "json",
"--dry-run",
}
args = append(args, tt.extraArgs...)
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
var dry map[string]interface{}
if err := json.Unmarshal([]byte(out), &dry); err != nil {
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out)
}
calls, _ := dry["api"].([]interface{})
if len(calls) != 1 {
t.Fatalf("expected 1 api call in dry-run, got %d: %v", len(calls), calls)
}
call := calls[0].(map[string]interface{})
if got := call["method"]; got != "POST" {
t.Fatalf("method = %v, want POST", got)
}
if got := call["url"]; got != "/open-apis/task/v2/attachments/upload" {
t.Fatalf("url = %v, want upload path", got)
}
params, _ := call["params"].(map[string]interface{})
if got := params["user_id_type"]; got != "open_id" {
t.Fatalf("params.user_id_type = %v, want open_id", got)
}
body := call["body"].(map[string]interface{})
if got := body["resource_type"]; got != tt.wantResourceType {
t.Fatalf("resource_type = %v, want %v", got, tt.wantResourceType)
}
if got := body["resource_id"]; got != "task-guid-123" {
t.Fatalf("resource_id = %v, want task-guid-123", got)
}
fileDesc := body["file"].(map[string]interface{})
if got := fileDesc["field"]; got != "file" {
t.Fatalf("file.field = %v, want file", got)
}
if got := fileDesc["path"]; got != "./some.pdf" {
t.Fatalf("file.path = %v, want ./some.pdf", got)
}
if got := fileDesc["name"]; got != "some.pdf" {
t.Fatalf("file.name = %v, want some.pdf", got)
}
})
}
}
// ── multipart body helper ──────────────────────────────────────────────────
type capturedAttachmentMultipart struct {
Fields map[string]string
Files map[string][]byte
FileNames map[string]string
}
func decodeTaskAttachmentMultipart(t *testing.T, stub *httpmock.Stub) capturedAttachmentMultipart {
t.Helper()
contentType := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("parse content-type %q: %v", contentType, err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
body := capturedAttachmentMultipart{
Fields: map[string]string{},
Files: map[string][]byte{},
FileNames: map[string]string{},
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("read multipart part: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("read multipart data: %v", err)
}
if part.FileName() != "" {
body.Files[part.FormName()] = data
body.FileNames[part.FormName()] = part.FileName()
continue
}
body.Fields[part.FormName()] = string(data)
}
return body
}
+191
View File
@@ -0,0 +1,191 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
)
var relativeTimeRe = regexp.MustCompile(`^([+-])(\d+)([dwmh])$`)
func isRelativeTime(s string) bool {
return relativeTimeRe.MatchString(s)
}
func parseRelativeTime(s string) (time.Time, error) {
matches := relativeTimeRe.FindStringSubmatch(s)
if len(matches) == 0 {
return time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid relative time format: %s", s)
}
sign := matches[1]
amountStr := matches[2]
unit := matches[3]
amount, err := strconv.Atoi(amountStr)
if err != nil {
return time.Time{}, err
}
if sign == "-" {
amount = -amount
}
now := time.Now()
switch unit {
case "d":
return now.AddDate(0, 0, amount), nil
case "w":
return now.AddDate(0, 0, amount*7), nil
case "m":
return now.Add(time.Duration(amount) * time.Minute), nil
case "h":
return now.Add(time.Duration(amount) * time.Hour), nil
}
panic(fmt.Sprintf("unreachable: relativeTimeRe matched unexpected unit %q", unit))
}
const (
// ErrCodeTaskInvalidParams is returned when request parameters are invalid.
ErrCodeTaskInvalidParams = 1470400
// ErrCodeTaskPermissionDenied is returned when the user has no permission.
ErrCodeTaskPermissionDenied = 1470403
// ErrCodeTaskNotFound is returned when the resource is not found.
ErrCodeTaskNotFound = 1470404
// ErrCodeTaskConflict is returned when concurrent call conflict.
ErrCodeTaskConflict = 1470422
// ErrCodeTaskInternalError is returned when server error occurs.
ErrCodeTaskInternalError = 1470500
// ErrCodeTaskAssigneeLimit is returned when assignee limit exceeded.
ErrCodeTaskAssigneeLimit = 1470610
// ErrCodeTaskFollowerLimit is returned when follower limit exceeded.
ErrCodeTaskFollowerLimit = 1470611
// ErrCodeTasklistMemberLimit is returned when tasklist member limit exceeded.
ErrCodeTasklistMemberLimit = 1470612
// ErrCodeTaskReminderExists is returned when reminder already exists.
ErrCodeTaskReminderExists = 1470613
)
// taskAPIHints carries the task-specific recovery hint for each known Lark API
// code, layered onto the typed error after errclass.BuildAPIError classifies
// it. errclass.APIHint only covers context-free subtypes (e.g. conflict); these
// hints carry the resource context APIHint intentionally leaves to the caller.
// Authorization (1470403) is omitted: BuildAPIError already attaches the
// canonical permission hint.
var taskAPIHints = map[int]string{
ErrCodeTaskInvalidParams: "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).",
ErrCodeTaskNotFound: "Please verify if the task, tasklist, or group ID is correct and has not been deleted.",
ErrCodeTaskConflict: "Avoid making concurrent API calls using the same client_token.",
ErrCodeTaskInternalError: "Please try again. If the error persists, check the content validity or contact support.",
ErrCodeTaskAssigneeLimit: "The current task has reached the maximum number of assignees.",
ErrCodeTaskFollowerLimit: "The current task has reached the maximum number of followers.",
ErrCodeTasklistMemberLimit: "The current tasklist has reached the maximum number of members.",
ErrCodeTaskReminderExists: "The task already has a reminder set. Remove the existing reminder before adding a new one.",
}
func callTaskAPITyped(runtime *common.RuntimeContext, method, url string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
data, err := runtime.CallAPITyped(method, url, params, body)
return data, applyTaskAPIHint(err)
}
func applyTaskAPIHint(err error) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok {
if hint := taskAPIHints[p.Code]; hint != "" {
p.Hint = hint
}
}
return err
}
// HandleTaskApiResult interprets a parsed Lark API response. A non-zero code is
// classified into a typed errs.* error by errclass.BuildAPIError — Category,
// Subtype, Code, and log_id are sourced from internal/errclass/codemeta_task.go
// — with the task-specific recovery hint (taskAPIHints) layered on top.
func HandleTaskApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
return handleTaskAPIResult(result, err, action, errclass.ClassifyContext{})
}
func HandleTaskApiResultWithContext(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
return handleTaskAPIResult(result, err, action, cc)
}
func handleTaskAPIResult(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
if err != nil {
return nil, err
}
resultMap, _ := result.(map[string]interface{})
codeVal, hasCode := resultMap["code"]
if !hasCode {
// A Lark response always carries a top-level code; its absence (with no
// transport error) means a malformed or unexpected body.
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: unexpected response (missing code field)", action)
}
code, ok := util.ToFloat64(codeVal)
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: malformed response (non-numeric code %v)", action, codeVal)
}
larkCode := int(code)
if larkCode != 0 {
typedErr := errclass.BuildAPIError(resultMap, cc)
return nil, applyTaskAPIHint(typedErr)
}
data, _ := resultMap["data"].(map[string]interface{})
return data, nil
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// truncateTaskURL removes extra query parameters from task applink, keeping only guid.
func truncateTaskURL(u string) string {
if u == "" {
return ""
}
if idx := strings.Index(u, "&"); idx != -1 {
return u[:idx]
}
return u
}
// parseTimeFlagSec parses a time flag that can be absolute (ISO 8601, timestamp) or relative (+/- Nd/w/m/h).
// It returns the Unix seconds string.
func parseTimeFlagSec(input string, hint string) (string, error) {
if isRelativeTime(input) {
t, err := parseRelativeTime(input)
if err != nil {
return "", err
}
// Snap to day if unit is days or weeks
if strings.HasSuffix(input, "d") || strings.HasSuffix(input, "w") {
if hint == "end" {
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
} else {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
}
return fmt.Sprintf("%d", t.Unix()), nil
}
return common.ParseTime(input, hint)
}
+265
View File
@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/smartystreets/goconvey/convey"
)
func TestContains(t *testing.T) {
convey.Convey("contains", t, func() {
list := []string{"a", "b", "c"}
convey.So(contains(list, "a"), convey.ShouldBeTrue)
convey.So(contains(list, "d"), convey.ShouldBeFalse)
convey.So(contains([]string{}, "a"), convey.ShouldBeFalse)
})
}
func TestParseRelativeTime_TypedError(t *testing.T) {
_, err := parseRelativeTime("not-relative")
if err == nil {
t.Fatal("parseRelativeTime(\"not-relative\") expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid relative time format") {
t.Errorf("message = %q, want substring %q", err.Error(), "invalid relative time format")
}
}
// apiResult builds a parsed Lark API response with a non-zero code, as
// HandleTaskApiResult receives it after json.Unmarshal.
func apiResult(code int, msg string) map[string]interface{} {
return map[string]interface{}{"code": float64(code), "msg": msg}
}
// TestHandleTaskApiResult_TypedMapping locks the API code → typed
// category/subtype/exit mapping. Classification is sourced from
// internal/errclass/codemeta_task.go via errclass.BuildAPIError; the
// task-specific recovery hint is layered on from taskAPIHints. 1470400 surfaces
// exit 1 (API-side parameter rejection, was exit 2 under legacy); 1470403
// routes to CategoryAuthorization and surfaces exit 3 (was exit 1).
func TestHandleTaskApiResult_TypedMapping(t *testing.T) {
tests := []struct {
name string
code int
wantSubtype errs.Subtype
wantExit int
wantRetry bool
}{
{"invalid_params", ErrCodeTaskInvalidParams, errs.SubtypeInvalidParameters, output.ExitAPI, false},
{"not_found", ErrCodeTaskNotFound, errs.SubtypeNotFound, output.ExitAPI, false},
{"conflict", ErrCodeTaskConflict, errs.SubtypeConflict, output.ExitAPI, true},
{"internal", ErrCodeTaskInternalError, errs.SubtypeServerError, output.ExitAPI, true},
{"assignee_limit", ErrCodeTaskAssigneeLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
{"follower_limit", ErrCodeTaskFollowerLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
{"member_limit", ErrCodeTasklistMemberLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
{"reminder_exists", ErrCodeTaskReminderExists, errs.SubtypeAlreadyExists, output.ExitAPI, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := HandleTaskApiResult(apiResult(tt.code, "raw upstream detail"), nil, "do thing")
if data != nil {
t.Errorf("data = %v, want nil on error", data)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T, want typed errs.* error", err)
}
if p.Subtype != tt.wantSubtype {
t.Errorf("subtype = %q, want %q", p.Subtype, tt.wantSubtype)
}
if p.Code != tt.code {
t.Errorf("code = %d, want %d", p.Code, tt.code)
}
if got := output.ExitCodeOf(err); got != tt.wantExit {
t.Errorf("exit code = %d, want %d", got, tt.wantExit)
}
if p.Retryable != tt.wantRetry {
t.Errorf("retryable = %v, want %v", p.Retryable, tt.wantRetry)
}
// These CategoryAPI codes carry the task-specific recovery hint.
if p.Hint != taskAPIHints[tt.code] {
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[tt.code])
}
// BuildAPIError uses the raw upstream msg as the message.
if !strings.Contains(err.Error(), "raw upstream detail") {
t.Errorf("message = %q, want raw upstream detail", err.Error())
}
})
}
}
// TestHandleTaskApiResult_PermissionDenied verifies 1470403 routes to a typed
// *errs.PermissionError (exit 3) carrying the canonical permission hint from
// BuildAPIError — taskAPIHints intentionally omits it so the canonical hint
// stands.
func TestHandleTaskApiResult_PermissionDenied(t *testing.T) {
_, err := HandleTaskApiResult(apiResult(ErrCodeTaskPermissionDenied, "no permission"), nil, "do thing")
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("err = %T, want *errs.PermissionError", err)
}
if pe.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypePermissionDenied)
}
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d", got, output.ExitAuth)
}
if strings.TrimSpace(pe.Hint) == "" {
t.Error("expected a canonical permission hint, got empty")
}
}
func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
_, err := HandleTaskApiResultWithContext(map[string]interface{}{
"code": float64(99991672),
"msg": "access denied",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "task:attachment:write"},
},
},
}, nil, "upload task attachment", errclass.ClassifyContext{
Brand: "lark",
AppID: "cli_a123",
Identity: "bot",
})
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("err = %T, want *errs.PermissionError", err)
}
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
}
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
t.Errorf("MissingScopes = %#v, want task:attachment:write", pe.MissingScopes)
}
if !strings.Contains(pe.Hint, pe.ConsoleURL) {
t.Errorf("hint = %q, want to include console URL %q", pe.Hint, pe.ConsoleURL)
}
}
func TestCallTaskAPITyped_TaskHint(t *testing.T) {
cfg := taskTestConfig(t)
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/task/v2/tasks/t-1",
Body: map[string]interface{}{
"code": float64(ErrCodeTaskReminderExists),
"msg": "reminder exists",
},
})
_, err := callTaskAPITyped(rt, http.MethodGet, "/open-apis/task/v2/tasks/t-1", nil, nil)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T, want typed errs.* error", err)
}
if p.Hint != taskAPIHints[ErrCodeTaskReminderExists] {
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[ErrCodeTaskReminderExists])
}
}
// TestHandleTaskApiResult_MalformedResponse covers the two malformed-body arms:
// a response with no top-level code, and one whose code is non-numeric. Both
// must surface a typed internal invalid_response error (exit 5) rather than
// silently passing through as a success.
func TestHandleTaskApiResult_MalformedResponse(t *testing.T) {
cases := []struct {
name string
result map[string]interface{}
}{
{"missing code field", map[string]interface{}{"msg": "weird", "data": map[string]interface{}{}}},
{"non-numeric code", map[string]interface{}{"code": "oops", "msg": "weird"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
data, err := HandleTaskApiResult(tc.result, nil, "do thing")
if data != nil {
t.Errorf("data = %v, want nil", data)
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError", err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
}
})
}
}
// TestHandleTaskApiResult_Success returns the data map unchanged when code == 0.
func TestHandleTaskApiResult_Success(t *testing.T) {
want := map[string]interface{}{"guid": "t-1"}
data, err := HandleTaskApiResult(map[string]interface{}{
"code": float64(0),
"data": want,
}, nil, "do thing")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if data["guid"] != "t-1" {
t.Errorf("data = %v, want guid=t-1", data)
}
}
// TestHandleTaskApiResult_UnknownCode covers the fallback arm: an uncatalogued
// code becomes a generic CategoryAPI error with SubtypeUnknown and no layered
// hint.
func TestHandleTaskApiResult_UnknownCode(t *testing.T) {
_, err := HandleTaskApiResult(apiResult(9999999, "weird"), nil, "do thing")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T, want typed errs.* error", err)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
}
if p.Code != 9999999 {
t.Errorf("code = %d, want 9999999", p.Code)
}
if got := output.ExitCodeOf(err); got != output.ExitAPI {
t.Errorf("exit code = %d, want %d", got, output.ExitAPI)
}
var ae *errs.APIError
if !errors.As(err, &ae) {
t.Errorf("error type = %T, want *errs.APIError", err)
}
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
var AddTaskToTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-task-add",
Description: "add tasks to a tasklist",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "section-guid", Desc: "section guid"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
taskIds := strings.Split(runtime.Str("task-id"), ",")
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
body := map[string]interface{}{
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
tasklistGuid := extractTasklistGuid(runtime.Str("tasklist-id"))
taskIds := strings.Split(runtime.Str("task-id"), ",")
params := map[string]interface{}{"user_id_type": "open_id"}
body := map[string]interface{}{
"tasklist_guid": tasklistGuid,
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
var successful []map[string]interface{}
var failed []map[string]interface{}
for _, taskId := range taskIds {
taskId = strings.TrimSpace(taskId)
if taskId == "" {
continue
}
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId)+"/add_tasklist", params, body)
if err != nil {
failDetail := map[string]interface{}{
"guid": taskId,
}
if p, ok := errs.ProblemOf(err); ok {
failDetail["type"] = string(p.Subtype)
failDetail["code"] = p.Code
failDetail["message"] = p.Message
failDetail["hint"] = p.Hint
} else {
failDetail["type"] = "api_error"
failDetail["message"] = err.Error()
}
failed = append(failed, failDetail)
} else {
task, _ := data["task"].(map[string]interface{})
guid, _ := task["guid"].(string)
taskUrl, _ := task["url"].(string)
taskUrl = truncateTaskURL(taskUrl)
successful = append(successful, map[string]interface{}{
"guid": guid,
"url": taskUrl,
})
}
}
// Standardized write output: return resource identifiers
resultData := map[string]interface{}{
"successful_tasks": successful,
"failed_tasks": failed,
"tasklist_guid": tasklistGuid,
}
// Item-level failures surface as a non-zero exit (ok:false) so callers
// don't have to inspect failed_tasks to detect a partial add; the full
// payload (successful + failed) stays on stdout either way.
if len(failed) > 0 {
return runtime.OutPartialFailure(resultData, nil)
}
runtime.OutFormat(resultData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Tasks added to tasklist %s!\n", tasklistGuid)
fmt.Fprintf(w, "Successful: %d, Failed: %d\n", len(successful), len(failed))
if len(successful) > 0 {
fmt.Fprintln(w, "Successful Tasks:")
for _, t := range successful {
guid, _ := t["guid"].(string)
taskUrl, _ := t["url"].(string)
fmt.Fprintf(w, " - ID: %s", guid)
if taskUrl != "" {
fmt.Fprintf(w, ", URL: %s", taskUrl)
}
fmt.Fprintln(w)
}
}
if len(failed) > 0 {
fmt.Fprintln(w, "Failed Tasks:")
for _, f := range failed {
fmt.Fprintf(w, " - %s: %s\n", f["guid"], f["message"])
}
}
})
return nil
},
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestAddTaskToTasklist_Success(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-1",
},
},
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
t.Errorf("expected tasklist_guid in output, got: %s", out)
}
}
// TestAddTaskToTasklist_PartialFailure exercises the batch path: some tasks
// succeed, others fail with typed API errors. Successful and failed tasks both
// land in stdout as an ok:false envelope, and the command returns the typed
// partial-failure exit signal (exit 1) via runtime.OutPartialFailure. The
// failed_tasks[].type carries the typed subtype (e.g. "permission_denied",
// "not_found") read off errs.ProblemOf.
func TestAddTaskToTasklist_PartialFailure(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-ok/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-ok",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-perm/add_tasklist",
Body: map[string]interface{}{
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-missing/add_tasklist",
Body: map[string]interface{}{
"code": ErrCodeTaskNotFound, "msg": "task not found",
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-ok,task-perm,task-missing", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
// Partial failure now surfaces as a non-zero exit (ok:false), not nil.
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError on partial failure, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
out := stdout.String()
// Successful task is in stdout.
if !strings.Contains(out, "task-ok") {
t.Errorf("expected successful task-ok in output, got: %s", out)
}
// Failed tasks carry the typed subtype, not the legacy Detail.Type.
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
}
if !strings.Contains(out, string(errs.SubtypeNotFound)) {
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypeNotFound, out)
}
// The legacy shapes must not leak.
if strings.Contains(out, "permission_error") {
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
}
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
var CreateTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-create",
Description: "create a tasklist and optionally add tasks",
Risk: "write",
Scopes: []string{"task:tasklist:write", "task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "name", Desc: "tasklist name", Required: true},
{Name: "member", Desc: "comma-separated open_ids to add as editors"},
{Name: "data", Desc: "JSON array of tasks to create within this tasklist"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := buildTasklistCreateBody(runtime)
d := common.NewDryRunAPI().
Desc("1. Create Tasklist").
POST("/open-apis/task/v2/tasklists").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
if dataStr := runtime.Str("data"); dataStr != "" {
d.Desc("2. Create Tasks within the new tasklist (concurrently)")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildTasklistCreateBody(runtime)
params := map[string]interface{}{"user_id_type": "open_id"}
// Validate --data (client input) before any remote write, so a malformed
// payload fails fast without creating an orphan tasklist.
var tasks []map[string]interface{}
if dataStr := runtime.Str("data"); dataStr != "" {
if err := json.Unmarshal([]byte(dataStr), &tasks); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse --data as JSON array: %v", err).WithParam("--data")
}
}
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists", params, body)
if err != nil {
return err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
tasklistGuid, _ := tasklist["guid"].(string)
tasklistName, _ := tasklist["name"].(string)
tasklistUrl, _ := tasklist["url"].(string)
tasklistUrl = truncateTaskURL(tasklistUrl)
var createdTasks []map[string]interface{}
var failedTasks []map[string]interface{}
if len(tasks) > 0 {
var wg sync.WaitGroup
var mu sync.Mutex
for i, taskDef := range tasks {
wg.Add(1)
go func(idx int, tDef map[string]interface{}) {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(runtime.IO().ErrOut, "recovered in defer: %v\n", r)
}
wg.Done()
}()
// Add tasklist_guid to the task definition
tDef["tasklists"] = []map[string]interface{}{
{
"tasklist_guid": tasklistGuid,
},
}
// If assignee is provided as string, convert it to members
if assignee, ok := tDef["assignee"].(string); ok {
tDef["members"] = []map[string]interface{}{
{
"id": assignee,
"role": "assignee",
"type": "user",
},
}
delete(tDef, "assignee")
}
tData, tErr := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, tDef)
mu.Lock()
defer mu.Unlock()
if tErr != nil {
summary, _ := tDef["summary"].(string)
failedTasks = append(failedTasks, buildTaskCreateFailure(idx, summary, tErr))
return
}
if t, ok := tData["task"].(map[string]interface{}); ok {
guid, _ := t["guid"].(string)
urlVal, _ := t["url"].(string)
urlVal = truncateTaskURL(urlVal)
createdTasks = append(createdTasks, map[string]interface{}{
"guid": guid,
"url": urlVal,
})
}
}(i, taskDef)
}
wg.Wait()
}
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": tasklistGuid,
"url": tasklistUrl,
"created_tasks": createdTasks,
"failed_tasks": failedTasks,
}
pretty := func(w io.Writer) {
fmt.Fprintf(w, "✅ Tasklist created successfully!\n")
fmt.Fprintf(w, "Tasklist Name: %s\n", tasklistName)
fmt.Fprintf(w, "Tasklist ID: %s\n", tasklistGuid)
if tasklistUrl != "" {
fmt.Fprintf(w, "Tasklist URL: %s\n", tasklistUrl)
}
if len(tasks) > 0 {
fmt.Fprintln(w, strings.Repeat("-", 20))
fmt.Fprintf(w, "Tasks created: %d/%d\n", len(createdTasks), len(tasks))
for _, t := range createdTasks {
guid, _ := t["guid"].(string)
urlVal, _ := t["url"].(string)
fmt.Fprintf(w, " - ID: %s", guid)
if urlVal != "" {
fmt.Fprintf(w, ", URL: %s", urlVal)
}
fmt.Fprintln(w)
}
if len(failedTasks) > 0 {
fmt.Fprintf(w, "\nFailed tasks:\n")
for _, f := range failedTasks {
fmt.Fprintf(w, " - Index %v (%s): %s\n", f["index"], f["summary"], f["message"])
}
}
}
}
// Sub-task creation failures surface as a non-zero exit. JSON/JQ callers
// need an ok:false envelope, while pretty output should preserve the
// command-specific human-readable summary.
if len(failedTasks) > 0 {
if runtime.Format == "pretty" && runtime.JqExpr == "" {
runtime.OutFormat(outData, nil, pretty)
return output.PartialFailure(output.ExitAPI)
}
return runtime.OutPartialFailure(outData, nil)
}
runtime.OutFormat(outData, nil, pretty)
return nil
},
}
func buildTaskCreateFailure(index int, summary string, err error) map[string]interface{} {
failDetail := map[string]interface{}{
"index": index,
"summary": summary,
}
if p, ok := errs.ProblemOf(err); ok {
failDetail["type"] = string(p.Subtype)
failDetail["code"] = p.Code
failDetail["message"] = p.Message
failDetail["hint"] = p.Hint
} else {
failDetail["type"] = "api_error"
failDetail["message"] = err.Error()
}
return failDetail
}
func buildTasklistCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": runtime.Str("name"),
}
if memberStr := runtime.Str("member"); memberStr != "" {
ids := strings.Split(memberStr, ",")
var members []map[string]interface{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
members = append(members, map[string]interface{}{
"id": id,
"role": "editor",
"type": "user",
})
}
body["members"] = members
}
return body
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestCreateTasklist_PartialFailure exercises the batch sub-task path: the
// tasklist is created (code 0), then two sub-tasks are created concurrently —
// one succeeds, one fails with a typed API error. The command returns the typed
// partial-failure exit signal (*output.PartialFailureError, ExitAPI) via
// runtime.OutPartialFailure, and stdout carries both created_tasks (the
// success) and failed_tasks (the failure) so the partial result is inspectable.
// Sub-tasks are routed by summary via BodyFilter because both POST the same
// /tasks URL and run on separate goroutines.
func TestCreateTasklist_PartialFailure(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{
"guid": "tl-new",
"name": "My List",
"url": "https://example.feishu.cn/tl-new",
},
},
},
})
// Succeeding sub-task (summary "ok-task").
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-ok",
"url": "https://example.feishu.cn/task-ok",
},
},
},
})
// Failing sub-task (summary "bad-task") → typed permission_denied.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
Body: map[string]interface{}{
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
},
})
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
data := `[{"summary":"ok-task"},{"summary":"bad-task"}]`
args := []string{"+tasklist-create", "--name", "My List", "--data", data, "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
out := stdout.String()
// The tasklist itself is created and stays in the payload.
if !strings.Contains(out, "tl-new") {
t.Errorf("expected created tasklist guid tl-new in output, got: %s", out)
}
// Success lands in created_tasks.
if !strings.Contains(out, "task-ok") {
t.Errorf("expected created sub-task task-ok in output, got: %s", out)
}
// Failure lands in failed_tasks (keyed by index + summary).
if !strings.Contains(out, "bad-task") {
t.Errorf("expected failed sub-task bad-task in output, got: %s", out)
}
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
}
if !strings.Contains(out, `"code": 1470403`) && !strings.Contains(out, `"code":1470403`) {
t.Errorf("expected task permission code in failed_tasks, got: %s", out)
}
if strings.Contains(out, "permission_error") {
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
}
}
func TestCreateTasklist_PartialFailurePrettyOutput(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{
"guid": "tl-new",
"name": "My List",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-ok"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks",
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
Body: map[string]interface{}{"code": ErrCodeTaskPermissionDenied, "msg": "no permission"},
})
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, []string{
"+tasklist-create",
"--name", "My List",
"--data", `[{"summary":"ok-task"},{"summary":"bad-task"}]`,
"--as", "bot",
"--format", "pretty",
}, f, stdout)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
}
out := stdout.String()
for _, want := range []string{
"Tasklist created successfully",
"Tasks created: 1/2",
"Failed tasks:",
"Index",
"bad-task",
"user lacks permission",
} {
if !strings.Contains(out, want) {
t.Errorf("pretty output missing %q; got:\n%s", want, out)
}
}
if strings.Contains(out, `"ok":`) {
t.Errorf("pretty partial failure should use text output, got JSON envelope:\n%s", out)
}
}
// TestCreateTasklist_InvalidDataJSON covers the --data validation arm: a string
// that is not a JSON array must surface a typed *errs.ValidationError
// (invalid_argument, exit 2) after the tasklist create succeeds.
func TestCreateTasklist_InvalidDataJSON(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
// No POST /tasklists stub is registered on purpose: invalid --data must be
// rejected before any remote write, leaving no orphan tasklist. If the
// ordering regressed (create first), the POST would hit no stub and surface
// as a non-validation transport error, failing the assertion below.
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-create", "--name", "My List", "--data", "{not-an-array", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
}
}
// TestCreateTasklist_MalformedResponse covers the create-tasklist parse arm: a
// 200 with a non-JSON body must surface a typed
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard.
func TestCreateTasklist_MalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists",
RawBody: []byte("not json"),
})
s := CreateTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-create", "--name", "My List", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d (ExitInternal)", got, output.ExitInternal)
}
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
var MembersTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-members",
Description: "manage tasklist members",
Risk: "write",
Scopes: []string{"task:tasklist:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
{Name: "set", Desc: "comma-separated open_ids to set as exact members (replaces existing)"},
{Name: "add", Desc: "comma-separated open_ids to add as members"},
{Name: "remove", Desc: "comma-separated open_ids to remove from members"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
hasSet := runtime.Str("set") != ""
hasAdd := runtime.Str("add") != ""
hasRemove := runtime.Str("remove") != ""
if hasSet && (hasAdd || hasRemove) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --set with --add or --remove")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
tlId := url.PathEscape(extractTasklistGuid(runtime.Str("tasklist-id")))
if runtime.Str("set") != "" || (runtime.Str("add") == "" && runtime.Str("remove") == "") {
d.Desc("GET tasklist details/members").
GET("/open-apis/task/v2/tasklists/" + tlId).
Params(map[string]interface{}{"user_id_type": "open_id"})
}
if runtime.Str("add") != "" {
body := buildTlMembersBody(runtime.Str("add"))
d.Desc("Add members").
POST("/open-apis/task/v2/tasklists/" + tlId + "/add_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
if runtime.Str("remove") != "" {
body := buildTlMembersBody(runtime.Str("remove"))
d.Desc("Remove members").
POST("/open-apis/task/v2/tasklists/" + tlId + "/remove_members").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(body)
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
tlId := url.PathEscape(extractTasklistGuid(runtime.Str("tasklist-id")))
params := map[string]interface{}{"user_id_type": "open_id"}
setStr := runtime.Str("set")
addStr := runtime.Str("add")
removeStr := runtime.Str("remove")
// If no modifications, just list
if setStr == "" && addStr == "" && removeStr == "" {
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
if err != nil {
return err
}
tl, _ := data["tasklist"].(map[string]interface{})
membersRaw, _ := tl["members"].([]interface{})
tlUrl, _ := tl["url"].(string)
tlUrl = truncateTaskURL(tlUrl)
var members []interface{}
for _, m := range membersRaw {
if mObj, ok := m.(map[string]interface{}); ok {
members = append(members, map[string]interface{}{
"id": mObj["id"],
"role": mObj["role"],
"type": mObj["type"],
})
}
}
outData := map[string]interface{}{
"guid": tlId,
"url": tlUrl,
"name": tl["name"],
"members": members,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "Tasklist: %s (%s)\n", tl["name"], tlId)
if tlUrl != "" {
fmt.Fprintf(w, "Tasklist URL: %s\n", tlUrl)
}
fmt.Fprintf(w, "Members (%d):\n", len(members))
for _, m := range members {
if mObj, ok := m.(map[string]interface{}); ok {
fmt.Fprintf(w, " - %s (%s)\n", mObj["id"], mObj["role"])
}
}
})
return nil
}
var lastTasklist map[string]interface{}
if setStr != "" {
// Query existing to diff for "set" behavior
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
if err != nil {
return err
}
lastTasklist, _ = data["tasklist"].(map[string]interface{})
var existingIds []string
if members, ok := lastTasklist["members"].([]interface{}); ok {
for _, m := range members {
if mObj, ok := m.(map[string]interface{}); ok {
if id, ok := mObj["id"].(string); ok {
existingIds = append(existingIds, id)
}
}
}
}
targetIds := strings.Split(setStr, ",")
var targetClean []string
for _, t := range targetIds {
t = strings.TrimSpace(t)
if t != "" {
targetClean = append(targetClean, t)
}
}
// Diff
var toAdd []string
var toRemove []string
for _, t := range targetClean {
if !contains(existingIds, t) {
toAdd = append(toAdd, t)
}
}
for _, e := range existingIds {
if !contains(targetClean, e) {
toRemove = append(toRemove, e)
}
}
if len(toAdd) > 0 {
body := buildTlMembersBody(strings.Join(toAdd, ","))
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
if err != nil {
return err
}
lastTasklist, _ = data["tasklist"].(map[string]interface{})
}
if len(toRemove) > 0 {
body := buildTlMembersBody(strings.Join(toRemove, ","))
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
if err != nil {
return err
}
lastTasklist, _ = data["tasklist"].(map[string]interface{})
}
} else {
// Add / Remove mode
if addStr != "" {
body := buildTlMembersBody(addStr)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
if err != nil {
return err
}
lastTasklist, _ = data["tasklist"].(map[string]interface{})
}
if removeStr != "" {
body := buildTlMembersBody(removeStr)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
if err != nil {
return err
}
lastTasklist, _ = data["tasklist"].(map[string]interface{})
}
}
tlUrl, _ := lastTasklist["url"].(string)
tlUrl = truncateTaskURL(tlUrl)
// Standardized write output: return resource identifiers
outData := map[string]interface{}{
"guid": tlId,
"url": tlUrl,
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Tasklist members updated successfully!\n")
fmt.Fprintf(w, "Tasklist ID: %s\n", tlId)
if tlUrl != "" {
fmt.Fprintf(w, "Tasklist URL: %s\n", tlUrl)
}
})
return nil
},
}
func buildTlMembersBody(idsStr string) map[string]interface{} {
ids := strings.Split(idsStr, ",")
var members []map[string]interface{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
members = append(members, map[string]interface{}{
"id": id,
"role": "editor",
"type": "user",
})
}
return map[string]interface{}{
"members": members,
}
}
+199
View File
@@ -0,0 +1,199 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"errors"
"testing"
"github.com/smartystreets/goconvey/convey"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestBuildTlMembersBody(t *testing.T) {
convey.Convey("Build with ids", t, func() {
body := buildTlMembersBody("u1, u2 , ")
members := body["members"].([]map[string]interface{})
convey.So(len(members), convey.ShouldEqual, 2)
})
}
// TestMembersTasklist_SetCombinedWithAddRejected covers the Validate guard:
// --set is mutually exclusive with --add/--remove. It must surface a typed
// *errs.ValidationError (exit 2) before any API call is made.
func TestMembersTasklist_SetCombinedWithAddRejected(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--add", "ou_b", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (ExitValidation)", got, output.ExitValidation)
}
}
// TestMembersTasklist_ListMalformedResponse covers the list arm (no
// set/add/remove): a 200 with a non-JSON body must surface a typed
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard,
// not a silent success.
func TestMembersTasklist_ListMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_SetMalformedResponse covers the --set arm: the diff path
// first GETs the tasklist; a non-JSON body there must surface the typed
// internal invalid_response error.
func TestMembersTasklist_SetMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_AddMalformedResponse covers the add/remove arm: the POST
// to add_members returns a non-JSON body, which must surface the typed internal
// invalid_response error.
func TestMembersTasklist_AddMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/tl-123/add_members",
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--add", "ou_a", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_SetRemoveDiffMalformedResponse covers the --set diff's
// remove_members arm: the GET returns an existing member absent from the target
// set, so the shortcut issues a remove_members POST whose 200 carries a
// non-JSON body, which must surface the typed internal invalid_response error.
// The target equals one existing member, so no add_members call precedes it.
func TestMembersTasklist_SetRemoveDiffMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
Status: 200,
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{
"url": "https://example.com/tl-123",
"members": []interface{}{
map[string]interface{}{"id": "ou_keep"},
map[string]interface{}{"id": "ou_drop"},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
Status: 200,
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_keep", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// TestMembersTasklist_RemoveMalformedResponse covers the add/remove mode's
// remove_members arm: with only --remove set, the add arm is skipped and the
// remove_members POST returns a non-JSON body, which must surface the typed
// internal invalid_response error.
func TestMembersTasklist_RemoveMalformedResponse(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
Status: 200,
RawBody: []byte("not json"),
})
s := MembersTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--remove", "ou_a", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
assertInvalidResponse(t, err)
}
// assertInvalidResponse asserts a typed *errs.InternalError(invalid_response)
// with exit 5 — the contract for a parse-response failure across the members
// arms.
func assertInvalidResponse(t *testing.T, err error) {
t.Helper()
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if got := output.ExitCodeOf(err); got != output.ExitInternal {
t.Errorf("exit code = %d, want %d (ExitInternal)", got, output.ExitInternal)
}
}