Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shortcuts/task/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ var CreateTask = common.Shortcut{
"guid": guid,
"url": urlVal,
}
projectTaskFields(outData, task, standardTaskOutputFields...)

runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task created successfully!\n")
Expand Down
1 change: 1 addition & 0 deletions shortcuts/task/task_assign.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ var AssignTask = common.Shortcut{
"guid": taskId,
"url": urlVal,
}
projectTaskFields(outData, task, standardTaskOutputFields...)

runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task assignes updated successfully!\n")
Expand Down
1 change: 1 addition & 0 deletions shortcuts/task/task_complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ var CompleteTask = common.Shortcut{
"completed_at": completedAt,
"already_completed": alreadyCompleted,
}
projectTaskFields(outData, task, taskOutputSummary, taskOutputMembers, taskOutputStart, taskOutputDue)

runtime.OutFormat(outData, nil, func(w io.Writer) {
summary, _ := task["summary"].(string)
Expand Down
10 changes: 10 additions & 0 deletions shortcuts/task/task_complete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ func TestCompleteTask(t *testing.T) {
`"status": "done"`,
`"completed_at": "1775174400000"`,
`"already_completed": false`,
`"summary": "Test Task task-789"`,
`"members":`,
`"start":`,
`"due":`,
},
},
}
Expand All @@ -76,6 +80,9 @@ func TestCompleteTask(t *testing.T) {
"summary": "Test Task " + tt.taskId,
"completed_at": completedAt,
"url": "https://example.com/" + tt.taskId,
"members": fullTaskOutputFixture()["members"],
"start": fullTaskOutputFixture()["start"],
"due": fullTaskOutputFixture()["due"],
},
},
},
Expand All @@ -93,6 +100,9 @@ func TestCompleteTask(t *testing.T) {
"summary": "Test Task " + tt.taskId,
"completed_at": "1775174400000",
"url": "https://example.com/" + tt.taskId,
"members": fullTaskOutputFixture()["members"],
"start": fullTaskOutputFixture()["start"],
"due": fullTaskOutputFixture()["due"],
},
},
},
Expand Down
1 change: 1 addition & 0 deletions shortcuts/task/task_followers.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ var FollowersTask = common.Shortcut{
"guid": taskId,
"url": urlVal,
}
projectTaskFields(outData, task, standardTaskOutputFields...)

runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task followers updated successfully!\n")
Expand Down
1 change: 1 addition & 0 deletions shortcuts/task/task_get_my_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ var GetMyTasks = common.Shortcut{
}
}
}
projectTaskFields(outputItem, item, taskOutputMembers, taskOutputStart)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include standardized due and status here.

Line 225 only projects members and start; JSON still exposes aliases due_at and completed, but omits the new standard due and status fields. Preserve those aliases, but also project due and project or derive status from completed. Add direct JSON assertions for both fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/task/task_get_my_tasks.go` at line 225, Update projectTaskFields to
also project the standardized due and status fields alongside the existing
members and start fields. Preserve the due_at and completed aliases, derive or
project status from completed, and add direct JSON assertions covering both due
and status.

outputItems = append(outputItems, outputItem)
}

Expand Down
9 changes: 9 additions & 0 deletions shortcuts/task/task_get_my_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
expectedOutput: []string{
`"due_at": "` + expectedRFC3339 + `"`,
`"created_at": "` + expectedRFC3339 + `"`,
`"role": "assignee"`,
`"start": {`,
},
},
{
Expand Down Expand Up @@ -74,6 +76,13 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
"guid": "task-123",
"summary": "Test Task",
"created_at": tsStr,
"members": []interface{}{
map[string]interface{}{"id": "ou_owner", "role": "assignee", "type": "user"},
},
"start": map[string]interface{}{
"timestamp": tsStr,
"is_all_day": false,
},
"due": map[string]interface{}{
"timestamp": tsStr,
},
Expand Down
4 changes: 3 additions & 1 deletion shortcuts/task/task_get_related_tasks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,15 @@ func TestGetRelatedTasks_Execute(t *testing.T) {
"tasklists": []interface{}{},
"url": "https://example.com/task-123",
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
"start": map[string]interface{}{"timestamp": "1775088000000", "is_all_day": false},
"due": map[string]interface{}{"timestamp": "1775174400000", "is_all_day": false},
},
},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`, `"start":`, `"due":`},
},
{
name: "pretty pagination followed by me",
Expand Down
31 changes: 31 additions & 0 deletions shortcuts/task/task_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package task

type taskOutputField string

const (
taskOutputSummary taskOutputField = "summary"
taskOutputMembers taskOutputField = "members"
taskOutputStart taskOutputField = "start"
taskOutputDue taskOutputField = "due"
taskOutputStatus taskOutputField = "status"
)

var standardTaskOutputFields = []taskOutputField{
taskOutputSummary,
taskOutputMembers,
taskOutputStart,
taskOutputDue,
taskOutputStatus,
}

func projectTaskFields(dst, task map[string]interface{}, fields ...taskOutputField) {
for _, field := range fields {
key := string(field)
if value, ok := task[key]; ok {
dst[key] = value
}
}
}
Comment on lines +24 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Project from a typed task-output shape.

This new shared path accepts raw map[string]interface{} values and silently omits absent or malformed fields. Decode the response into a typed task-output struct at the boundary, then project that shape into output maps.

As per coding guidelines, “Parse map[string]interface{} into typed structs at the boundary, use one projection function per shape, and prefer distinct types when same-typed values could be silently swapped.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/task/task_output.go` around lines 24 - 31, Update projectTaskFields
to accept a typed task-output struct rather than raw map[string]interface{}
input, and project its validated fields into the destination map. Decode task
responses into that struct at the boundary before invoking projectTaskFields,
preserving one projection path for the task-output shape and preventing
malformed or silently missing fields from being propagated.

Source: Coding guidelines

144 changes: 144 additions & 0 deletions shortcuts/task/task_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package task

import (
"encoding/json"
"net/http"
"reflect"
"testing"

"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)

func TestProjectTaskFields(t *testing.T) {
task := map[string]interface{}{
"summary": "Ship compact fields",
"members": []interface{}{map[string]interface{}{
"id": "ou_owner", "name": "Owner", "type": "user", "role": "assignee",
}},
"start": map[string]interface{}{"timestamp": "1000", "is_all_day": false},
"due": map[string]interface{}{"timestamp": "2000", "is_all_day": false},
"status": "todo",
}
out := map[string]interface{}{"guid": "task-1"}

projectTaskFields(out, task, standardTaskOutputFields...)

for _, field := range standardTaskOutputFields {
key := string(field)
if !reflect.DeepEqual(out[key], task[key]) {
t.Fatalf("%s = %#v, want %#v", key, out[key], task[key])
}
}
if out["guid"] != "task-1" {
t.Fatalf("existing guid changed: %#v", out)
}
}

func TestProjectTaskFieldsOmitsAbsentFields(t *testing.T) {
out := map[string]interface{}{}
projectTaskFields(out, map[string]interface{}{"summary": "Only title"}, standardTaskOutputFields...)

if out["summary"] != "Only title" {
t.Fatalf("summary = %#v, want Only title", out["summary"])
}
for _, key := range []string{"members", "start", "due", "status"} {
if _, ok := out[key]; ok {
t.Fatalf("%s unexpectedly projected: %#v", key, out)
}
}
}

func TestTaskRootOutputFields(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
method string
url string
args []string
}{
{
name: "create", shortcut: CreateTask, method: http.MethodPost,
url: "/open-apis/task/v2/tasks",
args: []string{"+create", "--summary", "Requested title", "--as", "bot", "--format", "json"},
},
{
name: "reopen", shortcut: ReopenTask, method: http.MethodPatch,
url: "/open-apis/task/v2/tasks/task-1",
args: []string{"+reopen", "--task-id", "task-1", "--as", "bot", "--format", "json"},
},
{
name: "assign", shortcut: AssignTask, method: http.MethodPost,
url: "/open-apis/task/v2/tasks/task-1/add_members",
args: []string{"+assign", "--task-id", "task-1", "--add", "ou_owner", "--as", "bot", "--format", "json"},
},
{
name: "followers", shortcut: FollowersTask, method: http.MethodPost,
url: "/open-apis/task/v2/tasks/task-1/add_members",
args: []string{"+followers", "--task-id", "task-1", "--add", "ou_follower", "--as", "bot", "--format", "json"},
},
}

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: tt.method,
URL: tt.url,
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"task": fullTaskOutputFixture()},
},
})

shortcut := tt.shortcut
shortcut.AuthTypes = []string{"bot", "user"}
if err := runMountedTaskShortcut(t, shortcut, tt.args, f, stdout); err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}

assertStandardTaskFields(t, decodeTaskOutputData(t, stdout.Bytes()))
})
}
}

func fullTaskOutputFixture() map[string]interface{} {
return map[string]interface{}{
"guid": "task-1",
"url": "https://example.com/task-1&suite_entity_num=t1",
"summary": "Server title",
"members": []interface{}{
map[string]interface{}{"id": "ou_owner", "name": "Owner", "type": "user", "role": "assignee"},
},
"start": map[string]interface{}{"timestamp": "1000", "is_all_day": false},
"due": map[string]interface{}{"timestamp": "2000", "is_all_day": false},
"status": "todo",
}
}

func decodeTaskOutputData(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode output: %v\n%s", err, raw)
}
data, ok := envelope["data"].(map[string]interface{})
if !ok {
t.Fatalf("data = %#v, want object", envelope["data"])
}
return data
}

func assertStandardTaskFields(t *testing.T, data map[string]interface{}) {
t.Helper()
for _, key := range []string{"summary", "members", "start", "due", "status"} {
if _, ok := data[key]; !ok {
t.Errorf("data missing %q: %#v", key, data)
}
}
}
2 changes: 2 additions & 0 deletions shortcuts/task/task_query_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
}
}
}
projectTaskFields(out, task, taskOutputMembers, taskOutputStart, taskOutputStatus)
return out
}

Expand Down Expand Up @@ -198,6 +199,7 @@ func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
out["completed_at"] = completed
}
}
projectTaskFields(out, task, taskOutputStart, taskOutputDue)
return out
}

Expand Down
21 changes: 21 additions & 0 deletions shortcuts/task/task_query_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package task

import (
"errors"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -74,6 +75,11 @@ func TestOutputTaskSummary(t *testing.T) {
"summary": "summary",
"url": "https://example.com/task-123&suite_entity_num=t1",
"created_at": "1775174400000",
"members": []interface{}{
map[string]interface{}{"id": "ou_owner", "role": "assignee", "type": "user"},
},
"start": map[string]interface{}{"timestamp": "1775088000000", "is_all_day": false},
"status": "todo",
"due": map[string]interface{}{
"timestamp": "1775174400000",
},
Expand All @@ -100,6 +106,15 @@ func TestOutputTaskSummary(t *testing.T) {
if got["url"] == "" {
t.Fatalf("expected url in output, got %#v", got)
}
if tt.name == "with timestamps and due" {
if !reflect.DeepEqual(got["members"], tt.task["members"]) ||
!reflect.DeepEqual(got["start"], tt.task["start"]) || got["status"] != "todo" {
t.Fatalf("search projection lost compact fields: %#v", got)
}
if _, duplicated := got["due"]; duplicated {
t.Fatalf("search projection duplicated due alongside due_at: %#v", got)
}
}
})
}
}
Expand Down Expand Up @@ -200,6 +215,8 @@ func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
"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"}},
"start": map[string]interface{}{"timestamp": "1775088000000", "is_all_day": false},
"due": map[string]interface{}{"timestamp": "1775174400000", "is_all_day": false},
"created_at": "1775174400000",
"completed_at": "1775174400000",
},
Expand All @@ -219,6 +236,10 @@ func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected related task output: %#v", got)
}
if tt.name == "full related task" &&
(!reflect.DeepEqual(got["start"], tt.task["start"]) || !reflect.DeepEqual(got["due"], tt.task["due"])) {
t.Fatalf("related task projection lost start/due: %#v", got)
}
})
}

Expand Down
5 changes: 4 additions & 1 deletion shortcuts/task/task_reminder.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ var ReminderTask = common.Shortcut{

if runtime.Bool("remove") {
if len(reminders) == 0 {
runtime.OutFormat(map[string]interface{}{"guid": taskId}, nil, func(w io.Writer) {
outData := map[string]interface{}{"guid": taskId}
projectTaskFields(outData, taskObj, standardTaskOutputFields...)
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "No existing reminders to remove.")
})
return nil
Expand Down Expand Up @@ -169,6 +171,7 @@ var ReminderTask = common.Shortcut{
"guid": taskId,
"url": urlVal,
}
projectTaskFields(outData, taskObj, standardTaskOutputFields...)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a contract test for the successful update path.

The new test only exercises --remove with no reminders, which returns at Lines 80-82. Add a JSON test for --set or removal of an existing reminder and assert standard fields, so reverting this projection fails a test. As per coding guidelines, “Every behavior change must have an accompanying test.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/task/task_reminder.go` at line 174, Add a contract test covering
the successful reminder update path in the task reminder tests, using --set or
removing an existing reminder rather than --remove with no reminders. Assert the
response includes the standard task output fields produced by projectTaskFields,
so removing that projection causes the test to fail.

Source: Coding guidelines


runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task reminders updated successfully!\n")
Expand Down
Loading
Loading