From ebc0c53ab55859aebaf8f57397e071665f6be2de Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Thu, 23 Jul 2026 20:48:38 +0800 Subject: [PATCH] fix/task id handling (#2023) * fix: validate task GUID inputs * fix: make task updates self-confirming * fix: confirm task completion state * docs: clarify task ID workflow * test: cover task ID dry runs * fix: address task ID review feedback --- shortcuts/task/shortcuts.go | 35 +++ shortcuts/task/shortcuts_test.go | 80 +++++++ shortcuts/task/task_complete.go | 42 +++- shortcuts/task/task_complete_test.go | 101 +++++++++ shortcuts/task/task_update.go | 74 +++++-- shortcuts/task/task_update_test.go | 201 ++++++++++++++++++ skills/lark-task/SKILL.md | 7 + .../references/lark-task-complete.md | 8 +- .../lark-task/references/lark-task-update.md | 8 +- .../task/task_id_handling_dryrun_test.go | 99 +++++++++ .../task/task_id_handling_workflow_test.go | 70 ++++++ 11 files changed, 693 insertions(+), 32 deletions(-) create mode 100644 shortcuts/task/task_update_test.go create mode 100644 tests/cli_e2e/task/task_id_handling_dryrun_test.go create mode 100644 tests/cli_e2e/task/task_id_handling_workflow_test.go diff --git a/shortcuts/task/shortcuts.go b/shortcuts/task/shortcuts.go index 7de989ca5..4112ebd84 100644 --- a/shortcuts/task/shortcuts.go +++ b/shortcuts/task/shortcuts.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" @@ -100,6 +101,40 @@ func extractTaskGuid(input string) string { return extractTasklistGuid(input) } +var taskDisplayNumberPattern = regexp.MustCompile(`^t[0-9]+$`) + +func parseTaskGUID(input string) (string, error) { + input = strings.TrimSpace(input) + invalid := func(format string, args ...interface{}) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...). + WithParam("--task-id"). + WithHint("provide the Task OpenAPI GUID or a task applink containing guid=") + } + + if input == "" { + return "", invalid("task ID is empty") + } + + lowerInput := strings.ToLower(input) + if strings.HasPrefix(lowerInput, "http://") || strings.HasPrefix(lowerInput, "https://") { + u, err := url.Parse(input) + if err != nil { + return "", invalid("invalid task applink: %v", err).WithCause(err) + } + guid := strings.TrimSpace(u.Query().Get("guid")) + if guid == "" { + return "", invalid("task applink is missing a non-empty guid query parameter") + } + return guid, nil + } + + if taskDisplayNumberPattern.MatchString(input) { + return "", invalid("task display number %q is not a Task OpenAPI GUID", input) + } + + return input, nil +} + func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { body := make(map[string]interface{}) diff --git a/shortcuts/task/shortcuts_test.go b/shortcuts/task/shortcuts_test.go index 0b4a65420..29f525181 100644 --- a/shortcuts/task/shortcuts_test.go +++ b/shortcuts/task/shortcuts_test.go @@ -4,8 +4,11 @@ package task import ( + "errors" + "net/url" "testing" + "github.com/larksuite/cli/errs" "github.com/smartystreets/goconvey/convey" ) @@ -15,3 +18,80 @@ func TestShortcutsRegistration(t *testing.T) { convey.So(len(list), convey.ShouldBeGreaterThan, 0) }) } + +func TestParseTaskGUID(t *testing.T) { + t.Run("accepts GUIDs and task applinks", func(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {name: "opaque GUID", input: "task-guid-123", want: "task-guid-123"}, + {name: "trimmed GUID", input: " task-guid-123 ", want: "task-guid-123"}, + { + name: "task applink", + input: "https://applink.larksuite.com/client/todo/detail?guid=task-guid-123", + want: "task-guid-123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTaskGUID(tt.input) + if err != nil { + t.Fatalf("parseTaskGUID(%q) error = %v", tt.input, err) + } + if got != tt.want { + t.Fatalf("parseTaskGUID(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } + }) + + t.Run("rejects unusable task identifiers", func(t *testing.T) { + for _, input := range []string{ + "", + "https://applink.larksuite.com/client/todo/detail", + "https://%", + "t12345", + } { + t.Run(input, func(t *testing.T) { + _, err := parseTaskGUID(input) + if err == nil { + t.Fatalf("parseTaskGUID(%q) error = nil, want typed validation error", input) + } + + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("parseTaskGUID(%q) error type = %T, want typed error", input, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument) + } + if problem.Hint == "" { + t.Fatal("problem hint is empty") + } + + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != "--task-id" { + t.Fatalf("param = %q, want %q", validationErr.Param, "--task-id") + } + }) + } + }) + + t.Run("preserves applink parse cause", func(t *testing.T) { + _, err := parseTaskGUID("https://%") + if err == nil { + t.Fatal("parseTaskGUID() error = nil, want URL parse error") + } + + var urlErr *url.Error + if !errors.As(err, &urlErr) { + t.Fatalf("error chain = %T %v, want *url.Error cause", err, err) + } + }) +} diff --git a/shortcuts/task/task_complete.go b/shortcuts/task/task_complete.go index 8f6d81951..9b4688657 100644 --- a/shortcuts/task/task_complete.go +++ b/shortcuts/task/task_complete.go @@ -25,45 +25,59 @@ var CompleteTask = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ - {Name: "task-id", Desc: "task id", Required: true}, + {Name: "task-id", Desc: "task GUID or task applink URL", Required: true}, + }, + + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := parseTaskGUID(runtime.Str("task-id")) + return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body := buildCompleteBody() - taskId := url.PathEscape(runtime.Str("task-id")) + taskGUID, err := parseTaskGUID(runtime.Str("task-id")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + taskID := url.PathEscape(taskGUID) return common.NewDryRunAPI(). - GET("/open-apis/task/v2/tasks/" + taskId). + 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). + 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")) + taskGUID, err := parseTaskGUID(runtime.Str("task-id")) + if err != nil { + return err + } + taskID := url.PathEscape(taskGUID) 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) + 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) + alreadyCompleted := completedAtStr != "" && completedAtStr != "0" // 2. If already completed, directly return success - if completedAtStr != "" && completedAtStr != "0" { + if alreadyCompleted { data = getData } else { // 3. Complete the task body := buildCompleteBody() - data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body) + data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskID, params, body) if err != nil { return err } @@ -73,11 +87,19 @@ var CompleteTask = common.Shortcut{ guid, _ := task["guid"].(string) urlVal, _ := task["url"].(string) urlVal = truncateTaskURL(urlVal) + completedAt, _ := task["completed_at"].(string) + status := "todo" + if completedAt != "" && completedAt != "0" { + status = "done" + } // Standardized write output: return resource identifiers outData := map[string]interface{}{ - "guid": guid, - "url": urlVal, + "guid": guid, + "url": urlVal, + "status": status, + "completed_at": completedAt, + "already_completed": alreadyCompleted, } runtime.OutFormat(outData, nil, func(w io.Writer) { diff --git a/shortcuts/task/task_complete_test.go b/shortcuts/task/task_complete_test.go index 36f0ef928..7394324bc 100644 --- a/shortcuts/task/task_complete_test.go +++ b/shortcuts/task/task_complete_test.go @@ -4,9 +4,12 @@ package task import ( + "encoding/json" + "errors" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/httpmock" ) @@ -45,6 +48,9 @@ func TestCompleteTask(t *testing.T) { formatFlag: "json", expectedOutput: []string{ `"guid": "task-789"`, + `"status": "done"`, + `"completed_at": "1775174400000"`, + `"already_completed": false`, }, }, } @@ -109,3 +115,98 @@ func TestCompleteTask(t *testing.T) { }) } } + +func TestTaskCompleteAcceptsTaskApplink(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + for _, method := range []string{"GET", "PATCH"} { + reg.Register(&httpmock.Stub{ + Method: method, + URL: "/open-apis/task/v2/tasks/task-guid-applink", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": "task-guid-applink", + "summary": "Applink task", + "completed_at": map[string]string{"GET": "0", "PATCH": "1775174400000"}[method], + "url": "https://example.com/task-guid-applink", + }, + }, + }, + }) + } + + err := runMountedTaskShortcut(t, CompleteTask, []string{ + "+complete", + "--task-id", "https://applink.larksuite.com/client/todo/detail?guid=task-guid-applink", + "--format", "json", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("CompleteTask error = %v", err) + } + reg.Verify(t) + if !strings.Contains(stdout.String(), `"guid": "task-guid-applink"`) { + t.Fatalf("output = %s, want normalized task GUID", stdout.String()) + } +} + +func TestTaskCompleteAlreadyCompletedReturnsServerState(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks/task-guid-done", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": "task-guid-done", + "summary": "Already done", + "completed_at": "1775174400000", + "url": "https://example.com/task-guid-done", + }, + }, + }, + }) + + err := runMountedTaskShortcut(t, CompleteTask, []string{ + "+complete", "--task-id", "task-guid-done", "--format", "json", "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("CompleteTask error = %v", err) + } + reg.Verify(t) + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data["status"] != "done" || data["completed_at"] != "1775174400000" || data["already_completed"] != true { + t.Fatalf("completion state = %#v, want done/already_completed server state", data) + } +} + +func TestTaskCompleteRejectsDisplayNumberBeforeRead(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + err := runMountedTaskShortcut(t, CompleteTask, []string{ + "+complete", "--task-id", "t12345", "--format", "json", "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("CompleteTask error = nil, want invalid task ID error") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("error = %T %v, want typed invalid-argument error", err, err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" { + t.Fatalf("error param = %#v, want --task-id", validationErr) + } +} diff --git a/shortcuts/task/task_update.go b/shortcuts/task/task_update.go index e12c33b10..00cf7f8c4 100644 --- a/shortcuts/task/task_update.go +++ b/shortcuts/task/task_update.go @@ -27,27 +27,42 @@ var UpdateTask = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ - {Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true}, + {Name: "task-id", Desc: "task GUID or task applink URL (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"}, }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := parseTaskGUIDs(runtime.Str("task-id")) + return err + }, + 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) + taskIDs, err := parseTaskGUIDs(runtime.Str("task-id")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + preview := common.NewDryRunAPI() + for _, taskID := range taskIDs { + preview.PATCH("/open-apis/task/v2/tasks/" + url.PathEscape(taskID)). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(body) + } + return preview }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + taskIDs, err := parseTaskGUIDs(runtime.Str("task-id")) + if err != nil { + return err + } + body, err := buildTaskUpdateBody(runtime) if err != nil { // buildTaskUpdateBody already returns a typed validation error; @@ -55,17 +70,11 @@ var UpdateTask = common.Shortcut{ 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 - } - + for _, taskID := range taskIDs { 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) + data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, body) if err != nil { return err } @@ -76,19 +85,28 @@ var UpdateTask = common.Shortcut{ } } + updateFields, _ := body["update_fields"].([]string) var tasks []map[string]interface{} for _, task := range updatedTasks { guid, _ := task["guid"].(string) urlVal, _ := task["url"].(string) urlVal = truncateTaskURL(urlVal) + confirmed := make(map[string]interface{}) + for _, field := range updateFields { + if value, ok := task[field]; ok { + confirmed[field] = value + } + } tasks = append(tasks, map[string]interface{}{ - "guid": guid, - "url": urlVal, + "guid": guid, + "url": urlVal, + "confirmed": confirmed, }) } // Standardized write output: return resource identifiers outData := map[string]interface{}{ - "tasks": tasks, + "updated_fields": updateFields, + "tasks": tasks, } runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) { @@ -112,6 +130,26 @@ var UpdateTask = common.Shortcut{ }, } +func parseTaskGUIDs(input string) ([]string, error) { + parts := strings.Split(input, ",") + taskGUIDs := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.TrimSpace(part) == "" { + continue + } + guid, err := parseTaskGUID(part) + if err != nil { + return nil, err + } + taskGUIDs = append(taskGUIDs, guid) + } + if len(taskGUIDs) == 0 { + _, err := parseTaskGUID("") + return nil, err + } + return taskGUIDs, nil +} + func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { taskObj := make(map[string]interface{}) var updateFields []string diff --git a/shortcuts/task/task_update_test.go b/shortcuts/task/task_update_test.go new file mode 100644 index 000000000..396477db7 --- /dev/null +++ b/shortcuts/task/task_update_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package task + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestParseTaskGUIDs(t *testing.T) { + got, err := parseTaskGUIDs(" task-guid-1, https://applink.larksuite.com/client/todo/detail?guid=task-guid-2 ") + if err != nil { + t.Fatalf("parseTaskGUIDs() error = %v", err) + } + want := []string{"task-guid-1", "task-guid-2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseTaskGUIDs() = %v, want %v", got, want) + } + + _, err = parseTaskGUIDs("task-guid-1,t12345") + if err == nil { + t.Fatal("parseTaskGUIDs() error = nil, want invalid display-number error") + } +} + +func TestTaskUpdateDryRunPreviewsEveryTaskID(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", "") + cmd.Flags().String("summary", "updated", "") + cmd.Flags().String("description", "", "") + cmd.Flags().String("due", "", "") + cmd.Flags().String("data", "", "") + + preview := UpdateTask.DryRun(context.Background(), &common.RuntimeContext{Cmd: cmd}) + payload, err := json.Marshal(preview) + if err != nil { + t.Fatalf("marshal dry-run preview: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(payload, &got); err != nil { + t.Fatalf("decode dry-run preview: %v", err) + } + if len(got.API) != 2 { + t.Fatalf("dry-run API calls = %d, want 2; payload: %s", len(got.API), payload) + } + + wantURLs := []string{ + "/open-apis/task/v2/tasks/task-guid-1", + "/open-apis/task/v2/tasks/task-guid-2", + } + for i, call := range got.API { + if call.Method != "PATCH" { + t.Errorf("api[%d].method = %q, want PATCH", i, call.Method) + } + if call.URL != wantURLs[i] { + t.Errorf("api[%d].url = %q, want %q", i, call.URL, wantURLs[i]) + } + if !reflect.DeepEqual(call.Params, map[string]interface{}{"user_id_type": "open_id"}) { + t.Errorf("api[%d].params = %#v", i, call.Params) + } + if !reflect.DeepEqual(call.Body, got.API[0].Body) { + t.Errorf("api[%d].body = %#v, want same body as first call %#v", i, call.Body, got.API[0].Body) + } + } +} + +func TestTaskUpdateNormalizesAllIDsAndReturnsConfirmedFields(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + first := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/task/v2/tasks/task-guid-1", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": "task-guid-1", + "url": "https://example.com/task-guid-1", + "summary": "server summary one", + "description": "server description one", + }, + }, + }, + } + second := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/task/v2/tasks/task-guid-2", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "task": map[string]interface{}{ + "guid": "task-guid-2", + "url": "https://example.com/task-guid-2", + "summary": "server summary two", + }, + }, + }, + } + reg.Register(first) + reg.Register(second) + + err := runMountedTaskShortcut(t, UpdateTask, []string{ + "+update", + "--task-id", "task-guid-1,https://applink.larksuite.com/client/todo/detail?guid=task-guid-2", + "--summary", "requested summary", + "--description", "requested description", + "--format", "json", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("UpdateTask error = %v", err) + } + reg.Verify(t) + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + data, ok := envelope["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data = %#v, want object", envelope["data"]) + } + if got := stringSlice(data["updated_fields"]); !reflect.DeepEqual(got, []string{"summary", "description"}) { + t.Fatalf("updated_fields = %v, want [summary description]", got) + } + + tasks, ok := data["tasks"].([]interface{}) + if !ok || len(tasks) != 2 { + t.Fatalf("tasks = %#v, want two tasks", data["tasks"]) + } + firstTask := tasks[0].(map[string]interface{}) + if firstTask["guid"] != "task-guid-1" || firstTask["url"] != "https://example.com/task-guid-1" { + t.Fatalf("first task identifiers = %#v", firstTask) + } + if got := firstTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{ + "summary": "server summary one", "description": "server description one", + }) { + t.Fatalf("first confirmed = %#v", got) + } + + secondTask := tasks[1].(map[string]interface{}) + if got := secondTask["confirmed"]; !reflect.DeepEqual(got, map[string]interface{}{ + "summary": "server summary two", + }) { + t.Fatalf("second confirmed = %#v; omitted server fields must not be echoed from the request", got) + } +} + +func TestTaskUpdateValidatesEveryIDBeforeFirstWrite(t *testing.T) { + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + err := runMountedTaskShortcut(t, UpdateTask, []string{ + "+update", + "--task-id", "task-guid-1,t12345", + "--summary", "must not be written", + "--format", "json", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("UpdateTask error = nil, want invalid task ID error") + } + + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("error = %T %v, want typed invalid-argument error", err, err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" { + t.Fatalf("error param = %#v, want --task-id", validationErr) + } +} + +func stringSlice(value interface{}) []string { + items, _ := value.([]interface{}) + result := make([]string, 0, len(items)) + for _, item := range items { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result +} diff --git a/skills/lark-task/SKILL.md b/skills/lark-task/SKILL.md index 61dedcfdf..a044d4800 100644 --- a/skills/lark-task/SKILL.md +++ b/skills/lark-task/SKILL.md @@ -38,6 +38,13 @@ metadata: > Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识(GUID),不是客户端展示的任务编号(例如 `t104121` / `suite_entity_num`)。 > 对于 Feishu 的任务 applink(例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。 +> **从任务清单定位并修改任务的最短路径**: +> 1. 已知任务清单 GUID 时直接使用,不要先搜索;已知任务清单 applink 时,取 URL query 中的 `guid` 作为 `tasklist_guid`。 +> 2. 只有清单名称或关键词、没有 GUID/applink 时,才调用一次 `+tasklist-search` 解析目标清单。 +> 3. 按原生 API 规则先执行 `lark-cli schema task.tasklists.tasks`,再执行 `lark-cli task tasklists tasks --params '{"tasklist_guid":""}' --as user`。 +> 4. 从清单任务结果中取任务的 `guid`,直接传给 `+update` 或 `+complete`;禁止传客户端展示编号(例如 `t104121`)。这两个 shortcut 也可直接接收包含 `guid=` 的任务 applink。 +> 5. `+update` 返回 `updated_fields` 和每个任务的服务端 `confirmed` 字段;`+complete` 返回 `status`、`completed_at`、`already_completed`。这些字段已确认目标状态时,不要例行追加 `tasks get`;仅在服务端未返回所需字段或用户明确要求完整复核时再查询详情。 + | Shortcut | 说明 | |----------|------| | [`+create`](references/lark-task-create.md) | create a task | diff --git a/skills/lark-task/references/lark-task-complete.md b/skills/lark-task/references/lark-task-complete.md index 364b2a27c..bc9a9da8b 100644 --- a/skills/lark-task/references/lark-task-complete.md +++ b/skills/lark-task/references/lark-task-complete.md @@ -9,19 +9,23 @@ Mark a task as completed. ```bash # Complete a task lark-cli task +complete --task-id "" + +# A task applink is accepted directly; the CLI extracts its guid query value +lark-cli task +complete --task-id "https://applink.larksuite.com/client/todo/task?guid=" ``` ## Parameters | Parameter | Required | Description | |-----------|----------|-------------| -| `--task-id ` | Yes | The task GUID to complete. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. | +| `--task-id ` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Display task IDs such as `t104121` / `suite_entity_num` are rejected. | ## Workflow 1. Confirm the task to complete. 2. Execute the command. -3. Report success. +3. Read `data.status`, `data.completed_at`, and `data.already_completed` from the result. `already_completed: true` means the shortcut observed an already-completed task and skipped the PATCH. +4. Do not routinely call `task tasks get` when the result already reports `status: done` and a non-zero `completed_at`. Query details only if confirmation fields are absent or the user explicitly asks for a full verification. > [!CAUTION] > This is a **Write Operation** -- You must confirm the user's intent before executing. diff --git a/skills/lark-task/references/lark-task-update.md b/skills/lark-task/references/lark-task-update.md index f1b5fca6c..b177ca5e9 100644 --- a/skills/lark-task/references/lark-task-update.md +++ b/skills/lark-task/references/lark-task-update.md @@ -13,6 +13,9 @@ lark-cli task +update --task-id "" --summary "New Summary" # Update multiple tasks' due dates lark-cli task +update --task-id "," --due "+2d" +# A task applink is accepted directly; the CLI extracts its guid query value +lark-cli task +update --task-id "https://applink.larksuite.com/client/todo/task?guid=" --summary "New Summary" + # Update with JSON data lark-cli task +update --task-id "" --data '{"description": "New description"}' ``` @@ -21,7 +24,7 @@ lark-cli task +update --task-id "" --data '{"description": "New descr | Parameter | Required | Description | |-----------|----------|-------------| -| `--task-id ` | Yes | The task GUID to update. Comma-separated task GUIDs are supported for multiple tasks. For Feishu task applinks, use the `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. | +| `--task-id ` | Yes | Task OpenAPI GUID or a task applink containing `guid=`. Comma-separated GUIDs/applinks are supported for multiple tasks. Display task IDs such as `t104121` / `suite_entity_num` are rejected. | | `--summary ` | No | New summary/title for the task. | | `--description ` | No | New description for the task. | | `--due