Skip to content

Commit e753b15

Browse files
authored
fix: expose completion state in my tasks output (#1641)
* fix: expose completion state in my tasks output * test: cover my tasks pretty completion state
1 parent bdffffb commit e753b15

4 files changed

Lines changed: 207 additions & 7 deletions

File tree

shortcuts/task/task_get_my_tasks.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{
200200
for _, item := range filteredItems {
201201
urlVal, _ := item["url"].(string)
202202
urlVal = truncateTaskURL(urlVal)
203+
completed, completedAt := taskCompletionState(item)
203204
outputItem := map[string]interface{}{
204-
"guid": item["guid"],
205-
"summary": item["summary"],
206-
"url": urlVal,
205+
"guid": item["guid"],
206+
"summary": item["summary"],
207+
"url": urlVal,
208+
"completed": completed,
207209
}
208210
if createdAtStr, ok := item["created_at"].(string); ok {
209211
if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil {
210212
outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
211213
}
212214
}
215+
if !completedAt.IsZero() {
216+
outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339)
217+
}
213218
if dueObj, ok := item["due"].(map[string]interface{}); ok {
214219
if tsStr, ok := dueObj["timestamp"].(string); ok {
215220
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
@@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{
237242
summary, _ := item["summary"].(string)
238243
urlVal, _ := item["url"].(string)
239244
urlVal = truncateTaskURL(urlVal)
245+
completed, completedAt := taskCompletionState(item)
240246

241247
var dueTimeStr string
242248
if dueObj, ok := item["due"].(map[string]interface{}); ok {
@@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{
259265
if urlVal != "" {
260266
fmt.Fprintf(w, " URL: %s\n", urlVal)
261267
}
268+
fmt.Fprintf(w, " Completed: %t\n", completed)
269+
if !completedAt.IsZero() {
270+
fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04"))
271+
}
262272
if dueTimeStr != "" {
263273
fmt.Fprintf(w, " Due: %s\n", dueTimeStr)
264274
}
@@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{
278288
return nil
279289
},
280290
}
291+
292+
func taskCompletionState(item map[string]interface{}) (bool, time.Time) {
293+
completedAtStr, _ := item["completed_at"].(string)
294+
if completedAtStr == "" || completedAtStr == "0" {
295+
return false, time.Time{}
296+
}
297+
ts, err := strconv.ParseInt(completedAtStr, 10, 64)
298+
if err != nil {
299+
return false, time.Time{}
300+
}
301+
return true, time.UnixMilli(ts)
302+
}

shortcuts/task/task_get_my_tasks_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
110110
}
111111
}
112112

113+
func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) {
114+
tsMs := int64(1775174400000)
115+
tsStr := strconv.FormatInt(tsMs, 10)
116+
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
117+
118+
f, stdout, _, reg := taskShortcutTestFactory(t)
119+
warmTenantToken(t, f, reg)
120+
121+
reg.Register(&httpmock.Stub{
122+
Method: "GET",
123+
URL: "/open-apis/task/v2/tasks",
124+
Body: map[string]interface{}{
125+
"code": 0, "msg": "success",
126+
"data": map[string]interface{}{
127+
"items": []interface{}{
128+
map[string]interface{}{
129+
"guid": "task-open",
130+
"summary": "Open Task",
131+
"completed_at": "0",
132+
"url": "https://example.com/task-open",
133+
},
134+
map[string]interface{}{
135+
"guid": "task-done",
136+
"summary": "Done Task",
137+
"completed_at": tsStr,
138+
"url": "https://example.com/task-done",
139+
},
140+
},
141+
"has_more": false,
142+
"page_token": "",
143+
},
144+
},
145+
})
146+
147+
s := GetMyTasks
148+
s.AuthTypes = []string{"bot", "user"}
149+
150+
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout)
151+
if err != nil {
152+
t.Fatalf("expected no error, got %v", err)
153+
}
154+
155+
outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`)
156+
for _, expected := range []string{
157+
`"guid": "task-open"`,
158+
`"completed": false`,
159+
`"guid": "task-done"`,
160+
`"completed": true`,
161+
`"completed_at": "` + expectedCompletedAt + `"`,
162+
} {
163+
if !strings.Contains(outNorm, expected) {
164+
t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String())
165+
}
166+
}
167+
}
168+
169+
func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) {
170+
tsMs := int64(1775174400000)
171+
tsStr := strconv.FormatInt(tsMs, 10)
172+
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
173+
174+
f, stdout, _, reg := taskShortcutTestFactory(t)
175+
warmTenantToken(t, f, reg)
176+
177+
reg.Register(&httpmock.Stub{
178+
Method: "GET",
179+
URL: "/open-apis/task/v2/tasks",
180+
Body: map[string]interface{}{
181+
"code": 0, "msg": "success",
182+
"data": map[string]interface{}{
183+
"items": []interface{}{
184+
map[string]interface{}{
185+
"guid": "task-open",
186+
"summary": "Open Task",
187+
"completed_at": "0",
188+
"url": "https://example.com/task-open",
189+
},
190+
map[string]interface{}{
191+
"guid": "task-done",
192+
"summary": "Done Task",
193+
"completed_at": tsStr,
194+
"url": "https://example.com/task-done",
195+
},
196+
},
197+
"has_more": false,
198+
"page_token": "",
199+
},
200+
},
201+
})
202+
203+
s := GetMyTasks
204+
s.AuthTypes = []string{"bot", "user"}
205+
206+
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout)
207+
if err != nil {
208+
t.Fatalf("expected no error, got %v", err)
209+
}
210+
211+
out := stdout.String()
212+
for _, expected := range []string{
213+
"[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n",
214+
"[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n",
215+
} {
216+
if !strings.Contains(out, expected) {
217+
t.Fatalf("output missing expected string (%s), got: %s", expected, out)
218+
}
219+
}
220+
if count := strings.Count(out, "Completed At:"); count != 1 {
221+
t.Fatalf("Completed At count = %d, want 1; output: %s", count, out)
222+
}
223+
}
224+
113225
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
114226
// Execute (--created_at / --due-start / --due-end). The parse runs before any
115227
// API call, so a malformed value deterministically surfaces a typed

tests/cli_e2e/task/coverage.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
## Metrics
44
- Denominator: 29 leaf commands
5-
- Covered: 14
6-
- Coverage: 48.3%
5+
- Covered: 15
6+
- Coverage: 51.7%
77

88
## Summary
99
- TestTask_StatusWorkflow: creates a task via `task +create`, then proves `task +complete`, `task tasks get`, and `task +reopen` through `complete`, `get completed task`, `reopen`, and `get reopened task`; asserts `status` flips between `done` and `todo` and `completed_at` is set then cleared.
@@ -13,9 +13,10 @@
1313
- TestTask_TasklistWorkflowAsBot: runs `create tasklist with task`, then `get tasklist`, `list tasklist tasks`, and `get task`; proves `task +tasklist-create`, `task tasklists get`, `task tasklists tasks`, and `task tasks get` with seeded task payload and task-to-tasklist linkage.
1414
- TestTask_TasklistWorkflowAsUser: creates a tasklist as `--as user`, patches its name through `task tasklists patch`, then proves both `task tasklists get` and `task tasklists list` return the patched tasklist.
1515
- TestTask_TasklistAddTaskWorkflow: creates a standalone tasklist and task, runs `add task to tasklist`, then `list tasklist tasks` and `get task with tasklist link`; proves `task +tasklist-task-add`, `task tasklists tasks`, and `task tasks get`, including no failed tasks in the add response.
16+
- TestTask_GetMyTasksDryRun: validates `task +get-my-tasks --dry-run` request shape for `type=my_tasks`, `user_id_type=open_id`, `completed`, `page_token`, and default `page_size` without calling live APIs.
1617
- Cleanup path note: workflow-created tasks and tasklists are deleted through direct `task tasks delete` / `task tasklists delete` cleanup paths in `helpers_test.go::createTask`, `helpers_test.go::createTasklist`, `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot`, and `tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser`, but those cleanup-only executions are not counted as command coverage because no testcase asserts delete behavior as the primary proof surface.
1718
- Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only.
18-
- Blocked area: `task +get-my-tasks` and `task tasks list` did not return the workflow-created user task deterministically in UAT, so they are left uncovered instead of being counted from flaky list visibility.
19+
- Blocked area: `task +get-my-tasks` live result assertions and `task tasks list` did not return the workflow-created user task deterministically in UAT, so live list visibility remains uncovered instead of being counted from flaky results.
1920
- Blocked area: the remaining user-oriented shortcuts still need deterministic user-owned fixtures or collaborator fixtures beyond the self-owned task created inside the testcase.
2021
- Gap pattern: direct `tasks create/delete/list/patch`, `tasklists create/delete/list/patch`, `members *`, and `subtasks *` APIs still lack deterministic direct-call workflows, so shortcut coverage does not count for those leaf commands.
2122

@@ -28,7 +29,7 @@
2829
|| task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | |
2930
|| task +create | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow; task_comment_workflow_test.go::TestTask_CommentWorkflow; task_reminder_workflow_test.go::TestTask_ReminderWorkflow; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `summary` + `description`; `due.timestamp` + `due.is_all_day` | |
3031
|| task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` |
31-
| | task +get-my-tasks | shortcut | | none | UAT did not return the workflow-created user task deterministically in my-tasks views |
32+
| | task +get-my-tasks | shortcut | task_get_my_tasks_dryrun_test.go::TestTask_GetMyTasksDryRun | `--complete`; `--page-token`; dry-run only | live UAT did not return the workflow-created user task deterministically in my-tasks views |
3233
|| task +reminder | shortcut | task_reminder_workflow_test.go::TestTask_ReminderWorkflow/set reminder; task_reminder_workflow_test.go::TestTask_ReminderWorkflow/remove reminder | `--task-id --set 30m`; `--task-id --remove` | |
3334
|| task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | |
3435
|| task +tasklist-create | shortcut | tasklist_workflow_test.go::TestTask_TasklistWorkflowAsBot/create tasklist with task as bot; tasklist_workflow_test.go::TestTask_TasklistWorkflowAsUser/create tasklist as user; tasklist_add_task_workflow_test.go::TestTask_TasklistAddTaskWorkflow | `--name` only; `--name` plus task array in `--data` | |
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package task
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
clie2e "github.com/larksuite/cli/tests/cli_e2e"
12+
"github.com/stretchr/testify/require"
13+
"github.com/tidwall/gjson"
14+
)
15+
16+
// TestTask_GetMyTasksDryRun validates the request shape emitted by
17+
// task +get-my-tasks under --dry-run. Fake credentials are sufficient because
18+
// dry-run stops before any network call.
19+
func TestTask_GetMyTasksDryRun(t *testing.T) {
20+
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
21+
t.Setenv("LARKSUITE_CLI_APP_ID", "task_dryrun_test")
22+
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_dryrun_secret")
23+
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
24+
25+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
26+
t.Cleanup(cancel)
27+
28+
result, err := clie2e.RunCmd(ctx, clie2e.Request{
29+
Args: []string{
30+
"task", "+get-my-tasks",
31+
"--complete",
32+
"--page-token", "pt_001",
33+
"--dry-run",
34+
},
35+
DefaultAs: "user",
36+
})
37+
require.NoError(t, err)
38+
result.AssertExitCode(t, 0)
39+
40+
out := result.Stdout
41+
if count := gjson.Get(out, "api.#").Int(); count != 1 {
42+
t.Fatalf("expected 1 API call, got %d\nstdout:\n%s", count, out)
43+
}
44+
if method := gjson.Get(out, "api.0.method").String(); method != "GET" {
45+
t.Fatalf("api[0].method = %q, want GET\nstdout:\n%s", method, out)
46+
}
47+
if url := gjson.Get(out, "api.0.url").String(); url != "/open-apis/task/v2/tasks" {
48+
t.Fatalf("api[0].url = %q, want /open-apis/task/v2/tasks\nstdout:\n%s", url, out)
49+
}
50+
if got := gjson.Get(out, "api.0.params.type").String(); got != "my_tasks" {
51+
t.Fatalf("api[0].params.type = %q, want my_tasks\nstdout:\n%s", got, out)
52+
}
53+
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
54+
t.Fatalf("api[0].params.user_id_type = %q, want open_id\nstdout:\n%s", got, out)
55+
}
56+
if got := gjson.Get(out, "api.0.params.completed").Bool(); !got {
57+
t.Fatalf("api[0].params.completed = %v, want true\nstdout:\n%s", got, out)
58+
}
59+
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "pt_001" {
60+
t.Fatalf("api[0].params.page_token = %q, want pt_001\nstdout:\n%s", got, out)
61+
}
62+
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
63+
t.Fatalf("api[0].params.page_size = %d, want 50\nstdout:\n%s", got, out)
64+
}
65+
}

0 commit comments

Comments
 (0)