fix: expose completion state in my tasks output (#1641)

* fix: expose completion state in my tasks output

* test: cover my tasks pretty completion state
This commit is contained in:
ILUO
2026-07-01 15:41:57 +08:00
committed by GitHub
parent bdffffb368
commit e753b15d84
4 changed files with 207 additions and 7 deletions

View File

@@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{
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,
"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 {
@@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{
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 {
@@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{
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)
}
@@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{
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)
}

View File

@@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
}
}
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

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 14
- Coverage: 48.3%
- Covered: 15
- Coverage: 51.7%
## Summary
- 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 @@
- 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.
- 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.
- 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.
- 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.
- 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.
- Blocked area: assignee, follower, and tasklist member mutations still require stable real-user `open_id` fixtures; the current suite is bot-safe only.
- 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.
- 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.
- 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.
- 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.
@@ -28,7 +29,7 @@
| ✓ | task +complete | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/complete | `--task-id` | |
| ✓ | 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` | |
| ✕ | task +followers | shortcut | | none | requires real follower open_id fixtures; shortcut defaults to `--as user` |
| | task +get-my-tasks | shortcut | | none | UAT did not return the workflow-created user task deterministically in my-tasks views |
| | 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 |
| ✓ | 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` | |
| ✓ | task +reopen | shortcut | task_status_workflow_test.go::TestTask_StatusWorkflow/reopen | `--task-id` | |
| ✓ | 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` | |

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestTask_GetMyTasksDryRun validates the request shape emitted by
// task +get-my-tasks under --dry-run. Fake credentials are sufficient because
// dry-run stops before any network call.
func TestTask_GetMyTasksDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"task", "+get-my-tasks",
"--complete",
"--page-token", "pt_001",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if count := gjson.Get(out, "api.#").Int(); count != 1 {
t.Fatalf("expected 1 API call, got %d\nstdout:\n%s", count, out)
}
if method := gjson.Get(out, "api.0.method").String(); method != "GET" {
t.Fatalf("api[0].method = %q, want GET\nstdout:\n%s", method, out)
}
if url := gjson.Get(out, "api.0.url").String(); url != "/open-apis/task/v2/tasks" {
t.Fatalf("api[0].url = %q, want /open-apis/task/v2/tasks\nstdout:\n%s", url, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != "my_tasks" {
t.Fatalf("api[0].params.type = %q, want my_tasks\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
t.Fatalf("api[0].params.user_id_type = %q, want open_id\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.completed").Bool(); !got {
t.Fatalf("api[0].params.completed = %v, want true\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "pt_001" {
t.Fatalf("api[0].params.page_token = %q, want pt_001\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api[0].params.page_size = %d, want 50\nstdout:\n%s", got, out)
}
}