-
Notifications
You must be signed in to change notification settings - Fork 476
feat(task): add task shortcuts with skill docs and tests #377
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ILUO
wants to merge
23
commits into
larksuite:main
Choose a base branch
from
ILUO:feat/task-shortcuts-skill-docs-tests
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
b014acc
feat(task): add task shortcuts with skill docs and tests
ILUO a60d16d
fix(task): rename subscribe-event shortcut
ILUO 9601035
Merge branch 'larksuite:main' into feat/task-shortcuts-skill-docs-tests
ILUO 32c8043
docs(task): document task event payload shape
ILUO 9bc54e9
fix(task): validate subscribe-event api response
ILUO a09c568
refactor(task): remove unused buildUserIDs helper
ILUO 83ff0e6
fix(task): handle api error codes in set-ancestor
ILUO 8741e7c
test(task): avoid widening auth types in subscribe-event
ILUO b0d4b97
docs(task): clarify get-related-tasks page-token unit
ILUO c6d3bdf
fix(task): avoid nil names in tasklist-search pretty output
ILUO ff5b927
fix(task): reject reversed time ranges
ILUO f766d32
feat(task): support bot identity for subscribe-event
ILUO 84b729f
docs(task): clarify bot subscribe-event scope
ILUO e3bfd26
docs(task): clarify related-task pagination semantics
ILUO 168b01c
docs(task): add BOE selftest report (boe_task_tasklist_oapi_support)
ILUO b1f4c2c
fix(task): use rfc3339 time filters for search endpoints
ILUO 920d870
docs(task): prefer related-task shortcuts over search for scoped queries
ILUO a2fc95c
docs(task): clarify tasklist search routing
ILUO 3fcdfaa
docs(task): route keywordless tasklist queries to list API
ILUO a470a26
docs(task): refine search routing heuristics
ILUO 6624f05
Merge branch 'larksuite:main' into feat/task-shortcuts-skill-docs-tests
ILUO 893aeca
feat(event): include task user-access updates in catch-all subscribe
ILUO 9dd7bba
docs(task): remove auth status --json guidance
ILUO File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| // Copyright (c) 2026 Lark Technologies Pte. Ltd. | ||
| // SPDX-License-Identifier: MIT | ||
|
|
||
| package task | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "strings" | ||
|
|
||
| larkcore "github.com/larksuite/oapi-sdk-go/v3/core" | ||
|
|
||
| "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 { | ||
| queryParams := make(larkcore.QueryParams) | ||
| queryParams.Set("user_id_type", "open_id") | ||
| queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize)) | ||
| if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") { | ||
| queryParams.Set("completed", "false") | ||
| } | ||
| if pageToken := runtime.Str("page-token"); pageToken != "" { | ||
| queryParams.Set("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++ { | ||
| apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ | ||
| HttpMethod: http.MethodGet, | ||
| ApiPath: "/open-apis/task/v2/task_v2/list_related_task", | ||
| QueryParams: queryParams, | ||
| }) | ||
| var result map[string]interface{} | ||
| if err == nil { | ||
| if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { | ||
| return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks") | ||
| } | ||
| } | ||
| data, err := HandleTaskApiResult(result, err, "list related tasks") | ||
| 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 | ||
| } | ||
| queryParams.Set("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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } | ||
| }) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.